2-2 Hooks 深度
Hooks 是什么,解决什么问题
模型决定调用一个工具,到工具真正执行——中间有一个缝隙。
Hook 就活在这个缝隙里。
没有 hook 的世界:模型说"我要删这个文件",工具直接执行,你只能事后看日志。
有 hook 的世界:模型说"我要删这个文件",hook 先拿到这个意图,检查、记录、决定放不放行——然后才到执行。
这是整个 harness 里唯一的强制介入点,其他层(prompt、权限配置)都可能被绕过或误判,hook 不会——它不经过模型,是纯工程逻辑。
PreToolUse Hook:核心机制
Claude Code 的 hook 系统里,最重要的是 PreToolUse。
触发时机:模型产生工具调用意图之后、工具实际执行之前。
拿到的信息:
- 工具名称(
Write、Bash、Edit…) - 完整入参(要写的文件路径、要执行的命令、具体内容…)
能做的事:
- 记录日志
- 校验参数
- 修改参数(谨慎用)
- 拦截,阻止执行
退出码 2:拦截的语义
Hook 脚本通过退出码告诉 Claude Code 下一步怎么处理:
| 退出码 | 语义 |
|---|---|
0 | 放行,继续执行工具 |
1 | hook 自身出错,通常当作警告处理 |
2 | 主动拦截,工具不执行,原因返回给模型 |
退出码 2 是关键。它的语义是:"我看到你想做什么了,不行,原因如下。"
模型会收到 hook 输出的拒绝原因,可以据此调整策略或向用户汇报——整个过程不需要人介入。
实操:写一个阻断危险操作的 Hook
场景:禁止 agent 执行任何包含 rm -rf 的 bash 命令。
#!/usr/bin/env python3
# hooks/pre_tool_use.py
import json
import sys
def main():
# Claude Code 通过 stdin 传入工具调用信息
payload = json.load(sys.stdin)
tool_name = payload.get("tool", "")
tool_input = payload.get("input", {})
# 只检查 Bash 工具
if tool_name == "Bash":
command = tool_input.get("command", "")
if "rm -rf" in command:
# 输出拒绝原因(模型会读到这条信息)
print(f"[BLOCKED] 危险命令被拦截: {command}")
print("不允许执行 rm -rf,请改用更安全的删除方式,或明确列出目标路径。")
sys.exit(2) # 退出码 2 = 主动拦截
# 其余情况放行
sys.exit(0)
if __name__ == "__main__":
main()
在 .claude/settings.json 里注册:
{
"hooks": {
"PreToolUse": [
{
"command": "python3 hooks/pre_tool_use.py"
}
]
}
}
Hook 的几个实用模式
审计日志:不拦截,只记录所有工具调用到文件。出了问题可以完整回放 agent 的每一步操作。
参数白名单:只允许写特定目录,其余路径一律 exit 2。比权限配置更细粒度,可以写任意业务逻辑。
预算守门:统计当前 session 已调用工具次数/估算花费,超过阈值就拦截并通知。
人工审批门:对高风险操作(push 代码、发请求到外部服务),exit 2 并输出"需要人工确认,请在终端输入 y 继续"——把无人值守临时切回人工介入,而不是让操作直接执行。
一个容易犯的错误
Hook 逻辑过于复杂,自己成了故障点。
Hook 挂掉(比如脚本崩溃、超时),Claude Code 的处理取决于配置——可能当作 exit 1 处理(放行),也可能阻断整个流程。
原则:hook 逻辑要保持轻量,只做判断,不做复杂计算。复杂的审批逻辑应该是 hook 调外部服务,而不是把所有逻辑塞进 hook 脚本里。
本节核心一句话
Hook 是 harness 里唯一不依赖模型判断就能强制介入的机制,exit 2 是它的核心武器。