Skip to content

fix(hooks): make guard-main-checkout-bash agree with itself on backslashes and comment-named heredocs - #11278

Merged
hotlong merged 3 commits into
mainfrom
claude/issue-11131-guard-bash-pass-agreement
Aug 23, 2026
Merged

fix(hooks): make guard-main-checkout-bash agree with itself on backslashes and comment-named heredocs#11278
hotlong merged 3 commits into
mainfrom
claude/issue-11131-guard-bash-pass-agreement

Conversation

@claude

@claudeclaudeBot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Fixes#11131
Fixes#11133
Fixes#11234

Three graded cards on one file pair, one commit each, per the PM fold ruling recorded on #11131. All measurements below were re-taken against current main (a2ec377), not merely the commit the cards were measured at (95437e7d2) — the only churn on these two files since then is merged PR #11129, exactly as all three cards state.

Governed surface — draft, human merge only

.claude/** is a governed surface. This PR is opened as draft deliberately: it is not to be flipped ready by an agent, auto-merge is not armed, and it wants a human merge.

What changed, per member

#11131split_segments() had no backslash branch (fail-OPEN)

Outside quotes a backslash escapes the next character, so an escaped \" opens no quoted region. tokenize() modelled that; split_segments() did not. The disagreement let a real write through: 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 the sed -i rode along as just another argument.

probe (cwd = shared primary checkout)beforeafter
echo \" ; sed -i s/a/b/ pkg/x.tshook-exit=0 ALLOWEDhook-exit=2 blocked
sed -i s/a/b/ pkg/x.ts (control)hook-exit=2 blockedhook-exit=2 blocked
node -e "…require(\"./a.json\")…st=>st.a…" (#10247 mirror)hook-exit=0 ALLOWEDhook-exit=0 ALLOWED
grep -rn "he said \"sed -i\" once" .claude/ (#10247 mirror)hook-exit=0 ALLOWEDhook-exit=0 ALLOWED

The mirror case #10247 — an escaped quote inside a double-quoted word, which was a false BLOCK — stays green in both its twins.

#11133strip_heredocs() read a heredoc introducer named in a comment (fail-OPEN)

strip_heredocs() ran on raw text and modelled neither quotes nor comments, so a <<EOF occurring inside prose registered as a real introducer. The delimiter then 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.

probe (cwd = shared primary checkout)beforeafter
# use cat > /tmp/n <<EOF for notessed -i s/a/b/ pkg/x.tshook-exit=0 ALLOWEDhook-exit=2 blocked
# plain prose commentsed -i s/a/b/ pkg/x.ts (control)hook-exit=2 blockedhook-exit=2 blocked
real heredoc body is still prosehook-exit=0 ALLOWEDhook-exit=0 ALLOWED
introducing line's own redirect is still a writehook-exit=2 blockedhook-exit=2 blocked

Shape chosen: the card's candidate 1 (narrow, keeps the existing pass order), not candidate 2 (one merged scanner). Only the delimiter scan consults the comment rule; the line itself is passed through untouched, so the landed #10570 comment rule in the two quote-aware passes remains the single implementation of it — a second copy here is exactly how the two would drift.

The card's ordering hazard is the reason candidate 2 was not taken, and it is structurally out of reach in candidate 1: an unbalanced apostrophe inside a heredoc body cannot desynchronise the comment scanner, because body lines are consumed by the pending branch and continue before the scan is ever reached. Pinned as its own self-test case in both directions.

#11234 — header enumeration count

Three layers:Four layers:. PR #10406 correctly moved the count TwoThree when it added layer 3; PR #11129 added layer 4 and left the count alone. Comment-only, no logic, no self-test change. The header now reads Four layers: above exactly four enumerated items (verified by counting both).

Layer 3's prose is also extended to state the outside-quotes half of the backslash rule that #11131 fixes. This deliberately does not add a fifth enumerated item, so the count #11234 corrects stays right.

Verification

Self-test matrix, .claude/hooks/guard-main-checkout-bash.selftest.sh, at 1a619db2:

before (BASE a2ec377): 100 passed, 0 failed
after (#11131 cases): 110 passed, 0 failed
after (#11133 cases): 121 passed, 0 failed
final union at 1a619db2: 121 passed, 0 failed

Both directions are pinned for every behaviour change, in the file's own matrix conventions.

Reverse-verification — each fix reverted from its own commit, mutation confirmed absent on disk by grep count before reading any result, restored under an EXIT INT TERM trap:

One case is labelled in the matrix as not discriminating and kept as a plain regression pin: echo \" && echo x > pkg/x.ts blocked even while the passes disagreed, because a > target is collected wherever it appears. What the disagreement actually lost was the command-name writers, once the collapsed segment's head word became echo. The comment says so, so no future reader mistakes it for a pin on this fix.

Precision sweep (the hook's stated philosophy — no new false BLOCKS): the full pre-existing allowed-forms matrix is green, including the documented fail-open shapes, the non-ASCII-operator section, the #10247 section, the #10570 comment section, the escape hatch, the jq-less fallback and the no-cwd cases. New allow-side twins were added for both fixes: the escape must not manufacture a target, the same command aimed at a linked worktree stays allowed, and a quoted# on an introducing line must not truncate the scan (truncating there would expose the heredoc body as commands — the load-bearing precision case).

Gates — derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack on the actual change set (2 paths, three-dot vs merge base a2ec37707), not from a hand-written list:

gateverdict line
pnpm check:doc-authoring✓ doc authoring guard: 389 files clean — no bare metadata literals.
pnpm --filter @objectstack/lint run check:doc-formula-expressions✓ check:doc-formula-expressions: 22 record-scoped formula example(s) across 416 files / 1447 TS blocks judged clean
pnpm check:pm-governed-merges✓ check-governed-merges --self-test: 119 assertions
pnpm check:skill-frame-sync✓ check-skill-frame-sync: 4 copies of the decision frame are structurally isomorphic across 3 files
node scripts/check-nul-bytes.mjscheck-nul-bytes: OK (scanned 6332 text file(s) … no raw ASCII control bytes).

check:doc-formula-expressions first came back red with ERR_MODULE_NOT_FOUND on @objectstack/formula/dist/index.mjs — the stale-dist trap in a fresh worktree, unrelated to this diff (which is two .sh files). Green after pnpm --workspace-concurrency=2 --filter '@objectstack/lint^...' build.

Control: .claude/hooks/guard-shared-stash.selftest.sh shares the Bash matcher and payload plumbing and is untouched by this PR (empty diff vs a2ec377 and vs HEAD). Read 32 passed, 0 failed both before and after.

Scope

File surface is exactly the hook plus its self-test — verified against the recorded base a2ec377:

.claude/hooks/guard-main-checkout-bash.selftest.sh | 50 +++++++++
.claude/hooks/guard-main-checkout-bash.sh | 64 ++++++++++++-

Nothing else was touched. In particular, and per the dispatch exclusions: the #10570 / PR #11129 comment-rule behaviour in tokenize() / split_segments() is left exactly as it landed, guard-shared-stash.sh is a different hook and untouched, and the objectui copy is not touched here — that port follows after this merges, in the declared objectstack → objectui direction.

skip-changeset: .claude/**-only, publishes nothing.


Generated by Claude Code

…eady had (#11131)
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RMTpSRF5CjMmQBFfPtPCwJ
…thing (#11133)
strip_heredocs() ran on raw text and modelled neither quotes nor comments, so a
`<<EOF` occurring inside prose registered as a real introducer. The delimiter then
never appeared on a line of its own, the pending heredoc was never satisfied, and
every remaining line — including real commands — was dropped before either
quote-aware pass could see it. Fail-OPEN, and total for the rest of the command.
Measured probe, cwd inside the shared primary checkout:
"# use cat > /tmp/n <<EOF for notes" + sed -i s/a/b/ pkg/x.ts
before: hook-exit=0 ALLOWED after: hook-exit=2
"# plain prose comment" + same write (control)
before: hook-exit=2 after: hook-exit=2
Shape: the card's candidate 1 (narrow, keeps the existing pass order), not candidate 2
(one merged scanner). Only the DELIMITER SCAN consults the comment rule; the line
itself is passed through untouched, so the landed #10570 comment rule in the two
quote-aware passes stays the single implementation of it. The card's ordering hazard —
an unbalanced apostrophe in a heredoc BODY desynchronising a quote-aware comment
scanner — is structurally out of reach here: body lines are consumed by the `pending`
branch and never reach the scan. Pinned as its own case.
Selftest: 110 -> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RMTpSRF5CjMmQBFfPtPCwJ
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RMTpSRF5CjMmQBFfPtPCwJ
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 23, 2026
@os-zhuang
os-zhuang self-requested a review August 23, 2026 08:36
@hotlong
hotlong marked this pull request as ready for review August 23, 2026 08:44
@hotlong
hotlong added this pull request to the merge queueAug 23, 2026
Merged via the queue into main with commit 764dbbcAug 23, 2026
27 checks passed
@hotlong
hotlong deleted the claude/issue-11131-guard-bash-pass-agreement branch August 23, 2026 08:55
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment