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
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
(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
#[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
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
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
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
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
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
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(®ex_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(®ex_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
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
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
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
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
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
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
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
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_portscodex-rs/sandboxing/src/seatbelt_tests.rs :: dynamic_network_policy_preserves_restricted_policy_for_managed_network_without_proxy_configcodex-rs/sandboxing/src/seatbelt_tests.rs :: create_seatbelt_args_with_read_only_git_and_codex_subpathscodex-rs/sandboxing/src/seatbelt_tests.rs :: create_seatbelt_args_rejects_symlinked_writable_rootcodex-rs/sandboxing/src/seatbelt_tests.rs :: seatbelt_prevents_writable_root_replacementcodex-rs/sandboxing/src/seatbelt_tests.rs :: unreadable_glob_policy_includes_canonicalized_static_prefix
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 的重试路径。
