OpenAI Codex 的执行安全模型:三层防线

让 AI 在你的机器上执行命令,这件事的风险是显而易见的。Codex 的安全设计试图在"能用"和"安全"之间找到一个不太难受的平衡点。读它的源码,会发现这个平衡是通过三个层次的机制叠加实现的:审批策略、规则引擎、平台沙箱。三者各司其职,又互相配合。

本文直接从代码出发,分析这三层防线的具体实现。

一、第一层:AskForApproval 审批策略

最外层的控制是一个枚举,定义在 codex-rs/protocol/src/protocol.rs

pub enum AskForApproval {
    /// 只有"已知安全"的只读命令才自动放行,其余全部询问用户
    UnlessTrusted,

    /// 已废弃:全部自动放行,依赖沙箱保护;失败时再升级给用户
    OnFailure,

    /// 模型自己决定何时询问用户(默认值)
    #[default]
    OnRequest,

    /// 细粒度控制:分别配置不同类型的审批流程
    Granular(GranularApprovalConfig),

    /// 永不询问用户;失败直接返回给模型,不升级
    Never,
}

五种模式覆盖了从"完全信任"到"完全不信任"的整个区间。默认值是 OnRequest,意思是把决定权交给模型——模型认为有必要时才问用户。

UnlessTrusted 是最保守的非交互模式:只有通过 is_known_safe_command() 检查的命令才自动放行,其他全部要求人工确认。这个模式适合对机器控制权有较高要求的场景。

Granular 模式最有意思,它允许对不同类型的审批请求分别开关:

pub struct GranularApprovalConfig {
    /// 沙箱命令审批(含 with_additional_permissions 和 require_escalated)
    pub sandbox_approval: bool,
    /// execpolicy 规则触发的 prompt
    pub rules: bool,
    /// skill 脚本执行的审批
    #[serde(default)]
    pub skill_approval: bool,
    /// request_permissions 工具触发的审批
    #[serde(default)]
    pub request_permissions: bool,
    /// MCP elicitation 弹窗
    pub mcp_elicitations: bool,
}

这个设计的意图是:不同的使用场景对"哪类操作需要人工介入"有不同的答案。比如在 CI 环境里,你可能希望关闭 sandbox_approval(让命令自动执行),但保留 mcp_elicitations(MCP 工具调用时仍然要求确认)。粒度控制让这种差异化配置成为可能,而不是只能在"全问"和"全不问"之间二选一。

当审批策略和规则引擎产生冲突时,有明确的优先级规则。在 exec_policy.rs 里:

pub(crate) fn prompt_is_rejected_by_policy(
    approval_policy: AskForApproval,
    prompt_is_rule: bool,
) -> Option<&'static str> {
    match approval_policy {
        AskForApproval::Never => Some(PROMPT_CONFLICT_REASON),
        AskForApproval::OnFailure | AskForApproval::OnRequest | AskForApproval::UnlessTrusted => None,
        AskForApproval::Granular(granular_config) => {
            if prompt_is_rule {
                if !granular_config.allows_rules_approval() {
                    Some(REJECT_RULES_APPROVAL_REASON)
                } else {
                    None
                }
            } else if !granular_config.allows_sandbox_approval() {
                Some(REJECT_SANDBOX_APPROVAL_REASON)
            } else {
                None
            }
        }
    }
}

逻辑很清晰:Never 模式下所有 prompt 都被拒绝;Granular 模式下根据 prompt 来源(规则触发还是沙箱触发)分别判断;其他模式不拦截 prompt。

二、第二层:ExecPolicy 规则引擎

审批策略决定"是否询问用户",规则引擎决定"命令本身是否被允许执行"。这是两个正交的维度。

规则的基本结构

规则引擎的核心是前缀匹配。每条规则由一个命令前缀(PrefixPattern)和一个决策(Decision:Allow / Prompt / Forbidden)组成:

pub struct PrefixRule {
    pub pattern: PrefixPattern,
    pub decision: Decision,
    pub justification: Option<String>,
}

pub struct PrefixPattern {
    pub first: Arc<str>,           // 第一个 token,固定字符串
    pub rest: Arc<[PatternToken]>, // 后续 token,可以是固定字符串或候选集合
}

pub enum PatternToken {
    Single(String),
    Alts(Vec<String>),  // 允许多个候选值,如 ["-c"|"--command"]
}

匹配逻辑是前缀匹配:规则的 pattern 只需匹配命令的前 N 个 token,后续 token 不影响匹配结果。这意味着一条 allow git 规则会放行所有以 git 开头的命令,包括 git push --force

规则按程序名(第一个 token)索引,存储在 MultiMap<String, RuleRef> 里。查找时先精确匹配第一个 token,找到候选规则集合后再逐条检查后续 token。

决策聚合

一条命令可能匹配多条规则。最终决策取所有匹配规则中优先级最高的那个。优先级顺序是 Forbidden > Prompt > Allow——只要有一条规则禁止,命令就被禁止:

fn from_matches(matched_rules: Vec<RuleMatch>) -> Self {
    let decision = matched_rules.iter().map(RuleMatch::decision).max();
    // ...
    Self { decision, matched_rules }
}

这里用了 max(),前提是 Decision 实现了 Ord,且 Forbidden > Prompt > Allow。这是一个"最严格规则优先"的设计,和防火墙的 deny-override 语义一致。

启发式回退

当没有任何规则匹配时,规则引擎不会直接放行,而是调用一个启发式函数 render_decision_for_unmatched_command()。这个函数综合考虑多个因素:

  • 命令是否在"已知安全"列表里(is_known_safe_command
  • 命令是否被判定为危险(command_might_be_dangerous
  • 当前的审批策略(AskForApproval
  • 沙箱类型(受限沙箱还是无限制环境)

OnRequest 模式 + 受限沙箱的组合下,非危险命令会被直接放行(Decision::Allow),因为沙箱本身提供了保护;但如果命令请求了沙箱覆盖权限(sandbox_permissions.requests_sandbox_override()),则需要用户确认。

被禁止的前缀建议

规则引擎还有一个细节:当用户批准某条命令时,系统会尝试自动生成一条 allow 规则,方便下次跳过询问。但有一批命令前缀被明确列入黑名单,不会被自动建议:

static BANNED_PREFIX_SUGGESTIONS: &[&[&str]] = &[
    &["python3"],
    &["python3", "-c"],
    &["bash"],
    &["bash", "-lc"],
    &["sh", "-c"],
    &["zsh"],
    &["node", "-e"],
    &["perl", "-e"],
    &["sudo"],
    &["env"],
    &["osascript"],
    // ... 完整列表还包括 python, py, pypy, ruby, php, lua,
    //     powershell, pwsh 及其各种调用形式
];

这些都是"可以执行任意代码的解释器入口"。如果允许自动建议 allow python3,等于允许了所有 Python 代码,这显然不是用户批准某次具体命令时的意图。被禁止的前缀建议列表阻止了这类过度宽泛的规则被静默添加。

注意 git 也在这个列表里——git 可以通过 hooks 执行任意代码,把它加入 allow 前缀同样过于宽泛。

三、第三层:平台沙箱

前两层是"软"控制:审批策略和规则引擎决定是否执行命令,但一旦命令被放行,它在系统上的行为没有任何约束。第三层是"硬"控制:通过操作系统级别的沙箱机制,限制进程实际能做什么。

Codex 在三个平台上使用不同的沙箱技术,统一通过 SandboxManager 管理:

pub enum SandboxType {
    None,
    MacosSeatbelt,
    LinuxSeccomp,
    WindowsRestrictedToken,
}

macOS:Seatbelt(sandbox-exec)

macOS 的沙箱基于 Apple 的 Seatbelt 机制,通过 /usr/bin/sandbox-exec 执行。Codex 明确硬编码了这个路径:

pub const MACOS_PATH_TO_SEATBELT_EXECUTABLE: &str = "/usr/bin/sandbox-exec";

注释里解释了原因:只信任 /usr/bin 下的 sandbox-exec,防止攻击者在 PATH 里注入恶意版本。如果 /usr/bin/sandbox-exec 被篡改,攻击者已经有了 root 权限,这是另一个问题。

沙箱策略写在 seatbelt_base_policy.sbpl 文件里,用 Scheme 语法描述。基础策略的核心原则是"默认拒绝":

(version 1)

; start with closed-by-default
(deny default)

; child processes inherit the policy of their parent
(allow process-exec)
(allow process-fork)
(allow signal (target same-sandbox))

(deny default) 开始,然后逐条添加允许项。这是正确的安全设计方向——默认拒绝比默认允许更安全,因为遗漏的允许项只会导致功能缺失,而遗漏的拒绝项会导致安全漏洞。

文件系统访问权限在运行时动态生成,根据 SandboxPolicyReadOnlyWorkspaceWrite 等)计算出具体的允许路径列表,注入到策略字符串里。

Linux:bubblewrap 和 Landlock

Linux 上使用两种机制的组合:

bubblewrap:基于 Linux user namespace 的容器化工具,通过 bind mount 构建一个隔离的文件系统视图。沙箱进程只能看到被显式挂载进来的目录。Codex 把它封装成一个独立的辅助程序 codex-linux-sandbox

pub const CODEX_LINUX_SANDBOX_ARG0: &str = "codex-linux-sandbox";

pub fn create_linux_sandbox_command_args_for_permission_profile(
    command: Vec<String>,
    command_cwd: &Path,
    permission_profile: &PermissionProfile,
    sandbox_policy_cwd: &Path,
    use_legacy_landlock: bool,
    allow_network_for_proxy: bool,
) -> Vec<String> {
    // 把 permission_profile 序列化成 JSON,作为参数传给 sandbox helper
    let permission_profile_json = serde_json::to_string(permission_profile)...;
    let mut linux_cmd = vec![
        "--sandbox-policy-cwd", sandbox_policy_cwd,
        "--command-cwd", command_cwd,
        "--permission-profile", permission_profile_json,
    ];
    // ...
}

权限描述以 JSON 形式传递给辅助程序,由辅助程序在内部解析并构建 bubblewrap 参数。这个设计把"权限描述"和"沙箱实现"解耦——主进程只需要描述想要什么权限,具体怎么实现交给辅助程序。

Landlock:Linux 内核 5.13 引入的文件系统访问控制机制,不依赖 user namespace,可以在不需要 root 权限的情况下限制进程的文件系统访问。Codex 支持 --use-legacy-landlock 参数,在不支持 bubblewrap 的环境(如 WSL1)下回退到纯 Landlock 模式。

WSL1 的情况值得注意:WSL1 不支持 user namespace,因此 bubblewrap 无法工作。代码里有明确的检测和错误提示:

pub(crate) const WSL1_BWRAP_WARNING: &str = concat!(
    "Codex's Linux sandbox uses bubblewrap, which is not supported on WSL1 ",
    "because WSL1 cannot create the required user namespaces. ",
    "Use WSL2 for sandboxed shell commands."
);

沙箱选择逻辑

SandboxManager::select_initial() 决定使用哪种沙箱:

pub fn select_initial(&self, file_system_policy, network_policy, pref, ...) -> SandboxType {
    match pref {
        SandboxablePreference::Forbid => SandboxType::None,
        SandboxablePreference::Require => get_platform_sandbox(...),
        SandboxablePreference::Auto => {
            if should_require_platform_sandbox(file_system_policy, network_policy, ...) {
                get_platform_sandbox(...)
            } else {
                SandboxType::None
            }
        }
    }
}

Auto 模式下,只有当权限配置需要沙箱保护时才启用沙箱。如果用户配置了 DangerFullAccessExternalSandbox(表示已经在外部沙箱里运行),则跳过平台沙箱。

四、三层防线的协作方式

三层防线不是简单的串联,而是有复杂的交互:

规则引擎可以绕过沙箱。当 ExecPolicy 规则明确 Allow 某条命令时,bypass_sandbox 标志会被设置为 true,命令直接执行,不经过平台沙箱。这是性能优化:如果命令已经被明确信任,再套一层沙箱只是增加开销。

Decision::Allow => ExecApprovalRequirement::Skip {
    bypass_sandbox: commands.iter().all(|command| {
        exec_policy
            .matches_for_command_with_options(command, None, &match_options)
            .iter()
            .any(|rule_match| {
                is_policy_match(rule_match) && rule_match.decision() == Decision::Allow
            })
    }),
    // ...
},

注意条件:只有当所有解析出的命令片段都被明确的规则(PrefixRuleMatch,不是启发式)Allow 时,才绕过沙箱。启发式允许的命令仍然走沙箱。

审批策略可以把 Prompt 变成 Forbidden。规则引擎说"需要询问用户",但如果审批策略是 NeverGranular 且对应的开关关闭,prompt 会被直接转化为 Forbidden。这让无人值守的自动化场景成为可能:明确告诉系统"不要等我,遇到需要询问的命令直接拒绝"。

沙箱策略影响启发式决策。在 render_decision_for_unmatched_command() 里,受限沙箱下的非危险命令会被直接放行,而无沙箱环境下同样的命令可能需要 prompt。沙箱的存在改变了风险评估,进而影响了规则引擎的启发式判断。

五、设计上的取舍

这套设计有几个值得注意的取舍:

规则是前缀匹配,不是精确匹配allow cargo build 会同时允许 cargo build --releasecargo build --target x86_64-unknown-linux-musl,但也会允许 cargo build && rm -rf /——不过后者在命令解析阶段会被拆分成两条命令,rm -rf / 会单独被规则引擎评估。命令解析(parse_shell_lc_plain_commands)是安全的关键前提。

规则可以在运行时追加。用户批准某条命令时,系统可以把对应的前缀规则写入 ~/.codex/rules/default.rules,下次同类命令自动放行。这是用户体验的优化,但也意味着规则库会随使用积累而膨胀。BANNED_PREFIX_SUGGESTIONS 列表的存在,是防止这个机制被滥用的安全阀。

沙箱保护 .codex.git/hooks。在 WorkspaceWrite 模式下,工作目录是可写的,但 WritableRoot 结构会把某些子路径标记为只读:

pub struct WritableRoot {
    pub root: AbsolutePathBuf,
    pub read_only_subpaths: Vec<AbsolutePathBuf>, // .codex, .git/hooks 等
    pub protected_metadata_names: Vec<String>,
}

这防止了一类攻击:模型通过修改 .git/hooks.codex/rules 来提升自己的权限。即使在"可以写工作目录"的模式下,这些路径仍然受保护。

六、总结

Codex 的执行安全模型可以用一句话概括:策略决定是否询问,规则决定是否允许,沙箱决定能做什么

三层防线的设计思路:

  • AskForApproval:控制人机交互的频率,从"全问"到"全不问",粒度可细化到不同类型的操作
  • ExecPolicy:前缀匹配的规则引擎,支持 Allow / Prompt / Forbidden 三种决策,启发式兜底,规则可动态追加
  • 平台沙箱:macOS 用 Seatbelt(deny default 的 SBPL 策略),Linux 用 bubblewrap + Landlock,统一通过 SandboxManager 抽象

这套设计的核心假设是:不同的使用场景需要不同的安全级别,没有一个配置能适合所有人。所以它提供了足够多的旋钮,让用户根据自己的信任模型来调整。代价是复杂性——理解这三层如何交互,需要读相当多的代码。