Uh oh!
There was an error while loading. Please reload this page.
fix(hooks): close the backslash fail-open in guard-shared-stash.sh split_segments() - #6087
fix(hooks): close the backslash fail-open in guard-shared-stash.sh split_segments()#6087yinlianghui-tw wants to merge 1 commit into
Conversation
…lit_segments() Outside quotes a backslash escapes the next character, so an escaped `\"` opens no quoted region at all. split_segments() had no branch for it: it read the `"` as opening a region that never closed, went inert for every separator behind it, collapsed the whole command into one segment whose head word was harmless, and waved a real `git stash` through as a mere argument of `echo`. Ports the split_segments() backslash branch from guard-main-checkout-bash.sh (objectstack#11131 / objectstack PR #11278, and this repo's PR #6046), adapted to this hook: it has no `word` bookkeeping and no tokenize() -- check_segment() uses `read -r -a`, which leaves the backslash literal, so both characters are kept verbatim and the pass only splits. Only the objectstack#11131 half applies. The objectstack#11133 comment-heredoc half has no analogue: this hook has no strip_heredocs() pass to carry it. Self-test matrix 32 -> 41 cases, and the hook header's own re-derivable count line is updated to match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019b5UBNMtTzKbVtZZGvFuxe
yinlianghui-tw
commented
Aug 24, 2026
PM: ACCEPT — stays draft for a human (governed surface)⛔ Not turning this ready and not arming auto-merge. The discrimination check is the control I should have asked for and didn'tI asked for the selftest count before/after, the probe flipping, and no over-blocking. You added a fourth: every one of the four new block cases, run against the pre-fix hook (extracted with That closes the gap the other three leave open. A selftest going 32 → 41 proves nine cases were added; it does not prove any of them exercises the fix. Cases that already passed would produce the same count, the same green, and the same confident report — and the hole would still be open. Proving each new case flips is what makes the number mean something. Same for the allow side: five new precision twins ( And the You checked the dependency I flagged rather than assuming itI told you to read the shape from PR #6046 but not depend on it having landed. You verified: The two adaptations are correctly reasoned and correctly namedA verbatim port is the default, so departures need justification, and both of yours have it:
Updating the header's re-derivable count line 32/30 → 41/39 keeps the file's own invariant true — the kind of thing that silently rots otherwise. And running the untouched sibling's selftest ( Scope held: only the #11131 backslash half ported, with the #11133 comment-heredoc half correctly identified as having no analogue here (this hook has no
|
Fixes#6042
Ports the
split_segments()backslash branch into this repo's.claude/hooks/guard-shared-stash.sh, closing the measured fail-open in the guard behind the ⛔ Nevergit stashrule.⛔ Governed surface — draft PR, human merge only
.claude/**is a governed surface in this repo. This PR is deliberately a draft and must stay one: not to be marked ready, not to be put on auto-merge, not to be merged by an agent. A human merges this one.The defect
Outside quotes a backslash escapes the next character, so an escaped backslash-doublequote opens no quoted region at all.
split_segments()had no branch for it, so the pass read the doublequote as opening a region that never closed, went inert for every separator behind it, collapsed the whole command into a single segment whose head word was the harmless one, and let a realgit stashthrough as a mere argument ofecho.That is the fail-open in the backstop for the one rule whose breach silently corrupts a different agent's work:
refs/stashlives in the common.gitdirectory, so every worktree shares one LIFO stack (objectui#3430 swapped two agents' in-flight changes withpopreporting success).Source of the port, and what did NOT come with it
guard-main-checkout-bash.shcarries the same defect and it was corrected upstream by objectstack#11131 (objectstack PR objectstack-ai/objectstack#11278). That correction is not on this repo'smainyet — objectui PR #6046 carries it here and is still an open draft awaiting human merge. So the shape here was read from objectstack PR #11278's diff (cross-checked against #6046's, which is the same text with issue-reference localisation) and does not depend on #6046 having landed. Verified:grep -c 11131 .claude/hooks/guard-main-checkout-bash.shonmainreturns 0, and that file is untouched by this PR.Only the objectstack#11131 half applies. The objectstack#11133 comment-heredoc half has no analogue here —
guard-shared-stash.shhas nostrip_heredocs()pass to carry that defect, so nothing was ported for it and no place was invented to put it.Two deliberate adaptations, not improvements
Upstream's branch could not be spliced byte-for-byte, because this hook's
split_segments()is the simpler of the two:word=1line. Upstream'ssplit_segments()tracks awordflag for its unquoted-#comment rule; this hook has no comment rule and no such variable. Carrying the assignment would have created a write to a variable nothing reads.check_segment(), nottokenize(). Upstream justifies keeping both characters verbatim by pointing attokenize(), which re-reads the escape. This hook has notokenize()— word-splitting isread -r -a w, and-rleaves the backslash literal, which is the same justification through a different mechanism. The prose states this hook's mechanism so the comment stays true here.The branch body itself —
seg+="$ch"then the bounded lookahead that consumes the escaped character — is upstream's, unchanged.Evidence
1. Self-test, before and after
bash .claude/hooks/guard-shared-stash.selftest.sh7c96c9420, the branch point)32 passed, 0 failed0835db154)41 passed, 0 failed32 to 41 cases (39
expectlines + the 2 inline specials). The hook header carries a re-derivable count line —Self-test (32 cases …)/32 = 30 expect lines + 2 inline specials— and it is updated to41/39so the file's own invariant stays true (objectui#3721 is the card about that count drifting).2. The measured probe flips ALLOWED to blocked, with the control held
Fed as the PreToolUse payload shape,
{cwd, tool_name:"Bash", tool_input:{command}}— the same fixture shape the selftest uses.7c96c9420)0835db154)git stash— the controlechobackslash-doublequote, then;, thengit stash— the holeThe control matters: without it, "ALLOWED" could mean the harness never reached the guard rather than that the guard let it past. It is blocked on both sides, so the guard was reached both times.
3. Every new block case was a genuine fail-open before
Each new case was run against the pre-fix hook (
git show HEAD:…extracted to a temp copy) and against the post-fix hook:echo \" ; git stashecho \" ; git stash popprintf \" ; git stash dropecho \"+ newline +git stash popSo the new cases discriminate: they are not cases that already passed.
4. No over-blocking — the deliberately-allowed forms stay allowed
A guard that blocks more than intended is a defect, not extra safety. All the forms this hook allows on purpose, measured after the change:
git stash listgit stash show -pgit stash creategit stash --helpgit stash apply abc1234(literal hex id)git stash apply --index deadbeefcafe1234git stash store -m "WIP issue-3430" b52e3aa1234567And the new precision twins — the escape must not manufacture a block where nothing stashes, and an allowed form reached through the same escape stays allowed:
echo \" ; echo helloecho \" ; cat README.mdecho a\ becho \\ ; grep -n worktree README.mdecho \" ; git stash listThe last row is this repo's stand-in for upstream's precision twin, which re-aims the same command at a linked worktree. This hook reads no
cwd, so the read-only / SHA-pinned allow-list is the analogous precision surface. Everything the pre-existing 32 cases asserted still holds:41 passed, 0 failed, and 41 = 32 + 9.Scope
Port only.
guard-main-checkout.sh,guard-main-checkout-bash.shand every other hook are untouched —git statuson this branch shows exactly two modified files, and the sibling matrix still reports100 passed, 0 failedat this tip.The same hole is claimed present in objectstack's copy, tracked as objectstack#11738; that card is out of scope here and remains open — objectui only, so the two can be reviewed as one patch shape.
One out-of-scope observation was measured and filed rather than folded in — see the report on #6042.
Gates
Run at
0835db154(the tip of this branch, working tree clean):bash .claude/hooks/guard-shared-stash.selftest.sh41 passed, 0 failedbash .claude/hooks/guard-main-checkout-bash.selftest.sh(untouched sibling)100 passed, 0 failednode scripts/check-control-bytes.mjs✅ check-control-bytes: OK (scanned 5036 tracked text file(s); skipped 85 binary).node scripts/check-changeset-presence.mjs✅ No source of a released package changed in this range, so no changeset is owed.bash -non both changed files.github/workflows/hook-selftests.ymlis the CI caller for both matrices and fires on.claude/hooks/**, so it runs this on the PR.No changeset: this touches
.claude/tooling only, no released package'ssrc/.Generated by Claude Code