voyage/tests/hooks/bash-guard-corpus-4-5b2-a.test.mjs

217 lines
11 KiB
JavaScript

// 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
// <path>. 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 <name> " 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<EOF\ncat - \\\\\\<<${D}\n\\$(touch PWNED)\n${D}\nEOF`;
// Every corpus run gets this long: about 63 hook starts.
const CORPUS_TIMEOUT_MS = 300_000;
function runCorpus(hook) {
return new Promise((resolve) => {
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}`);
});