Hooks:确定性自动化
Hooks 是 Claude Code 在其生命周期的特定节点自动运行的 shell 命令。权限决定一个操作是否被允许,而 hooks 让你围绕该操作运行确定性的逻辑——格式化、校验、日志、关卡、脱敏。这样你就能让行为变成有保证的,而不是"请记得……"。
- 何时应该使用 hook,而不是指令、权限或 skill
- 完整的生命周期:会话、提示词、工具、子代理、任务、压缩和回合结束事件
- hook 如何接入:事件、matcher,以及 stdin 上的 JSON payload
- hook 阻止操作的两种方式——退出码 2 与 stdout 上的 JSON
- 各事件共享的通用 JSON 输出字段:continue、additionalContext、systemMessage、reloadSkills、sessionTitle、suppressOutput、updatedInput
- 区分快速安全的 hook 与迟缓无声的 hook 的良好实践和常见错误
何时使用 hook
当你希望某个行为被保证,而不只是被请求时,就使用 hook。每种常见任务都对应一个生命周期事件:
- 每次编辑文件后自动格式化 / lint(
Edit|Write上的PostToolUse)。 - 在违反规则的操作运行之前阻止它(
PreToolUse)。 - 在助手的消息显示到终端之前对其脱敏(
MessageDisplay)。 - 在
SessionStart之后重新加载 skills或设置会话标题。 - 会话结束、任务完成或某个回合失败时通知或记录日志(
Stop、StopFailure、SessionEnd)。 - 在会话开始或加载指令时注入上下文。
工作方式
你在 settings.json 中注册 hooks,匹配一个事件(通常还有一个 matcher——多为工具名称,有时是子代理类型、压缩触发方式或通知类别)。事件触发时,Claude 会运行你的命令,并在 stdin 上传入 JSON payload(事件名称、会话,以及工具名称和输入等特定于事件的字段)。命令的退出码和 stdout 决定接下来发生什么。
- 在 settings.json 中,把 hook 注册到你关心的生命周期事件下——例如用 PostToolUse 做工具之后的自动化,或用 MessageDisplay 做脱敏。
- 添加 matcher,让 hook 只在相关时触发,例如文件编辑用 matcher "Edit|Write",或在 SessionStart 上用 matcher "startup|resume"。
- 事件触发时,Claude 运行你的命令,并通过 stdin 传入 JSON payload——事件名称、会话,以及特定于事件的字段(tool_name、tool_input、notification 等)。
- 命令的退出码和输出决定结果:放行操作、运行你的逻辑、阻止它,或返回结构化 JSON 来改变 Claude 看到的内容。
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" }
]
}
]
}
}
上面的 hook 从 stdin 的 JSON(.tool_input.file_path)中读出被编辑文件的路径并格式化它。不要假设某个环境变量里存着路径——请从 stdin 读取。 像 ${CLAUDE_PROJECT_DIR} 这样的路径占位符确实可用,用于定位脚本。
生命周期一览
Claude Code 的 hooks 贯穿整个会话,而不只是工具调用。按阶段分组,你就知道该把逻辑挂在哪里。
| 阶段 | 事件 | 典型用途 |
|---|---|---|
| 会话 | SessionStart、SessionEnd、Setup、InstructionsLoaded、ConfigChange、CwdChanged、DirectoryAdded、FileChanged | 启动时注入上下文,结束时做快照,响应 CLAUDE.md 重新加载或磁盘上 .env 的变化 |
| 提示词 | UserPromptSubmit、UserPromptExpansion | 在 Claude 看到提示词之前对其设置关卡或改写 |
| 工具 | PreToolUse、PermissionRequest、PermissionDenied、PostToolUse、PostToolUseFailure、PostToolBatch | 允许/拒绝操作,升级交给人工,编辑后格式化,自动模式拒绝后重试 |
| 子代理 | SubagentStart、SubagentStop | 记录子代理的启动/停止,阻止子代理返回 |
| 任务 | TaskCreated、TaskCompleted | 回滚误创建的任务,对完成设置关卡 |
| 压缩 | PreCompact、PostCompact | 压缩前对对话记录做快照,压缩后验证 |
| 模型 | PreModelSwitch、PostModelSwitch | 在模型切换生效前阻止它;在模型变更后做出反应,包括恢复的会话还原模型时 |
| 回合 | Stop、StopFailure、Notification、MessageDisplay | 完成时通知,API 失败时告警,对显示的文本脱敏 |
| 团队 + MCP | TeammateIdle、Elicitation、ElicitationResult、WorktreeCreate、WorktreeRemove | 跨会话协调,对 MCP elicitation 设置关卡,包装 worktree 操作 |
每个事件确切的 stdin 字段、matcher 语法和阻止规则都在官方 hooks 参考中。上面的目录是地图;参考文档才是实地。
hook 如何阻止操作
有两种方式,取决于事件:
- 退出码 2 —— hook 使操作失败,它写到 stderr 的内容会成为 Claude 看到的消息。简单,适用于阻塞型事件上的命令 hook(
PreToolUse、UserPromptSubmit、UserPromptExpansion、Stop、SubagentStop、TeammateIdle、TaskCreated、TaskCompleted、ConfigChange、PostToolBatch、PreCompact、PreModelSwitch、Elicitation、ElicitationResult、WorktreeCreate、WorktreeRemove)。在PermissionRequest上退出码 2 会被忽略;请改用它的decision对象来拒绝。 - stdout 上的 JSON(退出码 0) —— 返回结构化决定。对
PreToolUse,就是permissionDecision,取值为"allow"、"deny"、"ask"(把这次调用交给用户)或"defer"(仅用于由会恢复它们的应用驱动的-p运行),并附上permissionDecisionReason。非阻塞型事件(PostToolUse、Notification、MessageDisplay、StopFailure、大多数会话事件)会忽略阻止尝试——但它们的 JSON 输出仍然可以引导 Claude 接下来看到什么。
下面的脚本是 Bash 工具上的 PreToolUse hook。从上到下读:它从 stdin 取出命令,如果看起来有破坏性,就向 stderr 写入原因并以退出码 2 阻止。
#!/usr/bin/env bash
# PreToolUse hook on the Bash tool: refuse to delete things.
command=$(jq -r '.tool_input.command' < /dev/stdin)
if [[ "$command" == rm\ * || "$command" == *"rm -rf"* ]]; then
echo "Blocked: destructive 'rm' is not allowed by policy." >&2
exit 2
fi
exit 0
通用 JSON 输出字段
大多数事件都接受 stdout 上的结构化 JSON 对象,用来引导接下来发生什么——它独立于 permissionDecision 这类特定于事件的决定字段。最有用的几个:
| 字段 | 作用 | 适用位置 |
|---|---|---|
continue | 若为 false,Claude 完全停止处理——优先于所有特定于事件的决定 | 大多数事件 |
systemMessage | 向 Claude 的上下文 / 对话记录添加一条消息,让它知道你为何这样做 | 大多数阻塞型事件 |
additionalContext | 注入额外上下文,Claude 在下一回合可以读取并据此行动 | SessionStart、UserPromptSubmit 等 |
reloadSkills | 重新扫描 skill 目录,使会话能获取你刚写好的 skill | SessionStart(以及其他会话事件) |
sessionTitle | 设置界面中的会话标题——在 SessionStart 上位于 hookSpecificOutput 之下 | SessionStart |
suppressOutput | 对 Claude 隐藏工具的输出(适合输出很吵的命令) | 工具事件 |
updatedInput | 在工具运行前改写其输入(例如添加 --dry-run 标志) | PreToolUse |
terminalSequence | 发出终端转义序列——响铃、窗口标题、通知角标 | Notification、StopFailure 等 |
特定于事件的决定字段——PreToolUse 上的 permissionDecision("allow"、"deny"、"ask" 或 "defer",外加 permissionDecisionReason)、PermissionRequest 上的 decision.behavior("allow" 或 "deny"),以及 PermissionDenied 上让 Claude 重试被拒绝调用的 retry: true——叠加在这些通用字段之上。
心智模型
PreToolUse hook 在操作之前运行并可以阻止它;PostToolUse hook 在操作成功之后运行并对结果做出反应。
良好实践
- 保持 hooks 快速且幂等——它们会频繁运行。所有
SessionEndhooks 共用 1.5 秒的总预算,所以会话结束时的工作要尽量少。 - 对真正的问题要大声失败,但不要因为外观上的小问题而阻止。
- 把 hook 的输出当作给 Claude 的反馈——stderr 上(退出码 2)或
systemMessage(JSON)中清晰的消息能帮助它自我纠正。 - 想说的不止"已阻止"时,优先使用 JSON 输出而非退出码——
PreToolUse上的permissionDecision: "ask"把决定推给人工而不是砰地关门,additionalContext则让你能引导而不只是拒绝。 - Hooks 以你的 shell 权限运行——审查任何不是你自己写的 hook(审查第三方代码)。
常见错误
- 从环境变量读取文件路径。 路径在 stdin 的 JSON 里(
.tool_input.file_path),而不是$CLAUDE_FILE_PATH。把 stdin 通过管道交给jq。 - 无声的阻止。 如果
PreToolUsehook 以退出码 2 退出而 stderr 什么也没有,Claude 被阻止了却不知道为什么,也就无法调整。务必写明清晰的原因(或使用permissionDecisionReason)。 - 试图阻止非阻塞型事件。
PostToolUse在工具运行之后触发——退出码 2 只是在 Claude 耳边说一句话,无法撤销操作。如果需要设置关卡,请挂在PreToolUse上。 - 缓慢的 hook。
PostToolUsehook 在每一次匹配的编辑后运行。一个耗时 3 秒的 linter 会让整个会话显得迟缓——保持 hooks 快速,最好只处理发生变化的部分。 - 过宽的 matcher。
matcher: ".*"会在每个工具上触发。用精确名称、Edit|Write列表,或每个 handler 的if字段(例如"if": "Bash(git push *)")来收窄。 - 忘了非工具事件也有 matcher。
SessionStart匹配startup|resume|clear|compact|fork;Notification匹配permission_prompt|idle_prompt|agent_completed这类类别;PreCompact匹配manual|auto。单个 hook 接到了错误的子集,要么触发过于频繁,要么从不触发。 - 信任不是你写的 hook。 hook 以你的权限运行任意 shell。先审查来自插件或模板的任何 hook——参见审查第三方代码。
- hook 以你的权限运行任意 shell——在没有读过之前,切勿接入来自插件或模板的 hook。
值得借鉴的配方
可直接复制粘贴的起步模板在 Hooks 与 settings.json 配方中。下面几个高杠杆的例子,展示较新事件的用法:
自动格式化已编辑的文件(Edit|Write 上的 PostToolUse)
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" }
]
}
]
}
}启动时设置按项目命名的会话标题(SessionStart)
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume",
"hooks": [
{
"type": "command",
"command": "jq -nc --arg t \"$(basename \"$PWD\") — $(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo detached)\" '{hookSpecificOutput:{sessionTitle:$t}}'"
}
]
}
]
}
}SessionStart 之后重新加载 skills(获取你刚写好的 skill)
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume",
"hooks": [
{ "type": "command", "command": "printf '{\"reloadSkills\": true}\n'" }
]
}
]
}
}对显示的助手文本中的 API 密钥脱敏(MessageDisplay)
{
"hooks": {
"MessageDisplay": [
{
"hooks": [
{
"type": "command",
"command": "jq -r '.message.content' | sed -E 's/(sk-[A-Za-z0-9_-]{20,})/sk-***REDACTED***/g'"
}
]
}
]
}
}回合失败时通知(StopFailure——速率限制、过载、认证)
{
"hooks": {
"StopFailure": [
{
"matcher": "rate_limit|overloaded|authentication_failed|billing_error",
"hooks": [
{ "type": "command", "command": "osascript -e 'display notification \"Claude Code turn failed — check the terminal\" with title \"Claude Code\"'" }
]
}
]
}
}自测
0/4- Hooks 让行为有保证而非只是被请求——围绕操作的确定性逻辑,而权限只能允许或拒绝。
- 生命周期包含会话、提示词、工具、子代理、任务、压缩和回合各阶段的 30 多个事件——写脚本之前先选对阶段。
- 在 settings.json 中针对事件加 matcher 注册;Claude 通过 stdin 传入 JSON payload,并读取你的退出码和输出。
- 从 stdin(.tool_input.file_path)读取文件路径——而不是环境变量。
- 用退出码 2(stderr 成为消息)或 stdout 上的结构化 JSON 阻止(工具事件用 permissionDecision,{continue: false} 停止一切)。
- 通用输出字段——additionalContext、systemMessage、reloadSkills、sessionTitle、suppressOutput、updatedInput——让你引导 Claude,而不只是放行或拒绝。
- 保持 hooks 快速、幂等且匹配范围窄——并审查任何不是你写的 hook,因为它以你的 shell 权限运行。
下一步
- Mods:进程内扩展 —— 进程内的兄弟机制:可以改写事件而不只是允许或阻止它的 TypeScript 中间件,并以
classic.<Event>的形式覆盖本页的每个事件 - settings.json · 权限
- Skills —— 专业知识 vs 自动化
- 加固自主运行