From f6a2e188ceb31bb43a4954777651039bcfb34f47 Mon Sep 17 00:00:00 2001 From: daymade Date: Sun, 16 Aug 2026 11:26:41 +0800 Subject: [PATCH] =?UTF-8?q?docs(hook-pitfalls):=20=E8=AE=A2=E6=AD=A3=20#32?= =?UTF-8?q?=20=E7=AC=AC=201=20=E6=9D=A1=E4=BF=AE=E6=B3=95=E2=80=94?= =?UTF-8?q?=E2=80=94=E6=88=91=E6=8E=A8=E8=8D=90=E7=9A=84=20assert=20?= =?UTF-8?q?=E5=AE=9E=E6=B5=8B=E6=98=AF=20fail-OPEN=20=E7=9A=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一提交在 #32 里把 `assert len(qmask) == len(out)` 当作首选修法推荐。 落地到守卫本体时实测发现,在那个调用点上 assert 的方向正好是反的: 该 python 块的判定结果不靠退出码、靠 stdout 回传给 shell(FORM=$(python3 …)), 且它自己一律 sys.exit(0) 收尾。所以未捕获的 AssertionError → stdout 空 → FORM 空 → 被读成「没有违规」→ hook 退出 1;而 PreToolUse 只认 exit 2 为 block, 非 2 的非零是「非阻断错误、工具照常执行」。 强制触发断言的变体实测:git commit -a -m x → exit=1 → **照样放行**。 换成显式 `if len(qmask) != len(out): 按违规上报` 后实测:危险命令与健康命令 均 exit=2(保守方向,Tier-0 闸门该往这边倒)。 所以条目里补了一条可泛化的判据:**一个绊线是 fail-open 还是 fail-closed, 是「调用点怎么消费结果」的属性,不是语言构造的属性**——同一个 assert, 放在「退出码即判定」的 hook 里是 fail-closed,放在「stdout 即判定」的块里 就是 fail-open。判定方法是强制触发一次、读退出码,不是靠直觉。 「不如改存元组、从结构上消灭断裂可能」那半句保留,它本来就是更好的答案。 守卫本体的对应修复见私有仓 daymade/scripts 93b9b9b。 Co-Authored-By: Claude Opus 5 --- .../references/hook_pitfalls.md | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/daymade-claude-code/claude-code-hooks/references/hook_pitfalls.md b/daymade-claude-code/claude-code-hooks/references/hook_pitfalls.md index 19234384..210581ff 100644 --- a/daymade-claude-code/claude-code-hooks/references/hook_pitfalls.md +++ b/daymade-claude-code/claude-code-hooks/references/hook_pitfalls.md @@ -1476,11 +1476,26 @@ this list and describe defects you reach by asking a different question): **Fixes, in the order they buy the most:** -1. **Make the invariant executable, not documentary.** `assert len(qmask) - == len(out)` immediately after the loop costs nothing and converts a - silent misalignment into a loud failure. A comment saying "these must - stay equal" is not enforcement; the next person appending a branch will - not read it. Better still, remove the invariant's ability to break: +1. **Make the invariant executable, not documentary — but check which way + your "loud failure" actually falls.** A comment saying "these must stay + equal" is not enforcement; the next person appending a branch will not + read it. The reflex is `assert len(qmask) == len(out)` — and in this + guard that reflex was **measured to be wrong, in the dangerous + direction**. This block returns its verdict on **stdout** (the shell does + `FORM=$(python3 …)`) and always `sys.exit(0)` itself, so an uncaught + `AssertionError` prints nothing to stdout, leaves `FORM` empty, reads as + "no violation found", and exits the hook **1** — and PreToolUse treats + any nonzero-but-not-2 as a non-blocking error, so the tool runs anyway. + Forcing the assertion to fire measured exactly that: `git commit -a -m x` + → exit 1 → allowed. Replacing it with an explicit `if len(qmask) != + len(out): report_violation()` measured exit 2 for both the dangerous and + the healthy command — conservative, which is the correct direction for a + Tier-0 gate. **Generalize: whether a tripwire is fail-open or fail-closed + is a property of how *the call site consumes the result*, not of the + language construct.** The same `assert` is fail-closed in a hook whose + exit code is the verdict and fail-open in a block whose stdout is the + verdict. Determine this by forcing the failure and reading the exit code, + not by intuition. Better still, remove the invariant's ability to break: append `(char, in_quote)` **tuples** to one array so no branch *can* update one without the other. Two arrays that must stay in lockstep are a data-structure choice you can simply decline to make.