本教程带你掌握 Claude Code 钩子系统中最核心的两个工具事件——PreToolUse 与 PostToolUse,构建一条确定性的代码质量护栏:前者在工具执行前拦截危险命令、保护敏感文件、改写入参;后者在工具执行后自动格式化、自动 Lint、自动跑测试。读完即可在一个项目里跑通"保存即格式化 + 危险命令拦截"的完整闭环。适合已用 Claude Code 跑过至少一个项目、想把"靠 LLM 自觉"升级为"铁律执行"的开发者与工程负责人。
if 字段;v2.0.10 及以上可解锁 updatedInput 入参改写)jq(brew install jq / apt-get install jq),用于在钩子脚本里解析 stdin 的 JSONgofmt 等)Claude Code 会遵循 CLAUDE.md 里的约定——但只是"大概率遵循"。当规则是"永远不要删 .env""每次写完文件都要跑 Prettier"时,"大概率"就不够了。
钩子(Hooks)是用户定义的事件处理器,在工具调用的特定生命周期点确定性地执行。关键区别在于:
| 机制 | 性质 | 是否每次执行 |
|---|---|---|
CLAUDE.md |
建议(suggestion) | 否,LLM 偶尔跳过 |
| Hooks | 强制(enforcement) | 是,无例外 |
| Skills | 上下文相关专业知识 | 仅在匹配时激活 |
一句话判断标准:违反会造成破坏 → 用钩子;违反只是次优 → 用 CLAUDE.md。
一次工具调用的完整链路如下,PreToolUse 和 PostToolUse 正好夹住"工具执行"这个不可逆动作:
flowchart LR
A[用户提示] --> B[Claude 生成工具调用]
B --> C["PreToolUse 钩子<br/>可拦截/改写/放行"]
C -->|放行| D[工具执行<br/>产生副作用]
D --> E["PostToolUse 钩子<br/>可格式化/Lint/测试"]
E --> F[Claude 继续下一步]
C -->|拦截 exit 2| G[取消调用<br/>错误反馈给 Claude]
PreToolUse:在 Claude 决定调用什么工具、传什么参数之后,但工具真正执行之前触发。这是唯一能"阻止副作用发生"的同步检查点。可拦截、可放行(跳过权限对话框)、可改写参数。PostToolUse:在工具成功执行之后触发。动作已发生,无法撤销,但可以"反应"——格式化刚写的文件、对刚改的代码跑 Lint、触发相关测试。关键时序事实:
PreToolUse钩子在任何权限模式检查之前触发。也就是说,即便会话以bypassPermissions(跳过所有权限)模式运行,permissionDecision: "deny"依然能拦住调用。这让你能把"谁都无法绕过"的策略焊死在钩子里。
钩子脚本通过 stdin(收 JSON)、stdout/stderr、退出码与 Claude Code 通信。其中退出码是核心控制信号:
| 退出码 | 含义 | 适用场景 |
|---|---|---|
0 |
无异议,动作正常进行 | PreToolUse 下不等于批准,正常权限流程仍生效 |
2 |
阻断动作;stderr 会被当作反馈喂给 Claude | 让 Claude 调整策略后重试 |
| 其他非 0 | 动作照常进行,仅记一条 hook error 提示 |
非阻断性警告 |
需要更精细控制时,退出 0 并向 stdout 打印结构化 JSON。例如 PreToolUse 拒绝并给出理由:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "请用 rg 替代 grep 以提升性能"
}
}
permissionDecision 取值:allow(跳过交互式权限提示)、deny(取消调用并把理由回传 Claude)、ask(照常弹权限对话框)、defer(仅非交互 -p 模式可用,保留调用供 SDK 包装器后续处理)。
这是回报最快的一个钩子:Claude 每写/改一个文件,自动跑 Prettier。
把以下内容写入项目根目录的 .claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write 2>/dev/null || true",
"statusMessage": "Formatting with Prettier..."
}
]
}
]
}
}
操作说明:matcher: "Edit|Write" 让钩子只对文件编辑类工具触发(不对 Bash、Read 触发,避免无谓开销)。jq 从 stdin 的 JSON 中取出刚改的文件路径,管道给 Prettier。2>/dev/null || true 保证对 Prettier 不支持的文件类型(如 .md)不报错。
预期结果:让 Claude 改一个 .ts 文件,文件保存瞬间自动按 Prettier 规范重排;过程中底部状态栏显示自定义文字"Formatting with Prettier..."。
常见问题:
- 钩子不触发?在会话里输入 /hooks,确认 PostToolUse 下出现该钩子;matcher 区分大小写,Edit|Write 必须精确。
- 看到 hook error?通常是 jq: command not found——装好 jq 即可。
PreToolUse 是生产安全的头号事件。下面这个钩子拦截 rm -rf、git push --force、DROP TABLE 等。
新建 .claude/hooks/block-destructive.sh:
#!/bin/bash
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if [ -z "$COMMAND" ]; then exit 0; fi
DANGEROUS_PATTERNS=('rm -rf' 'git push --force' 'git push -f' 'DROP TABLE' 'git reset --hard')
for pattern in "${DANGEROUS_PATTERNS[@]}"; do
if echo "$COMMAND" | grep -qi "$pattern"; then
echo "Blocked: command matches dangerous pattern '$pattern'" >&2
exit 2 # 退出码 2 = 阻断,stderr 反馈给 Claude
fi
done
exit 0
赋予可执行权限,并在 settings.json 注册:
chmod +x .claude/hooks/block-destructive.sh
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-destructive.sh", "timeout": 5 }
]
}
]
}
}
预期结果:让 Claude"清理临时目录"。原本它会执行 rm -rf /tmp/project-cache;钩子激活后,命令被拦截,Claude 收到错误反馈,自动改用更安全的 find /tmp/project-cache -type f -delete 重试——无需人工干预。
常见问题:
- 钩子"坏了"、.env 照改不误?90% 是退出码写错:写了 exit 1(非阻断警告)而不是 exit 2(阻断)。这是最高频的踩坑点。
- 用 "$CLAUDE_PROJECT_DIR" 或绝对路径引用脚本,避免 command not found。要彻底绕开 shell 引号问题,可加 "args": [] 切到 exec 形式直接 spawn 脚本。
把 .env、lock 文件、CI 配置焊死,任何工具(含 Bash 里的 sed/echo >)都改不动。注意 matcher 用 Edit|Write|Bash 三管齐下:
#!/bin/bash
# .claude/hooks/protect-files.sh
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
[ -z "$FILE_PATH" ] && exit 0
for pattern in ".env" "package-lock.json" "pnpm-lock.yaml" ".git/" ".github/workflows/"; do
if [[ "$FILE_PATH" == *"$pattern"* ]]; then
echo "Blocked: '$FILE_PATH' 是受保护文件" >&2
exit 2
fi
done
exit 0
{
"matcher": "Edit|Write|Bash",
"hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh" }]
}
updatedInput 改写命令(v2.0.10+)除了"阻断后等 Claude 重试",PreToolUse 还能直接改写工具参数再放行。把破坏性的 rm -rf 就地改成交互式 rm -ri:
#!/bin/bash
# .claude/hooks/safer-rm.sh
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if echo "$COMMAND" | grep -q '^rm -rf'; then
SAFER=$(echo "$COMMAND" | sed 's/^rm -rf/rm -ri/')
jq -n --arg cmd "$SAFER" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "allow",
updatedInput: { command: $cmd }
}
}'
exit 0
fi
exit 0
updatedInput 在执行前替换工具入参,配合 permissionDecision: "allow" 跳过权限对话框直接跑改写后的命令。
同一事件可挂多个钩子,所有匹配钩子并行跑完再合并结果。一个钩子返回 deny 不会阻止兄弟钩子执行——别指望一个钩子的 deny 能压制另一个钩子的副作用。
合并规则按最严格者胜,优先级为 deny > defer > ask > allow。下例在 Bash 上挂两个 PreToolUse:一个记日志(exit 0,无决策),一个拦 rm -rf(exit 2,拒绝):
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "jq -r .tool_input.command >> ~/.claude/bash.log" },
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-rm-rf.sh" }
]
}
]
}
}
当 Claude 试图跑 rm -rf /tmp/build:日志钩子已写盘(exit 0),护栏钩子 exit 2 拒绝。拒绝获胜,命令被拦,但日志条目依然保留——因为日志钩子早已跑完。
if 字段做精细过滤(v2.1.85+)matcher 只能在工具名层面过滤(如 Bash)。if 字段用权限规则语法把工具名和参数一起过滤,让钩子进程只在真正匹配时才 spawn。下例仅在 Claude 跑 git 子命令时触发,而非所有 Bash 调用:
{
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "if": "Bash(git *)", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/check-git-policy.sh" }
]
}
]
}
if 失败时会"放行"(仍跑钩子):当 Bash 命令无法解析、或 $()/反引号/$VAR 出现且模式不只含命令名时,过滤器会保守地触发钩子。因此要用权限系统而非钩子来强制硬性允许/拒绝,钩子的 if 只是性能优化。
钩子默认静默失败。排查时按顺序用这三招:
/hooks 浏览:会话内输入 /hooks,确认钩子挂在正确事件下、matcher 准确。echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./my-hook.sh; echo $?——直接看退出码。claude --debug-file /tmp/claude.log 启动,另一终端 tail -f;或会话内 /debug 开日志、Ctrl+O 看转录视图里每个钩子的一行摘要。高频坑:JSON 解析失败却"脚本是好的"——通常是 Git Bash/BASH_ENV 指向的 profile 里有无条件 echo,把杂讯拼到了 JSON 前。把 profile 里的 echo 包进 if [[ $- == *i* ]]; then ... fi(仅交互式 shell 输出)即可。
把前面的能力拼成一个可直接落地的 .claude/settings.json,覆盖"拦截危险命令 + 保护敏感文件 + 保存即格式化 + 完成通知"四件事:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-destructive.sh", "timeout": 5 }]
},
{
"matcher": "Edit|Write|Bash",
"hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh", "timeout": 5 }]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write 2>/dev/null || true",
"statusMessage": "Formatting..."
}
]
}
],
"Notification": [
{
"matcher": "",
"hooks": [{ "type": "command", "command": "notify-send 'Claude Code' '需要你的注意'" }]
}
]
}
}
落地后实测收益(多项目 6 个月数据):误删命令从每月 2–3 次降到 0;每会话手动跑格式化从 5–10 次降到 0;.env/lock 文件误改在源头被拦;单次工具调用钩子开销约 60ms(matcher 越窄越省——空 matcher 约多 80ms/次,一个提示 15–30 次工具调用会累计成 1–2 秒延迟,务必收窄)。
确定性规则覆盖不到、需要"判断"时,换钩子类型而非写更复杂的 shell:
type: "prompt":单轮 LLM 评估(默认 Haiku)。适合"Claude 是不是真的做完了"这类判断。返回 {"ok": true} 放行,{"ok": false, "reason": "还差什么"} 喂回 Claude 让它继续。type: "agent":多轮子代理,可 Read/Grep/Glob、最多 50 轮工具调用、默认超时 60s。适合"跑测试套件并验证全过"这类需读取代码、执行命令的复杂校验。Stop 钩子用 prompt/agent 做"完成度校验"时,务必解析 stdin 的 stop_hook_active 字段:为 true 就 exit 0 放行,否则 Claude 会陷入"停下→钩子让它继续→又停下"的循环——连续阻断 8 次后 Claude Code 会强制放行。
code.claude.com/docs/en/hooks-guide——全部事件、JSON 契约、if/HTTP/MCP 钩子allow/deny/ask 规则如何与钩子叠加(deny 规则永远压过钩子的 allow)git push --force + 保护 .env + 保存即格式化"的三钩配置,并用 /hooks 与 debug 日志验证生效;能否解释"为什么 exit 1 拦不住而 exit 2 能"。if 字段、prompt/agent/HTTP 钩子updatedInput 改写、多钩组合与排错[!tip] 51学通信知识星球 星主:爱卫生(微信:gprshome201101)——公众号「51学通信」创办者,19 年通信行业运维与讲师经验,著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书(各大电商平台有售)。 星球定位:深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域,主打运维岗位与信令分析。 当前规模:5200 篇主题 / 990 个回答,198 元/年。 加入福利:加入星球后请加爱卫生微信,拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题,另有群内分享。 AI 加持:51学通信知识星球已作为 skill 接入 WorkBuddy,使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例,做总结、找文档和 PPT 等场景全覆盖。
