From 186ed88fa186701f92799dcc8b686b283f16e67f Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Tue, 5 May 2026 12:06:34 -0700 Subject: [PATCH] cli: add macOS seatbelt debug flags for Mach services and Apple events --- codex-rs/cli/src/debug_sandbox.rs | 84 +++++++++---- codex-rs/cli/src/lib.rs | 63 ++++++++++ codex-rs/sandboxing/src/manager.rs | 3 + codex-rs/sandboxing/src/seatbelt.rs | 76 ++++++++++++ codex-rs/sandboxing/src/seatbelt_tests.rs | 143 ++++++++++++++++++++++ 5 files changed, 348 insertions(+), 21 deletions(-) diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 6d59bf7d23..6909dcd2d4 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -49,6 +49,9 @@ pub async fn run_command_under_seatbelt( config_profile: _, cwd, include_managed_config, + allow_mach_services, + allow_appleevent_bundle_ids, + allow_lsopen, allow_unix_sockets, log_denials, config_overrides, @@ -58,8 +61,8 @@ pub async fn run_command_under_seatbelt( &permissions_profile, include_managed_config, ); - run_command_under_sandbox( - DebugSandboxConfigOptions { + run_command_under_sandbox(RunCommandUnderSandboxParams { + config_options: DebugSandboxConfigOptions { permissions_profile, cwd, managed_requirements_mode, @@ -68,10 +71,13 @@ pub async fn run_command_under_seatbelt( command, config_overrides, codex_linux_sandbox_exe, - SandboxType::Seatbelt, + sandbox_type: SandboxType::Seatbelt, log_denials, - &allow_unix_sockets, - ) + allow_mach_services: &allow_mach_services, + allow_appleevent_bundle_ids: &allow_appleevent_bundle_ids, + allow_lsopen, + allow_unix_sockets: &allow_unix_sockets, + }) .await } @@ -101,8 +107,8 @@ pub async fn run_command_under_landlock( &permissions_profile, include_managed_config, ); - run_command_under_sandbox( - DebugSandboxConfigOptions { + run_command_under_sandbox(RunCommandUnderSandboxParams { + config_options: DebugSandboxConfigOptions { permissions_profile, cwd, managed_requirements_mode, @@ -111,10 +117,13 @@ pub async fn run_command_under_landlock( command, config_overrides, codex_linux_sandbox_exe, - SandboxType::Landlock, - /*log_denials*/ false, - &[], - ) + sandbox_type: SandboxType::Landlock, + log_denials: false, + allow_mach_services: &[], + allow_appleevent_bundle_ids: &[], + allow_lsopen: false, + allow_unix_sockets: &[], + }) .await } @@ -135,8 +144,8 @@ pub async fn run_command_under_windows_sandbox( &permissions_profile, include_managed_config, ); - run_command_under_sandbox( - DebugSandboxConfigOptions { + run_command_under_sandbox(RunCommandUnderSandboxParams { + config_options: DebugSandboxConfigOptions { permissions_profile, cwd, managed_requirements_mode, @@ -145,10 +154,13 @@ pub async fn run_command_under_windows_sandbox( command, config_overrides, codex_linux_sandbox_exe, - SandboxType::Windows, - /*log_denials*/ false, - &[], - ) + sandbox_type: SandboxType::Windows, + log_denials: false, + allow_mach_services: &[], + allow_appleevent_bundle_ids: &[], + allow_lsopen: false, + allow_unix_sockets: &[], + }) .await } @@ -186,16 +198,43 @@ impl ManagedRequirementsMode { } } -async fn run_command_under_sandbox( +struct RunCommandUnderSandboxParams<'a> { config_options: DebugSandboxConfigOptions, command: Vec, config_overrides: CliConfigOverrides, codex_linux_sandbox_exe: Option, sandbox_type: SandboxType, log_denials: bool, - #[cfg_attr(not(target_os = "macos"), allow(unused_variables))] - allow_unix_sockets: &[AbsolutePathBuf], -) -> anyhow::Result<()> { + #[cfg_attr(not(target_os = "macos"), allow(dead_code))] + allow_mach_services: &'a [String], + #[cfg_attr(not(target_os = "macos"), allow(dead_code))] + allow_appleevent_bundle_ids: &'a [String], + #[cfg_attr(not(target_os = "macos"), allow(dead_code))] + allow_lsopen: bool, + #[cfg_attr(not(target_os = "macos"), allow(dead_code))] + allow_unix_sockets: &'a [AbsolutePathBuf], +} + +async fn run_command_under_sandbox(params: RunCommandUnderSandboxParams<'_>) -> anyhow::Result<()> { + let RunCommandUnderSandboxParams { + config_options, + command, + config_overrides, + codex_linux_sandbox_exe, + sandbox_type, + log_denials, + allow_mach_services, + allow_appleevent_bundle_ids, + allow_lsopen, + allow_unix_sockets, + } = params; + #[cfg(not(target_os = "macos"))] + let _ = ( + allow_mach_services, + allow_appleevent_bundle_ids, + allow_lsopen, + allow_unix_sockets, + ); let config = load_debug_sandbox_config( config_overrides .parse_overrides() @@ -280,6 +319,9 @@ async fn run_command_under_sandbox( sandbox_policy_cwd: sandbox_policy_cwd.as_path(), enforce_managed_network: false, network: network.as_ref(), + extra_mach_services: allow_mach_services, + extra_appleevent_bundle_ids: allow_appleevent_bundle_ids, + allow_lsopen, extra_allow_unix_sockets: allow_unix_sockets, }); spawn_debug_sandbox_child( diff --git a/codex-rs/cli/src/lib.rs b/codex-rs/cli/src/lib.rs index 5e2ba0caa5..872455559a 100644 --- a/codex-rs/cli/src/lib.rs +++ b/codex-rs/cli/src/lib.rs @@ -50,6 +50,18 @@ pub struct SeatbeltCommand { )] pub include_managed_config: bool, + /// Allow the sandboxed command to look up this Mach service name. Repeat to allow multiple services. + #[arg(long = "allow-mach-service", value_name = "SERVICE", value_parser = parse_non_empty_string)] + pub allow_mach_services: Vec, + + /// Allow the sandboxed command to send AppleEvents to this destination bundle ID. Repeat to allow multiple destinations. + #[arg(long = "allow-appleevent-destination", value_name = "BUNDLE_ID", value_parser = parse_non_empty_string)] + pub allow_appleevent_bundle_ids: Vec, + + /// Allow the sandboxed command to use LaunchServices open APIs. + #[arg(long = "allow-lsopen", default_value_t = false)] + pub allow_lsopen: bool, + /// Allow the sandboxed command to bind/connect AF_UNIX sockets rooted at this path. Relative paths are resolved against the current directory. Repeat to allow multiple paths. #[arg(long = "allow-unix-socket", value_parser = parse_allow_unix_socket_path)] pub allow_unix_sockets: Vec, @@ -71,6 +83,15 @@ fn parse_allow_unix_socket_path(raw: &str) -> Result { .map_err(|err| format!("invalid path {raw}: {err}")) } +fn parse_non_empty_string(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + Err("value must not be empty".to_string()) + } else { + Ok(trimmed.to_string()) + } +} + #[derive(Debug, Parser)] pub struct LandlockCommand { /// Named permissions profile to apply from the active configuration stack. @@ -140,3 +161,45 @@ pub struct WindowsCommand { #[arg(trailing_var_arg = true)] pub command: Vec, } + +#[cfg(test)] +mod tests { + use super::SeatbeltCommand; + use clap::Parser; + use pretty_assertions::assert_eq; + + #[test] + fn seatbelt_command_parses_additional_allowlist_flags() { + let command = SeatbeltCommand::try_parse_from([ + "seatbelt", + "--allow-mach-service", + "com.apple.foo", + "--allow-mach-service", + "com.apple.bar", + "--allow-appleevent-destination", + "com.apple.finder", + "--allow-lsopen", + "--allow-unix-socket", + "/tmp/codex.sock", + "--", + "/bin/echo", + "hi", + ]) + .expect("parse"); + + assert_eq!( + command.allow_mach_services, + vec!["com.apple.foo".to_string(), "com.apple.bar".to_string()] + ); + assert_eq!( + command.allow_appleevent_bundle_ids, + vec!["com.apple.finder".to_string()] + ); + assert!(command.allow_lsopen); + assert_eq!(command.allow_unix_sockets.len(), 1); + assert_eq!( + command.command, + vec!["/bin/echo".to_string(), "hi".to_string()] + ); + } +} diff --git a/codex-rs/sandboxing/src/manager.rs b/codex-rs/sandboxing/src/manager.rs index 39ef358f8c..c1eaeb097f 100644 --- a/codex-rs/sandboxing/src/manager.rs +++ b/codex-rs/sandboxing/src/manager.rs @@ -233,6 +233,9 @@ impl SandboxManager { sandbox_policy_cwd, enforce_managed_network, network, + extra_mach_services: &[], + extra_appleevent_bundle_ids: &[], + allow_lsopen: false, extra_allow_unix_sockets: &[], }); let mut full_command = Vec::with_capacity(1 + args.len()); diff --git a/codex-rs/sandboxing/src/seatbelt.rs b/codex-rs/sandboxing/src/seatbelt.rs index 7057ec46b9..7e40733365 100644 --- a/codex-rs/sandboxing/src/seatbelt.rs +++ b/codex-rs/sandboxing/src/seatbelt.rs @@ -241,6 +241,61 @@ fn unix_socket_policy(proxy: &ProxyPolicyInputs) -> String { policy } +fn seatbelt_string_literal(value: &str) -> String { + value.replace('\\', "\\\\").replace('"', "\\\"") +} + +fn extra_mach_lookup_policy(extra_mach_services: &[String]) -> String { + let services = extra_mach_services + .iter() + .map(|service| service.trim()) + .filter(|service| !service.is_empty()) + .collect::>(); + if services.is_empty() { + return String::new(); + } + + let services = services + .into_iter() + .map(|service| format!(" (global-name \"{}\")", seatbelt_string_literal(service))) + .collect::>() + .join("\n"); + format!("(allow mach-lookup\n{services}\n)") +} + +fn extra_appleevent_policy(extra_appleevent_bundle_ids: &[String]) -> String { + let bundle_ids = extra_appleevent_bundle_ids + .iter() + .map(|bundle_id| bundle_id.trim()) + .filter(|bundle_id| !bundle_id.is_empty()) + .collect::>(); + if bundle_ids.is_empty() { + return String::new(); + } + + let destinations = bundle_ids + .into_iter() + .map(|bundle_id| { + format!( + " (appleevent-destination \"{}\")", + seatbelt_string_literal(bundle_id) + ) + }) + .collect::>() + .join("\n"); + format!( + "(allow mach-lookup\n (global-name \"com.apple.coreservices.appleevents\"))\n(allow appleevent-send\n{destinations}\n)" + ) +} + +fn lsopen_policy(allow_lsopen: bool) -> String { + if allow_lsopen { + "(allow lsopen)".to_string() + } else { + String::new() + } +} + #[cfg_attr(not(test), allow(dead_code))] fn dynamic_network_policy( sandbox_policy: &SandboxPolicy, @@ -584,6 +639,9 @@ fn create_seatbelt_command_args_for_legacy_policy( sandbox_policy_cwd, enforce_managed_network, network, + extra_mach_services: &[], + extra_appleevent_bundle_ids: &[], + allow_lsopen: false, extra_allow_unix_sockets: &[], }) } @@ -596,6 +654,9 @@ pub struct CreateSeatbeltCommandArgsParams<'a> { pub sandbox_policy_cwd: &'a Path, pub enforce_managed_network: bool, pub network: Option<&'a NetworkProxy>, + pub extra_mach_services: &'a [String], + pub extra_appleevent_bundle_ids: &'a [String], + pub allow_lsopen: bool, pub extra_allow_unix_sockets: &'a [AbsolutePathBuf], } @@ -607,6 +668,9 @@ pub fn create_seatbelt_command_args(args: CreateSeatbeltCommandArgsParams<'_>) - sandbox_policy_cwd, enforce_managed_network, network, + extra_mach_services, + extra_appleevent_bundle_ids, + allow_lsopen, extra_allow_unix_sockets, } = args; @@ -704,6 +768,9 @@ pub fn create_seatbelt_command_args(args: CreateSeatbeltCommandArgsParams<'_>) - 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 mach_lookup_policy = extra_mach_lookup_policy(extra_mach_services); + let appleevent_policy = extra_appleevent_policy(extra_appleevent_bundle_ids); + let lsopen_policy = lsopen_policy(allow_lsopen); let include_platform_defaults = file_system_sandbox_policy.include_platform_defaults(); let deny_read_policy = @@ -715,6 +782,15 @@ pub fn create_seatbelt_command_args(args: CreateSeatbeltCommandArgsParams<'_>) - deny_read_policy, network_policy, ]; + if !mach_lookup_policy.is_empty() { + policy_sections.push(mach_lookup_policy); + } + if !appleevent_policy.is_empty() { + policy_sections.push(appleevent_policy); + } + if !lsopen_policy.is_empty() { + policy_sections.push(lsopen_policy); + } if include_platform_defaults { policy_sections.push(MACOS_RESTRICTED_READ_ONLY_PLATFORM_DEFAULTS.to_string()); } diff --git a/codex-rs/sandboxing/src/seatbelt_tests.rs b/codex-rs/sandboxing/src/seatbelt_tests.rs index d905d0adcf..bc54895710 100644 --- a/codex-rs/sandboxing/src/seatbelt_tests.rs +++ b/codex-rs/sandboxing/src/seatbelt_tests.rs @@ -206,6 +206,9 @@ fn explicit_unreadable_paths_are_excluded_from_full_disk_read_and_write_access() sandbox_policy_cwd: Path::new("/"), enforce_managed_network: false, network: None, + extra_mach_services: &[], + extra_appleevent_bundle_ids: &[], + allow_lsopen: false, extra_allow_unix_sockets: &[], }); @@ -278,6 +281,9 @@ fn explicit_unreadable_paths_are_excluded_from_readable_roots() { sandbox_policy_cwd: Path::new("/"), enforce_managed_network: false, network: None, + extra_mach_services: &[], + extra_appleevent_bundle_ids: &[], + allow_lsopen: false, extra_allow_unix_sockets: &[], }); @@ -581,6 +587,9 @@ fn create_seatbelt_args_allowlists_explicit_unix_socket_paths_without_proxy() { sandbox_policy_cwd: cwd.path(), enforce_managed_network: false, network: None, + extra_mach_services: &[], + extra_appleevent_bundle_ids: &[], + allow_lsopen: false, extra_allow_unix_sockets: &extra_allow_unix_sockets, }); let policy = seatbelt_policy_arg(&args); @@ -639,6 +648,9 @@ async fn create_seatbelt_args_merges_proxy_and_explicit_unix_socket_paths() -> a sandbox_policy_cwd: cwd.path(), enforce_managed_network: false, network: Some(&network_proxy), + extra_mach_services: &[], + extra_appleevent_bundle_ids: &[], + allow_lsopen: false, extra_allow_unix_sockets: &extra_allow_unix_sockets, }); @@ -680,6 +692,9 @@ fn create_seatbelt_args_preserves_full_network_with_explicit_unix_socket_paths() sandbox_policy_cwd: cwd.path(), enforce_managed_network: false, network: None, + extra_mach_services: &[], + extra_appleevent_bundle_ids: &[], + allow_lsopen: false, extra_allow_unix_sockets: &extra_allow_unix_sockets, }); let policy = seatbelt_policy_arg(&args); @@ -723,6 +738,134 @@ fn unix_socket_policy_non_empty_output_is_newline_terminated() { ); } +#[test] +fn create_seatbelt_args_allowlists_extra_mach_services() { + let cwd = TempDir::new().expect("temp cwd"); + let file_system_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( + &SandboxPolicy::new_read_only_policy(), + cwd.path(), + ); + let extra_mach_services = vec![ + "com.apple.beta".to_string(), + "com.apple.alpha".to_string(), + "com.apple.beta".to_string(), + ]; + let args = create_seatbelt_command_args(CreateSeatbeltCommandArgsParams { + command: vec!["/usr/bin/true".to_string()], + file_system_sandbox_policy: &file_system_policy, + network_sandbox_policy: NetworkSandboxPolicy::Restricted, + sandbox_policy_cwd: cwd.path(), + enforce_managed_network: false, + network: None, + extra_mach_services: &extra_mach_services, + extra_appleevent_bundle_ids: &[], + allow_lsopen: false, + extra_allow_unix_sockets: &[], + }); + let policy = seatbelt_policy_arg(&args); + + assert!( + policy.contains( + "(allow mach-lookup\n (global-name \"com.apple.alpha\")\n (global-name \"com.apple.beta\")\n)" + ), + "policy should allow the requested Mach services in stable order:\n{policy}" + ); + assert_eq!( + policy.matches("(global-name \"com.apple.alpha\")").count(), + 1 + ); + assert_eq!( + policy.matches("(global-name \"com.apple.beta\")").count(), + 1 + ); +} + +#[test] +fn create_seatbelt_args_allowlists_appleevent_destinations() { + let cwd = TempDir::new().expect("temp cwd"); + let file_system_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( + &SandboxPolicy::new_read_only_policy(), + cwd.path(), + ); + let extra_appleevent_bundle_ids = vec![ + "com.apple.mail".to_string(), + "com.apple.finder".to_string(), + "com.apple.mail".to_string(), + ]; + let args = create_seatbelt_command_args(CreateSeatbeltCommandArgsParams { + command: vec!["/usr/bin/true".to_string()], + file_system_sandbox_policy: &file_system_policy, + network_sandbox_policy: NetworkSandboxPolicy::Restricted, + sandbox_policy_cwd: cwd.path(), + enforce_managed_network: false, + network: None, + extra_mach_services: &[], + extra_appleevent_bundle_ids: &extra_appleevent_bundle_ids, + allow_lsopen: false, + extra_allow_unix_sockets: &[], + }); + let policy = seatbelt_policy_arg(&args); + + assert!( + policy.contains( + "(allow mach-lookup\n (global-name \"com.apple.coreservices.appleevents\"))" + ), + "policy should allow lookup of the AppleEvents service:\n{policy}" + ); + assert!( + policy.contains( + "(allow appleevent-send\n (appleevent-destination \"com.apple.finder\")\n (appleevent-destination \"com.apple.mail\")\n)" + ), + "policy should allow the requested AppleEvent destinations in stable order:\n{policy}" + ); + assert_eq!( + policy + .matches("(global-name \"com.apple.coreservices.appleevents\")") + .count(), + 1 + ); + assert_eq!( + policy + .matches("(appleevent-destination \"com.apple.finder\")") + .count(), + 1 + ); + assert_eq!( + policy + .matches("(appleevent-destination \"com.apple.mail\")") + .count(), + 1 + ); +} + +#[test] +fn create_seatbelt_args_allows_lsopen_when_requested() { + let cwd = TempDir::new().expect("temp cwd"); + let file_system_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( + &SandboxPolicy::new_read_only_policy(), + cwd.path(), + ); + let args = create_seatbelt_command_args(CreateSeatbeltCommandArgsParams { + command: vec!["/usr/bin/true".to_string()], + file_system_sandbox_policy: &file_system_policy, + network_sandbox_policy: NetworkSandboxPolicy::Restricted, + sandbox_policy_cwd: cwd.path(), + enforce_managed_network: false, + network: None, + extra_mach_services: &[], + extra_appleevent_bundle_ids: &[], + allow_lsopen: true, + extra_allow_unix_sockets: &[], + }); + let policy = seatbelt_policy_arg(&args); + + assert!( + policy.contains("(allow lsopen)"), + "policy should allow lsopen when requested:\n{policy}" + ); + assert_eq!(policy.matches("(allow lsopen)").count(), 1); +} + #[test] fn unix_socket_dir_params_use_stable_param_names() { let params = unix_socket_dir_params(&ProxyPolicyInputs {