ARTICLE · 1167408
Codex 源码-Tool Approvals 审批流与网络策略
Codex 源码解析系列
第 19 讲:Tool Approvals 审批流与网络策略
基于 OpenAI Codex 源码 · 2026-10-13
💡 本讲一句话:Codex 里每一次"越权动作"(跑命令、写 stdin、调 MCP、碰网络)都要过一条三级裁决链——Hooks 配置层先表态,Guardian 自动审查接着审,都不管才弹给用户;而"始终允许"不是魔法,是会话级缓存 + 按 host 的网络审批状态机。读完你会明白:AI Agent 的"权限弹窗"背后是一台怎样的确定性机器。
一、审批三级裁决链:Hooks → Guardian → User
第 17、18 讲我们看了命令怎么被沙箱卡住、补丁怎么先干跑再落盘。但还有一个问题没回答:当策略判定"这个动作需要审批"时,到底谁来拍板?Codex 的答案在 core/src/tools/approvals.rs(873 行)里——Session::request_approval 是所有审批动作的总入口,它把裁决权按固定优先级分给三个角色:
① Hooks(配置层):用户/企业可以在 hooks 里写"这类命令直接放行/拒绝",零交互、确定性最高;② Guardian(自动审查):一个独立的 LLM 审查会话,按策略替用户做决定,适合无人值守场景;③ User(弹窗):最后防线,把动作和理由摆到人面前。三级是串行短路的——上一级给出明确裁决,下一级根本不会执行。
📄 codex-rs/core/src/tools/approvals.rs(第 493-558 行,中间省略网络审批的遥测分支)
let resolution = match run_permission_request_hooks( // 第一问永远是 Hooks——配置层可以无条件放行/拒绝,零交互成本 self, // 会话实例:hooks 需要它读取当前 turn 的策略配置 ctx.review_context.turn(), // 传入当前 turn 上下文,hook 据此判断策略是否允许该动作 &permission_request_run_id, // 用 call_id 当 hook 运行 id:重试时加 :retry 后缀避免撞车 action.permission_request_payload(), // 把审批动作序列化成 hook 能理解的 payload(工具名+入参)).await // 异步跑完所有 permission_request hooks,拿到配置层裁决(或弃权){ Some(PermissionRequestDecision::Allow) => ApprovalResolution { // hook 放行 → 直接 Approved,后面两级根本不用跑 decision: ReviewDecision::Approved, // hook 放行 → 最终裁决=批准 source: ApprovalResolutionSource::Hook, // 记录来源=Hook(遥测里映射成 Config) }, Some(PermissionRequestDecision::Deny { message }) => ApprovalResolution { // hook 拒绝 → 带理由直接拒,同样跳过后面两级 decision: ReviewDecision::denied(message), // hook 拒绝 → 带上它给的拒绝理由 source: ApprovalResolutionSource::Hook, // 来源标记为 Hook(遥测里映射成 Config) }, None => self.request_reviewer_approval(action, &ctx).await, // hook 弃权(None)→ 进入第二级裁决链};// …(中间省略:网络审批的遥测记录与特殊裁决分支处理)…async fn request_reviewer_approval( // 第二级入口:Guardian 优先,用户兜底——两级串行、不并行 self: &Arc<Self>, // 会话实例:两级裁决都要用它查策略/发弹窗 action: ApprovalAction, // 待审批的动作工单(命令/MCP/网络…) ctx: &ApprovalContext, // 审批上下文:review 环境、取消信号、理由等) -> ApprovalResolution { // 返回"裁决+来源"二元组,供上层统一处理 if let Some(decision) = self.request_guardian_approval(action.clone(), ctx).await { // Guardian 启用且给出裁决 → 采用它的结果 ApprovalResolution { // Guardian 给出裁决 → 组装成带来源标记的结果 decision, // 返回最终裁决:Approved/ForSession/Denied… source: ApprovalResolutionSource::Guardian, // 来源=自动审查(遥测映射成 AutomatedReviewer) } } else { // Guardian 未启用/弃权返回 None → 落到用户弹窗,这是最后防线 ApprovalResolution { // Guardian 给出裁决 → 组装成带来源标记的结果 decision: self.request_user_approval(&action, ctx).await, // 落到用户弹窗,拿到人的最终选择 source: ApprovalResolutionSource::User, // 来源标记为 User(遥测里就是"人做的决定") } }}为什么这样设计?三个细节值得注意。其一,裁决来源被显式建模成枚举(Hook/Guardian/User)而不是混在一个返回值里——因为下游的遥测、拒绝文案、MCP 策略处理全都依赖"是谁做的决定":hook 拒了叫 "rejected by configuration",Guardian 拒了叫 "automatic approval review denied the action",用户拒了才叫 "rejected by user"。其二,Guardian 返回的是 Option——它有权弃权(未启用、超时、策略不允许自动审查),弃权不等于拒绝,必须继续往下走;这是"自动审查"和"硬门禁"的本质区别。其三,整条链是 match 短路而不是 if-else 串联,三级优先级在代码结构上就是不可调换的。
🔑 设计要点:审批链的短路顺序 = 确定性从高到低。Hooks 是纯配置(可审计、可复现),Guardian 是模型判断(有不确定性但能无人值守),User 是人类直觉(最可靠但最贵)。把便宜的裁决放前面,用户弹窗只处理真正需要人脑的边界情况。
二、两张核心数据表:ApprovalAction 与 ReviewDecision
裁决链的输入是一张"审批工单"——ApprovalAction 枚举把七种需要审批的动作统一建模:跑命令、写 stdin、execve、打补丁、调 MCP 工具、访问网络、请求权限提升。每个变体都携带了弹窗展示所需的全部上下文(命令、cwd、环境 id)和裁决后落盘所需的策略修订提案:
📄 codex-rs/core/src/tools/approvals.rs(第 67-150 行,省略 WriteStdin/Execve/ApplyPatch/McpToolCall 变体)
pub(crate) enum ApprovalAction { // 所有需要审批的动作统一建模成这个枚举——一张"审批工单" ExecCommand { // 跑 shell 命令:带环境、沙箱权限、可选的 execpolicy 修订提案 id: String, // 本次工具调用的唯一 id:弹窗/遥测都靠它关联 environment_id: String, // 在哪个执行环境里跑(本地/远程容器),审批记忆按环境隔离 command: Vec<String>, // 命令参数数组:弹窗展示 + Guardian 审查的对象 cwd: PathUri, // 工作目录:弹窗展示 + Guardian 判断路径风险 sandbox_permissions: SandboxPermissions, // 本次申请的沙箱权限级别(读/写/联网) additional_permissions: Option<AdditionalPermissionProfile>, // 本次额外申请的权限(如出网) justification: Option<String>, // 模型给出的"为什么需要这个权限",会显示在弹窗里 tty: bool, // 是否需要 TTY:交互式命令审批时要提示用户 proposed_execpolicy_amendment: Option<ExecPolicyAmendment>, // 用户可顺手把这类命令永久放行 }, NetworkAccess { // 网络访问审批:host/port/协议齐全,供 Guardian 和 UI 展示目标 id: String, // 本次工具调用的唯一 id:弹窗/遥测都靠它关联 turn_id: String, // 所属 turn:遥测与事件流按它归组 environment_id: String, // 在哪个执行环境里跑(本地/远程容器),审批记忆按环境隔离 target: String, // "https://api.example.com:443" 这种人类可读的目标串 host: String, // 目标域名:审批键的核心字段 protocol: NetworkApprovalProtocol, // http/https/socks5:同域名不同协议分开审批 port: u16, // 目标端口:与 host+protocol 一起构成唯一审批键 trigger: Option<GuardianNetworkAccessTrigger>, // 是哪个命令触发了这次网络访问 command: Vec<String>, // 命令参数数组:弹窗展示 + Guardian 审查的对象 cwd: AbsolutePathBuf, // 命令的工作目录:Guardian 评估风险时参考 },}为什么这样设计?注意 justification 和 proposed_execpolicy_amendment 这两个字段:前者让弹窗不只是"允许/拒绝"二选一,而是把模型的意图摆出来供人判断;后者把"永久放行"做成一个显式的策略修订提案——用户点的是"批准这条规则变更"而不是模糊的"记住我的选择"。审批从一次性动作升级成了策略管理入口。
裁决链的输出则是 ReviewDecision(定义在 protocol crate,跨进程共享),一共八种:
📄 codex-rs/protocol/src/protocol.rs(第 4089-4124 行)
pub enum ReviewDecision { // 审批的最终裁决:8 种,覆盖"放行/永久放行/改策略/拒绝/超时/中止" Approved, // 本次放行,下次同类请求还要再问 ApprovedExecpolicyAmendment { // 放行 + 把 execpolicy 修订落盘,以后匹配的命令免审 proposed_execpolicy_amendment: ExecPolicyAmendment, // 要落盘的命令策略修订:以后匹配的命令免审 }, ApprovedForSession, // 本会话内同类请求自动放行(进 session 缓存) ApprovedMcpPolicyAmendment, // MCP 工具:跨会话的策略级放行 NetworkPolicyAmendment { // 网络审批专属:持久化一条 allow/deny 域名规则 network_policy_amendment: NetworkPolicyAmendment, // 要落盘的域名规则(allow/deny):跨会话生效 }, Denied { rejection: String }, // 拒绝但会话继续,模型可以换个办法 TimedOut, // Guardian 自动审查超时——按拒绝处理并给出专门提示 Abort, // 用户中止:整个 turn 停掉,等下一条指令}为什么这样设计?八种裁决把"放行的粒度"和"拒绝的烈度"都拆开了:放行分本次 / 本会话 / 永久策略三档,对应三种不同的持久化动作;拒绝分 Denied(继续干活)和 Abort(整个 turn 停掉)两档——用户说"不"的时候,系统必须知道该退到哪一步。而 TimedOut 单独成项而不是归入 Denied,是因为它的文案和后续行为(提示用户 Guardian 超时)完全不同。
💡 一句话记忆:放行有三档粒度(本次/会话/永久),拒绝有两档烈度(继续/中止)——审批系统不是"允许或禁止"的开关,而是一台把人的意图翻译成不同持久化动作的状态机。
三、"始终允许"不是魔法:会话级审批缓存
用户在弹窗里点"Always allow(本会话始终允许)"之后,同类请求就不再打扰了。这个能力由 core/src/tools/sandboxing.rs 里的 with_cached_approval 提供——一个通用的"先查缓存、未命中才问人"包装器:
📄 codex-rs/core/src/tools/sandboxing.rs(第 70-116 行)
pub(crate) async fn with_cached_approval<K, F, Fut>( // "始终允许"缓存包装器:先查 session 级审批记忆,命中就不弹窗 services: &SessionServices, // 会话服务:审批缓存和遥测都挂在它上面 tool_name: &str, // 工具名只用于埋点计数 keys: Vec<K>, // 一个请求可能有多个 key(apply_patch 一次改多文件) fetch: F, // 真正去问 Guardian/用户的闭包,缓存未命中才执行) -> ReviewDecision // 返回最终裁决(可能来自缓存,也可能刚问过人)where // 泛型约束:key 可序列化、fetch 是异步闭包 K: Serialize, // key 序列化成 JSON 字符串当 map 的键——对任意类型通用的缓存 F: FnOnce() -> Fut, // fetch=真正去问 Guardian/用户的闭包,只在缓存未命中时执行 Fut: Future<Output = ReviewDecision>, // fetch 的返回必须是审批裁决{ if keys.is_empty() { // 防御:没有 key 就跳过缓存直接问,避免空集合 all() 恒真误命中 return fetch().await; // 无 key 可缓存 → 直接走完整审批链 } let already_approved = { // 全部 key 都"本会话已放行"才算命中——部分命中也要重新问 let store = services.tool_approvals.lock().await; // 拿会话级审批缓存(只读锁) keys.iter() // 逐个 key 查缓存 .all(|key| matches!(store.get(key), Some(ReviewDecision::ApprovedForSession))) // 全部 key 都"本会话已放行"才算命中——部分命中也要重新问 }; if already_approved { // 命中 → 直接返回,用户完全无感 return ReviewDecision::ApprovedForSession; // 缓存命中 → 直接返回,用户完全无感 } let decision = fetch().await; // 未命中 → 走完整审批链(Hooks→Guardian→User) services.session_telemetry.counter( // 埋点:每次真实走到弹窗都计数,按工具名+裁决结果分维度 "codex.approval.requested", /*inc*/ 1, // 计数器名:每次真实走到审批链都记一笔 &[("tool", tool_name), ("approved", decision.to_opaque_string())], // 维度:工具名+裁决结果,便于统计弹窗频率 ); if matches!(decision, ReviewDecision::ApprovedForSession) { // 用户选了"始终允许" → 每个 key 单独入库 let mut store = services.tool_approvals.lock().await; // 拿写锁:把"始终允许"记进缓存 for key in keys { // 逐个 key 入库(apply_patch 多文件时每个都单独记) store.put(key, ReviewDecision::ApprovedForSession); // 存单 key:下次只碰其中一个文件也能命中 } } decision // 返回最终裁决:Approved/ForSession/Denied…}为什么这样设计?三个细节。其一,命中条件是 all() 而不是 any():apply_patch 一次改 5 个文件,如果只批准过其中 4 个,第 5 个没批过——必须重新问,否则等于偷偷扩大了授权范围。其二,写入时按单 key 拆分存储:下次请求哪怕只碰其中一个文件也能命中缓存,"始终允许"的语义是"这类操作我信任"而不是"那次完整请求我信任"。其三,只有 ApprovedForSession 才写缓存——Approved(本次放行)和 Denied 都不留痕,保证"这次特批"不会变成"永久授权"。缓存本身是 HashMap<String, ReviewDecision>(key 的 JSON 序列化),会话结束即销毁,天然不跨会话。
四、网络审批入口:begin_network_approval
命令审批是"事前问一次",网络审批则是"全程盯着"——因为一条 curl 跑起来之后可能访问任意多个域名。Codex 的做法(core/src/tools/network_approval.rs,1237 行):在命令启动前派生一个执行级网络代理接管它的出口流量,策略没覆盖到的请求实时回调进审批流。入口函数 begin_network_approval 的收尾段把三样东西绑定在一起:
📄 codex-rs/core/src/tools/network_approval.rs(第 1186-1223 行)
let registration_id = Uuid::new_v4().to_string(); // 给这次"执行级网络审批"发一个唯一注册号let attribution_token = Uuid::new_v4().to_string(); // 归因 token:代理里的每个请求都能反查到是哪次调用发起的let execution_proxy = network // 从会话级代理派生出"本次命令专属"的执行代理 .for_execution( // 从会话级代理派生"本次命令专属"的执行代理 &environment_id, // 绑定环境:不同环境的网络策略互相隔离 ®istration_id, // 绑定注册号:代理拦截请求时靠它找回主人调用 attribution_token, // 归因 token:每个出站连接都能反查到发起它的工具调用 environment_policy, // 环境网络策略(域名白名单等)挂到执行代理上 fallback_policy_decider, // 策略没覆盖到的请求 → 回调进审批流,而不是默认放行 ) .map_err(|err| { // 派生失败 = 本次命令的网络出口建不起来 → 直接拒绝执行,fail loud ToolError::Codex(codex_protocol::error::CodexErr::Io(io::Error::other( // 包装成 IO 类错误:网络出口建不起来属于基础设施故障 format!("failed to create execution-scoped network proxy: {err}"), // 错误信息带上根因,方便排查 ))) })?; // ? 提前返回错误——代理没建好,命令绝不带病启动let cancellation_token = CancellationToken::new(); // 审批被拒/超时时的取消信号,会传给执行进程session.services.network_approval.register_call(ActiveNetworkApprovalCall { // 登记在册:代理拦截到请求时靠它找回"主人调用" registration_id: registration_id.clone(), // 注册号:后续所有网络请求都挂在这个 id 下 turn_id: turn.sub_id.clone(), // 所属 turn:审批事件按 turn 归组展示 trigger, // 触发这次网络审批的工具调用上下文(命令、cwd) tool_name, // 触发网络访问的工具名(unified_exec/mcp…):遥测维度 command, // 原始命令文本:弹窗里让用户知道"谁在联网" environment_id, // 环境 id:审批键的一部分,跨环境不共享记忆 permission_profile, // Managed/Unmanaged 决定能否走"问用户"流程 environments: environments.clone(), // 快照整个执行环境:Guardian 审查时按原环境评估 cancellation_token: cancellation_token.clone(), // 取消信号:审批被拒时用来掐断正在跑的请求}).await; // 登记完成:代理拦截到白名单外请求时就能找回这次调用的全部上下文Ok(Some(ActiveNetworkApproval { // 返回给 orchestrator:命令带着这个代理跑,网络出口被接管 registration_id: Some(registration_id), // 带注册号返回:命令结束后用它结算审批结果 cancellation_token, // 取消句柄交给 orchestrator:拒绝时由它触发进程取消 execution_proxy, // 执行代理本体:命令的出站流量全部穿过它}))为什么这样设计?核心是"代理即边界":命令不是"被允许联网后自己随便连",而是它的每一个出站连接都必须穿过这个执行级代理——代理拿着 environment_policy(域名白名单)逐请求裁决,白名单外的请求走 fallback_policy_decider 回调进审批流。而 register_call 登记的是"这次调用"的完整上下文(命令、环境、权限 profile),因为审批决策必须知道是谁在请求:同一个域名,npm install 触发和 curl | sh 触发的风险完全不同。最后,cancellation_token 把"审批拒绝"和"进程取消"连成一根线——用户说"不"的那一刻,正在跑的请求会被主动掐断。
五、handle_inline_policy_request:一个网络请求的裁决路径
代理拦截到一个白名单外的请求后,会回调 NetworkApprovalService::handle_inline_policy_request(约 400 行,本系列最长的单函数之一)。它的前半段是"快速通道"——用两个会话级集合做 O(1) 裁决:
📄 codex-rs/core/src/tools/network_approval.rs(第 659-674 行)
let key = HostApprovalKey::from_request(&request, protocol, environment_id.clone()); // host 小写化 + 协议/端口 → 唯一审批键{ let _commit_guard = self.session_policy_commit_lock.lock().await; // 先拿"策略提交锁":查缓存和改缓存必须同序,防止持久化与内存状态错位 { let denied_hosts = self.session_denied_hosts.lock().await; // 先查会话级拒绝记忆(只读锁) if denied_hosts.contains(&key) { // 本会话被拒过的 host → 直接硬拒,不再问(拒绝是强信号) return NetworkDecision::deny(REASON_NOT_ALLOWED); // 本会话拒过 → 硬拒,不再弹窗(拒绝是强信号) } } let approved_hosts = self.session_approved_hosts.lock().await; // 再查会话级放行记忆 if approved_hosts.contains(&key) { // 本会话已放行过 → 秒过,零弹窗 return NetworkDecision::Allow; // 本会话已放行过 → 秒过,零弹窗 }}为什么这样设计?注意deny 检查在 allow 检查之前:同一个 host,用户先拒过、后来某个流程又想放行——拒绝记忆优先。这是安全系统的经典原则:否定是强信号,肯定只是弱授权("我允许你访问 A"不等于"我撤销了之前对 A 的禁令")。而 session_policy_commit_lock 这把锁的存在,是因为后面还有"持久化策略修订 + 更新内存缓存"的组合操作——两者必须原子地按同一顺序提交,否则会出现"磁盘上已放行、内存里还拒绝"的撕裂状态。
快速通道没命中,请求就进入真正的审批:构造 ApprovalAction::NetworkAccess 工单 → 走第一节的三级裁决链(Hooks→Guardian→User)→ 把人的裁决翻译回代理能执行的放行语义:
📄 codex-rs/core/src/tools/network_approval.rs(第 893-920 行,省略拒绝时的 outcome 记账细节)
let resolved = match approval_decision { // 把"人的裁决"翻译成代理能执行的放行语义(AllowOnce/AllowForSession/Deny) ReviewDecision::Approved | ReviewDecision::ApprovedExecpolicyAmendment { .. } => { // "本次放行"或"命令策略级放行" if self.session_denied_hosts.lock().await.contains(&key) { // 会话级拒绝记录优先:这次批了,deny 记忆也不作废 // …(把 policy_denial_message 记进 call outcome)… PendingApprovalDecision::Deny // deny 记忆优先:这次批了,会话级拒绝也不作废 } else { // 没有 deny 记忆 → 正常放行 PendingApprovalDecision::AllowOnce // "本次放行":只开这一次,下次同 host 还要再问 } } ReviewDecision::ApprovedForSession => { // "本会话始终允许" if self.session_denied_hosts.lock().await.contains(&key) { // 同样先查 deny 记忆 // …(同上:deny 记忆优先)… PendingApprovalDecision::Deny // deny 记忆优先:这次批了,会话级拒绝也不作废 } else { // 没有 deny 记忆 → 正常放行 self.session_approved_hosts.lock().await.insert(key.clone()); // "始终允许"→ 写入会话白名单,同 host 后续秒过 PendingApprovalDecision::AllowForSession // 写入白名单后放行:同 host 后续请求秒过 } }为什么这样设计?这里有一个容易忽略的不对称:ApprovedForSession 会写 session_approved_hosts,但拒绝只写 outcome、不写 approved 集合——因为 deny 的"记忆"由更上层的 session_denied_hosts(在审批入口统一维护)负责,而这里只负责把裁决翻译成代理动作。另外注意 NetworkPolicyAmendment(Allow) 分支(紧随其后):用户选择"永久允许这个域名"时,会先调 persist_network_policy_amendment 把规则落盘,成功后才移除 deny 记录并写入会话白名单——持久化失败则降级为警告事件,绝不假装放行。顺序就是安全边界。
六、fail-closed 兜底:PendingHostApprovalOwner
并发场景下,同一个 host 可能有多个请求同时到达(比如 npm install 并发拉取多个包)。Codex 用"pending 审批 + owner 代管"去重:第一个请求成为 owner 走完整审批,其余请求挂在同一个 PendingHostApproval(SyncOnceLock + Notify)上等待。而 owner 的生命周期由 Rust 的 Drop trait 兜底——无论审批流程怎么异常退出,等待者都不会被无限挂起:
📄 codex-rs/core/src/tools/network_approval.rs(第 348-376 行,中间省略断连超时记账)
impl Drop for PendingHostApprovalOwner<'_> { // owner=审批代管人:它异常消失时,必须让所有等待者 fail-closed fn drop(&mut self) { // owner 被销毁(panic/取消/超时)时自动执行——异常路径的兜底 if !self.completed { // 正常 complete() 过就跳过——这里只兜底 panic/取消/超时等异常路径 if self.cancellation.as_ref().and_then(NetworkRequestCancellation::reason) // 取出取消原因枚举 == Some(NetworkRequestCancellationReason::ProcessFinished) { // 进程自然结束触发的清理不算"审查失败":直接发布当前裁决,不额外记账 self.publish_and_remove(self.decision_on_drop); // 正常清理:发布当前裁决并摘除 pending,唤醒等待者 return; // 进程自然结束不算审查失败 → 不额外记账直接返回 } // …(中间省略:断连超时记账——把"审批未完成请求就断开"写进 call outcome)… self.cancel_execution_if_denied(self.decision_on_drop); // 默认裁决是 Deny → 顺手取消执行进程,别让它继续跑 self.publish_and_remove(self.decision_on_drop); // 唤醒等待者并摘除这一代 pending:新请求不能挂到旧审批上 } }}为什么这样设计?这是整篇源码里最"Rust"的一段。owner 结构体的默认裁决是 Deny(构造时写死),所以任何异常路径——panic、任务被取消、审批超时——只要 owner 被 drop,等待者拿到的就是拒绝。安全系统的默认值必须是"关":宁可误杀一个合法请求,也不能让一个未审请求溜过去。而 publish_and_remove 里先摘除 pending、再唤醒等待者的顺序(配合 Arc::ptr_eq 校验"还是我这一代"),保证并发重试的新请求不会挂到一个已经完结的旧审批上——这是典型的"代数(generation)防悬挂"手法。用 Drop trait 而不是显式 cleanup 调用,是因为 Rust 能保证异常路径也必然执行,这正是 fail-closed 需要的语义。
💡 本讲安全哲学三连:① deny 优先于 allow(否定是强信号);② 持久化成功才生效(失败降级为警告,绝不假装放行);③ 异常路径默认拒绝(Drop 兜底 fail-closed)。三条合起来就是:任何不确定,都朝"不放行"的方向坍缩。
七、数据流:一个网络请求的完整审批路径
网络请求审批全路径(handle_inline_policy_request)
① 出口接管:begin_network_approval → for_execution
命令启动前派生执行级代理,register_call 登记调用上下文(命令/环境/profile)。
▼
② 逐请求裁决:代理 policy decider
每个出站连接按 environment_policy(域名白名单)判断;命中即放行,未命中进审批流。
▼
③ 快速通道:denied_hosts → approved_hosts
会话级拒绝记忆优先硬拒;已放行 host 秒过。两查都在 session_policy_commit_lock 内完成。
▼
④ 去重等待:get_or_create_pending_approval
同 host 并发请求共享一个 pending;非 owner 挂 Notify 等裁决,owner 走完整审批链。
▼
⑤ 三级裁决:Hooks → Guardian → User
🔹 AskForApproval=Never 或 profile 不允许 → 直接硬拒🔹 Guardian 超时/弃权 → 落用户弹窗;owner 异常消失 → Drop fail-closed
▼
⑥ 裁决落地:PendingApprovalDecision
🔹 Approved → AllowOnce(只开一次)🔹 ForSession → 写会话白名单;Amendment(Allow) → 先落盘策略再移除 deny 记录
整条路径上,任何一步的失败都朝"拒绝"方向坍缩:代理建不起来 → 命令不跑;审批超时 → 按拒绝处理;owner 异常消失 → Drop 兜底 Deny。这就是 Codex 网络审批的可审计性来源——不存在"没审到就默认放行"的路径。
八、小结:审批系统是一台确定性机器
把本讲的源码串起来看,Codex 的 Tool Approvals 其实回答了三个问题:谁来裁决——Hooks→Guardian→User 三级短路链,确定性从高到低排列;裁决记多久——本次/会话/永久三档粒度,分别对应"不留痕 / session 缓存 / 策略落盘"三种持久化动作;异常怎么办——deny 优先、持久化成功才生效、Drop fail-closed,所有不确定都朝拒绝方向坍缩。
而网络审批是这套机制最复杂的实例:它把"事前问一次"升级成"全程代理 + 逐请求裁决 + 并发去重 + 会话级 host 记忆",用 NetworkApprovalService(1237 行)把状态机写得滴水不漏。理解了这一讲,你就掌握了 Codex 安全模型里"人、策略、自动审查"三方博弈的完整规则。
下一讲(第 20 讲)我们看 Parallel 工具并行执行与编排:当多个工具调用同时发起时,Codex 怎么决定哪些能并发、哪些必须串行。
📚 系列导航
← 第 18 讲:Apply Patch 补丁应用——先干跑,再决定放不放行
→ 第 20 讲:Parallel 工具并行执行与编排
关注公众号「AI技术推荐官」获取更多源码解析内容