@orchestrator-claude/definitions 3.43.1 → 3.44.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/agents/adversarial-reviewer.md +35 -29
- package/agents/agentic-architect.md +1 -1
- package/agents/api-extractor.md +1 -1
- package/agents/architecture-mediator.md +1 -1
- package/agents/azure-devops-engineer.md +1 -1
- package/agents/backend-architect.md +1 -1
- package/agents/business-rule-miner.md +1 -1
- package/agents/cicd-platform-engineer.md +1 -1
- package/agents/ciclo-engineer.md +1 -1
- package/agents/code-archaeologist.md +1 -1
- package/agents/contract-architect.md +1 -1
- package/agents/datamodel-architect.md +1 -1
- package/agents/frontend-architect.md +1 -1
- package/agents/frontend-engineer.md +1 -1
- package/agents/harness-architect.md +1 -1
- package/agents/harness-engineer.md +1 -1
- package/agents/implementer.md +1 -1
- package/agents/infra-architect.md +1 -1
- package/agents/legacy-discoverer.md +1 -1
- package/agents/legacy-synthesizer.md +1 -1
- package/agents/methodology-researcher.md +2 -1
- package/agents/operational-artifact-builder.md +1 -1
- package/agents/planner.md +1 -1
- package/agents/presentation-engineer.md +1 -1
- package/agents/python-data-engineer.md +1 -1
- package/agents/researcher.md +1 -1
- package/agents/reviewer.md +1 -1
- package/agents/schema-extractor.md +1 -1
- package/agents/specifier.md +1 -1
- package/agents/task-generator.md +1 -1
- package/hooks/agent-git-guard.mjs +0 -2
- package/hooks/dangling-guard.mjs +18 -3
- package/hooks/gate-guardian.mjs +8617 -273
- package/hooks/gate-guardian.ts +296 -62
- package/hooks/lib/api-client.ts +154 -28
- package/hooks/lib/artifact-gate.ts +518 -0
- package/hooks/lib/artifact-producers.ts +106 -0
- package/hooks/lib/gate-logic.ts +6 -5
- package/hooks/lib/workflow-artifact-client.ts +351 -0
- package/hooks/mailbox-listener.mjs +0 -2
- package/hooks/playwright-output-guard.mjs +0 -2
- package/hooks/post-implement.mjs +18 -3
- package/hooks/pre-agent-dispatch.mjs +18 -3
- package/hooks/security-gate-honest.mjs +0 -2
- package/hooks/session-start.mjs +18 -3
- package/hooks/subagent-start.mjs +18 -3
- package/hooks/subagent-stop.mjs +18 -3
- package/hooks/user-prompt.mjs +18 -3
- package/hooks/verification-before-completion.mjs +0 -2
- package/hooks/workflow-guard.mjs +504 -29
- package/hooks/workflow-guard.ts +370 -27
- package/manifest.json +46 -46
- package/package.json +1 -1
package/hooks/workflow-guard.ts
CHANGED
|
@@ -17,6 +17,32 @@
|
|
|
17
17
|
* foreign (trc/red-bull) cwd, where they become load-bearing: a Classe-A
|
|
18
18
|
* gate NEVER no-ops silently on an unrecognized project path.
|
|
19
19
|
*
|
|
20
|
+
* R7 (RFC-044 F3, S4) — evaluated FIRST, before rule 1, so SKIP_WORKFLOW_GUARD
|
|
21
|
+
* does not reach it (Q-PM-5). A Write/Edit whose resolved path lies under
|
|
22
|
+
* project-guidelines/workflows/ of a MANAGED project (registered, not
|
|
23
|
+
* `.unmanaged`, DV-P10) is allowed only when: the caller is a sub-agent whose
|
|
24
|
+
* normalized agent_type is in ARTIFACT_PRODUCER_AGENTS; the path is
|
|
25
|
+
* workflows/<wf>/<phase>/<type>.md (parseVaultPath, the one path function);
|
|
26
|
+
* and probeWorkflow(<wf of the path>) answers an ACTIVE workflow whose
|
|
27
|
+
* currentPhase is <phase>. Anything else is a DENY with an R7-* reason; the
|
|
28
|
+
* network failures carry the "API fora" runbook. The network is consulted ONLY
|
|
29
|
+
* for such a path (the 2-pass below is preserved for every other path), and an
|
|
30
|
+
* exception in the R7 branch is a DENY, never the fail-open main().catch.
|
|
31
|
+
*
|
|
32
|
+
* R8 (RFC-044 F3, S6c) — evaluated before R7, pure (no network). For a caller
|
|
33
|
+
* whose normalized agent_type is in VAULT_ONLY_WRITERS (agent_id plays no
|
|
34
|
+
* part; without agent_type R8 does not apply), the decision comes ENTIRELY
|
|
35
|
+
* from R8 + R7, never from rules 1-7:
|
|
36
|
+
* - a workflows/ path of a managed project: an artifact type outside the
|
|
37
|
+
* agent's pinned set is DENY R8-TYPE (no probe); otherwise R7 decides;
|
|
38
|
+
* - its OWN memory dir <targetProjectRoot>/.claude/agent-memory/<slug>/
|
|
39
|
+
* (resolved path, prefix with the trailing separator): allowed, silent
|
|
40
|
+
* as before (R8-MEMORY in the log);
|
|
41
|
+
* - anything else (src/, docs, another project, no project, `.unmanaged`,
|
|
42
|
+
* SKIP_WORKFLOW_GUARD on): DENY R8-OUTSIDE-VAULT;
|
|
43
|
+
* - an exception while deciding for such an agent: DENY R8-EXCEPTION.
|
|
44
|
+
* Edit decides like Write (R8 does not read tool_name).
|
|
45
|
+
*
|
|
20
46
|
* Allow paths (in evaluation order — see decideWorkflowGuardAction):
|
|
21
47
|
* 1. SKIP_WORKFLOW_GUARD=true (explicit bypass, all paths)
|
|
22
48
|
* 2. Non-guarded path (file outside src/ or tests/)
|
|
@@ -35,19 +61,27 @@
|
|
|
35
61
|
* - No active workflow (target project IS registered)
|
|
36
62
|
*/
|
|
37
63
|
|
|
38
|
-
import { dirname, join } from 'node:path';
|
|
64
|
+
import { dirname, join, resolve as resolvePath, sep as PATH_SEP } from 'node:path';
|
|
39
65
|
import { existsSync } from 'node:fs';
|
|
66
|
+
import {
|
|
67
|
+
parseVaultPath,
|
|
68
|
+
WORKFLOW_ARTIFACT_ROOT,
|
|
69
|
+
} from '@orchestrator-claude/vault-projection/workflow-artifact';
|
|
40
70
|
import {
|
|
41
71
|
log,
|
|
42
72
|
readStdin,
|
|
43
73
|
getField,
|
|
44
74
|
getActiveWorkflow,
|
|
45
75
|
getWorkflowMode,
|
|
76
|
+
normalizeAgentName,
|
|
46
77
|
outputPreToolUse,
|
|
47
78
|
resolveAgentMemoryBaseDir,
|
|
48
79
|
resolveHookProjectRoot,
|
|
80
|
+
type WorkflowStatus,
|
|
49
81
|
} from './lib/api-client.js';
|
|
50
82
|
import { appendObservation } from './lib/observations-helper.js';
|
|
83
|
+
import { ARTIFACT_PRODUCER_AGENTS, VAULT_ONLY_WRITERS } from './lib/artifact-producers.js';
|
|
84
|
+
import { probeWorkflow, type ProbeResult } from './lib/workflow-artifact-client.js';
|
|
51
85
|
|
|
52
86
|
const HOOK = 'WORKFLOW-GUARD';
|
|
53
87
|
|
|
@@ -84,12 +118,17 @@ export interface WorkflowGuardDecision {
|
|
|
84
118
|
* `matchedRule === 7` as the sole signal for whether the (network) active
|
|
85
119
|
* workflow must actually be fetched to confirm the decision — see main().
|
|
86
120
|
*/
|
|
87
|
-
matchedRule: 1 | 2 | 3 | 4 | 5 | 6 | 7;
|
|
121
|
+
matchedRule: 'R8' | 'R7' | 1 | 2 | 3 | 4 | 5 | 6 | 7;
|
|
88
122
|
/** Whether outputPreToolUse must be called. Rules 2/3 are silent pass-throughs (pre-existing behavior — no hookSpecificOutput emitted). */
|
|
89
123
|
emitOutput: boolean;
|
|
90
124
|
output?: WorkflowGuardOutput;
|
|
91
125
|
logMessages: string[];
|
|
92
126
|
observation?: WorkflowGuardObservation;
|
|
127
|
+
/**
|
|
128
|
+
* R7 only: set when the decision still needs probeWorkflow(<wf of the path>). The decision that carries it
|
|
129
|
+
* is already a DENY (R7-PROBE-PENDING), so a caller that forgets to probe fails CLOSED.
|
|
130
|
+
*/
|
|
131
|
+
r7NeedsProbe?: { workflowId: string; phase: string };
|
|
93
132
|
}
|
|
94
133
|
|
|
95
134
|
export interface WorkflowGuardWorkflowInput {
|
|
@@ -128,6 +167,237 @@ export interface WorkflowGuardDecisionInput {
|
|
|
128
167
|
agentId: string;
|
|
129
168
|
/** getField(stdin, 'agent_type') — empty string when absent. */
|
|
130
169
|
agentType: string;
|
|
170
|
+
/** R7: probeWorkflow(<wf of the path>), filled by main() only when a previous decision asked for it. */
|
|
171
|
+
r7Probe?: ProbeResult;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// R7 (RFC-044 F3, S4): workflow artifacts on disk — pure part
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
const WORKFLOWS_SEGMENT = `/${WORKFLOW_ARTIFACT_ROOT}/`;
|
|
179
|
+
/** The house definition of "active" (api-client.ts getActiveWorkflow). */
|
|
180
|
+
const ACTIVE_WORKFLOW_STATUSES: ReadonlySet<string> = new Set([
|
|
181
|
+
'in_progress',
|
|
182
|
+
'awaiting_agent',
|
|
183
|
+
'awaiting_approval',
|
|
184
|
+
]);
|
|
185
|
+
|
|
186
|
+
/** Runbook "API fora" (plan sec. 9), short; the full text lives in the plan and in the S7 amendment. */
|
|
187
|
+
export const R7_RUNBOOK = [
|
|
188
|
+
'Runbook "API fora" (RFC-044 F3):',
|
|
189
|
+
' 1. curl -sS -m 3 "${ORCHESTRATOR_API_URL:-http://localhost:3001}/api/v1/health" (200 = API up)',
|
|
190
|
+
' 2. 200 but still denied (R7-NO-TOKEN, or cause no-token): check ORCHESTRATOR_ADMIN_EMAIL, ORCHESTRATOR_ADMIN_PASSWORD, ORCHESTRATOR_PROJECT_ID',
|
|
191
|
+
' 3. no answer: bring the API back up (the project container/service); the hook retries on the next Write',
|
|
192
|
+
'SKIP_WORKFLOW_GUARD nao se aplica a R7, nem .orchestrator/.unmanaged; nao edite hooks.json nem os .mjs.',
|
|
193
|
+
].join('\n');
|
|
194
|
+
|
|
195
|
+
export type R7PathMatch =
|
|
196
|
+
| { kind: 'none' }
|
|
197
|
+
| { kind: 'shape'; rel: string }
|
|
198
|
+
| { kind: 'key'; rel: string; workflowId: string; phase: string; artifactType: string };
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Does the RESOLVED path lie under .../project-guidelines/workflows/ (any prefix)? Resolution first:
|
|
202
|
+
* `workflows/../specs/x.md` and `workflows.bak/...` do not match; `//` is normalized. A match is then
|
|
203
|
+
* either a well-formed workflows/<wf>/<phase>/<type>.md (parseVaultPath) or a shape violation.
|
|
204
|
+
*/
|
|
205
|
+
export function matchR7Path(filePath: string): R7PathMatch {
|
|
206
|
+
if (filePath.length === 0) return { kind: 'none' };
|
|
207
|
+
const resolved = resolvePath(filePath);
|
|
208
|
+
const at = resolved.indexOf(WORKFLOWS_SEGMENT);
|
|
209
|
+
if (at === -1) return { kind: 'none' };
|
|
210
|
+
const rel = resolved.slice(at + 1);
|
|
211
|
+
if (filePath.endsWith('/')) return { kind: 'shape', rel };
|
|
212
|
+
const key = parseVaultPath(rel);
|
|
213
|
+
return key === null ? { kind: 'shape', rel } : { kind: 'key', rel, ...key };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function r7Deny(
|
|
217
|
+
label: string,
|
|
218
|
+
message: string,
|
|
219
|
+
logLine: string,
|
|
220
|
+
extra: Partial<WorkflowGuardDecision> = {}
|
|
221
|
+
): WorkflowGuardDecision {
|
|
222
|
+
return {
|
|
223
|
+
matchedRule: 'R7',
|
|
224
|
+
emitOutput: true,
|
|
225
|
+
output: {
|
|
226
|
+
decision: 'deny',
|
|
227
|
+
reason: `Workflow Guard ${label}: ${message}`,
|
|
228
|
+
context:
|
|
229
|
+
'R7 (RFC-044 F3): project-guidelines/workflows/<wf>/<phase>/<type>.md is written only by an artifact-producing sub-agent, while <wf> is active and in <phase>.',
|
|
230
|
+
},
|
|
231
|
+
logMessages: [`DENY (${label}) ${logLine}`],
|
|
232
|
+
...extra,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function r7FromProbe(
|
|
237
|
+
probe: ProbeResult,
|
|
238
|
+
target: { rel: string; workflowId: string; phase: string },
|
|
239
|
+
agent: string
|
|
240
|
+
): WorkflowGuardDecision {
|
|
241
|
+
const where = `file=${target.rel} agent=${agent}`;
|
|
242
|
+
if (probe.kind === 'not-found') {
|
|
243
|
+
return r7Deny(
|
|
244
|
+
'R7-WORKFLOW-NOT-FOUND',
|
|
245
|
+
`workflow ${target.workflowId} (from the path) does not exist in this project.`,
|
|
246
|
+
where
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
if (probe.kind === 'unavailable') {
|
|
250
|
+
// Only a token the API REJECTED proves the API is up. `no-token` is also what a DOWN API gives (login
|
|
251
|
+
// fails before any probe; measured S557 against a closed port), so it stays under API-UNAVAILABLE.
|
|
252
|
+
const label = probe.cause === 'unauthorized' ? 'R7-NO-TOKEN' : 'R7-API-UNAVAILABLE';
|
|
253
|
+
return r7Deny(
|
|
254
|
+
label,
|
|
255
|
+
`could not confirm workflow ${target.workflowId} with the API (cause: ${probe.cause}${probe.httpStatus === undefined ? '' : `, HTTP ${String(probe.httpStatus)}`}).\n${R7_RUNBOOK}`,
|
|
256
|
+
`${where} cause=${probe.cause}`
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
if (!ACTIVE_WORKFLOW_STATUSES.has(probe.status)) {
|
|
260
|
+
return r7Deny(
|
|
261
|
+
'R7-WORKFLOW-NOT-ACTIVE',
|
|
262
|
+
`workflow ${target.workflowId} is ${probe.status}, not active.`,
|
|
263
|
+
where
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
if ((probe.currentPhase ?? '').toLowerCase() !== target.phase) {
|
|
267
|
+
return r7Deny(
|
|
268
|
+
'R7-PHASE-NOT-CURRENT',
|
|
269
|
+
`workflow ${target.workflowId} is in phase ${String(probe.currentPhase)}, not ${target.phase}.`,
|
|
270
|
+
where
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
return {
|
|
274
|
+
matchedRule: 'R7',
|
|
275
|
+
emitOutput: true,
|
|
276
|
+
output: {
|
|
277
|
+
decision: 'allow',
|
|
278
|
+
context: `R7: ${agent} writes ${target.rel} (workflow ${target.workflowId} active, phase ${target.phase}).`,
|
|
279
|
+
},
|
|
280
|
+
logMessages: [`ALLOW (R7) ${where}`],
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** R7, pure. null = R7 does not apply (not a workflows/ path, or not a managed project): current rules. */
|
|
285
|
+
function decideR7(input: WorkflowGuardDecisionInput): WorkflowGuardDecision | null {
|
|
286
|
+
const match = matchR7Path(input.filePath);
|
|
287
|
+
if (match.kind === 'none') return null;
|
|
288
|
+
if (input.targetProjectRoot === null || input.isUnmanaged) return null;
|
|
289
|
+
return decideR7ForMatch(input, match);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** R7 on a workflows/ path of a managed project (the caller has checked both). */
|
|
293
|
+
function decideR7ForMatch(
|
|
294
|
+
input: WorkflowGuardDecisionInput,
|
|
295
|
+
match: Exclude<R7PathMatch, { kind: 'none' }>
|
|
296
|
+
): WorkflowGuardDecision {
|
|
297
|
+
const agent = input.agentType ? normalizeAgentName(input.agentType) : '';
|
|
298
|
+
if (agent === '') {
|
|
299
|
+
return r7Deny(
|
|
300
|
+
'R7-MAIN-AGENT',
|
|
301
|
+
`${match.rel} is written only by an artifact-producing sub-agent, never by the main agent (Q-PM-5).`,
|
|
302
|
+
`file=${match.rel}`
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
if (!ARTIFACT_PRODUCER_AGENTS.has(agent)) {
|
|
306
|
+
return r7Deny(
|
|
307
|
+
'R7-NOT-PRODUCER',
|
|
308
|
+
`agent ${agent} is not an artifact producer (ARTIFACT_PRODUCER_AGENTS).`,
|
|
309
|
+
`file=${match.rel} agent=${agent}`
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
if (match.kind === 'shape') {
|
|
313
|
+
return r7Deny(
|
|
314
|
+
'R7-SHAPE',
|
|
315
|
+
`${match.rel} is not project-guidelines/workflows/<workflow_id>/<phase>/<artifact_type>.md.`,
|
|
316
|
+
`file=${match.rel} agent=${agent}`
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
const target = { rel: match.rel, workflowId: match.workflowId, phase: match.phase };
|
|
320
|
+
if (input.r7Probe === undefined) {
|
|
321
|
+
return r7Deny('R7-PROBE-PENDING', 'workflow not confirmed yet.', `file=${match.rel}`, {
|
|
322
|
+
r7NeedsProbe: { workflowId: match.workflowId, phase: match.phase },
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
return r7FromProbe(input.r7Probe, target, agent);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
// R8 (RFC-044 F3, S6c): the Write of a VAULT_ONLY_WRITERS agent — pure
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
/** The fenced agent's bare name, or null when R8 does not apply (no agent_type, or an agent outside the set). */
|
|
333
|
+
function vaultOnlyWriter(agentType: string): string | null {
|
|
334
|
+
if (agentType === '') return null;
|
|
335
|
+
const agent = normalizeAgentName(agentType);
|
|
336
|
+
return VAULT_ONLY_WRITERS.has(agent) ? agent : null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const R8_CONTEXT =
|
|
340
|
+
'R8 (RFC-044 F3, S6c): this agent writes only (1) its artifact project-guidelines/workflows/<wf>/<phase>/<type>.md of a managed project, under R7, and (2) its own memory under .claude/agent-memory/<slug>/. SKIP_WORKFLOW_GUARD does not apply.';
|
|
341
|
+
|
|
342
|
+
function r8Deny(label: string, message: string, logLine: string): WorkflowGuardDecision {
|
|
343
|
+
return {
|
|
344
|
+
matchedRule: 'R8',
|
|
345
|
+
emitOutput: true,
|
|
346
|
+
output: {
|
|
347
|
+
decision: 'deny',
|
|
348
|
+
reason: `Workflow Guard ${label}: ${message}`,
|
|
349
|
+
context: R8_CONTEXT,
|
|
350
|
+
},
|
|
351
|
+
logMessages: [`DENY (${label}) ${logLine}`],
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Is the RESOLVED path inside <root>/.claude/agent-memory/<agent>/ (the separator keeps `<agent>-x/` out)? */
|
|
356
|
+
function isOwnMemory(filePath: string, root: string, agent: string): boolean {
|
|
357
|
+
const dir = join(root, '.claude', 'agent-memory', agent) + PATH_SEP;
|
|
358
|
+
return resolvePath(filePath).startsWith(dir);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** R8, pure. null = R8 does not apply (the caller is not a VAULT_ONLY_WRITERS agent): R7 and rules 1-7. */
|
|
362
|
+
function decideR8(input: WorkflowGuardDecisionInput): WorkflowGuardDecision | null {
|
|
363
|
+
const agent = vaultOnlyWriter(input.agentType);
|
|
364
|
+
if (agent === null) return null;
|
|
365
|
+
const where = `file=${input.filePath} agent=${agent}`;
|
|
366
|
+
const match = matchR7Path(input.filePath);
|
|
367
|
+
if (match.kind !== 'none' && input.targetProjectRoot !== null && !input.isUnmanaged) {
|
|
368
|
+
const types = VAULT_ONLY_WRITERS.get(agent) ?? null;
|
|
369
|
+
if (match.kind === 'key' && types !== null && !types.has(match.artifactType)) {
|
|
370
|
+
return r8Deny(
|
|
371
|
+
'R8-TYPE',
|
|
372
|
+
`agent ${agent} writes only ${[...types].join(', ')} under workflows/, not ${match.artifactType} (R-6: never the artifact under review).`,
|
|
373
|
+
`file=${match.rel} agent=${agent}`
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
return decideR7ForMatch(input, match);
|
|
377
|
+
}
|
|
378
|
+
if (
|
|
379
|
+
input.targetProjectRoot !== null &&
|
|
380
|
+
isOwnMemory(input.filePath, input.targetProjectRoot, agent)
|
|
381
|
+
) {
|
|
382
|
+
return { matchedRule: 'R8', emitOutput: false, logMessages: [`ALLOW (R8-MEMORY) ${where}`] };
|
|
383
|
+
}
|
|
384
|
+
return r8Deny(
|
|
385
|
+
'R8-OUTSIDE-VAULT',
|
|
386
|
+
`agent ${agent} may not write ${input.filePath}. It writes only project-guidelines/workflows/<wf>/<phase>/<type>.md of a managed project (R7) and its own memory .claude/agent-memory/${agent}/.`,
|
|
387
|
+
where
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** An exception while deciding for a VAULT_ONLY_WRITERS agent: a DENY, never main().catch -> exit 0. */
|
|
392
|
+
export function r8ExceptionDecision(error: unknown): WorkflowGuardDecision {
|
|
393
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
394
|
+
return r8Deny('R8-EXCEPTION', `unexpected error (${message}).`, 'exception');
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** An exception anywhere in the R7 branch of main(): a DENY, never main().catch -> exit 0 (fail-open). */
|
|
398
|
+
export function r7ExceptionDecision(error: unknown): WorkflowGuardDecision {
|
|
399
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
400
|
+
return r7Deny('R7-EXCEPTION', `unexpected error (${message}).\n${R7_RUNBOOK}`, 'exception');
|
|
131
401
|
}
|
|
132
402
|
|
|
133
403
|
const CONFIG_DOC_RE = /\.(json|ya?ml|md|env(\..*)?)$/;
|
|
@@ -152,6 +422,14 @@ export function decideWorkflowGuardAction(
|
|
|
152
422
|
agentType,
|
|
153
423
|
} = input;
|
|
154
424
|
|
|
425
|
+
// R8 (RFC-044 F3, S6c): FIRST — a VAULT_ONLY_WRITERS agent is decided by R8 + R7 only.
|
|
426
|
+
const r8 = decideR8(input);
|
|
427
|
+
if (r8 !== null) return r8;
|
|
428
|
+
|
|
429
|
+
// R7 (RFC-044 F3, S4): BEFORE rule 1 — SKIP_WORKFLOW_GUARD does not reach it.
|
|
430
|
+
const r7 = decideR7(input);
|
|
431
|
+
if (r7 !== null) return r7;
|
|
432
|
+
|
|
155
433
|
// Rule 1: explicit global bypass
|
|
156
434
|
if (skipWorkflowGuard) {
|
|
157
435
|
return {
|
|
@@ -340,17 +618,48 @@ export function decideWorkflowGuardAction(
|
|
|
340
618
|
};
|
|
341
619
|
}
|
|
342
620
|
|
|
343
|
-
|
|
344
|
-
|
|
621
|
+
/** Everything main() reads or writes outside the pure decision; injected by the tests. */
|
|
622
|
+
export interface WorkflowGuardDeps {
|
|
623
|
+
readStdin: () => Promise<Record<string, unknown>>;
|
|
624
|
+
env: Readonly<Record<string, string | undefined>>;
|
|
625
|
+
resolveHookProjectRoot: (dir: string) => string | null;
|
|
626
|
+
isUnmanaged: (projectRoot: string) => boolean;
|
|
627
|
+
getActiveWorkflow: () => Promise<WorkflowStatus | null>;
|
|
628
|
+
getWorkflowMode: (workflowId: string) => Promise<string | null>;
|
|
629
|
+
probeWorkflow: (workflowId: string) => Promise<ProbeResult>;
|
|
630
|
+
output: (output: WorkflowGuardOutput) => void;
|
|
631
|
+
log: (line: string) => void;
|
|
632
|
+
appendObservation: (
|
|
633
|
+
stdin: Record<string, unknown>,
|
|
634
|
+
observation: WorkflowGuardObservation
|
|
635
|
+
) => void;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** R7 network step: probe the workflow OF THE PATH and decide again; any exception is a DENY. */
|
|
639
|
+
async function decideWithProbe(
|
|
640
|
+
input: WorkflowGuardDecisionInput,
|
|
641
|
+
needs: { workflowId: string },
|
|
642
|
+
deps: WorkflowGuardDeps
|
|
643
|
+
): Promise<WorkflowGuardDecision> {
|
|
644
|
+
try {
|
|
645
|
+
const r7Probe = await deps.probeWorkflow(needs.workflowId);
|
|
646
|
+
return decideWorkflowGuardAction({ ...input, r7Probe });
|
|
647
|
+
} catch (e) {
|
|
648
|
+
return r7ExceptionDecision(e);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
export async function mainWithDeps(deps: WorkflowGuardDeps): Promise<void> {
|
|
653
|
+
const stdin = await deps.readStdin();
|
|
345
654
|
const filePath =
|
|
346
655
|
getField(stdin, 'tool_input.file_path') ||
|
|
347
656
|
getField(stdin, 'file_path') ||
|
|
348
657
|
getField(stdin, 'input.file_path');
|
|
349
658
|
|
|
350
|
-
log(
|
|
659
|
+
deps.log(`file_path=${filePath}`);
|
|
351
660
|
|
|
352
|
-
const skipWorkflowGuard =
|
|
353
|
-
const skipSubagentGuard =
|
|
661
|
+
const skipWorkflowGuard = deps.env.SKIP_WORKFLOW_GUARD === 'true';
|
|
662
|
+
const skipSubagentGuard = deps.env.SKIP_SUBAGENT_GUARD === 'true';
|
|
354
663
|
const agentId = getField(stdin, 'agent_id');
|
|
355
664
|
const agentType = getField(stdin, 'agent_type');
|
|
356
665
|
|
|
@@ -358,12 +667,10 @@ async function main(): Promise<void> {
|
|
|
358
667
|
// (file_path), not the session's cwd. Cheap fs walk-up (no network) — safe
|
|
359
668
|
// to compute unconditionally for every Write/Edit.
|
|
360
669
|
const targetDir = filePath.length > 0 ? dirname(filePath) : process.cwd();
|
|
361
|
-
const targetProjectRoot = resolveHookProjectRoot(targetDir);
|
|
362
|
-
const isUnmanaged =
|
|
363
|
-
targetProjectRoot !== null &&
|
|
364
|
-
existsSync(join(targetProjectRoot, '.orchestrator', '.unmanaged'));
|
|
670
|
+
const targetProjectRoot = deps.resolveHookProjectRoot(targetDir);
|
|
671
|
+
const isUnmanaged = targetProjectRoot !== null && deps.isUnmanaged(targetProjectRoot);
|
|
365
672
|
|
|
366
|
-
const baseInput:
|
|
673
|
+
const baseInput: WorkflowGuardDecisionInput = {
|
|
367
674
|
filePath,
|
|
368
675
|
skipWorkflowGuard,
|
|
369
676
|
skipSubagentGuard,
|
|
@@ -371,6 +678,7 @@ async function main(): Promise<void> {
|
|
|
371
678
|
isUnmanaged,
|
|
372
679
|
agentId,
|
|
373
680
|
agentType,
|
|
681
|
+
workflow: null,
|
|
374
682
|
};
|
|
375
683
|
|
|
376
684
|
// Two-pass evaluation: try with workflow=null first. Rules 1-5 never
|
|
@@ -380,41 +688,76 @@ async function main(): Promise<void> {
|
|
|
380
688
|
// unmanaged, and unregistered paths. Only when the trial itself lands on
|
|
381
689
|
// rule 7 ("no active workflow") do we actually need the network fetch to
|
|
382
690
|
// confirm — at most one real fetch, matching original behavior exactly.
|
|
383
|
-
|
|
691
|
+
// R7 has its own network step (probe of the workflow of the PATH), taken
|
|
692
|
+
// only when the trial asks for it (r7NeedsProbe), inside a DENY-on-throw.
|
|
693
|
+
let decision: WorkflowGuardDecision;
|
|
694
|
+
try {
|
|
695
|
+
decision = decideWorkflowGuardAction(baseInput);
|
|
696
|
+
} catch (e) {
|
|
697
|
+
if (vaultOnlyWriter(agentType) !== null) {
|
|
698
|
+
decision = r8ExceptionDecision(e);
|
|
699
|
+
} else {
|
|
700
|
+
if (matchR7Path(filePath).kind === 'none') throw e;
|
|
701
|
+
decision = r7ExceptionDecision(e);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
384
704
|
|
|
385
|
-
if (decision.
|
|
386
|
-
|
|
705
|
+
if (decision.r7NeedsProbe) {
|
|
706
|
+
decision = await decideWithProbe(baseInput, decision.r7NeedsProbe, deps);
|
|
707
|
+
} else if (decision.matchedRule === 7) {
|
|
708
|
+
const activeWorkflow = await deps.getActiveWorkflow();
|
|
387
709
|
let workflow: WorkflowGuardWorkflowInput | null = null;
|
|
388
710
|
if (activeWorkflow) {
|
|
389
|
-
const mode = await getWorkflowMode(activeWorkflow.id);
|
|
711
|
+
const mode = await deps.getWorkflowMode(activeWorkflow.id);
|
|
390
712
|
workflow = { id: activeWorkflow.id, mode, currentPhase: activeWorkflow.currentPhase };
|
|
391
713
|
}
|
|
392
714
|
decision = decideWorkflowGuardAction({ ...baseInput, workflow });
|
|
393
715
|
}
|
|
394
716
|
|
|
395
717
|
for (const line of decision.logMessages) {
|
|
396
|
-
log(
|
|
718
|
+
deps.log(line);
|
|
397
719
|
}
|
|
398
720
|
|
|
399
721
|
if (decision.observation) {
|
|
400
722
|
try {
|
|
401
|
-
|
|
402
|
-
// RFC-040 Operator Multi-Project — Slice 5 (C5): resolves under the
|
|
403
|
-
// project root walked up from cwd (falls back to cwd when unregistered).
|
|
404
|
-
const memoryBaseDir = resolveAgentMemoryBaseDir(process.cwd());
|
|
405
|
-
appendObservation(memoryBaseDir, 'main', {
|
|
406
|
-
timestamp: new Date().toISOString(),
|
|
407
|
-
session_id: sessionId,
|
|
408
|
-
...decision.observation,
|
|
409
|
-
});
|
|
723
|
+
deps.appendObservation(stdin, decision.observation);
|
|
410
724
|
} catch {
|
|
411
725
|
// fail-open — never block on telemetry
|
|
412
726
|
}
|
|
413
727
|
}
|
|
414
728
|
|
|
415
729
|
if (decision.emitOutput && decision.output) {
|
|
416
|
-
|
|
730
|
+
deps.output(decision.output);
|
|
417
731
|
}
|
|
418
732
|
}
|
|
419
733
|
|
|
734
|
+
const defaultDeps: WorkflowGuardDeps = {
|
|
735
|
+
readStdin,
|
|
736
|
+
env: process.env,
|
|
737
|
+
resolveHookProjectRoot,
|
|
738
|
+
isUnmanaged: (projectRoot) => existsSync(join(projectRoot, '.orchestrator', '.unmanaged')),
|
|
739
|
+
getActiveWorkflow,
|
|
740
|
+
getWorkflowMode,
|
|
741
|
+
probeWorkflow: (workflowId) => probeWorkflow(workflowId),
|
|
742
|
+
output: outputPreToolUse,
|
|
743
|
+
log: (line) => {
|
|
744
|
+
log(HOOK, line);
|
|
745
|
+
},
|
|
746
|
+
appendObservation: (stdin, observation) => {
|
|
747
|
+
const sessionId = getField(stdin, 'session_id') || getField(stdin, 'claude_session_id') || '';
|
|
748
|
+
// RFC-040 Operator Multi-Project — Slice 5 (C5): resolves under the
|
|
749
|
+
// project root walked up from cwd (falls back to cwd when unregistered).
|
|
750
|
+
const memoryBaseDir = resolveAgentMemoryBaseDir(process.cwd());
|
|
751
|
+
appendObservation(memoryBaseDir, 'main', {
|
|
752
|
+
timestamp: new Date().toISOString(),
|
|
753
|
+
session_id: sessionId,
|
|
754
|
+
...observation,
|
|
755
|
+
});
|
|
756
|
+
},
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
async function main(): Promise<void> {
|
|
760
|
+
await mainWithDeps(defaultDeps);
|
|
761
|
+
}
|
|
762
|
+
|
|
420
763
|
main().catch(() => process.exit(0));
|