Files
portal/tools/enforce-workflow-gate.test.mjs
T
Дмитрий 5520534424 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 25e184e5) 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
2026-05-30 10:50:50 +03:00

66 lines
2.5 KiB
JavaScript

// 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);
});
});