B3|沙箱 × 审批 × ExecPolicy:能力、同意、前缀禁令是三套旋钮
先别把安全写成一句 system prompt
B2 把工具推进了 Orchestrator。下一件错事是把 Orchestrator 理解成「弹一个 Allow 按钮」。于是 --yolo 被当成「关提示」,沙箱被当成「macOS 才有的东西」,rm -rf 靠模型「答应不乱来」。
代码里安全是 三套独立旋钮,Orchestrator 只负责按顺序拧(tools/orchestrator.rs:1-7,run 里第一步就是 Approval,:141):
- 能做什么 — 操作系统层面的沙箱 / permission profile
- 要不要问 —
AskForApproval,以及问谁(人还是auto_review) - 哪些命令前缀直接禁掉 — ExecPolicy,spawn 之前的 Starlark 规则
拧成一套:要么 CI 卡死在审批上,要么信任目录等于裸跑。prompt 里的「不要访问 /」两边都不算数。
三个旋钮是三条独立的判断轴,不是一条链上先后依次判断——同一次 exec_command 会同时问过这三轴,缺任何一轴,另外两轴单独都堵不住漏洞:
三轴分别对应三种"如果没有会怎样":没有能力轴,审批点头就等于给了整台机器;没有同意轴,能力足够时就没人能拦;没有 ExecPolicy 轴,"永远不许跑 sudo" 只能写在 prompt 里,换个拼写就绕过去。--yolo(第 7 节)之所以危险,是因为它一次性把前两轴都拧到最松,只剩 ExecPolicy 一轴顶着。
1. 能力:物理上能不能,不是愿不愿意
配置入口曾经是 SandboxMode(protocol/src/config_types.rs:104-113):read-only(默认)、workspace-write、danger-full-access。落到运行时仍能投影成 SandboxPolicy(protocol/src/protocol.rs:1067-1118)——那是 兼容层。真正的能力档案是下一节的 PermissionProfile。
| 政策 | 磁盘 | 网络默认 |
|---|---|---|
ReadOnly | 不能写工作区 | 关,可单独打开 |
WorkspaceWrite | cwd(外加 writable_roots)可写 | 关,可单独打开 |
DangerFullAccess | 无限制 | 开。注释写着 Use with caution(:1072-1074) |
ExternalSandbox | 假定进程已在外部隔离里 | 只还管网络旋钮(:1085-1091) |
WorkspaceWrite 不是「整个 home 可写」。WritableRoot 的注释点名:.git、.codex,尤其 .git/hooks,必须在可写根下面保持只读,否则 agent 改 hook 就能提权(:1121-1125)。网络是第二条能力轨:NetworkAccess(:1049-1058),和磁盘不是同一个 bool。有网之后的托管 HTTP/SOCKS、allow/deny/ask、假凭证,见 G6——profile 开网 ≠ 启动代理。
能力旋钮回答的是 Seatbelt / bwrap / token 会不会拒绝这次 open/connect。它不回答「要不要弹窗」。可以「有写能力但每次都问」,也可以「没写能力所以问了也升不上去」——Orchestrator 里 owner 的 network policy 甚至禁止用升级沙箱来绕过(orchestrator.rs:155-163)。
没有独立的能力层会怎样?审批通过就等于 DangerFullAccess。模型只要骗到一次 Allow,就拥有整台机器。
2. Permission profile:三档是内置档案,不是最终配置模型
运行时类型是 PermissionProfile(protocol/src/models.rs:420-433):
| 变体 | 含义 |
|---|---|
Managed | Codex 自己编沙箱:磁盘条目 + 网络政策 |
Disabled | 不套外层沙箱(--yolo / Full Access 的能力侧) |
External | 隔离在调用方;这里只还管网络 |
三档内置 id(:407-414)::read-only、:workspace、:danger-full-access。用户 toml 里是 default_permissions = ":workspace",再加命名档案 [permissions.<id>],可以 extends 父档案(config/src/permissions_toml.rs:113-118)。解析会合并继承链、拒绝环和未定义父级(:122-142)。企业 allowed_permission_profiles / default_permissions 能锁死你选哪几份(config_requirements.rs:1038-1039)。
ActivePermissionProfile 是给 UI 看的 sidecar:稳定 id 和 extends,不要从编译后的条目反推名字(models.rs:436-453)。运行时必须遵守的是 PermissionProfile 本身。
编译发生在 compile_permission_profile(core/src/config/permissions.rs:376-382):把配置里的路径、glob、symbolic roots 物化成 FileSystemSandboxPolicy + NetworkSandboxPolicy。Turn 再按当前 environment 的 workspace roots 填一次(B6)。compatibility_sandbox_policy_for_permission_profile(sandboxing/src/manager.rs:693-702)才投影回 SandboxPolicy,给还在读三档枚举的调用方。Disabled → DangerFullAccess;External → ExternalSandbox(models.rs:686-701)。
所以 /permissions(E10)选的是 档案名,不是再拧一次 SandboxMode。CLI --sandbox 仍能派生内置档案(config_toml.rs 注明:有 default_permissions 时不要从 sandbox_mode 重建)。自定义 glob、继承、按环境覆盖,都只存在于 profile 这条路上。
B6 的 has_full_access 用的也是 profile:Never 且每个已选环境都是 Disabled。只把 SandboxMode 拧到 danger-full-access、远程附件还是 Managed,不算 Full Access。
没有独立的 profile 会怎样?所有能力都得塞进四个 SandboxPolicy 变体;远程 environment 无法带自己的磁盘条目;企业无法锁「只许 :workspace」。三档是内置快捷方式,不是配置模型的天花板。
3. 平台不同,决策必须统一
SandboxType(sandboxing/src/manager.rs:42-47)只有四种:None、MacosSeatbelt、LinuxSeccomp、WindowsRestrictedToken。get_platform_sandbox(:67-80)按 OS 选;Windows 还要 feature 打开才给 token 沙箱,否则是 None。
落到实现:
- macOS: Seatbelt 策略文件编进 crate(
sandboxing/src/seatbelt.rs:21-25,seatbelt_base_policy.sbpl等)。Orchestrator 还会把允许的 symlinkCODEX_HOME告诉 manager(orchestrator.rs:146-151)。 - Linux:
codex-linux-sandbox模块文档写明两件事同时做:进程内no_new_privs+ seccomp,以及 bubblewrap 做文件系统隔离(linux-sandbox/src/lib.rs:1-6)。landlock.rs把自己定位成 legacy/backup;现行文件系统限制在 bwrap(landlock.rs:1-4)。 - Windows:
SandboxType仍只有WindowsRestrictedToken(Feature::WindowsSandbox关着则get_platform_sandbox给None)。档位是WindowsSandboxLevel:Disabled/RestrictedToken/Elevated(protocol/src/config_types.rs的枚举)。开关键是experimental_windows_sandbox、elevated_windows_sandbox、windows_sandbox_service(features/src/lib.rs)。legacy restricted-token 管不住 managed networking 和 deny-read,那些必须 elevated 后端(windows-sandbox-rs/src/unified_exec/mod.rs模块头,以及managed networking requires the elevated Windows sandbox backend)。mxc-sandbox是 native MXC helper,不是 第四个SandboxType;windows-sandbox-service是本机帮手进程。Orchestrator 看见的仍是同一种SandboxAttempt。Windows 实现比 macOS/Linux 重,但决策面不另开一套审批。
Orchestrator 不 cfg 出三套审批逻辑。它选 SandboxAttempt,Runtime 的 run 拿着尝试去 spawn。失败后能不能升级,看 Sandboxable::escalate_on_failure(B2)。检测到「像沙箱拒绝」才升级;exit 2 / 126 / 127 是 quick reject,禁止当成沙箱拒绝(sandboxing/src/violation.rs:33-37)——shell 用错、命令不存在,升出沙箱会把 typo 变成裸跑。
没有「决策统一、实现分平台」会怎样?每个 Handler 自己 #[cfg(target_os)]。TUI 和远程 exec-server 的「workspace-write」会变成两种东西。A1 的多 OS 执行环境也就没法共用同一条 turn。
4. 同意:问不问、问谁,都不是沙箱
AskForApproval(protocol/src/protocol.rs:985-1007):
| 值 | 线上名字 | 含义 |
|---|---|---|
OnRequest | 默认;兼容 on-failure | 模型 / 政策认为该问就问 |
Never | never | 不问人。失败立刻回模型,不升级给用户(:1005-1007) |
UnlessTrusted | untrusted | 未信任项目:除非 ExecPolicy 明文允许,否则要批(:986-990) |
Granular | granular | 按通道开关:sandbox 升级、execpolicy prompt、skill、request_permissions、MCP elicitation(:1010-1024) |
codex exec 默认 Never(A1 写过)。那是 同意旋钮拧到不问,不是关沙箱。CI 仍然可以是 workspace-write。
问谁是另一条轴:ApprovalsReviewer(protocol/src/config_types.rs:176-189)。默认 user。auto_review(旧名 guardian_subagent)把升级请求交给一个专门 prompt 的子代理,按风险框架批或拒。CLI --approve-for-me(别名 not-so-yolo)走这条,并且 和 --sandbox / --yolo 互斥(utils/cli/src/shared_options.rs:43-50)。Guardian 是同意链上的评审员,不是第四套 OS 沙箱。评审员 Session 见 附录 A。
Never 遇上 ExecPolicy 的 prompt 规则,不是「悄悄允许」,而是把 prompt 变成 Forbidden(exec_policy.rs:210-221,:408-414)。Granular 还可以单独关掉 rules 通道,policy-rule 提示和 sandbox 升级提示互不覆盖(:213-215)。
没有独立的同意层会怎样?要么沙箱失败无法问人(能力不够却没人授权升级),要么「问过了」被理解成「已经拥有 DangerFullAccess」。两套旋钮就是为了让「这次允许」和「物理上能」不是同一个 bool。
user-verification crate 站在这三套旋钮之外,回答的是另一个问题:这是不是同一个人、同一台机器,不是「这条命令该不该跑」。它在 macOS 上用 Secure Enclave / Touch ID 建一把本机私钥,ensure_key 创建、verify 签一段挑战字节,注释写明「Implementations never perform network registration」——本机签名,不是替 Guardian 或审批做决定。目前只有 macOS 实现(其余平台是 unsupported 占位),还没有接进 AskForApproval 或 ExecPolicy 的判定路径。先知道它存在、和 Guardian/同意层是分开的轴,比现在就假设它已经参与决策更重要。
process-hardening crate 也在三套旋钮之外,防的是另一类攻击面:进程本身被 ptrace 挂上、被 LD_PRELOAD/DYLD_* 劫持加载、或者 core dump 把内存里的密钥写到磁盘。pre_main_hardening() 设计成用 #[ctor::ctor] 在 main() 之前跑:Linux 上 prctl(PR_SET_DUMPABLE, 0),macOS 上 ptrace(PT_DENY_ATTACH),两边都把 RLIMIT_CORE 设成 0、清掉危险前缀的环境变量。目前只在 voice-host 和 responses-api-proxy 的 main() 里被显式调用,主 CLI/TUI 进程并没有接这一层——这是进程自保,不是沙箱替身,抄的时候不要假设它已经罩住了整条 harness。
5. ExecPolicy:spawn 之前的前缀禁令,不靠模型自觉
第三套旋钮在 execpolicy/。规则文件是 Starlark(parser.rs:57-66,Dialect::Extended)。内置函数 prefix_rule(pattern, decision=...)(:347-360),decision 默认 allow。
Decision 只有三个值(decision.rs:9-16):
Allow— 不必再批Prompt— 要问人;approval_policy="never"时直接拒Forbidden— 不再考虑,拦住
匹配按命令前缀(PrefixPattern,rule.rs:37-58),还可以绑定 host 上的真实可执行路径,防止 ./git 冒充 git。未信任项目的默认同意是 UnlessTrusted:没有 allow 规则的命令都要问。这和 B5 / C1「未信任项目不加载项目层说明书」是同一信任边界的执行面。
ExecPolicy 发生在 spawn 之前。它不看命令输出,不看模型有没有在 prompt 里保证「只读」。该用机械拦截的(危险前缀、明确禁掉的程序)放这里;该留给 prompt 的(风格、何时用 skill)不要写成 forbidden。
没有这一层会怎样?沙箱只能说「这次 write 被 OS 拒了」,说不清「永远不许跑 sudo」。模型换一种拼写就能绕过「请不要 sudo」的说明书。前缀规则按 argv 匹配,比自然语言硬。
6. MCP 不被这套 OS 沙箱罩住
stdio MCP 服务器是 长生命周期进程,不是 per-command 的 Seatbelt 子进程。StdioServerLauncher 的职责是「进程放哪」(rmcp-client/src/stdio_server_launcher.rs:73-78)。
本地启动:Command::new,清环境再填 MCP 自己的 env,没有 SandboxManager(:281-287)。远程 executor 路径上 ExecParams.sandbox 显式 None(:630)。服务器要活过很多个 tool call;sandbox 的「失败升级再试一次」对长进程不成立;凭证和网络往往是服务级的。
那 MCP 的安全靠什么?审批和 elicitation(同意旋钮、Granular 的 mcp_elicitations)、网络政策、把 sandbox 状态当 meta 传给服务(B2 的 MCP handler),而不是把 Node/Python 服务器塞进和 ls 同一个 bwrap。D3 展开连接与供应链;这里只钉死:三套旋钮罩的是 exec/patch 这类短命令,罩不住 MCP 进程本身。
没有这层区分会怎样?要么每个 MCP call 都重新 spawn 进沙箱(连接和 OAuth 全裂),要么误以为「开了 workspace-write,MCP 也就被隔离了」——stdio 服务器从启动那一刻起就在 harness 旁边跑。
7. --yolo 只给已经隔离好的环境
CLI 上的真名是 --dangerously-bypass-approvals-and-sandbox,别名 --yolo。帮助文本写着:跳过所有确认、命令不进沙箱,EXTREMELY DANGEROUS. Intended solely for running in environments that are externally sandboxed.(shared_options.rs:52-58)
TUI 启动时它同时拧两套旋钮:SandboxMode::DangerFullAccess + AskForApproval::Never(tui/src/startup_orchestration.rs:29-33)。exec 同样把 sandbox_mode 打成 full access(exec/src/lib.rs:328-330)。UI 用 is_yolo_mode 检测的就是这个组合:Never,且 permission profile 是 Disabled 或 managed 下磁盘 Unrestricted + 网络 Enabled(tui/src/history_cell/session.rs:209-228)。
产品里还有 SandboxPolicy::ExternalSandbox:承认「隔离在外面」,本进程不再套一层 Seatbelt。--yolo 是给那种环境的开关,不是日常开发默认。容器、CI 的独立 VM、已经 sandbox-exec 过的外层,才是它的合法位置。本机交互 TUI 打开它,等于第三套 ExecPolicy 也失去「再问一次」的退路——Never 把 policy prompt 变成 Forbidden 或直接放行,取决于规则,但用户已经不在回路里。
没有「只给外层隔离」这条纪律会怎样?--yolo 会从「我已经在 VM 里」滑成「我嫌弹窗烦」。F2 把它列为反模式,证据是帮助文本和它同时关掉的两套旋钮,不是道德说教。
8. 结语:带走一句话
Codex 的安全模型不是「模型答应不乱来」,而是三套旋钮必须分开拧——能力现在是可继承的 permission profile(三档只是内置档案;Windows elevated / MXC 不是第四个 SandboxType)、审批管愿不愿意、ExecPolicy 管哪些前缀永远不许进门;MCP 和 --yolo 都站在这套模型的边界上,不是第四个 bool。