fix(hooks): the args-heredoc guard lets through a heredoc operator the shell reads as text
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
12bca8b953
commit
9f7ba6503f
3 changed files with 417 additions and 15 deletions
133
tests/hooks/bash-guard-false-stops.test.mjs
Normal file
133
tests/hooks/bash-guard-false-stops.test.mjs
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
// tests/hooks/bash-guard-false-stops.test.mjs
|
||||
// [SLT] [ESC] Fix round 4.5b, part 4 (decision 90).
|
||||
// Defects: the args-heredoc guard in hooks/scripts/pre-bash-executor.mjs
|
||||
// refused commands in which the shell reads a << as text. A << inside a
|
||||
// string, whose cutting quote closes that string, was read as an unreadable
|
||||
// opener and blocked (SLT). A backslash-escaped < was read as the start of an
|
||||
// opener (ESC); and the shell reading took an escaped < plus the real << for a
|
||||
// here-string, so a real opener after it was not checked. The guard must skip
|
||||
// both, and still check every real opener around them.
|
||||
// The helpers ARGS_DELIM, ARGS_RULE, PARSER_LINE, bashInput,
|
||||
// expectArgsBlocked, expectArgsAllowed, UNQUOTED and expectUnquoted are copied
|
||||
// from tests/hooks/bash-guard-args-gate.test.mjs:27-54, not imported, because
|
||||
// importing a test file would run its tests. The guard is only spawned, so
|
||||
// this file loads at 12bca8b 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)}`);
|
||||
}
|
||||
|
||||
// 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 of the forms with a << after an escaped <.
|
||||
const HDBODY = 'HDBODY #$(touch PWNED1)';
|
||||
// The body line and the closing line of the ESC controls.
|
||||
const BODY = `\nbrief.md $(touch PWNED)\n${D}`;
|
||||
|
||||
test('[SLT] a commit message whose string holds "<<" passes', async () => {
|
||||
// Red: the << sits in a "..." string; its cutting " closes that string.
|
||||
await expectArgsAllowed(`git commit -F - <<'EOF'\nfix: let fix "<<" handling pass for ${D}\n\nbody\nEOF`);
|
||||
|
||||
// Control: no string is open before the cut quote (the regex reading blocks it too).
|
||||
await expectUnquoted(`${PARSER_LINE} <<"${D}\nbrief.md`);
|
||||
// Control: an even, non-zero count of " before the <, a spelling the regex
|
||||
// reading cannot see, stays blocked.
|
||||
await expectUnquoted(`echo "x" && ${PARSER_LINE} <<V"OYAGE_ARGS_EOF_7F3C9E\nbrief.md\n${D}`);
|
||||
});
|
||||
|
||||
test("[SLT] a grep pattern '<<' on a line that names the delimiter passes", async () => {
|
||||
// Red: the << sits in a '...' string; its cutting ' closes that string.
|
||||
await expectArgsAllowed(`grep -n '<<' file.md | grep ${D}\necho done`);
|
||||
});
|
||||
|
||||
test('[SLT] a file write whose body holds "<<" in a string passes', async () => {
|
||||
// Red: a heredoc body line holds "<<" in a string.
|
||||
await expectArgsAllowed(`cat <<'EOF' > t.mjs\nconst s = "<<";\nconst d = '${D}';\nEOF`);
|
||||
});
|
||||
|
||||
test('[SLT] a string holding << before the shipped argument heredoc passes, and hides no later opener', async () => {
|
||||
// Red: the string's << is text; the shipped heredoc after it passes.
|
||||
await expectArgsAllowed(`echo "a<<b" && ${PARSER_LINE} <<'${D}'\nbrief.md\n${D}`);
|
||||
|
||||
// Control: the skipped string hides no continuation-split unquoted opener after it.
|
||||
await expectUnquoted(`echo "a<<b" && ${PARSER_LINE} <<VOYAGE_ARGS\\\n_EOF_7F3C9E\nbrief.md $(touch PWNED)\n${D}`);
|
||||
});
|
||||
|
||||
test('[ESC] a backslash-escaped < starts no opener, and every real opener around it is still checked', async () => {
|
||||
// Red: \<< is a literal < and a lone <; the next line runs as a command.
|
||||
await expectArgsAllowed(`echo \\<<${D}\necho next`);
|
||||
|
||||
// Red: the << after an escaped < is read as usual, so each is blocked.
|
||||
await expectUnquoted(`cat \\<<<VOYAGE_ARGS\\\n_EOF_7F3C9E\n${HDBODY}`);
|
||||
await expectUnquoted(`cat \\<\\\n<<VOYAGE_ARGS\\\n_EOF_7F3C9E\n${HDBODY}`);
|
||||
await expectUnquoted(`cat \\<<\\\n<${D}\n${HDBODY}\n${D}`);
|
||||
await expectUnquoted(`cat \\<<<VOYAGE_ARGS\\\n_EOF\\\n_7F3C9E\n${HDBODY}\n${D}`);
|
||||
await expectClosedEarly(`cat \\<<<V"OYAGE_ARGS_EOF_7F3C9E"\nx\n${D}\ntouch PWNED2`);
|
||||
|
||||
// Control: the opener line, the body line and the closing line.
|
||||
// An even run of backslashes: the < is not escaped.
|
||||
await expectUnquoted(`echo \\\\<<${D}${BODY}`);
|
||||
// A quote right before the <, no backslash run.
|
||||
await expectUnquoted(`echo '\\'<<${D}${BODY}`);
|
||||
// The same, continuation-split.
|
||||
await expectUnquoted(`echo '\\'<<VOYAGE_ARGS\\\n_EOF_7F3C9E${BODY}`);
|
||||
// A ' before an odd run: the rule is off.
|
||||
await expectUnquoted(`echo 'x' && echo \\<<${D}${BODY}`);
|
||||
// The << after the escaped <.
|
||||
await expectUnquoted(`echo \\<<<${D}${BODY}`);
|
||||
// A raw run of two: a line break ends the run, a continuation does not lengthen it.
|
||||
await expectUnquoted(`echo a\\\\\n\\\\<<${D}${BODY}`);
|
||||
|
||||
// Control: exactly the lines given.
|
||||
// A " before an odd run of three: inside "..." \< keeps its backslash, so
|
||||
// the inner shell opens an unquoted heredoc.
|
||||
await expectUnquoted(`bash -c "cat \\\\\\<<${D}\n\\$(touch PWNED)\n${D}"`);
|
||||
// A backquote before an odd run of three.
|
||||
await expectUnquoted(`x=\`cat - \\\\\\<<${D}\n$(echo EXPANDED)\n${D}\``);
|
||||
// A heredoc fed to a shell: the outer heredoc strips one backslash level
|
||||
// (operator decision 2026-10-31).
|
||||
await expectUnquoted(`bash <<EOF\ncat - \\\\\\<<${D}\n\\$(touch PWNED)\n${D}\nEOF`);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue