Skip to content

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

@claude

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's split_segments() has no backslash branch on its
in-quote path:

    if [ -n "$q" ]; then
      seg+="$ch"
      [ "$ch" = "$q" ] && q=""     # <- a \" here CLOSES the quote; in bash it does not
      continue
    fi

Inside "…" a \" is a literal quote and the quoted region stays open. Reading it as closing
the 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, whose split_segments() carries the branch today:

      if [ "$q" = '"' ] && [ "$ch" = '\' ] && [ $((i + 1)) -lt "$n" ]; then
        case "${s:i+1:1}" in
          '"' | '\' | '$' | '`') seg+="$ch" ; i=$((i + 1)) ; seg+="${s:i:1}" ; continue ;;
        esac
      fi

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:

command verdict wanted
grep -rn "cd x && git stash pop" .claude/ allow allow — control, pinned in the self-test today
grep -rn "he said \"cd x && git stash pop\" once" .claude/ BLOCK allow
echo "he said \"x && git stash pop\" once" BLOCK allow

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 separator inside '…' or "…" does NOT split, so writing about the ban is never caught by
the ban … (objectstack#4890's lesson — the PR writing a rule must not trip it.)

A guard that blocks grep for quoting a rule is the failure mode #4890 named, and the one the
hook'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's split_segments(), and add the
two rows above to .claude/hooks/guard-shared-stash.selftest.sh — that file's own instruction is to
re-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.
⚠️ Cross-repo: objectui's copy is declared case-for-case identical, so it likely carries this too —
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

Activity

  1. os-steve commented on Aug 24, 2026

    @os-steve
    Collaborator

    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/main while accepting #11738. The single-level control the card names as passing does pass:

    exit=0   grep -rn "cd x && git stash pop" .claude/     ← correctly ALLOWED
    

    So 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 awaiting os-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, awaiting os-zhuang).
    Restart-when: #11803 lands on origin/main.
    Unlock-action: verify by content that split_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.sh has the #10406 repair and guard-shared-stash.sh does 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

  2. huangyiirene commented on Sep 2, 2026

    @huangyiirene
    Collaborator

    Unlock scan (R+89, triage seat, session session_019kDRpB7D2XzVzkaLp57T5D): the recorded restart condition — PR #11803 lands on origin/main — is met (merged 2026-08-25T11:12Z), and the recorded Unlock-action ("verify by content that split_segments() carries the backslash branch") is verified: .claude/hooks/guard-shared-stash.sh:115 carries the '\') case on origin/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

  3. added theissue type on Sep 2, 2026
  4. claude commented on Sep 3, 2026

    @claude
    ContributorAuthor

    Claim: PM loop round R2 (wave 1)
    Session: session_01LAwHpn4uVuf4N1geBcD5i3 (domain:devx execution 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 of split_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 from node scripts/pm/dispatch-gates.mjs --tier on 2d40f9146: "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's S, opus suggestion 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 current origin/main, not the card's line numbers. Governed surface (.claude/**) ⇒ draft PR, human merge, never armed by this seat.


    Generated by Claude Code

  5. claude commented on Sep 3, 2026

    @claude
    ContributorAuthor

    Claim: dev seat (os-dev subagent) on #11804
    Session: session_01LAwHpn4uVuf4N1geBcD5i3
    Branch: claude/issue-11804-stash-guard-inquote-escape (pushed empty at 2d40f9146 as the write probe)
    Worktree: objectstack-issue-11804
    File surface: .claude/hooks/guard-shared-stash.sh (the in-quote path of split_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.sh are 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

  6. claude commented on Sep 3, 2026

    @claude
    ContributorAuthor

    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

  7. claude commented on Sep 3, 2026

    @claude
    ContributorAuthor

    ACCEPT — governed path · domain:devx execution seat · PM session session_01LAwHpn4uVuf4N1geBcD5i3 · seat post #6023 · R2 wave 1.

    Reviewed against GitHub, not the report: PR #14839 (draft, base main, head 573be1353, Fixes #11804 first 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.sh untouched.
    • The fix is the precedent's branch, verbatim in shape: inside "…" a backslash before " \ $ ` keeps both characters and does not close the region; gated on q = '"' so single quotes stay literal (the selftest pins that gate with echo '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 pop rode through before), post-fix 51/51; ablation deleting only the new branch reproduces the same four reds with the mutation proven on disk (blob 086c457a → ec18b884 → restored).
    • Card rows reproduced on 2d40f9146 before 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-merges 243 assertions, skill-frame-sync, doc-formula-expressions after building its prerequisites), check:nul-bytes clean, all five .claude/hooks/*.selftest.sh green. CI on 573be1353: 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 from os-zhuang and hotlong. Human merge is the audit record. pm:dispatched stays until MERGED; #11804 closes via Fixes.

    Out-of-scope finding (objectui guard-shared-stash.sh lacks the same in-quote branch, measured at 6411def) is the PM's to file at the destination repo — being handled in this round.


    Generated by Claude Code

  8. claude commented on Sep 3, 2026

    @claude
    ContributorAuthor

    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

  9. claude commented on Sep 3, 2026

    @claude
    ContributorAuthor

    ACCEPT — landed · domain:devx execution seat · PM session session_01LAwHpn4uVuf4N1geBcD5i3 · seat post #6023 · R2 close-out.

    PR #14839 MERGED 08:42:03Z as 2b9f5810b (queue, released by os-zhuang's approval pinned to head 573be1353 + auto-merge 04:54Z — the authorized-approval path; human approval is the audit record for the governed .claude/hooks/** surface). Probe on 431979e67: guard-shared-stash.sh carries the in-quote branch ('"' | '\\' | '$' | ''case arm ×1) and its header readsSelf-test (51 cases; the selftest carries the four new he saidcases (control: theOS_ALLOW_STASH=1 escape-hatch case). The objectui parity card objectstack-ai/objectui#7441 is now unblocked (Blocked-by` this card). Zero rework.

    Close-out: pm:dispatched stripped in this same action; the card closes via Fixes.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions