From 431af0807ce18b66643c28e1c9cd1d5d8f1e4a8e Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Mon, 23 Mar 2026 21:26:35 -0700 Subject: [PATCH] Move macOS sandbox builders into codex-sandboxing (#15593) ## Summary - move macOS permission merging/intersection logic and tests from `codex-core` into `codex-sandboxing` - move seatbelt policy builders, permissions logic, SBPL assets, and their tests into `codex-sandboxing` - keep `codex-core` owning only the seatbelt spawn wrapper and switch call sites to import the moved APIs directly ## Notes - no re-exports added - moved the seatbelt tests with the implementation so internal helpers could stay private - local verification is still finishing while this PR is open --- codex-rs/Cargo.lock | 7 + codex-rs/Cargo.toml | 2 +- codex-rs/cli/src/debug_sandbox.rs | 4 +- codex-rs/core/src/lib.rs | 1 - codex-rs/core/src/sandboxing/mod.rs | 14 +- codex-rs/core/src/seatbelt.rs | 567 +----------------- .../runtimes/shell/unix_escalation_tests.rs | 4 +- codex-rs/sandboxing/BUILD.bazel | 5 + codex-rs/sandboxing/Cargo.toml | 7 + codex-rs/sandboxing/src/lib.rs | 5 + .../src}/macos_permissions.rs | 4 +- .../src}/macos_permissions_tests.rs | 0 ...estricted_read_only_platform_defaults.sbpl | 0 codex-rs/sandboxing/src/seatbelt.rs | 540 +++++++++++++++++ .../src/seatbelt_base_policy.sbpl | 0 .../src/seatbelt_network_policy.sbpl | 0 .../src/seatbelt_permissions.rs | 10 +- .../src/seatbelt_permissions_tests.rs | 0 .../src/seatbelt_tests.rs | 81 ++- 19 files changed, 650 insertions(+), 601 deletions(-) rename codex-rs/{core/src/sandboxing => sandboxing/src}/macos_permissions.rs (98%) rename codex-rs/{core/src/sandboxing => sandboxing/src}/macos_permissions_tests.rs (100%) rename codex-rs/{core => sandboxing}/src/restricted_read_only_platform_defaults.sbpl (100%) create mode 100644 codex-rs/sandboxing/src/seatbelt.rs rename codex-rs/{core => sandboxing}/src/seatbelt_base_policy.sbpl (100%) rename codex-rs/{core => sandboxing}/src/seatbelt_network_policy.sbpl (100%) rename codex-rs/{core => sandboxing}/src/seatbelt_permissions.rs (96%) rename codex-rs/{core => sandboxing}/src/seatbelt_permissions_tests.rs (100%) rename codex-rs/{core => sandboxing}/src/seatbelt_tests.rs (94%) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index f9a523dd9..c14c633c4 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2468,9 +2468,16 @@ dependencies = [ name = "codex-sandboxing" version = "0.0.0" dependencies = [ + "codex-network-proxy", "codex-protocol", + "codex-utils-absolute-path", + "dirs", + "libc", "pretty_assertions", "serde_json", + "tempfile", + "tracing", + "url", ] [[package]] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index db203d83b..e0c763373 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -290,7 +290,7 @@ supports-color = "3.0.2" syntect = "5" sys-locale = "0.3.2" tempfile = "3.23.0" -tar = "0.4.44" +tar = "0.4.45" test-log = "0.2.19" textwrap = "0.16.2" thiserror = "2.0.17" diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 71a266101..03226cced 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -12,13 +12,13 @@ use codex_core::config::ConfigOverrides; use codex_core::config::NetworkProxyAuditMetadata; use codex_core::exec_env::create_env; #[cfg(target_os = "macos")] -use codex_core::seatbelt::create_seatbelt_command_args_for_policies_with_extensions; -#[cfg(target_os = "macos")] use codex_core::spawn::CODEX_SANDBOX_ENV_VAR; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use codex_protocol::config_types::SandboxMode; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_sandboxing::landlock::create_linux_sandbox_command_args_for_policies; +#[cfg(target_os = "macos")] +use codex_sandboxing::seatbelt::create_seatbelt_command_args_for_policies_with_extensions; use codex_utils_cli::CliConfigOverrides; use tokio::process::Child; use tokio::process::Command as TokioCommand; diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index e51bf4507..cd6b51b7e 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -93,7 +93,6 @@ mod event_mapping; mod response_debug_context; pub mod review_format; pub mod review_prompts; -mod seatbelt_permissions; mod thread_manager; pub mod web_search; pub mod windows_sandbox_read_grants; diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index fd055d4de..e9cf082ca 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -6,8 +6,6 @@ sandbox placement and transformation of portable CommandSpec into a ready‑to‑spawn environment. */ -pub(crate) mod macos_permissions; - use crate::exec::ExecCapturePolicy; use crate::exec::ExecExpiration; use crate::exec::ExecToolCallOutput; @@ -16,10 +14,6 @@ use crate::exec::StdoutStream; use crate::exec::execute_exec_request; use crate::protocol::SandboxPolicy; #[cfg(target_os = "macos")] -use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE; -#[cfg(target_os = "macos")] -use crate::seatbelt::create_seatbelt_command_args_for_policies_with_extensions; -#[cfg(target_os = "macos")] use crate::spawn::CODEX_SANDBOX_ENV_VAR; use crate::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use crate::tools::sandboxing::SandboxablePreference; @@ -40,10 +34,14 @@ use codex_protocol::protocol::NetworkAccess; use codex_protocol::protocol::ReadOnlyAccess; use codex_sandboxing::landlock::allow_network_for_proxy; use codex_sandboxing::landlock::create_linux_sandbox_command_args_for_policies; +use codex_sandboxing::macos_permissions::intersect_macos_seatbelt_profile_extensions; +use codex_sandboxing::macos_permissions::merge_macos_seatbelt_profile_extensions; +#[cfg(target_os = "macos")] +use codex_sandboxing::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE; +#[cfg(target_os = "macos")] +use codex_sandboxing::seatbelt::create_seatbelt_command_args_for_policies_with_extensions; use codex_utils_absolute_path::AbsolutePathBuf; use dunce::canonicalize; -use macos_permissions::intersect_macos_seatbelt_profile_extensions; -use macos_permissions::merge_macos_seatbelt_profile_extensions; use std::collections::HashMap; use std::collections::HashSet; use std::path::Path; diff --git a/codex-rs/core/src/seatbelt.rs b/codex-rs/core/src/seatbelt.rs index 2d1e0a7b5..f81016a99 100644 --- a/codex-rs/core/src/seatbelt.rs +++ b/codex-rs/core/src/seatbelt.rs @@ -1,40 +1,19 @@ #![cfg(target_os = "macos")] -use codex_network_proxy::NetworkProxy; -use codex_network_proxy::PROXY_URL_ENV_KEYS; -use codex_network_proxy::has_proxy_url_env_vars; -use codex_network_proxy::proxy_url_env_value; -use codex_utils_absolute_path::AbsolutePathBuf; -use std::collections::BTreeMap; -use std::collections::BTreeSet; -use std::collections::HashMap; -use std::ffi::CStr; -use std::path::Path; -use std::path::PathBuf; -use tokio::process::Child; -use tracing::warn; -use url::Url; - use crate::protocol::SandboxPolicy; -use crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions; -use crate::seatbelt_permissions::build_seatbelt_extensions; use crate::spawn::CODEX_SANDBOX_ENV_VAR; use crate::spawn::SpawnChildRequest; use crate::spawn::StdioPolicy; use crate::spawn::spawn_child_async; +use codex_network_proxy::NetworkProxy; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; - -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_RESTRICTED_READ_ONLY_PLATFORM_DEFAULTS: &str = - include_str!("restricted_read_only_platform_defaults.sbpl"); - -/// 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"; +use codex_sandboxing::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE; +use codex_sandboxing::seatbelt::create_seatbelt_command_args_for_policies_with_extensions; +use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; +use tokio::process::Child; pub async fn spawn_command_under_seatbelt( command: Vec, @@ -45,12 +24,14 @@ pub async fn spawn_command_under_seatbelt( network: Option<&NetworkProxy>, mut env: HashMap, ) -> std::io::Result { - let args = create_seatbelt_command_args( + let args = create_seatbelt_command_args_for_policies_with_extensions( command, - sandbox_policy, + &FileSystemSandboxPolicy::from_legacy_sandbox_policy(sandbox_policy, sandbox_policy_cwd), + NetworkSandboxPolicy::from(sandbox_policy), sandbox_policy_cwd, /*enforce_managed_network*/ false, network, + /*extensions*/ None, ); let arg0 = None; env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string()); @@ -66,529 +47,3 @@ pub async fn spawn_command_under_seatbelt( }) .await } - -fn is_loopback_host(host: &str) -> bool { - host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1" -} - -fn proxy_scheme_default_port(scheme: &str) -> u16 { - match scheme { - "https" => 443, - "socks5" | "socks5h" | "socks4" | "socks4a" => 1080, - _ => 80, - } -} - -fn proxy_loopback_ports_from_env(env: &HashMap) -> Vec { - let mut ports = BTreeSet::new(); - for key in PROXY_URL_ENV_KEYS { - let Some(proxy_url) = proxy_url_env_value(env, key) else { - continue; - }; - let trimmed = proxy_url.trim(); - if trimmed.is_empty() { - continue; - } - - let candidate = if trimmed.contains("://") { - trimmed.to_string() - } else { - format!("http://{trimmed}") - }; - let Ok(parsed) = Url::parse(&candidate) else { - continue; - }; - let Some(host) = parsed.host_str() else { - continue; - }; - if !is_loopback_host(host) { - continue; - } - - let scheme = parsed.scheme().to_ascii_lowercase(); - let port = parsed - .port() - .unwrap_or_else(|| proxy_scheme_default_port(scheme.as_str())); - ports.insert(port); - } - ports.into_iter().collect() -} - -#[derive(Debug, Default)] -struct ProxyPolicyInputs { - ports: Vec, - has_proxy_config: bool, - allow_local_binding: bool, - unix_domain_socket_policy: UnixDomainSocketPolicy, -} - -#[derive(Debug, Clone)] -// Keep allow-all and allowlist modes disjoint so we don't carry ignored state. -enum UnixDomainSocketPolicy { - AllowAll, - Restricted { allowed: Vec }, -} - -impl Default for UnixDomainSocketPolicy { - fn default() -> Self { - Self::Restricted { allowed: vec![] } - } -} - -#[derive(Debug, Clone)] -struct UnixSocketPathParam { - index: usize, - path: AbsolutePathBuf, -} - -fn proxy_policy_inputs(network: Option<&NetworkProxy>) -> ProxyPolicyInputs { - if let Some(network) = network { - let mut env = HashMap::new(); - network.apply_to_env(&mut env); - let unix_domain_socket_policy = if network.dangerously_allow_all_unix_sockets() { - UnixDomainSocketPolicy::AllowAll - } else { - let allowed = network - .allow_unix_sockets() - .iter() - .filter_map( - |socket_path| match normalize_path_for_sandbox(Path::new(socket_path)) { - Some(path) => Some((path.to_string_lossy().to_string(), path)), - None => { - warn!( - "ignoring network.allow_unix_sockets entry because it could not be normalized: {socket_path}" - ); - None - } - }, - ) - .collect::>() - .into_values() - .collect(); - UnixDomainSocketPolicy::Restricted { allowed } - }; - return ProxyPolicyInputs { - ports: proxy_loopback_ports_from_env(&env), - has_proxy_config: has_proxy_url_env_vars(&env), - allow_local_binding: network.allow_local_binding(), - unix_domain_socket_policy, - }; - } - - ProxyPolicyInputs::default() -} - -fn normalize_path_for_sandbox(path: &Path) -> Option { - // `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)) -} - -fn unix_socket_path_params(proxy: &ProxyPolicyInputs) -> Vec { - let mut deduped_paths: BTreeMap = BTreeMap::new(); - let UnixDomainSocketPolicy::Restricted { allowed } = &proxy.unix_domain_socket_policy else { - return vec![]; - }; - for path in allowed { - deduped_paths - .entry(path.to_string_lossy().to_string()) - .or_insert_with(|| path.clone()); - } - - deduped_paths - .into_values() - .enumerate() - .map(|(index, path)| UnixSocketPathParam { index, path }) - .collect() -} - -fn unix_socket_path_param_key(index: usize) -> String { - format!("UNIX_SOCKET_PATH_{index}") -} - -fn unix_socket_dir_params(proxy: &ProxyPolicyInputs) -> Vec<(String, PathBuf)> { - unix_socket_path_params(proxy) - .into_iter() - .map(|param| { - ( - unix_socket_path_param_key(param.index), - param.path.into_path_buf(), - ) - }) - .collect() -} - -/// Returns zero or more complete Seatbelt policy lines for unix socket rules. -/// When non-empty, the returned string is newline-terminated so callers can -/// append it directly to larger policy blocks. -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 - ) { - // Keep AllowAll genuinely broad here; path qualifiers look narrower - // without a clear macOS behavioral benefit. - 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); - // Use subpath so allowlists cover sockets created beneath approved directories. - 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 -} - -#[cfg_attr(not(test), allow(dead_code))] -fn dynamic_network_policy( - sandbox_policy: &SandboxPolicy, - enforce_managed_network: bool, - proxy: &ProxyPolicyInputs, -) -> String { - dynamic_network_policy_for_network( - NetworkSandboxPolicy::from(sandbox_policy), - enforce_managed_network, - proxy, - ) -} - -fn dynamic_network_policy_for_network( - network_policy: NetworkSandboxPolicy, - enforce_managed_network: bool, - proxy: &ProxyPolicyInputs, -) -> String { - let should_use_restricted_network_policy = - !proxy.ports.is_empty() || proxy.has_proxy_config || enforce_managed_network; - if should_use_restricted_network_policy { - let mut policy = String::new(); - if proxy.allow_local_binding { - policy.push_str("; allow loopback local binding and loopback traffic\n"); - policy.push_str("(allow network-bind (local ip \"localhost:*\"))\n"); - policy.push_str("(allow network-inbound (local ip \"localhost:*\"))\n"); - policy.push_str("(allow network-outbound (remote ip \"localhost:*\"))\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("; allow unix domain sockets for local IPC\n"); - policy.push_str(&unix_socket_policy); - } - return format!("{policy}{MACOS_SEATBELT_NETWORK_POLICY}"); - } - - if proxy.has_proxy_config { - // Proxy configuration is present but we could not infer any valid loopback endpoints. - // Fail closed to avoid silently widening network access in proxy-enforced sessions. - return String::new(); - } - - if enforce_managed_network { - // Managed network requirements are active but no usable proxy endpoints - // are available. Fail closed for network access. - return String::new(); - } - - if network_policy.is_enabled() { - // No proxy env is configured: retain the existing full-network behavior. - format!( - "(allow network-outbound)\n(allow network-inbound)\n{MACOS_SEATBELT_NETWORK_POLICY}" - ) - } else { - String::new() - } -} - -#[cfg_attr(not(test), allow(dead_code))] -pub(crate) fn create_seatbelt_command_args( - command: Vec, - sandbox_policy: &SandboxPolicy, - sandbox_policy_cwd: &Path, - enforce_managed_network: bool, - network: Option<&NetworkProxy>, -) -> Vec { - create_seatbelt_command_args_with_extensions( - command, - sandbox_policy, - sandbox_policy_cwd, - enforce_managed_network, - network, - /*extensions*/ None, - ) -} - -fn root_absolute_path() -> AbsolutePathBuf { - match AbsolutePathBuf::from_absolute_path(Path::new("/")) { - Ok(path) => path, - Err(err) => panic!("root path must be absolute: {err}"), - } -} - -#[derive(Debug, Clone)] -struct SeatbeltAccessRoot { - root: AbsolutePathBuf, - excluded_subpaths: Vec, -} - -fn build_seatbelt_access_policy( - action: &str, - param_prefix: &str, - roots: Vec, -) -> (String, Vec<(String, PathBuf)>) { - let mut policy_components = Vec::new(); - let mut params = Vec::new(); - - 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_param = format!("{param_prefix}_{index}"); - params.push((root_param.clone(), root.into_path_buf())); - - if access_root.excluded_subpaths.is_empty() { - policy_components.push(format!("(subpath (param \"{root_param}\"))")); - continue; - } - - let mut require_parts = vec![format!("(subpath (param \"{root_param}\"))")]; - for (excluded_index, excluded_subpath) in - access_root.excluded_subpaths.into_iter().enumerate() - { - let excluded_subpath = - normalize_path_for_sandbox(excluded_subpath.as_path()).unwrap_or(excluded_subpath); - let excluded_param = format!("{param_prefix}_{index}_RO_{excluded_index}"); - params.push((excluded_param.clone(), excluded_subpath.into_path_buf())); - require_parts.push(format!( - "(require-not (subpath (param \"{excluded_param}\")))" - )); - } - policy_components.push(format!("(require-all {} )", require_parts.join(" "))); - } - - if policy_components.is_empty() { - (String::new(), Vec::new()) - } else { - ( - format!("(allow {action}\n{}\n)", policy_components.join(" ")), - params, - ) - } -} - -#[cfg_attr(not(test), allow(dead_code))] -pub(crate) fn create_seatbelt_command_args_with_extensions( - command: Vec, - sandbox_policy: &SandboxPolicy, - sandbox_policy_cwd: &Path, - enforce_managed_network: bool, - network: Option<&NetworkProxy>, - extensions: Option<&MacOsSeatbeltProfileExtensions>, -) -> Vec { - create_seatbelt_command_args_for_policies_with_extensions( - command, - &FileSystemSandboxPolicy::from_legacy_sandbox_policy(sandbox_policy, sandbox_policy_cwd), - NetworkSandboxPolicy::from(sandbox_policy), - sandbox_policy_cwd, - enforce_managed_network, - network, - extensions, - ) -} - -pub fn create_seatbelt_command_args_for_policies_with_extensions( - command: Vec, - file_system_sandbox_policy: &FileSystemSandboxPolicy, - network_sandbox_policy: NetworkSandboxPolicy, - sandbox_policy_cwd: &Path, - enforce_managed_network: bool, - network: Option<&NetworkProxy>, - extensions: Option<&MacOsSeatbeltProfileExtensions>, -) -> Vec { - let unreadable_roots = - file_system_sandbox_policy.get_unreadable_roots_with_cwd(sandbox_policy_cwd); - let (file_write_policy, file_write_dir_params) = - if file_system_sandbox_policy.has_full_disk_write_access() { - if unreadable_roots.is_empty() { - // Allegedly, this is more permissive than `(allow file-write*)`. - ( - r#"(allow file-write* (regex #"^/"))"#.to_string(), - Vec::new(), - ) - } else { - build_seatbelt_access_policy( - "file-write*", - "WRITABLE_ROOT", - vec![SeatbeltAccessRoot { - root: root_absolute_path(), - excluded_subpaths: unreadable_roots.clone(), - }], - ) - } - } else { - build_seatbelt_access_policy( - "file-write*", - "WRITABLE_ROOT", - file_system_sandbox_policy - .get_writable_roots_with_cwd(sandbox_policy_cwd) - .into_iter() - .map(|root| SeatbeltAccessRoot { - 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( - "file-read*", - "READABLE_ROOT", - vec![SeatbeltAccessRoot { - root: root_absolute_path(), - excluded_subpaths: unreadable_roots, - }], - ); - ( - format!("; allow read-only file operations\n{policy}"), - params, - ) - } - } else { - let (policy, params) = build_seatbelt_access_policy( - "file-read*", - "READABLE_ROOT", - 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(), - root, - }) - .collect(), - ); - if policy.is_empty() { - (String::new(), params) - } else { - ( - format!("; allow read-only file operations\n{policy}"), - params, - ) - } - }; - - let proxy = proxy_policy_inputs(network); - let network_policy = - dynamic_network_policy_for_network(network_sandbox_policy, enforce_managed_network, &proxy); - let seatbelt_extensions = extensions.map_or_else( - || { - // Backward-compatibility default when no extension profile is provided. - build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions::default()) - }, - build_seatbelt_extensions, - ); - - let include_platform_defaults = file_system_sandbox_policy.include_platform_defaults(); - let mut policy_sections = vec![ - MACOS_SEATBELT_BASE_POLICY.to_string(), - file_read_policy, - file_write_policy, - network_policy, - ]; - if include_platform_defaults { - policy_sections.push(MACOS_RESTRICTED_READ_ONLY_PLATFORM_DEFAULTS.to_string()); - } - if !seatbelt_extensions.policy.is_empty() { - policy_sections.push(seatbelt_extensions.policy.clone()); - } - - let full_policy = policy_sections.join("\n"); - - let dir_params = [ - file_read_dir_params, - file_write_dir_params, - macos_dir_params(), - unix_socket_dir_params(&proxy), - seatbelt_extensions.dir_params, - ] - .concat(); - - let mut seatbelt_args: Vec = vec!["-p".to_string(), full_policy]; - let definition_args = dir_params - .into_iter() - .map(|(key, value)| format!("-D{key}={value}", value = value.to_string_lossy())); - seatbelt_args.extend(definition_args); - seatbelt_args.push("--".to_string()); - seatbelt_args.extend(command); - seatbelt_args -} - -/// Wraps libc::confstr to return a String. -fn confstr(name: libc::c_int) -> Option { - let mut buf = vec![0_i8; (libc::PATH_MAX as usize) + 1]; - let len = unsafe { libc::confstr(name, buf.as_mut_ptr(), buf.len()) }; - if len == 0 { - return None; - } - // confstr guarantees NUL-termination when len > 0. - let cstr = unsafe { CStr::from_ptr(buf.as_ptr()) }; - cstr.to_str().ok().map(ToString::to_string) -} - -/// Wraps confstr to return a canonicalized PathBuf. -fn confstr_path(name: libc::c_int) -> Option { - let s = confstr(name)?; - let path = PathBuf::from(s); - path.canonicalize().ok().or(Some(path)) -} - -fn macos_dir_params() -> Vec<(String, PathBuf)> { - if let Some(p) = confstr_path(libc::_CS_DARWIN_USER_CACHE_DIR) { - return vec![("DARWIN_USER_CACHE_DIR".to_string(), p)]; - } - vec![] -} - -#[cfg(test)] -#[path = "seatbelt_tests.rs"] -mod tests; diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs index 37c1b53a1..3adee9c29 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs @@ -20,8 +20,6 @@ use crate::protocol::GranularApprovalConfig; use crate::protocol::ReadOnlyAccess; use crate::protocol::SandboxPolicy; use crate::sandboxing::SandboxPermissions; -#[cfg(target_os = "macos")] -use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE; use crate::skills::SkillMetadata; use codex_execpolicy::Decision; use codex_execpolicy::Evaluation; @@ -40,6 +38,8 @@ use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::SkillScope; +#[cfg(target_os = "macos")] +use codex_sandboxing::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE; use codex_shell_escalation::EscalationExecution; use codex_shell_escalation::EscalationPermissions; use codex_shell_escalation::ExecResult; diff --git a/codex-rs/sandboxing/BUILD.bazel b/codex-rs/sandboxing/BUILD.bazel index b4b3377a7..57837fa80 100644 --- a/codex-rs/sandboxing/BUILD.bazel +++ b/codex-rs/sandboxing/BUILD.bazel @@ -3,4 +3,9 @@ load("//:defs.bzl", "codex_rust_crate") codex_rust_crate( name = "sandboxing", crate_name = "codex_sandboxing", + compile_data = [ + "src/restricted_read_only_platform_defaults.sbpl", + "src/seatbelt_base_policy.sbpl", + "src/seatbelt_network_policy.sbpl", + ], ) diff --git a/codex-rs/sandboxing/Cargo.toml b/codex-rs/sandboxing/Cargo.toml index dc6f4b122..0e2ab443a 100644 --- a/codex-rs/sandboxing/Cargo.toml +++ b/codex-rs/sandboxing/Cargo.toml @@ -12,8 +12,15 @@ path = "src/lib.rs" workspace = true [dependencies] +codex-network-proxy = { workspace = true } codex-protocol = { workspace = true } +codex-utils-absolute-path = { workspace = true } +dirs = { workspace = true } +libc = { workspace = true } serde_json = { workspace = true } +tracing = { workspace = true, features = ["log"] } +url = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/sandboxing/src/lib.rs b/codex-rs/sandboxing/src/lib.rs index 517579e28..17291ec2a 100644 --- a/codex-rs/sandboxing/src/lib.rs +++ b/codex-rs/sandboxing/src/lib.rs @@ -1 +1,6 @@ pub mod landlock; +pub mod macos_permissions; +#[cfg(target_os = "macos")] +pub mod seatbelt; +#[cfg(target_os = "macos")] +mod seatbelt_permissions; diff --git a/codex-rs/core/src/sandboxing/macos_permissions.rs b/codex-rs/sandboxing/src/macos_permissions.rs similarity index 98% rename from codex-rs/core/src/sandboxing/macos_permissions.rs rename to codex-rs/sandboxing/src/macos_permissions.rs index 1a409d4bd..22314937e 100644 --- a/codex-rs/core/src/sandboxing/macos_permissions.rs +++ b/codex-rs/sandboxing/src/macos_permissions.rs @@ -7,7 +7,7 @@ use codex_protocol::models::MacOsSeatbeltProfileExtensions; /// Merges macOS seatbelt profile extensions by taking the permissive union of /// each permission field. -pub(crate) fn merge_macos_seatbelt_profile_extensions( +pub fn merge_macos_seatbelt_profile_extensions( base: Option<&MacOsSeatbeltProfileExtensions>, permissions: Option<&MacOsSeatbeltProfileExtensions>, ) -> Option { @@ -38,7 +38,7 @@ pub(crate) fn merge_macos_seatbelt_profile_extensions( } } -pub(crate) fn intersect_macos_seatbelt_profile_extensions( +pub fn intersect_macos_seatbelt_profile_extensions( requested: Option, granted: Option, ) -> Option { diff --git a/codex-rs/core/src/sandboxing/macos_permissions_tests.rs b/codex-rs/sandboxing/src/macos_permissions_tests.rs similarity index 100% rename from codex-rs/core/src/sandboxing/macos_permissions_tests.rs rename to codex-rs/sandboxing/src/macos_permissions_tests.rs diff --git a/codex-rs/core/src/restricted_read_only_platform_defaults.sbpl b/codex-rs/sandboxing/src/restricted_read_only_platform_defaults.sbpl similarity index 100% rename from codex-rs/core/src/restricted_read_only_platform_defaults.sbpl rename to codex-rs/sandboxing/src/restricted_read_only_platform_defaults.sbpl diff --git a/codex-rs/sandboxing/src/seatbelt.rs b/codex-rs/sandboxing/src/seatbelt.rs new file mode 100644 index 000000000..e7cca3d2a --- /dev/null +++ b/codex-rs/sandboxing/src/seatbelt.rs @@ -0,0 +1,540 @@ +use codex_network_proxy::NetworkProxy; +use codex_network_proxy::PROXY_URL_ENV_KEYS; +use codex_network_proxy::has_proxy_url_env_vars; +use codex_network_proxy::proxy_url_env_value; +use codex_protocol::models::MacOsSeatbeltProfileExtensions; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::protocol::SandboxPolicy; +use codex_utils_absolute_path::AbsolutePathBuf; +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::collections::HashMap; +use std::ffi::CStr; +use std::path::Path; +use std::path::PathBuf; +use tracing::warn; +use url::Url; + +use crate::seatbelt_permissions::build_seatbelt_extensions; + +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_RESTRICTED_READ_ONLY_PLATFORM_DEFAULTS: &str = + include_str!("restricted_read_only_platform_defaults.sbpl"); + +/// 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"; + +fn is_loopback_host(host: &str) -> bool { + host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1" +} + +fn proxy_scheme_default_port(scheme: &str) -> u16 { + match scheme { + "https" => 443, + "socks5" | "socks5h" | "socks4" | "socks4a" => 1080, + _ => 80, + } +} + +fn proxy_loopback_ports_from_env(env: &HashMap) -> Vec { + let mut ports = BTreeSet::new(); + for key in PROXY_URL_ENV_KEYS { + let Some(proxy_url) = proxy_url_env_value(env, key) else { + continue; + }; + let trimmed = proxy_url.trim(); + if trimmed.is_empty() { + continue; + } + + let candidate = if trimmed.contains("://") { + trimmed.to_string() + } else { + format!("http://{trimmed}") + }; + let Ok(parsed) = Url::parse(&candidate) else { + continue; + }; + let Some(host) = parsed.host_str() else { + continue; + }; + if !is_loopback_host(host) { + continue; + } + + let scheme = parsed.scheme().to_ascii_lowercase(); + let port = parsed + .port() + .unwrap_or_else(|| proxy_scheme_default_port(scheme.as_str())); + ports.insert(port); + } + ports.into_iter().collect() +} + +#[derive(Debug, Default)] +struct ProxyPolicyInputs { + ports: Vec, + has_proxy_config: bool, + allow_local_binding: bool, + unix_domain_socket_policy: UnixDomainSocketPolicy, +} + +#[derive(Debug, Clone)] +// Keep allow-all and allowlist modes disjoint so we don't carry ignored state. +enum UnixDomainSocketPolicy { + AllowAll, + Restricted { allowed: Vec }, +} + +impl Default for UnixDomainSocketPolicy { + fn default() -> Self { + Self::Restricted { allowed: vec![] } + } +} + +#[derive(Debug, Clone)] +struct UnixSocketPathParam { + index: usize, + path: AbsolutePathBuf, +} + +fn proxy_policy_inputs(network: Option<&NetworkProxy>) -> ProxyPolicyInputs { + if let Some(network) = network { + let mut env = HashMap::new(); + network.apply_to_env(&mut env); + let unix_domain_socket_policy = if network.dangerously_allow_all_unix_sockets() { + UnixDomainSocketPolicy::AllowAll + } else { + let allowed = network + .allow_unix_sockets() + .iter() + .filter_map( + |socket_path| match normalize_path_for_sandbox(Path::new(socket_path)) { + Some(path) => Some((path.to_string_lossy().to_string(), path)), + None => { + warn!( + "ignoring network.allow_unix_sockets entry because it could not be normalized: {socket_path}" + ); + None + } + }, + ) + .collect::>() + .into_values() + .collect(); + UnixDomainSocketPolicy::Restricted { allowed } + }; + return ProxyPolicyInputs { + ports: proxy_loopback_ports_from_env(&env), + has_proxy_config: has_proxy_url_env_vars(&env), + allow_local_binding: network.allow_local_binding(), + unix_domain_socket_policy, + }; + } + + ProxyPolicyInputs::default() +} + +fn normalize_path_for_sandbox(path: &Path) -> Option { + // `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)) +} + +fn unix_socket_path_params(proxy: &ProxyPolicyInputs) -> Vec { + let mut deduped_paths: BTreeMap = BTreeMap::new(); + let UnixDomainSocketPolicy::Restricted { allowed } = &proxy.unix_domain_socket_policy else { + return vec![]; + }; + for path in allowed { + deduped_paths + .entry(path.to_string_lossy().to_string()) + .or_insert_with(|| path.clone()); + } + + deduped_paths + .into_values() + .enumerate() + .map(|(index, path)| UnixSocketPathParam { index, path }) + .collect() +} + +fn unix_socket_path_param_key(index: usize) -> String { + format!("UNIX_SOCKET_PATH_{index}") +} + +fn unix_socket_dir_params(proxy: &ProxyPolicyInputs) -> Vec<(String, PathBuf)> { + unix_socket_path_params(proxy) + .into_iter() + .map(|param| { + ( + unix_socket_path_param_key(param.index), + param.path.into_path_buf(), + ) + }) + .collect() +} + +/// Returns zero or more complete Seatbelt policy lines for unix socket rules. +/// When non-empty, the returned string is newline-terminated so callers can +/// append it directly to larger policy blocks. +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 + ) { + // Keep AllowAll genuinely broad here; path qualifiers look narrower + // without a clear macOS behavioral benefit. + 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); + // Use subpath so allowlists cover sockets created beneath approved directories. + 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 +} + +#[cfg_attr(not(test), allow(dead_code))] +fn dynamic_network_policy( + sandbox_policy: &SandboxPolicy, + enforce_managed_network: bool, + proxy: &ProxyPolicyInputs, +) -> String { + dynamic_network_policy_for_network( + NetworkSandboxPolicy::from(sandbox_policy), + enforce_managed_network, + proxy, + ) +} + +fn dynamic_network_policy_for_network( + network_policy: NetworkSandboxPolicy, + enforce_managed_network: bool, + proxy: &ProxyPolicyInputs, +) -> String { + let should_use_restricted_network_policy = + !proxy.ports.is_empty() || proxy.has_proxy_config || enforce_managed_network; + if should_use_restricted_network_policy { + let mut policy = String::new(); + if proxy.allow_local_binding { + policy.push_str("; allow loopback local binding and loopback traffic\n"); + policy.push_str("(allow network-bind (local ip \"localhost:*\"))\n"); + policy.push_str("(allow network-inbound (local ip \"localhost:*\"))\n"); + policy.push_str("(allow network-outbound (remote ip \"localhost:*\"))\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("; allow unix domain sockets for local IPC\n"); + policy.push_str(&unix_socket_policy); + } + return format!("{policy}{MACOS_SEATBELT_NETWORK_POLICY}"); + } + + if proxy.has_proxy_config { + // Proxy configuration is present but we could not infer any valid loopback endpoints. + // Fail closed to avoid silently widening network access in proxy-enforced sessions. + return String::new(); + } + + if enforce_managed_network { + // Managed network requirements are active but no usable proxy endpoints + // are available. Fail closed for network access. + return String::new(); + } + + if network_policy.is_enabled() { + // No proxy env is configured: retain the existing full-network behavior. + format!( + "(allow network-outbound)\n(allow network-inbound)\n{MACOS_SEATBELT_NETWORK_POLICY}" + ) + } else { + String::new() + } +} + +fn root_absolute_path() -> AbsolutePathBuf { + match AbsolutePathBuf::from_absolute_path(Path::new("/")) { + Ok(path) => path, + Err(err) => panic!("root path must be absolute: {err}"), + } +} + +#[derive(Debug, Clone)] +struct SeatbeltAccessRoot { + root: AbsolutePathBuf, + excluded_subpaths: Vec, +} + +fn build_seatbelt_access_policy( + action: &str, + param_prefix: &str, + roots: Vec, +) -> (String, Vec<(String, PathBuf)>) { + let mut policy_components = Vec::new(); + let mut params = Vec::new(); + + 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_param = format!("{param_prefix}_{index}"); + params.push((root_param.clone(), root.into_path_buf())); + + if access_root.excluded_subpaths.is_empty() { + policy_components.push(format!("(subpath (param \"{root_param}\"))")); + continue; + } + + let mut require_parts = vec![format!("(subpath (param \"{root_param}\"))")]; + for (excluded_index, excluded_subpath) in + access_root.excluded_subpaths.into_iter().enumerate() + { + let excluded_subpath = + normalize_path_for_sandbox(excluded_subpath.as_path()).unwrap_or(excluded_subpath); + let excluded_param = format!("{param_prefix}_{index}_RO_{excluded_index}"); + params.push((excluded_param.clone(), excluded_subpath.into_path_buf())); + require_parts.push(format!( + "(require-not (subpath (param \"{excluded_param}\")))" + )); + } + policy_components.push(format!("(require-all {} )", require_parts.join(" "))); + } + + if policy_components.is_empty() { + (String::new(), Vec::new()) + } else { + ( + format!("(allow {action}\n{}\n)", policy_components.join(" ")), + params, + ) + } +} + +#[cfg_attr(not(test), allow(dead_code))] +fn create_seatbelt_command_args_with_extensions( + command: Vec, + sandbox_policy: &SandboxPolicy, + sandbox_policy_cwd: &Path, + enforce_managed_network: bool, + network: Option<&NetworkProxy>, + extensions: Option<&MacOsSeatbeltProfileExtensions>, +) -> Vec { + create_seatbelt_command_args_for_policies_with_extensions( + command, + &FileSystemSandboxPolicy::from_legacy_sandbox_policy(sandbox_policy, sandbox_policy_cwd), + NetworkSandboxPolicy::from(sandbox_policy), + sandbox_policy_cwd, + enforce_managed_network, + network, + extensions, + ) +} + +pub fn create_seatbelt_command_args_for_policies_with_extensions( + command: Vec, + file_system_sandbox_policy: &FileSystemSandboxPolicy, + network_sandbox_policy: NetworkSandboxPolicy, + sandbox_policy_cwd: &Path, + enforce_managed_network: bool, + network: Option<&NetworkProxy>, + extensions: Option<&MacOsSeatbeltProfileExtensions>, +) -> Vec { + let unreadable_roots = + file_system_sandbox_policy.get_unreadable_roots_with_cwd(sandbox_policy_cwd); + let (file_write_policy, file_write_dir_params) = + if file_system_sandbox_policy.has_full_disk_write_access() { + if unreadable_roots.is_empty() { + // Allegedly, this is more permissive than `(allow file-write*)`. + ( + r#"(allow file-write* (regex #"^/"))"#.to_string(), + Vec::new(), + ) + } else { + build_seatbelt_access_policy( + "file-write*", + "WRITABLE_ROOT", + vec![SeatbeltAccessRoot { + root: root_absolute_path(), + excluded_subpaths: unreadable_roots.clone(), + }], + ) + } + } else { + build_seatbelt_access_policy( + "file-write*", + "WRITABLE_ROOT", + file_system_sandbox_policy + .get_writable_roots_with_cwd(sandbox_policy_cwd) + .into_iter() + .map(|root| SeatbeltAccessRoot { + 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( + "file-read*", + "READABLE_ROOT", + vec![SeatbeltAccessRoot { + root: root_absolute_path(), + excluded_subpaths: unreadable_roots, + }], + ); + ( + format!("; allow read-only file operations\n{policy}"), + params, + ) + } + } else { + let (policy, params) = build_seatbelt_access_policy( + "file-read*", + "READABLE_ROOT", + 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(), + root, + }) + .collect(), + ); + if policy.is_empty() { + (String::new(), params) + } else { + ( + format!("; allow read-only file operations\n{policy}"), + params, + ) + } + }; + + let proxy = proxy_policy_inputs(network); + let network_policy = + dynamic_network_policy_for_network(network_sandbox_policy, enforce_managed_network, &proxy); + let seatbelt_extensions = extensions.map_or_else( + || { + // Backward-compatibility default when no extension profile is provided. + build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions::default()) + }, + build_seatbelt_extensions, + ); + + let include_platform_defaults = file_system_sandbox_policy.include_platform_defaults(); + let mut policy_sections = vec![ + MACOS_SEATBELT_BASE_POLICY.to_string(), + file_read_policy, + file_write_policy, + network_policy, + ]; + if include_platform_defaults { + policy_sections.push(MACOS_RESTRICTED_READ_ONLY_PLATFORM_DEFAULTS.to_string()); + } + if !seatbelt_extensions.policy.is_empty() { + policy_sections.push(seatbelt_extensions.policy.clone()); + } + + let full_policy = policy_sections.join("\n"); + + let dir_params = [ + file_read_dir_params, + file_write_dir_params, + macos_dir_params(), + unix_socket_dir_params(&proxy), + seatbelt_extensions.dir_params, + ] + .concat(); + + let mut seatbelt_args: Vec = 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); + seatbelt_args +} + +/// Wraps libc::confstr to return a String. +fn confstr(name: libc::c_int) -> Option { + let mut buf = vec![0_i8; (libc::PATH_MAX as usize) + 1]; + let len = unsafe { libc::confstr(name, buf.as_mut_ptr(), buf.len()) }; + if len == 0 { + return None; + } + // confstr guarantees NUL-termination when len > 0. + let cstr = unsafe { CStr::from_ptr(buf.as_ptr()) }; + cstr.to_str().ok().map(ToString::to_string) +} + +/// Wraps confstr to return a canonicalized PathBuf. +fn confstr_path(name: libc::c_int) -> Option { + let s = confstr(name)?; + let path = PathBuf::from(s); + path.canonicalize().ok().or(Some(path)) +} + +fn macos_dir_params() -> Vec<(String, PathBuf)> { + if let Some(p) = confstr_path(libc::_CS_DARWIN_USER_CACHE_DIR) { + return vec![("DARWIN_USER_CACHE_DIR".to_string(), p)]; + } + vec![] +} + +#[cfg(test)] +#[path = "seatbelt_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/seatbelt_base_policy.sbpl b/codex-rs/sandboxing/src/seatbelt_base_policy.sbpl similarity index 100% rename from codex-rs/core/src/seatbelt_base_policy.sbpl rename to codex-rs/sandboxing/src/seatbelt_base_policy.sbpl diff --git a/codex-rs/core/src/seatbelt_network_policy.sbpl b/codex-rs/sandboxing/src/seatbelt_network_policy.sbpl similarity index 100% rename from codex-rs/core/src/seatbelt_network_policy.sbpl rename to codex-rs/sandboxing/src/seatbelt_network_policy.sbpl diff --git a/codex-rs/core/src/seatbelt_permissions.rs b/codex-rs/sandboxing/src/seatbelt_permissions.rs similarity index 96% rename from codex-rs/core/src/seatbelt_permissions.rs rename to codex-rs/sandboxing/src/seatbelt_permissions.rs index 5cbfd6509..d53e7496a 100644 --- a/codex-rs/core/src/seatbelt_permissions.rs +++ b/codex-rs/sandboxing/src/seatbelt_permissions.rs @@ -1,12 +1,10 @@ -#![cfg(target_os = "macos")] - use std::collections::BTreeSet; use std::path::PathBuf; -pub use codex_protocol::models::MacOsAutomationPermission; -pub use codex_protocol::models::MacOsContactsPermission; -pub use codex_protocol::models::MacOsPreferencesPermission; -pub use codex_protocol::models::MacOsSeatbeltProfileExtensions; +use codex_protocol::models::MacOsAutomationPermission; +use codex_protocol::models::MacOsContactsPermission; +use codex_protocol::models::MacOsPreferencesPermission; +use codex_protocol::models::MacOsSeatbeltProfileExtensions; #[derive(Debug, Clone, PartialEq, Eq, Default)] pub(crate) struct SeatbeltExtensionPolicy { diff --git a/codex-rs/core/src/seatbelt_permissions_tests.rs b/codex-rs/sandboxing/src/seatbelt_permissions_tests.rs similarity index 100% rename from codex-rs/core/src/seatbelt_permissions_tests.rs rename to codex-rs/sandboxing/src/seatbelt_permissions_tests.rs diff --git a/codex-rs/core/src/seatbelt_tests.rs b/codex-rs/sandboxing/src/seatbelt_tests.rs similarity index 94% rename from codex-rs/core/src/seatbelt_tests.rs rename to codex-rs/sandboxing/src/seatbelt_tests.rs index bab136201..925285763 100644 --- a/codex-rs/core/src/seatbelt_tests.rs +++ b/codex-rs/sandboxing/src/seatbelt_tests.rs @@ -1,7 +1,7 @@ +use super::MACOS_PATH_TO_SEATBELT_EXECUTABLE; use super::MACOS_SEATBELT_BASE_POLICY; use super::ProxyPolicyInputs; use super::UnixDomainSocketPolicy; -use super::create_seatbelt_command_args; use super::create_seatbelt_command_args_for_policies_with_extensions; use super::create_seatbelt_command_args_with_extensions; use super::dynamic_network_policy; @@ -9,18 +9,18 @@ use super::macos_dir_params; use super::normalize_path_for_sandbox; use super::unix_socket_dir_params; use super::unix_socket_policy; -use crate::protocol::ReadOnlyAccess; -use crate::protocol::SandboxPolicy; -use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE; -use crate::seatbelt_permissions::MacOsAutomationPermission; -use crate::seatbelt_permissions::MacOsContactsPermission; -use crate::seatbelt_permissions::MacOsPreferencesPermission; -use crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions; +use codex_protocol::models::MacOsAutomationPermission; +use codex_protocol::models::MacOsContactsPermission; +use codex_protocol::models::MacOsPreferencesPermission; +use codex_protocol::models::MacOsSeatbeltProfileExtensions; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::protocol::ReadOnlyAccess; +use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use std::fs; @@ -105,7 +105,7 @@ fn explicit_unreadable_paths_are_excluded_from_full_disk_read_and_write_access() let file_system_policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { - value: crate::protocol::FileSystemSpecialPath::Root, + value: FileSystemSpecialPath::Root, }, access: FileSystemAccessMode::Write, }, @@ -277,12 +277,13 @@ sys.exit(0 if allowed else 13) #[test] fn seatbelt_args_without_extension_profile_keep_legacy_preferences_read_access() { let cwd = std::env::temp_dir(); - let args = create_seatbelt_command_args( + let args = create_seatbelt_command_args_with_extensions( vec!["echo".to_string(), "ok".to_string()], &SandboxPolicy::new_read_only_policy(), cwd.as_path(), - false, + /*enforce_managed_network*/ false, None, + /*extensions*/ None, ); let policy = &args[1]; assert!(policy.contains("(allow user-preference-read)")); @@ -295,7 +296,7 @@ fn seatbelt_legacy_workspace_write_nested_readable_root_stays_writable() { let cwd = tmp.path().join("workspace"); fs::create_dir_all(cwd.join("docs")).expect("create docs"); let docs = AbsolutePathBuf::from_absolute_path(cwd.join("docs")).expect("absolute docs"); - let args = create_seatbelt_command_args( + let args = create_seatbelt_command_args_with_extensions( vec!["/bin/true".to_string()], &SandboxPolicy::WorkspaceWrite { writable_roots: vec![], @@ -308,8 +309,9 @@ fn seatbelt_legacy_workspace_write_nested_readable_root_stays_writable() { exclude_slash_tmp: true, }, cwd.as_path(), - false, + /*enforce_managed_network*/ false, None, + /*extensions*/ None, ); let docs_param = format!("-DWRITABLE_ROOT_0_RO_0={}", docs.as_path().display()); @@ -636,7 +638,14 @@ fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() { .iter() .map(std::string::ToString::to_string) .collect(); - let args = create_seatbelt_command_args(shell_command.clone(), &policy, &cwd, false, None); + let args = create_seatbelt_command_args_with_extensions( + shell_command.clone(), + &policy, + &cwd, + /*enforce_managed_network*/ false, + None, + /*extensions*/ None, + ); // Build the expected policy text using a raw string for readability. // Note that the policy includes: @@ -740,8 +749,14 @@ fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() { .iter() .map(std::string::ToString::to_string) .collect(); - let write_hooks_file_args = - create_seatbelt_command_args(shell_command_git, &policy, &cwd, false, None); + let write_hooks_file_args = create_seatbelt_command_args_with_extensions( + shell_command_git, + &policy, + &cwd, + /*enforce_managed_network*/ false, + None, + /*extensions*/ None, + ); let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) .args(&write_hooks_file_args) .current_dir(&cwd) @@ -771,8 +786,14 @@ fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() { .iter() .map(std::string::ToString::to_string) .collect(); - let write_allowed_file_args = - create_seatbelt_command_args(shell_command_allowed, &policy, &cwd, false, None); + let write_allowed_file_args = create_seatbelt_command_args_with_extensions( + shell_command_allowed, + &policy, + &cwd, + /*enforce_managed_network*/ false, + None, + /*extensions*/ None, + ); let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) .args(&write_allowed_file_args) .current_dir(&cwd) @@ -833,7 +854,14 @@ fn create_seatbelt_args_with_read_only_git_pointer_file() { .iter() .map(std::string::ToString::to_string) .collect(); - let args = create_seatbelt_command_args(shell_command, &policy, &cwd, false, None); + let args = create_seatbelt_command_args_with_extensions( + shell_command, + &policy, + &cwd, + /*enforce_managed_network*/ false, + None, + /*extensions*/ None, + ); let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) .args(&args) @@ -863,8 +891,14 @@ fn create_seatbelt_args_with_read_only_git_pointer_file() { .iter() .map(std::string::ToString::to_string) .collect(); - let gitdir_args = - create_seatbelt_command_args(shell_command_gitdir, &policy, &cwd, false, None); + let gitdir_args = create_seatbelt_command_args_with_extensions( + shell_command_gitdir, + &policy, + &cwd, + /*enforce_managed_network*/ false, + None, + /*extensions*/ None, + ); let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) .args(&gitdir_args) .current_dir(&cwd) @@ -921,12 +955,13 @@ fn create_seatbelt_args_for_cwd_as_git_repo() { .iter() .map(std::string::ToString::to_string) .collect(); - let args = create_seatbelt_command_args( + let args = create_seatbelt_command_args_with_extensions( shell_command.clone(), &policy, vulnerable_root.as_path(), - false, + /*enforce_managed_network*/ false, None, + /*extensions*/ None, ); let tmpdir_env_var = std::env::var("TMPDIR")