macOS 沙箱 · Seatbelt 策略生成

macOS 上 Codex 不装任何驱动,而是把每条命令交给系统自带的 /usr/bin/sandbox-exec,附上一段现拼的策略。策略由 codex-sandboxing 按 PermissionProfile 生成:以 (deny default) 为底,叠上读、写、网络三段,再追加守护进程套接字、XPC、读禁区、fcntl 等收尾拒绝;路径一律经 -D 参数传入。本篇拆开这段 SBPL 的生成顺序,以及被拦下之后怎么发现。

作者 David更新于 第 29 篇(共 57 篇)

macOS 沙箱 · Seatbelt 策略生成

上一篇里编排器决定“要套沙箱”之后,剩下的事交给 codex-sandboxing 的 SandboxManager。三个平台三种实现,这一篇是 macOS。

用户看到的样子

macOS 上沙箱开箱即用:不需要安装任何东西,read-only 与 workspace-write 下的每条命令都在 Seatbelt 里跑,子进程继承同一份策略,越界的写入会得到 “Operation not permitted”。被沙箱包起来的进程能看到环境变量 CODEX_SANDBOX=seatbelt,网络受限时还有 CODEX_SANDBOX_NETWORK_DISABLED=1,脚本可以据此跳过需要联网的测试。想单独验证策略,用 codex sandbox -- <命令>:-P 选权限配置档,--allow-unix-socket 放行指定的 Unix 套接字,--log-denials 在命令结束后打印被拦截的操作(用法见手册的沙箱、审批与安全)。

从 SandboxManager 到 sandbox-exec

SandboxManager::select_initial 在 macOS 上返回 SandboxType::MacosSeatbelt,transform 随即调用 create_seatbelt_command_args_with_profile,把原命令变成 sandbox-exec 的参数。可执行文件的路径是写死的:

/// When working with `sandbox-exec`, only consider `sandbox-exec` in `/usr/bin`
/// to defend against an attacker trying to inject a malicious version on the
/// PATH. If /usr/bin/sandbox-exec has been tampered with, then the attacker
/// already has root access.
pub const MACOS_PATH_TO_SEATBELT_EXECUTABLE: &str = "/usr/bin/sandbox-exec";

(codex-rs/sandboxing/src/seatbelt.rs:55)

策略用 -p 以字符串形式整段传入,不落盘;策略里出现的每个路径都写成 (param "WRITABLE_ROOT_0") 这样的占位,真实路径经 -DWRITABLE_ROOT_0=/path 参数传进去,省去在 SBPL 里转义路径的麻烦,策略文本也不随路径变化。

图表加载中…

基础策略:默认全拒

四个 .sbpl 文件以 include_str! 编进二进制。基础策略的开头就是整套设计的基调:

; start with closed-by-default
(deny default)

; child processes inherit the policy of their parent
(allow process-exec)
(allow process-fork)
(allow signal (target same-sandbox))

(codex-rs/sandboxing/src/seatbelt_base_policy.sbpl:7)

文件头注明参考了 Chrome 的沙箱策略。之后是一串精挑细选的放行:hw.*、kern.* 等只读 sysctl 白名单(让程序能查询 CPU 与内存信息),/dev/null 写入,伪终端 openpty() 与 /dev/ttys*,Python 多进程需要的 POSIX 信号量,PyTorch 与 libomp 注册 OpenMP 运行时用的共享内存,每一条都附着注释说明是给谁用的。

读与写

读策略分两种情况。配置档有全盘读权限时是一句 (allow file-read*),有读禁区时改成“根目录减去禁区”;只读特定路径(配置档用了 :minimal 之类)时逐个放行可读根,再叠上 seatbelt_read_only_platform_defaults.sbpl 里的系统路径(/usr/lib、系统框架等)和 /Applications。偏好设置那份策略只在全盘可读时加入,注释的理由是 cfprefs 的 IPC 可能泄露文件系统读根之外的数据。

写策略是重点。全盘可写时是 (allow file-write* (regex #"^/"));否则每个可写根生成一个 require-all:先是 (subpath (param "WRITABLE_ROOT_i")),再为每个只读子路径和元数据名追加排除条件:

            for (excluded_param, excluded_subpath) in excluded_subpaths {
                params.push((excluded_param.clone(), excluded_subpath.into_path_buf()));
                // Exclude both the exact protected path and anything beneath it.
                // `subpath` alone leaves a gap for first-time creation of the
                // protected directory itself, such as `mkdir .codex`.
                require_parts.push(format!(
                    "(require-not (literal (param \"{excluded_param}\")))"
                ));
                require_parts.push(format!(
                    "(require-not (subpath (param \"{excluded_param}\")))"
                ));
            }
        }
        for metadata_name in access_root.protected_metadata_names {
            let regex =
                seatbelt_protected_metadata_name_regex(&root, &metadata_name).replace('"', "\\\"");
            require_parts.push(format!(r#"(require-not (regex #"{regex}"))"#));
        }
        policy_components.push(format!("(require-all {} )", require_parts.join(" ")));

(codex-rs/sandboxing/src/seatbelt.rs:557)

同时用 literal 和 subpath 两个条件,是为了堵住“目录还不存在时 mkdir .codex”这个缝;.git、.agents、.codex 还额外用正则 ^<根>/<名字>(/.*)?$ 兜底,即使目录不存在也拦得住(元数据保护的来历见权限模型)。另外几处细节都在防“借改名绕过”:

  • 每个可写根本身禁止 file-write-unlink,沙箱里的进程不能把一个“下次还要用来生成策略”的目录整个换掉;
  • 只读子路径的所有祖先目录(在可写根之内的部分)同样禁止 unlink,否则把祖先目录改个名,受保护的子路径就挪到了排除条件之外;
  • 可写根的路径里含有符号链接就直接报错,除非根位于 CODEX_HOME 之下,并在 config.toml 顶层设置了 allow_symlinked_codex_home = true;/tmp 到 /private/tmp 这类系统级别名则先规范化再写进策略。

网络:全开、只连代理、或者全关

    let should_use_restricted_network_policy = !proxy.ports.is_empty()
        || proxy.has_proxy_config
        || enforce_managed_network
        || (!network_policy.is_enabled() && has_some_unix_socket_access);
    if should_use_restricted_network_policy {
        let mut policy = String::new();
        if proxy.allow_local_binding {
            // ...
        }
        // ...
        for port in &proxy.ports {
            policy.push_str(&format!(
                "(allow network-outbound (remote ip \"localhost:{port}\"))\n"
            ));
        }

(codex-rs/sandboxing/src/seatbelt.rs:325)

只要有受管代理(环境里的代理变量指向回环地址,或者托管要求强制走代理),网络段就只放行到 localhost 上这几个代理端口的出站连接;allow_local_binding 打开时再允许本地监听、回环流量和 53 端口的 DNS。有代理配置却解析不出可用端口、或托管要求在但没有代理端点时,网络段为空,也就是全拒,注释写的是 “Fail closed”。没有代理、网络开着时是 (allow network-outbound) 加 (allow network-inbound);网络关着就什么都不加,交给 (deny default)。网络段不为空时,末尾都会附上 seatbelt_network_policy.sbpl:只放行 AF_SYSTEM 套接字和 TLS 证书校验、DNS 配置等少数系统服务的 mach 查找。允许的 Unix 套接字以 subpath 放行,--allow-unix-socket 走的就是这条路。

收尾的几条拒绝

读、写、网络三段之后,还有一串无论怎么放行都要保留的拒绝,拼接顺序决定了它们压在最后:

    let mut policy_sections = vec![
        MACOS_SEATBELT_BASE_POLICY.to_string(),
        file_read_policy,
        file_write_policy,
        network_policy,
    ];
    if file_system_sandbox_policy.has_full_disk_read_access() {
        policy_sections.push(MACOS_SEATBELT_PREFERENCES_POLICY.to_string());
    }
    // ...
    policy_sections.push("(deny mach-lookup (xpc-service-name-prefix \"\"))".to_string());
    policy_sections.push(deny_read_policy);
    // ...
    let mut seatbelt_args: Vec<String> = vec!["-p".to_string(), full_policy];
    let definition_args = dir_params
        .into_iter()
        .map(|(key, value): (String, PathBuf)| {
            format!("-D{key}={value}", value = value.to_string_lossy())
        });
    seatbelt_args.extend(definition_args);
    seatbelt_args.push("--".to_string());

(codex-rs/sandboxing/src/seatbelt.rs:1041)

  • 守护进程套接字:文件系统受限时,禁止读写 app-server 控制套接字所在目录,也禁止向其中的 Unix 套接字发起连接,否则沙箱里的命令可以借网络授权或套接字白名单反过来驱动拥有更高权限的 app-server(见守护进程与传输);
  • XPC:拒绝所有 XPC 服务查找;
  • 读禁区:拒绝读取的 glob(配置档里的 deny glob 和托管的 deny_read)被翻译成正则,读写一并拒绝,并禁止改名其祖先目录;
  • fcntl:文件系统受限时拒绝 fcntl 命令 80 与 110(F_MAKECOMPRESSED、F_TRANSFEREXTENTS),注释说它们能经只读描述符修改文件,绕过 file-write*。

被拦下之后

Seatbelt 本身不告诉调用方“是我拦的”。codex-sandboxing 的 is_likely_sandbox_denied 靠非零退出码加输出里的关键词判断,判为拒绝就以 SandboxErr::Denied 交给编排器,由它决定要不要请求提权重试;一次性执行与 apply_patch 路径还会用 record_filesystem_sandbox_violation 记一条带后端名 seatbelt 的日志。想看确切被拦的是什么,就用 codex sandbox --log-denials:它在命令运行期间起一个 log stream --style ndjson,只收沙箱子系统的消息,同时用 PidTracker 跟踪子进程树,结束后按进程号过滤、去重,打印“哪个进程被拒了什么能力”(codex-rs/cli/src/debug_sandbox/seatbelt.rs)。这条调试路径还会在策略末尾追加 (deny file-ioctl (ioctl-command TIOCSTI)):codex sandbox 继承了你的控制终端,这条拒绝防止子进程往退出后恢复运行的 shell 里塞输入。

和《从 LLM 到 Coding Agent》对照

那本书的权限系统停在“执行前问一句”;Seatbelt 是它下面那层:问过或没问,命令都跑在内核强制的边界里。Grok Build 在 macOS 上同样用 Seatbelt(见其沙箱与权限),也处理了 /tmp 到 /private/tmp 的别名;Codex 的策略则是每条命令现拼一份,把配置档、代理端口、读禁区一次性翻译成 SBPL,外加一串针对“改名绕过”的拒绝。


上一篇:审批流程 · 什么时候停下来问你 · 下一篇:Linux 沙箱 · bubblewrap 与 Landlock

本页目录