#![cfg(target_os = "macos")] use std::collections::HashMap; use std::ffi::CStr; use std::path::Path; use std::path::PathBuf; use tokio::process::Child; use crate::protocol::SandboxPolicy; use crate::spawn::CODEX_SANDBOX_ENV_VAR; use crate::spawn::StdioPolicy; use crate::spawn::spawn_child_async; const MACOS_SEATBELT_BASE_POLICY: &str = include_str!("seatbelt_base_policy.sbpl"); const MACOS_SEATBELT_NETWORK_POLICY: &str = include_str!("seatbelt_network_policy.sbpl"); /// When working with `sandbox-exec`, only consider `sandbox-exec` in `/usr/bin` /// to defend against an attacker trying to inject a malicious version on the /// PATH. If /usr/bin/sandbox-exec has been tampered with, then the attacker /// already has root access. pub(crate) const MACOS_PATH_TO_SEATBELT_EXECUTABLE: &str = "/usr/bin/sandbox-exec"; pub async fn spawn_command_under_seatbelt( command: Vec, command_cwd: PathBuf, sandbox_policy: &SandboxPolicy, sandbox_policy_cwd: &Path, stdio_policy: StdioPolicy, mut env: HashMap, ) -> std::io::Result { let args = create_seatbelt_command_args(command, sandbox_policy, sandbox_policy_cwd); let arg0 = None; env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string()); spawn_child_async( PathBuf::from(MACOS_PATH_TO_SEATBELT_EXECUTABLE), args, arg0, command_cwd, sandbox_policy, stdio_policy, env, ) .await } pub(crate) fn create_seatbelt_command_args( command: Vec, sandbox_policy: &SandboxPolicy, sandbox_policy_cwd: &Path, ) -> Vec { let (file_write_policy, file_write_dir_params) = { if sandbox_policy.has_full_disk_write_access() { // Allegedly, this is more permissive than `(allow file-write*)`. ( r#"(allow file-write* (regex #"^/"))"#.to_string(), Vec::new(), ) } else { let writable_roots = sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd); let mut writable_folder_policies: Vec = Vec::new(); let mut file_write_params = Vec::new(); for (index, wr) in writable_roots.iter().enumerate() { // Canonicalize to avoid mismatches like /var vs /private/var on macOS. let canonical_root = wr.root.canonicalize().unwrap_or_else(|_| wr.root.clone()); let root_param = format!("WRITABLE_ROOT_{index}"); file_write_params.push((root_param.clone(), canonical_root)); if wr.read_only_subpaths.is_empty() { writable_folder_policies.push(format!("(subpath (param \"{root_param}\"))")); } else { // Add parameters for each read-only subpath and generate // the `(require-not ...)` clauses. let mut require_parts: Vec = Vec::new(); require_parts.push(format!("(subpath (param \"{root_param}\"))")); for (subpath_index, ro) in wr.read_only_subpaths.iter().enumerate() { let canonical_ro = ro.canonicalize().unwrap_or_else(|_| ro.clone()); let ro_param = format!("WRITABLE_ROOT_{index}_RO_{subpath_index}"); require_parts .push(format!("(require-not (subpath (param \"{ro_param}\")))")); file_write_params.push((ro_param, canonical_ro)); } let policy_component = format!("(require-all {} )", require_parts.join(" ")); writable_folder_policies.push(policy_component); } } if writable_folder_policies.is_empty() { ("".to_string(), Vec::new()) } else { let file_write_policy = format!( "(allow file-write*\n{}\n)", writable_folder_policies.join(" ") ); (file_write_policy, file_write_params) } } }; let file_read_policy = if sandbox_policy.has_full_disk_read_access() { "; allow read-only file operations\n(allow file-read*)" } else { "" }; // TODO(mbolin): apply_patch calls must also honor the SandboxPolicy. let network_policy = if sandbox_policy.has_full_network_access() { MACOS_SEATBELT_NETWORK_POLICY } else { "" }; let full_policy = format!( "{MACOS_SEATBELT_BASE_POLICY}\n{file_read_policy}\n{file_write_policy}\n{network_policy}" ); let dir_params = [file_write_dir_params, macos_dir_params()].concat(); let mut seatbelt_args: Vec = vec!["-p".to_string(), full_policy]; let definition_args = dir_params .into_iter() .map(|(key, value)| format!("-D{key}={value}", value = value.to_string_lossy())); seatbelt_args.extend(definition_args); seatbelt_args.push("--".to_string()); seatbelt_args.extend(command); seatbelt_args } /// Wraps libc::confstr to return a String. fn confstr(name: libc::c_int) -> Option { let mut buf = vec![0_i8; (libc::PATH_MAX as usize) + 1]; let len = unsafe { libc::confstr(name, buf.as_mut_ptr(), buf.len()) }; if len == 0 { return None; } // confstr guarantees NUL-termination when len > 0. let cstr = unsafe { CStr::from_ptr(buf.as_ptr()) }; cstr.to_str().ok().map(ToString::to_string) } /// Wraps confstr to return a canonicalized PathBuf. fn confstr_path(name: libc::c_int) -> Option { let s = confstr(name)?; let path = PathBuf::from(s); path.canonicalize().ok().or(Some(path)) } fn macos_dir_params() -> Vec<(String, PathBuf)> { if let Some(p) = confstr_path(libc::_CS_DARWIN_USER_CACHE_DIR) { return vec![("DARWIN_USER_CACHE_DIR".to_string(), p)]; } vec![] } #[cfg(test)] mod tests { use super::MACOS_SEATBELT_BASE_POLICY; use super::create_seatbelt_command_args; use super::macos_dir_params; use crate::protocol::SandboxPolicy; use pretty_assertions::assert_eq; use std::fs; use std::path::Path; use std::path::PathBuf; use tempfile::TempDir; #[test] fn create_seatbelt_args_with_read_only_git_subpath() { // Create a temporary workspace with two writable roots: one containing // a top-level .git directory and one without it. let tmp = TempDir::new().expect("tempdir"); let PopulatedTmp { root_with_git, root_without_git, root_with_git_canon, root_with_git_git_canon, root_without_git_canon, } = populate_tmpdir(tmp.path()); let cwd = tmp.path().join("cwd"); // Build a policy that only includes the two test roots as writable and // does not automatically include defaults TMPDIR or /tmp. let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![root_with_git, root_without_git], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, }; let args = create_seatbelt_command_args( vec!["/bin/echo".to_string(), "hello".to_string()], &policy, &cwd, ); // Build the expected policy text using a raw string for readability. // Note that the policy includes: // - the base policy, // - read-only access to the filesystem, // - write access to WRITABLE_ROOT_0 (but not its .git) and WRITABLE_ROOT_1. let expected_policy = format!( r#"{MACOS_SEATBELT_BASE_POLICY} ; allow read-only file operations (allow file-read*) (allow file-write* (require-all (subpath (param "WRITABLE_ROOT_0")) (require-not (subpath (param "WRITABLE_ROOT_0_RO_0"))) ) (subpath (param "WRITABLE_ROOT_1")) (subpath (param "WRITABLE_ROOT_2")) ) "#, ); let mut expected_args = vec![ "-p".to_string(), expected_policy, format!( "-DWRITABLE_ROOT_0={}", root_with_git_canon.to_string_lossy() ), format!( "-DWRITABLE_ROOT_0_RO_0={}", root_with_git_git_canon.to_string_lossy() ), format!( "-DWRITABLE_ROOT_1={}", root_without_git_canon.to_string_lossy() ), format!("-DWRITABLE_ROOT_2={}", cwd.to_string_lossy()), ]; expected_args.extend( macos_dir_params() .into_iter() .map(|(key, value)| format!("-D{key}={value}", value = value.to_string_lossy())), ); expected_args.extend(vec![ "--".to_string(), "/bin/echo".to_string(), "hello".to_string(), ]); assert_eq!(expected_args, args); } #[test] fn create_seatbelt_args_for_cwd_as_git_repo() { // Create a temporary workspace with two writable roots: one containing // a top-level .git directory and one without it. let tmp = TempDir::new().expect("tempdir"); let PopulatedTmp { root_with_git, root_with_git_canon, root_with_git_git_canon, .. } = populate_tmpdir(tmp.path()); // Build a policy that does not specify any writable_roots, but does // use the default ones (cwd and TMPDIR) and verifies the `.git` check // is done properly for cwd. let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, }; let args = create_seatbelt_command_args( vec!["/bin/echo".to_string(), "hello".to_string()], &policy, root_with_git.as_path(), ); let tmpdir_env_var = std::env::var("TMPDIR") .ok() .map(PathBuf::from) .and_then(|p| p.canonicalize().ok()) .map(|p| p.to_string_lossy().to_string()); let tempdir_policy_entry = if tmpdir_env_var.is_some() { r#" (subpath (param "WRITABLE_ROOT_2"))"# } else { "" }; // Build the expected policy text using a raw string for readability. // Note that the policy includes: // - the base policy, // - read-only access to the filesystem, // - write access to WRITABLE_ROOT_0 (but not its .git) and WRITABLE_ROOT_1. let expected_policy = format!( r#"{MACOS_SEATBELT_BASE_POLICY} ; allow read-only file operations (allow file-read*) (allow file-write* (require-all (subpath (param "WRITABLE_ROOT_0")) (require-not (subpath (param "WRITABLE_ROOT_0_RO_0"))) ) (subpath (param "WRITABLE_ROOT_1")){tempdir_policy_entry} ) "#, ); let mut expected_args = vec![ "-p".to_string(), expected_policy, format!( "-DWRITABLE_ROOT_0={}", root_with_git_canon.to_string_lossy() ), format!( "-DWRITABLE_ROOT_0_RO_0={}", root_with_git_git_canon.to_string_lossy() ), format!( "-DWRITABLE_ROOT_1={}", PathBuf::from("/tmp") .canonicalize() .expect("canonicalize /tmp") .to_string_lossy() ), ]; if let Some(p) = tmpdir_env_var { expected_args.push(format!("-DWRITABLE_ROOT_2={p}")); } expected_args.extend( macos_dir_params() .into_iter() .map(|(key, value)| format!("-D{key}={value}", value = value.to_string_lossy())), ); expected_args.extend(vec![ "--".to_string(), "/bin/echo".to_string(), "hello".to_string(), ]); assert_eq!(expected_args, args); } struct PopulatedTmp { root_with_git: PathBuf, root_without_git: PathBuf, root_with_git_canon: PathBuf, root_with_git_git_canon: PathBuf, root_without_git_canon: PathBuf, } fn populate_tmpdir(tmp: &Path) -> PopulatedTmp { let root_with_git = tmp.join("with_git"); let root_without_git = tmp.join("no_git"); fs::create_dir_all(&root_with_git).expect("create with_git"); fs::create_dir_all(&root_without_git).expect("create no_git"); fs::create_dir_all(root_with_git.join(".git")).expect("create .git"); // Ensure we have canonical paths for -D parameter matching. let root_with_git_canon = root_with_git.canonicalize().expect("canonicalize with_git"); let root_with_git_git_canon = root_with_git_canon.join(".git"); let root_without_git_canon = root_without_git .canonicalize() .expect("canonicalize no_git"); PopulatedTmp { root_with_git, root_without_git, root_with_git_canon, root_with_git_git_canon, root_without_git_canon, } } }