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
|
@@ -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 {
|