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