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
50 changes: 50 additions & 0 deletions .claude/hooks/guard-main-checkout-bash.selftest.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -170,6 +170,30 @@ expect allow 'grep -rn "he said \"sed -i\" once" .claude/'
expect block 'node -e "console.log(\"hi\")" > pkg/out.json'
expect block 'sed -i "s/\"a\"/\"b\"/" pkg/x.ts'

echo "== an UNQUOTED \\\" opens no quote, so the write behind it is still seen (#11131) =="
CWD="$MAIN"
# The measured hole: segmentation read the escaped `"` as OPENING a quoted region that never
# closed. Every separator behind it went inert, the command collapsed into one `echo`
# segment, and the real in-place write was just another argument. tokenize() always had the
# backslash branch; this is the pass agreement.
expect block 'echo \" ; sed -i s/a/b/ pkg/x.ts'
expect block 'echo \" ; rm -rf pkg/x.ts'
expect block 'printf \" ; tee pkg/a.ts'
expect block "$(printf 'echo \\"\nsed -i s/a/b/ pkg/x.ts\n')"
# Precision twins: the escape must not manufacture a target where nothing is written, and
# the same command aimed at a linked worktree stays allowed.
expect allow 'echo \" ; echo hello'
expect allow 'echo \" ; cat README.md'
expect allow 'echo a\ b'
expect allow 'echo \\ ; grep -n worktree README.md'
# NOT a discriminating case for this fix, kept as a plain regression pin: a `>` target is
# collected wherever it appears, so this blocked even while the passes disagreed. What the
# disagreement lost was the COMMAND-NAME writers (sed -i / rm / tee) — once the command
# collapsed into one segment the head word became `echo` and they were mere arguments.
expect block 'echo \" && echo x > pkg/x.ts'
CWD="$WT"
expect allow 'echo \" ; sed -i s/a/b/ pkg/x.ts'

echo "== a shell COMMENT is text, not a command (#10570) =="
CWD="$MAIN"
# The measured false blocks: prose in a comment put a real `>` in operator position and the
Expand DownExpand Up@@ -199,6 +223,32 @@ expect block 'echo ${x#a} > pkg/x.ts'
# an escaped `\#` outside quotes is a literal, not a comment opener
expect allow 'echo \# not a comment'

echo "== a heredoc introducer NAMED in a comment introduces nothing (#11133) =="
CWD="$MAIN"
# The measured hole: `<<EOF` inside prose registered as a real introducer. `EOF` never
# appeared on a line of its own, so the pending heredoc was never satisfied and every
# remaining line — including the real write — was dropped before analysis.
expect block "$(printf '# use cat > /tmp/n <<EOF for notes\nsed -i s/a/b/ pkg/x.ts\n')"
expect block "$(printf '# see the <<EOF trick\necho x >> pkg/x.ts\n')"
expect block "$(printf 'git status # cat <<MARKER writes notes\nrm -rf pkg/x.ts\n')"
expect block "$(printf '# heredocs: <<-EOF and <<"Q" both introduce\ntouch pkg/new.ts\n')"
# Real heredocs are untouched: a body is still prose, and the introducing line's own
# redirect is still a write.
expect allow "$(printf "cat > /tmp/notes.md <<'EOF'\nsed -i 's/a/b/' %s/pkg/x.ts\nEOF\n" "$MAIN")"
expect block "$(printf 'cat > %s/notes.md <<EOF\nhello\nEOF\n' "$MAIN")"
# An inline comment AFTER a real introducer must not cancel it — the body is still stripped,
# and the negative twin shows a real write after the terminator is still caught.
expect allow "$(printf 'cat > /tmp/n.md <<EOF # notes\nsed -i s/a/b/ pkg/x.ts\nEOF\n')"
expect block "$(printf 'cat > /tmp/n.md <<EOF # notes\nhello\nEOF\nsed -i s/a/b/ pkg/x.ts\n')"
# A QUOTED `#` on the introducing line is not a comment: truncating there would lose the
# real introducer and expose the body as commands. This is the load-bearing precision case.
expect allow "$(printf "grep '#' README.md <<EOF\nsed -i 's/a/b/' pkg/x.ts\nEOF\n")"
# Ordering pin: an unbalanced apostrophe inside a heredoc BODY must not desynchronise the
# comment rule for the lines that follow. Body lines never reach the comment scan, so the
# real write after the terminator is still analysed.
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 "== 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\""
Expand Down
64 changes: 62 additions & 2 deletions .claude/hooks/guard-main-checkout-bash.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,7 +48,7 @@
# for anyone who means to write there, and the target of this guard is the reflexive
# `sed -i` an agent reaches for mid-task, not a determined evader.
#
# Writing ABOUT the ban must never trip the ban (#4890's lesson). Three layers:
# 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;
# 2. heredoc bodies are stripped before analysis — the LINES of a `cat > /tmp/notes <<EOF`
Expand All@@ -61,6 +61,11 @@
# in that tail put a REAL ASCII `>` in operator position, so the guard named the
# following JS fragment as a write "target" and blocked a pure-read command (#10247).
# Single quotes take no escapes: inside '…' a backslash is literal, as in a real shell.
# OUTSIDE quotes the same escape holds and both passes must agree that it does: a `\"`
# there opens no quoted region at all. Only tokenise() knew that, so segmentation read
# the `"` as opening a region that never closed, went inert for every separator behind
# it, and let a real `sed -i` through as a mere argument — the fail-OPEN mirror of the
# false block above (#11131). The two passes now share the rule in both directions.
# 4. shell COMMENTS are text, not commands — both quote-aware passes stop at an unquoted
# `#` that starts a WORD and resume at the next newline (#10570). A comment cannot
# write anything, so reading one as a command is a false BLOCK: prose arrows (`->`,
Expand DownExpand Up@@ -118,6 +123,47 @@ fi
# on newlines, so without this pass a documented example inside the body ("sed -i … main
# checkout") would be analysed as if the agent had run it. Drop body lines (and their
# terminator); the introducing line — which is where the real redirection lives — stays.
#
# A `<<WORD` that is merely NAMED inside a COMMENT introduces nothing — bash removes the
# comment before it ever looks for a heredoc. Reading one as a real introducer was fail-OPEN
# and badly so: the delimiter never appeared on a line of its own, so the pending heredoc
# was never satisfied and EVERY remaining line — including real commands — was dropped
# before either quote-aware pass could see it (#11133).
#
# Only the DELIMITER SCAN consults the comment rule; the line itself is passed through
# untouched, because both quote-aware passes already own that rule (#10570) and a second
# implementation of it here is exactly how the two would drift.
#
# This is why the fix is a narrow scan-side truncation rather than a comment-stripping pass
# run BEFORE this one: a heredoc BODY may contain an unbalanced apostrophe, and a quote-aware
# comment scanner run over the raw text would desynchronise on it for every following line.
# Body lines never reach this scan — they are consumed by the `pending` branch below and
# `continue` before it — so that hazard is structurally out of reach here.
#
# Word-start rule, identical to split_segments()/tokenize(): a `#` opens a comment only where
# a WORD could start — at line start, after a blank, or after one of `; | & ( ) > <`.
strip_line_comment() {
local s="$1" n=${#1} i ch q="" word=0
for ((i = 0; i < n; i++)); do
ch="${s:i:1}"
if [ -n "$q" ]; then
if [ "$q" = '"' ] && [ "$ch" = '\' ] && [ $((i + 1)) -lt "$n" ]; then i=$((i + 1)); continue; fi
[ "$ch" = "$q" ] && q=""
continue
fi
case "$ch" in
'#')
if [ "$word" = 0 ]; then printf '%s' "${s:0:i}" ; return ; fi
;; # foo#bar, ${x#y}, url/#frag
'\') i=$((i + 1)) ; word=1 ;;
"'" | '"') q="$ch" ; word=1 ;;
';' | '|' | '&' | '(' | ')' | ' ' | $'\t' | '>' | '<') word=0 ;;
*) word=1 ;;
esac
done
printf '%s' "$s"
}

strip_heredocs() {
local s="$1" out="" line scan d t
local -a pending=()
Expand All@@ -130,8 +176,10 @@ strip_heredocs() {
continue
fi
out+="$line"$'\n'
# A `<<WORD` behind an unquoted word-start `#` is prose, not an introducer (#11133).
scan="$(strip_line_comment "$line")"
# `<<<` is a herestring, not a heredoc — mask it before hunting for delimiters.
scan="${line//<<</__OS_HERESTRING__}"
scan="${scan//<<</__OS_HERESTRING__}"
case "$scan" in
*'<<'*)
while IFS= read -r d; do
Expand DownExpand Up@@ -182,6 +230,18 @@ split_segments() {
fi
seg+="$ch" # foo#bar, ${x#y}, url/#frag
;;
'\')
# Outside quotes a backslash escapes the NEXT character, so an escaped `\"` does NOT
# open a quoted region. tokenize() has always had this branch; this pass did not, and
# the disagreement was a fail-OPEN hole: the `"` after the backslash opened a quote
# here that never closed, every later separator went inert, the whole command
# collapsed into one `echo` segment, and a real `sed -i` behind it was just another
# argument (#11131). Both characters are kept verbatim — this pass only SPLITS, and
# tokenize() re-reads the escape when it strips quoting.
seg+="$ch"
if [ $((i + 1)) -lt "$n" ]; then i=$((i + 1)) ; seg+="${s:i:1}" ; fi
word=1
;;
"'" | '"') q="$ch" ; seg+="$ch" ; word=1 ;;
';' | '|' | '&' | '(' | ')' | $'\n') segments+=("$seg") ; seg="" ; word=0 ;;
'{' | '}') segments+=("$seg") ; seg="" ; word=1 ;;
Expand Down
Loading