// tests/hooks/bash-guard-4-5b2-a-known.test.mjs // [KNA] Fix round 4.5b2, part A (decision 122): the false stops part A keeps. // The three commands below are refused by the args-heredoc guard in // hooks/scripts/pre-bash-executor.mjs although the shell reads their << as // text. Decision 122 dropped their fix; the CHANGELOG lists 943f9841 and // a6735ef9 under Known issues. This file holds at e2d6bec too, so it stands in // no red wrapper's file list and KNA is no red-proof id. It goes red if a later // change lets one of them through; its untagged mutation test shows that each // keep-assertion can go red. // The helpers bashInput, ARGS_DELIM, ARGS_RULE, expectArgsBlocked and D are // copied from tests/hooks/bash-guard-false-stops.test.mjs:26-68, not imported, // because importing a test file would run its tests. import { test, after } from 'node:test'; import { strict as assert } from 'node:assert'; 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 { 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'); function bashInput(command) { return { tool_name: 'Bash', tool_input: { command } }; } const ARGS_DELIM = 'VOYAGE_ARGS_EOF_7F3C9E'; const ARGS_RULE = /args-heredoc-closed-early/; async function expectArgsBlocked(command, hook = PRE_BASH) { const { code, stderr } = await runHook(hook, bashInput(command)); assert.match(stderr, ARGS_RULE, `the guard did not name args-heredoc-closed-early for: ${JSON.stringify(command)}`); assert.strictEqual(code, 2, `the guard did not block: ${JSON.stringify(command)}`); return stderr; } const D = ARGS_DELIM; // Catalogue K1 (943f9841): a grep string holding << right before the delimiter. const K1 = `grep -n '<<${D}' file\necho done`; // Catalogue K2 (a6735ef9): the review's commit message. const K2 = `git commit -m "don't use '<<' for ${D}" -m "second\nline"`; // Catalogue K3 (a6735ef9): the review's escaped-quote grep. const K3 = `echo "x\\"y" && grep "<<" f | grep ${D}\nnext`; test('[KNA] a grep string holding << right before the delimiter is still refused (943f9841)', async () => { await expectArgsBlocked(K1); }); test("[KNA] the review's commit message is still refused (a6735ef9)", async () => { await expectArgsBlocked(K2); }); test("[KNA] the review's escaped-quote grep is still refused (a6735ef9)", async () => { await expectArgsBlocked(K3); }); // The copies, removed after the file's tests. const copies = []; after(() => { for (const dir of copies) rmSync(dir, { recursive: true, force: true }); }); // A copy of the plugin whose hook has `find` (asserted to occur exactly once) // replaced by `replace`; returns the copy's hook path. function mutatedHook(find, replace) { const root = pluginCopy(); copies.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}`); writeFileSync(hook, src.replace(find, () => replace)); return hook; } async function expectPasses(name, command, hook) { const { code, stderr } = await runHook(hook, bashInput(command)); assert.doesNotMatch(stderr, ARGS_RULE, `${name} was still refused under the mutation`); assert.strictEqual(code, 0, `${name} did not exit 0 under the mutation: ${stderr}`); } test('part-A known: each keep-assertion goes red when the reading that refuses it lets it through', async () => { // The regex reading off: its loop runs over no matches. K1 is refused only // by the regex reading, so it passes; K2 and K3 stay refused. const regexOff = mutatedHook(' for (const argsOpener of argsOpeners) {', ' for (const argsOpener of []) {'); await expectPasses('K1', K1, regexOff); await expectArgsBlocked(K2, regexOff); await expectArgsBlocked(K3, regexOff); // The string count off: every cut opener counts as inside a string. K2 and // K3 are refused only by the string count, so they pass; K1 stays refused. const countOff = mutatedHook(' return text.slice(lineStart, o.index).split(q).length % 2 === 0;', ' return true;'); await expectPasses('K2', K2, countOff); await expectPasses('K3', K3, countOff); await expectArgsBlocked(K1, countOff); });