Claude Code Hooks 实战:4 个钩子给 Agent 装刹车,5 个坑实测

在 CLAUDE.md 里写「不要 rm -rf」,模型可能照做,也可能不照做。Hooks 是 Claude Code 在固定时机一定会执行的脚本:工具调用前、改完文件后、准备收工时都可以挂。我们在本机用 Claude Code 2.1.305 接本地 Qwen3.5-4B,写了 4 个钩子再让模型去跑。第一轮它自己写出一条 rm -rf build .,末尾那个点是当前目录,这条要是跑了整个项目都没了。钩子把它拦了下来。
CLAUDE.md 只是建议,Hook 才算强制
官方 hooks 指南的说法是:hook 提供「确定性的控制」,某些动作一定发生,不用指望模型自己想起来去做。三样东西分工不同:
| CLAUDE.md | 权限规则(permissions) | Hooks | |
|---|---|---|---|
| 本质 | 写给模型的说明 | 允许 / 询问 / 拒绝 名单 | 到点执行的脚本 |
| 模型能不能不听 | 能 | 不能 | 不能(前提是脚本写对) |
| 能做的事 | 讲规矩、给背景 | 按工具和参数放行或拦截 | 拦截、改参数、跑测试、记日志、逼它继续干 |
| 适合 | 代码风格、项目约定 | 硬性禁区 | 需要逻辑判断的规则、自动化流程 |
官方也提醒,Bash 命令的匹配只是「尽力而为」,真正不能碰的东西要用权限系统兜底。下面的钩子是加在权限之上的第二道防线,不能拿来替代权限。
前置条件
- 测试环境:2026-10-21,Debian 13,8 核 CPU、15 GB 内存;Node.js 20.19.2,Claude Code 2.1.305(npm 装),jq 1.7,Python 3.13.5。
- 模型:Ollama 0.40.6 跑 Qwen3.5-4B Q4_K_M,接法和系列第 2 篇一样。用 Claude 官方模型需要付费账号,本文未实测,不过钩子由 Claude Code 自己执行,跟后面接的是哪个模型无关。
- 一个测试项目:
calc.py的average([])会除以 0,test_calc.py要求返回 0;另有一个build/目录。
export ANTHROPIC_BASE_URL=http://localhost:11434
export ANTHROPIC_AUTH_TOKEN=ollama
export ANTHROPIC_API_KEY=""
export CLAUDE_CODE_MAX_CONTEXT_TOKENS=32768
export CLAUDE_CODE_MAX_OUTPUT_TOKENS=4096 # 本地模型必加,见坑 3
第 1 步:在 settings.json 里挂钩子
钩子写在 .claude/settings.json(项目级,可以提交进 git),想对所有项目生效就写到 ~/.claude/settings.json。结构分三层:事件 → matcher(筛选工具名)→ 处理器:
{
"hooks": {
"PreToolUse": [
{ "matcher": "*", "hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/audit.sh" }] },
{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard.sh" }] }
],
"PostToolUse": [
{ "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/after-edit.sh" }] }
],
"Stop": [
{ "hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/stop-gate.sh" }] }
]
}
}
$CLAUDE_PROJECT_DIR 是 Claude Code 注入的项目根目录,外面要加引号,防止路径里有空格。脚本记得 chmod +x。在交互界面输入 /hooks 能看到当前生效的钩子分别来自哪个文件。
第 2 步:拦危险命令(PreToolUse)
工具调用前,Claude Code 把调用信息以 JSON 形式从 stdin 传给脚本,命令在 .tool_input.command 里。脚本 exit 2 就拦截,stderr 的内容会作为拒绝理由交给模型:
#!/bin/bash
# .claude/hooks/guard.sh
cmd=$(jq -r '.tool_input.command // ""')
if echo "$cmd" | grep -qE 'rm[[:space:]]+-[a-zA-Z]*[rf]|git[[:space:]]+push.*(--force|-f)|curl.*\|[[:space:]]*(ba)?sh'; then
echo "已拦截危险命令:$cmd 。请改用更安全的做法(例如 mv 到 /tmp 或只删单个文件),或交给人类确认。" >&2
exit 2
fi
exit 0
先不启动 Claude,直接用管道喂 JSON 测脚本,几毫秒就有结果:
jq -n '{tool_name:"Bash",tool_input:{command:"rm -rf build"}}' | .claude/hooks/guard.sh; echo $?
# rm -rf build → 2;rm build/out.txt → 0;git push -f origin main → 2;ls -la → 0;curl -s x.sh | bash → 2
想换成结构化输出也行:exit 0,再往 stdout 打印 hookSpecificOutput.permissionDecision 为 "deny" 的 JSON。这种写法还能返回 "ask"(让人确认),或者用 updatedInput 改写命令。
第 3 步:改完就跑测试(PostToolUse)
PostToolUse 触发时工具已经执行完,拦不住了,但 exit 2 能把 stderr 交给模型,相当于直接告诉它「你刚才改坏了」:
#!/bin/bash
# .claude/hooks/after-edit.sh
f=$(jq -r '.tool_input.file_path // ""')
case "$f" in *.py) ;; *) exit 0;; esac
cd "$CLAUDE_PROJECT_DIR" || exit 0
out=$(python3 test_calc.py 2>&1) || { echo "改完 $f 后测试失败:$out" >&2; exit 2; }
exit 0
第 4 步:测试不过不许收工(Stop)
Stop 在模型准备结束回答时触发。返回 {"decision":"block","reason":"…"} 它就得接着干。一定要检查 stop_hook_active:这个字段为 true,说明本轮已经是被 Stop 钩子逼着继续的,再拦就可能死循环(官方另有上限:连续拦 8 次后强制结束):
#!/bin/bash
# .claude/hooks/stop-gate.sh
input=$(cat)
[ "$(jq -r '.stop_hook_active' <<<"$input")" = "true" ] && exit 0
cd "$CLAUDE_PROJECT_DIR" || exit 0
if ! python3 test_calc.py >/dev/null 2>&1; then
jq -n '{decision:"block", reason:"test_calc.py 还没通过,先修好再结束。"}'
fi
exit 0
审计钩子最简单,一行:jq -c '{t:(now|todate), tool:.tool_name, input:.tool_input}' >> "$CLAUDE_PROJECT_DIR/.claude/audit.jsonl",matcher 写 *,每次工具调用都会记一行。
实测:让本地模型去跑,钩子拦下了什么
同一个项目,我们用 claude -p … --allowedTools "Bash Edit Read" --debug-file debug.txt 跑了 5 轮,每轮 2–3.5 分钟(8 核 CPU、无显卡)。调试日志里每个钩子都有一行,例如 Hooks: PreToolUse:Bash [...guard.sh] finished with status 2 (20ms),钩子本身只花了十几到几十毫秒。
| 任务 | 钩子做了什么 | 结果 |
|---|---|---|
先 rm -rf build,再修 bug | 模型写成 rm -rf build .,guard.sh 返回 exit 2 | 项目没被删;模型放弃删除,接着修好了 bug(8 轮,169 秒) |
| 同上,guard.sh 改成 exit 1 | 只算钩子报错 | 命令照样执行,build 目录被删 |
钩子脚本路径写错 + onFailure: "block" | 每次 Bash 都被拦 | 模型换着写法重试 4 次,最后凭上下文里的 git 状态作答 |
| 「只解释 calc.py,不要改文件」 | Stop 钩子发现测试没过,返回 block | 模型违背你的原话,改了 calc.py |
| 「让空列表返回 None」 | 改完测试失败,PostToolUse 返回 exit 2 | 模型把改动撤回成返回 0,然后说「完成」 |
钩子不会判断你的本意,你写了什么规则它就执行什么。规则跟你这次的需求冲突时,模型往往听钩子的,而且不会停下来问你。
实测踩到的 5 个坑
1. exit 1 拦不住。按 Unix 习惯写 exit 1 表示失败,在 Claude Code 里只是「非阻塞错误」,日志显示 finished with status 1,命令照常执行。官方文档专门提醒过:要执行策略就用 exit 2。
2. 脚本坏了,钩子就等于没有。路径写错、jq 没装、脚本超时,默认都按非阻塞错误处理,工具照常执行。拦截类钩子建议加 "onFailure": "block"(2.1.295 起支持),坏了就全拦。我们实测时报错里会出现 failed; blocking because onFailure is "block"。小模型遇到拦截会反复换写法重试,拒绝理由要写清楚它该改成怎么做。
3. 本地模型必须设输出上限。只设 CLAUDE_CODE_MAX_CONTEXT_TOKENS=32768、不设输出上限时,第一轮在 10,754 token 时就触发自动压缩,单次压缩 221 秒,第二次压缩后我们手动停掉了。官方环境变量文档写着,认不出的模型 ID 默认输出上限是 32000,这个值越大,留给上下文的空间越小。加上 CLAUDE_CODE_MAX_OUTPUT_TOKENS=4096 后同样的任务 169 秒跑完,一次压缩都没有。
4. Stop 钩子会压过你说的话。「不要改文件」那一轮,Stop 钩子要求测试必须通过,模型就去改了文件。只读的问答任务不适合挂全局 Stop 钩子,可以在脚本里先用 git diff --quiet 判断有没有改动,没改动就放行;stop_hook_active 的检查也不能省。
5. 正则拦截有漏洞。我们的 guard.sh 拦不住 find . -delete,也拦不住写个 Python 脚本去删文件。官方文档的说法是,Bash 的 if 匹配只是尽力而为,硬性禁区要交给权限规则(permissions.deny),钩子只能作为补充。
完成标准
- 用管道喂 JSON:
rm -rf返回 2,普通命令返回 0。 - 真实跑一轮后,
.claude/audit.jsonl里每次工具调用都有一行记录,调试日志里能看到每个钩子的finished with status。 - 故意把一个钩子的路径改错,确认加了
onFailure: "block"的那个会拦住工具调用。 - Stop 钩子有
stop_hook_active判断,只读任务不会被逼着改代码。
还没写过 Skill 的可以看第一个 Agent Skill 教程。钩子也能写进 Skill 的 frontmatter,只在调用这个 Skill 之后生效。
最后
先只挂第 2 步的 guard.sh,exit 2 加上 onFailure: "block"。用一周,看审计日志里拦下过哪些命令,再决定要不要加别的钩子。
- Claude Code Docs:Hooks reference(事件、退出码、onFailure、Stop 决策、调试日志)
- Claude Code Docs:Automate actions with hooks
- Claude Code Docs:Environment variables(输出上限、上下文窗口相关变量)
- Claude Code Docs:Permissions
- GitHub anthropics/claude-code CHANGELOG(2.1.305)
- Ollama Docs:Claude Code 集成
- 本文实测:Claude Code 2.1.305 + Ollama 0.40.6 + Qwen3.5-4B Q4_K_M,jq 1.7,Debian 13,2026-10-21