有一类动作你每天重复:改完文件跑格式化、提交前跑测试、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 工具 | 按当前版本的正则支持情况核对 |
三个细节:
command用绝对路径或$CLAUDE_PROJECT_DIR开头,不要用./——工作目录不一定是你想的那个。timeout单位是秒,不写就用默认值(当前版本约 60s,按版本核对)。- 一个 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. 自测
PreToolUse与PostToolUse在阻断能力上的本质差别是什么?- 想让动作被阻止并把原因反馈给模型,退出码该是多少,信息写在哪里?
- hook 脚本里为什么必须读掉 stdin?
- 往 stdout 打日志会导致什么后果?
- 为什么说"hook 写坏了等于没有保护"?
- 哪些检查应该放 CI 而不是 hook,判断依据是什么?