第 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 / forbidden | core/src/exec_policy.rs、execpolicy/ |
| 2. 危险命令判定 + 审批策略 | 没有规则命中时,按"是不是危险命令 × 审批策略 × 沙箱类型"算出 Skip / NeedsApproval / Forbidden | core/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"。读下来是四条规则:
- 危险命令优先:不管沙箱是什么模式,危险命令在 never 下直接 Forbidden,其他策略下都要审批。源码注释的意思是:宁可问用户,但用户关掉了提问,就只能拒绝。
- “危险"的定义很窄。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都不在名单里,要靠沙箱和规则兜底。 - never 不等于不设防:never 下非危险命令是 Allow,但"relying on the sandbox”,仍然在沙箱里跑。
- 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 起)按下面的顺序处理:
- 审批:
Forbidden直接返回拒绝(orchestrator.rs:199-201);NeedsApproval调request_approval,先跑 PermissionRequest hook;hook 没给结论时,交给 Guardian(启用自动审批时)或用户,二者只走其一,Guardian 没被路由到才问用户(core/src/tools/approvals.rs:505-507, :554-570)。 - 选沙箱:
sandbox_override_for_first_attempt(tools/sandboxing.rs:252-284)只在两种情况下第一次就跳过沙箱:execpolicy 显式 allow,或者模型申请了require_escalated。用户批准了一条危险命令,但模型没申请出沙箱,它照样在沙箱里跑。 - 尝试:在选好的沙箱里执行。
- 被拒后升级:
// 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。设计这张表时要注意:
- 预期从规格推,不从实现抄。每一格先按文档 / 设计写预期,再跑;和实现不一致时去查是谁错了。直接把被测代码的逻辑抄进测试,只会测出"代码和自己一致"。
- oracle 要看副作用,不只看返回值。“写工作区外"的断言是"文件不存在”,“联网"的断言是"对端没收到请求”(Codex 的测试用一个本地 HTTP 端点判断)。只断言退出码非 0,分不清是沙箱挡的还是命令本身写错了。
- 每层单独测,再测组合。execpolicy 有
match/not_match自检,危险命令启发式有自己的单元测试(is_dangerous_command.rs末尾),沙箱有codex sandbox可以直接打;矩阵测的是它们叠起来之后的行为。 - 专门测启发式的误判。等价写法(
rm -fr、rm -r -f、sudo rm -rf、bash -lc 'rm -rf x')一组;会被误识别为沙箱拒绝的输出(命令自己打印Permission denied)一组;不会被识别的拒绝(断网的curl)一组。 - 组合太多就分层抽样。全组合 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 的测试工程。它怎么用一个假的模型服务器,把这一章的每条审批和沙箱路径确定地跑一遍。