连载06 - Hooks 源码深度解析:Claude Code 的确定性自动化体系

6 阅读28分钟

我是东厂:Skill管不了的,我Hooks管

AI Coding 系列第 05d 篇 · 自动化体系


上一篇讲 Skill 的时候,我提过一个判断:Skill 的本质是固化默认动作。

这话没问题,但有一个场景它盖不住。

你写了一个 code review Skill,步骤里写得清清楚楚:"改完代码后跑 prettier --write,确保格式统一。" Claude 大部分时候会照做,但偶尔它就是忘了。不是因为 Skill 写得不好,而是因为这一步对它来说只是"一段建议"——它理解了,但在上下文繁忙的时候,它可能觉得"格式化不是最紧急的",于是跳过了。

你把这行加粗,加感叹号,甚至在 Skill 里写"这一步绝对不能跳过"。大部分时候管用,但你永远没法保证 100%。

因为 Skill 的执行本质上是概率型的——它靠模型理解指令来行动,而理解本身就带不确定性。

Hooks 做的是完全不同的事:它不经过模型,不需要理解,不存在"忘了"的可能。 你配好一条规则"每次 Write 工具执行完毕后跑 prettier --write",它就是每次都跑,没有例外。

这就是 Hooks 的定位:Skill 管"怎么做",Hooks 管"做完之后必须发生什么"。 一个靠理解,一个靠规则。一个是概率型的,一个是确定型的。

理解了这个区分,后面所有内容都会顺下来。

Hooks核心概念:确定性自动化层


这篇文章对谁有用

  • 你已经用 Skill 约束过 Claude 的行为,但发现有些步骤它偶尔还是会漏
  • 你希望 Claude 改完代码自动跑 lint / 格式化 / 类型检查,不需要每次提醒
  • 你想在 Claude 调用危险命令之前自动拦截,而不是事后补救
  • 你想把 Claude 的工作流和团队的 CI / 通知 / 日志系统打通
  • 你好奇 Hooks 除了"跑 shell 命令"还能做什么(剧透:远不止这些)

先说结论

  • Hooks 是事件驱动的确定性自动化,不经过模型推理,100% 执行
  • 它和 Skill 不是替代关系,而是互补:Skill 管任务流程,Hooks 管质量保障和安全边界
  • Hooks 有五种类型:command(跑脚本)、http(调接口)、mcp_tool(调 MCP 工具)、prompt(让 Claude 做一次性判断)、agent(起子 Agent 评估)
  • 事件分三层:Session 级(启动/结束)、Turn 级(用户输入/Claude 回复完成)、Tool 级(工具调用前后)
  • PreToolUse 是最强大的事件——它可以拦截、放行、修改 Claude 即将执行的操作
  • Hook 的退出码和 JSON 输出决定了它如何反馈给 Claude,这不是 shell 技巧,是通信协议
  • Hooks 可以写在 Skill 的 frontmatter 里,和 Skill 的生命周期绑定

一、Hooks 是什么,不是什么

先把边界画清楚。

Hooks 是事件驱动的自动化层。当 Claude Code 执行到某个特定节点——比如它刚用 Write 工具写了一个文件,或者它准备调用 Bash 执行一条命令——你预先配好的动作就会被触发。

它的思路和 Git hooks 几乎一样:pre-commit 在提交前跑检查,post-merge 在合并后跑脚本。只不过 Claude Code 的 hooks 绑定的不是 Git 操作,而是 Claude 的工具调用和会话生命周期。

但 Hooks 不是

  • 不是 AI 行为规则——那是 CLAUDE.md 和 Skill 的事
  • 不是修改 Claude 思考方式的机制——它不碰模型推理
  • 不是万能拦截器——它能拦截工具调用,但拦不住 Claude 的思考过程

一句话定位:Hooks 是 Claude Code 工作流里的"系统级回调"。 你定义条件和动作,系统在满足条件时无条件执行。

Skill vs Hooks:概率型 vs 确定型

1.1 从源码看类型系统:Hooks 的精确定义

理解 Hooks 的最好方式,是看源码里它如何被类型系统精确定义。Claude Code 用 Zod 构建了完整的 Hook 类型体系,这份严谨是整个 Hook 系统可靠性的基石。

关于 Zod:TypeScript 的类型检查只在编译时生效——代码一跑起来,类型就"消失"了。Zod 做的事是在运行时校验数据——它定义一套 schema(数据结构规则),然后在你拿到任何数据(用户配置、Hook 输出、API 响应)时,当场检查这数据是否符合规则。你可以把它理解为"运行时类型警察":编译期 TypeScript 保证代码逻辑类型正确,运行时 Zod 保证外部数据格式正确。Claude Code 为什么依赖它?因为 Hook 的配置和输出都来自外部——用户写的 JSON、shell 脚本的 stdout——这些在编译期完全不可知,只有 Zod 能在程序跑起来之后拦住格式错误。

关于源码:本文引用的源码路径(如 src/schemas/hooks.ts)来自 Claude Code 的开源参考实现 claude-code-cli。这些文件不在你本地安装的 Claude Code 目录里——需要 clone 仓库才能看到完整实现。如果你对某个机制的细节感兴趣,沿着路径翻源码会比读任何二手解释都更透彻。

从源码可以知道:Hook 的四种类型(command/prompt/agent/http)通过 Zod 的 discriminatedUnion 严格定义,返回值结构根据事件类型展开 15 种分支。输出格式之所以必须精确,不是系统挑剔,而是 Zod 在运行时强制校验——多一个字段或少一个字段都会产生错误。

🔬 展开查看源码详情

src/schemas/hooks.ts:176-189 定义了四种可持久化的 Hook 类型,用 Zod 的 discriminatedUnion 模式——也就是用 type 字段做标签,区分布局的四种具体形态:

// 实际源码中的四种 Hook 类型定义
z.discriminatedUnion('type', [
  BashCommandHookSchema,  // { type: 'command', command: '...' }
  PromptHookSchema,       // { type: 'prompt', prompt: '...' }
  AgentHookSchema,        // { type: 'agent', prompt: '...' }
  HttpHookSchema,         // { type: 'http', url: '...' }
])

这个设计意味着:你的配置 JSON 里 type 字段写错一个字,Zod 会在启动时直接报解析错误,不会等到运行时才发现。这就是"确定性"的第一层体现——类型系统保证配置是正确的,否则不让通过。

src/types/hooks.ts:50-166 定义了 Hook 返回值的完整类型——syncHookResponseSchema。这不是一个简单的 {ok: true} 结构,而是一个联合类型(union),根据 hookSpecificOutput.hookEventName 的不同值,展开不同的字段结构:

// hookSpecificOutput 是一个 discriminator union
// hookEventName 的值决定了能用哪些字段
z.union([
  { hookEventName: 'PreToolUse',     → updatedInput, permissionDecision
  { hookEventName: 'PostToolUse',    → updatedMCPToolOutput, additionalContext
  { hookEventName: 'SessionStart',   → additionalContext, initialUserMessage, watchPaths
  { hookEventName: 'PermissionRequest'decision: { behavior, updatedInput } | { behavior, message }
  { hookEventName: 'Elicitation',     → action: 'accept' | 'decline' | 'cancel'
  // ... 共 15 种 discriminator
])

这意味着:你的 PostToolUse Hook 返回了 updatedInput 字段?Zod 校验会拒绝它,因为 PostToolUse 的 hookSpecificOutput 里根本没定义这个字段。Hook 返回正确的 JSON 不只是"建议",而是被 Zod 运行时强制校验的。

src/types/hooks.ts:169-176 还有一个关键设计——同步和异步响应的区分。hookJSONOutputSchema 是一个 union([asyncHookResponseSchema, syncHookResponseSchema])。Hook 输出的第一行如果是 {"async": true},系统就知道这不是同步结果,而是"我已经在后台运行了"的信号。这个协议在 execCommandHook 里通过实时解析 stdout 的第一行来实现,后面会展开讲。

这对你有什么用:理解了这个类型体系,你就知道为什么 Hook 输出格式必须精确——不是系统"挑剔",而是 Zod 在运行时执行严格校验(src/utils/hooks.ts:382-397validateHookJson)。如果你的 JSON 里多了一个不该有的字段,或者少了一个必需的字段,Hook 不会静默失败,而是会产生一条带详细错误信息的 non_blocking_error 消息。


二、配置放在哪,决定谁受影响

和 Skill 的存放位置逻辑一样,Hooks 的配置位置决定了它的作用域。

用户级(所有项目都生效):

~/.claude/settings.json

项目级(仅当前项目,可以提交到仓库):

.claude/settings.json

项目级本地(仅当前项目,不提交):

.claude/settings.local.json

插件级(插件启用时生效):

<plugin>/hooks/hooks.json

Skill / Agent 级(Skill 激活期间生效):

写在 SKILL.md 的 YAML frontmatter 里,后面会展开讲。

判断标准很简单:这个 Hook 换个项目还适用吗?"写文件后自动 prettier"大概率适用所有项目,放用户级。"改了 .prisma 文件后自动跑 prisma generate"只在用 Prisma 的项目里有意义,放项目级。

2.1 源码级:配置合并引擎如何工作

你把 Hook 配置在这五个层级,系统怎么决定最终生效哪些?从源码可以知道:合并走五层优先级链,managed(企业策略)层拥有最高控制权——用户级 disableAllHooks 关不掉 managed 层的东西。去重用 seenFiles 防止同一文件被读两次,Session hooks 存在内存 Map 里不落盘。

🔬 展开查看源码详情

src/utils/hooks/hooksConfigSnapshot.ts:18-53getHooksFromAllowedSources() 是合并逻辑的入口。它按以下优先级链依次决策:

1. policySettings.disableAllHooks == true?
   → 返回 {}(所有 Hook 禁用,包括 managed hooks)

2. policySettings.allowManagedHooksOnly == true?
   → 只返回 policySettings 里的 hooks(企业策略模式)

3. isRestrictedToPluginOnly('hooks') == true?
   → 只返回 policySettings 里的 hooks(插件限制模式)

4. 用户级 settings.disableAllHooks == true?
   → 只返回 policySettings 里的 hooks(用户禁用但 managed 不受影响)

5. 正常模式:getSettings_DEPRECATED() 合并 user + project + local

这个链表设计体现了安全分层思想:managed(企业策略)层可以在第1步就关掉所有 Hook,但用户层(第4步)不能关掉 managed 层的 Hook——因为 disableAllHooks 在用户级只是"关掉非 managed 的 Hook",不影响 policySettings。

src/utils/hooks/hooksSettings.ts:92-161getAllHooks() 进一步合并来自 editable 源(userSettings / projectSettings / localSettings)和 session hooks。注意它用 seenFiles 做了去重——当用户主目录就是项目目录时,~/.claude/settings.json.claude/settings.json 是同一个文件,不去重就会导致同一条配置执行两次。

Session hooks 是另一个维度的配置——它们存在内存中(src/utils/hooks/sessionHooks.ts:62SessionHooksState = Map<string, SessionStore>),不落盘。Session hooks 来自三个渠道:

  • Skill frontmatter 里的 hooks 字段
  • Agent 定义里的 hooks 字段
  • 内部系统注册的 function hooks(比如 structured output enforcement)

src/utils/hooks.ts:1492-1566getHooksConfig() 把所有来源合并成最终要执行的 Hook 列表。合并顺序是:snapshot hooks → registered hooks(SDK/plugin)→ session hooks → session function hooks。

这对你有什么用:如果你发现某个 Hook 总是被覆盖,用 /hooks 命令查看当前生效列表,对照上面的合并链就能定位是哪个源覆盖了它。也意味着你不能在用户级 disableAllHooks 来关掉企业策略层的 Hook——这是故意的安全设计。

配置的基本 JSON 结构长这样:

{
  "hooks": {
    "事件名": [
      {
        "matcher": "匹配模式",
        "hooks": [
          {
            "type": "command",
            "command": "你要执行的命令"
          }
        ]
      }
    ]
  }
}

三层嵌套:事件名 → 匹配规则 → 具体动作。看起来层级多,但逻辑很清晰:什么时候触发(事件)→ 在什么条件下触发(matcher)→ 触发后做什么(hooks 数组)。


三、事件分三个层级,从粗到细

Hooks 能绑定的事件不只是"工具调用前后"。它覆盖了 Claude Code 会话的整个生命周期,按粒度分三层。

Session 级:会话生命周期节点触发

SessionStart — 会话启动时。适合加载开发环境上下文、设置环境变量、打印项目状态。

它有四种子场景,通过 matcher 区分:

  • startup:全新会话启动
  • resume:恢复之前的会话
  • clear:清空对话后重新开始
  • compact:上下文压缩后重新加载
{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup",
        "hooks": [
          {
            "type": "command",
            "command": "echo '当前分支:'$(git branch --show-current) '| 未提交文件:'$(git status --short | wc -l | tr -d ' ')"
          }
        ]
      }
    ]
  }
}

这样每次新会话启动,Claude 第一眼就能看到当前分支和未提交文件数。不需要它自己去查,上下文一开始就对了。

SessionEnd — 会话结束时。适合做清理、统计、日志归档。

Turn 级:每轮对话触发

UserPromptSubmit — 用户发送消息后、Claude 开始处理之前。可以用来做输入校验、自动注入上下文。

Stop — Claude 完成一轮回复时。适合做收尾检查、发通知。

这里有个特别实用的用法:如果你的 Stop Hook 返回 { "continue": false, "stopReason": "请先跑完测试再结束" },Claude 会看到这个消息并继续工作,而不是直接结束。这等于给了你一个"拦截 Claude 过早收手"的能力。

StopFailure — Claude 非正常停止时(比如限流、认证失败、账单错误)。matcher 可以区分具体原因:rate_limitauthentication_failedbilling_error 等。

Tool 级:每次工具调用触发

PreToolUse — Claude 即将调用某个工具之前。这是最强大的事件,因为它能拦截、放行、甚至修改即将执行的操作。后面单独展开。

PostToolUse — 工具调用成功之后。最常用的事件——写文件后跑格式化、编辑后跑 lint、执行命令后记日志。

PostToolUseFailure — 工具调用失败后。适合做错误收集和诊断。

PermissionRequest — 权限弹窗出现时。可以用来自动批准或拒绝特定操作。

还有一些更细粒度的事件,比如 FileChanged(文件变更时)、CwdChanged(工作目录切换时)、PreCompact / PostCompact(上下文压缩前后)、WorktreeCreate / WorktreeRemove(git worktree 操作时)。不是每个都常用,但知道它们存在很重要——当你遇到"我想在某个时机自动做某件事"的需求时,很可能已经有对应的事件。

Hooks事件体系:三层从粗到细


四、matcher:决定什么条件下才触发

配了事件之后,matcher 决定"在这个事件里,我只关心哪些情况"。

对于 Tool 级事件(PreToolUse、PostToolUse 等),matcher 匹配的是工具名称

写法含义例子
省略 / "" / "*"匹配所有工具任何工具调用都触发
纯字母数字精确匹配"Write" 只在写文件时触发
| 分隔匹配多个工具"Edit|Write" 写或编辑文件时触发
其他字符当作正则表达式"^Notebook" 匹配所有笔记本工具

MCP 工具的匹配格式是 mcp__<server>__<tool>,比如 mcp__memory__.* 匹配 memory 服务器的所有工具。

还有一个更精细的过滤:if 字段。它用权限规则语法做二次筛选。

{
  "matcher": "Bash",
  "if": "Bash(git *)",
  "hooks": [{ "type": "command", "command": "echo 'Git 操作被执行'" }]
}

这个 Hook 只在 Claude 用 Bash 执行 git 开头的命令时才触发——不是所有 Bash 调用,只是 git 相关的。ifmatcher 配合,能做到非常精准的条件筛选,不会误触发。

4.1 源码级:matcher 匹配引擎的三层判断

你写的 matcher 字符串是怎么被解析和匹配的?从源码可以知道:matcher 经历三层判断——空值/* 全部放行 → 纯字母数字做精确或管道匹配 → 含特殊字符走正则。if 条件二次筛选用的是权限规则引擎的 AST 匹配,不是正则。

🔬 展开查看源码详情

src/utils/hooks.ts:1346-1381matchesPattern() 函数执行了三层判断:

第一层:空值和通配符(matchesPattern:1347-1349

if (!matcher || matcher === '*') {
  return true  // 匹配所有
}

省略 matcher、写空字符串、写 "*",三者等效——全部放行。

第二层:纯字母数字模式(matchesPattern:1351-1361

if (/^[a-zA-Z0-9_|]+$/.test(matcher)) {
  // 精确匹配或管道分隔的多个精确匹配
  if (matcher.includes('|')) {
    const patterns = matcher.split('|').map(p => normalizeLegacyToolName(p.trim()))
    return patterns.includes(matchQuery)
  }
  return matchQuery === normalizeLegacyToolName(matcher)
}

关键细节:normalizeLegacyToolName 会把旧版工具名映射到新名。也就是说你写 "Write""WriteFile"(旧名)都能正确匹配。管道符 | 会被拆成数组逐一比对——不是正则的或运算符,是精确匹配的或逻辑。

第三层:正则表达式模式(matchesPattern:1363-1380

// 包含任何非字母数字下划线管道符的字符 → 当作正则
const regex = new RegExp(matcher)
if (regex.test(matchQuery)) return true
// 也对旧版工具名做正则匹配,兼容 legacy 名称
for (const legacyName of getLegacyToolNames(matchQuery)) {
  if (regex.test(legacyName)) return true
}

如果你在 matcher 里写了 ^.*() 等任何特殊字符,系统会自动切换到正则模式。这就是为什么 "^Notebook" 能用、".*" 也能用——因为在 [a-zA-Z0-9_|] 之外就进入正则分支。

if 条件的二次筛选用的是权限规则引擎src/utils/hooks.ts:1390-1421prepareIfConditionMatcher() 针对不同工具类型调用各自的 preparePermissionMatcher 方法。比如 Bash 工具的 matcher 会解析你的命令字符串("rm -rf *"),生成一个 AST 节点,然后用 tree-sitter 做真正的命令模式匹配——不是正则,是 AST。

一个常见坑:你在 matcher 里写 "Bash"(希望匹配 Bash 工具名),它确实全由字母数字组成——满足 [a-zA-Z0-9_|]+ 检测,所以它被当作精确匹配,不会进正则分支。这和有些人的直觉不同——他们认为 "Bash" 混合大小写、看起来像正则。实际上只有包含 ^.$( 等特殊字符的字符串才会进正则分支。


五、不只是跑 shell 命令:Hook 的五种类型

很多人以为 Hooks 就是"跑 shell 命令",其实远不止。Claude Code 支持五种 Hook 类型,覆盖了从本地脚本到远程服务到 AI 判断的完整谱系。

1. command — 执行 shell 命令

最常用的类型。Claude 的操作信息以 JSON 格式通过 stdin 传给你的命令。

{
  "type": "command",
  "command": "npx prettier --write $(cat /dev/stdin | jq -r '.tool_input.file_path')",
  "timeout": 30
}

支持 async: true 让命令在后台执行不阻塞 Claude,还有 asyncRewake: true 可以在异步命令完成后唤醒 Claude 继续处理。

2. http — 调用外部 HTTP 接口

把事件信息发到外部服务。非常适合做日志收集、团队通知、审计系统对接。

{
  "type": "http",
  "url": "https://your-team-webhook.com/claude-events",
  "headers": {
    "Authorization": "Bearer ${API_TOKEN}"
  }
}

请求体是 JSON POST,包含完整的事件信息。headers 里支持 ${ENV_VAR} 语法引用环境变量,通过 allowedEnvVars 字段控制哪些环境变量可以被引用。非 2xx 响应不会阻断 Claude,只记录错误。

3. mcp_tool — 调用已连接的 MCP 工具

直接调用当前会话里已经连接的 MCP 服务器工具。输入支持模板变量。

{
  "type": "mcp_tool",
  "server": "memory",
  "tool": "add_memory",
  "input": {
    "content": "Claude edited file: ${tool_input.file_path}"
  }
}

这个例子在每次文件编辑后,自动把操作记录写进 MCP memory 服务器。不需要写 shell 脚本,不需要 HTTP 请求,直接用已有的 MCP 连接。

4. prompt — 让 Claude 做一次性判断

这是一个很有意思的类型:它在 Hook 触发时起一个轻量的 Claude 实例,做一次 yes/no 判断。

{
  "type": "prompt",
  "prompt": "以下 Bash 命令是否可能造成数据丢失或不可逆操作?只回答 yes 或 no。命令:${tool_input.command}",
  "model": "haiku",
  "timeout": 30
}

prompt 类型的 Hook 适合做那些不好用规则硬写、但又需要快速判断的场景。比如"这条命令是不是危险的"这个问题,用正则匹配只能覆盖已知的危险模式,用 prompt Hook 可以做更灵活的语义判断。

不过要注意:它每次触发都会消耗 token,而且有延迟。只在 PreToolUse 这类需要做决策的事件上用才划算,别挂在 PostToolUse 这种高频事件上。

5. agent — 起子 Agent 做深度评估

最重量级的类型。它会起一个有 Read、Grep、Glob 工具的子 Agent,做更复杂的评估。

{
  "type": "agent",
  "prompt": "检查当前工作目录下是否有未提交的敏感文件(.env, credentials, private keys)。如果发现,返回 block 决策。",
  "timeout": 60
}

agent 类型目前还是实验性的,但它代表了一个很有意思的方向:用 AI 来守护 AI 的操作边界。 主 Agent 准备执行某个操作,另一个独立的 Agent 先跑一轮检查,觉得安全才放行。

Hook五种类型完整谱系

5.1 源码级:execCommandHook — 命令执行引擎的内部运作

整个 Hooks 系统最核心的函数是近600行的 execCommandHook()从源码可以知道:Windows bash 模式自动做 Cygwin 路径转换,PowerShell 模式走原生路径;异步检测协议通过实时解析 stdout 第一行 JSON 实现;asyncRewake 让后台任务完成后能唤醒 Claude。

🔬 展开查看源码详情

src/utils/hooks.ts:747-1335 是执行引擎,处理 shell 选择、路径转换、环境变量注入、stdin/stdout/stderr 生命周期、异步检测、超时控制。

Windows 路径的 Cygwin 转换陷阱。 execCommandHook:808-810 对 Windows bash 模式下的所有路径做 POSIX 转换:C:\Users\foo/c/Users/foo。这是因为 Git Bash 底层是 Cygwin,不认识 Windows 路径。PowerShell Hook(shell: "powershell")走的是原生路径,不做此转换。这意味着:如果你在 Windows 上写 bash Hook,环境变量 CLAUDE_PROJECT_DIR 的值是 /c/Users/... 格式,不是 C:\Users\...

环境变量注入清单(execCommandHook:882-926)。 每个 Hook 进程获得 CLAUDE_PROJECT_DIR(repo root,不是 worktree 路径)、CLAUDE_PLUGIN_ROOT(plugin/skill 的根目录)、CLAUDE_PLUGIN_DATA(plugin 数据目录)、CLAUDE_PLUGIN_OPTION_*(plugin 的用户配置项)。SessionStart/Setup/CwdChanged/FileChanged 事件还额外获得 CLAUDE_ENV_FILE——Hook 把 export VAR=value 写进这个 .sh 文件,系统随后把所有 .sh 文件拼接注入后续 Bash 命令。PowerShell Hook 不会获得此变量(PS 写的是 $env:FOO = 'bar',bash 无法解析)。

异步检测协议(execCommandHook:1117-1164)。 系统不需要等 Hook 执行完才判断它是同步还是异步——它实时解析 stdout 的第一行:如果第一行是 {"async": true},进程立刻被转入后台执行,主逻辑不等待。这个协议的精妙之处在于:Hook 的脚本可以先 echo 一行 JSON 告知"我要异步了",然后慢慢做耗时操作。配置级的 "async": true(第 995-1030 行)则更早——spawn 之后直接 stdin 写入、直接后台化,不等第一行。

asyncRewake 机制(execCommandHook:206-245)。asyncRewake: true 的后台进程以 exit code 2 结束时,stderr 内容会通过消息队列注入 Claude 的上下文,让它"醒来"处理阻断信息。典型场景:写文件后异步跑测试套件,测试失败(exit 2)时 Claude 自动看到失败信息并修复。


六、退出码和 JSON 输出:Hook 如何和 Claude 通信

这是很多人忽略的关键细节。Hook 不是"跑完就完了"——它的退出码和标准输出决定了后续 Claude 的行为。

退出码

  • exit 0:成功。如果 stdout 有内容,尝试解析为 JSON
  • exit 2:阻断错误。Claude 会看到 stderr 里的信息,当前操作被中止
  • 其他:非阻断错误。stderr 信息记录到日志,但 Claude 继续工作

exit 2 是一个特别设计的"强制刹车"。比如你的 PreToolUse Hook 检测到 Claude 要删一个关键文件,exit 2 + stderr 写上"禁止删除 production.env",这个操作就会被直接拦住。

JSON 输出格式

如果 Hook 以 exit 0 退出,stdout 输出的 JSON 可以精确控制 Claude 的后续行为:

{
  "continue": true,
  "systemMessage": "lint 发现 3 个警告,已自动修复 2 个",
  "decision": "allow",
  "reason": "操作安全,已通过检查",
  "hookSpecificOutput": {
    "additionalContext": "修复了 import 排序和尾部逗号"
  }
}

几个关键字段:

continue — 设为 false 时 Claude 停止当前任务,stopReason 会显示给用户。

systemMessage — 以系统消息的形式显示给用户,适合传达重要但不阻断的信息。

decision — 只在 PreToolUse 和 PermissionRequest 事件中有效。可选值:

  • allow:放行,不再弹权限确认
  • deny(或 block):拒绝执行,Claude 会看到拒绝原因并尝试其他方案
  • ask:交给用户决定
  • defer:这个 Hook 不做判断,留给后续 Hook 或默认逻辑

hookSpecificOutput.updatedInput — 这个最有意思:它可以修改 Claude 即将执行的工具输入。比如 Claude 要写文件到 /tmp/test.js,你的 Hook 可以把路径改成 /tmp/sandbox/test.js。Claude 不知道路径被改了,但实际操作发生在你指定的安全位置。

理解了这套通信协议,你对 Hooks 能做什么的认知会完全不一样。它不只是"跑个脚本",它是一个双向通信通道——Claude 告诉 Hook "我要做什么",Hook 告诉 Claude "可以做 / 不可以做 / 改一下再做"。

6.1 源码级:退出码和 JSON 输出的处理管线

从 Hook 进程退出到 Claude 感受到反馈,中间经过三层处理管线。从源码可以知道:stdout 第一字符必须是 { 才能进 JSON 解析;Zod 校验失败会生成精确的错误路径;hookEventName 与实际事件不匹配会被直接抛错丢弃。

🔬 展开查看源码详情

第一层:parseHookOutputsrc/utils/hooks.ts:399-451

function parseHookOutput(stdout: string) {
  const trimmed = stdout.trim()
  if (!trimmed.startsWith('{')) {
    return { plainText: stdout }  // 非 JSON → 当作纯文本
  }
  // 尝试用 Zod 校验这个 JSON
  const result = validateHookJson(trimmed)
  ...
}

这里有一个关键点:stdout 不是以 { 开头的,系统就直接把它当纯文本输出。所以如果你的 Hook 在 JSON 之前 echo 了任何东西(比如 bashrc 的 motd),JSON 解析会失败。这也是为什么 || true 不能放在 stdout 输出 JSON 的命令里——|| true 不解决问题,echo 的杂质字面量才是问题。

第二层:validateHookJsonsrc/utils/hooks.ts:382-397

function validateHookJson(jsonString: string) {
  const parsed = jsonParse(jsonString)
  const validation = hookJSONOutputSchema().safeParse(parsed)
  if (validation.success) {
    return { json: validation.data }
  }
  // 构造详细的错误信息
  const errors = validation.error.issues
    .map(err => `  - ${err.path.join('.')}: ${err.message}`)
    .join('\n')
  return { validationError: `Hook JSON output validation failed:\n${errors}\n...` }
}

Zod 在这里做运行时校验。你的 JSON 结构如果不对——比如 PostToolUse Hook 的 hookSpecificOutput 里写了 updatedInput(这是 PreToolUse 才有的字段)——Zod 会生成一条具体的错误路径,告诉你是哪个字段不合法。

第三层:processHookJSONOutputsrc/utils/hooks.ts:489-737

这是最长的处理函数。它根据 hookSpecificOutput.hookEventName 的值,走不同的 switch 分支提取相应字段。例如:

case 'PreToolUse':
  result.updatedInput = json.hookSpecificOutput.updatedInput
  result.additionalContext = json.hookSpecificOutput.additionalContext
  // 权限决策覆盖
  if (json.hookSpecificOutput.permissionDecision === 'allow') {
    result.permissionBehavior = 'allow'
  } else if (...) { ... }
  break

case 'PostToolUse':
  result.updatedMCPToolOutput = json.hookSpecificOutput.updatedMCPToolOutput
  result.additionalContext = json.hookSpecificOutput.additionalContext
  break

注意第 583-590 行——如果 hookSpecificOutput.hookEventName 和实际触发的事件名不匹配(比如你在 PostToolUse Hook 里返回了 hookEventName: 'PreToolUse'),系统会直接抛错。这是一个常见的坑:你需要保证返回的 hookEventName 和实际事件一致,否则整个 Hook 结果会被丢弃。

退出码的实际处理流程(src/utils/hooks.ts:2617-2696

exit 0stdout 是 JSON → processHookJSONOutput → 结构化处理
exit 0stdout 不是 JSON → 创建 hook_success 消息,stdout 作为纯文本内容
exit 2 → 创建 hook_blocking_error 消息,stderr 作为阻断原因
其他 exit code → 创建 hook_non_blocking_error 消息,记录错误但不阻断

这里有两个容易被忽略的细节:

  1. exit 2 的 stderr 内容会被包装成 [hookCommand]: ${stderr} 格式传给 Claude。所以你的 stderr 文字应该直接是 Claude 能理解的说明,不需要额外格式。

  2. exit 0 但 stdout 是 JSON 且 suppressOutput: true 时,即使 JSON 校验通过,stdout 也不会出现在对话记录里——只处理结构化字段。这在你只想修改 updatedInput 而不想污染上下文时很有用。

退出码与JSON输出处理管线


七、PreToolUse 深入:拦截、放行、修改

PreToolUse 值得单独拉出来讲,因为它是整个 Hooks 体系里最强大、也最需要谨慎使用的事件。

拦截危险操作

最直接的用法:阻止 Claude 执行你不想让它碰的命令。

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "if": "Bash(rm -rf *)",
        "hooks": [
          {
            "type": "command",
            "command": "echo '{\"decision\": \"deny\", \"reason\": \"禁止执行 rm -rf,请使用更安全的删除方式\"}'"
          }
        ]
      }
    ]
  }
}

Claude 会看到拒绝原因,然后尝试用其他方式完成任务——比如逐个删除,或者用 trash 命令。它不会傻等,也不会崩溃,而是理解了约束之后调整策略。

自动放行可信操作

反过来,你也可以让某些操作自动通过,不再弹权限确认框。

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "if": "Bash(npm test *)",
        "hooks": [
          {
            "type": "command",
            "command": "echo '{\"decision\": \"allow\"}'"
          }
        ]
      }
    ]
  }
}

每次 Claude 要跑 npm test,不再问你"确认执行吗?",直接跑。这在你信任测试命令的安全性时很有用——减少人工确认的打断,让 Claude 的工作流更顺畅。

修改工具输入

最高级的用法:在 Claude 不知情的情况下,修改它即将执行的操作。

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write",
        "hooks": [
          {
            "type": "command",
            "command": "python3 -c \"import sys,json; d=json.load(sys.stdin); d['hookSpecificOutput']={'updatedInput':{'file_path': d['tool_input']['file_path'].replace('/src/','/src/sandbox/')}}; print(json.dumps(d))\""
          }
        ]
      }
    ]
  }
}

这个 Hook 把所有写入 /src/ 的文件重定向到 /src/sandbox/。Claude 以为自己在正常写文件,实际上所有改动都进了沙箱。等你确认没问题,再合并回去。

这种能力在高风险场景下特别有价值——你不需要告诉 Claude "先写到沙箱里"(它可能忘),而是从系统层面保证它的所有写操作都是安全的。


八、Hooks 和 Skills 的联动:frontmatter 里的 hooks 字段

前面说了 Hooks 的配置放在 settings.json 里。但还有一种更精细的用法:把 Hooks 写在 Skill 的 frontmatter 里,让它只在这个 Skill 激活期间生效。

---
name: secure-deploy
description: Deploy with security checks. Manual trigger only.
disable-model-invocation: true
context: fork
hooks:
  PreToolUse:
    - matcher: "Bash"
      hooks:
        - type: command
          command: "./scripts/deploy-security-check.sh"
          statusMessage: "Running security scan..."
  Stop:
    - hooks:
        - type: command
          command: "./scripts/notify-deploy-complete.sh"
          once: true
---

执行部署流程...

这里有几个值得注意的点。

hooks 字段的格式和 settings.json 里的一模一样,只是写成了 YAML。当这个 Skill 被触发时,这些 Hooks 才生效;Skill 结束后,Hooks 自动失效。

statusMessage 字段可以自定义 Hook 执行时的等待提示,用户会在界面上看到"Running security scan..."而不是默认的等待动画。

once: true 表示这个 Hook 在当前会话里只执行一次。比如部署完成通知,你只想收到一次,不想每次 Claude 说完话都发一遍。这个字段只在 Skill / Agent 的 frontmatter hooks 里有效。

这个组合的威力在于:Skill 定义了"做什么",Hooks 保证了"做的过程中,哪些安全检查绝对不能跳过"。 Skill 是概率型的任务流程,Hooks 是确定型的安全护栏。两者绑在一起,一个能力包就同时有了灵活性和可靠性。

8.1 源码级:Hook 的并行执行与权限优先级仲裁

当同一个事件上挂了多个 Hook,它们并行执行而非串行。从源码可以知道:权限优先级为 deny > ask > allow(安全优先),仅 allow/ask 的 Hook 可修改工具输入,去重用 \0 分隔符确保不同 plugin 的同名 Hook 不被误合并。

🔬 展开查看源码详情

src/utils/hooks.ts:2142-2972executeHooks()all(hookPromises) 将同一个事件的所有匹配 Hook 并行启动。每个 Hook 在自己的 Promise 里独立运行,有自己的超时和 abort signal。

但并行执行带来了一个问题:多个 Hook 可能返回不一致的权限决策。比如 Hook A 说 allow,Hook B 说 deny,应该听谁的?

权限优先级规则在 src/utils/hooks.ts:2820-2847

deny  >  ask  >  allow

一个 deny 会覆盖所有 allow 和 ask;一个 ask 会覆盖 allow;allow 只是最后的 fallback。这个顺序不是随意的——它体现了安全优先的原则:任何 Hook 的拒绝都能推翻其他 Hook 的放行,但任何一个 Hook 的放行不能推翻其他 Hook 的拒绝。

updatedInput 的合并也值得注意(src/utils/hooks.ts:2850-2880:只有标记了 allowask 的 Hook 才能修改工具输入。标记为 deny 的 Hook 的 updatedInput 会被忽略——既然都拒绝执行了,修改输入就没有意义。

去重机制(src/utils/hooks.ts:1453-1455

function hookDedupKey(m: MatchedHook, payload: string): string {
  return `${m.pluginRoot ?? m.skillRoot ?? ''}\0${payload}`
}

同一个 Hook 命令可能同时出现在用户级和项目级配置中,去重机制用 \0 分隔符 + plugin/skill 命名空间确保:同一个来源内的重复只执行一次,但不同 plugin 的同名 Hook 不会相互覆盖。去重键包含 command/prompt/url 内容、shell 类型、和 if 条件——也就是说,{command: "echo x", shell: "bash"}{command: "echo x", shell: "powershell"} 被视为不同的 Hook。

内部 callback hook 的性能优化(src/utils/hooks.ts:2036-2067:当所有匹配的 Hook 都是 callback 类型(内部 Hook,如 sessionFileAccessHooks、attributionHooks)时,系统跳过 span/tracing/progress/resultLoop 等全套开销,直接同步调用。实测这个 fast-path 将 PostToolUse 的每次命中从 6µs 降到 ~1.8µs。这对理解系统设计很重要——内部 Hook 和用户 Hook 是两条不同的执行路径。


九、Hook 的输入:Claude 告诉你它在做什么

每个 Hook 触发时,都会收到一个 JSON 输入(对 command 类型是 stdin,对 http 类型是请求体)。这个输入包含了当前事件的完整上下文。

所有事件都有的公共字段:

{
  "session_id": "abc123",
  "transcript_path": "/path/to/transcript.jsonl",
  "cwd": "/your/project/root",
  "hook_event_name": "PostToolUse"
}

Tool 级事件额外包含:

{
  "tool_name": "Write",
  "tool_input": {
    "file_path": "/src/index.ts",
    "content": "..."
  },
  "tool_output": "File written successfully"  // 只在 PostToolUse 里有
}

SessionStart 事件有一个特殊能力:通过 CLAUDE_ENV_FILE 环境变量,你可以把 Hook 设置的环境变量持久化到整个会话。

#!/bin/bash
if [ -n "$CLAUDE_ENV_FILE" ]; then
  echo "export PROJECT_VERSION=$(cat package.json | jq -r '.version')" >> "$CLAUDE_ENV_FILE"
  echo "export GIT_BRANCH=$(git branch --show-current)" >> "$CLAUDE_ENV_FILE"
fi

这样 Claude 在整个会话里都能访问到 $PROJECT_VERSION$GIT_BRANCH,不需要每次重新查。


十、实战:一个项目级配置的完整例子

把前面讲的这些组合成一个真实可用的项目配置。这个配置覆盖了日常开发最常用的五个自动化场景。

{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup",
        "hooks": [
          {
            "type": "command",
            "command": "echo \"分支: $(git branch --show-current) | 待提交: $(git status --short | wc -l | tr -d ' ') 个文件\""
          }
        ]
      }
    ],
    "PreToolUse": [
      {
        "matcher": "Bash",
        "if": "Bash(rm -rf *)",
        "hooks": [
          {
            "type": "command",
            "command": "echo '{\"decision\": \"deny\", \"reason\": \"rm -rf 被禁止,请用更安全的删除方式\"}'"
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "npx prettier --write $(cat /dev/stdin | jq -r '.tool_input.file_path') 2>/dev/null || true"
          }
        ]
      },
      {
        "matcher": "Write|Edit",
        "if": "Write(*.ts)|Write(*.tsx)|Edit(*.ts)|Edit(*.tsx)",
        "hooks": [
          {
            "type": "command",
            "command": "npx tsc --noEmit 2>&1 | head -15 || true"
          }
        ]
      },
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "echo \"$(date '+%H:%M:%S') $(cat /dev/stdin | jq -r '.tool_name'): $(cat /dev/stdin | jq -r '.tool_input.file_path')\" >> .claude-activity.log"
          }
        ]
      }
    ],
    "Stop": [
      {
        "matcher": "*",
        "hooks": [
          {
            "type": "command",
            "command": "osascript -e 'display notification \"任务完成\" with title \"Claude Code\"' 2>/dev/null || notify-send 'Claude Code' '任务完成' 2>/dev/null || true"
          }
        ]
      }
    ]
  }
}

这个配置做了五件事:

  1. 新会话启动时,显示当前分支和未提交文件数——Claude 从第一步就有正确的上下文
  2. 拦截 rm -rf——不管 Claude 出于什么理由想执行这个命令,直接拒绝
  3. 写 / 编辑文件后自动格式化——|| true 确保不支持的文件类型不会阻断流程
  4. TypeScript 文件改动后自动类型检查——Claude 立刻看到类型错误,下一步就能修复
  5. 任务完成后桌面通知——macOS 和 Linux 都覆盖,你可以安心去做别的事

10.1 源码级:信任检查——为什么所有 Hook 都要过一道安全闸

从源码可以知道shouldSkipHookDueToTrust() 是所有 Hook 执行前的统一守门人——交互模式下未通过信任对话框则所有 Hook 跳过。这个集中检查源于两个历史漏洞(SessionEnd 和 SubagentStop 绕过信任),修复后成为唯一的安全入口。

🔬 展开查看源码详情

src/utils/hooks.ts:286-296shouldSkipHookDueToTrust() 是 Hook 执行的第一道守门人,在 executeHooks() 的第 1994 行被调用。它强制执行一条简单但重要的规则:

export function shouldSkipHookDueToTrust(): boolean {
  const isInteractive = !getIsNonInteractiveSession()
  if (!isInteractive) {
    return false  // SDK/CI 模式:隐式信任,直接执行
  }
  const hasTrust = checkHasTrustDialogAccepted()
  return !hasTrust  // 交互模式下:没通过信任对话框 → 跳过
}

所有 Hook——无一例外——都需要 workspace trust。 这不是针对某个具体事件的检查,而是对所有事件的集中拦截。源码注释里明确写了两条历史漏洞:SessionEnd Hook 在用户拒绝信任对话框时仍执行了;SubagentStop Hook 在子代理完成时绕过了信任检查。这两个漏洞促使团队把信任检查集中到一个地方——executeHooks() 入口处——而不是分散在每个事件调用点。

与之配套的是 hooksConfigSnapshot.ts:18-53 的快照机制:信任对话框弹出之前,系统就通过 captureHooksConfigSnapshot() 拍了一张 Hook 配置的快照。即使用户拒绝信任,后来 Hook 配置也不会被动态加载和解析。先截屏、再弹窗、再执行——用一个时间顺序保证了"拒绝信任后,没有新的 Hook 能被注入"。

在企业策略层面,shouldDisableAllHooksIncludingManaged()hooksConfigSnapshot.ts:83-88)和 isEnvTruthy(CLAUDE_CODE_SIMPLE)hooks.ts:1982)提供了两个额外的全局开关:前者由企业策略层的 manage settings 控制,后者是一个环境变量级别的紧急熔断。


十一、避坑指南

Hook 命令要快。 Hook 在 Claude 工作流里同步执行(除非你用了 async: true)。一个跑 5 秒的 Hook,每次写文件都阻塞 5 秒。全量测试套件不适合放在 PostToolUse 里——只跑相关测试或做快速检查。

|| true 处理非关键 Hook 的失败。 Prettier 不支持的文件类型会报错,这不应该影响 Claude 继续工作。非关键的 Hook 加上 || true,让失败静默通过。

stdout 不要有杂质。 如果你的 Hook 返回 JSON,确保 stdout 里只有 JSON。shell 的 .bashrc / .zshrc 里的 echo、motd、conda 提示等都会污染输出,导致 JSON 解析失败。

PreToolUse 的 deny 不是终点。 Claude 收到 deny 后不会崩溃,它会理解拒绝原因并尝试其他方案。所以 reason 字段尽量写清楚"为什么不行"和"建议怎么做",这样 Claude 的调整方向会更准。

多个 Hook 的执行顺序。 同一个事件上挂了多个 Hook,它们并行执行(源码中用 all(hookPromises) 并发调度)。返回的权限决策按 deny > ask > allow 的优先级仲裁。需要注意的是:并不是配得越靠前 Hook 就越先生效——安全优先的仲裁规则才是决定最终行为的机制。

/hooks 命令调试。 在 Claude Code 里输入 /hooks,可以查看当前生效的所有 Hook 配置。配完之后先确认它们真的被加载了,再去测试效果。


十二、什么该用 Skill,什么该用 Hooks

到这里,一个自然的问题是:同样是"让 Claude 写完文件后跑 lint",我到底该写在 Skill 里还是配成 Hook?

判断标准只有一个:

这件事需不需要模型理解?

  • 需要理解上下文才能决定怎么做 → Skill
  • 不需要理解,每次都一样执行 → Hook

"代码审查时按固定顺序检查数据库、异步和错误处理" → 这需要 Claude 理解代码逻辑,Skill。

"每次写文件后跑 prettier" → 不需要理解,纯机械执行,Hook。

"检测到 Claude 要执行危险命令时阻止" → 不需要理解(或者用 prompt 类型做轻量判断),Hook。

"根据 PR 的改动范围决定需要跑哪些测试套件" → 需要理解改动语义,Skill。

更精确地说:Skill 是你给 Claude 的任务书,Hooks 是你给系统的执行规则。 Skill 的执行者是模型,Hooks 的执行者是系统。当你发现自己在 Skill 里写的某一步"每次都一样、不需要判断",那一步就该提取成 Hook。

最强的组合是两者配合:Skill 定义任务流程和判断逻辑,Hooks(尤其是 Skill frontmatter 里的 hooks)保证流程中的确定性步骤不会被跳过。


本篇实践任务

任务一(5 分钟,马上做): 在你的 ~/.claude/settings.json 里加一个 Stop Hook,任务结束后发桌面通知。做完之后,开一个新会话让 Claude 帮你改一个文件,然后去泡杯茶——等通知弹出来再回来看结果。

任务二(有 TypeScript 项目必做): 加上 PostToolUse Hook,Write 和 Edit 之后自动跑 prettier --write + tsc --noEmit。观察 Claude 的行为变化:当它看到类型错误时,会不会主动去修?

任务三(想做安全防护的): 配一个 PreToolUse Hook,拦截 rm -rf 命令。然后故意让 Claude 执行一个可能触发 rm -rf 的任务,观察它收到 deny 后的调整策略。


下篇预告

第 05b 篇:Plugins 打包与分发——把 Skill + Hooks + CLAUDE.md 封装成团队可复用的能力包

上一篇讲了 Skill(任务模板),这一篇讲了 Hooks(确定性守卫),加上第 04 篇的 CLAUDE.md(行为约束)——这三者刚好是 Plugin 的三块核心砖。下一篇聚焦怎么把它们打包成一个可安装、可共享的 Plugin,让你把个人的最佳实践变成团队的基础设施。


AI Coding 系列持续更新。Skill 管"怎么做",Hooks 管"做完之后必须发生什么"——把概率型和确定型的控制手段分清楚,你对 Claude 的掌控感会提升一个量级。