夜雨聆风学习资料网

ARTICLE · 1215476

Codex 源码-Guardian 安全审查 v1+v2

Codex 源码-Guardian 安全审查 v1+v2

Codex 源码解析系列

第 26 讲:Guardian 安全审查 v1+v2

基于 OpenAI Codex 源码 · 2026-10-20

💡 本讲一句话:Guardian 是 Codex 里"执行前审查动作"的第二大脑——v1 每次审批都跑一轮完整 LLM 评审(90 秒截止、最多重试 3 次、失败即拒绝),v2 再加一条异步打分快车道:低风险动作直接放行,不唤醒评审模型。本讲把决策路由、会话池复用、提示词预算、输出契约和熔断器逐行拆开。

一、Guardian 是什么:执行前的"第二大脑"

前几讲我们拆过沙箱(bwrap/Landlock/Seatbelt)和 ExecPolicy——它们管的是"能不能跑"。但还有一类风险是静态规则覆盖不了的:一条命令语法完全合法、权限也在范围内,意图却可能是数据外泄或破坏性操作。这类判断需要理解上下文,只能交给模型来做。

Guardian 就是干这件事的:当 agent 提出一个需要审批的动作(执行命令、写 stdin、打补丁、访问网络、调 MCP 工具)时,Codex 会另起一个隔离的评审会话,把"父会话的对话记录 + 这个动作的完整 JSON + 安全策略"喂给模型,让它输出结构化的 allow/deny 裁决。源码分布在三个 crate:

🔹 core/src/guardian/(约 9700 行):宿主侧——审批请求模型、决策路由、评审会话池的宿主适配、提示词组装🔹 ext/guardian-reviewer/(约 3500 行):与宿主解耦的审查策略——重试预算、截止时间、熔断器、输出契约解析🔹 ext/guardian-v2/(约 4600 行):异步打分器(LunaSampler),给快车道供能

先看所有审批请求的统一模型——GuardianApprovalRequest(core/src/guardian/approval_request.rs,第 18-85 行):

📄 codex-rs/core/src/guardian/approval_request.rs (第 18-85 行)

pub(crate) enum GuardianApprovalRequest { // 统一审批请求模型:所有需要审查的动作都归一化成这七种    ExecCommand { // shell 命令执行(unified exec / shell 工具)        id: String, // 审批 ID,用于去重、追踪和事件关联        environment_id: String, // 命令跑在哪个执行环境(本地/远程/cloud),评审要判断"哪台机器"        command: Vec<String>, // shlex 切分后的 argv——评审看到的是精确参数,不是拼接字符串        cwd: PathUri, // 工作目录:决定这条命令属于哪个仓库、哪个项目        sandbox_permissions: crate::sandboxing::SandboxPermissions, // 请求的沙箱权限级别(只读/读写/提权)        additional_permissions: Option<AdditionalPermissionProfile>, // 基线之外的额外权限(如网络出口),是风险放大器        justification: Option<String>, // agent 自述"为什么需要这条命令"——评审判断意图的关键证据        tty: bool, // 是否需要 TTY:影响能否后台运行,也暗示交互式操作    },    WriteStdin { /* ... */ }, // 向交互进程写 stdin(如回答密码提示)——审查的是"输入内容+目标进程"    #[cfg(unix)]    Execve { /* ... */ }, // execve 系统调用拦截:抓住绕过 shell 解析直接起进程的命令    ApplyPatch { /* ... */ }, // 文件补丁应用:files + 完整 patch 文本就是审查对象    NetworkAccess { /* ... */ }, // 网络出口请求:host/port/protocol,外加 trigger(是哪条命令触发的)    McpToolCall { /* ... */ }, // MCP 工具调用:server/tool_name/arguments + connector 元数据与注解    RequestPermissions { /* ... */ }, // agent 主动申请扩大权限——七种里风险最高的一类}

为什么这样设计:把七类动作收敛成一个枚举,而不是每类工具各写一套审批逻辑——评审侧只需要一个 guardian_approval_request_to_json() 就能把任何动作序列化成"待审 JSON",策略、提示词、输出契约全部复用。注意每个变体都刻意携带证据字段(justification、trigger、sandbox_permissions):Guardian 的评审质量取决于它能看到多少上下文,这些字段就是"卷宗"的一部分。#[cfg(unix)] 门控 Execve 则说明拦截点跟平台能力绑定——Windows 上没有这条路径。

📌 请求模型的三个设计点:① 归一化:七类动作一个枚举,评审链路只写一遍;② 证据内嵌:justification/trigger/sandbox_permissions 随请求走,评审不靠猜;③ 平台门控:Execve 仅 unix,审批面跟系统能力对齐。

二、决策路由:谁来决定"要不要审"

入口在 core/src/tools/approvals.rs 的 request_guardian_approval()(第 560-665 行):它把工具层的 ApprovalAction 转成 ReviewAction,然后调 decide_approval()。真正有意思的是 core/src/guardian/decision.rs(第 64-101 行)里两个布尔量的推导——require_guardian(必须走 Guardian,不能问用户)和 require_fresh_review(必须重新审,不能吃缓存证据):

📄 codex-rs/core/src/guardian/decision.rs (第 64-101 行)

let model_requires_review = // 当前模型是否在"强制自动审查"名单里(管理员策略层下发)    requirements.auto_review_required_for_model(&context.model_info.slug);let require_guardian = options.require_guardian // 调用方显式要求走 Guardian(strict auto review 模式)    || model_requires_review // 或者模型本身被强制审查    || requirements.approvals_reviewer // 或管理员禁止"用户手动审批"→ 只能交给 Guardian,没有退路        .can_set(&codex_protocol::config_types::ApprovalsReviewer::User).is_err();let require_fresh_review = options.require_synchronous_review // 调用方要求全新评审(不信任任何缓存证据)    || model_requires_review && !turn.config.features.enabled(codex_features::Feature::GuardianV2) // v1 模型没有异步打分器,每次都必须完整同步审    || options.external_cancel.as_ref().is_some_and(CancellationToken::is_cancelled) // 外部取消信号已触发→状态可能变了,必须重审确认    || reasons.retry.is_some() // 这是被拒后的重试——证据/意图可能变化,缓存裁决不可信    || matches!(&request.request, Ok(GuardianApprovalRequest::ExecCommand { sandbox_permissions, .. }) // 请求提权(escalated permissions)的命令永远新鲜审查        if sandbox_permissions.requires_escalated_permissions());let full_access = context.environments().has_full_access( // 环境是"完全访问"(无沙箱限制)→ 根本不需要评审    context.approval_policy, &turn.config.permissions.effective_permission_profile());if full_access { return Some(if let Err(decision) = request.validate(&context) { decision } // 全权限:先做合法性校验,不合法直接拒绝        else if options.external_cancel.as_ref().is_some_and(CancellationToken::is_cancelled) { ReviewDecision::Abort } // 已取消→中止        else { ReviewDecision::Approved }); } // 否则直接批准——省掉一次完整的模型调用

为什么这样设计:这两个布尔量把"策略来源"拆得很干净:require_guardian 回答"能不能不问用户"(管理员强制、模型名单、禁止手动审批三条路),require_fresh_review 回答"能不能用缓存结论"。注意 model_requires_review && !GuardianV2 这一条——v2 的异步打分器可以复用"最近一次评分",所以 v2 模型不必每次 fresh;而 v1 没有这个能力,只能全量审。full_access 快速路径则体现了成本意识:无沙箱环境里用户已经给了完全信任,再跑一轮 LLM 评审纯属浪费。

💡 两个布尔量的分工:🔹 require_guardian = "必须审"(策略维度:谁有权跳过评审)🔹 require_fresh_review = "必须重审"(新鲜度维度:缓存证据是否还有效)两者正交——可以"必须审但允许用缓存",也可以"不强制审但这次要新鲜的"。

三、评审会话池:一个 trunk,多个 fork

每次审批都从零启动一个 LLM 会话太贵(冷启动 + prompt cache 全失效)。ext/guardian-reviewer/src/pool.rs(第 81-288 行)的 ReviewerPool 给每个父线程维护一个常驻评审会话(trunk):空闲且上下文没变就复用;忙了或配置变了就 fork 一个临时会话。选择逻辑的核心片段:

📄 codex-rs/ext/guardian-reviewer/src/pool.rs (第 199-263 行)

if let Some(trunk) = state.trunk.as_ref() // 已有常驻评审会话    && trunk.session.context() != &context // 但它的上下文键与本次请求不一致(模型/权限/历史版本变了)    && trunk.review_lock.try_acquire().is_ok() // 且它当前空闲(没有评审在跑)    && let Some(stale) = state.trunk.take() { // → 判定为"过期 trunk",摘下来    shutdown_in_background(Arc::clone(&stale.session)); // 后台关闭旧会话:清理不阻塞本次审批的进度}// ……trunk 为空则现场 spawn 一个 TrunkNew(带 deadline + 取消令牌)……if trunk.session.context() != &context { // 上下文不一致但 trunk 正忙→不能动它    return Box::pin(self.review_ephemeral(&request, context, /*snapshot*/ None)).await; // fork 一个临时会话独立跑本次评审}let guard = match trunk.review_lock.try_acquire() { // 尝试拿 trunk 的唯一许可(Semaphore=1,保证串行)    Ok(guard) => guard, // 空闲→复用常驻会话:prompt cache key 稳定,token 成本最低    Err(_) => { return Box::pin(self.review_ephemeral( // 忙→从最后一次提交的快照 fork        &request, context, trunk.session.snapshot().await, // snapshot() 拿到已提交上下文,并行评审互不污染    )).await; }};

为什么这样设计:trunk 复用的收益是 prompt cache key 稳定——同一个评审会话连续追加多轮审批,前缀 token 全部命中缓存(review_session.rs 里甚至有专门的 prompt_cache_key_override_for_review_session() 生成 "guardian:{parent_thread_id}")。而复用的前提是上下文键相等:GuardianReviewSessionReuseKey(review_session.rs 第 205-234 行)把模型、权限、指令、MCP 服务器、父历史版本号等二十多个字段全部纳入比较——任何一个变了,旧会话里的"评审记忆"就可能基于过期授权,必须重建。trunk 忙时不排队而是 fork 快照,保证并行审批互不阻塞;临时会话跑完即弃(EphemeralCleanup 的 Drop 守卫兜底清理,即使 panic 也不会泄漏会话)。

四、评审提示词:Full/Delta 转录游标

复用 trunk 之后,每次审批还要决定"喂多少历史"。core/src/guardian/prompt.rs(第 47-58、189-223 行)用一个转录游标解决:记住评审已经看到过多少条对话记录,下次只发增量——但前提是父会话的历史版本没变:

📄 codex-rs/core/src/guardian/prompt.rs (第 47-58、189-202 行)

/// 指向 guardian 已经评审过的转录末尾;保存的计数只在父历史版本仍匹配时可复用pub(crate) struct GuardianTranscriptCursor { // 转录游标:增量评审的"书签"    pub(crate) parent_history_version: u64, // 父会话历史版本号——回滚/压缩后旧游标立即失效    pub(crate) transcript_entry_count: usize, // 已评审过的条目数,下次从这里继续发}pub(crate) enum GuardianPromptMode { // 两种提示词形态:全量 / 增量    Full, // 首次评审或游标失效→发完整转录    Delta { cursor: GuardianTranscriptCursor }, // 复用 trunk→只发新增条目}let prompt_shape = match mode { // 运行时再校验一次游标是否仍然有效    GuardianPromptMode::Full => GuardianPromptShape::Full, // 全量模式直接通过    GuardianPromptMode::Delta { cursor } => {        if cursor.parent_history_version == transcript_cursor.parent_history_version // 父历史版本没变(没有回滚/压缩)            && cursor.transcript_entry_count <= transcript_cursor.transcript_entry_count // 且游标位置不超前于当前转录长度        { GuardianPromptShape::Delta { already_seen_entry_count: cursor.transcript_entry_count } } // → 增量:跳过已看过的条目,省 token        else { GuardianPromptShape::Full } // 任一条件不满足→退回全量,宁可多花 token 也不能漏证据    }};

为什么这样设计:评审会话是复用的,但父会话的历史随时可能变化(用户回滚、上下文压缩)。游标把"已评审到哪"变成可验证的状态:parent_history_version 一变,旧书签作废,自动降级为 Full——正确性优先于省 token。转录内容本身由 guardian-context crate 的 ContextProfile::synchronous() 渲染,每条工具调用/结果都有独立 token 上限(mod.rs 里 GUARDIAN_MAX_TOOL_ENTRY_TOKENS、Node REPL 证据单独放宽到 6000 token),防止一条超长输出撑爆评审上下文。

评审的"法条"来自 core/assets/guardian/policy.md(第 1-65 行),按风险类别给出 allow/deny 规则,节选:

📜 policy.md 风险分类(原文规则,中文标注):🔹 Data Exfiltration:把敏感数据发往未受信目的地 = high;"授权创建内容 ≠ 授权外传内容"——payload 必须追溯到原始数据来源🔹 Credential Probing:常规认证失败后去浏览器 profile/日志里挖凭据执行动作 = high;正常 auth-service 流程不算🔹 Persistent Security Weakening:持久性放宽安全设置(权限篡改、过宽访问)= high;为任务临时关掉窄范围检查通常只是 medium🔹 Destructive Actions:不可恢复的删除/生产破坏 = high;"不要假设用户有版本控制"——必要时用只读命令先确认🔹 通用原则:"Prior Guardian decisions are context, not precedent"(历史裁决是上下文,不是判例)

五、评审会话的"锁死配置"

评审模型本身也是一个 Codex agent——如果它继承了父会话的全部能力,就可能出现"评审者发起新审批""评审者调用 MCP 工具改状态"的递归灾难。ext/guardian-reviewer/src/settings.rs(第 44-77 行)用一份 override 清单把它锁死:

📄 codex-rs/ext/guardian-reviewer/src/settings.rs (第 44-77 行)

pub fn reviewer_config_overrides( // 为评审会话生成"锁死"配置    parent_permissions: &PermissionProfile, model: &str, reasoning_effort: Option<ReasoningEffort>,) -> ReviewerConfigOverrides {    ReviewerConfigOverrides {        request_max_retries: 1, stream_max_retries: 1, // HTTP 层只重试 1 次——真正的重试预算由外层 run_with_retry 统一管理,避免双重放大        include_skill_instructions: false, use_memories: false, dedicated_memory_tools: false, // 评审者不需要技能/记忆:它是法官不是 agent,少一个能力面就少一类风险        inherit_token_budget: false, notify: None, developer_instructions: None, // 切断所有可能改变行为的旁路(预算继承、通知、开发者指令)        approval_policy: AskForApproval::Never, // 关键一行:评审者自己永远不能触发新审批——从根上杜绝递归审查        permission_profile: read_only_guardian_permission_profile(parent_permissions), // 与父权限求交集再取只读子集:评审只能"看",不能"动"        include_apps_instructions: false, inherit_mcp_servers: false, // 不继承 MCP 服务器→没有外部工具可调(内置只读检查除外)        disabled_features: vec![Feature::Collab, Feature::MultiAgentV2, Feature::GuardianV2, // 逐个关掉"会行动"的特性:多智能体、协作、hooks……            Feature::TokenBudget, Feature::ContextManagement, Feature::CodexHooks,            Feature::Apps, Feature::Plugins, Feature::WebSearchRequest, Feature::WebSearchCached],    }}

为什么这样设计:这是典型的最小权限原则在 agent 架构里的落地。三条防线层层递进:① approval_policy = Never——评审者想发起审批也发不出去(防递归);② 权限 profile 与父会话求交集后取只读子集(intersect_with_read_only(),settings.rs 第 79-85 行)——即使它想写文件也越不过父会话的只读边界;③ MCP/技能/hooks/web search 全部禁用——评审者的"工具面"被压到最小。reviewer_config.rs(第 19-107 行)还负责把 policy.md + 租户策略配置拼成 base_instructions,并标记 provenance=Custom——评审会话的"系统提示词"就是安全策略本身。

六、重试与截止时间预算

评审是一次真实的 LLM 调用,会超时、会被限流、模型输出可能不是合法 JSON。ext/guardian-reviewer/src/lib.rs(第 35-36 行)定下总预算:MAX_REVIEW_ATTEMPTS = 3、REVIEW_TIMEOUT = 90s。retry.rs(第 21-81 行)在同一个 deadline 内做指数退避重试:

📄 codex-rs/ext/guardian-reviewer/src/retry.rs (第 35-68 行)

let mut attempt_count = 1; // 从第 1 次尝试开始loop {    let (outcome, mut analytics_result) = run_attempt(deadline).await; // 执行一次评审 turn(deadline 是全局共享的,不是每次重置)    if attempt_count >= max_attempts || !should_retry_guardian_review(&outcome) { return ...; } // 次数用尽或错误不可重试→立即终止    let retry_at = match &outcome { // 后端若返回 Retry-After,优先尊重它        GuardianReviewOutcome::Error(GuardianReviewError::Session { retry_at, .. }) => *retry_at,        _ => None,    };    if let Some(error) = wait_before_guardian_retry(attempt_count, retry_at, deadline, external_cancel).await { return ...; } // 退避等待;期间被取消/到点则直接返回对应错误    attempt_count += 1;}// wait_before_guardian_retry 里的退避公式(第 63-65 行):let exponential_delay = 200.0 * 2.0_f64.powi(attempt_count.saturating_sub(1) as i32); // 200ms → 400ms → 800ms,指数翻倍let jitter = rand::rng().random_range(0.9..1.1); // ±10% 随机抖动:多个线程同时重试时错开峰值,避免打爆后端let retry_delay = Duration::from_millis((exponential_delay * jitter) as u64);

为什么这样设计:"共享 deadline"是精髓——3 次尝试总共只有 90 秒,而不是每次 90 秒。这保证审批延迟有硬上界(用户不会无限等待),同时退避抖动防止多个父线程的评审在同一时刻冲击同一个后端。

更精细的是"哪些错误值得重试"——retry.rs(第 83-122 行)把错误分成可恢复与不可恢复两类:

📄 codex-rs/ext/guardian-reviewer/src/retry.rs (第 83-122 行,节选)

fn should_retry_guardian_review(outcome: &GuardianReviewOutcome) -> bool { // 核心问题:这个失败"值得再试一次"吗?    match outcome {        GuardianReviewOutcome::Error(GuardianReviewError::Parse { .. }) => true, // JSON 解析失败→多半是模型格式漂移,重试常能救回        GuardianReviewOutcome::Error(GuardianReviewError::Session { error_info: Some(error), .. }) => match error {            CodexErrorInfo::RateLimitExceeded | CodexErrorInfo::ServerOverloaded // 429/503→后端瞬时过载,退避后重试有意义                | CodexErrorInfo::InternalServerError => true, // 5xx 同理:服务端临时故障            CodexErrorInfo::HttpConnectionFailed { http_status_code } | ... // 网络层错误按状态码细分                => matches!(http_status_code, None | Some(408 | 429 | 500..=599)), // 只有超时/限流/服务端错误可重试;4xx 客户端错误重试无用            CodexErrorInfo::ContextWindowExceeded // 上下文超限→重试只会再次超限(需要先压缩)                | CodexErrorInfo::Unauthorized // 认证失败→重试不会改变结果                | ... => false, // 其余策略/参数类错误全部不可重试——快速失败,把时间留给用户决策        },        GuardianReviewOutcome::Completed(_) | ...Timeout | Cancelled => false, // 已有结论或被取消→不再重试    }}

📌 重试哲学的分界线:"后端状态可能自己变好"的错误(限流/过载/超时)→ 退避重试;"输入本身有问题"的错误(上下文超限/认证失败/策略拒绝)→ 立即失败。把不可恢复错误也塞进重试循环,只会白白烧掉 90 秒预算。

七、裁决契约与 fail-closed

评审模型必须输出结构化裁决。ext/guardian-reviewer/src/assessment.rs(第 12-18、80-103 行)定义了契约——注意 risk_level 和 user_authorization 是分离的两个维度,只有 outcome 必填:

📄 codex-rs/ext/guardian-reviewer/src/assessment.rs (第 12-18、80-103 行)

pub struct GuardianAssessment { // 评审者必须产出的结构化裁决    pub risk_level: codex_protocol::protocol::GuardianRiskLevel, // 风险等级 low/medium/high/critical——"动作本身多危险"    pub user_authorization: ..., // 用户授权程度 unknown/low/medium/high——"用户允许了多少",与风险正交    pub outcome: GuardianAssessmentOutcome, // 最终裁决 allow/deny——唯一必填字段    pub rationale: String, // 理由说明:拒绝时会展示给 agent 和用户,也是熔断器计数的依据}// JSON schema(作为 final_output_json_schema 下发):{ "type": "object", "additionalProperties": false, // 禁止额外字段→输出面收敛,解析更稳  "properties": {    "risk_level": { "enum": ["low","medium","high","critical"] }, // 四档风险    "user_authorization": { "enum": ["unknown","low","medium","high"] }, // 授权独立成维:高风险+高授权可以放行,低风险+零授权也要警惕    "outcome": { "enum": ["allow","deny"] }, // 二值裁决    "rationale": { "type":"string" } },  "required": ["outcome"] } // 只强制 outcome——risk/authorization 缺失时有兜底默认值,降低解析失败率

为什么这样设计:"风险"和"授权"拆开是关键洞察——rm -rf 在用户明确授权的自己仓库里是低风险动作,而 curl | sh 即使语法无害也可能是高危外泄。policy.md 的每条 outcome rule 都是"风险 × 授权"矩阵上的一个格子。解析侧(parse_guardian_assessment,第 24-65 行)还做了宽容恢复:模型把 JSON 包在散文里也能从 {...} 中抠出来——"非 JSON 输出仍是评审失败,这只是薄恢复路径"。

裁决之后是 completion.rs(第 58-214 行)的 complete_review()——所有失败路径都fail-closed:

📄 codex-rs/ext/guardian-reviewer/src/completion.rs (第 105-159 行,节选)

GuardianReviewError::Timeout => { // 评审超时→"没审完"不等于"不安全",但也不能放行    return ReviewCompletion { decision: Some(ReviewDecision::TimedOut), ... }; // 返回 TimedOut:提示词告诉 agent 可重试一次或问用户}GuardianReviewError::InputBudgetExceeded | PromptBuild | Session | Parse => { // 其余失败→fail-closed(拒绝)    analytics.terminal_status = GuardianReviewTerminalStatus::FailedClosed; // 遥测里单独标记"失败关闭"——与真实 deny 区分开,便于审计    return ReviewCompletion { decision: Some(ReviewDecision::denied(format!(        "{rationale}\n{REVIEW_FAILURE_INSTRUCTIONS}"))), ... }; // 拒绝 + 附带指令:不要绕过审批检查,修好错误或问用户}

为什么这样设计:安全系统的默认姿态必须是"不确定时拒绝"。但 Codex 很讲究失败语义的区分度:Timeout 单独成一种决策(agent 知道可以重试),fail-closed 的 deny 会附带 REVIEW_FAILURE_INSTRUCTIONS("这是评审失败,不是判定动作不安全——不要绕过检查")。两种文案都刻意防止 agent 把"系统故障"误读为"行为被禁止"而开始疯狂换姿势绕路。

八、熔断器:拒绝堆积就掐断 turn

如果 agent 连续提交危险动作被反复拒绝,说明它可能"钻牛角尖"了。ext/guardian-reviewer/src/circuit_breaker.rs(第 3-97 行)按 turn 计数拒绝,触发阈值就中断整个 turn:

📄 codex-rs/ext/guardian-reviewer/src/circuit_breaker.rs (第 3-7、58-90 行)

const MAX_CONSECUTIVE_CYBER_GUARDIAN_DENIALS_PER_TURN: u32 = 1; // cyber 特化模型:连续 1 次拒绝就熔断——对安全敏感场景零容忍const MAX_CONSECUTIVE_GUARDIAN_DENIALS_PER_TURN: u32 = 3; // 普通模型:同一 turn 内连续 3 次被拒→触发中断const MAX_RECENT_CYBER_AUTO_REVIEW_DENIALS_PER_TURN: u32 = 1; // cyber 模型:滑动窗口里哪怕只有 1 次拒绝也熔断const MAX_RECENT_AUTO_REVIEW_DENIALS_PER_TURN: u32 = 10; // 普通模型:允许"偶尔被拒但整体在推进"——50 次评审内最多容忍 10 次拒绝pub const AUTO_REVIEW_DENIAL_WINDOW_SIZE: usize = 50; // 滑动窗口大小:统计的是"拒绝密度",不是单纯连续次数// record_denial() 核心逻辑(第 58-90 行):turn.consecutive_denials = turn.consecutive_denials.saturating_add(1); // 连续计数 +1(saturating 防溢出)if !turn.interrupt_triggered && (turn.consecutive_denials >= max_consecutive_denials || recent_denials >= max_recent_denials) {    turn.interrupt_triggered = true; // 一次性闩锁:每个 turn 只触发一次中断,避免重复 abort    GuardianRejectionCircuitBreakerAction::InterruptTurn { ... } // 返回"中断当前 turn"动作——宿主执行 abort_turn_if_active(review.rs 第 145-180 行)} else { GuardianRejectionCircuitBreakerAction::Continue } // 未触发→放行继续:agent 还可以尝试其他路径

为什么这样设计:双阈值(连续 + 窗口密度)覆盖了两种失败模式:死循环型(同一危险动作反复提交→连续计数命中)和广撒网型(换着花样试各种危险操作→窗口密度命中)。cyber 模型单独收紧到"1 次即熔断",因为它的任务域本身就是高危的。触发后 review.rs 的 record_guardian_denial()(第 145-180 行)会发 GuardianWarning 事件并调用 abort_turn_if_active()——注意注释特意说明"Guardian 的中断要补发 idle 生命周期事件,用户主动中断不走这条路":两种 abort 的语义不同,遥测和 UI 状态机都要区分。

九、Guardian v2:异步打分快车道

v1 的问题是"每次审批都付一次完整 LLM 评审的钱和延迟"。ext/guardian-v2/src/async_scorer/(约 4600 行)的思路:让一个异步打分器在后台持续给工具调用打风险分,审批时先查缓存——低风险且授权没变就直接放行,只有高风险、分数过期或评分失败才回落到 v1 的同步评审。approval.rs(第 218-260 行)的 cached_evidence() 是快车道入口:

📄 codex-rs/ext/guardian-v2/src/async_scorer/approval.rs (第 218-260 行,节选)

let lag = progress.latest_tool_call.load(Ordering::Acquire) // 线程内最新工具调用的序号    .saturating_sub(latest_scored) // 减去最近一次成功打分的序号→"距上次评分又过了几个调用"    .saturating_sub(progress.wrapper_lag.discount(input.tool_call_id, latest_scored)); // 扣除 wrapper lag:setup/reset 类调用不算真实动作,不该让分数显得过期let (reason, label) = match score { // 按异步打分结果分流(score 来自 LunaSampler 的后台评分)    _ if lag > config.max_tool_call_lag => (GuardianReviewReason::StaleScore, "stale_score"), // 分数太旧→不可信,回落同步评审    None => (GuardianReviewReason::MissingScore, "missing_score"), // 还没有分(打分器未启动/失败)→回落    Some(score) if score < config.review_threshold => { // 风险低于阈值:候选快车道        if scored_authorization.as_ref() != Some(&current) { (GuardianReviewReason::AuthorizationChanged, ...) } // 但打分后的授权上下文变了(用户改了权限/指令)→缓存作废,回落重审        else { record_fast_decision(metrics, "approved", "low_risk"); return Ok(()); } // 低风险+授权未变→直接放行:零模型调用、零延迟    }    Some(score) if score >= config.review_threshold => (GuardianReviewReason::ElevatedRisk, "elevated_risk"), // 高风险→必须走同步评审(v1 全链路)};

为什么这样设计:v2 没有替换 v1,而是在前面加了一层"证据缓存"——decision.rs 里 ApprovalDecision::Allow if !require_fresh_review 分支就是快车道出口(命中时还会打 GuardianV2Event::FastDecision 遥测)。三个失效条件(分数过期 / 授权变化 / 动作超尺寸)对应三类信任边界:时间、上下文、范围。任何一条不满足就降级到 v1 全量评审——快车道是优化,不是安全捷径;安全底线始终由同步评审兜底。

十、v1 vs v2:一张表看懂双引擎

维度
Guardian v1(同步评审)
Guardian v2(异步打分快车道)
触发时机
每次审批请求都进入 decide_approval
先查异步分数,低风险直接放行
模型调用
每次一轮完整 LLM 评审 turn(trunk 复用省缓存)
LunaSampler 后台采样;仅高风险/分数过期回落 v1
延迟预算
90s deadline × ≤3 次尝试(共享截止)
快车道零模型调用;回落时与 v1 相同
证据来源
转录游标 Full/Delta + 动作 JSON + policy.md
异步分数 + 授权快照比对(AuthorizationChanged 失效)
失败语义
fail-closed deny / Timeout 可重试一次
评分缺失/过期→降级 v1,安全底线不变

一次审批的完整数据流

审批决策流(v1+v2)

① 触发:工具审批请求 → request_guardian_approval

tools/approvals.rs 把 ApprovalAction 归一成 ReviewAction,生成 review_id。

▼

② 路由:decide_approval 推导两个布尔量

require_guardian(必须审)× require_fresh_review(必须重审);full_access 直接批准。

▼

③ 快车道(v2):cached_evidence 查异步分数

🔹 低风险+授权未变 → Allow,流程结束🔹 高风险/分数过期/缺失 → 回落同步评审

▼

④ 选池:ReviewerPool.review

trunk 空闲且上下文键匹配→复用;忙/不匹配→fork 临时会话(快照隔离)。

▼

⑤ 组提示词:Full/Delta 转录 + 动作 JSON + policy

游标版本校验通过发增量,否则全量;每条目独立 token 上限。

▼

⑥ 评审 turn:wait_for_guardian_review 事件循环

90s deadline × ≤3 次尝试;指数退避+抖动;只重试可恢复错误。

▼

⑦ 解析裁决:strict JSON {risk, authorization, outcome}

宽容抠 JSON;outcome 必填,risk/authorization 有兜底默认。

▼

⑧ 执行:complete_review fail-closed + 熔断计数

🔹 Approved → 动作放行🔹 Denied → 拒绝+指令;连续 3 次(cyber 模型 1 次)中断整个 turn

📚 系列导航

← 第 25 讲:ExecPolicy 命令策略引擎

→ 第 27 讲:Network Proxy 网络代理引擎

关注公众号「AI技术推荐官」获取更多源码解析内容

相关学习资料