diff --git a/codex-rs/core/README.md b/codex-rs/core/README.md index 9974a591cf8..8a66b47b48c 100644 --- a/codex-rs/core/README.md +++ b/codex-rs/core/README.md @@ -14,6 +14,30 @@ When using the workspace-write sandbox policy, the Seatbelt profile allows writes under the configured writable roots while keeping `.git` (directory or pointer file), the resolved `gitdir:` target, and `.codex` read-only. +Network access and filesystem read/write roots are controlled by +`SandboxPolicy`. Seatbelt consumes the resolved policy and enforces it. + +Seatbelt also supports macOS permission-profile extensions layered on top of +`SandboxPolicy`: + +- no extension profile provided: + keeps legacy default preferences read access (`user-preference-read`). +- extension profile provided with no `macos_preferences` grant: + does not add preferences access clauses. +- `macos_preferences = "readonly"`: + enables cfprefs read clauses and `user-preference-read`. +- `macos_preferences = "readwrite"`: + includes readonly clauses plus `user-preference-write` and cfprefs shm write + clauses. +- `macos_automation = true`: + enables broad Apple Events send permissions. +- `macos_automation = ["com.apple.Notes", ...]`: + enables Apple Events send only to listed bundle IDs. +- `macos_accessibility = true`: + enables `com.apple.axserver` mach lookup. +- `macos_calendar = true`: + enables `com.apple.CalendarAgent` mach lookup. + ### Linux Expects the binary containing `codex-core` to run the equivalent of `codex sandbox linux` (legacy alias: `codex debug landlock`) when `arg0` is `codex-linux-sandbox`. See the `codex-arg0` crate for details. diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 764fc4fbba9..e3888ecf867 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -77,6 +77,7 @@ pub use model_provider_info::create_oss_provider_with_base_url; mod event_mapping; 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/seatbelt.rs b/codex-rs/core/src/seatbelt.rs index fc425f2184c..29c05e0183d 100644 --- a/codex-rs/core/src/seatbelt.rs +++ b/codex-rs/core/src/seatbelt.rs @@ -14,6 +14,9 @@ use tokio::process::Child; use url::Url; use crate::protocol::SandboxPolicy; +use crate::seatbelt_permissions::MacOsPreferencesPermission; +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; @@ -179,6 +182,24 @@ pub(crate) fn create_seatbelt_command_args( 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, + None, + ) +} + +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 { let (file_write_policy, file_write_dir_params) = { if sandbox_policy.has_full_disk_write_access() { @@ -275,15 +296,33 @@ pub(crate) fn create_seatbelt_command_args( let proxy = proxy_policy_inputs(network); let network_policy = dynamic_network_policy(sandbox_policy, enforce_managed_network, &proxy); - - let full_policy = format!( - "{MACOS_SEATBELT_BASE_POLICY}\n{file_read_policy}\n{file_write_policy}\n{network_policy}" + let seatbelt_extensions = extensions.map_or_else( + || { + // Backward-compatibility default when no extension profile is provided. + build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions { + macos_preferences: MacOsPreferencesPermission::ReadOnly, + ..Default::default() + }) + }, + build_seatbelt_extensions, ); + let full_policy = if seatbelt_extensions.policy.is_empty() { + format!( + "{MACOS_SEATBELT_BASE_POLICY}\n{file_read_policy}\n{file_write_policy}\n{network_policy}" + ) + } else { + format!( + "{MACOS_SEATBELT_BASE_POLICY}\n{file_read_policy}\n{file_write_policy}\n{network_policy}\n{}", + seatbelt_extensions.policy + ) + }; + let dir_params = [ file_read_dir_params, file_write_dir_params, macos_dir_params(), + seatbelt_extensions.dir_params, ] .concat(); @@ -328,10 +367,14 @@ mod tests { use super::MACOS_SEATBELT_BASE_POLICY; use super::ProxyPolicyInputs; use super::create_seatbelt_command_args; + use super::create_seatbelt_command_args_with_extensions; use super::dynamic_network_policy; use super::macos_dir_params; use crate::protocol::SandboxPolicy; use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE; + use crate::seatbelt_permissions::MacOsAutomationPermission; + use crate::seatbelt_permissions::MacOsPreferencesPermission; + use crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions; use pretty_assertions::assert_eq; use std::fs; use std::path::Path; @@ -395,6 +438,66 @@ mod tests { ); } + #[test] + fn seatbelt_args_include_macos_permission_extensions() { + let cwd = std::env::temp_dir(); + let args = create_seatbelt_command_args_with_extensions( + vec!["echo".to_string(), "ok".to_string()], + &SandboxPolicy::new_read_only_policy(), + cwd.as_path(), + false, + None, + Some(&MacOsSeatbeltProfileExtensions { + macos_preferences: MacOsPreferencesPermission::ReadWrite, + macos_automation: MacOsAutomationPermission::BundleIds(vec![ + "com.apple.Notes".to_string(), + ]), + macos_accessibility: true, + macos_calendar: true, + }), + ); + let policy = &args[1]; + + assert!(policy.contains("(allow user-preference-write)")); + assert!(policy.contains("(appleevent-destination \"com.apple.Notes\")")); + assert!(policy.contains("com.apple.axserver")); + assert!(policy.contains("com.apple.CalendarAgent")); + } + + #[test] + fn seatbelt_args_without_extension_profile_keep_legacy_preferences_read_access() { + let cwd = std::env::temp_dir(); + let args = create_seatbelt_command_args( + vec!["echo".to_string(), "ok".to_string()], + &SandboxPolicy::new_read_only_policy(), + cwd.as_path(), + false, + None, + ); + let policy = &args[1]; + assert!(policy.contains("(allow user-preference-read)")); + assert!(!policy.contains("(allow user-preference-write)")); + } + + #[test] + fn seatbelt_args_omit_macos_extensions_when_profile_is_empty() { + let cwd = std::env::temp_dir(); + let args = create_seatbelt_command_args_with_extensions( + vec!["echo".to_string(), "ok".to_string()], + &SandboxPolicy::new_read_only_policy(), + cwd.as_path(), + false, + None, + Some(&MacOsSeatbeltProfileExtensions::default()), + ); + let policy = &args[1]; + assert!(!policy.contains("appleevent-send")); + assert!(!policy.contains("com.apple.axserver")); + assert!(!policy.contains("com.apple.CalendarAgent")); + assert!(!policy.contains("user-preference-read")); + assert!(!policy.contains("user-preference-write")); + } + #[test] fn create_seatbelt_args_allows_local_binding_when_explicitly_enabled() { let policy = dynamic_network_policy( @@ -564,6 +667,14 @@ mod tests { (allow file-write* (require-all (subpath (param "WRITABLE_ROOT_0")) (require-not (subpath (param "WRITABLE_ROOT_0_RO_0"))) (require-not (subpath (param "WRITABLE_ROOT_0_RO_1"))) ) (subpath (param "WRITABLE_ROOT_1")) (subpath (param "WRITABLE_ROOT_2")) ) + +; macOS permission profile extensions +(allow ipc-posix-shm-read* (ipc-posix-name-prefix "apple.cfprefs.")) +(allow mach-lookup + (global-name "com.apple.cfprefsd.daemon") + (global-name "com.apple.cfprefsd.agent") + (local-name "com.apple.cfprefsd.agent")) +(allow user-preference-read) "#, ); @@ -851,6 +962,14 @@ mod tests { (allow file-write* (require-all (subpath (param "WRITABLE_ROOT_0")) (require-not (subpath (param "WRITABLE_ROOT_0_RO_0"))) (require-not (subpath (param "WRITABLE_ROOT_0_RO_1"))) ) (subpath (param "WRITABLE_ROOT_1")){tempdir_policy_entry} ) + +; macOS permission profile extensions +(allow ipc-posix-shm-read* (ipc-posix-name-prefix "apple.cfprefs.")) +(allow mach-lookup + (global-name "com.apple.cfprefsd.daemon") + (global-name "com.apple.cfprefsd.agent") + (local-name "com.apple.cfprefsd.agent")) +(allow user-preference-read) "#, ); diff --git a/codex-rs/core/src/seatbelt_base_policy.sbpl b/codex-rs/core/src/seatbelt_base_policy.sbpl index 00676a86f4d..72b13c96cd7 100644 --- a/codex-rs/core/src/seatbelt_base_policy.sbpl +++ b/codex-rs/core/src/seatbelt_base_policy.sbpl @@ -12,9 +12,6 @@ (allow process-fork) (allow signal (target same-sandbox)) -; Allow cf prefs to work. -(allow user-preference-read) - ; process-info (allow process-info* (target same-sandbox)) diff --git a/codex-rs/core/src/seatbelt_permissions.rs b/codex-rs/core/src/seatbelt_permissions.rs new file mode 100644 index 00000000000..29313d7a3c9 --- /dev/null +++ b/codex-rs/core/src/seatbelt_permissions.rs @@ -0,0 +1,246 @@ +#![cfg(target_os = "macos")] + +use std::collections::BTreeSet; +use std::path::PathBuf; + +#[allow(dead_code)] +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum MacOsPreferencesPermission { + #[default] + None, + ReadOnly, + ReadWrite, +} + +#[allow(dead_code)] +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum MacOsAutomationPermission { + #[default] + None, + All, + BundleIds(Vec), +} + +#[allow(dead_code)] +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct MacOsSeatbeltProfileExtensions { + pub macos_preferences: MacOsPreferencesPermission, + pub macos_automation: MacOsAutomationPermission, + pub macos_accessibility: bool, + pub macos_calendar: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub(crate) struct SeatbeltExtensionPolicy { + pub(crate) policy: String, + pub(crate) dir_params: Vec<(String, PathBuf)>, +} + +impl MacOsSeatbeltProfileExtensions { + pub fn normalized(&self) -> Self { + let mut normalized = self.clone(); + if let MacOsAutomationPermission::BundleIds(bundle_ids) = &self.macos_automation { + let bundle_ids = normalize_bundle_ids(bundle_ids); + normalized.macos_automation = if bundle_ids.is_empty() { + MacOsAutomationPermission::None + } else { + MacOsAutomationPermission::BundleIds(bundle_ids) + }; + } + normalized + } +} + +pub(crate) fn build_seatbelt_extensions( + extensions: &MacOsSeatbeltProfileExtensions, +) -> SeatbeltExtensionPolicy { + let extensions = extensions.normalized(); + let mut clauses = Vec::new(); + + match extensions.macos_preferences { + MacOsPreferencesPermission::None => {} + MacOsPreferencesPermission::ReadOnly => { + clauses.push( + "(allow ipc-posix-shm-read* (ipc-posix-name-prefix \"apple.cfprefs.\"))" + .to_string(), + ); + clauses.push( + "(allow mach-lookup\n (global-name \"com.apple.cfprefsd.daemon\")\n (global-name \"com.apple.cfprefsd.agent\")\n (local-name \"com.apple.cfprefsd.agent\"))" + .to_string(), + ); + clauses.push("(allow user-preference-read)".to_string()); + } + MacOsPreferencesPermission::ReadWrite => { + clauses.push( + "(allow ipc-posix-shm-read* (ipc-posix-name-prefix \"apple.cfprefs.\"))" + .to_string(), + ); + clauses.push( + "(allow mach-lookup\n (global-name \"com.apple.cfprefsd.daemon\")\n (global-name \"com.apple.cfprefsd.agent\")\n (local-name \"com.apple.cfprefsd.agent\"))" + .to_string(), + ); + clauses.push("(allow user-preference-read)".to_string()); + clauses.push("(allow user-preference-write)".to_string()); + clauses.push( + "(allow ipc-posix-shm-write-data (ipc-posix-name-prefix \"apple.cfprefs.\"))" + .to_string(), + ); + clauses.push( + "(allow ipc-posix-shm-write-create (ipc-posix-name-prefix \"apple.cfprefs.\"))" + .to_string(), + ); + } + } + + match extensions.macos_automation { + MacOsAutomationPermission::None => {} + MacOsAutomationPermission::All => { + clauses.push( + "(allow mach-lookup\n (global-name \"com.apple.coreservices.launchservicesd\")\n (global-name \"com.apple.coreservices.appleevents\"))" + .to_string(), + ); + clauses.push("(allow appleevent-send)".to_string()); + } + MacOsAutomationPermission::BundleIds(bundle_ids) => { + if !bundle_ids.is_empty() { + clauses.push( + "(allow mach-lookup (global-name \"com.apple.coreservices.appleevents\"))" + .to_string(), + ); + let destinations = bundle_ids + .iter() + .map(|bundle_id| format!(" (appleevent-destination \"{bundle_id}\")")) + .collect::>() + .join("\n"); + clauses.push(format!("(allow appleevent-send\n{destinations}\n)")); + } + } + } + + if extensions.macos_accessibility { + clauses.push("(allow mach-lookup (local-name \"com.apple.axserver\"))".to_string()); + } + + if extensions.macos_calendar { + clauses.push("(allow mach-lookup (global-name \"com.apple.CalendarAgent\"))".to_string()); + } + + if clauses.is_empty() { + SeatbeltExtensionPolicy::default() + } else { + SeatbeltExtensionPolicy { + policy: format!( + "; macOS permission profile extensions\n{}\n", + clauses.join("\n") + ), + dir_params: Vec::new(), + } + } +} + +fn normalize_bundle_ids(bundle_ids: &[String]) -> Vec { + let mut unique = BTreeSet::new(); + for bundle_id in bundle_ids { + let candidate = bundle_id.trim(); + if is_valid_bundle_id(candidate) { + unique.insert(candidate.to_string()); + } + } + unique.into_iter().collect() +} + +fn is_valid_bundle_id(bundle_id: &str) -> bool { + if bundle_id.len() < 3 || !bundle_id.contains('.') { + return false; + } + bundle_id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_') +} + +#[cfg(test)] +mod tests { + use super::MacOsAutomationPermission; + use super::MacOsPreferencesPermission; + use super::MacOsSeatbeltProfileExtensions; + use super::build_seatbelt_extensions; + use pretty_assertions::assert_eq; + + #[test] + fn preferences_read_only_emits_read_clauses_only() { + let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions { + macos_preferences: MacOsPreferencesPermission::ReadOnly, + ..Default::default() + }); + assert!(policy.policy.contains("(allow user-preference-read)")); + assert!(!policy.policy.contains("(allow user-preference-write)")); + } + + #[test] + fn preferences_read_write_emits_write_clauses() { + let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions { + macos_preferences: MacOsPreferencesPermission::ReadWrite, + ..Default::default() + }); + assert!(policy.policy.contains("(allow user-preference-read)")); + assert!(policy.policy.contains("(allow user-preference-write)")); + assert!(policy.policy.contains( + "(allow ipc-posix-shm-write-create (ipc-posix-name-prefix \"apple.cfprefs.\"))" + )); + } + + #[test] + fn automation_all_emits_unscoped_appleevents() { + let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions { + macos_automation: MacOsAutomationPermission::All, + ..Default::default() + }); + assert!(policy.policy.contains("(allow appleevent-send)")); + assert!( + policy + .policy + .contains("com.apple.coreservices.launchservicesd") + ); + } + + #[test] + fn automation_bundle_ids_are_normalized_and_scoped() { + let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions { + macos_automation: MacOsAutomationPermission::BundleIds(vec![ + " com.apple.Notes ".to_string(), + "com.apple.Calendar".to_string(), + "bad bundle".to_string(), + "com.apple.Notes".to_string(), + ]), + ..Default::default() + }); + assert!( + policy + .policy + .contains("(appleevent-destination \"com.apple.Calendar\")") + ); + assert!( + policy + .policy + .contains("(appleevent-destination \"com.apple.Notes\")") + ); + assert!(!policy.policy.contains("bad bundle")); + } + + #[test] + fn accessibility_and_calendar_emit_mach_lookups() { + let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions { + macos_accessibility: true, + macos_calendar: true, + ..Default::default() + }); + assert!(policy.policy.contains("com.apple.axserver")); + assert!(policy.policy.contains("com.apple.CalendarAgent")); + } + + #[test] + fn empty_extensions_emit_empty_policy() { + let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions::default()); + assert_eq!(policy.policy, ""); + } +}