本内容改编自小山学堂《学 AI 产品,从入门到精通》,为二次演绎配音版
模块:T3 解剖 OpenAI Codex:把安全观写进类型系统
来源:xueai.miyang.cn(小山学堂 · 洛小山)
一次会话轮次会经过十一个挂钩点。协议认四种处理器类型,运行表只装命令和 MCP 工具。超时默认放行,拆卸期丢掉标准输出。
核心判断:挂钩点的名字不给你能力,事件合同才给你能力。配置面可以比执行器宽,这是有意的设计;但因此而产生的「名字里有、实际跑不了」的落差,必须由发现阶段的稳定文案和必选策略的拒绝启动来兜住。
把这套挂钩系统按「谁决定你能改什么」分成三层,逐层收窄:
| 层 | 名称 | 宽度 | 决定什么 |
|---|---|---|---|
| 第一层 | 协议面 | 十一个事件名 + 四种处理器类型 | 你能不能写下来 |
| 第二层 | 配置面 | 四个类型标签全接住,后两个是空结构体 | 你能不能解析过 |
| 第三层 | 运行表 | 只有命令和 MCP 工具 | 你能不能执行 |
再按生命周期位置排一张合同表:
| 挂钩点 | 能改什么 | 失败时 |
|---|---|---|
| 会话开始 | 尊重继续为假 | 放行 |
| 用户提交 | 拦截被写成停止,停当前消息 | 匹配器被忽略 |
| 工具前 | 拦工具、改输入 | 默认放行 |
| 审批请求 | 不改输入,只给一次性批准 | 任一拒绝立刻赢 |
| 工具后 | 只在成功后跑,拒绝的是结果 | 副作用已发生 |
| 轮次结束 | 带提示词的拦截才继续 | 无提示词被忽略 |
| 会话结束 | 纯通知 | 标准输出整段丢弃 |
能力地图的读法:纵向看三层是「认得出 → 解析得过 → 跑得了」的逐级收窄;横向看生命周期是「还来不来得及改」的逐级收窄。两条轴的交点,才是这个挂钩点真正的合同。
你刚从另一个框架搬来一份挂钩配置,里面三条处理器:命令拦危险 shell、提示词让小模型审用户提交、子代理在轮次结束时再起一个会话跑检查工具。原来那边三条都能跑。贴进 Codex,只有命令那条亮了,后两条日志各写一句 not supported yet。
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。JSON 挂钩的运行时类型名直接叫 ClaudeHooksEngine。兼容不是注释里的愿望,是类型名。标准输入喂 JSON,标准输出按模式解析。旁边还留着一条旧的 notify 路,只在会话收工时 fire-and-forget 一条命令。两条路不要混。出处 codex-rs/hooks/src/engine/mod.rs L107–119。
先把生态里已有的四个类型接住,未知标签才不会把整份配置打爆。这是很实际的取舍:如果解析阶段直接拒绝不认识的类型,用户搬一份配置过来要先删掉一半才能启动;解析时宽容、运行前再挑,用户至少能把会话开起来。
代价是搬家的人会按枚举名理解能力。因此必须配两个补偿机制:
not supported yet),让落差可被检索;这里还有一个来源维度的区分:普通用户配置里这两条只进警告、会话继续,因为那是个人选择,错了不影响别人;托管必选挂钩配了它们则启动失败,因为那是组织级安全承诺,不能悄悄降级。同一个未实现类型、两种处理,依据是配置来源是个人还是组织。
结论:事件名高度重合是对齐动作,不代表能力对齐。评估时数运行表,不要数枚举。
一条工具前挂钩,超时设一秒,脚本里睡五秒。作者以为挂钩崩溃等于拦截。工具照样执行,日志状态是失败,文案带 timed out after 1s。
超时把 error 写成一句话,exit_code 为空。解析看见 error 只标 Failed,拦截控制位保持默认 false,工具注册表继续往下走。出处 codex-rs/hooks/src/engine/command_runner.rs L317–326。
会拦的路只有两条:
异步挂钩即使返回 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。原样搬一条带改写的审批挂钩,在这里会失败,不会改写。
拦截路径默认放行,是为了可用性(一条挂掉的 linter 不该让所有工具停摆);审批路径默认拒绝,是为了安全(那一层不能把看不懂的字段当成允许)。不是不一致,是两层的风险方向不同。
可以推演一下反面:若失败按拦截处理,任何一次网络抖动或脚本依赖缺失都会让整个会话卡死,用户唯一出路是删掉挂钩重开。相比之下,漏拦一次可以靠日志事后追,卡死一次是当场不可用。
对应到写法上:不要把自己的安全逻辑藏在脚本的异常退出里,要明确写一句拒绝,并把理由写进标准错误,让模型和用户都能看见。
allow 被映射成一次性的已批准,不进会话缓存。这意味着无法靠审批挂钩做长期授权,同样的命令第二次执行还要再问一遍。
这看起来麻烦,但它保证了每一次敏感操作都有一次明确记录,审计时能追到单次行为而不是一条早已过期的策略。需要长期授权时,应改策略配置,而不是指望挂钩缓存。
工具后想拦一次危险写入,文件已经落盘;会话结束想往上下文里塞收尾说明,标准输出被丢掉,协议枚举里也没有这个事件名。共同点是:挂钩点已经走过它能改的那一段。
codex-rs/core/src/session/turn.rs L509–538。codex-rs/hooks/src/events/session_end.rs L20–24。codex-rs/hooks/src/output_spill.rs L53–91。2500 token 预算的实际影响是:让挂钩吐一大段完整检查报告,模型大概率只看见开头和结尾,中间全丢。因此挂钩输出应写成结论先行的一句话,完整日志另行落盘,不要在标准输出里堆细节。
换一套运行时,该问的仍然是同一句:这个点还来不来得及改已经发生的事。
| 直觉 | 实际行为 | 正确做法 |
|---|---|---|
| 挂钩崩溃 = 拦住了 | 失败默认放行,工具照常执行 | 显式返回 deny/block |
| 退出码 2 就是拦截 | 退出码 2 且标准错误非空才算拦截 | 同时写理由到标准错误 |
| 异步挂钩返回 deny 能拦 | 异步加不上控制效果 | 需要拦截必须同步 |
| 审批挂钩能改写输入 | 该字段被标为保留,fail closed | 改写逻辑挂到工具前 |
| 轮次结束的 block 会继续 | 必须带 continuation prompt,否则忽略 | 拦截同时给出提示词 |
| 会话结束能回灌上下文 | 标准输出整段丢弃 | 收尾逻辑挂轮次结束 |
| 审批通过一次就长期有效 | 一次性批准,不进缓存 | 长期授权改策略配置 |
当「挂钩没生效」时,按下面顺序定位,绝大多数问题停在前两步:
前四步都是配置层问题,改起来比改脚本便宜得多。
上线前逐条过:
updatedInput 这类保留字段?quizFiles)一律不进入口播稿,仅作为集页下方的文字自测卡渲染。项目里有一条工具前命令挂钩,匹配器对着无害的 echo。
推演两趟结局:工具会不会执行、模型看见什么、挂钩状态分别是 failed 还是 blocked。然后解释,为什么第一种不能靠「挂钩挂了」来当拦截。
协议、配置、运行是三张表,能跑的只有命令和 MCP。失败默认放行,要拦就给理由。每个挂钩点能改的东西跟它在生命周期的位置绑定,拆卸期和工具跑完之后,已经来不及改已经发生的事。
*来源:xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》)*