Protect macOS Seatbelt writable root anchors (#39599)

## Why

A sandboxed process could replace a writable directory root, changing the
authority boundary used to construct a later sandbox policy.

## What changed

- Deny unlinking or renaming directory roots granted write access by Seatbelt,
  while preserving replacement and deletion behavior for writable files.
- Reject writable roots with nested symlink components and report these as
  Seatbelt preparation errors instead of network proxy failures.
- Normalize writable roots before adding them to the generated Seatbelt policy.

## Testing

Add coverage for symlink rejection, error classification, directory-root
replacement and rename protection, newly created roots, and writable file
replacement.

GitOrigin-RevId: ab1ed4e55f20034bc43e028e6529d3d1f0d8181c
This commit is contained in:
jif
2026-08-19 19:13:21 +00:00
committed by copyberry
parent d75c85f651
commit f6950546e5
6 changed files with 415 additions and 25 deletions

View File

@@ -8,4 +8,5 @@ codex_rust_crate(
"src/seatbelt_network_policy.sbpl",
],
crate_name = "codex_sandboxing",
test_tags = ["no-sandbox"],
)

View File

@@ -68,6 +68,10 @@ impl From<SandboxTransformError> for CodexErr {
SandboxTransformError::EnvironmentNetworkProxy(message) => {
CodexErr::UnsupportedOperation(message)
}
#[cfg(target_os = "macos")]
SandboxTransformError::SeatbeltPreparation(message) => {
CodexErr::UnsupportedOperation(message)
}
#[cfg(target_os = "linux")]
SandboxTransformError::Wsl1UnsupportedForBubblewrap => {
CodexErr::UnsupportedOperation(crate::bwrap::WSL1_BWRAP_WARNING.to_string())

View File

@@ -207,6 +207,8 @@ pub enum SandboxTransformError {
},
MissingLinuxSandboxExecutable,
EnvironmentNetworkProxy(String),
#[cfg(target_os = "macos")]
SeatbeltPreparation(String),
#[cfg(target_os = "linux")]
Wsl1UnsupportedForBubblewrap,
#[cfg(not(target_os = "macos"))]
@@ -234,6 +236,10 @@ impl std::fmt::Display for SandboxTransformError {
Self::EnvironmentNetworkProxy(err) => {
write!(f, "failed to prepare environment network proxy: {err}")
}
#[cfg(target_os = "macos")]
Self::SeatbeltPreparation(err) => {
write!(f, "failed to prepare Seatbelt sandbox: {err}")
}
#[cfg(target_os = "linux")]
Self::Wsl1UnsupportedForBubblewrap => write!(f, "{WSL1_BWRAP_WARNING}"),
#[cfg(not(target_os = "macos"))]
@@ -253,6 +259,8 @@ impl std::error::Error for SandboxTransformError {
| Self::InvalidSandboxPolicyCwd { source, .. } => Some(source),
Self::MissingLinuxSandboxExecutable => None,
Self::EnvironmentNetworkProxy(_) => None,
#[cfg(target_os = "macos")]
Self::SeatbeltPreparation(_) => None,
#[cfg(target_os = "linux")]
Self::Wsl1UnsupportedForBubblewrap => None,
#[cfg(not(target_os = "macos"))]
@@ -360,6 +368,7 @@ impl SandboxManager {
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?;
@@ -380,7 +389,14 @@ impl SandboxManager {
},
self.seatbelt_profile,
)
.map_err(SandboxTransformError::EnvironmentNetworkProxy)?;
.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);

View File

@@ -122,6 +122,62 @@ fn unsandboxed_transform_preserves_foreign_cwd_and_unrestricted_file_system_poli
);
}
#[cfg(target_os = "macos")]
#[test]
fn symlinked_workspace_reports_seatbelt_preparation_error() {
use std::os::unix::fs::symlink;
let manager = SandboxManager::new();
let temp_dir = TempDir::new().expect("create temp dir");
let target = temp_dir.path().join("target");
let workspace = temp_dir.path().join("workspace");
std::fs::create_dir(&target).expect("create target");
symlink(&target, &workspace).expect("create symlinked workspace");
let workspace = AbsolutePathBuf::from_absolute_path(workspace).expect("absolute workspace");
let workspace_uri = PathUri::from_abs_path(&workspace);
let permissions = PermissionProfile::from_runtime_permissions(
&FileSystemSandboxPolicy::workspace_write(
&[],
/*exclude_tmpdir_env_var*/ true,
/*exclude_slash_tmp*/ true,
),
NetworkSandboxPolicy::Restricted,
);
let error = manager
.transform(SandboxTransformRequest {
command: SandboxCommand {
program: "true".into(),
args: Vec::new(),
cwd: workspace_uri.clone(),
env: HashMap::new(),
managed_network: None,
additional_permissions: None,
},
permissions: &permissions,
sandbox: SandboxType::MacosSeatbelt,
enforce_managed_network: false,
environment_id: None,
network: None,
sandbox_policy_cwd: &workspace_uri,
codex_linux_sandbox_exe: None,
use_legacy_landlock: false,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
windows_sandbox_private_desktop: false,
})
.expect_err("symlinked workspace should be rejected");
assert!(matches!(
&error,
super::SandboxTransformError::SeatbeltPreparation(message)
if message.contains("symlinked writable roots are not supported")
));
assert!(
!error.to_string().contains("network proxy"),
"filesystem error should not be attributed to network proxy: {error}"
);
}
#[test]
fn transform_additional_permissions_enable_network_for_external_sandbox() {
let manager = SandboxManager::new();

View File

@@ -9,6 +9,7 @@ use codex_protocol::permissions::PROTECTED_METADATA_PATH_NAMES;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::WritableRoot;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::canonicalize_preserving_symlinks;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::collections::HashMap;
@@ -32,6 +33,22 @@ pub(crate) enum MacosSeatbeltProfile {
FileSystemHelper,
}
#[derive(Debug)]
pub(crate) enum SeatbeltPreparationError {
FileSystem(String),
EnvironmentNetworkProxy(String),
}
impl std::fmt::Display for SeatbeltPreparationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::FileSystem(message) | Self::EnvironmentNetworkProxy(message) => {
f.write_str(message)
}
}
}
}
/// 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
@@ -358,19 +375,76 @@ struct SeatbeltAccessRoot {
protected_metadata_names: Vec<String>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum SeatbeltAccessKind {
Read,
Write,
}
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<AbsolutePathBuf, 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 = canonicalize_preserving_symlinks(root.as_path()).map_err(|err| {
SeatbeltPreparationError::FileSystem(format!(
"failed to normalize writable root {} for Seatbelt: {err}",
root.display()
))
})?;
AbsolutePathBuf::from_absolute_path(normalized).map_err(|err| {
SeatbeltPreparationError::FileSystem(format!(
"failed to normalize writable root {} for Seatbelt: {err}",
root.display()
))
})
}
fn build_seatbelt_access_policy(
action: &str,
param_prefix: &str,
access_kind: SeatbeltAccessKind,
roots: Vec<SeatbeltAccessRoot>,
) -> (String, Vec<(String, PathBuf)>) {
) -> 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 =
normalize_path_for_sandbox(access_root.root.as_path()).unwrap_or(access_root.root);
let root = match access_kind {
SeatbeltAccessKind::Read => {
normalize_path_for_sandbox(access_root.root.as_path()).unwrap_or(access_root.root)
}
SeatbeltAccessKind::Write => normalize_writable_root_for_sandbox(access_root.root)?,
};
let root_param = format!("{param_prefix}_{index}");
params.push((root_param.clone(), root.clone().into_path_buf()));
if access_kind == SeatbeltAccessKind::Write {
// A sandboxed process must not be able to replace an authority
// boundary that will be reused to build the next sandbox policy.
root_anchor_denies.push(format!(
"(deny file-write-unlink (require-all (literal (param \"{root_param}\")) (vnode-type DIRECTORY)))"
));
}
if access_root.excluded_subpaths.is_empty()
&& access_root.protected_metadata_names.is_empty()
@@ -406,12 +480,14 @@ fn build_seatbelt_access_policy(
}
if policy_components.is_empty() {
(String::new(), Vec::new())
Ok((String::new(), Vec::new()))
} else {
(
format!("(allow {action}\n{}\n)", policy_components.join(" ")),
params,
)
let mut policies = vec![format!(
"(allow {action}\n{}\n)",
policy_components.join(" ")
)];
policies.extend(root_anchor_denies);
Ok((policies.join("\n"), params))
}
}
@@ -633,12 +709,13 @@ 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())
}
pub(crate) fn create_seatbelt_command_args_with_profile(
args: CreateSeatbeltCommandArgsParams<'_>,
profile: MacosSeatbeltProfile,
) -> Result<Vec<String>, String> {
) -> Result<Vec<String>, SeatbeltPreparationError> {
let CreateSeatbeltCommandArgsParams {
command,
file_system_sandbox_policy,
@@ -663,19 +740,17 @@ pub(crate) fn create_seatbelt_command_args_with_profile(
)
} else {
build_seatbelt_access_policy(
"file-write*",
"WRITABLE_ROOT",
SeatbeltAccessKind::Write,
vec![SeatbeltAccessRoot {
root: root_absolute_path(),
excluded_subpaths: unreadable_roots.clone(),
protected_metadata_names: Vec::new(),
}],
)
)?
}
} else {
build_seatbelt_access_policy(
"file-write*",
"WRITABLE_ROOT",
SeatbeltAccessKind::Write,
file_system_sandbox_policy
.get_writable_roots_with_cwd(sandbox_policy_cwd)
.into_iter()
@@ -689,7 +764,7 @@ pub(crate) fn create_seatbelt_command_args_with_profile(
excluded_subpaths: root.read_only_subpaths,
})
.collect(),
)
)?
};
let (file_read_policy, file_read_dir_params) =
@@ -701,14 +776,13 @@ pub(crate) fn create_seatbelt_command_args_with_profile(
)
} else {
let (policy, params) = build_seatbelt_access_policy(
"file-read*",
"READABLE_ROOT",
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,
@@ -716,8 +790,7 @@ pub(crate) fn create_seatbelt_command_args_with_profile(
}
} else {
let (policy, params) = build_seatbelt_access_policy(
"file-read*",
"READABLE_ROOT",
SeatbeltAccessKind::Read,
file_system_sandbox_policy
.get_readable_roots_with_cwd(sandbox_policy_cwd)
.into_iter()
@@ -731,7 +804,7 @@ pub(crate) fn create_seatbelt_command_args_with_profile(
root,
})
.collect(),
);
)?;
if policy.is_empty() {
(String::new(), params)
} else {
@@ -747,7 +820,8 @@ pub(crate) fn create_seatbelt_command_args_with_profile(
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);

View File

@@ -63,6 +63,25 @@ fn seatbelt_policy_arg(args: &[String]) -> &str {
.expect("seatbelt args should include policy text")
}
#[cfg(target_os = "macos")]
fn restricted_write_policy(paths: &[&Path]) -> FileSystemSandboxPolicy {
let mut entries = vec![FileSystemSandboxEntry::new(
FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
FileSystemAccessMode::Read,
)];
entries.extend(paths.iter().map(|path| {
FileSystemSandboxEntry::new(
AbsolutePathBuf::from_absolute_path(path)
.expect("absolute writable path")
.into(),
FileSystemAccessMode::Write,
)
}));
FileSystemSandboxPolicy::restricted(entries)
}
fn seatbelt_protected_metadata_name_requirements(root: &Path) -> String {
let mut root = root.to_string_lossy().to_string();
while root.len() > 1 && root.ends_with('/') {
@@ -1201,6 +1220,226 @@ fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() {
);
}
#[cfg(unix)]
#[test]
fn create_seatbelt_args_rejects_symlinked_writable_root() {
use std::os::unix::fs::symlink;
let tmp = TempDir::new().expect("tempdir");
let target = tmp.path().join("target");
let workspace = tmp.path().join("workspace");
fs::create_dir(&target).expect("create target");
symlink(&target, &workspace).expect("create symlinked workspace");
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: Vec::new(),
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
let error = create_seatbelt_command_args_for_legacy_policy(
vec!["/usr/bin/true".to_string()],
&policy,
&workspace,
/*enforce_managed_network*/ false,
/*network*/ None,
)
.expect_err("symlinked workspace should be rejected");
assert!(
error.contains("symlinked writable roots are not supported"),
"unexpected error: {error}"
);
assert!(
error.contains(&workspace.display().to_string()),
"error should identify the rejected workspace: {error}"
);
}
#[cfg(target_os = "macos")]
#[test]
fn seatbelt_prevents_writable_root_replacement() {
let tmp = TempDir::new().expect("tempdir");
let workspace = tmp.path().join("workspace");
let target = tmp.path().join("target");
fs::create_dir(&workspace).expect("create workspace");
fs::create_dir(&target).expect("create target");
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: Vec::new(),
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
let shell_command = vec![
"/bin/sh".to_string(),
"-c".to_string(),
"rm -rf \"$PWD\" && ln -s \"$1\" \"$PWD\"".to_string(),
"sh".to_string(),
target.display().to_string(),
];
let args = create_seatbelt_command_args_for_legacy_policy(
shell_command,
&policy,
&workspace,
/*enforce_managed_network*/ false,
/*network*/ None,
)
.expect("build seatbelt command");
let policy_text = seatbelt_policy_arg(&args);
assert!(
policy_text.contains(
"(deny file-write-unlink (require-all (literal (param \"WRITABLE_ROOT_0\")) (vnode-type DIRECTORY)))"
),
"expected writable-root anchor protection in policy:\n{policy_text}"
);
let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE)
.args(&args)
.current_dir(&workspace)
.output()
.expect("execute seatbelt command");
let stderr = String::from_utf8_lossy(&output.stderr);
let workspace_metadata = fs::symlink_metadata(&workspace).expect("workspace should remain");
assert!(
workspace_metadata.is_dir() && !workspace_metadata.file_type().is_symlink(),
"sandboxed command replaced {}: {stderr}",
workspace.display()
);
assert!(
!output.status.success(),
"workspace replacement should fail under Seatbelt"
);
}
#[cfg(target_os = "macos")]
#[test]
fn seatbelt_allows_file_root_replacement_and_deletion() {
let tmp = TempDir::new().expect("tempdir");
let target = tmp.path().join("target.txt");
let replacement = tmp.path().join("replacement.txt");
fs::write(&target, "before").expect("write target");
fs::write(&replacement, "after").expect("write replacement");
let policy = restricted_write_policy(&[target.as_path(), replacement.as_path()]);
let args = create_seatbelt_command_args(CreateSeatbeltCommandArgsParams {
command: vec![
"/bin/sh".to_string(),
"-c".to_string(),
"mv \"$1\" \"$2\" && test \"$(cat \"$2\")\" = after && rm \"$2\"".to_string(),
"sh".to_string(),
replacement.display().to_string(),
target.display().to_string(),
],
file_system_sandbox_policy: &policy,
network_sandbox_policy: NetworkSandboxPolicy::Restricted,
sandbox_policy_cwd: tmp.path(),
enforce_managed_network: false,
managed_network: None,
environment_id: None,
network: None,
extra_allow_unix_sockets: &[],
})
.expect("build seatbelt command");
let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE)
.args(&args)
.current_dir(tmp.path())
.output()
.expect("execute seatbelt command");
assert!(
output.status.success(),
"file replacement and deletion should succeed under Seatbelt: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(!target.exists(), "target should have been deleted");
assert!(!replacement.exists(), "replacement should have been moved");
}
#[cfg(target_os = "macos")]
#[test]
fn seatbelt_prevents_writable_directory_root_rename() {
let tmp = TempDir::new().expect("tempdir");
let source = tmp.path().join("source");
let destination = tmp.path().join("destination");
fs::create_dir(&source).expect("create source");
let policy = restricted_write_policy(&[source.as_path(), destination.as_path()]);
let args = create_seatbelt_command_args(CreateSeatbeltCommandArgsParams {
command: vec![
"/bin/mv".to_string(),
source.display().to_string(),
destination.display().to_string(),
],
file_system_sandbox_policy: &policy,
network_sandbox_policy: NetworkSandboxPolicy::Restricted,
sandbox_policy_cwd: tmp.path(),
enforce_managed_network: false,
managed_network: None,
environment_id: None,
network: None,
extra_allow_unix_sockets: &[],
})
.expect("build seatbelt command");
let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE)
.args(&args)
.current_dir(tmp.path())
.output()
.expect("execute seatbelt command");
assert!(
!output.status.success(),
"directory-root rename should fail under Seatbelt"
);
assert!(source.is_dir(), "source directory should remain in place");
assert!(
!destination.exists(),
"destination should not have been created"
);
}
#[cfg(target_os = "macos")]
#[test]
fn seatbelt_protects_writable_root_created_as_directory() {
let tmp = TempDir::new().expect("tempdir");
let writable_root = tmp.path().join("writable-root");
let policy = restricted_write_policy(&[writable_root.as_path()]);
let args = create_seatbelt_command_args(CreateSeatbeltCommandArgsParams {
command: vec![
"/bin/sh".to_string(),
"-c".to_string(),
"mkdir \"$1\" && rmdir \"$1\"".to_string(),
"sh".to_string(),
writable_root.display().to_string(),
],
file_system_sandbox_policy: &policy,
network_sandbox_policy: NetworkSandboxPolicy::Restricted,
sandbox_policy_cwd: tmp.path(),
enforce_managed_network: false,
managed_network: None,
environment_id: None,
network: None,
extra_allow_unix_sockets: &[],
})
.expect("build seatbelt command");
let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE)
.args(&args)
.current_dir(tmp.path())
.output()
.expect("execute seatbelt command");
assert!(
!output.status.success(),
"removing a newly created directory root should fail under Seatbelt"
);
assert!(
writable_root.is_dir(),
"newly created directory root should remain protected"
);
}
#[test]
fn create_seatbelt_args_block_first_time_dot_codex_creation_with_metadata_name_regex() {
let tmp = TempDir::new().expect("tempdir");