详解 Claude Code Hook:把 AI Agent 的关键节点变成可编排工作流
Claude Code 很适合让模型自己读代码、改文件、跑命令,但工程团队真正关心的往往不是“能不能自动执行”,而是“能不能在关键节点稳定地接入规则”。
比如:文件写完后自动格式化;准备执行危险命令前先拦截;Claude 等待输入时给你发通知;上下文压缩后重新注入项目约定;会话结束时记录审计日志。Hooks 的价值就在这里:它把 Claude Code 的生命周期暴露成可编排的事件点,让团队可以把确定性的工程规则接到 Agent 工作流里。
Hook 到底是什么
在 Claude Code 里,Hook 是一组配置好的处理器,会在特定事件发生时自动运行。事件可能是“用户刚提交 prompt”“工具调用前”“工具调用后”“Claude 准备停止”“配置文件发生变化”,处理器则可以是本地命令、HTTP 服务、MCP 工具、一次模型判断,或者实验性的 agent 检查。
可以把它理解成一条事件管线:
flowchart LR
A[Claude Code 生命周期事件] --> B{matcher / if 是否命中}
B -- 否 --> C[跳过]
B -- 是 --> D[执行 hook handler]
D --> E{返回结果}
E --> F[继续执行]
E --> G[阻止 / 修改 / 注入上下文]
E --> H[记录日志 / 发送通知]
这和在 shell 里给 claude 包一层脚本不同。外层脚本只能看到启动和退出,Hook 能看到 Claude Code 内部更细的时机:工具调用前、工具调用后、权限弹窗出现前、上下文压缩前后、工作目录变化、文件变化、子代理启动结束等。
配置结构:事件、匹配器、处理器
Hook 配置一般写在 Claude Code 的 settings 里。最常见的项目级位置是 .claude/settings.json,用户级位置是 ~/.claude/settings.json。配置结构分三层:
- 顶层是
hooks。 - 第二层按事件名分组,比如
PreToolUse、PostToolUse、Notification。 - 每个事件下可以有多个 matcher group,每个 group 里再放一个或多个 hook handler。
一个典型配置长这样:
| |
这个例子表达的是:当 Claude 成功使用 Edit 或 Write 工具后,把 hook 输入 JSON 中的文件路径取出来交给 Prettier。它不会在 Read、Bash 或其他工具后触发。
处理器类型目前可以分成五类:
| 类型 | 适合场景 |
|---|---|
command | 本地脚本、格式化、审计、轻量校验 |
http | 把事件发到内部服务、云函数或团队共享审计系统 |
mcp_tool | 调用已经连接的 MCP server 工具 |
prompt | 让模型做一次轻量 yes/no 判断,例如“任务是否完成” |
agent | 需要读文件、搜索代码、运行命令后再判断的复杂验证;官方标注为实验性能力 |
如果规则是确定性的,优先用 command 或 http。如果规则需要语义判断,再考虑 prompt。如果判断必须实际检查代码库状态,才考虑 agent,并且要注意超时和稳定性。
HTTP hook 有一个容易忽略的差异:Claude Code 会把事件 JSON 作为 POST body 发给端点,端点也用同一套 JSON 输出格式返回结果;但 HTTP 状态码本身不能表达“阻止”。非 2xx、连接失败或超时都会被当作非阻塞错误处理。要阻止工具调用或拒绝权限,端点必须返回 2xx,并在响应 JSON 里写入对应的 decision 或 hookSpecificOutput 字段。
生命周期:常用事件怎么选
Claude Code 的 Hook 事件很多,可以按节奏理解:
flowchart TD
S[SessionStart / Setup] --> U[UserPromptSubmit]
U --> L[Agentic loop]
L --> P[PreToolUse]
P --> R[PermissionRequest / PermissionDenied]
R --> T[Tool 执行]
T --> O[PostToolUse / PostToolUseFailure]
O --> B[PostToolBatch]
B --> L
L --> X[Stop / StopFailure]
X --> E[SessionEnd]
C[ConfigChange / CwdChanged / FileChanged] -.异步事件.-> L
K[PreCompact / PostCompact] -.上下文管理.-> L
日常最容易用到的是下面这些:
| 事件 | 触发时机 | 典型用途 |
|---|---|---|
SessionStart | 新会话、恢复会话、/clear、压缩后重新进入会话 | 注入动态上下文、初始化环境变量 |
UserPromptSubmit | 用户 prompt 交给 Claude 前 | 拦截敏感请求、补充上下文 |
PreToolUse | 工具调用执行前 | 阻止危险命令、改写工具输入、自动权限决策 |
PermissionRequest | 权限弹窗即将出现时 | 对特定工具调用自动允许或拒绝 |
PostToolUse | 工具成功执行后 | 格式化、记录日志、根据结果补充上下文 |
PostToolBatch | 一批并行工具全部结束后、下一次模型请求前 | 基于整批工具结果做汇总检查 |
Notification | Claude Code 发送通知时 | 桌面通知、IM 通知 |
Stop | Claude 准备结束本轮回复时 | 检查任务是否真正完成,不满足则继续 |
StopFailure | 本轮因为 API 错误结束时 | 告警、记录失败原因 |
PreCompact / PostCompact | 上下文压缩前后 | 压缩前阻止或记录压缩前后状态;压缩后补充上下文通常用 SessionStart 的 compact matcher |
ConfigChange | settings、skills 等配置发生变化时 | 审计配置变化、阻止非授权变更 |
CwdChanged / FileChanged | 工作目录或被监听文件变化时 | 重载环境、刷新 watch paths |
SessionEnd | 会话结束时 | 清理临时文件、保存统计信息 |
这里有一个容易误判的点:PostToolUse 已经晚了,它不能撤销工具造成的副作用。 如果你要阻止 rm、拦截写入生产配置、禁止访问敏感目录,应该放在 PreToolUse。PostToolUse 更适合格式化、审计、补充上下文和结果检查。
matcher 和 if:别让 Hook 到处乱跑
Hook 不是越宽越好。没有 matcher,或者 matcher 为空,通常表示在该事件的每次发生时都触发。对于高频事件,范围过宽会让 Claude Code 变慢,也会制造难排查的副作用。
matcher 的规则可以这样记:
| matcher 写法 | 含义 |
|---|---|
""、"*" 或省略 | 匹配该事件的所有发生 |
只包含字母、数字、_、` | ` |
| 包含其他字符 | 按 JavaScript 正则表达式处理,例如 mcp__github__.* |
不同事件的 matcher 匹配字段并不一样。工具事件匹配 tool_name,例如 Bash、Edit、Write、mcp__github__search_repositories;Notification 匹配通知类型;SessionStart 匹配启动来源;SessionEnd 匹配结束原因;PreCompact / PostCompact 匹配 manual 或 auto。
如果需要同时按工具名和参数过滤,用 if 字段更合适。matcher 只能先把范围缩到某类工具,if 可以进一步写成权限规则语法:
| |
这个配置只关心 Bash 里的 git push 子命令。像 npm test && git push origin main 这类复合命令,也会因为其中一个子命令命中而触发。if 只对工具事件有效,包括 PreToolUse、PostToolUse、PostToolUseFailure、PermissionRequest、PermissionDenied;放到其他事件上不会达到过滤目的,反而会让 hook 不运行。
输入输出:stdin、stdout、stderr 和退出码
命令型 Hook 的通信模型非常 Unix:Claude Code 把事件上下文作为 JSON 写入脚本的 stdin;脚本通过 stdout、stderr 和退出码表达结果。
一个 PreToolUse 的输入大致会包含这些字段:
| |
退出码语义需要特别注意:
| 退出码 | 含义 |
|---|---|
0 | 成功。Claude Code 会解析 stdout 里的 JSON 输出;多数事件的普通 stdout 只进 debug log |
2 | 阻塞型错误。通常会阻止当前动作,并把 stderr 反馈给 Claude 或用户 |
| 其他非零值 | 多数事件下是非阻塞错误,动作继续执行,只显示 hook 错误摘要 |
这意味着,如果你写的是“安全策略”,不要用常规的 exit 1 期待它阻止动作。大多数事件里,exit 1 只是报告 hook 出错,Claude Code 仍会继续。要阻止工具调用、prompt 处理、上下文压缩或停止行为,通常应该使用 exit 2,或者在 exit 0 时输出结构化 JSON。
例如,一个最小的危险命令拦截脚本可以这样写:
| |
如果要表达更细的控制,推荐让脚本 exit 0 并输出 JSON。不要混用两套机制:Claude Code 只会在退出码为 0 时解析 JSON;如果脚本 exit 2,stdout 里的 JSON 会被忽略。
PreToolUse 的结构化拒绝可以写成:
| |
permissionDecision 常见值包括:
| 值 | 行为 |
|---|---|
allow | 跳过交互式权限提示,但不能覆盖 deny 或 ask 规则 |
deny | 取消工具调用,并把原因反馈给 Claude |
ask | 正常询问用户 |
defer | 非交互模式下延后工具调用,适合集成方自己收集审批结果后恢复 |
这里的边界也很关键:Hook 可以收紧限制,但不能绕过更高优先级的 deny 规则。即使 hook 返回 allow,如果 settings 或组织级策略里有 deny,工具调用仍会被拒绝。
JSON 输出:阻止、停止、注入上下文
结构化 JSON 输出比退出码更适合做可维护的策略。除了 PreToolUse 的 hookSpecificOutput,还有几类常见输出字段:
| 字段 | 作用 |
|---|---|
continue: false | 无论事件类型如何,停止后续处理 |
stopReason | 配合 continue: false,给用户看的停止原因 |
systemMessage | 给用户展示警告信息 |
terminalSequence | 让 Claude Code 代为发出受限的终端通知序列 |
decision: "block" + reason | 部分事件使用的顶层阻止语义 |
hookSpecificOutput.additionalContext | 把动态上下文注入 Claude 的上下文窗口 |
比如在 PostToolUse 后告诉 Claude“刚编辑的是生成文件,不应该直接改”,可以返回:
| |
additionalContext 很有用,但不要把它当成第二份 CLAUDE.md。静态规则应该写进 CLAUDE.md,动态信息才适合通过 Hook 注入,例如当前分支、部署环境、刚刚查询到的 CI 状态、某个目录的临时只读规则。并且最好写成事实描述,而不是伪装成系统指令,否则可能触发 Claude 的提示注入防护。
多个 Hook 同时命中时怎么合并
同一个事件可以命中多个 hook。Claude Code 会执行所有匹配的处理器,然后合并结果。
最典型的是 PreToolUse:如果多个 hook 分别返回 allow、ask、deny,最终以最严格的结果为准,deny 优先于 ask,ask 优先于 allow。这让团队可以把“审计日志”和“安全阻断”拆开写:日志 hook 负责记录,安全 hook 负责拒绝;即使安全 hook 拒绝了命令,日志 hook 也可以已经完成记录。
但并发也带来一个坑:如果多个 PreToolUse hook 都返回 updatedInput 试图改写同一个工具参数,最后谁生效取决于哪个 hook 更晚完成,顺序并不稳定。工程上应避免多个 hook 同时改写同一份输入,把改写逻辑合并到一个脚本里会更可控。
几个高价值实践场景
1. 编辑后自动格式化
文件写入后自动格式化是最适合 PostToolUse 的场景。它不阻止 Claude 写文件,只在写完后把格式拉回项目标准:
| |
如果 Claude 通过 Bash 间接修改文件,例如运行代码生成器,这个 hook 看不到具体文件。需要更强覆盖时,可以配合 Stop 在每轮结束前扫描 git status --porcelain,或者额外监听 Bash 并由脚本自行计算变更文件。
2. 执行前阻止危险命令
安全策略应该尽量放在 PreToolUse。比如阻止生产库迁移:
| |
脚本里应该读取 stdin JSON,而不是只靠字符串包含判断。至少要检查当前目录、命令内容、环境变量和目标数据库标识。只要发现可能触达生产环境,就用 exit 2 或 JSON permissionDecision: "deny" 阻断。
3. Claude 等待你时发送通知
Notification 适合把 Claude Code 的状态接到桌面通知、企业 IM 或手机推送。它不能阻止行为,主要负责副作用:
| |
这里 matcher 只监听 permission_prompt 和 idle_prompt,避免认证成功、MCP elicitation 等其他通知也触发同一套提醒。
4. 上下文压缩后重新注入关键信息
上下文压缩会把长会话压成摘要,可能丢掉一些项目级细节。可以用 SessionStart 的 compact matcher,在压缩后重新注入动态上下文:
| |
SessionStart 的 stdout 会作为上下文提供给 Claude。注意这里适合注入“当前事实”,例如当前分支、活跃任务、测试命令,而不是长篇规则。固定规则仍然应该沉到 CLAUDE.md。
5. 用 Stop Hook 检查任务是否真的完成
Stop 在 Claude 准备结束本轮回复时触发。它可以阻止 Claude 停止,让 Claude 继续工作。适合“必须满足某个完成条件”的场景,例如测试必须通过、lint 必须通过、所有 TODO 必须完成。
但 Stop 不是“任务完成事件”。它在 Claude 每次准备结束回复时都会触发,不会在用户手动中断时触发;如果本轮因为 API 错误结束,触发的是 StopFailure。写 Stop hook 时也要避免无限阻塞,官方输入里提供了 stop_hook_active 用于判断当前是否已经处在 hook 触发的继续流程中;Claude Code 也会在连续阻塞多次后结束这一轮。
用 prompt hook 可以做轻量判断:
| |
如果判断必须运行测试或读取代码,用 agent hook 更合适,但它仍是实验性能力。更稳定的做法是把确定性检查放到 command hook 里,例如直接运行 npm test,失败时 exit 2 并把错误摘要写到 stderr。
安全边界:Hook 很强,也很危险
Hook 的权限来自当前用户。命令型 Hook 可以读取、修改、删除当前用户能访问的文件,也可以执行网络请求或调用本地凭据。因此,Hook 不应该被当作“无害配置”看待。
实践上至少遵守这些规则:
- Hook 脚本使用绝对路径,优先放进项目的
.claude/hooks/并接受代码审查。 - 所有从 stdin 读到的路径、命令、参数都视为不可信输入。
- Shell 变量全部加引号,避免空格、换行和 glob 展开造成意外。
- 对文件路径做路径穿越检查,拒绝
..、.git/、.env、密钥目录等敏感目标。 - stdout 如果要输出 JSON,就保持纯 JSON;日志写 stderr 或 debug log。
- 能用
matcher和if缩小范围,就不要让脚本在每次事件上都跑。 - 对会阻塞 Claude 的 hook 设置合理
timeout,避免把交互卡死。
对于耗时任务,可以给命令型 hook 设置 async: true 让它后台运行;但异步 hook 不能阻止或控制 Claude 的当前动作,decision、permissionDecision、continue 这类返回字段不会产生同步控制效果。因此安全拦截、权限决策和“必须通过才继续”的检查不应该异步化。
还有一个权限模式相关的细节:PreToolUse 在权限模式检查前触发。即使用户使用了 --dangerously-skip-permissions 或 bypass permissions,只要 hook 返回 deny,工具调用仍然会被拦截。反过来,hook 返回 allow 也不能突破 deny 规则。这是 Hooks 能作为团队安全护栏的关键原因。
调试:先看 /hooks,再看 debug log
配置好之后,先在 Claude Code 里运行 /hooks。这个界面是只读的,但可以看到每个事件下配置了多少 hook、matcher 是什么、配置来自哪个文件、实际命令或 URL 是什么。它很适合排查“我以为配置了,但其实没有加载”的问题。
如果 hook 没触发,优先检查四件事:
- 配置文件位置是否正确:用户级、项目级、本地级或组织托管配置。
- 事件类型是否选错:阻止工具调用要用
PreToolUse,执行后处理才用PostToolUse。 - matcher 是否大小写一致,工具名是否真实匹配。
- 使用了
if时,是否放在了支持if的工具事件上。
如果 hook 触发了但行为不对,用 claude --debug 或 claude --debug-file <path> 看执行细节。debug log 会记录哪些 hook 匹配、退出码、stdout 和 stderr。需要更细的匹配过程时,可以设置 CLAUDE_CODE_DEBUG_LOG_LEVEL=verbose。
我的使用建议
Claude Code Hooks 最适合承载“确定性、可审计、可重复”的工程规则,而不是把所有决策都交给另一个模型。
我会按这个顺序设计:
- 先写静态规则:长期不变的项目规范放
CLAUDE.md。 - 再写确定性 Hook:格式化、审计、安全阻断、环境加载,用
command或http。 - 最后才用模型判断:只有规则无法用脚本清晰表达时,再引入
prompt或agent。
更具体地说:想阻止副作用,用 PreToolUse;想处理结果,用 PostToolUse;想检查本轮是否完成,用 Stop;想通知人,用 Notification;想补动态上下文,用 SessionStart 或 additionalContext。
Hook 不是为了让 Claude Code “更自动”这么简单,它真正解决的是 Agent 工程化里的控制点问题。把这些控制点设计好,AI Agent 才能从一个强大的交互工具,变成可以接入团队流程的工程系统。