avatar


5.Hook

引子

在工程里有一类事必须必然发生(改完代码必须格式化、rm -rf必须拦下、测试没过不许收工),这类硬规则写进CLAUDE.md,本质上仍是"建议"。

那么,**怎样让一件事百分之百发生,而不靠模型自觉?

Hook(钩子)。

原理

理解Hook,有四个要点:

  1. 凭什么是确定的?
  2. 挂在循环的哪个节点?
  3. 配置长什么样?
  4. 哪几个事件最常用?

Hook以当前用户的权限自动运行任意命令,不会逐次向我们确认。一条配置错误或恶意的Hook足以删除文件、外泄数据。启用任何来路不明的Hook配置前,务必逐条审阅其命令内容。

确定性机制:Hook凭什么"必然"

Hook的"必然",靠的是绕开模型。
前几章的CLAUDE.md、Skill(技能)、Sub-Agent(子智能体)有一个共同点:都作用于模型的输入,要不要照做,最终由模型的概率环节决定,大概率遵守,但没有机制保证百分之百。
Hook不同,它不进入模型的输入,而是一段挂在循环节点上的代码:事件一到,系统直接执行,模型没有投票权。这就是它能"必然发生"的原因。

运行机制

运行机制:工具调用循环(agent loop)的每个阶段都有固定的节点,Hook就挂在这些节点上,循环本身的机制见《1.Start》

按粒度,事件分三层:

  • 会话级
    整个会话触发一次,如会话启动、会话结束。
  • 轮级
    每轮对话触发一次,如用户提交prompt、Claude完成响应。
  • 工具级
    每次工具调用触发一次,如工具执行前、工具执行后。

配置结构

Hook配置是标准JSON,写在settings文件的hooks键下,按事件名分组。
每个事件对应一个数组,数组元素由两部分组成:matcher限定这组Hook的适用范围,hooks是处理器列表。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "./.claude/hooks/check.sh" }
]
}
]
}
}

事件

什么是事件

类比前端里的事件(click、touch),我们能更好的理解ClaudeCode中的事件。
在前端,click是"用户点了一下"这个时刻,onclick是你挂在这个时刻上的处理器,点击一发生,处理器就自动执行。touch与ontouch同理。

而Hook只是把监听对象从"页面上的用户动作"换成了"工具调用循环里的节点":PreToolUse是"工具即将执行"这个时刻,你挂上一段命令,工具一要执行,命令就自动跑。
上文的「运行机制」按粒度把这些时刻分成会话级、轮级、工具级三层,而事件就分布在这三层里。

五个高频事件

在约三十个事件里,下面五个覆盖了绝大多数场景。
注意,这里只讲每个事件何时触发、能做什么;拦截、放行、注入这些动作在协议上怎么表达,见后文「输入输出协议」。

  • PreToolUse
    工具执行前触发,整个体系里最常用的拦截点。
    它可以放行、拒绝、转交用户确认,还可以改写工具参数后再执行。
    拦截危险命令、保护敏感文件都在这里做。
  • PostToolUse
    工具成功执行后触发。
    此刻操作已经发生、无法撤销,能做的是补偿与反馈:自动格式化刚写入的文件、记录审计日志,或把问题反馈给Claude要求它处理。
  • UserPromptSubmit
    用户提交prompt后、Claude开始处理前触发。
    可以拦下这轮输入,也可以向这轮对话注入信息。
  • Stop
    Claude认为本轮工作完成、准备停下时触发。
    可以阻止它收工,并把原因反馈给它继续干活。
    "测试没过不许结束"这类质量闸门就挂在这里。
  • SessionStart
    会话开始或恢复时触发,matcher可区分startup/resume/clear/compact四种来源。
    适合在会话起点注入环境信息,或在上下文压缩后重新注入提醒。
    它不能阻止任何事。

选型:CLAUDE.md、权限规则,还是Hook

一句话分工:希望模型遵守的约定,写CLAUDE.md;能事先划死的边界,交给权限规则;必须临场判断、必然执行的动作,挂Hook。

性质 怎么生效
CLAUDE.md 建议 写进模型的输入,模型大概率听
权限 静态策略 用allow/deny/ask规则事先划死边界,语法见《1.Start》
Hook 动态响应 在事件发生的瞬间用代码做决定

Hook与权限之间有一条明确的分界:PreToolUse返回allow可以跳过交互式确认,但不能越过权限规则里的deny

示例

需求

假设存在一个Node.js项目,团队约定:

  1. 破坏性命令必须拦截
  2. 写完文件立即格式化
  3. 测试没过不许收工
  4. 上下文压缩后要重申规范

我们把四段配置都放在项目的.claude/settings.json,可以合并进同一个hooks键。

第一道

第一道,PreToolUse拦截危险命令。
matcher限定Bash,脚本用jq取出即将执行的命令,发现rm -rf就以exit 2阻止,stderr里的原因会反馈给Claude。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "if grep -q 'rm -rf' <<< \"$(cat | jq -r '.tool_input.command')\"; then echo 'Destructive command blocked' >&2; exit 2; fi"
}
]
}
]
}
}

第二道

第二道,PostToolUse自动格式化。
matcher写Edit|Write,文件一写完就交给Prettier。Claude不需要知道、也不需要记得项目用什么格式化规范,这件事从"模型的义务"变成了"系统的性质"。
示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
}

第三道

第三道,Stop测试闸门。
Claude准备收工时跑一遍npm test,失败就以exit 2把它按回去继续修。
示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "npm test > /tmp/test.log 2>&1; if [ $? -ne 0 ]; then echo 'Tests failed' >&2; exit 2; fi"
}
]
}
]
}
}

注意:Stop事件不支持matcher,所以上面的配置条目里只有hooks、没有matcher

假设我们ClaudeCode修复一个怎么都修不好的BUG,ClaudeCode每想收工一次,Stop就跑一遍测试,失败,把ClaudeCode按回去继续修;改完再想停,又被按回去,如此往复,永远停不下来。

ClaudeCode用一个输入字段stop_hook_active来打破这个循环——当这一次Stop是"被上一次Stop Hook拦回来、Claude被迫继续后又想停"才触发的,这个字段就是true
我们写的"command"脚本一看到它为true,就该明白"我已经拦过一轮了,别再拦了",直接exit 0放行、让Claude停下。
示例代码:

1
2
3
4
5
6
7
8
#!/bin/bash
input=$(cat)
# 已经拦过一轮,放行,避免死循环
if [ "$(jq -r '.stop_hook_active' <<< "$input")" = "true" ]; then
exit 0
fi
npm test > /tmp/test.log 2>&1
if [ $? -ne 0 ]; then echo 'Tests failed' >&2; exit 2; fi

那么,如果我们没这么写呢?如果"command"脚本漏了这道判断,ClaudeCode自身还有一道兜底:同一个Stop被连续阻止8次后会强制放行,不至于真的卡死(这个上限可用环境变量CLAUDE_CODE_STOP_HOOK_BLOCK_CAP调高)。

第四道

第四道,SessionStart压缩后提醒。
matcher取compact,只在上下文压缩后的新起点触发,把最容易在压缩中丢失的约定重新交代一遍。
示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"hooks": {
"SessionStart": [
{
"matcher": "compact",
"hooks": [
{
"type": "command",
"command": "echo 'Reminder: use pnpm not npm. Run tests before commit.'"
}
]
}
]
}
}

实践

完整事件清单

事件总数约三十个,且随版本时有增删,这里不逐一罗列,完整、权威的清单以官方文档为准:

查阅时按三层结构(会话级、轮级、工具级,见前文「运行机制」)去定位,重点看三件事:何时触发、可否阻止、matcher取什么值。五个高频事件已在前文「五个高频事件」精讲。

配置位置

Hook可以配置在六个位置。其中四个就是settings文件的标准层级——~/.claude/settings.json.claude/settings.json.claude/settings.local.json与Managed设置,各层作用域与优先级见《1.Start》。Hook特有的是另外两个来源。

  • 插件的hooks/hooks.json
    插件启用期间生效,随插件分发,打包方式见《7.Plugin》
  • Skill与Agent的front matter
    组件激活期间生效,随组件定义分发。Skill可以在front matter里声明hooks字段、随Skill激活而生效,见《3.Skill》;Sub-Agent同理,见《4.SubAgent》

注意:多个位置的Hook是叠加关系而非覆盖关系,插件、Skill与settings文件中挂在同一事件上的Hook会重合执行。

matcher写法

语法规则

写法 语义 例子
精确工具名 大小写敏感的全名匹配 BashEdit
管道分隔 多工具"或" Edit\|WriteEdit,Write
正则 按正则匹配工具名 mcp__github__.*
*或空字符串 匹配全部工具 *
事件专用值 非工具事件各有取值 SessionStartcompactNotificationpermission_prompt

两个注意事项:

  1. matcher大小写敏感,bash匹配不到Bash工具,且写错不会报错、只是静默不触发
  2. FileChanged的matcher按文件名字面值匹配,写法如.envrc\|.env,其中的管道符需要转义。

例子

例如,匹配MCP工具,用mcp__github__.*一次覆盖某个服务器的所有工具(工具的命名规则见《6.MCP》)。
示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
{
"hooks": {
"PreToolUse": [
{
"matcher": "mcp__github__.*",
"hooks": [
{ "type": "command", "command": "./.claude/hooks/audit.sh" }
]
}
]
}
}

五种Hook类型

语法规则

Hook类型共五种:

  1. command,执行一条shell命令或脚本。
  2. http,把事件发送到一个HTTP端点。
  3. prompt,把当前情况交给一个小模型做单次评估,默认用Haiku,可用model字段覆盖。适合规则写不死、但一眼能判断的检查。
  4. agent,实验特性。启动一个能调用工具的Agent做多轮核查,最多50个工具轮次,成本与延迟也最高。
  5. mcp_tool,直接把MCP服务器上的某个工具当Hook执行。
    关于MCP,参考《6.MCP》

注意:

  • 每种类型在hooks数组里都是一个处理器对象,最小形状如下。五种可以并挂在同一事件上,都会被执行(顺序不确定,见后文「通用规则」)。
  • 不同类型的Hook有不同的字段,具体参考官方文档。

例子

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "./.claude/hooks/check.sh" },
{ "type": "http", "url": "http://localhost:8080/hook" },
{ "type": "prompt", "prompt": "这条命令安全吗?只回答safe或unsafe。$ARGUMENTS" },
{ "type": "agent", "prompt": "结合代码库核查这次操作是否安全。$ARGUMENTS" },
{ "type": "mcp_tool", "server": "security", "tool": "scan" }
]
}
]
}
}

解释说明:

  • command执行本地脚本。
  • http把"事件的输入JSON"POST到url
  • promptagent$ARGUMENTS会被替换成"事件的输入JSON",然后交给模型判断。
  • mcp_tool调用server上名为tool的工具。

输入输出协议

概述

Hook与ClaudeCode之间的通信很朴素:输入走stdin,输出走exit code加stdout。

输入:stdin

事件触发时,Hook从stdin收到一份JSON,通用字段如下。

示例代码:

1
2
3
4
5
6
7
8
{
"session_id": "abc123",
"prompt_id": "d3f8-…",
"transcript_path": "/path/to/transcript.jsonl",
"cwd": "/current/dir",
"permission_mode": "default",
"hook_event_name": "PreToolUse"
}

输出:exit code

只有command类型以exit code表达意图。

exit code 行为
0 成功。若stdout是JSON则解析为决策,否则继续正常流程
2 阻止。对可阻止事件生效,stderr内容反馈给Claude;对不可阻止事件仅显示stderr
其他 视为脚本自身故障,流程继续。stderr首行连同Hook错误通知显示,完整输出记入调试日志
  • exit 2的设计把"有意阻止"与"脚本坏了"分开:脚本自身的故障,不应该拦下正常的工作流。

输出:JSON

语法规则

exit code是粗粒度的,细粒度控制用exit 0加stdout JSON。注意,exit 2与JSON输出不可混用:一旦以exit 2退出,stdout里的JSON就会被忽略。

字段 说明
continue 设为false时Claude停止处理,配合stopReason给出原因
decision 取值"block",用于Stop/PostToolUse/UserPromptSubmit,配合reason说明缘由
additionalContext 注入Claude上下文的额外信息
systemMessage 显示给用户的警示文本
suppressOutput 隐藏stdout
terminalSequence 向终端发送转义序列,可用于通知、改标题
hookSpecificOutput 事件特定的控制

例子

顶层decision: "block"用于StopPostToolUseUserPromptSubmit。上文测试闸门用exit 2实现,等价的JSON写法是:

1
2
3
4
{
"decision": "block",
"reason": "测试未通过,请继续修复后再收工"
}

若不想阻止、只想补充信息,用additionalContext把内容注入Claude的上下文,工作流照常继续:

1
2
3
4
5
6
{
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "该文件为自动生成,请改动 src/schema.ts 后重新生成"
}
}

一事件挂了多个Hook

当同一事件挂了多个Hook时,规则如下:

  • 最严决策胜出
    deny > defer > ask > allow取最严格的决策。例如同一个Bash挂了两个PreToolUse Hook,白名单脚本判定放行(allow),安全扫描却发现危险命令要求拒绝(deny),取最严,最终按deny执行、命令被拦下。
  • additionalContext拼接
    所有匹配Hook返回的additionalContext会拼成一段一起交给Claude,而非相互覆盖。例如SessionStart挂了两个Hook,一个注入当前分支与最近提交,另一个注入团队编码规范,两段会拼接后进入上下文。
  • 执行顺序不确定
    同一事件的多个Hook执行顺序不定,应避免让多个PreToolUse Hook修改同一工具的updatedInput
文章作者: Kaka Wan Yifan
文章链接: https://kakawanyifan.com/12805X
版权声明: 本博客所有文章版权为文章作者所有,未经书面许可,任何机构和个人不得以任何形式转载、摘编或复制。

留言板