引子
在工程里有一类事必须必然发生(改完代码必须格式化、rm -rf必须拦下、测试没过不许收工),这类硬规则写进CLAUDE.md,本质上仍是"建议"。
那么,**怎样让一件事百分之百发生,而不靠模型自觉?
Hook(钩子)。
原理
理解Hook,有四个要点:
- 凭什么是确定的?
- 挂在循环的哪个节点?
- 配置长什么样?
- 哪几个事件最常用?
Hook以当前用户的权限自动运行任意命令,不会逐次向我们确认。一条配置错误或恶意的Hook足以删除文件、外泄数据。启用任何来路不明的Hook配置前,务必逐条审阅其命令内容。
确定性机制:Hook凭什么"必然"
Hook的"必然",靠的是绕开模型。
前几章的CLAUDE.md、Skill(技能)、Sub-Agent(子智能体)有一个共同点:都作用于模型的输入,要不要照做,最终由模型的概率环节决定,大概率遵守,但没有机制保证百分之百。
Hook不同,它不进入模型的输入,而是一段挂在循环节点上的代码:事件一到,系统直接执行,模型没有投票权。这就是它能"必然发生"的原因。
运行机制
运行机制:工具调用循环(agent loop)的每个阶段都有固定的节点,Hook就挂在这些节点上,循环本身的机制见《1.Start》。
按粒度,事件分三层:
- 会话级
整个会话触发一次,如会话启动、会话结束。 - 轮级
每轮对话触发一次,如用户提交prompt、Claude完成响应。 - 工具级
每次工具调用触发一次,如工具执行前、工具执行后。
配置结构
Hook配置是标准JSON,写在settings文件的hooks键下,按事件名分组。
每个事件对应一个数组,数组元素由两部分组成:matcher限定这组Hook的适用范围,hooks是处理器列表。
示例代码:
1 | { |
事件
什么是事件
类比前端里的事件(click、touch),我们能更好的理解ClaudeCode中的事件。
在前端,click是"用户点了一下"这个时刻,onclick是你挂在这个时刻上的处理器,点击一发生,处理器就自动执行。touch与ontouch同理。
而Hook只是把监听对象从"页面上的用户动作"换成了"工具调用循环里的节点":PreToolUse是"工具即将执行"这个时刻,你挂上一段命令,工具一要执行,命令就自动跑。
上文的「运行机制」按粒度把这些时刻分成会话级、轮级、工具级三层,而事件就分布在这三层里。
五个高频事件
在约三十个事件里,下面五个覆盖了绝大多数场景。
注意,这里只讲每个事件何时触发、能做什么;拦截、放行、注入这些动作在协议上怎么表达,见后文「输入输出协议」。
PreToolUse
工具执行前触发,整个体系里最常用的拦截点。
它可以放行、拒绝、转交用户确认,还可以改写工具参数后再执行。
拦截危险命令、保护敏感文件都在这里做。PostToolUse
工具成功执行后触发。
此刻操作已经发生、无法撤销,能做的是补偿与反馈:自动格式化刚写入的文件、记录审计日志,或把问题反馈给Claude要求它处理。UserPromptSubmit
用户提交prompt后、Claude开始处理前触发。
可以拦下这轮输入,也可以向这轮对话注入信息。Stop
Claude认为本轮工作完成、准备停下时触发。
可以阻止它收工,并把原因反馈给它继续干活。
"测试没过不许结束"这类质量闸门就挂在这里。SessionStart
会话开始或恢复时触发,matcher可区分startup/resume/clear/compact四种来源。
适合在会话起点注入环境信息,或在上下文压缩后重新注入提醒。
它不能阻止任何事。
选型:CLAUDE.md、权限规则,还是Hook
一句话分工:希望模型遵守的约定,写CLAUDE.md;能事先划死的边界,交给权限规则;必须临场判断、必然执行的动作,挂Hook。
| 层 | 性质 | 怎么生效 |
|---|---|---|
| CLAUDE.md | 建议 | 写进模型的输入,模型大概率听 |
| 权限 | 静态策略 | 用allow/deny/ask规则事先划死边界,语法见《1.Start》 |
| Hook | 动态响应 | 在事件发生的瞬间用代码做决定 |
Hook与权限之间有一条明确的分界:PreToolUse返回allow可以跳过交互式确认,但不能越过权限规则里的deny。
示例
需求
假设存在一个Node.js项目,团队约定:
- 破坏性命令必须拦截
- 写完文件立即格式化
- 测试没过不许收工
- 上下文压缩后要重申规范
我们把四段配置都放在项目的.claude/settings.json,可以合并进同一个hooks键。
第一道
第一道,PreToolUse拦截危险命令。
matcher限定Bash,脚本用jq取出即将执行的命令,发现rm -rf就以exit 2阻止,stderr里的原因会反馈给Claude。
示例代码:
1 | { |
第二道
第二道,PostToolUse自动格式化。
matcher写Edit|Write,文件一写完就交给Prettier。Claude不需要知道、也不需要记得项目用什么格式化规范,这件事从"模型的义务"变成了"系统的性质"。
示例代码:
1 | { |
第三道
第三道,Stop测试闸门。
Claude准备收工时跑一遍npm test,失败就以exit 2把它按回去继续修。
示例代码:
1 | { |
注意:Stop事件不支持matcher,所以上面的配置条目里只有hooks、没有matcher。
第四道
第四道,SessionStart压缩后提醒。
matcher取compact,只在上下文压缩后的新起点触发,把最容易在压缩中丢失的约定重新交代一遍。
示例代码:
1 | { |
实践
完整事件清单
事件总数约三十个,且随版本时有增删,这里不逐一罗列,完整、权威的清单以官方文档为准:
查阅时按三层结构(会话级、轮级、工具级,见前文「运行机制」)去定位,重点看三件事:何时触发、可否阻止、matcher取什么值。五个高频事件已在前文「五个高频事件」精讲。
配置位置
Hook可以配置在六个位置。其中四个就是settings文件的标准层级——~/.claude/settings.json、.claude/settings.json、.claude/settings.local.json与Managed设置,各层作用域与优先级见《1.Start》。Hook特有的是另外两个来源。
- 插件的
hooks/hooks.json
插件启用期间生效,随插件分发,打包方式见《7.Plugin》。 - Skill与Agent的front matter
组件激活期间生效,随组件定义分发。Skill可以在front matter里声明hooks字段、随Skill激活而生效,见《3.Skill》;Sub-Agent同理,见《4.SubAgent》。
注意:多个位置的Hook是叠加关系而非覆盖关系,插件、Skill与settings文件中挂在同一事件上的Hook会重合执行。
matcher写法
语法规则
| 写法 | 语义 | 例子 |
|---|---|---|
| 精确工具名 | 大小写敏感的全名匹配 | Bash、Edit |
| 管道分隔 | 多工具"或" | Edit\|Write、Edit,Write |
| 正则 | 按正则匹配工具名 | mcp__github__.* |
*或空字符串 |
匹配全部工具 | * |
| 事件专用值 | 非工具事件各有取值 | SessionStart的compact、Notification的permission_prompt |
两个注意事项:
- matcher大小写敏感,
bash匹配不到Bash工具,且写错不会报错、只是静默不触发 FileChanged的matcher按文件名字面值匹配,写法如.envrc\|.env,其中的管道符需要转义。
例子
例如,匹配MCP工具,用mcp__github__.*一次覆盖某个服务器的所有工具(工具的命名规则见《6.MCP》)。
示例代码:
1 | { |
五种Hook类型
语法规则
Hook类型共五种:
command,执行一条shell命令或脚本。http,把事件发送到一个HTTP端点。prompt,把当前情况交给一个小模型做单次评估,默认用Haiku,可用model字段覆盖。适合规则写不死、但一眼能判断的检查。agent,实验特性。启动一个能调用工具的Agent做多轮核查,最多50个工具轮次,成本与延迟也最高。mcp_tool,直接把MCP服务器上的某个工具当Hook执行。
关于MCP,参考《6.MCP》。
注意:
- 每种类型在
hooks数组里都是一个处理器对象,最小形状如下。五种可以并挂在同一事件上,都会被执行(顺序不确定,见后文「通用规则」)。 - 不同类型的Hook有不同的字段,具体参考官方文档。
例子
示例代码:
1 | { |
解释说明:
command执行本地脚本。http把"事件的输入JSON"POST到url。prompt与agent的$ARGUMENTS会被替换成"事件的输入JSON",然后交给模型判断。mcp_tool调用server上名为tool的工具。
输入输出协议
概述
Hook与ClaudeCode之间的通信很朴素:输入走stdin,输出走exit code加stdout。
输入:stdin
事件触发时,Hook从stdin收到一份JSON,通用字段如下。
示例代码:
1 | { |
输出:exit code
只有command类型以exit code表达意图。
| exit code | 行为 |
|---|---|
| 0 | 成功。若stdout是JSON则解析为决策,否则继续正常流程 |
| 2 | 阻止。对可阻止事件生效,stderr内容反馈给Claude;对不可阻止事件仅显示stderr |
| 其他 | 视为脚本自身故障,流程继续。stderr首行连同Hook错误通知显示,完整输出记入调试日志 |
exit 2的设计把"有意阻止"与"脚本坏了"分开:脚本自身的故障,不应该拦下正常的工作流。
输出:JSON
语法规则
exit code是粗粒度的,细粒度控制用exit 0加stdout JSON。注意,exit 2与JSON输出不可混用:一旦以exit 2退出,stdout里的JSON就会被忽略。
| 字段 | 说明 |
|---|---|
continue |
设为false时Claude停止处理,配合stopReason给出原因 |
decision |
取值"block",用于Stop/PostToolUse/UserPromptSubmit,配合reason说明缘由 |
additionalContext |
注入Claude上下文的额外信息 |
systemMessage |
显示给用户的警示文本 |
suppressOutput |
隐藏stdout |
terminalSequence |
向终端发送转义序列,可用于通知、改标题 |
hookSpecificOutput |
事件特定的控制 |
例子
顶层decision: "block"用于Stop、PostToolUse、UserPromptSubmit。上文测试闸门用exit 2实现,等价的JSON写法是:
1 | { |
若不想阻止、只想补充信息,用additionalContext把内容注入Claude的上下文,工作流照常继续:
1 | { |
一事件挂了多个Hook
当同一事件挂了多个Hook时,规则如下:
- 最严决策胜出
按deny>defer>ask>allow取最严格的决策。例如同一个Bash挂了两个PreToolUseHook,白名单脚本判定放行(allow),安全扫描却发现危险命令要求拒绝(deny),取最严,最终按deny执行、命令被拦下。 additionalContext拼接
所有匹配Hook返回的additionalContext会拼成一段一起交给Claude,而非相互覆盖。例如SessionStart挂了两个Hook,一个注入当前分支与最近提交,另一个注入团队编码规范,两段会拼接后进入上下文。- 执行顺序不确定
同一事件的多个Hook执行顺序不定,应避免让多个PreToolUseHook修改同一工具的updatedInput。