[feat] add seatbelt permission files (#11639)

Add seatbelt permission extension abstraction as permission files for
seatbelt profiles. This should complement our current sandbox policy
This commit is contained in:
Celia Chen 2026-02-12 15:30:22 -08:00 committed by GitHub
parent 76256a8cec
commit dfd1e199a0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 393 additions and 6 deletions

View file

@ -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.

View file

@ -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;

View file

@ -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<String> {
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<String>,
sandbox_policy: &SandboxPolicy,
sandbox_policy_cwd: &Path,
enforce_managed_network: bool,
network: Option<&NetworkProxy>,
extensions: Option<&MacOsSeatbeltProfileExtensions>,
) -> Vec<String> {
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)
"#,
);

View file

@ -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))

View file

@ -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<String>),
}
#[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::<Vec<String>>()
.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<String> {
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, "");
}
}