Claude Code Hooks 实战:用确定性规则约束 AI 行为
从一个重复提醒开始
我开始认真思考 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 的完整流程:
这个流程图展示了几个关键点:
第一,PreToolUse 有三种退出状态:0(允许)、1(警告但允许)、2(阻断)。只有 exit 2 能真正阻止工具执行。
第二,Hook 通过 stdin 接收 JSON 输入,包含工具名称、参数、会话信息等上下文。
第三,PostToolUse 在工具执行成功后触发,适合做日志记录、格式化、检查等后续操作。
配置维度对比
在团队落地 Hooks 时,有几个维度需要权衡:
| 维度 | 项目级配置 | 用户级配置 | 企业级配置 |
|---|---|---|---|
| 文件位置 | .claude/settings.json | ~/.claude/settings.json | 组织策略管理 |
| 作用范围 | 单个项目 | 所有项目 | 组织内所有项目 |
| 是否提交 Git | 是(团队共享) | 否(个人偏好) | 否(集中管理) |
| 适用场景 | 项目特定规则 | 个人工作流偏好 | 组织级安全合规 |
| 更新方式 | Git 提交 | 手动编辑 | 策略平台下发 |
项目级配置适合团队共享的规则,比如代码格式化标准、敏感文件列表、危险命令黑名单。用户级配置适合个人偏好,比如桌面通知、日志详细程度。企业级配置适合安全合规要求,比如禁止访问某些目录、必须通过审计系统。
Hook 类型对比
Claude Code 支持多种 Hook 类型,选择合适的类型很重要:
| 类型 | 执行方式 | 成本 | 适用场景 | 局限性 |
|---|---|---|---|---|
| command | Shell 命令 | 零 token | 文件操作、命令检查、格式化 | 需要编写脚本 |
| prompt | 注入提示词 | 低 token | 上下文补充、简单检查 | 依赖模型理解 |
| agent | 子代理执行 | 高 token | 复杂分析、多步骤验证 | 成本高、延迟大 |
| http | HTTP 请求 | 网络延迟 | 远程验证、审计系统 | 需要服务端 |
| mcp_tool | MCP 工具调用 | 工具成本 | 与外部服务集成 | 需要 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。
参考资料
- Hooks Reference - Claude Code Official Documentation - 官方 Hook 事件参考和配置规范
- Automate actions with hooks - Claude Code Guide - 官方自动化指南和最佳实践
- Claude Code Hooks: 12 Production Configs - 生产环境配置示例和踩坑记录
- Claude Code Hooks (2026): Block Claude Reading .env + 30 Hook Examples - 完整的 Hook 示例集合
- The 4 Hooks Every Claude Code Project Needs - Towards AI 的基础 Hook 推荐
- Claude Code Hooks: Complete Guide to All 12 Lifecycle Events - 生命周期事件完整指南