From 6bbc96e979e148983e8a980bcac1e4d6485a0963 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 08:21:26 +0000 Subject: [PATCH 1/3] fix(hooks): give split_segments() the backslash branch tokenize() already had (#11131) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Outside quotes a backslash escapes the next character, so an escaped `\"` opens no quoted region. tokenize() modelled that; split_segments() did not. The disagreement was fail-OPEN: the `"` behind the backslash opened a quote that never closed, every separator after it went inert, the command collapsed into a single `echo` segment, and a real `sed -i` into the shared checkout rode through as just another argument. Measured probe, cwd inside the shared primary checkout: echo \" ; sed -i s/a/b/ pkg/x.ts before: hook-exit=0 ALLOWED after: hook-exit=2 sed -i s/a/b/ pkg/x.ts (control) before: hook-exit=2 after: hook-exit=2 The mirror case #10247 (an escaped quote INSIDE a double-quoted word, a false BLOCK) stays allowed — its twins are in the matrix next door and are unchanged. Selftest: 100 -> 110 passed, 0 failed. Both directions pinned, including the precision twins where the escape must NOT manufacture a target, and the same command aimed at a linked worktree. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RMTpSRF5CjMmQBFfPtPCwJ --- .../guard-main-checkout-bash.selftest.sh | 24 +++++++++++++++++++ .claude/hooks/guard-main-checkout-bash.sh | 17 +++++++++++++ 2 files changed, 41 insertions(+) diff --git a/.claude/hooks/guard-main-checkout-bash.selftest.sh b/.claude/hooks/guard-main-checkout-bash.selftest.sh index 49a7834c5b..bb34abcca0 100755 --- a/.claude/hooks/guard-main-checkout-bash.selftest.sh +++ b/.claude/hooks/guard-main-checkout-bash.selftest.sh @@ -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 diff --git a/.claude/hooks/guard-main-checkout-bash.sh b/.claude/hooks/guard-main-checkout-bash.sh index 3f367a0f6d..635a28107d 100755 --- a/.claude/hooks/guard-main-checkout-bash.sh +++ b/.claude/hooks/guard-main-checkout-bash.sh @@ -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 (`->`, @@ -182,6 +187,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 ;; From 389fddccfe1c4fff3bf80036782a30a5d7b0c0e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 08:23:40 +0000 Subject: [PATCH 2/3] fix(hooks): a heredoc introducer named inside a comment introduces nothing (#11133) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit strip_heredocs() ran on raw text and modelled neither quotes nor comments, so a `< /tmp/n < 121 passed, 0 failed. Both directions pinned, including a QUOTED `#` on an introducing line (truncating there would expose the body as commands) and an inline comment after a real introducer. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RMTpSRF5CjMmQBFfPtPCwJ --- .../guard-main-checkout-bash.selftest.sh | 26 +++++++++++ .claude/hooks/guard-main-checkout-bash.sh | 45 ++++++++++++++++++- 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/.claude/hooks/guard-main-checkout-bash.selftest.sh b/.claude/hooks/guard-main-checkout-bash.selftest.sh index bb34abcca0..82cb02734d 100755 --- a/.claude/hooks/guard-main-checkout-bash.selftest.sh +++ b/.claude/hooks/guard-main-checkout-bash.selftest.sh @@ -223,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: `< /tmp/n <> pkg/x.ts\n')" +expect block "$(printf 'git status # cat < /tmp/notes.md <<'EOF'\nsed -i 's/a/b/' %s/pkg/x.ts\nEOF\n" "$MAIN")" +expect block "$(printf 'cat > %s/notes.md < /tmp/n.md < /tmp/n.md < /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\"" diff --git a/.claude/hooks/guard-main-checkout-bash.sh b/.claude/hooks/guard-main-checkout-bash.sh index 635a28107d..08c0212b9e 100755 --- a/.claude/hooks/guard-main-checkout-bash.sh +++ b/.claude/hooks/guard-main-checkout-bash.sh @@ -123,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 `< <`. +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=() @@ -135,8 +176,10 @@ strip_heredocs() { continue fi out+="$line"$'\n' + # A `< Date: Sun, 23 Aug 2026 08:24:30 +0000 Subject: [PATCH 3/3] docs(hooks): the header enumerates four layers, not three (#11234) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #10406 correctly moved the count "Two" -> "Three" when it added layer 3. PR #11129 (#10570) added layer 4 and left the count alone. This header is the enumerated contract a reader checks a change against — and it is the thing that told objectui's dev the two copies must not drift — so a miscount is worth the one word. No logic, no self-test change: 121 passed, 0 failed, unchanged. The objectui copy carries the miscount verbatim and deliberately (objectui#5459); it ports downstream from here like any other drift item. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RMTpSRF5CjMmQBFfPtPCwJ --- .claude/hooks/guard-main-checkout-bash.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/hooks/guard-main-checkout-bash.sh b/.claude/hooks/guard-main-checkout-bash.sh index 08c0212b9e..e8354a8cc3 100755 --- a/.claude/hooks/guard-main-checkout-bash.sh +++ b/.claude/hooks/guard-main-checkout-bash.sh @@ -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 <