avatar


5.Hook

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

引子

在工程里有一类事必须要做(例如:改完代码必须格式化、rm -rf必须拦下、测试没过不许收工),我们把这类硬规则写进CLAUDE.md,本质上仍是"建议",靠的是模型的"自觉"。

那么,怎样让一项任务百分之百被执行,而不靠模型自觉?

Hook(钩子)

原理

理解Hook,有四个要点:

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

确定性机制

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

运行机制

运行机制:工具调用循环(agent loop)的每个阶段都有固定的节点,Hook就挂在这些节点上。

配置结构

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只是把监听对象从"页面上的用户动作"换成了"工具调用循环里的节点",这就是事件。
按粒度,事件分三层:

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

五个高频事件

在约三十个事件里,下面五个覆盖了绝大多数场景。

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

比较

  • 希望模型遵守的约定,写CLAUDE.md
  • 能事先划清的边界,交给权限规则。
  • 必须临场判断、必然执行的动作,挂Hook。
性质 怎么生效
CLAUDE.md 建议 写进模型的输入,模型大概率听。
权限 静态策略 用allow/deny/ask规则事先划清边界。
Hook 动态响应 在事件发生的瞬间用代码做决定。

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

示例

需求

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

  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控制测试闸门。

示例代码:

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

上下文压缩后要重申规范

基于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设置)

另有两个:

  • Plugin的hooks/hooks.json
    Plugin启用期间生效,随插件分发。
  • Skill与Agent的YAML front matter
    组件激活期间生效,随组件定义分发。

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

matcher写法

语法规则

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

注意:

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

例子

例如,用mcp__github__.*一次覆盖某个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执行。

例子

示例代码:

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挂在同一事件上,这五种都会被执行,但顺序不确定。

输入输出协议

概述

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

输出: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 事件特定的控制

例子

上文测试闸门用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执行顺序不定。
文章作者: Kaka Wan Yifan
文章链接: https://kakawanyifan.com/12805
版权声明: 本博客所有文章版权为文章作者所有,未经书面许可,任何机构和个人不得以任何形式转载、摘编或复制。

留言板