Hooks:把「别忘了」变成闸门

定制与自动化第 2 / 3 篇

本文的每个数字都是跑出来的。 环境:Claude Code 2.1.258 · Node v22.22.3 · 被试模型 claude-haiku-4-5-20251001。验证代码在仓库 experiments/claude-code-hooks/, 整套可重跑(node a-blocking.mjs 等),每个结论都对应一个脚本。

把重复叮嘱 agent 的话固化成 hook,动机很直接:判据写进配置就不靠记性了。

但「写成了 hook」和「它真的在守着」之间隔着四个条件,而默认写法一个都不满足。 更麻烦的是,不满足时它不报错 —— 它安静地退化成一条提醒,你却以为那里有道闸门。

这一篇的产出就是那四个条件,以及每一条是被哪个实验逼出来的。

一、先把「提醒」和「闸门」分开

第一个要定的是判定依据。不能看会话输出里有没有出现拒绝字样 —— 一个只打日志的 hook,输出里同样会出现 REFUSED。判定只能看那件事到底有没有发生。

任务固定为「创建 hello.txt」,判定就是这个文件在不在。三个用例缺一不可:

// experiments/claude-code-hooks/a-blocking.mjs
const CASES = [
  { name: 'A1 无 hook(基线)',      expectFile: true  },
  { name: 'A2 hook 打日志 exit 0',  expectFile: true  },
  { name: 'A3 hook exit 2',        expectFile: false },
];
用例 hook 干了什么 hello.txt
A1 基线 没有 hook 存在
A2 echo "REFUSED..." >&2; exit 0 存在
A3 echo "REFUSED..." >&2; exit 2 不存在

A1 不能省。 没有它,A3 的「文件不存在」什么也证明不了 —— 可能是 agent 压根没动手。

A2 才是这一节的重点:hook 跑了,喊了,agent 也确实看见了那句话, 文件照样建出来。这就是提醒。它和闸门在会话输出里长得几乎一样。

规则本身很简单:PreToolUse 的退出码里,0 放行、2 阻断。 麻烦的是剩下那些码。

二、hook 自己坏掉的时候,它放行

这是全篇最重的一节。一个 hook 只有在它自己出故障时也给出确定结果才算闸门。

每种坏法跑 5 次,matcher 用 *(拦所有工具),并让 hook 把收到的 tool_name 记进日志 —— 「放行」必须由日志证明 hook 真的跑过:

坏法 放行 hook 触发 现实里对应什么
exit 1 5/5 5 次 脚本里手滑写了 exit 1,或某条命令自己失败了
exit 127(命令不存在) 5/5 5 次 hook 脚本被删、路径写错、换台机器没装依赖
超时(timeout: 5) 5/5 5 次 网络调用卡住、等锁、等一个不会来的输入
挂死(不设 timeout) 放行,但先卡约 600 秒 1 次 同上,只是没写超时
exit 2(对照组) 0/5 5 次 ——

非零退出码里只有 2 是阻断。 exit 1 是四个码里最像「失败」的那一个, 而它放行。exit 127 意味着你的闸门脚本根本没跑起来 —— 也放行。

这几种坏法的共同点是静默:会话照常往下走,claude 进程退出码是 0, hook 日志里甚至有一条漂亮的触发记录。你唯一能观察到的现象,就是什么现象都没有。

不设 timeout 那一行值得单独说:整轮耗时 613.1 秒,而同样任务正常跑完约 10 到 15 秒 —— 默认超时因此落在 600 秒上下(这是从总耗时推出来的量级,不是读到的配置值)。 它最后还是放行了。所以一个挂死的 hook 给你的不是保护,是十分钟的等待外加一次放行。

这个数差一点就没量出来:上一版探针我把 harness 的上限设成了 600 秒, 正好卡在边界上,会话被我自己 SIGKILL 掉 —— 目标文件当然不存在, 输出于是报了个漂亮的「阻断」。那是 harness 的上限撞出来的假结果,不是被测对象的行为。 凡是自己设的上限,撞上了就必须把该轮判为不可用。

一个被推翻的解释

C 组第一版里有个用例前后两次跑出了相反结果。当时最顺手的解释是机制问题: 那个 hook 没有读 stdin(Claude Code 会往 hook 的 stdin 写一段 JSON), 猜想是管道写失败导致退出码被当成异常。

听起来很合理,所以拿去测了 —— 每种配置 5 次:

配置 阻断次数
exit 2 · 不读 stdin 5/5
exit 2 · 读掉 stdin 5/5
exit 1 · 不读 stdin 0/5
exit 1 · 读掉 stdin 0/5

stdin 无关。 那个用例的前后不一致另有来源,见下一节。

记下来是因为:这个解释当时完全说得通,而且顺手就能编出一条 「写 hook 记得把 stdin 读掉」的建议塞进文章。没跑那 20 次的话,它就进正文了。

多个 hook 同挂一个事件

好消息,exit 2 是有传染性的 —— 两个 hook 挂在同一事件上, 任一个 exit 2 就阻断,与顺序无关(先放后阻、先阻后放,各测都是阻断)。

所以全局配置里已有的 hook 不会削弱你新加的那个。 (本机测试时全局挂着三个 matcher: "*" 的观测 hook,它们只 printf '{}', 不阻断 —— 这是下面所有实验的共同前提。)

三、拦住了,然后它换了条路

装上 trace 之后,某次记到的工具序列是 Write, Bash, Skill —— agent 的 Write 被拦下之后,接连拿 Bash 和 Skill 又试了两次。 如果 matcher 只写了 Write,第二次就没人拦了。

那不是 hook 放行,是绕道。而只看「目标文件在不在」的话,两者一模一样。

于是两个自变量各跑 5 次 —— matcher 宽窄,以及 stderr 里给不给理由:

配置 闸门失守 hook 平均触发 agent 试过的工具
窄 Write · 给理由 0/5 1.0 次 Write
窄 Write · 无理由 3/5 1.0 次 Write
宽 * · 给理由 0/5 1.0 次 Write
宽 * · 无理由 0/5 2.0 次 Write, Bash

两条结论:

① stderr 里给不给理由,决定它会不会绕道。 「给理由」那两行用的是:

Policy: hello.txt must never be created by any means. Do not try another tool.

说清了「无论用什么手段」和「别换工具」,agent 就停手。 只丢一句干巴巴的拒绝,它会把这当成工具故障,换一个工具重试 —— 这是很合理的行为。

② 失守的那 3 次,hook 触发次数是 1.0。 也就是说 hook 日志上只有一条 「拦截成功」的记录,而文件建出来了。看日志会以为它一直在正常工作。

对比宽 matcher 那行的 2.0 次:agent 同样试了第二条路,只是这次被拦住了。

这也顺带解释了上一节那个前后不一致的用例 —— 它正是「窄 matcher · 无理由」这一格, 而这一格实测就有 3/5 会失守。但那一次跑的时候还没装 trace, 所以它具体是不是绕道,已经回溯不了了。 能说的只有机制存在、频率可观, 不能说那一次就是它。观测手段是后来才补上的,补之前的运行结果只能作废,不能追认。

四、被推翻的那条流行说法

原本这一节的假设是:同一条叮嘱写在 CLAUDE.md 里会漏,写成 hook 不会漏 —— 「模型记不住」几乎是写 hook 最常被引用的理由。

叮嘱选了一条容易在多步骤中途被挤掉的:每个新建的 .md 首行必须是 <!-- checked -->。 判定全部由脚本做,不看会话说了什么。三个对照臂,每臂 10 轮:

任务 CLAUDE.md CLAUDE.md 稀释 hook
easy · 3 个文件一次写完 10/10 —— 10/10
hard · 5 个文件 + 中途重写 2 个 10/10 10/10 10/10

「稀释」那一臂是前两档全绿之后才加的:原来的 CLAUDE.md 只有一条规则、 且与手上任务直接相关,那不像真的。稀释臂把同一条规则埋进 20 条项目约定的第 13 位 —— 变量是规则密度,不是任务难度。

50 轮,一次没漏。 我没能复现「CLAUDE.md 记不住」。

所以这一节的结论要反过来写:

  • 不要拿「模型会忘」当写 hook 的理由。 至少在「规则明确、可机器判定、 与当前任务相关」这类叮嘱上,实测复现不出来。这类事情写进 CLAUDE.md 就够了, 多一个 hook 是多一处要维护的东西。
  • hook 真正给你的不是更高的遵守率,是另一种东西:它的结果不取决于模型、 不取决于上下文有多长、也不取决于这一轮运气。50/50 说明不了第 51 轮, 而 exit 2 可以。

⚠️ 这一组的局限必须说清楚:被试是 haiku,叮嘱是文件写入类、可机器判定, 每臂只有 10 轮 —— 两个数字之差不能当统计结论引用, 10/10 和 10/10 之间没有可比的差距,能说的只有「在这个范围内没测出失效」。

五、这一篇的判据

回到开头:怎么知道自己的 hook 是闸门还是提醒。四个条件,缺一条就退化成提醒。

{
  "hooks": {
    "PreToolUse": [{
      "matcher": "Write|Edit|MultiEdit|NotebookEdit|Bash",
      "hooks": [{
        "type": "command",
        "command": "node .claude/gate.mjs",
        "timeout": 10
      }]
    }]
  }
}
  1. 只有 exit 2 算阻断。 脚本里任何一条命令自己失败并把非 2 的退出码 带出来,闸门就没了 —— 且不报错。
  2. stderr 必须写清理由,并明确「别换工具」。 少了这句,实测 3/5 会绕道。
  3. matcher 要覆盖所有能达成同一效果的工具,不是只覆盖你脑子里最先想到的那个。 Write 拦得住,Bash 里一个重定向就绕过去了。
  4. 显式写 timeout。 不写的话默认在 600 秒上下,而超时的动作是放行 —— 等十分钟再放行,比立刻放行更糟:你会以为它在干活。

第 4 条引出最后一句,也是这篇最该记住的一句:

hook 是 fail-open 的,所以它不能是唯一那道闸门。

真正不能失守的判据要放在构建期或 CI里 —— 那里失败是红的、是会挡住合并的。 hook 的位置是「提前失败」:在 agent 动手前就把明显违规的操作拦下来, 省掉一轮无用功。把它当最后一道防线用,等于把防线建在一个坏掉时会自动打开的门上。


本文没有回答的两个问题:

一是 PostToolUse 和 Stop 的阻断语义是否与 PreToolUse 一致。 本篇只测了 PreToolUse —— 另外两类的「阻断」发生在操作已经做完之后, 它到底是回滚、是让 agent 重做、还是仅仅追加一条消息,需要另一组实验, 不能从这一篇的结论外推。

二是更强的模型在第四节那组对照里会不会有不同表现。 换模型重跑一遍就能知道(LAB_MODEL=sonnet node b-claudemd-vs-hook.mjs), 但那是另一个成本量级,本篇不做。这里不给「大模型应该更好」这种没跑过的推断。