从设计者的角度理解源码--ClaudeCode的Hook体系设计

王大爷 2026年10月06日 4次浏览

从设计者的角度理解源码--ClaudeCode的Hook体系设计

引言

本篇,作为《从设计者的角度理解源码》系列的第六篇,试图回答一个比「工具怎么统一」更棘手的问题:Claude Code 怎么敢让用户自己写的代码(Hook),插进它的核心执行流程,参与「要不要执行这个危险操作」的决策?

上一篇我们讲了工具体系——Tool 接口是一套「插座标准」,几十种工具都是 Claude Code 自己造的插头,可信、可控,插座只需要统一规格。但 Hook 不一样:它是用户造的插头。用户会在 settings.json 里配一个 command 型 hook,指向一段 shell 脚本,这个脚本的输出是一条来自另一个进程的 stdout 字符串——编译期完全看不到它,它可能写错、可能被滥用、甚至可能被篡改。

一边是「不可信的外部逻辑」,一边是「必须可信的核心决策」(拦不拦截、改不改参数、放不放行)。这个矛盾怎么解?

本文的核心隐喻:Hook 系统是一套「带保险丝的扩展插座」。插座(统一的核心流程)上留了几个固定的孔位(五钩点),任何人都能把自制的插头插进来(扩展),但每一个孔位后面都接了一根保险丝——契约双保险校验产出、数据/行为分离决定哪些能存档。保险丝的作用不是「拒绝外部插头」,而是「让不可信的东西,以可信的方式参与进来」。

要解开这个矛盾,得先把 Hook 分成两段来看。第一段是通用核心——不管哪个事件触发,都走「契约 → 配置 → 匹配 → 执行」同一条链,这套机制对全部 27 个事件一视同仁。第二段是消费侧——执行完拿到 HookResult 之后,不同的生命周期各自用各自的方式消费它:有的把它并进权限裁决,有的提取一段文字回填,有的干脆发了就不管。

┌─────────────────────────────────────────────────────────────────────┐
│                    第一部分:通用核心(对 27 个事件全部通用)          │
│                                                                     │
│  ① 契约层  coreTypes.ts / coreSchemas.ts / types/hooks.ts           │
│     HOOK_EVENTS 27 事件 + HookJSONOutput 双保险                      │
│        ↓  settings.json 声明                                          │
│  ② 配置层  schemas/hooks.ts                                          │
│     4 数据型 schema + if 条件 + 数据/行为分离                         │
│        ↓  事件触发                                                     │
│  ③ 匹配层  utils/hooks.ts getMatchingHooks                           │
│     事件名 → matchQuery → matcher 过滤 → 去重                         │
│        ↓  匹配到的 hooks                                               │
│  ④ 执行层  utils/hooks.ts executeHooks(唯一分发器)                  │
│     快速路径 / 懒序列化 / 六路分发 / 产出校验                          │
└─────────────────────────────┬───────────────────────────────────────┘
                              │ HookResult(归一后的意图)
                              ▼
┌─────────────────────────────────────────────────────────────────────┐
│                    第二部分:消费侧(各生命周期各自消费)              │
│                                                                     │
│  工具侧      → 翻译(7分支) + 裁决(四路矩阵)     ← 最重,见第五章      │
│  会话生命周期 → 收集附加上下文 / 看阻断          ← 见第六章            │
│  压缩侧      → 提取 newCustomInstructions        ← 见第七章            │
│  通知/审计侧 → fire-and-forget 或只记日志        ← 见第八章            │
│  环境/协作侧 → 提取 watchPaths / 阻断型          ← 见第九章            │
└─────────────────────────────────────────────────────────────────────┘

一句话概括整套 Hook 机制:

Hook = 事件订阅(何时介入)+ 判别式入参(事实完整)+ 稀疏归一返回(意图受校验)+ 可插拔执行器(数据/行为分离)


第一部分:通用核心

一、契约层:Hook 被建模成什么

哎,Hook 到底有哪几种?什么时候会触发?

这要回到最底层的那份「事件清单」——HOOK_EVENTS(coreTypes.ts L24-53):

export const HOOK_EVENTS = [
  'PreToolUse', 'PostToolUse', 'PostToolUseFailure',
  'Notification', 'UserPromptSubmit', 'SessionStart', 'SessionEnd',
  'Stop', 'StopFailure', 'SubagentStart', 'SubagentStop',
  'PreCompact', 'PostCompact', 'PermissionRequest', 'PermissionDenied',
  'Setup', 'TeammateIdle', 'TaskCreated', 'TaskCompleted',
  'Elicitation', 'ElicitationResult', 'ConfigChange',
  'WorktreeCreate', 'WorktreeRemove', 'InstructionsLoaded',
  'CwdChanged', 'FileChanged',
] as const

27 个事件。注意一个关键事实:工具侧只有 5 个——PreToolUse / PostToolUse / PostToolUseFailure / PermissionRequest / PermissionDenied。其余 22 个分布在会话开始结束、压缩前后、通知、配置变更、文件变化、任务创建……每一个都是一个「外置挂载点」。而这个文件头有一句关键注释:

Types are generated from Zod schemas in coreSchemas.ts.

有了:事件的类型是从 Zod schema 生成的——coreSchemas.ts 是「single source of truth」(契约真源),TS 类型是它的投影。这和第五篇工具体系的「z.infer 以 schema 为唯一事实源」是同一个哲学。

哎,那 Hook 的返回值到底被建模成什么?为什么一份要定义两份?

因为同一份契约,要过两条信任边界。返回值 HookJSONOutput 有两种来路:

  • 进程内路径:TypeScript 写的 callback/function hook,返回值类型就是 SDK 侧声明的 HookJSONOutput。编译器看得见它,可信。
  • 外部进程路径:command 型 hook 是子进程,输出是 stdout 字符串,运行时 JSON.parse 才知道。编译器看不见它,不可信。

于是设计者用两份定义各守一边:

// 契约源①:SDK 侧类型(对外承诺,进程内 TS hook 用)
// src/entrypoints/agentSdkTypes.js 里的 HookJSONOutput

// 契约源②:Zod 推断类型(运行期校验形状,外部进程产出用)
const hookJSONOutputSchema = lazySchema(() =>
  z.union([asyncHookResponseSchema, syncHookResponseSchema()]),
)
type SchemaHookJSONOutput = z.infer<ReturnType<typeof hookJSONOutputSchema>>

哎,两份定义,万一它们对不上(漂移)怎么办?

这是第一根保险丝——编译期互锁(types/hooks.ts L195-200):

type Assert<T extends true> = T
type _assertSDKTypesMatch = Assert<
  IsEqual<SchemaHookJSONOutput, HookJSONOutput>
>

只要两份定义不一致,IsEqual 返回 false,编译失败。而编译期管不住外部进程,所以有第二根保险丝——运行期三道校验:validateHookJson 的 safeParse(结构)、hookEventName 必须等于预期事件(语义,防「串台」)、未知 decision 走 default 抛错(穷尽)。

有了:一份契约、两道门,各守一条信任边界——进程内 TS hook 靠编译期(门①),外部进程 hook 靠运行期(门②),最终收敛到同一个 HookJSONOutput。

哎,那入参和返回值,为什么要求差那么多?入参那么严(必填一堆),返回值那么松(几乎全可选)?

根子还是信任方向:

入参 HookInput返回值 HookJSONOutput
语义事实(快照)意图(指令)
结构封闭、必填、判别式开放、可选、稀疏
谁产出主进程(可信)Hook(不可信)
校验落在哪产出侧消费侧

入参是我们构造的(createBaseHookInput),是「事实快照」——hook 少了 tool_name/tool_input 就没法判断该不该拦,所以必须完整。返回值是外部产出的「意图表达」——hook 只需说「要不要干预」,说得越多越容易冲突,缺省即取默认语义(不表态 = 不干预),所以必须稀疏。

这里有几个常见误区,值得单独澄清:

  • 「SDK 侧那份是零依赖」不准确。准确说法是零业务依赖——它仍 import 了 8 行的纯工具 lazySchema,但不 import 任何业务模块,业务枚举(如 PermissionBehavior)都自建,好让类型生成脚本独立消费。
  • 「主进程不能复用 SDK 的 schema」也站不住。它并非「不能」,而是「没有」——types/hooks.ts 已跨层 import 了 SDK 的运行期值 HOOK_EVENTS。两份并存的真实动机是分层职责与演进节奏分离。
  • 「入参的使用方只有 SDK」不成立。入参使用方有三个(主进程构造 / hook 经 stdin 读 / SDK 校验),但运行期校验方只有 SDK——使用方 ≠ 校验方。
  • 「一个业务一种要求」恰好说反了。真正「多」的是入参——27 个事件各有一套 schema 分支;而返回值只有一套校验规则。「多」体现在 schema 物理份数(返回值 2 份 vs 入参 1 份),不在校验规则。

契约层的一句话总结:谁接收不可信数据,谁负责校验。 入参是可信流出,校验在产出侧;返回值是不可信流入,校验在消费侧。一份契约、两道门、方向相反的两类要求,全是为了同一个目标——让不可信的外部逻辑,以可信的方式进入决策。


二、配置层:Hook 从哪来、怎么声明

哎,Hook 是怎么「写」出来的?用户在 settings.json 里写的是什么?

写的是数据,不是代码。看 schemas/hooks.ts L31-171 定义的四种纯数据 schema——command(跑 shell)、prompt(调 LLM)、agent(跑子 Agent)、http(发请求):

const BashCommandHookSchema = z.object({
  type: z.literal('command'),
  command: z.string(),          // shell 命令
  if: IfConditionSchema(),      // 细粒度过滤条件
  shell: z.enum(SHELL_TYPES).optional(),
  timeout: z.number().positive().optional(),
  statusMessage: z.string().optional(),
  once: z.boolean().optional(),
  async: z.boolean().optional(),     // 后台运行
  asyncRewake: z.boolean().optional(),
})

注意每个字段——timeout、once、async、statusMessage——全都是可序列化的数据。它们被 discriminatedUnion('type', [...]) 组合,且排除了 function 类型(注释写明「excludes function hooks - they can't be persisted」)。

有了:这就是「数据/行为分离」的第一刀——能不能写进 settings.json,是「配置」和「代码」的分水岭。4 种数据型 hook 能配置即扩展(改一行 JSON 就加一个 hook);2 种行为型(callback/function)是函数,不可序列化,只能进程内临时用。

这条分水岭不是拍脑袋,而是被一个 bug 逼出来的「数据纪律」。看 schemas/hooks.ts L130-137 的注释:

DO NOT add `.transform()` here. This schema is used by parseSettingsFile,
and updateSettingsForSource round-trips the parsed result through
JSON.stringify — a transformed function value is silently dropped,
deleting the user's prompt from settings.json (gh-24920).

.transform() 会产生函数值,JSON 往返时被静默丢弃,用户的 prompt 就被删了。凡是数据侧成员,绝不允许携带不可序列化的行为。


三、匹配层:Hook 怎么被选中

哎,一次事件触发,二十几个事件、每个事件可能配了多个 hook,怎么知道「这次该跑哪些 hook」?

这是 getMatchingHooks 的活(utils/hooks.ts L1649)。它的核心是三步:

第一步,从事件名推导 matchQuery——不同事件,取输入的不同字段作为匹配依据:

switch (hookInput.hook_event_name) {
  case 'PreToolUse': case 'PostToolUse': case 'PostToolUseFailure':
  case 'PermissionRequest': case 'PermissionDenied':
    matchQuery = hookInput.tool_name      // 工具类事件 → 匹配工具名
    break
  case 'SessionStart': matchQuery = hookInput.source   // 会话开始 → 匹配来源
  case 'SessionEnd':   matchQuery = hookInput.reason   // 会话结束 → 匹配原因
  case 'FileChanged':  matchQuery = basename(hookInput.file_path)  // 文件变化 → 匹配文件名
  ...
}

第二步,用 matchQuery 过滤 matcher——matcher.matcher 是用户配的粗粒度匹配(如 Bash),用 matchesPattern 判断是否命中。

第三步,flatMap 展开 + 去重——把匹配到的 matcher 里的 hooks 数组摊平,再按 hookDedupKey(命令/URL + 来源命名空间)去重,防重复配置。

有了:匹配层做的是「从 27 个事件 × 一堆配置里,筛出这次该跑的那几个 hook」。而粗粒度 matcher(Bash)之外的细粒度 if 条件(Bash(git *)),则要靠 prepareIfConditionMatcher——它回调工具的匹配闭包(Bash 用 parseForSecurity 拆子命令),实现依赖倒置。


四、执行层:Hook 怎么被跑起来

哎,一个 hook 被选中后,到底怎么「跑」?谁来跑?怎么校验它吐回来的东西?

这是整个 Hook 系统的「心脏」——executeHooks(utils/hooks.ts L2000),唯一的核心分发器。它的完整流程:

① 提前退出:shouldDisableAllHooksIncludingManaged / CLAUDE_CODE_SIMPLE / trust 检查
   ↓
② getMatchingHooks:匹配(见第三章)
   ↓
③ 内部 callback 快速路径:全 internal 时直接 await hook.callback,跳过 span/progress/校验
   ↓ (-70% 延迟:6.01µs → 1.8µs)
④ progress 预发送:为每个 hook yield 一条 hook_progress 进度消息
   ↓
⑤ getJsonInput 懒序列化:首次调用 stringify(hookInput),同批共享;callback/function 已 return,永不付此成本
   ↓
⑥ 六路分发:matchingHooks.map(...) 并行执行,按 hook.type 路由到六个执行器
   ↓
⑦ 产出校验:validateHookJson / processHookJSONOutput

哎,那个「内部 callback 快速路径」是什么?为什么要专门开一条快路?

这是性能优化的一个巧思(L2084-2114)。内部 hook(sessionFileAccessHooks、attributionHooks)的 callback 只返回 {}、不用 abort signal、不做权限决策——如果还走完整的 span/progress/JSON 校验流程,纯属浪费。所以全 internal 时直接 await hook.callback,把每次 PostToolUse 的延迟从 6µs 降到 1.8µs(-70%)。

有了:快路不是「偷工减料」,而是**「当一类 hook 的行为足够简单、足够可信时,跳过对它无意义的通用开销」**。通用的校验是给「不可信的外部进程」准备的,对「可信的内部 callback」就是浪费。

哎,那「懒序列化」又是什么?为什么要懒?

getJsonInput(L2176-2188)只在第一次被数据型 hook 需要时才 stringify(hookInput),结果缓存复用。而 callback/function 在到达它之前就 return 了,所以纯行为型 hook 的批次永不序列化;4 种数据型共享同一份惰性缓存,同一批只序列化一次。这正是「数据/行为分离」在运行时的物理佐证。

哎,那六路分发之后,吐回来的东西怎么校验?

三道校验:validateHookJson(safeParse 结构)、hookEventName 必须等于预期事件(语义)、未知 decision 走 default 抛错(穷尽)。然后归一成 HookResult(L373),每个字段都对应一个协议字段,缺省即默认语义。

哎,等等——为什么有的 hook 结果是「逐条 yield」出来的,有的是「一次性聚合返回」的?

这是理解消费侧差异的根因。底层其实有两个执行器:

执行器返回形态适用场景
executeHooks(AsyncGenerator)逐条 yield AggregatedHookResult(含 blockingError / additionalContexts / 进度)决策型、需要边跑边看
executeHooksOutsideREPL(Promise)聚合返回 HookOutsideReplResult[]非交互、只取最终结果

有了:executeHooks 是「边跑边吐」,适合那些要「一个 hook 的结果立刻影响下一步」的场景;executeHooksOutsideREPL 是「跑完打包」,适合那些「跑完再统一看结果」的场景。这一分野,直接决定了第二部分六类消费场景的差异。


第二部分:消费侧

执行层产出 HookResult 之后,故事才刚开始。不同的生命周期,用完全不同的方式消费这份结果——下面按复杂度从高到低讲六类。

五、工具侧:翻译 + 裁决(最重的一类)

哎,Hook 说了 allow,就真的放行吗?它说的 deny、ask 又怎么处理?

这是唯一「把 hook 决策合并进权限系统」的场景,所以最复杂。它多出两层别的场景都没有的东西——翻译层和裁决层,都藏在 toolHooks.ts 里(注意:这个文件在 services/tools/ 下,它是工具服务的一部分,不是 Hook 系统的本体)。

翻译层(runPreToolUseHooks,L584-610)把底层吐出的原始结果流翻译成 7 分支判别联合:

{ type: 'message' }                透传底层消息(进度/附件)
{ type: 'hookPermissionResult' }   钩子的权限意图(allow/ask/deny)
{ type: 'hookUpdatedInput' }       钩子改参但未表态(passthrough)
{ type: 'preventContinuation' }    钩子要求停止流程
{ type: 'stopReason' }             停止原因
{ type: 'additionalContext' }      附加上下文
{ type: 'stop' }                   终止生成器(中止/出错)

裁决层(resolveHookPermissionDecision,L451-557)把权限意图归一成四路矩阵:

hook 决策处理
allow先过 checkRuleBasedPermissions,无规则才采纳;有 deny 规则则 deny 覆盖
deny直采早退(否决只会被更否决)
ask转 canUseTool + forceDecision(弹窗展示 hook 的 ask 文案)
passthrough只采纳 updatedInput,不表态

核心不变量是 「钩子 allow 不豁免 settings.json 的 deny/ask 规则」。而它返回 {decision, input} 两个字段,是为了防 TOCTOU——决策和「决策对应的那份输入」必须成对绑定,避免「用 A 输入拿到的批准,去执行 B 输入」。

有了:工具侧之所以「重」,是因为 hook 的意见要并进权限系统的优先级链里参与裁决——它不只是「发个通知」,而是「改写了最终决策」。这正是它独有的复杂度,也是为什么它不该被当成 Hook 体系的通用层。


六、会话生命周期:收集附加上下文 / 看阻断

工具侧之外,最常被用到的是会话生命周期类。它们不并进权限,而是「收集 hook 贡献的零碎内容」。

看 processSessionStartHooks(sessionStart.ts L132-156)——SessionStart 事件忽略 blockingError,只收集四样东西:

for await (const hookResult of executeSessionStartHooks(...)) {
  if (hookResult.message) hookMessages.push(hookResult.message)
  if (hookResult.additionalContexts?.length) additionalContexts.push(...)
  if (hookResult.initialUserMessage) pendingInitialUserMessage = ...
  if (hookResult.watchPaths?.length) allWatchPaths.push(...)
}

有了:会话开始时的 hook,作用是「往上下文里塞东西」——附加说明、初始消息、要监听的文件路径。它不阻断(注释明说「ignoring blocking errors」),因为会话刚开始,没什么好拦的。

而 Stop / SubagentStop 相反——它们看 blockingError(executeStopHooks L3696),如果 hook 返回 exit 2,就阻止「停止」这个动作(比如 hook 判断「还有未保存的工作,先别停」)。UserPromptSubmit 则能改写用户的 prompt——用户按下回车后、真正发给模型前,hook 可以先加工一遍。


七、压缩侧:提取自定义指令回填

压缩(compact)是另一个「消费结果」的典型场景,但消费方式和工具侧完全不同——它提取结构化字段,回填进压缩流程。

看 executePreCompactHooks(hooks.ts L4018),它聚合结果后,把 hook 的 stdout 拼成 newCustomInstructions 返回:

const successfulOutputs = results
  .filter(result => result.succeeded && result.output.trim().length > 0)
  .map(result => result.output.trim())
return {
  newCustomInstructions: successfulOutputs.join('\n\n'),  // 拼成压缩指令
  userDisplayMessage: displayMessages.join('\n'),
}

消费方在 compact.ts L421-431 把它合并进压缩指令:

const hookResult = await executePreCompactHooks({ trigger, customInstructions })
customInstructions = mergeHookInstructions(customInstructions, hookResult.newCustomInstructions)

有了:压缩侧的消费方式是「hook 产出文字 → 回填进后续流程的输入」。hook 在这里不是「决策者」,而是「压缩指令的贡献者」——它说「压缩时请保留 XXX 的信息」,这段文字被拼进 prompt,影响模型怎么压缩。


八、通知/审计侧:fire-and-forget 或只记日志

这一类最轻——发出去就不管结果,或者只把失败写到日志。

  • Notification:void executeNotificationHooks(...)(print.ts L1366、notifier.ts L25)——系统要发个通知,顺带通知 hook,不等待、不关心结果。
  • StopFailure:void executeStopFailureHooks(...)——会话失败后的清理通知。
  • SessionEnd:写完 stderr 后 clearSessionHooks(hooks.ts L4185-4197)——会话要关了,通知一下,然后清掉 session hook。
  • InstructionsLoaded:注释明言「fire-and-forget — observability/audit only」(hooks.ts L4383-4386)——纯粹为了可观测/审计,不支持阻断。
  • ConfigChange:审计日志;而且有个关键设计——policy 来源的 block 结果被强制忽略(hooks.ts L4267-4297):
// Policy 设置是企业管理的,绝不能被 Hook 阻塞。
// Hook 仍会触发(用于审计日志),但阻塞结果会被忽略。
if (source === 'policy_settings') {
  return results.map(r => ({ ...r, blocked: false }))
}

有了:通知/审计类的 hook,「结果」几乎不被消费——它存在的意义是让外部系统「知道」发生了什么(审计、日志、旁路通知)。甚至连「阻断」这种能力,在 policy 场景下都被刻意剥夺(企业策略不能被你一个 hook 挡住)。


九、环境监控 + 协作侧:提取 watchPaths / 阻断型

最后两类,一类提取「副作用」,一类是纯「阻断」。

环境监控:CwdChanged / FileChanged 走 executeEnvHooks(hooks.ts L4302-4355),返回值里有 watchPaths(hook 声明「我还要监听这些文件」)和 systemMessages(hook 想注入的系统消息):

const watchPaths = results.flatMap(r => r.watchPaths ?? [])
const systemMessages = results.map(r => r.systemMessage).filter(Boolean)

协作侧:TeammateIdle / TaskCreated / TaskCompleted 是纯阻断型——exit 2 就阻止「进入空闲 / 创建任务 / 完成任务」。它们的消费方式极其简单:看有没有 blockingError,有就中止那个动作。

消费侧的一句话总结:同样一份 HookResult,工具侧拿它并进权限裁决,会话侧拿它塞上下文,压缩侧拿它回填指令,通知侧发了就不管,环境侧拿它换 watchPaths,协作侧拿它当「阻断开关」。 「消费方式」不是 Hook 的固有属性,而是「这个事件在哪个生命周期、那个生命周期要拿它干嘛」决定的。


十、实例层 + 可迁移的设计原则

前九章讲完了「通用机制」和「六类消费场景」,最后落到一个真实的 command 型 hook 怎么实现,再提炼原则。

哎,一个 command 型 hook,command: "~/my-check.sh",到底怎么变成子进程跑起来的?

execCommandHook(utils/hooks.ts L786)是它的执行引擎,藏着整套设计里「跨平台细节」最密集的一段。第一步 shell 选择(hook.shell ?? DEFAULT_HOOK_SHELL);第二步 Windows 路径转换(windowsPathToPosixPath 把 C:\Users\foo 转成 /c/Users/foo);第三步 插件变量替换(${CLAUDE_PLUGIN_ROOT} 等)。然后两条完全不同的 spawn 路径(L999-1026):

// Bash:shell 选项让 Node 把整串交给 shell 解析
child = spawn(finalCommand, [], { shell: isWindows ? findGitBashPath() : true, ... })

// PowerShell:显式 argv,无 shell 选项
child = spawn(pwshPath, ['-NoProfile', '-NonInteractive', '-Command', cmd], ...)

子进程 stdout 是流式收集的,同时做两件「带外通信」:第一行 {"async":true} 就把进程后台化;promptRequestSchema 格式的 JSON 行触发主进程回写 stdin(hook 执行到一半能「问用户问题」)。而 exit code 2 = blocking,最终翻译成 deny 决策。

有了:command 型 hook 的实现,本质是把「用户写的 shell 命令」变成「一个可控的、可后台化的、能带外通信的子进程」,再把 stdout/exit code 翻译成 HookResult。

可迁移的设计原则

回顾这「通用核心 + 消费侧」的完整图景,有六条原则值得迁移到任何「要接纳不可信外部扩展」的系统里。

第一,谁接收不可信数据,谁负责校验。

入参(可信流出)校验在产出侧,返回值(不可信流入)校验在消费侧。信任方向决定校验位置——不要问「这个数据要不要校验」,要问「这个数据是从可信方来的,还是从不可信方来的」。

第二,契约定义了两遍,就配一道编译期互锁。

SDK 类型和 Zod schema 是同一份契约的两份定义,靠 Assert<IsEqual> 锁死。凡是「同一份东西被写了两遍」,漂移就是迟早的事——要么合并,要么加互锁。

第三,「能否序列化」是「配置」和「代码」的分水岭。

6 种 Hook 第一刀切在「能不能写进 settings.json」,而不是「怎么执行」。能序列化的,就交给用户配置;不能序列化的,就留在进程内。 混在一起,存档(JSON 往返)就会静默吞掉行为。

第四,通用机制与消费场景分离。

「契约→配置→匹配→执行」是通用的、对 27 个事件一视同仁;「翻译+裁决」只是工具侧这一类的消费方式。把某一个消费场景的复杂度误当成通用机制的复杂度,是理解 Hook 的最大陷阱。

第五,翻译与裁决分离。

toolHooks.ts 只把底层结果流翻译成判别联合,不决定放行还是拦截;裁决在下一层。「翻译成什么」和「怎么决定」是两件事,混在一起,任何一方的演进都会牵动另一方。

第六,不可信的扩展点,需要「快速路径 + 严格校验」双轨。

内部 callback 走快路(跳过无意义的通用开销),外部进程走三道校验(safeParse + 语义 + 穷尽)。「可信」和「不可信」应该走不同代价的路径,而不是一刀切地要么全校验、要么全放行。

贯穿这六条原则的,是整套 Hook 体系最根本的设计哲学:

Hook 体系不是「拒绝外部代码」,而是「让不可信的外部代码,以可信的方式参与决策」。

它的目标不是「把用户写的东西挡在外面」,而是「给它一个安全的入口」——入口有固定的孔位(五钩点)、有保险丝(契约双保险)、有分类(数据/行为分离),然后让不同的生命周期,各自用各自的方式消费这份「被校验过的意图」。

保险丝烧断的永远不是「外部扩展」本身,而是「不可信」——把不可信拦在门外,把扩展留在门内。

这又绕回了整个系列反复出现的那个主题——用上下文代替状态机。消息机制把「发生了什么」翻译成自然语言,权限系统把「该不该做」翻译成决策,工具体系把「我是什么」翻译成接口声明,而 Hook 体系做的,是把「外部想干预什么」翻译成一份经过校验的、可插拔的、确定性的意图——然后交给不同的生命周期去消费。Claude Code 从头到尾都在做同一件事:让一切——包括用户写的代码——都成为 LLM 可以安全理解并据以决策的上下文。