From c42985576acb591c1064ff10b8a82ca73d4e9a4b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 14:27:04 +0000 Subject: [PATCH] fix(hooks): port the guard-main-checkout-bash.sh fix family from objectstack PR #11278 Verbatim cross-repo port of the three commits on objectstack PR #11278 (objectstack#11131 + #11133 + #11234) into this repo's copy of .claude/hooks/guard-main-checkout-bash.sh, plus the selftest additions. 1. split_segments() gains the backslash branch tokenize() already had. Outside quotes a backslash escapes the next character, so an escaped \" opens no quoted region. Only tokenize() knew that; 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. 2. strip_heredocs()'s delimiter scan learns the comment rule via a new strip_line_comment() helper. A `< `Four layers:`. objectui#5459's port carried the miscount deliberately to stay byte-identical with an upstream that was itself wrong; upstream has now corrected it, so correcting it here restores parity rather than breaking it. Selftest matrix 100 -> 121 cases. Both blocks were spliced from upstream's file verbatim; only issue references are localised (`#11131` -> `objectstack#11131` etc.), matching this copy's existing convention for upstream refs. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019b5UBNMtTzKbVtZZGvFuxe --- .../guard-main-checkout-bash.selftest.sh | 50 +++++++++++++++ .claude/hooks/guard-main-checkout-bash.sh | 64 ++++++++++++++++++- 2 files changed, 112 insertions(+), 2 deletions(-) diff --git a/.claude/hooks/guard-main-checkout-bash.selftest.sh b/.claude/hooks/guard-main-checkout-bash.selftest.sh index 1c7c16278..2dc939487 100755 --- a/.claude/hooks/guard-main-checkout-bash.selftest.sh +++ b/.claude/hooks/guard-main-checkout-bash.selftest.sh @@ -169,6 +169,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 (objectstack#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 (objectstack#10570) ==" CWD="$MAIN" # The measured false blocks: prose in a comment put a real `>` in operator position and the @@ -198,6 +222,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 (objectstack#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 b5c3658d2..fc144fbff 100755 --- a/.claude/hooks/guard-main-checkout-bash.sh +++ b/.claude/hooks/guard-main-checkout-bash.sh @@ -41,7 +41,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 (objectstack#4890's lesson). Three layers: +# Writing ABOUT the ban must never trip the ban (objectstack#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 <` in operator position, so the guard named the # following JS fragment as a write "target" and blocked a pure-read command (objectstack#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 (objectstack#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 (objectstack#10570). A comment cannot # write anything, so reading one as a command is a false BLOCK: prose arrows (`->`, @@ -113,6 +118,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=() @@ -125,8 +171,10 @@ strip_heredocs() { continue fi out+="$line"$'\n' + # A `<