Skip to content

命令规范化与审批缓存

区分一次批准、Session审批缓存和持久化ExecPolicy amendment,追踪命令规范化、结构化键、规则写盘与内存更新。

基于rust-v0.150.0
CodexRustSecurityExecPolicy

命令规范化与审批缓存 ​

Codex 对“这次执行可以继续”“本 Session 内相同请求不再询问”“以后匹配这个 prefix 的命令都允许”使用三种不同状态。Approved 只解决当前审批;ApprovedForSession 写入内存 ApprovalStore,复用条件由 environment、命令、cwd、TTY 和权限共同构成;ApprovedExecpolicyAmendment 则把 token prefix 写入 rules/default.rules,并同步更新当前进程的 ExecPolicy。

命令字符串不能直接作为缓存键。/bin/bash -lc 'cargo test' 与 bash -lc 'cargo test' 的 shell 文本不同,但 word-only argv 相同;heredoc 又不能只保留 python3,否则两个不同脚本会错误复用同一批准。Core 因此先生成 canonical command,再把它嵌入结构化 key。对于 environment 自带的 requirements ExecPolicy,key 还会加入排序后的 policy fingerprint,防止 policy 改变后继续复用旧批准。

本文承接Shell命令解析与安全分类和ExecPolicy语言与规则。前文解释 shell 如何拆段,本文关注批准如何复用以及复用何时失效;不把网络和 MCP 的独立持久化策略混入通用 command cache,也不把一次 Session approval 写成永久规则。

1. 三类复用 ​

协议把可持久化 prefix 表示为透明数组类型 ExecPolicyAmendment。它不是 shell 字符串,也不包含 cwd、environment 或 TTY;command 中的每个 String 都是 prefix 的一个 token,最终会生成 prefix_rule(..., decision="allow")。

源码位置:codex-rs/protocol/src/approvals.rs :: ExecPolicyAmendment

rust
/// Proposed execpolicy change to allow commands starting with this prefix.
///
/// The `command` tokens form the prefix that would be added as an execpolicy
/// `prefix_rule(..., decision="allow")`, letting the agent bypass approval for
/// commands that start with this token sequence.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
#[serde(transparent)]
#[ts(type = "Array<string>")]
pub struct ExecPolicyAmendment {
    pub command: Vec<String>,
}

impl ExecPolicyAmendment {
    pub fn new(command: Vec<String>) -> Self {
        Self { command }
    }

    pub fn command(&self) -> &[String] {
        &self.command
    }
}

三类决定在 ReviewDecision 中也是三个独立变体。ApprovedForSession 的注释明确限定为 session-scoped approval cache;ApprovedExecpolicyAmendment 则要求携带服务端之前提出的 amendment。

源码位置:codex-rs/protocol/src/protocol.rs :: ReviewDecision

rust
pub enum ReviewDecision {
    /// User has approved this command and the agent should execute it.
    Approved,

    /// User has approved this command and wants to apply the proposed execpolicy
    /// amendment so future matching commands are permitted.
    ApprovedExecpolicyAmendment {
        proposed_execpolicy_amendment: ExecPolicyAmendment,
    },

    /// User has approved this request and wants future prompts in the same
    /// session-scoped approval cache to be automatically approved for the
    /// remainder of the session.
    ApprovedForSession,

    /// User has approved this MCP tool call and wants to amend its policy so
    /// matching future calls are automatically approved across sessions.
    ApprovedMcpPolicyAmendment,

    NetworkPolicyAmendment {
        network_policy_amendment: NetworkPolicyAmendment,
    },

    Denied { rejection: String },
    TimedOut,
    Abort,
}

这三者的复用边界如下:

决定存储位置作用域匹配单位
Approved不保存当前请求当前 approval ID
ApprovedForSessionSessionServices.tool_approvals当前 Session结构化 serialized key
ApprovedExecpolicyAmendmentrules/default.rules 与内存 Policy后续 Session/重启token prefix rule

2. 命令规范化 ​

2.1 单命令展开 ​

canonicalize_command_for_approval 首先复用 shell-command 的 plain parser。只有 lowering 后恰好得到一个 command,才直接使用 inner argv。/bin/bash -lc 'cargo test -p codex-core' 因而得到 ['cargo', 'test', '-p', 'codex-core'];由 shell wrapper 引入的空格差异不会进入 canonical command。

源码位置:codex-rs/core/src/command_canonicalization.rs :: canonicalize_command_for_approval

rust
const CANONICAL_BASH_SCRIPT_PREFIX: &str = "__codex_shell_script__";
const CANONICAL_POWERSHELL_SCRIPT_PREFIX: &str = "__codex_powershell_script__";

/// Canonicalize command argv for approval-cache matching.
///
/// This keeps approval decisions stable across wrapper-path differences (for
/// example `/bin/bash -lc` vs `bash -lc`) and across shell wrapper tools while
/// preserving exact script text for complex scripts where we cannot safely
/// recover a tokenized command sequence.
pub(crate) fn canonicalize_command_for_approval(command: &[String]) -> Vec<String> {
    if let Some(commands) = parse_shell_lc_plain_commands(command)
        && let [single_command] = commands.as_slice()
    {
        return single_command.clone();
    }

    if let Some((_shell, script)) = extract_bash_command(command) {
        let shell_mode = command.get(1).cloned().unwrap_or_default();
        return vec![
            CANONICAL_BASH_SCRIPT_PREFIX.to_string(),
            shell_mode,
            script.to_string(),
        ];
    }

    if let Some((_shell, script)) = extract_powershell_command(command) {
        return vec![
            CANONICAL_POWERSHELL_SCRIPT_PREFIX.to_string(),
            script.to_string(),
        ];
    }

    command.to_vec()
}

注意 [single_command] 的切片模式:即使 cargo build && echo ok 能安全拆成两个 plain segment,也不会把两个命令拼成一个 canonical argv。它会进入 Bash script sentinel 分支,保留完整脚本文本。

源码位置:codex-rs/core/src/command_canonicalization_tests.rs :: canonicalizes_word_only_shell_scripts_to_inner_command

rust
#[test]
fn canonicalizes_word_only_shell_scripts_to_inner_command() {
    let command_a = vec![
        "/bin/bash".to_string(),
        "-lc".to_string(),
        "cargo test -p codex-core".to_string(),
    ];
    let command_b = vec![
        "bash".to_string(),
        "-lc".to_string(),
        "cargo   test   -p codex-core".to_string(),
    ];

    assert_eq!(
        canonicalize_command_for_approval(&command_a),
        vec![
            "cargo".to_string(),
            "test".to_string(),
            "-p".to_string(),
            "codex-core".to_string(),
        ]
    );
    assert_eq!(
        canonicalize_command_for_approval(&command_a),
        canonicalize_command_for_approval(&command_b)
    );
}

2.2 复杂脚本键 ​

heredoc、重定向、变量展开或多个 segment 不能缩成一个 argv,因此 canonical form 使用 __codex_shell_script__、原始 -c/-lc mode 和完整 script。这样 /bin/zsh 与 zsh 的 wrapper path 可以共享 command component,但脚本文本或 shell mode 的任何变化仍会产生不同结果。

源码位置:codex-rs/core/src/command_canonicalization_tests.rs :: canonicalizes_heredoc_scripts_to_stable_script_key

rust
#[test]
fn canonicalizes_heredoc_scripts_to_stable_script_key() {
    let script = "python3 <<'PY'\nprint('hello')\nPY";
    let command_a = vec![
        "/bin/zsh".to_string(),
        "-lc".to_string(),
        script.to_string(),
    ];
    let command_b = vec!["zsh".to_string(), "-lc".to_string(), script.to_string()];

    assert_eq!(
        canonicalize_command_for_approval(&command_a),
        vec![
            "__codex_shell_script__".to_string(),
            "-lc".to_string(),
            script.to_string(),
        ]
    );
    assert_eq!(
        canonicalize_command_for_approval(&command_a),
        canonicalize_command_for_approval(&command_b)
    );
}

这不是把复杂脚本判为等价,而是把 wrapper executable 从 command component 中移除,同时保留真正影响执行的 mode 和完整 source text。

2.3 PowerShell键 ​

PowerShell 使用独立 sentinel,并保留完整 -Command body。powershell.exe -NoProfile -Command 'Write-Host hi' 与 powershell -Command 'Write-Host hi' 的 command component 相同,但不同脚本不会合并。

源码位置:codex-rs/core/src/command_canonicalization_tests.rs :: canonicalizes_powershell_wrappers_to_stable_script_key

rust
#[test]
fn canonicalizes_powershell_wrappers_to_stable_script_key() {
    let script = "Write-Host hi";
    let command_a = vec![
        "powershell.exe".to_string(),
        "-NoProfile".to_string(),
        "-Command".to_string(),
        script.to_string(),
    ];
    let command_b = vec![
        "powershell".to_string(),
        "-Command".to_string(),
        script.to_string(),
    ];

    assert_eq!(
        canonicalize_command_for_approval(&command_a),
        vec![
            "__codex_powershell_script__".to_string(),
            script.to_string(),
        ]
    );
    assert_eq!(
        canonicalize_command_for_approval(&command_a),
        canonicalize_command_for_approval(&command_b)
    );
}

非 shell command 不做 basename、path 或参数重排,直接复制原始 argv。因此普通 executable 的绝对路径和相对名称默认不是同一个 canonical command。

3. Session缓存键 ​

3.1 结构字段 ​

Unified Exec 的 approval key 不只有 canonical command。结构体还记录 environment ID、原始 executable、cwd、TTY、sandbox permissions 和 additional permissions。原始 executable 单独存在意味着:即使 canonical command component 去除了 /bin/bash 与 bash 的差异,完整 key 仍会按 executable 字段区分它们。

源码位置:codex-rs/core/src/tools/runtimes/unified_exec.rs :: UnifiedExecApprovalKey

rust
/// Cache key for approval decisions that can be reused across equivalent
/// unified-exec launches.
#[derive(serde::Serialize, Clone, Debug, Eq, PartialEq, Hash)]
pub struct UnifiedExecApprovalKey {
    pub environment_id: String,
    pub executable: Option<String>,
    pub command: Vec<String>,
    pub cwd: PathUri,
    pub tty: bool,
    pub sandbox_permissions: SandboxPermissions,
    pub additional_permissions: Option<AdditionalPermissionProfile>,
}

ApprovalAction::cache_keys 从真实执行请求构造 key。Execve interception、MCP、network 和 request_permissions 返回空 key,不进入这套通用缓存;Apply Patch 则按每个目标文件分别产生 key。

源码位置:codex-rs/core/src/tools/approvals.rs :: ApprovalAction::cache_keys

rust
pub(crate) fn cache_keys(&self) -> Vec<ApprovalCacheKey> {
    match self {
        Self::ExecCommand {
            environment_id,
            command,
            cwd,
            tty,
            sandbox_permissions,
            additional_permissions,
            ..
        } => vec![ApprovalCacheKey::ExecCommand(UnifiedExecApprovalKey {
            environment_id: environment_id.clone(),
            executable: command.first().cloned(),
            command: canonicalize_command_for_approval(command),
            cwd: cwd.clone(),
            tty: *tty,
            sandbox_permissions: *sandbox_permissions,
            additional_permissions: additional_permissions.clone(),
        })],
        #[cfg(unix)]
        Self::Execve { .. } => Vec::new(),
        Self::McpToolCall { .. }
        | Self::NetworkAccess { .. }
        | Self::RequestPermissions { .. } => Vec::new(),
        Self::ApplyPatch {
            environment_id,
            files,
            ..
        } => files
            .iter()
            .cloned()
            .map(|path| {
                ApprovalCacheKey::ApplyPatch(ApplyPatchApprovalKey {
                    environment_id: environment_id.clone(),
                    path,
                })
            })
            .collect(),
    }
}

这组字段建立了复用不变量:切换 remote environment、工作目录、TTY 模式、sandbox override 或 additional permissions 都不能命中旧 key。key 没有把“看起来相似”当成等价,而是要求执行上下文的安全相关字段相同。

3.2 Environment隔离 ​

Unified Exec runtime 从 TurnEnvironment.selection.environment_id 建立 approval action。测试只改变 environment ID,其余请求保持不变,断言两个 key 不相等。

源码位置:codex-rs/core/src/tools/runtimes/unified_exec.rs :: approval_key_includes_environment_id

rust
#[tokio::test]
async fn approval_key_includes_environment_id() {
    let manager = UnifiedExecProcessManager::default();
    let runtime = UnifiedExecRuntime::new(&manager, UnifiedExecShellMode::Direct);
    let mut request = test_request(
        SandboxPermissions::UseDefault,
        ExecApprovalRequirement::Skip {
            bypass_sandbox: false,
            proposed_execpolicy_amendment: None,
        },
    );
    request.turn_environment.selection.environment_id = "remote".to_string();
    let original_key = runtime
        .approval_action(&request, "call-1")
        .expect("build approval action")
        .cache_keys();
    request.turn_environment.selection.environment_id = "other".to_string();
    let other_key = runtime
        .approval_action(&request, "call-1")
        .expect("build approval action")
        .cache_keys();

    assert_ne!(original_key, other_key);
}

3.3 Policy指纹 ​

environment 可能在 Session 期间提供新的 requirements ExecPolicy。Core 把该 policy 的 fingerprint 与 ApprovalCacheKey 组成 tuple,再交给通用 cache。fingerprint 由所有 prefix rules 的 debug representation 排序生成,因此 rule 顺序不同但内容相同会得到同样指纹,内容变化则使旧 approval miss。

源码位置:codex-rs/execpolicy/src/policy.rs :: RequirementsExecPolicy::fingerprint

rust
impl RequirementsExecPolicy {
    pub fn new(policy: Policy) -> Self {
        Self { policy }
    }

    pub fn fingerprint(&self) -> Vec<String> {
        let mut entries = Vec::new();
        for (program, rules) in self.policy.rules().iter_all() {
            for rule in rules {
                entries.push(format!("{program}:{rule:?}"));
            }
        }
        entries.sort();
        entries
    }
}

源码位置:codex-rs/core/src/tools/approvals.rs :: request_user_approval ExecCommand branch

rust
let policy_fingerprint = ctx
    .review_context
    .environments()
    .turn_environments()
    .find(|environment| environment.selection.environment_id == *environment_id)
    .and_then(|environment| environment.config().exec_policy.as_ref())
    .map(codex_execpolicy::RequirementsExecPolicy::fingerprint);
let cache_keys = action
    .cache_keys()
    .into_iter()
    .map(|key| (key, &policy_fingerprint))
    .collect();
with_cached_approval(&self.services, tool_name, cache_keys, || async {
    self.request_command_approval(
        ctx.review_context.turn(),
        ctx.call_id.clone(),
        /*approval_id*/ None,
        Some(environment_id.clone()),
        command.clone(),
        cwd,
        reason,
        ctx.network_approval_context.clone(),
        proposed_execpolicy_amendment.clone(),
        additional_permissions.clone(),
        /*available_decisions*/ None,
        /*plugin_attribution_override*/ None,
    )
    .await
})
.await

这里的指纹针对 environment-owned ExecPolicy,不是完整 Session 配置 hash。其它 key 字段仍由 UnifiedExecApprovalKey 单独承担。

4. Session缓存流程 ​

4.1 命中条件 ​

ApprovalStore 把任意可序列化 key 转为 JSON string,再存储 ReviewDecision。with_cached_approval 只有在 key 非空且每一个 key 都已经保存为 ApprovedForSession 时才跳过 fetch;普通 Approved、Denied 或部分 key 命中都不会复用。

源码位置:codex-rs/core/src/tools/sandboxing.rs :: ApprovalStore, with_cached_approval

rust
#[derive(Clone, Default, Debug)]
pub(crate) struct ApprovalStore {
    // Store serialized keys for generic caching across requests.
    map: HashMap<String, ReviewDecision>,
}

impl ApprovalStore {
    pub fn get<K>(&self, key: &K) -> Option<ReviewDecision>
    where
        K: Serialize,
    {
        let s = serde_json::to_string(key).ok()?;
        self.map.get(&s).cloned()
    }

    pub fn put<K>(&mut self, key: K, value: ReviewDecision)
    where
        K: Serialize,
    {
        if let Ok(s) = serde_json::to_string(&key) {
            self.map.insert(s, value);
        }
    }
}

pub(crate) async fn with_cached_approval<K, F, Fut>(
    services: &SessionServices,
    tool_name: &str,
    keys: Vec<K>,
    fetch: F,
) -> ReviewDecision
where
    K: Serialize,
    F: FnOnce() -> Fut,
    Fut: Future<Output = ReviewDecision>,
{
    if keys.is_empty() {
        return fetch().await;
    }

    let already_approved = {
        let store = services.tool_approvals.lock().await;
        keys.iter()
            .all(|key| matches!(store.get(key), Some(ReviewDecision::ApprovedForSession)))
    };

    if already_approved {
        return ReviewDecision::ApprovedForSession;
    }

fetch 完成后,只有 ApprovedForSession 会逐 key 写回。telemetry 在 fetch 之后记录真实决定;cache hit 直接返回,不再产生新的用户审批请求。

源码位置:codex-rs/core/src/tools/sandboxing.rs :: with_cached_approval

rust
let decision = fetch().await;

services.session_telemetry.counter(
    "codex.approval.requested",
    /*inc*/ 1,
    &[
        ("tool", tool_name),
        ("approved", decision.to_opaque_string()),
    ],
);

if matches!(decision, ReviewDecision::ApprovedForSession) {
    let mut store = services.tool_approvals.lock().await;
    for key in keys {
        store.put(key, ReviewDecision::ApprovedForSession);
    }
}

decision

4.2 Session所有权 ​

缓存挂在 SessionServices.tool_approvals,由 tokio::sync::Mutex 保护。它跨 Turn 存活,但不会自然跨 Session 重建;新的 SessionServices 会创建新的 ApprovalStore。

源码位置:codex-rs/core/src/state/service.rs :: SessionServices

rust
pub(crate) struct SessionServices {
    /// The single owner of live MCP connections for this thread.
    pub(crate) mcp_runtime: Arc<McpRuntime>,
    /// Immutable MCP handlers scoped to this thread's current binding.
    pub(crate) mcp_handler_cache: McpHandlerCache,
    pub(crate) unified_exec_manager: UnifiedExecProcessManager,
    pub(crate) elicitations: ElicitationService,
    #[cfg_attr(not(unix), allow(dead_code))]
    pub(crate) shell_zsh_path: Option<PathBuf>,
    #[cfg_attr(not(unix), allow(dead_code))]
    pub(crate) main_execve_wrapper_exe: Option<PathBuf>,
    pub(crate) analytics_events_client: AnalyticsEventsClient,
    pub(crate) hooks: ArcSwap<Hooks>,
    pub(crate) rollout_thread_trace: ThreadTraceContext,
    pub(crate) user_shell: Arc<crate::shell::Shell>,
    pub(crate) show_raw_agent_reasoning: bool,
    pub(crate) exec_policy: Arc<ExecPolicyManager>,
    pub(crate) auth_manager: Arc<AuthManager>,
    /// Upload-only clients shared across turns without logging signed blob URLs.
    pub(crate) openai_file_upload_client_pool: RouteAwareClientPool,
    pub(crate) models_manager: SharedModelsManager,
    pub(crate) session_telemetry: SessionTelemetry,
    pub(crate) tool_approvals: Mutex<ApprovalStore>,
    pub(crate) guardian_rejection_circuit_breaker: Mutex<GuardianRejectionCircuitBreaker>,
    pub(crate) runtime_handle: Handle,
    pub(crate) skills_service: Arc<HostSkillsService>,

Apply Patch 的多文件语义展示了 all 与逐 key 写入的目的:第一次批准 A、B 两个文件时分别存储;后续只修改 A,可以命中 A 的 key。反过来,若新请求包含未批准的 C,all 为 false,仍会请求审批。

5. Amendment候选 ​

5.1 两种来源 ​

ExecPolicy evaluation 可以从显式 requested prefix_rule 或 heuristic match 生成 amendment。Prompt 场景若已有显式 Prompt rule,不能再建议一个 Allow amendment;否则取第一个 heuristic Prompt。Allow 场景只在没有任何显式 policy match 时取 heuristic Allow。

源码位置:codex-rs/core/src/exec_policy.rs :: try_derive_execpolicy_amendment_for_prompt_rules, try_derive_execpolicy_amendment_for_allow_rules

rust
fn try_derive_execpolicy_amendment_for_prompt_rules(
    matched_rules: &[RuleMatch],
) -> Option<ExecPolicyAmendment> {
    if matched_rules
        .iter()
        .any(|rule_match| is_policy_match(rule_match) && rule_match.decision() == Decision::Prompt)
    {
        return None;
    }

    matched_rules
        .iter()
        .find_map(|rule_match| match rule_match {
            RuleMatch::HeuristicsRuleMatch {
                command,
                decision: Decision::Prompt,
            } => Some(ExecPolicyAmendment::from(command.clone())),
            _ => None,
        })
}

fn try_derive_execpolicy_amendment_for_allow_rules(
    matched_rules: &[RuleMatch],
) -> Option<ExecPolicyAmendment> {
    if matched_rules.iter().any(is_policy_match) {
        return None;
    }

    matched_rules
        .iter()
        .find_map(|rule_match| match rule_match {
            RuleMatch::HeuristicsRuleMatch {
                command,
                decision: Decision::Allow,
            } => Some(ExecPolicyAmendment::from(command.clone())),
            _ => None,
        })
}

当前实现不再用 used_complex_parsing 全局关闭 amendment。是否允许自动生成候选只取决于 AllowPrefixRules::Honor;复杂 shell 在无法采用请求 prefix 时,可以回退为完整 wrapper command amendment。

源码位置:codex-rs/core/src/exec_policy.rs :: create_exec_approval_requirement_for_parsed_commands

rust
let exec_policy = self.current_for_environment(environment_policy, allow_prefix_rules);
// Avoid reusable approvals when this model does not honor prefix rules.
let auto_amendment_allowed = allow_prefix_rules == AllowPrefixRules::Honor;
let exec_policy_fallback = |cmd: &[String]| {
    render_decision_for_unmatched_command(
        cmd,
        UnmatchedCommandContext {
            approval_policy,
            permission_profile: &permission_profile,
            windows_sandbox_level,
            sandbox_permissions,
            command_origin,
        },
    )
};

5.2 Prefix拒绝 ​

显式 requested prefix 必须非空、不能与 BANNED_PREFIX_SUGGESTIONS 完全相等,并且当前 evaluation 不能已有任何显式 policy match。banlist 覆盖 shell wrapper、python -c、node -e、PowerShell、裸 git、裸 rm 和 sudo 等过宽边界。

源码位置:codex-rs/core/src/exec_policy.rs :: derive_requested_execpolicy_amendment_from_prefix_rule

rust
let prefix_rule = prefix_rule?;
if prefix_rule.is_empty() {
    return None;
}
if BANNED_PREFIX_SUGGESTIONS.iter().any(|banned| {
    prefix_rule.len() == banned.len()
        && prefix_rule
            .iter()
            .map(String::as_str)
            .eq(banned.iter().copied())
}) {
    return None;
}

if matched_rules.iter().any(is_policy_match) {
    return None;
}

let amendment = ExecPolicyAmendment::new(prefix_rule.clone());
if prefix_rule_would_approve_all_commands(
    exec_policy,
    &amendment.command,
    commands,
    exec_policy_fallback,
    match_options,
) {
    Some(amendment)
} else {
    None
}

banlist 使用精确 token equality,而不是 starts-with。['python', '-c'] 被拒绝,['python', '-c', "print('hi')"] 可以继续进入全段模拟。后者仍然是具体脚本文本 prefix,不会授权所有 python -c。

5.3 全段模拟 ​

候选 prefix 会被临时加入 policy clone,然后对 shell parser 给出的每个 command segment 重跑 check_with_options。任一段得到 Prompt 或 Forbidden,就不能把该 prefix 作为“批准整次调用”的 amendment。

源码位置:codex-rs/core/src/exec_policy.rs :: prefix_rule_would_approve_all_commands

rust
fn prefix_rule_would_approve_all_commands(
    exec_policy: &Policy,
    prefix_rule: &[String],
    commands: &[Vec<String>],
    exec_policy_fallback: &impl Fn(&[String]) -> Decision,
    match_options: &MatchOptions,
) -> bool {
    let mut policy_with_prefix_rule = exec_policy.clone();
    if policy_with_prefix_rule
        .add_prefix_rule(prefix_rule, Decision::Allow)
        .is_err()
    {
        return false;
    }

    commands.iter().all(|command| {
        policy_with_prefix_rule
            .check_with_options(command, exec_policy_fallback, match_options)
            .decision
            == Decision::Allow
    })
}

测试输入 cargo install cargo-insta && rm -rf /tmp/codex 请求 ['cargo', 'install']。临时 rule 只能批准第一段,第二段仍是 Prompt,因此 requested prefix 被拒绝,最终候选回退到 heuristic Prompt 的 ['rm', '-rf', '/tmp/codex']。

源码位置:codex-rs/core/src/exec_policy_tests.rs :: request_rule_falls_back_when_prefix_rule_does_not_approve_all_commands

rust
let command = vec![
    "bash".to_string(),
    "-lc".to_string(),
    "cargo install cargo-insta && rm -rf /tmp/codex".to_string(),
];

let requirement = manager
    .create_exec_approval_requirement_for_command(ExecApprovalRequest {
        command: &command,
        approval_policy: AskForApproval::OnRequest,
        permission_profile: PermissionProfile::Disabled,
        environment_policy: None,
        windows_sandbox_level: WindowsSandboxLevel::Disabled,
        sandbox_permissions: SandboxPermissions::RequireEscalated,
        prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]),
        allow_prefix_rules: AllowPrefixRules::Honor,
    })
    .await;

assert_eq!(
    requirement,
    ExecApprovalRequirement::NeedsApproval {
        reason: None,
        proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![
            "rm".to_string(),
            "-rf".to_string(),
            "/tmp/codex".to_string(),
        ])),
    }
);

6. 用户决定 ​

普通 command approval 的默认 decisions 是 Approved、可选的 ApprovedExecpolicyAmendment 和 Abort。只有服务端确实提出 amendment 时,客户端才会看到持久化选项。网络 prompt 的默认 choices 才包含 ApprovedForSession;additional permissions 则只允许一次批准或终止。

源码位置:codex-rs/protocol/src/approvals.rs :: ExecApprovalRequestEvent::default_available_decisions

rust
if network_approval_context.is_some() {
    let mut decisions = vec![ReviewDecision::Approved, ReviewDecision::ApprovedForSession];
    if let Some(amendment) = proposed_network_policy_amendments.and_then(|amendments| {
        amendments
            .iter()
            .find(|amendment| amendment.action == NetworkPolicyRuleAction::Allow)
    }) {
        decisions.push(ReviewDecision::NetworkPolicyAmendment {
            network_policy_amendment: amendment.clone(),
        });
    }
    decisions.push(ReviewDecision::Abort);
    return decisions;
}

if additional_permissions.is_some() {
    return vec![ReviewDecision::Approved, ReviewDecision::Abort];
}

let mut decisions = vec![ReviewDecision::Approved];
if let Some(prefix) = proposed_execpolicy_amendment {
    decisions.push(ReviewDecision::ApprovedExecpolicyAmendment {
        proposed_execpolicy_amendment: prefix.clone(),
    });
}
decisions.push(ReviewDecision::Abort);
decisions

这意味着协议支持 ApprovedForSession,通用 cache 也能处理它,但普通 command prompt 的默认 UI choices 不自动展示该选项。客户端若使用服务端提供的 available_decisions,不会把 Session cache 与 ExecPolicy persistence 混成同一个按钮。

7. 规则落盘 ​

7.1 Token序列化 ​

持久化入口拒绝空 prefix。每个 token 通过 serde_json::to_string 转义,再拼成 Starlark list;空格、引号和反斜杠因此按 token 数据保存,而不是按 shell source 手工插值。

源码位置:codex-rs/execpolicy/src/amend.rs :: blocking_append_allow_prefix_rule

rust
pub fn blocking_append_allow_prefix_rule(
    policy_path: &Path,
    prefix: &[String],
) -> Result<(), AmendError> {
    if prefix.is_empty() {
        return Err(AmendError::EmptyPrefix);
    }

    let tokens = prefix
        .iter()
        .map(serde_json::to_string)
        .collect::<Result<Vec<_>, _>>()
        .map_err(|source| AmendError::SerializePrefix { source })?;
    let pattern = format!("[{}]", tokens.join(", "));
    let rule = format!(r#"prefix_rule(pattern={pattern}, decision="allow")"#);
    append_rule_line(policy_path, &rule)
}

7.2 文件锁与去重 ​

append_rule_line 创建 rules/ 目录后,以 read + append 模式打开文件并获取 advisory lock。它从头读取现有内容;若已经存在完全相同的一行则直接成功。文件末尾没有换行时先补一个换行,再追加新 rule。

源码位置:codex-rs/execpolicy/src/amend.rs :: append_rule_line, append_locked_line

rust
fn append_rule_line(policy_path: &Path, rule: &str) -> Result<(), AmendError> {
    let dir = policy_path
        .parent()
        .ok_or_else(|| AmendError::MissingParent {
            path: policy_path.to_path_buf(),
        })?;
    match std::fs::create_dir(dir) {
        Ok(()) => {}
        Err(ref source) if source.kind() == std::io::ErrorKind::AlreadyExists => {}
        Err(source) => {
            return Err(AmendError::CreatePolicyDir {
                dir: dir.to_path_buf(),
                source,
            });
        }
    }

    append_locked_line(policy_path, rule)
}

fn append_locked_line(policy_path: &Path, line: &str) -> Result<(), AmendError> {
    let mut file = OpenOptions::new()
        .create(true)
        .read(true)
        .append(true)
        .open(policy_path)
        .map_err(|source| AmendError::OpenPolicyFile {
            path: policy_path.to_path_buf(),
            source,
        })?;
    file.lock().map_err(|source| AmendError::LockPolicyFile {
        path: policy_path.to_path_buf(),
        source,
    })?;

    file.seek(SeekFrom::Start(0))
        .map_err(|source| AmendError::SeekPolicyFile {
            path: policy_path.to_path_buf(),
            source,
        })?;
    let mut contents = String::new();
    file.read_to_string(&mut contents)
        .map_err(|source| AmendError::ReadPolicyFile {
            path: policy_path.to_path_buf(),
            source,
        })?;

    if contents.lines().any(|existing| existing == line) {
        return Ok(());
    }

advisory lock 只能协调遵守同一 locking protocol 的写入者;外部进程若无锁改写文件,仍可能竞争。文件级 exact-line 去重也不等于语义去重:格式不同但语义相同的 rule 可以同时存在,运行时 strictest decision 会处理多个匹配。

8. 内存更新 ​

Core 用 Semaphore 串行化 amendment update,把阻塞文件 IO 放进 spawn_blocking。写盘后,它检查当前内存 policy 是否已经有显式 Allow;若没有,clone Policy、加入 prefix rule,再用 ArcSwap::store 原子替换。

源码位置:codex-rs/core/src/exec_policy.rs :: ExecPolicyManager::append_amendment_and_update

rust
pub(crate) async fn append_amendment_and_update(
    &self,
    codex_home: &Path,
    amendment: &ExecPolicyAmendment,
) -> Result<(), ExecPolicyUpdateError> {
    let _update_guard =
        self.update_lock
            .acquire()
            .await
            .map_err(|_| ExecPolicyUpdateError::AddRule {
                source: ExecPolicyRuleError::InvalidRule(
                    "exec policy update semaphore closed".to_string(),
                ),
            })?;
    let policy_path = default_policy_path(codex_home);
    spawn_blocking({
        let policy_path = policy_path.clone();
        let prefix = amendment.command.clone();
        move || blocking_append_allow_prefix_rule(&policy_path, &prefix)
    })
    .await
    .map_err(|source| ExecPolicyUpdateError::JoinBlockingTask { source })?
    .map_err(|source| ExecPolicyUpdateError::AppendRule {
        path: policy_path,
        source,
    })?;

    let current_policy = self.current();
    let match_options = MatchOptions {
        resolve_host_executables: true,
    };
    let existing_evaluation = current_policy.check_multiple_with_options(
        [&amendment.command],
        &|_| Decision::Forbidden,
        &match_options,
    );

源码位置:codex-rs/core/src/exec_policy.rs :: ExecPolicyManager::append_amendment_and_update

rust
let already_allowed = existing_evaluation.decision == Decision::Allow
    && existing_evaluation.matched_rules.iter().any(|rule_match| {
        is_policy_match(rule_match) && rule_match.decision() == Decision::Allow
    });
if already_allowed {
    return Ok(());
}

let mut updated_policy = current_policy.as_ref().clone();
updated_policy.add_prefix_rule(&amendment.command, Decision::Allow)?;
self.policy.store(Arc::new(updated_policy));
Ok(())

“先写盘、后更新内存”的顺序意味着写盘失败不会污染当前 Policy;反过来,写盘成功而内存 add_prefix_rule 失败时,文件已包含 rule,但本进程需要后续 reload 才能看到。错误类型保留 AppendRule、JoinBlockingTask 和 AddRule 三个阶段。

9. 失败路径 ​

用户决定由 session handler 接收。若是 ApprovedExecpolicyAmendment,handler 先尝试持久化;失败时发送 Warning,但随后仍把原决定交给 pending approval。也就是说,规则保存失败不会把“批准当前执行”改写成 Abort,只是未来复用没有建立。

源码位置:codex-rs/core/src/session/handlers.rs :: exec_approval

rust
pub async fn exec_approval(
    sess: &Arc<Session>,
    approval_id: String,
    turn_id: Option<String>,
    decision: ReviewDecision,
) {
    let event_turn_id = turn_id.unwrap_or_else(|| approval_id.clone());
    if let ReviewDecision::ApprovedExecpolicyAmendment {
        proposed_execpolicy_amendment,
    } = &decision
        && let Err(err) = sess
            .persist_execpolicy_amendment(proposed_execpolicy_amendment)
            .await
    {
        let message = format!("Failed to apply execpolicy amendment: {err}");
        tracing::warn!("{message}");
        let warning = EventMsg::Warning(WarningEvent { message });
        sess.send_event_raw(Event {
            id: event_turn_id.clone(),
            msg: warning,
        })
        .await;
    }
    match decision {
        ReviewDecision::Abort => {
            sess.interrupt_task().await;
        }
        other => sess.notify_approval(&approval_id, other).await,
    }
}

Session cache 失败路径不同:key serialization 失败时 ApprovalStore::get 返回 None、put 静默跳过,因此当前 fetch 仍可继续,但不会复用。空 key 的 action 也直接执行 fetch,避免“没有身份的批准”进入全局桶。

10. 测试边界 ​

几组测试共同定义了文章可以外推的范围:

  • command canonicalization 的 4 项单测覆盖单个 word-only shell、heredoc sentinel、PowerShell sentinel 和非 shell 原样保留。
  • amend 的 6 项单测覆盖目录创建、token 序列化、末尾换行、prefix/network 共存和非法 network host;prefix exact-line 去重由 append_locked_line 源码路径保证。
  • Core ExecPolicy 测试覆盖 banned prefix、请求 prefix 的全段模拟、heuristic fallback、文件与内存同时更新和空 prefix 错误。
  • environment_command_policy_changes_invalidate_session_approvals 先返回 ApprovedForSession,随后改变同一 environment 的 policy,断言第二次必须出现新的 command approval。
  • Apply Patch 集成测试先对同一文件返回 ApprovedForSession,下一 Turn 修改该文件时断言直接完成,展示 ApprovalStore 跨 Turn、逐 key 的 Session 语义。
  • amendment 端到端测试先确认 default.rules 已写入目标 prefix;当前 macOS fixture 随后的命令执行在 shell eval 中出现 parse error,因此该测试没有完成“第二次执行无提示”的最终断言,不能计为完整通过。

源码位置:

  • codex-rs/core/src/command_canonicalization_tests.rs :: tests
  • codex-rs/core/src/exec_policy_tests.rs :: amendment tests
  • codex-rs/execpolicy/src/amend.rs :: tests
  • codex-rs/core/tests/suite/exec_policy.rs :: environment_command_policy_changes_invalidate_session_approvals
  • codex-rs/core/tests/suite/approvals.rs :: approving_apply_patch_for_session_skips_future_prompts_for_same_file, approving_execpolicy_amendment_persists_policy_and_skips_future_prompts

下面的 integration 断言展示 policy fingerprint 的可观察结果:两次 command 相同,第一次批准为 Session,第二次只改变 environment ExecPolicy,仍必须收到新的 approval event。

源码位置:codex-rs/core/tests/suite/exec_policy.rs :: environment_command_policy_changes_invalidate_session_approvals

rust
for (attempt, decision) in [
    ("before-owner-policy", ReviewDecision::ApprovedForSession),
    ("after-owner-policy", ReviewDecision::Approved),
] {
    if attempt == "after-owner-policy" {
        let mut policy = Policy::empty();
        policy.add_prefix_rule(&["echo".to_string()], Decision::Prompt)?;
        test.codex
            .environment_ready(
                &selection,
                EnvironmentConfig {
                    allow_login_shell: true,
                    permission_profile: PermissionProfileSnapshot::legacy(
                        PermissionProfile::Disabled,
                    ),
                    shell_environment_policy: Default::default(),
                    windows_sandbox_level: WindowsSandboxLevel::from_config(&test.config),
                    windows_sandbox_private_desktop: test
                        .config
                        .permissions
                        .windows_sandbox_private_desktop,
                    use_legacy_landlock: test.config.features.use_legacy_landlock(),
                    exec_policy: Some(RequirementsExecPolicy::new(policy)),
                    mcp_policy: None,
                    network_policy: None,
                    selected_capability_roots: Vec::new(),
                },
            )
            .await?;
    }

    let args = json!({ "cmd": "echo approval", "yield_time_ms": 1_000 });

这些结果不能证明 canonicalization 等价于完整 shell 语义,也不能证明 advisory lock 能阻止不遵守锁协议的外部写入者。Session cache 只复用用户批准,不会跳过后续 sandbox enforcement;ExecPolicy amendment 则会改变未来 policy evaluation,是否能 bypass sandbox 仍取决于所有 command segment 都有显式 Allow。

11. 继续阅读 ​

可以沿三条路径检查自己的理解:

  1. 从 ApprovalAction::cache_keys 开始,说明同一脚本在 environment、cwd、TTY 或 additional permissions 变化后为何不能复用 Session approval。
  2. 从 derive_requested_execpolicy_amendment_from_prefix_rule 开始,说明为什么 cargo install && rm -rf 不能只保存 cargo install。
  3. 从 exec_approval 走到 append_amendment_and_update,说明写盘失败、内存更新失败和成功更新分别怎样影响当前执行与未来执行。

下一篇跨平台Sandbox抽象将继续分析 approval requirement 之后,PermissionProfile 如何选择本地或 executor-managed sandbox backend。