Repository navigation
guard-shared-stash.sh's split_segments() lacks the in-quote backslash branch its sibling guard carries — false BLOCK on nested-escape reads, fail-open once the escapes pair up (port of objectstack#14839) #7441
Description
Activity
分诊 ·
pm:queue/domain:skills/priority:p2/bug/tooling⛔ 本席位只分诊:不认领、不派单、不写码、不合并、不裁决 decision-box 卡。
⭐ 前提已解锁 —— 卡片的
Blocked-by:今天不再成立卡片写着
Blocked-by: objectstack-ai/objectstack#11804。按本席的跨仓解锁纪律,⛔ 不把「另一张卡的状态」当前提读,改测树本身:objectstack-ai/objectstack#14839(修 console: React warnings in Studio — <div> inside <p> in the package sheet, Select switching uncontrolled→controlled in the create form #11804 的那条 PR)已合并:merged: true、merged_at 2026-09-03T08:42:03Z、merged_by os-zhuang、head573be135。⚠️ 单看state: closed会误判——closed≠merged,本席在 R+150 因此吃过一次亏,这里是逐字段读的。- 该 PR 的 diff 是
.claude/hooks/guard-shared-stash.sh的 7 行分支 + 自测用例,移植源今天就在 objectstackmain上可读。
⇒ 本卡的解锁判据(移植源存在且可读)已满足,因此打
pm:queue而不是pm:blocked。缺陷在 objectui 侧仍然存在(复核,不是照抄)
读
origin/main=a472b07的.claude/hooks/guard-shared-stash.sh,split_segments()在 第 87 行。它的引号内路径逐字是:if [ -n "$q" ]; then seg+="$ch" [ "$ch" = "$q" ] && q="" continue fi
—— 没有任何 backslash 分支:引号内遇到
\就当普通字符,下一个"直接把q清空。
对照(同一次读、同一个函数):引号外的'\')分支存在且带着objectstack#11131的注释,会成对吞掉转义符。⇒ 「引号内缺、引号外有」这个内部不对称,卡片说的形状今天仍然如此,不是已被别的 PR 顺手修掉的陈卡。⚠️ 我没有在本容器里执行那三行 payload 复跑(纪律④:门禁类断言「跑一遍才是证据」)。所以本条复核确立的是代码形状与卡片一致,⛔ 不是对allow / block / block三行退出码的背书——那三行由上游 PR 在 objectui6411def上实测过,接手者应在动手前于当前 ref 重跑一次。为什么锚定
domain:skills修复落在
.claude/hooks/guard-shared-stash.sh+ 其.selftest.sh。按 objectui 车道表.claude/**即domain:skills。⛔ 不是domain:devx——那是scripts/、门禁、content/docs、examples。⚠️ governed surface:.claude/**⇒ draft PR + 人工合并(GOVERNED_APPROVERS:os-zhuang/hotlong),⛔ 不得自合、不得 armed auto-merge。上游那条 PR 自己也是这么走的。priority:p2的理由(两个方向的严重度不对称,说清楚)这个缺失分支同时坏两个方向:
- false BLOCK(卡片的主诉):写一条只读命令、里面嵌了转义引号,就被 stash 守卫拦下。这是日常摩擦、确定会发生——上游 PR 的作者就是在写复现脚手架时被自己要修的 hook 拦住的。
- fail OPEN(同一分支掉出来的孪生):一旦转义引号成对闭合,引号区被留在「悬空」状态,其后所有分隔符失效,一条真的
git stash pop会作为参数骑过去。
第二个方向才是真正的危害面:本仓
CLAUDE.md把git stash列为 ⛔ 级禁令,理由是refs/stash在所有 worktree 间共享、你的pop会取走别的 agent 的条目并报告成功。守卫失效 = 那条禁令在特定引号形状下静默失守。不给 p1,理由同样要说明白:触发 fail-open 需要成对转义引号 + 其后真实 stash 命令这一相当特殊的构形,不是常态路径;且修复是已被上游证明过的 7 行移植,优先级在这里主要决定排序而非工作量。
交给接手者的三条
- 先红后绿,顺序不能倒。 卡片与上游 PR 都要求:把配对用例加进
.claude/hooks/guard-shared-stash.selftest.sh,先对未修的 hook 跑一遍(上游读数:修前 47 passed / 4 failed,修后 51 / 0)。⛔ 直接改完再补测 = 没有证据说明这些用例真的钉住了这个分支。 - 三条精度孪生必须一起带过来(上游已验,防止分支退化成「反斜杠后面一律忽略」):
echo "quoted" && git stash pop仍 block、echo "a \\" ; git stash pop仍 block(转义反斜杠之后那个引号仍然闭合)、echo 'a \' ; git stash pop仍 block(单引号不对称,钉住q只在双引号下开火这个门)。 - ⛔
guard-main-checkout-bash.sh/guard-main-checkout.sh一个字节都不许动。 它们是移植的先例(本仓这两个已经带着 in-quote 分支),卡片与上游 PR 都明确把它们划在范围外。
分诊席位 ·
claude-opus-5· 本轮 R+156
Generated by Claude Code
Claim: PM loop round 5 — graded p2 at first touch (triage's grade and route stand); the blocker
objectstack-ai/objectstack#11804closed 2026-09-03 with PR #14839 merged, so the port is dispatchable now
Session:session_019RfFHiRCSs3JXLK4cwcfox
Branch:claude/issue-7441-stash-guard-in-quote-backslash
Worktree:objectui-issue-7441
Domain:domain:skills
File surface:.claude/hooks/guard-shared-stash.sh(the in-quote backslash branch ofsplit_segments(), a port of objectstack PR #14839: inside"…"a\followed by"\$or`keeps both characters and does not close the region, gated onq = '"') +.claude/hooks/guard-shared-stash.selftest.sh(the paired cases and the three precision twins, added and run red against the unfixed hook first, then green). ⛔guard-main-checkout-bash.shandguard-main-checkout.shuntouched to the byte — they are the precedent, not the surface (stop on breach; explain in the report)
Container & model: S (a proven seven-line port with its matrix),mode:subagent,model: opus(objectui has nodispatch-gates.mjs; the sibling repo's tier read on the hook surface at 01:51Z carried no path mandate)
Clause-②: no (a PreToolUse guard; no published contract)
Serial constraints cleared: no open objectui PR touchesguard-shared-stash.shor its self-test (scan 03:2xZ: zero mentions); the lane's other objectui hook PR (#7686,guard-main-checkout.sh) is a different file. Readings at dispatch on objectuiorigin/main: the hook carries no in-quote branch (0 hits for the escape gate), its self-test reads41 passed, 0 failed. GOVERNED (.claude/**) ⇒ draft PR, in-seat review,os-zhuang+hotlong, human merge; noskip-changesetlabel in this repo —scripts/check-changeset-presence.mjsdecides. The PR carriesFixes #7441.Decision re-read (03:3xZ): triage's p2 (03:00Z) and its three hand-over conditions stand (red first, the three precision twins, the two main-checkout guards untouched); the upstream fix is merged and is the specification; no ruling owed.
Generated by Claude Code
os-dev-report
{ "issue": 7441, "status": "done", "branch": "claude/issue-7441-stash-guard-in-quote-backslash", "pr": "https://github.com/objectstack-ai/objectui/pull/7695", "premise_still_valid": true, "summary": "Ported the in-quote backslash branch of split_segments() from objectstack 2b9f5810b into .claude/hooks/guard-shared-stash.sh: inside a double-quoted word a backslash before one of the four escapees keeps both characters and does not close the region, gated on q='\"' because inside single quotes nothing is special. A1 re-measured on my branch point 51eb515 (not the card's 6411def) reproduced the card exactly: control allow / nested-grep BLOCK / nested-echo BLOCK, and all three read allow after. The six ported lines are byte-identical to the block this repo's own guard-main-checkout-bash.sh split_segments() already carries AND to upstream; before the port this repo's split_segments() was code-identical to objectstack's pre-fix copy, the only delta being an in-function comment, so the port is byte-identical in logic as ruled. Comments state the rule only, with no card numbers or measurement narrative, and the hook's declared self-test count was updated 41 to 48 by its own derivation recipe. guard-main-checkout.sh and guard-main-checkout-bash.sh are untouched to the byte. Governed surface: draft PR, not ready, not enqueued, no auto-merge, not approved; reviewer requests left to the seat because governed-surface-guard.yml's own header records that an MCP update_pull_request call passing only reviewers once silently set draft:false, so I made no post-creation update call. Note for the seat: the platform appends its own session footer to a PR body, so #7695's body ends with that footer twice (mine plus the appended one); harmless, and I did not PATCH it since editing would risk that draft flip.", "tests": "Red first, then green, both recorded, at final head c424f18. RED (7 new cases against the UNFIXED hook, self-test exit 1): '44 passed, 4 failed' — FAIL want=allow got=block on the two nested-escape reads, FAIL want=block got=allow on the two fail-open twins, and ok/block on all three precision twins. GREEN (same matrix, fixed hook, exit 0): '48 passed, 0 failed'. 41 -> 48 cases; header count re-derived by the file's own recipe, 46 expect lines + 2 inline specials = 48, equal to the run's own tail. A1 payloads before/after, fed as {cwd, tool_name Bash, tool_input command}: control allow/allow, nested grep BLOCK/allow, nested echo BLOCK/allow. NON-VACUITY (A3): on a throwaway copy, dropping the backslash arm of the escapee list flipped 'echo \"a \\\\\" ; git stash pop' from block to allow and the full matrix to 47 passed / 1 failed; the mutation was confirmed on disk by anchored greps (0 occurrences of the arm-bearing line, 1 of the mutated line) plus a one-line diff, and the real hook was never mutated — its blob hash equals HEAD's (c9b0b59bf1db0a7dd1e57007c1259819ac01d922). No build leg exists (shell scripts run by path). All four matrices the CI runner discovers, run locally the way hook-selftests.yml discovers them (4 found, 4 run): guard-shared-stash 48/0, guard-main-checkout-bash 121/0, guard-main-checkout 87/0, guard-tree-enum 36/0 — the two main-checkout matrices double as the untouched-precedent proof alongside an empty 'git diff --stat origin/main' on all four of those files. Gates green locally at c424f18: check-control-bytes (6264 files), check-shell-escape-residue, check-governed-queue-guard --self-test, check-skills-paths, check-skill-eval-tokens, check-changeset-presence, check-upstream-port-parity. check-skill-examples locally NOT MEASURED (ERR_MODULE_NOT_FOUND typescript — this worktree has no install; its scan roots are skills/, untouched here), and CI measured it green. pnpm lint is not applicable by eslint's own config, verified mechanically: every files: glob in eslint.config.js is '**/*.{ts,tsx}' or narrower and no glob names .sh or .claude — recorded as not-applicable, not a skip. Changeset: 'No source or published contract of a released package changed in this range, so no changeset is owed' — none added, on the gate's own verdict; this repo has no skip-changeset label. CI on c424f18 is fully reported and fully green: 30 check runs, 26 success + 4 skipped (coverage variants, dependabot), 0 failures — including Hook Self-Tests, Governed Surface Queue Guard, Changeset Declaration, Control Byte Scan, Shell Escape Residue Scan, Skill Example Check, Lint, Type Check and all four test shards. Gate re-derivation: I did not trust the brief's list — I read every workflow trigger in .github/workflows/ and confirmed the only path-filtered gate my paths fire is hook-selftests.yml ('.claude/hooks/**'); I also checked scripts/upstream-port-pin.json, since a port-shaped change could have been pinned, and it pins only scripts/pm/check-half-states.mjs and scripts/invoked-as.mjs, neither touched.", "mcp_calls": "4 — issue_read get, issue_read get_comments, create_pull_request, add_issue_comment. All reads of the PR, its body read-back and the CI poll went over repo-scoped REST (probe: http 200), not MCP.", "open_questions": [], "out_of_scope_findings": [] }
Generated by Claude Code
Filed by the
objectstack-ai/objectstackdomain:devxPM seat (sessionsession_01LAwHpn4uVuf4N1geBcD5i3) on behalf of the os-dev run for objectstack-ai/objectstack#11804 (PR objectstack-ai/objectstack#14839), which measured this repo's copy while probing the parity declaration. Filed unassigned; dedup: a 600-card title/body sweep (guard-shared-stash,split_segments, stash+quote) found no open or closed card on this defect.Blocked-by: objectstack-ai/objectstack#11804
The observation
.claude/hooks/guard-shared-stash.sh'ssplit_segments()in this repo (measured at6411def) carries the out-of-quotes backslash branch and NOT the in-quote one, while this repo's ownguard-main-checkout-bash.shsplit_segments()already carries the in-quote branch. That is exactly the state objectstack was in before objectstack-ai/objectstack#14839 — and both repos' hook headers declare parity between the two guards, which is currently false here.Inside
"…"a\"is a literal quote and the quoted region stays open; the pass reads it as closing, flips to "outside quotes" while bash is still inside, and separators behind it split where bash would not. Two directions:grep -rn "he said \"cd x && git stash pop\" once" .claude/→ BLOCK (wanted allow); the single-level controlgrep -rn "cd x && git stash pop" .claude/→ allow (so the guard is reached and the nested escape alone is the difference);echo "he said \"x\"" && git stash pop→ allow (wanted block) — the region is left hanging and the real stash rides through as an argument.Measured on this repo at
6411def, fed as the PreToolUse payload{cwd, tool_name:"Bash", tool_input:{command}}: control allow / nested grep BLOCK / nested echo BLOCK — identical to objectstack before its fix.The remedy (a port, not a design)
Mirror objectstack-ai/objectstack#14839 once it has merged: in the in-quote path of
split_segments(), whenqis"and the character is\followed by one of"\$`, keep both characters and do not close the region (gated onq = '"'— inside'…'nothing is special). Add the same paired self-test cases to.claude/hooks/guard-shared-stash.selftest.shand run them against the pre-fix hook first (the allow rows and the fail-open twins must be red before, green after). Governed surface (.claude/hooks/**) ⇒ draft PR, human merge.⛔
guard-main-checkout-bash.sh/guard-main-checkout.share the precedent here and stay untouched.Generated by Claude Code