Repository navigation
guard-shared-stash.sh's split_segments() misreads an escaped quote INSIDE a double-quoted word — the #10406 half, a false BLOCK on read-only commands #11804
Description
Activity
Triage (devx lane PM seat, session
e2eac1a7-8000-5c95-9749-38aec2ace6fc).pm:queue→pm:blocked,domain:devx. Blocked on sequencing only.Premise spot-checked
I ran the guard directly on
origin/mainwhile accepting #11738. The single-level control the card names as passing does pass:exit=0 grep -rn "cd x && git stash pop" .claude/ ← correctly ALLOWEDSo the false block is specifically the nested case (an escaped quote inside a double-quoted word), consistent with the card. I did not reproduce the nested form myself; the card's author did, and their reading of the single-level control matches mine.
Why this direction matters as much as #11738's
#11738 fixed a false allow — the guard letting a banned command through. This is a false block — the guard stopping a pure read. Those fail in opposite directions and the second is easy to under-rate, but it contradicts the hook header's own promise that writing about the ban is never caught by it. That promise is load-bearing: the header is where an agent learns the rule, and a guard that punishes quoting the rule teaches agents to work around the guard rather than follow it. A guard people route around is worse than one that is merely absent.
The blocker
⛔ PR #11803 (#11738) is open and edits
guard-shared-stash.sh— the same file, the same function (split_segments()). It is a governed-surface PR awaitingos-zhuang, so it will not land quickly. Two open PRs rewriting one shell parser is exactly where a hand-resolved conflict silently drops a branch.Blocked-by:PR #11803 (governed, draft, awaitingos-zhuang).
Restart-when:#11803 lands onorigin/main.
Unlock-action:verify by content thatsplit_segments()carries the backslash branch, then dispatch.⚠️ Carried forward, and this is the reason to keep the card visible rather than let it drift: until this lands, the two guards'split_segments()stay divergent on the in-quote half —guard-main-checkout-bash.shhas the #10406 repair andguard-shared-stash.shdoes not. #11738's dev named that cost explicitly rather than widening their PR to close it, which was the right call under my no-widening instruction on a governed surface. The debt is real and now has a row.⚠️ For whoever takes it: the fix is #10406's repair, not a new one. Two guards diverging on the same parser is how this family got here, and #11738 already established that the shapes transfer. Non-vacuity is the usual bar — the nested read must be blocked before and allowed after, with the single-level control passing throughout so the change is shown not to have simply loosened the guard.
Generated by Claude Code
Unlock scan (R+89, triage seat, session
session_019kDRpB7D2XzVzkaLp57T5D): the recorded restart condition — PR #11803 lands onorigin/main— is met (merged 2026-08-25T11:12Z), and the recordedUnlock-action("verify by content thatsplit_segments()carries the backslash branch") is verified:.claude/hooks/guard-shared-stash.sh:115carries the'\')case onorigin/main(a39b02a6).Released →
pm:queue·priority:p3·domain:devx· type Bug (a false BLOCK on read-only commands: an escaped quote inside a double-quoted word — the #10406 half, which PR #11803 explicitly left for this card). Governed surface (.claude/hooks/**) ⇒ draft PR, human merge; the deliverable is the paired self-test cases (block case + precision twin) run against the pre-fix hook first, as #11803 did.Size/model suggestion: S,
opus.
Generated by Claude Code
Claim: PM loop round R2 (wave 1)
Session:session_01LAwHpn4uVuf4N1geBcD5i3(domain:devxexecution seat, seat post #6023)
Branch:claude/issue-11804-stash-guard-inquote-escape
Worktree:objectstack-issue-11804
Domain:domain:devx
File surface:.claude/hooks/guard-shared-stash.sh(the in-quote path ofsplit_segments()only) +.claude/hooks/guard-shared-stash.selftest.sh(paired cases: the nested-escape block case and its precision twin, run against the pre-fix hook first) (stop on breach; explain in the report)
Container & model: S,mode:subagent,model: opus— tier readout fromnode scripts/pm/dispatch-gates.mjs --tieron2d40f9146: "no path-derived mandate — floor sonnet · default opus · ceiling fable"; opus because the judgment is a shell tokenizer's quoting rule, not a gate verdict (triage'sS, opussuggestion adopted)
Clause-②: no
Serial constraints cleared: PR #11803 (#11738, the same function's other half) MERGED 2026-08-25T11:12Z; none of the 44 open PRs read 04:3xZ has a head touching.claude/hooks/**(list read via REST, branches diffed for the pm/skills-lane ones); same-day churn:8ce628a(2026-09-02T18:55Z, PR #14499) touched both hook files — dev works from currentorigin/main, not the card's line numbers. Governed surface (.claude/**) ⇒ draft PR, human merge, never armed by this seat.
Generated by Claude Code
Claim: dev seat (os-dev subagent) on #11804
Session:session_01LAwHpn4uVuf4N1geBcD5i3
Branch:claude/issue-11804-stash-guard-inquote-escape(pushed empty at2d40f9146as the write probe)
Worktree:objectstack-issue-11804
File surface:.claude/hooks/guard-shared-stash.sh(the in-quote path ofsplit_segments()only) +.claude/hooks/guard-shared-stash.selftest.sh(paired cases: nested-escape allow case + precision twin, run against the pre-fix hook first).guard-main-checkout-bash.sh/guard-main-checkout.share read-only precedent here and are not touched.
Governed surface (.claude/**) ⇒ draft PR, human merge; never flipped ready by this seat.
Generated by Claude Code
os-dev-report
{ "issue": 11804, "status": "done", "branch": "claude/issue-11804-stash-guard-inquote-escape", "pr": "https://github.com/objectstack-ai/objectstack/pull/14839", "premise_still_valid": true, "summary": "Premise reproduced on the current tree (2d40f9146), not the card's stale line numbers: the control allowed, both nested-escape reads falsely BLOCKED. Ported the in-quote backslash branch from guard-main-checkout-bash.sh's split_segments() into guard-shared-stash.sh (7 lines, same shape and same escapee list, gated on q being a double quote because inside single quotes nothing is special), verified against bash semantics before copying; the two guards had NOT diverged in shape since PR #11803, so the PM's mechanism assumption held as stated. Self-test grows 44 to 51 cases. Two refutations of the dispatch's framing worth the PM's attention: (1) the same missing rule ALSO fails OPEN, not only closed - once the escapes pair up the quoted region is left hanging and a real 'git stash pop' behind a properly closed quote rode through as an argument; two of the four pre-fix red cases are that direction, and the card scoped itself to the false block only. (2) The defect reproduced on me live: the pre-fix hook in the shared checkout blocked the Bash call that was authoring the repro harness, because the harness quoted the example. Draft PR, never flipped ready, no auto-merge; skip-changeset applied additively and confirmed by comparison read-back. guard-main-checkout-bash.sh and guard-main-checkout.sh untouched.", "tests": "Channel: REST only, zero MCP GitHub calls. THREE CARD ROWS, exit captured before any pipe, payload {cwd,tool_name:Bash,tool_input:{command}}. Pre-fix at 2d40f9146: control 'grep -rn \"cd x && git stash pop\" .claude/' exit=0 allow; nested grep exit=2 BLOCK; nested echo exit=2 BLOCK. Post-fix: exit=0 allow / exit=0 allow / exit=0 allow, control unchanged at exit=0. SELF-TEST NON-VACUITY (new cases written and run against the pre-fix hook FIRST, per the triage ruling): pre-fix exit=1, '47 passed, 4 failed' - two want=allow got=block (the card's rows) and two want=block got=allow (the fail-open direction); three precision twins green throughout. Post-fix exit=0, '51 passed, 0 failed'. ABLATION from the committed state (573be135), deleting only the new branch: mutation confirmed ON DISK before measuring - gate-line count 1 to 0 and blob hash 086c457a to ec18b884; the mutation script aborts non-zero on shape mismatch and DID abort twice on a bad anchor, those runs discarded not silently retried. Mutated hook: exit=1, '47 passed, 4 failed', the same four. Restore proven by observation not exit code: 'git diff HEAD' empty AND blob hash back to 086c457a; restored hook exit=0, '51 passed, 0 failed'. NO REBUILD LEG APPLIES and none is claimed - the self-test execs the hook file by path, there is no compiled artifact between edit and measurement. GATE UNION RE-RUN AFTER THE FINAL COMMIT at git rev-parse --short HEAD = 573be135, all six families derived by 'node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack' from the real change set (2 paths, working tree; no additions beyond the dispatched list): check:agent-test-spelling exit=0, check:bash32-floor exit=0 ('26 tracked shell file(s) name no bash 4+ construct'), check:doc-authoring exit=0 ('393 files clean'), check:pm-governed-merges exit=0 ('243 assertions'), check:skill-frame-sync exit=0 ('12 cases pass'), check:doc-formula-expressions exit=0 ('58 cases passed'), plus check:nul-bytes exit=0 ('scanned 8070 text file(s), no raw ASCII control bytes') and a control-byte self-scan of both edited files (grep exit 1, clean). CI's always-runs hook step covered: all five .claude/hooks/*.selftest.sh green - 49/121/87/51/36 passed, 0 failed. NOT MEASURED then repaired: check:doc-formula-expressions first answered exit=3 PREREQUISITE NOT MET twice (@objectstack/formula, then @objectstack/lint unbuilt) - the gate's own 'nothing was measured' code, not a finding; both built via scripts/pm/os-verify-lock.sh (VERDICT command-exit 0, held 208s waited 170s; then held 18s waited 0s) and the gate then ran green. CI convergence not awaited, per the standing clause.", "mcp_calls": "0 - no MCP GitHub tool was called. Card body, both prior comments, claim comment, PR creation, label write and every read-back went through repo-scoped REST as the dispatch directed. One non-GitHub MCP call (Claude Code Remote add_repo) was used to get read-only access to objectui for the probe the card asked for.", "open_questions": [], "out_of_scope_findings": [ "NOT FILED - handed to the PM to file in objectstack-ai/objectui, with the reason: this session has read-only access to objectui (REST to that repo answers 403 and the GitHub API tools do not cover unattached repos), and the dedup search the filing discipline requires is 403 through this proxy, so filing it myself would have meant filing without dedup. Finding, probed rather than trusted per the card's explicit instruction: on objectui at 6411def, .claude/hooks/guard-shared-stash.sh's split_segments() carries the out-of-quotes branch and NOT the in-quote one, and the same three commands measure allow / block / block there - identical to this repo before this PR. That repo's own guard-main-checkout-bash.sh already has the in-quote branch, so objectui is internally divergent in exactly the way this PR repaired here. The parity declaration in both repos' headers is therefore currently false on this branch, which is the drift the declaration exists to prevent." ] }
Generated by Claude Code
ACCEPT — governed path ·
domain:devxexecution seat · PM sessionsession_01LAwHpn4uVuf4N1geBcD5i3· seat post #6023 · R2 wave 1.Reviewed against GitHub, not the report: PR #14839 (draft, base
main, head573be1353,Fixes #11804first line,skip-changeset+size/s).- File surface holds: exactly
.claude/hooks/guard-shared-stash.sh(+29/−5) and.claude/hooks/guard-shared-stash.selftest.sh(+24).guard-main-checkout-bash.sh/guard-main-checkout.shuntouched. - The fix is the precedent's branch, verbatim in shape: inside
"…"a backslash before"\$`keeps both characters and does not close the region; gated onq = '"'so single quotes stay literal (the selftest pins that gate withecho 'a \' ; git stash pop→ block). The header comment now states both directions and the\-arm rationale. - Deliverable as ruled (unlock 2026-09-02): paired cases run against the pre-fix hook first — 44 → 51 cases; pre-fix
47 passed, 4 failed(the card's two allow rows + two fail-OPEN twins the dev found:echo "he said "x"" && git stash poprode through before), post-fix 51/51; ablation deleting only the new branch reproduces the same four reds with the mutation proven on disk (blob086c457a→ec18b884→ restored). - Card rows reproduced on
2d40f9146before editing: control allow / nested grep BLOCK / nested echo BLOCK → all allow after, control unchanged. - Gates: the six families derived for the surface all exit 0 (
agent-test-spelling,bash32-floor,doc-authoring,pm-governed-merges243 assertions,skill-frame-sync,doc-formula-expressionsafter building its prerequisites),check:nul-bytesclean, all five.claude/hooks/*.selftest.shgreen. CI on573be1353: 11 green / 10 skipped / 3 in progress, zero red at review time. - Refutation accepted and recorded: the card scoped itself to the false BLOCK; the same missing rule also fails OPEN once escapes pair up — the PR pins both directions. Good run.
Landing = human gate.
.claude/hooks/**is a governed surface: the PR stays draft, never armed by this seat; review requested fromos-zhuangandhotlong. Human merge is the audit record.pm:dispatchedstays until MERGED; #11804 closes viaFixes.Out-of-scope finding (objectui
guard-shared-stash.shlacks the same in-quote branch, measured at6411def) is the PM's to file at the destination repo — being handled in this round.
Generated by Claude Code
- File surface holds: exactly
Out-of-scope finding from the os-dev report (objectui parity gap) filed at the destination repo by the PM seat: objectstack-ai/objectui#7441 (unassigned,
Blocked-by: objectstack-ai/objectstack#11804, port of PR #14839 after it merges). Dedup: 600-card REST sweep of objectui, no prior card.
Generated by Claude Code
ACCEPT — landed ·
domain:devxexecution seat · PM sessionsession_01LAwHpn4uVuf4N1geBcD5i3· seat post #6023 · R2 close-out.PR #14839 MERGED 08:42:03Z as
2b9f5810b(queue, released byos-zhuang's approval pinned to head573be1353+ auto-merge 04:54Z — the authorized-approval path; human approval is the audit record for the governed.claude/hooks/**surface). Probe on431979e67:guard-shared-stash.shcarries the in-quote branch ('"' | '\\' | '$' | ''case arm ×1) and its header readsSelf-test (51 cases; the selftest carries the four newhe saidcases (control: theOS_ALLOW_STASH=1escape-hatch case). The objectui parity card objectstack-ai/objectui#7441 is now unblocked (Blocked-by` this card). Zero rework.Close-out:
pm:dispatchedstripped in this same action; the card closes viaFixes.
Generated by Claude Code
Found while implementing #11738 (PR #11803) and deliberately not folded into it: that card
scopes to the #11131 half of the backslash rule (an escape outside quotes), and this is
the #10406 half (an escape inside a double-quoted word). Same function, opposite failure
direction, different upstream fix — so it gets its own card rather than widening a governed-surface PR.
The observation
.claude/hooks/guard-shared-stash.sh'ssplit_segments()has no backslash branch on itsin-quote path:
Inside
"…"a\"is a literal quote and the quoted region stays open. Reading it as closingthe region flips the pass to "outside quotes" while the real shell is still inside, so separators
behind it split where bash would not — and the tail of a pure-read command gets promoted to a
segment of its own.
This is a false BLOCK, not a fail-open. It is the mirror of #11738 and the exact shape #10406
fixed in
guard-main-checkout-bash.sh, whosesplit_segments()carries the branch today:Measured
On
de1cb8def, fed as the PreToolUse payload shape{cwd, tool_name:"Bash", tool_input:{command}}.Both commands are pure reads that write nothing and touch no stash:
grep -rn "cd x && git stash pop" .claude/grep -rn "he said \"cd x && git stash pop\" once" .claude/echo "he said \"x && git stash pop\" once"The control is load-bearing: the already-pinned single-level case passes, so the guard is reached
and the difference is the nested escape alone.
Why it matters
The hook's own header promises the opposite of what it does here:
A guard that blocks
grepfor quoting a rule is the failure mode #4890 named, and the one thehook's header says gets guards disabled — after which it guards nothing. Severity is a triage call,
not mine: a false block is loud and recoverable (
OS_ALLOW_STASH=1), unlike #11738's silent hole.Suggested fix
Port the #10406 in-quote branch from
guard-main-checkout-bash.sh'ssplit_segments(), and add thetwo rows above to
.claude/hooks/guard-shared-stash.selftest.sh— that file's own instruction is tore-run the self-test on any change to the hook. After #11803 lands, this is the remaining half of
the backslash rule, so the two guards'
split_segments()stay divergent until it is done..claude/**is a governed surface — draft PR, human merge.probe it rather than trusting the parity declaration (that is exactly what #11738 asked for, and
the parity did hold there).
Filed unassigned by the dev seat on #11738;
domain:*, grading and queueing are the triage seat's.Refs: #11738 · PR #11803 · #11131 · #10406 · PR #11278 · objectui#6042.
Generated by Claude Code
Generated by Claude Code