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 / forbidden | core/src/exec_policy.rs、execpolicy/ |
| 2. 危险命令判定 + 审批策略 | 没有规则命中时,按"是不是危险命令 × 审批策略 × 沙箱类型"算出 Skip / NeedsApproval / Forbidden | 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 |
编排顺序写在 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 | 从不问;失败直接回给模型 |
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"], # 加载时必须不命中
)
- 多条规则同时命中时取最严的:
forbidden>prompt>allow(execpolicy/README.md)。 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 只写了后者。
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(中间注释有省略)。
- 危险命令优先:不管沙箱是什么模式,危险命令在 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,但仍然在沙箱里跑。
- 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。
- 配置
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)。 - 审批的优先级:先跑 PermissionRequest hook,hook 没给结论时才轮到 Guardian(启用时)或用户(
core/src/tools/approvals.rs:505-507)。 - Guardian 输出 JSON:
outcome是allow/deny,risk_level是low/medium/high/critical(ext/guardian-reviewer/src/assessment.rs:85-116)。这是 LLM-as-Judge 用在安全门禁上的生产实例,第 6 周讲 Judge 时会再用到。
▶互动演示:审批策略 × 沙箱模式 × 命令类别
选审批策略、模型是否申请出沙箱、审批人的回答,下面的 3 × 6 矩阵按源码逻辑实时算出每格结果;点一格看逐步判定和对应源码。假设:没有 execpolicy 规则命中、没配网络代理、系统是 macOS(Seatbelt)、workspace-write 未开 network_access,"工作区外"指工作区、/tmp、$TMPDIR 以外的路径。
✎练习
~/.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。touch ~/notes.txt,没带 require_escalated。会发生什么?exec_policy.rs),被拦后 wants_no_sandbox_approval(OnRequest) 返回 false,不重试。模型要写工作区外,得自己带上 require_escalated 和 justification 再来一次,这时才弹审批。rm -rf ./build(没带 require_escalated),用户点了批准。结果是?sandbox_override_for_first_attempt 只在模型申请了 require_escalated 时才跳过沙箱,所以第一次仍在只读沙箱里跑,被拦;on-request 不升级重试。prefix_rule(pattern = ["rm", "-rf"], decision = "forbidden")。用 codex execpolicy check 检查下面哪条命令,matchedRules 会是空的?-fr 和 -rf 是不同的 token(本机实测 rm -fr build 和 rm -r -f build 都返回 {"matchedRules":[]})。在 Codex 运行时,这条漏网的命令还会被危险命令启发式抓住(带 -f 的 rm),这就是多层防御的意义;测试时要把这类等价写法都列进 match。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 才会升级到沙箱外重跑。
追问准备
- 为什么有了审批还要沙箱?审批靠人或模型看命令文本,看不出
make test里面会不会去写~/.ssh;沙箱在内核层面挡住实际的系统调用,和命令文本无关。反过来,沙箱只管读写和联网,管不了"在工作区里删光源码",所以危险命令还要审批。 - 为什么可写目录里还要保护 .git?写
.git/hooks就能让代码在你下一次git commit时在沙箱外运行,等于越狱。 - execpolicy 的 allow 规则有什么风险?全部命中 allow 的命令会直接跳过沙箱。规则写宽了(比如
["python"])等于给任意代码开了免沙箱通行证。 - 怎么测?做越权矩阵:审批配置 × 沙箱模式 × 命令类别 × 是否申请提权,每格写预期,用真实沙箱跑(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 的测试工程,看它怎么用假的模型服务器把这些路径确定地跑一遍。