[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:
parent
76256a8cec
commit
dfd1e199a0
5 changed files with 393 additions and 6 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
"#,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
246
codex-rs/core/src/seatbelt_permissions.rs
Normal file
246
codex-rs/core/src/seatbelt_permissions.rs
Normal 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, "");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue