fix(hooks): the args-heredoc guard reads the opener's word as the shell does
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
8c52c3c32e
commit
bba5cb89b3
3 changed files with 681 additions and 22 deletions
176
tests/hooks/bash-guard-opener-word.test.mjs
Normal file
176
tests/hooks/bash-guard-opener-word.test.mjs
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
// 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)}`);
|
||||
}
|
||||
|
||||
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 { 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)}`);
|
||||
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 <\\`);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue