Skip to content

macOS沙箱执行流程

追踪 Seatbelt argv 从 policy transform 到本地进程、Unified Exec 输出收尾、denial 分类与审批重试。

基于rust-v0.150.0
CodexRustSecuritymacOSSeatbelt

macOS沙箱执行流程 ​

Seatbelt policy 生成只是“准备了一个 launch command”,还没有执行用户程序。macOS 本地流程要经过四个边界:SandboxManager::transform 生成 /usr/bin/sandbox-exec argv;Core 将 SandboxExecRequest 转为含环境、capture 和权限快照的 ExecRequest;spawn 层创建 child 并收集 stdout/stderr;退出后再依据 concrete sandbox type、exit code 和输出分类 denial。

0.150.0 还增加了 Unified Exec 与 executor-managed 路径的区分。local Seatbelt 进程的 SandboxType 是 MacosSeatbelt;remote 或 shell-snapshot request 在 Core 侧可能是 None,但 sandbox intent 仍通过 exec_server_sandbox 传给 executor。因而 denial 判断不能只看一个枚举值,重试也不能简单“把 sandbox 字段改成 None”。

本文承接macOS Seatbelt规则生成和跨平台Sandbox抽象。前文讲 SBPL 如何生成,本篇讲它如何进入进程生命周期、何时被识别为拒绝、为什么普通非零退出不应自动升级;不重复每条文件或网络 SBPL 规则。

1. Transform ​

1.1 本机包装 ​

SandboxManager::transform 接收 SandboxType::MacosSeatbelt 时,先用 effective PermissionProfile 生成 Seatbelt 参数,再把固定路径 /usr/bin/sandbox-exec 放在 command 首位。-- 之后是原始 command;SBPL 文本和 -D 定义只属于 wrapper 参数。

源码位置:codex-rs/sandboxing/src/manager.rs :: SandboxManager::transform

rust
let (argv, arg0_override, pending_sandboxed_request) = match sandbox {
    SandboxType::None => (os_argv_to_strings(argv), None, None),
    #[cfg(target_os = "macos")]
    SandboxType::MacosSeatbelt => {
        use crate::seatbelt::CreateSeatbeltCommandArgsParams;
        use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE;
        use crate::seatbelt::SeatbeltPreparationError;
        use crate::seatbelt::create_seatbelt_command_args_with_profile;

        let pending = pending_sandboxed_request?;
        let (file_system_sandbox_policy, network_sandbox_policy) = pending
            .effective_permission_profile
            .to_runtime_permissions();
        let mut args = create_seatbelt_command_args_with_profile(
            CreateSeatbeltCommandArgsParams {
                command: os_argv_to_strings(argv),
                file_system_sandbox_policy: &file_system_sandbox_policy,
                network_sandbox_policy,
                sandbox_policy_cwd: pending.native_sandbox_policy_cwd.as_path(),
                enforce_managed_network,
                managed_network,
                environment_id,
                network,
                extra_allow_unix_sockets: &[],
            },
            self.seatbelt_profile,
        )
        .map_err(|err| match err {
            SeatbeltPreparationError::FileSystem(message) => {
                SandboxTransformError::SeatbeltPreparation(message)
            }
            SeatbeltPreparationError::EnvironmentNetworkProxy(message) => {
                SandboxTransformError::EnvironmentNetworkProxy(message)
            }
        })?;
        let mut full_command = Vec::with_capacity(1 + args.len());
        full_command.push(MACOS_PATH_TO_SEATBELT_EXECUTABLE.to_string());
        full_command.append(&mut args);
        (full_command, None, Some(pending))
    }

如果同一代码在非 macOS 编译,显式请求 MacosSeatbelt 会返回 SeatbeltUnavailable;这不是把 Linux 或 Windows 当成 Seatbelt 的兼容实现。

1.2 Request字段 ​

transform 的返回值仍使用 PathUri cwd、network environment ID、PermissionProfile、Windows compatibility 字段和 arg0。它是一次 launch snapshot,不是已创建的 child handle。

源码位置:codex-rs/sandboxing/src/manager.rs :: SandboxExecRequest

rust
#[derive(Debug)]
pub struct SandboxExecRequest {
    pub command: Vec<String>,
    pub cwd: PathUri,
    pub sandbox_policy_cwd: PathUri,
    pub env: HashMap<String, String>,
    pub network: Option<NetworkProxy>,
    pub network_environment_id: Option<String>,
    pub sandbox: SandboxType,
    pub windows_sandbox_level: WindowsSandboxLevel,
    pub windows_sandbox_private_desktop: bool,
    pub permission_profile: PermissionProfile,
    pub arg0: Option<String>,
}

pending_sandboxed_request 在 Seatbelt 分支中已经完成 cwd 和 profile 的 native 准备;None 分支则保留 base effective profile,以支持 foreign cwd 或 executor-owned request。

2. Core ExecRequest ​

2.1 环境标记 ​

Core 的 adapter 不会把环境变量当作 sandbox 本身。它在 network policy 未启用时写入 CODEX_SANDBOX_NETWORK_DISABLED=1,在 macOS Seatbelt concrete type 下写入 CODEX_SANDBOX=seatbelt。这些字段供子进程和诊断读取,真正的边界仍由 sandbox-exec 应用 SBPL。

源码位置:codex-rs/core/src/sandboxing/mod.rs :: ExecRequest::from_sandbox_exec_request

rust
let network_sandbox_policy = permission_profile.network_sandbox_policy();
if !network_sandbox_policy.is_enabled() {
    env.insert(
        CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR.to_string(),
        "1".to_string(),
    );
}
#[cfg(target_os = "macos")]
if sandbox == SandboxType::MacosSeatbelt {
    env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string());
}

2.2 adapter转换 ​

from_sandbox_exec_request 还会根据 Windows concrete type 计算 filesystem overrides;macOS 则保留 transform 的 command/cwd/env,并把 capture expiration 复制到 ExecRequest。这一步没有启动进程。

源码位置:codex-rs/core/src/sandboxing/mod.rs :: ExecRequest::from_sandbox_exec_request

rust
pub(crate) fn from_sandbox_exec_request(
    request: SandboxExecRequest,
    options: ExecOptions,
    windows_sandbox_workspace_roots: Vec<AbsolutePathBuf>,
) -> Result<Self, CodexErr> {
    let SandboxExecRequest {
        command,
        cwd,
        sandbox_policy_cwd: windows_sandbox_policy_cwd,
        mut env,
        network,
        network_environment_id,
        sandbox,
        windows_sandbox_level,
        windows_sandbox_private_desktop,
        permission_profile,
        arg0,
        ..
    } = request;
    let ExecOptions {
        expiration,
        capture_policy,
    } = options;
    let network_sandbox_policy = permission_profile.network_sandbox_policy();

ExecRequest 同时携带 sandbox 和 permission_profile。后者用于后续 network/sandbox 逻辑,前者用于 denial 分类和平台指标;两者不能只保留其中一个。

3. Local spawn ​

3.1 普通执行入口 ​

最窄的 local path 由 execute_env 调用 execute_exec_request。Unified Exec 则通过 SandboxAttempt::env_for 创建同样的 ExecRequest,再由 process manager 管理长生命周期 session。

源码位置:codex-rs/core/src/sandboxing/mod.rs :: execute_env;codex-rs/core/src/unified_exec/process_manager.rs :: open_session_with_exec_env

rust
pub async fn execute_env(
    exec_request: ExecRequest,
    stdout_stream: Option<StdoutStream>,
) -> codex_protocol::error::Result<ExecToolCallOutput> {
    execute_exec_request(exec_request, stdout_stream, /*after_spawn*/ None).await
}

Unified Exec 的 manager path 会根据 environment 或 shell snapshot 决定是否调用 env_for_exec_server;local Seatbelt 请求则走本机 env_for,保留 concrete MacosSeatbelt。

源码位置:codex-rs/core/src/unified_exec/process_manager.rs :: open_session_with_exec_env

rust
let mut request = if environment.is_remote() || request.exec_server_shell_snapshot.is_some() {
    attempt.env_for_exec_server(command, options)?
} else {
    attempt.env_for(command, options, network, environment_id)?
};
request.exec_server_shell_snapshot = shell_snapshot;

3.2 child与输出 ​

local process manager 创建 child 后,把 stdout/stderr reader、expiration、capture policy 和 sandbox type 绑定到执行状态。child handle 的所有权属于 process manager,不属于 policy generator。

源码位置:codex-rs/core/src/exec.rs :: execute_exec_request

rust
let params = ExecParams {
    command,
    cwd,
    expiration,
    capture_policy,
    env,
    network: network.clone(),
    network_environment_id,
    sandbox_permissions: SandboxPermissions::UseDefault,
    windows_sandbox_level,
    windows_sandbox_private_desktop,
    justification: None,
    arg0,
};

ExecParams 进入 spawn 层后才会产生实际 child。输出被拆成 stdout、stderr 和 aggregated output,后续 denial heuristic 会读取这些不同视图。

4. Denial识别 ​

4.1 Seatbelt类型 ​

is_likely_sandbox_denied 是 side-effect-free predicate。它先排除 SandboxType::None 和 exit 0,再识别 executor-managed keyword、Linux Seccomp 的 SIGSYS;在当前 macOS concrete type 上,常见的 operation not permitted、read-only file system 等输出只能说明“很像 sandbox denial”,不能说明唯一原因。

源码位置:codex-rs/sandboxing/src/denial.rs :: is_likely_sandbox_denied

rust
pub fn is_likely_sandbox_denied(
    sandbox_type: SandboxType,
    exec_output: &ExecToolCallOutput,
) -> bool {
    if sandbox_type == SandboxType::None || exec_output.exit_code == 0 {
        return false;
    }

    if is_likely_executor_managed_sandbox_denied(exec_output) {
        return true;
    }

    const QUICK_REJECT_EXIT_CODES: [i32; 3] = [2, 126, 127];
    if QUICK_REJECT_EXIT_CODES.contains(&exec_output.exit_code) {
        return false;
    }

    #[cfg(unix)]
    {
        const EXIT_CODE_SIGNAL_BASE: i32 = 128;
        const SIGSYS_CODE: i32 = libc::SIGSYS;
        if sandbox_type == SandboxType::LinuxSeccomp
            && exec_output.exit_code == EXIT_CODE_SIGNAL_BASE + SIGSYS_CODE
        {
            return true;
        }
    }

    false
}

这里的 quick reject 保护很重要:command not found(127)、permission/argument usage error(2)和不可执行文件(126)不能仅凭非零退出被升级为 Seatbelt denial。

4.2 Executor关键词 ​

当具体 backend 不在 Core 进程中时,is_likely_executor_managed_sandbox_denied 扫描 stderr、stdout 和 aggregated output 的七类关键词。它同样只返回可能性,不修改状态。

源码位置:codex-rs/sandboxing/src/denial.rs :: is_likely_executor_managed_sandbox_denied

rust
pub fn is_likely_executor_managed_sandbox_denied(exec_output: &ExecToolCallOutput) -> bool {
    if exec_output.exit_code == 0 {
        return false;
    }

    const SANDBOX_DENIED_KEYWORDS: [&str; 7] = [
        "operation not permitted",
        "permission denied",
        "read-only file system",
        "seccomp",
        "sandbox",
        "landlock",
        "failed to write file",
    ];

    [
        &exec_output.stderr.text,
        &exec_output.stdout.text,
        &exec_output.aggregated_output.text,
    ]
    .into_iter()
    .any(|section| {
        let lower = section.to_lowercase();
        SANDBOX_DENIED_KEYWORDS
            .iter()
            .any(|needle| lower.contains(needle))
    })
}

4.3 Unified Exec等待 ​

长生命周期 Unified Exec 不会在 child 刚退出的瞬间读取一个可能尚未 drain 完的空 buffer。check_for_sandbox_denial 先等待最多 20ms 的 output notification,再快照聚合输出;若 executor 已报告 denial 或 concrete predicate 命中,才构造 UnifiedExecError::SandboxDenied。

源码位置:codex-rs/core/src/unified_exec/process.rs :: check_for_sandbox_denial, check_for_sandbox_denial_with_text

rust
pub(super) async fn check_for_sandbox_denial(&self) -> Result<(), UnifiedExecError> {
    let _ = tokio::time::timeout(
        Duration::from_millis(20),
        self.output.output_notify.notified(),
    )
    .await;

    let aggregated = self.snapshot_output().await;
    let aggregated_text = String::from_utf8_lossy(&aggregated);
    self.check_for_sandbox_denial_with_text(aggregated_text.as_ref())
        .await?;

    Ok(())
}

源码位置:codex-rs/core/src/unified_exec/process.rs :: check_for_sandbox_denial_with_text

rust
let executor_reported_denial = self.state_rx.borrow().sandbox_denied;
let sandbox_type = self.sandbox_type();
if !self.has_exited() || (!executor_reported_denial && sandbox_type == SandboxType::None) {
    return Ok(());
}

let exit_code = self.exit_code().unwrap_or(-1);
let exec_output = ExecToolCallOutput {
    exit_code,
    stderr: StreamOutput::new(text.to_string()),
    aggregated_output: StreamOutput::new(text.to_string()),
    ..Default::default()
};
let likely_sandbox_denial = is_likely_sandbox_denied(sandbox_type, &exec_output);
if executor_reported_denial || likely_sandbox_denial {
    return Err(UnifiedExecError::sandbox_denied(
        text.to_string(),
        exec_output,
    ));
}

executor reported denial 是跨 owner 的显式信号;Core concrete predicate 则是本机的补充判断。两者合并后才进入统一错误模型。

5. 错误传播 ​

5.1 普通execute ​

短命令路径在 finalize_exec_result 中先把 signal、timeout、exit code 和 output 组装成 ExecToolCallOutput。timeout 先返回 SandboxErr::Timeout;只有 likely denial 才返回 SandboxErr::Denied。

源码位置:codex-rs/core/src/exec.rs :: finalize_exec_result

rust
if timed_out {
    return Err(CodexErr::Sandbox(SandboxErr::Timeout {
        output: Box::new(exec_output),
    }));
}

if is_likely_sandbox_denied(sandbox_type, &exec_output) {
    record_filesystem_sandbox_violation(sandbox_type, &exec_output);
    return Err(CodexErr::Sandbox(SandboxErr::Denied {
        output: Box::new(exec_output),
        network_policy_decision: None,
    }));
}

Ok(RawExecToolCallOutput {
    exit_status,
    stdout,
    stderr,
    aggregated_output,
    timed_out,
})

普通 non-zero 但没有 denial 证据的 command 会保留为普通执行结果,让上层看到真实 exit code 和输出,而不是伪装成 sandbox 问题。

5.2 Unified Exec ​

Unified process 记录 sandbox_denied,退出事件携带该分类;调用方随后把它转换为 UnifiedExecError::SandboxDenied,保留截断后的 output snippet。这种状态可被 process manager、SSE 事件和 retry orchestration 分别消费。

源码位置:codex-rs/core/src/unified_exec/process_manager.rs :: exit bookkeeping

rust
process.sandbox_denied = is_likely_sandbox_denied(process.sandbox, &exec_output);
let _ = process.wake_tx.send(seq);
process.events.publish(ExecProcessEvent::Exited {
    seq,
    exit_code,
    sandbox_denied: Some(process.sandbox_denied),
});
Some(ExecExitedNotification {
    process_id: process_id.clone(),
    seq,
    exit_code,
    sandbox_denied: Some(process.sandbox_denied),
})

6. 审批与重试 ​

6.1 首次attempt ​

Orchestrator 在首次执行前已经完成 approval requirement、sandbox override、managed network 和 owner 选择。Seatbelt denial 并不会由 is_likely_sandbox_denied 直接重跑;它只把结果交给 orchestrator。

源码位置:codex-rs/core/src/tools/orchestrator.rs :: ToolOrchestrator::run

rust
let sandbox_override = if unsandboxed_allowed {
    sandbox_override_for_first_attempt(
        tool.sandbox_permissions(req),
        &requirement,
        &file_system_sandbox_policy,
    )
} else {
    SandboxOverride::NoOverride
};
let sandbox_requested = match sandbox_override {
    SandboxOverride::BypassSandboxFirstAttempt => false,
    SandboxOverride::NoOverride => self.sandbox.should_sandbox(
        &permissions,
        sandbox_preference,
        managed_network_active,
    ),
};
let initial_sandbox = if sandbox_requested && !executor_managed_process_sandbox {
    self.sandbox.select_initial(
        &permissions,
        sandbox_preference,
        turn_ctx.windows_sandbox_level,
        managed_network_active,
    )
} else {
    SandboxType::None
};

6.2 denial后重算 ​

首次 attempt 失败后,Orchestrator 重新计算 retry_sandbox_requested。local owner 重新选择 concrete backend;executor-managed owner 保持 host SandboxType::None,由 exec-server context 继续强制。deny-read 存在时,unsandboxed_allowed 为 false,不能用 retry 把唯一的文件读取边界去掉。

源码位置:codex-rs/core/src/tools/orchestrator.rs :: retry sandbox construction

rust
let retry_sandbox_requested = !unsandboxed_allowed
    && self.sandbox.should_sandbox(
        &permissions,
        sandbox_preference,
        managed_network_active,
    );
let retry_sandbox = if retry_sandbox_requested && !executor_managed_process_sandbox {
    self.sandbox.select_initial(
        &permissions,
        sandbox_preference,
        turn_ctx.windows_sandbox_level,
        managed_network_active,
    )
} else {
    SandboxType::None
};
let retry_attempt = SandboxAttempt {
    sandbox: retry_sandbox,
    sandbox_requested: retry_sandbox_requested,
    permissions: &permissions,
    exec_server_permissions: permission_profile,
    enforce_managed_network: managed_network_active,
    manager: &self.sandbox,
    sandbox_cwd: &sandbox_policy_cwd,
    workspace_roots,

是否请求第二次审批取决于 escalation policy、additional permissions 和当前 turn 的 approval flow;denial heuristic 本身不授予 unsandboxed execution。

7. 测试边界 ​

本篇针对执行和 denial 路径运行了以下测试:

  • codex-core exec tests:8 项目标断言覆盖无关键词、stderr 关键词、quick reject exit、SandboxType::None、aggregated output 和 Linux SIGSYS。
  • codex-sandboxing violation tests:10 项目标断言覆盖 concrete/executor keyword、stream aggregation、network policy text 排除和 violation 映射。
  • codex-core Unified Exec process tests:5 项目标测试覆盖 process state、sandbox type、终态错误和远程 process 语义。
  • codex-core Unified Exec process-manager tests:15 项覆盖 environment overlay、exec-server 参数、输出 drain、network denial grace 和 process retention。
  • codex-core sandboxing tests:11 项覆盖 env_for、env_for_exec_server、deny-read、Windows fallback 和 executor context。
  • codex-exec-server process-sandbox tests:6 项覆盖 executor-native argv、sandbox wrapper、custom arg0、managed proxy 和 native launch。
  • codex-exec-server local-process tests:13 项覆盖 child 环境、退出收尾、network policy notification、process retention 和 native cwd 拒绝。

源码位置:

  • codex-rs/core/src/exec_tests.rs :: sandbox_detection_identifies_keyword_in_stderr
  • codex-rs/core/src/exec_tests.rs :: sandbox_detection_respects_quick_reject_exit_codes
  • codex-rs/core/src/exec_tests.rs :: sandbox_detection_uses_aggregated_output
  • codex-rs/sandboxing/src/violation_tests.rs :: classifies_filesystem_violation_from_aggregated_output
  • codex-rs/core/src/unified_exec/process_manager_tests.rs :: late_network_denial_grace_observes_cancellation_after_exit
  • codex-rs/core/src/tools/sandboxing_tests.rs :: exec_server_env_keeps_command_native_and_carries_sandbox_context
  • codex-rs/exec-server/src/process_sandbox_tests.rs :: sandbox_request_wraps_native_argv_on_executor
  • codex-rs/exec-server/src/local_process.rs :: exit_before_shutdown_records_success
bash
cargo test -p codex-core --lib exec_tests -- --nocapture --test-threads=1
cargo test -p codex-sandboxing --lib violation::tests:: -- --nocapture --test-threads=1
cargo test -p codex-core --lib unified_exec::process_tests:: -- --nocapture --test-threads=1
cargo test -p codex-core --lib unified_exec::process_manager::tests:: -- --nocapture --test-threads=1
cargo test -p codex-core --lib tools::sandboxing::tests:: -- --nocapture --test-threads=1
cargo test -p codex-exec-server --lib process_sandbox::tests:: -- --nocapture --test-threads=1
cargo test -p codex-exec-server --lib local_process::tests:: -- --nocapture --test-threads=1

这些测试证明 transform/adapter 的字段传播、输出分类和 retry 输入准备;不能证明某个关键词必然由 Seatbelt 产生,也不能证明所有 macOS 版本都允许测试 fixture 调用 sandbox-exec。当前环境若在 sandbox_apply 阶段被宿主拒绝,应与用户 command 在已应用 policy 下返回的 denial 分开解释。

8. 继续阅读 ​

建议按 SandboxManager::transform → ExecRequest::from_sandbox_exec_request → execute_exec_request 或 open_session_with_exec_env → output drain → is_likely_sandbox_denied → ToolOrchestrator retry 阅读。读完后应能区分:SBPL 生成、wrapper 启动、child 输出、denial heuristic、executor-reported denial 和第二次 attempt 各自属于哪一层。

下一篇Linux Landlock策略将把同一 execution contract 放到 Linux,分析 PermissionProfile 如何进入 Landlock/Bubblewrap 以及 legacy flag 的兼容边界。