Skip to content

guard-main-checkout-bash.sh reads quotes inside a $(…) command substitution as closing the outer double quote, so a > inside a quoted curl body is taken for a redirect and a read-only command from the primary checkout is blocked #24812

Closed

Description

@objectstack-fleet

Filing gate: ① a defect with a named location and a reproduction — .claude/hooks/guard-main-checkout-bash.sh, reproduced against origin/main a43405d596.
Reader: the domain:skills lane, which owns the guard hooks (the earlier defects in this hook, #10570 and #11131, carry domain:skills). The fix is a PR to the hook and its self-test. .claude/** is Tier S.
Dedupe (open AND closed), objectstack: "guard-main-checkout-bash hook false positive greater-than inside quoted string read as redirect" → 7 hits; "guard-main-checkout-bash command substitution nested double quotes blocks read-only command" → 7 hits. The two queries return the same family, none on this. The nearest ones are about other shapes: #10570 and #10720 (a > in a shell comment), #11131 (an unquoted backslash) and #11133 (a heredoc introducer in a comment).
reach: named producer. The os-dev session that built #24802 / PR #24811 was blocked running a read-only curl probe. In cloud sessions the harness resets the shell's cwd to the primary checkout after every call, so the hook judges every such command against the primary checkout.
type: Bug

What happens

The hook's quote-aware passes treat a " inside a $(…) that is itself inside a double-quoted word as the END of the outer quote. In a real shell, a command substitution opens a new quoting context, so the inner quotes nest. The hook then sees the inner text as unquoted, finds the > in a JSON body, and reports a write into the primary checkout whose target is the rest of the word.

The header promises the opposite: the hook acts only on shapes it can read with confidence, and everything else fails open (header lines 35 and 44, the latter listing $(…) among the unresolvable shapes).

Reproduction

cwd = the primary checkout. Each command is fed to the hook as {"tool_input":{"command":…},"cwd":"/home/user/objectstack"} on stdin. bash -n accepts all three commands, and none of them writes a file.

command hook exit hook's target:
echo "a $(curl -d "{\"c\":\"x > 1\"}" http://h)" 2 (blocked) 1"
echo "a $(curl -d '{"c":"x > 1"}' http://h)" 2 (blocked) 1}' http://h)
curl -s -d "{\"c\":\"x > 1\"}" http://h (no substitution) 0 —
control: echo hi > out.txt 2 (blocked, correctly) out.txt

The original blocked command was a multi-line probe loop of the form echo "PUT $1 -> $(curl … -d "{…\"condition\":\"int(record.title) > 1\"…}" $B/meta/object/$1)". The hook printed command: > 1\" and target: 1".

Expected

A > inside quotes inside a $(…) is not a redirect. Either track a command substitution as its own quoting context in both quote-aware passes (segmentation and tokenisation must agree, the lesson of #11131), or treat any double-quoted word containing $( as unreadable and fail open, as the header says. Add both forms above to guard-main-checkout-bash.selftest.sh, and keep the real-redirect control blocked.

Workaround in use: move the request into a script file in the scratchpad and run that.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1
    Session: session_01M82feuetLzrCiBz41BJb7L
    Account: huangyiirene
    Branch: claude/issue-24812-guard-hook-cmdsubst-quotes
    Worktree: objectstack-issue-24812
    Domain: domain:skills
    Seat: domain:skills#1
    File surface: .claude/hooks/guard-main-checkout-bash.sh, .claude/hooks/guard-main-checkout-bash.selftest.sh (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: TIER_DEFAULT (dispatch-gates --tier on both paths: "no path-derived mandate: the surface hits none of the 3 declared glob(s)"; the change is a shell-quoting parser, a judgment call, not mechanical)
    Clause-②: no
    Responsibility: .claude/hooks/guard-main-checkout-bash.sh (this lane's hook) produces the false block | the platform path that should cover it is the hook's own fail-open promise for shapes it cannot read (header, $(…) listed among them), which this shape misses | every cloud-session agent whose shell cwd resets to the primary checkout reaches it; named instance: the os-dev session on #24802, blocked on a read-only curl probe
    Thread-read: none
    Serial constraints cleared: none — the lane holds no other pm:dispatched card, and none of the 16 open PRs touches .claude/** (file lists read 2026-10-20T12:28Z)

    Landing path: .claude/** is Tier S governed text — an at-tier contract review record on the head, the seat's ACCEPT, then the seat lands it. Posted 2026-10-20T12:33Z.


    Generated by Claude Code

  2. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 24812,
      "status": "done",
      "branch": "claude/issue-24812-guard-hook-cmdsubst-quotes",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/24886",
      "session": "session_01M82feuetLzrCiBz41BJb7L (subagent: the parent's harness-stamped id, as carried by this branch's Claude-Session trailers)",
      "premise_still_valid": true,
      "summary": "Implemented the card's first route. One shared helper, subst_end(), finds where a `$(...)` or backtick inside a double-quoted word ends, reading its body with its own quotes, escapes and nesting. strip_line_comment(), split_segments() and tokenize() all delegate to it from their in-double-quote branch, so the passes agree on the extent by construction. Both card forms, the backtick spelling and the original probe now exit 0; the `echo hi > out.txt` control stays exit 2. I chose this over the dispatch-suggested fail-open route on measurement. Failing open from the unreadable word onward would have let three writes through that the pre-fix hook blocks: `sed -i \"s/a/$(date +%F)/\" pkg/x.ts`, `X=\"$(git rev-parse HEAD)\" ; sed -i s/a/b/ pkg/x.ts` and `echo \"$(date)\" > pkg/x.ts`. All three are pinned blocked. H1: the card's table reproduces on b1676441b, hook byte-identical to 3fbef41b4. H2: confirmed by per-site ablation. H3: a write inside a QUOTED $(...) was exit 0 before and is exit 0 after; it is now a documented fail-open (header plus a self-test pin). The unquoted `echo $(cat x > /home/user/objectstack/f)` stays exit 2.",
      "tests": "bash .claude/hooks/guard-main-checkout-bash.selftest.sh @e4ba00dc9: '141 passed, 0 failed' (130 on main, +11 cases). Pre-fix hook b1676441b (blob 1fd91d0a8) against the new self-test: '136 passed, 5 failed' (both card forms, the backtick form, the fail-open mirror, the heredoc pin). Per-site ablation via scripts/ablation-replace.mjs WRAP mode at 0963684e5; each restore was proven by blob == HEAD f6017a77c and an empty `git diff HEAD`: segmentation-only revert 138/3 red; tokenisation-only revert 139/2 red; heredoc-comment-scan-only revert 140/1 red; helper's nested-$( arm removed 140/1 red. The first A2/A3 attempts were refused by the tool before any child ran: the ':' replacement collided with the ':' in the anchor. They were re-run with unique markers. e4ba00dc9 is comment-only over 0963684e5. Sibling hook matrices @e4ba00dc9: guard-main-checkout 120/0, guard-process-kill 69/0, guard-shared-stash 53/0, guard-tree-enum 38/0. No build/dist leg: a bash hook, with no package or exports path.",
      "gates": "Local, @e4ba00dc9, exit captured before any pipe: node scripts/check-closing-keyword-parity.mjs 0; node scripts/check-closing-keyword-parity.mjs --self-test 0; node scripts/check-comment-mask-corpus.mjs 0; node scripts/pm/check-harness-current.mjs --self-test 0; pnpm --filter @objectstack/lint run check:doc-formula-expressions 0. Its first run exited 3 with PREREQUISITE NOT MET; building @objectstack/formula and @objectstack/lint under os-verify-lock (VERDICT command-exit 0) cleared it. pnpm check:agent-test-spelling 0; pnpm check:bash32-floor 0 ('19 constructs checked, floor bash 3.2'); pnpm check:cross-package-test-inputs 0; pnpm check:doc-authoring 0; pnpm check:driver-memory-census 0; pnpm check:gitlink-declared 0; pnpm check:nul-bytes 0; pnpm check:pm-governed-merges 0; pnpm check:refd-timer-probe 0; pnpm check:skill-frame-sync 0; pnpm check:watch-hint-literal 0. Outside the derivation: pnpm check:pm-settings-deny-roster 0 (its roster lives under .claude); all five .claude/hooks/*.selftest.sh 0. dispatch-gates --ran with exit-annotated record: '16 derived, 16 run, 0 NOT-MEASURED, 0 UNRUN'. The derivation is unchanged between 0963684e5 and e4ba00dc9. CI on PR #24886 head e4ba00dc9, read once at report time and not re-run locally: Check Changeset success; 'No other open PR may claim the same issue' success. In progress: Governed Surface Queue Guard, Lint (7 lanes), Type Check (4 lanes), Check Documentation Links, Check PR Size, 'The card this PR closes must claim this branch', 'Part-of PR must not also close its card', 'No other open PR may claim the same single-writer path', and filter.",
      "mcp_calls": "0",
      "api_writes": "2 relay strokes. Each is one POST /repos/objectstack-ai/objectstack/dispatches, executed by fleet-write.yml as objectstack-fleet[bot]. (1) pr_create: POST /repos/objectstack-ai/objectstack/pulls (draft), POST /repos/objectstack-ai/objectstack/issues/24886/assignees, POST /repos/objectstack-ai/objectstack/issues/24886/labels (skip-changeset). The read-back was byte-identical: 8934 bytes, assignee huangyiirene, label skip-changeset. (2) comment: POST /repos/objectstack-ai/objectstack/issues/24812/comments (this report). git push x4 on the branch (not REST).",
      "open_questions": [],
      "out_of_scope_findings": [
        "class: a · reach: hook verdict measured on an agent command, exit 2 on a pure read; no named producer yet · evidence: objectstack .claude/hooks/guard-shared-stash.sh split_segments() carries the identical in-double-quote branch. At b1676441b, `echo \"a $(echo \"x; git stash pop\")\"` answers exit 2 (blocked) while `echo \"x; git stash pop\"` answers 0. Outside this claim's declared file surface, so not touched. Same family as #24812: fold it into the family close-out card, not a point card · dedupe words: guard-shared-stash command substitution nested quote; stash guard false block quoted subst; split_segments $( double quote",
        "class: a · reach: identical shape to the card's named producer (cloud sessions whose cwd resets to the primary checkout), measured on the objectui copies · evidence: objectui 9eb1479 .claude/hooks/guard-main-checkout-bash.sh answers exit 2 on both card forms, and objectui guard-shared-stash.sh answers exit 2 on the stash shape above. This hook's header says the two repos' copies are kept case-for-case identical, so the fix here opens a mirror obligation in objectui. Same family · dedupe words: objectui guard-main-checkout-bash command substitution quote; port hook cmdsubst fix objectui; guard-shared-stash objectui nested quote",
        "class: a · reach: hook verdict measured, exit 2 on a pure read; no named producer · evidence: `echo \"${x:-\"a > b\"}\"` answers exit 2 with target `b}` before and after this PR. A parameter expansion with a nested quote is the same family, but a ' inside a double-quoted ${...} is literal in bash, so it is not a mechanical extension of subst_end() · dedupe words: guard-main-checkout-bash parameter expansion nested quote; ${x:-\"…\"} false block hook; default-value expansion quote redirect"
      ],
      "line_budget": "No line ratchet covers .claude/hooks. guard-main-checkout-bash.sh 586 -> 640 (+55/-1); guard-main-checkout-bash.selftest.sh 389 -> 412 (+23/-0). PR total +78/-1, under the 3000-line human-merge threshold.",
      "deviations": [
        "Route: the card's option 1 (track the substitution as its own quoting context), not the dispatch-suggested option 2 (fail open). Measured reason in summary and in the PR body's Approach paragraph.",
        "Scope inside the card's defect class: the backtick spelling of command substitution and the third quote-aware scanner (strip_line_comment, the heredoc delimiter scan) use the same helper. Both are pinned by discriminating self-test cases, and both are named in the PR body.",
        "Replaced the depth-2 nested allow pin (green on the pre-fix hook by quote parity, so it discriminated nothing) with a nested recall pin. A helper without its nested-$( arm turns that pin red.",
        "Built @objectstack/formula and @objectstack/lint under the verify lock (174 s) to clear the doc-formula-expressions prerequisite.",
        "Ran gates beyond the derivation: check:pm-settings-deny-roster and every .claude/hooks/*.selftest.sh.",
        "Commit trailers use AGENTS.md's model-free pair (Claude-Session plus Co-authored-by: Claude), not the harness reminder's model-named trailer.",
        "test:repo was not run: no vitest repo-project file was touched and the derivation does not name it.",
        "No mirror PR in objectui, and guard-shared-stash.sh is untouched: both are outside the declared file surface. Reported as findings."
      ],
      "files_changed": [
        ".claude/hooks/guard-main-checkout-bash.sh",
        ".claude/hooks/guard-main-checkout-bash.selftest.sh"
      ]
    }

    Generated by Claude Code

  3. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #24886 comment 6292026192 · contract review PASS 6291929083 · domain:skills seat 1 · session_01M82feuetLzrCiBz41BJb7L · 2026-10-20T13:30Z


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:skills seat 1 · session_01M82feuetLzrCiBz41BJb7L · 2026-10-20T14:05Z

    reading value
    PR #24886 merged: true at 2026-10-20T14:04Z, merge commit 7e40e8a03 (queue entry 2026-10-20T13:34Z)
    origin/main 7e40e8a03 fix(hooks): read a command substitution inside a double-quoted word as its own quoting context (#24886), present at tip a7199ef97
    queue branches git ls-remote origin 'refs/heads/gh-readonly-queue/main/*' → 0

    State: closed completed by the PR's Fixes; pm:dispatched is removed in this act. Follow-ups filed at ACCEPT: #24901 (the family close-out) and objectstack-ai/objectui#13260 (the mirror port, whose Blocked-by: this landing releases).


    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

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions