diff --git a/codex-rs/config/src/loader/macos.rs b/codex-rs/config/src/loader/macos.rs index 53c8a6d20a..1dd279951c 100644 --- a/codex-rs/config/src/loader/macos.rs +++ b/codex-rs/config/src/loader/macos.rs @@ -1,3 +1,8 @@ +//! Loads administrator configuration only from forced macOS preferences. +//! +//! Ordinary user defaults must never become trusted managed configuration layers. +//! Forced values must be strings containing base64-encoded TOML. + use crate::RequirementsLayerEntry; use crate::config_requirements::RequirementSource; use crate::config_toml::ConfigToml; @@ -8,6 +13,8 @@ use crate::strict_config::config_error_from_ignored_toml_value_fields_for_source use base64::Engine; use base64::prelude::BASE64_STANDARD; use codex_utils_absolute_path::AbsolutePathBufGuard; +use core_foundation::base::Boolean; +use core_foundation::base::CFType; use core_foundation::base::TCFType; use core_foundation::string::CFString; use core_foundation::string::CFStringRef; @@ -21,6 +28,12 @@ const MANAGED_PREFERENCES_APPLICATION_ID: &str = "com.openai.codex"; const MANAGED_PREFERENCES_CONFIG_KEY: &str = "config_toml_base64"; const MANAGED_PREFERENCES_REQUIREMENTS_KEY: &str = "requirements_toml_base64"; +#[link(name = "CoreFoundation", kind = "framework")] +unsafe extern "C" { + fn CFPreferencesCopyAppValue(key: CFStringRef, application_id: CFStringRef) -> *mut c_void; + fn CFPreferencesAppValueIsForced(key: CFStringRef, application_id: CFStringRef) -> Boolean; +} + #[derive(Debug, Clone)] pub(super) struct ManagedAdminConfigLayer { pub config: TomlValue, @@ -121,27 +134,73 @@ fn load_managed_admin_requirements() -> io::Result> { } fn load_managed_preference(key_name: &str) -> io::Result> { - #[link(name = "CoreFoundation", kind = "framework")] - unsafe extern "C" { - fn CFPreferencesCopyAppValue(key: CFStringRef, application_id: CFStringRef) -> *mut c_void; + let key = CFString::new(key_name); + let application = CFString::new(MANAGED_PREFERENCES_APPLICATION_ID); + load_managed_preference_with( + key_name, + || preference_is_forced(&key, &application), + || copy_preference_value(&key, &application), + ) +} + +fn preference_is_forced(key: &CFString, application: &CFString) -> bool { + unsafe { + CFPreferencesAppValueIsForced(key.as_concrete_TypeRef(), application.as_concrete_TypeRef()) + != 0 + } +} + +fn copy_preference_value(key: &CFString, application: &CFString) -> Option { + let value_ref = unsafe { + CFPreferencesCopyAppValue(key.as_concrete_TypeRef(), application.as_concrete_TypeRef()) + }; + if value_ref.is_null() { + return None; } - let value_ref = unsafe { - CFPreferencesCopyAppValue( - CFString::new(key_name).as_concrete_TypeRef(), - CFString::new(MANAGED_PREFERENCES_APPLICATION_ID).as_concrete_TypeRef(), - ) - }; + // CopyAppValue returns an owned property-list value, not necessarily a string. + Some(unsafe { CFType::wrap_under_create_rule(value_ref) }) +} - if value_ref.is_null() { +fn load_managed_preference_with( + key_name: &str, + mut is_forced: impl FnMut() -> bool, + copy_value: impl FnOnce() -> Option, +) -> io::Result> { + // CopyAppValue also searches user-writable domains. Only forced values may + // supply administrator configuration or override lower requirements layers. + if !is_forced() { tracing::debug!( - "Managed preferences for {MANAGED_PREFERENCES_APPLICATION_ID} key {key_name} not found", + "No forced managed preference for {MANAGED_PREFERENCES_APPLICATION_ID} key {key_name}" ); return Ok(None); } - let value = unsafe { CFString::wrap_under_create_rule(value_ref as _) }.to_string(); - Ok(Some(value)) + let Some(value) = copy_value() else { + tracing::debug!( + "Managed preferences for {MANAGED_PREFERENCES_APPLICATION_ID} key {key_name} not found" + ); + return Ok(None); + }; + + // Reject a user-default fallback if the preference stopped being forced + // during the read. These separate calls do not form an atomic snapshot. + if !is_forced() { + tracing::debug!( + "Managed preference {MANAGED_PREFERENCES_APPLICATION_ID}:{key_name} is no longer forced after reading" + ); + return Ok(None); + } + + let value = value.downcast::().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!( + "Managed preference {MANAGED_PREFERENCES_APPLICATION_ID}:{key_name} must be a string" + ), + ) + })?; + Ok(Some(value.to_string())) } fn parse_managed_config_base64( @@ -228,3 +287,7 @@ fn decode_managed_preferences_base64(encoded: &str) -> io::Result { io::Error::new(io::ErrorKind::InvalidData, err) }) } + +#[cfg(test)] +#[path = "macos_tests.rs"] +mod tests; diff --git a/codex-rs/config/src/loader/macos_tests.rs b/codex-rs/config/src/loader/macos_tests.rs new file mode 100644 index 0000000000..1bd6302b0d --- /dev/null +++ b/codex-rs/config/src/loader/macos_tests.rs @@ -0,0 +1,139 @@ +//! Exercise managed preference trust checks with injected reads and in-memory values. + +use super::MANAGED_PREFERENCES_APPLICATION_ID; +use super::MANAGED_PREFERENCES_CONFIG_KEY; +use super::MANAGED_PREFERENCES_REQUIREMENTS_KEY; +use super::load_managed_preference_with; +use base64::Engine; +use base64::prelude::BASE64_STANDARD; +use core_foundation::array::CFArray; +use core_foundation::base::TCFType; +use core_foundation::boolean::CFBoolean; +use core_foundation::data::CFData; +use core_foundation::dictionary::CFDictionary; +use core_foundation::number::CFNumber; +use core_foundation::string::CFString; +use pretty_assertions::assert_eq; +use std::cell::Cell; +use std::io; + +#[test] +fn unforced_preferences_are_ignored_without_reading() { + for key in [ + MANAGED_PREFERENCES_CONFIG_KEY, + MANAGED_PREFERENCES_REQUIREMENTS_KEY, + ] { + assert_eq!( + load_managed_preference_with( + key, + || false, + || panic!("unforced preference must not be read"), + ) + .expect("ignore unforced preference"), + None + ); + } +} + +#[test] +fn forced_preferences_without_a_value_are_absent() { + for key in [ + MANAGED_PREFERENCES_CONFIG_KEY, + MANAGED_PREFERENCES_REQUIREMENTS_KEY, + ] { + assert_eq!( + load_managed_preference_with(key, || true, || None) + .expect("load absent forced preference"), + None + ); + } +} + +#[test] +fn preferences_that_become_unforced_during_read_are_ignored() { + for key in [ + MANAGED_PREFERENCES_CONFIG_KEY, + MANAGED_PREFERENCES_REQUIREMENTS_KEY, + ] { + for value in [ + CFString::new("ordinary user default").as_CFType(), + CFBoolean::true_value().as_CFType(), + ] { + let forced = Cell::new(/*value*/ true); + assert_eq!( + load_managed_preference_with( + key, + || forced.get(), + || { + forced.set(/*val*/ false); + Some(value) + }, + ) + .expect("ignore preference that became unforced during the read"), + None + ); + } + } +} + +#[test] +fn forced_string_preferences_preserve_contents() { + let encoded = BASE64_STANDARD.encode("sandbox_mode = 'read-only'"); + for key in [ + MANAGED_PREFERENCES_CONFIG_KEY, + MANAGED_PREFERENCES_REQUIREMENTS_KEY, + ] { + for contents in ["", "Préférence gérée ✓", &encoded] { + assert_eq!( + load_managed_preference_with( + key, + || true, + || Some(CFString::new(contents).as_CFType()), + ) + .expect("load string preference"), + Some(contents.to_owned()) + ); + } + } +} + +#[test] +fn forced_non_string_preferences_return_redacted_errors() { + let private_contents = CFString::new("private preference contents"); + let values = [ + ("boolean", CFBoolean::true_value().as_CFType()), + ("number", CFNumber::from(/*value*/ 42).as_CFType()), + ( + "data", + CFData::from_buffer(b"private preference contents").as_CFType(), + ), + ( + "array", + CFArray::from_CFTypes(std::slice::from_ref(&private_contents)).as_CFType(), + ), + ( + "dictionary", + CFDictionary::from_CFType_pairs(&[(CFString::new("secret"), private_contents)]) + .as_CFType(), + ), + ]; + for key in [ + MANAGED_PREFERENCES_CONFIG_KEY, + MANAGED_PREFERENCES_REQUIREMENTS_KEY, + ] { + for (type_name, value) in &values { + let error = load_managed_preference_with(key, || true, || Some(value.clone())) + .expect_err("reject non-string preference"); + assert_eq!( + (error.kind(), error.to_string()), + ( + io::ErrorKind::InvalidData, + format!( + "Managed preference {MANAGED_PREFERENCES_APPLICATION_ID}:{key} must be a string" + ), + ), + "unexpected diagnostic for {type_name} preference {key}" + ); + } + } +}