// tests/hooks/bash-guard-4-5b2-a.test.mjs // [RXT] [CNT] [ESR] [SFX] [SIS] [EVL] Fix round 4.5b2, part A (decisions 91, 107 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 // heredoc or a closed quote earlier in the command made the guard refuse a // 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). 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). A heredoc operator a second shell reads from \<\< (eval, // bash -c, sh -c) was seen by neither reading (EVL). // The helpers bashInput, ARGS_DELIM, ARGS_RULE, PARSER_LINE, // expectArgsBlocked, expectArgsAllowed, UNQUOTED, expectUnquoted, // CLOSED_EARLY, expectClosedEarly, D and BODY are copied from // tests/hooks/bash-guard-false-stops.test.mjs:26-68, not imported, because // importing a test file would run its tests. The guard is only spawned, so // this file loads at e2d6bec and its red is an assertion. import { test, after } from 'node:test'; import { strict as assert } from 'node:assert'; import { writeFileSync, rmSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { runHook, runHookWithEnv } from '../helpers/hook-helper.mjs'; import { tmp, pluginCopy } from './guard-helpers.mjs'; import { fastestWithin } from '../helpers/timing.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/; const PARSER_LINE = 'node /plugin/lib/parsers/arg-parser.mjs --command trekendsession --stdin'; async function expectArgsBlocked(command) { const { code, stderr } = await runHook(PRE_BASH, 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; } async function expectArgsAllowed(command) { const { code, stderr } = await runHook(PRE_BASH, bashInput(command)); assert.doesNotMatch(stderr, ARGS_RULE, `the guard refused a well-formed heredoc: ${JSON.stringify(command)}`); assert.strictEqual(code, 0, `the guard refused a well-formed heredoc: ${stderr}`); } // The unquoted reason: the guard blocks and says the opener is unquoted. const UNQUOTED = /opens VOYAGE_ARGS_EOF_7F3C9E unquoted/; async function expectUnquoted(command) { const stderr = await expectArgsBlocked(command); assert.match(stderr, UNQUOTED, `the guard did not give the unquoted reason for: ${JSON.stringify(command)}`); } // The closed-early reason: a line equal to the closing line, then more text. const CLOSED_EARLY = /hold a line equal to the heredoc's closing line/; async function expectClosedEarly(command) { const stderr = await expectArgsBlocked(command); assert.match(stderr, CLOSED_EARLY, `the guard did not give the closed-early reason for: ${JSON.stringify(command)}`); assert.doesNotMatch(stderr, UNQUOTED, `the guard gave the unquoted reason for: ${JSON.stringify(command)}`); } const D = ARGS_DELIM; // The body line and the closing line of the escape-rule controls. const BODY = `\nbrief.md $(touch PWNED)\n${D}`; test('[ESR] a here-string before an escaped < leaves the rule on', async () => { // Red: the here-string <<< is text to the quote scan, so \< is a literal < // and the next line runs as a command. await expectArgsAllowed(`cat << { // Red: the heredoc EOF is closed before the run and the ' in its body is // body text, so \< is a literal < and the next line runs as a command. await expectArgsAllowed(`cat <<'EOF'\ndon't\nEOF\necho \\<<${D}\nnext`); // Control: an earlier heredoc not yet closed: the run stands in its body, // so the rule is off. await expectUnquoted(`cat <<'EOF'\necho \\<<${D}${BODY}`); // Control: the would-be closing line is EOF plus a trailing blank, which // does not close the heredoc, so the run stands in its body. await expectUnquoted(`cat <<'EOF'\nEOF \necho \\<<${D}${BODY}\nEOF`); // Control: the would-be closing line is EOF plus a CR, which does not // close the heredoc, so the run stands in its body. await expectUnquoted(`cat <<'EOF'\nEOF\r\necho \\<<${D}${BODY}\nEOF`); // Control: the would-be closing line is a tab plus EOF under << without -, // which does not close the heredoc, so the run stands in its body. await expectUnquoted(`cat <<'EOF'\n\tEOF\necho \\<<${D}${BODY}\nEOF`); // Control: two heredocs on one line: the scan cannot follow them, so the // rule is off. await expectUnquoted(`cat < { // Red. Moved from tests/hooks/bash-guard-false-stops.test.mjs:117-118 // (decision 106): the ' is closed before the run, so the shell reads \< as a // literal <. await expectArgsAllowed(`echo 'x' && echo \\<<${D}${BODY}`); // Control: a " still open before the run: inside "..." \< keeps its // backslash, so the rule is off. await expectUnquoted(`echo "y \\<<${D}${BODY}`); // Control: a $' string before the run: the scan does not decode it, so the // rule is off. await expectUnquoted(`echo $'x' && echo \\<<${D}${BODY}`); // Control: a closed backquote before the run: the scan cannot follow a // backquote, so the rule is off. await expectUnquoted(`x=\`date\`; echo \\<<${D}${BODY}`); // Control that passes: a first odd run of three. The shell reads \\ and \< // and then a rmSync(preloadDir, { recursive: true, force: true })); test("[RXT] an error inside the regex reading's escape limit blocks the command (exit 2), never lets it through", async () => { const file = join(preloadDir, 'rxt-preload.mjs'); writeFileSync(file, PRELOAD); const forced = { NODE_OPTIONS: '--import=' + pathToFileURL(file).href }; const shipped = `${PARSER_LINE} <<'${D}'\nbrief.md\n${D}`; // Red, forced: the shipped heredoc under the preload is blocked by the catch. const { code, stderr } = await runHookWithEnv(PRE_BASH, bashInput(shipped), forced); assert.ok(stderr.includes('[rxt-preload] thrown'), `the preload did not throw inside the escape limit: ${stderr}`); assert.strictEqual(code, 2, `the guard did not block when its escape limit threw (exit ${code}): ${stderr}`); assert.match(stderr, ARGS_RULE, `the guard did not name args-heredoc-closed-early: ${stderr}`); assert.ok(!stderr.includes('RXT forced read error'), `the error was not caught (an uncaught throw exits 1): ${stderr}`); // Control: the same command with no preload passes, so the block can come // only from the catch. await expectArgsAllowed(shipped); // Control: a command with no delimiter passes under the preload. const plain = await runHookWithEnv(PRE_BASH, bashInput('ls -la'), forced); assert.strictEqual(plain.code, 0, `ls -la was refused under the preload: ${plain.stderr}`); }); test('[CNT] a heredoc opener split between its two < still turns the escape rule off', async () => { // Red: the outer opener <\ + line break + { // 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`); }); // The eval word, written in two parts so the live guard's dynamic-eval rule // does not refuse the file write. const EV = 'ev' + 'al'; // The body line of the re-read forms; the ' after the word opens the string // that carries it, and the next ' closes it. const XBODY = "\n$(touch PWNED)\n'"; test('[EVL] an eval of a twice-escaped heredoc operator with a body is blocked', async () => { // Red (catalogue X1): the outer shell removes the backslashes, so the // second shell (eval) reads < { // Red (catalogue X1b): bash -c gets the string cat < { // Red (catalogue X2): the second shell reads << "D...", the word after a // blank. Measured: /bin/bash and /bin/zsh -f create PWNED. await expectUnquoted(`${EV} cat \\<\\< "${D}\n\\$(touch PWNED)\n"`); // Controls: the word is read as a second shell reads it once the outer // shell has removed its quoting (3a's word loop). // An escaped blank before the word. await expectUnquoted(`${EV} cat \\<\\<\\ ${D}'${XBODY}`); // An empty quote pair and a blank before the word. await expectUnquoted(`${EV} cat \\<\\<'' ${D}'${XBODY}`); // An escaped - before the word. await expectUnquoted(`${EV} cat \\<\\<\\-${D}'${XBODY}`); // The split forms: a backslash-newline inside or before the word is removed // by one of the two shells, so the check removes it inside the delimiter. // Catalogue XS1: the split in the bare word. await expectUnquoted(`${EV} cat \\<\\<${SPLIT}'${XBODY}`); // Catalogue XS2: the split inside a "..." word. await expectUnquoted(`${EV} cat \\<\\<"${SPLIT}"'${XBODY}`); // Catalogue XS3: the split inside the '...' string that carries the body. await expectUnquoted(`${EV} cat \\<\\<'${SPLIT}\n$(touch PWNED)\n'`); // Catalogue XS4: a backslash-newline between the operator and the word. await expectUnquoted(`${EV} cat \\<\\<\\\n${D}'${XBODY}`); }); 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. const many = ('\\<' + 'x'.repeat(40) + ' ').repeat(20000); const command = `echo ${many}<<'${D}'\nbrief.md\n${D}`; let last = { code: null, stderr: '' }; const timing = await fastestWithin(15000, async () => { const started = Date.now(); last = await runHook(PRE_BASH, bashInput(command)); return Date.now() - started; }); assert.strictEqual(last.code, 0, `the guard did not pass the long command (exit ${last.code}): ${last.stderr.slice(0, 300)}`); assert.ok(timing.ok, `the guard took ${timing.fastest} ms at best (runs ${timing.runs.join(', ')}) on ${command.length} characters (limit 15000 ms)`); });