Hook 系统,可编程的挂点
前面几章讲的机制都是厂商写死的,hook 系统把其中一批关键节点开放给用户。用户在 settings.json 里注册自己的脚本,程序走到特定事件时替用户执行,输出还能反过来影响程序的行为。事件清单是一个叫 HOOK_EVENTS 的常量数组,二十八项,从公开文档熟悉的 PreToolUse、PostToolUse、Stop,到内部的 TeammateIdle、WorktreeCreate、FileChanged,比对外公开的多出一大截。
注册的匹配条件有三层。事件名指定挂在哪里,工具名模式限定只对某类工具生效,if 条件用一段表达式做精细过滤。实现上 hooks.ts 是一个五千行的执行引擎,一次 hook 触发要把所有匹配的注册项按顺序跑完,处理超时、中断和结果合并。hook 有四种类型。command 型最常见,spawn 一个 shell 进程,事件数据从标准输入喂进去。prompt 型往模型上下文里追加一段提示。http 型发一个网络请求。agent 型直接派一个子代理去处理,等于用户能注册第 7 章那套递归机器。
四种类型里 PreToolUse 的 hook 权力最大,它可以改写这次工具调用的入参,updatedInput 字段提交修改后的参数,decision 字段填 deny 直接否决执行。否决之外还有一个容易被忽略的细节,hook 的裁决和权限系统怎么相处。裁决逻辑集中在 resolveHookPermissionDecision,规则写得很明确。hook 说不许,那就真的不许。hook 说可以,事情没完,还要再查一遍用户的 deny 和 ask 规则,用户显式配置的拒绝能压过 hook 的放行。源码注释解释了两类例外,需要真人交互的工具,还有投机执行场景,这两种情况 hook 批准了也必须再走一遍完整权限检查。
command 型 hook 的执行细节相当工程化。事件数据序列化成 JSON 写进子进程标准输入,环境变量里注入项目根目录,插件来源的 hook 额外拿到插件目录和插件数据目录,插件声明的配置项也按约定前缀导出成环境变量,命令串里还能引用模板变量做替换。SessionStart、Setup、CwdChanged、FileChanged 这几个事件的 hook 拿到一个环境文件路径,往里写的环境变量定义会被后续的命令执行继承,等于 hook 能给整个会话注入环境。超时按注册项单独配置,没配就走全局默认。退出码有约定含义,非零代表拦截,输出第一行若是合法 JSON 则按结构化协议解析。支持异步模式,子进程先回一句挂起声明就转入后台登记,不阻塞主循环,回头再收割结果。源码里有一条防御值得注意,插件目录被删后 hook 不再执行,因为脚本缺失时的退出码和主动拦截的退出码无法区分,放任执行会让 UserPromptSubmit 和 Stop 永久卡死。Windows 上有整套路径转换和 shell 选择的特殊处理,光这部分注释就占了源码一大段。
这套系统的存在改变了 harness 的性质。没有 hook,用户对程序行为的定制只能停在配置开关。有了二十八种事件加四种执行体,用户可以在权限裁决之前改参数、在会话开始时注入环境、在每轮结束时做检查,等于拿到了一套官方扩展点。第三方生态的规范执行、企业内部的合规审计,都挂在这套挂点上运转。