Learning AI Quality 返回 KuthorX Blog II博客首页

第 17 章

第 3 周:Agent 要执行 rm -rf,谁来拦?

读 OpenAI Codex 源码里的沙箱与审批:AskForApproval 的四个取值、三种沙箱模式、可写目录里仍然只读的 .git、Starlark 写的 execpolicy 规则、审批 → 选沙箱 → 尝试 → 被拒后升级的编排顺序,以及在本机不调模型实测越权操作。一段讲解视频,一个审批策略 × 沙箱模式 × 命令类别的策略矩阵。

第 2 周写的 agent loop 里,工具就是一个普通函数:模型说调,循环就调。换成一个能跑 shell 的 Agent,模型说"执行 rm -rf ./build",循环也照做吗?Codex 的回答是叠好几层:先查用户写的规则,再看是不是危险命令、审批策略怎么配,需要时找人(或者另一个模型)审批,最后命令还要在操作系统沙箱里跑,被拦下后再决定要不要升级。这一章按源码把每一层拆开,再从测开的角度设计一张越权矩阵。

源码基于 openai/codex commit 7993248 (2026-10-01),下文路径都相对 codex-rs/。本机实验用 codex-cli 0.159.2(macOS),只跑不调模型的 codex sandbox 和 codex execpolicy check。本章只讲模型发起的 shell 命令(exec_command 工具);apply_patch 有自己的一套检查(core/src/safety.rs),不在讨论范围。

讲解视频

互动演示

选审批策略、模型是否申请出沙箱、审批人批准还是拒绝,下面的 3 × 6 矩阵(沙箱模式 × 命令类别)按源码逻辑实时算出每一格的结果:直接执行、要求审批、沙箱拒绝、升级重试、策略拒绝。点一格可以看逐步判定和对应的源码行。演示假设没有 execpolicy 规则命中、没配网络代理、系统是 macOS。页面底部有自动判分的练习。

互动演示:审批策略 × 沙箱模式 × 命令类别 在新标签页打开

一条命令要过几道关

层决定什么源码
1. execpolicy 规则用户写的 Starlark 规则,命中就给出 allow / prompt / forbiddencore/src/exec_policy.rs、execpolicy/
2. 危险命令判定 + 审批策略没有规则命中时,按"是不是危险命令 × 审批策略 × 沙箱类型"算出 Skip / NeedsApproval / Forbiddencore/src/exec_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

后三步的顺序写在编排器的模块注释里( core/src/tools/orchestrator.rs:1-7 ):

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).

审批在前、沙箱在后,意味着一件容易误解的事:审批通过不等于不进沙箱。后面会反复看到。

和第 2 周的 loop 比,多出来的东西是:工具执行之前有一个"判定 → 审批 → 选执行环境"的阶段,执行之后还有一个"失败是不是被沙箱挡的、要不要换环境重试"的阶段。这两个阶段的输入是配置(审批策略、沙箱模式、规则文件),输出决定了模型最终拿到什么样的 tool result。

审批策略 AskForApproval

protocol/src/protocol.rs:986-1009 :

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,
}

逐行看:enum 是"取值只能是下面几种之一"的类型,相当于 Python 的 Enum。#[serde(rename = "untrusted")] 规定这个值序列化成配置文件里的哪个字符串,#[serde(alias = "on-failure")] 表示读配置时 "on-failure" 也认作它,#[default] 标出默认值。Granular(...) 是带数据的枚举值,里面装着一组布尔开关。

代码里的枚举名配置里的字符串CLI -a含义
UnlessTrusted"untrusted"(已不能写进配置)无只给被标为不受信任的项目内部使用:没有规则显式放行的命令都要审批
OnRequest(默认)"on-request","on-failure" 是别名on-request沙箱内随便跑;模型显式申请出沙箱才问
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 里的 AskForApproval 也只剩 on-request、granular、never;本机 codex --help 的 -a 只列出 on-request 和 never(源码 utils/cli/src/approval_mode_cli_arg.rs 一致)。
  • 默认值怎么来的( core/src/config/mod.rs:3741-3750 ):没有显式配置时,项目受信任 → OnRequest;项目被标为不受信任 → UnlessTrusted;两者都不是 → AskForApproval::default(),也就是 OnRequest。也就是说,config.toml / -c 里写 untrusted 会报错,CLI -a 也不提供;core 加载配置时只在项目被标为 untrusted 且没有显式配置时自动选上它(app-server 协议的 override 仍接受 "untrusted",见 app-server-protocol/src/protocol/v2/shared.rs:207-210 )。

沙箱模式

配置项 sandbox_mode 有三个值(protocol/src/config_types.rs:104-114)。协议层的 SandboxPolicy 还多一个 external-sandbox,表示"进程已经在外部沙箱里,自己不再套"(protocol/src/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 )。workspace-write 默认可写 /tmp 和 $TMPDIR,用 exclude_slash_tmp、exclude_tmpdir_env_var 可以关掉(protocol.rs:1109-1119)。

可写目录里仍然只读的路径

protocol/src/permissions.rs:40-51 :

const PROTECTED_METADATA_GIT_PATH_NAME: &str = ".git";
const PROTECTED_METADATA_AGENTS_PATH_NAME: &str = ".agents";
const PROTECTED_METADATA_CODEX_PATH_NAME: &str = ".codex";
const PROTECTED_METADATA_AWS_PATH_NAME: &str = ".aws";

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

为什么偏偏是这四个?因为改了它们就能绕出沙箱:

  • .git:往 .git/hooks 写一个钩子,下次你自己 git commit 时,这段代码就在沙箱外、以你的身份执行。如果 .git 是 worktree / submodule 那种指向别处的文件,会顺着里面的 gitdir: 把真实目录也保护起来( permissions.rs:2347-2390 )。
  • .codex:Codex 自己的项目配置和规则在这里,改了就能给自己放权。工作区根目录的 .codex 即使还不存在也保护,“第一次创建"也要走审批(同一段代码 :2374-2381 的注释)。
  • .aws:源码注释原话是 “AWS profiles can select credential helpers that the application executes”,profile 里可以指定一个程序作为凭证助手,它会被别的程序执行。
  • .agents:agent 相关的元数据目录,存在时保护。

execpolicy:用 Starlark 写命令规则

规则文件放在配置层目录下的 rules/*.rules,默认文件名 default.rules(core/src/exec_policy.rs:54-56)。语法是 Starlark(Python 的一个受限子集,Bazel 也用它)。主力是 prefix_rule,按命令的 token 前缀匹配(execpolicy/README.md):

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"],               # 加载时必须不命中
)
  • 多条规则同时命中时取最严的:forbidden > prompt > allow。
  • match / not_match 在加载规则文件时就校验(execpolicy/src/rule.rs:246-306),任何一条不符合,整个文件加载失败。README 自己的说法是 “think of them as unit tests”。规则自带测试用例,这是这一章最值得测开借鉴的设计。
  • 解析器里还有 network_rule 和 host_executable 两个内置函数(execpolicy/src/parser.rs:410, :437),README 只介绍了后者。

allow 规则会让命令跳过沙箱。当一条命令拆出来的每一段都被 prefix_rule 显式 allow 时,判定是 Skip { bypass_sandbox: true },第一次就在沙箱外执行( exec_policy.rs:440-454 )。例外是策略里有"禁止读取"的路径:绕过沙箱会把禁读一起绕掉,所以这时仍然留在沙箱里(core/src/tools/sandboxing.rs:252-262)。写一条 prefix_rule(pattern = ["python"]) 等于给任意 Python 代码开了免沙箱通行证,allow 规则要写得窄。

动手:codex execpolicy check

在 /tmp/laq_sbx/policy.rules 写三条规则:上面那条 git 只读规则,加上:

prefix_rule(pattern = ["git", "push"], decision = "prompt", justification = "推送到远端要人确认")
prefix_rule(pattern = ["rm", "-rf"], decision = "forbidden", justification = "不要递归强删,改用 git clean -n 先预览")

本机 codex-cli 0.159.2 的真实输出:

$ codex execpolicy check --rules policy.rules git status
{"matchedRules":[{"prefixRuleMatch":{"matchedPrefix":["git","status"],"decision":"allow","justification":"只读的 git 子命令"}}],"decision":"allow"}

$ codex execpolicy check --rules policy.rules git push origin main
{"matchedRules":[{"prefixRuleMatch":{"matchedPrefix":["git","push"],"decision":"prompt","justification":"推送到远端要人确认"}}],"decision":"prompt"}

$ codex execpolicy check --rules policy.rules rm -rf build
{"matchedRules":[{"prefixRuleMatch":{"matchedPrefix":["rm","-rf"],"decision":"forbidden","justification":"不要递归强删,改用 git clean -n 先预览"}}],"decision":"forbidden"}

$ codex execpolicy check --rules policy.rules rm -fr build
{"matchedRules":[]}

$ codex execpolicy check --rules policy.rules rm -r -f build
{"matchedRules":[]}

后两条说明 prefix_rule 是按 token 字面匹配的:-fr、-r -f 和 -rf 意思一样,但不是同一个 token。在 Codex 运行时,这种漏网的写法还会被下一节的危险命令启发式抓住(它检查的是"带 f 的 rm”),这正是多层防御的意义。另外,check 子命令拿到的是原始 token;运行时会先把 bash -lc '...' 里的简单命令拆出来再逐段匹配(exec_policy.rs 的 commands_for_exec_policy_for_platform)。

把规则写错时的样子(not_match 里的例子其实会命中):

$ cat broken.rules
prefix_rule(
    pattern = ["git"],
    decision = "allow",
    not_match = ["git push origin main"],
)
$ codex execpolicy check --rules broken.rules git status
Error: failed to parse policy at broken.rules

Caused by:
    expected example to not match rule `PrefixRuleMatch { matched_prefix: ["git"], decision: Allow, resolved_program: None, justification: None }`: git push origin main

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

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

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 相同
}

match x { A => ..., B => ... } 相当于 Python 3.10 的 match 语句,A | B 表示"A 或 B"。读下来是四条规则:

  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,但"relying on the sandbox”,仍然在沙箱里跑。
  4. on-request 在受限沙箱里默认不问:只有模型在 exec_command 参数里写了 sandbox_permissions: "require_escalated"(申请出沙箱,还要附一句给用户看的 justification)才 Prompt。这就是"模型决定什么时候要审批"的实现方式。

Prompt 之后还有一道:never 和 granular 关了 sandbox_approval 时,这个 Prompt 会被 prompt_is_rejected_by_policy 改判为 Forbidden(exec_policy.rs:216-236)。模型在这两种策略下申请 require_escalated,exec_command 会直接回一句 you cannot ask for escalated permissions if the approval policy is ...(core/src/tools/handlers/unified_exec/exec_command.rs:341-350)。

编排:审批 → 选沙箱 → 尝试 → 被拒后升级

判定结果有三种:Skip(不用审批)、NeedsApproval、Forbidden(core/src/tools/sandboxing.rs:150-171)。ToolOrchestrator::run(orchestrator.rs:125 起)按下面的顺序处理:

  1. 审批:Forbidden 直接返回拒绝(orchestrator.rs:199-201);NeedsApproval 调 request_approval,先跑 PermissionRequest hook;hook 没给结论时,交给 Guardian(启用自动审批时)或用户,二者只走其一,Guardian 没被路由到才问用户(core/src/tools/approvals.rs:505-507, :554-570)。
  2. 选沙箱:sandbox_override_for_first_attempt( tools/sandboxing.rs:252-284 )只在两种情况下第一次就跳过沙箱:execpolicy 显式 allow,或者模型申请了 require_escalated。用户批准了一条危险命令,但模型没申请出沙箱,它照样在沙箱里跑。
  3. 尝试:在选好的沙箱里执行。
  4. 被拒后升级:
// 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));
}

出处 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”,代码在 orchestrator.rs:481-511)
granular(sandbox_approval=true)第一次没审批过,带着拒绝原因再问一次,同意后在沙箱外重跑

注意 untrusted 这一行的含义:在 untrusted 项目里,用户批准了 rm -rf ./build,如果它在只读沙箱里被拦下,Codex 会不再询问就在沙箱外重跑。用户批准的是"执行这条命令",不是"在沙箱里执行这条命令"。

“被沙箱拦下"是猜出来的

编排器怎么知道失败是沙箱造成的? sandboxing/src/denial.rs 的注释承认没有确定的办法:

/// We don't have a fully deterministic way to tell if our command failed
/// because of the sandbox - a command in the user's zshrc file might hit an
/// error, but the command itself might fail or succeed for other reasons.

实际规则是:退出码非 0,并且 stdout / stderr 里出现 operation not permitted、permission denied、read-only file system、seccomp、sandbox、landlock、failed to write file 之一(denial.rs:50-58);Linux 上被 seccomp 杀掉(退出码 128 + SIGSYS)也算。这会带来两类误判:

  • 误报:一个和沙箱无关的测试脚本,失败时恰好打印了 Permission denied,会被当成沙箱拒绝。在 untrusted 下,它会被拿到沙箱外重跑。
  • 漏报:curl 在断网的沙箱里输出 Could not resolve host(下一节实测,退出码 6),不含任何关键词,不会被识别为沙箱拒绝,也就不会升级重试,模型看到的只是一次普通的联网失败。

操作系统沙箱与网络

macOS:Seatbelt

sandboxing/src/seatbelt.rs:58-62 :

/// 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";

写死绝对路径而不是从 PATH 找,是防"有人在 PATH 前面放一个假的 sandbox-exec”。基线策略在 sandboxing/src/seatbelt_base_policy.sbpl:7-8 :

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

先全部禁止,再按需逐条 allow(这个文件头部注明参考了 Chrome 的沙箱策略)。可写目录以 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)。

Linux:bubblewrap + seccomp,Landlock 退居备份

  • linux-sandbox/src/lib.rs:1-5:进程内做 no_new_privs + seccomp,文件系统隔离交给 bubblewrap(bwrap)。
  • linux-sandbox/src/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 等 syscall,socket 只允许 AF_UNIX(landlock.rs:202-231);io_uring 的三个 syscall 在有 socket 限制的模式下一律禁(注释说它能绕过 socket() 建出 AF_VSOCK),ptrace 除 VM socket 模式外也禁(landlock.rs:186-199)。
  • Windows 用 restricted token 等机制,本章没有核对实现细节,不展开。

网络代理白名单

network-proxy 是 Codex 自带的本地 HTTP 代理(默认 127.0.0.1:3128)和 SOCKS5 代理(默认 127.0.0.1:8081),按域名 allow / deny(network-proxy/README.md):

  • deny 永远优先;没有任何 allow 条目时全部拦截;通配符 * 单独使用时,除非显式开启,否则会被拒绝(README:242),一般写 *.openai.com 这种。
  • allow_local_binding = false 时拦截回环和私有地址;解析到私有 IP 的域名即使在 allow 里也拦。
  • mode = "limited" 只放 GET、HEAD、OPTIONS,用于"只读联网"。

沙箱只让进程连代理端口,代理再决定放不放。这样网络策略就从"全开 / 全关"变成了可以按域名细调。

Guardian:让一个子 agent 替你审批

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

pub fn routes_approval_policy_to_guardian(
    policy: AskForApproval,
    reviewer: ApprovalsReviewer,
) -> bool {
    matches!(
        policy,
        AskForApproval::OnRequest | AskForApproval::Granular(_)
    ) && reviewer == ApprovalsReviewer::AutoReview
}
  • 配置 approvals_reviewer = "auto_review"(旧值 guardian_subagent 仍可用,默认 user,protocol/src/config_types.rs:183-201)。CLI 的 --approve-for-me 等于同时设置 approvals_reviewer="auto_review"、approval_policy="on-request"、sandbox_mode="workspace-write"(cli/src/main.rs:3172-3190 的测试断言了这三项)。
  • 只有 on-request 和 granular 会路由给 Guardian,untrusted 默认仍然问人。例外:开启 strict auto-review(require_guardian)时,本该问人的请求也转给 Guardian(ext/guardian-reviewer/src/routing.rs:121-131)。
  • Guardian 是一个专门写了 prompt 的子 agent,输出 JSON:outcome 是 allow / deny,risk_level 是 low / medium / high / critical(ext/guardian-reviewer/src/assessment.rs:85-116)。

这是 LLM-as-Judge 用在安全门禁上的生产实例。它和人工审批在同一个位置,所以它的漏判率、误杀率就是这道门的漏判率、误杀率,第 6 周讲 Judge 校准时会再回来看。

动手:在本机测越权

codex sandbox 不调模型,直接在 Codex 的沙箱里跑一条命令,适合用来实测。命令行参数以本机 0.159.2 的 --help 为准:-C 要和 --permission-profile 一起用(源码 cli/src/lib.rs:70-73 也是 requires = "permissions_profile"),所以下面直接 cd 到临时目录里跑。按源码 cli/src/debug_sandbox.rs:690-731,没用权限 profile 的旧式配置下,codex sandbox 默认用 read-only,不继承 ~/.codex/config.toml 里的 sandbox_mode,要用 -c sandbox_mode=... 显式指定。所有实验都在 /tmp/laq_sbx/ws(一个刚 git init 的临时仓库)里做。

$ cd /tmp/laq_sbx/ws
$ codex sandbox -- cat README.md                       # 默认 read-only:读
hi
$ codex sandbox -- touch new.txt                       # 默认 read-only:写
touch: new.txt: Operation not permitted
$ codex sandbox -- rm -rf build                        # 默认 read-only:强删
rm: build/a.o: Operation not permitted
rm: build: Operation not permitted

切到 workspace-write,并用两个 exclude 关掉 /tmp 和 $TMPDIR,这样 /tmp/laq_sbx/outside.txt 才算"工作区外"(WW 是下面四个 -c 参数的简写):

$ WW='-c sandbox_mode="workspace-write" -c sandbox_workspace_write.exclude_slash_tmp=true -c sandbox_workspace_write.exclude_tmpdir_env_var=true'
$ codex sandbox $WW -- touch new.txt                   # 写工作区:成功,exit=0
$ codex sandbox $WW -- touch .git/hooks/pre-commit
touch: .git/hooks/pre-commit: Operation not permitted
$ codex sandbox $WW -- sh -c 'echo x >> .git/config'
sh: .git/config: Operation not permitted
$ codex sandbox $WW -- mkdir .codex
mkdir: .codex: Operation not permitted
$ codex sandbox $WW -- touch /tmp/laq_sbx/outside.txt
touch: /tmp/laq_sbx/outside.txt: Operation not permitted
$ codex sandbox -c 'sandbox_mode="workspace-write"' -- touch /tmp/laq_sbx/outside.txt   # 不加 exclude:/tmp 默认可写,exit=0
$ codex sandbox $WW -- curl -sS -m 8 -o /dev/null -w '%{http_code}\n' https://example.com
curl: (6) Could not resolve host: example.com
000
$ codex sandbox $WW -c sandbox_workspace_write.network_access=true -- curl -sS -m 8 -o /dev/null -w '%{http_code}\n' https://example.com
200
$ codex sandbox $WW -- sh -c 'env | grep ^CODEX_SANDBOX'
CODEX_SANDBOX_NETWORK_DISABLED=1
CODEX_SANDBOX=seatbelt
$ codex sandbox -c 'sandbox_mode="workspace-write"' -- rm -rf build                    # 工作区内强删:成功,exit=0

(zsh 下 $WW 不会自动按空格拆开,实际是用 eval codex sandbox $WW -- ... 跑的。)几点观察:

  • 只读子路径确实生效:.git/hooks、.git/config 写不进,工作区根目录下还不存在的 .codex 也建不了。
  • 断网发生在 DNS 解析这一步,curl 的报错里没有 operation not permitted 之类的关键词,对应上一节说的"漏报"。
  • 最后一条提醒:沙箱不管"在工作区里删光东西"。rm -rf build 在 workspace-write 里完全合法,能拦它的只有审批这一层,这就是危险命令启发式要存在的原因。
  • --log-denials(抓 macOS 沙箱拒绝日志)这次输出 None found.,原因没有深究。

测开视角:越权矩阵怎么设计

这一章的所有规则都可以收成一个表:审批配置 × 沙箱模式 × 命令类别 × 模型是否申请出沙箱,每一格写预期结果。按互动演示的取法,5 种审批配置(untrusted、on-request、granular 开 / 关 sandbox_approval、never)× 3 种沙箱 × 6 类命令 × 2 = 180 格;审批人批准时,按源码逻辑算出来是直接执行 42、要求审批 58、沙箱拒绝 26、升级重试 12、策略拒绝 42(视频里那张彩色网格就是这 180 格)。

Codex 自己就是这么测的。core/tests/suite/approvals.rs 里定义了一个 ScenarioSpec 结构体:

struct ScenarioSpec {
    name: &'static str,
    approval_policy: AskForApproval,
    sandbox_policy: SandboxPolicy,
    action: ActionKind,
    sandbox_permissions: SandboxPermissions,
    features: Vec<Feature>,
    outcome: Outcome,
    expectation: Expectation,
}

(approvals.rs:639-648)然后在 scenarios()(:907 起)里列了 56 个场景(其中 1 个在 linux aarch64 上被 #[cfg] 排除,那里是 55 个),名字就是用例说明,例如 read_only_on_request_requires_approval、workspace_write_never_blocks_outside_workspace、simple_command_escalation_granular_sandbox_disabled_rejects。outcome 写"会不会弹审批、审批人怎么回",expectation 写"文件有没有被创建、输出里有没有某句话",测试函数 approval_matrix_covers_group(:1924)按组遍历执行。它在真实沙箱里跑命令,模型由 mock 服务器扮演,这一点下一讲细看。

用 Python 写自己的版本,结构是一样的:

import itertools

POLICIES = ["untrusted", "on-request", "granular_on", "granular_off", "never"]
SANDBOXES = ["read-only", "workspace-write", "danger-full-access"]
ACTIONS = ["read_ws", "write_ws", "write_git", "write_outside", "network", "rm_rf"]

cells = list(itertools.product(POLICIES, SANDBOXES, ACTIONS, [False, True]))
print(len(cells))  # 180

真实输出是 180。设计这张表时要注意:

  1. 预期从规格推,不从实现抄。每一格先按文档 / 设计写预期,再跑;和实现不一致时去查是谁错了。直接把被测代码的逻辑抄进测试,只会测出"代码和自己一致"。
  2. oracle 要看副作用,不只看返回值。“写工作区外"的断言是"文件不存在”,“联网"的断言是"对端没收到请求”(Codex 的测试用一个本地 HTTP 端点判断)。只断言退出码非 0,分不清是沙箱挡的还是命令本身写错了。
  3. 每层单独测,再测组合。execpolicy 有 match / not_match 自检,危险命令启发式有自己的单元测试(is_dangerous_command.rs 末尾),沙箱有 codex sandbox 可以直接打;矩阵测的是它们叠起来之后的行为。
  4. 专门测启发式的误判。等价写法(rm -fr、rm -r -f、sudo rm -rf、bash -lc 'rm -rf x')一组;会被误识别为沙箱拒绝的输出(命令自己打印 Permission denied)一组;不会被识别的拒绝(断网的 curl)一组。
  5. 组合太多就分层抽样。全组合 180 格(乘上批准 / 拒绝是 360)在真实沙箱里跑是可以的,但再加上 Linux / Windows、托管网络、权限 profile 就会爆炸。先保证每个维度的每个取值至少出现一次,再把"会改变结果的交互"(比如审批 × 是否申请出沙箱)做全组合。Codex 的 56 个场景就是这样挑出来的。

常见错误说法

  • “审批通过了,命令就在沙箱外执行”:只有模型申请了 require_escalated,或者 execpolicy 显式 allow,才跳过沙箱。批准一条没申请出沙箱的命令,它照样在沙箱里跑。
  • “Codex 的默认审批策略是 untrusted”:默认是 OnRequest;untrusted 已经不能写进配置,写了会报错,只在项目被标为不受信任时由代码内部选上。
  • “never 就是不设防”:never 只是不问人,非危险命令照样在沙箱里跑,危险命令直接拒绝。
  • “workspace-write 下工作区里什么都能写”:.git、.agents、.codex、.aws 仍然只读(.git / .agents / .aws 存在时;工作区根的 .codex 不存在也保护);反过来,/tmp 和 $TMPDIR 默认是可写的。
  • “沙箱能防住 rm -rf”:沙箱只管写到哪里,管不了"在工作区里删光东西",那要靠审批。
  • “Codex 用 Landlock 做 Linux 沙箱”:这个 commit 里文件系统隔离靠 bubblewrap,Landlock 只是备用。

下一讲:Codex 的测试工程。它怎么用一个假的模型服务器,把这一章的每条审批和沙箱路径确定地跑一遍。