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:
Kjell Tore Guttormsen 2026-10-28 23:41:19 +01:00
commit bba5cb89b3
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
3 changed files with 681 additions and 22 deletions

View file

@ -264,17 +264,153 @@ const commandView = commandPositionView(deobfuscated.replace(/\x1B\[[0-9;]*m/g,
// delimiter would close the heredoc there, and the shell would run every line
// after it as a command (fix round 4.5b, R6). This check reads the raw
// command: `normalized` and `commandView` collapse lines and drop heredoc
// bodies (hooks/lib/bash-view.mjs). Every opener of the delimiter in the
// command is checked, so a quoted first mention cannot hide a later unquoted
// heredoc. bash reads a heredoc body only from the lines after the opener's
// line, so an opener with no non-blank line after its own line (a one-line
// grep or commit message that only mentions it) has no body: nothing can
// close early and an unquoted opener has nothing to expand, so it is skipped.
// An opener with a non-blank line after it is blocked unless it is quoted
// (<<'D', <<"D" or <<\D, whose body is not expanded) and exactly one line,
// trimmed, equals the delimiter with every line after that one empty. A
// command that never names the delimiter skips the whole check.
// bodies (hooks/lib/bash-view.mjs). A command that never names the delimiter
// skips the whole check.
// Every opener in the command is read in two ways, and the command is blocked
// when either reading blocks it (the union):
// 1. A regex finds <<D, <<'D', <<"D" and <<\D, with an optional - and
// spaces (fix round 4.5b and its part 2).
// 2. A shell reading (fix round 4.5b, part 3) starts at every < in the
// text, wherever it stands; like the regex, it does not lex the command
// around it. A backslash directly followed by a line break is a
// continuation: removed, not quoting. The word after << (and an optional
// -, spaces and tabs) is read as the shell reads it: a backslash makes
// the next character literal and quotes the word; '...', "...", $'...'
// and $"..." quote it, so a partly quoted word such as
// <<"VOYAGE"_ARGS_EOF_7F3C9E counts. The word ends at unquoted
// whitespace (a line break included),
// at one of ; & | ( ) < > or at the end of the text. Its body lines are
// the raw lines after the line on which the word ends: continuations
// change only how the word is read, never the line count.
// bash reads a heredoc body only from the lines after the opener's line, so
// an opener with no non-blank line after it (a one-line grep or commit
// message that only mentions it) has no body: nothing can close early and an
// unquoted opener has nothing to expand, so it is skipped. An opener with a
// non-blank line after it is blocked unless it is quoted (its body is not
// expanded) and exactly one line, trimmed, equals the delimiter with every
// line after that one empty (the regex reading counts every line of the
// command, the shell reading the body lines).
// A word the guard cannot read is treated as unquoted, whatever its letters:
// a quote with no partner anywhere in the rest of the text (the word is cut
// there, so its line is the quote's line), any backslash inside "...",
// $"..." or $'...' (no escape there is decoded), a backslash at the end of
// the text, a - reached only after a continuation (bash reads <<-, zsh
// reads the - as part of the word), and a continuation between a $ and the
// quote it opens. The word is compared with the delimiter after trim(), the
// same trim the body lines get, so a CR (a CRLF line), VT, FF or other
// character trim() strips around the word does not hide it.
// After a quoted opener that passes, a later opener is skipped only when its
// word is cut by an unpaired quote and is readable otherwise: the shell meets
// the same unpaired quote, stops with a parse error and runs nothing after it,
// so a typed argument such as fix "<<" handling passes. Every other later
// opener is checked, because the accepted opener may sit inside a string and
// must not hide a real opener after it.
// The shell reading fails closed: an error in it blocks (exit 2), because an
// exit 1 is a non-blocking error and the command would run.
const ARGS_DELIM = 'VOYAGE_ARGS_EOF_7F3C9E';
function blockArgs(unquoted, unreadable) {
const why = unquoted
? `The heredoc that hands the arguments to the arg parser opens ${ARGS_DELIM} unquoted, so the shell would expand its body. ` +
`Way forward: open it as <<'${ARGS_DELIM}'.`
: `The arguments hold a line equal to the heredoc's closing line ${ARGS_DELIM}, or text follows that closing line, ` +
'so the shell would run what follows as commands. Way forward: take that line out of the arguments and run the command again.';
const unread = unreadable ? " The guard could not read the opener's word, so it treats it as unquoted." : '';
process.stderr.write(
'[voyage] BLOCKED: args-heredoc-closed-early\n' +
` Command: ${normalized.slice(0, 200)}${normalized.length > 200 ? '...' : ''}\n` +
` ${why}${unread}\n`
);
process.exit(2);
}
// Each heredoc operator in `text`, in text order, as the shell reads its word:
// { index (the operator's first <), end, word, quoted, unreadable, cutOnly }.
function argsShellOpeners(text) {
const n = text.length;
// The index after any run of continuations that starts at k.
const skip = (k) => {
while (text[k] === '\\' && text[k + 1] === '\n') k += 2;
return k;
};
const openers = [];
let i = text.indexOf('<');
while (i !== -1) {
let next = i + 1;
let k = skip(i + 1);
if (text[k] === '<') {
const second = k;
// The scan resumes right after the second <, never at the word's end,
// so a quoted word that spans lines cannot hide a < inside it.
next = second + 1;
k = skip(second + 1);
if (text[k] === '<') {
next = k + 1; // a here-string: move past it
} else {
let unreadable = false;
if (text[k] === '-') {
// After a continuation, bash reads <<- and zsh reads - as the word.
if (k !== second + 1) unreadable = true;
k = skip(k + 1);
}
while (k < n) {
if (text[k] === ' ' || text[k] === '\t') k += 1;
else if (text[k] === '\\' && text[k + 1] === '\n') k += 2;
else break;
}
const start = k;
let word = '';
let quoted = false;
let cut = -1;
while (k < n) {
const c = text[k];
if (c === ' ' || c === '\t' || c === '\n' || ';&|()<>'.includes(c)) break;
if (c === '\\') {
if (text[k + 1] === '\n') { k += 2; continue; }
if (k + 1 >= n) { unreadable = true; k += 1; break; }
word += text[k + 1];
quoted = true;
k += 2;
continue;
}
// A $ opens $'...' or $"..." also across continuations; a
// continuation between the $ and the quote makes the word unreadable.
const q1 = c === '$' ? skip(k + 1) : k + 1;
const dollar = c === '$' && (text[q1] === "'" || text[q1] === '"');
if (dollar && q1 !== k + 1) unreadable = true;
if (c === "'" || c === '"' || dollar) {
const open = dollar ? q1 : k;
const q = text[open];
const close = text.indexOf(q, open + 1);
if (close === -1) { cut = open; break; }
const inner = text.slice(open + 1, close);
if ((q === '"' || dollar) && inner.includes('\\')) unreadable = true;
word += inner;
quoted = true;
k = close + 1;
continue;
}
word += c;
k += 1;
}
const isCut = cut !== -1;
if (k > start || unreadable || isCut) {
openers.push({
index: i,
end: isCut ? cut : k,
word,
quoted,
unreadable: unreadable || isCut,
cutOnly: isCut && !unreadable,
});
}
}
}
i = text.indexOf('<', next);
}
return openers;
}
if (command.includes(ARGS_DELIM)) {
const argsOpeners = command.matchAll(new RegExp(`<<-?\\s*(['"\\\\]?)${ARGS_DELIM}(['"]?)`, 'g'));
const argsLines = command.split('\n');
@ -287,20 +423,37 @@ if (command.includes(ARGS_DELIM)) {
if (!hasBody) continue;
const [, open, close] = argsOpener;
const quoted = open === '\\' ? close === '' : open !== '' && close === open;
if (!quoted || !closesOnceAtEnd) {
const why = !quoted
? `The heredoc that hands the arguments to the arg parser opens ${ARGS_DELIM} unquoted, so the shell would expand its body. ` +
`Way forward: open it as <<'${ARGS_DELIM}'.`
: `The arguments hold a line equal to the heredoc's closing line ${ARGS_DELIM}, or text follows that closing line, ` +
'so the shell would run what follows as commands. Way forward: take that line out of the arguments and run the command again.';
process.stderr.write(
'[voyage] BLOCKED: args-heredoc-closed-early\n' +
` Command: ${normalized.slice(0, 200)}${normalized.length > 200 ? '...' : ''}\n` +
` ${why}\n`
);
process.exit(2);
}
if (!quoted || !closesOnceAtEnd) blockArgs(!quoted, false);
}
// The shell reading. The loop records the first failing verdict; an error
// anywhere in it blocks.
let argsVerdict = null;
try {
let bodyFrom = Infinity;
for (const o of argsShellOpeners(command)) {
if (o.index >= bodyFrom && o.cutOnly) continue;
// The word is compared trimmed, as the body lines are: a word of D plus
// characters trim() strips (a CR from a CRLF line, a VT, an FF, a
// no-break space) is closed by a line equal to it, which trims to D.
if (o.word.trim() !== ARGS_DELIM && !o.unreadable) continue;
const nl = command.indexOf('\n', o.end);
if (nl === -1) continue;
// The raw lines after the line on which the word ends.
const body = argsLines.slice(command.slice(0, nl).split('\n').length);
if (!body.some((line) => line.trim() !== '')) continue;
const isQuoted = o.quoted && !o.unreadable;
const closing = body.flatMap((l, i) => (l.trim() === ARGS_DELIM ? [i] : []));
if (isQuoted && closing.length === 1 && body.slice(closing[0] + 1).every((line) => line.trim() === '')) {
if (bodyFrom === Infinity) bodyFrom = nl + 1;
continue;
}
argsVerdict = [!isQuoted, o.unreadable];
break;
}
} catch {
blockArgs(true, true);
}
if (argsVerdict) blockArgs(argsVerdict[0], argsVerdict[1]);
}
// END args-heredoc-closed-early

View file

@ -0,0 +1,330 @@
#!/usr/bin/env bash
# tests/e2e-fix-round-4-5b-part-3.sh
# End to end for fix round 4-5b, part 3 (plan
# .claude/projects/2026-10-28-fix-round-4-5b-part-3, End-to-end:, decision
# 89): the shipped products run the way a session runs them, in a temp
# directory.
# 1. CQW, the shipped way: the parser block of commands/trekreview.md
# (## Phase 1), with ${CLAUDE_PLUGIN_ROOT} and $ARGUMENTS filled in, passes
# the Bash hook, and /bin/bash runs it to JSON that keeps a value closed
# on its own line.
# 2. CQW through the parser CLI's --stdin: `"my dir "` pairs, and
# `--project "my dir"/x --since "HEAD~1"` keeps --since = HEAD~1.
# 3. PQO through the Bash hook (hooks/scripts/pre-bash-executor.mjs):
# partly quoted, $'...' and backslash-newline openers are blocked; a
# string-borne << hides no real opener; the shipped form with a typed
# `fix "<<" handling` argument and the agents/review-orchestrator.md
# block pass.
# 4. PQO premise: /bin/bash itself runs the line after the closing line of
# a partly quoted opener, so blocking it is not a false positive.
# 5. PQO fix rounds 1 and 2 of step 2, through the Bash hook: a
# continuation between a $ and its quote (at the word's start and inside
# it) is blocked; a partly quoted opener whose word ends in a CR (CRLF
# lines) or a VT, closed by the same line, is blocked when a line follows
# the closing line; with CRLF lines and the closing line last it passes.
# Prints one "ok - <case>" or "FAIL - <case>: <why>" line per case and exits 1
# when any case failed, else 0. Starts nothing but node and /bin/bash (and the
# usual file tools). bash 3.2 compatible, ASCII only, no arrays.
set -u
cd "$(dirname "$0")/.." || { echo "FAIL - setup: cannot cd to the repo root"; exit 1; }
ROOT="$(pwd)"
ARGS="$ROOT/lib/parsers/arg-parser.mjs"
HOOK="$ROOT/hooks/scripts/pre-bash-executor.mjs"
unset CLAUDE_CODE_SESSION_ID
TMP="$(mktemp -d "${TMPDIR:-/tmp}/e2e-fix-round-4-5b-part-3.XXXXXX")" || { echo "FAIL - setup: mktemp"; exit 1; }
trap 'rm -rf "$TMP"' EXIT
TMP="$(cd "$TMP" && pwd -P)" || { echo "FAIL - setup: cannot enter the temp directory"; exit 1; }
export LC_ALL=C
export LANG=C
FAILED=0
pass() { echo "ok - $1"; }
bad() { echo "FAIL - $1"; FAILED=$((FAILED + 1)); }
# jsonok <js expression on j>: stdin is JSON; exit 0 when the expression is true.
jsonok() {
node -e 'let s="";process.stdin.on("data",(d)=>{s+=d;}).on("end",()=>{let j;try{j=JSON.parse(s);}catch(e){process.exit(3);}process.exit(Function("j","return ("+process.argv[1]+");")(j)?0:1);});' "$1"
}
# hookrun <out> <line>...: the Bash hook with a Bash tool input whose command
# is the given lines joined by newlines; stderr goes to <out>. Returns the
# hook's exit status.
hookrun() {
out="$1"
shift
node -e 'process.stdout.write(JSON.stringify({ tool_name: "Bash", tool_input: { command: process.argv.slice(1).join("\n") } }));' "$@" \
| node "$HOOK" > /dev/null 2> "$out"
}
# hookfile <out> <file>: the Bash hook with the file's text as the command.
hookfile() {
node -e 'process.stdout.write(JSON.stringify({ tool_name: "Bash", tool_input: { command: require("fs").readFileSync(process.argv[1], "utf8") } }));' "$2" \
| node "$HOOK" > /dev/null 2> "$1"
}
D=VOYAGE_ARGS_EOF_7F3C9E
PARSER_LINE='node /plugin/lib/parsers/arg-parser.mjs --command trekendsession --stdin'
# The typed arguments: a quoted value closed on its own line.
TYPED="$(printf '%s\n%s' '--project "notes' '" --since HEAD')"
# ---- 1. CQW, the shipped way ----
case_cqw_shipped() {
name="trekreview Phase 1 block: passes the hook and keeps a value closed on its own line"
W="$TMP/shipped"
mkdir -p "$W" || { bad "$name: mkdir"; return; }
# The block as written, with ${CLAUDE_PLUGIN_ROOT} and $ARGUMENTS filled in
# the way Claude Code fills them.
node -e '
const fs = require("fs");
const [md, root, args] = process.argv.slice(1);
const text = fs.readFileSync(md, "utf8");
const s = text.indexOf("## Phase 1 ");
if (s === -1) process.exit(3);
const rest = text.slice(s);
const e = rest.search(/\n## /);
const phase = e === -1 ? rest : rest.slice(0, e);
const blocks = phase.split("\n```bash\n").slice(1).map((b) => b.split("\n```")[0]).filter((b) => b.includes("<<" + String.fromCharCode(39) + "VOYAGE_ARGS_EOF_7F3C9E" + String.fromCharCode(39)));
if (blocks.length !== 1) { console.error("expected one Phase 1 parser block, found " + blocks.length); process.exit(3); }
process.stdout.write(blocks[0].split("${CLAUDE_PLUGIN_ROOT}").join(root).split("$ARGUMENTS").join(args) + "\n");
' "$ROOT/commands/trekreview.md" "$ROOT" "$TYPED" > "$TMP/phase1.sh" \
|| { bad "$name: could not read the Phase 1 block"; return; }
hookfile "$TMP/shipped.err" "$TMP/phase1.sh"
c=$?
if [ "$c" -ne 0 ]; then
bad "$name: the hook exited $c: $(cat "$TMP/shipped.err")"
return
fi
( cd "$W" && /bin/bash "$TMP/phase1.sh" ) > "$TMP/shipped.json" 2> "$TMP/shipped.out"
c=$?
if [ "$c" -ne 0 ]; then
bad "$name: the block exited $c: $(cat "$TMP/shipped.out")"
elif ! jsonok 'j.flags["--project"] === "notes\n" && j.flags["--since"] === "HEAD" && j.positional.length === 0' < "$TMP/shipped.json"; then
bad "$name: the parse is not --project = notes + line break, --since = HEAD, no positional: $(cat "$TMP/shipped.json")"
else
pass "$name (hook exit 0; --project = notes + line break, --since = HEAD)"
fi
}
# ---- 2. CQW through the parser CLI's --stdin ----
case_cqw_cli() {
name="arg-parser --stdin: \"my dir \" pairs, and --since stays HEAD~1"
printf '%s\n' '--project "my dir " --since HEAD' | node "$ARGS" --command trekreview --stdin > "$TMP/cli-1.json" 2>&1
c1=$?
printf '%s\n' '--project "my dir"/x --since "HEAD~1"' | node "$ARGS" --command trekreview --stdin > "$TMP/cli-2.json" 2>&1
c2=$?
if [ "$c1" -ne 0 ] || [ "$c2" -ne 0 ]; then
bad "$name: the parser exited $c1 / $c2: $(cat "$TMP/cli-1.json" "$TMP/cli-2.json")"
elif ! jsonok 'j.flags["--project"] === "my dir " && j.flags["--since"] === "HEAD"' < "$TMP/cli-1.json"; then
bad "$name: --project is not \"my dir \": $(cat "$TMP/cli-1.json")"
elif ! jsonok 'j.flags["--since"] === "HEAD~1"' < "$TMP/cli-2.json"; then
bad "$name: --since is not HEAD~1: $(cat "$TMP/cli-2.json")"
else
pass "$name"
fi
}
# ---- 3. PQO through the Bash hook ----
# blocked <name> <out> <line>...: the hook exits 2 and names args-heredoc-closed-early.
blocked() {
bname="$1"
bout="$2"
shift 2
hookrun "$bout" "$@"
c=$?
if [ "$c" -ne 2 ]; then
bad "$bname: the hook exited $c, not 2: $(cat "$bout")"
return 1
elif ! grep -q 'args-heredoc-closed-early' "$bout"; then
bad "$bname: the hook did not name args-heredoc-closed-early: $(cat "$bout")"
return 1
fi
return 0
}
# unquoted <name> <out> <line>...: blocked, with the unquoted reason.
unquoted() {
qname="$1"
uout="$2"
shift 2
blocked "$qname" "$uout" "$@" || return 1
if ! grep -q "opens $D unquoted" "$uout"; then
bad "$qname: the hook did not give the unquoted reason: $(cat "$uout")"
return 1
fi
return 0
}
# allowed <name> <out> <line>...: the hook exits 0.
allowed() {
aname="$1"
aout="$2"
shift 2
hookrun "$aout" "$@"
c=$?
if [ "$c" -ne 0 ]; then
bad "$aname: the hook exited $c: $(cat "$aout")"
return 1
fi
return 0
}
case_pqo_partly_quoted() {
name='Bash hook: a partly quoted opener <<"VOYAGE"_ARGS_EOF_7F3C9E that closes early is blocked'
blocked "$name" "$TMP/pqo-1.err" "$PARSER_LINE "'<<"VOYAGE"_ARGS_EOF_7F3C9E' 'brief.md' "$D" 'touch PWNED' \
&& pass "$name (exit 2, args-heredoc-closed-early)"
}
case_pqo_dollar() {
name="Bash hook: a \$'...' opener that closes early is blocked"
blocked "$name" "$TMP/pqo-2.err" "$PARSER_LINE <<\$'$D'" 'brief.md' "$D" 'touch PWNED' \
&& pass "$name (exit 2, args-heredoc-closed-early)"
}
case_pqo_continuation() {
name='Bash hook: a backslash-newline opener that closes early is blocked'
blocked "$name" "$TMP/pqo-3.err" "$PARSER_LINE"' <<\' "$D" 'brief.md' "$D" 'touch PWNED' \
&& pass "$name (exit 2, args-heredoc-closed-early)"
}
case_pqo_continuation_last() {
name='Bash hook: a backslash-newline opener with the closing line last is blocked as unquoted'
unquoted "$name" "$TMP/pqo-4.err" "$PARSER_LINE"' <<\' "$D" 'brief.md' "$D" \
&& pass "$name (exit 2, opens $D unquoted)"
}
case_pqo_fake_accepted() {
name='Bash hook: a fake accepted opener in a string hides no real opener'
unquoted "$name" "$TMP/pqo-5.err" "echo \"<<'$D' x\"" "$PARSER_LINE"' <<VOYAGE_ARGS\' '_EOF_7F3C9E' 'brief.md' "$D" \
&& pass "$name (exit 2, opens $D unquoted)"
}
case_pqo_spanning_word() {
name='Bash hook: a quoted word spanning lines in a string hides no real opener'
unquoted "$name" "$TMP/pqo-6.err" "echo \"<<'\"" "$PARSER_LINE"' <<VOYAGE_ARGS\' '_EOF_7F3C9E' "brief.md don't" "$D" \
&& pass "$name (exit 2, opens $D unquoted)"
}
case_pqo_typed_argument() {
name='Bash hook: the shipped form with the typed argument fix "<<" handling passes'
allowed "$name" "$TMP/pqo-7.err" "$PARSER_LINE <<'$D'" 'fix "<<" handling' "$D" \
&& pass "$name (exit 0)"
}
case_pqo_orchestrator() {
name='Bash hook: the agents/review-orchestrator.md parser block passes'
# The lines from the one holding the opener through the next line equal to
# the delimiter, with ${CLAUDE_PLUGIN_ROOT} and $ARGUMENTS filled in.
node -e '
const fs = require("fs");
const [md, root, args, delim] = process.argv.slice(1);
const lines = fs.readFileSync(md, "utf8").split("\n");
const opener = "--stdin <<" + String.fromCharCode(39) + delim + String.fromCharCode(39);
const s = lines.findIndex((l) => l.includes(opener));
if (s === -1) { console.error("no line holds " + opener); process.exit(3); }
const e = lines.findIndex((l, i) => i > s && l === delim);
if (e === -1) { console.error("no closing line after the opener"); process.exit(3); }
process.stdout.write(lines.slice(s, e + 1).join("\n").split("${CLAUDE_PLUGIN_ROOT}").join(root).split("$ARGUMENTS").join(args) + "\n");
' "$ROOT/agents/review-orchestrator.md" "$ROOT" "$TYPED" "$D" > "$TMP/orchestrator.sh" \
|| { bad "$name: could not read the parser block"; return; }
hookfile "$TMP/pqo-8.err" "$TMP/orchestrator.sh"
c=$?
if [ "$c" -ne 0 ]; then
bad "$name: the hook exited $c: $(cat "$TMP/pqo-8.err")"
else
pass "$name (exit 0)"
fi
}
# ---- 4. PQO premise: bash runs the line after the closing line ----
case_pqo_premise() {
name='/bin/bash runs the line after the closing line of a partly quoted opener'
P="$TMP/premise"
mkdir -p "$P" || { bad "$name: mkdir"; return; }
printf '%s\n' 'cat > /dev/null <<"VOYAGE"_ARGS_EOF_7F3C9E' 'brief.md' "$D" "touch \"$TMP/ran\"" > "$TMP/premise.sh" \
|| { bad "$name: could not write the script"; return; }
( cd "$P" && /bin/bash "$TMP/premise.sh" ) > "$TMP/premise.out" 2>&1
if [ ! -e "$TMP/ran" ]; then
bad "$name: the line after the closing line did not run: $(cat "$TMP/premise.out")"
else
pass "$name (the block is not a false positive)"
fi
}
# ---- 5. PQO fix rounds: a continuation after $, a CR or VT word end ----
# The CR and VT bytes, built with printf escapes so this file stays ASCII.
CR="$(printf '\r')"
VT="$(printf '\013')"
case_fix_bytes() {
name='fix-round cases: the CR and VT bytes were built'
if [ "${#CR}" -ne 1 ] || [ "${#VT}" -ne 1 ]; then
bad "$name: CR is ${#CR} byte(s), VT is ${#VT} byte(s), not 1 each"
else
pass "$name"
fi
}
case_fix_dollar_continuation() {
name="Bash hook: <<\$ + backslash-newline + '$D' that closes early is blocked as unquoted"
unquoted "$name" "$TMP/fix-1.err" "$PARSER_LINE"' <<$\' "'$D'" 'brief.md' "$D" 'touch PWNED' \
&& pass "$name (exit 2, opens $D unquoted)"
}
case_fix_midword_dollar_continuation() {
name="Bash hook: <<VOYAGE\$ + backslash-newline + '_ARGS_EOF_7F3C9E' that closes early is blocked"
blocked "$name" "$TMP/fix-2.err" "$PARSER_LINE"' <<VOYAGE$\' "'_ARGS_EOF_7F3C9E'" 'brief.md' "$D" 'touch PWNED' \
&& pass "$name (exit 2, args-heredoc-closed-early)"
}
case_fix_crlf_partly_quoted() {
name='Bash hook: a partly quoted opener with CRLF lines that closes early is blocked'
blocked "$name" "$TMP/fix-3.err" "$PARSER_LINE"' <<"VOYAGE"_ARGS_EOF_7F3C9E'"$CR" "brief.md$CR" "$D$CR" "touch PWNED$CR" \
&& pass "$name (exit 2, args-heredoc-closed-early)"
}
case_fix_vt_partly_quoted() {
name='Bash hook: a partly quoted opener whose word ends in a VT, closed by D + VT, is blocked'
blocked "$name" "$TMP/fix-4.err" "$PARSER_LINE"' <<"VOYAGE"_ARGS_EOF_7F3C9E'"$VT" 'brief.md' "$D$VT" 'touch PWNED' \
&& pass "$name (exit 2, args-heredoc-closed-early)"
}
case_fix_crlf_closing_last() {
name='Bash hook: a partly quoted opener with CRLF lines and the closing line last passes'
allowed "$name" "$TMP/fix-5.err" "$PARSER_LINE"' <<"VOYAGE"_ARGS_EOF_7F3C9E'"$CR" "brief.md$CR" "$D$CR" \
&& pass "$name (exit 0)"
}
case_cqw_shipped
case_cqw_cli
case_pqo_partly_quoted
case_pqo_dollar
case_pqo_continuation
case_pqo_continuation_last
case_pqo_fake_accepted
case_pqo_spanning_word
case_pqo_typed_argument
case_pqo_orchestrator
case_pqo_premise
case_fix_bytes
case_fix_dollar_continuation
case_fix_midword_dollar_continuation
case_fix_crlf_partly_quoted
case_fix_vt_partly_quoted
case_fix_crlf_closing_last
if [ "$FAILED" -ne 0 ]; then
echo "e2e: $FAILED case(s) failed"
exit 1
fi
echo "e2e: ok"
exit 0

View 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 <\\`);
});