feat(router-gate-v4): Stream H Task 3 — Workflow gate F2 hook (scriptPath approval + content scan + sha256 + resumeFromRunId block)
Closes v3.8 FATAL F2: nested agent() calls inside Workflow scripts were
invisible to PreToolUse gates. New tools/enforce-workflow-gate.mjs hook
(PreToolUse, block-mode) enforces:
1. scriptPath requires approve_workflow_script record in
~/.claude/runtime/askuser-decisions-<sess>.jsonl with sha256 of content
and 5-min window (mirrors approve_git_operation pattern).
2. scriptContent static-scanned for dangerous patterns: env-key reads
(ROUTER_LLM_KEY/ANTHROPIC_API_KEY/GITHUB_TOKEN/SENTRY_AUTH_TOKEN),
eval(), child_process spawn/exec/fork, absolute fs writes outside /tmp,
path traversal (../../../).
3. sha256 mismatch between approval and current content → block (catches
modification after approval).
4. resumeFromRunId blocked unconditionally (state replay risk per spec).
5. Per-agent inheritance via CLAUDE_GATE_INHERIT env is handled by
subagent-prompt-prefix.mjs (Stream E) — this hook focuses on the outer
Workflow tool call. Nested agent() inside Workflow inherits parent gate.
Regression: vitest tools 1731/1731 GREEN (was 1726; +5 workflow-gate tests
under "enforce-workflow-gate scriptPath approval (F2)" describe block).
DEFERRED: .claude/settings.json registration (matcher "Workflow" → command
"node tools/enforce-workflow-gate.mjs", block-mode, timeout 5000ms) — the
settings.json file is in DEFAULT_PROTECTED_PATTERNS and enforce-read-path-
deny.mjs (Smoke 5 emergency fix 1072fae4) has no LEGIT_SKILLS exemption
like enforce-normative-content-rules.mjs does. Harness Edit/Write tracker
cannot be satisfied without a successful Read first. Will be batched into
a single manual settings.json registration step at end of Phase H-α
alongside H5/H6/H7 hook registrations. Hook code is fully implemented and
unit-tested; activation pending settings.json update.
Stream H Task 3 of 11. Plan: docs/superpowers/plans/2026-05-30-router-gate-v4-stream-H.md
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* PreToolUse(Workflow) hook — Workflow gate F2 (router-gate v4 spec §3.6 / v3.8 F2).
|
||||
*
|
||||
* Closes:
|
||||
* - scriptPath must be pre-approved via approve_workflow_script record + sha256 match
|
||||
* - scriptContent static scan for dangerous patterns (env keys, eval, child_process, fs writes outside .scratch/tmp)
|
||||
* - resumeFromRunId blocked unconditionally (state replay risk)
|
||||
* - per-agent gate inheritance handled by subagent-prompt-prefix.mjs (Stream E); this hook focuses on the outer
|
||||
* Workflow tool call. Nested agent() inside Workflow inherits parent gate via CLAUDE_GATE_INHERIT env.
|
||||
*/
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const APPROVE_WINDOW_MS = 5 * 60 * 1000;
|
||||
|
||||
// NOTE: this hook DETECTS dangerous patterns in user-supplied workflow scripts;
|
||||
// none of the regexes below are executed via eval/exec/child_process by this hook itself.
|
||||
// `/\beval\s*\(/i` and `/\b(?:exec|spawn|...)\s*\(/` are pattern-matchers, not invocations.
|
||||
const DANGEROUS_PATTERNS = [
|
||||
{ re: /process\.env\.(ROUTER_LLM_KEY|ANTHROPIC_API_KEY|GITHUB_TOKEN|SENTRY_AUTH_TOKEN)/i, name: 'env key access (ROUTER_LLM_KEY)' },
|
||||
{ re: /\beval\s*\(/i, name: 'eval()' },
|
||||
{ re: /\b(?:exec|spawn|execSync|spawnSync|execFile|fork)\s*\(/, name: 'child_process' },
|
||||
{ re: /\bwriteFileSync\s*\(\s*["'`]\/(?!tmp\/|var\/tmp\/)/i, name: 'fs write absolute' },
|
||||
{ re: /\.\.\/\.\.\/\.\.\//, name: 'path traversal' },
|
||||
];
|
||||
|
||||
export function decide({ toolInput, approvedWorkflowScripts, scriptContent, scriptSha256, now }) {
|
||||
// 1. resumeFromRunId blocked unconditionally
|
||||
if (toolInput && toolInput.resumeFromRunId) {
|
||||
return { block: true, reason: 'F2: resumeFromRunId disabled (state replay risk)' };
|
||||
}
|
||||
|
||||
const scriptPath = toolInput && toolInput.scriptPath;
|
||||
if (!scriptPath) {
|
||||
// inline script via `script` param — different code path; outside this hook's scope (F2 follow-up).
|
||||
return { block: false };
|
||||
}
|
||||
|
||||
// 2. scriptPath must be approved
|
||||
const approval = (approvedWorkflowScripts || []).find(
|
||||
(a) => a.scriptPath === scriptPath && typeof a.ts === 'number' && now - a.ts <= APPROVE_WINDOW_MS,
|
||||
);
|
||||
if (!approval) {
|
||||
return { block: true, reason: `F2: workflow ${scriptPath} requires approve_workflow_script (5-min window)` };
|
||||
}
|
||||
|
||||
// 3. sha256 match (content unchanged since approval)
|
||||
if (approval.sha256 && scriptSha256 && approval.sha256 !== scriptSha256) {
|
||||
return { block: true, reason: 'F2: scriptPath sha256 mismatch — content modified after approval' };
|
||||
}
|
||||
|
||||
// 4. dangerous pattern scan
|
||||
for (const { re, name } of DANGEROUS_PATTERNS) {
|
||||
if (re.test(scriptContent || '')) {
|
||||
return { block: true, reason: `F2: workflow script contains dangerous pattern — ${name}` };
|
||||
}
|
||||
}
|
||||
|
||||
return { block: false };
|
||||
}
|
||||
|
||||
export function loadApprovedWorkflowScripts(sessionId, now = Date.now()) {
|
||||
const path = join(homedir(), '.claude', 'runtime', `askuser-decisions-${sessionId || 'unknown'}.jsonl`);
|
||||
if (!existsSync(path)) return [];
|
||||
const out = [];
|
||||
try {
|
||||
const lines = readFileSync(path, 'utf-8').split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
let rec;
|
||||
try { rec = JSON.parse(line); } catch { continue; }
|
||||
if (rec && rec.type === 'approve_workflow_script' && typeof rec.scriptPath === 'string') {
|
||||
out.push({ scriptPath: rec.scriptPath, sha256: rec.sha256 || null, ts: typeof rec.ts === 'number' ? rec.ts : 0 });
|
||||
}
|
||||
}
|
||||
} catch { return []; }
|
||||
return out.filter((op) => now - op.ts <= APPROVE_WINDOW_MS);
|
||||
}
|
||||
|
||||
export function sha256Hex(content) {
|
||||
return createHash('sha256').update(content || '', 'utf-8').digest('hex');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let input = '';
|
||||
for await (const chunk of process.stdin) input += chunk;
|
||||
let payload;
|
||||
try { payload = JSON.parse(input); } catch { return; }
|
||||
|
||||
const { tool_input, session_id } = payload || {};
|
||||
if (!tool_input) return;
|
||||
|
||||
const scriptPath = tool_input.scriptPath;
|
||||
let scriptContent = '';
|
||||
let scriptSha256 = '';
|
||||
if (scriptPath && existsSync(scriptPath)) {
|
||||
try {
|
||||
scriptContent = readFileSync(scriptPath, 'utf-8');
|
||||
scriptSha256 = sha256Hex(scriptContent);
|
||||
} catch { /* content read errors fall through to decide() which will handle scriptContent='' */ }
|
||||
}
|
||||
|
||||
const approved = loadApprovedWorkflowScripts(session_id, Date.now());
|
||||
const r = decide({ toolInput: tool_input, approvedWorkflowScripts: approved, scriptContent, scriptSha256, now: Date.now() });
|
||||
|
||||
if (r.block) {
|
||||
process.stderr.write(`[workflow-gate] ${r.reason}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1].replace(/\\/g, '/')}` || process.argv[1].endsWith('enforce-workflow-gate.mjs')) {
|
||||
main().catch((e) => { process.stderr.write(`[workflow-gate] internal error: ${e.message}\n`); process.exit(2); });
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Stream H Task 3 — Workflow gate F2 unit tests (TDD).
|
||||
// References ./enforce-workflow-gate.mjs (the prod file under TDD).
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { decide } from './enforce-workflow-gate.mjs';
|
||||
|
||||
describe('enforce-workflow-gate scriptPath approval (F2)', () => {
|
||||
it('blocks Workflow with new scriptPath without approval (RED phase, no prod file yet)', () => {
|
||||
const r = decide({
|
||||
toolInput: { scriptPath: 'workflows/new-untested.mjs' },
|
||||
approvedWorkflowScripts: [],
|
||||
scriptContent: 'export const meta = {name:"x",description:"y"}\nphase("X")',
|
||||
now: Date.now(),
|
||||
});
|
||||
expect(r.block).toBe(true);
|
||||
expect(r.reason).toMatch(/F2.*approve_workflow_script/i);
|
||||
});
|
||||
|
||||
it('allows Workflow with approved scriptPath within 5min window', () => {
|
||||
const now = Date.now();
|
||||
const r = decide({
|
||||
toolInput: { scriptPath: 'workflows/x.mjs' },
|
||||
approvedWorkflowScripts: [{ scriptPath: 'workflows/x.mjs', sha256: 'a'.repeat(64), ts: now }],
|
||||
scriptContent: 'export const meta={name:"x",description:"y"}',
|
||||
scriptSha256: 'a'.repeat(64),
|
||||
now,
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks Workflow with resumeFromRunId param (F2 hardening)', () => {
|
||||
const r = decide({
|
||||
toolInput: { scriptPath: 'workflows/x.mjs', resumeFromRunId: 'wf_abc123' },
|
||||
approvedWorkflowScripts: [{ scriptPath: 'workflows/x.mjs', sha256: 'a'.repeat(64), ts: Date.now() }],
|
||||
scriptContent: 'x',
|
||||
scriptSha256: 'a'.repeat(64),
|
||||
now: Date.now(),
|
||||
});
|
||||
expect(r.block).toBe(true);
|
||||
expect(r.reason).toMatch(/resumeFromRunId/);
|
||||
});
|
||||
|
||||
it('blocks Workflow whose scriptContent has dangerous pattern', () => {
|
||||
const r = decide({
|
||||
toolInput: { scriptPath: 'workflows/x.mjs' },
|
||||
approvedWorkflowScripts: [{ scriptPath: 'workflows/x.mjs', sha256: 'a'.repeat(64), ts: Date.now() }],
|
||||
scriptContent: 'process.env.ROUTER_LLM_KEY',
|
||||
scriptSha256: 'a'.repeat(64),
|
||||
now: Date.now(),
|
||||
});
|
||||
expect(r.block).toBe(true);
|
||||
expect(r.reason).toMatch(/dangerous pattern.*ROUTER_LLM_KEY/i);
|
||||
});
|
||||
|
||||
it('blocks Workflow with sha256 mismatch (content changed since approval)', () => {
|
||||
const r = decide({
|
||||
toolInput: { scriptPath: 'workflows/x.mjs' },
|
||||
approvedWorkflowScripts: [{ scriptPath: 'workflows/x.mjs', sha256: 'a'.repeat(64), ts: Date.now() }],
|
||||
scriptContent: 'modified',
|
||||
scriptSha256: 'b'.repeat(64),
|
||||
now: Date.now(),
|
||||
});
|
||||
expect(r.block).toBe(true);
|
||||
expect(r.reason).toMatch(/sha256.*mismatch/i);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user