fix(hooks): the args-heredoc guard judges a split delimiter as written out, also inside a string

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-11-02 08:52:12 +01:00
commit 7ab4100489
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
3 changed files with 200 additions and 11 deletions

View file

@ -0,0 +1,100 @@
// 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);
});

View file

@ -1,5 +1,5 @@
// tests/hooks/bash-guard-4-5b2-a.test.mjs
// [RXT] [CNT] [RSK] [QSC] [SFX] [ESR] [EVL] Fix round 4.5b2, part A (decisions 91 and 107).
// [RXT] [CNT] [ESR] [SFX] [SIS] Fix round 4.5b2, part A (decisions 91 and 123).
// Defects: the args-heredoc guard in hooks/scripts/pre-bash-executor.mjs
// turned its escape rule off for the whole command when a ', " or backquote
// or a << stood anywhere before an escaped <, so a here-string, a closed
@ -7,7 +7,10 @@
// command in which the shell reads \< as a literal < (ESR). Two branches part
// 4 added had no test: the try that makes an error inside the regex reading's
// escape limit block (RXT), and the continuation-split outer opener that keeps
// the escape rule off (CNT).
// the escape rule off (CNT). A delimiter split by a backslash-newline with a
// suffix after it was compared as an exact word, so it passed (SFX). Part 4's
// string skip skipped a split opener inside a string, which the regex reading
// cannot see (SIS).
// The helpers bashInput, ARGS_DELIM, ARGS_RULE, PARSER_LINE,
// expectArgsBlocked, expectArgsAllowed, UNQUOTED, expectUnquoted,
// CLOSED_EARLY, expectClosedEarly, D and BODY are copied from
@ -181,6 +184,65 @@ test('[CNT] a heredoc opener split between its two < still turns the escape rule
await expectUnquoted(`bash <\\\n<EOF\ncat - \\\\\\<<${D}\n\\$(touch PWNED)\n${D}\nEOF`);
});
// The delimiter split by a backslash-newline into its two halves; the shell
// removes the backslash-newline and reads the delimiter written out.
const SPLIT = 'VOYAGE_ARGS\\\n_EOF_7F3C9E';
// The body line of the split-suffix forms.
const SBODY = '\nbody $(echo EXPANDED)';
test('[SFX] a split delimiter with an X suffix is blocked as the same opener written out is', async () => {
// Red (catalogue SFX-X): the shell reads the word as the delimiter plus X,
// an unquoted heredoc. Measured: /bin/bash and /bin/zsh -f print
// body EXPANDED.
await expectUnquoted(`cat <<${SPLIT}X${SBODY}`);
// Control: the same opener written out is blocked as unquoted.
await expectUnquoted(`cat <<${D}X${SBODY}`);
});
test('[SFX] a split delimiter with a backquote suffix is blocked as the same opener written out is', async () => {
// Red (catalogue SFX-bq): measured, the form is a parse error in /bin/bash
// and /bin/zsh -f; it is blocked conservatively, as written out.
await expectUnquoted(`cat <<${SPLIT}\`${SBODY}`);
// Control: the same opener written out is blocked as unquoted.
await expectUnquoted(`cat <<${D}\`${SBODY}`);
});
test('[SFX] a split delimiter with a . suffix is blocked as the same opener written out is', async () => {
// Red (catalogue SFX-dot): the shell reads the word as the delimiter plus
// a dot, an unquoted heredoc. Measured: /bin/bash and /bin/zsh -f print
// body EXPANDED.
await expectUnquoted(`cat <<${SPLIT}.${SBODY}`);
// Control: the same opener written out is blocked as unquoted.
await expectUnquoted(`cat <<${D}.${SBODY}`);
});
test('[SIS] a split delimiter inside a string is never skipped as text', async () => {
// Measured: SIS1-SIS4 and SIS6 create PWNED under /bin/bash and
// /bin/zsh -f and pass the hook at e2d6bec and 1731e41. The inner shell
// removes the backslash-newline and opens an unquoted heredoc.
const RUN = '\n\\$(touch PWNED); touch RAN\n';
// Red (catalogue SIS1).
await expectUnquoted(`bash -c 'cat <<${SPLIT}'"${RUN}"`);
// Control (SIS2): a . after the split delimiter.
await expectUnquoted(`bash -c 'cat <<${SPLIT}.'"${RUN}"`);
// Control (SIS3): a word character after the split delimiter.
await expectUnquoted(`bash -c 'cat <<${SPLIT}X'"${RUN}"`);
// Control (SIS4): SIS1 with a closing line.
await expectUnquoted(`bash -c 'cat <<${SPLIT}'"${RUN}${D}"`);
// Control (SIS0): the delimiter written out, blocked already at e2d6bec.
await expectUnquoted(`bash -c 'cat <<${D}'"${RUN}"`);
// Control (SIS6): SIS1 in the body of a quoted heredoc fed to bash; the
// skip after a quoted opener that passes must not skip a split opener.
await expectUnquoted(`bash <<'${D}'\nbash -c 'cat <<${SPLIT}'"${RUN}"\n${D}`);
// Control that passes (SIS5): a split delimiter inside a grep string with
// no line after the command, so no body (decision 87's one-line rule).
await expectArgsAllowed(`grep -n '<<${SPLIT}' file`);
});
test('part-A scan: a long command with many escaped < is read in linear time', async () => {
// About 870 KB: 20000 escaped < before the shipped quoted heredoc. The quote
// scan resumes where its last call stopped, so the text is read once.