// tests/hooks/bash-guard-opener-word.test.mjs // [PQO] Fix round 4.5b, part 3 (decision 89). // Defect: the args-heredoc guard in hooks/scripts/pre-bash-executor.mjs found // heredoc openers with one regex that needs the whole word quoted, so an // opener whose word is only partly quoted (<<"VOYAGE"_ARGS_EOF_7F3C9E), quoted // as $'...' or $"...", or split by a backslash-newline was not recognised: the // guard exited 0 and the shell ran the line after the closing line. The guard // must read the opener's word as the shell does, and treat a word it cannot // read as unquoted. // The helpers ARGS_DELIM, ARGS_RULE, PARSER_LINE, bashInput, // expectArgsBlocked and expectArgsAllowed are copied from // tests/hooks/bash-guard.test.mjs:21-23,327-341, not imported, because // importing a test file would run its tests. The guard is only spawned, so // this file loads at ce98f62 and its red is an assertion. import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { runHook } from '../helpers/hook-helper.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; const BODY = `\nbrief.md\n${D}`; const EARLY = `${BODY}\ntouch PWNED`; // The five backslash-newline openers (the brief's). const CONTINUATION_OPENERS = [ `<<\\\n${D}`, `< { // Red: every spelling, followed by a body, the closing line and a command. assert.equal(BRIEF_SPELLINGS.length + PLANNING_SPELLINGS.length, 19); for (const opener of [...BRIEF_SPELLINGS, ...PLANNING_SPELLINGS]) { await expectArgsBlocked(`${PARSER_LINE} ${opener}${EARLY}`); } // The unterminated quote the regex does not match also gets the unquoted reason. await expectUnquoted(`${PARSER_LINE} ${UNTERMINATED_OPENER}${EARLY}`); // Red: word ends. Unquoted whitespace or one of ; & | ( ) < > ends the word. for (const end of [';echo x', '>/dev/null', '|cat', '&', '> bug\n${D}`); await expectArgsAllowed(`${PARSER_LINE} <<'${D}'\nx <<"VOYAGE"_ARGS_EOF_7F3C9E y\n${D}`); // Control: a continuation before the opener passes. await expectArgsAllowed(`node /plugin/lib/parsers/arg-parser.mjs --command trekendsession \\\n --stdin <<'${D}'${BODY}`); // Control: a one-line mention passes; with a line after it, it is blocked. await expectArgsAllowed(`grep -n "<<'${D}'" f`); await expectArgsBlocked(`grep -n "<<'${D}'" f\necho x`); // Control: a delimiter line before a correct heredoc is blocked. await expectArgsBlocked(`echo hi\n${D}\n${PARSER_LINE} <<'${D}'${BODY}`); // Control: openers cut off at the end of the text never make the guard exit 1. await expectArgsAllowed(`echo ${D}\nx <<\\`); await expectArgsAllowed(`echo ${D}\nx <<"`); await expectArgsAllowed(`echo ${D}\nx <<$'`); await expectArgsAllowed(`echo ${D}\nx <\\`); });