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::permissions::FileSystemSandboxPolicy;use codex_protocol::permissions::NetworkSandboxPolicy;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 std::collections::BTreeMap;use std::collections::BTreeSet;use std::collections::HashMap;use std::collections::VecDeque;use std::path::Path;use std::path::PathBuf;use tracing::warn;use url::Url;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<String, String>) -> Vec<u16> { 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<u16>, 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<AbsolutePathBuf> },}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>, extra_allow_unix_sockets: &[AbsolutePathBuf],) -> ProxyPolicyInputs { let extra_allowed = extra_allow_unix_sockets .iter() .filter_map(|socket_path| normalize_path_for_sandbox(socket_path.as_path())) .collect::<Vec<_>>(); match network { Some(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 mut allowed = network .allow_unix_sockets() .iter() .filter_map(|socket_path| { match normalize_path_for_sandbox(Path::new(socket_path)) { Some(path) => Some(path), None => { warn!( "ignoring network.allow_unix_sockets entry because it could not be normalized: {socket_path}" ); None } } }) .collect::<Vec<_>>(); allowed.extend(extra_allowed); UnixDomainSocketPolicy::Restricted { allowed } }; 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, } } None => ProxyPolicyInputs { unix_domain_socket_policy: UnixDomainSocketPolicy::Restricted { allowed: extra_allowed, }, ..Default::default() }, }}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))}fn unix_socket_path_params(proxy: &ProxyPolicyInputs) -> Vec<UnixSocketPathParam> { let mut deduped_paths: BTreeMap<String, AbsolutePathBuf> = 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 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 local binding and loopback traffic\n"); 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 DNS lookups while application traffic remains proxy-routed\n"); 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("; 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. 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("; allow unix domain sockets for local IPC\n"); policy.push_str(&unix_socket_policy); } format!("{policy}{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<AbsolutePathBuf>, protected_metadata_names: Vec<String>,}fn build_seatbelt_access_policy( action: &str, param_prefix: &str, roots: Vec<SeatbeltAccessRoot>,) -> (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.clone().into_path_buf())); if access_root.excluded_subpaths.is_empty() && access_root.protected_metadata_names.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}_EXCLUDED_{excluded_index}"); params.push((excluded_param.clone(), excluded_subpath.into_path_buf())); // Exclude both the exact protected path and anything beneath it. // `subpath` alone leaves a gap for first-time creation of the // protected directory itself, such as `mkdir .codex`. require_parts.push(format!( "(require-not (literal (param \"{excluded_param}\")))" )); require_parts.push(format!( "(require-not (subpath (param \"{excluded_param}\")))" )); } for metadata_name in access_root.protected_metadata_names { let regex = seatbelt_protected_metadata_name_regex(&root, &metadata_name).replace('"', "\\\""); require_parts.push(format!(r#"(require-not (regex #"{regex}"))"#)); } policy_components.push(format!("(require-all {} )", require_parts.join(" "))); } if policy_components.is_empty() { (String::new(), Vec::new()) } else { ( format!("(allow {action}\n{}\n)", policy_components.join(" ")), params, ) }}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}fn build_seatbelt_unreadable_glob_policy( file_system_sandbox_policy: &FileSystemSandboxPolicy, cwd: &Path,) -> String { // Seatbelt does not understand the filesystem policy's glob syntax directly. // Convert each unreadable pattern into an anchored regex deny rule and apply // it to both reads and unlink-style writes so a denied path cannot be probed // through destructive filesystem operations. 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 regexes = BTreeSet::new(); if let Some(regex) = seatbelt_regex_for_unreadable_glob(&pattern) { regexes.insert(regex); } if let Some(pattern) = canonicalize_glob_static_prefix_for_sandbox(&pattern) && let Some(regex) = seatbelt_regex_for_unreadable_glob(&pattern) { regexes.insert(regex); } for regex in regexes { let regex = regex.replace('"', "\\\""); policy_components.push(format!(r#"(deny file-read* (regex #"{regex}"))"#)); policy_components.push(format!(r#"(deny file-write-unlink (regex #"{regex}"))"#)); } } policy_components.join("\n")}fn canonicalize_glob_static_prefix_for_sandbox(pattern: &str) -> Option<String> { let first_glob_index = pattern .char_indices() .find_map(|(index, ch)| matches!(ch, '*' | '?' | '[' | ']').then_some(index)); let Some(first_glob_index) = first_glob_index else { return normalize_path_for_sandbox(Path::new(pattern)) .map(|path| path.to_string_lossy().to_string()); }; let static_prefix = &pattern[..first_glob_index]; let prefix_end = if static_prefix.ends_with('/') { static_prefix.len() - 1 } else { static_prefix.rfind('/').unwrap_or(0) }; if prefix_end == 0 { return None; } let root = normalize_path_for_sandbox(Path::new(&pattern[..prefix_end]))?; let root = root.to_string_lossy(); let suffix = &pattern[prefix_end..]; let normalized_pattern = format!("{root}{suffix}"); (normalized_pattern != pattern).then_some(normalized_pattern)}fn seatbelt_regex_for_unreadable_glob(pattern: &str) -> 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, and closed character classes remain character classes. // A pattern with no glob metacharacters is treated as exact path plus subtree. let mut regex = String::from("^"); let mut chars = pattern.chars().collect::<VecDeque<_>>(); let mut saw_glob = false; 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("[^/]"); } '[' => { saw_glob = true; let mut class = Vec::new(); let mut closed = false; while let Some(class_ch) = chars.pop_front() { if class_ch == ']' { closed = true; break; } class.push(class_ch); } if !closed { regex.push_str("\\["); for class_ch in class.into_iter().rev() { chars.push_front(class_ch); } continue; } regex.push('['); let mut class_chars = class.into_iter(); if let Some(first) = class_chars.next() { match first { '!' => regex.push('^'), '^' => regex.push_str("\\^"), _ => regex.push(first), } } for class_ch in class_chars { match class_ch { '\\' => regex.push_str("\\\\"), _ => regex.push(class_ch), } } regex.push(']'); } ']' => { saw_glob = true; regex.push_str("\\]"); } _ => regex.push_str(®ex_lite::escape(&ch.to_string())), } } if !saw_glob { regex.push_str("(/.*)?"); } regex.push('$'); Some(regex)}#[cfg_attr(not(test), allow(dead_code))]fn create_seatbelt_command_args_for_legacy_policy( command: Vec<String>, sandbox_policy: &SandboxPolicy, sandbox_policy_cwd: &Path, enforce_managed_network: bool, network: Option<&NetworkProxy>,) -> Vec<String> { let file_system_sandbox_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( sandbox_policy, sandbox_policy_cwd, ); create_seatbelt_command_args(CreateSeatbeltCommandArgsParams { command, file_system_sandbox_policy: &file_system_sandbox_policy, network_sandbox_policy: NetworkSandboxPolicy::from(sandbox_policy), sandbox_policy_cwd, enforce_managed_network, network, extra_allow_unix_sockets: &[], })}#[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 network: Option<&'a NetworkProxy>, pub extra_allow_unix_sockets: &'a [AbsolutePathBuf],}pub fn create_seatbelt_command_args(args: CreateSeatbeltCommandArgsParams<'_>) -> Vec<String> { let CreateSeatbeltCommandArgsParams { command, file_system_sandbox_policy, network_sandbox_policy, sandbox_policy_cwd, enforce_managed_network, network, extra_allow_unix_sockets, } = args; 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(), protected_metadata_names: Vec::new(), }], ) } } 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 { 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( "file-read*", "READABLE_ROOT", 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( "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(), protected_metadata_names: Vec::new(), 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, extra_allow_unix_sockets); 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, deny_read_policy, network_policy, ]; if include_platform_defaults { policy_sections.push(MACOS_RESTRICTED_READ_ONLY_PLATFORM_DEFAULTS.to_string()); } let full_policy = policy_sections.join("\n"); let dir_params = [ file_read_dir_params, file_write_dir_params, unix_socket_dir_params(&proxy), ] .concat(); 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); seatbelt_args}#[cfg(test)]#[path = "seatbelt_tests.rs"]mod tests;