/* Module: sandboxing Build platform wrappers and produce ExecRequest for execution. Owns low-level sandbox placement and transformation of portable CommandSpec into a ready‑to‑spawn environment. */ pub(crate) mod macos_permissions; use crate::exec::ExecExpiration; use crate::exec::ExecToolCallOutput; use crate::exec::SandboxType; use crate::exec::StdoutStream; use crate::exec::execute_exec_request; use crate::landlock::allow_network_for_proxy; use crate::landlock::create_linux_sandbox_command_args; 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_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; use codex_network_proxy::NetworkProxy; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::MacOsSeatbeltProfileExtensions; use codex_protocol::models::PermissionProfile; pub use codex_protocol::models::SandboxPermissions; use codex_protocol::protocol::ReadOnlyAccess; use codex_utils_absolute_path::AbsolutePathBuf; use dunce::canonicalize; use macos_permissions::merge_macos_seatbelt_profile_extensions; use std::collections::HashMap; use std::collections::HashSet; use std::path::Path; use std::path::PathBuf; #[derive(Debug)] pub struct CommandSpec { pub program: String, pub args: Vec, pub cwd: PathBuf, pub env: HashMap, pub expiration: ExecExpiration, pub sandbox_permissions: SandboxPermissions, pub additional_permissions: Option, pub justification: Option, } #[derive(Debug)] pub struct ExecRequest { pub command: Vec, pub cwd: PathBuf, pub env: HashMap, pub network: Option, pub expiration: ExecExpiration, pub sandbox: SandboxType, pub windows_sandbox_level: WindowsSandboxLevel, pub sandbox_permissions: SandboxPermissions, pub sandbox_policy: SandboxPolicy, pub justification: Option, pub arg0: Option, } /// Bundled arguments for sandbox transformation. /// /// This keeps call sites self-documenting when several fields are optional. pub(crate) struct SandboxTransformRequest<'a> { pub spec: CommandSpec, pub policy: &'a SandboxPolicy, pub sandbox: SandboxType, pub enforce_managed_network: bool, // TODO(viyatb): Evaluate switching this to Option> // to make shared ownership explicit across runtime/sandbox plumbing. pub network: Option<&'a NetworkProxy>, pub sandbox_policy_cwd: &'a Path, #[cfg(target_os = "macos")] pub macos_seatbelt_profile_extensions: Option<&'a MacOsSeatbeltProfileExtensions>, pub codex_linux_sandbox_exe: Option<&'a PathBuf>, pub use_linux_sandbox_bwrap: bool, pub windows_sandbox_level: WindowsSandboxLevel, } pub enum SandboxPreference { Auto, Require, Forbid, } #[derive(Debug, thiserror::Error)] pub(crate) enum SandboxTransformError { #[error("missing codex-linux-sandbox executable path")] MissingLinuxSandboxExecutable, #[cfg(not(target_os = "macos"))] #[error("seatbelt sandbox is only available on macOS")] SeatbeltUnavailable, } #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct EffectiveSandboxPermissions { pub(crate) sandbox_policy: SandboxPolicy, pub(crate) macos_seatbelt_profile_extensions: Option, } impl EffectiveSandboxPermissions { pub(crate) fn new( sandbox_policy: &SandboxPolicy, macos_seatbelt_profile_extensions: Option<&MacOsSeatbeltProfileExtensions>, additional_permissions: Option<&PermissionProfile>, ) -> Self { let Some(additional_permissions) = additional_permissions else { return Self { sandbox_policy: sandbox_policy.clone(), macos_seatbelt_profile_extensions: macos_seatbelt_profile_extensions.cloned(), }; }; Self { sandbox_policy: sandbox_policy_with_additional_permissions( sandbox_policy, additional_permissions, ), macos_seatbelt_profile_extensions: merge_macos_seatbelt_profile_extensions( macos_seatbelt_profile_extensions, additional_permissions.macos.as_ref(), ), } } } pub(crate) fn normalize_additional_permissions( additional_permissions: PermissionProfile, ) -> Result { Ok(PermissionProfile { network: additional_permissions.network, file_system: additional_permissions .file_system .map(|file_system| FileSystemPermissions { read: file_system .read .map(|paths| normalize_permission_paths(paths, "file_system.read")), write: file_system .write .map(|paths| normalize_permission_paths(paths, "file_system.write")), }), macos: additional_permissions.macos, }) } fn normalize_permission_paths( paths: Vec, _permission_kind: &str, ) -> Vec { let mut out = Vec::with_capacity(paths.len()); let mut seen = HashSet::new(); for path in paths { let canonicalized = canonicalize(path.as_path()) .ok() .and_then(|path| AbsolutePathBuf::from_absolute_path(path).ok()) .unwrap_or(path); if seen.insert(canonicalized.clone()) { out.push(canonicalized); } } out } fn dedup_absolute_paths(paths: Vec) -> Vec { let mut out = Vec::with_capacity(paths.len()); let mut seen = HashSet::new(); for path in paths { if seen.insert(path.to_path_buf()) { out.push(path); } } out } fn additional_permission_roots( additional_permissions: &PermissionProfile, ) -> (Vec, Vec) { ( dedup_absolute_paths( additional_permissions .file_system .as_ref() .and_then(|file_system| file_system.read.clone()) .unwrap_or_default(), ), dedup_absolute_paths( additional_permissions .file_system .as_ref() .and_then(|file_system| file_system.write.clone()) .unwrap_or_default(), ), ) } fn merge_read_only_access_with_additional_reads( read_only_access: &ReadOnlyAccess, extra_reads: Vec, ) -> ReadOnlyAccess { match read_only_access { ReadOnlyAccess::FullAccess => ReadOnlyAccess::FullAccess, ReadOnlyAccess::Restricted { include_platform_defaults, readable_roots, } => { let mut merged = readable_roots.clone(); merged.extend(extra_reads); ReadOnlyAccess::Restricted { include_platform_defaults: *include_platform_defaults, readable_roots: dedup_absolute_paths(merged), } } } } fn merge_network_access( base_network_access: bool, additional_permissions: &PermissionProfile, ) -> bool { base_network_access || additional_permissions .network .as_ref() .and_then(|network| network.enabled) .unwrap_or(false) } fn sandbox_policy_with_additional_permissions( sandbox_policy: &SandboxPolicy, additional_permissions: &PermissionProfile, ) -> SandboxPolicy { if additional_permissions.is_empty() { return sandbox_policy.clone(); } let (extra_reads, extra_writes) = additional_permission_roots(additional_permissions); match sandbox_policy { SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { sandbox_policy.clone() } SandboxPolicy::WorkspaceWrite { writable_roots, read_only_access, network_access, exclude_tmpdir_env_var, exclude_slash_tmp, } => { let mut merged_writes = writable_roots.clone(); merged_writes.extend(extra_writes); SandboxPolicy::WorkspaceWrite { writable_roots: dedup_absolute_paths(merged_writes), read_only_access: merge_read_only_access_with_additional_reads( read_only_access, extra_reads, ), network_access: merge_network_access(*network_access, additional_permissions), exclude_tmpdir_env_var: *exclude_tmpdir_env_var, exclude_slash_tmp: *exclude_slash_tmp, } } SandboxPolicy::ReadOnly { access, network_access, } => { if extra_writes.is_empty() { SandboxPolicy::ReadOnly { access: merge_read_only_access_with_additional_reads(access, extra_reads), network_access: merge_network_access(*network_access, additional_permissions), } } else { // todo(dylan) - for now, this grants more access than the request. We should restrict this, // but we should add a new SandboxPolicy variant to handle this. While the feature is still // UnderDevelopment, it's a useful approximation of the desired behavior. SandboxPolicy::WorkspaceWrite { writable_roots: dedup_absolute_paths(extra_writes), read_only_access: merge_read_only_access_with_additional_reads( access, extra_reads, ), network_access: merge_network_access(*network_access, additional_permissions), exclude_tmpdir_env_var: false, exclude_slash_tmp: false, } } } } } #[derive(Default)] pub struct SandboxManager; impl SandboxManager { pub fn new() -> Self { Self } pub(crate) fn select_initial( &self, policy: &SandboxPolicy, pref: SandboxablePreference, windows_sandbox_level: WindowsSandboxLevel, has_managed_network_requirements: bool, ) -> SandboxType { match pref { SandboxablePreference::Forbid => SandboxType::None, SandboxablePreference::Require => { // Require a platform sandbox when available; on Windows this // respects the experimental_windows_sandbox feature. crate::safety::get_platform_sandbox( windows_sandbox_level != WindowsSandboxLevel::Disabled, ) .unwrap_or(SandboxType::None) } SandboxablePreference::Auto => match policy { SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { if has_managed_network_requirements { crate::safety::get_platform_sandbox( windows_sandbox_level != WindowsSandboxLevel::Disabled, ) .unwrap_or(SandboxType::None) } else { SandboxType::None } } _ => crate::safety::get_platform_sandbox( windows_sandbox_level != WindowsSandboxLevel::Disabled, ) .unwrap_or(SandboxType::None), }, } } pub(crate) fn transform( &self, request: SandboxTransformRequest<'_>, ) -> Result { let SandboxTransformRequest { mut spec, policy, sandbox, enforce_managed_network, network, sandbox_policy_cwd, #[cfg(target_os = "macos")] macos_seatbelt_profile_extensions, codex_linux_sandbox_exe, use_linux_sandbox_bwrap, windows_sandbox_level, } = request; #[cfg(not(target_os = "macos"))] let macos_seatbelt_profile_extensions = None; let effective_permissions = EffectiveSandboxPermissions::new( policy, macos_seatbelt_profile_extensions, spec.additional_permissions.as_ref(), ); let mut env = spec.env; if !effective_permissions .sandbox_policy .has_full_network_access() { env.insert( CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR.to_string(), "1".to_string(), ); } let mut command = Vec::with_capacity(1 + spec.args.len()); command.push(spec.program); command.append(&mut spec.args); let (command, sandbox_env, arg0_override) = match sandbox { SandboxType::None => (command, HashMap::new(), None), #[cfg(target_os = "macos")] SandboxType::MacosSeatbelt => { let mut seatbelt_env = HashMap::new(); seatbelt_env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string()); let mut args = create_seatbelt_command_args_with_extensions( command.clone(), &effective_permissions.sandbox_policy, sandbox_policy_cwd, enforce_managed_network, network, effective_permissions .macos_seatbelt_profile_extensions .as_ref(), ); 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); (full_command, seatbelt_env, None) } #[cfg(not(target_os = "macos"))] SandboxType::MacosSeatbelt => return Err(SandboxTransformError::SeatbeltUnavailable), SandboxType::LinuxSeccomp => { let exe = codex_linux_sandbox_exe .ok_or(SandboxTransformError::MissingLinuxSandboxExecutable)?; let allow_proxy_network = allow_network_for_proxy(enforce_managed_network); let mut args = create_linux_sandbox_command_args( command.clone(), &effective_permissions.sandbox_policy, sandbox_policy_cwd, use_linux_sandbox_bwrap, allow_proxy_network, ); let mut full_command = Vec::with_capacity(1 + args.len()); full_command.push(exe.to_string_lossy().to_string()); full_command.append(&mut args); ( full_command, HashMap::new(), Some("codex-linux-sandbox".to_string()), ) } // On Windows, the restricted token sandbox executes in-process via the // codex-windows-sandbox crate. We leave the command unchanged here and // branch during execution based on the sandbox type. #[cfg(target_os = "windows")] SandboxType::WindowsRestrictedToken => (command, HashMap::new(), None), // When building for non-Windows targets, this variant is never constructed. #[cfg(not(target_os = "windows"))] SandboxType::WindowsRestrictedToken => (command, HashMap::new(), None), }; env.extend(sandbox_env); Ok(ExecRequest { command, cwd: spec.cwd, env, network: network.cloned(), expiration: spec.expiration, sandbox, windows_sandbox_level, sandbox_permissions: spec.sandbox_permissions, sandbox_policy: effective_permissions.sandbox_policy, justification: spec.justification, arg0: arg0_override, }) } pub fn denied(&self, sandbox: SandboxType, out: &ExecToolCallOutput) -> bool { crate::exec::is_likely_sandbox_denied(sandbox, out) } } pub async fn execute_env( exec_request: ExecRequest, stdout_stream: Option, ) -> crate::error::Result { let effective_policy = exec_request.sandbox_policy.clone(); execute_exec_request(exec_request, &effective_policy, stdout_stream, None).await } pub async fn execute_exec_request_with_after_spawn( exec_request: ExecRequest, stdout_stream: Option, after_spawn: Option>, ) -> crate::error::Result { let effective_policy = exec_request.sandbox_policy.clone(); execute_exec_request(exec_request, &effective_policy, stdout_stream, after_spawn).await } #[cfg(test)] mod tests { #[cfg(target_os = "macos")] use super::EffectiveSandboxPermissions; use super::SandboxManager; use super::normalize_additional_permissions; use super::sandbox_policy_with_additional_permissions; use crate::exec::SandboxType; use crate::protocol::ReadOnlyAccess; use crate::protocol::SandboxPolicy; use crate::tools::sandboxing::SandboxablePreference; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::FileSystemPermissions; #[cfg(target_os = "macos")] use codex_protocol::models::MacOsAutomationPermission; #[cfg(target_os = "macos")] use codex_protocol::models::MacOsPreferencesPermission; #[cfg(target_os = "macos")] use codex_protocol::models::MacOsSeatbeltProfileExtensions; use codex_protocol::models::NetworkPermissions; use codex_protocol::models::PermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; use dunce::canonicalize; use pretty_assertions::assert_eq; use tempfile::TempDir; #[test] fn danger_full_access_defaults_to_no_sandbox_without_network_requirements() { let manager = SandboxManager::new(); let sandbox = manager.select_initial( &SandboxPolicy::DangerFullAccess, SandboxablePreference::Auto, WindowsSandboxLevel::Disabled, false, ); assert_eq!(sandbox, SandboxType::None); } #[test] fn danger_full_access_uses_platform_sandbox_with_network_requirements() { let manager = SandboxManager::new(); let expected = crate::safety::get_platform_sandbox(false).unwrap_or(SandboxType::None); let sandbox = manager.select_initial( &SandboxPolicy::DangerFullAccess, SandboxablePreference::Auto, WindowsSandboxLevel::Disabled, true, ); assert_eq!(sandbox, expected); } #[test] fn normalize_additional_permissions_preserves_network() { let temp_dir = TempDir::new().expect("create temp dir"); let path = AbsolutePathBuf::from_absolute_path( canonicalize(temp_dir.path()).expect("canonicalize temp dir"), ) .expect("absolute temp dir"); let permissions = normalize_additional_permissions(PermissionProfile { network: Some(NetworkPermissions { enabled: Some(true), }), file_system: Some(FileSystemPermissions { read: Some(vec![path.clone()]), write: Some(vec![path.clone()]), }), ..Default::default() }) .expect("permissions"); assert_eq!( permissions.network, Some(NetworkPermissions { enabled: Some(true), }) ); assert_eq!( permissions.file_system, Some(FileSystemPermissions { read: Some(vec![path.clone()]), write: Some(vec![path]), }) ); } #[cfg(target_os = "macos")] #[test] fn normalize_additional_permissions_preserves_macos_permissions() { let permissions = normalize_additional_permissions(PermissionProfile { macos: Some(MacOsSeatbeltProfileExtensions { macos_preferences: MacOsPreferencesPermission::ReadWrite, macos_automation: MacOsAutomationPermission::BundleIds(vec![ "com.apple.Notes".to_string(), ]), macos_accessibility: true, macos_calendar: true, }), ..Default::default() }) .expect("permissions"); assert_eq!( permissions.macos, Some(MacOsSeatbeltProfileExtensions { macos_preferences: MacOsPreferencesPermission::ReadWrite, macos_automation: MacOsAutomationPermission::BundleIds(vec![ "com.apple.Notes".to_string(), ]), macos_accessibility: true, macos_calendar: true, }) ); } #[test] fn read_only_additional_permissions_can_enable_network_without_writes() { let temp_dir = TempDir::new().expect("create temp dir"); let path = AbsolutePathBuf::from_absolute_path( canonicalize(temp_dir.path()).expect("canonicalize temp dir"), ) .expect("absolute temp dir"); let policy = sandbox_policy_with_additional_permissions( &SandboxPolicy::ReadOnly { access: ReadOnlyAccess::Restricted { include_platform_defaults: true, readable_roots: vec![path.clone()], }, network_access: false, }, &PermissionProfile { network: Some(NetworkPermissions { enabled: Some(true), }), file_system: Some(FileSystemPermissions { read: Some(vec![path.clone()]), write: Some(Vec::new()), }), ..Default::default() }, ); assert_eq!( policy, SandboxPolicy::ReadOnly { access: ReadOnlyAccess::Restricted { include_platform_defaults: true, readable_roots: vec![path], }, network_access: true, } ); } #[cfg(target_os = "macos")] #[test] fn effective_permissions_merge_macos_extensions_with_additional_permissions() { let temp_dir = TempDir::new().expect("create temp dir"); let path = AbsolutePathBuf::from_absolute_path( canonicalize(temp_dir.path()).expect("canonicalize temp dir"), ) .expect("absolute temp dir"); let effective_permissions = EffectiveSandboxPermissions::new( &SandboxPolicy::ReadOnly { access: ReadOnlyAccess::Restricted { include_platform_defaults: true, readable_roots: vec![path.clone()], }, network_access: false, }, Some(&MacOsSeatbeltProfileExtensions { macos_preferences: MacOsPreferencesPermission::ReadOnly, macos_automation: MacOsAutomationPermission::BundleIds(vec![ "com.apple.Calendar".to_string(), ]), macos_accessibility: false, macos_calendar: false, }), Some(&PermissionProfile { file_system: Some(FileSystemPermissions { read: Some(vec![path]), write: Some(Vec::new()), }), macos: Some(MacOsSeatbeltProfileExtensions { macos_preferences: MacOsPreferencesPermission::ReadWrite, macos_automation: MacOsAutomationPermission::BundleIds(vec![ "com.apple.Notes".to_string(), ]), macos_accessibility: true, macos_calendar: true, }), ..Default::default() }), ); assert_eq!( effective_permissions.macos_seatbelt_profile_extensions, Some(MacOsSeatbeltProfileExtensions { macos_preferences: MacOsPreferencesPermission::ReadWrite, macos_automation: MacOsAutomationPermission::BundleIds(vec![ "com.apple.Calendar".to_string(), "com.apple.Notes".to_string(), ]), macos_accessibility: true, macos_calendar: true, }) ); } }