读完这篇你会拿到:
- 30 个钩子点的完整地图,知道每个事件触发时到底传给你什么 JSON
- 退出码 0 / 2 / 其他的精确语义,以及"拒绝"和"出错"是两件事
- JSON 结构化输出的所有字段,从
permissionDecision到terminalSequence - 4 种 hook 类型(command / http / mcp_tool / prompt / agent)的差异化用法
- 异步 hook、持久化环境变量、defer 工具调用这种"高级玩法"
- 12 个真实可跑的案例脚本
- 安全红线 & 调试技巧
长文预警,建议收藏后慢慢看。

一、先把概念钉死:hooks 跟 prompt 是两个世界
很多人第一次接触 hooks,会问这个问题:
"我直接在 prompt 里告诉 Claude'别 rm -rf',不就行了吗?为啥还要配 hook?"
答案很扎心:prompt 是劝告,hook 是制度。
Claude 是 LLM,本质是个"用概率生成下一个 token 的机器"。你让它"记得不要 rm -rf",它十次里有八次记得——剩下两次它就会"忘记"或者"判断失误"。
Hook 不一样。它是在事件点强制执行的 shell 命令。到点了不管 Claude 想不想,那个 pre-tool-use.sh 一定会跑,跑完 exit 2 就一定能拦截。
这跟系统编程里的 signal handler 是一个思路:你不希望进程靠"自觉"响应 SIGTERM,你装个 handler 强制处理。Claude 的 hook 就是那个 handler。
所以:
| Prompt 提醒 | Hook | |
|---|---|---|
| 性质 | 概率性(80% 记得) | 确定性(100% 执行) |
| 适用 | 软规范("请遵循我们的代码风格") | 硬规则("绝对不能改 .env") |
| 触发点 | 每次 Claude 思考时 | 固定的生命周期事件点 |
| 实现 | 自然语言 | shell 命令 / HTTP / LLM 判定 |
经验法则:能用 hook 兜底的,不要依赖 prompt。
二、30 个钩子点的完整地图
我把所有事件按"触发频率"分了三类。先建立全局视野,再决定你该配哪几个。
2.1 会话级(一辈子一次)
SessionStart、Setup、SessionEnd、InstructionsLoaded、ConfigChange、PreCompact、PostCompact、WorktreeCreate、WorktreeRemove
这八个钩子都跟"会话本身"相关,不跟单条对话或单次工具调用绑定。
2.2 轮次级(每轮对话一次)
UserPromptSubmit、UserPromptExpansion、Stop、StopFailure、MessageDisplay、PostToolBatch、Notification、TeammateIdle、TaskCreated、TaskCompleted
每次你和 Claude 来回一轮,会触发这一组里的部分事件。
2.3 工具级(每个工具调用)
PreToolUse、PostToolUse、PostToolUseFailure、PermissionRequest、PermissionDenied、SubagentStart、SubagentStop、CwdChanged、FileChanged、Elicitation、ElicitationResult
Claude 每调一个工具,会触发这一组。这是最容易写出花活儿的一类。
2.4 8 个最常用的事件(按使用频率排序)
| 排名 | 事件 | 干啥用的 |
|---|---|---|
| 1 | PostToolUse | 工具调完后做事(格式化、lint、自动 commit) |
| 2 | PreToolUse | 工具调前拦截(防 rm、防改敏感文件) |
| 3 | Notification | 桌面通知、声音提醒 |
| 4 | UserPromptSubmit | 改写/增强用户的 prompt,注入上下文 |
| 5 | SessionStart | 启动时加载项目上下文 |
| 6 | Stop | Claude 答完一轮时做事(自动验收) |
| 7 | PermissionRequest | 自动批准特定权限 |
| 8 | PostCompact | 压缩后重新注入关键信息 |
剩下 22 个事件等你遇到具体场景再查表,文档里都有详细 JSON schema。
三、退出码语言:0 / 2 / 其他到底是啥
这是最容易栽的地方。新手往往以为"退出码 1 = 阻止",错。
3.1 三种退出码的精确语义
| 退出码 | 含义 | Claude 的反应 |
|---|---|---|
| 0 | hook 没意见 | 正常流程继续(不会"批准",权限流程照旧) |
| 2 | 拒绝 + 反馈 | 操作被阻止;你的 stderr 内容作为反馈给 Claude |
| 其他(1、3、127…) | 出错 | 操作继续,但转录里显示 "hook error" + stderr 第一行 |
关键点 1:退出 0 不会"自动批准"。比如 PreToolUse hook 退出 0 只是说"我没意见",Claude 接下来还会走正常的权限检查。如果你想跳过权限提示,必须用 permissionDecision: "allow"(后面讲)。
关键点 2:退出 2 之后,Claude 收到的反馈是 stderr 而不是 stdout。如果你的拒绝原因写到 stdout,Claude 看不到,你就失败了。
关键点 3:退出非零非二的码(比如 shell 里 exit 1),不会阻止操作。Claude 还会执行那个工具。这跟"退出 2 = 阻止"的直觉相反,是 hooks 设计里反直觉但重要的一点。
3.2 用错退出码的两个经典 bug
Bug 1:以为 exit 1 能阻止
#!/bin/bash
if [ dangerous ]; then
echo "Blocked" >&2
exit 1 # ❌ 不会阻止!Claude 还是会跑那个命令
fi
exit 0
修法:把 exit 1 改成 exit 2。
Bug 2:拒绝原因写到 stdout
#!/bin/bash
if [ dangerous ]; then
echo "Blocked: ..." # ❌ 写到 stdout,Claude 看不到
exit 2
fi
exit 0
修法:改成 echo "..." >&2。
3.3 哪个事件能"阻止",哪个不能
不是所有事件都支持"阻止"语义。下面这些事件不支持退出 2 阻止(即使你退出 2 也只会显示 stderr 给用户,操作继续):
SessionStartSetupNotificationStopStopFailureSessionEndMessageDisplayTeammateIdleWorktreeCreateWorktreeRemove
这些事件想"阻止"得用 JSON 输出里的 decision 字段。
完整列表查文档的 Exit code 2 behavior per event。
四、JSON 输出:让 hook 跟 Claude 说得更细
退出码只能表达"同意 / 拒绝"二元决策。真实场景里你需要更复杂的反馈,比如:
- "允许执行,但要在用户终端弹个警告"
- "拒绝,并把推荐工具告诉 Claude(让它换个做法)"
- "批准后顺便切换到
acceptEdits模式"
这些都需要结构化 JSON 输出。
4.1 JSON 输出的通用结构
{
"continue": true,
"stopReason": "stopped",
"systemMessage": "这是一条给用户看的消息",
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "用 rg 代替 grep,性能更好"
}
}
字段含义:
continue:true/false,false 时整个会话立即终止stopReason:终止原因,会显示给用户systemMessage:纯文本消息,弹出给用户看hookSpecificOutput:不同事件用不同子字段
4.2 PreToolUse 的三种决策
hookSpecificOutput.permissionDecision:
| 值 | 含义 |
|---|---|
| "allow" | 跳过权限提示直接执行 |
| "deny" | 取消工具调用,把 reason 给 Claude |
| "ask" | 照常弹出权限提示 |
| "defer" | 退出进程,让 Agent SDK 接管(用于 -p 模式) |
⚠️ 重要:"allow" 不能覆盖权限系统里的 deny 规则。也就是说,如果你的权限配置拒绝 Bash(rm *),那 hook 即使返回 "allow",这条命令还是会被拒。hook 可以收紧权限,不能放宽。
4.3 完整案例:拒绝并给建议
#!/bin/bash
# 用 rg 替代 grep 的 hook
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command')
if echo "$COMMAND" | grep -q '^grep\b'; then
cat <<EOF
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "项目约定用 rg (ripgrep),不要 grep。性能快 5-10 倍,gitignore 也自动尊重。"
},
"systemMessage": "检测到 grep 命令,已自动拦截并要求改用 rg"
}
EOF
exit 0 # 注意:结构化输出时退出 0,不是 2
fi
exit 0
⚠️ 注意:用 JSON 输出时,退出码必须是 0。Claude Code 在你退出 2 时会忽略 JSON。
4.4 终端通知:terminalSequence
如果你想在用户的终端弹个原生通知(类似 Claude 自己的进度提示),用 OSC(Operating System Command)序列:
{
"terminalSequence": "\u001b]777;notify;Title;Body\u0007"
}
777 是 iTerm2 的私有 OSC 编号,其他终端可能不支持。常见的 OSC:
| 编号 | 含义 |
|---|---|
| 0 | 设置窗口标题 |
| 1 | 设置图标标题 |
| 2 | 设置窗口标题 |
| 9 / 99 | iTerm2 进度条 |
| 777 | iTerm2 通知 |
五、Matcher 匹配器:精确控制触发条件
5.1 三种评估方式
Matcher 的值有三种评估方式,根据值的字符判断走哪种:
| 值的特征 | 评估方式 | 例子 | ||
|---|---|---|---|---|
| "*"、"" 或省略 | 匹配所有 | "" | ||
| 仅含字母数字、_、-、空格、,、`\ | ` | 精确字符串或列表 | Bash、`Edit\ | Write、Edit,Write` |
| 包含其他字符(.、*、(、[ 等) | JavaScript 正则(未锚定) | ^Notebook、mcp.read. |
Edit|Write 和 Edit,Write 在 v2.1.191+ 是等价的。
5.2 MCP 工具的命名约定
MCP 工具名字长这样:mcp__<server>__<tool>,比如:
mcp__github__search_repositoriesmcp__filesystem__read_filemcp__plugin_my_plugin_db__query
注意中间是两个下划线,不是冒号也不是点。
要匹配某个 server 的所有工具:
{
"matcher": "mcp__github__.*"
}
要匹配所有 MCP 工具:
{
"matcher": "mcp__.*__.*"
}
5.3 实战:同时匹配多个工具
匹配"代码相关"的工具:
{
"matcher": "Edit|Write|MultiEdit"
}
匹配 MCP 工具:
{
"matcher": "mcp__github__create_.*|mcp__github__update_.*"
}
5.4 容易栽的坑
坑 1:匹配器区分大小写。"Bash" 和 "bash" 不一样。
坑 2:如果你想写正则但漏了 .*,匹配会很严格。"^Read" 不会匹配 "ReadFile",但 "Read" 会(因为未锚定)。
坑 3:Matcher 是"或"语义,不是"且"。"Edit|Write" = 匹配 Edit 或 Write。要"且"得用后面的 if 字段。
六、if 字段:比 Matcher 更细的过滤
if 字段在 v2.1.85 加入,比 Matcher 更精确。Matcher 只看工具名,if 可以看工具名 + 参数。
6.1 基本语法
if 字段用权限规则语法,跟你写 permissions.allow 的格式一样:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(git *)",
"command": "./.claude/hooks/check-git-policy.sh"
}
]
}
]
}
}
这段配置:只有当 Claude 跑 Bash 且命令以 git 开头时,才触发检查脚本。
6.2 if 的解析规则(尽力而为)
| if 模式 | 命令 | 触发? | 说明 |
|---|---|---|---|
| Bash(git *) | git push | ✅ | 命令名匹配 |
| Bash(git *) | npm test && git push | ✅ | 每个子命令都被检查 |
| Bash(git *) | echo $(git log) | ✅ | $() 和反引号内命令也被检查 |
| Bash(git *) | echo $(date) | ❌ | 没有子命令匹配 git |
| Bash(git push *) | echo $(date) | ✅ | 模式过宽,只要有任何 git 就触发 |
| Bash(git push *) | git log | ❌ | 命令名不匹配 |
6.3 为什么是"尽力而为"
if 字段是性能优化,不是安全机制。
如果 Bash 命令里嵌入复杂语法(条件命令、eval、转义),if 可能解析不出来。这种时候 hook 还是会跑——所谓"fail-open"。
所以硬安全约束要靠权限系统的 deny 规则,不要只靠 hook 的 if。
6.4 if vs Matcher 怎么选
- 想按"工具类型"过滤 → Matcher
- 想按"工具 + 具体参数"过滤 →
if if只在工具事件上有效:PreToolUse、PostToolUse、PostToolUseFailure、PermissionRequest、PermissionDenied- 把
if用到其他事件上会让 hook 静默不跑
七、4 种 hook 类型(不止 shell 命令)
很多人以为 hooks 就是 shell 命令,其实还有 4 种类型,每种适合不同场景。
7.1 command(最常用)
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs prettier --write"
}
两种形式:
- Shell 形式:省略
args,通过sh -c(macOS/Linux)或 Git Bash(Windows)执行 - Exec 形式:提供
args,直接 spawn 进程,绕过 shell
Exec 形式的好处:避免 shell 引号问题、避免 .bashrc 污染 JSON、性能稍好。
{
"type": "command",
"command": "/path/to/script.sh",
"args": ["--check", "--strict"]
}
7.2 http
把事件数据 POST 到一个 HTTP 端点:
{
"type": "http",
"url": "http://localhost:8080/hooks/tool-use",
"headers": {
"Authorization": "Bearer $MY_TOKEN"
},
"allowedEnvVars": ["MY_TOKEN"]
}
⚠️ 一定要写 allowedEnvVars!没有它,环境变量插值会被清空。这是安全设计,不是 bug。
7.3 mcp_tool
在已连接的 MCP 服务器上调用工具:
{
"type": "mcp_tool",
"toolName": "mcp__audit__log_action",
"toolInput": {
"action": "tool_used"
}
}
适合把 hook 跟 MCP 工具链集成,比如"工具调用后调 MCP 审计工具记录"。
7.4 prompt(基于提示)
让 Claude 模型判断要不要继续:
{
"type": "prompt",
"prompt": "Check if all tasks are complete. If not, respond with {\"ok\": false, \"reason\": \"what remains to be done\"}."
}
默认用 Haiku 模型(快便宜),可以指定别的:
{
"type": "prompt",
"prompt": "...",
"model": "sonnet"
}
适合"软判断"场景,比如"测试全过没?没过让 Claude 继续"。
7.5 agent(基于代理)
比 prompt 更猛,开一个 subagent 去验证。subagent 可以读文件、搜代码、跑命令。
{
"type": "agent",
"prompt": "Verify that all unit tests pass. Run the test suite and check the results.",
"timeout": 120
}
实验性,生产慎用。但作为"自动验收人"非常香。
7.6 5 种类型对比
| 类型 | 适合场景 | 性能 | 成本 |
|---|---|---|---|
| command | 确定性规则、文件操作、外部通知 | 极快 | 极低(本地命令) |
| http | 团队共享审计、集成现有服务 | 取决于服务 | 网络 + 服务成本 |
| mcp_tool | 复用 MCP 工具链 | 取决于 MCP | 取决于 MCP |
| prompt | 软判断("任务完成没") | 中等(单轮 LLM) | 模型调用成本 |
| agent | 复杂验证("测试过没") | 慢(多轮 LLM + 工具) | 高 |
经验法则:能用 command 解决就别上 prompt,能用 prompt 解决就别上 agent。
八、12 个真实可跑的案例
这一节是全文最实操的部分。每个例子都包含完整代码 + 注册到 settings.json 的方法。直接抄就能用。
案例 1:阻止 rm -rf(PreToolUse + if 字段)
.claude/hooks/block-rm.sh:
#!/bin/bash
COMMAND=$(jq -r '.tool_input.command')
if echo "$COMMAND" | grep -qE '\brm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+)?(-[a-zA-Z]*r[a-zA-Z]*\s+)?'; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "危险命令已拦截。请用 mv 到回收站,或用 trash-cli (macOS: trash, Linux: gio trash)。"
},
systemMessage: "⚠️ rm 命令被拦截"
}'
exit 0
fi
exit 0
注册:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(rm *)",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-rm.sh",
"args": []
}
]
}
]
}
}
案例 2:自动格式化 + 自动 lint
.claude/hooks/auto-format.sh:
#!/bin/bash
FILE_PATH=$(jq -r '.tool_input.file_path')
# 只处理存在的文件
[ ! -f "$FILE_PATH" ] && exit 0
case "$FILE_PATH" in
*.cpp|*.h|*.hpp|*.cc)
command -v clang-format >/dev/null && clang-format -i "$FILE_PATH"
;;
*.py)
command -v black >/dev/null && black "$FILE_PATH" 2>/dev/null
;;
*.ts|*.tsx|*.js|*.jsx)
command -v prettier >/dev/null && prettier --write "$FILE_PATH" 2>/dev/null
;;
*.go)
command -v gofmt >/dev/null && gofmt -w "$FILE_PATH"
;;
*.rs)
command -v rustfmt >/dev/null && rustfmt "$FILE_PATH"
;;
esac
exit 0
注册:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs -I{} ${CLAUDE_PROJECT_DIR}/.claude/hooks/auto-format.sh",
"args": []
}
]
}
]
}
}
案例 3:保护敏感文件
.claude/hooks/protect-files.sh:
#!/bin/bash
FILE_PATH=$(jq -r '.tool_input.file_path // empty')
PROTECTED_PATTERNS=(
".env"
".env.local"
".env.production"
"package-lock.json"
"yarn.lock"
"CMakeLists.txt"
"vcpkg.json"
".git/"
"id_rsa"
"*.pem"
"*.key"
)
for pattern in "${PROTECTED_PATTERNS[@]}"; do
if [[ "$FILE_PATH" == *"$pattern"* ]]; then
cat <<EOF >&2
Blocked: $FILE_PATH matches protected pattern '$pattern'
如确实需要修改,请在 .claude/settings.local.json 中临时禁用此 hook,或联系团队负责人。
EOF
exit 2
fi
done
exit 0
案例 4:会话启动注入项目上下文
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume",
"hooks": [
{
"type": "command",
"command": "echo 'Project: '$(basename $(pwd))' | Build: CMake (vcpkg for deps) | Test: ctest | Lint: clang-tidy | Style: clang-format | Recent:' && git log --oneline -5"
}
]
}
]
}
}
stdout 任何内容都会注入到 Claude 上下文。
案例 5:压缩后保留关键信息
{
"hooks": {
"PostCompact": [
{
"hooks": [
{
"type": "command",
"command": "echo '⚠️ IMPORTANT: This is a C++ project using CMake + vcpkg. Do not use Makefile. Use std::format instead of printf. Run ctest before committing.'"
}
]
}
]
}
}
案例 6:自动验收(Stop + prompt)
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Check whether the user's original request has been fully addressed. If anything remains unfinished or unclear, respond with {\"ok\": false, \"reason\": \"<what's left to do>\"}. Otherwise respond {\"ok\": true}."
}
]
}
]
}
}
Claude 答完一轮,Haiku 来判断"事干完没",没干完就让 Claude 继续干。
案例 7:自动跑测试(Stop + agent)
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "agent",
"prompt": "Run the project's test suite (e.g., `npm test`, `pytest`, `ctest`, `go test ./...`) and check whether all tests pass. If any fail, respond {\"ok\": false, \"reason\": \"<list of failing tests with their error messages>\"}. Otherwise respond {\"ok\": true}.",
"timeout": 180
}
]
}
]
}
}
⚠️ 警告:agent hook 是实验性的,且 timeout 默认 60 秒(这个例子里我手动改成 180)。
案例 8:所有 Bash 命令审计日志
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "jq -c '{ts: now|todate, cmd: .tool_input.command, cwd: .cwd}' >> ~/.claude/audit.jsonl"
}
]
}
]
}
}
每个 Bash 命令执行前都追加一行 JSON 到审计日志。可以配合 grep / jq 后续分析。
案例 9:direnv 集成(持久化环境变量)
如果你用 direnv 管理项目级环境变量,配这个让 Claude 的 Bash 工具也吃到:
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "direnv export bash > \"$CLAUDE_ENV_FILE\""
}
]
}
],
"CwdChanged": [
{
"hooks": [
{
"type": "command",
"command": "direnv export bash > \"$CLAUDE_ENV_FILE\""
}
]
}
]
}
}
$CLAUDE_ENV_FILE 是 Claude Code 提供的环境变量,指向一个临时文件,Claude Code 在每个 Bash 命令前都会 source 这个文件。这样 direnv 的变量就生效了。
案例 10:自动批准 ExitPlanMode
{
"hooks": {
"PermissionRequest": [
{
"matcher": "ExitPlanMode",
"hooks": [
{
"type": "command",
"command": "echo '{\"hookSpecificOutput\": {\"hookEventName\": \"PermissionRequest\", \"decision\": {\"behavior\": \"allow\"}}}'"
}
]
}
]
}
}
⚠️ matcher 必须严格。空 matcher 会自动批准所有权限提示,等于自杀。
案例 11:异步后台 lint(async)
不想让 lint 阻塞 Claude 主流程?
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"async": true,
"command": "jq -r '.tool_input.file_path' | xargs clang-tidy"
}
]
}
]
}
}
async: true 让 hook 在后台跑,Claude 不等它。但异步 hook 的输出(stdout/stderr/退出码)都不会影响 Claude——它是纯"通知式"的。
如果你想异步跑,但 hook 拒绝时还要 Claude 知道,用 asyncRewake: true。
案例 12:HTTP hook 推到团队审计服务
{
"hooks": {
"PostToolUse": [
{
"hooks": [
{
"type": "http",
"url": "https://audit.internal.example.com/claude/events",
"headers": {
"Authorization": "Bearer $AUDIT_TOKEN",
"Content-Type": "application/json"
},
"allowedEnvVars": ["AUDIT_TOKEN"]
}
]
}
]
}
}
九、几个高级玩法
9.1 异步 Hook(async)
两种模式:
| 字段 | 含义 |
|---|---|
| async: true | 后台跑,输出不影响 Claude |
| asyncRewake: true | 后台跑,但退出码 2 时"唤醒" Claude 接收反馈 |
什么时候用 asyncRewake?比如后台跑 lint,发现错误时还想让 Claude 修。
9.2 Defer 工具调用
PreToolUse 的 permissionDecision 可以是 "defer",这时 hook 进程会退出,让上层进程(比如 Claude Agent SDK)接管。
用途:在 -p 模式下,hook 不能直接弹权限提示,所以退出进程让 SDK 自己处理。
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "defer"
}
}
需要 Claude Code v2.1.89+。
9.3 一次性 hook(once)
只跑一次的 hook,用 once: true:
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"once": true,
"command": "echo 'Welcome! This message appears only once per session.'"
}
]
}
]
}
}
适合"第一次启动时提醒用户"这种场景。
9.4 自定义加载器消息(statusMessage)
Hook 跑起来时,Claude Code 会在底部显示一个加载提示。你可以用 statusMessage 自定义:
{
"type": "command",
"command": "long-running-lint.sh",
"statusMessage": "正在跑 clang-tidy,预计 30 秒…"
}
用户体验上的小细节,但做产品级配置时很有用。
9.5 通过 additionalContext 注入上下文
UserPromptSubmit hook 可以往 Claude 上下文里塞东西:
{
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": "用户当前在 src/database/ 目录下。最近 3 次提交都与 connection pool 相关。"
}
}
这跟 stdout 注入有什么区别?
- stdout 注入:每次事件触发都加,适合"项目元信息"
- additionalContext:动态生成,适合"本次任务相关的临时上下文"
十、安全红线(必看)
hooks 是个强大的能力,也是个巨大的攻击面。Claude Code 本身做了一些安全设计,你也要自己绷紧神经。
10.1 v2.1.139+ 的重要变化
从 v2.1.139 开始,macOS / Linux 上 hook 在无控制终端的会话中运行,无法访问 /dev/tty。
这意味着:
- hook 不能再从用户终端读取输入(防止"假装问用户"窃取信息)
- 一些需要交互的命令会失败(设计上的取舍)
10.2 你必须做的
1. 永远不要把 secret 写到 stdout
stdout 内容会被注入到 Claude 上下文,Claude 可能会把它写进文件、commit、推到远程。
#!/bin/bash
# ❌ 危险
echo "My API key is: $(cat ~/.aws/credentials)"
# ✅ 正确
echo "Loaded AWS credentials" >&2
2. 谨慎使用 allowedEnvVars
不要写 *,要明确列出:
{
"allowedEnvVars": ["MY_TOKEN", "USER_NAME"]
}
3. hook 脚本本身的权限管理
- 项目 hook 用
${CLAUDE_PROJECT_DIR}引用,相对路径相对当前仓库根 - 用户 hook 用绝对路径或
~ - 脚本要
chmod +x但 owner 要是你自己
4. 不要 hook 改 .claude/、~/.claude/ 下的文件
Claude Code 的部分目录对 hook 是"高危"的——写错位置会破坏配置。建议用 permissions.deny 显式禁止:
{
"permissions": {
"deny": ["Edit(.claude/**)", "Write(.claude/**)"]
}
}
10.3 团队环境
如果是团队项目:
- 项目级
.claude/settings.json提交到 git - 涉及 secret 的 hook 放
.claude/settings.local.json - 团队共享的审计/通知 hook 放 plugin
- review PR 时把 hook 当普通代码 review
十一、调试大全
11.1 三种查看 hook 状态的方式
方式 1:/hooks 菜单
只读浏览,看你的 hook 是否注册成功、各事件的 hook 数。
方式 2:Ctrl+O 转录视图
切到转录视图,每个 hook 触发会显示一行:
- 成功 → 静默
- 阻止 → 显示 stderr
- 错误 → 显示 "hook error" + stderr 第一行
方式 3:--debug-file 启动日志
claude --debug-file /tmp/claude.log
另一个终端:
tail -f /tmp/claude.log
能看到 hook 的:是否匹配、退出码、stdout、stderr 全量内容。
不写启动参数也能开:会话内 /debug 开启日志,会告诉你日志路径。
11.2 三个最常见的坑
坑 1:jq 解析失败
症状:hook error 或 stderr 显示 jq 错误。
排查:
echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./your-hook.sh
echo "Exit code: $?"
坑 2:路径找不到
症状:command not found。
修法:用绝对路径或 ${CLAUDE_PROJECT_DIR} 变量。Windows 用 PowerShell 时配 "shell": "powershell"。
坑 3:shell 配置污染 JSON
症状:JSON validation failed,即使脚本输出合法。
原因:.bashrc / .zshrc 里有 echo "Shell ready",在非交互 shell 也会执行(因为 hook 跑在非交互 shell 里,但 BASH_ENV / shell rc 文件还会被 source)。
修法:
# 在 ~/.zshrc 或 ~/.bashrc 里
if [[ $- == *i* ]]; then
echo "Shell ready"
fi
i 表示交互式 shell。
十二、我现在的完整配置
参考用。包含:
- 桌面通知
- 自动格式化
- 保护敏感文件
- 自动 ExitPlanMode
- 项目上下文注入
~/.claude/settings.json:
{
"hooks": {
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude 在等你\" with title \"Claude Code\"'"
}
]
}
],
"PermissionRequest": [
{
"matcher": "ExitPlanMode",
"hooks": [
{
"type": "command",
"command": "echo '{\"hookSpecificOutput\": {\"hookEventName\": \"PermissionRequest\", \"decision\": {\"behavior\": \"allow\"}}}'"
}
]
}
]
}
}
项目 .claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs -I{} sh -c 'case \"{}\" in *.cpp|*.h|*.hpp|*.cc) clang-format -i \"{}\" ;; *.py) black \"{}\" 2>/dev/null ;; *.md) prettier --write \"{}\" 2>/dev/null ;; esac'"
}
]
}
],
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh",
"args": []
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(rm *)",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-rm.sh",
"args": []
}
]
}
],
"SessionStart": [
{
"matcher": "startup|resume",
"hooks": [
{
"type": "command",
"command": "echo '项目: '$(basename $(pwd))' | 构建: CMake + vcpkg | 测试: ctest | 最近:' && git log --oneline -3"
}
]
}
],
"PostCompact": [
{
"hooks": [
{
"type": "command",
"command": "echo '⚠️ C++ 项目约定:CMake + vcpkg;用 std::format 不用 printf;提交前跑 ctest。'"
}
]
}
]
}
}
辅助脚本:
.claude/hooks/protect-files.sh:
#!/bin/bash
FILE_PATH=$(jq -r '.tool_input.file_path // empty')
PROTECTED=(".env" "CMakeLists.txt" "vcpkg.json" ".git/" "*.key" "*.pem")
for p in "${PROTECTED[@]}"; do
if [[ "$FILE_PATH" == *"$p"* ]]; then
echo "Blocked: $FILE_PATH matches '$p'" >&2
exit 2
fi
done
exit 0
.claude/hooks/block-rm.sh:
#!/bin/bash
COMMAND=$(jq -r '.tool_input.command')
if echo "$COMMAND" | grep -qE '\brm\s'; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "请用 mv 或 trash-cli,不要直接 rm。"
}
}'
fi
exit 0
chmod +x 两个脚本。
十三、FAQ:常见问题集中解答
写完上面那堆,朋友群里立刻扔过来一堆问题。我把最有代表性的 12 个挑出来,统一答一下。
Q1:hooks 跟 CLAUDE.md / subagents / skills 怎么分工?
这四个东西看起来都"扩展 Claude",但分工完全不同:
| 工具 | 干啥 | 触发时机 | 写入方式 |
|---|---|---|---|
| CLAUDE.md | 给 Claude 静态上下文(项目约定、编码风格) | 每次会话开始自动加载 | Markdown |
| Skills | 让 Claude 调用一组预定义的工作流 | Claude 主动 /命令 触发 | Markdown + 可选脚本 |
| Subagents | 把任务派给独立上下文的"副驾" | Claude 自动 / @ 触发 | Markdown 文件 |
| Hooks | 在事件点强制执行 shell 命令 | 固定生命周期点 | JSON 配置 + shell 脚本 |
经验法则:
- "我希望 Claude 记得我们的项目约定" → CLAUDE.md
- "我想让 Claude 能调用一个复杂的测试流程" → Skill
- "我想让 Claude 把脏活派给独立的副驾" → Subagent
- "我需要 Claude 每次写完文件自动格式化" → Hook
四个不互斥,可以组合用。比如我项目的 CLAUDE.md 里写"用 C++20″,配合 hook 自动 clang-format,再加上 cpp-reviewer subagent 自动审查。
Q2:多个 hook 同时触发,谁优先?
答案是:都跑,按规则合并结果。
具体来说:
- 同一事件有多个 hook → 全部并行执行
- 多个
PreToolUse返回权限决策 → 最严格的赢:deny > defer > ask > allow - 多个 hook 都想写
additionalContext→ 全部拼起来注入
⚠️ 不要依赖 hook A 的输出去影响 hook B 的行为(比如 hook A 写文件,hook B 读那个文件)。并行执行意味着顺序不确定,依赖关系会偶发翻车。
如果两个 hook 想"接力",把它们合并成一个 hook 脚本里的多次调用,别拆成两个 hook。
Q3:改完 settings.json 要重启 Claude Code 吗?
通常不用。 Claude Code 监视 ~/.claude/settings.json 和 .claude/settings.json,几秒内自动拾取。
例外情况:
- 第一次创建
.claude/agents/或类似目录 → 重启一次让它发现 - 用
--disable-slash-commands启动的会话 → 完全不监听 - 极少数文件监视器 bug → 重启解决
如果 /hooks 菜单里看不到你新加的 hook,但 JSON 文件写得没问题,大概率是文件监视器抽风了,重启一次就好。
Q4:hook 能在 Windows / macOS / Linux 之间共享吗?
配置 JSON 可以共享,shell 脚本不行——因为命令语法不一样。
跨平台 hook 的标准做法:
1. 命令里区分平台
# 在 hook 脚本里
case "$(uname -s)" in
Darwin)
osascript -e 'display notification "..."'
;;
Linux)
notify-send "Claude" "..."
;;
MINGW*|CYGWIN*|MSYS*)
powershell.exe -Command "[System.Reflection.Assembly]::..."
;;
esac
2. JSON 里用 $CLAUDE_PROJECT_DIR
这个变量在三个 OS 上都解析成项目根目录,跨平台脚本里直接用。
3. Windows 上指定 shell
{
"type": "command",
"command": "C:/path/to/script.ps1",
"shell": "powershell"
}
最干净的方案:hook 脚本本身用纯 Bash(Git Bash 兼容),JSON 配置不动。macOS / Linux 原生 bash,Windows 装 Git for Windows 后 Claude Code 自带 Git Bash。PowerShell 留给 Windows 特有场景。
Q5:hook 性能开销大吗?频繁触发会不会拖慢?
老实说:有点,但一般可以忽略。
实测数字(我自己的项目,MacBook Pro M2):
| 场景 | hook 类型 | 每次开销 |
|---|---|---|
| PostToolUse 跑 prettier | command | 50-200ms |
| PreToolUse 解析 Bash 命令 | command | 5-10ms |
| SessionStart 跑 git log | command | 50-100ms |
| Stop 用 prompt 判定 | prompt | 1-3s(Haiku 单轮) |
| Stop 用 agent 跑测试 | agent | 30-120s |
优化建议:
- 热路径 hook(PostToolUse / PreToolUse)必须轻量。超过 500ms 用户会感觉"卡"
- 重活儿用
async: true,别阻塞 Claude - 重活儿用
prompt/agent放到Stop而不是PostToolUse - 冷启动钩子(SessionStart)可以重,反正一年就跑几次
- 文件 I/O 别同步阻塞——读 secret、写日志用
&后台跑
Q6:hook 输出字符有上限吗?
有,且文档里没说清楚。
实测上限是 10,000 字符。超出部分会被截断。
具体场景:
- stdout 注入上下文:超出截断,可能导致关键信息丢失
- stderr 反馈给 Claude:超出截断,反馈不完整
- JSON 输出:解析失败,整个 hook 视为错误
如果你的 hook 需要输出大段内容(比如 dump 整个文件),写到文件里别写到 stdout:
#!/bin/bash
# ❌ 危险
cat /etc/passwd
# ✅ 正确
cat /etc/passwd > /tmp/claude-context-dump.txt
echo "Dump written to /tmp/claude-context-dump.txt"
Q7:为什么我的 hook 静默不跑?
按这个清单排查:
1. JSON 格式错——多余逗号、缺引号、错括号。/hooks 菜单里看不到就是格式错。
2. matcher 不匹配——区分大小写。bash ≠ Bash。
3. 触发的事件不对——PreToolUse 在工具执行前,PostToolUse 在之后;Notification 是给"通知事件",不是所有事件。
4. 权限被拒绝——permissions.deny 里有 Agent(...) 把整个 subagent 系统禁用时,hook 也跑不了。
5. 在非交互模式(-p)下 PermissionRequest 不触发——改用 PreToolUse。
6. matcher 留空但 hook 类型是 prompt——prompt hook 必须有非空 matcher(设计上要求)。
7. 文件权限——hook 脚本没 chmod +x。
8. --disable-slash-commands——这个标志顺便禁用了 hook 监视。
Q8:hook 能 chain 起来吗?一个 hook 调用另一个 hook?
严格说不能"hook 调用 hook"(hook 不会触发另一个 hook 事件),但你可以让一个 hook 脚本里顺序执行多个动作:
#!/bin/bash
# 一个 hook 脚本里做三件事
jq -r '.tool_input.command' >> ~/.claude/bash.log
clang-format -i "$(jq -r '.tool_input.file_path')"
ctest --output-on-failure 2>&1 | tail -20
exit 0
如果真的需要 hook A 的结果影响 hook B(比如 hook A 拦截了命令,hook B 才能继续),把它们合并成一个 hook 脚本,别拆成两个 hook 配置文件项。
Q9:hook 能修改 Claude 即将看到的 prompt 吗?
能,但仅限 UserPromptSubmit。
{
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": "当前在 src/auth/ 目录。最近一次提交: abc123。"
}
}
⚠️ 注意:additionalContext 是追加到上下文,不是替换原 prompt。如果你想"重写"用户的 prompt,得用 UserPromptExpansion(专门干这个的事件,但更复杂)。
Q10:团队怎么共享 hook 而不泄露 secret?
分层策略:
项目级(.claude/settings.json,提交 git):通用 hook,所有人都用
- 自动格式化
- 拦截 rm
- 注入项目约定
本地级(.claude/settings.local.json,gitignore):个人配置
- 个人通知脚本
- 调试用 verbose hook
- 本地路径相关的 hook
用户级(~/.claude/settings.json,本机私有):跨项目通用
- 桌面通知
- 个人习惯的 hook
Plugin 级:可分享的封装
- 团队通用的工作流 hook 包
- 配合
${CLAUDE_PLUGIN_ROOT}做路径隔离
secret 相关(API key、token)一律不写到 hook 命令里。用环境变量 + allowedEnvVars(HTTP hook)或 $VAR(command hook)。
Q11:plugin 里的 hook 跟项目 hook 冲突怎么办?
plugin hook 优先级最低,项目 hook 覆盖 plugin hook。
具体冲突解决顺序(高 → 低):
- 托管设置(管理员级别)
- 用户级
~/.claude/settings.json - 项目级
.claude/settings.json - 项目级
.claude/settings.local.json - Plugin 的
hooks/hooks.json
如果你装了 plugin 提供的 hook 想禁用它,别改 plugin 文件——在项目级配置里覆盖:
{
"permissions": {
"deny": ["Agent(plugin:hook-name)"]
}
}
或者用 disableAllHooks: true(但这会禁用所有 hook,慎用)。
Q12:hook 能用环境变量吗?怎么从 Claude Code 传进来?
三种方式:
方式 1:Claude Code 内置环境变量
#!/bin/bash
echo "Project: $CLAUDE_PROJECT_DIR"
echo "Plugin root: $CLAUDE_PLUGIN_ROOT"
echo "Env file: $CLAUDE_ENV_FILE"
echo "Session: $CLAUDE_SESSION_ID"
这些是 Claude Code 自动注入的,跨平台可用。
方式 2:host 系统的环境变量
hook 命令在 shell 上下文跑,能直接读到 host 环境变量:
#!/bin/bash
echo "USER=$USER"
echo "PATH=$PATH"
方式 3:HTTP hook 传 header(带白名单)
{
"type": "http",
"url": "https://example.com/hook",
"headers": {
"Authorization": "Bearer $MY_TOKEN"
},
"allowedEnvVars": ["MY_TOKEN"]
}
$MY_TOKEN 会被替换为环境变量值,但只在你白名单里的变量才生效。
如果还有问题没覆盖到,直接扔评论区或者邮件,我看到就回。
十四、写在最后:hooks 是"AI + 确定性"的混合架构
回过头看,hooks 真正的设计哲学不是"加个自动化脚本",而是承认一个事实:
LLM 永远会有概率失误,关键路径必须有"机械的兜底层"。
医疗 AI 误诊时不能靠"再训一遍模型",必须有"人 review"的环节。自动驾驶 AI 看到行人时不能靠"概率足够高",必须有"硬刹车"的逻辑。Claude 写代码同理——
重要的不是"Claude 记不记得",而是"如果它忘了,系统能不能兜住"。
Hooks、Subagents、Permissions、CLAUDE.md,这些 Claude Code 的设计元素本质上都在做同一件事:把"AI 应该做的"和"系统必须保证的"分开。
AI 做创造性的部分(写代码、判断意图、生成方案),hooks 做确定性的部分(格式化、拦截敏感文件、审计日志)。两个层各司其职。
下次再遇到"Claude 又忘了"的场景,别再写 prompt 劝告了——加个 hook,让它没法忘。
参考链接: