/** * Tests for msd-workflow-guard.js PreToolUse hook. */ 'use strict'; process.env.MSD_TEST_MODE = '1'; const { test, describe, before, after } = require('node:test'); // #3504: test-only fault injection flag for the fail-closed posture tests below. // Makes the hook throw right after stdin parse — the internal-error vector the // outer catch must handle without downgrading the force-add block to an allow. const FAULT_ENV = { ...process.env, MSD_TEST_WORKFLOW_GUARD_FAULT: '1' }; const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const { runHook: runHookSeam } = require('./helpers/process-seam.cjs'); const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const { PROBE_TIMEOUT_MS, HOOK_FANOUT_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); const { cleanup } = require('./helpers.cjs'); const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'msd-workflow-guard.js'); function runHook(payload, timeoutMs = 5000) { const input = JSON.stringify(payload); const r = runHookSeam(HOOK_PATH, [], { input, timeoutMs }); if (r.exitCode === 0) { return { exitCode: 0, stdout: r.stdout.trim(), stderr: '' }; } return { exitCode: r.exitCode ?? 1, stdout: r.stdout.trim(), stderr: r.stderr.trim(), }; } describe('workflow guard blocks force-add on a worktree-agent branch', () => { // A repo on a worktree-agent-* branch with the guard enabled: the one // state where the Bash branch produces an observable block, so a dormant // guard (silent exit 0) is distinguishable from a working one (exit 2). let repoDir; before(() => { repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-workflow-guard-')); const initResult = runHookSeam( '-c', ['git init -q -b worktree-agent-test && git config user.email t@t && git config user.name t'], { interpreter: 'bash', cwd: repoDir }, ); throwIfFailed(initResult, 'bash -c '); fs.mkdirSync(path.join(repoDir, '.planning')); fs.writeFileSync( path.join(repoDir, '.planning', 'config.json'), JSON.stringify({ hooks: { workflow_guard: true } }) ); }); after(() => { cleanup(repoDir); }); test('Bash force-add on a worktree-agent branch is blocked', () => { const r = runHook({ tool_name: 'Bash', tool_input: { command: 'git add -f secrets.env' }, cwd: repoDir, }); assert.equal(r.exitCode, 2); assert.equal(JSON.parse(r.stdout).code, 'WORKTREE_AGENT_FORCE_ADD_FORBIDDEN'); }); }); // #3504 (epic #1900 F22b) — the enabled force-add guard must fail CLOSED on // internal error. The outer catch used to be `catch { process.exit(0) }`, so // any throw between stdin parse and the block decision silently downgraded a // should-BLOCK call into an allow. No JSON-expressible input throws today // (#2547/#2595 hardened every read; tokenize is total), so the fault vector is // the hook's test-only seam MSD_TEST_WORKFLOW_GUARD_FAULT=1, which throws // immediately after parse. The payload uses a BENIGN command — proving the // catch's own context re-derivation blocks, not the happy-path detector. describe('#3504: internal error fails closed for the enabled force-add guard', () => { function makeGuardRepo(branch, workflowGuard) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-workflow-guard-fc-')); const initResult = runHookSeam( '-c', [`git init -q -b ${branch} && git config user.email t@t && git config user.name t`], { interpreter: 'bash', cwd: dir }, ); throwIfFailed(initResult, `bash -c `); fs.mkdirSync(path.join(dir, '.planning')); fs.writeFileSync( path.join(dir, '.planning', 'config.json'), JSON.stringify({ hooks: { workflow_guard: workflowGuard } }) ); return dir; } function runFaultHook(payload, cwd) { const r = runHookSeam(HOOK_PATH, [], { input: JSON.stringify({ ...payload, cwd }), env: FAULT_ENV, timeoutMs: PROBE_TIMEOUT_MS, }); return { exitCode: r.exitCode ?? 1, stdout: r.stdout.trim(), stderr: r.stderr.trim() }; } describe('blocking context holds → exit 2', () => { let agentRepo; before(() => { agentRepo = makeGuardRepo('worktree-agent-test', true); }); after(() => { cleanup(agentRepo); }); test('internal error on a benign agent-branch Bash call blocks (exit 2)', () => { const r = runFaultHook({ tool_name: 'Bash', tool_input: { command: 'git status' } }, agentRepo); assert.equal(r.exitCode, 2, `fault + guard enabled + worktree-agent branch must fail CLOSED. stderr: ${r.stderr}`); const output = JSON.parse(r.stdout); assert.equal(output.code, 'WORKTREE_AGENT_FORCE_ADD_FORBIDDEN'); assert.equal(output.origin, 'fail-closed', 'a block emitted by the catch must identify itself structurally, not claim a detected force-add'); assert.ok(r.stderr.length > 0, 'block reason must reach stderr (exit-2 protocol)'); }); test('fault before force-add detection still blocks the force-add call', () => { const r = runFaultHook( { tool_name: 'Bash', tool_input: { command: 'git add -f secrets.env' } }, agentRepo); assert.equal(r.exitCode, 2); assert.equal(JSON.parse(r.stdout).code, 'WORKTREE_AGENT_FORCE_ADD_FORBIDDEN'); }); }); describe('blocking context absent → exit 0 (advisory posture unchanged)', () => { let agentRepo; before(() => { agentRepo = makeGuardRepo('worktree-agent-test', true); }); after(() => { cleanup(agentRepo); }); test('internal error on a Write advisory stays exit 0', () => { const r = runFaultHook( { tool_name: 'Write', tool_input: { path: path.join(agentRepo, 'src', 'app.js') } }, agentRepo); assert.equal(r.exitCode, 0, `advisory legs fail open by design. stderr: ${r.stderr}`); }); test('unparseable payload stays exit 0', () => { const r = runHookSeam(HOOK_PATH, [], { input: 'not-json{', env: FAULT_ENV, timeoutMs: PROBE_TIMEOUT_MS }); assert.equal(r.exitCode ?? 1, 0, 'a payload JSON.parse cannot read establishes no context'); }); }); describe('guard disabled or off an agent branch → exit 0', () => { let mainRepo; let disabledRepo; before(() => { mainRepo = makeGuardRepo('main', true); disabledRepo = makeGuardRepo('worktree-agent-test', false); }); after(() => { cleanup(mainRepo); cleanup(disabledRepo); }); test('internal error off an agent branch stays exit 0', () => { const r = runFaultHook({ tool_name: 'Bash', tool_input: { command: 'git status' } }, mainRepo); assert.equal(r.exitCode, 0, `non-agent branch must stay allowed. stderr: ${r.stderr}`); }); test('internal error with the guard disabled stays exit 0', () => { const r = runFaultHook( { tool_name: 'Bash', tool_input: { command: 'git status' } }, disabledRepo); assert.equal(r.exitCode, 0, `disabled guard is inert even on faults. stderr: ${r.stderr}`); }); test('internal error outside a MSD project (no .planning/) stays exit 0', (t) => { const bare = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-workflow-guard-bare-')); t.after(() => cleanup(bare)); const initResult = runHookSeam( '-c', ['git init -q -b worktree-agent-test'], { interpreter: 'bash', cwd: bare, timeoutMs: PROBE_TIMEOUT_MS }, ); throwIfFailed(initResult, 'bash -c '); const r = runFaultHook({ tool_name: 'Bash', tool_input: { command: 'git status' } }, bare); assert.equal(r.exitCode, 0, `non-MSD dir must stay allowed. stderr: ${r.stderr}`); }); }); }); // #3504 isolated-review finding 1: the force-add detector's global-flag walk // had drifted from git-cmd.js's classifier — six flags known inline, every // other git global option (`-c =`, `--no-optional-locks`, // `--literal-pathspecs`, `--namespace=…`) broke the walk BEFORE the `add` // token was reached, and the whole invocation was silently skipped. A silent // miss is not a throw, so the fail-closed catch is structurally blind to it — // these spellings must be classified by the shared walk (skipToSubcommand). describe('#3504: global-flag spellings of git add -f reach the shared classifier', () => { let agentRepo; let mainRepo; before(() => { const mk = (branch) => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-workflow-guard-bypass-')); // Bash FAN-OUT: three chained git commands under one `bash` // interpreter, not a single git plumbing call — the wrong class for // `PROBE_TIMEOUT_MS`. Same class as the observed CI failures in // tests/quick-branching.test.cjs (PR #3787 run 32668773524) and // tests/worktree-safety.test.cjs (`next` run 32608945654). See // HOOK_FANOUT_TIMEOUT_MS in ./helpers/timeouts.cjs for the class // rationale. const initResult = runHookSeam( '-c', [`git init -q -b ${branch} && git config user.email t@t && git config user.name t`], { interpreter: 'bash', cwd: dir, timeoutMs: HOOK_FANOUT_TIMEOUT_MS }, ); throwIfFailed(initResult, `bash -c `); return dir; }; agentRepo = mk('worktree-agent-test'); mainRepo = mk('main'); for (const repo of [agentRepo, mainRepo]) { fs.mkdirSync(path.join(repo, '.planning')); fs.writeFileSync( path.join(repo, '.planning', 'config.json'), JSON.stringify({ hooks: { workflow_guard: true } }) ); } }); after(() => { cleanup(agentRepo); cleanup(mainRepo); }); function runGuard(command, cwd) { const r = runHookSeam(HOOK_PATH, [], { input: JSON.stringify({ tool_name: 'Bash', tool_input: { command }, cwd }), timeoutMs: PROBE_TIMEOUT_MS, }); return { exitCode: r.exitCode ?? 1, stdout: r.stdout.trim() }; } // Each of these exited 0 pre-fix (the silent-miss bypass); each must exit 2. for (const [label, command] of [ ['-c config override before the subcommand', 'git -c core.hooksPath=/tmp/x add -f secrets.env'], ['-c glued form (-ckey=value)', 'git -cfoo.bar=1 add -f secrets.env'], ['--no-optional-locks', 'git --no-optional-locks add -f secrets.env'], ['--literal-pathspecs', 'git --literal-pathspecs add -f secrets.env'], ['--namespace=… (=form argument-taking flag)', 'git --namespace=foo add -f secrets.env'], ['env-prefix + boolean flag combo', 'GIT_PAGER=cat git --no-replace-objects add -f secrets.env'], ['compound command after &&', 'cd /tmp && git add --force secrets.env'], ]) { test(`force-add is blocked behind a global flag (${label})`, () => { const r = runGuard(command, agentRepo); assert.equal(r.exitCode, 2, `must block. stderr: ${r.stderr}`); const output = JSON.parse(r.stdout); assert.equal(output.code, 'WORKTREE_AGENT_FORCE_ADD_FORBIDDEN'); assert.equal(output.origin, 'force-add-detected'); }); } test('-C resolves the probed repo: force-add via -C at the agent repo from a main-branch cwd still blocks', () => { const r = runGuard(`git -C ${JSON.stringify(agentRepo)} add -f secrets.env`, mainRepo); assert.equal(r.exitCode, 2, 'the branch probe must follow -C to the target repo'); assert.equal(JSON.parse(r.stdout).origin, 'force-add-detected'); }); test('the same spellings on a non-agent branch stay allowed (no over-block)', () => { const r = runGuard('git -c core.hooksPath=/tmp/x add -f secrets.env', mainRepo); assert.equal(r.exitCode, 0); assert.equal(r.stdout, ''); }); });