前情提要
- Hook 是在某个事件发生时自动执行的 shell 命令,写在
settings.json的hooks里:json{ "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [{ "type": "command", "command": ".claude/hooks/block-push.sh" }] } ] } }
- Hook 从标准输入拿到一段 JSON(事件名、工具名、参数……),用退出码表态:
退出码 意思 0放行 2阻断,stderr 的内容交给模型 其他 出错了,但不阻断,只提示你
- 常见用途:拦下
git push、改完文件自动格式化、结束前检查测试有没有过- 一句常听到的话:「CLAUDE.md 是建议,Hook 是规定」
Hook 怎么写,你可能已经会了。这一节回答另外几个问题:它挂在循环的哪里?凭什么模型绕不过去?它说「放行」,是不是就能绕开你的 deny 规则?
代码主要在 src/utils/hooks.ts(执行 Hook)和 src/services/tools/toolHooks.ts(Hook 和权限怎么配合)。
一、Hook 挂在循环的哪些缝隙

Hook 就是挂在循环「缝隙」里的脚本。把第2节的循环和第3节的七道关画在一起,标出这些缝隙:
SessionStart ────────► 会话开始;压缩之后也会再触发一次(source = compact)
UserPromptSubmit ────► 你按下回车之后、发给模型之前
while (true) {
调模型
├─ 有工具调用(toolExecution.ts):
│ 参数校验
│ PreToolUse ─────► 校验之后、权限判定之前
│ 权限判定
│ 执行工具
│ PostToolUse ────► 执行成功之后(失败则是 PostToolUseFailure)
│
└─ 没有工具调用(query.ts):
Stop ───────────► 准备结束时;exit 2 → 把反馈塞回消息,continue 再转一圈
}
PreCompact / PostCompact ──► 压缩前后
SubagentStart / SubagentStop ──► 子代理开始、结束
SessionEnd ──────────► 会话结束
最常用的是三个:工具调用前(PreToolUse)、工具调用后(PostToolUse)、准备结束时(Stop)。
Hook 不在模型里面,而是挂在循环的缝隙上,由 Claude Code 自己执行。
二、PreToolUse 为什么拦得住

看一下 PreToolUse 的位置:参数校验之后、权限判定之前。
这个位置决定了它为什么拦得住:
- 工具还没开始执行,Hook 说不行,后面的步骤就都不走了
- 它在权限判定之前,拦下来就不用再弹窗问你
- 它在参数校验之后,拿到的参数一定是格式正确的,脚本不用处理乱七八糟的输入
而且整个过程,模型完全插不上手。它能做的只是「提出要调用某个工具」,这个请求要不要执行,是循环里的代码说了算,Hook 就是这段代码的一部分。
Hook 排在执行之前、跑在模型之外,所以模型绕不过去。
三、退出码:三个分支
前情提要里那张退出码表,在 src/utils/hooks.ts 里就是三个分支:
// src/utils/hooks.ts(简化)
if (result.status === 0) {
return { outcome: 'success' } // 成功:输出记下来,不干预
}
if (result.status === 2) { // 阻断:把 stderr 的内容作为错误反馈给模型
return { blockingError: `[${hook.command}]: ${result.stderr || 'No stderr output'}` }
}
return { outcome: 'non_blocking_error' } // 其他非零:不算严重错误,只在界面上提示注意第三个分支:退出码是 1、127 这类「普通出错」时,不会阻断。脚本自己写错了、某个命令没装,Agent 照样往下跑,只是界面上提示一下。
这意味着:想拦,就必须明确 exit 2。 脚本中途出错退出,不等于拦下了。
四、Stop Hook:让循环再转一圈

Stop Hook 返回 exit 2 时,循环里发生的事情就是第2节那张表里的 stop_hook_blocking:
// src/query.ts(简化)
if (stopHookResult.blockingErrors.length > 0) {
state = {
messages: [...messages, ...assistantMessages, ...stopHookResult.blockingErrors],
stopHookActive: true, // 下一次 Stop Hook 收到的输入里,stop_hook_active 就是 true
transition: { reason: 'stop_hook_blocking' },
}
continue // 不结束,再转一圈
}模型说「我做完了」,Stop Hook 说「还没有」,它的 stderr 被塞回消息里,循环接着转。