diff --git a/codex-rs/windows-sandbox-rs/Cargo.toml b/codex-rs/windows-sandbox-rs/Cargo.toml index 1b936f05cab..ac290101cc5 100644 --- a/codex-rs/windows-sandbox-rs/Cargo.toml +++ b/codex-rs/windows-sandbox-rs/Cargo.toml @@ -3,17 +3,29 @@ edition = "2021" license.workspace = true name = "codex-windows-sandbox" version.workspace = true +build = "build.rs" [lib] name = "codex_windows_sandbox" path = "src/lib.rs" +[[bin]] +name = "codex-windows-sandbox-setup" +path = "src/bin/setup_main.rs" + [dependencies] anyhow = "1.0" -chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] } +base64 = { workspace = true } dunce = "1.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] } +windows = { version = "0.58", features = [ + "Win32_Foundation", + "Win32_NetworkManagement_WindowsFirewall", + "Win32_System_Com", + "Win32_System_Variant", +] } [dependencies.codex-protocol] package = "codex-protocol" path = "../protocol" @@ -41,11 +53,18 @@ features = [ "Win32_System_Console", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", + "Win32_NetworkManagement_NetManagement", "Win32_Networking_WinSock", "Win32_System_LibraryLoader", "Win32_System_Com", + "Win32_Security_Cryptography", "Win32_Security_Authentication_Identity", + "Win32_UI_Shell", + "Win32_System_Registry", ] version = "0.52" [dev-dependencies] tempfile = "3" + +[build-dependencies] +winres = "0.1" diff --git a/codex-rs/windows-sandbox-rs/build.rs b/codex-rs/windows-sandbox-rs/build.rs new file mode 100644 index 00000000000..768f6e82808 --- /dev/null +++ b/codex-rs/windows-sandbox-rs/build.rs @@ -0,0 +1,5 @@ +fn main() { + let mut res = winres::WindowsResource::new(); + res.set_manifest_file("codex-windows-sandbox-setup.manifest"); + let _ = res.compile(); +} diff --git a/codex-rs/windows-sandbox-rs/codex-windows-sandbox-setup.manifest b/codex-rs/windows-sandbox-rs/codex-windows-sandbox-setup.manifest new file mode 100644 index 00000000000..14807962f83 --- /dev/null +++ b/codex-rs/windows-sandbox-rs/codex-windows-sandbox-setup.manifest @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/codex-rs/windows-sandbox-rs/src/acl.rs b/codex-rs/windows-sandbox-rs/src/acl.rs index 34d523d1f53..f84c5368930 100644 --- a/codex-rs/windows-sandbox-rs/src/acl.rs +++ b/codex-rs/windows-sandbox-rs/src/acl.rs @@ -34,6 +34,7 @@ use windows_sys::Win32::Storage::FileSystem::FILE_ALL_ACCESS; use windows_sys::Win32::Storage::FileSystem::FILE_APPEND_DATA; use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_NORMAL; use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_BACKUP_SEMANTICS; +use windows_sys::Win32::Storage::FileSystem::FILE_DELETE_CHILD; use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_EXECUTE; use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_READ; use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_WRITE; @@ -45,12 +46,16 @@ use windows_sys::Win32::Storage::FileSystem::FILE_WRITE_DATA; use windows_sys::Win32::Storage::FileSystem::FILE_WRITE_EA; use windows_sys::Win32::Storage::FileSystem::OPEN_EXISTING; use windows_sys::Win32::Storage::FileSystem::READ_CONTROL; +use windows_sys::Win32::Storage::FileSystem::DELETE; const SE_KERNEL_OBJECT: u32 = 6; const INHERIT_ONLY_ACE: u8 = 0x08; const GENERIC_WRITE_MASK: u32 = 0x4000_0000; const DENY_ACCESS: i32 = 3; /// Fetch DACL via handle-based query; caller must LocalFree the returned SD. +/// +/// # Safety +/// Caller must free the returned security descriptor with `LocalFree` and pass an existing path. pub unsafe fn fetch_dacl_handle(path: &Path) -> Result<(*mut ACL, *mut c_void)> { let wpath = to_wide(path); let h = CreateFileW( @@ -88,11 +93,13 @@ pub unsafe fn fetch_dacl_handle(path: &Path) -> Result<(*mut ACL, *mut c_void)> Ok((p_dacl, p_sd)) } -/// Fast mask-based check: does any ACE for provided SIDs grant at least one desired bit? Skips inherit-only. -pub unsafe fn dacl_quick_mask_allows( +/// Fast mask-based check: does an ACE for provided SIDs grant the desired mask? Skips inherit-only. +/// When `require_all_bits` is true, all bits in `desired_mask` must be present; otherwise any bit suffices. +pub unsafe fn dacl_mask_allows( p_dacl: *mut ACL, psids: &[*mut c_void], desired_mask: u32, + require_all_bits: bool, ) -> bool { if p_dacl.is_null() { return false; @@ -141,22 +148,25 @@ pub unsafe fn dacl_quick_mask_allows( let ace = &*(p_ace as *const ACCESS_ALLOWED_ACE); let mut mask = ace.Mask; MapGenericMask(&mut mask, &mapping); - if (mask & desired_mask) != 0 { + if (require_all_bits && (mask & desired_mask) == desired_mask) + || (!require_all_bits && (mask & desired_mask) != 0) + { return true; } } false } -/// Path-based wrapper around the quick mask check (single DACL fetch). -pub fn path_quick_mask_allows( +/// Path-based wrapper around the mask check (single DACL fetch). +pub fn path_mask_allows( path: &Path, psids: &[*mut c_void], desired_mask: u32, + require_all_bits: bool, ) -> Result { unsafe { let (p_dacl, sd) = fetch_dacl_handle(path)?; - let has = dacl_quick_mask_allows(p_dacl, psids, desired_mask); + let has = dacl_mask_allows(p_dacl, psids, desired_mask, require_all_bits); if !sd.is_null() { LocalFree(sd as HLOCAL); } @@ -326,16 +336,23 @@ pub unsafe fn dacl_effective_allows_mask( } #[allow(dead_code)] -const WRITE_ALLOW_MASK: u32 = FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE; +const WRITE_ALLOW_MASK: u32 = FILE_GENERIC_READ + | FILE_GENERIC_WRITE + | FILE_GENERIC_EXECUTE + | DELETE + | FILE_DELETE_CHILD; /// Ensure all provided SIDs have a write-capable allow ACE on the path. /// Returns true if any ACE was added. +/// +/// # Safety +/// Caller must pass valid SID pointers and an existing path; free the returned security descriptor with `LocalFree`. #[allow(dead_code)] pub unsafe fn ensure_allow_write_aces(path: &Path, sids: &[*mut c_void]) -> Result { let (p_dacl, p_sd) = fetch_dacl_handle(path)?; let mut entries: Vec = Vec::new(); for sid in sids { - if dacl_quick_mask_allows(p_dacl, &[*sid], WRITE_ALLOW_MASK) { + if dacl_mask_allows(p_dacl, &[*sid], WRITE_ALLOW_MASK, true) { continue; } entries.push(EXPLICIT_ACCESS_W { diff --git a/codex-rs/windows-sandbox-rs/src/audit.rs b/codex-rs/windows-sandbox-rs/src/audit.rs index 7e35bf7517b..e0a8970fe9b 100644 --- a/codex-rs/windows-sandbox-rs/src/audit.rs +++ b/codex-rs/windows-sandbox-rs/src/audit.rs @@ -1,5 +1,5 @@ use crate::acl::add_deny_write_ace; -use crate::acl::path_quick_mask_allows; +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}; @@ -84,7 +84,7 @@ 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_quick_mask_allows(path, &[psid_world], write_mask) + path_mask_allows(path, &[psid_world], write_mask, false) } pub fn audit_everyone_writable( diff --git a/codex-rs/windows-sandbox-rs/src/bin/setup_main.rs b/codex-rs/windows-sandbox-rs/src/bin/setup_main.rs new file mode 100644 index 00000000000..c3bc5724f3b --- /dev/null +++ b/codex-rs/windows-sandbox-rs/src/bin/setup_main.rs @@ -0,0 +1,12 @@ +#[path = "../setup_main_win.rs"] +mod win; + +#[cfg(target_os = "windows")] +fn main() -> anyhow::Result<()> { + win::main() +} + +#[cfg(not(target_os = "windows"))] +fn main() { + panic!("codex-windows-sandbox-setup is Windows-only"); +} diff --git a/codex-rs/windows-sandbox-rs/src/dpapi.rs b/codex-rs/windows-sandbox-rs/src/dpapi.rs new file mode 100644 index 00000000000..d254fa11693 --- /dev/null +++ b/codex-rs/windows-sandbox-rs/src/dpapi.rs @@ -0,0 +1,81 @@ +use anyhow::anyhow; +use anyhow::Result; +use windows_sys::Win32::Foundation::GetLastError; +use windows_sys::Win32::Foundation::HLOCAL; +use windows_sys::Win32::Foundation::LocalFree; +use windows_sys::Win32::Security::Cryptography::CryptProtectData; +use windows_sys::Win32::Security::Cryptography::CryptUnprotectData; +use windows_sys::Win32::Security::Cryptography::CRYPT_INTEGER_BLOB; +use windows_sys::Win32::Security::Cryptography::CRYPTPROTECT_UI_FORBIDDEN; + +fn make_blob(data: &[u8]) -> CRYPT_INTEGER_BLOB { + CRYPT_INTEGER_BLOB { + cbData: data.len() as u32, + pbData: data.as_ptr() as *mut u8, + } +} + +#[allow(clippy::unnecessary_mut_passed)] +pub fn protect(data: &[u8]) -> Result> { + let mut in_blob = make_blob(data); + let mut out_blob = CRYPT_INTEGER_BLOB { + cbData: 0, + pbData: std::ptr::null_mut(), + }; + let ok = unsafe { + CryptProtectData( + &mut in_blob, + std::ptr::null(), + std::ptr::null(), + std::ptr::null_mut(), + std::ptr::null_mut(), + CRYPTPROTECT_UI_FORBIDDEN, + &mut out_blob, + ) + }; + if ok == 0 { + return Err(anyhow!("CryptProtectData failed: {}", unsafe { GetLastError() })); + } + let slice = + unsafe { std::slice::from_raw_parts(out_blob.pbData, out_blob.cbData as usize) }.to_vec(); + unsafe { + if !out_blob.pbData.is_null() { + LocalFree(out_blob.pbData as HLOCAL); + } + } + Ok(slice) +} + +#[allow(clippy::unnecessary_mut_passed)] +pub fn unprotect(blob: &[u8]) -> Result> { + let mut in_blob = make_blob(blob); + let mut out_blob = CRYPT_INTEGER_BLOB { + cbData: 0, + pbData: std::ptr::null_mut(), + }; + let ok = unsafe { + CryptUnprotectData( + &mut in_blob, + std::ptr::null_mut(), + std::ptr::null(), + std::ptr::null_mut(), + std::ptr::null_mut(), + CRYPTPROTECT_UI_FORBIDDEN, + &mut out_blob, + ) + }; + if ok == 0 { + return Err(anyhow!( + "CryptUnprotectData failed: {}", + unsafe { GetLastError() } + )); + } + let slice = + unsafe { std::slice::from_raw_parts(out_blob.pbData, out_blob.cbData as usize) }.to_vec(); + unsafe { + if !out_blob.pbData.is_null() { + LocalFree(out_blob.pbData as HLOCAL); + } + } + Ok(slice) +} diff --git a/codex-rs/windows-sandbox-rs/src/identity.rs b/codex-rs/windows-sandbox-rs/src/identity.rs new file mode 100644 index 00000000000..1c984dd9279 --- /dev/null +++ b/codex-rs/windows-sandbox-rs/src/identity.rs @@ -0,0 +1,171 @@ +use crate::dpapi; +use crate::logging::debug_log; +use crate::policy::SandboxPolicy; +use crate::setup::run_elevated_setup; +use crate::setup::sandbox_users_path; +use crate::setup::setup_marker_path; +use crate::setup::SandboxUserRecord; +use crate::setup::SandboxUsersFile; +use crate::setup::SetupMarker; +use crate::setup::{gather_read_roots, gather_write_roots}; +use anyhow::anyhow; +use anyhow::Context; +use anyhow::Result; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use base64::Engine; +use std::collections::HashMap; +use std::fs; +use std::path::Path; + +#[derive(Debug, Clone)] +struct SandboxIdentity { + username: String, + password: String, +} + +#[derive(Debug, Clone)] +pub struct SandboxCreds { + pub username: String, + pub password: String, +} + +fn load_marker(codex_home: &Path) -> Result> { + let path = setup_marker_path(codex_home); + let marker = match fs::read_to_string(&path) { + Ok(contents) => match serde_json::from_str::(&contents) { + Ok(m) => Some(m), + Err(err) => { + debug_log( + &format!("sandbox setup marker parse failed: {err}"), + Some(codex_home), + ); + None + } + }, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => None, + Err(err) => { + debug_log( + &format!("sandbox setup marker read failed: {err}"), + Some(codex_home), + ); + None + } + }; + Ok(marker) +} + +fn load_users(codex_home: &Path) -> Result> { + let path = sandbox_users_path(codex_home); + let file = match fs::read_to_string(&path) { + Ok(contents) => contents, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => { + debug_log( + &format!("sandbox users read failed: {err}"), + Some(codex_home), + ); + return Ok(None); + } + }; + match serde_json::from_str::(&file) { + Ok(users) => Ok(Some(users)), + Err(err) => { + debug_log( + &format!("sandbox users parse failed: {err}"), + Some(codex_home), + ); + Ok(None) + } + } +} + +fn decode_password(record: &SandboxUserRecord) -> Result { + let blob = BASE64_STANDARD + .decode(record.password.as_bytes()) + .context("base64 decode password")?; + let decrypted = dpapi::unprotect(&blob)?; + let pwd = String::from_utf8(decrypted).context("sandbox password not utf-8")?; + Ok(pwd) +} + +fn select_identity(policy: &SandboxPolicy, codex_home: &Path) -> Result> { + let _marker = match load_marker(codex_home)? { + Some(m) if m.version_matches() => m, + _ => return Ok(None), + }; + let users = match load_users(codex_home)? { + Some(u) if u.version_matches() => u, + _ => return Ok(None), + }; + let chosen = if !policy.has_full_network_access() { + users.offline + } else { + users.online + }; + let password = decode_password(&chosen)?; + Ok(Some(SandboxIdentity { + username: chosen.username.clone(), + password, + })) +} + +pub fn require_logon_sandbox_creds( + policy: &SandboxPolicy, + policy_cwd: &Path, + command_cwd: &Path, + env_map: &HashMap, + codex_home: &Path, +) -> Result { + let sandbox_dir = crate::setup::sandbox_dir(codex_home); + let needed_read = gather_read_roots(command_cwd, policy, policy_cwd); + let mut needed_write = gather_write_roots(policy, policy_cwd, command_cwd, env_map); + // Ensure the sandbox directory itself is writable by sandbox users. + needed_write.push(sandbox_dir.clone()); + let mut setup_reason: Option = None; + let mut _existing_marker: Option = None; + + let mut identity = match load_marker(codex_home)? { + Some(marker) if marker.version_matches() => { + _existing_marker = Some(marker.clone()); + let selected = select_identity(policy, codex_home)?; + if selected.is_none() { + setup_reason = + Some("sandbox users missing or incompatible with marker version".to_string()); + } + selected + } + _ => { + setup_reason = Some("sandbox setup marker missing or incompatible".to_string()); + None + } + }; + + if identity.is_none() { + if let Some(reason) = &setup_reason { + crate::logging::log_note(&format!("sandbox setup required: {reason}"), Some(&sandbox_dir)); + } else { + crate::logging::log_note("sandbox setup required", Some(&sandbox_dir)); + } + run_elevated_setup( + policy, + policy_cwd, + command_cwd, + env_map, + codex_home, + Some(needed_read.clone()), + Some(needed_write.clone()), + )?; + identity = select_identity(policy, codex_home)?; + } + // Always refresh ACLs (non-elevated) for current roots via the setup binary. + crate::setup::run_setup_refresh(policy, policy_cwd, command_cwd, env_map, codex_home)?; + let identity = identity.ok_or_else(|| { + anyhow!( + "Windows sandbox setup is missing or out of date; rerun the sandbox setup with elevation" + ) + })?; + Ok(SandboxCreds { + username: identity.username, + password: identity.password, + }) +} diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index 45595052ab7..56b59e387ca 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -4,14 +4,50 @@ macro_rules! windows_modules { }; } -windows_modules!(acl, allow, audit, cap, env, logging, policy, token, winutil); +windows_modules!( + acl, allow, audit, cap, dpapi, env, identity, logging, policy, process, token, winutil +); +#[cfg(target_os = "windows")] +#[path = "setup_orchestrator.rs"] +mod setup; + +#[cfg(target_os = "windows")] +pub use acl::ensure_allow_write_aces; +#[cfg(target_os = "windows")] +pub use acl::fetch_dacl_handle; +#[cfg(target_os = "windows")] +pub use acl::path_mask_allows; #[cfg(target_os = "windows")] pub use audit::apply_world_writable_scan_and_denies; #[cfg(target_os = "windows")] +pub use cap::load_or_create_cap_sids; +#[cfg(target_os = "windows")] +pub use dpapi::protect as dpapi_protect; +#[cfg(target_os = "windows")] +pub use dpapi::unprotect as dpapi_unprotect; +#[cfg(target_os = "windows")] +pub use identity::require_logon_sandbox_creds; +#[cfg(target_os = "windows")] +pub use logging::log_note; +#[cfg(target_os = "windows")] +pub use logging::LOG_FILE_NAME; +#[cfg(target_os = "windows")] +pub use setup::run_elevated_setup; +#[cfg(target_os = "windows")] +pub use setup::run_setup_refresh; +#[cfg(target_os = "windows")] +pub use setup::sandbox_dir; +#[cfg(target_os = "windows")] +pub use setup::SETUP_VERSION; +#[cfg(target_os = "windows")] +pub use token::convert_string_sid_to_sid; +#[cfg(target_os = "windows")] pub use windows_impl::run_windows_sandbox_capture; #[cfg(target_os = "windows")] pub use windows_impl::CaptureResult; +#[cfg(target_os = "windows")] +pub use winutil::string_from_sid_bytes; #[cfg(not(target_os = "windows"))] pub use stub::apply_world_writable_scan_and_denies; diff --git a/codex-rs/windows-sandbox-rs/src/process.rs b/codex-rs/windows-sandbox-rs/src/process.rs index 9f73e5d0d43..978dcfe60a5 100644 --- a/codex-rs/windows-sandbox-rs/src/process.rs +++ b/codex-rs/windows-sandbox-rs/src/process.rs @@ -30,6 +30,7 @@ use windows_sys::Win32::System::Threading::PROCESS_INFORMATION; use windows_sys::Win32::System::Threading::STARTF_USESTDHANDLES; use windows_sys::Win32::System::Threading::STARTUPINFOW; +#[allow(dead_code)] pub fn make_env_block(env: &HashMap) -> Vec { let mut items: Vec<(String, String)> = env.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); @@ -49,6 +50,7 @@ pub fn make_env_block(env: &HashMap) -> Vec { w } +#[allow(dead_code)] fn quote_arg(a: &str) -> String { let needs_quote = a.is_empty() || a.chars().any(|ch| ch.is_whitespace() || ch == '"'); if !needs_quote { @@ -79,6 +81,7 @@ fn quote_arg(a: &str) -> String { out.push('"'); out } + #[allow(dead_code)] unsafe fn ensure_inheritable_stdio(si: &mut STARTUPINFOW) -> Result<()> { for kind in [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE] { @@ -100,6 +103,7 @@ unsafe fn ensure_inheritable_stdio(si: &mut STARTUPINFOW) -> Result<()> { /// # Safety /// Caller must provide a valid primary token handle (`h_token`) with appropriate access, /// and the `argv`, `cwd`, and `env_map` must remain valid for the duration of the call. +#[allow(dead_code)] pub unsafe fn create_process_as_user( h_token: HANDLE, argv: &[String], @@ -156,7 +160,7 @@ pub unsafe fn create_process_as_user( CREATE_UNICODE_ENVIRONMENT, env_block.as_ptr() as *mut c_void, to_wide(cwd).as_ptr(), - &mut si, + &si, &mut pi, ); if ok == 0 { diff --git a/codex-rs/windows-sandbox-rs/src/setup_main_win.rs b/codex-rs/windows-sandbox-rs/src/setup_main_win.rs new file mode 100644 index 00000000000..80433ebd557 --- /dev/null +++ b/codex-rs/windows-sandbox-rs/src/setup_main_win.rs @@ -0,0 +1,913 @@ +#![cfg(target_os = "windows")] + +use anyhow::Context; +use anyhow::Result; +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine; +use codex_windows_sandbox::convert_string_sid_to_sid; +use codex_windows_sandbox::dpapi_protect; +use codex_windows_sandbox::ensure_allow_write_aces; +use codex_windows_sandbox::fetch_dacl_handle; +use codex_windows_sandbox::load_or_create_cap_sids; +use codex_windows_sandbox::log_note; +use codex_windows_sandbox::path_mask_allows; +use codex_windows_sandbox::sandbox_dir; +use codex_windows_sandbox::string_from_sid_bytes; +use codex_windows_sandbox::LOG_FILE_NAME; +use codex_windows_sandbox::SETUP_VERSION; +use rand::rngs::SmallRng; +use rand::RngCore; +use rand::SeedableRng; +use serde::Deserialize; +use serde::Serialize; +use std::ffi::c_void; +use std::ffi::OsStr; +use std::fs::File; +use std::io::Write; +use std::os::windows::ffi::OsStrExt; +use std::path::Path; +use std::path::PathBuf; +use std::sync::mpsc; +use std::time::Duration; +use windows::core::Interface; +use windows::core::BSTR; +use windows::Win32::Foundation::VARIANT_TRUE; +use windows::Win32::NetworkManagement::WindowsFirewall::INetFwPolicy2; +use windows::Win32::NetworkManagement::WindowsFirewall::INetFwRule3; +use windows::Win32::NetworkManagement::WindowsFirewall::NetFwPolicy2; +use windows::Win32::NetworkManagement::WindowsFirewall::NetFwRule; +use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_ACTION_BLOCK; +use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_IP_PROTOCOL_ANY; +use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_PROFILE2_ALL; +use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_RULE_DIR_OUT; +use windows::Win32::System::Com::CoCreateInstance; +use windows::Win32::System::Com::CoInitializeEx; +use windows::Win32::System::Com::CoUninitialize; +use windows::Win32::System::Com::CLSCTX_INPROC_SERVER; +use windows::Win32::System::Com::COINIT_APARTMENTTHREADED; +use windows_sys::Win32::Foundation::GetLastError; +use windows_sys::Win32::Foundation::LocalFree; +use windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER; +use windows_sys::Win32::Foundation::HLOCAL; +use windows_sys::Win32::NetworkManagement::NetManagement::NERR_Success; +use windows_sys::Win32::NetworkManagement::NetManagement::NetLocalGroupAddMembers; +use windows_sys::Win32::NetworkManagement::NetManagement::NetUserAdd; +use windows_sys::Win32::NetworkManagement::NetManagement::NetUserSetInfo; +use windows_sys::Win32::NetworkManagement::NetManagement::LOCALGROUP_MEMBERS_INFO_3; +use windows_sys::Win32::NetworkManagement::NetManagement::UF_DONT_EXPIRE_PASSWD; +use windows_sys::Win32::NetworkManagement::NetManagement::UF_SCRIPT; +use windows_sys::Win32::NetworkManagement::NetManagement::USER_INFO_1; +use windows_sys::Win32::NetworkManagement::NetManagement::USER_INFO_1003; +use windows_sys::Win32::NetworkManagement::NetManagement::USER_PRIV_USER; +use windows_sys::Win32::Security::Authorization::ConvertStringSidToSidW; +use windows_sys::Win32::Security::Authorization::SetEntriesInAclW; +use windows_sys::Win32::Security::Authorization::SetNamedSecurityInfoW; +use windows_sys::Win32::Security::Authorization::EXPLICIT_ACCESS_W; +use windows_sys::Win32::Security::Authorization::GRANT_ACCESS; +use windows_sys::Win32::Security::Authorization::SE_FILE_OBJECT; +use windows_sys::Win32::Security::Authorization::TRUSTEE_IS_SID; +use windows_sys::Win32::Security::Authorization::TRUSTEE_W; +use windows_sys::Win32::Security::LookupAccountNameW; +use windows_sys::Win32::Security::ACL; +use windows_sys::Win32::Security::CONTAINER_INHERIT_ACE; +use windows_sys::Win32::Security::DACL_SECURITY_INFORMATION; +use windows_sys::Win32::Security::OBJECT_INHERIT_ACE; +use windows_sys::Win32::Security::SID_NAME_USE; +use windows_sys::Win32::Storage::FileSystem::DELETE; +use windows_sys::Win32::Storage::FileSystem::FILE_DELETE_CHILD; +use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_EXECUTE; +use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_READ; +use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_WRITE; + +#[derive(Debug, Deserialize)] +struct Payload { + version: u32, + offline_username: String, + online_username: String, + codex_home: PathBuf, + read_roots: Vec, + write_roots: Vec, + real_user: String, + #[serde(default)] + refresh_only: bool, +} + +#[derive(Serialize)] +struct SandboxUserRecord { + username: String, + password: String, +} + +#[derive(Serialize)] +struct SandboxUsersFile { + version: u32, + offline: SandboxUserRecord, + online: SandboxUserRecord, +} + +#[derive(Serialize)] +struct SetupMarker { + version: u32, + offline_username: String, + online_username: String, + created_at: String, + read_roots: Vec, + write_roots: Vec, +} + +fn log_line(log: &mut File, msg: &str) -> Result<()> { + let ts = chrono::Utc::now().to_rfc3339(); + writeln!(log, "[{ts}] {msg}")?; + Ok(()) +} + +fn to_wide(s: &OsStr) -> Vec { + let mut v: Vec = s.encode_wide().collect(); + v.push(0); + v +} + +fn random_password() -> String { + const CHARS: &[u8] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+"; + let mut rng = SmallRng::from_entropy(); + let mut buf = [0u8; 24]; + rng.fill_bytes(&mut buf); + buf.iter() + .map(|b| { + let idx = (*b as usize) % CHARS.len(); + CHARS[idx] as char + }) + .collect() +} + +fn sid_bytes_to_psid(sid: &[u8]) -> Result<*mut c_void> { + let sid_str = string_from_sid_bytes(sid).map_err(anyhow::Error::msg)?; + let sid_w = to_wide(OsStr::new(&sid_str)); + let mut psid: *mut c_void = std::ptr::null_mut(); + if unsafe { ConvertStringSidToSidW(sid_w.as_ptr(), &mut psid) } == 0 { + return Err(anyhow::anyhow!( + "ConvertStringSidToSidW failed: {}", + unsafe { GetLastError() } + )); + } + Ok(psid) +} + +fn ensure_local_user(name: &str, password: &str, log: &mut File) -> Result<()> { + let name_w = to_wide(OsStr::new(name)); + let pwd_w = to_wide(OsStr::new(password)); + unsafe { + let info = USER_INFO_1 { + usri1_name: name_w.as_ptr() as *mut u16, + usri1_password: pwd_w.as_ptr() as *mut u16, + usri1_password_age: 0, + usri1_priv: USER_PRIV_USER, + usri1_home_dir: std::ptr::null_mut(), + usri1_comment: std::ptr::null_mut(), + usri1_flags: UF_SCRIPT | UF_DONT_EXPIRE_PASSWD, + usri1_script_path: std::ptr::null_mut(), + }; + let status = NetUserAdd( + std::ptr::null(), + 1, + &info as *const _ as *mut u8, + std::ptr::null_mut(), + ); + if status != NERR_Success { + // Try update password via level 1003. + let pw_info = USER_INFO_1003 { + usri1003_password: pwd_w.as_ptr() as *mut u16, + }; + let upd = NetUserSetInfo( + std::ptr::null(), + name_w.as_ptr(), + 1003, + &pw_info as *const _ as *mut u8, + std::ptr::null_mut(), + ); + if upd != NERR_Success { + log_line(log, &format!("NetUserSetInfo failed for {name} code {upd}"))?; + return Err(anyhow::anyhow!( + "failed to create/update user {name}, code {status}/{upd}" + )); + } + } + let group = to_wide(OsStr::new("Users")); + let member = LOCALGROUP_MEMBERS_INFO_3 { + lgrmi3_domainandname: name_w.as_ptr() as *mut u16, + }; + let _ = NetLocalGroupAddMembers( + std::ptr::null(), + group.as_ptr(), + 3, + &member as *const _ as *mut u8, + 1, + ); + } + Ok(()) +} + +fn resolve_sid(name: &str) -> Result> { + let name_w = to_wide(OsStr::new(name)); + let mut sid_buffer = vec![0u8; 68]; + let mut sid_len: u32 = sid_buffer.len() as u32; + let mut domain: Vec = Vec::new(); + let mut domain_len: u32 = 0; + let mut use_type: SID_NAME_USE = 0; + loop { + let ok = unsafe { + LookupAccountNameW( + std::ptr::null(), + name_w.as_ptr(), + sid_buffer.as_mut_ptr() as *mut c_void, + &mut sid_len, + domain.as_mut_ptr(), + &mut domain_len, + &mut use_type, + ) + }; + if ok != 0 { + sid_buffer.truncate(sid_len as usize); + return Ok(sid_buffer); + } + let err = unsafe { GetLastError() }; + if err == ERROR_INSUFFICIENT_BUFFER { + sid_buffer.resize(sid_len as usize, 0); + domain.resize(domain_len as usize, 0); + continue; + } + return Err(anyhow::anyhow!( + "LookupAccountNameW failed for {name}: {}", + err + )); + } +} + +fn add_inheritable_allow_no_log(path: &Path, sid: &[u8], mask: u32) -> Result<()> { + unsafe { + let mut psid: *mut c_void = std::ptr::null_mut(); + let sid_str = string_from_sid_bytes(sid).map_err(anyhow::Error::msg)?; + let sid_w = to_wide(OsStr::new(&sid_str)); + if ConvertStringSidToSidW(sid_w.as_ptr(), &mut psid) == 0 { + return Err(anyhow::anyhow!( + "ConvertStringSidToSidW failed: {}", + GetLastError() + )); + } + let (existing_dacl, sd) = fetch_dacl_handle(path)?; + let trustee = TRUSTEE_W { + pMultipleTrustee: std::ptr::null_mut(), + MultipleTrusteeOperation: 0, + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: TRUSTEE_IS_SID, + ptstrName: psid as *mut u16, + }; + let ea = EXPLICIT_ACCESS_W { + grfAccessPermissions: mask, + grfAccessMode: GRANT_ACCESS, + grfInheritance: OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE, + Trustee: trustee, + }; + let mut new_dacl: *mut ACL = std::ptr::null_mut(); + let set = SetEntriesInAclW(1, &ea, existing_dacl, &mut new_dacl); + if set != 0 { + return Err(anyhow::anyhow!("SetEntriesInAclW failed: {}", set)); + } + let res = SetNamedSecurityInfoW( + to_wide(path.as_os_str()).as_ptr() as *mut u16, + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + new_dacl, + std::ptr::null_mut(), + ); + if res != 0 { + return Err(anyhow::anyhow!( + "SetNamedSecurityInfoW failed for {}: {}", + path.display(), + res + )); + } + if !new_dacl.is_null() { + LocalFree(new_dacl as HLOCAL); + } + if !sd.is_null() { + LocalFree(sd as HLOCAL); + } + if !psid.is_null() { + LocalFree(psid as HLOCAL); + } + } + Ok(()) +} + +fn try_add_inheritable_allow_with_timeout( + path: &Path, + sid: &[u8], + mask: u32, + _log: &mut File, + timeout: Duration, +) -> Result<()> { + let (tx, rx) = mpsc::channel::>(); + let path_buf = path.to_path_buf(); + let sid_vec = sid.to_vec(); + std::thread::spawn(move || { + let res = add_inheritable_allow_no_log(&path_buf, &sid_vec, mask); + let _ = tx.send(res); + }); + match rx.recv_timeout(timeout) { + Ok(res) => res, + Err(mpsc::RecvTimeoutError::Timeout) => Err(anyhow::anyhow!( + "ACL grant timed out on {} after {:?}", + path.display(), + timeout + )), + Err(e) => Err(anyhow::anyhow!( + "ACL grant channel error on {}: {e}", + path.display() + )), + } +} + +fn run_netsh_firewall(sid: &str, log: &mut File) -> Result<()> { + let local_user_spec = format!("O:LSD:(A;;CC;;;{sid})"); + let hr = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) }; + if hr.is_err() { + return Err(anyhow::anyhow!("CoInitializeEx failed: {hr:?}")); + } + let result = unsafe { + (|| -> Result<()> { + let policy: INetFwPolicy2 = CoCreateInstance(&NetFwPolicy2, None, CLSCTX_INPROC_SERVER) + .map_err(|e| anyhow::anyhow!("CoCreateInstance NetFwPolicy2: {e:?}"))?; + let rules = policy + .Rules() + .map_err(|e| anyhow::anyhow!("INetFwPolicy2::Rules: {e:?}"))?; + let name = BSTR::from("Codex Sandbox Offline - Block Outbound"); + let rule: INetFwRule3 = match rules.Item(&name) { + Ok(existing) => existing.cast().map_err(|e| { + anyhow::anyhow!("cast existing firewall rule to INetFwRule3: {e:?}") + })?, + Err(_) => { + let new_rule: INetFwRule3 = + CoCreateInstance(&NetFwRule, None, CLSCTX_INPROC_SERVER) + .map_err(|e| anyhow::anyhow!("CoCreateInstance NetFwRule: {e:?}"))?; + new_rule + .SetName(&name) + .map_err(|e| anyhow::anyhow!("SetName: {e:?}"))?; + new_rule + .SetDirection(NET_FW_RULE_DIR_OUT) + .map_err(|e| anyhow::anyhow!("SetDirection: {e:?}"))?; + new_rule + .SetAction(NET_FW_ACTION_BLOCK) + .map_err(|e| anyhow::anyhow!("SetAction: {e:?}"))?; + new_rule + .SetEnabled(VARIANT_TRUE) + .map_err(|e| anyhow::anyhow!("SetEnabled: {e:?}"))?; + new_rule + .SetProfiles(NET_FW_PROFILE2_ALL.0) + .map_err(|e| anyhow::anyhow!("SetProfiles: {e:?}"))?; + new_rule + .SetProtocol(NET_FW_IP_PROTOCOL_ANY.0) + .map_err(|e| anyhow::anyhow!("SetProtocol: {e:?}"))?; + rules + .Add(&new_rule) + .map_err(|e| anyhow::anyhow!("Rules::Add: {e:?}"))?; + new_rule + } + }; + rule.SetLocalUserAuthorizedList(&BSTR::from(local_user_spec.as_str())) + .map_err(|e| anyhow::anyhow!("SetLocalUserAuthorizedList: {e:?}"))?; + rule.SetEnabled(VARIANT_TRUE) + .map_err(|e| anyhow::anyhow!("SetEnabled: {e:?}"))?; + rule.SetProfiles(NET_FW_PROFILE2_ALL.0) + .map_err(|e| anyhow::anyhow!("SetProfiles: {e:?}"))?; + rule.SetAction(NET_FW_ACTION_BLOCK) + .map_err(|e| anyhow::anyhow!("SetAction: {e:?}"))?; + rule.SetDirection(NET_FW_RULE_DIR_OUT) + .map_err(|e| anyhow::anyhow!("SetDirection: {e:?}"))?; + rule.SetProtocol(NET_FW_IP_PROTOCOL_ANY.0) + .map_err(|e| anyhow::anyhow!("SetProtocol: {e:?}"))?; + log_line( + log, + &format!( + "firewall rule configured via COM with LocalUserAuthorizedList={local_user_spec}" + ), + )?; + Ok(()) + })() + }; + unsafe { + CoUninitialize(); + } + result +} + +fn lock_sandbox_dir( + dir: &Path, + real_user: &str, + sandbox_user_sids: &[Vec], + log: &mut File, +) -> Result<()> { + std::fs::create_dir_all(dir)?; + let system_sid = resolve_sid("SYSTEM")?; + let admins_sid = resolve_sid("Administrators")?; + let real_sid = resolve_sid(real_user)?; + let entries = [ + ( + system_sid, + FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | DELETE, + ), + ( + admins_sid, + FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | DELETE, + ), + ( + real_sid, + FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE, + ), + ]; + let sandbox_entries: Vec<(Vec, u32)> = sandbox_user_sids + .iter() + .map(|sid| { + ( + sid.clone(), + FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | DELETE, + ) + }) + .collect(); + unsafe { + let mut eas: Vec = Vec::new(); + let mut sids: Vec<*mut c_void> = Vec::new(); + for (sid_bytes, mask) in entries + .iter() + .map(|(s, m)| (s, *m)) + .chain(sandbox_entries.iter().map(|(s, m)| (s, *m))) + { + let sid_str = string_from_sid_bytes(sid_bytes).map_err(anyhow::Error::msg)?; + let sid_w = to_wide(OsStr::new(&sid_str)); + let mut psid: *mut c_void = std::ptr::null_mut(); + if ConvertStringSidToSidW(sid_w.as_ptr(), &mut psid) == 0 { + return Err(anyhow::anyhow!( + "ConvertStringSidToSidW failed: {}", + GetLastError() + )); + } + sids.push(psid); + eas.push(EXPLICIT_ACCESS_W { + grfAccessPermissions: mask, + grfAccessMode: GRANT_ACCESS, + grfInheritance: OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE, + Trustee: TRUSTEE_W { + pMultipleTrustee: std::ptr::null_mut(), + MultipleTrusteeOperation: 0, + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: TRUSTEE_IS_SID, + ptstrName: psid as *mut u16, + }, + }); + } + let mut new_dacl: *mut ACL = std::ptr::null_mut(); + let set = SetEntriesInAclW( + eas.len() as u32, + eas.as_ptr(), + std::ptr::null_mut(), + &mut new_dacl, + ); + if set != 0 { + return Err(anyhow::anyhow!( + "SetEntriesInAclW sandbox dir failed: {}", + set + )); + } + let path_w = to_wide(dir.as_os_str()); + let res = SetNamedSecurityInfoW( + path_w.as_ptr() as *mut u16, + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + new_dacl, + std::ptr::null_mut(), + ); + if res != 0 { + return Err(anyhow::anyhow!( + "SetNamedSecurityInfoW sandbox dir failed: {}", + res + )); + } + if !new_dacl.is_null() { + LocalFree(new_dacl as HLOCAL); + } + for sid in sids { + if !sid.is_null() { + LocalFree(sid as HLOCAL); + } + } + } + log_line( + log, + &format!("sandbox dir ACL applied at {}", dir.display()), + )?; + Ok(()) +} + +fn write_secrets( + codex_home: &Path, + offline_user: &str, + offline_pwd: &str, + online_user: &str, + online_pwd: &str, + _read_roots: &[PathBuf], + _write_roots: &[PathBuf], +) -> Result<()> { + let sandbox_dir = sandbox_dir(codex_home); + std::fs::create_dir_all(&sandbox_dir)?; + let offline_blob = dpapi_protect(offline_pwd.as_bytes())?; + let online_blob = dpapi_protect(online_pwd.as_bytes())?; + let users = SandboxUsersFile { + version: SETUP_VERSION, + offline: SandboxUserRecord { + username: offline_user.to_string(), + password: BASE64.encode(offline_blob), + }, + online: SandboxUserRecord { + username: online_user.to_string(), + password: BASE64.encode(online_blob), + }, + }; + let marker = SetupMarker { + version: SETUP_VERSION, + offline_username: offline_user.to_string(), + online_username: online_user.to_string(), + created_at: chrono::Utc::now().to_rfc3339(), + read_roots: Vec::new(), + write_roots: Vec::new(), + }; + let users_path = sandbox_dir.join("sandbox_users.json"); + let marker_path = sandbox_dir.join("setup_marker.json"); + std::fs::write(users_path, serde_json::to_vec_pretty(&users)?)?; + std::fs::write(marker_path, serde_json::to_vec_pretty(&marker)?)?; + Ok(()) +} + +pub fn main() -> Result<()> { + let ret = real_main(); + if let Err(e) = &ret { + // Best-effort: log unexpected top-level errors. + if let Ok(codex_home) = std::env::var("CODEX_HOME") { + let sbx_dir = sandbox_dir(Path::new(&codex_home)); + let _ = std::fs::create_dir_all(&sbx_dir); + let log_path = sbx_dir.join(LOG_FILE_NAME); + if let Ok(mut f) = File::options().create(true).append(true).open(&log_path) { + let _ = writeln!( + f, + "[{}] top-level error: {}", + chrono::Utc::now().to_rfc3339(), + e + ); + } + } + } + ret +} + +fn real_main() -> Result<()> { + let mut args = std::env::args().collect::>(); + if args.len() != 2 { + anyhow::bail!("expected payload argument"); + } + let payload_b64 = args.remove(1); + let payload_json = BASE64 + .decode(payload_b64) + .context("failed to decode payload b64")?; + let payload: Payload = + serde_json::from_slice(&payload_json).context("failed to parse payload json")?; + if payload.version != SETUP_VERSION { + anyhow::bail!("setup version mismatch"); + } + let sbx_dir = sandbox_dir(&payload.codex_home); + std::fs::create_dir_all(&sbx_dir)?; + let log_path = sbx_dir.join(LOG_FILE_NAME); + let mut log = File::options() + .create(true) + .append(true) + .open(&log_path) + .context("open log")?; + log_line(&mut log, "setup binary started")?; + log_note("setup binary started", Some(sbx_dir.as_path())); + let result = run_setup(&payload, &mut log, &sbx_dir); + if let Err(err) = &result { + let _ = log_line(&mut log, &format!("setup error: {err:?}")); + log_note(&format!("setup error: {err:?}"), Some(sbx_dir.as_path())); + } + result +} + +fn run_setup(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<()> { + let refresh_only = payload.refresh_only; + let offline_pwd = if refresh_only { + None + } else { + Some(random_password()) + }; + let online_pwd = if refresh_only { + None + } else { + Some(random_password()) + }; + if refresh_only { + log_line(log, "refresh-only mode: skipping user creation/firewall")?; + } else { + log_line( + log, + &format!( + "ensuring sandbox users offline={} online={}", + payload.offline_username, payload.online_username + ), + )?; + ensure_local_user( + &payload.offline_username, + offline_pwd.as_ref().unwrap(), + log, + )?; + ensure_local_user(&payload.online_username, online_pwd.as_ref().unwrap(), log)?; + } + let offline_sid = resolve_sid(&payload.offline_username)?; + let online_sid = resolve_sid(&payload.online_username)?; + let offline_psid = sid_bytes_to_psid(&offline_sid)?; + let online_psid = sid_bytes_to_psid(&online_sid)?; + let offline_sid_str = string_from_sid_bytes(&offline_sid).map_err(anyhow::Error::msg)?; + log_line( + log, + &format!( + "resolved SIDs offline={} online={}", + offline_sid_str, + string_from_sid_bytes(&online_sid).map_err(anyhow::Error::msg)? + ), + )?; + let caps = load_or_create_cap_sids(&payload.codex_home); + let cap_psid = unsafe { + convert_string_sid_to_sid(&caps.workspace) + .ok_or_else(|| anyhow::anyhow!("convert capability SID failed"))? + }; + let mut refresh_errors: Vec = Vec::new(); + let users_sid = resolve_sid("Users")?; + let users_psid = sid_bytes_to_psid(&users_sid)?; + let auth_sid = resolve_sid("Authenticated Users")?; + let auth_psid = sid_bytes_to_psid(&auth_sid)?; + let everyone_sid = resolve_sid("Everyone")?; + let everyone_psid = sid_bytes_to_psid(&everyone_sid)?; + let rx_psids = vec![users_psid, auth_psid, everyone_psid]; + log_line(log, &format!("resolved capability SID {}", caps.workspace))?; + if !refresh_only { + run_netsh_firewall(&offline_sid_str, log)?; + } + + log_line( + log, + &format!( + "refresh: processing {} read roots, {} write roots", + payload.read_roots.len(), + payload.write_roots.len() + ), + )?; + for root in &payload.read_roots { + if !root.exists() { + log_line( + log, + &format!("read root {} missing; skipping", root.display()), + )?; + continue; + } + match path_mask_allows( + root, + &rx_psids, + FILE_GENERIC_READ | FILE_GENERIC_EXECUTE, + true, + ) { + Ok(has) => { + if has { + log_line( + log, + &format!( + "Users/AU/Everyone already has RX on {}; skipping", + root.display() + ), + )?; + continue; + } + } + Err(e) => { + refresh_errors.push(format!( + "read mask check failed on {}: {}", + root.display(), + e + )); + log_line( + log, + &format!( + "read mask check failed on {}: {}; continuing", + root.display(), + e + ), + )?; + } + } + log_line( + log, + &format!("granting read ACE to {} for sandbox users", root.display()), + )?; + let mut successes = 0usize; + let read_mask = FILE_GENERIC_READ | FILE_GENERIC_EXECUTE; + for (label, sid_bytes) in [("offline", &offline_sid), ("online", &online_sid)] { + match try_add_inheritable_allow_with_timeout( + root, + sid_bytes, + read_mask, + log, + Duration::from_millis(100), + ) { + Ok(_) => { + successes += 1; + } + Err(e) => { + log_line( + log, + &format!( + "grant read ACE timed out/failed on {} for {label}: {e}", + root.display() + ), + )?; + // Best-effort: continue to next SID/root. + } + } + } + if successes == 2 { + log_line(log, &format!("granted read ACE to {}", root.display()))?; + } else { + log_line( + log, + &format!( + "read ACE incomplete on {} (success {}/2)", + root.display(), + successes + ), + )?; + } + } + + for root in &payload.write_roots { + if !root.exists() { + log_line( + log, + &format!("write root {} missing; skipping", root.display()), + )?; + continue; + } + let sids = vec![offline_psid, online_psid, cap_psid]; + let write_mask = FILE_GENERIC_READ + | FILE_GENERIC_WRITE + | FILE_GENERIC_EXECUTE + | DELETE + | FILE_DELETE_CHILD; + let mut need_grant = false; + for (label, psid) in [ + ("offline", offline_psid), + ("online", online_psid), + ("cap", cap_psid), + ] { + let has = match path_mask_allows(root, &[psid], write_mask, true) { + Ok(h) => h, + Err(e) => { + refresh_errors.push(format!( + "write mask check failed on {} for {label}: {}", + root.display(), + e + )); + log_line( + log, + &format!( + "write mask check failed on {} for {label}: {}; continuing", + root.display(), + e + ), + )?; + false + } + }; + log_line( + log, + &format!( + "write check {label} on {} => {}", + root.display(), + if has { "present" } else { "missing" } + ), + )?; + if !has { + need_grant = true; + } + } + if need_grant { + log_line( + log, + &format!( + "granting write ACE to {} for sandbox users and capability SID", + root.display() + ), + )?; + match unsafe { ensure_allow_write_aces(root, &sids) } { + Ok(res) => { + log_line( + log, + &format!( + "write ACE {} on {}", + if res { "added" } else { "already present" }, + root.display() + ), + )?; + } + Err(e) => { + refresh_errors.push(format!("write ACE failed on {}: {}", root.display(), e)); + log_line( + log, + &format!("write ACE grant failed on {}: {}", root.display(), e), + )?; + } + } + } else { + log_line( + log, + &format!( + "write ACE already present for all sandbox SIDs on {}", + root.display() + ), + )?; + } + } + + if refresh_only { + log_line( + log, + &format!( + "setup refresh: processed {} read roots, {} write roots; errors={:?}", + payload.read_roots.len(), + payload.write_roots.len(), + refresh_errors + ), + )?; + } + if !refresh_only { + lock_sandbox_dir( + &sandbox_dir(&payload.codex_home), + &payload.real_user, + &[offline_sid.clone(), online_sid.clone()], + log, + )?; + log_line(log, "sandbox dir ACL applied")?; + write_secrets( + &payload.codex_home, + &payload.offline_username, + offline_pwd.as_ref().unwrap(), + &payload.online_username, + online_pwd.as_ref().unwrap(), + &payload.read_roots, + &payload.write_roots, + )?; + log_line( + log, + "sandbox users and marker written (sandbox_users.json, setup_marker.json)", + )?; + } + unsafe { + if !offline_psid.is_null() { + LocalFree(offline_psid as HLOCAL); + } + if !online_psid.is_null() { + LocalFree(online_psid as HLOCAL); + } + if !cap_psid.is_null() { + LocalFree(cap_psid as HLOCAL); + } + if !users_psid.is_null() { + LocalFree(users_psid as HLOCAL); + } + if !auth_psid.is_null() { + LocalFree(auth_psid as HLOCAL); + } + if !everyone_psid.is_null() { + LocalFree(everyone_psid as HLOCAL); + } + } + if refresh_only && !refresh_errors.is_empty() { + log_line( + log, + &format!("setup refresh completed with errors: {:?}", refresh_errors), + )?; + anyhow::bail!("setup refresh had errors"); + } + log_line(log, "setup binary completed")?; + log_note("setup binary completed", Some(sbx_dir)); + Ok(()) +} diff --git a/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs b/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs new file mode 100644 index 00000000000..ab2e6e74cbe --- /dev/null +++ b/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs @@ -0,0 +1,392 @@ +use serde::Deserialize; +use serde::Serialize; +use std::collections::HashMap; +use std::ffi::c_void; +use std::os::windows::process::CommandExt; +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; +use std::process::Stdio; + +use crate::allow::compute_allow_paths; +use crate::allow::AllowDenyPaths; +use crate::logging::log_note; +use crate::policy::SandboxPolicy; +use anyhow::anyhow; +use anyhow::Context; +use anyhow::Result; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use base64::Engine; + +use windows_sys::Win32::Foundation::CloseHandle; +use windows_sys::Win32::Foundation::GetLastError; +use windows_sys::Win32::Security::AllocateAndInitializeSid; +use windows_sys::Win32::Security::CheckTokenMembership; +use windows_sys::Win32::Security::FreeSid; +use windows_sys::Win32::Security::SECURITY_NT_AUTHORITY; + +pub const SETUP_VERSION: u32 = 2; +pub const OFFLINE_USERNAME: &str = "CodexSandboxOffline"; +pub const ONLINE_USERNAME: &str = "CodexSandboxOnline"; +const SECURITY_BUILTIN_DOMAIN_RID: u32 = 0x0000_0020; +const DOMAIN_ALIAS_RID_ADMINS: u32 = 0x0000_0220; + +pub fn sandbox_dir(codex_home: &Path) -> PathBuf { + codex_home.join(".sandbox") +} + +pub fn setup_marker_path(codex_home: &Path) -> PathBuf { + sandbox_dir(codex_home).join("setup_marker.json") +} + +pub fn sandbox_users_path(codex_home: &Path) -> PathBuf { + sandbox_dir(codex_home).join("sandbox_users.json") +} + +pub fn run_setup_refresh( + policy: &SandboxPolicy, + policy_cwd: &Path, + command_cwd: &Path, + env_map: &HashMap, + codex_home: &Path, +) -> Result<()> { + // Skip in danger-full-access. + if matches!(policy, SandboxPolicy::DangerFullAccess) { + return Ok(()); + } + let payload = ElevationPayload { + version: SETUP_VERSION, + offline_username: OFFLINE_USERNAME.to_string(), + online_username: ONLINE_USERNAME.to_string(), + codex_home: codex_home.to_path_buf(), + read_roots: gather_read_roots(command_cwd, policy, policy_cwd), + write_roots: gather_write_roots(policy, policy_cwd, command_cwd, env_map), + real_user: std::env::var("USERNAME").unwrap_or_else(|_| "Administrators".to_string()), + refresh_only: true, + }; + let json = serde_json::to_vec(&payload)?; + let b64 = BASE64_STANDARD.encode(json); + let exe = find_setup_exe(); + log_note( + &format!("setup refresh: invoking {}", exe.display()), + Some(&sandbox_dir(codex_home)), + ); + // Refresh should never request elevation; ensure verb isn't set and we don't trigger UAC. + let mut cmd = Command::new(&exe); + cmd.arg(&b64).stdout(Stdio::null()).stderr(Stdio::null()); + let cwd = std::env::current_dir().unwrap_or_else(|_| codex_home.to_path_buf()); + log_note( + &format!( + "setup refresh: spawning {} (cwd={}, payload_len={})", + exe.display(), + cwd.display(), + b64.len() + ), + Some(&sandbox_dir(codex_home)), + ); + let status = cmd + .status() + .map_err(|e| { + log_note( + &format!("setup refresh: failed to spawn {}: {e}", exe.display()), + Some(&sandbox_dir(codex_home)), + ); + e + }) + .context("spawn setup refresh")?; + if !status.success() { + log_note( + &format!("setup refresh: exited with status {status:?}"), + Some(&sandbox_dir(codex_home)), + ); + return Err(anyhow!("setup refresh failed with status {}", status)); + } + Ok(()) +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SetupMarker { + pub version: u32, + pub offline_username: String, + pub online_username: String, + #[serde(default)] + pub created_at: Option, +} + +impl SetupMarker { + pub fn version_matches(&self) -> bool { + self.version == SETUP_VERSION + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SandboxUserRecord { + pub username: String, + /// DPAPI-encrypted password blob, base64 encoded. + pub password: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SandboxUsersFile { + pub version: u32, + pub offline: SandboxUserRecord, + pub online: SandboxUserRecord, +} + +impl SandboxUsersFile { + pub fn version_matches(&self) -> bool { + self.version == SETUP_VERSION + } +} + +fn is_elevated() -> Result { + unsafe { + let mut administrators_group: *mut c_void = std::ptr::null_mut(); + let ok = AllocateAndInitializeSid( + &SECURITY_NT_AUTHORITY, + 2, + SECURITY_BUILTIN_DOMAIN_RID, + DOMAIN_ALIAS_RID_ADMINS, + 0, + 0, + 0, + 0, + 0, + 0, + &mut administrators_group, + ); + if ok == 0 { + return Err(anyhow!( + "AllocateAndInitializeSid failed: {}", + GetLastError() + )); + } + let mut is_member = 0i32; + let check = CheckTokenMembership(0, administrators_group, &mut is_member as *mut _); + FreeSid(administrators_group as *mut _); + if check == 0 { + return Err(anyhow!("CheckTokenMembership failed: {}", GetLastError())); + } + Ok(is_member != 0) + } +} + +fn canonical_existing(paths: &[PathBuf]) -> Vec { + paths + .iter() + .filter_map(|p| { + if !p.exists() { + return None; + } + Some(dunce::canonicalize(p).unwrap_or_else(|_| p.clone())) + }) + .collect() +} + +pub(crate) fn gather_read_roots( + command_cwd: &Path, + policy: &SandboxPolicy, + policy_cwd: &Path, +) -> Vec { + let mut roots: Vec = Vec::new(); + for p in [ + PathBuf::from(r"C:\Windows"), + PathBuf::from(r"C:\Program Files"), + PathBuf::from(r"C:\Program Files (x86)"), + PathBuf::from(r"C:\ProgramData"), + ] { + roots.push(p); + } + if let Ok(up) = std::env::var("USERPROFILE") { + roots.push(PathBuf::from(up)); + } + roots.push(command_cwd.to_path_buf()); + if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = policy { + for root in writable_roots { + let candidate = if root.is_absolute() { + root.clone() + } else { + policy_cwd.join(root) + }; + roots.push(candidate); + } + } + canonical_existing(&roots) +} + +pub(crate) fn gather_write_roots( + policy: &SandboxPolicy, + policy_cwd: &Path, + command_cwd: &Path, + env_map: &HashMap, +) -> Vec { + let mut roots: Vec = Vec::new(); + // Always include the command CWD for workspace-write. + if matches!(policy, SandboxPolicy::WorkspaceWrite { .. }) { + roots.push(command_cwd.to_path_buf()); + } + let AllowDenyPaths { allow, .. } = + compute_allow_paths(policy, policy_cwd, command_cwd, env_map); + roots.extend(allow); + canonical_existing(&roots) +} + +#[derive(Serialize)] +struct ElevationPayload { + version: u32, + offline_username: String, + online_username: String, + codex_home: PathBuf, + read_roots: Vec, + write_roots: Vec, + real_user: String, + #[serde(default)] + refresh_only: bool, +} + +fn quote_arg(arg: &str) -> String { + let needs = arg.is_empty() + || arg + .chars() + .any(|c| matches!(c, ' ' | '\t' | '\n' | '\r' | '"')); + if !needs { + return arg.to_string(); + } + let mut out = String::from("\""); + let mut bs = 0; + for ch in arg.chars() { + match ch { + '\\' => { + bs += 1; + } + '"' => { + out.push_str(&"\\".repeat(bs * 2 + 1)); + out.push('"'); + bs = 0; + } + _ => { + if bs > 0 { + out.push_str(&"\\".repeat(bs)); + bs = 0; + } + out.push(ch); + } + } + } + if bs > 0 { + out.push_str(&"\\".repeat(bs * 2)); + } + out.push('"'); + out +} + +fn find_setup_exe() -> PathBuf { + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + let candidate = dir.join("codex-windows-sandbox-setup.exe"); + if candidate.exists() { + return candidate; + } + } + } + PathBuf::from("codex-windows-sandbox-setup.exe") +} + +fn run_setup_exe(payload: &ElevationPayload, needs_elevation: bool) -> Result<()> { + use windows_sys::Win32::System::Threading::GetExitCodeProcess; + use windows_sys::Win32::System::Threading::WaitForSingleObject; + use windows_sys::Win32::System::Threading::INFINITE; + use windows_sys::Win32::UI::Shell::ShellExecuteExW; + use windows_sys::Win32::UI::Shell::SEE_MASK_NOCLOSEPROCESS; + use windows_sys::Win32::UI::Shell::SHELLEXECUTEINFOW; + let exe = find_setup_exe(); + let payload_json = serde_json::to_string(payload)?; + let payload_b64 = BASE64_STANDARD.encode(payload_json.as_bytes()); + + if !needs_elevation { + let status = Command::new(&exe) + .arg(&payload_b64) + .creation_flags(0x08000000) // CREATE_NO_WINDOW + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .context("failed to launch setup helper")?; + if !status.success() { + return Err(anyhow!( + "setup helper exited with status {:?}", + status.code() + )); + } + return Ok(()); + } + + let exe_w = crate::winutil::to_wide(&exe); + let params = quote_arg(&payload_b64); + let params_w = crate::winutil::to_wide(params); + let verb_w = crate::winutil::to_wide("runas"); + let mut sei: SHELLEXECUTEINFOW = unsafe { std::mem::zeroed() }; + sei.cbSize = std::mem::size_of::() as u32; + sei.fMask = SEE_MASK_NOCLOSEPROCESS; + sei.lpVerb = verb_w.as_ptr(); + sei.lpFile = exe_w.as_ptr(); + sei.lpParameters = params_w.as_ptr(); + // Hide the window for the elevated helper. + sei.nShow = 0; // SW_HIDE + let ok = unsafe { ShellExecuteExW(&mut sei) }; + if ok == 0 || sei.hProcess == 0 { + return Err(anyhow!( + "ShellExecuteExW failed to launch setup helper: {}", + unsafe { GetLastError() } + )); + } + unsafe { + WaitForSingleObject(sei.hProcess, INFINITE); + let mut code: u32 = 1; + GetExitCodeProcess(sei.hProcess, &mut code); + CloseHandle(sei.hProcess); + if code != 0 { + return Err(anyhow!("setup helper exited with status {}", code)); + } + } + Ok(()) +} + +pub fn run_elevated_setup( + policy: &SandboxPolicy, + policy_cwd: &Path, + command_cwd: &Path, + env_map: &HashMap, + codex_home: &Path, + read_roots_override: Option>, + write_roots_override: Option>, +) -> Result<()> { + // Ensure the shared sandbox directory exists before we send it to the elevated helper. + let sbx_dir = sandbox_dir(codex_home); + std::fs::create_dir_all(&sbx_dir)?; + let mut write_roots = if let Some(roots) = write_roots_override { + roots + } else { + gather_write_roots(policy, policy_cwd, command_cwd, env_map) + }; + if !write_roots.contains(&sbx_dir) { + write_roots.push(sbx_dir.clone()); + } + let read_roots = if let Some(roots) = read_roots_override { + roots + } else { + gather_read_roots(command_cwd, policy, policy_cwd) + }; + let payload = ElevationPayload { + version: SETUP_VERSION, + offline_username: OFFLINE_USERNAME.to_string(), + online_username: ONLINE_USERNAME.to_string(), + codex_home: codex_home.to_path_buf(), + read_roots, + write_roots, + real_user: std::env::var("USERNAME").unwrap_or_else(|_| "Administrators".to_string()), + refresh_only: false, + }; + let needs_elevation = !is_elevated()?; + run_setup_exe(&payload, needs_elevation) +} diff --git a/codex-rs/windows-sandbox-rs/src/token.rs b/codex-rs/windows-sandbox-rs/src/token.rs index 7e565bc67a2..d6c21f637a7 100644 --- a/codex-rs/windows-sandbox-rs/src/token.rs +++ b/codex-rs/windows-sandbox-rs/src/token.rs @@ -24,7 +24,6 @@ use windows_sys::Win32::Security::TOKEN_DUPLICATE; use windows_sys::Win32::Security::TOKEN_PRIVILEGES; use windows_sys::Win32::Security::TOKEN_QUERY; use windows_sys::Win32::System::Threading::GetCurrentProcess; -use windows_sys::Win32::System::Threading::OpenProcessToken; const DISABLE_MAX_PRIVILEGE: u32 = 0x01; const LUA_TOKEN: u32 = 0x04; @@ -192,7 +191,14 @@ unsafe fn enable_single_privilege(h_token: HANDLE, name: &str) -> Result<()> { tp.PrivilegeCount = 1; tp.Privileges[0].Luid = luid; tp.Privileges[0].Attributes = 0x00000002; // SE_PRIVILEGE_ENABLED - let ok2 = AdjustTokenPrivileges(h_token, 0, &tp, 0, std::ptr::null_mut(), std::ptr::null_mut()); + let ok2 = AdjustTokenPrivileges( + h_token, + 0, + &tp, + 0, + std::ptr::null_mut(), + std::ptr::null_mut(), + ); if ok2 == 0 { return Err(anyhow!("AdjustTokenPrivileges failed: {}", GetLastError())); } @@ -203,24 +209,6 @@ unsafe fn enable_single_privilege(h_token: HANDLE, name: &str) -> Result<()> { Ok(()) } -/// # Safety -/// Opens the current process token and adjusts privileges; caller should ensure this is needed in the current context. -#[allow(dead_code)] -pub unsafe fn enable_privilege_on_current(name: &str) -> Result<()> { - let mut h: HANDLE = 0; - let ok = OpenProcessToken( - GetCurrentProcess(), - TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, - &mut h, - ); - if ok == 0 { - return Err(anyhow!("OpenProcessToken failed: {}", GetLastError())); - } - let res = enable_single_privilege(h, name); - CloseHandle(h); - res -} - /// # Safety /// Caller must close the returned token handle. #[allow(dead_code)]