use crate::acl::add_deny_write_ace; use crate::acl::path_mask_allows; use crate::cap::cap_sid_file; use crate::cap::load_or_create_cap_sids; use crate::logging::{debug_log, log_note}; use crate::policy::SandboxPolicy; use crate::token::convert_string_sid_to_sid; use crate::token::world_sid; use anyhow::anyhow; use anyhow::Result; use std::collections::HashSet; use std::ffi::c_void; use std::ffi::OsStr; use std::path::Path; use std::path::PathBuf; use std::time::Duration; use std::time::Instant; use windows_sys::Win32::Storage::FileSystem::FILE_APPEND_DATA; use windows_sys::Win32::Storage::FileSystem::FILE_WRITE_ATTRIBUTES; use windows_sys::Win32::Storage::FileSystem::FILE_WRITE_DATA; use windows_sys::Win32::Storage::FileSystem::FILE_WRITE_EA; // Preflight scan limits const MAX_ITEMS_PER_DIR: i32 = 1000; const AUDIT_TIME_LIMIT_SECS: i64 = 2; const MAX_CHECKED_LIMIT: i32 = 50000; // Case-insensitive suffixes (normalized to forward slashes) to skip during one-level child scan const SKIP_DIR_SUFFIXES: &[&str] = &[ "/windows/installer", "/windows/registration", "/programdata", ]; pub(crate) fn normalize_path_key(p: &Path) -> String { let n = dunce::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()); n.to_string_lossy().replace('\\', "/").to_ascii_lowercase() } fn unique_push(set: &mut HashSet, out: &mut Vec, p: PathBuf) { if let Ok(abs) = p.canonicalize() { if set.insert(abs.clone()) { out.push(abs); } } } fn gather_candidates(cwd: &Path, env: &std::collections::HashMap) -> Vec { let mut set: HashSet = HashSet::new(); let mut out: Vec = Vec::new(); // 1) CWD first (so immediate children get scanned early) unique_push(&mut set, &mut out, cwd.to_path_buf()); // 2) TEMP/TMP next (often small, quick to scan) for k in ["TEMP", "TMP"] { if let Some(v) = env.get(k).cloned().or_else(|| std::env::var(k).ok()) { unique_push(&mut set, &mut out, PathBuf::from(v)); } } // 3) User roots if let Some(up) = std::env::var_os("USERPROFILE") { unique_push(&mut set, &mut out, PathBuf::from(up)); } if let Some(pubp) = std::env::var_os("PUBLIC") { unique_push(&mut set, &mut out, PathBuf::from(pubp)); } // 4) PATH entries (best-effort) if let Some(path) = env .get("PATH") .cloned() .or_else(|| std::env::var("PATH").ok()) { for part in std::env::split_paths(OsStr::new(&path)) { if !part.as_os_str().is_empty() { unique_push(&mut set, &mut out, part); } } } // 5) Core system roots last for p in [PathBuf::from("C:/"), PathBuf::from("C:/Windows")] { unique_push(&mut set, &mut out, p); } out } unsafe fn path_has_world_write_allow(path: &Path) -> Result { let mut world = world_sid()?; let psid_world = world.as_mut_ptr() as *mut c_void; let write_mask = FILE_WRITE_DATA | FILE_APPEND_DATA | FILE_WRITE_EA | FILE_WRITE_ATTRIBUTES; path_mask_allows(path, &[psid_world], write_mask, false) } pub fn audit_everyone_writable( cwd: &Path, env: &std::collections::HashMap, logs_base_dir: Option<&Path>, ) -> Result> { let start = Instant::now(); let mut flagged: Vec = Vec::new(); let mut seen: HashSet = HashSet::new(); let mut checked = 0usize; let check_world_writable = |path: &Path| -> bool { match unsafe { path_has_world_write_allow(path) } { Ok(has) => has, Err(err) => { debug_log( &format!( "AUDIT: treating unreadable ACL as not world-writable: {} ({err})", path.display() ), logs_base_dir, ); false } } }; // Fast path: check CWD immediate children first so workspace issues are caught early. if let Ok(read) = std::fs::read_dir(cwd) { for ent in read.flatten().take(MAX_ITEMS_PER_DIR as usize) { if start.elapsed() > Duration::from_secs(AUDIT_TIME_LIMIT_SECS as u64) || checked > MAX_CHECKED_LIMIT as usize { break; } let ft = match ent.file_type() { Ok(ft) => ft, Err(_) => continue, }; if ft.is_symlink() || !ft.is_dir() { continue; } let p = ent.path(); checked += 1; let has = check_world_writable(&p); if has { let key = normalize_path_key(&p); if seen.insert(key) { flagged.push(p); } } } } // Continue with broader candidate sweep let candidates = gather_candidates(cwd, env); for root in candidates { if start.elapsed() > Duration::from_secs(AUDIT_TIME_LIMIT_SECS as u64) || checked > MAX_CHECKED_LIMIT as usize { break; } checked += 1; let has_root = check_world_writable(&root); if has_root { let key = normalize_path_key(&root); if seen.insert(key) { flagged.push(root.clone()); } } // one level down best-effort if let Ok(read) = std::fs::read_dir(&root) { for ent in read.flatten().take(MAX_ITEMS_PER_DIR as usize) { let p = ent.path(); if start.elapsed() > Duration::from_secs(AUDIT_TIME_LIMIT_SECS as u64) || checked > MAX_CHECKED_LIMIT as usize { break; } // Skip reparse points (symlinks/junctions) to avoid auditing link ACLs let ft = match ent.file_type() { Ok(ft) => ft, Err(_) => continue, }; if ft.is_symlink() { continue; } // Skip noisy/irrelevant Windows system subdirectories let pl = p.to_string_lossy().to_ascii_lowercase(); let norm = pl.replace('\\', "/"); if SKIP_DIR_SUFFIXES.iter().any(|s| norm.ends_with(s)) { continue; } if ft.is_dir() { checked += 1; let has_child = check_world_writable(&p); if has_child { let key = normalize_path_key(&p); if seen.insert(key) { flagged.push(p); } } } } } } let elapsed_ms = start.elapsed().as_millis(); if !flagged.is_empty() { let mut list = String::new(); for p in &flagged { list.push_str(&format!("\n - {}", p.display())); } crate::logging::log_note( &format!( "AUDIT: world-writable scan FAILED; cwd={cwd:?}; checked={checked}; duration_ms={elapsed_ms}; flagged:{}", list ), logs_base_dir, ); return Ok(flagged); } // Log success once if nothing flagged crate::logging::log_note( &format!("AUDIT: world-writable scan OK; checked={checked}; duration_ms={elapsed_ms}"), logs_base_dir, ); Ok(Vec::new()) } pub fn apply_world_writable_scan_and_denies( codex_home: &Path, cwd: &Path, env_map: &std::collections::HashMap, sandbox_policy: &SandboxPolicy, logs_base_dir: Option<&Path>, ) -> Result<()> { let flagged = audit_everyone_writable(cwd, env_map, logs_base_dir)?; if flagged.is_empty() { return Ok(()); } if let Err(err) = apply_capability_denies_for_world_writable( codex_home, &flagged, sandbox_policy, cwd, logs_base_dir, ) { log_note( &format!("AUDIT: failed to apply capability deny ACEs: {}", err), logs_base_dir, ); } Ok(()) } pub fn apply_capability_denies_for_world_writable( codex_home: &Path, flagged: &[PathBuf], sandbox_policy: &SandboxPolicy, cwd: &Path, logs_base_dir: Option<&Path>, ) -> Result<()> { if flagged.is_empty() { return Ok(()); } std::fs::create_dir_all(codex_home)?; let cap_path = cap_sid_file(codex_home); let caps = load_or_create_cap_sids(codex_home)?; std::fs::write(&cap_path, serde_json::to_string(&caps)?)?; let (active_sid, workspace_roots): (*mut c_void, Vec) = match sandbox_policy { SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { let sid = unsafe { convert_string_sid_to_sid(&caps.workspace) } .ok_or_else(|| anyhow!("ConvertStringSidToSidW failed for workspace capability"))?; let mut roots: Vec = vec![dunce::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf())]; for root in writable_roots { let candidate = root.as_path(); roots.push(dunce::canonicalize(candidate).unwrap_or_else(|_| root.to_path_buf())); } (sid, roots) } SandboxPolicy::ReadOnly => ( unsafe { convert_string_sid_to_sid(&caps.readonly) }.ok_or_else(|| { anyhow!("ConvertStringSidToSidW failed for readonly capability") })?, Vec::new(), ), SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { return Ok(()); } }; for path in flagged { if workspace_roots.iter().any(|root| path.starts_with(root)) { continue; } let res = unsafe { add_deny_write_ace(path, active_sid) }; match res { Ok(true) => log_note( &format!("AUDIT: applied capability deny ACE to {}", path.display()), logs_base_dir, ), Ok(false) => {} Err(err) => log_note( &format!( "AUDIT: failed to apply capability deny ACE to {}: {}", path.display(), err ), logs_base_dir, ), } } Ok(()) } #[cfg(test)] mod tests { use super::gather_candidates; use std::collections::HashMap; use std::fs; #[test] fn gathers_path_entries_by_list_separator() { let tmp = tempfile::tempdir().expect("tempdir"); let dir_a = tmp.path().join("Tools"); let dir_b = tmp.path().join("Bin"); let dir_space = tmp.path().join("Program Files"); fs::create_dir_all(&dir_a).expect("dir a"); fs::create_dir_all(&dir_b).expect("dir b"); fs::create_dir_all(&dir_space).expect("dir space"); let mut env_map = HashMap::new(); env_map.insert( "PATH".to_string(), format!( "{};{};{}", dir_a.display(), dir_b.display(), dir_space.display() ), ); let candidates = gather_candidates(tmp.path(), &env_map); let canon_a = dir_a.canonicalize().expect("canon a"); let canon_b = dir_b.canonicalize().expect("canon b"); let canon_space = dir_space.canonicalize().expect("canon space"); assert!(candidates.contains(&canon_a)); assert!(candidates.contains(&canon_b)); assert!(candidates.contains(&canon_space)); } }