把 Claude Code 的 hook 视作”由宿主在固定时间点自动触发的可编程拦截器”,就抓住了它的全部关键特征。三个视角同时成立:触发时机由 Claude Code 宿主在请求循环的固定节点触发,确定性来自宿主而非模型自行决定,配置层级则由 settings.json 的写入位置决定作用域。三者互相支撑,缺一个就会把 hook 退回”提示词建议”。
一、hook 是什么:一次正名
hook 是在 Claude Code 会话生命周期中,由宿主在特定时间点自动触发的 shell 命令、HTTP 端点、模型提示或代理逻辑。把它和 CLAUDE.md 里的”建议型指令”做一次区分最直观:CLAUDE.md 是概率性提示,模型”应该”做;hook 是确定性命令,宿主”必须”做。文档明确写明”hooks are deterministic because the harness runs them, not the model”,这句话把 hook 的本质点透——执行权握在 Claude Code 宿主手里。
理解这一点,就能避开一类常见误区:以为”我多写一段提示词,模型就会自动跑格式化”。模型不跑,宿主跑;宿主怎么知道要跑?靠事件触发。
二、第一个核心特征:触发时机
hook 不会自己启动,它要等到宿主宣布”某个事件发生了”才执行。Claude Code 在请求循环里安排了 25+ 种事件,按”每会话一次、每回合一次、每工具调用一次”三档节奏分布。
| 事件 | 触发节奏 | 典型用途 |
|---|---|---|
| SessionStart / SessionEnd | 每会话一次 | 注入 git 状态、加载 TODO 列表 |
| UserPromptSubmit / Stop | 每回合一次 | 追加冲刺上下文、回合收尾校验 |
| PreToolUse / PostToolUse | 每工具调用一次 | 阻断危险命令、运行格式化 |
PreToolUse 选得最多的原因藏在这张表里:它执行于”工具调用真正落地之前”,是唯一允许阻断的时机;PostToolUse 在工具跑完之后才到,错误已经发生,只能注入上下文补救。官方博客在介绍 8 种事件时反复强调”firing after Claude chooses a tool but before the tool actually executes”,这种”前/后”的语义错位是选型时最容易栽的坑。
三、第二个核心特征:确定性
确定性指”该不该跑”由宿主决定,不依赖模型判断。落地有三层证据。
第一层证据是退出码语义。PreToolUse、PermissionRequest、UserPromptSubmit、Stop 以及 config-change 类事件,退出码 2 都会阻断后续动作;PostToolUse、Notification 等事后事件,退出码 2 仅把 stderr 回灌给 Claude,已发生的事改不了。退出码是宿主读到的硬信号,不是给模型参考的”建议”。
第二层证据是匹配器过滤。matcher 字段用三种语法筛目标:* 或空字符串匹配全部;纯字母数字加 | , - 走精确字符串;包含其他字符的串走 JavaScript 正则。例如 "Edit|Write" 只对编辑类工具生效,匹配器是宿主解析,不是模型判断。
第三层证据是多 hook 合并策略。同一个事件匹配到多个 hook 时,宿主并行执行所有命令,自动去重;PreToolUse 类的权限决策按”最严格胜出”合并(deny > ask > allow)。这条规则在官方文档里写得很直白:don’t rely on one hook’s deny to suppress side effects in another hook,因为每个 hook 都真实地执行完,宿主再统一合并。
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "jq -r .tool_input.command >> ~/.claude/bash.log" },
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-rm-rf.sh" }
]
}
]
}
}
这段配置展示了三层证据同时生效:宿主在 PreToolUse 时点触发,退出码语义定义阻断,并行执行两个 hook,按最严格策略合并。
四、第三个核心特征:配置层级
配置层级决定”这条规则谁会受影响”。Claude Code 提供四档位置,从高到低分别覆盖组织、当前会话、当前项目、当前用户。
| 位置 | 作用域 | 是否可分享 | 写入方式 |
|---|---|---|---|
| 托管设置 | 组织 | 是 | 管理员下发 |
.claude/settings.json |
当前项目 | 是(提交到仓库) | 手动编辑 |
.claude/settings.local.json |
当前项目 | 否(gitignored) | 手动编辑 |
~/.claude/settings.json |
当前用户所有项目 | 否 | 手动编辑 |
另外还有 Plugin hooks/hooks.json 一档,写在外挂里、随插件分发。企业管理员可以用 allowManagedHooksOnly 把用户/项目/插件 hook 全部关掉,强制走组织统一分发的 hook 列表,托管设置下”enabledPlugins”强制启用的外挂 hook 是唯一例外。
把三特征串起来看:触发时机回答”什么时候跑”,确定性回答”怎么跑”(宿主而非模型),配置层级回答”谁被它影响”。任何一段 hook 配置写完,先对这三个问题逐项自问一遍,比对着模板抄更不易翻车。
五、把三特征落到一次落地
按下面的步骤把”阻断 rm -rf”这条规则装到项目里,能完整走过三个特征:
- 在仓库根目录建
.claude/hooks/block-rm.sh,内容是从 stdin 读tool_input.command,命中rm -rf时输出permissionDecision: "deny",否则exit 0; - 把上面那段
PreToolUse配置写进.claude/settings.json,matcher用"Bash"收窄,$CLAUDE_PROJECT_DIR引用仓库根; - 在
.claude/settings.local.json写一份个人覆盖版本,仅匹配本机路径,留给本地调试; - 故意让 Claude 跑一次
rm -rf /tmp/build,观察宿主是否先并行执行两个 hook、再按 deny 阻断、最后在上下文回灌permissionDecisionReason; - 跑一次
rm file.txt,确认未命中模式时 hook 静默通过,不污染对话。
效果:危险命令在执行前被宿主拦截;普通命令不会被额外打扰。注意点:matcher 不要写 *,否则连文件写入都会被无谓触发;hook 命令建议放仓库内脚本,方便团队复用。
六、易混淆点对照
下面把和 hook 长得像但本质不同的两件事放一起,避免混淆。
| 项 | 执行者 | 是否依赖模型 | 退出码生效 |
|---|---|---|---|
| Hook | 宿主 | 否 | 是 |
| CLAUDE.md 指令 | 模型 | 是 | 否 |
| PostToolUse 注入上下文 | 宿主(事件后) | 否 | 仅回灌 stderr |
最后这栏尤其关键:PostToolUse 跑在”事件之后”,退出码 2 不能阻止工具执行,只能让宿主把 stderr 塞回给 Claude 看到。所以拿 hook 做”事后阻断”是徒劳的,要阻断必须选 PreToolUse 或 PermissionRequest。
常见问题(FAQ)
Q1:hook 和 CLAUDE.md 指令会冲突吗?
不会。hook 由宿主强制执行,CLAUDE.md 是模型参考建议,宿主永远先按 hook 决定。
Q2:matcher 写 * 会出什么问题?
宿主会对所有事件触发该 hook,无谓开销陡增,且 PreToolUse 类的退出码 2 会批量阻断正常工具调用。
Q3:PostToolUse 退出码 2 能回滚工具吗?
不能。PostToolUse 跑在工具完成之后,退出码 2 只把 stderr 注入上下文,工具动作已经发生。