Codex信任边界
Codex 能读写工作区、启动命令、访问网络、调用 MCP 工具,还能加载插件、Skills 和 Hook。 这些能力并不共享一个统一的“安全开关”:有些限制只是 Core 中的策略判断,有些操作需要用户 批准,有些边界由操作系统强制,还有一些扩展本来就运行在 Codex 的受信任宿主域中。
理解这套系统时,最危险的简化是“开了 sandbox 就安全”。一个沙箱只约束被放入其中的进程; 它不会自动隔离 Codex 主进程、同用户运行的本地 MCP server、受信任 Hook,也不会把已经传入子进程 的环境变量变成秘密。真正的安全结论必须回答四个问题:谁提出操作、谁做策略判断、谁执行操作、 最后由谁强制拒绝。
阅读本文前,建议先了解 Codex 产品形态全景 中 CLI、App Server 与 SDK 的进程关系,以及 Turn端到端链路 中工具调用的入口。 本文是一张安全责任地图:重点是区分 policy、approval、process placement 与 OS enforcement,不替代 各平台沙箱实现的逐文件分析。
读完后,应能从一个工具名称继续追问到真正的执行主体,判断“用户批准”“允许调用”和“操作系统已经 隔离”分别由哪一层证明,并识别 MCP、Hook、Skill 离开普通 shell 沙箱链路的位置。
1. Codex 的信任边界
模型只产生结构化工具调用,不能直接调用操作系统 API。codex-core 接收调用后,先由工具路由、 exec policy、审批系统和 ToolOrchestrator 决定如何尝试执行;真正的 shell 子进程再由 codex-sandboxing 转换为平台命令并启动。MCP、Hook 和 Rust extension 则走不同的执行路径。
图中最重要的边不是 MODEL → CORE,而是 Core 之后的分叉:shell 类工具可以进入平台沙箱; 本地 stdio MCP 和命令 Hook 由宿主直接创建;Rust extension 已经编译或注入到宿主进程中。 因此,安装 MCP、信任 Hook 或启用宿主 extension,都是比批准一次普通工具调用更大的信任决定。
可以把各主体的职责归纳为下表:
| 主体 | 掌握的能力 | 默认受什么约束 | 不能替代什么 |
|---|---|---|---|
| 模型 | 生成文本和工具参数 | 工具 schema、Core 路由与策略 | 不能直接证明命令安全 |
codex-core | 配置、认证、工具与进程编排 | 自身代码和宿主 OS 用户权限 | 不受给 shell 子进程准备的沙箱约束 |
| shell/apply_patch 子进程 | 文件与进程操作 | PermissionProfile 转换出的平台沙箱 | 审批本身不能产生 OS 隔离 |
| 受管网络代理 | 域名策略、连接转发与审计 | 网络 policy 与沙箱强制的代理路径 | 若进程能直接出网,代理不能阻止绕过 |
| 本地 stdio MCP | 任意 MCP server 实现 | server 配置、宿主用户权限 | MCP tool approval 不是 server 进程沙箱 |
| 远端 MCP | 远端服务拥有的工具与数据 | HTTP 身份、服务端授权和 tool allowlist | 本地文件沙箱不能约束远端服务内部行为 |
| 命令 Hook | 生命周期拦截、改写、审批或附加上下文 | hash 信任、enable 状态和超时 | 不经过普通工具的 ToolOrchestrator |
| Rust extension | 注册工具、MCP、prompt 和生命周期 contributor | 编译/宿主装配边界 | 不是可由运行时插件沙箱隔离的脚本 |
2. 权限控制三轴
Codex 的安全状态至少由三个相互独立的类型决定:
PermissionProfile描述本次 conversation、Turn 或 command 的文件系统和网络能力;AskForApproval描述遇到风险操作时是否以及如何征询批准;SandboxType描述当前主机最终选中了哪一种具体 OS 执行后端。
PermissionProfile 是当前运行时的规范表示,而 SandboxPolicy 是仍保留的兼容表示。前者有三种 enforcement 语义:
PermissionProfile | 文件系统含义 | 网络含义 | 信任假设 |
|---|---|---|---|
Managed | Codex 根据 entries 构造限制或无限制策略 | Restricted 或 Enabled | Codex 负责选择并配置平台沙箱 |
Disabled | 不施加外层文件沙箱 | 不建立受管隔离 | 用户明确接受宿主权限执行 |
External | Codex 不重复施加文件沙箱 | 仍记录外部环境的网络设置 | 调用方已经提供可信的外部隔离 |
源码位置:codex-rs/protocol/src/models.rs :: PermissionProfile
#[serde(tag = "type", rename_all = "snake_case")]
#[ts(tag = "type")]
pub enum PermissionProfile {
// Managed 表示 Codex 必须把文件与网络能力转换为具体平台强制。
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case")]
Managed {
file_system: ManagedFileSystemPermissions,
network: NetworkSandboxPolicy,
},
// Disabled 明确关闭外层沙箱,不等同于“已获用户批准”。
Disabled,
// External 把文件隔离责任交给宿主,但仍携带网络语义。
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case")]
External { network: NetworkSandboxPolicy },
}
impl Default for PermissionProfile {
fn default() -> Self {
// 默认 fail closed:空 restricted 文件条目加受限网络。
Self::Managed {
file_system: ManagedFileSystemPermissions::Restricted {
entries: Vec::new(),
glob_scan_max_depth: None,
},
network: NetworkSandboxPolicy::Restricted,
}
}
}默认值不是 workspace-write,而是没有放行条目的 restricted profile。产品层选择具体 profile 后,Core 才把它物化成当前 cwd/environment 对应的文件规则。
SandboxPermissions 则是单次命令的覆盖请求:UseDefault 保持 Turn 配置, WithAdditionalPermissions 在沙箱内扩展这一条命令的文件/网络权限,RequireEscalated 请求无沙箱 执行。后两者只是请求;能否执行仍取决于 exec policy、审批策略和文件 deny-read 约束。
一次普通 shell 调用的真实顺序如下。注意“获得批准”和“进入沙箱”是两个不同步骤。
AskForApproval::Never 的含义是“不弹出批准请求”,不是“关闭沙箱”。未匹配规则的普通命令仍可 在既定沙箱中运行;危险或策略禁止的命令可以直接变成 Forbidden,沙箱拒绝也会原样返回模型, 而不会自动升级。反过来,用户批准只表示接受这次策略风险;真正的文件和网络边界仍必须由后续 选中的 OS sandbox 强制。
审批解析还有明确的优先级:PermissionRequest Hook 先执行;没有给出结论时,才由 Guardian 或 用户决定。Denied、超时和 abort 最终都转换为 ToolError::Rejected。因此 UI 没有出现弹窗,既 可能是配置自动允许,也可能是 Hook/Guardian 已经处理,不能仅凭交互界面推断权限状态。
3. 文件边界保护写入
FileSystemSandboxPolicy 由一组 FileSystemSandboxEntry 组成,每个 entry 把路径或特殊路径映射为 Read、Write 或 Deny。路径匹配采用更具体条目优先;同等具体度冲突时,优先级是 Deny > Write > Read。
默认 workspace-write 语义容易被误读。它通常包含:
- 根路径的
Read,使大部分磁盘内容可读; - project roots、
/tmp和可选TMPDIR的Write; - 用户显式配置的额外 writable roots;
- writable root 顶层
.git、.agents、.codex的只读 carve-out。
所以 workspace-write 的核心保证是“限制写到哪里”,不是“只能读工作区”。 has_full_disk_read_access() 甚至会在存在根读权限且没有 deny-read 条目时返回 true。保护元数据目录 也只是阻止默认写入,读取仍然允许;若用户明确给这些路径增加 write entry,限制可以被覆盖。
源码位置:codex-rs/protocol/src/permissions.rs :: FileSystemSandboxPolicy::workspace_write
pub fn workspace_write(
writable_roots: &[AbsolutePathBuf],
exclude_tmpdir_env_var: bool,
exclude_slash_tmp: bool,
) -> Self {
// 根路径先取得 Read,这正是 workspace-write 默认允许广泛读取的原因。
let mut entries = vec![FileSystemSandboxEntry::new(
FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
FileSystemAccessMode::Read,
)];
// project roots 被提升为 Write,具体 root 在运行时按环境物化。
entries.push(FileSystemSandboxEntry::new(
FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
FileSystemAccessMode::Write,
));
if !exclude_slash_tmp {
entries.push(FileSystemSandboxEntry::new(
FileSystemPath::Special {
value: FileSystemSpecialPath::SlashTmp,
},
FileSystemAccessMode::Write,
));
}
if !exclude_tmpdir_env_var {
entries.push(FileSystemSandboxEntry::new(
FileSystemPath::Special {
value: FileSystemSpecialPath::Tmpdir,
},
FileSystemAccessMode::Write,
));
}
entries.extend(writable_roots.iter().cloned().map(|path| {
FileSystemSandboxEntry::new(
FileSystemPath::Path { path },
FileSystemAccessMode::Write,
)
}));
// 三个元数据目录是默认只读 carve-out,不是 deny-read。
append_default_read_only_project_root_subpath_if_no_explicit_rule(&mut entries, ".git");
append_default_read_only_project_root_subpath_if_no_explicit_rule(&mut entries, ".agents");
append_default_read_only_project_root_subpath_if_no_explicit_rule(&mut entries, ".codex");
for writable_root in writable_roots {
for protected_path in default_read_only_subpaths_for_writable_root(
writable_root,
/*protect_missing_dot_codex*/ false,
) {
append_default_read_only_path_if_no_explicit_rule(&mut entries, protected_path);
}
}
FileSystemSandboxPolicy::restricted(entries)
}顺序体现的是默认策略构造,不是简单“后写覆盖前写”:最终解析还要比较路径具体度和 access 优先级。 显式规则可以改变默认 carve-out,所以审计时必须看物化后的完整 entries。
这里有两层防护。ReadDenyMatcher 供宿主实现的直接文件读取工具在访问前检查;无效 deny glob 会 fail closed,避免配置拼写错误扩大可读范围。shell 子进程则需要平台沙箱将同一 policy 编译为 OS 规则。配置层的“路径不能读”不会凭空限制一个未进入沙箱的任意进程。
deny-read 还会改变升级语义。unsandboxed_execution_allowed() 在 policy 存在 denied read restrictions 时返回 false;RequireEscalated 也会被转换为保留 deny-read 的受限尝试。原因很直接: 如果一次批准能把命令改为完全无沙箱运行,那么 deny-read 就会被无声绕过。
源码位置:codex-rs/core/src/tools/sandboxing.rs :: unsandboxed_execution_allowed, sandbox_permissions_preserving_denied_reads
pub(crate) fn unsandboxed_execution_allowed(
file_system_sandbox_policy: &FileSystemSandboxPolicy,
) -> bool {
// deny-read 只能由沙箱执行;存在限制时禁止无沙箱升级。
!file_system_sandbox_policy.has_denied_read_restrictions()
}
pub(crate) fn sandbox_permissions_preserving_denied_reads(
sandbox_permissions: SandboxPermissions,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
) -> SandboxPermissions {
if sandbox_permissions.requires_escalated_permissions()
&& !unsandboxed_execution_allowed(file_system_sandbox_policy)
{
// 将 RequireEscalated 降回默认沙箱,避免批准动作静默丢掉拒读规则。
SandboxPermissions::UseDefault
} else {
sandbox_permissions
}
}这段代码把“批准升级”和“可否绕过文件沙箱”拆开判断:即使请求 escalated,只要 deny-read 存在, 最终仍保持 sandboxed attempt。
apply_patch 的自动批准也比“目标在 workspace”更严格。assess_patch_safety() 除了检查所有目标 都在 writable roots,还要求存在平台沙箱;hard link 可能让看似安全的路径指向其他 inode,所以 patch 即使被判定为可自动批准,仍要在沙箱中执行。平台后端缺失且审批策略又禁止询问时,安全路径 是拒绝,而不是把 patch 当作纯文本操作直接执行。
4. 沙箱请求与后端
SandboxManager::should_sandbox() 先回答“策略是否要求沙箱”,select_initial() 再回答“当前主机 能提供哪一种后端”。这两个答案分别保存在 SandboxAttempt.sandbox_requested 和 SandboxAttempt.sandbox 中。它们不能合并成一个布尔值:策略可能要求隔离,但平台选择最后得到 SandboxType::None。
| 平台 | SandboxType | 主要强制机制 | 本文关注的失败边界 |
|---|---|---|---|
| macOS | MacosSeatbelt | /usr/bin/sandbox-exec 与动态 Seatbelt profile | 非 macOS 请求 Seatbelt 会报不可用 |
| Linux | LinuxSeccomp | codex-linux-sandbox;默认文件系统路径采用 bubblewrap,另有 Landlock 路径;seccomp 约束网络/进程能力 | helper 缺失、WSL1 不支持 bubblewrap 时转换失败 |
| Windows | WindowsRestrictedToken | restricted token 或 elevated backend | sandbox disabled 时无具体后端;不支持的权限形状拒绝准备 |
| 其他平台 | None | 无内建平台后端 | 只能依赖外部隔离或拒绝需要强制的操作 |
源码位置:codex-rs/sandboxing/src/manager.rs :: SandboxType, get_platform_sandbox
pub enum SandboxType {
None,
MacosSeatbelt,
LinuxSeccomp,
WindowsRestrictedToken,
}
pub fn get_platform_sandbox(windows_sandbox_enabled: bool) -> Option<SandboxType> {
// 后端选择由编译目标决定,Windows 还需要运行时 enable gate。
if cfg!(target_os = "macos") {
Some(SandboxType::MacosSeatbelt)
} else if cfg!(target_os = "linux") {
Some(SandboxType::LinuxSeccomp)
} else if cfg!(target_os = "windows") {
if windows_sandbox_enabled {
Some(SandboxType::WindowsRestrictedToken)
} else {
None
}
} else {
None
}
}函数返回 None 只说明主机没有选中内建后端;调用方仍须结合 sandbox_requested 判断这是允许的无沙箱 执行,还是平台能力不足导致不能满足策略。
LinuxSeccomp 是历史命名,不能据此推断“Linux 只有 seccomp”。实际 launcher 会组合文件系统与 网络/进程控制,具体平台矩阵将在后文单独展开。
SandboxAttempt::env_for() 在本机执行路径中调用 SandboxManager::transform();exec-server 路径则 把 PermissionProfile、原始命令和网络上下文发给执行环境,由执行端在自己的 OS 边界转换。 这避免控制端把本地路径规则错误地当成远端路径规则。
首次执行返回 SandboxErr::Denied 后,ToolOrchestrator 只在 runtime 声明可升级、审批策略允许、 且 deny-read 等约束没有禁止绕过时才尝试第二次。网络拒绝还必须能归因到明确 host,无法形成审批 上下文的网络策略拒绝会直接返回。重试不是“任何错误都再跑一次”,普通进程失败不会触发沙箱升级。
5. 网络代理约束
受管网络由两部分共同完成:OS sandbox 阻止子进程直接出网,并把允许的流量引到 codex-network-proxy;代理再根据 host、protocol、port、allowlist/denylist 和动态审批结果决定是否 转发。只有代理而没有出站隔离时,子进程可以绕过代理,因此 allowlist 不能单独构成强制边界。
evaluate_host_policy() 先计算 baseline。明确 block 不会被普通动态决策覆盖;allowlist miss 只有在 requirements 允许扩展时才可进入 decider。每次 allow/deny 都带 decision source 写入审计事件,便于 区分基线配置、受管要求和交互审批。
网络批准还受两个条件限制:AskForApproval::Never 不启动交互流程;只有 PermissionProfile::Managed 才由 NetworkApprovalService 管理。service 用 environment、host、 protocol、port 和活动 tool call 建立归因;并发调用无法唯一归因时,不会凭猜测把一次批准套给另一 条连接。
三个“没有受管代理”的场景尤其重要:
PermissionProfile::Disabled的 danger-full-access Turn 不暴露受管 proxy;RequireEscalated的无沙箱尝试不继续声称受管网络受到强制;- 用户主动运行的 shell command 不继承 agent 工具使用的 managed proxy。
这些行为有专门的 session 测试。它们不是功能缺失,而是在避免虚假的安全承诺:既然进程不受 Codex 管理的网络沙箱约束,就不能把一个可绕过的代理环境变量描述成“网络已限制”。
6. MCP审批边界
McpServerConfig 把 server 生命周期、transport、tool 暴露面、审批和凭据放在一个配置对象中: enabled 决定是否启动,required 决定启动失败是否致命,startup_timeout_sec 与 tool_timeout_sec 限制等待,enabled_tools 先做 allowlist,disabled_tools 再从结果中移除工具, 全局和逐工具 approval mode 决定每次调用是否询问。
但这些字段控制的是 Codex 如何连接和调用 MCP,不是如何隔离 MCP server 的实现。
三种放置方式有不同的信任后果:
| transport/owner | 如何启动 | 凭据与环境来自哪里 | 主要边界 |
|---|---|---|---|
| local stdio | LocalStdioServerLauncher 直接用 tokio::process::Command 创建宿主子进程 | 宿主的受限默认 env,加上显式配置变量 | 不经过 shell 工具的 SandboxManager,进程仍以当前 OS 用户访问文件 |
| executor stdio | ExecutorStdioServerLauncher 请求执行器创建 process | 基础环境和 remote-source 变量由执行器解析 | sandbox: None、managed network false;隔离责任属于执行环境 |
| streamable HTTP | 本地或 executor HTTP client 连接服务 | bearer env、header env、OAuth 或允许的第一方身份 | server 在远端运行,本地沙箱不能限制其数据处理 |
本地 stdio MCP 默认只传 HOME、LOGNAME、PATH、SHELL、USER、locale、terminal、tmp 和 timezone 等基础变量;额外 secret 必须由 env_vars 或显式 env 配置加入。远端 stdio 不把本机的 PATH/HOME 复制到 executor,而是在 executor 侧解析 remote-source 变量,避免跨环境误传宿主秘密。 不过,少传环境变量不等于文件隔离:同用户的本地 MCP 仍可能自己打开宿主可读文件。
HTTP bearer token 只从命名环境变量解析,缺失、空值或非 Unicode 都会让连接启动失败。显式 authorization 优先于自动认证;ChatGpt 身份只允许可信第一方本地 origin,executor-owned MCP 不得 接收这种宿主会话身份。对于不可信 server,Codex 还会移除工具 metadata 中用于伪造 connector identity 的字段。
required: true 的 server 启动失败可以使无交互 codex exec 失败退出;可选 server 则报告状态并 继续。无论哪种情况,enabled_tools/disabled_tools 只是缩小模型可见和可调用的工具面,并不会 降低已经启动的本地 server 进程的 OS 权限。
7. 扩展宿主边界
Codex 源码里“扩展能力”至少有四种完全不同的执行模型。只看 UI 上的插件名称,会掩盖它们的 信任差异。
| 扩展形式 | 加载的内容 | 是否直接执行代码 | 执行边界 |
|---|---|---|---|
| plugin bundle | skills、MCP、apps、hooks、commands、interface | bundle 本身是资源;其中 Hook/MCP 可进一步执行代码 | 由各 capability 自己的 runtime 决定 |
| command Hook | 生命周期 shell command | 是 | hooks 引擎直接创建宿主子进程 |
| Skill | SKILL.md 指令及资源 | 加载本身不执行代码;指令可促使模型请求工具 | 资源由对应 authority/provider 读取,后续命令走普通 tool policy/approval/sandbox |
| Rust extension | typed contributor trait object | 是 | 与 Codex 同进程,属于 trusted computing base |
7.1 Bundle路径安全
plugin manifest 中的资源路径必须以 ./ 开始,不能包含 parent/root/prefix 逃逸,解析结果必须 留在 plugin root。bundle 归档只接受普通目录和文件;解包拒绝 symlink、hard link、路径穿越和超出 总大小限制。marketplace requirements 还能把来源限制为精确 Git URL/ref、host pattern 或 local path。
这些检查解决的是“一个已选 bundle 能否逃出安装目录”和“允许从哪里安装”,并不证明插件作者 可信,也不证明 Hook、MCP 或 Skill 行为无害。来源约束和内容信任是两件事。
模型提出 plugin install 时也不能静默安装任意 ID:请求必须指向前一步枚举出的 discoverable tool, 给出非空 suggest_reason,再通过 MCP elicitation 获得 Accept,最后重新检查安装是否完成。这条 流程防止模型凭空构造包名,但用户仍需判断来源和 capability。
7.2 Hook 命令执行
Hook discovery 会为规范化命令计算 hash。managed source 标记为 Managed;普通 source 只有保存的 trusted_hash 与当前 hash 相同才是 Trusted。命令修改后状态变为 Modified,没有 hash 则是 Untrusted;除非显式 bypass trust,只有 enabled 且 Managed/Trusted 的 handler 会进入执行集合。
执行时 command_runner::run_command() 直接使用 shell 和 tokio::process::Command,设置 cwd、stdin、 stdout/stderr 与 timeout 后 spawn。它没有调用 ToolOrchestrator 或 SandboxManager。因此信任一次 Hook hash 的含义是允许这条命令以 Codex 宿主用户运行。Hook 可以在 PreToolUse 阻止或改写工具, 也能在 PermissionRequest 先于 Guardian/用户作决定,这使 Hook 同时处于代码执行边界和策略控制边界。
7.3 Skill authority
ext/skills 不再由一个本地 loader 代表所有 Skill。当前实现把 Skill 资源绑定到显式的 SkillSourceKind:Host 表示 Codex 宿主快照,Executor 表示执行环境拥有的目录, Orchestrator 表示由编排器通过 MCP resource 提供的内容。SkillAuthority 和 SkillPackageId 共同构成资源身份;读取时必须回到声明该 authority 的 provider,不能把远端 资源解析成一个宿主本地路径。
源码位置:codex-rs/ext/skills/src/catalog.rs :: SkillSourceKind, SkillAuthority, SkillPackageId
pub enum SkillSourceKind {
Host,
Executor,
Orchestrator,
Custom(String),
}
pub struct SkillAuthority {
pub kind: SkillSourceKind,
pub id: String,
}
pub struct SkillPackageId(pub String);SkillProvider 将列举、读取和搜索操作放在同一个 authority 边界内。执行环境 provider 会校验 package 前缀和 resource 的相对路径,再通过该环境的文件系统读取;编排器 provider 则通过 MCP resource 分页、超时和数量上限获取内容。这个设计解决的不是“Skill 文本自动可信”,而是防止 一次列举结果被换成另一个来源的资源。
相关源码:
codex-rs/ext/skills/src/provider.rs :: SkillProvider, SkillListQuery, SkillReadRequestcodex-rs/ext/skills/src/provider/executor.rs :: ExecutorSkillProvider::list, ExecutorSkillProvider::readcodex-rs/ext/skills/src/provider/orchestrator.rs :: OrchestratorSkillProvider::listcodex-rs/ext/skills/src/sources.rs :: SkillProviders::list_for_turn, SkillProviders::read
读取成功后,SkillsExtension::turn_input_contributor 才把主提示文本包装成 SkillInstructions 并放入本次 Turn 的 fragments;加载动作本身不执行 Skill 目录中的脚本。 Skill 指示的命令仍由 Turn 的普通 exec policy、approval 和 sandbox 处理。因而新的安全问题 分成两层:authority 决定“谁能提供和读取资源”,prompt injection 决定“文本会诱导模型请求什么”, 而命令执行边界仍由工具运行时负责。
7.4 Rust扩展宿主
ExtensionRegistryBuilder 接受 ContextContributor、ToolContributor、 McpServerContributor、生命周期 contributor、approval reviewer 等 trait object,构建不可变 ExtensionRegistry 供 Core 使用。这些 contributor 是宿主编译或装配进来的 Rust 对象,运行在 Codex 进程内;它们不是 plugin bundle 中可下载的动态库,也不会被 shell 沙箱约束。
8. 凭据与环境隔离
Codex 主进程必须持有 OpenAI API key、token、agent identity private key,以及可能存在的 PAT、 Bedrock key 和 MCP OAuth 凭据。这些秘密的存储位置决定了同用户子进程能否读取它们,文件系统 workspace-write 不能自动承担 secret vault 的职责。
认证存储有四种模式:
AuthCredentialsStoreMode | 存储方式 | 失败/暴露特征 |
|---|---|---|
File | $CODEX_HOME/auth.json,Unix 创建权限为 0600 | 当前默认;同一 OS 用户的进程仍可读取 |
Keyring | OS keyring | keyring 不可用时失败,不降级文件 |
Auto | 优先 keyring,失败回退文件 | 可用性高,但必须观察是否发生回退 |
Ephemeral | 当前进程内存 | 退出后丢失,不写持久存储 |
MCP OAuth 的默认则是 Auto;file 模式使用 $CODEX_HOME/.credentials.json,源码明确说明同用户的 其他应用可以读取。keyring backend 在非 Windows 默认直接保存序列化 auth payload;Windows 默认 把 payload 放在本地加密 secrets 文件,并把文件 key 放进 OS keyring。
shell 子进程的环境由 ShellEnvironmentPolicy 重建,并在 spawn 前配合 env_clear(),防止未选中的 变量从 parent 偷渡进去。但默认 policy 是 inherit: All 且 ignore_default_excludes: true:也就是 默认不会启用 *KEY*、*SECRET*、*TOKEN* 三组过滤。用户必须主动选择 Core/None、开启默认 排除或配置 exclude/include_only,才会缩小变量面。
本地 MCP 的默认 env allowlist 比 shell 更窄,但 Hook 的 Command 没有经过 ShellEnvironmentPolicy 重建;它会继承宿主环境并叠加 handler env。再结合 Hook 和 local MCP 的 宿主级文件读取能力,可以得到一个明确结论:
文件沙箱负责约束被沙箱化进程的路径能力;环境策略负责减少主动传入的秘密;keyring 负责减少 同用户进程可直接打开的明文凭据。三者互补,任何一个都不能替代另外两个。
Unix 0600 只阻止其他用户读取文件,不阻止同一用户身份下的进程。workspace-write 默认又常有 全盘读能力,所以仅把 .codex 设为只读不能保护 auth.json 的机密性。需要强秘密隔离时,应优先 使用 keyring,并为 agent 工具配置 deny-read;对 local MCP 与 Hook,还必须从安装/信任层判断其是否 有资格以宿主用户运行。
9. 拒绝与降级
安全失败不只有“允许/拒绝”两个结果。下面的状态机把策略拒绝、等待批准、首次沙箱执行和条件式重试 分开;它描述的是一次工具调用的安全状态,而不是 Session 的生命周期。
只有 SandboxErr::Denied 才进入升级条件检查;普通命令退出码或进程错误直接进入 Failed。审批通过也 不会跳过 sandbox transform,deny-read 仍可阻止无沙箱重试。
下表用“发生了什么”而不是笼统的“权限失败”区分关键结果:
| 场景 | 决策/强制点 | 结果 | 是否自动降级 |
|---|---|---|---|
exec rule 命中 Forbidden | ExecPolicyManager | 执行前返回 ToolError::Rejected | 否 |
| approval 被 Hook、Guardian 或用户拒绝 | Session::request_approval | 返回 rejected;不启动命令 | 否 |
| approval 超时或 abort | approval resolver | 转换为 rejected | 否 |
| 首次执行被 OS sandbox 拒绝 | 子进程/ToolOrchestrator | 只有满足升级条件才进入二次审批/尝试 | 条件式 |
| 存在 deny-read 却请求无沙箱 | unsandboxed_execution_allowed | 保持受限执行或直接拒绝 | 不允许丢掉 deny-read |
| sandbox helper/路径转换失败 | SandboxManager::transform | 返回准备错误,不伪装成命令成功 | 否 |
| 网络 baseline 明确 deny | network proxy | 断开并记录 policy audit | 否 |
| 网络 allowlist miss 且允许扩展 | proxy + approval service | 可归因时请求批准 | 条件式 |
| danger-full-access 或 escalated 执行 | session/orchestrator | 不暴露或不强制 managed proxy | 是能力升级,不是安全降级 |
| required MCP 启动失败 | MCP lifecycle | 调用方可把 session/exec 视为启动失败 | 否 |
| optional MCP 启动失败 | MCP lifecycle | 报告 server 状态,其他功能继续 | 是 |
| MCP tool 被 allow/deny list 移除 | tool projection | 模型不可见、不可调用该工具 | server 进程权限不变 |
| Hook hash 缺失或已修改 | hooks discovery | handler 不进入执行集合并产生状态/警告 | 否 |
| plugin 路径逃逸或 archive link | manifest/archive loader | 忽略字段或拒绝 bundle | 否 |
auth Auto 的 keyring 不可用 | auth storage | 回退 auth.json | 是,机密性边界改变 |
这里最需要警惕的是“能力升级”和“安全降级”的术语混淆。经批准的无沙箱重试确实扩大了命令能力, 但它不会继续宣称原先的文件/网络强制仍然存在。真正危险的是系统在后端缺失时保持“sandboxed”表象 却无实际 enforcement;这正是源码用 sandbox_requested、具体 SandboxType 和错误结果分开建模的 原因。
10. 信任边界测试
安全文章不能只根据类型名推导保证。下面四组测试分别回答:操作系统是否真的阻止写入、宿主凭据是否 会流向 executor-owned MCP、Hook trust 被显式绕过时还剩什么约束,以及 Skill 声明能否扩大 Turn 权限。
10.1 OS拒绝结果
shell_zsh_fork_still_enforces_workspace_write_sandbox 不是只检查生成的 sandbox 参数,而是真的让 zsh-fork 执行 touch /tmp/...。测试要求命令输出含平台拒绝特征,并再次检查目标文件不存在;只有两条 断言同时成立,才能证明这次执行没有只在策略层“声称拒绝”。
源码位置:codex-rs/core/tests/suite/skill_approval.rs
// :: shell_zsh_fork_still_enforces_workspace_write_sandbox(关键路径)
let outside_path = "/tmp/codex-zsh-fork-workspace-write-deny.txt";
let workspace_write_profile = restrictive_workspace_write_profile();
let _ = fs::remove_file(outside_path);
let command = format!("touch {outside_path}");
let arguments = shell_command_arguments(&command)?;
let mocks =
mount_function_call_agent_response(&server, tool_call_id, &arguments, "shell_command")
.await;
submit_turn_with_policies(
&test,
"write outside workspace with zsh fork",
AskForApproval::Never,
workspace_write_profile,
)
.await?;
wait_for_turn_complete(&test).await;
let call_output = mocks
.completion
.single_request()
.function_call_output(tool_call_id);
let output = call_output["output"].as_str().unwrap_or_default();
// 第一条断言观察子进程返回的OS拒绝,而非Core预判结果。
assert!(
output_shows_sandbox_denial(output),
"expected sandbox denial, got output: {output:?}"
);
// 第二条断言检查最终副作用,防止“报错但已经写入”的假阴性。
assert!(
!Path::new(outside_path).exists(),
"command should not write outside workspace under WorkspaceWrite policy"
);这项测试带有 #[cfg(unix)],并在无网络或缺少 zsh-fork runtime 时跳过。它直接证明当前可运行 Unix fixture 的 enforcement,不证明 macOS Seatbelt、Linux bubblewrap/Landlock 和 Windows restricted token 都被同一测试覆盖;跨平台结论仍需结合 跨平台能力矩阵 的平台 fixture。
10.2 MCP 调用审批
hosted_actor_credentials_are_only_available_to_host_owned_mcp_servers 为同一 MCP 配置构造两种 placement。 默认 host-owned server 可以取得 hosted actor header;仅把 environment_id 改为 customer executor 后, auth provider 必须变成 None。
源码位置:codex-rs/codex-mcp/src/connection_manager_tests.rs
// :: hosted_actor_credentials_are_only_available_to_host_owned_mcp_servers(关键断言)
let local_server = EffectiveMcpServer::configured(local_config.clone());
let local_provider =
chatgpt_auth_provider_for_server(&local_server, Some(Arc::clone(&provider)))
.expect("host-owned Codex Apps must retain hosted authentication");
assert_eq!(
local_provider
.to_auth_headers()
.get("x-openai-actor-authorization")
.and_then(|value| value.to_str().ok()),
Some("hosted-actor-secret")
);
let mut remote_config = local_config;
remote_config.environment_id = "customer-executor".to_string();
let remote_server = EffectiveMcpServer::configured(remote_config);
// placement改变后,即使配置请求ChatGpt auth,也不能转交宿主actor凭据。
assert!(
chatgpt_auth_provider_for_server(&remote_server, Some(provider)).is_none(),
"customer-owned executors must never receive hosted actor credentials"
);这组测试证明身份传播受 server owner 限制,但没有给 MCP server 加 shell 沙箱。tool allowlist、逐工具 approval 和 credential routing 都发生在调用或连接边界;一个已启动的 local stdio server 仍以自己的宿主 进程权限运行。
10.3 Hook trust
bypass_hook_trust_allows_enabled_untrusted_handlers 构造没有 trusted hash 的普通 Hook,并打开 bypass_hook_trust。测试同时看到 HookTrustStatus::Untrusted 和一个可执行 handler,说明 bypass 是明确的 信任扩权,不是把状态伪装成 Trusted。相邻测试再证明 enabled: false 仍会把 handler 排除。
源码位置:codex-rs/hooks/src/engine/discovery.rs
// :: bypass_hook_trust_allows_enabled_untrusted_handlers(关键断言)
append_matcher_groups(
&mut handlers,
&mut hook_entries,
&mut warnings,
&mut display_order,
&unmanaged_hook_handler_source(
&source_path,
&hook_states,
/*bypass_hook_trust*/ true,
),
HookEventName::PreToolUse,
vec![command_group(Some("Bash"))],
);
assert_eq!(warnings, Vec::<String>::new());
// bypass让未受信任命令进入执行集合,但不会改写审计状态。
assert_eq!(handlers.len(), 1);
assert_eq!(hook_entries[0].trust_status, HookTrustStatus::Untrusted);
assert_eq!(hook_entries[0].enabled, true);第二个测试把同一绕过开关与显式禁用组合起来。discovery 仍保留 entry 供 UI/审计展示,但不会把命令放入 运行时 handlers;这说明 enabled 是独立于 trust status 的执行过滤条件。
源码位置:codex-rs/hooks/src/engine/discovery.rs
// :: bypass_hook_trust_respects_disabled_handlers(关键断言)
// 即使bypass trust,显式disabled仍阻止命令进入handlers。
assert_eq!(handlers, Vec::<ConfiguredHandler>::new());
assert_eq!(hook_entries[0].trust_status, HookTrustStatus::Untrusted);
assert_eq!(hook_entries[0].enabled, false);因此审计 Hook 时必须同时记录 trust_status、bypass_hook_trust 和 enabled。只看到 Untrusted 不能断言 它必然不会运行;只看到 enabled 也不能断言普通模式下已经获得 hash 信任。
10.4 Skill authority
ext/skills 的测试不再把 Skill 当作带有独立脚本权限的本地目录,而是验证来源身份和资源路由。 例如,executor Skill 的 catalog entry 使用 SkillSourceKind::Executor 和环境 id 构成 authority; 调用 SkillProviders::read 时,provider 会检查 package 前缀和资源相对路径,路径不匹配就返回错误。
源码位置:codex-rs/ext/skills/src/invocation_tests.rs
fn detect_executor_skill(command: &str, environments: &[&str]) -> Option<SkillInvocation> {
let skill_path = PathUri::parse("file:///skills/demo/SKILL.md")
.expect("valid skill URI");
let catalog = SkillCatalog {
entries: environments
.iter()
.map(|environment| {
SkillCatalogEntry::new(
SkillPackageId(format!("skill://{environment}/demo")),
SkillAuthority::new(SkillSourceKind::Executor, *environment),
format!("{environment}-skill"),
"test skill",
SkillResourceId::environment(
format!("skill://{environment}/demo/SKILL.md"),
*environment,
skill_path.clone(),
),
)
})
.collect(),
warnings: Vec::new(),
};
let turn_store = ExtensionData::new("test-turn");
turn_store.insert(ExecutorSkillsStepState(catalog));
detect_implicit_skill_invocation(
&turn_store,
"executor-a",
command,
&PathUri::parse("file:///skills").expect("valid workdir URI"),
None,
)
}
let invocation = detect_executor_skill("cat demo/SKILL.md", &["executor-a"])
.expect("document read should identify the executor skill");
assert_eq!(invocation.skill_name, "executor-a-skill");这类测试证明的是“调用命令可以被映射回拥有该资源的 executor”,不是 Skill 文本可信,也不是 executor 进程获得了新的 shell 权限。实际资源读取仍受 executor 文件系统和传入的 sandbox context 限制;Host 与 Orchestrator Skill 则由各自 provider 负责读取。
10.5 测试适用边界
上述测试不能合并成“Codex 所有扩展都已被沙箱化”的结论。OS fixture 只覆盖被送进普通 shell runtime 的 命令;MCP 测试覆盖凭据归属,不覆盖 server 进程隔离;Hook 测试恰恰证明显式 bypass 可以运行 Untrusted 命令;Skill 测试只证明来源路径能映射到对应 executor authority。Rust extension 与 Hook/local MCP 的宿主进程权限,当前没有 统一外层 sandbox 的端到端测试,因为实现本身也没有声明这样的统一边界。
11. 运行现象定位
排查一条可疑文件或网络访问时,按执行主体而不是按工具显示名称开始:
- 确认调用来自 shell/apply_patch、MCP、Hook,还是 Rust extension;只有第一类默认进入
ToolOrchestrator的 shell 沙箱链路。 - 记录当前
PermissionProfile的 enforcement、filesystem entries、network policy,以及单次命令 的SandboxPermissions;不要只看旧的sandbox_mode文案。 - 分别检查
ExecApprovalRequirement和最终SandboxAttempt。Approved说明策略同意,SandboxType才说明平台后端。 - 文件问题检查最具体 entry、deny-read、symlink/canonical path、protected metadata 与 hard link; “路径位于 cwd”不是充分条件。
- 网络问题检查进程是否被强制使用 managed proxy,再看 proxy audit 的 decision source;只看到
HTTP_PROXY一类变量不能证明直连已被封锁。 - MCP 问题同时检查 server placement、tool filter/approval、环境变量来源和认证方式;批准 tool call 不代表 server 获得了新的沙箱。
- 凭据问题同时检查 storage mode 和进程环境。若使用 file store,要把所有同用户宿主进程视为 潜在读取者,而不只是 agent shell。
对应的源码入口可以按职责阅读:
| 问题 | 主要文件与类型 |
|---|---|
| 权限规范与路径语义 | codex-rs/protocol/src/models.rs::PermissionProfile、codex-rs/protocol/src/permissions.rs::FileSystemSandboxPolicy |
| exec policy 与审批要求 | codex-rs/core/src/exec_policy.rs::ExecPolicyManager、codex-rs/core/src/tools/approvals.rs |
| 首次尝试和升级重试 | codex-rs/core/src/tools/orchestrator.rs::ToolOrchestrator |
| 平台后端选择与转换 | codex-rs/sandboxing/src/manager.rs::SandboxManager |
| 网络决策和审批归因 | codex-rs/network-proxy/src/network_policy.rs、codex-rs/core/src/tools/network_approval.rs |
| MCP transport 与凭据 | codex-rs/config/src/mcp_types.rs、codex-rs/codex-mcp/src/rmcp_client.rs、codex-rs/rmcp-client/src/stdio_server_launcher.rs |
| plugin bundle 与 Hook trust | codex-rs/core-plugins/src/manifest.rs、codex-rs/core-plugins/src/plugin_bundle_archive.rs、codex-rs/hooks/src/engine |
| Skill 与 extension | codex-rs/ext/skills/src/catalog.rs、codex-rs/ext/skills/src/provider.rs、codex-rs/ext/skills/src/extension.rs、codex-rs/ext/extension-api/src/registry.rs |
| 登录凭据与子进程环境 | codex-rs/login/src/auth/storage.rs、codex-rs/protocol/src/shell_environment.rs、codex-rs/core/src/exec_env.rs |
12. 版本升级重点
后续 tag 不能只比较枚举是否新增。以下变化会直接使本文结论失效或需要细化:
PermissionProfile是否仍是规范权限模型,legacySandboxPolicy的投影是否改变;- workspace-write 是否仍默认全盘可读,
.git/.agents/.codex的保护范围是否扩大; - deny-read 是否仍阻止无沙箱升级,以及直接文件工具和 OS sandbox 是否使用一致语义;
- Linux 文件系统后端是否继续以 bubblewrap 为默认,Windows 是否改变无后端时的处理;
- managed proxy 是否覆盖新的执行主体,escalated/user shell 是否仍明确退出受管网络边界;
- local/executor MCP 是否开始使用新的 sandbox 或 managed network,凭据来源规则是否变化;
- Hook trust hash、执行 runtime 和 inherited environment 是否变化;
- Skill authority/provider 是否增加新的来源,resource 读取是否仍禁止跨 authority 路径替换,plugin install elicitation 是否扩展到新的客户端;
- auth store 与 shell environment 的默认值是否改变。
安全边界的核心不是某个枚举名称,而是从输入到强制点的完整链路。只有同时确认 policy、approval、 process placement、OS enforcement 和 secret propagation,才能判断一次操作到底被什么保护、又在哪个 位置离开了保护范围。
可以用下面的只读搜索把本文的 policy and enforcement 主线落回源码:
rg -n "PermissionProfile|SandboxAttempt|ExecApprovalRequirement|SandboxManager" codex-rs