学 AI 产品 · 专业 AI 产品经理播客第 3 章 · T3 解剖 OpenAI Codex:把安全观写进类型系统 · EP 16
第 3 章 · EP 16

挂钩点能改什么,由事件合同决定

时长 15:44音色 云健 · 男声

同步字幕

章节导航(点击跳转)

0:00开场 · 钩子不等于能力1:57
1:57三张表 · 认得出、解析得过、跑得了2:41
4:38兼容是类型名,不是注释1:54
6:33失败默认放行1:58
8:31审批路径反过来1:32
10:04位置决定合同2:38
12:43一次推演1:26
14:09可带走的原则1:34
解读全文

ep28 · 挂钩点能改什么,由事件合同决定

本内容改编自小山学堂《学 AI 产品,从入门到精通》,为二次演绎配音版
模块:T3 解剖 OpenAI Codex:把安全观写进类型系统
来源:xueai.miyang.cn(小山学堂 · 洛小山)

一句话速览

一次会话轮次会经过十一个挂钩点。协议认四种处理器类型,运行表只装命令和 MCP 工具。超时默认放行,拆卸期丢掉标准输出。

核心判断:挂钩点的名字不给你能力,事件合同才给你能力。配置面可以比执行器宽,这是有意的设计;但因此而产生的「名字里有、实际跑不了」的落差,必须由发现阶段的稳定文案和必选策略的拒绝启动来兜住。


能力地图

把这套挂钩系统按「谁决定你能改什么」分成三层,逐层收窄:

层名称宽度决定什么
第一层协议面十一个事件名 + 四种处理器类型你能不能写下来
第二层配置面四个类型标签全接住,后两个是空结构体你能不能解析过
第三层运行表只有命令和 MCP 工具你能不能执行

再按生命周期位置排一张合同表:

挂钩点能改什么失败时
会话开始尊重继续为假放行
用户提交拦截被写成停止,停当前消息匹配器被忽略
工具前拦工具、改输入默认放行
审批请求不改输入,只给一次性批准任一拒绝立刻赢
工具后只在成功后跑,拒绝的是结果副作用已发生
轮次结束带提示词的拦截才继续无提示词被忽略
会话结束纯通知标准输出整段丢弃

能力地图的读法:纵向看三层是「认得出 → 解析得过 → 跑得了」的逐级收窄;横向看生命周期是「还来不来得及改」的逐级收窄。两条轴的交点,才是这个挂钩点真正的合同。


思路一 · 认得出和跑得了拆开

问题

你刚从另一个框架搬来一份挂钩配置,里面三条处理器:命令拦危险 shell、提示词让小模型审用户提交、子代理在轮次结束时再起一个会话跑检查工具。原来那边三条都能跑。贴进 Codex,只有命令那条亮了,后两条日志各写一句 not supported yet。

机制

  • 协议面:十一个事件名,snake_case 序列化;旁边四个处理器类型也在。出处 codex-rs/protocol/src/protocol.rs L1508–1531。
  • 配置面:类型标签把四个名字都接住,后两个是空结构体。解析能过,字段里没有可执行内容。出处 codex-rs/config/src/hook_config.rs L183–187。
  • 发现阶段:看见后两个就跳过,文案 prompt hooks are not supported yet / agent hooks are not supported yet。普通用户配置只进警告;托管必选挂钩配了它们,启动会失败。出处 codex-rs/hooks/src/engine/discovery.rs L626–645。

提示1 · 兼容被写进了标识符

JSON 挂钩的运行时类型名直接叫 ClaudeHooksEngine。兼容不是注释里的愿望,是类型名。标准输入喂 JSON,标准输出按模式解析。旁边还留着一条旧的 notify 路,只在会话收工时 fire-and-forget 一条命令。两条路不要混。出处 codex-rs/hooks/src/engine/mod.rs L107–119。

提示2 · 横向对比时的正确数法

  • 另一框架还原源码里事件枚举有 27 项,Codex 的 11 点都在,另有失败后事件、通知、工作树、文件变更。那边的运行面比配置面宽,小模型钩子真的会跑,构造用户消息时绕开常规输入处理以避免再次触发用户提交事件。
  • DSH 在前后执行上各开一道插件瀑布,原生插件能做兼容桥能做的一切;文件映射只留五点,没有改写、没有审批前钩子。
  • Grok 用十五个事件名补观察面,但拦截判断只对工具前返回真,没有审批前挂钩。

为什么配置面要做得比执行器宽

先把生态里已有的四个类型接住,未知标签才不会把整份配置打爆。这是很实际的取舍:如果解析阶段直接拒绝不认识的类型,用户搬一份配置过来要先删掉一半才能启动;解析时宽容、运行前再挑,用户至少能把会话开起来。

代价是搬家的人会按枚举名理解能力。因此必须配两个补偿机制:

  • 发现阶段留下稳定文案(not supported yet),让落差可被检索;
  • 必选策略碰到未实现类型必须拒绝启动,而不是静默跳过。

这里还有一个来源维度的区分:普通用户配置里这两条只进警告、会话继续,因为那是个人选择,错了不影响别人;托管必选挂钩配了它们则启动失败,因为那是组织级安全承诺,不能悄悄降级。同一个未实现类型、两种处理,依据是配置来源是个人还是组织。

结论:事件名高度重合是对齐动作,不代表能力对齐。评估时数运行表,不要数枚举。


思路二 · 失败默认放行

问题

一条工具前挂钩,超时设一秒,脚本里睡五秒。作者以为挂钩崩溃等于拦截。工具照样执行,日志状态是失败,文案带 timed out after 1s。

机制

超时把 error 写成一句话,exit_code 为空。解析看见 error 只标 Failed,拦截控制位保持默认 false,工具注册表继续往下走。出处 codex-rs/hooks/src/engine/command_runner.rs L317–326。

会拦的路只有两条:

  1. JSON 里明确给出 deny 或 block;
  2. 退出码 2 且标准错误非空 —— 退出码 2 却没有理由,算失败,不拦。

异步挂钩即使返回 deny 也加不上控制效果。只有同步、可信、未超时的处理器能改下一步。出处 codex-rs/hooks/src/events/pre_tool_use.rs L261–277。

审批路径的规则是反的

审批请求跑在安全审查和用户审批界面之前,不改工具输入。折叠规则:任一 deny 立刻赢,否则保留最后一次 allow;allow 映射成一次性已批准,不进会话缓存,下次同样命令还要再问。出处 codex-rs/core/src/tools/approvals.rs L454–474。

跨框架搬家的坑:别家框架输出里的 updatedInput 字段,在 Codex 被标为保留字段,看见就 fail closed。原样搬一条带改写的审批挂钩,在这里会失败,不会改写。

提示3 · 一个系统里两种默认值并存

拦截路径默认放行,是为了可用性(一条挂掉的 linter 不该让所有工具停摆);审批路径默认拒绝,是为了安全(那一层不能把看不懂的字段当成允许)。不是不一致,是两层的风险方向不同。

可以推演一下反面:若失败按拦截处理,任何一次网络抖动或脚本依赖缺失都会让整个会话卡死,用户唯一出路是删掉挂钩重开。相比之下,漏拦一次可以靠日志事后追,卡死一次是当场不可用。

对应到写法上:不要把自己的安全逻辑藏在脚本的异常退出里,要明确写一句拒绝,并把理由写进标准错误,让模型和用户都能看见。

提示4 · 允许不进会话缓存的实际含义

allow 被映射成一次性的已批准,不进会话缓存。这意味着无法靠审批挂钩做长期授权,同样的命令第二次执行还要再问一遍。

这看起来麻烦,但它保证了每一次敏感操作都有一次明确记录,审计时能追到单次行为而不是一条早已过期的策略。需要长期授权时,应改策略配置,而不是指望挂钩缓存。


思路三 · 晚了就改不了已经发生的事

问题

工具后想拦一次危险写入,文件已经落盘;会话结束想往上下文里塞收尾说明,标准输出被丢掉,协议枚举里也没有这个事件名。共同点是:挂钩点已经走过它能改的那一段。

机制

  • 轮次结束:block 带着继续提示词才在轮次层继续,不重发会话开始事件;没有提示词的 block 被忽略。把控制权交回任务壳的是停止标记,且停止优先于拦截。出处 codex-rs/core/src/session/turn.rs L509–538。
  • 用户提交:block 被写成停止,停的是当前这条用户消息,不拿标准错误当下一轮提示词;匹配器在这里被忽略。
  • 会话结束:拆卸期通知。超时默认 1 秒、上限 3 秒,给上层服务的 5 秒关机留余量。退出码 0 即完成,标准输出整段丢掉,MCP 形态直接跳过。出处 codex-rs/hooks/src/events/session_end.rs L20–24。
  • 文本预算:挂钩文本进模型前先变成开发者角色片段,默认预算 2500 个近似 token。超限全文写临时目录,模型只看见头尾预览加一行路径;写盘失败就只截断。出处 codex-rs/hooks/src/output_spill.rs L53–91。

提示5 · 挂钩输出要结论先行

2500 token 预算的实际影响是:让挂钩吐一大段完整检查报告,模型大概率只看见开头和结尾,中间全丢。因此挂钩输出应写成结论先行的一句话,完整日志另行落盘,不要在标准输出里堆细节。

三层递进的收窄

  • 工具还没跑,才能改输入或跳过;
  • 工具跑完了,只能改模型看见的那一截;
  • 到了拆卸期只剩几秒,读 JSON、回灌上下文、再等外部调用都会把关机拖过上限,于是退化为纯通知。

换一套运行时,该问的仍然是同一句:这个点还来不来得及改已经发生的事。


常见误判对照

直觉实际行为正确做法
挂钩崩溃 = 拦住了失败默认放行,工具照常执行显式返回 deny/block
退出码 2 就是拦截退出码 2 且标准错误非空才算拦截同时写理由到标准错误
异步挂钩返回 deny 能拦异步加不上控制效果需要拦截必须同步
审批挂钩能改写输入该字段被标为保留,fail closed改写逻辑挂到工具前
轮次结束的 block 会继续必须带 continuation prompt,否则忽略拦截同时给出提示词
会话结束能回灌上下文标准输出整段丢弃收尾逻辑挂轮次结束
审批通过一次就长期有效一次性批准,不进缓存长期授权改策略配置

排查路径

当「挂钩没生效」时,按下面顺序定位,绝大多数问题停在前两步:

  1. 看状态是 failed 还是 blocked。failed 说明控制位没动,脚本再怎么报错都不算拦截。
  2. 看有没有给理由。退出码 2 但标准错误为空,仍判 failed。
  3. 看是不是异步。异步处理器的返回值不产生控制效果。
  4. 看挂的位置对不对。要改输入必须挂在工具前,工具后拦的只是结果。
  5. 最后才看脚本本身。到这一步才需要打开脚本调逻辑。

前四步都是配置层问题,改起来比改脚本便宜得多。

审查清单

上线前逐条过:

  • 运行表里实际生效了几种处理器类型?有没有只进警告、没进运行表的配置?
  • 托管必选挂钩里有没有配提示词或子代理类型?(配了会直接启动失败)
  • 所有需要拦截的挂钩,是否都给了明确拒绝理由(deny/block,或退出码 2 + 非空标准错误)?
  • 需要拦截的挂钩是否标记为同步?异步返回 deny 是无效的。
  • 有没有依赖「挂钩挂了 = 拦住了」这种错误假设?
  • 需要改输入的逻辑,是否真的挂在工具执行之前?
  • 轮次结束的拦截是否带了继续提示词?没带会被静默忽略。
  • 跨框架搬来的挂钩,输出里有没有含 updatedInput 这类保留字段?
  • 收尾逻辑是否误挂在会话结束?(那里标准输出会被丢掉,应改挂轮次结束)
  • 挂钩输出是否可能超过 2500 token 预算?超限后模型只能看到头尾预览。

约束说明

  1. 取材约束:本集全部内容取自小山学堂《学 AI 产品,从入门到精通》对应课节,未跨集取材,未虚构源码行号、文案或案例。
  2. 题库隔离:题库页(quizFiles)一律不进入口播稿,仅作为集页下方的文字自测卡渲染。
  3. 源码时效:文中行号对应 openai/codex 仓库 commit 4f39251a01;横向对比部分核对时间为 2026-08-22。
  4. 解读边界:本文为二次演绎的解读稿,用于配合音频理解;具体行为以实际运行版本为准。
  5. 数字与术语:为便于朗读,口播稿中数字一律用中文;技术专有名词保留原文标识符。

课堂练习

项目里有一条工具前命令挂钩,匹配器对着无害的 echo。

  1. 先把超时设成 1,脚本里睡 5 秒;
  2. 再把脚本改成退出码 2,并向标准错误写一句拦截理由。

推演两趟结局:工具会不会执行、模型看见什么、挂钩状态分别是 failed 还是 blocked。然后解释,为什么第一种不能靠「挂钩挂了」来当拦截。


Takeaway

协议、配置、运行是三张表,能跑的只有命令和 MCP。失败默认放行,要拦就给理由。每个挂钩点能改的东西跟它在生命周期的位置绑定,拆卸期和工具跑完之后,已经来不及改已经发生的事。


*来源:xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》)*