Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .claude/hooks/guard-main-checkout-bash.selftest.sh
Original file line number Diff line number Diff line change
Expand Up @@ -304,12 +304,35 @@ expect allow "$(printf "grep '#' README.md <<EOF\nsed -i 's/a/b/' pkg/x.ts\nEOF\
expect block "$(printf "cat > /tmp/n.md <<'EOF'\n# it's a note, don't strip me\nEOF\nsed -i s/a/b/ pkg/x.ts\n")"
expect allow "$(printf "cat > /tmp/n.md <<'EOF'\n# it's a note, don't strip me\nEOF\ngit status\n")"

echo "== a \$(…) inside \"…\" is its own quoting context (#24812) =="
CWD="$MAIN"
# The measured false blocks: a `"` inside the substitution was read as CLOSING the outer string,
# so the `>` in a quoted JSON body became a redirect and the fragment after it a write target.
# Pure reads — nothing is written.
expect allow 'echo "a $(curl -d "{\"c\":\"x > 1\"}" http://h)"'
expect allow "echo \"a \$(curl -d '{\"c\":\"x > 1\"}' http://h)\""
expect allow 'echo "a `curl -d "{\"c\":\"x > 1\"}" http://h`"' # the backtick spelling
# Recall is untouched: a real write beside a quoted substitution still blocks — in the same
# command, behind a separator, and inside a sed -i script — and so does the card's control.
expect block 'echo hi > out.txt'
expect block 'echo "a $(curl -d "{\"c\":\"x > 1\"}" http://h)" > pkg/x.ts'
expect block 'X="$(git rev-parse HEAD)" ; sed -i s/a/b/ pkg/x.ts'
expect block 'sed -i "s/a/$(date +%F)/" pkg/x.ts'
# The fail-OPEN mirror: one `"` in the body used to leave the outer string hanging, so the
# separator behind it went inert and the real write rode through as text.
expect block "echo \"\$(printf '\"')\" ; sed -i s/a/b/ pkg/x.ts"
expect block "echo \"\$(echo \"\$(printf '\"')\")\" ; sed -i s/a/b/ pkg/x.ts" # nested
# The heredoc scan reads the body the same way: the ` #` inside it is no comment, so the
# introducer behind it is found and the body stays prose.
expect allow "$(printf 'echo "$(echo " #")" <<EOF\nsed -i s/a/b/ pkg/x.ts\nEOF\n')"

echo "== shapes this guard deliberately does NOT claim (documented fail-open) =="
CWD="$MAIN"
expect allow "bash -c \"sed -i s/a/b/ $MAIN/pkg/x.ts\""
expect allow 'echo pkg/x.ts | xargs sed -i s/a/b/'
expect allow 'node -e "fs.writeFileSync(\"pkg/x.ts\", \"y\")"'
expect allow 'python3 -c "open(\"pkg/x.ts\",\"w\").write(\"y\")"'
expect allow 'echo "$(sed -i s/a/b/ pkg/x.ts)"' # a QUOTED $(…) body is one inert word
expect allow 'sed -i s/a/b/ $SOMEDIR/x.ts'
expect allow 'sed -i s/a/b/ pkg/*.ts'

Expand Down
56 changes: 55 additions & 1 deletion .claude/hooks/guard-main-checkout-bash.sh
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@
#
# Fails open, deliberately, on:
# - wrapped invocations: bash -c '…', xargs, ssh host '…', make, a shell script that
# writes (the target is one indirection away from any token this hook can see);
# writes, a command inside a QUOTED "$(…)" (the target is one indirection away from any
# token this hook can see);
# - programs whose writes live inside their own source text: python -c "open(p,'w')",
# node -e, awk > (the target is not a shell token, so there is nothing to read);
# - any target containing an expansion or glob ($VAR, `…`, $(…), *, ?, ~) — unresolvable
Expand All @@ -51,6 +52,8 @@
# Writing ABOUT the ban must never trip the ban (#4890's lesson). Four layers:
# 1. quote-aware segmentation + tokenisation — a `>` or a `sed -i` inside '…' or "…" is
# literal text, so `grep -n "sed -i" .claude/` and `echo "never sed -i in main"` pass;
# a `$(…)` or backtick inside "…" is a quoting context of its own, so a `"` in its body
# nests instead of closing the outer string (subst_end(), shared by every pass);
# 2. heredoc bodies are stripped before analysis — the LINES of a `cat > /tmp/notes <<EOF`
# body are documentation, not commands, and segmentation alone (which splits on
# newlines) would happily read them as such;
Expand Down Expand Up @@ -118,6 +121,48 @@ fi

[ -n "$cmd" ] || exit 0

# --- a command substitution inside "…" is its own quoting context ---------------------
# Inside "…", `$(` or a backtick opens a NEW quoting context: a `"` in the body starts a string
# of its own and does not close the outer one. Each scanner below used to read that `"` as the
# closer, so the rest of the body became bare shell, and a `>` in a quoted curl body was taken
# for a redirect whose "target" was the next fragment — a false BLOCK (#24812). All three
# quote-aware scanners hand such a body to this ONE function, so they agree on where it ends.
#
# subst_end S I: S[I] is the `$` of a `$(`, or a backtick, met inside "…". Sets SUBST_END to
# the index of the character that closes the substitution, reading the body with its own
# quotes, escapes and nested `(…)` / `$(…)` / backticks; when the input ends first, to the
# last index — the rest is one inert run (bash refuses such a command anyway). The body is
# kept verbatim as part of the word, so a target holding it still fails open on its `$`.
# Boundary: a `(` or `)` bash would not count (a `case` pattern, a comment) moves the end —
# too early, and the rest is read as it was before this function existed; too late, and the
# text swallowed with the body is inert, i.e. fails open.
SUBST_END=0
subst_end() {
local s="$1" i="$2" n=${#1} st ch top
if [ "${s:i:1}" = '`' ]; then st='`'; else st=')' ; i=$((i + 1)); fi
for ((i = i + 1; i < n; i++)); do
ch="${s:i:1}" ; top="${st:${#st}-1:1}"
if [ "$top" = "'" ]; then [ "$ch" = "'" ] && st="${st%?}"; continue; fi
if [ "$ch" = '\' ]; then i=$((i + 1)); continue; fi
if [ "$top" = '"' ]; then
case "$ch" in
'"') st="${st%?}" ;;
'`') st+='`' ;;
'$') if [ "${s:i+1:1}" = '(' ]; then st+=')' ; i=$((i + 1)); fi ;;
esac
continue
fi
case "$ch" in
"'" | '"') st+="$ch" ;;
'(') st+=')' ;;
')') [ "$top" = ')' ] && st="${st%?}" ;;
'`') if [ "$top" = '`' ]; then st="${st%?}"; else st+='`'; fi ;;
esac
if [ -z "$st" ]; then SUBST_END=$i; return; fi
done
SUBST_END=$((n - 1))
}

# --- heredoc bodies are text, not commands ---------------------------------------------
# `cat > /tmp/notes.md <<EOF` … `EOF` carries prose over several lines. Segmentation splits
# on newlines, so without this pass a documented example inside the body ("sed -i … main
Expand Down Expand Up @@ -148,6 +193,9 @@ strip_line_comment() {
ch="${s:i:1}"
if [ -n "$q" ]; then
if [ "$q" = '"' ] && [ "$ch" = '\' ] && [ $((i + 1)) -lt "$n" ]; then i=$((i + 1)); continue; fi
if [ "$q" = '"' ] && { [ "$ch" = '`' ] || [ "${s:i:2}" = '$(' ]; }; then
subst_end "$s" "$i" ; i=$SUBST_END ; continue
fi
[ "$ch" = "$q" ] && q=""
continue
fi
Expand Down Expand Up @@ -217,6 +265,9 @@ split_segments() {
seg+="$ch" ; i=$((i + 1)) ; seg+="${s:i:1}" ; continue ;;
esac
fi
if [ "$q" = '"' ] && { [ "$ch" = '`' ] || [ "${s:i:2}" = '$(' ]; }; then
subst_end "$s" "$i" ; seg+="${s:i:SUBST_END-i+1}" ; i=$SUBST_END ; continue
fi
seg+="$ch"
[ "$ch" = "$q" ] && q=""
continue
Expand Down Expand Up @@ -274,6 +325,9 @@ tokenize() {
i=$((i + 1)) ; tok+="${s:i:1}" ; have=1 ; continue ;;
esac
fi
if [ "$q" = '"' ] && { [ "$ch" = '`' ] || [ "${s:i:2}" = '$(' ]; }; then
subst_end "$s" "$i" ; tok+="${s:i:SUBST_END-i+1}" ; i=$SUBST_END ; have=1 ; continue
fi
if [ "$ch" = "$q" ]; then q=""; else tok+="$ch"; fi
have=1
continue
Expand Down
Loading