Claude Code 3.7 Hooks实战:AI编程自律全攻略
Claude Code Hooks 实战指南:让 AI 编程助手掌握"自控"能力
设想一个常见场景——你在 CLAUDE.md 中郑重写下三条铁律:
- 永远不要修改 .env 文件- 每次改完代码自动跑 prettier- Bash 命令不允许 rm -rf /
但实际运行中,Claude Code 在一次长会话里,经过上下文压缩后直接"失忆"——它擅自修改了 .env,改完代码没触发格式化,提交了一堆格式混乱的代码,甚至执行了 rm -rf /tmp/build。虽然这次没造成重大损失,但谁能担保下一次不会酿成大祸?
问题的根本原因一针见血:CLAUDE.md 只是"建议",Hooks 才是硬性"规则"。LLM 可能选择性忽略建议,但无法绕过系统层级的强制执行机制。
| 特性 | CLAUDE.md | Hooks |
|---|---|---|
| 执行方式 | LLM 自主决定是否遵从 | 系统强制触发,每次必执行 |
| 可靠性 | 依赖模型"自我约束" | 确定性保障 |
| 能力 | 仅可写文本指令 | 可运行脚本、调用 API、执行逻辑判断 |
| 适用场景 | 通用规范、个人偏好 | 安全红线、自动化工序、质量门禁 |
Hooks 生命周期——20+ 事件分层速查
不少文章宣称 Hooks 只有 8 个核心事件,但官方文档支持的事件远多于这个数目。按实际使用频率,可划分为三个层次。
高频事件(日常开发几乎必用)
| 事件 | 触发时机 | 核心用途 |
|---|---|---|
PreToolUse | 工具执行前 | 拦截危险命令、保护敏感文件、参数校验 |
PostToolUse | 工具执行后 | 自动格式化、运行测试、变更审计 |
Stop | Claude 完成回复时 | 质量门禁(测试是否通过、任务是否完成) |
Notification | Claude 发送通知时 | 桌面提醒、转发到 Slack/飞书 |
UserPromptSubmit | 用户提交输入前 | 输入验证、注入上下文 |
中频事件(特定场景很有用)
| 事件 | 触发时机 | 核心用途 |
|---|---|---|
SessionStart | 会话启动/恢复时 | 初始化环境、压缩后重新注入上下文 |
SessionEnd | 会话结束时 | 清理临时文件、记录统计 |
SubagentStart / SubagentStop | 子袋里启动/完成时 | 子任务分发监控与验收 |
PostToolUseFailure | 工具执行失败时 | 自动重试、错误上报 |
PermissionRequest | 权限弹窗时 | 自动审批/拒绝特定权限 |
PreCompact / PostCompact | 上下文压缩前后 | 压缩前保存关键信息/压缩后重新注入 |
低频但实用的事件
| 事件 | 触发时机 |
|---|---|
ConfigChange | 配置文件变更时 — 审计/阻止未授权修改 |
CwdChanged | 工作目录切换时 — 自动加载 direnv 等环境变量 |
FileChanged | 监听文件变化 — 监听 .envrc/.env 变更自动重载 |
InstructionsLoaded | CLAUDE.md/rules 加载时 — 追踪上下文加载情况 |
TaskCreated / TaskCompleted | 任务创建/完成时 — 生命周期追踪 |
WorktreeCreate / WorktreeRemove | worktree 创建/删除时 |
StopFailure | API 错误导致停止时 — 错误恢复/降级处理 |
TeammateIdle | agent team 成员空闲时 — 协调团队任务 |
Setup | --init-only 启动时 — CI 一次性初始化 |
PermissionDenied | 工具调用被自动拒绝时 — 返回 {retry: true} 允许重试 |
UserPromptExpansion | 命令展开为 prompt 前 — 可拦截命令展开 |
四种 Hook 类型
Hooks 不只限于执行 shell 命令,官方支持 4 种类型,各自适用于不同场景。
1. Command Hook(最常用)
运行 shell 命令,通过 stdin 接收 JSON 上下文,通过 exit code 和 stdout 返回决策结果。
{ "type": "command", "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" }
适用场景:确定性规则——格式化、拦截、日志记录。
2. Prompt Hook
将 hook 输入加上你的 prompt 发送给一个 Claude 模型(默认 Haiku),让模型做出判断,返回 {"ok": true/false, "reason": "..."}。
{"type": "prompt","prompt": "Check if all tasks are complete. If not, respond with {"ok": false, "reason": "what remains to be done"}."}
适用场景:需要理解语义的判断——任务是否完成、代码质量是否达标。
3. Agent Hook(实验性)
与 Prompt Hook 类似,但会 spawn 一个子袋里,可以读文件、搜索代码、运行命令来做更深入的验证。默认 60 秒超时,最多 50 次工具调用。
{"type": "agent","prompt": "Verify that all unit tests pass. Run the test suite and check the results.","timeout": 120}
适用场景:需要实际验证代码状态的场景。生产环境建议先用 command hook。
4. HTTP Hook
POST 事件数据到 HTTP 端点,适合接入审计服务、Webhook 等。
{"type": "http","url": "http://localhost:8080/hooks/tool-use","headers": { "Authorization": "Bearer $MY_TOKEN" },"allowedEnvVars": ["MY_TOKEN"]}
适用场景:团队审计、外部系统集成。
选型速查:
| 场景 | 推荐类型 |
|---|---|
| 格式化 / lint / 拦截 | command |
| 判断任务是否完成 | prompt |
| 需要跑测试验证 | agent |
| 团队审计 / 外部通知 | http |
Hook 通信协议
理解 Hook 与 Claude Code 之间的通信机制,是编写高质量 Hook 的基础。
输入:stdin 收到 JSON
每个 Hook 都通过 stdin 接收事件数据。例如 PreToolUse 事件:
{"session_id": "abc123","cwd": "/Users/sarah/myproject","hook_event_name": "PreToolUse","tool_name": "Bash","tool_input": {"command": "npm test"}}
你的脚本解析这些字段来做判断。UserPromptSubmit 收到 prompt 文本,SessionStart 收到 source(startup/resume/compact),不同事件的输入字段不同。
输出:exit code + stdout/stderr
| Exit Code | 含义 | 行为 |
|---|---|---|
0 | 无异议 | 正常流程继续;stdout 内容注入 Claude 上下文 |
2 | 阻止 | 动作被拦截,stderr 作为反馈发给 Claude |
| 其他 | 出错 | 动作继续,stderr 显示为 hook error |
JSON 结构化输出(更精细的控制)
Exit code 只有"放行/阻止"两种状态。要更精细的控制,exit 0 并向 stdout 输出 JSON:
PreToolUse 拦截并给理由:
{"hookSpecificOutput": {"hookEventName": "PreToolUse","permissionDecision": "deny","permissionDecisionReason": "Use rg instead of grep for better performance"}}
permissionDecision 三种值:
"allow":跳过交互式权限弹窗"deny":拦截工具调用,reason 反馈给 Claude"ask":正常弹出权限确认
需要特别注意的是:exit 2 和 JSON 输出不能混用。Exit 2 时 Claude Code 会忽略 JSON;要用 JSON 输出必须 exit 0。
实战案例:一份完整配置的逐行拆解
与其给你一堆零散的示例,不如把一份真实可用的配置拆透。以下配置来自实际项目,覆盖了 Hooks 最核心的三个场景:拦截 → 保护 → 校验。
完整配置
{"permissions": {"allow": ["Read","Bash(ls *)", "Bash(cat *)", "Bash(head *)", "Bash(tail *)","Bash(wc *)", "Bash(find *)", "Bash(grep *)", "Bash(echo *)","Bash(mkdir *)","Bash(git status)", "Bash(git log *)", "Bash(git diff *)","Bash(git branch *)", "Bash(git show *)","Bash(node --version)", "Bash(npm --version)","Bash(npx tsc --noEmit)"],"deny": ["Bash(rm -rf *)", "Bash(sudo *)", "Bash(curl *)", "Bash(wget *)","Read(./.env*)", "Read(./secrets/**)", "Read(./**/credentials*)","Edit(./.env*)", "Edit(./secrets/**)","WebFetch"],"ask": ["Bash(git push *)", "Bash(npm install *)"],"defaultMode": "acceptEdits"},"model": "ark-code-latest","hooks": {"PreToolUse": [{"matcher": "Bash","hooks": [{"type": "command","command": "python3 -c "import json,sys; d=json.load(sys.stdin); cmd=d.get('tool_input',{}).get('command',''); blocked=['rm -rf /','DROP TABLE','DROP DATABASE',':(){:|:&};:']; sys.exit(2) if any(b in cmd for b in blocked) else sys.exit(0)""}]},{"matcher": "Edit|Write|MultiEdit","hooks": [{"type": "command","command": "python3 -c "import json,sys; d=json.load(sys.stdin); p=d.get('tool_input',{}).get('file_path',''); sys.exit(2 if any(x in p for x in ['.env','package-lock.json','.git/']) else 0)""}]}],"PostToolUse": [{"matcher": "Edit|Write|MultiEdit","hooks": [{"type": "command","command": "node .claude/contracts/cli/post-edit-check.js","onFailure": "notify"}]}]},"enabledPlugins": {"code-review@claude-plugins-official": false,"security-guidance@claude-plugins-official": true},"language": "chinese","alwaysThinkingEnabled": true,"effortLevel": "medium"}
逐层拆解
第一层:Permissions — 静态权限规则
permissions 是 Claude Code 的静态防线——无需执行任何脚本,纯规则匹配,零延迟:
- allow:白名单放行。只允许读文件和安全的 Bash 命令(
ls、cat、git status等) - deny:黑名单拦截。
rm -rf、sudo、curl、wget一律禁止;.env和secrets/目录既不能读也不能改 - ask:需要确认。
git push和npm install影响较大,每次都弹确认框
permissions vs hooks 的分工:
| 维度 | permissions | hooks |
|---|---|---|
| 判断方式 | 静态规则匹配(glob 模式) | 动态脚本执行(可编程) |
| 延迟 | 零延迟 | 需要脚本执行时间 |
| 能力 | 只能按命令模式匹配 | 可以解析命令内容、检查文件路径、调 API |
| 例子 | Bash(rm -rf *) 拦截所有 rm -rf | 检查 rm -rf /tmp/build 是安全的,但 rm -rf / 必须拦截 |
这里有个关键设计思路:permissions 做粗粒度的静态拦截,hooks 做细粒度的动态判断。两者互补,不是替代关系。
第二层:PreToolUse — 工具执行前的两道关卡
关卡 1:Bash 危险命令检测
{"matcher": "Bash","hooks": [{"type": "command","command": "python3 -c "import json,sys; d=json.load(sys.stdin); cmd=d.get('tool_input',{}).get('command',''); blocked=['rm -rf /','DROP TABLE','DROP DATABASE',':(){:|:&};:']; sys.exit(2) if any(b in cmd for b in blocked) else sys.exit(0)""}]}
逐行拆解:
matcher: "Bash"— 只在 Claude 要执行 Bash 命令时触发- 从 stdin 读 JSON → 提取
tool_input.command字段 - 检查命令是否包含 4 种危险模式:
rm -rf /— 根目录删除DROP TABLE/DROP DATABASE— 数据库破坏:(){:|:&};:— fork bomb
- 命中任何一个 → exit 2(拦截),Claude 收到拦截反馈
- 都没命中 → exit 0(放行),走正常的 permission 流程
你可能会问:为什么 permissions 里已经有 Bash(rm -rf *) 了还要加这个 Hook?答案在于精细度。permissions 只能做 glob 匹配——Bash(rm -rf *) 会拦截所有 rm -rf 开头的命令,包括 rm -rf /tmp/build 这种安全的清理操作。而 Hook 可以检查命令内容,只拦截真正危险的 rm -rf /。
关卡 2:敏感文件保护
{"matcher": "Edit|Write|MultiEdit","hooks": [{"type": "command","command": "python3 -c "import json,sys; d=json.load(sys.stdin); p=d.get('tool_input',{}).get('file_path',''); sys.exit(2 if any(x in p for x in ['.env','package-lock.json','.git/']) else 0)""}]}
matcher: "Edit|Write|MultiEdit"— 覆盖所有文件编辑工具(管道符表示"或")- 提取
tool_input.file_path - 文件路径包含
.env、package-lock.json、.git/任一 → exit 2 拦截 - 否则 → exit 0 放行
这里有个双重保险:permissions 的 deny 规则里已经有 Edit(./.env*) 做静态拦截,即使 Hook 放行了,deny 规则仍然生效。
第三层:PostToolUse — 编辑后的自动化校验
{"matcher": "Edit|Write|MultiEdit","hooks": [{"type": "command","command": "node .claude/contracts/cli/post-edit-check.js","onFailure": "notify"}]}
- 文件编辑完成后自动触发
- 运行
post-edit-check.js做校验(TypeScript 类型检查、lint 等) "onFailure": "notify"— Hook 执行失败时通知用户,而不是静默忽略
post-edit-check.js 实现参考:
const { execSync } = require('child_process');const input = JSON.parse(require('fs').readFileSync('/dev/stdin', 'utf8'));const filePath = input.tool_input?.file_path || '';// 只检查 .ts/.tsx 文件if (!filePath.match(/.(ts|tsx)$/)) {process.exit(0);}try {execSync('npx tsc --noEmit', { stdio: 'pipe', timeout: 30000 });process.exit(0);} catch (e) {console.error(`TypeScript check failed after editing ${filePath}`);process.exit(0); // 不阻止,只是通知}
第四层:Plugins — 安全插件兜底
"enabledPlugins": {"code-review@claude-plugins-official": false,"security-guidance@claude-plugins-official": true}
security-guidance 插件启用后,会在 Claude 的工具调用链中注入安全审查——它内部就是用 Hooks 实现的,每次工具调用前后跑一个独立模型做安全评估。这相当于在你手动配置的 Hooks 之外,再加一层官方维护的安全防线。
code-review 关掉是因为项目已有自定义的代码审查规则,避免重复。
Matcher 机制详解
Matcher 控制 Hook 何时触发,是精准配置的关键。
各事件的 Matcher 过滤字段
| 事件 | Matcher 过滤什么 | 示例值 |
|---|---|---|
PreToolUse / PostToolUse | 工具名 | Bash、Edit|Write、mcp__.* |
SessionStart | 会话启动来源 | startup、resume、compact |
SessionEnd | 会话结束原因 | clear、resume、logout |
Notification | 通知类型 | permission_prompt、idle_prompt |
SubagentStart / SubagentStop | 袋里类型 | Explore、Plan、自定义袋里名 |
ConfigChange | 配置来源 | user_settings、project_settings |
Stop / UserPromptSubmit | 不支持 matcher | 始终触发 |
if 字段:更精细的过滤(v2.1.85+)
Matcher 只能按工具名过滤。if 字段用权限规则语法,可以同时匹配工具名和参数:
{"matcher": "Bash","hooks": [{"type": "command","if": "Bash(git *)","command": ".claude/hooks/check-git-policy.sh"}]}
只在 Bash 命令是 git 子命令时才触发 Hook,其他 Bash 命令忽略。if 只对工具事件有效(PreToolUse、PostToolUse 等)。
踩坑记录
坑 1:Stop Hook 有 8 次上限
Stop Hook 连续 8 次返回 block 后,Claude Code 会强制停止。这是防无限循环的安全机制。
解法:检查 stop_hook_active 字段:
INPUT=$(cat)if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; thenexit 0# 已触发多次,允许停止fi
坑 2:PostToolUse 无法撤销操作
工具已经执行完了才触发 PostToolUse。需要拦截的必须用 PreToolUse。
坑 3:PermissionRequest 在非交互模式不触发
用 claude -p 跑非交互模式时,PermissionRequest Hook 不触发。改用 PreToolUse。
坑 4:Hook 输出的 JSON 被 shell profile 污染
~/.bashrc 或 ~/.zshrc 里的 echo "Shell ready" 会被 prepend 到 Hook 的 JSON 输出前面,导致解析失败。
解法:用交互检测包裹:
if [[ $- == *i* ]]; thenecho "Shell ready"fi
坑 5:Hook allow 不能覆盖 deny 规则
PreToolUse Hook 返回 "allow" 可以跳过交互式权限弹窗,但如果 settings.json 里有对应的 deny 规则,工具调用仍然会被阻止。Hook 可以收紧限制,但不能放松限制。这是一条安全底线——即使 Hook 代码有 bug,deny 规则仍然兜底。
坑 6:exit 2 和 JSON 输出不能混用
Exit 2 时 Claude Code 忽略 stdout 的 JSON;要用结构化 JSON 输出控制行为,必须 exit 0。
速查表
| 操作 | 命令/配置 |
|---|---|
| 查看 Hook | /hooks |
| 禁用所有 Hook | "disableAllHooks": true |
| Hook 超时 | 默认 10 分钟(command),30 秒(prompt),60 秒(agent) |
| Exit 0 | 放行,stdout 注入上下文 |
| Exit 2 | 阻止,stderr 反馈给 Claude |
| Exit 其他 | 放行,stderr 显示为 hook error |
| JSON 输出 | Exit 0 + stdout 写 JSON(不能和 Exit 2 混用) |
| matcher 语法 | 管道分隔:Edit|Write;正则:mcp__.* |
if 字段 | 权限规则语法:Bash(git *),仅工具事件可用 |
| 环境变量 | $CLAUDE_PROJECT_DIR 指向项目根目录 |
| 调试 | claude --debug-file /tmp/claude.log 或 /debug |
| Hook 配置位置 | ~/.claude/settings.json(全局)/ .claude/settings.json(项目)/ .claude/settings.local.json(本地) |
