前七课里,你影响 Claude 的手段全是「说给它听」:CLAUDE.md、plan、Skills。hook 换了一条路——不跟模型商量,直接在它的执行流程上插入确定性执行的 shell 命令。格式化、拦截敏感文件、完工前强制跑测试,都属于这一课。
为什么这天学这个
这课直接踩在两块已有的地基上。D3(权限模式与沙箱)讲的是静态门禁:一条工具调用「允许还是不允许」。hook 在同一条链路上给你第二种控制:在调用前后运行你的代码,能拦截、能改写、能反馈——而且一个返回 deny 的 PreToolUse hook 连 bypassPermissions 模式都拦得住,这是权限规则做不到的角度。D7(Skills)留下的那条对比线在本课收口:Skill 是「教会模型怎么做」,遵不遵守终究由模型决定;hook 是「不管模型怎么想都会发生」。
往后看,hook 会反复出现:D9(MCP)的工具同样能被 hook 匹配(mcp__服务名__工具名);D13(Headless 与 CI)里 hook 是无人值守时唯一可靠的守门机制;D14(Plugins)则把 hook 打包分发给整个团队。跳过这课,后面这三课都会缺一块。
概念讲清楚
官方hook 是用户定义的 shell 命令(也可以是 HTTP 端点或 LLM prompt),Claude Code 在其生命周期的特定节点自动运行它们,给你确定性控制:某些动作一定会发生,而不是寄希望于模型「自觉想起来去做」。官方文档同时给出选型原则:能写成确定性规则的用 command hook;需要判断力的决定,可以用 type: "prompt"(单轮模型评估)或实验性的 type: "agent"(带工具的子代理校验)。
一张表分清:提示、门禁、强制
本站观点把前几课的机制放在一起对照,hook 的位置就清楚了:
| 机制 | 本质 | 谁来执行 | 模型能忽略吗 |
|---|---|---|---|
| CLAUDE.md / Skills(D4、D7) | 写给模型看的指示 | 模型读了以后照做 | 能——它是提示,不是约束 |
| 权限规则(D3) | 静态的 allow / ask / deny 门禁 | Claude Code 在调用工具时查表 | 不能,但只能「放行或拦下」,不能附加动作 |
| Hooks(本课) | 挂在生命周期节点上的你的代码 | Claude Code 无条件运行 | 不能——模型根本不参与,还能拦截、改写、反馈 |
hook 插在 agentic loop 的哪里
官方事件按三种节奏触发:每会话一次(SessionStart / SessionEnd)、每轮一次(UserPromptSubmit、Stop)、以及 agentic loop 里每一次工具调用都触发的 PreToolUse / PostToolUse。下图是主干路径:
常用事件清单
官方参考文档共列出 31 个事件,下面是最常用的一批;每个事件可配 matcher 过滤(工具事件按工具名匹配,如 Bash、Edit|Write、正则 mcp__.*;留空则每次都触发,匹配区分大小写):
| 事件 | 何时触发 | 典型用途 |
|---|---|---|
SessionStart | 会话开始或恢复时(matcher 可区分 startup/resume/compact 等) | 注入上下文,压缩后补回关键信息 |
UserPromptSubmit | 你提交 prompt 后、Claude 处理前 | 校验或拦截 prompt,附加环境信息 |
PreToolUse | 每次工具调用执行前,可拦截 | 保护敏感文件,禁止危险命令 |
PermissionRequest | Claude Code 准备向你弹权限询问时 | 代替你自动批准特定请求 |
PostToolUse | 每次工具调用成功后(已执行,不可撤销) | 自动格式化,记录审计日志 |
Notification | Claude Code 发通知时(等你输入、等权限) | 桌面通知,不用盯终端 |
Stop / SubagentStop | Claude(或子代理)结束本轮回复时,可打回 | 完工校验:测试没过不许停 |
PreCompact / SessionEnd | 上下文压缩前 / 会话终止时 | 备份转录、清理临时文件 |
配置写在哪里
官方hook 写在 settings 文件的 hooks 键下,三层嵌套:事件名 → matcher 分组 → 处理器数组。下面是官方的「编辑后自动跑 Prettier」示例,放进项目根的 .claude/settings.json 即生效:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
}
放在哪个文件决定作用范围:
| 位置 | 作用范围 | 可共享 |
|---|---|---|
~/.claude/settings.json | 你的所有项目 | 否,仅本机 |
.claude/settings.json | 单个项目 | 是,可提交进仓库 |
.claude/settings.local.json | 单个项目 | 否,默认被 gitignore |
| 企业托管策略设置 | 整个组织 | 是,管理员控制 |
插件 hooks/hooks.json | 插件启用期间 | 是,随插件分发(D14) |
| Skill / agent 的 frontmatter | 该组件激活期间 | 是,写在组件文件里 |
会话内输入 /hooks 可以按事件浏览当前生效的全部 hook——注意这个菜单是只读的,增删改要直接编辑 settings JSON(或让 Claude 替你改)。想一键全关,在 settings 里设 "disableAllHooks": true(托管设置里的 hook 除外)。
退出码与输出如何影响流程
官方事件触发时,Claude Code 把事件数据以 JSON 送进你脚本的 stdin(含 session_id、cwd、hook_event_name,工具事件还有 tool_name、tool_input);你的脚本用退出码回话:
| 退出码 | 含义 | 效果 |
|---|---|---|
0 | 无异议 | 动作正常继续。对 PreToolUse 这不等于批准,正常权限流程照走;UserPromptSubmit 和 SessionStart 的 stdout 会注入 Claude 的上下文 |
2 | 阻塞 | 拦下动作,stderr 作为原因反馈给 Claude 让它调整。具体效果随事件而异:PreToolUse 拦截工具调用,Stop 阻止收工,PostToolUse 因为工具已执行只能反馈不能撤销 |
| 其他 | 非阻塞错误 | 动作照常执行,transcript 里显示一条 hook error 通知 |
需要比「拦 / 不拦」更细的控制时,exit 0 并向 stdout 打印一个 JSON 对象:PreToolUse 可返回 permissionDecision 为 "allow"(跳过权限询问)、"deny"(拦截并把 permissionDecisionReason 告诉 Claude)或 "ask"(转给用户确认);Stop / PostToolUse 用顶层 {"decision": "block", "reason": "..."}。两种方式二选一:exit 2 时 stdout 里的 JSON 会被忽略。
三个高频误区
- 官方「exit 1 也算失败,应该会拦截吧」——不会。多数事件只有 exit 2 阻塞,exit 1 是非阻塞错误,动作照常执行。要执行策略,必须写
exit 2。 - 官方「hook 放行了就等于有权限」——方向搞反了。hook 的 deny 比权限模式更硬(
bypassPermissions下照样拦),但 hook 的 allow 压不过 settings 里的 deny 规则:hook 只能收紧,不能放宽,D3 的门禁始终在。 - 本站观点「Stop hook 会不会把 Claude 锁死在干活循环里」——官方留了保险:输入里有
stop_hook_active字段供脚本自查,且连续阻塞 8 次无进展后 Claude Code 会强制结束本轮。写 Stop hook 第一行就该检查这个字段。
当天能做完的实操
目标:30 分钟配好两个 hook——一个审计日志(PostToolUse),一个敏感文件拦截(PreToolUse)——并亲眼看到拦截发生。示例用 jq 解析 JSON,先确认 jq --version 有输出(macOS 用 brew install jq)。
- 在一个测试项目里新建
.claude/settings.json,写入两个 hook:{ "hooks": { "PostToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "jq -r '.tool_input.command' >> ~/.claude/command-log.txt" } ] } ], "PreToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh" } ] } ] } } - 创建拦截脚本
.claude/hooks/protect-files.sh(内容照抄官方示例),并执行chmod +x .claude/hooks/protect-files.sh:#!/bin/bash INPUT=$(cat) FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') PROTECTED_PATTERNS=(".env" "package-lock.json" ".git/") for pattern in "${PROTECTED_PATTERNS[@]}"; do if [[ "$FILE_PATH" == *"$pattern"* ]]; then echo "Blocked: $FILE_PATH matches protected pattern '$pattern'" >&2 exit 2 fi done exit 0 - 启动 Claude Code,输入
/hooks。预期:PostToolUse 和 PreToolUse 下各出现 1 个 hook,能看到事件、matcher、来源文件和命令。 - 触发日志 hook:让 Claude 随便跑条命令(比如「列出当前目录文件」),然后
cat ~/.claude/command-log.txt。预期:文件里多了刚执行的命令——hook 成功时界面上不显示任何东西,看效果才是验证方式。 - 触发拦截 hook:先
touch .env,再让 Claude「给 .env 加一行注释」。预期:编辑在执行前被拦下,Claude 收到脚本 stderr 里的 Blocked 原因并向你解释它换了做法或放弃。 - 懒人路线:官方明确说可以让 Claude 替你写 hook。把下面这段直接粘给 Claude Code,让它自己配置、自己演示:
帮我在当前项目配置两个 hook,写进 .claude/settings.json:
1. PostToolUse + matcher "Bash":用 jq 提取 .tool_input.command,追加到 ~/.claude/command-log.txt;
2. PreToolUse + matcher "Edit|Write":调用 .claude/hooks/protect-files.sh,脚本检查文件路径是否命中 .env、package-lock.json、.git/,命中就向 stderr 输出原因并 exit 2 拦截,否则 exit 0。
脚本要 chmod +x。配置完成后:先解释这份 JSON 的三层结构(事件 → matcher → 处理器),然后跑一条测试命令验证日志 hook,最后尝试编辑 .env 演示一次拦截。
验收标准
- 能不看笔记讲清 hook 与 Skill 的本质区别(确定性执行 vs 模型自觉),以及 hook 与 D3 权限规则的关系(deny 更硬、allow 不能越权)。
- 能为需求对号入座:自动格式化用哪个事件?保护敏感文件用哪个?「测试没过不许收工」用哪个?(PostToolUse / PreToolUse / Stop)
- 实操两个 hook 都验证过:命令日志有新行;
.env编辑被拦截且 Claude 收到了原因;/hooks里能找到它们。 - 自测题:PreToolUse hook 脚本以 exit 1 退出,工具调用会被拦截吗?exit 0 呢?
permissionDecision: "deny")才拦截;exit 1 是非阻塞错误,动作照常执行;exit 0 表示「无异议」,调用继续走正常权限流程——不等于批准。答不上来就回读「退出码与输出」一节。