夜雨聆风学习资料网

ARTICLE · 1167408

Codex 源码-Tool Approvals 审批流与网络策略

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 超时)完全不同。

裁决
放行粒度 / 拒绝烈度
下游动作
Approved
本次放行
执行动作,不写任何缓存
ApprovedForSession
本会话放行
写入 tool_approvals / session_approved_hosts 缓存
ApprovedExecpolicyAmendment
永久放行(命令)
execpolicy 修订落盘,后续匹配免审
NetworkPolicyAmendment(Allow)
永久放行(域名)
persist_network_policy_amendment + 移除 deny 记录
Denied
拒绝,会话继续
ToolError::Rejected(理由),模型可换方案重试
TimedOut
Guardian 超时
按拒绝处理,文案提示自动审查未完成
Abort
中止整个 turn
CodexErr::TurnAborted,等用户下一条指令
ApprovedMcpPolicyAmendment
永久放行(MCP)
跨会话 MCP 策略放行,不进普通缓存路径

💡 一句话记忆:放行有三档粒度(本次/会话/永久),拒绝有两档烈度(继续/中止)——审批系统不是"允许或禁止"的开关,而是一台把人的意图翻译成不同持久化动作的状态机。

三、"始终允许"不是魔法:会话级审批缓存

用户在弹窗里点"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, // 绑定环境:不同环境的网络策略互相隔离        &registration_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技术推荐官」获取更多源码解析内容

相关学习资料