diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index dfd339083f..06d6edc469 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2350,7 +2350,6 @@ dependencies = [ "owo-colors", "predicates", "pretty_assertions", - "regex-lite", "serde", "serde_json", "sqlx", diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 727f367502..64f1a62c8d 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -64,7 +64,6 @@ http = { workspace = true } libc = { workspace = true } os_info = { workspace = true } owo-colors = { workspace = true } -regex-lite = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } supports-color = { workspace = true } diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index a1931aced0..529d9cd17d 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -1,8 +1,3 @@ -#[cfg(target_os = "macos")] -mod pid_tracker; -#[cfg(target_os = "macos")] -mod seatbelt; - use std::path::PathBuf; use std::process::Stdio; @@ -23,6 +18,10 @@ use codex_sandboxing::landlock::create_linux_sandbox_command_args_for_permission use codex_sandboxing::seatbelt::CreateSeatbeltCommandArgsParams; #[cfg(target_os = "macos")] use codex_sandboxing::seatbelt::create_seatbelt_command_args; +#[cfg(target_os = "macos")] +use codex_sandboxing::seatbelt_denials::DenialLogger; +#[cfg(target_os = "macos")] +use codex_sandboxing::seatbelt_denials::SandboxDenial; use codex_sandboxing::with_managed_mitm_ca_readable_root; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_cli::CliConfigOverrides; @@ -35,9 +34,6 @@ use crate::SeatbeltCommand; use crate::WindowsCommand; use crate::exit_status::handle_exit_status; -#[cfg(target_os = "macos")] -use seatbelt::DenialLogger; - #[cfg(target_os = "macos")] pub async fn run_command_under_seatbelt( command: SeatbeltCommand, @@ -234,7 +230,11 @@ async fn run_command_under_sandbox( } #[cfg(target_os = "macos")] - let mut denial_logger = log_denials.then(DenialLogger::new).flatten(); + let mut denial_logger = if log_denials { + DenialLogger::new().await + } else { + None + }; #[cfg(not(target_os = "macos"))] let _ = log_denials; @@ -338,7 +338,7 @@ async fn run_command_under_sandbox( #[cfg(target_os = "macos")] if let Some(denial_logger) = &mut denial_logger { - denial_logger.on_child_spawn(&child); + denial_logger.on_child_pid(child.id()); } let status = child.wait().await?; @@ -350,7 +350,7 @@ async fn run_command_under_sandbox( if denials.is_empty() { eprintln!("None found."); } else { - for seatbelt::SandboxDenial { name, capability } in denials { + for SandboxDenial { name, capability } in denials { eprintln!("({name}) {capability}"); } } diff --git a/codex-rs/cli/src/debug_sandbox/seatbelt.rs b/codex-rs/cli/src/debug_sandbox/seatbelt.rs deleted file mode 100644 index a1d6435b51..0000000000 --- a/codex-rs/cli/src/debug_sandbox/seatbelt.rs +++ /dev/null @@ -1,114 +0,0 @@ -use std::collections::HashSet; -use tokio::io::AsyncBufReadExt; -use tokio::process::Child; -use tokio::task::JoinHandle; - -use super::pid_tracker::PidTracker; - -pub struct SandboxDenial { - pub name: String, - pub capability: String, -} - -pub struct DenialLogger { - log_stream: Child, - pid_tracker: Option, - log_reader: Option>>, -} - -impl DenialLogger { - pub(crate) fn new() -> Option { - let mut log_stream = start_log_stream()?; - let stdout = log_stream.stdout.take()?; - let log_reader = tokio::spawn(async move { - let mut reader = tokio::io::BufReader::new(stdout); - let mut logs = Vec::new(); - let mut chunk = Vec::new(); - loop { - match reader.read_until(b'\n', &mut chunk).await { - Ok(0) | Err(_) => break, - Ok(_) => { - logs.extend_from_slice(&chunk); - chunk.clear(); - } - } - } - logs - }); - - Some(Self { - log_stream, - pid_tracker: None, - log_reader: Some(log_reader), - }) - } - - pub(crate) fn on_child_spawn(&mut self, child: &Child) { - if let Some(root_pid) = child.id() { - self.pid_tracker = PidTracker::new(root_pid as i32); - } - } - - pub(crate) async fn finish(mut self) -> Vec { - let pid_set = match self.pid_tracker { - Some(tracker) => tracker.stop().await, - None => Default::default(), - }; - - if pid_set.is_empty() { - return Vec::new(); - } - - let _ = self.log_stream.kill().await; - let _ = self.log_stream.wait().await; - - let logs_bytes = match self.log_reader.take() { - Some(handle) => handle.await.unwrap_or_default(), - None => Vec::new(), - }; - let logs = String::from_utf8_lossy(&logs_bytes); - - let mut seen: HashSet<(String, String)> = HashSet::new(); - let mut denials: Vec = Vec::new(); - for line in logs.lines() { - if let Ok(json) = serde_json::from_str::(line) - && let Some(msg) = json.get("eventMessage").and_then(|v| v.as_str()) - && let Some((pid, name, capability)) = parse_message(msg) - && pid_set.contains(&pid) - && seen.insert((name.clone(), capability.clone())) - { - denials.push(SandboxDenial { name, capability }); - } - } - denials - } -} - -fn start_log_stream() -> Option { - use std::process::Stdio; - - const PREDICATE: &str = r#"(((processID == 0) AND (senderImagePath CONTAINS "/Sandbox")) OR (subsystem == "com.apple.sandbox.reporting"))"#; - - tokio::process::Command::new("log") - .args(["stream", "--style", "ndjson", "--predicate", PREDICATE]) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .kill_on_drop(true) - .spawn() - .ok() -} - -fn parse_message(msg: &str) -> Option<(i32, String, String)> { - // Example message: - // Sandbox: processname(1234) deny(1) capability-name args... - static RE: std::sync::OnceLock = std::sync::OnceLock::new(); - let re = RE.get_or_init(|| { - #[expect(clippy::unwrap_used)] - regex_lite::Regex::new(r"^Sandbox:\s*(.+?)\((\d+)\)\s+deny\(.*?\)\s*(.+)$").unwrap() - }); - - let (_, [name, pid_str, capability]) = re.captures(msg)?.extract(); - let pid = pid_str.trim().parse::().ok()?; - Some((pid, name.to_string(), capability.to_string())) -} diff --git a/codex-rs/sandboxing/Cargo.toml b/codex-rs/sandboxing/Cargo.toml index 226849521b..6a5468f73c 100644 --- a/codex-rs/sandboxing/Cargo.toml +++ b/codex-rs/sandboxing/Cargo.toml @@ -21,6 +21,7 @@ libc = { workspace = true } serde_json = { workspace = true } regex-lite = { workspace = true } tracing = { workspace = true, features = ["log"] } +tokio = { workspace = true, features = ["io-util", "process", "rt", "time"] } url = { workspace = true } which = { workspace = true } @@ -28,4 +29,4 @@ which = { workspace = true } anyhow = { workspace = true } pretty_assertions = { workspace = true } tempfile = { workspace = true } -tokio = { workspace = true, features = ["macros", "rt"] } +tokio = { workspace = true, features = ["macros"] } diff --git a/codex-rs/sandboxing/src/lib.rs b/codex-rs/sandboxing/src/lib.rs index 2dda65f9d5..dba6a52906 100644 --- a/codex-rs/sandboxing/src/lib.rs +++ b/codex-rs/sandboxing/src/lib.rs @@ -5,6 +5,7 @@ mod manager; pub mod policy_transforms; #[cfg(target_os = "macos")] pub mod seatbelt; +pub mod seatbelt_denials; #[cfg(target_os = "linux")] pub use bwrap::find_system_bwrap_in_path; diff --git a/codex-rs/sandboxing/src/seatbelt_denials/mod.rs b/codex-rs/sandboxing/src/seatbelt_denials/mod.rs new file mode 100644 index 0000000000..191fa13180 --- /dev/null +++ b/codex-rs/sandboxing/src/seatbelt_denials/mod.rs @@ -0,0 +1,286 @@ +#[cfg(target_os = "macos")] +use std::collections::HashSet; +#[cfg(target_os = "macos")] +use std::collections::VecDeque; +#[cfg(target_os = "macos")] +use tokio::io::AsyncBufReadExt; +#[cfg(target_os = "macos")] +use tokio::process::Child; +#[cfg(target_os = "macos")] +use tokio::task::JoinHandle; + +#[cfg(target_os = "macos")] +use self::pid_tracker::PidTracker; + +#[cfg(target_os = "macos")] +const LOG_STREAM_READY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); +#[cfg(target_os = "macos")] +const LOG_STREAM_FLUSH_GRACE_PERIOD: std::time::Duration = std::time::Duration::from_millis(100); +#[cfg(target_os = "macos")] +const LOG_STREAM_READY_PREFIX: &str = "Filtering the log data using "; +#[cfg(target_os = "macos")] +const MAX_COLLECTED_LOG_CHARS: usize = 1_000; + +#[cfg(target_os = "macos")] +mod pid_tracker; + +#[cfg(target_os = "macos")] +/// A unique macOS Seatbelt sandbox denial emitted by a process. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SandboxDenial { + pub name: String, + pub capability: String, +} + +#[cfg(target_os = "macos")] +#[derive(Clone, Debug, PartialEq, Eq)] +struct LoggedDenial { + pid: i32, + denial: SandboxDenial, + chars: usize, +} + +/// Best-effort collector for macOS Seatbelt denials emitted by a process tree. +pub struct DenialLogger { + #[cfg(target_os = "macos")] + log_stream: Child, + #[cfg(target_os = "macos")] + pid_tracker: Option, + #[cfg(target_os = "macos")] + log_reader: JoinHandle>, + #[cfg(target_os = "macos")] + tracked_pid_tx: std::sync::mpsc::Sender, +} + +impl DenialLogger { + /// Starts collecting Seatbelt denial log messages. + #[cfg(target_os = "macos")] + pub async fn new() -> Option { + Self::new_with_limit(/*max_chars*/ None).await + } + + /// Starts collecting Seatbelt denials while retaining at most 1,000 characters. + #[cfg(target_os = "macos")] + pub async fn new_bounded() -> Option { + Self::new_with_limit(Some(MAX_COLLECTED_LOG_CHARS)).await + } + + #[cfg(target_os = "macos")] + async fn new_with_limit(max_chars: Option) -> Option { + let mut log_stream = start_log_stream()?; + let stdout = log_stream.stdout.take()?; + let stderr = log_stream.stderr.take()?; + let (ready_tx, mut ready_rx) = tokio::sync::mpsc::channel(1); + let (tracked_pid_tx, tracked_pid_rx) = std::sync::mpsc::channel(); + let log_reader = tokio::spawn(async move { + let mut stdout = tokio::io::BufReader::new(stdout); + let mut stderr = tokio::io::BufReader::new(stderr); + let mut logged_denials = VecDeque::new(); + let mut tracked_pids = HashSet::new(); + let mut collected_chars = 0; + let mut stdout_line = Vec::new(); + let mut stderr_line = String::new(); + let mut stdout_open = true; + let mut stderr_open = true; + while stdout_open || stderr_open { + tokio::select! { + result = stdout.read_until(b'\n', &mut stdout_line), if stdout_open => { + stdout_open = result.is_ok_and(|read| read > 0); + if stdout_line.starts_with(LOG_STREAM_READY_PREFIX.as_bytes()) { + let _ = ready_tx.try_send(()); + } else if let Some(denial) = parse_log_line(&stdout_line) { + tracked_pids.extend(tracked_pid_rx.try_iter()); + push_logged_denial( + &mut logged_denials, + &mut collected_chars, + denial, + max_chars, + &tracked_pids, + ); + } + stdout_line.clear(); + } + result = stderr.read_line(&mut stderr_line), if stderr_open => { + stderr_open = result.is_ok_and(|read| read > 0); + if stderr_line.starts_with(LOG_STREAM_READY_PREFIX) { + let _ = ready_tx.try_send(()); + } + stderr_line.clear(); + } + } + } + logged_denials + }); + + let ready = tokio::time::timeout(LOG_STREAM_READY_TIMEOUT, ready_rx.recv()) + .await + .is_ok_and(|result| result.is_some()); + if !ready { + let _ = log_stream.kill().await; + let _ = log_stream.wait().await; + log_reader.abort(); + return None; + } + + Some(Self { + log_stream, + pid_tracker: None, + log_reader, + tracked_pid_tx, + }) + } + + /// Returns no logger on platforms without macOS Seatbelt. + #[cfg(not(target_os = "macos"))] + pub async fn new() -> Option { + None + } + + /// Returns no logger on platforms without macOS Seatbelt. + #[cfg(not(target_os = "macos"))] + pub async fn new_bounded() -> Option { + None + } + + /// Starts tracking the process tree rooted at `child_pid`. + #[cfg(target_os = "macos")] + pub fn on_child_pid(&mut self, child_pid: Option) { + if let Some(root_pid) = child_pid { + self.pid_tracker = PidTracker::new(root_pid as i32, self.tracked_pid_tx.clone()); + } + } + + /// Does nothing on platforms without macOS Seatbelt. + #[cfg(not(target_os = "macos"))] + pub fn on_child_pid(&mut self, child_pid: Option) { + let _ = child_pid; + } + + /// Stops collection and returns unique denials from the tracked process tree. + #[cfg(target_os = "macos")] + pub async fn finish(mut self) -> Vec { + let pid_set = match self.pid_tracker { + Some(tracker) => tracker.stop().await, + None => Default::default(), + }; + + if !pid_set.is_empty() { + tokio::time::sleep(LOG_STREAM_FLUSH_GRACE_PERIOD).await; + } + let _ = self.log_stream.kill().await; + let _ = self.log_stream.wait().await; + let logged_denials = self.log_reader.await.unwrap_or_default(); + if pid_set.is_empty() { + return Vec::new(); + } + + let mut seen: HashSet<(String, String)> = HashSet::new(); + let mut denials: Vec = Vec::new(); + for LoggedDenial { pid, denial, .. } in logged_denials { + if pid_set.contains(&pid) + && seen.insert((denial.name.clone(), denial.capability.clone())) + { + denials.push(denial); + } + } + denials + } + + /// Returns no denials on platforms without macOS Seatbelt. + #[cfg(not(target_os = "macos"))] + pub async fn finish(self) -> Vec { + Vec::new() + } +} + +#[cfg(target_os = "macos")] +fn push_logged_denial( + logged_denials: &mut VecDeque, + collected_chars: &mut usize, + logged_denial: LoggedDenial, + max_chars: Option, + tracked_pids: &HashSet, +) { + let denial_chars = logged_denial.chars; + if let Some(max_chars) = max_chars { + if denial_chars > max_chars { + return; + } + logged_denials.push_back(logged_denial); + *collected_chars += denial_chars; + + while *collected_chars > max_chars { + let removal_index = logged_denials + .iter() + .position(|logged_denial| !tracked_pids.contains(&logged_denial.pid)) + .unwrap_or(0); + let Some(removed) = logged_denials.remove(removal_index) else { + break; + }; + *collected_chars -= removed.chars; + } + return; + } + logged_denials.push_back(logged_denial); +} + +#[cfg(target_os = "macos")] +fn parse_log_line(line: &[u8]) -> Option { + let json = serde_json::from_slice::(line).ok()?; + let msg = json.get("eventMessage")?.as_str()?; + let (pid, name, capability) = parse_message(msg)?; + let chars = name.chars().count() + capability.chars().count(); + Some(LoggedDenial { + pid, + denial: SandboxDenial { name, capability }, + chars, + }) +} + +#[cfg(target_os = "macos")] +fn start_log_stream() -> Option { + use std::process::Stdio; + + const PREDICATE: &str = r#"(((processID == 0) AND (senderImagePath CONTAINS "/Sandbox")) OR (subsystem == "com.apple.sandbox.reporting"))"#; + + tokio::process::Command::new("log") + .args(["stream", "--style", "ndjson", "--predicate", PREDICATE]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .ok() +} + +#[cfg(target_os = "macos")] +fn parse_message(msg: &str) -> Option<(i32, String, String)> { + // Example message: + // Sandbox: processname(1234) deny(1) capability-name args... + static RE: std::sync::OnceLock = std::sync::OnceLock::new(); + let re = RE.get_or_init(|| { + #[expect(clippy::unwrap_used)] + regex_lite::Regex::new(r"^Sandbox:\s*(.+?)\((\d+)\)\s+deny\(.*?\)\s*(.+)$").unwrap() + }); + + let (_, [name, pid_str, capability]) = re.captures(msg)?.extract(); + let pid = pid_str.trim().parse::().ok()?; + Some((pid, name.to_string(), capability.to_string())) +} + +/// Formats denials for appending to command output. +pub fn format_sandbox_denials(denials: &[SandboxDenial]) -> Option> { + if denials.is_empty() { + return None; + } + + let mut formatted = String::from("\n=== Sandbox denials ===\n"); + for SandboxDenial { name, capability } in denials { + formatted.push_str(&format!("({name}) {capability}\n")); + } + Some(formatted.into_bytes()) +} + +#[cfg(all(test, target_os = "macos"))] +#[path = "seatbelt_denials_tests.rs"] +mod tests; diff --git a/codex-rs/cli/src/debug_sandbox/pid_tracker.rs b/codex-rs/sandboxing/src/seatbelt_denials/pid_tracker.rs similarity index 82% rename from codex-rs/cli/src/debug_sandbox/pid_tracker.rs rename to codex-rs/sandboxing/src/seatbelt_denials/pid_tracker.rs index 07bd2fd046..57f0e48a72 100644 --- a/codex-rs/cli/src/debug_sandbox/pid_tracker.rs +++ b/codex-rs/sandboxing/src/seatbelt_denials/pid_tracker.rs @@ -1,4 +1,7 @@ +//! Descendant process tracking for macOS Seatbelt denial collection. + use std::collections::HashSet; +use std::sync::mpsc::Sender; use tokio::task::JoinHandle; use tracing::warn; @@ -10,13 +13,15 @@ pub(crate) struct PidTracker { } impl PidTracker { - pub(crate) fn new(root_pid: i32) -> Option { + pub(crate) fn new(root_pid: i32, tracked_pid_tx: Sender) -> Option { if root_pid <= 0 { return None; } + let _ = tracked_pid_tx.send(root_pid); let kq = unsafe { libc::kqueue() }; - let handle = tokio::task::spawn_blocking(move || track_descendants(kq, root_pid)); + let handle = + tokio::task::spawn_blocking(move || track_descendants(kq, root_pid, tracked_pid_tx)); Some(Self { kq, handle }) } @@ -112,19 +117,29 @@ fn watch_children( parent: i32, seen: &mut HashSet, active: &mut HashSet, + tracked_pid_tx: &Sender, ) { for child_pid in list_child_pids(parent) { - add_pid_watch(kq, child_pid, seen, active); + add_pid_watch(kq, child_pid, seen, active, tracked_pid_tx); } } /// Watch `pid` and its children, updating `seen` and `active` sets. -fn add_pid_watch(kq: libc::c_int, pid: i32, seen: &mut HashSet, active: &mut HashSet) { +fn add_pid_watch( + kq: libc::c_int, + pid: i32, + seen: &mut HashSet, + active: &mut HashSet, + tracked_pid_tx: &Sender, +) { if pid <= 0 { return; } let newly_seen = seen.insert(pid); + if newly_seen { + let _ = tracked_pid_tx.send(pid); + } let mut should_recurse = newly_seen; if active.insert(pid) { @@ -145,7 +160,7 @@ fn add_pid_watch(kq: libc::c_int, pid: i32, seen: &mut HashSet, active: &mu } if should_recurse { - watch_children(kq, pid, seen, active); + watch_children(kq, pid, seen, active, tracked_pid_tx); } } const STOP_IDENT: libc::uintptr_t = 1; @@ -182,7 +197,7 @@ fn trigger_stop_event(kq: libc::c_int) { } /// Put all of the above together to track all the descendants of `root_pid`. -fn track_descendants(kq: libc::c_int, root_pid: i32) -> HashSet { +fn track_descendants(kq: libc::c_int, root_pid: i32, tracked_pid_tx: Sender) -> HashSet { if kq < 0 { let mut seen = HashSet::new(); seen.insert(root_pid); @@ -199,7 +214,7 @@ fn track_descendants(kq: libc::c_int, root_pid: i32) -> HashSet { let mut seen: HashSet = HashSet::new(); let mut active: HashSet = HashSet::new(); - add_pid_watch(kq, root_pid, &mut seen, &mut active); + add_pid_watch(kq, root_pid, &mut seen, &mut active, &tracked_pid_tx); const EVENTS_CAP: usize = 32; let mut events: [libc::kevent; EVENTS_CAP] = @@ -211,7 +226,7 @@ fn track_descendants(kq: libc::c_int, root_pid: i32) -> HashSet { if !pid_is_alive(root_pid) { break; } - add_pid_watch(kq, root_pid, &mut seen, &mut active); + add_pid_watch(kq, root_pid, &mut seen, &mut active, &tracked_pid_tx); if active.is_empty() { continue; } @@ -256,7 +271,7 @@ fn track_descendants(kq: libc::c_int, root_pid: i32) -> HashSet { } if (ev.fflags & libc::NOTE_FORK) != 0 { - watch_children(kq, pid, &mut seen, &mut active); + watch_children(kq, pid, &mut seen, &mut active, &tracked_pid_tx); } if (ev.fflags & libc::NOTE_EXIT) != 0 { @@ -281,43 +296,12 @@ mod tests { use std::process::Stdio; use std::time::Duration; - #[test] - fn pid_is_alive_detects_current_process() { - let pid = std::process::id() as i32; - assert!(pid_is_alive(pid)); - } - - #[cfg(target_os = "macos")] - #[test] - fn list_child_pids_includes_spawned_child() { - let mut child = Command::new("/bin/sleep") - .arg("5") - .stdin(Stdio::null()) - .spawn() - .expect("failed to spawn child process"); - - let child_pid = child.id() as i32; - let parent_pid = std::process::id() as i32; - - let mut found = false; - for _ in 0..100 { - if list_child_pids(parent_pid).contains(&child_pid) { - found = true; - break; - } - std::thread::sleep(Duration::from_millis(10)); - } - - let _ = child.kill(); - let _ = child.wait(); - - assert!(found, "expected to find child pid {child_pid} in list"); - } - #[cfg(target_os = "macos")] #[tokio::test] async fn pid_tracker_collects_spawned_children() { - let tracker = PidTracker::new(std::process::id() as i32).expect("failed to create tracker"); + let (tracked_pid_tx, tracked_pid_rx) = std::sync::mpsc::channel(); + let tracker = PidTracker::new(std::process::id() as i32, tracked_pid_tx) + .expect("failed to create tracker"); let mut child = Command::new("/bin/sleep") .arg("0.1") @@ -328,6 +312,15 @@ mod tests { let child_pid = child.id() as i32; let parent_pid = std::process::id() as i32; + let mut child_was_published = false; + for _ in 0..100 { + if tracked_pid_rx.try_iter().any(|pid| pid == child_pid) { + child_was_published = true; + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + let _ = child.wait(); let seen = tracker.stop().await; @@ -340,12 +333,18 @@ mod tests { seen.contains(&child_pid), "expected tracker to include child pid {child_pid}" ); + assert!( + child_was_published, + "expected tracker to publish child pid {child_pid} before stop" + ); } #[cfg(target_os = "macos")] #[tokio::test] async fn pid_tracker_collects_bash_subshell_descendants() { - let tracker = PidTracker::new(std::process::id() as i32).expect("failed to create tracker"); + let (tracked_pid_tx, _tracked_pid_rx) = std::sync::mpsc::channel(); + let tracker = PidTracker::new(std::process::id() as i32, tracked_pid_tx) + .expect("failed to create tracker"); let child = Command::new("/bin/bash") .arg("-c") diff --git a/codex-rs/sandboxing/src/seatbelt_denials/seatbelt_denials_tests.rs b/codex-rs/sandboxing/src/seatbelt_denials/seatbelt_denials_tests.rs new file mode 100644 index 0000000000..8c0c9cee45 --- /dev/null +++ b/codex-rs/sandboxing/src/seatbelt_denials/seatbelt_denials_tests.rs @@ -0,0 +1,101 @@ +use super::*; +use pretty_assertions::assert_eq; + +fn logged_denial(pid: i32, name: String) -> LoggedDenial { + let chars = name.chars().count(); + LoggedDenial { + pid, + denial: SandboxDenial { + name, + capability: String::new(), + }, + chars, + } +} + +#[test] +fn collected_logs_are_capped_at_one_thousand_characters() { + let mut logged_denials = VecDeque::new(); + let mut collected_chars = 0; + let old_denial = logged_denial(/*pid*/ 1, "a".repeat(600)); + let recent_denial = logged_denial(/*pid*/ 2, "é".repeat(500)); + let tracked_pids = HashSet::from([1, 2]); + + push_logged_denial( + &mut logged_denials, + &mut collected_chars, + old_denial, + Some(MAX_COLLECTED_LOG_CHARS), + &tracked_pids, + ); + push_logged_denial( + &mut logged_denials, + &mut collected_chars, + recent_denial.clone(), + Some(MAX_COLLECTED_LOG_CHARS), + &tracked_pids, + ); + + assert_eq!(logged_denials, VecDeque::from([recent_denial])); + assert_eq!(collected_chars, 500); +} + +#[test] +fn tracked_denials_outlive_unrelated_noise_after_descendant_discovery() { + let mut logged_denials = VecDeque::new(); + let mut collected_chars = 0; + let root_denial = logged_denial(/*pid*/ 1, "a".repeat(400)); + let descendant_denial = logged_denial(/*pid*/ 2, "b".repeat(400)); + let unrelated_denial = logged_denial(/*pid*/ 3, "c".repeat(300)); + let mut tracked_pids = HashSet::from([1]); + + push_logged_denial( + &mut logged_denials, + &mut collected_chars, + root_denial.clone(), + Some(MAX_COLLECTED_LOG_CHARS), + &tracked_pids, + ); + push_logged_denial( + &mut logged_denials, + &mut collected_chars, + descendant_denial.clone(), + Some(MAX_COLLECTED_LOG_CHARS), + &tracked_pids, + ); + tracked_pids.insert(2); + push_logged_denial( + &mut logged_denials, + &mut collected_chars, + unrelated_denial, + Some(MAX_COLLECTED_LOG_CHARS), + &tracked_pids, + ); + + assert_eq!( + logged_denials, + VecDeque::from([root_denial, descendant_denial]) + ); + assert_eq!(collected_chars, 800); +} + +#[test] +fn parses_denial_from_large_log_record() { + let line = serde_json::to_vec(&serde_json::json!({ + "eventMessage": "Sandbox: touch(1234) deny(1) file-write-create /private/tmp/nope", + "metadata": "x".repeat(2_000), + })) + .expect("valid log record"); + + assert_eq!( + parse_log_line(&line), + Some(LoggedDenial { + pid: 1234, + denial: SandboxDenial { + name: "touch".to_string(), + capability: "file-write-create /private/tmp/nope".to_string(), + }, + chars: "touch".chars().count() + "file-write-create /private/tmp/nope".chars().count(), + }) + ); +}