Skip to content

macOS Seatbelt规则生成

追踪 PermissionProfile 到 SBPL 的文件根、metadata carveout、glob deny、代理端口与 sandbox-exec 参数生成。

基于rust-v0.150.0
CodexRustSecuritymacOSSeatbelt

macOS Seatbelt规则生成 ​

Codex 的 macOS Seatbelt 生成器接收的是已经解析好的 PermissionProfile 和网络上下文,而不是一个“允许目录列表”。它必须同时表达 readable roots、writable roots、Deny entries、受保护 metadata、unreadable glob、代理端口和 Unix socket allowlist,然后把这些关系编译为 SBPL policy 与 -DNAME=PATH 参数。

0.150.0 的关键不变量是路径权限不能因为字符串 canonicalize 而扩大。顶层系统别名(例如 /tmp)可以被解析,用户可控制路径中的 nested symlink 则拒绝;不存在的 writable file 使用 literal 匹配,目录使用 subpath;read-only carveout 的祖先还要禁止 rename,避免进程把受保护子树移动到规则之外。网络同样采用保守策略:proxy 配置存在但没有可用 loopback endpoint 时,不能恢复 blanket network allow。

本文承接跨平台Sandbox抽象和PermissionProfile解析。前文解释 backend owner 与 canonical 权限,本文只讲 macOS 侧 SBPL 文本和参数生成;不把 policy string 当成已经获得内核 enforcement,也不展开 sandbox-exec 的全部 Apple 语法。

1. 输入与模块 ​

1.1 policy片段 ​

Seatbelt 模块把静态 base/network/preferences policy 作为内嵌资源;动态部分由 Rust 根据 runtime policy 生成。MacosSeatbeltProfile 区分普通进程与 filesystem helper,后者不应获得普通进程的 /Applications 读取默认值。

源码位置:codex-rs/sandboxing/src/seatbelt.rs :: policy constants, MacosSeatbeltProfile

rust
const MACOS_SEATBELT_BASE_POLICY: &str = include_str!("seatbelt_base_policy.sbpl");
const MACOS_SEATBELT_NETWORK_POLICY: &str = include_str!("seatbelt_network_policy.sbpl");
const MACOS_SEATBELT_PREFERENCES_POLICY: &str = include_str!("seatbelt_preferences_policy.sbpl");
const MACOS_RESTRICTED_READ_ONLY_PLATFORM_DEFAULTS: &str =
    include_str!("restricted_read_only_platform_defaults.sbpl");
const MACOS_PROCESS_APPLICATIONS_READ_POLICY: &str =
    r#"(allow file-read* (subpath "/Applications"))"#;

#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(crate) enum MacosSeatbeltProfile {
    #[default]
    Process,
    FileSystemHelper,
}

base policy 从 (deny default) 开始,只开放进程继承、同 sandbox signal、PTY、必要 sysctl 和基础 IPC。seatbelt_preferences_policy.sbpl 只有 full-disk-read 条件满足时才拼接,避免 Preferences IPC 成为受限读取的旁路。

源码位置:codex-rs/sandboxing/src/seatbelt_base_policy.sbpl :: default deny;codex-rs/sandboxing/src/seatbelt_preferences_policy.sbpl :: preferences access

scheme
(version 1)

; 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))

1.2 参数入口 ​

CreateSeatbeltCommandArgsParams 让调用方一次传入 command、filesystem policy、network policy、sandbox cwd、managed network、environment ID、NetworkProxy 和额外 Unix sockets。生成器不重新解析 profile 名称,这保证策略解析和 SBPL 编译之间只有一个权限来源。

源码位置:codex-rs/sandboxing/src/seatbelt.rs :: CreateSeatbeltCommandArgsParams

rust
#[derive(Debug)]
pub struct CreateSeatbeltCommandArgsParams<'a> {
    pub command: Vec<String>,
    pub file_system_sandbox_policy: &'a FileSystemSandboxPolicy,
    pub network_sandbox_policy: NetworkSandboxPolicy,
    pub sandbox_policy_cwd: &'a Path,
    pub enforce_managed_network: bool,
    pub managed_network: Option<&'a ManagedNetworkSandboxContext>,
    pub environment_id: Option<&'a str>,
    pub network: Option<&'a NetworkProxy>,
    pub extra_allow_unix_sockets: &'a [AbsolutePathBuf],
}

2. 路径规范化 ​

2.1 普通绝对路径 ​

normalize_path_for_sandbox 先检查 is_absolute(),然后尝试 canonicalize;canonicalize 失败时保留绝对原值。这一函数用于 readable roots、socket paths 和 glob 静态前缀,但它不会把相对路径解释为当前工作目录。

源码位置:codex-rs/sandboxing/src/seatbelt.rs :: normalize_path_for_sandbox

rust
fn normalize_path_for_sandbox(path: &Path) -> Option<AbsolutePathBuf> {
    // `AbsolutePathBuf::from_absolute_path()` normalizes relative paths against the current
    // working directory, so keep the explicit check to avoid silently accepting relative entries.
    if !path.is_absolute() {
        return None;
    }

    let absolute_path = AbsolutePathBuf::from_absolute_path(path).ok()?;
    let normalized_path = absolute_path
        .as_path()
        .canonicalize()
        .ok()
        .and_then(|canonical_path| AbsolutePathBuf::from_absolute_path(canonical_path).ok());
    normalized_path.or(Some(absolute_path))
}

测试 normalize_path_for_sandbox_rejects_relative_paths 以 relative.sock 为输入,断言返回 None。这证明的是输入验证,不代表绝对路径一定存在或可访问。

2.2 Writable root ​

可写 root 的检查更严格。nested_symlink_component 允许 /tmp 这类顶层系统 alias,但发现用户控制的深层 symlink 就返回错误。只有 top-level alias 会被 normalize_top_level_alias_for_sandbox 解析;深层组件可能在 sandbox 进程运行期间被替换,预先跟随会把路径检查变成新的授权。

源码位置:codex-rs/sandboxing/src/seatbelt.rs :: nested_symlink_component, normalize_top_level_alias_for_sandbox, normalize_writable_root_for_sandbox

rust
fn nested_symlink_component(path: &Path) -> Option<&Path> {
    // Keep top-level macOS aliases such as `/tmp -> /private/tmp` compatible,
    // but reject symlinks in user-controlled path components.
    path.ancestors().find(|ancestor| {
        let Ok(metadata) = std::fs::symlink_metadata(ancestor) else {
            return false;
        };
        metadata.file_type().is_symlink() && ancestor.parent().and_then(Path::parent).is_some()
    })
}

fn normalize_writable_root_for_sandbox(
    root: AbsolutePathBuf,
) -> Result<NormalizedWritableRoot, SeatbeltPreparationError> {
    if let Some(symlink) = nested_symlink_component(root.as_path()) {
        return Err(SeatbeltPreparationError::FileSystem(format!(
            "writable root {} contains symlink component {}; symlinked writable roots are not supported",
            root.display(),
            symlink.display()
        )));
    }

    let normalized = normalize_top_level_alias_for_sandbox(root)?;
    let metadata = match std::fs::symlink_metadata(normalized.as_path()) {
        Ok(metadata) => metadata,
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
            return Ok(NormalizedWritableRoot::Subpath(normalized));
        }
        Err(err) => {
            return Err(SeatbeltPreparationError::FileSystem(format!(
                "failed to inspect Seatbelt writable root {}: {err}",
                normalized.display()
            )));
        }
    };
    if metadata.is_dir() {
        return Ok(NormalizedWritableRoot::Subpath(normalized));
    }

    Ok(NormalizedWritableRoot::Literal(normalized))
}

不存在的 root 也返回 Subpath,因为未来创建目录后应该允许其后代写入;已存在的普通文件则返回 Literal,避免把同名目录树一并放开。

3. 文件访问策略 ​

3.1 Root与排除项 ​

build_seatbelt_access_policy 为每个 root 分配 READABLE_ROOT_N 或 WRITABLE_ROOT_N 参数。read 使用 subpath;write 根据 root 是目录还是文件选择 subpath 或 literal。excluded path 同时加入 literal 和 subpath 两个 require-not,这样首次创建 .codex 目录也会被阻止。

源码位置:codex-rs/sandboxing/src/seatbelt.rs :: build_seatbelt_access_policy

rust
fn build_seatbelt_access_policy(
    access_kind: SeatbeltAccessKind,
    roots: Vec<SeatbeltAccessRoot>,
) -> Result<(String, Vec<(String, PathBuf)>), SeatbeltPreparationError> {
    let mut policy_components = Vec::new();
    let mut root_anchor_denies = Vec::new();
    let mut params = Vec::new();
    let (action, param_prefix) = match access_kind {
        SeatbeltAccessKind::Read => ("file-read*", "READABLE_ROOT"),
        SeatbeltAccessKind::Write => ("file-write*", "WRITABLE_ROOT"),
    };

    for (index, access_root) in roots.into_iter().enumerate() {
        let (root, path_match) = match access_kind {
            SeatbeltAccessKind::Read => {
                let root = normalize_path_for_sandbox(access_root.root.as_path())
                    .unwrap_or(access_root.root);
                (root, SeatbeltPathMatch::Subpath)
            }
            SeatbeltAccessKind::Write => {
                match normalize_writable_root_for_sandbox(access_root.root)? {
                    NormalizedWritableRoot::Subpath(root) => (root, SeatbeltPathMatch::Subpath),
                    NormalizedWritableRoot::Literal(root) => (root, SeatbeltPathMatch::Literal),
                }
            }
        };
        let root_param = format!("{param_prefix}_{index}");
        params.push((root_param.clone(), root.clone().into_path_buf()));
        if access_kind == SeatbeltAccessKind::Write {
            root_anchor_denies.push(format!(
                "(deny file-write-unlink (require-all (literal (param \"{root_param}\")) (vnode-type DIRECTORY)))"
            ));
        }
        let root_filter = match path_match {
            SeatbeltPathMatch::Literal => format!("(literal (param \"{root_param}\"))"),
            SeatbeltPathMatch::Subpath => format!("(subpath (param \"{root_param}\"))"),
        };

root anchor deny 是 0.150.0 的重要新增保护:即使 writable root 是目录,也不能通过 rename 把这个作为权限锚点的目录整个替换掉。否则下一次 policy 生成可能会基于被替换后的路径获得错误授权。

3.2 Metadata保护 ​

每个 writable root 会根据 PROTECTED_METADATA_PATH_NAMES 生成额外的 protected names。若 profile 已经明确允许写入某个 metadata path,该名字不会重复加入;否则 .git、.codex、.agents 等名称会生成 anchored regex。

源码位置:codex-rs/sandboxing/src/seatbelt.rs :: protected_metadata_names_for_writable_root, seatbelt_protected_metadata_name_regex

rust
fn seatbelt_protected_metadata_name_regex(root: &AbsolutePathBuf, name: &str) -> String {
    let mut root = root.to_string_lossy().to_string();
    while root.len() > 1 && root.ends_with('/') {
        root.pop();
    }
    let root = regex_lite::escape(&root);
    let name = regex_lite::escape(name);
    if root == "/" {
        format!(r#"^/{name}(/.*)?$"#)
    } else {
        format!(r#"^{root}/{name}(/.*)?$"#)
    }
}

fn protected_metadata_names_for_writable_root(
    file_system_sandbox_policy: &FileSystemSandboxPolicy,
    writable_root: &WritableRoot,
    cwd: &Path,
) -> Vec<String> {
    let mut names = writable_root.protected_metadata_names.clone();
    for name in PROTECTED_METADATA_PATH_NAMES {
        if names.iter().any(|existing| existing == name) {
            continue;
        }
        let path = writable_root.root.join(*name);
        if !file_system_sandbox_policy.can_write_path_with_cwd(path.as_path(), cwd) {
            names.push((*name).to_string());
        }
    }
    names
}

build_seatbelt_access_policy 将 regex 作为 require-not 拼到同一 root 的 require-all 中。它不仅拒绝写入已存在的 metadata,也阻止第一次创建受保护目录。

源码位置:codex-rs/sandboxing/src/seatbelt.rs :: build_seatbelt_access_policy

rust
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(" ")));

4. Unreadable glob ​

4.1 从glob到regex ​

Seatbelt 不认识 PermissionProfile 的 git-style glob。生成器把 * 限制在单个 path component,**/ 允许零个或多个目录,? 只匹配一个非 / 字符,闭合 character class 保留为 class,brace group 转为 alternation,反斜杠用于转义元字符。

源码位置:codex-rs/sandboxing/src/seatbelt.rs :: seatbelt_regex_for_unreadable_glob, seatbelt_regex_for_glob

rust
fn seatbelt_regex_for_unreadable_glob(pattern: &str) -> Option<String> {
    seatbelt_regex_for_glob(pattern, GlobMatch::Subtree)
}

fn seatbelt_regex_for_glob(pattern: &str, glob_match: GlobMatch) -> Option<String> {
    if pattern.is_empty() {
        return None;
    }

    // Translate the supported git-style glob subset into a Seatbelt regex:
    // `*` and `?` stay within one path component, `**/` can consume zero or
    // more components, closed character classes remain character classes,
    // brace groups become alternations, and backslashes escape metacharacters.
    let mut regex = String::from("^");
    let mut chars = pattern.chars().collect::<VecDeque<_>>();
    let mut saw_glob = false;
    let mut alternate_depth = 0;

    while let Some(ch) = chars.pop_front() {
        match ch {
            '*' => {
                saw_glob = true;
                if chars.front() == Some(&'*') {
                    chars.pop_front();
                    if chars.front() == Some(&'/') {
                        chars.pop_front();
                        regex.push_str("(.*/)?");
                    } else {
                        regex.push_str(".*");
                    }
                } else {
                    regex.push_str("[^/]*");
                }
            }
            '?' => {
                saw_glob = true;
                regex.push_str("[^/]");
            }
            '\\' => {
                if let Some(escaped) = chars.pop_front() {
                    regex.push_str(&regex_lite::escape(&escaped.to_string()));
                } else {
                    regex.push_str("\\\\");
                }
            }
            '{' => {
                saw_glob = true;
                alternate_depth += 1;
                regex.push('(');
            }
            '}' if alternate_depth > 0 => {
                alternate_depth -= 1;
                regex.push(')');
            }
            ',' if alternate_depth > 0 => regex.push('|'),
            _ => regex.push_str(&regex_lite::escape(&ch.to_string())),
        }
    }

4.2 Glob拒绝与祖先 ​

build_seatbelt_unreadable_glob_policy 对每个 unreadable pattern 生成 deny file-read* 和 deny file-write*。它还为 glob 的静态前缀生成 canonical variant,并对可能包含匹配文件的祖先目录生成 deny file-write-unlink,防止通过 rename 把文件移出 glob 的作用范围。

源码位置:codex-rs/sandboxing/src/seatbelt.rs :: build_seatbelt_unreadable_glob_policy

rust
fn build_seatbelt_unreadable_glob_policy(
    file_system_sandbox_policy: &FileSystemSandboxPolicy,
    cwd: &Path,
) -> String {
    let unreadable_globs = file_system_sandbox_policy.get_unreadable_globs_with_cwd(cwd);
    if unreadable_globs.is_empty() {
        return String::new();
    }
    let mut policy_components = Vec::new();
    for pattern in unreadable_globs {
        let mut patterns = BTreeSet::from([pattern.clone()]);
        if let Some(pattern) = canonicalize_glob_static_prefix_for_sandbox(&pattern) {
            patterns.insert(pattern);
        }
        for pattern in patterns {
            let Some(regex) = seatbelt_regex_for_unreadable_glob(&pattern) else {
                continue;
            };
            let regex = regex.replace('"', "\\\"");
            policy_components.push(format!(r#"(deny file-read* (regex #"{regex}"))"#));
            policy_components.push(format!(r#"(deny file-write* (regex #"{regex}"))"#));
            for ancestor in Path::new(&pattern).ancestors().skip(1) {
                let Some(regex) = ancestor
                    .to_str()
                    .and_then(|path| seatbelt_regex_for_glob(path, GlobMatch::Exact))
                else {
                    continue;
                };
                let regex = regex.replace('"', "\\\"");
                policy_components.push(format!(
                    r#"(deny file-write-unlink (require-all (vnode-type DIRECTORY) (regex #"{regex}")))"#
                ));
            }
        }
    }

    policy_components.join("\n")
}

5. 网络与socket ​

5.1 Proxy输入 ​

proxy_policy_inputs 合并两类来源:managed network 直接提供 loopback ports 与 local binding;普通 NetworkProxy 则先为指定 environment 写入临时 env,再从 HTTP/SOCKS URL 中提取 loopback ports。Unix socket allowlist 只接受可规范化的绝对路径,重复路径按字符串去重。

源码位置:codex-rs/sandboxing/src/seatbelt.rs :: proxy_policy_inputs, unix_socket_path_params

rust
fn proxy_policy_inputs(
    managed_network: Option<&ManagedNetworkSandboxContext>,
    network: Option<&NetworkProxy>,
    environment_id: Option<&str>,
    extra_allow_unix_sockets: &[AbsolutePathBuf],
) -> Result<ProxyPolicyInputs, String> {
    let extra_allowed = extra_allow_unix_sockets
        .iter()
        .filter_map(|socket_path| normalize_path_for_sandbox(socket_path.as_path()))
        .collect::<Vec<_>>();

    let unix_domain_socket_policy = match network {
        Some(network) if network.dangerously_allow_all_unix_sockets() => {
            UnixDomainSocketPolicy::AllowAll
        }
        Some(network) => {
            let mut allowed = network
                .allow_unix_sockets()
                .iter()
                .filter_map(|socket_path| normalize_path_for_sandbox(Path::new(socket_path)))
                .collect::<Vec<_>>();
            allowed.extend(extra_allowed);
            UnixDomainSocketPolicy::Restricted { allowed }
        }
        None => UnixDomainSocketPolicy::Restricted {
            allowed: extra_allowed,
        },
    };
    if let Some(managed_network) = managed_network {
        return Ok(ProxyPolicyInputs {
            ports: managed_network.loopback_ports.clone(),
            has_proxy_config: true,
            allow_local_binding: managed_network.allow_local_binding,
            unix_domain_socket_policy,
        });
    }

UnixDomainSocketPolicy::AllowAll 与 restricted allowlist 是互斥变体。allowlist 会为每个路径分配 UNIX_SOCKET_PATH_N,生成器使用 subpath 覆盖目录下创建的 socket,而不是把所有 AF_UNIX 路径放开。

源码位置:codex-rs/sandboxing/src/seatbelt.rs :: unix_socket_policy

rust
fn unix_socket_policy(proxy: &ProxyPolicyInputs) -> String {
    let socket_params = unix_socket_path_params(proxy);
    let has_unix_socket_access = matches!(
        proxy.unix_domain_socket_policy,
        UnixDomainSocketPolicy::AllowAll
    ) || !socket_params.is_empty();
    if !has_unix_socket_access {
        return String::new();
    }

    let mut policy = String::new();
    policy.push_str("(allow system-socket (socket-domain AF_UNIX))\n");
    if matches!(
        proxy.unix_domain_socket_policy,
        UnixDomainSocketPolicy::AllowAll
    ) {
        policy.push_str("(allow network-bind (local unix-socket))\n");
        policy.push_str("(allow network-outbound (remote unix-socket))\n");
        return policy;
    }

    for param in socket_params {
        let key = unix_socket_path_param_key(param.index);
        policy.push_str(&format!(
            "(allow network-bind (local unix-socket (subpath (param \"{key}\"))))\n"
        ));
        policy.push_str(&format!(
            "(allow network-outbound (remote unix-socket (subpath (param \"{key}\"))))\n"
        ));
    }
    policy
}

5.2 Dynamic network ​

网络生成有三个实际结果:

  • proxy ports、proxy config、managed enforcement 或仅有 Unix socket access 时,使用受限 policy,只允许 loopback proxy、显式 local binding、必要 DNS 和 socket allowlist。
  • network enabled 且没有 proxy config 时,保留 full inbound/outbound network。
  • proxy config 存在但没有有效 endpoint,或 managed network 没有 endpoint 时,返回空字符串,让 base (deny default) 保持网络关闭。

源码位置:codex-rs/sandboxing/src/seatbelt.rs :: dynamic_network_policy_for_network

rust
fn dynamic_network_policy_for_network(
    network_policy: NetworkSandboxPolicy,
    enforce_managed_network: bool,
    proxy: &ProxyPolicyInputs,
) -> String {
    let has_some_unix_socket_access = match &proxy.unix_domain_socket_policy {
        UnixDomainSocketPolicy::AllowAll => true,
        UnixDomainSocketPolicy::Restricted { allowed } => !allowed.is_empty(),
    };
    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 {
            policy.push_str("(allow network-bind (local ip \"*:*\"))\n");
            policy.push_str("(allow network-inbound (local ip \"localhost:*\"))\n");
            policy.push_str("(allow network-outbound (remote ip \"localhost:*\"))\n");
        }
        if proxy.allow_local_binding && !proxy.ports.is_empty() {
            policy.push_str("(allow network-outbound (remote ip \"*:53\"))\n");
        }
        for port in &proxy.ports {
            policy.push_str(&format!(
                "(allow network-outbound (remote ip \"localhost:{port}\"))\n"
            ));
        }
        let unix_socket_policy = unix_socket_policy(proxy);
        if !unix_socket_policy.is_empty() {
            policy.push_str(&unix_socket_policy);
        }
        return format!("{policy}{MACOS_SEATBELT_NETWORK_POLICY}");
    }

    if proxy.has_proxy_config || enforce_managed_network {
        // Proxy configuration or managed requirements without usable endpoints stay fail-closed.
        return String::new();
    }

    if network_policy.is_enabled() {
        let mut policy = String::from("(allow network-outbound)\n(allow network-inbound)\n");
        let unix_socket_policy = unix_socket_policy(proxy);
        if !unix_socket_policy.is_empty() {
            policy.push_str(&unix_socket_policy);
        }
        format!("{policy}{MACOS_SEATBELT_NETWORK_POLICY}")
    } else {
        String::new()
    }
}

managed network context中的 port 不是任意 localhost port;测试要求 policy 包含指定端口,同时不包含 blanket outbound、未经请求的 local binding 或无 proxy port 时的 DNS allow。

6. 总装配 ​

create_seatbelt_command_args_with_profile 是总装配入口。它先计算 unreadable/writable roots 和 protected ancestors,再生成 read/write policy、proxy policy、preferences/platform defaults、glob deny,最后把 protected ancestor deny 放到 policy 尾部,防止宽泛 allow 重新打开 rename。

源码位置:codex-rs/sandboxing/src/seatbelt.rs :: create_seatbelt_command_args_with_profile

rust
pub(crate) fn create_seatbelt_command_args_with_profile(
    args: CreateSeatbeltCommandArgsParams<'_>,
    profile: MacosSeatbeltProfile,
) -> Result<Vec<String>, SeatbeltPreparationError> {
    let CreateSeatbeltCommandArgsParams {
        command,
        file_system_sandbox_policy,
        network_sandbox_policy,
        sandbox_policy_cwd,
        enforce_managed_network,
        managed_network,
        environment_id,
        network,
        extra_allow_unix_sockets,
    } = args;

    let unreadable_roots =
        file_system_sandbox_policy.get_unreadable_roots_with_cwd(sandbox_policy_cwd);
    let writable_roots = file_system_sandbox_policy
        .get_writable_roots_with_cwd_preserving_mutable_paths(sandbox_policy_cwd);
    let mut protected_ancestors = BTreeSet::new();
    for writable_root in &writable_roots {
        let root = normalize_path_for_sandbox(writable_root.root.as_path())
            .unwrap_or_else(|| writable_root.root.clone());
        for protected_directory in writable_root.read_only_subpaths.iter().filter_map(|path| {
            normalize_path_for_sandbox(path.as_path())
                .unwrap_or_else(|| path.clone())
                .parent()
        }) {
            for ancestor in protected_directory.ancestors() {
                if !ancestor.as_path().starts_with(root.as_path()) {
                    break;
                }
                protected_ancestors.insert(ancestor);
            }
        }
    }

核心 read/write 分支依据 full-disk access 做优化:没有 carveout 时可以使用 root regex;有 unreadable roots 时回到显式 access roots;restricted profile 则始终由 build_seatbelt_access_policy 生成参数化 subpath/literal 规则。

源码位置:codex-rs/sandboxing/src/seatbelt.rs :: create_seatbelt_command_args_with_profile

rust
let (file_write_policy, file_write_dir_params) =
    if file_system_sandbox_policy.has_full_disk_write_access() {
        if unreadable_roots.is_empty() {
            (
                r#"(allow file-write* (regex #"^/"))"#.to_string(),
                Vec::new(),
            )
        } else {
            build_seatbelt_access_policy(
                SeatbeltAccessKind::Write,
                vec![SeatbeltAccessRoot {
                    root: root_absolute_path(),
                    excluded_subpaths: unreadable_roots.clone(),
                    protected_metadata_names: Vec::new(),
                }],
            )?
        }
    } else {
        build_seatbelt_access_policy(
            SeatbeltAccessKind::Write,
            writable_roots
                .into_iter()
                .map(|root| SeatbeltAccessRoot {
                    protected_metadata_names: protected_metadata_names_for_writable_root(
                        file_system_sandbox_policy,
                        &root,
                        sandbox_policy_cwd,
                    ),
                    root: root.root,
                    excluded_subpaths: root.read_only_subpaths,
                })
                .collect(),
        )?
    };

let (file_read_policy, file_read_dir_params) =
    if file_system_sandbox_policy.has_full_disk_read_access() {
        if unreadable_roots.is_empty() {
            (
                "; allow read-only file operations\n(allow file-read*)".to_string(),
                Vec::new(),
            )
        } else {
            let (policy, params) = build_seatbelt_access_policy(
                SeatbeltAccessKind::Read,
                vec![SeatbeltAccessRoot {
                    root: root_absolute_path(),
                    excluded_subpaths: unreadable_roots,
                    protected_metadata_names: Vec::new(),
                }],
            )?;
            (format!("; allow read-only file operations\n{policy}"), params)
        }
    } else {
        let (policy, params) = build_seatbelt_access_policy(
            SeatbeltAccessKind::Read,
            file_system_sandbox_policy
                .get_readable_roots_with_cwd(sandbox_policy_cwd)
                .into_iter()
                .map(|root| SeatbeltAccessRoot {
                    excluded_subpaths: unreadable_roots
                        .iter()
                        .filter(|path| path.as_path().starts_with(root.as_path()))
                        .cloned()
                        .collect(),
                    protected_metadata_names: Vec::new(),
                    root,
                })
                .collect(),
        )?;
        if policy.is_empty() {
            (String::new(), params)
        } else {
            (format!("; allow read-only file operations\n{policy}"), params)
        }
    };

最后的 sections 顺序很重要:base → read → write → network → preferences/defaults → glob deny → protected ancestor deny。参数定义顺序则由 read roots、write roots、protected ancestors、Unix socket paths 的拼接顺序决定,测试会逐项比较 -DWRITABLE_ROOT_N。

源码位置:codex-rs/sandboxing/src/seatbelt.rs :: create_seatbelt_command_args_with_profile

rust
let proxy = proxy_policy_inputs(
    managed_network,
    network,
    environment_id,
    extra_allow_unix_sockets,
)
.map_err(SeatbeltPreparationError::EnvironmentNetworkProxy)?;
let network_policy =
    dynamic_network_policy_for_network(network_sandbox_policy, enforce_managed_network, &proxy);

let include_platform_defaults = file_system_sandbox_policy.include_platform_defaults();
let deny_read_policy =
    build_seatbelt_unreadable_glob_policy(file_system_sandbox_policy, sandbox_policy_cwd);
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());
}
if include_platform_defaults {
    policy_sections.push(MACOS_RESTRICTED_READ_ONLY_PLATFORM_DEFAULTS.to_string());
    if profile == MacosSeatbeltProfile::Process {
        policy_sections.push(MACOS_PROCESS_APPLICATIONS_READ_POLICY.to_string());
    }
}
policy_sections.push(deny_read_policy);
policy_sections.extend(protected_ancestor_params.iter().map(|(key, _)| {
    format!(
        "(deny file-write-unlink (require-all (vnode-type DIRECTORY) (literal (param \"{key}\"))))"
    )
}));

let full_policy = policy_sections.join("\n");
let dir_params = [
    file_read_dir_params,
    file_write_dir_params,
    protected_ancestor_params,
    unix_socket_dir_params(&proxy),
]
.concat();

生成器不会在 policy 内容中暴露个人路径模板;路径只作为本次执行的 -D 参数注入,公开文章中的示例使用源码测试中的符号名。

7. sandbox-exec命令 ​

Seatbelt executable 固定为 /usr/bin/sandbox-exec。代码先放 -p 和完整 policy,再加入定义参数、-- 分隔符和原始 command。-- 后的部分不再参与 policy 参数解析,保证程序 argv 与 SBPL 参数边界清晰。

源码位置:codex-rs/sandboxing/src/seatbelt.rs :: create_seatbelt_command_args, MACOS_PATH_TO_SEATBELT_EXECUTABLE

rust
pub fn create_seatbelt_command_args(
    args: CreateSeatbeltCommandArgsParams<'_>,
) -> Result<Vec<String>, String> {
    create_seatbelt_command_args_with_profile(args, MacosSeatbeltProfile::Process)
        .map_err(|err| err.to_string())
}

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());
seatbelt_args.extend(command);
Ok(seatbelt_args)

SandboxManager::transform 会在外层把 /usr/bin/sandbox-exec 放到 command 首位;本篇函数返回的是其参数列表。两层组合后才是实际 launch argv。

8. 测试边界 ​

本篇针对源码运行了 codex-sandboxing lib 全量测试,共 94 项通过。关键测试按输入与断言分为四组:

  • 文件访问:Root Write + Deny carveout 仍要求 sandbox;额外 writable path 不会丢失 Deny;metadata regex 阻止首次创建 .codex、.git 和 .agents,并阻止 writable root replacement、ancestor rename 与 symlink rebound。
  • glob:**/*.env、brace alternation、character class、backslash escape 和 canonical static prefix 都生成 anchored regex;对应 file-read、file-write 和 ancestor unlink deny 同时出现。
  • 网络:指定 proxy ports 只允许 localhost 端口,不产生 blanket outbound/inbound;local binding 开启时才加入 loopback 和 DNS;proxy config 或 managed network 没有 endpoint 时保持 restricted policy,不恢复 full network。
  • Unix socket:绝对路径生成 UNIX_SOCKET_PATH_N 与 subpath allow;相对路径被忽略;AllowAll 使用无路径限定的 AF_UNIX bind/outbound。

源码位置:

  • codex-rs/sandboxing/src/seatbelt_tests.rs :: create_seatbelt_args_routes_network_through_proxy_ports
  • codex-rs/sandboxing/src/seatbelt_tests.rs :: dynamic_network_policy_preserves_restricted_policy_for_managed_network_without_proxy_config
  • codex-rs/sandboxing/src/seatbelt_tests.rs :: create_seatbelt_args_with_read_only_git_and_codex_subpaths
  • codex-rs/sandboxing/src/seatbelt_tests.rs :: create_seatbelt_args_rejects_symlinked_writable_root
  • codex-rs/sandboxing/src/seatbelt_tests.rs :: seatbelt_prevents_writable_root_replacement
  • codex-rs/sandboxing/src/seatbelt_tests.rs :: unreadable_glob_policy_includes_canonicalized_static_prefix
bash
cargo test -p codex-sandboxing --lib -- --nocapture --test-threads=1

这些测试证明的是 Rust 生成的 SBPL 文本、参数顺序和 macOS 可执行 fixture 的行为;边界在于不能外推到未运行的 macOS 版本,也不能把 policy string 单独视为 kernel 已经接受。这不意味着生成文本本身就等于完整的内核安全证明;sandbox-exec 还依赖实际系统、路径状态和调用方传入的 command。

9. 继续阅读 ​

可以沿着以下调用链继续阅读:SandboxManager::transform 先把 PermissionProfile 和 SandboxType::MacosSeatbelt 交给 create_seatbelt_command_args_with_profile;后者生成 full_policy 与 -D 参数;外层再把 /usr/bin/sandbox-exec 放到 argv 首位;子进程启动后,Sandbox denial 由执行层根据输出分类。

下一篇macOS沙箱执行流程将继续分析这组 argv 如何进入 sandbox-exec、环境变量如何传递,以及 Seatbelt denial 如何回到 Unified Exec 的重试路径。