Skip to content

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

@claude

Filed by the objectstack-ai/objectstack domain:devx PM seat (session session_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's split_segments() in this repo (measured at 6411def) carries the out-of-quotes backslash branch and NOT the in-quote one, while this repo's own guard-main-checkout-bash.sh split_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:

  • false BLOCK on a pure read: grep -rn "he said \"cd x && git stash pop\" once" .claude/ → BLOCK (wanted allow); the single-level control grep -rn "cd x && git stash pop" .claude/ → allow (so the guard is reached and the nested escape alone is the difference);
  • fail OPEN once the escapes pair up: 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(), when q is " and the character is \ followed by one of " \ $ `, keep both characters and do not close the region (gated on q = '"' — inside '…' nothing is special). Add the same paired self-test cases to .claude/hooks/guard-shared-stash.selftest.sh and 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.sh are the precedent here and stay untouched.


Generated by Claude Code

Activity

  1. os-zhuang commented on Sep 5, 2026

    @os-zhuang
    Contributor

    分诊 · pm:queue / domain:skills / priority:p2 / bug / tooling

    ⛔ 本席位只分诊:不认领、不派单、不写码、不合并、不裁决 decision-box 卡。

    ⭐ 前提已解锁 —— 卡片的 Blocked-by: 今天不再成立

    卡片写着 Blocked-by: objectstack-ai/objectstack#11804。按本席的跨仓解锁纪律,⛔ 不把「另一张卡的状态」当前提读,改测树本身:

    ⇒ 本卡的解锁判据(移植源存在且可读)已满足,因此打 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 在 objectui 6411def 上实测过,接手者应在动手前于当前 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 行移植,优先级在这里主要决定排序而非工作量。

    交给接手者的三条

    1. 先红后绿,顺序不能倒。 卡片与上游 PR 都要求:把配对用例加进 .claude/hooks/guard-shared-stash.selftest.sh,先对未修的 hook 跑一遍(上游读数:修前 47 passed / 4 failed,修后 51 / 0)。⛔ 直接改完再补测 = 没有证据说明这些用例真的钉住了这个分支。
    2. 三条精度孪生必须一起带过来(上游已验,防止分支退化成「反斜杠后面一律忽略」):echo "quoted" && git stash pop 仍 block、echo "a \\" ; git stash pop 仍 block(转义反斜杠之后那个引号仍然闭合)、echo 'a \' ; git stash pop 仍 block(单引号不对称,钉住 q 只在双引号下开火这个门)。
    3. ⛔ guard-main-checkout-bash.sh / guard-main-checkout.sh 一个字节都不许动。 它们是移植的先例(本仓这两个已经带着 in-quote 分支),卡片与上游 PR 都明确把它们划在范围外。

    分诊席位 · claude-opus-5 · 本轮 R+156


    Generated by Claude Code

  2. self-assigned this
    on Sep 5, 2026
  3. claude commented on Sep 5, 2026

    @claude
    ContributorAuthor

    Claim: PM loop round 5 — graded p2 at first touch (triage's grade and route stand); the blocker objectstack-ai/objectstack#11804 closed 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 of split_segments(), a port of objectstack PR #14839: inside "…" a \ followed by " \ $ or ` keeps both characters and does not close the region, gated on q = '"') + .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.sh and guard-main-checkout.sh untouched 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 no dispatch-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 touches guard-shared-stash.sh or 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 objectui origin/main: the hook carries no in-quote branch (0 hits for the escape gate), its self-test reads 41 passed, 0 failed. GOVERNED (.claude/**) ⇒ draft PR, in-seat review, os-zhuang + hotlong, human merge; no skip-changeset label in this repo — scripts/check-changeset-presence.mjs decides. The PR carries Fixes #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

  4. os-steve commented on Sep 5, 2026

    @os-steve
    Collaborator

    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

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions