钩子装上了不等于生效:把 PreToolUse 收窄并审计

· 约 5 分钟阅读

我给仓库装过一个拦 rm -rf 的钩子,两天后才发现它一次都没拦住东西。脚本第一行调用 jq,而 hook 进程的 PATH 里没有 jq,脚本直接退出;配置看起来齐全,实际上每一次删除都被静默放行。

Claude Code 的 hook 参考把 PreToolUse 定义为工具调用执行前触发、可以阻断它的事件。[1] 阻断要成立,得同时满足三件事:事件真的触发、脚本真的被启动、返回值真的被解析。任何一环断掉,你手里就只剩一份配置文件。

会不会在不该触发的时候触发#

matcher 只过滤到工具粒度。写 "matcher": "Bash" 意味着每一条 Bash 命令都要启动一次进程、读一遍标准输入。[1] 如果你的拦截逻辑只关心删除类命令,那 ls、grep、npm test 都在替你付这笔开销,长任务里这些进程启动会累计成可感知的停顿。

官方参考给了第二层过滤:写在 handler 里的 if 条件。命中 matcher 之后,Claude Code 再检查子命令是否匹配,"if": "Bash(rm *)" 只在命令匹配 rm * 时启动脚本。文档里的说法很直接,如果命令是 npm test,if 检查失败,脚本根本不会运行,进程启动开销也就省掉了;而 if 本身是可选的,不写的话匹配组里每个 handler 都会跑。[1]

静默返回到底算不算放行#

算。退出码 0 且没有任何输出,表示这次 hook 没有做出决定,工具调用继续走正常的权限流程;文档专门点明,hook 可以拒绝调用,但保持沉默并不等于批准。[1]

这条语义经常被写歪。“脚本崩了就当作通过”是最顺手的写法,它把闸门默认打开,还额外送你一条绿色日志。更稳妥的做法是让脚本自己校验输入:解析不出 tool_input.command 时,直接输出一个带原因的 deny,或者至少把异常写进 stderr,而不是安静地退出。拦截器挡的是不可逆操作,宁可误报,不可漏报。

怎么证明它现在真的在跑#

配置有没有生效,不该靠打印日志去猜。/hooks 会打开一个只读清单,列出每个事件下配置了几个 hook、来自哪个设置文件(用户、项目、local、插件、会话),以及每个 handler 的完整命令或 URL;要增删改仍然得直接编辑 JSON。[1] 这适合回答“我改的到底是哪一份”。

执行细节写在调试日志里。用 claude --debug-file <path> 把日志写到固定位置,或者加 --debug 之后去读 ~/.claude/debug/<session-id>.txt;要看 matcher 命中计数这类更细的信息,再把 CLAUDE_CODE_DEBUG_LOG_LEVEL=verbose 设上。[1] 排查“钩子没触发”时,先看日志里有没有这个事件的记录,就能把“配置错了”和“脚本自己失败了”分开。

快速上手:收窄一个删除拦截器#

先建目录和脚本。脚本在拿不到命令时按拒绝处理,避免静默放行,拦截规则用一层 grep 兜底:

mkdir -p .claude/hooks
cat > .claude/hooks/guard-destructive.sh <<'SH'
#!/usr/bin/env bash
set -uo pipefail

payload=$(cat)
cmd=$(printf '%s' "$payload" | jq -r '.tool_input.command // empty' 2>/dev/null)

if [ -z "$cmd" ]; then
  printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"guard: 无法解析 tool_input.command,按拒绝处理"}}'
  exit 0
fi

if printf '%s' "$cmd" | grep -qE '(^|[;&|[:space:]])rm[[:space:]]+-[a-zA-Z]*[rf][a-zA-Z]*([[:space:]]|$)'; then
  printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"guard: 递归删除被拦截"}}'
  exit 0
fi

printf 'guard: allow cmd=%s\n' "$cmd" >&2
exit 0
SH
chmod +x .claude/hooks/guard-destructive.sh

把 if 挂在 handler 上,而不是 matcher 组上。[1]

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

装完之后做四次确认,全是本地且可回滚的操作。先用管道手测脚本的两条分支,不经过 agent:

printf '%s' '{"tool_input":{"command":"rm -rf build"}}' | ./.claude/hooks/guard-destructive.sh
printf '%s' '{"tool_input":{"command":"rm file.txt"}}' | ./.claude/hooks/guard-destructive.sh

第一条应该输出 deny 的 JSON,第二条只往 stderr 写一行 allow。然后在真实会话里让 agent 跑一次 npm test,到调试日志里确认这个 hook 完全没有被启动,if 才算真的省下了开销。最后跑一次真正的递归删除,看日志里出现该 hook 的执行记录、Claude 收到那串拒绝原因。整个过程不动业务代码,最坏的结果也只是你自己误触一次拦截。

信任边界比配置更关键#

钩子的作用域由定义位置决定:用户级 ~/.claude/settings.json 作用于你所有项目,项目级 .claude/settings.json 可以提交进仓库,.claude/settings.local.json 会被 gitignore,插件、Skill frontmatter 和子代理 frontmatter 各在自己的生命周期里生效。子代理里定义的 hook 只在该子代理运行期间有效。[1] 这些条目还是跨层合并的,不会互相替换。[1]

真正需要警惕的是执行环境。交互式会话中,Claude Code 会扣住所有设置文件里的 hook,包括你自己的 ~/.claude/settings.json,直到你为这个文件夹接受工作区信任对话框;而 -p 或 SDK 会话从不显示这个对话框,直接把文件夹当成已信任,于是仓库 .claude/settings.json 里被提交进来的 hook 会跑在一个你从没信任过的目录里。[1] 要在陌生仓库上做脚本化任务,官方建议是先读它的 .claude/ 设置文件,用 --bare 启动,或者当次直接关掉所有 hook,也就是 --settings '{"disableAllHooks": true}'。[1] 另外,disableAllHooks 压不住管理员通过 managed policy settings 下发的 hook,只有同一层级的设置能关掉它们。[1]

子代理里的拦截器只是兜底#

把拦截器塞进子代理是个自然的想法,但它的定位是兜底。子代理各自在独立的上下文窗口里运行,有自己的系统提示、工具集与独立权限。[2] 官方示例里的数据库子代理只给 Bash 工具,再用 PreToolUse hook 检查命令里有没有写操作,检测到就退出码 2,阻断调用并把错误信息回传给模型。[2] 文档也说明,真正让子代理收手的是系统提示,hook 只是兜底:万一它还是尝试写,Claude Code 会挡住这条命令。[2]

换句话说,子代理里的 hook 保证的是越界不落地,不是懂得该做什么。规则该写在系统提示和计划文件里,hook 负责在最后一刻踩刹车。

拦截型 hook 值得花时间的地方不在写法,而在两件事:触发面足够窄,失败模式足够显眼。窄到不影响正常命令,显眼到它坏掉的那天你就能发现。

Sources#

[1] https://docs.anthropic.com/en/docs/claude-code/hooks — Hooks reference - Claude Code Docs [2] https://code.claude.com/docs/en/sub-agents.md — Create custom subagents - Claude Code Docs