Skip to content

fix(hooks): guard-main-checkout-bash reads shell comments as text, not commands - #11129

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-10570-hook-comment-rule
Aug 22, 2026
Merged

fix(hooks): guard-main-checkout-bash reads shell comments as text, not commands#11129
os-zhuang merged 1 commit into
mainfrom
claude/issue-10570-hook-comment-rule

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Fixes #10570

Both quote-aware passes of .claude/hooks/guard-main-checkout-bash.sh now apply the shell's comment rule: outside quotes and heredoc bodies, an unquoted # that starts a WORD begins a comment that runs to the next newline.

Everything here is verified at 5f520708c, the final commit on this branch.

The defect, reproduced before the fix

A comment cannot write anything, so reading one as a command is a false BLOCK — the fail-closed direction. Prose arrows put a real ASCII > in operator position and the following word was named as a write target; a ; in the same comment first cut it into its own "segment".

$ python3 -c "import json; print(json.dumps({'cwd':'/home/user/objectstack','tool_input':{'command':'# rename foo -> bar\necho hello'}}))" \
    | bash .claude/hooks/guard-main-checkout-bash.sh
⛔ Blocked: this Bash command WRITES into the shared PRIMARY checkout, not a worktree.
   command: # rename foo -> bar
   target:  bar
hook-exit=2

The control — the same command without the comment line — exits 0. The measured #10720 shape (a comment carrying both ; and -> ahead of pure-curl calls) reproduced identically, naming pm:dispatched as its target.

What changed

  • split_segments() gains a word flag: an unquoted # at word start skips to (not past) the newline, so a separator inside a comment never splits. What resets word is exactly what delimits a word in the shell — start of input, blank, and the metacharacters ; | & ( ) newline > <.
  • { and } deliberately do NOT reset it. They are reserved words, not metacharacters, so a # right after one continues the word. That is load-bearing rather than cosmetic: this pass splits on {/} anyway, and treating the # of ${#arr[@]} as a comment would swallow the rest of the line — including a real redirect behind it. The self-test pins that negative twin.
  • tokenize() gains the same rule, keyed on its existing have flag, which already means "a word is open".
  • Header docblock gains layer 4 alongside the quote and heredoc layers it joins.

Nothing changes about what the hook blocks once it has a real command. The word-start condition is the whole rule: foo#bar, ${x#y}, curl 'url/#frag', sed 's/#//' and grep '#' are not comments and keep every verdict they had. Precision over recall, the hook's own yardstick, is what the fix serves — a comment was a shape the hook did not model yet claimed with false confidence.

Self-test — extended in both directions

.claude/hooks/guard-main-checkout-bash.selftest.sh, per the hook header's own discipline. 82 cases before, 100 after; 100 passed, 0 failed. The 18 new cases:

  • the two measured false blocks, verbatim, plus an inline-comment shape and a two-comment-line shape carrying 2>;
  • recall guards: a real redirect on a line AFTER a comment still blocks; a real redirect on the SAME line ahead of an inline comment still blocks; a sed -i after a comment carrying a ; still blocks;
  • non-comment # shapes, in both verdict directions: touch foo#bar and rm -rf pkg/x.ts#old still BLOCK, while a quoted #, a URL fragment, sed 's/#//' and an escaped backslash-# still allow;
  • ${#TOK[@]} and ${x#a} ahead of a real redirect still BLOCK — the case that would go quiet if word state were reset by {.

Reverse-verification (ablation)

Run from the committed state by a script whose trap ... EXIT INT TERM owns the restore (the restore call is spelled inline in the script; angle-bracket placeholders do not survive this text field). No build step is involved: the hook file is the artefact the payload runs against, so reaching disk is the whole proof chain, and it was proven rather than inferred from an editor's exit code.

step on-disk proof
pre-ablation marker occurrences 3, word-start guard lines 2
mutated marker occurrences 0, guard lines 0, 20702 bytes to 18156 (delta -2546) — asserted, not assumed
restored (trap) marker occurrences 3, guard lines 2, git status --porcelain on the hook: 0 lines

Against the ablated hook the card's exact repro JSON blocks again (hook-exit=2, both shapes, with the control still 0) and the self-test reports 96 passed, 4 failed.

Direction, as observed rather than as templated: exactly the 4 allow-direction comment cases flip; the 14 recall and non-comment cases stay green in both states. That is the expected asymmetry — the ablation removes only the false-block fix, so a case that blocks for reasons the fix never touched cannot flip. It also means those 14 are pins against future regression, not discriminators for this one.

Gates

node scripts/pm/dispatch-gates.mjs with no hand-fed paths, derived at the final commit; all four families it named were run, each verdict line quoted from the gate itself, exit codes captured before any pipe.

  • ✓ doc authoring guard: 389 files clean — no bare metadata literals.
  • ✓ check:doc-formula-expressions: 22 record-scoped formula example(s) across 416 files / 1447 TS blocks judged clean by @objectstack/formula. (self-test: 30 cases passed)
  • ✓ check-governed-merges --self-test: 119 assertionsRC_pm_governed=0
  • ✓ check-skill-frame-sync: 4 copies of the decision frame are structurally isomorphic across 3 files
  • plus ✓ check-nul-bytes --self-test: 75 assertions over a temp git repo (real scan() path) — any-edit family; a direct control-character scan over both changed files also returned no matches.

Repo-wide pnpm lint is CI's run. The narrowing here is a measurement, not a skip: eslint's own config supplies the population, and for both changed paths it reports File ignored because no matching configuration was supplied — 2 files, read from --format json. Invariance for untouched files: every files: glob in the flat config is a TS/JS extension list and a scan of the config for any shell population returns nothing (exit 1), so a diff made only of .sh files cannot move any other file's verdict.

Out of scope, filed separately

The two sibling fail-opens measured while in this function pair are NOT touched here — both are unadjudicated, and both are the opposite (fail-open) direction from this card:

  • an unquoted backslash still makes the two passes disagree (a command whose escaped double quote is followed by ; sed -i s/a/b/ pkg/x.ts exits 0, while the same sed -i alone exits 2);
  • a heredoc introducer named inside a comment makes strip_heredocs() swallow the following real write — confirmed pre-existing at origin/main, unchanged by this PR.

Governed surface: draft PR, human merge. skip-changeset.claude/hooks/** publishes nothing.

Generated by Claude Code

…t commands

Both quote-aware passes (`split_segments()` and `tokenize()`) now apply the shell's
comment rule: outside quotes and heredoc bodies, an unquoted `#` that starts a WORD
begins a comment running to the next newline.

The word-start condition is the whole rule — `foo#bar`, `${x#y}`, `curl 'url/#frag'`,
`sed 's/#//'` and `grep '#'` are not comments and keep every verdict they had. In
`split_segments()` that means `{`/`}` do not reset word state (they are reserved words,
not metacharacters), so the `#` of `${#arr[@]}` cannot swallow a real redirect behind it.

Nothing changes about what the hook blocks once it has a real command; this is only about
what counts as a command. Self-test extended in both directions: 82 -> 100 cases.

Fixes #10570

Copy link
Copy Markdown
Contributor Author

PM 验收 — ACCEPT(终态)

复核:席位 session_01MsbKEG4LtERSLaDrbehM3e 全 diff 审毕(两文件,hook + selftest)。裁定范围逐项落地:词首未引 # 至换行规则入两个 quote-aware pass(split_segments 加词态旗、tokenize 键在既有 have 旗上);召回面零变更——花括号重置词态的判断正确且承重(${#arr[@]} 后接真实重定向仍拦,双负孪生钉死——否则参数展开的 # 会吞掉后面的真实写入,恰是范围禁止的召回回归);自测 82→100 双向扩展(两条实测假拦原样转绿、注释后真实重定向仍拦、行内注释前真实重定向仍拦、foo#bar/展开/引号/URL 片段/转义 # 全部判决不动)。消融从提交态起、字节差 −2546 上盘证明、无构建腿的论证正确(hook 文件即工件)、方向按观察而非模板(恰 4 例翻转、14 例双态绿)。

三张发现卡处置全部正确#11131(反斜杠 fail-open——姊妹旧卡 404 后按派发指令查重新立,⛔ 未在本单修);#11133(注释内 heredoc 引导词使 strip_heredocs 吞掉后续真实写入——第三 pass、本卡两 pass 范围之外,带真实排序设计问题,独立成卡正当);objectui#5712(镜像 hook 的第二处漂移,落修复仓并挂靠既有移植卡 #5459 为姊妹)。评论遭 sanitizer 损毁后 PATCH 修复并二次读尾确认——处置符合平台读数纪律。

治理面终态.claude/hooks/** —— 本 PR 保持 draft,仅维护者合并;已指派。合并后剥 #10570 状态标。⚠️ 合并后 #11131/#11133 进下轮定级(fail-open 方向,权重高于本卡的 fail-closed)。


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 32595643152 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Console Pin Gate — 失败步骤: Build the Console SPA at the pinned objectui SHA

    ✗ Build failed in 4.59s
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

跨 PR 相同签名(24h,按失败测试文件聚合):

  • ⚠️ 本次没有可用的聚合签名(日志里没有能解析出测试文件名的 FAIL 行)—— 这不是「没有同签名的其他 PR」,是这一轮没测到。跨 PR 聚合本次不可用,请手工比对其他 PR 的同类评论。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 94 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

Copy link
Copy Markdown
Contributor Author

队列失败判读收据:与 PR #11126 第二次踢出逐字同签名(Console Pin Gate / pinned objectui SPA 构建失败——六个已退休 @objectstack/spec/ui theme Schema import 的确定性红,根因日志实锤见 #11126 的终态判读评论)。与本 PR diff(.claude/hooks 注释规则)零交集。⛔ 不重投——等 objectui#5710(在飞,fable)→ objectstack#10856 pin bump 落地、门恢复确定性绿后由维护者重投。


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 32596032444 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Console Pin Gate — 失败步骤: Build the Console SPA at the pinned objectui SHA

    ✗ Build failed in 4.65s
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

跨 PR 相同签名(24h,按失败测试文件聚合):

  • ⚠️ 本次没有可用的聚合签名(日志里没有能解析出测试文件名的 FAIL 行)—— 这不是「没有同签名的其他 PR」,是这一轮没测到。跨 PR 聚合本次不可用,请手工比对其他 PR 的同类评论。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • ⚠️ 本 PR 过去 24h 已在队列失败 1 次(不含本次)。 内容未变而反复失败 ⇒ 高度怀疑 flaky 测试或与同组 PR 的语义冲突,重排不解决。
  • 过去 24h 队列共有 95 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

Merged via the queue into main with commit 5fdd463 Aug 22, 2026
30 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-10570-hook-comment-rule branch August 22, 2026 20:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/s skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

guard-main-checkout-bash.sh reads a > inside a shell COMMENT as a redirect and false-blocks the command

2 participants