2423 字
约 8 分钟
22
S24-X06 · Hooks:在固定时机自动扣扳机

有一类动作你每天重复:改完文件跑格式化、提交前跑测试、push 前提醒自己别强推、长任务跑完想知道结果。这些都不需要"更聪明的模型",需要的是在固定时机自动执行固定脚本。hook 就是干这个的。

它不改变模型的推理,只改变"什么时候允许发生什么"。理解这一点,才不会指望 hook 去做它做不到的事。


1. 事件类型:什么时机有扳机

事件 触发时机 典型用途 能否阻断
SessionStart 会话开始 打印当前分支、注入上下文 否
UserPromptSubmit 用户提交提示词后、模型处理前 补上下文、拦截敏感词 是
PreToolUse 工具调用前 危险命令拦截、参数校验 是
PostToolUse 工具调用成功后 自动格式化、lint、记录改动 否
Notification 需要提醒时 桌面通知、IM 推送 否
Stop 主流程准备结束时 收尾检查、发完成通知 是
SubagentStop 子代理结束 汇总子任务结果 是
PreCompact 上下文压缩前 备份对话要点 否
SessionEnd 会话结束 清理临时文件、写日志 否

事件集合在不同版本里会增减,按当前版本核对一次再做设计,别照抄旧文章的事件名。阻断能力也要以实际行为为准:PostToolUse 发生在调用之后,它最多把意见反馈回去,撤不回已经执行的命令。


2. 配置结构:matcher + command

hook 写在 settings 里(用户级或项目级都行),结构是"事件 → 匹配器 → 命令"三层:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/guard.sh",
            "timeout": 30
          }
        ]
      }
    ]
  }
}
matcher 写法 匹配 备注
Bash 只用 Bash 工具时 精确工具名
Edit|Write 编辑或写文件 竖线表示多选
* 或省略 所有工具 谨慎,会影响每次调用
mcp__.* 所有 MCP 工具 按当前版本的正则支持情况核对

三个细节:

  1. command 用绝对路径或 $CLAUDE_PROJECT_DIR 开头,不要用 ./——工作目录不一定是你想的那个。
  2. timeout 单位是秒,不写就用默认值(当前版本约 60s,按版本核对)。
  3. 一个 matcher 可以挂多个 hook,它们按顺序执行,任一阻断即阻断。

3. 退出码:三种结果,三种语义

退出码 含义 后果
0 成功 继续;stdout 在会话记录里可见,默认不注入上下文
2 阻断性错误 动作被阻止,stderr 内容回传给模型,模型会据此调整
其他非零 非阻断错误 stderr 给用户看,流程继续

这条设计很关键:退出码 2 的 stderr 是给模型看的,所以要写成"为什么不行、该怎么改",而不是一段堆栈。写 Error: command not allowed 模型只能瞎猜;写 禁止 git push --force,请改走 PR 流程,它就会换个做法。

退出码之外还有更精细的表达方式:往 stdout 写一段 JSON。两者不要混用——同时写退出码 2 和 JSON,行为以当前版本的实现为准,容易出意外。


4. 脚本收到什么:stdin 的 JSON

hook 进程的 stdin 是一段 JSON,包含会话与调用的上下文:

{
  "session_id": "abc123",
  "transcript_path": "/home/me/.claude/projects/xxx/session.jsonl",
  "cwd": "/repo/apps/web",
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": { "command": "git push --force origin main" }
}
字段 用途
session_id 区分会话,做日志关联
transcript_path 会话记录文件,可读来做事后分析
cwd 当前工作目录,拼相对路径的基准
hook_event_name 一个脚本挂多个事件时用它分流
tool_name / tool_input 判断这次调用要做什么
tool_response 仅 PostToolUse 有,含执行结果
#!/usr/bin/env bash
set -euo pipefail
payload="$(cat)"                       # 必须读完 stdin,否则上游可能被反压挂住
event="$(jq -r '.hook_event_name // empty' <<<"$payload")"
cmd="$(jq -r '.tool_input.command // empty' <<<"$payload")"
printf '%s %s\n' "$event" "$cmd" >>"${TMPDIR:-/tmp}/claude-hooks.log"
exit 0

5. 输出约定:用 JSON 精确表达意图

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "禁止强推,请开分支走 PR"
  }
}

约定要点:

  • permissionDecision 取值包含 allow、deny、ask 一类(按当前版本核对完整取值),ask 表示"转人工确认";
  • permissionDecisionReason 会呈现给人/模型,写清楚原因;
  • UserPromptSubmit 这类事件用 {"decision":"block","reason":"..."} 表达拦截;
  • 想往上下文里补信息,用 additionalContext 一类字段(事件与字段名按当前版本核对)。

不要往 stdout 打日志。stdout 在某些事件下会被当作结构化输出解析,混了人类可读日志就会解析失败,表现为"hook 没生效"。日志一律走 stderr 或文件。


6. 三个直接能用的接法

6.1 改完文件自动格式化 + lint

#!/usr/bin/env bash
set -euo pipefail
payload="$(cat)"
file="$(jq -r '.tool_input.file_path // empty' <<<"$payload")"
[ -z "$file" ] && exit 0
case "$file" in
  *.ts|*.tsx|*.js|*.jsx) npx prettier --write "$file" >/dev/null 2>&1 || true ;;
  *.py)                   ruff format "$file" >/dev/null 2>&1 || true ;;
  *.go)                   gofmt -w "$file" >/dev/null 2>&1 || true ;;
esac
exit 0

配 PostToolUse + matcher: Edit|Write。注意 || true:格式化失败不该阻断主流程。

6.2 危险命令二次确认

#!/usr/bin/env bash
set -euo pipefail
cmd="$(jq -r '.tool_input.command // empty' <<<"$(cat)")"
case "$cmd" in
  *"push --force"*|*"push -f"*|*"reset --hard"*|*"rm -rf /"*)
    jq -n '{hookSpecificOutput:{
      hookEventName:"PreToolUse",
      permissionDecision:"ask",
      permissionDecisionReason:"命中高危模式,请人工确认"}}'
    exit 0 ;;
esac
exit 0

配 PreToolUse + matcher: Bash。字符串匹配注定能被绕过,它的定位是"多一道确认",不是沙箱。

6.3 任务完成发通知

#!/usr/bin/env bash
set -euo pipefail
[ "$(jq -r '.hook_event_name' <<<"$(cat)")" = "Stop" ] || exit 0
curl -sS -X POST "${NOTIFY_WEBHOOK:?}" \
  -H 'content-type: application/json' \
  -d "$(jq -n --arg t "任务结束 @ $(basename "$PWD")" '{text:$t}')" >/dev/null
exit 0

配 Stop。跑长任务时这条比盯屏有用得多;webhook 地址从环境变量取,别写死在脚本里。


7. 超时与失败:必须提前想清楚

情况 实际表现 应对
超过 timeout 该 hook 判失败,按非阻断处理(按版本核对) 把长任务拆小或异步
脚本不存在 / 无执行权限 报错但不一定中断主流程 chmod +x,路径用 $CLAUDE_PROJECT_DIR
脚本不读 stdin 上游写入被阻塞,表现为"卡住" 开头 cat 掉,或用 jq 读取
依赖命令缺失(jq) 报 command not found 脚本开头做依赖检查,缺失就 exit 0
脚本自己有语法错 每次调用都报错 单独手动跑一遍验证
网络调用慢 每次工具调用都变慢 加短超时 curl --max-time 3

一个反直觉的点:hook 的失败默认是"放行"。这意味着写坏了的 hook 不会保护你,只会安静地什么都不做。所以每次改完 hook,都要故意触发一次确认它真的跑了。


8. 排错:六个高频坑

现象 原因 处理
每次操作都明显变慢 hook 里跑了全量测试/构建 只处理本次涉及的文件,或用后台任务
hook 完全不触发 matcher 大小写不符、事件名写错、配置在错的 settings 层 改成 * 试触发,确认后再收窄
会话上下文被莫名的文字塞满 hook 往 stdout 打了日志 日志改 stderr 或写文件
脚本报"找不到文件" 用了相对路径,基准目录不是仓库根 全部改成 $CLAUDE_PROJECT_DIR/...
阻断不生效,命令照跑 用了 exit 1 而不是 exit 2 阻断必须退出码 2,并写好 stderr
团队里只有我这儿生效 配置写进了 settings.local.json 移到项目 settings.json 并提交

第 5 条是最常见的一处误读:exit 1 只是"出错了",不会阻止动作;只有退出码 2 才是"阻断"。


9. 边界:这些别交给 hook

需求 更合适的位置 理由
必须一定执行的检查 CI 流水线 hook 可能被跳过、超时、脚本出错
绝对禁止的操作 权限 deny 规则 + 分支保护 规则匹配比脚本稳
全量测试、镜像构建 CI 本地 hook 里跑会拖垮交互
长期审计留痕 服务端日志 本地文件说没就没
想控制模型"怎么想" 记忆文件 / 命令模板 hook 不参与推理

一句话:hook 负责"时机",不负责"保证"。


10. 自测

  1. PreToolUse 与 PostToolUse 在阻断能力上的本质差别是什么?
  2. 想让动作被阻止并把原因反馈给模型,退出码该是多少,信息写在哪里?
  3. hook 脚本里为什么必须读掉 stdin?
  4. 往 stdout 打日志会导致什么后果?
  5. 为什么说"hook 写坏了等于没有保护"?
  6. 哪些检查应该放 CI 而不是 hook,判断依据是什么?
S24-X06 · Hooks:在固定时机自动扣扳机
https://www.clxhxhhr.top/posts/5698/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。