// tests/hooks/bash-guard-args-gate.test.mjs // [CSG] [CTB] Fix round 4.5b, part 4 (decision 90). // Defects: the args-heredoc guard in hooks/scripts/pre-bash-executor.mjs ran // its whole check only when the command held the delimiter literally, so an // unquoted opener whose delimiter is split by a backslash-newline, with a body // and no closing line, was never read: the guard exited 0 and the shell // expanded the body (CSG). And the branch that makes the guard fail closed // when its own shell reading throws had no test (CTB). // The helpers ARGS_DELIM, ARGS_RULE, PARSER_LINE, bashInput, // expectArgsBlocked, expectArgsAllowed, UNQUOTED and expectUnquoted are copied // from tests/hooks/bash-guard-opener-word.test.mjs:22-53, not imported, // because importing a test file would run its tests. The guard is only // spawned, so this file loads at 591f3c4 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 } 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/; 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)}`); } const D = ARGS_DELIM; test('[CSG] the guard checks an opener whose delimiter is split by a backslash-newline, with a body and no closing line', async () => { // Red: the command never holds the delimiter literally; the shell removes // the continuation, reads the delimiter as the word and expands the body. await expectUnquoted(`${PARSER_LINE} < rmSync(preloadDir, { recursive: true, force: true })); test('[CTB] an error inside the shell reading blocks the command (exit 2), never lets it through', async () => { const file = join(preloadDir, 'ctb-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('[ctb-preload] thrown'), `the preload did not throw inside the shell reading: ${stderr}`); assert.strictEqual(code, 2, `the guard did not block when its shell reading threw (exit ${code}): ${stderr}`); assert.match(stderr, ARGS_RULE, `the guard did not name args-heredoc-closed-early: ${stderr}`); assert.ok(stderr.includes("The guard could not read the opener's word"), `the could-not-read suffix is missing: ${stderr}`); assert.ok(!stderr.includes('CTB 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}`); });