fix(hooks): an escaped quote inside a double-quoted word does not close it - #7695
Conversation
…split_segments
Adds the paired cases to guard-shared-stash.selftest.sh ahead of the fix, so the
matrix is on record failing for the reason the missing branch explains.
Inside "…" a backslash is special only before " \ $ ` , so an escaped \" is a
literal quote and the quoted region stays OPEN. split_segments() reads it as
closing, which breaks in both directions: a pure read whose quoted text carries a
nested escape is falsely BLOCKED, and once the escapes pair up the region is left
hanging and a real stash rides through as a mere argument.
Run against the unfixed hook (41 -> 48 cases, 44 passed / 4 failed):
== an escaped \" INSIDE a double-quoted word does NOT close it ==
FAIL want=allow got=block grep -rn "he said \"cd x && git stash pop\" once" .claude/
FAIL want=allow got=block echo "he said \"x && git stash pop\" once"
FAIL want=block got=allow echo "he said \"x\"" && git stash pop
FAIL want=block got=allow echo "he said \"x\"" ; git stash drop
ok block echo "quoted" && git stash pop
ok block echo "a \\" ; git stash pop
ok block echo 'a \' ; git stash pop
44 passed, 4 failed
The three ok rows are the precision twins. They hold on both sides of the fix and
pin the branch against degrading into "ignore whatever follows a backslash".
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox
…se it
guard-shared-stash.sh's split_segments() had no backslash branch on its in-quote
path. Inside "…" a backslash is special only before " \ $ ` , so an escaped \" is
a literal quote and the quoted region stays OPEN; the pass read it as CLOSING,
went outside quotes while bash was still inside, and separators behind it split
where bash would not. The tail of a pure READ became a segment of its own, judged
on its own head word — a false BLOCK on a command that touches no stash, which is
the one failure the hook's own header promises can never happen. The same gap also
fails OPEN in the other direction: once the escapes pair up the quoted region is
left hanging and a real stash command behind it rides through as a mere argument.
Ports the in-quote branch this repo's guard-main-checkout-bash.sh split_segments()
already carries, in the same shape and with the same escapee list (" \ $ `), gated
on q='"' because inside '…' nothing is special. That guard and guard-main-checkout.sh
are the precedent here and are untouched.
Self-test: 48 passed, 0 failed. Against the unfixed hook one commit ago the same
matrix read 44 passed, 4 failed — the four that flipped are the two nested-escape
reads (false BLOCK) and the two fail-open twins; the three precision twins blocked
on both sides. Header case count updated 41 -> 48.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox
|
ACCEPT — governed in-seat review of PR #7695, head Implemented-by: Verified in a detached compare worktree at the PR head against merge-base Landing regime: governed ( Generated by Claude Code |
Fixes #7441
Ports the in-quote backslash branch of
split_segments()into.claude/hooks/guard-shared-stash.sh, from objectstack2b9f5810b(PR objectstack-ai/objectstack#14839).The rule
Inside
"…"a backslash is special only before"\$`, so an escaped\"is aliteral quote and the quoted region stays open. This pass read it as closing, went
outside quotes while bash was still inside, and separators behind it split where bash would
not. Two directions, both now pinned:
own head word, so a command that touches no stash was blocked. That is the one failure the
hook's own header promises can never happen ("writing about the ban is never caught by the ban").
command behind it rode through as a mere argument.
Inside
'…'nothing is special, hence theq = '"'gate.Premise re-measured on this branch point (A1)
The card measured objectui at
6411def; re-measured here at51eb515, fed as the PreToolUsepayload
{cwd, tool_name:"Bash", tool_input:{command}}:grep -rn "cd x && git stash pop" .claude/(control)grep -rn "he said \"cd x && git stash pop\" once" .claude/echo "he said \"x && git stash pop\" once"The control holds on both sides, so the nested escape alone is the difference.
The ported hunk
split_segments(), in-quote path,.claude/hooks/guard-shared-stash.sh:Those six lines are byte-identical to the block this repo's own
guard-main-checkout-bash.shsplit_segments()already carries, and byte-identical toupstream
2b9f5810b— verified bydiffon the block extracted from each of the three files(indentation included). Before the port, this repo's
split_segments()was code-identical toobjectstack's pre-fix copy; the only delta between them was an in-function comment.
The rest of the diff is the header case count (
41to48, with its derivation line) and acomment stating the rule. Upstream's comment block is deliberately not ported verbatim.
Red first, then green
The seven cases went into the self-test first and were run against the unfixed hook:
After the fix, same matrix:
41 to 48 cases; the four that flipped are the two nested-escape reads and the two fail-open
twins. The three precision twins block on both sides — they pin the branch against
degrading into "ignore whatever follows a backslash". The header's declared count is
re-derived by its own recipe: 46
expectlines + 2 inline specials = 48, equal to the run'sown tail.
Non-vacuity of the precision twins
On a throwaway copy, dropping the
\arm of the escapee list (the copy's only change, oneline, confirmed on disk by anchored greps: 0 occurrences of the arm-bearing line, 1 of the
mutated line):
So that twin really does pin that arm. The real hook was never mutated — its blob hash equals
HEAD:.claude/hooks/guard-shared-stash.sh(c9b0b59bf1db0a7dd1e57007c1259819ac01d922).The two main-checkout guards are untouched
They are the in-repo precedent for this port, not its surface:
Their matrices still pass unchanged:
guard-main-checkout-bash.selftest.sh121 passed / 0 failed,guard-main-checkout.selftest.sh87 passed / 0 failed.Changeset
node scripts/check-changeset-presence.mjsdecides, and this repo has noskip-changesetlabel:No changeset added, on the gate's own verdict.
Gates run locally, at
c424f18(the head this PR pushes).claude/hooks/guard-shared-stash.selftest.sh.claude/hooks/guard-main-checkout-bash.selftest.sh.claude/hooks/guard-main-checkout.selftest.sh.claude/hooks/guard-tree-enum.selftest.shcheck-control-bytescheck-shell-escape-residuecheck-governed-queue-guard --self-testcheck-skills-pathscheck-skill-eval-tokenscheck-changeset-presencecheck-upstream-port-parityscripts/pm/check-half-states.mjsandscripts/invoked-as.mjsonly, neither touchedcheck-skill-examplesERR_MODULE_NOT_FOUND: typescript(no install in this worktree); its scan roots areskills/, which this diff does not touch. CI runs it with a full install.pnpm lintfiles:glob ineslint.config.jsis**/*.{ts,tsx}or narrower; no glob names.shor.claude, so eslint never selects these files. Not a skip.The self-test discovery was run the way
hook-selftests.ymlruns it(
find .claude/hooks -type f -name '*.selftest.sh'): 4 matrices discovered, 4 run, 4 green.Governance
.claude/**is governed surface: draft PR, human merge. Not marked ready, not enqueued,no auto-merge, not approved. Reviewer requests left to the dispatching seat — this workflow's
own header records that an MCP
update_pull_requestcall passing onlyreviewerssilentlyset
draft: falseonce, so this PR makes no post-creation update call.🤖 Generated with Claude Code
Generated by Claude Code
Generated by Claude Code