wogiflow 2.12.0 → 2.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wogiflow",
3
- "version": "2.12.0",
3
+ "version": "2.12.1",
4
4
  "description": "AI-powered development workflow management system with multi-model support",
5
5
  "main": "lib/index.js",
6
6
  "bin": {
@@ -0,0 +1,388 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Wogi Flow - Manager Role Boundary Gate
5
+ *
6
+ * Mechanically enforces the manager's role boundaries in workspace mode.
7
+ * The manager is an orchestrator — it dispatches work to workers via channels.
8
+ * It must NOT directly modify files in member repos or run commands there.
9
+ *
10
+ * Activation: WOGI_REPO_NAME === 'manager'
11
+ *
12
+ * Rules:
13
+ * - Edit/Write: BLOCKED on any file inside a member repo
14
+ * - Read/Glob/Grep: ALLOWED for .workflow/state/, package.json; BLOCKED for source code
15
+ * - Bash: If command contains a member repo path, must match a read-only allowlist
16
+ * Otherwise BLOCKED with a dispatch redirect message
17
+ *
18
+ * Design: Allowlist-based (not blocklist). New tools/commands are blocked by default.
19
+ * Only explicitly whitelisted read patterns are allowed in member repos.
20
+ *
21
+ * Source: Workspace manager repeatedly violated role boundaries despite prompt rules
22
+ * (cd into worker repos, npm install, bridge sync). Prompt-only enforcement failed
23
+ * 3 times — mechanical gate required.
24
+ */
25
+
26
+ 'use strict';
27
+
28
+ const path = require('node:path');
29
+ const fs = require('node:fs');
30
+
31
+ // ============================================================
32
+ // Member Path Resolution
33
+ // ============================================================
34
+
35
+ let _cachedMemberPaths = null;
36
+
37
+ /**
38
+ * Load and cache resolved member repo paths from workspace manifest.
39
+ * Returns an array of { name, resolvedPath } objects.
40
+ *
41
+ * @returns {Array<{ name: string, resolvedPath: string }>}
42
+ */
43
+ function getMemberPaths() {
44
+ if (_cachedMemberPaths) return _cachedMemberPaths;
45
+
46
+ const workspaceRoot = process.env.WOGI_WORKSPACE_ROOT;
47
+ if (!workspaceRoot || !path.isAbsolute(workspaceRoot)) return [];
48
+
49
+ const manifestPath = path.join(workspaceRoot, '.workspace', 'state', 'workspace-manifest.json');
50
+ if (!fs.existsSync(manifestPath)) return [];
51
+
52
+ try {
53
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
54
+ const members = manifest.members || {};
55
+ const paths = [];
56
+
57
+ for (const [name, member] of Object.entries(members)) {
58
+ if (typeof name !== 'string' || !member) continue;
59
+ const memberPath = member.path || member.root;
60
+ if (typeof memberPath !== 'string') continue;
61
+
62
+ // Store both the original (normalized) path AND the symlink-resolved path.
63
+ // On macOS, /tmp → /private/tmp, so commands may contain either form.
64
+ const normalized = path.resolve(workspaceRoot, memberPath);
65
+ let resolved = normalized;
66
+ try {
67
+ resolved = fs.realpathSync(normalized);
68
+ } catch (_err) {
69
+ // Path doesn't exist — use normalized only
70
+ }
71
+
72
+ // allPaths: deduplicated list of paths to match against
73
+ const allPaths = [resolved];
74
+ if (normalized !== resolved) allPaths.push(normalized);
75
+
76
+ paths.push({ name, resolvedPath: resolved, allPaths, port: member.port || member.channelPort });
77
+ }
78
+
79
+ _cachedMemberPaths = paths;
80
+ return paths;
81
+ } catch (_err) {
82
+ return [];
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Check if a path is inside any member repo.
88
+ * Returns the member name if found, null otherwise.
89
+ *
90
+ * @param {string} targetPath - Absolute path to check
91
+ * @returns {{ name: string, resolvedPath: string } | null}
92
+ */
93
+ function findMemberForPath(targetPath) {
94
+ if (!targetPath || !path.isAbsolute(targetPath)) return null;
95
+
96
+ const members = getMemberPaths();
97
+
98
+ // Try both normalized and symlink-resolved versions of the target path.
99
+ // On macOS, /tmp → /private/tmp, so we need to check both forms.
100
+ const normalized = path.resolve(targetPath);
101
+ let resolved = normalized;
102
+ try {
103
+ // Walk up to find the nearest existing ancestor, resolve from there
104
+ let check = normalized;
105
+ while (check !== path.dirname(check)) {
106
+ if (fs.existsSync(check)) {
107
+ resolved = fs.realpathSync(check) + normalized.slice(check.length);
108
+ break;
109
+ }
110
+ check = path.dirname(check);
111
+ }
112
+ } catch (_err) {
113
+ // Can't resolve — use normalized
114
+ }
115
+
116
+ const candidates = [resolved, normalized];
117
+
118
+ for (const member of members) {
119
+ const memberPaths = member.allPaths || [member.resolvedPath];
120
+ for (const candidate of candidates) {
121
+ for (const mp of memberPaths) {
122
+ if (candidate === mp || candidate.startsWith(mp + path.sep)) {
123
+ return member;
124
+ }
125
+ }
126
+ }
127
+ }
128
+
129
+ return null;
130
+ }
131
+
132
+ /**
133
+ * Get the channel port for a member repo (for dispatch redirect messages).
134
+ *
135
+ * @param {string} memberName
136
+ * @returns {number|null}
137
+ */
138
+ function getMemberPort(memberName) {
139
+ const workspaceRoot = process.env.WOGI_WORKSPACE_ROOT;
140
+ if (!workspaceRoot) return null;
141
+
142
+ try {
143
+ const manifestPath = path.join(workspaceRoot, '.workspace', 'state', 'workspace-manifest.json');
144
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
145
+ const member = manifest.members?.[memberName];
146
+ return member?.port ?? member?.channelPort ?? null;
147
+ } catch (_err) {
148
+ return null;
149
+ }
150
+ }
151
+
152
+ // ============================================================
153
+ // Read-Only Allowlist for Bash Commands
154
+ // ============================================================
155
+
156
+ /**
157
+ * Patterns that are allowed when a Bash command references a member repo path.
158
+ * If the command matches ANY of these patterns, it's a permitted read operation.
159
+ * Everything else is blocked by default (allowlist, not blocklist).
160
+ */
161
+ const ALLOWED_BASH_PATTERNS = [
162
+ // Reading workflow state files
163
+ /\bcat\s+.*\.workflow\b/,
164
+ /\bls\s+.*\.workflow\b/,
165
+ /\bhead\s+.*\.workflow\b/,
166
+ /\btail\s+.*\.workflow\b/,
167
+ /\bwc\s+.*\.workflow\b/,
168
+
169
+ // Reading package.json
170
+ /\bcat\s+.*package\.json\b/,
171
+
172
+ // Git read-only operations (with -C for cross-directory)
173
+ /\bgit\s+(-C\s+\S+\s+)?log\b/,
174
+ /\bgit\s+(-C\s+\S+\s+)?status\b/,
175
+ /\bgit\s+(-C\s+\S+\s+)?diff\b/,
176
+ /\bgit\s+(-C\s+\S+\s+)?show\b/,
177
+ /\bgit\s+(-C\s+\S+\s+)?blame\b/,
178
+ /\bgit\s+(-C\s+\S+\s+)?rev-parse\b/,
179
+ /\bgit\s+(-C\s+\S+\s+)?branch\b/,
180
+ /\bgit\s+(-C\s+\S+\s+)?tag\s+-l\b/,
181
+ /\bgit\s+(-C\s+\S+\s+)?ls-files\b/,
182
+ /\bgit\s+(-C\s+\S+\s+)?describe\b/,
183
+ /\bgit\s+(-C\s+\S+\s+)?remote\s+-v\b/,
184
+
185
+ // Grep/find for reading
186
+ /\bgrep\s+/,
187
+ /\bfind\s+.*-name\b/,
188
+
189
+ // Curl is always allowed (it's the dispatch mechanism)
190
+ /\bcurl\s+/,
191
+
192
+ // Health checks
193
+ /\bwget\s+/,
194
+ ];
195
+
196
+ /**
197
+ * Check if a Bash command that references a member repo matches the read-only allowlist.
198
+ *
199
+ * @param {string} command - The Bash command string
200
+ * @returns {boolean} True if the command is an allowed read-only operation
201
+ */
202
+ function isAllowedReadCommand(command) {
203
+ const trimmed = command.trim();
204
+ return ALLOWED_BASH_PATTERNS.some(pattern => pattern.test(trimmed));
205
+ }
206
+
207
+ // ============================================================
208
+ // Gate Logic
209
+ // ============================================================
210
+
211
+ /**
212
+ * Check if a tool call violates manager role boundaries.
213
+ *
214
+ * @param {string} toolName - Tool being called (Bash, Edit, Write, Read, etc.)
215
+ * @param {Object} toolInput - Tool input parameters
216
+ * @returns {{ blocked: boolean, message?: string, reason?: string }}
217
+ */
218
+ function checkManagerBoundary(toolName, toolInput) {
219
+ // Only active in workspace manager mode
220
+ if (process.env.WOGI_REPO_NAME !== 'manager') {
221
+ return { blocked: false };
222
+ }
223
+
224
+ const members = getMemberPaths();
225
+ if (members.length === 0) {
226
+ // No manifest or no members — can't enforce, fail open
227
+ return { blocked: false };
228
+ }
229
+
230
+ // ── Edit / Write: check file_path ────────────────────────
231
+ if (toolName === 'Edit' || toolName === 'Write') {
232
+ const filePath = toolInput.file_path;
233
+ if (!filePath) return { blocked: false };
234
+
235
+ const member = findMemberForPath(filePath);
236
+ if (member) {
237
+ const port = member.port || getMemberPort(member.name);
238
+ const portHint = port ? ` (port ${port})` : '';
239
+ return {
240
+ blocked: true,
241
+ reason: 'manager-boundary-write',
242
+ message: [
243
+ `MANAGER BOUNDARY: Cannot modify files in worker repo "${member.name}" directly.`,
244
+ `Blocked: ${toolName} on ${path.basename(filePath)}`,
245
+ '',
246
+ `Dispatch to the worker instead:`,
247
+ ` curl -s -X POST http://localhost:${port || '{port}'} -H "X-Wogi-From: manager" -d "<describe what needs to change>"`,
248
+ '',
249
+ `You are an orchestrator — workers make changes, you coordinate.${portHint}`
250
+ ].join('\n')
251
+ };
252
+ }
253
+ return { blocked: false };
254
+ }
255
+
256
+ // ── Read / Glob / Grep: allow .workflow/state/ and package.json ──
257
+ if (toolName === 'Read' || toolName === 'Glob' || toolName === 'Grep') {
258
+ const targetPath = toolInput.file_path || toolInput.path;
259
+ if (!targetPath) return { blocked: false };
260
+
261
+ const member = findMemberForPath(targetPath);
262
+ if (member) {
263
+ // Compute relative path using the same form that matched.
264
+ // Try each member path form to find one that produces a clean relative.
265
+ const resolved = path.resolve(targetPath);
266
+ const memberPaths = member.allPaths || [member.resolvedPath];
267
+ let relative = path.relative(member.resolvedPath, resolved);
268
+ for (const mp of memberPaths) {
269
+ const rel = path.relative(mp, resolved);
270
+ if (!rel.startsWith('..')) { relative = rel; break; }
271
+ }
272
+
273
+ // Allowed paths: .workflow/, .workspace/, package.json, tsconfig.json
274
+ const allowedPrefixes = ['.workflow', '.workspace', '.claude'];
275
+ const allowedFiles = ['package.json', 'tsconfig.json', '.env.example'];
276
+ const baseName = path.basename(resolved);
277
+
278
+ const isAllowed = allowedPrefixes.some(prefix => relative.startsWith(prefix)) ||
279
+ allowedFiles.includes(baseName);
280
+
281
+ if (!isAllowed) {
282
+ return {
283
+ blocked: true,
284
+ reason: 'manager-boundary-read',
285
+ message: [
286
+ `MANAGER BOUNDARY: Cannot read source code in worker repo "${member.name}".`,
287
+ `Blocked: ${toolName} on ${relative}`,
288
+ '',
289
+ `You may read: .workflow/state/*, package.json, .claude/ (state files)`,
290
+ `For source code investigation, dispatch to the worker.`
291
+ ].join('\n')
292
+ };
293
+ }
294
+ }
295
+ return { blocked: false };
296
+ }
297
+
298
+ // ── Bash: check for member repo paths in the command ──────
299
+ if (toolName === 'Bash') {
300
+ const command = toolInput.command;
301
+ if (!command) return { blocked: false };
302
+
303
+ // Find if any member repo path appears in the command (check all path forms)
304
+ for (const member of members) {
305
+ const memberPaths = member.allPaths || [member.resolvedPath];
306
+ const matchedPath = memberPaths.find(mp => command.includes(mp));
307
+ if (matchedPath) {
308
+ // Member path found in command — check allowlist
309
+ if (isAllowedReadCommand(command)) {
310
+ return { blocked: false };
311
+ }
312
+
313
+ const port = member.port || getMemberPort(member.name);
314
+ return {
315
+ blocked: true,
316
+ reason: 'manager-boundary-bash',
317
+ message: [
318
+ `MANAGER BOUNDARY: Cannot run commands in worker repo "${member.name}".`,
319
+ `Blocked: ${command.length > 100 ? command.slice(0, 100) + '...' : command}`,
320
+ '',
321
+ `Dispatch to the worker instead:`,
322
+ ` curl -s -X POST http://localhost:${port || '{port}'} -H "X-Wogi-From: manager" -d "<your command>"`,
323
+ '',
324
+ `Allowed in member repos: read .workflow/state/, git log/status/diff, curl to ports.`,
325
+ `Everything else must be dispatched to the worker.`
326
+ ].join('\n')
327
+ };
328
+ }
329
+ }
330
+
331
+ // Check for cd into member repos (handles cd with various chaining operators)
332
+ const cdPattern = /\bcd\s+["']?([^\s"';&|]+)/g;
333
+ let match;
334
+ while ((match = cdPattern.exec(command)) !== null) {
335
+ const cdTarget = match[1];
336
+ // Try to resolve the cd target (absolute paths and simple relative paths)
337
+ let resolvedCd;
338
+ try {
339
+ resolvedCd = path.isAbsolute(cdTarget)
340
+ ? path.resolve(cdTarget)
341
+ : path.resolve(process.cwd(), cdTarget);
342
+ } catch (_err) {
343
+ continue;
344
+ }
345
+
346
+ const member = findMemberForPath(resolvedCd);
347
+ if (member) {
348
+ const port = member.port || getMemberPort(member.name);
349
+ return {
350
+ blocked: true,
351
+ reason: 'manager-boundary-cd',
352
+ message: [
353
+ `MANAGER BOUNDARY: Cannot cd into worker repo "${member.name}".`,
354
+ '',
355
+ `Dispatch to the worker instead:`,
356
+ ` curl -s -X POST http://localhost:${port || '{port}'} -H "X-Wogi-From: manager" -d "<your command>"`,
357
+ '',
358
+ `The manager stays in the workspace root. Workers execute in their own repos.`
359
+ ].join('\n')
360
+ };
361
+ }
362
+ }
363
+
364
+ return { blocked: false };
365
+ }
366
+
367
+ return { blocked: false };
368
+ }
369
+
370
+ /**
371
+ * Clear the cached member paths (for testing or re-initialization).
372
+ */
373
+ function clearCache() {
374
+ _cachedMemberPaths = null;
375
+ }
376
+
377
+ // ============================================================
378
+ // Exports
379
+ // ============================================================
380
+
381
+ module.exports = {
382
+ checkManagerBoundary,
383
+ getMemberPaths,
384
+ findMemberForPath,
385
+ getMemberPort,
386
+ isAllowedReadCommand,
387
+ clearCache
388
+ };
@@ -30,6 +30,8 @@ let checkScopeMutation = _noop;
30
30
  try { checkScopeMutation = require('../../core/scope-mutation-gate').checkScopeMutation; } catch (_err) { if (process.env.DEBUG) console.error(`[Hook] Scope mutation gate not loaded: ${_err.message}`); }
31
31
  let checkGitSafety = _noop;
32
32
  try { checkGitSafety = require('../../core/git-safety-gate').checkGitSafety; } catch (_err) { if (process.env.DEBUG) console.error(`[Hook] Git safety gate not loaded: ${_err.message}`); }
33
+ let checkManagerBoundary = _noop;
34
+ try { checkManagerBoundary = require('../../core/manager-boundary-gate').checkManagerBoundary; } catch (_err) { if (process.env.DEBUG) console.error(`[Hook] Manager boundary gate not loaded: ${_err.message}`); }
33
35
  const { claudeCodeAdapter } = require('../../adapters/claude-code');
34
36
  const { markSkillPending } = require('../../../flow-durable-session');
35
37
  const { getConfig } = require('../../../flow-utils');
@@ -224,6 +226,29 @@ runHook('PreToolUse', async ({ input, parsedInput }) => {
224
226
  }
225
227
  }
226
228
 
229
+ // Manager role boundary gate — blocks modifications in worker repos
230
+ // Runs early: role boundaries should be enforced before other gates
231
+ if (process.env.WOGI_REPO_NAME === 'manager') {
232
+ try {
233
+ const boundaryResult = checkManagerBoundary(toolName, toolInput);
234
+ if (boundaryResult.blocked) {
235
+ coreResult = {
236
+ allowed: false,
237
+ blocked: true,
238
+ reason: boundaryResult.reason,
239
+ message: boundaryResult.message
240
+ };
241
+ const output = claudeCodeAdapter.transformResult('PreToolUse', coreResult);
242
+ return { __raw: true, ...output };
243
+ }
244
+ } catch (err) {
245
+ // Fail-open: manager boundary errors should not block normal work
246
+ if (process.env.DEBUG) {
247
+ console.error(`[Hook] Manager boundary gate error (fail-open): ${err.message}`);
248
+ }
249
+ }
250
+ }
251
+
227
252
  // Commit log gate check
228
253
  if (toolName === 'Bash' && toolInput.command) {
229
254
  try {