数据截至 (上游 commit f1087ff151b0)
Codex 安全模型 — 审批策略 × OS 沙箱 × 失败再升级
这章讲什么: Codex 敢在你真机上跑命令、改文件,靠的是三层叠起来的护栏。本章讲清这三层各管什么、怎么协作:审批策略(哪些动作要问你)、沙箱策略(命令能动哪些资源)、以及把二者粘起来的
ToolOrchestrator(沙箱被拒时按策略再升级)。这是 Codex 区别于「玩具 agent」的地方。
1. 它要解决的小问题
你想让 agent 自主跑命令(跑测试、装依赖、改文件),但又怕它:删错 文件、把密钥发到外网、改坏 .git/hooks 提权。
两难:
- 管太松 → 危险,可能毁你机器或泄密;
- 管太严(每条命令都问你) → 烦,失去自动化意义。
Codex 的答案是把控制拆成两个正交的旋钮,再加一层智能重试:
| 旋钮 | 管什么 | 枚举 |
|---|---|---|
审批策略 AskForApproval | 哪些动作需要你点头 | UnlessTrusted / OnRequest / Granular / Never |
沙箱策略 SandboxPolicy | 命令实际能动哪些资源 | ReadOnly / WorkspaceWrite / DangerFullAccess / ExternalSandbox |
二者正交:你可以「自动放行,但关进只读沙箱」,也可以「全权访问,但每步都问我」。
2. 旋钮一:审批策略(谁要问你)
AskForApproval 定义在 protocol/src/protocol.rs:924,四档:
| 档位 | 含义 |
|---|---|
UnlessTrusted(序列化名 untrusted) | 只有「已知安全且只读」的命令自动放行,其余都问你 |
OnRequest(默认) | 由模型决定何时请你批准 |
Granular(...) | 细粒度开关:分别控制 shell 审批、规则提示、技能脚本、request_permissions、MCP elicitation 等是否允许 |
Never | 永不问你;命令失败直接把错误返回模型,不升级给你 |
直觉:UnlessTrusted 最谨慎(默认怀疑),Never 最放手,OnRequest 把判断权交给模型,Granular 给你逐类微调。
3. 旋钮二:沙箱策略(能动哪)
SandboxPolicy 定义在 protocol/src/protocol.rs:1010,描述命令被允许触碰的资源边界:
| 策略 | 文件 | 网络 |
|---|---|---|
ReadOnly | 只读 | 默认禁(可开 network_access) |
WorkspaceWrite | 只读全盘 + 可写工作区(cwd、可加 writable_roots、默认含 /tmp 与 TMPDIR) | 默认禁 |
DangerFullAccess | 无限制 | 无限制 |
ExternalSandbox | 已在外部沙箱中,放开磁盘、按外部设置定网络 | 由外部定 |
一个值得学的提权防护: WorkspaceWrite 即便把某个根设为可写,也会把根下一些敏感子路径设回只读——尤其是 .git/hooks、.codex 这类「改了就能提权」的目录。看 WritableRoot 的文档注释:
// codex-rs/protocol/src/protocol.rs:1038 起(节选)
/// 用来确保:可写根下那些「一旦被改就能提升 agent 权限」的目录
/// (如 .codex、.git,尤其 .git/hooks)不会被 agent 修改。
pub struct WritableRoot {
pub root: AbsolutePathBuf,
pub read_only_subpaths: Vec<AbsolutePathBuf>, // 这些子路径保持只读
pub protected_metadata_names: Vec<String>, // 这些元数据名禁止新建/替换
}
直觉:「可写工作区」不等于「整个工作区都能改」——会改命运的元数据被单独锁住。这是个很实在的安全细节。
4. 沙箱靠 OS 真隔离,不是「自律」
Codex 的沙箱不是「让模型保证不乱来」,而是操作系统层面真的把进程关进笼子,跑命令的子进程在内核层面就动不了笼子外的东西。三套实现按平台分:
| 平台 | 机制 | 枚举值 |
|---|---|---|
| macOS | Seatbelt(sandbox-exec + .sbpl 策略) | SandboxType::MacosSeatbelt |
| Linux | Landlock + seccomp | SandboxType::LinuxSeccomp |
| Windows | 受限令牌(restricted token) | SandboxType::WindowsRestrictedToken |
| 任意 | 不沙箱 | SandboxType::None |
枚举见 sandboxing/src/manager.rs:37。SandboxManager 负责把一条命令「包」进对应沙箱(transform/select_initial,manager.rs:334 起)。
macOS 的策略是一份 .sbpl(Seatbelt Policy Language),精神是「默认全禁,再逐项开口子」——这是最稳的安全姿势:
; codex-rs/sandboxing/src/seatbelt_base_policy.sbpl(节选)
(version 1)
(deny default) ; 关键:默认全部拒绝
(allow process-exec) ; 再按需放开:允许执行子进程
(allow process-fork)
(allow file-write-data ; 只放开极少数白名单,如 /dev/null
(require-all (path "/dev/null") (vnode-type CHARACTER-DEVICE)))
记住一句:
(deny default)起手——任何没被显式allow的能力都被内核挡掉。可写区域、网络等再由SandboxPolicy翻译成具体的 allow 规则注入。
5. 把两个旋钮粘起来:ToolOrchestrator
光有两个旋钮还不够。真正聪明的地方是 ToolOrchestrator——每个会动副作用的工具(shell / apply_patch)都经它走一套固定流程。文件顶部注释把它讲得极清:
// codex-rs/core/src/tools/orchestrator.rs:1 起(节选)
// 审批 → 选沙箱 → 尝试 → 被拒则用「升级后的沙箱策略」重试
// (靠缓存,重试时不再重复要审批)
流程图:
工具调用进来
│
① 审批:按 AskForApproval 判断
│ ├─ Skip → 直接放行
│ ├─ Forbidden → 拒绝
│ └─ NeedsApproval → 问你 / 问 guardian,拒了就终止
▼
② 选沙箱:按 SandboxPolicy 选 seatbelt/landlock/None
▼
③ 第一次尝试(在沙箱里跑)
│
├─ 成功 → 返回输出
└─ 被沙箱拒(SandboxErr::Denied)
│
▼
④ 升级判定:这个工具/策略允许「脱沙箱重试」吗?
├─ 允许 → (必要时再要一次审批) 脱沙箱重跑
└─ 不允许 → 把沙箱拒绝原样返回模型
怎么读这张图: 左边一路顺下来是「顺利放行」;右下角的分支是精华——沙箱拒绝不等于失败,而是触发一次「要不要升级权限重试」的决策。
真实锚点:
- 编排主流程
ToolOrchestrator::run(orchestrator.rs:134),// 1) Approval(:151)、// 2) First attempt under the selected sandbox(:223)。 - 审批要求的三态
ExecApprovalRequirement::{Skip, Forbidden, NeedsApproval}(orchestrator.rs:159-221)。 - 第一次尝试被拒后的升级逻辑:匹配
SandboxErr::Denied(orchestrator.rs:297),据escalate_on_failure()、unsandboxed_execution_allowed(...)、wants_no_sandbox_approval(...)等决定是否脱沙箱重试(:321-:391)。 - 升级重试默认不再重复要审批——靠「已批准」缓存
should_bypass_approval(approval_policy, already_approved)(:389-391),除非是严格自动审查(strict auto-review)需要 guardian 重新过一遍。
这套设计的巧妙:先用最严的沙箱试,大多数命令在沙箱里就跑通了,你完全无感;只有真撞 墙(比如要写沙箱外的路径)才升级,而升级与否由你设的审批策略说了算。安全与省心由此兼得。
6. 命令是否「已知安全」:assess 系列
审批那一步要回答「这命令安不安全到能自动放行?」。这由 safety.rs 的 assess_* 系列裁决,返回一个 SafetyCheck:
| 结果 | 含义 |
|---|---|
AutoApprove { .. } | 直接放行(可能附带选好的沙箱) |
AskUser | 升级给你点头 |
Reject { .. } | 直接拒绝 |
以补丁为例,assess_patch_safety(safety.rs:26)逻辑骨架:
- 若审批策略是
UnlessTrusted→ 直接AskUser(默认怀疑,补丁一律问你); - 若是
Never且沙箱不允许无沙箱执行 → 倾向Reject; - 否则结合沙箱类型给
AutoApprove。
SafetyCheck 枚举与各分支见 safety.rs:20、:39-108。AskForApproval::UnlessTrusted 那条所说的「known safe 只读命令」由 is_safe_command() 判定(注释 protocol.rs:902-904)。
7. 三层怎么协作(串起来看)
模型要跑一条命令
│
▼
[审批策略] 这条要问你吗? ──问── 你/guardian 点头或拒绝
│ 不用问 / 已批准
▼
[沙箱策略] 该把它关进哪种笼子?(ReadOnly/WorkspaceWrite/...)
│
▼
[OS 沙箱] seatbelt / landlock 真隔离地跑
│
├─ 通过 → 输出回灌模型
└─ 被拒 → [Orchestrator] 按审批策略决定:升级重试,还是把拒绝告诉模型
三层各司其职、正交可调,正是 Codex 能「自动化但不失控」的底座。
8. 边界与局限
- 沙箱依赖平台能力。 Linux 上若内核不支持 landlock、或缺
codex-linux-sandbox可执行,会退化(SandboxTransformError::MissingLinuxSandboxExecutable等,sandboxing/src/lib.rs:64起)。DangerFullAccess则等于不沙箱。 - 网络默认禁、按需开。
ReadOnly/WorkspaceWrite默认不放网络,需显式network_access: true;放网络后还有单独的网络审批/代理路径(orchestrator 里的network_approval系列)。 - 审批可被关到底。
Never+DangerFullAccess等于完全放手,该组合把安全责任全交给用户,文档/UI 会警示。 - 「升级」是双刃。 脱沙箱重试虽方便,但意味着权限抬升;Codex 用「是否已批准 / 是否 strict auto-review / 沙箱策略是否允许无沙箱执行」多个条件层层设防,改这块务必读全
orchestrator.rs:289-460的分支。
9. 代码地图
| 主题 | 文件 | 符号 |
|---|---|---|
| 审批策略枚举 | codex-rs/protocol/src/protocol.rs | AskForApproval、GranularApprovalConfig |
| 沙箱策略枚举 | codex-rs/protocol/src/protocol.rs | SandboxPolicy、WritableRoot |
| 安全裁决 | codex-rs/core/src/safety.rs | assess_patch_safety、SafetyCheck |
| 工具编排(三层粘合) | codex-rs/core/src/tools/orchestrator.rs | ToolOrchestrator::run、run_attempt、ExecApprovalRequirement |
| 沙箱选择/变换 | codex-rs/sandboxing/src/manager.rs | SandboxManager、SandboxType、select_initial |
| macOS 策略 | codex-rs/sandboxing/src/seatbelt_base_policy.sbpl | (deny default) |
| Linux 沙箱 | codex-rs/sandboxing/src/landlock.rs、codex-rs/linux-sandbox/src/ | landlock、bwrap |
| 沙箱被拒判定 | codex-rs/sandboxing/src/denial.rs | is_likely_sandbox_denied |