Skip to content

Codex信任边界

从策略决策、用户审批和操作系统强制三个层次解释 Codex 如何约束文件、网络、MCP、插件、Hook、Skills 与凭据访问。

基于rust-v0.150.0
CodexRustSecuritySandbox

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文件系统含义网络含义信任假设
ManagedCodex 根据 entries 构造限制或无限制策略Restricted 或 EnabledCodex 负责选择并配置平台沙箱
Disabled不施加外层文件沙箱不建立受管隔离用户明确接受宿主权限执行
ExternalCodex 不重复施加文件沙箱仍记录外部环境的网络设置调用方已经提供可信的外部隔离

源码位置:codex-rs/protocol/src/models.rs :: PermissionProfile

rust
#[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

rust
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

rust
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主要强制机制本文关注的失败边界
macOSMacosSeatbelt/usr/bin/sandbox-exec 与动态 Seatbelt profile非 macOS 请求 Seatbelt 会报不可用
LinuxLinuxSeccompcodex-linux-sandbox;默认文件系统路径采用 bubblewrap,另有 Landlock 路径;seccomp 约束网络/进程能力helper 缺失、WSL1 不支持 bubblewrap 时转换失败
WindowsWindowsRestrictedTokenrestricted token 或 elevated backendsandbox disabled 时无具体后端;不支持的权限形状拒绝准备
其他平台None无内建平台后端只能依赖外部隔离或拒绝需要强制的操作

源码位置:codex-rs/sandboxing/src/manager.rs :: SandboxType, get_platform_sandbox

rust
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 建立归因;并发调用无法唯一归因时,不会凭猜测把一次批准套给另一 条连接。

三个“没有受管代理”的场景尤其重要:

  1. PermissionProfile::Disabled 的 danger-full-access Turn 不暴露受管 proxy;
  2. RequireEscalated 的无沙箱尝试不继续声称受管网络受到强制;
  3. 用户主动运行的 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 stdioLocalStdioServerLauncher 直接用 tokio::process::Command 创建宿主子进程宿主的受限默认 env,加上显式配置变量不经过 shell 工具的 SandboxManager,进程仍以当前 OS 用户访问文件
executor stdioExecutorStdioServerLauncher 请求执行器创建 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 bundleskills、MCP、apps、hooks、commands、interfacebundle 本身是资源;其中 Hook/MCP 可进一步执行代码由各 capability 自己的 runtime 决定
command Hook生命周期 shell command是hooks 引擎直接创建宿主子进程
SkillSKILL.md 指令及资源加载本身不执行代码;指令可促使模型请求工具资源由对应 authority/provider 读取,后续命令走普通 tool policy/approval/sandbox
Rust extensiontyped 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

rust
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, SkillReadRequest
  • codex-rs/ext/skills/src/provider/executor.rs :: ExecutorSkillProvider::list, ExecutorSkillProvider::read
  • codex-rs/ext/skills/src/provider/orchestrator.rs :: OrchestratorSkillProvider::list
  • codex-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 用户的进程仍可读取
KeyringOS keyringkeyring 不可用时失败,不降级文件
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 命中 ForbiddenExecPolicyManager执行前返回 ToolError::Rejected否
approval 被 Hook、Guardian 或用户拒绝Session::request_approval返回 rejected;不启动命令否
approval 超时或 abortapproval resolver转换为 rejected否
首次执行被 OS sandbox 拒绝子进程/ToolOrchestrator只有满足升级条件才进入二次审批/尝试条件式
存在 deny-read 却请求无沙箱unsandboxed_execution_allowed保持受限执行或直接拒绝不允许丢掉 deny-read
sandbox helper/路径转换失败SandboxManager::transform返回准备错误,不伪装成命令成功否
网络 baseline 明确 denynetwork 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 discoveryhandler 不进入执行集合并产生状态/警告否
plugin 路径逃逸或 archive linkmanifest/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

rust
// :: 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

rust
// :: 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

rust
// :: 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

rust
// :: 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

rust
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. 运行现象定位 ​

排查一条可疑文件或网络访问时,按执行主体而不是按工具显示名称开始:

  1. 确认调用来自 shell/apply_patch、MCP、Hook,还是 Rust extension;只有第一类默认进入 ToolOrchestrator 的 shell 沙箱链路。
  2. 记录当前 PermissionProfile 的 enforcement、filesystem entries、network policy,以及单次命令 的 SandboxPermissions;不要只看旧的 sandbox_mode 文案。
  3. 分别检查 ExecApprovalRequirement 和最终 SandboxAttempt。Approved 说明策略同意, SandboxType 才说明平台后端。
  4. 文件问题检查最具体 entry、deny-read、symlink/canonical path、protected metadata 与 hard link; “路径位于 cwd”不是充分条件。
  5. 网络问题检查进程是否被强制使用 managed proxy,再看 proxy audit 的 decision source;只看到 HTTP_PROXY 一类变量不能证明直连已被封锁。
  6. MCP 问题同时检查 server placement、tool filter/approval、环境变量来源和认证方式;批准 tool call 不代表 server 获得了新的沙箱。
  7. 凭据问题同时检查 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 trustcodex-rs/core-plugins/src/manifest.rs、codex-rs/core-plugins/src/plugin_bundle_archive.rs、codex-rs/hooks/src/engine
Skill 与 extensioncodex-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 是否仍是规范权限模型,legacy SandboxPolicy 的投影是否改变;
  • 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 主线落回源码:

bash
rg -n "PermissionProfile|SandboxAttempt|ExecApprovalRequirement|SandboxManager" codex-rs