From 13f5350ece4ebbff6b2f37b4e7d4ef82a3b9e78d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sat, 30 May 2026 10:50:50 +0300 Subject: [PATCH] =?UTF-8?q?feat(router-gate-v4):=20Stream=20H=20Task=203?= =?UTF-8?q?=20=E2=80=94=20Workflow=20gate=20F2=20hook=20(scriptPath=20appr?= =?UTF-8?q?oval=20+=20content=20scan=20+=20sha256=20+=20resumeFromRunId=20?= =?UTF-8?q?block)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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-.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 --- tools/enforce-workflow-gate.mjs | 118 +++++++++++++++++++++++++++ tools/enforce-workflow-gate.test.mjs | 65 +++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 tools/enforce-workflow-gate.mjs create mode 100644 tools/enforce-workflow-gate.test.mjs diff --git a/tools/enforce-workflow-gate.mjs b/tools/enforce-workflow-gate.mjs new file mode 100644 index 00000000..82094d6a --- /dev/null +++ b/tools/enforce-workflow-gate.mjs @@ -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); }); +} diff --git a/tools/enforce-workflow-gate.test.mjs b/tools/enforce-workflow-gate.test.mjs new file mode 100644 index 00000000..33aa3000 --- /dev/null +++ b/tools/enforce-workflow-gate.test.mjs @@ -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); + }); +});