voyage/tests/hooks/bash-guard-opener-word.test.mjs
Kjell Tore Guttormsen 7cb038cce3
refactor: simplify pass (trekexecute)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 00:18:29 +01:00

175 lines
7.8 KiB
JavaScript

// 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}`,
`<<VOYAGE_ARGS\\\n_EOF_7F3C9E`,
`<\\\n<${D}`,
`<< \\\n${D}`,
`<<-\\\n${D}`,
];
const X5F_OPENER = `<<$'VOYAGE\\x5fARGS_EOF_7F3C9E'`;
const DQ_CONTINUATION_OPENERS = [
`<<"VOYAGE_ARGS\\\n_EOF_7F3C9E"`,
`<<VOYAGE"_ARGS\\\n_EOF_7F3C9E"`,
];
const OCTAL_OPENER = `<<$'\\126\\117YAGE_ARGS_EOF_7F3C9E'`;
const DASH_AFTER_CONTINUATION_OPENER = `<<\\\n-"VOYAGE"_ARGS_EOF_7F3C9E`;
const UNTERMINATED_OPENER = `<<V"OYAGE_ARGS_EOF_7F3C9E`;
const LOCALE_BACKSLASH_OPENER = `<<$"VOYAGE\\_ARGS_EOF_7F3C9E"`;
// The brief's 11 spellings.
const BRIEF_SPELLINGS = [
`<<"VOYAGE"_ARGS_EOF_7F3C9E`,
`<<V'OYAGE_ARGS_EOF_7F3C9E'`,
`<<VOYAGE\\_ARGS_EOF_7F3C9E`,
`<<$'VOYAGE_ARGS_EOF_7F3C9E'`,
`<<- "VOYAGE"_ARGS_EOF_7F3C9E`,
X5F_OPENER,
...CONTINUATION_OPENERS,
];
// 8 from planning, each measured to close at the plain line or shell-dependent.
const PLANNING_SPELLINGS = [
...DQ_CONTINUATION_OPENERS,
OCTAL_OPENER,
`<<$"VOYAGE_ARGS_EOF_7F3C9E"`,
DASH_AFTER_CONTINUATION_OPENER,
`<<-\t"VOYAGE"_ARGS_EOF_7F3C9E`,
UNTERMINATED_OPENER,
LOCALE_BACKSLASH_OPENER,
];
test('[PQO] the guard reads a heredoc opener\'s word as the shell does and checks every spelling of the delimiter', async () => {
// 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', '&', '</dev/null', ')']) {
await expectArgsBlocked(`${PARSER_LINE} <<"VOYAGE"_ARGS_EOF_7F3C9E${end}${EARLY}`);
}
// Red: a fake accepted opener inside a string hides no real opener after it.
await expectUnquoted(`echo "<<'${D}' x"\n${PARSER_LINE} <<VOYAGE_ARGS\\\n_EOF_7F3C9E\nbrief.md $(touch PWNED)\n${D}`);
await expectUnquoted(`echo "<<'${D}' x"\n${PARSER_LINE} <<\\\n-VOYAGE_ARGS_EOF_7F3C9E\nbrief.md $(touch PWNED)\n${D}`);
// Red: a quoted word spanning lines hides nothing, in both quote kinds.
await expectUnquoted(`echo "<<'"\n${PARSER_LINE} <<VOYAGE_ARGS\\\n_EOF_7F3C9E\nbrief.md $(touch PWNED) don't\n${D}`);
await expectUnquoted(`echo '<<"'\n${PARSER_LINE} <<VOYAGE_ARGS\\\n_EOF_7F3C9E\nbrief.md $(touch PWNED) say "hi\n${D}`);
// Red: a typed argument with another unreadable word after << (Known issue 3b).
await expectUnquoted(`${PARSER_LINE} <<'${D}'\nx <<$'\\t' y\n${D}`);
// Red: lines stay raw. A body line ending in a backslash does not join the
// early closing line to it.
await expectArgsBlocked(`${PARSER_LINE} <<"VOYAGE"_ARGS_EOF_7F3C9E\nfoo\\\n${D}\ntouch PWNED\n${D}`);
// Red: unquoted reason, with EARLY and with BODY (closing line last).
const unquotedOpeners = [
...CONTINUATION_OPENERS,
X5F_OPENER,
...DQ_CONTINUATION_OPENERS,
OCTAL_OPENER,
DASH_AFTER_CONTINUATION_OPENER,
LOCALE_BACKSLASH_OPENER,
];
for (const opener of unquotedOpeners) {
await expectUnquoted(`${PARSER_LINE} ${opener}${EARLY}`);
await expectUnquoted(`${PARSER_LINE} ${opener}${BODY}`);
}
// Control: an unterminated "-quoted delimiter with a body is blocked as unquoted.
await expectUnquoted(`${PARSER_LINE} <<"${D}\nbrief.md`);
// Control: well-formed spellings with the closing line last pass.
for (const opener of [
`<<"VOYAGE"_ARGS_EOF_7F3C9E`,
`<<V'OYAGE_ARGS_EOF_7F3C9E'`,
`<<VOYAGE\\_ARGS_EOF_7F3C9E`,
`<<$'VOYAGE_ARGS_EOF_7F3C9E'`,
`<<- "VOYAGE"_ARGS_EOF_7F3C9E`,
`<<$"VOYAGE_ARGS_EOF_7F3C9E"`,
`<<"VOYAGE"_ARGS_EOF_7F3C9E;echo x`,
]) {
await expectArgsAllowed(`${PARSER_LINE} ${opener}${BODY}`);
}
// Control: the shipped form, and the backslash-quoted opener.
await expectArgsAllowed(`${PARSER_LINE} <<'${D}'\nbrief.md Session 2 of 3\n${D}`);
await expectArgsAllowed(`${PARSER_LINE} <<\\${D}${BODY}`);
// Control: typed arguments inside the shipped heredoc pass (the operator's decision).
await expectArgsAllowed(`${PARSER_LINE} <<'${D}'\nfix "<<" handling\n${D}`);
await expectArgsAllowed(`${PARSER_LINE} <<'${D}'\nFix <<don't>> 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 <\\`);
});