From ef75171f18fc5e57cc1a22d17efb274561e7de5b Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Wed, 17 Jun 2026 10:00:42 -0700 Subject: [PATCH] Run fs helper through Windows sandbox wrapper (#28359) ## Why This is the final PR in the Windows fs-helper sandbox stack and contains the actual bug fix. The exec-server filesystem helper is a direct-spawn path: it asks `SandboxManager` for a `SandboxExecRequest`, then launches the returned argv itself. That works on macOS and Linux because the transformed argv is already a self-contained sandbox wrapper. On Windows, the transformed request carried `WindowsRestrictedToken` metadata, but the direct-spawn fs-helper runner still launched the helper argv directly. That means Windows filesystem built-ins backed by the fs-helper could run with the parent Codex process permissions instead of the configured Windows sandbox. This PR makes the direct-spawn transform produce a self-contained Windows wrapper argv before fs-helper launches it. ## What Changed - Added `SandboxManager::transform_for_direct_spawn()` for callers that launch the returned argv themselves. - Wrapped Windows restricted-token direct-spawn requests with `codex.exe --run-as-windows-sandbox` and then marked the outer request as unsandboxed, matching the macOS/Linux wrapper argv shape. - Updated `exec-server/src/fs_sandbox.rs` to use the direct-spawn transform for fs-helper launches. - Materialized the inner `codex.exe --codex-run-as-fs-helper` executable into `.sandbox-bin` so the sandboxed user can run it. - Carried runtime workspace roots through `FileSystemSandboxContext` as `PathUri` values so `:workspace_roots` policies resolve correctly without sending native client paths over exec-server JSON. - Preserved wrapper setup identity environment needed by Windows sandbox setup without changing the serialized inner helper environment. ## Verification - `just bazel-lock-update` - `just bazel-lock-check` - `just test -p codex-sandboxing transform_for_direct_spawn_windows` - `just test -p codex-exec-server fs_sandbox::tests` - `just fix -p codex-windows-sandbox -p codex-sandboxing -p codex-exec-server -p codex-core -p codex-file-system` Local note: `just fmt` completed Rust formatting, but this workstation still fails the non-Rust formatter phases because uv cannot open its cache and the local buildifier/dotslash path is missing. --- codex-rs/Cargo.lock | 2 + codex-rs/core/src/exec.rs | 386 +----------------- codex-rs/core/src/sandboxing/mod.rs | 2 +- codex-rs/core/src/session/turn_context.rs | 6 + .../core/src/tools/runtimes/apply_patch.rs | 6 + codex-rs/exec-server/src/fs_sandbox.rs | 63 ++- codex-rs/exec-server/tests/common/mod.rs | 4 + .../exec-server/tests/file_system_windows.rs | 60 +++ codex-rs/file-system/src/lib.rs | 4 + codex-rs/sandboxing/Cargo.toml | 4 + codex-rs/sandboxing/src/lib.rs | 12 + codex-rs/sandboxing/src/manager.rs | 163 ++++++++ codex-rs/sandboxing/src/manager_tests.rs | 152 +++++++ codex-rs/sandboxing/src/windows.rs | 382 +++++++++++++++++ .../src/helper_materialization.rs | 12 +- codex-rs/windows-sandbox-rs/src/lib.rs | 2 + 16 files changed, 861 insertions(+), 399 deletions(-) create mode 100644 codex-rs/sandboxing/src/windows.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index a0713e171..844a84ef7 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3792,7 +3792,9 @@ dependencies = [ "codex-network-proxy", "codex-protocol", "codex-utils-absolute-path", + "codex-utils-home-dir", "codex-utils-path-uri", + "codex-windows-sandbox", "dunce", "libc", "pretty_assertions", diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 9468534d8..2b390ddba 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -1,9 +1,9 @@ #[cfg(unix)] use std::os::unix::process::ExitStatusExt; -use std::collections::BTreeSet; use std::collections::HashMap; use std::io; +#[cfg(target_os = "windows")] use std::path::Path; use std::path::PathBuf; use std::process::ExitStatus; @@ -24,7 +24,6 @@ use crate::spawn::SpawnChildRequest; use crate::spawn::StdioPolicy; use crate::spawn::spawn_child_async; use codex_network_proxy::NetworkProxy; -use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::error::CodexErr; use codex_protocol::error::Result; use codex_protocol::error::SandboxErr; @@ -42,7 +41,14 @@ use codex_sandboxing::SandboxManager; use codex_sandboxing::SandboxTransformRequest; use codex_sandboxing::SandboxType; use codex_sandboxing::SandboxablePreference; -use codex_sandboxing::compatibility_sandbox_policy_for_permission_profile; +use codex_sandboxing::WindowsSandboxFilesystemOverrides; +#[cfg(test)] +use codex_sandboxing::permission_profile_supports_windows_restricted_token_sandbox; +use codex_sandboxing::resolve_windows_elevated_filesystem_overrides; +use codex_sandboxing::resolve_windows_restricted_token_filesystem_overrides; +#[cfg(test)] +use codex_sandboxing::unsupported_windows_restricted_token_sandbox_reason; +use codex_sandboxing::windows_sandbox_uses_elevated_backend; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; @@ -96,34 +102,6 @@ pub struct ExecParams { pub arg0: Option, } -/// Resolved filesystem overrides for the Windows sandbox backends. -/// -/// The elevated Windows backend consumes extra deny-read paths plus explicit -/// read and write roots during setup/refresh. The unelevated restricted-token -/// backend only consumes extra deny-write carveouts on top of the legacy -/// `WorkspaceWrite` allow set. Read-root overrides are layered on top of the -/// baseline helper roots that the elevated setup path needs to launch the -/// sandboxed command; split policies that opt into platform defaults carry -/// that explicitly with the override. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct WindowsSandboxFilesystemOverrides { - pub(crate) read_roots_override: Option>, - pub(crate) read_roots_include_platform_defaults: bool, - pub(crate) write_roots_override: Option>, - pub(crate) additional_deny_read_paths: Vec, - pub(crate) additional_deny_write_paths: Vec, -} - -fn windows_sandbox_uses_elevated_backend( - sandbox_level: WindowsSandboxLevel, - proxy_enforced: bool, -) -> bool { - // Windows firewall enforcement is tied to the logon-user sandbox identities, so - // proxy-enforced sessions must use that backend even when the configured mode is - // the default restricted-token sandbox. - proxy_enforced || matches!(sandbox_level, WindowsSandboxLevel::Elevated) -} - #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum ExecCapturePolicy { /// Shell-like execs keep the historical output cap and timeout behavior. @@ -997,352 +975,6 @@ async fn exec( consume_output(child, expiration, capture_policy, stdout_stream).await } -#[cfg_attr(not(target_os = "windows"), allow(dead_code))] -fn permission_profile_supports_windows_restricted_token_sandbox( - permission_profile: &PermissionProfile, -) -> bool { - match permission_profile { - PermissionProfile::Managed { file_system, .. } => { - !file_system.to_sandbox_policy().has_full_disk_write_access() - } - PermissionProfile::Disabled | PermissionProfile::External { .. } => false, - } -} - -#[cfg_attr(not(test), allow(dead_code))] -pub(crate) fn unsupported_windows_restricted_token_sandbox_reason( - sandbox: SandboxType, - permission_profile: &PermissionProfile, - sandbox_policy_cwd: &AbsolutePathBuf, - windows_sandbox_level: WindowsSandboxLevel, -) -> Option { - if windows_sandbox_level == WindowsSandboxLevel::Elevated { - resolve_windows_elevated_filesystem_overrides( - sandbox, - permission_profile, - sandbox_policy_cwd, - windows_sandbox_level == WindowsSandboxLevel::Elevated, - ) - .err() - } else { - resolve_windows_restricted_token_filesystem_overrides( - sandbox, - permission_profile, - sandbox_policy_cwd, - windows_sandbox_level, - ) - .err() - } -} - -pub(crate) fn resolve_windows_restricted_token_filesystem_overrides( - sandbox: SandboxType, - permission_profile: &PermissionProfile, - sandbox_policy_cwd: &AbsolutePathBuf, - windows_sandbox_level: WindowsSandboxLevel, -) -> std::result::Result, String> { - if sandbox != SandboxType::WindowsRestrictedToken - || windows_sandbox_level == WindowsSandboxLevel::Elevated - { - return Ok(None); - } - - let (file_system_sandbox_policy, network_sandbox_policy) = - permission_profile.to_runtime_permissions(); - - let needs_direct_runtime_enforcement = file_system_sandbox_policy - .needs_direct_runtime_enforcement(network_sandbox_policy, sandbox_policy_cwd); - - if permission_profile_supports_windows_restricted_token_sandbox(permission_profile) - && !needs_direct_runtime_enforcement - { - return Ok(None); - } - - if !permission_profile_supports_windows_restricted_token_sandbox(permission_profile) { - let permission_profile_name = permission_profile_display_name(permission_profile); - return Err(format!( - "windows sandbox backend cannot enforce file_system={:?}, network={network_sandbox_policy:?}, permission_profile={permission_profile_name}; refusing to run unsandboxed", - file_system_sandbox_policy.kind, - )); - } - - // The restricted-token backend can still enforce split write restrictions, - // but its WRITE_RESTRICTED token does not make capability SID deny-read ACEs - // participate in read access checks. Read restrictions therefore require the - // elevated backend, even when the filesystem root remains readable. - if !windows_policy_has_root_read_access(&file_system_sandbox_policy, sandbox_policy_cwd) { - return Err( - "windows unelevated restricted-token sandbox cannot enforce split filesystem read restrictions directly; refusing to run unsandboxed" - .to_string(), - ); - } - - let additional_deny_read_paths = codex_windows_sandbox::resolve_windows_deny_read_paths( - &file_system_sandbox_policy, - sandbox_policy_cwd, - )?; - if !additional_deny_read_paths.is_empty() { - return Err( - "windows unelevated restricted-token sandbox cannot enforce deny-read restrictions directly; refusing to run unsandboxed" - .to_string(), - ); - } - - let legacy_projection = compatibility_sandbox_policy_for_permission_profile( - permission_profile, - sandbox_policy_cwd.as_path(), - ); - let legacy_writable_roots = legacy_projection.get_writable_roots_with_cwd(sandbox_policy_cwd); - let split_writable_roots = - file_system_sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd); - let legacy_root_paths: BTreeSet = legacy_writable_roots - .iter() - .map(|root| normalize_windows_override_path(root.root.as_path())) - .collect::>()?; - let split_root_paths: BTreeSet = split_writable_roots - .iter() - .map(|root| normalize_windows_override_path(root.root.as_path())) - .collect::>()?; - - if legacy_root_paths != split_root_paths { - return Err( - "windows unelevated restricted-token sandbox cannot enforce split writable root sets directly; refusing to run unsandboxed" - .to_string(), - ); - } - - for writable_root in &split_writable_roots { - for read_only_subpath in &writable_root.read_only_subpaths { - if split_writable_roots.iter().any(|candidate| { - candidate.root.as_path() != writable_root.root.as_path() - && candidate - .root - .as_path() - .starts_with(read_only_subpath.as_path()) - }) { - return Err( - "windows unelevated restricted-token sandbox cannot reopen writable descendants under read-only carveouts directly; refusing to run unsandboxed" - .to_string(), - ); - } - } - } - - let mut additional_deny_write_paths = BTreeSet::new(); - for split_root in &split_writable_roots { - let split_root_path = normalize_windows_override_path(split_root.root.as_path())?; - let Some(legacy_root) = legacy_writable_roots.iter().find(|candidate| { - normalize_windows_override_path(candidate.root.as_path()) - .is_ok_and(|candidate_path| candidate_path == split_root_path) - }) else { - return Err( - "windows unelevated restricted-token sandbox cannot enforce split writable root sets directly; refusing to run unsandboxed" - .to_string(), - ); - }; - - for read_only_subpath in &split_root.read_only_subpaths { - if !legacy_root - .read_only_subpaths - .iter() - .any(|candidate| candidate == read_only_subpath) - { - additional_deny_write_paths.insert(normalize_windows_override_path( - read_only_subpath.as_path(), - )?); - } - } - } - - if additional_deny_read_paths.is_empty() && additional_deny_write_paths.is_empty() { - return Ok(None); - } - - Ok(Some(WindowsSandboxFilesystemOverrides { - read_roots_override: None, - read_roots_include_platform_defaults: false, - write_roots_override: None, - additional_deny_read_paths, - additional_deny_write_paths: additional_deny_write_paths - .into_iter() - .map(|path| AbsolutePathBuf::from_absolute_path(path).map_err(|err| err.to_string())) - .collect::>()?, - })) -} - -fn normalize_windows_override_path(path: &Path) -> std::result::Result { - AbsolutePathBuf::from_absolute_path(dunce::simplified(path)) - .map(AbsolutePathBuf::into_path_buf) - .map_err(|err| err.to_string()) -} - -fn windows_policy_has_root_read_access( - file_system_sandbox_policy: &FileSystemSandboxPolicy, - cwd: &AbsolutePathBuf, -) -> bool { - let Some(root) = cwd.as_path().ancestors().last() else { - return false; - }; - file_system_sandbox_policy.can_read_path_with_cwd(root, cwd.as_path()) -} - -pub(crate) fn resolve_windows_elevated_filesystem_overrides( - sandbox: SandboxType, - permission_profile: &PermissionProfile, - sandbox_policy_cwd: &AbsolutePathBuf, - use_windows_elevated_backend: bool, -) -> std::result::Result, String> { - if sandbox != SandboxType::WindowsRestrictedToken || !use_windows_elevated_backend { - return Ok(None); - } - - let (file_system_sandbox_policy, network_sandbox_policy) = - permission_profile.to_runtime_permissions(); - - if !permission_profile_supports_windows_restricted_token_sandbox(permission_profile) { - let permission_profile_name = permission_profile_display_name(permission_profile); - return Err(format!( - "windows sandbox backend cannot enforce file_system={:?}, network={network_sandbox_policy:?}, permission_profile={permission_profile_name}; refusing to run unsandboxed", - file_system_sandbox_policy.kind, - )); - } - - let additional_deny_read_paths = codex_windows_sandbox::resolve_windows_deny_read_paths( - &file_system_sandbox_policy, - sandbox_policy_cwd, - )?; - - let split_writable_roots = - file_system_sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd); - if has_reopened_writable_descendant(&split_writable_roots) { - return Err( - "windows elevated sandbox cannot reopen writable descendants under read-only carveouts directly; refusing to run unsandboxed" - .to_string(), - ); - } - - let needs_direct_runtime_enforcement = file_system_sandbox_policy - .needs_direct_runtime_enforcement(network_sandbox_policy, sandbox_policy_cwd); - let normalize_path = |path: PathBuf| dunce::canonicalize(&path).unwrap_or(path); - let legacy_projection = compatibility_sandbox_policy_for_permission_profile( - permission_profile, - sandbox_policy_cwd.as_path(), - ); - let legacy_writable_roots = legacy_projection.get_writable_roots_with_cwd(sandbox_policy_cwd); - let legacy_root_paths: BTreeSet = legacy_writable_roots - .iter() - .map(|root| normalize_path(root.root.to_path_buf())) - .collect(); - let split_readable_roots: Vec = file_system_sandbox_policy - .get_readable_roots_with_cwd(sandbox_policy_cwd) - .into_iter() - .map(codex_utils_absolute_path::AbsolutePathBuf::into_path_buf) - .map(&normalize_path) - .collect(); - let split_root_paths: Vec = split_writable_roots - .iter() - .map(|root| normalize_path(root.root.to_path_buf())) - .collect(); - let split_root_path_set: BTreeSet = split_root_paths.iter().cloned().collect(); - - // `has_full_disk_read_access()` is intentionally false when deny-read - // entries exist. For Windows setup overrides, the important question is - // whether the baseline still reads from the filesystem root and only needs - // additional deny ACLs layered on top. - let split_has_root_read_access = - windows_policy_has_root_read_access(&file_system_sandbox_policy, sandbox_policy_cwd); - let read_roots_override = if split_has_root_read_access { - None - } else { - Some(split_readable_roots) - }; - - let write_roots_override = if split_root_path_set == legacy_root_paths { - None - } else { - Some(split_root_paths) - }; - - let additional_deny_write_paths = if needs_direct_runtime_enforcement { - let mut deny_paths = BTreeSet::new(); - for writable_root in &split_writable_roots { - let writable_root_path = normalize_path(writable_root.root.to_path_buf()); - let legacy_root = legacy_writable_roots.iter().find(|candidate| { - normalize_path(candidate.root.to_path_buf()) == writable_root_path - }); - for read_only_subpath in &writable_root.read_only_subpaths { - let read_only_subpath_suffix = read_only_subpath - .as_path() - .strip_prefix(writable_root.root.as_path()) - .ok(); - let already_denied_by_legacy = legacy_root.is_some_and(|legacy_root| { - legacy_root.read_only_subpaths.iter().any(|candidate| { - candidate - .as_path() - .strip_prefix(legacy_root.root.as_path()) - .ok() - == read_only_subpath_suffix - }) - }); - if !already_denied_by_legacy { - deny_paths.insert(normalize_path(read_only_subpath.to_path_buf())); - } - } - } - deny_paths - .into_iter() - .map(|path| AbsolutePathBuf::from_absolute_path(path).map_err(|err| err.to_string())) - .collect::>()? - } else { - Vec::new() - }; - - if read_roots_override.is_none() - && write_roots_override.is_none() - && additional_deny_read_paths.is_empty() - && additional_deny_write_paths.is_empty() - { - return Ok(None); - } - - Ok(Some(WindowsSandboxFilesystemOverrides { - read_roots_include_platform_defaults: read_roots_override.is_some() - && file_system_sandbox_policy.include_platform_defaults(), - read_roots_override, - write_roots_override, - additional_deny_read_paths, - additional_deny_write_paths, - })) -} - -fn permission_profile_display_name(permission_profile: &PermissionProfile) -> &'static str { - match permission_profile { - PermissionProfile::Managed { .. } => "Managed", - PermissionProfile::Disabled => "Disabled", - PermissionProfile::External { .. } => "External", - } -} - -fn has_reopened_writable_descendant( - writable_roots: &[codex_protocol::protocol::WritableRoot], -) -> bool { - writable_roots.iter().any(|writable_root| { - writable_root - .read_only_subpaths - .iter() - .any(|read_only_subpath| { - writable_roots.iter().any(|candidate| { - candidate.root.as_path() != writable_root.root.as_path() - && candidate - .root - .as_path() - .starts_with(read_only_subpath.as_path()) - }) - }) - }) -} - /// Consumes the output of a child process according to the configured capture /// policy. async fn consume_output( diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index f1f53d80e..009cd71da 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -10,7 +10,6 @@ ExecRequest for execution. use crate::exec::ExecCapturePolicy; use crate::exec::ExecExpiration; use crate::exec::StdoutStream; -use crate::exec::WindowsSandboxFilesystemOverrides; use crate::exec::execute_exec_request; #[cfg(target_os = "macos")] use crate::spawn::CODEX_SANDBOX_ENV_VAR; @@ -24,6 +23,7 @@ use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_sandboxing::SandboxExecRequest; use codex_sandboxing::SandboxType; +use codex_sandboxing::WindowsSandboxFilesystemOverrides; use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashMap; diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index cd01f92b9..91880bdef 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -330,6 +330,12 @@ impl TurnContext { FileSystemSandboxContext { permissions: permissions.into(), cwd: Some(cwd.clone()), + workspace_roots: self + .config + .effective_workspace_roots() + .iter() + .map(PathUri::from_abs_path) + .collect(), windows_sandbox_level: self.windows_sandbox_level, windows_sandbox_private_desktop: self .config diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index 9ad9d284c..9d23fcab3 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -33,6 +33,7 @@ use codex_sandboxing::SandboxType; use codex_sandboxing::SandboxablePreference; use codex_sandboxing::policy_transforms::effective_permission_profile; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; use futures::future::BoxFuture; use std::path::PathBuf; use std::time::Instant; @@ -99,6 +100,11 @@ impl ApplyPatchRuntime { Some(FileSystemSandboxContext { permissions: permissions.into(), cwd: Some(attempt.sandbox_cwd.clone()), + workspace_roots: attempt + .workspace_roots + .iter() + .map(PathUri::from_abs_path) + .collect(), windows_sandbox_level: attempt.windows_sandbox_level, windows_sandbox_private_desktop: attempt.windows_sandbox_private_desktop, use_legacy_landlock: attempt.use_legacy_landlock, diff --git a/codex-rs/exec-server/src/fs_sandbox.rs b/codex-rs/exec-server/src/fs_sandbox.rs index 5b42f9a2f..9e9dbcf6b 100644 --- a/codex-rs/exec-server/src/fs_sandbox.rs +++ b/codex-rs/exec-server/src/fs_sandbox.rs @@ -9,6 +9,7 @@ use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_sandboxing::SandboxCommand; +use codex_sandboxing::SandboxDirectSpawnTransformRequest; use codex_sandboxing::SandboxExecRequest; use codex_sandboxing::SandboxManager; use codex_sandboxing::SandboxTransformRequest; @@ -88,7 +89,7 @@ impl FileSystemSandboxRunner { &file_system_policy, network_policy, ); - let command = self.sandbox_exec_request(&permission_profile, &cwd.uri, sandbox)?; + let command = self.sandbox_exec_request(&permission_profile, &cwd, sandbox)?; let request_json = serde_json::to_vec(&request).map_err(json_error)?; run_command(command, request_json).await } @@ -96,7 +97,7 @@ impl FileSystemSandboxRunner { fn sandbox_exec_request( &self, permission_profile: &PermissionProfile, - cwd: &PathUri, + cwd: &SandboxCwd, sandbox_context: &FileSystemSandboxContext, ) -> Result { let helper = &self.runtime_paths.codex_self_exe; @@ -112,22 +113,36 @@ impl FileSystemSandboxRunner { let command = SandboxCommand { program: helper.as_path().as_os_str().to_owned(), args: vec![CODEX_FS_HELPER_ARG1.to_string()], - cwd: cwd.clone(), + cwd: cwd.uri.clone(), env: self.helper_env.clone(), additional_permissions: None, }; + let native_workspace_roots = sandbox_context + .workspace_roots + .iter() + .map(native_workspace_root) + .collect::, _>>()?; + let workspace_roots = if native_workspace_roots.is_empty() { + std::slice::from_ref(&cwd.native) + } else { + native_workspace_roots.as_slice() + }; sandbox_manager - .transform(SandboxTransformRequest { - command, - permissions: permission_profile, - sandbox, - enforce_managed_network: false, - network: None, - sandbox_policy_cwd: cwd, - codex_linux_sandbox_exe: self.runtime_paths.codex_linux_sandbox_exe.as_deref(), - use_legacy_landlock: sandbox_context.use_legacy_landlock, - windows_sandbox_level: sandbox_context.windows_sandbox_level, - windows_sandbox_private_desktop: sandbox_context.windows_sandbox_private_desktop, + .transform_for_direct_spawn(SandboxDirectSpawnTransformRequest { + workspace_roots, + transform: SandboxTransformRequest { + command, + permissions: permission_profile, + sandbox, + enforce_managed_network: false, + network: None, + sandbox_policy_cwd: &cwd.uri, + codex_linux_sandbox_exe: self.runtime_paths.codex_linux_sandbox_exe.as_deref(), + use_legacy_landlock: sandbox_context.use_legacy_landlock, + windows_sandbox_level: sandbox_context.windows_sandbox_level, + windows_sandbox_private_desktop: sandbox_context + .windows_sandbox_private_desktop, + }, }) .map_err(|err| invalid_request(format!("failed to prepare fs sandbox: {err}"))) } @@ -158,6 +173,14 @@ fn native_sandbox_cwd(cwd: &PathUri) -> Result Result { + root.to_abs_path().map_err(|err| { + invalid_request(format!( + "file system sandbox workspace root is not native to this exec-server host: {err}" + )) + }) +} + fn helper_read_roots(runtime_paths: &ExecServerRuntimePaths) -> Vec { let mut roots = Vec::new(); for path in std::iter::once(runtime_paths.codex_self_exe.as_path()) @@ -517,15 +540,21 @@ mod tests { let runner = FileSystemSandboxRunner::new(runtime_paths); let native_cwd = AbsolutePathBuf::current_dir().expect("cwd"); let cwd = PathUri::from_abs_path(&native_cwd); - let file_system_policy = - restricted_policy(vec![path_entry(native_cwd, FileSystemAccessMode::Write)]); + let file_system_policy = restricted_policy(vec![path_entry( + native_cwd.clone(), + FileSystemAccessMode::Write, + )]); let network_policy = NetworkSandboxPolicy::Restricted; let permission_profile = PermissionProfile::from_runtime_permissions(&file_system_policy, network_policy); let sandbox_context = sandbox_context_with_cwd(&file_system_policy, cwd.clone()); + let sandbox_cwd = SandboxCwd { + uri: cwd, + native: native_cwd, + }; let request = runner - .sandbox_exec_request(&permission_profile, &cwd, &sandbox_context) + .sandbox_exec_request(&permission_profile, &sandbox_cwd, &sandbox_context) .expect("sandbox exec request"); assert_eq!(request.env.get(&path_key), Some(&path)); diff --git a/codex-rs/exec-server/tests/common/mod.rs b/codex-rs/exec-server/tests/common/mod.rs index 387edf36d..9eba3c87c 100644 --- a/codex-rs/exec-server/tests/common/mod.rs +++ b/codex-rs/exec-server/tests/common/mod.rs @@ -19,6 +19,7 @@ pub(crate) mod exec_server; pub(crate) const DELAYED_OUTPUT_AFTER_EXIT_PARENT_ARG: &str = "--codex-test-delayed-output-after-exit-parent"; +const CODEX_WINDOWS_SANDBOX_ARG1: &str = "--run-as-windows-sandbox"; const DELAYED_OUTPUT_AFTER_EXIT_CHILD_ARG: &str = "--codex-test-delayed-output-after-exit-child"; #[ctor] @@ -27,6 +28,9 @@ pub static TEST_BINARY_DISPATCH_GUARD: Option = { if argv1 == Some(CODEX_FS_HELPER_ARG1) { return TestBinaryDispatchMode::DispatchArg0Only; } + if argv1 == Some(CODEX_WINDOWS_SANDBOX_ARG1) { + return TestBinaryDispatchMode::DispatchArg0Only; + } if exe_name == CODEX_LINUX_SANDBOX_ARG0 { return TestBinaryDispatchMode::DispatchArg0Only; } diff --git a/codex-rs/exec-server/tests/file_system_windows.rs b/codex-rs/exec-server/tests/file_system_windows.rs index 9dd25343a..fbf8b0733 100644 --- a/codex-rs/exec-server/tests/file_system_windows.rs +++ b/codex-rs/exec-server/tests/file_system_windows.rs @@ -12,9 +12,14 @@ use std::path::Path; use std::process::Command; use anyhow::Result; +use codex_exec_server::FileSystemSandboxContext; +use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::protocol::SandboxPolicy; +use codex_utils_path_uri::PathUri; use test_case::test_case; use crate::support::FileSystemImplementation; +use crate::support::create_file_system_context; fn create_directory_junction(target: &Path, alias: &Path) -> Result<()> { let output = Command::new("cmd") @@ -54,3 +59,58 @@ async fn file_system_sandboxed_canonicalize_resolves_directory_junction( ) .await } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_remote_fs_helper_respects_windows_sandbox_write_policy() -> Result<()> { + let context = create_file_system_context(FileSystemImplementation::Remote).await?; + let file_system = context.file_system; + let tmp = tempfile::TempDir::new()?; + let readonly_dir = tmp.path().join("readonly"); + std::fs::create_dir_all(&readonly_dir)?; + + let mut sandbox = read_only_sandbox_for_cwd(readonly_dir.clone())?; + sandbox.windows_sandbox_level = WindowsSandboxLevel::RestrictedToken; + + let readable_file = readonly_dir.join("readable.txt"); + std::fs::write(&readable_file, b"readable")?; + let read_result = file_system + .read_file(&PathUri::from_path(&readable_file)?, Some(&sandbox)) + .await; + // Some local Windows hosts cannot create restricted tokens. Reaching that + // error still proves the remote fs helper went through the Windows sandbox + // launcher; before the wrapper fix this read would have run unsandboxed. + if is_unsupported_restricted_token_host(&read_result) { + return Ok(()); + } + assert_eq!(read_result?, b"readable"); + + let blocked_file = readonly_dir.join("blocked.txt"); + let error = file_system + .write_file( + &PathUri::from_path(&blocked_file)?, + b"blocked".to_vec(), + Some(&sandbox), + ) + .await + .expect_err("write outside the sandbox should fail"); + assert!( + !blocked_file.exists(), + "sandboxed fs helper must not create blocked file after error: {error}" + ); + + Ok(()) +} + +fn read_only_sandbox_for_cwd(cwd: std::path::PathBuf) -> Result { + Ok(FileSystemSandboxContext::from_legacy_sandbox_policy( + SandboxPolicy::new_read_only_policy(), + PathUri::from_path(cwd)?, + )?) +} + +fn is_unsupported_restricted_token_host(result: &std::io::Result) -> bool { + result.as_ref().err().is_some_and(|err| { + err.to_string() + .contains("windows sandbox failed: CreateRestrictedToken failed: 87") + }) +} diff --git a/codex-rs/file-system/src/lib.rs b/codex-rs/file-system/src/lib.rs index 83c984b99..3ba787440 100644 --- a/codex-rs/file-system/src/lib.rs +++ b/codex-rs/file-system/src/lib.rs @@ -62,6 +62,8 @@ pub struct FileSystemSandboxContext { pub permissions: PermissionProfile, #[serde(default, skip_serializing_if = "Option::is_none")] pub cwd: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub workspace_roots: Vec, pub windows_sandbox_level: WindowsSandboxLevel, #[serde(default)] pub windows_sandbox_private_desktop: bool, @@ -109,6 +111,7 @@ impl FileSystemSandboxContext { Self { permissions: permissions.into(), cwd, + workspace_roots: Vec::new(), windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, use_legacy_landlock: false, @@ -151,6 +154,7 @@ impl FileSystemSandboxContext { pub fn drop_cwd_if_unused(mut self) -> Self { if !self.has_cwd_dependent_permissions() { self.cwd = None; + self.workspace_roots.clear(); } self } diff --git a/codex-rs/sandboxing/Cargo.toml b/codex-rs/sandboxing/Cargo.toml index ccdcd8768..3185d782b 100644 --- a/codex-rs/sandboxing/Cargo.toml +++ b/codex-rs/sandboxing/Cargo.toml @@ -17,6 +17,7 @@ codex-network-proxy = { workspace = true } codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-path-uri = { workspace = true } +codex-windows-sandbox = { workspace = true } dunce = { workspace = true } libc = { workspace = true } serde_json = { workspace = true } @@ -25,6 +26,9 @@ tracing = { workspace = true, features = ["log"] } url = { workspace = true } which = { workspace = true } +[target.'cfg(windows)'.dependencies] +codex-utils-home-dir = { workspace = true } + [dev-dependencies] anyhow = { workspace = true } pretty_assertions = { workspace = true } diff --git a/codex-rs/sandboxing/src/lib.rs b/codex-rs/sandboxing/src/lib.rs index 7210acd0d..bebfefe06 100644 --- a/codex-rs/sandboxing/src/lib.rs +++ b/codex-rs/sandboxing/src/lib.rs @@ -5,12 +5,14 @@ mod manager; pub mod policy_transforms; #[cfg(target_os = "macos")] pub mod seatbelt; +mod windows; #[cfg(target_os = "linux")] pub use bwrap::find_system_bwrap_in_path; #[cfg(target_os = "linux")] pub use bwrap::system_bwrap_warning; pub use manager::SandboxCommand; +pub use manager::SandboxDirectSpawnTransformRequest; pub use manager::SandboxExecRequest; pub use manager::SandboxManager; pub use manager::SandboxTransformError; @@ -20,6 +22,12 @@ pub use manager::SandboxablePreference; pub use manager::compatibility_sandbox_policy_for_permission_profile; pub use manager::get_platform_sandbox; pub use manager::with_managed_mitm_ca_readable_root; +pub use windows::WindowsSandboxFilesystemOverrides; +pub use windows::permission_profile_supports_windows_restricted_token_sandbox; +pub use windows::resolve_windows_elevated_filesystem_overrides; +pub use windows::resolve_windows_restricted_token_filesystem_overrides; +pub use windows::unsupported_windows_restricted_token_sandbox_reason; +pub use windows::windows_sandbox_uses_elevated_backend; use codex_protocol::error::CodexErr; @@ -48,6 +56,10 @@ impl From for CodexErr { SandboxTransformError::SeatbeltUnavailable => CodexErr::UnsupportedOperation( "seatbelt sandbox is only available on macOS".to_string(), ), + #[cfg(target_os = "windows")] + SandboxTransformError::WindowsSandboxPreparation(message) => { + CodexErr::UnsupportedOperation(message) + } } } } diff --git a/codex-rs/sandboxing/src/manager.rs b/codex-rs/sandboxing/src/manager.rs index 35ebaeac5..6bfbaa345 100644 --- a/codex-rs/sandboxing/src/manager.rs +++ b/codex-rs/sandboxing/src/manager.rs @@ -7,6 +7,12 @@ use crate::landlock::allow_network_for_proxy; use crate::landlock::create_linux_sandbox_command_args_for_permission_profile; use crate::policy_transforms::effective_permission_profile; use crate::policy_transforms::should_require_platform_sandbox; +#[cfg(target_os = "windows")] +use crate::resolve_windows_elevated_filesystem_overrides; +#[cfg(target_os = "windows")] +use crate::resolve_windows_restricted_token_filesystem_overrides; +#[cfg(target_os = "windows")] +use crate::windows_sandbox_uses_elevated_backend; use codex_network_proxy::NetworkProxy; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::AdditionalPermissionProfile; @@ -21,6 +27,9 @@ use std::ffi::OsString; use std::io; use std::path::Path; +#[cfg(target_os = "windows")] +const WINDOWS_SANDBOX_WRAPPER_SETUP_ENV_ALLOWLIST: &[&str] = &["USERNAME", "USERPROFILE"]; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SandboxType { None, @@ -131,6 +140,16 @@ pub struct SandboxTransformRequest<'a> { pub windows_sandbox_private_desktop: bool, } +/// Bundled arguments for a sandbox transformation whose result will be spawned +/// directly from argv. +/// +/// Direct-spawn callers will not run a later platform-specific launcher, so the +/// returned command must encode any sandbox wrapper it needs. +pub struct SandboxDirectSpawnTransformRequest<'a> { + pub transform: SandboxTransformRequest<'a>, + pub workspace_roots: &'a [AbsolutePathBuf], +} + #[derive(Debug)] pub enum SandboxTransformError { InvalidCommandCwd { @@ -146,6 +165,8 @@ pub enum SandboxTransformError { Wsl1UnsupportedForBubblewrap, #[cfg(not(target_os = "macos"))] SeatbeltUnavailable, + #[cfg(target_os = "windows")] + WindowsSandboxPreparation(String), } impl std::fmt::Display for SandboxTransformError { @@ -168,6 +189,10 @@ impl std::fmt::Display for SandboxTransformError { Self::Wsl1UnsupportedForBubblewrap => write!(f, "{WSL1_BWRAP_WARNING}"), #[cfg(not(target_os = "macos"))] Self::SeatbeltUnavailable => write!(f, "seatbelt sandbox is only available on macOS"), + #[cfg(target_os = "windows")] + Self::WindowsSandboxPreparation(err) => { + write!(f, "failed to prepare windows sandbox wrapper: {err}") + } } } } @@ -182,6 +207,8 @@ impl std::error::Error for SandboxTransformError { Self::Wsl1UnsupportedForBubblewrap => None, #[cfg(not(target_os = "macos"))] Self::SeatbeltUnavailable => None, + #[cfg(target_os = "windows")] + Self::WindowsSandboxPreparation(_) => None, } } } @@ -336,6 +363,142 @@ impl SandboxManager { arg0: arg0_override, }) } + + pub fn transform_for_direct_spawn( + &self, + request: SandboxDirectSpawnTransformRequest<'_>, + ) -> Result { + #[cfg(target_os = "windows")] + { + let codex_home = codex_utils_home_dir::find_codex_home() + .map_err(|err| SandboxTransformError::WindowsSandboxPreparation(err.to_string()))?; + self.transform_for_direct_spawn_with_codex_home(request, codex_home.as_path()) + } + + #[cfg(not(target_os = "windows"))] + { + self.transform(request.transform) + } + } + + #[cfg(target_os = "windows")] + fn transform_for_direct_spawn_with_codex_home( + &self, + request: SandboxDirectSpawnTransformRequest<'_>, + codex_home: &Path, + ) -> Result { + let workspace_roots = request.workspace_roots; + let mut request = self.transform(request.transform)?; + if request.sandbox == SandboxType::WindowsRestrictedToken { + wrap_windows_sandbox_exec_request_for_direct_spawn( + &mut request, + workspace_roots, + codex_home, + )?; + } + Ok(request) + } +} + +#[cfg(target_os = "windows")] +fn wrap_windows_sandbox_exec_request_for_direct_spawn( + request: &mut SandboxExecRequest, + workspace_roots: &[AbsolutePathBuf], + codex_home: &Path, +) -> Result<(), SandboxTransformError> { + let Some(program) = request.command.first_mut() else { + return Err(SandboxTransformError::WindowsSandboxPreparation( + "sandbox command was empty".to_string(), + )); + }; + let source = std::path::PathBuf::from(&program); + let helper = codex_windows_sandbox::resolve_exe_for_launch(source.as_path(), codex_home); + *program = helper.to_string_lossy().into_owned(); + + let inner_command = std::mem::take(&mut request.command); + let proxy_enforced = request.network.is_some(); + let use_elevated = + windows_sandbox_uses_elevated_backend(request.windows_sandbox_level, proxy_enforced); + let overrides = if use_elevated { + resolve_windows_elevated_filesystem_overrides( + request.sandbox, + &request.permission_profile, + &request.sandbox_policy_cwd, + use_elevated, + ) + } else { + resolve_windows_restricted_token_filesystem_overrides( + request.sandbox, + &request.permission_profile, + &request.sandbox_policy_cwd, + request.windows_sandbox_level, + ) + } + .map_err(SandboxTransformError::WindowsSandboxPreparation)?; + let empty_paths: &[AbsolutePathBuf] = &[]; + let read_roots_override = overrides + .as_ref() + .and_then(|overrides| overrides.read_roots_override.as_deref()); + let read_roots_include_platform_defaults = overrides + .as_ref() + .is_some_and(|overrides| overrides.read_roots_include_platform_defaults); + let write_roots_override = overrides + .as_ref() + .and_then(|overrides| overrides.write_roots_override.as_deref()); + let deny_read_paths_override = overrides.as_ref().map_or(empty_paths, |overrides| { + overrides.additional_deny_read_paths.as_slice() + }); + let deny_write_paths_override = overrides.as_ref().map_or(empty_paths, |overrides| { + overrides.additional_deny_write_paths.as_slice() + }); + let mut wrapper_args = + codex_windows_sandbox::create_windows_sandbox_command_args_for_permission_profile( + inner_command, + &request.cwd, + workspace_roots, + &request.env, + &request.permission_profile, + request.windows_sandbox_level, + request.windows_sandbox_private_desktop, + proxy_enforced, + read_roots_override, + read_roots_include_platform_defaults, + write_roots_override, + deny_read_paths_override, + deny_write_paths_override, + codex_home, + ); + + request.command = Vec::with_capacity(1 + wrapper_args.len()); + request.command.push(source.to_string_lossy().into_owned()); + request.command.append(&mut wrapper_args); + request.sandbox = SandboxType::None; + request.arg0 = None; + add_windows_sandbox_wrapper_setup_env(&mut request.env); + Ok(()) +} + +#[cfg(target_os = "windows")] +fn add_windows_sandbox_wrapper_setup_env(env: &mut HashMap) { + add_windows_sandbox_wrapper_setup_env_from_vars(env, std::env::vars_os()); +} + +#[cfg(target_os = "windows")] +fn add_windows_sandbox_wrapper_setup_env_from_vars( + env: &mut HashMap, + vars: impl IntoIterator, +) { + for (key, value) in vars { + let key = key.to_string_lossy().into_owned(); + if !WINDOWS_SANDBOX_WRAPPER_SETUP_ENV_ALLOWLIST + .iter() + .any(|allowed| key.eq_ignore_ascii_case(allowed)) + { + continue; + } + env.retain(|existing, _| !existing.eq_ignore_ascii_case(&key)); + env.insert(key, value.to_string_lossy().into_owned()); + } } pub fn compatibility_sandbox_policy_for_permission_profile( diff --git a/codex-rs/sandboxing/src/manager_tests.rs b/codex-rs/sandboxing/src/manager_tests.rs index d76a1fd32..8f736b994 100644 --- a/codex-rs/sandboxing/src/manager_tests.rs +++ b/codex-rs/sandboxing/src/manager_tests.rs @@ -1,4 +1,6 @@ use super::SandboxCommand; +#[cfg(target_os = "windows")] +use super::SandboxDirectSpawnTransformRequest; use super::SandboxManager; use super::SandboxTransformRequest; use super::SandboxType; @@ -410,3 +412,153 @@ fn transform_linux_seccomp_uses_helper_alias_when_launcher_is_not_helper_path() assert_eq!(exec_request.arg0, Some("codex-linux-sandbox".to_string())); } + +#[cfg(target_os = "windows")] +#[test] +fn transform_for_direct_spawn_windows_preserves_only_wrapper_setup_identity() { + let mut env = HashMap::from([ + ("Path".to_string(), r"C:\Windows\System32".to_string()), + ("username".to_string(), "wrong-user".to_string()), + ("UserProfile".to_string(), r"C:\wrong".to_string()), + ]); + + super::add_windows_sandbox_wrapper_setup_env_from_vars( + &mut env, + [ + ("USERNAME", "alice"), + ("USERPROFILE", r"C:\Users\alice"), + ("OPENAI_API_KEY", "secret"), + ] + .map(|(key, value)| { + ( + std::ffi::OsString::from(key), + std::ffi::OsString::from(value), + ) + }), + ); + + assert_eq!( + env, + HashMap::from([ + ("Path".to_string(), r"C:\Windows\System32".to_string()), + ("USERNAME".to_string(), "alice".to_string()), + ("USERPROFILE".to_string(), r"C:\Users\alice".to_string()), + ]) + ); +} + +#[cfg(target_os = "windows")] +#[test] +fn transform_for_direct_spawn_windows_materializes_inner_helper() { + let codex_home = tempfile::TempDir::new().expect("codex home"); + let helper_dir = tempfile::TempDir::new().expect("helper dir"); + let configured_helper = helper_dir.path().join("configured-codex-helper.exe"); + std::fs::write(&configured_helper, b"helper").expect("write configured helper"); + let cwd = AbsolutePathBuf::from_absolute_path(helper_dir.path()).expect("absolute cwd"); + let cwd_uri = PathUri::from_abs_path(&cwd); + let blocked = cwd.join("blocked"); + std::fs::create_dir_all(blocked.as_path()).expect("create blocked path"); + let permissions = PermissionProfile::from_runtime_permissions( + &FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(/*subpath*/ None), + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: blocked }, + access: FileSystemAccessMode::Deny, + }, + ]), + NetworkSandboxPolicy::Restricted, + ); + let other_workspace = tempfile::TempDir::new().expect("other workspace"); + let other_workspace_root = AbsolutePathBuf::from_absolute_path(other_workspace.path()) + .expect("absolute other workspace"); + let workspace_roots = vec![cwd, other_workspace_root]; + let manager = SandboxManager::new(); + let exec_request = manager + .transform_for_direct_spawn_with_codex_home( + SandboxDirectSpawnTransformRequest { + workspace_roots: workspace_roots.as_slice(), + transform: SandboxTransformRequest { + command: SandboxCommand { + program: configured_helper.as_os_str().to_owned(), + args: vec!["--codex-run-as-fs-helper".to_string()], + cwd: cwd_uri.clone(), + env: HashMap::from([( + "Path".to_string(), + r"C:\Windows\System32".to_string(), + )]), + additional_permissions: None, + }, + permissions: &permissions, + sandbox: SandboxType::WindowsRestrictedToken, + enforce_managed_network: false, + network: None, + sandbox_policy_cwd: &cwd_uri, + codex_linux_sandbox_exe: None, + use_legacy_landlock: false, + windows_sandbox_level: WindowsSandboxLevel::Elevated, + windows_sandbox_private_desktop: false, + }, + }, + codex_home.path(), + ) + .expect("transform for direct spawn"); + + let separator_index = exec_request + .command + .iter() + .position(|arg| arg == "--") + .expect("wrapper argv separator"); + let materialized_helper = std::path::PathBuf::from(&exec_request.command[separator_index + 1]); + assert_eq!(exec_request.sandbox, SandboxType::None); + assert_eq!( + exec_request.command.first(), + Some(&configured_helper.display().to_string()) + ); + assert!( + exec_request + .command + .iter() + .any(|arg| arg == "--run-as-windows-sandbox") + ); + assert!( + exec_request + .command + .iter() + .any(|arg| arg == "--deny-read-paths-json") + ); + assert_eq!( + exec_request.command[separator_index + 2], + "--codex-run-as-fs-helper" + ); + assert_eq!( + exec_request + .command + .windows(2) + .filter_map(|args| { + (args[0] == "--workspace-root").then_some(std::path::PathBuf::from(&args[1])) + }) + .collect::>(), + workspace_roots + .iter() + .map(|root| root.as_path().to_path_buf()) + .collect::>() + ); + assert_eq!( + materialized_helper + .parent() + .and_then(std::path::Path::file_name), + Some(std::ffi::OsStr::new(".sandbox-bin")) + ); + assert!(materialized_helper.exists()); +} diff --git a/codex-rs/sandboxing/src/windows.rs b/codex-rs/sandboxing/src/windows.rs new file mode 100644 index 000000000..a57281145 --- /dev/null +++ b/codex-rs/sandboxing/src/windows.rs @@ -0,0 +1,382 @@ +use std::collections::BTreeSet; +use std::path::Path; +use std::path::PathBuf; + +use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::protocol::WritableRoot; +use codex_utils_absolute_path::AbsolutePathBuf; + +use crate::SandboxType; +use crate::compatibility_sandbox_policy_for_permission_profile; + +/// Resolved filesystem overrides for the Windows sandbox backends. +/// +/// The elevated Windows backend consumes extra deny-read paths plus explicit +/// read and write roots during setup/refresh. The unelevated restricted-token +/// backend only consumes extra deny-write carveouts on top of the legacy +/// `WorkspaceWrite` allow set. Read-root overrides are layered on top of the +/// baseline helper roots that the elevated setup path needs to launch the +/// sandboxed command; split policies that opt into platform defaults carry +/// that explicitly with the override. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WindowsSandboxFilesystemOverrides { + pub read_roots_override: Option>, + pub read_roots_include_platform_defaults: bool, + pub write_roots_override: Option>, + pub additional_deny_read_paths: Vec, + pub additional_deny_write_paths: Vec, +} + +pub fn windows_sandbox_uses_elevated_backend( + sandbox_level: WindowsSandboxLevel, + proxy_enforced: bool, +) -> bool { + // Windows firewall enforcement is tied to the logon-user sandbox identities, so + // proxy-enforced sessions must use that backend even when the configured mode is + // the default restricted-token sandbox. + proxy_enforced || matches!(sandbox_level, WindowsSandboxLevel::Elevated) +} + +pub fn permission_profile_supports_windows_restricted_token_sandbox( + permission_profile: &PermissionProfile, +) -> bool { + match permission_profile { + PermissionProfile::Managed { file_system, .. } => { + !file_system.to_sandbox_policy().has_full_disk_write_access() + } + PermissionProfile::Disabled | PermissionProfile::External { .. } => false, + } +} + +pub fn unsupported_windows_restricted_token_sandbox_reason( + sandbox: SandboxType, + permission_profile: &PermissionProfile, + sandbox_policy_cwd: &AbsolutePathBuf, + windows_sandbox_level: WindowsSandboxLevel, +) -> Option { + if windows_sandbox_level == WindowsSandboxLevel::Elevated { + resolve_windows_elevated_filesystem_overrides( + sandbox, + permission_profile, + sandbox_policy_cwd, + windows_sandbox_level == WindowsSandboxLevel::Elevated, + ) + .err() + } else { + resolve_windows_restricted_token_filesystem_overrides( + sandbox, + permission_profile, + sandbox_policy_cwd, + windows_sandbox_level, + ) + .err() + } +} + +pub fn resolve_windows_restricted_token_filesystem_overrides( + sandbox: SandboxType, + permission_profile: &PermissionProfile, + sandbox_policy_cwd: &AbsolutePathBuf, + windows_sandbox_level: WindowsSandboxLevel, +) -> std::result::Result, String> { + if sandbox != SandboxType::WindowsRestrictedToken + || windows_sandbox_level == WindowsSandboxLevel::Elevated + { + return Ok(None); + } + + let (file_system_sandbox_policy, network_sandbox_policy) = + permission_profile.to_runtime_permissions(); + + let needs_direct_runtime_enforcement = file_system_sandbox_policy + .needs_direct_runtime_enforcement(network_sandbox_policy, sandbox_policy_cwd); + + if permission_profile_supports_windows_restricted_token_sandbox(permission_profile) + && !needs_direct_runtime_enforcement + { + return Ok(None); + } + + if !permission_profile_supports_windows_restricted_token_sandbox(permission_profile) { + let permission_profile_name = permission_profile_display_name(permission_profile); + return Err(format!( + "windows sandbox backend cannot enforce file_system={:?}, network={network_sandbox_policy:?}, permission_profile={permission_profile_name}; refusing to run unsandboxed", + file_system_sandbox_policy.kind, + )); + } + + // The restricted-token backend can still enforce split write restrictions, + // but its WRITE_RESTRICTED token does not make capability SID deny-read ACEs + // participate in read access checks. Read restrictions therefore require the + // elevated backend, even when the filesystem root remains readable. + if !windows_policy_has_root_read_access(&file_system_sandbox_policy, sandbox_policy_cwd) { + return Err( + "windows unelevated restricted-token sandbox cannot enforce split filesystem read restrictions directly; refusing to run unsandboxed" + .to_string(), + ); + } + + let additional_deny_read_paths = codex_windows_sandbox::resolve_windows_deny_read_paths( + &file_system_sandbox_policy, + sandbox_policy_cwd, + )?; + if !additional_deny_read_paths.is_empty() { + return Err( + "windows unelevated restricted-token sandbox cannot enforce deny-read restrictions directly; refusing to run unsandboxed" + .to_string(), + ); + } + + let legacy_projection = compatibility_sandbox_policy_for_permission_profile( + permission_profile, + sandbox_policy_cwd.as_path(), + ); + let legacy_writable_roots = legacy_projection.get_writable_roots_with_cwd(sandbox_policy_cwd); + let split_writable_roots = + file_system_sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd); + let legacy_root_paths: BTreeSet = legacy_writable_roots + .iter() + .map(|root| normalize_windows_override_path(root.root.as_path())) + .collect::>()?; + let split_root_paths: BTreeSet = split_writable_roots + .iter() + .map(|root| normalize_windows_override_path(root.root.as_path())) + .collect::>()?; + + if legacy_root_paths != split_root_paths { + return Err( + "windows unelevated restricted-token sandbox cannot enforce split writable root sets directly; refusing to run unsandboxed" + .to_string(), + ); + } + + for writable_root in &split_writable_roots { + for read_only_subpath in &writable_root.read_only_subpaths { + if split_writable_roots.iter().any(|candidate| { + candidate.root.as_path() != writable_root.root.as_path() + && candidate + .root + .as_path() + .starts_with(read_only_subpath.as_path()) + }) { + return Err( + "windows unelevated restricted-token sandbox cannot reopen writable descendants under read-only carveouts directly; refusing to run unsandboxed" + .to_string(), + ); + } + } + } + + let mut additional_deny_write_paths = BTreeSet::new(); + for split_root in &split_writable_roots { + let split_root_path = normalize_windows_override_path(split_root.root.as_path())?; + let Some(legacy_root) = legacy_writable_roots.iter().find(|candidate| { + normalize_windows_override_path(candidate.root.as_path()) + .is_ok_and(|candidate_path| candidate_path == split_root_path) + }) else { + return Err( + "windows unelevated restricted-token sandbox cannot enforce split writable root sets directly; refusing to run unsandboxed" + .to_string(), + ); + }; + + for read_only_subpath in &split_root.read_only_subpaths { + if !legacy_root + .read_only_subpaths + .iter() + .any(|candidate| candidate == read_only_subpath) + { + additional_deny_write_paths.insert(normalize_windows_override_path( + read_only_subpath.as_path(), + )?); + } + } + } + + if additional_deny_read_paths.is_empty() && additional_deny_write_paths.is_empty() { + return Ok(None); + } + + Ok(Some(WindowsSandboxFilesystemOverrides { + read_roots_override: None, + read_roots_include_platform_defaults: false, + write_roots_override: None, + additional_deny_read_paths, + additional_deny_write_paths: additional_deny_write_paths + .into_iter() + .map(|path| AbsolutePathBuf::from_absolute_path(path).map_err(|err| err.to_string())) + .collect::>()?, + })) +} + +pub fn resolve_windows_elevated_filesystem_overrides( + sandbox: SandboxType, + permission_profile: &PermissionProfile, + sandbox_policy_cwd: &AbsolutePathBuf, + use_windows_elevated_backend: bool, +) -> std::result::Result, String> { + if sandbox != SandboxType::WindowsRestrictedToken || !use_windows_elevated_backend { + return Ok(None); + } + + let (file_system_sandbox_policy, network_sandbox_policy) = + permission_profile.to_runtime_permissions(); + + if !permission_profile_supports_windows_restricted_token_sandbox(permission_profile) { + let permission_profile_name = permission_profile_display_name(permission_profile); + return Err(format!( + "windows sandbox backend cannot enforce file_system={:?}, network={network_sandbox_policy:?}, permission_profile={permission_profile_name}; refusing to run unsandboxed", + file_system_sandbox_policy.kind, + )); + } + + let additional_deny_read_paths = codex_windows_sandbox::resolve_windows_deny_read_paths( + &file_system_sandbox_policy, + sandbox_policy_cwd, + )?; + + let split_writable_roots = + file_system_sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd); + if has_reopened_writable_descendant(&split_writable_roots) { + return Err( + "windows elevated sandbox cannot reopen writable descendants under read-only carveouts directly; refusing to run unsandboxed" + .to_string(), + ); + } + + let needs_direct_runtime_enforcement = file_system_sandbox_policy + .needs_direct_runtime_enforcement(network_sandbox_policy, sandbox_policy_cwd); + let normalize_path = |path: PathBuf| dunce::canonicalize(&path).unwrap_or(path); + let legacy_projection = compatibility_sandbox_policy_for_permission_profile( + permission_profile, + sandbox_policy_cwd.as_path(), + ); + let legacy_writable_roots = legacy_projection.get_writable_roots_with_cwd(sandbox_policy_cwd); + let legacy_root_paths: BTreeSet = legacy_writable_roots + .iter() + .map(|root| normalize_path(root.root.to_path_buf())) + .collect(); + let split_readable_roots: Vec = file_system_sandbox_policy + .get_readable_roots_with_cwd(sandbox_policy_cwd) + .into_iter() + .map(AbsolutePathBuf::into_path_buf) + .map(&normalize_path) + .collect(); + let split_root_paths: Vec = split_writable_roots + .iter() + .map(|root| normalize_path(root.root.to_path_buf())) + .collect(); + let split_root_path_set: BTreeSet = split_root_paths.iter().cloned().collect(); + + // `has_full_disk_read_access()` is intentionally false when deny-read + // entries exist. For Windows setup overrides, the important question is + // whether the baseline still reads from the filesystem root and only needs + // additional deny ACLs layered on top. + let split_has_root_read_access = + windows_policy_has_root_read_access(&file_system_sandbox_policy, sandbox_policy_cwd); + let read_roots_override = if split_has_root_read_access { + None + } else { + Some(split_readable_roots) + }; + + let write_roots_override = if split_root_path_set == legacy_root_paths { + None + } else { + Some(split_root_paths) + }; + + let additional_deny_write_paths = if needs_direct_runtime_enforcement { + let mut deny_paths = BTreeSet::new(); + for writable_root in &split_writable_roots { + let writable_root_path = normalize_path(writable_root.root.to_path_buf()); + let legacy_root = legacy_writable_roots.iter().find(|candidate| { + normalize_path(candidate.root.to_path_buf()) == writable_root_path + }); + for read_only_subpath in &writable_root.read_only_subpaths { + let read_only_subpath_suffix = read_only_subpath + .as_path() + .strip_prefix(writable_root.root.as_path()) + .ok(); + let already_denied_by_legacy = legacy_root.is_some_and(|legacy_root| { + legacy_root.read_only_subpaths.iter().any(|candidate| { + candidate + .as_path() + .strip_prefix(legacy_root.root.as_path()) + .ok() + == read_only_subpath_suffix + }) + }); + if !already_denied_by_legacy { + deny_paths.insert(normalize_path(read_only_subpath.to_path_buf())); + } + } + } + deny_paths + .into_iter() + .map(|path| AbsolutePathBuf::from_absolute_path(path).map_err(|err| err.to_string())) + .collect::>()? + } else { + Vec::new() + }; + + if read_roots_override.is_none() + && write_roots_override.is_none() + && additional_deny_read_paths.is_empty() + && additional_deny_write_paths.is_empty() + { + return Ok(None); + } + + Ok(Some(WindowsSandboxFilesystemOverrides { + read_roots_include_platform_defaults: read_roots_override.is_some() + && file_system_sandbox_policy.include_platform_defaults(), + read_roots_override, + write_roots_override, + additional_deny_read_paths, + additional_deny_write_paths, + })) +} + +fn normalize_windows_override_path(path: &Path) -> std::result::Result { + AbsolutePathBuf::from_absolute_path(dunce::simplified(path)) + .map(AbsolutePathBuf::into_path_buf) + .map_err(|err| err.to_string()) +} + +fn windows_policy_has_root_read_access( + file_system_sandbox_policy: &FileSystemSandboxPolicy, + cwd: &AbsolutePathBuf, +) -> bool { + let Some(root) = cwd.as_path().ancestors().last() else { + return false; + }; + file_system_sandbox_policy.can_read_path_with_cwd(root, cwd.as_path()) +} + +fn permission_profile_display_name(permission_profile: &PermissionProfile) -> &'static str { + match permission_profile { + PermissionProfile::Managed { .. } => "Managed", + PermissionProfile::Disabled => "Disabled", + PermissionProfile::External { .. } => "External", + } +} + +fn has_reopened_writable_descendant(writable_roots: &[WritableRoot]) -> bool { + writable_roots.iter().any(|writable_root| { + writable_root + .read_only_subpaths + .iter() + .any(|read_only_subpath| { + writable_roots.iter().any(|candidate| { + candidate.root.as_path() != writable_root.root.as_path() + && candidate + .root + .as_path() + .starts_with(read_only_subpath.as_path()) + }) + }) + }) +} diff --git a/codex-rs/windows-sandbox-rs/src/helper_materialization.rs b/codex-rs/windows-sandbox-rs/src/helper_materialization.rs index e5b202ded..bcc09f125 100644 --- a/codex-rs/windows-sandbox-rs/src/helper_materialization.rs +++ b/codex-rs/windows-sandbox-rs/src/helper_materialization.rs @@ -96,22 +96,26 @@ pub fn resolve_current_exe_for_launch(codex_home: &Path, fallback_executable: &s Ok(path) => path, Err(_) => return PathBuf::from(fallback_executable), }; + resolve_exe_for_launch(&source, codex_home) +} + +pub fn resolve_exe_for_launch(source: &Path, codex_home: &Path) -> PathBuf { let Some(file_name) = source.file_name() else { - return source; + return source.to_path_buf(); }; let destination = helper_bin_dir(codex_home).join(file_name); - match copy_from_source_if_needed(&source, &destination) { + match copy_from_source_if_needed(source, &destination) { Ok(_) => destination, Err(err) => { let sandbox_log_dir = crate::sandbox_dir(codex_home); log_note( &format!( - "helper copy failed for current executable: {err:#}; falling back to legacy path {}", + "helper copy failed for executable: {err:#}; falling back to legacy path {}", source.display() ), Some(&sandbox_log_dir), ); - source + source.to_path_buf() } } } diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index 4c1858430..a7f3df6e2 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -174,6 +174,8 @@ pub use elevated_impl::run_windows_sandbox_capture_for_permission_profile as run #[cfg(target_os = "windows")] pub use helper_materialization::resolve_current_exe_for_launch; #[cfg(target_os = "windows")] +pub use helper_materialization::resolve_exe_for_launch; +#[cfg(target_os = "windows")] pub use hide_users::hide_current_user_profile_dir; #[cfg(target_os = "windows")] pub use hide_users::hide_newly_created_users;