Claude Code Hooks 实战:用确定性规则约束 AI 行为

2阅读10分钟

从一个重复提醒开始

我开始认真思考 Hooks 这件事,是因为一个很小的问题:每次让 Claude 改完代码,我都要提醒它「记得运行 Prettier 格式化」。这个问题本身不复杂,但我发现自己在三天内说了十几次同样的话。

提示词可以引导 AI 行为,但提示词不是契约。模型可能忽略、可能忘记、可能在上下文压缩后丢掉关键约束。当我把「每次编辑后都要格式化」写进 CLAUDE.md,它确实执行了几次,然后又开始需要提醒。这类确定性规则——每次都必须执行、没有判断空间、不依赖上下文理解——不应该交给 LLM 来决定是否执行。

Claude Code Hooks 解决的就是这类问题:把确定性规则从提示词层移到工具层,用代码而不是自然语言来约束行为。

Hooks 的技术基础:事件驱动与确定性控制

Hooks 的设计思路来自两个工程原则:事件驱动架构和防御性编程。

事件驱动架构在 GUI 编程和 CI/CD 流水线中已经成熟应用多年。核心思想是:系统在特定生命周期节点发出事件,外部逻辑订阅这些事件并在触发时执行。Hooks 把同样的模式应用到 AI 编程工具中——Claude Code 在工具调用前、工具调用后、任务结束前等节点发出事件,用户定义的脚本订阅这些事件并执行检查或自动化动作。

防御性编程的原则是「不信任输入,验证所有边界」。在 AI 编程场景中,模型的输出就是需要验证的输入。Hooks 允许在工具执行前拦截、在工具执行后检查、在任务结束前审计。这种分层验证比单点提示更可靠。

根据 Anthropic 官方文档,Hooks 支持 28 个生命周期事件,包括 PreToolUse、PostToolUse、Stop、Notification 等。每个事件都有明确的触发条件和阻塞能力。这种设计把 AI 编程工具的控制权部分交还给开发者,让「必须执行」的规则变成可验证的代码。

案例一:保护敏感文件不被修改

第一个让我意识到 Hooks 必要性的场景是敏感文件保护。我在 CLAUDE.md 里写过「不要修改 .env 文件」,但 Claude 在处理配置相关任务时,有几次还是试图编辑 .env。提示词层面的约束不够稳定。

用 Hooks 实现文件保护的逻辑很直接:在 PreToolUse 事件中检查目标文件路径,如果匹配敏感文件模式,返回 exit code 2 阻断操作。

// .claude/settings.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "bash ${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-sensitive-files.sh"
          }
        ]
      }
    ]
  }
}
#!/bin/bash
# .claude/hooks/protect-sensitive-files.sh
 
# 从 stdin 读取 JSON 输入
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
 
# 定义敏感文件模式
SENSITIVE_PATTERNS='(\.env|\.env\.local|\.env\.production|secrets\.yaml|credentials\.json|id_rsa|\.pem)$'
 
if [[ -n "$FILE_PATH" && "$FILE_PATH" =~ $SENSITIVE_PATTERNS ]]; then
  # exit 2 阻断操作,stderr 内容会反馈给 Claude
  echo "BLOCKED: 不允许修改敏感文件 $FILE_PATH" >&2
  echo "如果需要修改配置,请在 CLAUDE.md 中明确授权,或手动编辑该文件" >&2
  exit 2
fi
 
# exit 0 或不输出,允许操作继续
exit 0

这个脚本的关键点有三个:

第一,stdin 读取。Claude Code 通过 stdin 把工具调用的上下文传给 Hook 脚本,必须用 cat 读取。我见过不少人直接写 jq -r '.tool_input.file_path' 而不先 cat,这样 jq 收不到输入。

第二,exit code 2。只有 exit code 2 会阻断操作并把 stderr 内容反馈给 Claude。exit code 1 会显示错误但不会阻断,这是最常见的配置错误。

第三,脚本外置。把逻辑放在独立脚本文件而不是内联 command,方便调试、测试和版本管理。

案例二:自动格式化与代码质量检查

第二个案例是编辑后自动格式化。这个场景适合用 PostToolUse 事件——工具执行成功后触发,不阻断操作,只做后续处理。

// .claude/settings.json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "bash ${CLAUDE_PROJECT_DIR}/.claude/hooks/auto-format.sh",
            "async": true
          }
        ]
      }
    ]
  }
}
#!/bin/bash
# .claude/hooks/auto-format.sh
 
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
 
# 只在文件存在且是目标类型时执行
if [[ ! -f "$FILE_PATH" ]]; then
  exit 0
fi
 
# 根据文件类型选择格式化工具
case "$FILE_PATH" in
  *.ts|*.tsx|*.js|*.jsx)
    npx prettier --write "$FILE_PATH" 2>/dev/null || true
    npx eslint --fix "$FILE_PATH" 2>/dev/null || true
    ;;
  *.json)
    npx prettier --write "$FILE_PATH" 2>/dev/null || true
    ;;
  *.md)
    npx prettier --write "$FILE_PATH" 2>/dev/null || true
    ;;
esac
 
# git add 让修改进入暂存区
git add "$FILE_PATH" 2>/dev/null || true
 
exit 0

这里有个细节:async: true。格式化不需要阻断 Claude 的后续操作,异步执行可以节省等待时间。PostToolUse 事件本身不能阻断操作(exit code 2 只影响显示),所以异步是安全的。

另一个细节是 || true。格式化失败不应该影响正常流程,用 || true 吞掉错误。但要注意,这也会吞掉有用的错误信息。如果格式化失败需要告警,应该把错误写到日志文件而不是直接忽略。

案例三:阻断破坏性命令

第三个案例是阻断危险命令。这个场景的复杂度在于命令匹配——不是简单的字符串匹配,而是要理解命令的语义。

// .claude/settings.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "bash ${CLAUDE_PROJECT_DIR}/.claude/hooks/block-dangerous-commands.sh"
          }
        ]
      }
    ]
  }
}
#!/bin/bash
# .claude/hooks/block-dangerous-commands.sh
 
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
 
# 危险命令模式
# 注意:这里用正则匹配,但命令解析有局限性
DANGEROUS_PATTERNS=(
  'rm\s+-rf\s+/'           # 递归删除根目录
  'rm\s+-rf\s+\*'          # 删除当前目录所有文件
  'git\s+reset\s+--hard'   # 丢弃所有未提交更改
  'git\s+checkout\s+--'    # 丢弃文件修改
  'git\s+push\s+.*--force' # 强制推送
  'DROP\s+TABLE'            # SQL 删除表
  'DROP\s+DATABASE'        # SQL 删除数据库
  'mkfs\.'                 # 格式化文件系统
  'dd\s+if=.*of=/dev/'     # 写入设备文件
)
 
for pattern in "${DANGEROUS_PATTERNS[@]}"; do
  if echo "$COMMAND" | grep -qEi "$pattern"; then
    echo "BLOCKED: 检测到危险命令" >&2
    echo "匹配模式: $pattern" >&2
    echo "命令: $COMMAND" >&2
    echo "" >&2
    echo "如果确实需要执行,请:" >&2
    echo "1. 明确说明原因和影响范围" >&2
    echo "2. 在 CLAUDE.md 中添加例外规则" >&2
    echo "3. 或手动在终端执行" >&2
    exit 2
  fi
done
 
exit 0

这个脚本的局限性在于命令解析。Bash 命令可以有各种变体:变量替换、子命令、管道、别名。简单的正则匹配会有漏网之鱼,也可能误伤正常命令。

更稳健的做法是用 if 字段做精确匹配:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "if": "Bash(rm *)",
        "hooks": [
          {
            "type": "command",
            "command": "bash ${CLAUDE_PROJECT_DIR}/.claude/hooks/check-rm.sh"
          }
        ]
      }
    ]
  }
}

if 字段用 Claude Code 的权限规则语法做前置过滤,只有匹配的命令才会触发 Hook。这样可以减少不必要的脚本执行,也降低了误伤风险。

Hook 执行流程

理解 Hook 的执行流程对正确使用很重要。下面是一个 PreToolUse Hook 的完整流程:

流程图画布 · 115%
Mermaid 流程图加载中...

这个流程图展示了几个关键点:

第一,PreToolUse 有三种退出状态:0(允许)、1(警告但允许)、2(阻断)。只有 exit 2 能真正阻止工具执行。

第二,Hook 通过 stdin 接收 JSON 输入,包含工具名称、参数、会话信息等上下文。

第三,PostToolUse 在工具执行成功后触发,适合做日志记录、格式化、检查等后续操作。

配置维度对比

在团队落地 Hooks 时,有几个维度需要权衡:

维度项目级配置用户级配置企业级配置
文件位置.claude/settings.json~/.claude/settings.json组织策略管理
作用范围单个项目所有项目组织内所有项目
是否提交 Git是(团队共享)否(个人偏好)否(集中管理)
适用场景项目特定规则个人工作流偏好组织级安全合规
更新方式Git 提交手动编辑策略平台下发

项目级配置适合团队共享的规则,比如代码格式化标准、敏感文件列表、危险命令黑名单。用户级配置适合个人偏好,比如桌面通知、日志详细程度。企业级配置适合安全合规要求,比如禁止访问某些目录、必须通过审计系统。

Hook 类型对比

Claude Code 支持多种 Hook 类型,选择合适的类型很重要:

类型执行方式成本适用场景局限性
commandShell 命令零 token文件操作、命令检查、格式化需要编写脚本
prompt注入提示词低 token上下文补充、简单检查依赖模型理解
agent子代理执行高 token复杂分析、多步骤验证成本高、延迟大
httpHTTP 请求网络延迟远程验证、审计系统需要服务端
mcp_toolMCP 工具调用工具成本与外部服务集成需要 MCP 服务器

对于确定性规则,command 类型是首选。它零 token 成本、执行快速、结果可预测。prompt 类型适合需要模型判断的场景,比如「这次修改是否需要更新文档」。agent 类型成本太高,只适合高价值的复杂验证。

事件类型对比

不同事件有不同的触发时机和阻塞能力:

事件触发时机能否阻断典型用途
PreToolUse工具执行前是(exit 2)命令检查、文件保护、权限控制
PostToolUse工具执行后格式化、日志、后续检查
Stop任务结束前是(exit 2)交付检查、验证命令是否执行
Notification通知发送时桌面通知、消息推送
SessionStart会话开始时上下文注入、环境初始化
PostCompact上下文压缩后重新注入关键信息

PreToolUse 是唯一能阻断工具执行的事件(PermissionRequest 也能阻断权限,但语义不同)。Stop 事件可以阻止 Claude 结束任务,适合做交付前的最终检查。PostToolUse 不能阻断,只能做后续处理。

Exit Code 语义对比

Exit code 的正确使用是 Hook 配置中最容易出错的地方:

Exit Code语义行为常见误用
0成功允许操作继续,stdout 解析为 JSON 输出
1非阻断错误显示错误信息但操作继续试图用 exit 1 阻断操作
2阻断错误阻止操作,stderr 反馈给 Claude在 PostToolUse 中用 exit 2(无效)

最常见的错误是用 exit 1 试图阻断操作。Exit 1 只会显示错误信息,不会阻止工具执行。只有 exit 2 能阻断。

另一个常见错误是在 PostToolUse 中用 exit 2。PostToolUse 在工具执行成功后触发,此时工具已经执行完毕,exit 2 无法回滚。PostToolUse 的 exit code 只影响错误信息显示。

代码示例:好做法 vs 坏做法

示例一:Exit Code 使用

# ❌ 坏做法:用 exit 1 试图阻断
if [[ "$FILE_PATH" =~ \.env$ ]]; then
  echo "BLOCKED: 不能修改 .env 文件" >&2
  exit 1  # 错误:exit 1 不会阻断操作
fi
 
# ✅ 好做法:用 exit 2 阻断
if [[ "$FILE_PATH" =~ \.env$ ]]; then
  echo "BLOCKED: 不能修改 .env 文件" >&2
  echo "请手动编辑或明确授权" >&2
  exit 2  # 正确:exit 2 阻断操作并反馈信息
fi

差异说明:Exit code 1 和 2 的区别决定了 Hook 能否真正阻止操作。Exit 1 只是警告,exit 2 才是阻断。

示例二:Stdin 读取

# ❌ 坏做法:直接用 jq 不读 stdin
FILE_PATH=$(jq -r '.tool_input.file_path')  # 错误:jq 收不到输入
 
# ✅ 好做法:先用 cat 读取 stdin
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path')
 
# ✅ 更好的做法:一行管道
FILE_PATH=$(cat | jq -r '.tool_input.file_path // empty')

差异说明:Claude Code 通过 stdin 传递 JSON 输入,必须先用 cat 读取,再通过管道传给 jq。直接调用 jq 会因为没有输入而失败。

示例三:Matcher 大小写

// ❌ 坏做法:matcher 小写
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "bash",  // 错误:工具名是 PascalCase
        "hooks": [...]
      }
    ]
  }
}
 
// ✅ 好做法:matcher 正确大小写
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",  // 正确:Bash、Edit、Write 都是 PascalCase
        "hooks": [...]
      }
    ]
  }
}
 
// ✅ 好做法:多个工具用 | 分隔
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",  // 匹配 Edit 或 Write 工具
        "hooks": [...]
      }
    ]
  }
}

差异说明:Matcher 区分大小写,工具名用 PascalCase。"bash" 不会匹配 Bash 工具,必须用 "Bash"

示例四:脚本外置 vs 内联命令

// ❌ 坏做法:复杂逻辑内联
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "CMD=$(cat | jq -r '.tool_input.command') && if echo \"$CMD\" | grep -qE 'rm -rf /'; then echo BLOCKED >&2; exit 2; fi"
          }
        ]
      }
    ]
  }
}
 
// ✅ 好做法:脚本外置
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "bash ${CLAUDE_PROJECT_DIR}/.claude/hooks/check-command.sh"
          }
        ]
      }
    ]
  }
}

差异说明:内联命令难以调试、测试和维护。把逻辑放在独立脚本文件中,可以用 shellcheck 检查语法,可以写单元测试,可以用 git 追踪变更。

Hooks vs Skills vs MCP

Hooks 不是万能的。理解它和其他扩展机制的边界很重要:

机制触发方式适用场景不适用场景
Hooks自动触发(事件驱动)确定性规则、自动化检查需要判断的决策
Skills手动触发(用户输入 /command)可复用提示词、工作流每次都要执行的规则
MCP工具调用外部服务集成本地文件操作

Hooks 适合「每次都必须执行」的规则。Skills 适合「用户主动触发」的工作流。MCP 适合「需要外部服务」的集成。

如果一个规则需要模型判断(比如「这次修改是否影响性能」),用 prompt 类型的 Hook 或 Skill,不要用 command 类型的 Hook。如果一个操作需要调用外部 API,用 MCP 或 http 类型的 Hook,不要用 command 硬编码。

Hook 设计检查清单

落地 Hooks 时,按以下清单逐项检查:

设计阶段

  • 每条 Hook 都有明确的触发条件和期望结果
  • 区分确定性规则和需要判断的规则,前者用 command Hook,后者用 prompt Hook 或 Skill
  • 选择正确的事件类型(PreToolUse 阻断、PostToolUse 后续处理、Stop 交付检查)
  • 选择正确的配置范围(项目级共享、用户级个人偏好)

实现阶段

  • Matcher 使用正确的工具名(PascalCase:Bash、Edit、Write)
  • 脚本通过 cat 读取 stdin,再用 jq 解析 JSON
  • 阻断操作使用 exit code 2,不是 exit code 1
  • stderr 输出清晰的错误信息,告诉 Claude 为什么被阻断以及如何继续
  • 复杂逻辑外置为独立脚本,方便调试和测试

维护阶段

  • 每条 Hook 都有注释说明用途和负责人
  • 每次误拦截都记录原因,必要时调整匹配规则
  • 每次放过高风险操作后补充规则
  • Claude Code 版本升级后检查 Hook 事件名和参数是否变化
  • 定期审查 Hook 列表,删除过时规则

团队协作

  • Hook 配置文件提交到 Git,团队共享
  • 在 README 或 CLAUDE.md 中说明关键 Hook 的用途
  • 新成员入职时介绍 Hook 机制和团队约定
  • 建立 Hook 变更的 review 流程

风险与边界

Hooks 的强大也带来风险。过度使用 Hooks 会让开发体验变重,每次工具调用都要经过多层检查,延迟累积会影响效率。

建议从三类规则开始:安全保护(敏感文件、危险命令)、质量验证(格式化、类型检查)、交付记录(审计日志、任务摘要)。每条规则都要能对应一个真实风险,不要为了用 Hooks 而用 Hooks。

另一个风险是 Hooks 之间的冲突。多个 PreToolUse Hook 可能相互矛盾:一个允许某个操作,另一个阻断。Claude Code 的执行顺序是按数组顺序,先执行的 Hook 优先。设计时要考虑 Hook 的优先级和冲突解决策略。

最后,Hooks 是本地执行的,不能替代服务端的审计和控制。对于需要集中管控的场景,应该结合企业级策略管理,而不是只依赖本地 Hooks。

参考资料

评论 0

0 / 1000