教程2026-10-21 · 智涌AI

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

Claude Code Hooks 教程封面:终端里 Bash rm -rf build 被 PreToolUse 钩子拦截,改完 calc.py 后 PostToolUse 自动跑测试

在 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 命令的匹配只是「尽力而为」,真正不能碰的东西要用权限系统兜底。下面的钩子是加在权限之上的第二道防线,不能拿来替代权限。

前置条件

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),钩子只能作为补充。

完成标准

还没写过 Skill 的可以看第一个 Agent Skill 教程。钩子也能写进 Skill 的 frontmatter,只在调用这个 Skill 之后生效。

最后

先只挂第 2 步的 guard.sh,exit 2 加上 onFailure: "block"。用一周,看审计日志里拦下过哪些命令,再决定要不要加别的钩子。

📬 订阅《智涌AI周报》

每周一封,精选 AI 大事和实用技巧。