2-2 Hooks 深度

1 阅读3分钟

2-2 Hooks 深度


Hooks 是什么,解决什么问题

模型决定调用一个工具,到工具真正执行——中间有一个缝隙。

Hook 就活在这个缝隙里。

没有 hook 的世界:模型说"我要删这个文件",工具直接执行,你只能事后看日志。

有 hook 的世界:模型说"我要删这个文件",hook 先拿到这个意图,检查、记录、决定放不放行——然后才到执行。

这是整个 harness 里唯一的强制介入点,其他层(prompt、权限配置)都可能被绕过或误判,hook 不会——它不经过模型,是纯工程逻辑。


PreToolUse Hook:核心机制

Claude Code 的 hook 系统里,最重要的是 PreToolUse

触发时机:模型产生工具调用意图之后、工具实际执行之前。

拿到的信息

  • 工具名称(WriteBashEdit…)
  • 完整入参(要写的文件路径、要执行的命令、具体内容…)

能做的事

  • 记录日志
  • 校验参数
  • 修改参数(谨慎用)
  • 拦截,阻止执行

退出码 2:拦截的语义

Hook 脚本通过退出码告诉 Claude Code 下一步怎么处理:

退出码语义
0放行,继续执行工具
1hook 自身出错,通常当作警告处理
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 是它的核心武器。