Agent 要执行 rm -rf,谁来拦?

Codex 不靠某一道开关,而是叠了好几层:execpolicy 规则 → 危险命令判定 + 审批策略 → 审批(用户 / Guardian)→ 操作系统沙箱 → 被拒后升级重试。每层都可能放行,也都可能拦下。

源码基于 openai/codex commit 7993248(2026-10-01),本机实验用 codex-cli 0.159.2(macOS)。这一讲只看模型发起的 shell 命令(exec_command);apply_patch 有自己的一套检查(core/src/safety.rs),不在矩阵里。

1一条命令要过几道关

第 2 周的 agent loop 里,工具就是一个普通函数,模型说调就调。Codex 在"模型说要执行"和"真的执行"之间插了五层:

层决定什么源码
1. execpolicy 规则用户写的 Starlark 规则,命中就给出 allow / prompt / forbiddencore/src/exec_policy.rs、execpolicy/
2. 危险命令判定 + 审批策略没有规则命中时,按"是不是危险命令 × 审批策略 × 沙箱类型"算出 Skip / NeedsApproval / Forbiddenexec_policy.rs:770-855
3. 审批需要审批时先跑 hook;hook 没给结论,再交给 Guardian(启用自动审批时)或用户,二者只走其一core/src/tools/approvals.rs:505-507
4. 操作系统沙箱macOS 用 Seatbelt,Linux 用 bubblewrap + seccomp,在内核层面挡住越权的读写和联网sandboxing/、linux-sandbox/
5. 被拒后升级沙箱拦下以后,要不要在沙箱外重跑一次core/src/tools/orchestrator.rs:436-555

编排顺序写在 orchestrator.rs 的模块注释里:

Central place for approvals + sandbox selection + retry semantics. Drives a
simple sequence for any ToolRuntime: approval → select sandbox → attempt →
retry with an escalated sandbox strategy on denial (no re‑approval thanks to
caching).

出处:core/src/tools/orchestrator.rs:1-7。"审批在前、沙箱在后"意味着:审批通过不等于不进沙箱,这一点后面的矩阵会反复体现。

2审批策略 AskForApproval

pub enum AskForApproval {
    /// Internal policy for projects marked untrusted. Commands require
    /// approval unless an explicit exec policy rule allows them.
    #[serde(rename = "untrusted")]
    #[strum(serialize = "untrusted")]
    UnlessTrusted,

    /// The model decides when to ask the user for approval.
    #[serde(alias = "on-failure")]
    #[default]
    OnRequest,

    // ... Granular(GranularApprovalConfig):按类别细分

    /// Never ask the user to approve commands. Failures are immediately returned
    /// to the model, and never escalated to the user for approval.
    Never,
}

出处:protocol/src/protocol.rs:986-1009。#[serde(rename = ...)] 决定它在配置文件里写成什么字符串,#[default] 标出默认值。

代码里的枚举名配置里的字符串CLI -a含义
UnlessTrusted"untrusted"(已不能写进配置)无只给被标为不受信任的项目内部使用:没有规则显式放行的命令都要审批
OnRequest(默认)"on-request","on-failure" 是别名on-request沙箱内随便跑;模型显式申请出沙箱(require_escalated)才问
Granular(..){ granular = { sandbox_approval = .., rules = .., ... } }无按类别开关:某类为 false 时,这类审批请求直接被拒,不弹给用户
Never"never"never从不问;失败直接回给模型
untrusted 已经退役:配置里写 approval_policy = "untrusted" 会直接报错 approval_policy = "untrusted" is no longer supported; remove this setting(core/src/config/mod.rs:228-231、:3733-3738)。core/config.schema.json 里也只剩 on-request、granular、never 三种;本机 codex --help 的 -a 只列出 on-request 和 never。也就是说,config.toml / -c 里写 untrusted 会报错,CLI -a 也不提供;core 加载配置时只在项目被标为 untrusted 且没有显式配置时自动选上它(app-server 协议的 override 仍接受 "untrusted",见 app-server-protocol/src/protocol/v2/shared.rs:207-210)。

没显式配置时怎么选(core/src/config/mod.rs:3741-3750):项目受信任 → OnRequest;项目被标为不受信任 → UnlessTrusted;都没有 → AskForApproval::default(),也就是 OnRequest。

3沙箱模式,以及可写目录里仍然只读的路径

配置项 sandbox_mode 有三个值(protocol/src/config_types.rs:104-114)。协议层的 SandboxPolicy 还多一个 external-sandbox,表示"进程已经在外部沙箱里"(protocol.rs:1072-1120)。

sandbox_mode能读能写联网
read-only(枚举默认值)全盘不能写(/dev/null 这类除外)默认不能
workspace-write全盘工作区、/tmp、$TMPDIR、writable_roots;.git 等四个元数据路径除外默认不能,network_access = true 才放开
danger-full-access不套沙箱

默认值有个细节:没配 sandbox_mode(也没配 default_permissions)时,配置走的是内置权限 profile(core/src/config/mod.rs:2566-2579 的 profiles_are_active 为真):当前目录有信任决定(trusted 或 untrusted)→ :workspace,否则 → :read-only;Windows 沙箱没启用时也回落 :read-only(core/src/config/permissions.rs:51-62)。

可写根目录里的只读子路径

/// Top-level workspace metadata paths that stay protected under writable roots.
pub const PROTECTED_METADATA_PATH_NAMES: &[&str] = &[
    PROTECTED_METADATA_GIT_PATH_NAME,     // ".git"
    PROTECTED_METADATA_AGENTS_PATH_NAME,  // ".agents"
    PROTECTED_METADATA_CODEX_PATH_NAME,   // ".codex"
    PROTECTED_METADATA_AWS_PATH_NAME,     // ".aws"
];

出处:protocol/src/permissions.rs:40-51(注释是本文加的,对应 40-43 行的常量)。

为什么要单独保护这四个?因为改了它们就能"越狱":往 .git/hooks 写一个钩子,下次你自己 git commit 时它就在沙箱外执行;改 .codex 能改 Codex 自己的配置和规则;.aws 里的 profile 可以指定由程序去执行的凭证助手(源码注释原话是 "AWS profiles can select credential helpers that the application executes")。.git 如果是 worktree / submodule 那种指向别处的文件,会顺着 gitdir: 把真实目录也保护起来(permissions.rs:2347-2390)。

4execpolicy:用 Starlark 写命令规则

规则文件放在配置层目录下的 rules/*.rules,默认文件名 default.rules(core/src/exec_policy.rs:54-56)。主力是 prefix_rule,按 token 前缀匹配:

prefix_rule(
    pattern = ["git", ["status", "diff", "log"]],   # 列表 = 这一位有几个候选
    decision = "allow",                             # allow | prompt | forbidden,默认 allow
    justification = "只读的 git 子命令",
    match = [["git", "status"], "git log --oneline"],   # 加载时必须命中
    not_match = ["git push origin main"],               # 加载时必须不命中
)
allow 规则会让命令跳过沙箱:当一条命令拆出来的每一段都被 prefix_rule 显式 allow 时,判定是 Skip { bypass_sandbox: true },第一次就直接在沙箱外执行(exec_policy.rs:440-454)。唯一的例外是策略里有"禁止读取"的路径,这时绕过沙箱会把禁读也一起绕掉,所以仍然留在沙箱里(tools/sandboxing.rs:252-262)。写 allow 规则等于给这条命令开了"免沙箱通行证",范围要窄。

5没有规则命中时:危险命令判定

if dangerous_command_match.is_some() || windows_managed_fs_restrictions_without_sandbox_backend
{
    return match approval_policy {
        AskForApproval::Never => Decision::Forbidden,
        AskForApproval::OnRequest
        | AskForApproval::UnlessTrusted
        | AskForApproval::Granular(_) => Decision::Prompt,
    };
}

match approval_policy {
    AskForApproval::Never => {
        // We allow the command to run, relying on the sandbox for
        // protection.
        Decision::Allow
    }
    AskForApproval::UnlessTrusted => {
        // ...
        Decision::Prompt
    }
    AskForApproval::OnRequest => {
        match file_system_sandbox_policy.kind {
            FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => {
                // ...
                Decision::Allow
            }
            FileSystemSandboxKind::Restricted => {
                // ...
                if sandbox_permissions.requests_sandbox_override() {
                    Decision::Prompt
                } else {
                    Decision::Allow
                }
            }
        }
    }
    // Granular(_) 的分支和 OnRequest 相同;prompt 是否被拒由 prompt_is_rejected_by_policy 决定
}

出处:core/src/exec_policy.rs:799-855(中间注释有省略)。

  1. 危险命令优先:不管沙箱是什么模式,危险命令在 never 下直接 Forbidden,其他策略下都要审批。设计注释说得很明白:宁可问,但用户关掉了提问,就只能拒绝。
  2. "危险"的定义很窄。POSIX 上只认带 -f / --force 的 rm,外加剥掉 sudo、env、trap、bash -lc 这些包装后再看里面那条(shell-command/src/command_safety/is_dangerous_command.rs:123-150)。包装嵌套超过 8 层直接按危险处理(fail closed)。git reset --hard、curl | sh 都不在名单里,要靠沙箱和规则兜。
  3. never 不等于放飞:never 下非危险命令是 Allow,但仍然在沙箱里跑。
  4. on-request 在受限沙箱里默认不问:只有模型在 exec_command 参数里写了 sandbox_permissions: "require_escalated"(申请出沙箱)才 Prompt。这就是"模型决定什么时候要审批"的实现。

6被沙箱拦下以后:谁会升级重试

// Under `Never` or `OnRequest`, do not retry without sandbox;
// surface a concise sandbox denial that preserves the
// original output.
if !tool.wants_no_sandbox_approval(approval_policy) {
    // ...(on-request 下的托管网络审批例外)
    return Err(ToolError::Codex(err));
}

出处:core/src/tools/orchestrator.rs:436-465。wants_no_sandbox_approval 在 tools/sandboxing.rs:348-355:untrusted → true,never / on-request → false,granular → 看 sandbox_approval。

审批策略沙箱拦下后
on-request、never、granular(sandbox_approval=false)不重试,把失败原样交给模型。on-request 下模型可以自己带上 require_escalated 再来一次
untrusted第一次已经审批过,不再问,直接在沙箱外重跑("no re‑approval thanks to caching")
granular(sandbox_approval=true)第一次没审批过,带着拒绝原因再问一次,同意后在沙箱外重跑
"被沙箱拦下"是猜出来的。sandboxing/src/denial.rs:5-9 的注释承认没有确定的判断方法:退出码非 0,且 stdout / stderr 里出现 operation not permitted、permission denied、read-only file system、seccomp、sandbox、landlock、failed to write file 之一,就当作沙箱拒绝(denial.rs:45-70);Linux 上被 seccomp 杀掉(退出码 128 + SIGSYS)也算。反过来,curl 被断网时输出的是 Could not resolve host(退出码 6,本机实测),不含这些关键词,不会被当成沙箱拒绝,也就不会触发升级重试。

7操作系统沙箱与网络

macOS:Seatbelt

/// When working with `sandbox-exec`, only consider `sandbox-exec` in `/usr/bin`
/// to defend against an attacker trying to inject a malicious version on the
/// PATH. If /usr/bin/sandbox-exec has been tampered with, then the attacker
/// already has root access.
pub const MACOS_PATH_TO_SEATBELT_EXECUTABLE: &str = "/usr/bin/sandbox-exec";

出处:sandboxing/src/seatbelt.rs:58-62。基线策略在 sandboxing/src/seatbelt_base_policy.sbpl:7-8:; start with closed-by-default / (deny default),再按需加 allow。可写目录以 WRITABLE_ROOT_n 这类参数用 -D 传给 sandbox-exec(seatbelt.rs:499-500, :1103),只读子路径用 require-not 从可写范围里扣掉。

网络:没开网络、也没配代理时,策略里根本不加网络规则,(deny default) 自然就断网了;配了代理时只放行到 localhost:<代理端口>;allow_local_binding 打开时另外放行本地 bind 和 loopback 流量,同时有代理端口时再放行 DNS(*:53)(seatbelt.rs:319-381,其中 :333-342)。被沙箱执行的进程还会带上 CODEX_SANDBOX=seatbelt 和 CODEX_SANDBOX_NETWORK_DISABLED=1(core/src/spawn.rs:21-26、core/src/sandboxing/mod.rs:172-178),Codex 自己的测试会用它跳过需要联网的用例。

Linux:bubblewrap + seccomp,Landlock 退居备份

linux-sandbox/src/lib.rs:1-5:进程内做 no_new_privs + seccomp,文件系统隔离交给 bubblewrap。landlock.rs:1-4 的注释写明 Landlock 只是 "legacy/backup utilities"。bwrap 参数里总有 --unshare-user;不继承 PID 命名空间时(!options.inherit_pid_namespace)加 --unshare-pid,受限网络时加 --unshare-net(linux-sandbox/src/bwrap.rs:283-304,其中 :299-301)。受限网络模式下 seccomp 禁掉 connect、bind、listen、sendto 等,socket 只允许 AF_UNIX(landlock.rs:202-231);io_uring 的三个 syscall 一律禁,ptrace 除了 VM socket 模式外也禁(landlock.rs:186-199)。

网络代理白名单

network-proxy 是本地的 HTTP(默认 127.0.0.1:3128)和 SOCKS5(默认 127.0.0.1:8081)代理,按域名 allow / deny,deny 永远优先;没有任何 allow 条目时全部拦截;默认拦私有和回环地址,解析到私有 IP 的域名即使被 allow 也拦;mode = "limited" 只放 GET、HEAD、OPTIONS(network-proxy/README.md)。沙箱只让进程连代理端口,代理再决定放不放。

8Guardian:让一个子 agent 替你审批

pub fn routes_approval_policy_to_guardian(
    policy: AskForApproval,
    reviewer: ApprovalsReviewer,
) -> bool {
    matches!(
        policy,
        AskForApproval::OnRequest | AskForApproval::Granular(_)
    ) && reviewer == ApprovalsReviewer::AutoReview
}

出处:ext/guardian-reviewer/src/routing.rs:54-62。

▶互动演示:审批策略 × 沙箱模式 × 命令类别

选审批策略、模型是否申请出沙箱、审批人的回答,下面的 3 × 6 矩阵按源码逻辑实时算出每格结果;点一格看逐步判定和对应源码。假设:没有 execpolicy 规则命中、没配网络代理、系统是 macOS(Seatbelt)、workspace-write 未开 network_access,"工作区外"指工作区、/tmp、$TMPDIR 以外的路径。

审批策略
模型参数
审批人回答
审批人
直接执行 要求审批 沙箱拒绝(失败交给模型) 升级重试(沙箱外重跑) 策略拒绝(不执行)

✎练习

题 1(配置):在 ~/.codex/config.toml 里写 approval_policy = "untrusted",按 commit 7993248 的源码会怎样?
core/src/config/mod.rs:3733 发现配置里是 UnlessTrusted 就返回 UnsupportedUntrustedApprovalPolicyError。core 只在项目被标为 untrusted、且没有显式配置时自动选上 UnlessTrusted(CLI -a 也不提供;app-server 协议的 override 仍接受 "untrusted")。C 说的别名是 on-failure。
题 2(编排):on-request + workspace-write,模型执行 touch ~/notes.txt,没带 require_escalated。会发生什么?
非危险命令在受限沙箱 + on-request 下是 Allow(exec_policy.rs),被拦后 wants_no_sandbox_approval(OnRequest) 返回 false,不重试。模型要写工作区外,得自己带上 require_escalated 和 justification 再来一次,这时才弹审批。
题 3(审批 ≠ 出沙箱):on-request + read-only,模型执行 rm -rf ./build(没带 require_escalated),用户点了批准。结果是?
危险命令在 on-request 下是 Prompt(不是 Forbidden,Forbidden 只在 never 下)。批准之后,sandbox_override_for_first_attempt 只在模型申请了 require_escalated 时才跳过沙箱,所以第一次仍在只读沙箱里跑,被拦;on-request 不升级重试。
题 4(规则):规则文件里有 prefix_rule(pattern = ["rm", "-rf"], decision = "forbidden")。用 codex execpolicy check 检查下面哪条命令,matchedRules 会是空的?
prefix_rule 按 token 字面匹配,-fr 和 -rf 是不同的 token(本机实测 rm -fr build 和 rm -r -f build 都返回 {"matchedRules":[]})。在 Codex 运行时,这条漏网的命令还会被危险命令启发式抓住(带 -f 的 rm),这就是多层防御的意义;测试时要把这类等价写法都列进 match。
题 5(越权矩阵):按上面演示的维度设计测试:5 种审批配置 × 3 种沙箱模式 × 6 类命令 × 模型是否申请出沙箱(2 种)。全组合一共多少格?
格
5 × 3 × 6 × 2 = 180。再乘上"审批人批准 / 拒绝"就是 360。全组合跑一遍可行但贵,Codex 自己的 core/tests/suite/approvals.rs 用 56 个 ScenarioSpec(linux aarch64 上 55 个) 挑代表格,每格写明预期结果(自动执行 / 审批后执行 / 文件没创建 / 输出含某句话)。

9面试要点

一句话讲清楚

Codex 对一条 shell 命令先查 Starlark 写的 execpolicy 规则,没命中再按"危险命令 × 审批策略 × 沙箱类型"判定要不要审批;审批(先 hook,hook 无结论再交 Guardian 或用户,二选一)通过后命令仍然默认在操作系统沙箱里跑(macOS Seatbelt 从 deny default 起步,Linux 用 bubblewrap + seccomp),被沙箱拦下后只有 untrusted 和 granular 开了 sandbox_approval 才会升级到沙箱外重跑。

追问准备

  1. 为什么有了审批还要沙箱?审批靠人或模型看命令文本,看不出 make test 里面会不会去写 ~/.ssh;沙箱在内核层面挡住实际的系统调用,和命令文本无关。反过来,沙箱只管读写和联网,管不了"在工作区里删光源码",所以危险命令还要审批。
  2. 为什么可写目录里还要保护 .git?写 .git/hooks 就能让代码在你下一次 git commit 时在沙箱外运行,等于越狱。
  3. execpolicy 的 allow 规则有什么风险?全部命中 allow 的命令会直接跳过沙箱。规则写宽了(比如 ["python"])等于给任意代码开了免沙箱通行证。
  4. 怎么测?做越权矩阵:审批配置 × 沙箱模式 × 命令类别 × 是否申请提权,每格写预期,用真实沙箱跑(Codex 的测试就这样做);另外单独测"拒绝识别"这类启发式的误判和漏判。

常见错误说法

❌ "审批通过了,命令就在沙箱外执行":只有模型申请了 require_escalated,或者 execpolicy 显式 allow,才会跳过沙箱。
❌ "Codex 的默认审批策略是 untrusted":默认是 OnRequest;untrusted 已经不能写进配置。
❌ "never 就是不设防":never 只是不问人,命令照样在沙箱里跑,危险命令直接拒绝。
❌ "workspace-write 下工作区里什么都能写":.git、.agents、.codex、.aws 仍然只读(.git / .agents / .aws 存在时;工作区根的 .codex 不存在也保护)。
❌ "Codex 用 Landlock 做 Linux 沙箱":这个 commit 里文件系统隔离靠 bubblewrap,Landlock 只是备用。

下一讲:Codex 的测试工程,看它怎么用假的模型服务器把这些路径确定地跑一遍。