fix(hooks): the args-heredoc guard blocks a heredoc operator a second shell would read, with a bypass corpus

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-11-02 09:57:28 +01:00
commit ebec492f6a
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
5 changed files with 878 additions and 3 deletions

View file

@ -335,6 +335,17 @@ const commandView = commandPositionView(deobfuscated.replace(/\x1B\[[0-9;]*m/g,
// the < is read as unescaped. The regex reading takes the shell reading's
// decision for the same <. The < after the escaped one is read as usual,
// so a << right after it is still checked (fix round 4.5b2, part A).
// A < followed (continuations skipped) by an odd run of backslashes and a
// second < is a heredoc operator a second shell may read: the outer shell
// removes the backslashes, so eval, bash -c or sh -c reads << there. It is
// blocked as an unquoted opener the guard could not read when the word after
// the second < names the delimiter as a second shell reads it (every
// backslash-newline removed, then a leading -, blanks, quotes and
// backslashes) and a non-blank line follows anywhere after the opener's line
// (the body may sit inside the quotes that carry the word). It is judged
// before every skip. A one-line form has no such line and passes. The rule
// does not look for the second shell, so the same text with none is blocked
// as well (fix round 4.5b2, part A, decision 107).
// 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';
@ -504,9 +515,30 @@ function argsCutInString(text, o) {
return text.slice(lineStart, o.index).split(q).length % 2 === 0;
}
// After the second < of a twice-escaped operator (p), does the word a second
// shell reads name the delimiter? Every backslash-newline is removed, then a
// leading -, blanks, quotes and backslashes (fix round 4.5b2, part A,
// decision 107).
function argsRereadNames(text, p) {
for (;;) {
if (text[p] === '\\' && text[p + 1] === '\n') { p += 2; continue; }
if ('- \t\'"\\'.includes(text[p]) && p < text.length) { p += 1; continue; }
break;
}
let j = 0;
while (j < ARGS_DELIM.length && p < text.length) {
if (text[p] === '\\' && text[p + 1] === '\n') { p += 2; continue; }
if (text[p] !== ARGS_DELIM[j]) return false;
p += 1;
j += 1;
}
return j === ARGS_DELIM.length;
}
// Each heredoc operator in `text`, in text order, as the shell reads its word:
// { index (the operator's first <), end, word, quoted, unreadable, cutOnly,
// dash (the - of <<- was read) }. Every < it judges escaped goes into
// dash (the - of <<- was read), reread (a twice-escaped operator a second
// shell reads, decision 107) }. Every < it judges escaped goes into
// lim.escaped, which the regex reading reads.
function argsShellOpeners(text, lim = argsEscapeLimit(text)) {
const n = text.length;
@ -518,6 +550,14 @@ function argsShellOpeners(text, lim = argsEscapeLimit(text)) {
const openers = [];
let i = text.indexOf('<');
while (i !== -1) {
{
const r = skip(i + 1);
let b = 0;
while (text[r + b] === '\\') b += 1;
if (b % 2 === 1 && text[r + b] === '<' && argsRereadNames(text, r + b + 1)) {
openers.push({ index: i, end: r + b + 1, word: '', quoted: false, unreadable: true, cutOnly: false, dash: false, reread: true });
}
}
if (argsEscapedLt(text, i, lim, openers)) {
lim.escaped.add(i);
i = text.indexOf('<', i + 1);
@ -624,6 +664,13 @@ if (command.replace(/\\\n/g, '').includes(ARGS_DELIM)) {
}
let bodyFrom = Infinity;
for (const o of argsShell) {
if (o.reread) {
if (/\n\s*\S/.test(command.slice(o.index))) {
argsVerdict = [true, true];
break;
}
continue;
}
if (o.index >= bodyFrom && o.cutOnly && !argsSplit(command, o)) continue;
if (o.cutOnly && argsCutInString(command, o)) continue;
// A delimiter split by a backslash-newline whose word has a suffix is

View file

@ -0,0 +1,303 @@
#!/usr/bin/env bash
# tests/e2e-fix-round-4-5b2-a.sh
# End to end for fix round 4.5b2, part A (plan
# .claude/projects/2026-10-31-guard-4-5b2-part-a, End-to-end:, decisions 91,
# 107 and 123): the Bash hook (hooks/scripts/pre-bash-executor.mjs) reads
# commands the way a session sends them, and /bin/bash and /bin/zsh -f show,
# each in a temp directory with HOME set to a temp directory, that each block
# is real and each pass is text to the shell.
# 1. ESR: the three commands that newly pass (a here-string, a closed quoted
# heredoc, a closed quote before an escaped <) pass the hook, and both
# shells run the line after them as a command (a stub next on PATH
# touches ran-next) and create no PWNED file.
# 2. Blocks that are no false positive: SFX-X and SFX-dot print
# body EXPANDED under both shells; SIS1 and the eval and sh -c forms of a
# twice-escaped operator (X1, X1s) create PWNED under both shells; the
# [CNT] command expands under /bin/bash, while zsh stops with a parse
# error.
# 3. The shipped form still passes: the parser block of
# commands/trekreview.md (## Phase 1), filled with the repo root and the
# typed argument fix "<<" handling.
# A missing /bin/zsh is a FAIL, not a skip.
# Prints one "ok - <case>" or "FAIL - <case>: <why>" line per case and exits 1
# when any case failed, else 0. Starts nothing but node, /bin/bash and
# /bin/zsh (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)"
HOOK="$ROOT/hooks/scripts/pre-bash-executor.mjs"
unset CLAUDE_CODE_SESSION_ID
TMP="$(mktemp -d "${TMPDIR:-/tmp}/e2e-fix-round-4-5b2-a.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)); }
# The premises' HOME, and a stub next that touches ran-next in the current
# directory.
mkdir -p "$TMP/home" "$TMP/bin" || { echo "FAIL - setup: cannot make the temp directories"; exit 1; }
printf '%s\n' '#!/bin/sh' 'touch ran-next' > "$TMP/bin/next" && chmod +x "$TMP/bin/next" \
|| { echo "FAIL - setup: cannot write the next stub"; exit 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
# 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
}
# premise <shell> <dir> <script> <out> <line>...: writes the lines to <script>
# and runs it in <dir> with /bin/bash (<shell> bash) or /bin/zsh -f (<shell>
# zsh), HOME=$TMP/home and the next stub first on PATH; stdout and stderr go
# to <out>, the shell's exit status to PSTATUS. Returns 1 when the script
# cannot be written or the shell is unknown or missing.
PSTATUS=0
premise() {
pshell="$1"
pdir="$2"
pscript="$3"
pout="$4"
shift 4
PSTATUS=0
mkdir -p "$pdir" || return 1
printf '%s\n' "$@" > "$pscript" || return 1
case "$pshell" in
bash) [ -x /bin/bash ] || return 1 ;;
zsh) [ -x /bin/zsh ] || return 1 ;;
*) return 1 ;;
esac
if [ "$pshell" = bash ]; then
( cd "$pdir" && HOME="$TMP/home" PATH="$TMP/bin:/usr/bin:/bin" /bin/bash "$pscript" ) > "$pout" 2>&1 < /dev/null
else
( cd "$pdir" && HOME="$TMP/home" PATH="$TMP/bin:/usr/bin:/bin" /bin/zsh -f "$pscript" ) > "$pout" 2>&1 < /dev/null
fi
PSTATUS=$?
return 0
}
# ---- 0. Setup: both shells are there ----
case_shells() {
if [ ! -x /bin/bash ]; then
bad 'setup: /bin/bash is missing'
fi
if [ ! -x /bin/zsh ]; then
bad 'setup: /bin/zsh is missing (a FAIL, not a skip)'
fi
}
# ---- 1. ESR: the commands that newly pass ----
# esr_case <tag> <name> <line>...: the hook passes the lines; under each shell
# the stub next runs (ran-next) and no PWNED file appears.
esr_case() {
etag="$1"
ename="$2"
shift 2
allowed "$ename" "$TMP/$etag.err" "$@" || return
for sh in bash zsh; do
edir="$TMP/$etag-$sh"
premise "$sh" "$edir" "$TMP/$etag-$sh.sh" "$TMP/$etag-$sh.out" "$@" \
|| { bad "$ename: could not run the premise under $sh"; return; }
if [ ! -e "$edir/ran-next" ]; then
bad "$ename: $sh did not run the line after the opener as a command: $(cat "$TMP/$etag-$sh.out")"
return
fi
if ls "$edir" | grep -q '^PWNED'; then
bad "$ename: $sh created a PWNED file"
return
fi
done
pass "$ename (exit 0; /bin/bash and /bin/zsh -f run next as a command)"
}
case_esr() {
esr_case esr-1 'ESR: a here-string before an escaped < leaves the rule on' \
'cat <<<x; echo \<<'"$D" 'next'
esr_case esr-2 'ESR: a closed quoted heredoc before an escaped < leaves the rule on' \
'cat <<'"'"'EOF'"'" "don't" 'EOF' 'echo \<<'"$D" 'next'
esr_case esr-3 'ESR: a quote closed before an escaped < leaves the rule on' \
'echo '"'"'x'"'"' && echo \<<'"$D" 'next'
}
# ---- 2. Blocks that are no false positive ----
# expanded_case <tag> <name> <line>...: the hook blocks the lines as unquoted;
# under each shell the body prints body EXPANDED.
expanded_case() {
xtag="$1"
xname="$2"
shift 2
unquoted "$xname" "$TMP/$xtag.err" "$@" || return
for sh in bash zsh; do
premise "$sh" "$TMP/$xtag-$sh" "$TMP/$xtag-$sh.sh" "$TMP/$xtag-$sh.out" "$@" \
|| { bad "$xname: could not run the premise under $sh"; return; }
if ! grep -q 'body EXPANDED' "$TMP/$xtag-$sh.out"; then
bad "$xname: $sh did not expand the body: $(cat "$TMP/$xtag-$sh.out")"
return
fi
done
pass "$xname (exit 2; /bin/bash and /bin/zsh -f print body EXPANDED)"
}
# pwned_case <tag> <name> <line>...: the hook blocks the lines as unquoted;
# under each shell the body creates PWNED.
pwned_case() {
ptag="$1"
pname="$2"
shift 2
unquoted "$pname" "$TMP/$ptag.err" "$@" || return
for sh in bash zsh; do
premise "$sh" "$TMP/$ptag-$sh" "$TMP/$ptag-$sh.sh" "$TMP/$ptag-$sh.out" "$@" \
|| { bad "$pname: could not run the premise under $sh"; return; }
if [ ! -e "$TMP/$ptag-$sh/PWNED" ]; then
bad "$pname: $sh did not expand the body: $(cat "$TMP/$ptag-$sh.out")"
return
fi
done
pass "$pname (exit 2; /bin/bash and /bin/zsh -f create PWNED)"
}
case_blocks() {
expanded_case sfx-x 'SFX: a split delimiter with an X suffix is blocked as unquoted' \
'cat <<VOYAGE_ARGS\' '_EOF_7F3C9EX' 'body $(echo EXPANDED)'
expanded_case sfx-dot 'SFX: a split delimiter with a . suffix is blocked as unquoted' \
'cat <<VOYAGE_ARGS\' '_EOF_7F3C9E.' 'body $(echo EXPANDED)'
pwned_case sis-1 'SIS: a split delimiter inside a bash -c string is blocked as unquoted' \
'bash -c '"'"'cat <<VOYAGE_ARGS\' '_EOF_7F3C9E'"'"'"' '\$(touch PWNED); touch RAN' '"'
pwned_case evl-x1 'EVL: an eval of a twice-escaped heredoc operator with a body is blocked as unquoted' \
'eval cat \<\<'"$D""'" '$(touch PWNED)' "'"
pwned_case evl-x1s 'EVL: the sh -c form of a twice-escaped heredoc operator is blocked as unquoted' \
'sh -c '"'"'cat '"'"'\<\<'"$D""'" '$(touch PWNED)' "'"
}
case_cnt() {
name='CNT: an outer opener split between its two < keeps the escape rule off'
L1='bash <\'
L2='<EOF'
L3='cat - \\\<<'"$D"
L4='\$(touch PWNED)'
L5="$D"
L6='EOF'
unquoted "$name" "$TMP/cnt.err" "$L1" "$L2" "$L3" "$L4" "$L5" "$L6" || return
premise bash "$TMP/cnt-bash" "$TMP/cnt-bash.sh" "$TMP/cnt-bash.out" "$L1" "$L2" "$L3" "$L4" "$L5" "$L6" \
|| { bad "$name: could not run the premise under bash"; return; }
if [ ! -e "$TMP/cnt-bash/PWNED" ]; then
bad "$name: /bin/bash did not expand the inner body: $(cat "$TMP/cnt-bash.out")"
return
fi
premise zsh "$TMP/cnt-zsh" "$TMP/cnt-zsh.sh" "$TMP/cnt-zsh.out" "$L1" "$L2" "$L3" "$L4" "$L5" "$L6" \
|| { bad "$name: could not run the premise under zsh"; return; }
if [ "$PSTATUS" -eq 0 ] || ! grep -q 'parse error' "$TMP/cnt-zsh.out"; then
bad "$name: /bin/zsh -f did not stop with a parse error (exit $PSTATUS): $(cat "$TMP/cnt-zsh.out")"
return
fi
pass "$name (exit 2; /bin/bash expands the inner body, /bin/zsh -f exits $PSTATUS with a parse error)"
}
# ---- 3. The shipped form still passes ----
case_shipped() {
name='shipped: the trekreview Phase 1 parser block with the typed argument fix "<<" handling passes'
# 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" 'fix "<<" handling' > "$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")"
else
pass "$name (exit 0)"
fi
}
case_shells
case_esr
case_blocks
case_cnt
case_shipped
if [ "$FAILED" -ne 0 ]; then
echo "e2e: $FAILED case(s) failed"
exit 1
fi
echo "e2e: ok"
exit 0

View file

@ -0,0 +1,251 @@
#!/usr/bin/env bash
# tests/guard-bypass-corpus-4-5b2-a.sh
# The bypass corpus of fix round 4.5b2, part A (plan
# .claude/projects/2026-10-31-guard-4-5b2-part-a/plan.md, Step 3, 3c;
# decisions 91, 107 and 123): commands the args-heredoc guard in
# hooks/scripts/pre-bash-executor.mjs must block. Each entry is fed to the
# hook as a Bash tool input; it passes when the hook exits 2 and names
# args-heredoc-closed-early.
#
# guard-bypass-corpus-4-5b2-a.sh [--hook <absolute path>]
#
# --hook defaults to hooks/scripts/pre-bash-executor.mjs of this repo.
# Exit 0 every entry blocked, 1 an entry not blocked (its FAIL line names it),
# 2 a setup failure ("corpus: setup failure: <reason>": a hook path that does
# not exist, an unknown argument, no entries, a bad line, a duplicate name);
# never 0 on a setup failure.
#
# Entries: one line each, "E <name> <JSON string>", after one comment line
# "# <name>: expands under bash|zsh|both|neither (blocked conservatively);
# measured <date>". The JSON keeps every byte ASCII (CR and tab show as \r
# and \t). fs-<n> is the blocked command on line <n> of
# tests/hooks/bash-guard-false-stops.test.mjs and ag-<n> the one on line <n> of
# tests/hooks/bash-guard-args-gate.test.mjs, each with its template expanded,
# as the branch left them (fixed when written: a later edit of those files
# does not update this corpus). The other names are the plan's (its Probe
# Catalogue and Step 3, 3c).
# "expands under" was measured with /bin/bash (3.2.57) and /bin/zsh -f (5.9),
# each in its own temp directory with HOME set to a temp directory and
# PATH=/usr/bin:/bin, the entry's $(touch PWNED) rewritten to
# $(touch PWNED); touch RAN: it expands when a PWNED file exists and RAN does
# not. "neither" entries are blocked conservatively: a parse error, a quote
# left open, or a body read as text.
# Starts nothing but node and the hook. bash 3.2 compatible, ASCII only, no
# arrays.
set -u
setup_fail() {
echo "corpus: setup failure: $1" >&2
exit 2
}
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" || setup_fail "cannot find the repo root from $0"
HOOK="$ROOT/hooks/scripts/pre-bash-executor.mjs"
while [ $# -gt 0 ]; do
case "$1" in
--hook)
[ $# -ge 2 ] || setup_fail "--hook needs a path"
HOOK="$2"
shift 2
;;
*)
setup_fail "unknown argument: $1"
;;
esac
done
[ -f "$HOOK" ] || setup_fail "no hook at $HOOK"
command -v node > /dev/null 2>&1 || setup_fail "node not found"
unset CLAUDE_CODE_SESSION_ID
# The reader: parses the entries on stdin, feeds each to the hook, prints
# "ok - <name>" or "FAIL - <name>: exit <n>" and a count line. Any error of
# its own is a setup failure (exit 2).
READER='
const fs = require("fs");
const { spawnSync } = require("child_process");
const setup = (why) => {
process.stderr.write("corpus: setup failure: " + why + "\n");
process.exit(2);
};
try {
const hook = process.argv[1];
if (!hook || !fs.existsSync(hook)) setup("no hook at " + hook);
const entries = [];
const seen = new Set();
fs.readFileSync(0, "utf8").split("\n").forEach((line, i) => {
if (line === "" || line.startsWith("#")) return;
const m = /^E ([a-z0-9][a-z0-9-]*) (".*")$/.exec(line);
if (!m) setup("bad line " + (i + 1) + ": " + line.slice(0, 80));
let command;
try {
command = JSON.parse(m[2]);
} catch {
setup("bad JSON string on line " + (i + 1));
}
if (typeof command !== "string") setup("not a JSON string on line " + (i + 1));
if (seen.has(m[1])) setup("duplicate name " + m[1]);
seen.add(m[1]);
entries.push({ name: m[1], command });
});
if (entries.length === 0) setup("no entries");
let failed = 0;
for (const e of entries) {
const r = spawnSync(process.execPath, [hook], {
input: JSON.stringify({ tool_name: "Bash", tool_input: { command: e.command } }),
encoding: "utf8",
timeout: 20000,
maxBuffer: 16 * 1024 * 1024,
});
if (r.status === 2 && String(r.stderr).includes("args-heredoc-closed-early")) {
console.log("ok - " + e.name);
continue;
}
failed += 1;
if (r.error && r.error.code === "ETIMEDOUT") console.log("FAIL - " + e.name + ": timeout after 20 s");
else if (r.status === null) console.log("FAIL - " + e.name + ": exit null (signal " + r.signal + ")");
else console.log("FAIL - " + e.name + ": exit " + r.status);
}
console.log("corpus: " + entries.length + " entries, " + (entries.length - failed) + " blocked, " + failed + " not blocked");
process.exit(failed === 0 ? 0 : 1);
} catch (err) {
setup("reader error: " + (err && err.message));
}
'
cat <<'CORPUS' | node -e "$READER" "$HOOK"
# fs-75: expands under neither (blocked conservatively); measured 2026-11-02
E fs-75 "node /plugin/lib/parsers/arg-parser.mjs --command trekendsession --stdin <<\"VOYAGE_ARGS_EOF_7F3C9E\nbrief.md"
# fs-78: expands under neither (blocked conservatively); measured 2026-11-02
E fs-78 "echo \"x\" && node /plugin/lib/parsers/arg-parser.mjs --command trekendsession --stdin <<V\"OYAGE_ARGS_EOF_7F3C9E\nbrief.md\nVOYAGE_ARGS_EOF_7F3C9E"
# fs-96: expands under both; measured 2026-11-02
E fs-96 "echo \"a<<b\" && node /plugin/lib/parsers/arg-parser.mjs --command trekendsession --stdin <<VOYAGE_ARGS\\\n_EOF_7F3C9E\nbrief.md $(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E"
# fs-104: expands under both; measured 2026-11-02
E fs-104 "cat \\<<<VOYAGE_ARGS\\\n_EOF_7F3C9E\nHDBODY #$(touch PWNED1)"
# fs-105: expands under both; measured 2026-11-02
E fs-105 "cat \\<\\\n<<VOYAGE_ARGS\\\n_EOF_7F3C9E\nHDBODY #$(touch PWNED1)"
# fs-106: expands under bash; measured 2026-11-02
E fs-106 "cat \\<<\\\n<VOYAGE_ARGS_EOF_7F3C9E\nHDBODY #$(touch PWNED1)\nVOYAGE_ARGS_EOF_7F3C9E"
# fs-107: expands under both; measured 2026-11-02
E fs-107 "cat \\<<<VOYAGE_ARGS\\\n_EOF\\\n_7F3C9E\nHDBODY #$(touch PWNED1)\nVOYAGE_ARGS_EOF_7F3C9E"
# fs-108: expands under both; measured 2026-11-02
E fs-108 "cat \\<<<V\"OYAGE_ARGS_EOF_7F3C9E\"\nx\nVOYAGE_ARGS_EOF_7F3C9E\ntouch PWNED2"
# fs-112: expands under both; measured 2026-11-02
E fs-112 "echo \\\\<<VOYAGE_ARGS_EOF_7F3C9E\nbrief.md $(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E"
# fs-114: expands under both; measured 2026-11-02
E fs-114 "echo '\\'<<VOYAGE_ARGS_EOF_7F3C9E\nbrief.md $(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E"
# fs-116: expands under both; measured 2026-11-02
E fs-116 "echo '\\'<<VOYAGE_ARGS\\\n_EOF_7F3C9E\nbrief.md $(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E"
# fs-118: expands under neither (blocked conservatively); measured 2026-11-02
E fs-118 "echo 'x' && echo \"y \\<<VOYAGE_ARGS_EOF_7F3C9E\nbrief.md $(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E"
# fs-120: expands under both; measured 2026-11-02
E fs-120 "echo \\<<<VOYAGE_ARGS_EOF_7F3C9E\nbrief.md $(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E"
# fs-122: expands under both; measured 2026-11-02
E fs-122 "echo a\\\\\n\\\\<<VOYAGE_ARGS_EOF_7F3C9E\nbrief.md $(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E"
# fs-127: expands under both; measured 2026-11-02
E fs-127 "bash -c \"cat \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n\\$(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\""
# fs-129: expands under both; measured 2026-11-02
E fs-129 "x=`cat - \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n$(echo EXPANDED)\nVOYAGE_ARGS_EOF_7F3C9E`"
# fs-132: expands under both; measured 2026-11-02
E fs-132 "bash <<EOF\ncat - \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n\\$(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\nEOF"
# ag-61: expands under both; measured 2026-11-02
E ag-61 "node /plugin/lib/parsers/arg-parser.mjs --command trekendsession --stdin <<VOYAGE_ARGS\\\n_EOF_7F3C9E\nbrief.md $(touch PWNED)"
# ag-62: expands under both; measured 2026-11-02
E ag-62 "node /plugin/lib/parsers/arg-parser.mjs --command trekendsession --stdin <<VOYAGE_ARGS_EOF\\\n_7F3C9E\nbrief.md $(touch PWNED)"
# scan-single-backslash: expands under both; measured 2026-11-02
E scan-single-backslash "echo '\\'; bash -c \"echo \\\"'\\\"; cat - \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n\\$(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\""
# scan-dq-escaped-quote: expands under both; measured 2026-11-02
E scan-dq-escaped-quote "bash -c \"echo \\\"x\\\"; cat - \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n\\$(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\""
# esc-quote-open: expands under neither (blocked conservatively); measured 2026-11-02
E esc-quote-open "echo \"y \\<<VOYAGE_ARGS_EOF_7F3C9E\nbrief.md $(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E"
# esc-bash-c-dq: expands under both; measured 2026-11-02
E esc-bash-c-dq "bash -c \"cat - \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n\\$(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\""
# esc-eval-dq: expands under both; measured 2026-11-02
E esc-eval-dq "eval \"cat - \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n\\$(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\""
# esc-bash-c-ansi: expands under bash; measured 2026-11-02
E esc-bash-c-ansi "bash -c $'cat - \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n$(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E'"
# esc-eval-ansi: expands under bash; measured 2026-11-02
E esc-eval-ansi "eval $'cat - \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n$(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E'"
# esc-bash-c-locale: expands under both; measured 2026-11-02
E esc-bash-c-locale "bash -c $\"cat - \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n\\$(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\""
# esc-heredoc-open: expands under both; measured 2026-11-02
E esc-heredoc-open "bash <<EOF\ncat - \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n\\$(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E"
# esc-backquote: expands under neither (blocked conservatively); measured 2026-11-02
E esc-backquote "x=`date`; echo \\<<VOYAGE_ARGS_EOF_7F3C9E\nbrief.md $(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E"
# esc-close-trailing-space: expands under neither (blocked conservatively); measured 2026-11-02
E esc-close-trailing-space "cat <<'EOF'\nEOF \necho \\<<VOYAGE_ARGS_EOF_7F3C9E\nbrief.md $(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\nEOF"
# esc-close-cr: expands under neither (blocked conservatively); measured 2026-11-02
E esc-close-cr "cat <<'EOF'\nEOF\r\necho \\<<VOYAGE_ARGS_EOF_7F3C9E\nbrief.md $(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\nEOF"
# esc-close-tab: expands under neither (blocked conservatively); measured 2026-11-02
E esc-close-tab "cat <<'EOF'\n\tEOF\necho \\<<VOYAGE_ARGS_EOF_7F3C9E\nbrief.md $(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\nEOF"
# esc-two-heredocs: expands under both; measured 2026-11-02
E esc-two-heredocs "cat <<A <<B\nA\necho \\<<VOYAGE_ARGS_EOF_7F3C9E\nbrief.md $(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\nB"
# esc-cj: expands under both; measured 2026-11-02
E esc-cj "echo \\<x; bash <<EOF\nx\\\nEOF\ncat - \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n\\$(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\nEOF"
# esc-e1: expands under both; measured 2026-11-02
E esc-e1 "# don't\nbash -c \"# it's\ncat - \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n\\$(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\""
# esc-e2: expands under both; measured 2026-11-02
E esc-e2 "echo $((1<<X))\nbash -c \":\nX\ncat - \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n\\$(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\""
# esc-e2b: expands under both; measured 2026-11-02
E esc-e2b "# see <<X\nbash -c \":\nX\ncat - \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n\\$(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\""
# esc-e4b: expands under both; measured 2026-11-02
E esc-e4b "x=$(cat <<X)\nbash -c \":\nX\ncat - \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n\\$(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\""
# esc-e4d: expands under both; measured 2026-11-02
E esc-e4d "cat <(cat <<X) >/dev/null\nbash -c \":\nX\ncat - \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n\\$(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\""
# esc-e8: expands under both; measured 2026-11-02
E esc-e8 "bash <<X$(:)\nX$\ncat - \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n\\$(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\nX$(:)"
# esc-e3: expands under both; measured 2026-11-02
E esc-e3 "bash -s <<X \\\nX\ncat - \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n\\$(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\nX"
# esc-e18: expands under bash; measured 2026-11-02
E esc-e18 "bash -c \"$\\\n(: '\"')# '\ncat - \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n\\$(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\""
# esc-e19: expands under both; measured 2026-11-02
E esc-e19 "bash -c $\\\n'# \\' \"'\"\ncat - \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n\\$(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\""
# esc-cnt: expands under bash; measured 2026-11-02
E esc-cnt "bash <\\\n<EOF\ncat - \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n\\$(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\nEOF"
# sfx-word: expands under both; measured 2026-11-02
E sfx-word "cat <<VOYAGE_ARGS\\\n_EOF_7F3C9EX\nbody $(echo EXPANDED)"
# sfx-dot: expands under both; measured 2026-11-02
E sfx-dot "cat <<VOYAGE_ARGS\\\n_EOF_7F3C9E.\nbody $(echo EXPANDED)"
# sfx-backquote: expands under neither (blocked conservatively); measured 2026-11-02
E sfx-backquote "cat <<VOYAGE_ARGS\\\n_EOF_7F3C9E`\nbody $(echo EXPANDED)"
# split-in-string: expands under both; measured 2026-11-02
E split-in-string "bash -c 'cat <<VOYAGE_ARGS\\\n_EOF_7F3C9E'\"\n\\$(touch PWNED); touch RAN\n\""
# split-in-string-dot: expands under both; measured 2026-11-02
E split-in-string-dot "bash -c 'cat <<VOYAGE_ARGS\\\n_EOF_7F3C9E.'\"\n\\$(touch PWNED); touch RAN\n\""
# split-in-string-word: expands under both; measured 2026-11-02
E split-in-string-word "bash -c 'cat <<VOYAGE_ARGS\\\n_EOF_7F3C9EX'\"\n\\$(touch PWNED); touch RAN\n\""
# split-in-string-closed: expands under both; measured 2026-11-02
E split-in-string-closed "bash -c 'cat <<VOYAGE_ARGS\\\n_EOF_7F3C9E'\"\n\\$(touch PWNED); touch RAN\nVOYAGE_ARGS_EOF_7F3C9E\""
# split-in-body: expands under both; measured 2026-11-02
E split-in-body "bash <<'VOYAGE_ARGS_EOF_7F3C9E'\nbash -c 'cat <<VOYAGE_ARGS\\\n_EOF_7F3C9E'\"\n\\$(touch PWNED); touch RAN\n\"\nVOYAGE_ARGS_EOF_7F3C9E"
# evl-x1: expands under both; measured 2026-11-02
E evl-x1 "eval cat \\<\\<VOYAGE_ARGS_EOF_7F3C9E'\n$(touch PWNED)\n'"
# evl-x1b: expands under both; measured 2026-11-02
E evl-x1b "bash -c cat\\ \\<\\<VOYAGE_ARGS_EOF_7F3C9E'\n$(touch PWNED)\n'"
# evl-sh-c: expands under both; measured 2026-11-02
E evl-sh-c "sh -c 'cat '\\<\\<VOYAGE_ARGS_EOF_7F3C9E'\n$(touch PWNED)\n'"
# evl-x2: expands under both; measured 2026-11-02
E evl-x2 "eval cat \\<\\< \"VOYAGE_ARGS_EOF_7F3C9E\n\\$(touch PWNED)\n\""
# evl-split-word: expands under both; measured 2026-11-02
E evl-split-word "eval cat \\<\\<VOYAGE_ARGS\\\n_EOF_7F3C9E'\n$(touch PWNED)\n'"
# evl-split-dq: expands under both; measured 2026-11-02
E evl-split-dq "eval cat \\<\\<\"VOYAGE_ARGS\\\n_EOF_7F3C9E\"'\n$(touch PWNED)\n'"
# evl-split-sq: expands under both; measured 2026-11-02
E evl-split-sq "eval cat \\<\\<'VOYAGE_ARGS\\\n_EOF_7F3C9E\n$(touch PWNED)\n'"
# evl-split-before-word: expands under both; measured 2026-11-02
E evl-split-before-word "eval cat \\<\\<\\\nVOYAGE_ARGS_EOF_7F3C9E'\n$(touch PWNED)\n'"
# evl-blank-escaped: expands under both; measured 2026-11-02
E evl-blank-escaped "eval cat \\<\\<\\ VOYAGE_ARGS_EOF_7F3C9E'\n$(touch PWNED)\n'"
# evl-empty-pair: expands under both; measured 2026-11-02
E evl-empty-pair "eval cat \\<\\<'' VOYAGE_ARGS_EOF_7F3C9E'\n$(touch PWNED)\n'"
# evl-escaped-dash: expands under both; measured 2026-11-02
E evl-escaped-dash "eval cat \\<\\<\\-VOYAGE_ARGS_EOF_7F3C9E'\n$(touch PWNED)\n'"
CORPUS
status=$?
case "$status" in
0 | 1 | 2) exit "$status" ;;
*) setup_fail "the reader exited $status" ;;
esac

View file

@ -1,5 +1,5 @@
// tests/hooks/bash-guard-4-5b2-a.test.mjs
// [RXT] [CNT] [ESR] [SFX] [SIS] Fix round 4.5b2, part A (decisions 91 and 123).
// [RXT] [CNT] [ESR] [SFX] [SIS] [EVL] Fix round 4.5b2, part A (decisions 91, 107 and 123).
// Defects: the args-heredoc guard in hooks/scripts/pre-bash-executor.mjs
// turned its escape rule off for the whole command when a ', " or backquote
// or a << stood anywhere before an escaped <, so a here-string, a closed
@ -10,7 +10,8 @@
// the escape rule off (CNT). A delimiter split by a backslash-newline with a
// suffix after it was compared as an exact word, so it passed (SFX). Part 4's
// string skip skipped a split opener inside a string, which the regex reading
// cannot see (SIS).
// cannot see (SIS). A heredoc operator a second shell reads from \<\< (eval,
// bash -c, sh -c) was seen by neither reading (EVL).
// The helpers bashInput, ARGS_DELIM, ARGS_RULE, PARSER_LINE,
// expectArgsBlocked, expectArgsAllowed, UNQUOTED, expectUnquoted,
// CLOSED_EARLY, expectClosedEarly, D and BODY are copied from
@ -243,6 +244,62 @@ test('[SIS] a split delimiter inside a string is never skipped as text', async (
await expectArgsAllowed(`grep -n '<<${SPLIT}' file`);
});
// The eval word, written in two parts so the live guard's dynamic-eval rule
// does not refuse the file write.
const EV = 'ev' + 'al';
// The body line of the re-read forms; the ' after the word opens the string
// that carries it, and the next ' closes it.
const XBODY = "\n$(touch PWNED)\n'";
test('[EVL] an eval of a twice-escaped heredoc operator with a body is blocked', async () => {
// Red (catalogue X1): the outer shell removes the backslashes, so the
// second shell (eval) reads <<D and an unquoted heredoc. Measured: /bin/bash
// and /bin/zsh -f create PWNED.
await expectUnquoted(`${EV} cat \\<\\<${D}'${XBODY}`);
// Control that passes: the one-line form has no line after the opener, so
// no body (decision 87's one-line rule).
await expectArgsAllowed(`${EV} cat \\<\\<${D}`);
// Control: with no second shell the shell reads \<\<D as text and the next
// line runs as a command; the rule refuses it all the same (it is not keyed
// on eval). This pins that known exception.
await expectUnquoted(`echo \\<\\<${D}\nnext`);
});
test('[EVL] a bash -c string built from a twice-escaped heredoc operator is blocked', async () => {
// Red (catalogue X1b): bash -c gets the string cat <<D'...', an unquoted
// heredoc. Measured: /bin/bash and /bin/zsh -f create PWNED.
await expectUnquoted(`bash -c cat\\ \\<\\<${D}'${XBODY}`);
// Control (catalogue X1s): the sh -c form.
await expectUnquoted(`sh -c 'cat '\\<\\<${D}'${XBODY}`);
});
test('[EVL] a blank between the twice-escaped operator and the word does not hide it', async () => {
// Red (catalogue X2): the second shell reads << "D...", the word after a
// blank. Measured: /bin/bash and /bin/zsh -f create PWNED.
await expectUnquoted(`${EV} cat \\<\\< "${D}\n\\$(touch PWNED)\n"`);
// Controls: the word is read as a second shell reads it once the outer
// shell has removed its quoting (3a's word loop).
// An escaped blank before the word.
await expectUnquoted(`${EV} cat \\<\\<\\ ${D}'${XBODY}`);
// An empty quote pair and a blank before the word.
await expectUnquoted(`${EV} cat \\<\\<'' ${D}'${XBODY}`);
// An escaped - before the word.
await expectUnquoted(`${EV} cat \\<\\<\\-${D}'${XBODY}`);
// The split forms: a backslash-newline inside or before the word is removed
// by one of the two shells, so the check removes it inside the delimiter.
// Catalogue XS1: the split in the bare word.
await expectUnquoted(`${EV} cat \\<\\<${SPLIT}'${XBODY}`);
// Catalogue XS2: the split inside a "..." word.
await expectUnquoted(`${EV} cat \\<\\<"${SPLIT}"'${XBODY}`);
// Catalogue XS3: the split inside the '...' string that carries the body.
await expectUnquoted(`${EV} cat \\<\\<'${SPLIT}\n$(touch PWNED)\n'`);
// Catalogue XS4: a backslash-newline between the operator and the word.
await expectUnquoted(`${EV} cat \\<\\<\\\n${D}'${XBODY}`);
});
test('part-A scan: a long command with many escaped < is read in linear time', async () => {
// About 870 KB: 20000 escaped < before the shipped quoted heredoc. The quote
// scan resumes where its last call stopped, so the text is read once.

View file

@ -0,0 +1,217 @@
// tests/hooks/bash-guard-corpus-4-5b2-a.test.mjs
// Fix round 4.5b2, part A (decisions 91, 107 and 123): the red proof of the
// bypass corpus tests/guard-bypass-corpus-4-5b2-a.sh. The corpus holds
// commands the args-heredoc guard in hooks/scripts/pre-bash-executor.mjs must
// block; these tests show it passes on the hook and fails on hooks that let
// an entry through. Untagged: no red wrapper lists this file.
// Each corpus run is /bin/bash tests/guard-bypass-corpus-4-5b2-a.sh --hook
// <path>. The mutated hooks are copies (pluginCopy,
// tests/hooks/guard-helpers.mjs), each find text asserted to occur exactly
// once first, so a later edit of a mutated line fails with "matched 0 times",
// never passes silently (the mutatedCopy pattern of
// tests/hooks/bash-guard-reader-pins.test.mjs).
import { test, after } from 'node:test';
import { strict as assert } from 'node:assert';
import { execFile } from 'node:child_process';
import { readFileSync, writeFileSync, rmSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { runHook } from '../helpers/hook-helper.mjs';
import { childEnv } from '../helpers/child-env.mjs';
import { tmp, pluginCopy } from './guard-helpers.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const PRE_BASH = join(ROOT, 'hooks', 'scripts', 'pre-bash-executor.mjs');
const CORPUS = join(ROOT, 'tests', 'guard-bypass-corpus-4-5b2-a.sh');
const ARGS_DELIM = 'VOYAGE_ARGS_EOF_7F3C9E';
const ARGS_RULE = /args-heredoc-closed-early/;
const UNQUOTED = /opens VOYAGE_ARGS_EOF_7F3C9E unquoted/;
const D = ARGS_DELIM;
// The names the plan lists (Step 3, 3c); each must stand in exactly one
// "E <name> " line of the corpus, so the corpus cannot be thinner than this.
const NAMES = [
'fs-75', 'fs-78', 'fs-96', 'fs-104', 'fs-105', 'fs-106', 'fs-107', 'fs-108', 'fs-112',
'fs-114', 'fs-116', 'fs-118', 'fs-120', 'fs-122', 'fs-127', 'fs-129', 'fs-132',
'ag-61', 'ag-62',
'scan-single-backslash', 'scan-dq-escaped-quote',
'esc-quote-open', 'esc-bash-c-dq', 'esc-eval-dq', 'esc-bash-c-ansi', 'esc-eval-ansi',
'esc-bash-c-locale', 'esc-heredoc-open', 'esc-backquote',
'esc-close-trailing-space', 'esc-close-cr', 'esc-close-tab', 'esc-two-heredocs',
'esc-cj', 'esc-e1', 'esc-e2', 'esc-e2b', 'esc-e4b', 'esc-e4d', 'esc-e8', 'esc-e3',
'esc-e18', 'esc-e19', 'esc-cnt',
'sfx-word', 'sfx-dot', 'sfx-backquote',
'split-in-string', 'split-in-string-dot', 'split-in-string-word', 'split-in-string-closed',
'split-in-body',
'evl-x1', 'evl-x1b', 'evl-sh-c', 'evl-x2',
'evl-split-word', 'evl-split-dq', 'evl-split-sq', 'evl-split-before-word',
'evl-blank-escaped', 'evl-empty-pair', 'evl-escaped-dash',
];
// The [CNT] command (tests/hooks/bash-guard-4-5b2-a.test.mjs): an opener split
// between its two < whose heredoc is fed to bash.
const CNT = `bash <\\\n<EOF\ncat - \\\\\\<<${D}\n\\$(touch PWNED)\n${D}\nEOF`;
// Every corpus run gets this long: about 63 hook starts.
const CORPUS_TIMEOUT_MS = 300_000;
function runCorpus(hook) {
return new Promise((resolve) => {
const child = execFile('/bin/bash', [CORPUS, '--hook', hook],
{ timeout: CORPUS_TIMEOUT_MS, env: childEnv(), maxBuffer: 16 * 1024 * 1024 },
(err, stdout, stderr) => {
resolve({ code: child.exitCode ?? 1, stdout: stdout || '', stderr: stderr || '' });
});
child.stdin.end();
});
}
const corpusText = () => readFileSync(CORPUS, 'utf8');
const entryLines = (text) => text.split('\n').filter((l) => l.startsWith('E '));
// The command of the corpus entry `name` (its JSON string).
function entryCommand(name) {
const line = entryLines(corpusText()).find((l) => l.startsWith(`E ${name} `));
assert.ok(line, `no corpus entry ${name}`);
return JSON.parse(line.slice(`E ${name} `.length));
}
const dirs = [];
after(() => {
for (const dir of dirs) rmSync(dir, { recursive: true, force: true });
});
// A hook stub in a temp directory: it reads the whole input, then runs `body`
// with `command` set to the input's command.
function stubHook(body) {
const dir = tmp('voyage-corpus-stub-');
dirs.push(dir);
const file = join(dir, 'stub-hook.mjs');
writeFileSync(file, `let s = '';
process.stdin.on('data', (d) => { s += d; });
process.stdin.on('end', () => {
const command = JSON.parse(s).tool_input.command;
${body}
});
`);
return file;
}
// A copy of the plugin whose hook has `find` (asserted to occur exactly once)
// replaced by `replace`. Returns the hook path and the unmutated text.
function mutatedHook(find, replace) {
const root = pluginCopy();
dirs.push(root);
const hook = join(root, 'hooks', 'scripts', 'pre-bash-executor.mjs');
const src = readFileSync(hook, 'utf8');
const count = src.split(find).length - 1;
assert.strictEqual(count, 1, `the mutation text matched ${count} times, not once: ${find}`);
return { hook, mutate: () => writeFileSync(hook, src.replace(find, () => replace)) };
}
const failLines = (out) => out.split('\n').filter((l) => l.startsWith('FAIL - '));
test('part-A corpus: every entry is blocked by the hook, and the count line counts every E line', async () => {
const { code, stdout, stderr } = await runCorpus(PRE_BASH);
assert.strictEqual(code, 0, `the corpus did not pass on the hook (exit ${code}):\n${failLines(stdout).join('\n')}\n${stderr}`);
const m = /^corpus: (\d+) entries, (\d+) blocked, (\d+) not blocked$/m.exec(stdout);
assert.ok(m, `no count line in the output:\n${stdout}`);
const lines = entryLines(corpusText()).length;
assert.strictEqual(Number(m[1]), lines, `the count line says ${m[1]} entries, the script holds ${lines} E lines`);
assert.strictEqual(Number(m[2]), lines, `the count line says ${m[2]} blocked of ${lines}`);
assert.strictEqual(stdout.split('\n').filter((l) => l.startsWith('ok - ')).length, lines, 'not one ok line per entry');
});
test('part-A corpus: the corpus holds every entry the plan names, each in exactly one E line', () => {
const lines = entryLines(corpusText());
for (const name of NAMES) {
const n = lines.filter((l) => l.startsWith(`E ${name} `)).length;
assert.strictEqual(n, 1, `the corpus holds ${n} E lines for ${name}, not one`);
}
assert.ok(lines.length >= NAMES.length, `the corpus holds ${lines.length} E lines, fewer than the ${NAMES.length} names`);
});
test('part-A corpus: a hook that lets every command through fails the corpus (exit 1)', async () => {
const hook = stubHook('process.exit(0);');
const { code, stdout } = await runCorpus(hook);
assert.strictEqual(code, 1, `the corpus did not fail on a hook that exits 0 (exit ${code}):\n${stdout}`);
assert.strictEqual(failLines(stdout).length, entryLines(corpusText()).length, 'not every entry failed');
});
test('part-A corpus: a hook that blocks with another rule name fails the corpus (exit 1)', async () => {
const hook = stubHook("process.stderr.write('[voyage] BLOCKED: some-other-rule\\n'); process.exit(2);");
const { code, stdout } = await runCorpus(hook);
assert.strictEqual(code, 1, `the corpus did not fail on a hook that names another rule (exit ${code}):\n${stdout}`);
assert.strictEqual(failLines(stdout).length, entryLines(corpusText()).length, 'not every entry failed');
});
test('part-A corpus: a hook that lets one entry through fails the corpus and names only that entry', async () => {
const name = 'split-in-body';
const text = entryCommand(name);
const hook = stubHook(`if (command === ${JSON.stringify(text)}) process.exit(0);
process.stderr.write('[voyage] BLOCKED: args-heredoc-closed-early\\n');
process.exit(2);`);
const { code, stdout } = await runCorpus(hook);
assert.strictEqual(code, 1, `the corpus did not fail when one entry passed (exit ${code}):\n${stdout}`);
const fails = failLines(stdout);
assert.strictEqual(fails.length, 1, `expected one FAIL line, got:\n${fails.join('\n')}`);
assert.ok(fails[0].startsWith(`FAIL - ${name}: `), `the FAIL line names another entry: ${fails[0]}`);
});
test('part-A corpus: a missing hook is a setup failure (exit 2)', async () => {
const dir = tmp('voyage-corpus-missing-');
dirs.push(dir);
const { code, stdout, stderr } = await runCorpus(join(dir, 'no-such-hook.mjs'));
assert.strictEqual(code, 2, `the corpus did not exit 2 on a missing hook (exit ${code}):\n${stdout}\n${stderr}`);
});
test('part-A corpus: a hook copy with the args gate off fails the corpus (exit 1)', async () => {
const { hook, mutate } = mutatedHook("if (command.replace(/\\\\\\n/g, '').includes(ARGS_DELIM)) {", 'if (false) {');
mutate();
const { code, stdout } = await runCorpus(hook);
assert.strictEqual(code, 1, `the corpus did not fail with the gate off (exit ${code}):\n${stdout}`);
});
test("part-A corpus: the scan's continuation check holds the [CNT] command on the branch", async () => {
const marker = '// scan: a continuation cannot be followed';
const { hook, mutate } = mutatedHook(String.raw` if (d === '\n') return unknown(); ${marker}`, '');
// The unmutated copy blocks the [CNT] command as unquoted.
const before = await runHook(hook, { tool_name: 'Bash', tool_input: { command: CNT } });
assert.strictEqual(before.code, 2, `the unmutated copy did not block the [CNT] command: ${before.stderr}`);
assert.match(before.stderr, UNQUOTED, `the unmutated copy did not give the unquoted reason: ${before.stderr}`);
mutate();
assert.ok(!readFileSync(hook, 'utf8').includes(marker), 'the continuation check is still in the copy');
// Without the check the scan skips the backslash and the line break, so the
// escape rule turns on and the [CNT] command passes.
const after_ = await runHook(hook, { tool_name: 'Bash', tool_input: { command: CNT } });
assert.doesNotMatch(after_.stderr, ARGS_RULE, `the mutated copy still blocked the [CNT] command: ${after_.stderr}`);
assert.strictEqual(after_.code, 0, `the mutated copy did not pass the [CNT] command (exit ${after_.code}): ${after_.stderr}`);
});
test("part-A corpus: a scan that reads a backslash inside '...' as an escape lets scan-single-backslash through", async () => {
// M1.
const { hook, mutate } = mutatedHook(
" if (mode === 'single') {\n if (c === \"'\") mode = 'none';",
" if (mode === 'single') {\n" + String.raw` if (c === '\\') { p += 2; continue; }` + "\n if (c === \"'\") mode = 'none';",
);
mutate();
const { code, stdout } = await runCorpus(hook);
assert.strictEqual(code, 1, `the corpus did not fail under M1 (exit ${code}):\n${stdout}`);
assert.ok(failLines(stdout).some((l) => l.startsWith('FAIL - scan-single-backslash: ')), `scan-single-backslash did not fail under M1:\n${stdout}`);
});
test('part-A corpus: a scan that ignores a backslash inside "..." lets scan-dq-escaped-quote through', async () => {
// M2. The first line alone also occurs inside argsShellOpeners, so the find
// text is both lines.
const { hook, mutate } = mutatedHook(
String.raw` if (c === '\\') {` + '\n // The scan never decides',
String.raw` if (c === '\\' && mode !== 'double') {` + '\n // The scan never decides',
);
mutate();
const { code, stdout } = await runCorpus(hook);
assert.strictEqual(code, 1, `the corpus did not fail under M2 (exit ${code}):\n${stdout}`);
assert.ok(failLines(stdout).some((l) => l.startsWith('FAIL - scan-dq-escaped-quote: ')), `scan-dq-escaped-quote did not fail under M2:\n${stdout}`);
});