// tests/hooks/bash-guard-corpus-4-5b2-a.test.mjs // Fix round 4.5b2, part A (decisions 91, 107 and 123): the red proof of the // bypass corpus tests/guard-bypass-corpus-4-5b2-a.sh. The corpus holds // commands the args-heredoc guard in hooks/scripts/pre-bash-executor.mjs must // block; these tests show it passes on the hook and fails on hooks that let // an entry through. Untagged: no red wrapper lists this file. // Each corpus run is /bin/bash tests/guard-bypass-corpus-4-5b2-a.sh --hook // . The mutated hooks are copies (pluginCopy, // tests/hooks/guard-helpers.mjs), each find text asserted to occur exactly // once first, so a later edit of a mutated line fails with "matched 0 times", // never passes silently (the mutatedCopy pattern of // tests/hooks/bash-guard-reader-pins.test.mjs). import { test, after } from 'node:test'; import { strict as assert } from 'node:assert'; import { execFile } from 'node:child_process'; import { readFileSync, writeFileSync, rmSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { runHook } from '../helpers/hook-helper.mjs'; import { childEnv } from '../helpers/child-env.mjs'; import { tmp, pluginCopy } from './guard-helpers.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); const ROOT = join(HERE, '..', '..'); const PRE_BASH = join(ROOT, 'hooks', 'scripts', 'pre-bash-executor.mjs'); const CORPUS = join(ROOT, 'tests', 'guard-bypass-corpus-4-5b2-a.sh'); const ARGS_DELIM = 'VOYAGE_ARGS_EOF_7F3C9E'; const ARGS_RULE = /args-heredoc-closed-early/; const UNQUOTED = /opens VOYAGE_ARGS_EOF_7F3C9E unquoted/; const D = ARGS_DELIM; // The names the plan lists (Step 3, 3c); each must stand in exactly one // "E " line of the corpus, so the corpus cannot be thinner than this. const NAMES = [ 'fs-75', 'fs-78', 'fs-96', 'fs-104', 'fs-105', 'fs-106', 'fs-107', 'fs-108', 'fs-112', 'fs-114', 'fs-116', 'fs-118', 'fs-120', 'fs-122', 'fs-127', 'fs-129', 'fs-132', 'ag-61', 'ag-62', 'scan-single-backslash', 'scan-dq-escaped-quote', 'esc-quote-open', 'esc-bash-c-dq', 'esc-eval-dq', 'esc-bash-c-ansi', 'esc-eval-ansi', 'esc-bash-c-locale', 'esc-heredoc-open', 'esc-backquote', 'esc-close-trailing-space', 'esc-close-cr', 'esc-close-tab', 'esc-two-heredocs', 'esc-cj', 'esc-e1', 'esc-e2', 'esc-e2b', 'esc-e4b', 'esc-e4d', 'esc-e8', 'esc-e3', 'esc-e18', 'esc-e19', 'esc-cnt', 'sfx-word', 'sfx-dot', 'sfx-backquote', 'split-in-string', 'split-in-string-dot', 'split-in-string-word', 'split-in-string-closed', 'split-in-body', 'evl-x1', 'evl-x1b', 'evl-sh-c', 'evl-x2', 'evl-split-word', 'evl-split-dq', 'evl-split-sq', 'evl-split-before-word', 'evl-blank-escaped', 'evl-empty-pair', 'evl-escaped-dash', ]; // The [CNT] command (tests/hooks/bash-guard-4-5b2-a.test.mjs): an opener split // between its two < whose heredoc is fed to bash. const CNT = `bash <\\\n { const child = execFile('/bin/bash', [CORPUS, '--hook', hook], { timeout: CORPUS_TIMEOUT_MS, env: childEnv(), maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => { resolve({ code: child.exitCode ?? 1, stdout: stdout || '', stderr: stderr || '' }); }); child.stdin.end(); }); } const corpusText = () => readFileSync(CORPUS, 'utf8'); const entryLines = (text) => text.split('\n').filter((l) => l.startsWith('E ')); // The command of the corpus entry `name` (its JSON string). function entryCommand(name) { const line = entryLines(corpusText()).find((l) => l.startsWith(`E ${name} `)); assert.ok(line, `no corpus entry ${name}`); return JSON.parse(line.slice(`E ${name} `.length)); } const dirs = []; after(() => { for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); }); // A hook stub in a temp directory: it reads the whole input, then runs `body` // with `command` set to the input's command. function stubHook(body) { const dir = tmp('voyage-corpus-stub-'); dirs.push(dir); const file = join(dir, 'stub-hook.mjs'); writeFileSync(file, `let s = ''; process.stdin.on('data', (d) => { s += d; }); process.stdin.on('end', () => { const command = JSON.parse(s).tool_input.command; ${body} }); `); return file; } // A copy of the plugin whose hook has `find` (asserted to occur exactly once) // replaced by `replace`. Returns the hook path and the unmutated text. function mutatedHook(find, replace) { const root = pluginCopy(); dirs.push(root); const hook = join(root, 'hooks', 'scripts', 'pre-bash-executor.mjs'); const src = readFileSync(hook, 'utf8'); const count = src.split(find).length - 1; assert.strictEqual(count, 1, `the mutation text matched ${count} times, not once: ${find}`); return { hook, mutate: () => writeFileSync(hook, src.replace(find, () => replace)) }; } const failLines = (out) => out.split('\n').filter((l) => l.startsWith('FAIL - ')); test('part-A corpus: every entry is blocked by the hook, and the count line counts every E line', async () => { const { code, stdout, stderr } = await runCorpus(PRE_BASH); assert.strictEqual(code, 0, `the corpus did not pass on the hook (exit ${code}):\n${failLines(stdout).join('\n')}\n${stderr}`); const m = /^corpus: (\d+) entries, (\d+) blocked, (\d+) not blocked$/m.exec(stdout); assert.ok(m, `no count line in the output:\n${stdout}`); const lines = entryLines(corpusText()).length; assert.strictEqual(Number(m[1]), lines, `the count line says ${m[1]} entries, the script holds ${lines} E lines`); assert.strictEqual(Number(m[2]), lines, `the count line says ${m[2]} blocked of ${lines}`); assert.strictEqual(stdout.split('\n').filter((l) => l.startsWith('ok - ')).length, lines, 'not one ok line per entry'); }); test('part-A corpus: the corpus holds every entry the plan names, each in exactly one E line', () => { const lines = entryLines(corpusText()); for (const name of NAMES) { const n = lines.filter((l) => l.startsWith(`E ${name} `)).length; assert.strictEqual(n, 1, `the corpus holds ${n} E lines for ${name}, not one`); } assert.ok(lines.length >= NAMES.length, `the corpus holds ${lines.length} E lines, fewer than the ${NAMES.length} names`); }); test('part-A corpus: a hook that lets every command through fails the corpus (exit 1)', async () => { const hook = stubHook('process.exit(0);'); const { code, stdout } = await runCorpus(hook); assert.strictEqual(code, 1, `the corpus did not fail on a hook that exits 0 (exit ${code}):\n${stdout}`); assert.strictEqual(failLines(stdout).length, entryLines(corpusText()).length, 'not every entry failed'); }); test('part-A corpus: a hook that blocks with another rule name fails the corpus (exit 1)', async () => { const hook = stubHook("process.stderr.write('[voyage] BLOCKED: some-other-rule\\n'); process.exit(2);"); const { code, stdout } = await runCorpus(hook); assert.strictEqual(code, 1, `the corpus did not fail on a hook that names another rule (exit ${code}):\n${stdout}`); assert.strictEqual(failLines(stdout).length, entryLines(corpusText()).length, 'not every entry failed'); }); test('part-A corpus: a hook that lets one entry through fails the corpus and names only that entry', async () => { const name = 'split-in-body'; const text = entryCommand(name); const hook = stubHook(`if (command === ${JSON.stringify(text)}) process.exit(0); process.stderr.write('[voyage] BLOCKED: args-heredoc-closed-early\\n'); process.exit(2);`); const { code, stdout } = await runCorpus(hook); assert.strictEqual(code, 1, `the corpus did not fail when one entry passed (exit ${code}):\n${stdout}`); const fails = failLines(stdout); assert.strictEqual(fails.length, 1, `expected one FAIL line, got:\n${fails.join('\n')}`); assert.ok(fails[0].startsWith(`FAIL - ${name}: `), `the FAIL line names another entry: ${fails[0]}`); }); test('part-A corpus: a missing hook is a setup failure (exit 2)', async () => { const dir = tmp('voyage-corpus-missing-'); dirs.push(dir); const { code, stdout, stderr } = await runCorpus(join(dir, 'no-such-hook.mjs')); assert.strictEqual(code, 2, `the corpus did not exit 2 on a missing hook (exit ${code}):\n${stdout}\n${stderr}`); }); test('part-A corpus: a hook copy with the args gate off fails the corpus (exit 1)', async () => { const { hook, mutate } = mutatedHook("if (command.replace(/\\\\\\n/g, '').includes(ARGS_DELIM)) {", 'if (false) {'); mutate(); const { code, stdout } = await runCorpus(hook); assert.strictEqual(code, 1, `the corpus did not fail with the gate off (exit ${code}):\n${stdout}`); }); test("part-A corpus: the scan's continuation check holds the [CNT] command on the branch", async () => { const marker = '// scan: a continuation cannot be followed'; const { hook, mutate } = mutatedHook(String.raw` if (d === '\n') return unknown(); ${marker}`, ''); // The unmutated copy blocks the [CNT] command as unquoted. const before = await runHook(hook, { tool_name: 'Bash', tool_input: { command: CNT } }); assert.strictEqual(before.code, 2, `the unmutated copy did not block the [CNT] command: ${before.stderr}`); assert.match(before.stderr, UNQUOTED, `the unmutated copy did not give the unquoted reason: ${before.stderr}`); mutate(); assert.ok(!readFileSync(hook, 'utf8').includes(marker), 'the continuation check is still in the copy'); // Without the check the scan skips the backslash and the line break, so the // escape rule turns on and the [CNT] command passes. const after_ = await runHook(hook, { tool_name: 'Bash', tool_input: { command: CNT } }); assert.doesNotMatch(after_.stderr, ARGS_RULE, `the mutated copy still blocked the [CNT] command: ${after_.stderr}`); assert.strictEqual(after_.code, 0, `the mutated copy did not pass the [CNT] command (exit ${after_.code}): ${after_.stderr}`); }); test("part-A corpus: a scan that reads a backslash inside '...' as an escape lets scan-single-backslash through", async () => { // M1. const { hook, mutate } = mutatedHook( " if (mode === 'single') {\n if (c === \"'\") mode = 'none';", " if (mode === 'single') {\n" + String.raw` if (c === '\\') { p += 2; continue; }` + "\n if (c === \"'\") mode = 'none';", ); mutate(); const { code, stdout } = await runCorpus(hook); assert.strictEqual(code, 1, `the corpus did not fail under M1 (exit ${code}):\n${stdout}`); assert.ok(failLines(stdout).some((l) => l.startsWith('FAIL - scan-single-backslash: ')), `scan-single-backslash did not fail under M1:\n${stdout}`); }); test('part-A corpus: a scan that ignores a backslash inside "..." lets scan-dq-escaped-quote through', async () => { // M2. The first line alone also occurs inside argsShellOpeners, so the find // text is both lines. const { hook, mutate } = mutatedHook( String.raw` if (c === '\\') {` + '\n // The scan never decides', String.raw` if (c === '\\' && mode !== 'double') {` + '\n // The scan never decides', ); mutate(); const { code, stdout } = await runCorpus(hook); assert.strictEqual(code, 1, `the corpus did not fail under M2 (exit ${code}):\n${stdout}`); assert.ok(failLines(stdout).some((l) => l.startsWith('FAIL - scan-dq-escaped-quote: ')), `scan-dq-escaped-quote did not fail under M2:\n${stdout}`); });