Hooks 系统只在特定生命周期事件上具备”阻断工具调用”的能力,并不是所有事件都能拦下工具;按官方语义,能阻断的事件集中在 PreToolUse、PostToolUse、UserPromptSubmit、Stop、SubagentStop、PermissionRequest 等少数节点,每个事件通过”退出码 + JSON 输出”两种通道向 Claude Code 表达决策。理解”哪一类事件能拦、怎么拦、拦下后会发生什么”,是写团队级安全策略的前提。
一、先把 Hook 事件全貌摆出来
Claude Code 的 Hook 事件覆盖了会话从启动到关闭的完整生命周期。按”是否能阻断主流程”做一次切分,会更直观。
| 事件名 | 触发时机 | 是否能阻断 | 典型用途 |
|---|---|---|---|
| PreToolUse | 工具执行前 | 是 | 策略守卫、改写参数、强制审批 |
| PostToolUse | 工具成功完成后 | 部分(仅错误反馈) | 自动格式化、注入额外上下文 |
| PostToolUseFailure | 工具执行失败后 | 否 | 失败重试建议、日志 |
| UserPromptSubmit | 用户提交提示后 | 是 | 注入上下文、阻断危险请求 |
| PermissionRequest | 权限弹窗展示时 | 是 | 自动放行或升级审批 |
| Stop | 主代理停止时 | 是(block 字段) | 阻止过早结束(loop 模式) |
| SubagentStop | 子代理停止时 | 是 | 校验子代理成果 |
| Notification | 通知发送时 | 否 | 桌面提醒、Slack 通知 |
| SessionStart | 会话开始/恢复 | 否 | 注入环境信息 |
| SessionEnd | 会话结束 | 否 | 清理与归档 |
| PreCompact / PostCompact | 压缩前后 | 否 | 备份会话、打印摘要 |
注:Claude Code 早期文档把 PostToolUse 描述为”能阻断”实为”只能返回错误反馈让 Claude 调整”,并非真正阻止已完成的工具结果;PreToolUse 才是真正能在工具执行前按下刹车的位置。
二、能阻断 vs 不能阻断的根本差异
事件是否”能阻断”取决于它在代理主循环里插入的时机点:发生在动作执行前的事件,hook 有机会拒绝或改写;发生在动作完成后的事件,hook 只能做事后补救。
- 发生在前的:PreToolUse、UserPromptSubmit、PermissionRequest;
- 发生在后但能”反向修正”的:PostToolUse(错误反馈让模型重试)、Stop(用 decision:block 阻止回合结束);
- 纯通知型:Notification、SessionStart、SessionEnd、PreCompact、PostCompact。
写策略前,先把事件按”前/后”排清楚,PreToolUse 留给”绝不能放过去的红线”,PostToolUse 留给”放过去了但要补一刀的修复”。
三、PreToolUse 的三种决策路径
PreToolUse 是 Hooks 系统里最关键的事件,官方为它定义了三种决策表达方式:退出码、JSON permissionDecision、JSON updatedInput 改写。
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|Edit|Write",
"hooks": [
{
"type": "command",
"command": "python3 -c \"import json,sys; d=json.load(sys.stdin); p=d.get('tool_input',{}).get('file_path','') or d.get('tool_input',{}).get('command',''); sys.exit(2 if any(x in p for x in ['.env','secrets/','credentials']) else 0)\""
}
]
}
]
}
}
当 hook 进程退出时,Claude Code 按下面三种语义处理:
- 退出码 0 表示”无意见”,PreToolUse 不会自动放行——还要走常规权限流程;
- 退出码 2 表示”阻断工具调用”,stderr 内容会作为反馈送给 Claude,让它调整;
- 退出码非 0 非 2 表示”非阻塞错误”,transcript 显示 hook error 但工具调用照常执行。
如果想要更精细的控制(自动放行、改写命令、给 Claude 补充上下文),可以在退出码 0 的同时,往 stdout 输出结构化 JSON:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "auto-approved by policy",
"updatedInput": {"command": "npm run lint -- --fix"}
}
}
permissionDecision 有四个值:allow 跳过交互式弹窗(仍受 deny 规则约束)、deny 阻断并把原因给 Claude、ask 走正常人工审批、defer 在 -p 非交互模式下保留工具调用由外层 SDK 决定。要注意 deny 比 allow 优先级高——团队 managed deny 规则始终胜出。
四、其他能阻断的事件与决策方式
不同事件走不同的决策通道,混用会失效。
| 事件 | 阻断字段 | 阻断后的反馈路径 |
|---|---|---|
| PreToolUse | hookSpecificOutput.permissionDecision = "deny" |
permissionDecisionReason → Claude |
| PreToolUse | exit 2 |
stderr → Claude |
| PostToolUse | decision: "block"(顶层字段) |
reason → Claude 作为反馈 |
| Stop | decision: "block" |
reason → Claude,强制继续工作 |
| SubagentStop | decision: "block" |
reason → Claude |
| UserPromptSubmit | 退出码 2 或 JSON decision.behavior = "block" |
stderr → Claude |
| PermissionRequest | hookSpecificOutput.decision.behavior = "allow/deny/ask" |
走权限流程 |
PostToolUse 的”阻断”与 PreToolUse 不一样:工具已经执行完,hook 返回 block 只是把”hook 拒绝接受这个结果”的信息告诉 Claude,让它修正后续行为;而 PreToolUse 的 deny 是真的不让工具跑。
五、把”能阻断”这件事落到配置里
下面这段配置展示一个精简可用的”高安全等级”策略:用 PreToolUse 拦危险命令、用 PostToolUse 自动 lint、用 Stop 防早停。
{
"hooks": {
"PreToolUse": [
{"matcher": "Bash", "hooks": [
{"type": "command", "command": "jq -r '.tool_input.command' | grep -qE 'rm -rf|DROP TABLE|sudo' && { echo 'Blocked by policy' >&2; exit 2; } || exit 0"}
]},
{"matcher": "Edit|Write", "hooks": [
{"type": "command", "command": "jq -r '.tool_input.file_path' | grep -qE '\\.env|secrets/|\\.git/' && { echo 'sensitive file' >&2; exit 2; } || exit 0"}
]}
],
"PostToolUse": [
{"matcher": "Edit|Write|MultiEdit", "hooks": [
{"type": "command", "command": "jq -r '.tool_input.file_path' | xargs -I{} npx prettier --write {}"}
]}
],
"Stop": [
{"hooks": [
{"type": "prompt", "prompt": "Are all tasks complete? Return {ok:true} or {ok:false, reason:'why not'}", "timeout": 30}
]}
]
}
}
关键点:PreToolUse 退出 2 直接阻断;PostToolUse 用 type: command 跑格式化;Stop 用 type: prompt 让模型判断是否要继续,达成”循环模式”效果。
六、阻断机制的边界与坑
Hook 阻断非常强力,但也并非万能,几个常见的边界要先认清:
- 同一事件多个 hook 并发执行:PreToolUse 上挂了多个 matcher,命中后会并发跑,按”deny 胜出”投票;
- 退出码与 JSON 不能混用:退出 2 时 Claude Code 直接忽略 stdout 的 JSON;要 structured 决策必须用退出 0 + stdout JSON;
- deny 规则始终胜出:即使 hook 返回
allow,被团队 managed deny 规则命中的工具调用依然会被拦下; - Notification / SessionStart 等事件不能被阻断:退出 2 在这些事件上只会把 stderr 显示给用户,工具流程照常推进;
- hook 自身有超时:command 类型默认 600 秒超时,超时后会按非阻塞错误处理。
把 Hook 看作”在固定时间点插入的、跑在你自己环境里的小程序”——它的阻断是确定性的,但只覆盖它注册的那个事件点,没注册的位置无法被它拦下。
常见问题(FAQ)
Q1:所有 Hook 事件都能阻断工具吗?
不能。只有 PreToolUse、UserPromptSubmit、PermissionRequest、Stop、SubagentStop 等少数事件能阻断;Notification、SessionStart、SessionEnd 仅做通知。
Q2:退出码 2 一定能阻断吗?
只在支持阻断的事件上生效。Notification、SessionStart 等事件退出 2 只会把 stderr 展示给用户,工具继续执行。
Q3:PreToolUse 用 JSON 和退出码 2 哪个更好?
看场景。要简单拦截危险命令,退出 2 + stderr 最直接;要自动改写参数或注入额外上下文,用退出 0 + stdout JSON。注意两者不能混用。