fix(hooks): guard-main-checkout-bash reads shell comments as text, not commands - #11129
Conversation
…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
PM 验收 — ACCEPT(终态)复核:席位 session_01MsbKEG4LtERSLaDrbehM3e 全 diff 审毕(两文件,hook + selftest)。裁定范围逐项落地:词首未引 三张发现卡处置全部正确:#11131(反斜杠 fail-open——姊妹旧卡 404 后按派发指令查重新立,⛔ 未在本单修);#11133(注释内 heredoc 引导词使 治理面终态: Generated by Claude Code |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 32595643152 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
|
队列失败判读收据:与 PR #11126 第二次踢出逐字同签名(Console Pin Gate / pinned objectui SPA 构建失败——六个已退休 Generated by Claude Code |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 32596032444 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Fixes #10570
Both quote-aware passes of
.claude/hooks/guard-main-checkout-bash.shnow 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".The control — the same command without the comment line — exits 0. The measured
#10720shape (a comment carrying both;and->ahead of pure-curlcalls) reproduced identically, namingpm:dispatchedas its target.What changed
split_segments()gains awordflag: an unquoted#at word start skips to (not past) the newline, so a separator inside a comment never splits. What resetswordis 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 existinghaveflag, which already means "a word is open".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/#//'andgrep '#'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:2>;sed -iafter a comment carrying a;still blocks;#shapes, in both verdict directions:touch foo#barandrm -rf pkg/x.ts#oldstill 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 TERMowns 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.git status --porcelainon the hook: 0 linesAgainst 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 reports96 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.mjswith 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 assertions—RC_pm_governed=0✓ check-skill-frame-sync: 4 copies of the decision frame are structurally isomorphic across 3 files✓ 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 lintis 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 reportsFile ignored because no matching configuration was supplied— 2 files, read from--format json. Invariance for untouched files: everyfiles: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.shfiles 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:
; sed -i s/a/b/ pkg/x.tsexits 0, while the samesed -ialone exits 2);strip_heredocs()swallow the following real write — confirmed pre-existing atorigin/main, unchanged by this PR.Governed surface: draft PR, human merge.
skip-changeset—.claude/hooks/**publishes nothing.Generated by Claude Code