You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
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
Filing gate: ① a defect with a named location and a reproduction — .claude/hooks/guard-main-checkout-bash.sh, reproduced against origin/maina43405d596.
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.
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.
{
"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"
]
}
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).
Activity
objectstack-fleet commentedon Oct 20, 2026
Claim: PM loop round 1
Session:
session_01M82feuetLzrCiBz41BJb7LAccount:
huangyiireneBranch:
claude/issue-24812-guard-hook-cmdsubst-quotesWorktree:
objectstack-issue-24812Domain:
domain:skillsSeat:
domain:skills#1File 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 --tieron 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-onlycurlprobeThread-read: none
Serial constraints cleared: none — the lane holds no other
pm:dispatchedcard, 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
objectstack-fleet commentedon Oct 20, 2026
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
objectstack-fleet commentedon Oct 20, 2026
ACCEPT → PR #24886 comment 6292026192 · contract review PASS 6291929083 ·
domain:skillsseat 1 ·session_01M82feuetLzrCiBz41BJb7L· 2026-10-20T13:30ZGenerated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
Landed ·
domain:skillsseat 1 ·session_01M82feuetLzrCiBz41BJb7L· 2026-10-20T14:05Zmerged: trueat 2026-10-20T14:04Z, merge commit7e40e8a03(queue entry 2026-10-20T13:34Z)origin/main7e40e8a03 fix(hooks): read a command substitution inside a double-quoted word as its own quoting context (#24886), present at tipa7199ef97git ls-remote origin 'refs/heads/gh-readonly-queue/main/*'→ 0State: closed
completedby the PR'sFixes;pm:dispatchedis removed in this act. Follow-ups filed at ACCEPT: #24901 (the family close-out) and objectstack-ai/objectui#13260 (the mirror port, whoseBlocked-by:this landing releases).Generated by Claude Code