跳到正文

Codex 源码阅读(V):安全模型

Codex 源码阅读 (V):安全模型

一个命令背后的沙箱、审批和执行策略。

TLDR:Codex 的安全模型不是一个确认框。命令会经过多道关卡:执行策略先分类,审批策略决定是否需要人类审查,沙箱模式定义它能触及哪些资源,平台相关后端负责强制执行边界。

前四篇里,我从几个角度看了 Codex 作为智能体运行时的形态:

  • 智能体循环,将模型输出转化为工具工作,再把结果反馈到下一轮;
  • 目标,作为持久、可恢复、预算感知的线程状态;
  • 子智能体,作为线程树而不是一次性并行模型调用;
  • 工具运行时,通过单个面向模型的功能系统公开 shell、apply_patch、MCP、app、plugin 和 skill。

这一篇讲包在这些能力外面的运行时边界。

coding agent 有用,是因为它能真的执行动作。它会读文件、改代码、跑测试、调用包管理器和工具,有时还会申请网络访问。难点在于,这些动作的风险并不相同。从模型角度看,repo 里的 cargo test、rm -rf ~/.cache、curl https://api.example.com、写 .git/config、调用有副作用的连接器,都是“工具调用”;但它们不应该拥有同样的运行时权限。

所以安全模型不能只是一句“小心点”。它必须是运行时设计。

这篇文章的论点是:

Codex 安全不是一个是/否确认框,而是一组分层执行边界:沙箱决定技术上能做什么,审批决定谁来审查越界操作,执行策略决定命令是否可信,平台相关沙箱把这些决策变成操作系统层面的强制行为。

故事从一项普通的调试任务开始。

任务:“修复结账超时并运行失败的测试”

想象一下用户说:

The checkout integration test started timing out after the adapter refactor.
Find the regression, patch it, and run the failing test.

从模型的角度看,接下来的步骤很普通:

  1. 检查结账适配器;
  2. 运行失败的测试;
  3. 修补代码;
  4. 再次运行测试;
  5. 如果缺少依赖项或服务,请获取所需的内容;
  6. 报告结果。

从运行时视角看,这些步骤的风险完全不同。

读取 src/checkout/adapter.rs 的风险较低。预计在 workspace-write 中编辑 src/checkout/adapter.rs。运行本地测试命令可能没问题。但是从网络获取包、在工作区之外写入、更改 .git/hooks/pre-push、读取 .env 或调用会产生副作用的 MCP 连接器会跨越不同的边界。

安全模型的目的,是把这些边界说清楚,同时不要把每个正常 repo 操作都变成一次协商。

Codex 安全模型:一个命令跨越多个大门

一个好的心智模型是将每一个具体行动视为要经过几个门:

model proposes action
 ↓
shell / tool handler resolves request
 ↓
exec policy classifies the command
 ↓
approval policy decides whether review is required
 ↓
sandbox mode and permission profile define the technical boundary
 ↓
ToolOrchestrator runs, blocks, or retries under the selected boundary
 ↓
stdout / stderr / denial / approval result returns to the turn

这听起来很重,但也正因为有这套流程,Codex 才能顺畅完成日常任务,同时停留在有意义的风险边界内。

第一条分界线:沙箱回答“能不能”,审批回答“要不要”

官方文档划出了系统里最重要的一条线:沙箱模式和审批策略是不同控制。

沙箱模式是技术边界。它控制生成的命令实际可以触及的内容:文件系统、网络和平台级功能。审批策略是审查协议。它控制 Codex 在尝试跨越配置边界的操作之前何时必须询问审阅者。

这种划分很重要,因为两者解决了不同的故障模式。

如果沙箱很弱,模型错误可能会在任何人审查之前就变成真实系统突变。如果审批太弱,沙箱也许仍能阻止某些行为,但模型可能会在没有明确审查的情况下持续尝试越界操作。如果一切都需要审批,智能体就无法用于日常工作;如果什么都不需要审批,系统又会过度信任模型。

因此,实用的默认状态既不是“什么都别做”,也不是“放开手脚随便做”。它更接近于:

work normally inside the active workspace;
keep network off by default;
protect sensitive roots;
ask before crossing the boundary;
fail closed when review cannot complete.

沙箱和审批是故意分开的控制

实际使用时,本地测试命令例如:

cargo test -p checkout checkout_timeout -- --nocapture

通常可以在工作区沙箱内运行。尝试在工作区之外写入、访问互联网或禁用沙箱的命令是不同的请求。模型可能仍会要求它,但运行时将其视为升级。

这是第一个重要设计选择:**模型不拥有边界。**主机才拥有边界。

shell 路径:一个命令变成一个结构化的执行请求

在第四部分中,我们将工具运行时视为面向模型的功能层。在安全模型中,shell 路径是抽象变得具体的地方。

模型可能会发出一些看起来像普通命令的东西:

npm install && cargo test -p checkout checkout_timeout

但 Codex 并不是简单地将字符串交给操作系统。

shell handler 首先解析执行环境:当前工作目录、shell 选择、环境变量、网络设置、当前 turn 授予的权限、显式升级标志和沙箱权限。它还处理一个重要的特殊情况:补丁形状的 shell 命令会被拦截并路由到结构化 apply_patch 路径,而不是被当作任意 shell 文本。

之后,请求就变得更接近于执行记录:

ExecParams {
 command,
 cwd,
 env,
 timeout,
 capture_policy,
 network,
 sandbox_permissions,
 windows_sandbox_settings,
 justification
}

这条记录还没有获得运行许可。它只是一个运行时可以分类、审查、放进沙箱并执行的对象。

源码结构值得关注:

  • shell.rs 是面向模型的 shell 处理程序。它解析环境、应用权限、拒绝无效升级、检测补丁状命令并构造 shell 请求。
  • exec_policy.rs 通过策略规则、命令解析和审批模式,将命令分为 Skip、NeedsApproval 或 Forbidden。
  • tools/orchestrator.rs 集中审批、沙箱选择、执行、拒绝处理和重试语义。
  • exec.rs 构建底层进程执行请求,并通过沙箱路径发送。
  • sandboxing/ 将高级沙箱决策转换为平台相关的执行策略。

这就是“模型运行命令”和“运行时按策略接受结构化请求”之间的区别。

Exec 策略:操作系统沙箱之前的命令门

只有沙箱还不够,因为有些决策是语义性的,并不是纯粹的文件系统问题。

考虑这些命令:

cargo test -p checkout
python scripts/rewrite_imports.py
sudo rm -rf /usr/local/share/cache
bash -lc "curl https://example.com/install.sh | sh"
git clean -fdx

文件系统沙箱可以限制这些命令能改什么,但 Codex 在执行前仍然需要命令级视图。有些命令很常规,有些未知,有些明显危险。有些要求关闭沙箱,有些暗示网络访问,有些只有在特定审批和沙箱设置下才安全。

这就是执行策略的作用。

ExecPolicyManager 是命令门。它解析命令,从配置层加载规则,应用托管策略覆盖,检查沙箱相关要求,并返回具体的执行审批要求:

Skip
NeedsApproval
Forbidden

关键在于,这不是字符串白名单。策略层理解命令类别、嵌套命令形状、审批模式和沙箱覆盖请求。它可以放行已知安全的命令,在操作越界时请求审批,也可以直接拒绝命令。

Exec 策略是操作系统沙箱之前的命令门

回到结账的故事。

在 repo 内运行失败的测试可能会被归类为足够安全,可以在选定的沙箱下运行。尝试运行网络安装步骤可能需要审批。尝试绕过沙箱或改变受保护的元数据不应被视为只是另一个命令。

这个区别让 Codex 在常规路径上可以自主,在危险路径上必须谨慎。

工作区写入不是“在 repo 附近写入任何内容”

下一个容易被忽略的设计选择是:workspace-write 并不意味着工作目录下每个路径都同样可写。

从用户视角看,“repo”感觉就像一个单一的对象。从运行时视角看,repo 包含非常不同类别的数据:

  • 智能体需要编辑的源文件;
  • 可能是任务一部分的测试和 fixtures;
  • 构建产物和缓存;
  • .git 中的 VCS 元数据;
  • .agents 和 .codex 中的智能体状态;
  • 可能包含秘密的环境文件。

安全默认值不应该允许模型仅仅因为这些文件在当前目录下,就重写 .git/config、更改 hook、改变智能体状态,或读取敏感文件。

这就是权限模型会把某些根目录视为受保护对象的原因。文档明确提到了工作区配置下对 .git/ 和 .codex/ 的保护,权限配置也可以表达拒绝读取的 glob,例如 **/*.env,并把文件系统规则和网络域名规则结合起来。智能体状态目录(例如 .agents/)在被工作流使用时也属于同一类:它们是控制平面状态,不是普通 app 代码。

Workspace-write 不是“在 repo 附近写入任何内容”

在 checkout 任务中,编辑这个文件是正常的:

src/checkout/adapter.rs

编辑此文件不是同一种操作:

.git/hooks/pre-push

读取这个文件可能会更糟:

.env

模型可能认为这三个都只是路径。运行时一定不能。

这也是为什么 danger-full-access 应该被看成逃生口,而不是更高级的生产力设置。完全访问会移除这些边界。在一次性虚拟机或专用容器里,这可以接受;但在个人机器或带有密钥的 repo 里,它不是好默认值。

网络是一个独立的爆炸半径

网络值得单独成类,因为它会改变威胁模型。

如果没有网络,错误命令的影响主要局限在本地文件系统和进程内。有了网络,错误命令可以下载未经审查的代码、泄露秘密、调用外部服务、改变远程系统,或把本地读取和外部写入串起来。

因此,Codex 将网络视为一个单独的边界,而不是 shell 执行的副作用。

合法工作可能需要这样的命令:

npm install

但这与运行本地测试不同。它可以获取包、运行安装脚本以及与外部注册表通信。根据配置的策略,运行时可能会阻止它、询问审阅者或仅通过具有域级规则的网络智能体允许它。

网络策略层区分了几个容易混淆的概念:

  • 网络是否已启用;
  • 智能体是否正在执行决定;
  • 允许或拒绝哪些域名;
  • 本地和私人目的地是否被封锁;
  • 被阻止的尝试是否可以转换为审批请求。

实现里有专门的 network_policy_decision.rs 路径,可以把被阻止的网络尝试转换成审阅上下文或明确的拒绝消息。这说明了一个设计目标:网络拒绝不应该看起来像随机进程失败,而应该成为模型可见、智能体可以推理的安全事件。

对这个 checkout 任务来说,好的智能体不应该在网络请求被拒绝后换个 shell 包装继续绕。它应该走本地路径:读 lockfile、检查缓存产物、运行更窄的测试;如果确实需要联网,再明确向用户请求对应的网络权限。

ToolOrchestrator:审批、沙箱、执行和重试的编排

一旦 shell 请求被分类,Codex 仍然必须正确运行它。

这就是 ToolOrchestrator 的重要性。它把执行编排集中在一处:

  1. 计算或接收审批要求;
  2. 在需要时将请求路由给用户审批或 guardian 自动审核;
  3. 选择第一次沙箱尝试;
  4. 在选择的沙箱下运行命令;
  5. 解释沙箱拒绝或网络决策;
  6. 仅在策略允许的情况下重试;
  7. 流式传输事件并记录结果。

这种集中化很容易被低估。如果没有它,每个工具 handler 都会长出自己的审批和沙箱逻辑,系统最终会自相矛盾。shell 命令可能按一种方式重试,MCP 工具按另一种方式重试,补丁路径又按第三种方式重试。

编排器为运行时提供了一个可以询问的地方:

Did the action require approval?
Was approval granted?
Which sandbox should the first attempt use?
Was the denial caused by sandbox filesystem limits, network policy, or something else?
Is a retry allowed?
What event should the user and model see?

这是智能体行为可解释的基础设施。

自动审核:第二个审阅者,而不是更大的沙箱

审批不一定总是人工点击。Codex 还有一条自动审核路径,由单独的审核智能体评估越界请求。

这与赋予主智能体更多权力不同。

主智能体仍留在配置好的沙箱和审批策略内。当它要求越界时,只有在模式支持的情况下,审查才会路由给 guardian。guardian 会重建一份紧凑记录,审查拟议动作,并返回结构化决定。

这里重要的是 fail-closed:如果审阅者超时、执行失败、返回格式错误,或反复拒绝请求,系统不会默默放行。它会阻止、警告,必要时触发断路器。

Auto-review 是一个审阅者,而不是一个更大的沙箱

在我们的故事中,假设模型说:

curl https://registry.npmjs.org/some-package

启用自动审核后,问题不再是“主模型能否说服自己这没问题?”问题是,独立的审核者在查看请求和上下文后是否判断升级是可接受的。

如果答案是否定的,拒绝就不是模型需要通过绕过策略来解决的难题,而是一条指令:选择更安全的路径,或询问用户。

这是一个微妙但关键的智能体设计点。安全边界必须是权威运行时事实,而不只是嵌在提示词里的建议。

平台沙箱:一份合约,不同操作系统

面向用户的模式故意简单:只读、工作区写入、完全访问、请求审批等等。在这种简单性的背后,操作系统的实现因平台而异。

在 macOS 上,Codex 可以使用 Seatbelt 沙箱。在 Linux 和 WSL2 上,当所需支持可用时,沙箱路径基于 bubblewrap 和 seccomp。在 Windows 上,运行时可以使用原生 Windows 沙箱路径,也可以使用 WSL2 支持的 Linux 沙箱行为,具体取决于环境。在云执行中,隔离由 OpenAI 管理的容器提供,设置阶段和智能体阶段的网络行为是分开的。

源布局反映了这一点:核心代码构建高级执行请求,而沙箱层将这些请求转换为特定于后端的命令和环境更改。 ExecRequest 携带公共字段 - 命令、cwd、环境、网络设置、沙箱选择、权限配置文件、文件系统策略和网络策略 - 因此本轮的其余部分可以统一处理输出和事件。

不同的操作系统后端,一份执行合约

这种抽象不只是为了代码简洁。它让用户即使身处不同环境,也能推断出同一种安全态势,哪怕底层执行机制不同。

不变量应该是:

same high-level policy;
platform-specific enforcement;
same model-visible result shape.

这个不变量解释了为什么智能体能收到连贯的拒绝消息,而不是看到“子进程以奇怪方式失败”。

为什么 danger-full-access 存在

有一个设置会故意绕过大部分摩擦:danger-full-access。它通常和 approval_policy = "never",或绕过审批与沙箱的 CLI 快捷方式一起使用。

它的存在是因为在某些工作流程中,外部环境已经是一次性的或隔离的。例如:

  • 没有秘密的一次性容器;
  • 临时虚拟机;
  • 专为智能体执行而设计的类似 CI 的环境;
  • 高度可信的本地实验,用户明确接受风险。

但它不应该被视为“高级模式”。它更像是在移除护栏,前提是 Codex 外面已经有另一层护栏。

danger-full-access 是逃生口,而不是更好的默认值

危险通常不是智能体突然变得恶意,而是它会带着真实系统权限犯普通模型错误。

模型可能误解路径,过度扩展清理命令,遵循文件里的恶意指令,运行有意外副作用的包脚本,或通过网络请求泄露本地状态。沙箱和审批系统存在,是因为这些不是罕见攻击,而是自主工具的正常故障模式。

所以更尖锐的建议是:

仅当环境已经是一次性的、不存在秘密且爆炸半径受 Codex 外部控制时才使用完全访问权限。

对于日常源代码工作,workspace-write 加上按请求审批是更一致的默认设置。

隐含的质量标准:拒绝也要有用

只说“不”的安全边界很难被智能体使用。Codex 需要把拒绝变成智能体循环的一部分。

当命令被阻止时,模型应该得到可操作的信息:

  • 命令需要网络;
  • 目的地不在许可名单上;
  • 该命令尝试在工作区之外写入;
  • 路径受到保护;
  • 审核者拒绝升级;
  • 沙箱后端拒绝在沙箱之外运行,因为必须保留拒绝读取规则。

这就是运行时需要流式事件和结构化结果,而不能只返回退出码的原因。被拒绝的网络尝试不是失败的测试;被禁止的命令也不是 shell 语法错误;审阅者拒绝后,换个写法重试也不是解决方案。

对于我们的结账任务,这决定了智能体在阻塞后是否仍然有用。

不良的智能体行为是:

Network blocked. Try curl through a different shell wrapper.

良好的智能体行为是:

Network is blocked. I can continue by inspecting the lockfile and running the local test. If the missing dependency is required, I will ask for access to the package registry explicitly.

这就是运行时安全和智能体质量的结合点。运行时必须给出清晰边界;模型必须把这些边界纳入计划。

源图

这是我阅读 Codex 这一部分时用的地图:

位置作用
codex-rs/core/src/tools/handlers/shell.rs面向模型的 shell 处理程序:环境解析、权限检查、补丁拦截、shell 请求构建。
codex-rs/core/src/exec_policy.rs命令策略管理器:解析命令意图并将策略降低为 Skip、NeedsApproval 或 Forbidden。
codex-rs/core/src/tools/orchestrator.rs工具调用的集中审批、沙箱、执行和重试编排。
codex-rs/core/src/tools/sandboxing.rs共享审批原语和沙箱尝试工具使用的抽象。
codex-rs/core/src/exec.rs较低级别的流程执行路径和沙箱感知的请求构造。
codex-rs/core/src/sandboxing/核心拥有的沙箱适配器和执行管道。
codex-rs/core/src/network_policy_decision.rs将网络策略块转变为审批上下文或模型可见的拒绝消息。
codex-rs/core/src/guardian/审核智能体审批决策的自动审核/guardian 路径。

官方文档也很重要,因为这不只是实现细节,也是面向用户的合约:

相关的源入口点是:

小结

最诱人但错误的描述是:

Codex 在运行危险命令之前询问。

这个说法太窄。

更好的描述是:

Codex 会把模型操作转换成结构化运行时请求,用执行策略分类,用沙箱和权限配置约束,把有风险的越界操作路由给审批或自动审核,并把拒绝作为事实报告回智能体循环。

这就是为什么这部分属于工具运行时之后。工具是模型的行为方式;安全模型是主机如何决定允许哪些操作成为现实。

更深一层的设计课并不只属于 Codex。任何严肃的 coding agent 最终都需要这种分离:

model intent
 != command authority
 != filesystem authority
 != network authority
 != approval authority

当这些概念混成一团时,智能体 demo 可能更简单,但系统更难被信任。Codex 的设计更复杂,是因为问题本身更复杂:自主编码需要运行时边界,而不只是一个“会礼貌请求”的模型。