Claude Code Hooks 详解:给你的智能体加一层确定性保障
什么是 Claude Code Hooks?
Hooks 是用户自定义的 shell 命令(也支持 HTTP 端点、MCP 工具和模型提示词),Claude Code 会在生命周期的固定节点自动执行它们:调用工具之前、编辑完成之后、会话开始时、Claude 回复结束时¹。CLAUDE.md 给模型的是指令,它大概率会照做;而 hooks 不管模型配不配合,都一定会执行。在任意会话里输入 /hooks,就能看到所有生命周期事件,以及每个事件上挂了什么。
![]()
多数开发者使用 Claude Code 时只靠两层控制:权限(permissions)决定智能体可以做什么,CLAUDE.md 描述它应该做什么。Hooks 是第三层,也是唯一能提供保证的一层。下面依次讲:心智模型、当前文档中的全部生命周期事件、精确的输入输出约定、配置方式、五种实用套路,以及一套决策框架。文中所有 API 细节都对过官方 hooks 参考文档和指南,核验时间为 2026 年 8 月 8 日——这套系统迭代很快,若本文与参考文档有出入,以参考文档为准。(刚接触 Claude Code?可以先看5 分钟上手指南或新手路径。)
太长不看版:Hooks 从标准输入(stdin)接收 JSON,通过退出码或写到标准输出(stdout)的 JSON 来回应。退出码 0 表示放行,退出码 2 表示拦截(只对支持拦截的事件有效),而退出码 1——Unix 里最常见的失败码——什么都不会拦,这是 hooks 最大的一个坑²。配置写在 settings.json 里,用 PreToolUse、Stop 这类事件名,配合 matcher 做过滤。凡是必须每次发生的事,用 hooks;凡是模型知道就好的事,写进 CLAUDE.md。
心智模型:给非确定性的内核套一层确定性的壳
编程智能体本质上是概率系统。你让它每次编辑后跑一遍 Prettier,它大多数时候会跑。但改动看起来无关紧要时、上下文太长时、或者你的措辞让它理解偏了时,它可能就跳过了。CLAUDE.md、skills、提示词都只是建议:质量很高、通常会被遵守,但从无保证。
Hooks 就是核心外层的那圈确定性外壳。指南开头用一句话定义它——“Hooks 是用户自定义的 shell 命令。”——然后把要点说得很直白:hooks 带来的是“确定性控制:某些动作一定会发生,而不是靠大语言模型(LLM)自己决定要不要跑。”³(指南这一句话已经跟不上现在的功能范围了;参考文档里的完整定义已经加入了 HTTP 端点和 LLM 提示,处理程序还能以 MCP 工具的形式出现——详见下面的“配置”部分。)格式化器每次编辑都会触发。命令守卫会检查每一次 Bash 调用。完成闸门会卡住每一次收尾。
这种约束是实打实的,不是摆设:PreToolUse 钩子先于任何权限模式检查触发,所以一个返回 permissionDecision: "deny" 的钩子,哪怕在 bypassPermissions 模式下、或者加了 --dangerously-skip-permissions,也能拦住这个工具。反过来就不成立——钩子返回 "allow",并不能放松设置里的拒绝规则。钩子只能把策略收得比权限允许的更紧,绝不能放松。⁴
生命周期:每个钩子事件
截至 2026 年 9 月 6 日,参考文档记录了 33 个钩子事件。¹它们分成三种节奏:每会话一次(SessionStart、SessionEnd),每轮对话一次(UserPromptSubmit、Stop、StopFailure),以及智能体循环里每次工具调用时触发(PreToolUse、PostToolUse)。其余的则在特定条件下触发——配置变更、上下文压缩、子智能体、MCP 交互。
这些你大多用不上。生产环境里几乎所有的配置,都是由五个事件搭起来的:PreToolUse、PostToolUse、UserPromptSubmit、SessionStart 和 Stop。剩下的,等你真需要的那天再说。
Agent SDK 中的相同事件
如果你是基于 Claude Agent SDK 开发,而不是直接驱动 CLI,那么你并不会遇到另一套钩子系统。SDK 触发的是同样的事件,钩子则以回调函数(callback function)的形式注册在 agent options 的 hooks 字段里,并用同一套 matcher 语法做过滤。在 Python 中写法是 ClaudeAgentOptions(hooks={"PreToolUse": [HookMatcher(matcher="Bash", hooks=[my_callback])]});在 TypeScript 中则是传给 query() 的 options 对象上的 hooks 键。回调返回的 JSON 输出结构,和 shell 钩子打印到 stdout 的内容一模一样,所以 PreToolUse 的拒绝、PermissionRequest 的决定,在两种环境下看起来没有区别。
有两点不同。第一是覆盖范围:PreToolUse、PostToolUse、PostToolUseFailure、UserPromptSubmit、Stop、SubagentStart、SubagentStop、PreCompact、PermissionRequest、Notification 在两个 SDK 里都有;而 SessionStart、SessionEnd、Setup、PostToolBatch、PermissionDenied,以及压缩、模型切换、任务、worktree、elicitation、文件监听这些事件,在撰写本文时只有 TypeScript 版本支持。第二是分层:只要对应的 settingSources(Python 里是 setting_sources)条目处于启用状态,来自设置文件的 shell 命令钩子依然会在 SDK 应用内运行,而 query() 的默认选项就是启用的。因此,一个 SDK 应用可以继承项目 settings.json 里的钩子,再在其上叠加自己的回调。
契约:输入 JSON,输出退出码或 JSON
命令钩子从 stdin 接收 JSON,通过退出码、stdout 和 stderr 给出答复。(HTTP 钩子接收同样的 JSON 作为 POST body,并通过响应体作答。)
每个事件都会带一个公共信封——session_id、transcript_path、cwd、hook_event_name,大多数事件还有 permission_mode——再加上各自特有的字段。一个针对 Bash 命令的 PreToolUse 钩子会收到:
{
"session_id": "abc123",
"transcript_path": "/home/user/.claude/projects/.../transcript.jsonl",
"cwd": "/home/user/my-project",
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": { "command": "npm test" }
}
其他事件则替换了尾部字段:UserPromptSubmit 携带 prompt,SessionStart 携带 source(取值为 startup/resume/clear/compact/fork——第五个值随 v2.1.214 的分叉会话出现,如果某个按 source 匹配的钩子还在用旧的四个值列表,会话分叉就会被它悄悄漏掉),Stop 携带 stop_hook_active 和 last_assistant_message。在子智能体内部触发的钩子还会额外收到 agent_id 和 agent_type。²
退出码
有三种结果:²
-
退出码 0——成功。Claude Code 会解析 stdout,查找 JSON 输出字段。对大多数事件来说,stdout 只会写进调试日志;但对
UserPromptSubmit、UserPromptExpansion和SessionStart,纯文本 stdout 会作为上下文追加进去,Claude 能看到。 -
退出码 2——阻断性错误。stdout(包括其中的 JSON)会被忽略;stderr 会作为错误消息回传给 Claude。「阻断」的具体含义取决于事件类型。
-
其他任何退出码——非阻断性错误。对话记录里会出现一条
<hook name> hook error提示,执行继续。
最后一条值得加粗强调:退出码 1 不会阻断任何东西。 官方文档对此有明确警告——Claude Code 会把退出码 1 当作非阻断性错误继续执行,尽管在 Unix 惯例里 1 才是失败码。策略类钩子必须用退出码 2。²
各个事件下退出码 2 的作用:²
其余事件都无法阻断。PostToolUse 和 PostToolUseFailure 会把 stderr 展示给 Claude(工具已经执行过了);SessionStart、Notification、SessionEnd、CwdChanged、FileChanged、PostCompact、SubagentStart 和 Setup 只把 stderr 展示给用户;DirectoryAdded 只把 stderr 写进调试日志;StopFailure、InstructionsLoaded、MessageDisplay 和 PermissionDenied 直接忽略退出码——对 PermissionDenied 来说,唯一的杠杆是 JSON 里的 retry: true。²
JSON 输出
想要比「阻断或沉默」更精细的控制,就用退出码 0,并往 stdout 打印一个 JSON 对象。先说一条规则:退出码和 JSON 二选一,绝不同时用——JSON 只在退出码为 0 时才会被处理,退出码 2 会让它被丢弃。⁵
通用字段对每个事件都有效:continue: false 让 Claude 完全停下(用户会看到 stopReason),suppressOutput 从对话记录里隐藏 stdout,systemMessage 给用户显示一条警告,terminalSequence 发出一个白名单内的终端转义(桌面通知、窗口标题、响铃)。决策类字段则是按事件区分的:⁵
有两个细节容易让人踩坑。
第一,PreToolUse 是顶层决策模式的一个例外:它过去用的是顶层 decision/reason,但这两个字段在这个事件里已经废弃("approve"/"block" 现在对应 "allow"/"deny");应该改用 hookSpecificOutput.permissionDecision。
第二,多个 PreToolUse hook 意见不一致时,优先级是 deny > defer > ask > allow —— 最严格的结果胜出。即便如此,最好还是让每个决策只由一个 hook 负责,别依赖这套裁决规则。
配置:settings.json、匹配器、作用域
Hook 的配置分三层嵌套:先选一个事件,再加一个匹配器组来限定触发时机,最后定义一个或多个 hook 处理器(hook handler)。6
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "/path/to/lint-check.sh" }
]
}
]
}
配置放在哪里决定了作用域:~/.claude/settings.json 对你的所有项目生效;.claude/settings.json 属于项目级,可以提交到仓库;.claude/settings.local.json 也是项目级,但会被 git 忽略。设置本身遵循标准的优先级顺序 —— 托管策略(managed policy)高于本地配置,高于项目配置,高于用户配置。9
Hook 还可以随插件发布(放在 hooks/hooks.json),也可以写在 skill 或 agent 的 frontmatter 里。企业管理员能强制下发生效的托管 hook,用户无法覆盖。6
匹配器(matcher)按字符来判断:"*"、"" 或直接省略,都表示匹配一切;只包含字母、数字、_、-、空格、逗号和 | 的值,会被当成精确字符串或列表(比如 Bash、Edit|Write);其余情况一律视为不加锚点的 JavaScript 正则,所以 Edit.* 既能匹配 Edit,也能匹配 NotebookEdit——如果你只想要某一个工具,请用 ^Edit$ 加锚点。匹配区分大小写,而且每个事件各看各的字段:工具事件看工具名,SessionStart 看 source,SubagentStart 看 agent 类型,Notification 看通知类型。
想在工具事件上做更细的过滤,可以给单个 handler 加 if 字段,里面放一条权限规则,例如 "Bash(git *)"。但它是尽力而为的:命令解析不了就放行(fail open)。所以真要硬性保证,请用权限规则,别靠 if。
还有一个语义变化要知道:从 v2.1.214 起,if 里的单段路径模式(比如 Edit(src/**))只匹配工作目录下顶层的 src。在这个版本之前写的 if,会悄悄不再匹配 packages/app/src/ 这类嵌套路径——想要以前那种任意深度的行为,请写成 Edit(**/src/**)。
Handler 共五种类型:command(shell 命令)、http(POST 到一个端点)、mcp_tool、prompt(单轮模型评估)和 agent(有 Read/Grep/Glob 权限的子代理,实验性)。默认超时:command/http/mcp_tool 是 600 秒(UserPromptSubmit 降到 30 秒,MessageDisplay 降到 10 秒),prompt 是 30 秒,agent 是 60 秒——每个 hook 都能用 timeout 覆盖。所有匹配上的 hook 并行执行,内容完全相同的 handler 会去重。环境变量 $CLAUDE_PROJECT_DIR 指向项目根目录,方便脚本定位。
想核实配置,用 /hooks:一个只读的浏览界面,列出每个事件、它配置了哪些 hook,以及每条配置来自哪个设置文件。要改动就去编辑 JSON(也可以让 Claude 帮你改)。想临时全关,设 "disableAllHooks": true。
五种模式
下面这些例子都做了简化、去掉了具体业务。hooks 教程里给出了其中几个更完整的生产版本,另一篇《Hooks for Apple Development》则把它们用到了 iOS 工具链上。
1. 编辑后自动格式化(PostToolUse)
这条直接来自官方指南——Claude 碰过的每个文件都会被格式化,没有例外:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" }
]
}
]
}
}
换成 ruff format、gofmt 或 swiftformat 也可以,看你的技术栈。
2. 拦截危险命令(PreToolUse,退出码 2)
#!/bin/bash
# .claude/hooks/guard-bash.sh —— 注册在 PreToolUse 上,matcher 设为 "Bash"
command=$(jq -r '.tool_input.command // empty')
case "$command" in
*"rm -rf"* | *"git push --force"* | *"DROP TABLE"*)
echo "Blocked: matches a destructive pattern. Propose a safer alternative." >&2
exit 2 ;;
esac
exit 0
退出码 2 会拦下这次调用,并把 stderr 的内容回传给 Claude,让它换个思路,而不是蒙头重试。用 JSON 也能达到同样效果:permissionDecision: "deny" 加上原因说明。好处是还可以往上扩展,比如改用 "ask"(把决定权交给人类),或者用 updatedInput 重写命令。5
3. 会话启动时注入上下文(SessionStart)
SessionStart 钩子的 stdout 直接输出什么,就会成为 Claude 能看到的上下文,根本不需要写 JSON:1
#!/bin/bash
# .claude/hooks/session-context.sh —— 注册在 SessionStart 上
echo "Current branch: $(git branch --show-current)"
echo "Recent commits:"
git log --oneline -5
echo "Uncommitted files: $(git status --porcelain | wc -l | tr -d ' ')"
exit 0
这类东西用来传「动态」信息。至于固定不变的约定,应该写进 CLAUDE.md —— 官方文档本身也是这么建议的,凡是没必要专门跑脚本的上下文,都放那儿。1
4. 在 Stop 上加一道完成关卡
Claude 回答完一轮内容时会触发 Stop。把这个钩子拦下来,就能逼着智能体一直干活,直到满足某个条件为止:
#!/bin/bash
# .claude/hooks/stop-gate.sh —— 注册在 Stop 上
input=$(cat)
if [ "$(echo "$input" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0 # 已经因为这个钩子继续过一次了;别再无限循环
fi
if ! npm test --silent > /tmp/stop-gate.log 2>&1; then
jq -n '{decision: "block", reason: "Tests are failing. Fix them before finishing. Log: /tmp/stop-gate.log"}'
fi
exit 0
stop_hook_active 的检查很关键:Claude Code 默认给 Stop 钩子最多连续阻止 8 次(可以用 CLAUDE_CODE_STOP_HOOK_BLOCK_CAP 提高上限),如果闸门从没检查过自己是否已经触发过继续指令,就会一路耗尽这些额度。想要更柔和的控制,可以返回 hookSpecificOutput.additionalContext,而不是 decision: "block"——同样是继续执行,但会被标记为反馈而非钩子错误。对于一次性的条件,内置的 /goal 命令就是一个零配置、会话级别的基于提示词的 Stop 钩子。
5. 分发器:一个入口,多个小钩子
注册十个钩子意味着 settings.json 里要有十条记录,还要在不同机器和项目之间同步。替代方案是:每个事件只注册一个分发器,按约定路由。
#!/bin/bash
# .claude/hooks/dispatch.sh — 每个关心的事件注册一次
input=$(cat)
event=$(echo "$input" | jq -r '.hook_event_name')
dir="$CLAUDE_PROJECT_DIR/.claude/hooks/$event"
[ -d "$dir" ] || exit 0
for hook in "$dir"/*.sh; do
[ -x "$hook" ] || continue
echo "$input" | "$hook" || exit $?
done
exit 0
现在加一个守卫,只需在 .claude/hooks/PreToolUse/ 下新建文件并 chmod +x——settings.json 永远不用改,每个脚本都小到可以单独测试,第一个 exit 2 会向上传播。有一点要注意:分发器会把 Claude Code 本可以并行运行的钩子串行化,而且它最适合基于退出码的钩子——输出 JSON 的钩子应该保持独立,因为 stdout 必须恰好包含一个 JSON 对象。
钩子 vs CLAUDE.md vs 技能 vs 记忆
四种机制,各司其职:
失败的场景是双向的。把约定写成钩子,你会得到脆弱的脚本,去做一句话指导就能搞定的事。把策略写成 CLAUDE.md 的散文,你会得到一个在某天关键时候直接 force-push 到 main 的 agent。判断标准是:模型忽略这条规则一次,代价是什么?烦人 → 写进 CLAUDE.md。出事故 → 做成钩子。
钩子做不到的事
诚实的局限,全部来自官方文档:
-
钩子不能调用工具或斜杠命令。命令类钩子只会说 stdout、stderr 和退出码——没别的。通过
additionalContext返回的上下文会以纯文本形式注入。 -
PostToolUse 无法撤销。 工具已经跑完了。要拦截,只能用 PreToolUse。
-
Stop 每次回复结束都会触发,不只是「任务完成」时,而且用户打断时它不会触发(API 出错触发的则是 StopFailure)。所以门控逻辑必须能容忍任务中途停止。
-
普通无头模式(-p)不会触发 PermissionRequest。 在 -p 模式下,只有当 Agent SDK 的 canUseTool 回调提供了提示时才会触发,后台子代理调用工具时也会触发;其他自动化场景一律改用 PreToolUse。
-
PreToolUse 看不到 @ 引用的文件。 通过提示词里的 @ 拉进来的文件不涉及任何工具调用;要保护这类路径,得用 Read 的 deny 规则。1
-
并行修改 updatedInput 天生不可靠。 当多个 PreToolUse hook 同时改写同一个工具的参数时,只有一个改写能生效,而且你控制不了是哪一个。让每个改写都由单独一个 hook 负责。
-
超时会直接取消 hook。 命令型 hook 默认 600 秒(UserPromptSubmit 是 30 秒,MessageDisplay 是 10 秒);一个慢到超时的门控,等于没跑。
-
输出上限为 10,000 个字符——超出的部分会写到文件里,原位置换成一段预览。
-
Hook 以你的完整用户权限运行。 官方文档自己的警告是:它们「可以修改、删除或访问你的用户账户能访问的任何文件。在把它们加进配置之前,请审查并测试所有 hook 命令。」8所以给变量加引号、用绝对路径、避开敏感文件。
-
一个坏掉的 hook 会拖累每一个会话,直到修好为止。调试可以用对话记录视图(Ctrl+O)、
claude --debug-file /tmp/claude.log,或者会话中途的/debug;一个经典坑是 shell 的 profile 在启动时会输出内容,把你的 hook 的 JSON 输出搞坏。7
常见问题
Claude Code hooks 是什么?
Hook 是用户自定义的命令——可以是 shell 脚本、HTTP 接口、MCP 工具,也可以是模型提示词——Claude Code 会在特定的生命周期节点自动执行它们。3它们通过 stdin 接收事件 JSON,再用退出码或 JSON 返回结果:拦截工具调用、注入上下文、改写参数、让代理继续干活。和 CLAUDE.md 里的指令不同,hook 每次都会执行,不受模型行为影响。
PreToolUse 钩子和权限规则有什么区别?
权限规则是声明式的:静态的「允许/拒绝/询问」模式,由 Claude Code 自己判断。PreToolUse 钩子则是可编程的:由你的代码检查完整的工具输入再做决定。钩子先于权限模式检查触发,所以即使在 bypassPermissions 模式下,钩子给出的「拒绝」依然有效——但钩子给出的「允许」无法覆盖设置里的拒绝规则。⁴凡是用模式能表达的需求,就用权限规则;只有当判断需要逻辑、外部状态或改写输入时,才动用钩子。
钩子在无界面(-p)模式下能用吗?
能用,但有一点细节:PermissionRequest 钩子会跳过普通的 -p 运行(那种场景没有东西来提供权限提示),不过当 Agent SDK 的 canUseTool 回调提供提示时它会触发,对后台子代理的工具调用也会触发。普通无界面运行中的自动权限决策,应该交给 PreToolUse。⁷无界面模式还解锁了一个交互式会话会忽略的选项:permissionDecision: "defer",它会暂停工具调用,让外层进程(Agent SDK 应用、自定义 UI)去收集输入,之后再恢复会话。⁵
为什么我的钩子跑了,却拦不住任何东西?
几乎总是因为违反了约定。退出码 1 不会拦截——只有退出码 2 才会,而且只在支持拦截的事件上才有效。²JSON 决策只在退出码为 0 时才解析——如果脚本打印了 {"decision": "block"} 然后以 2 退出,这段 JSON 会被丢弃。匹配器还区分大小写——bash 永远不会匹配 Bash。先用 /hooks 确认注册成功,然后把示例 JSON 通过管道喂给脚本,检查 echo $? 看看结果。⁷
参考资料
2026 年 8 月 8 日对照官方文档核实过。钩子 API 在 Claude Code v2.1.x 各个版本间有实质性变化(新增事件、新增字段、匹配器语义调整),所以涉及版本敏感的细节,请当作「截至该日期」的信息来看。
站内相关:Claude Code 指南的钩子章节讲的是全系统视角,涵盖提示钩子和代理钩子;钩子教程给出五个生产级实例和完整配置;Apple 开发中的 Hooks 讲的是 iOS 上的实际用法;如果你还没装 Claude Code,可以先看快速上手指南。
-
Anthropic,《Hooks 参考——Hook 生命周期与 Hook 事件》。code.claude.com/docs/en/hooks#hook-events↩↩↩↩↩↩
-
Anthropic,《Hooks 参考——Hook 的输入输出;退出码输出;各事件中退出码 2 的行为》。code.claude.com/docs/en/hooks#exit-code-output↩↩↩↩↩↩↩↩
-
Anthropic,《用 Hooks 自动执行操作》。code.claude.com/docs/en/hooks-guide↩↩↩
-
Anthropic,《Hooks 指南——Hooks 与权限模式》。code.claude.com/docs/en/hooks-guide#hooks-and-permission-modes↩↩
-
Anthropic,《Hooks 参考——JSON 输出与决策控制》。code.claude.com/docs/en/hooks#json-output↩↩↩↩↩↩↩
-
Anthropic,《Hooks 参考——配置:Hook 存放位置、匹配模式、Hook 处理字段、/hooks 菜单》。code.claude.com/docs/en/hooks#configuration↩↩↩↩↩↩↩
-
Anthropic,《Hooks 指南——限制与故障排查》。code.claude.com/docs/en/hooks-guide#limitations-and-troubleshooting↩↩↩↩↩
-
Anthropic,《Hooks 参考——安全注意事项》。code.claude.com/docs/en/hooks#security-considerations↩
-
Anthropic,《Claude Code 设置》。code.claude.com/docs/en/settings↩
-
Anthropic,《Agent SDK——用 Hooks 拦截并控制智能体行为:可用 Hooks(Python 与 TypeScript 对比)、配置 Hooks、Hook 输出》。code.claude.com/docs/en/agent-sdk/hooks(核实于 2026 年 9 月 6 日)↩↩↩↩
![]()
Blake Crosley
设计师、开发者,也是一位父亲。曾任 ZipRecruiter 产品设计副总裁;941 Apps 创始人兼设计工程师,代表作有 Return、Get Bananas、Reps 和 941 Tiles。