303 lines
11 KiB
Bash
303 lines
11 KiB
Bash
#!/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
|