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:
parent
7ab4100489
commit
ebec492f6a
5 changed files with 878 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
303
tests/e2e-fix-round-4-5b2-a.sh
Normal file
303
tests/e2e-fix-round-4-5b2-a.sh
Normal 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
|
||||
251
tests/guard-bypass-corpus-4-5b2-a.sh
Normal file
251
tests/guard-bypass-corpus-4-5b2-a.sh
Normal 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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
217
tests/hooks/bash-guard-corpus-4-5b2-a.test.mjs
Normal file
217
tests/hooks/bash-guard-corpus-4-5b2-a.test.mjs
Normal 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}`);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue