PreToolUse Hook 通过”读 stdin JSON + 退出码 + 可选 stdout JSON”三件套,实现对工具调用前的确定性拦截,stderr 文本与 permissionDecisionReason 共同构成反馈通道,让 Claude 看见阻断原因并修正行为。这套机制不依赖模型”听话”,因此在团队安全策略里属于”必须绕开它,否则工具根本跑不起来”的硬性护栏。
一、为什么 PreToolUse 是”确定性拦截”的位置
Claude Code 是按事件驱动的代理主循环:用户发消息 → 模型决定要调用哪个工具 → 主循环把工具调用交给对应执行器。Hooks 是一组在主循环固定位置触发的”外部回调”,每个事件点都有明确语义。
| 事件点 | 触发位置 | hook 能决定什么 |
|---|---|---|
| PreToolUse | 工具执行前 | 阻断、改写、放行 |
| PostToolUse | 工具执行成功后 | 注入上下文、报错让 Claude 重试 |
| Stop / SubagentStop | 代理完成一轮 | 阻止回合结束 |
| Notification | 通知发送时 | 仅显示 |
| SessionStart / SessionEnd | 会话生命周期 | 注入环境/清理 |
把 Hook 想成 Git 里的 pre-commit 钩子:在提交真正落地之前执行你的脚本,脚本返回错误就拦下。PreToolUse 的位置相当于”工具提交到执行器前”——只在这一个时点你拥有 100% 的话语权。
二、PreToolUse 拦截的标准流水线
完整的 PreToolUse 拦截流程是五步:
- 模型发出”调用工具 X、参数 Y”的请求;
- 主循环把请求序列化为 JSON,通过 stdin 喂给 hook 进程;
- hook 进程解析 JSON、判断是否允许、决定退出码;
- 主循环读取退出码:0 走常规权限流程、2 阻断、其他视为非阻塞错误;
- 阻断时,stderr 文本与 JSON 中的
permissionDecisionReason一起作为反馈注入到模型上下文。
{
"session_id": "abc123",
"cwd": "/Users/sarah/myproject",
"hook_event_name": "PreToolUse",
"tool_name": "Edit",
"tool_input": {"file_path": "/repo/.env", "old_string": "X", "new_string": "Y"}
}
hook 脚本能用 jq -r '.tool_input.file_path' 拿到绝对路径(tool_input.file_path 永远是绝对路径),再用 jq -r '.tool_input.command' 拿到 Bash 工具的命令字符串。
三、退出码语义:0 / 2 / 其他
退出码是 hook 表达”我要不要拦”的最快通道,三类退出码语义完全不同。
| 退出码 | 含义 | 适用事件 | 反馈路径 |
|---|---|---|---|
| 0 | 成功 / 无意见 | 所有事件 | 走常规权限流程;stdout JSON 被解析 |
| 2 | 阻断 | 仅支持阻断的事件 | stderr 文本送给 Claude |
| 其他非 0 | 非阻塞错误 | 所有事件 | transcript 显示 hook error,工具照常执行 |
注意退出码 2 只能”阻断”工具,不能自动放行——如果想让 hook 跳过权限弹窗直接放行,必须用退出码 0 + stdout JSON 的 permissionDecision: "allow" 形式。
四、用退出码 2 阻止编辑受保护文件
下面这段配置实现一个常见需求:阻止 Claude 编辑 .env、secrets/、.git/ 等敏感路径。它走”退出码 2 + stderr 解释”的极简通道。
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "python3 -c \"import json,sys; d=json.load(sys.stdin); p=d.get('tool_input',{}).get('file_path',''); bad=any(x in p for x in ['/.env','/secrets/','/.git/']); sys.stderr.write('BLOCKED: protected path '+p+'\\n' if bad else ''); sys.exit(2 if bad else 0)\""
}
]
}
]
}
}
hook 进程内的工作分四步:
- 读 stdin 拿到整个事件 JSON;
- 用
jq或 Python 解析tool_input.file_path; - 命中黑名单时往 stderr 写明原因,方便模型理解;
exit 2退出,主循环把工具调用拦下、把 stderr 喂给 Claude。
效果:Claude 看到”BLOCKED: protected path /repo/.env”这样的反馈,会改用其他路径或换工具;如果它继续尝试同一条命令,hook 会再次拦下,循环往复直到模型放弃或重写路径。
五、用 JSON 输出做精细放行与改写
退出码只能表达”放行/阻断”两种态度,要更细的控制(自动放行、改写命令、追加上下文),必须用退出码 0 + stdout JSON。PreToolUse 的结构化输出字段如下。
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "use rg instead of grep for better performance"
}
}
permissionDecision 四个值在 PreToolUse 上的语义:
allow:跳过交互式权限弹窗,但仍受 deny 规则约束;deny:取消工具调用,把permissionDecisionReason送给 Claude;ask:走正常人工审批弹窗;defer:在-p非交互模式下保留调用,由外层 Agent SDK 决定。
如果想在阻断的同时把命令改写成安全版本,可以同时塞 updatedInput:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "rewrote rm to a safer variant",
"updatedInput": {"command": "rm -i risky-file"}
}
}
注意两条硬性规则:退出码 2 与 stdout JSON 不能混用(退出 2 时 Claude Code 会忽略 JSON);deny 始终胜出,即使 hook 返回 allow 也会被团队级 deny 规则压过。
六、PostToolUse 的”伪阻断”——错误反馈让 Claude 重试
很多人误以为 PostToolUse 也能阻断工具,其实它在 Claude Code 主循环里”晚了一步”:工具已经执行完成,hook 拿到的 tool_response 是结果。但它仍能通过 decision: "block" 顶层字段,把”hook 拒绝接受这个结果”告诉 Claude,让模型在下一轮里重做或修正。
{
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"decision": {"behavior": "block", "reason": "test output contains errors, please rerun"}
}
}
把 PostToolUse 当成”事后把关人”:工具跑完了,但它跑得不对、不安全、留下垃圾,可以在这里喊停。PreToolUse 才是”事前把关人”。两者配合,构成本地闭环。
七、落地受保护文件拦截的工程实践
把上面的零散片段组装成可维护的策略,建议遵循四步法:
- 黑白名单分文件:把”绝对不能动”放进 PreToolUse 硬阻断,把”可以动但要二次确认”放进 PermissionRequest;
- 退出码 + JSON 双通道:默认用退出码 2 + stderr 应对简单场景,遇到需要改写参数或加上下文时切到 JSON 通道;
- matcher 收紧命中范围:用
Edit|Write|MultiEdit限定到文件编辑类工具,避免误命中 Read; - 测试 + 日志:先用
claude --debug跑一遍,观察 hook 触发是否符合预期,再用/hooks命令做交互式配置。
# 用 debug 模式验证 hook 行为
claude --debug
# 在 transcript 里查看 "hook error" 之外是否还有 "hook stderr"
# 全量 stderr 写入 debug log
最容易踩的坑是 hook 自身写错(路径匹配失败、JSON 解析报错)而没有 fail loudly——多 matcher 并发执行时,其中一个 hook 失败不会阻断其他 hook,所以一定要在本地逐条跑通再进 settings.json。
八、阻断机制的边界
最后回到边界问题:PreToolUse 阻断很硬,但仍有几处绕不开的限制。
- hook 自身有 600 秒默认超时,长任务会按非阻塞错误处理;
- hook 跑在本地用户权限下,恶意 hook 可以泄露数据——务必审查 settings.json 提交记录;
- hook 阻断只对注册过的事件点生效,模型如果绕开工具直接生成文件内容,hook 无法拦;
- 退出 2 在 Notification / SessionStart 等事件上只会显示 stderr,工具照常推进。
把 PreToolUse 当作”硬墙”、PostToolUse 当作”软补刀”、PermissionRequest 当作”人工兜底”,三层叠加才是完整的确定性拦截体系。
常见问题(FAQ)
Q1:退出码 2 和 JSON permissionDecision 哪个更稳?
退出码 2 更轻量、依赖少;JSON 更灵活、可改写参数。两者不能混用,混用会被忽略。
Q2:PreToolUse 拦下后 Claude 会看到原因吗?
会。stderr 文本与 permissionDecisionReason 字段都会作为反馈送进模型上下文。
Q3:怎么防止 Claude 绕过 hook?
把受保护文件拦截写在 .claude/settings.json(项目级)和 ~/.claude/settings.json(用户级),并禁止随意修改;同时配合团队 managed settings。