From 9a7213ca37c6aa19127100027f041599d2d28eb9 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Thu, 5 Nov 2026 00:14:16 +0000 Subject: [PATCH] Resolve workspace permissions at the sandbox launch boundary (#59530) ## What changed Add optional executor workspace roots to `SandboxTransformRequest` and resolve symbolic workspace rules after merging additional permissions. Preserve explicit deny rules and treat an empty root list as granting no workspace access. Route Windows restricted-token preparation through the shared transform path and use explicit roots when building its direct-spawn wrapper. Update Windows MXC debug launches to bind configured roots before adding the managed CA read grant. ## Testing Extend sandbox manager tests to cover workspace and subpath expansion, empty root lists, deny preservation, and the roots and write permissions serialized into the Windows wrapper. GitOrigin-RevId: 0bebdb08b67cbbc182c18b031b9d13a21a799682 --- codex-rs/cli/src/debug_sandbox.rs | 29 ++- codex-rs/core/src/exec.rs | 1 + codex-rs/core/src/shell_snapshot_sandbox.rs | 1 + codex-rs/core/src/tools/sandboxing.rs | 2 + codex-rs/core/tests/windows_sandbox.rs | 1 + codex-rs/exec-server/src/fs_sandbox.rs | 1 + codex-rs/exec-server/src/process_sandbox.rs | 1 + codex-rs/sandboxing/src/launch.rs | 55 +++++ codex-rs/sandboxing/src/lib.rs | 3 +- codex-rs/sandboxing/src/manager.rs | 129 +++++----- codex-rs/sandboxing/src/manager_tests.rs | 258 ++++++++++++-------- 11 files changed, 300 insertions(+), 181 deletions(-) create mode 100644 codex-rs/sandboxing/src/launch.rs diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index c0efcdb1f6..04ebcf160f 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -25,6 +25,7 @@ use codex_protocol::config_types::SandboxMode; use codex_protocol::models::PermissionProfile; use codex_protocol::models::SandboxEnforcement; use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_sandboxing::SandboxType::WindowsMxc; use codex_sandboxing::landlock::create_linux_sandbox_command_args_for_permission_profile; #[cfg(target_os = "macos")] use codex_sandboxing::seatbelt::CreateSeatbeltCommandArgsParams; @@ -270,6 +271,9 @@ async fn run_command_under_sandbox( } permission_profile => permission_profile.clone(), }, + None if matches!(sandbox_type, SandboxType::Windows) => { + config.permissions.permission_profile().clone() + } None => config.permissions.effective_permission_profile(), }; if matches!(permission_profile, PermissionProfile::Disabled) && sandbox_state_disable_network { @@ -298,7 +302,7 @@ async fn run_command_under_sandbox( // disk config. Preserve that backend rather than weakening enforcement. // An explicit MXC launch fails closed if the native implementation is unavailable. let windows_sandbox_type = if sandbox_state.as_ref().is_some_and(|state| state.use_mxc) { - codex_sandboxing::SandboxType::WindowsMxc + WindowsMxc } else { config.effective_local_windows_sandbox_type() }; @@ -327,7 +331,10 @@ async fn run_command_under_sandbox( if let SandboxType::Windows = sandbox_type { #[cfg(target_os = "windows")] { - if windows_sandbox_type != codex_sandboxing::SandboxType::WindowsMxc { + if windows_sandbox_type != WindowsMxc { + if sandbox_state.is_none() { + permission_profile = config.permissions.effective_permission_profile(); + } let workspace_roots = config .effective_workspace_roots() .iter() @@ -382,9 +389,10 @@ async fn run_command_under_sandbox( // policy came from managed requirements. #[cfg(any(target_os = "macos", target_os = "windows"))] let enforce_managed_network = network.is_some(); - let managed_mitm_ca_trust_bundle_path = match network.as_ref() { - Some(network) => network.managed_mitm_ca_trust_bundle_path(), - None => None, + // MXC adds the CA read grant in transform after binding the workspace roots. + let managed_mitm_ca_trust_bundle_path = match (network.as_ref(), &sandbox_type) { + (None, _) | (_, SandboxType::Windows) => None, + (Some(network), _) => network.managed_mitm_ca_trust_bundle_path(), }; let runtime_permission_profile = with_managed_mitm_ca_readable_root( permission_profile, @@ -486,6 +494,14 @@ async fn run_command_under_sandbox( .context("sandbox command must not be empty")?; let sandbox_policy_cwd = PathUri::from_abs_path(&sandbox_policy_cwd); let codex_self_exe = std::env::current_exe()?; + let workspace_roots = sandbox_state.is_none().then(|| { + config + .permissions + .workspace_roots() + .iter() + .map(PathUri::from_abs_path) + .collect::>() + }); let request = SandboxManager::new().transform(SandboxTransformRequest { command: SandboxCommand { program: program.into(), @@ -496,7 +512,8 @@ async fn run_command_under_sandbox( additional_permissions: None, }, permissions: &runtime_permission_profile, - sandbox: codex_sandboxing::SandboxType::WindowsMxc, + workspace_roots: workspace_roots.as_deref(), + sandbox: WindowsMxc, enforce_managed_network, environment_id: None, network: network.as_ref(), diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index f72396e383..8f5f02c177 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -407,6 +407,7 @@ pub async fn build_exec_request( .transform(SandboxTransformRequest { command, permissions: permission_profile, + workspace_roots: None, sandbox: sandbox_type, enforce_managed_network, environment_id: network_environment_id.as_deref(), diff --git a/codex-rs/core/src/shell_snapshot_sandbox.rs b/codex-rs/core/src/shell_snapshot_sandbox.rs index 0735470154..03e8542d52 100644 --- a/codex-rs/core/src/shell_snapshot_sandbox.rs +++ b/codex-rs/core/src/shell_snapshot_sandbox.rs @@ -164,6 +164,7 @@ impl ShellSnapshotSandbox { additional_permissions, }, permissions: &self.permissions, + workspace_roots: None, sandbox: self.sandbox, enforce_managed_network: self.enforce_managed_network, environment_id: Some(&self.environment_id), diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index d80079d307..c1bd44e750 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -504,6 +504,7 @@ impl<'a> SandboxAttempt<'a> { .transform(SandboxTransformRequest { command, permissions: self.permissions, + workspace_roots: None, sandbox: self.sandbox, enforce_managed_network: self.enforce_managed_network, environment_id, @@ -539,6 +540,7 @@ impl<'a> SandboxAttempt<'a> { .transform(SandboxTransformRequest { command, permissions: self.permissions, + workspace_roots: None, // The exec-server must receive the native command, not this host's wrapper. sandbox: SandboxType::None, enforce_managed_network: self.enforce_managed_network, diff --git a/codex-rs/core/tests/windows_sandbox.rs b/codex-rs/core/tests/windows_sandbox.rs index dda98253f0..c0c0d1d693 100644 --- a/codex-rs/core/tests/windows_sandbox.rs +++ b/codex-rs/core/tests/windows_sandbox.rs @@ -517,6 +517,7 @@ async fn windows_elevated_temp_only_core_and_direct_spawn_enforce_carveouts() -> additional_permissions: None, }, permissions: &permission_profile, + workspace_roots: None, sandbox: SandboxType::WindowsRestrictedToken, enforce_managed_network: false, environment_id: None, diff --git a/codex-rs/exec-server/src/fs_sandbox.rs b/codex-rs/exec-server/src/fs_sandbox.rs index 63eefde6ee..8da7523dfa 100644 --- a/codex-rs/exec-server/src/fs_sandbox.rs +++ b/codex-rs/exec-server/src/fs_sandbox.rs @@ -191,6 +191,7 @@ impl FileSystemSandboxRunner { transform: SandboxTransformRequest { command, permissions: permission_profile, + workspace_roots: None, sandbox, enforce_managed_network: false, environment_id: None, diff --git a/codex-rs/exec-server/src/process_sandbox.rs b/codex-rs/exec-server/src/process_sandbox.rs index 488b7c97f2..918e9cc6fa 100644 --- a/codex-rs/exec-server/src/process_sandbox.rs +++ b/codex-rs/exec-server/src/process_sandbox.rs @@ -311,6 +311,7 @@ pub(crate) async fn prepare_exec_request_with_telemetry( additional_permissions: None, }, permissions: &permissions, + workspace_roots: None, sandbox, enforce_managed_network: params.enforce_managed_network, environment_id: None, diff --git a/codex-rs/sandboxing/src/launch.rs b/codex-rs/sandboxing/src/launch.rs new file mode 100644 index 0000000000..d56f70de95 --- /dev/null +++ b/codex-rs/sandboxing/src/launch.rs @@ -0,0 +1,55 @@ +//! Bind filesystem and network inputs for sandbox preparation on the execution host. + +use std::io; + +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::FileSystemPath; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; + +/// The authorized profile, resolved on the host that will enforce it. +#[derive(Debug)] +pub(super) struct PreparedLaunchPolicy { + pub(super) permissions: PermissionProfile, +} + +impl PreparedLaunchPolicy { + /// An empty workspace list stays empty, even when the policy contains `:workspace_roots`. + pub(super) fn resolve( + permissions: PermissionProfile, + policy_cwd: &PathUri, + roots: Option<&[PathUri]>, + ) -> io::Result { + let Some(roots) = roots else { + return Ok(Self { permissions }); + }; + native_path(policy_cwd, "sandbox policy cwd")?; + let workspace_roots = native_workspace_roots(roots)?; + let (file_system, network) = permissions.to_runtime_permissions(); + for entry in &file_system.entries { + if let FileSystemPath::Path { path } = &entry.path { + native_path(path, "sandbox permission path")?; + } + } + let file_system = + file_system.materialize_project_roots_with_workspace_roots(&workspace_roots); + let permissions = PermissionProfile::from_runtime_permissions_with_enforcement( + permissions.enforcement(), + &file_system, + network, + ); + Ok(Self { permissions }) + } +} + +pub(super) fn native_workspace_roots(roots: &[PathUri]) -> io::Result> { + roots + .iter() + .map(|root| native_path(root, "sandbox workspace root")) + .collect::>>() +} + +fn native_path(path: &PathUri, field: &str) -> io::Result { + path.to_abs_path() + .map_err(|err| io::Error::new(err.kind(), format!("invalid {field}: {err}"))) +} diff --git a/codex-rs/sandboxing/src/lib.rs b/codex-rs/sandboxing/src/lib.rs index 5db90bba1e..bdf22e065d 100644 --- a/codex-rs/sandboxing/src/lib.rs +++ b/codex-rs/sandboxing/src/lib.rs @@ -75,7 +75,8 @@ pub fn system_bwrap_warning( impl From for CodexErr { fn from(err: SandboxTransformError) -> Self { match err { - error @ SandboxTransformError::InvalidCommandCwd { .. } + error @ SandboxTransformError::InvalidPolicy(_) + | error @ SandboxTransformError::InvalidCommandCwd { .. } | error @ SandboxTransformError::InvalidSandboxPolicyCwd { .. } => { CodexErr::InvalidRequest(error.to_string()) } diff --git a/codex-rs/sandboxing/src/manager.rs b/codex-rs/sandboxing/src/manager.rs index df6fcc8c6b..829b920296 100644 --- a/codex-rs/sandboxing/src/manager.rs +++ b/codex-rs/sandboxing/src/manager.rs @@ -1,3 +1,4 @@ +use self::launch::PreparedLaunchPolicy; use crate::LinuxSandboxPidNamespace; #[cfg(target_os = "linux")] use crate::bwrap::WSL1_BWRAP_WARNING; @@ -30,6 +31,9 @@ use std::ffi::OsString; use std::io; use std::path::Path; +#[path = "launch.rs"] +pub(super) mod launch; + #[cfg(target_os = "windows")] const WINDOWS_SANDBOX_WRAPPER_SETUP_ENV_ALLOWLIST: &[&str] = &[ "USERNAME", @@ -92,7 +96,7 @@ pub struct SandboxCommand { pub additional_permissions: Option, } -/// A host-native launch request produced after [`SandboxManager::transform`] validates URI inputs. +/// A host-native launch request produced after the sandbox manager validates URI inputs. /// Build this only at the execution boundary: in exec-server, or in its logical equivalent within /// app-server. Orchestration and transport code should retain [`PathUri`] values and defer /// conversion to native paths until this request is created. @@ -120,6 +124,8 @@ pub struct SandboxExecRequest { pub struct SandboxTransformRequest<'a> { pub command: SandboxCommand, pub permissions: &'a PermissionProfile, + /// Bind symbolic workspace rules to these executor roots. `None` means they are already resolved. + pub workspace_roots: Option<&'a [PathUri]>, pub sandbox: SandboxType, pub enforce_managed_network: bool, pub environment_id: Option<&'a str>, @@ -187,6 +193,7 @@ impl PendingSandboxedExecRequest { #[derive(Debug)] pub enum SandboxTransformError { + InvalidPolicy(io::Error), InvalidCommandCwd { cwd: PathUri, source: io::Error, @@ -211,6 +218,7 @@ pub enum SandboxTransformError { impl std::fmt::Display for SandboxTransformError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { + Self::InvalidPolicy(err) => err.fmt(f), Self::InvalidCommandCwd { cwd, source } => { write!( f, @@ -240,7 +248,7 @@ impl std::fmt::Display for SandboxTransformError { 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}") + write!(f, "failed to prepare Windows sandbox: {err}") } } } @@ -249,6 +257,7 @@ impl std::fmt::Display for SandboxTransformError { impl std::error::Error for SandboxTransformError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { + Self::InvalidPolicy(source) => Some(source), Self::InvalidCommandCwd { source, .. } | Self::InvalidSandboxPolicyCwd { source, .. } => Some(source), Self::MissingLinuxSandboxExecutable => None, @@ -350,21 +359,34 @@ impl SandboxManager { } } + /// Prepare filesystem and network enforcement for a command on the execution host. pub fn transform( &self, request: SandboxTransformRequest<'_>, ) -> Result { - if request.sandbox == SandboxType::WindowsRestrictedToken { - return self.prepare_legacy_windows_request(request, SandboxOverride::NoOverride); + #[cfg(windows)] + if request.sandbox == SandboxType::WindowsRestrictedToken + && request.enforce_managed_network + && request.windows_sandbox_level != WindowsSandboxLevel::Elevated + { + return Err(SandboxTransformError::WindowsSandboxPreparation( + "managed networking requires the elevated Windows sandbox backend".into(), + )); } let command = request.command; - let managed_mitm_ca_trust_bundle_path = request - .network - .and_then(NetworkProxy::managed_mitm_ca_trust_bundle_path); let permissions = effective_permission_profile( request.permissions, command.additional_permissions.as_ref(), ); + let managed_mitm_ca_trust_bundle_path = request + .network + .and_then(NetworkProxy::managed_mitm_ca_trust_bundle_path); + let policy = PreparedLaunchPolicy::resolve( + permissions, + request.sandbox_policy_cwd, + request.workspace_roots, + ) + .map_err(SandboxTransformError::InvalidPolicy)?; let backend = request.sandbox; let enforce_managed_network = request.enforce_managed_network; let mut managed_network = command.managed_network; @@ -381,7 +403,7 @@ impl SandboxManager { network_environment_id: request.environment_id.map(str::to_owned), sandbox: backend, windows_sandbox_level: request.windows_sandbox_level, - permission_profile: permissions, + permission_profile: policy.permissions, arg0: None, }; if backend == SandboxType::None { @@ -514,7 +536,7 @@ impl SandboxManager { )?; } SandboxType::WindowsRestrictedToken => { - unreachable!("native Windows uses its legacy launcher") + exec.permission_profile = pending?.effective_permission_profile; } } Ok(exec) @@ -558,85 +580,54 @@ impl SandboxManager { Ok(()) } - /// Prepare the unwrapped request for core's native Windows launcher, which binds its own - /// proxy environment and filesystem overrides. Other backends are prepared by [`Self::transform`]. - fn prepare_legacy_windows_request( - &self, - request: SandboxTransformRequest<'_>, - sandbox_override: SandboxOverride, - ) -> Result { - debug_assert_eq!(request.sandbox, SandboxType::WindowsRestrictedToken); - #[cfg(windows)] - if request.enforce_managed_network - && request.windows_sandbox_level != WindowsSandboxLevel::Elevated - { - return Err(SandboxTransformError::WindowsSandboxPreparation( - "managed networking requires the elevated Windows sandbox backend".into(), - )); - } - let command = request.command; - let mut argv = Vec::with_capacity(1 + command.args.len()); - argv.push(command.program.to_string_lossy().into_owned()); - argv.extend(command.args); - let trust_bundle = request - .network - .and_then(NetworkProxy::managed_mitm_ca_trust_bundle_path); - let mut prepared = SandboxExecRequest { - sandbox_override, - command: argv, - cwd: command.cwd, - sandbox_policy_cwd: request.sandbox_policy_cwd.clone(), - env: command.env, - network: request.network.cloned(), - network_environment_id: request.environment_id.map(str::to_owned), - sandbox: SandboxType::WindowsRestrictedToken, - windows_sandbox_level: request.windows_sandbox_level, - permission_profile: effective_permission_profile( - request.permissions, - command.additional_permissions.as_ref(), - ), - arg0: None, - }; - prepared.permission_profile = PendingSandboxedExecRequest::new( - &prepared.cwd, - &prepared.sandbox_policy_cwd, - prepared.permission_profile, - trust_bundle.as_ref(), - )? - .effective_permission_profile; - Ok(prepared) - } - pub fn transform_for_direct_spawn( &self, request: SandboxDirectSpawnTransformRequest<'_>, ) -> Result { - #[cfg(target_os = "windows")] - if request.transform.sandbox == SandboxType::WindowsRestrictedToken { - let codex_home = codex_utils_home_dir::find_codex_home() - .map_err(|err| SandboxTransformError::WindowsSandboxPreparation(err.to_string()))?; - return self.transform_for_direct_spawn_with_codex_home(request, codex_home.as_path()); + #[cfg(windows)] + { + self.transform_for_direct_spawn_with_codex_home(request, /*codex_home*/ None) + } + #[cfg(not(windows))] + { + self.transform(request.transform) } - self.transform(request.transform) } #[cfg(target_os = "windows")] fn transform_for_direct_spawn_with_codex_home( &self, request: SandboxDirectSpawnTransformRequest<'_>, - codex_home: &Path, + codex_home: Option<&Path>, ) -> Result { - let workspace_roots = request.workspace_roots; + let roots = request.transform.workspace_roots; + let fallback_roots = request.workspace_roots; let proxy_settings_mode = request.windows_sandbox_proxy_settings_mode; - let sandbox_exe = request.transform.sandbox_exe.ok_or_else(|| { + let sandbox_exe = request.transform.sandbox_exe; + let mut request = self.transform(request.transform)?; + if request.sandbox != SandboxType::WindowsRestrictedToken { + return Ok(request); + } + let owned_codex_home; + let codex_home = if let Some(codex_home) = codex_home { + codex_home + } else { + owned_codex_home = codex_utils_home_dir::find_codex_home() + .map_err(|err| SandboxTransformError::WindowsSandboxPreparation(err.to_string()))?; + owned_codex_home.as_path() + }; + let workspace_roots = roots + .map(launch::native_workspace_roots) + .transpose() + .map_err(SandboxTransformError::InvalidPolicy)?; + let sandbox_exe = sandbox_exe.ok_or_else(|| { SandboxTransformError::WindowsSandboxPreparation( "missing Codex executable path".to_string(), ) })?; - let mut request = self.transform(request.transform)?; wrap_windows_sandbox_exec_request_for_direct_spawn( &mut request, - workspace_roots, + workspace_roots.as_deref().unwrap_or(fallback_roots), codex_home, sandbox_exe, proxy_settings_mode, diff --git a/codex-rs/sandboxing/src/manager_tests.rs b/codex-rs/sandboxing/src/manager_tests.rs index 670117d859..b5486d4223 100644 --- a/codex-rs/sandboxing/src/manager_tests.rs +++ b/codex-rs/sandboxing/src/manager_tests.rs @@ -1,5 +1,5 @@ use super::SandboxCommand; -#[cfg(target_os = "windows")] +#[cfg(windows)] use super::SandboxDirectSpawnTransformRequest; use super::SandboxManager; use super::SandboxTransformRequest; @@ -115,6 +115,7 @@ fn unsandboxed_transform_preserves_foreign_cwd_and_unrestricted_file_system_poli additional_permissions: None, }, permissions: &permissions, + workspace_roots: None, sandbox: SandboxType::None, enforce_managed_network: false, environment_id: Some("remote"), @@ -175,6 +176,7 @@ fn symlinked_workspace_reports_seatbelt_preparation_error() { additional_permissions: None, }, permissions: &permissions, + workspace_roots: None, sandbox: SandboxType::MacosSeatbelt, enforce_managed_network: false, environment_id: None, @@ -229,6 +231,7 @@ fn transform_additional_permissions_enable_network_for_external_sandbox() { }), }, permissions: &permissions, + workspace_roots: None, sandbox: SandboxType::None, enforce_managed_network: false, environment_id: None, @@ -264,78 +267,77 @@ fn transform_additional_permissions_preserves_denied_entries() { .expect("absolute temp dir"); let allowed_path = workspace_root.join("allowed"); let denied_path = workspace_root.join("denied"); - let file_system_policy = FileSystemSandboxPolicy::restricted(vec![ - FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Read, - missing_path_behavior: None, - }, - FileSystemSandboxEntry { - path: denied_path.clone().into(), - access: FileSystemAccessMode::Deny, - missing_path_behavior: None, - }, - ]); + let denied = FileSystemSandboxEntry::new(denied_path.into(), FileSystemAccessMode::Deny); + let allowed = FileSystemSandboxEntry::new(allowed_path.into(), FileSystemAccessMode::Write); let permissions = PermissionProfile::from_runtime_permissions( - &file_system_policy, + &FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry::new( + FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(/*subpath*/ None), + }, + FileSystemAccessMode::Read, + ), + denied.clone(), + ]), NetworkSandboxPolicy::Restricted, ); - let exec_request = manager - .transform(SandboxTransformRequest { + let workspace = PathUri::from_abs_path(&workspace_root); + let build_dir = FileSystemSandboxEntry::new( + workspace_root.join("build").into(), + FileSystemAccessMode::Write, + ); + let read = FileSystemSandboxEntry::new(workspace_root.into(), FileSystemAccessMode::Read); + let additional = AdditionalPermissionProfile { + file_system: Some(FileSystemPermissions { + entries: vec![ + allowed.clone(), + FileSystemSandboxEntry::new( + FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(Some("build".to_string())), + }, + FileSystemAccessMode::Write, + ), + ], + ..Default::default() + }), + ..Default::default() + }; + for (roots, expected_entries) in [ + ( + std::slice::from_ref(&workspace), + vec![read, denied.clone(), allowed.clone(), build_dir], + ), + (&[][..], vec![denied, allowed]), + ] { + let request = SandboxTransformRequest { command: SandboxCommand { - program: "true".into(), + program: "pwd".into(), args: Vec::new(), cwd: cwd_uri.clone(), - env: HashMap::new(), + env: Default::default(), managed_network: None, - additional_permissions: Some(AdditionalPermissionProfile { - file_system: Some(FileSystemPermissions::from_read_write_roots( - /*read*/ None, - Some(vec![allowed_path.clone()]), - )), - ..Default::default() - }), + additional_permissions: Some(additional.clone()), }, permissions: &permissions, + workspace_roots: Some(roots), sandbox: SandboxType::None, enforce_managed_network: false, - environment_id: None, network: None, + environment_id: None, sandbox_policy_cwd: &cwd_uri, sandbox_exe: None, use_legacy_landlock: false, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }) - .expect("transform"); - - assert_eq!( - exec_request.permission_profile.file_system_sandbox_policy(), - FileSystemSandboxPolicy::restricted(vec![ - FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Read, - missing_path_behavior: None, - }, - FileSystemSandboxEntry { - path: denied_path.into(), - access: FileSystemAccessMode::Deny, - missing_path_behavior: None, - }, - FileSystemSandboxEntry { - path: allowed_path.into(), - access: FileSystemAccessMode::Write, - missing_path_behavior: None, - }, - ]) - ); - assert_eq!( - exec_request.permission_profile.network_sandbox_policy(), - NetworkSandboxPolicy::Restricted - ); + windows_sandbox_level: WindowsSandboxLevel::RestrictedToken, + }; + let exec_request = manager.transform(request).expect("transform"); + assert_eq!( + exec_request.permission_profile, + PermissionProfile::from_runtime_permissions( + &FileSystemSandboxPolicy::restricted(expected_entries), + NetworkSandboxPolicy::Restricted, + ) + ); + } } #[test] @@ -401,6 +403,7 @@ fn transform_linux_seccomp_request( additional_permissions: None, }, permissions: &permissions, + workspace_roots: None, sandbox: SandboxType::LinuxSeccomp, enforce_managed_network: false, environment_id: None, @@ -624,6 +627,7 @@ async fn linux_unix_socket_grant_uses_effective_managed_policy() -> anyhow::Resu additional_permissions: None, }, permissions: &PermissionProfile::Disabled, + workspace_roots: None, sandbox: SandboxType::LinuxSeccomp, enforce_managed_network, environment_id: None, @@ -711,7 +715,10 @@ fn transform_for_direct_spawn_windows_materializes_inner_helper() { 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 = AbsolutePathBuf::from_absolute_path( + canonicalize(helper_dir.path()).expect("canonical helper directory"), + ) + .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"); @@ -740,42 +747,48 @@ fn transform_for_direct_spawn_windows_materializes_inner_helper() { 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 other_workspace_root = AbsolutePathBuf::from_absolute_path( + canonicalize(other_workspace.path()).expect("canonical other workspace"), + ) + .expect("absolute other workspace"); + let workspace_roots = [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(), - windows_sandbox_proxy_settings_mode: - codex_windows_sandbox::WindowsSandboxProxySettingsMode::Preserve, - 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(), - )]), - managed_network: None, - additional_permissions: None, + let prepare = |roots, policy_cwd| { + manager + .transform_for_direct_spawn_with_codex_home( + SandboxDirectSpawnTransformRequest { + workspace_roots: workspace_roots.as_slice(), + windows_sandbox_proxy_settings_mode: + codex_windows_sandbox::WindowsSandboxProxySettingsMode::Preserve, + 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(), + )]), + managed_network: None, + additional_permissions: None, + }, + permissions: &permissions, + workspace_roots: roots, + sandbox: SandboxType::WindowsRestrictedToken, + enforce_managed_network: false, + environment_id: None, + network: None, + sandbox_policy_cwd: policy_cwd, + sandbox_exe: Some(configured_helper.as_path()), + use_legacy_landlock: false, + windows_sandbox_level: WindowsSandboxLevel::RestrictedToken, }, - permissions: &permissions, - sandbox: SandboxType::WindowsRestrictedToken, - enforce_managed_network: false, - environment_id: None, - network: None, - sandbox_policy_cwd: &cwd_uri, - sandbox_exe: Some(configured_helper.as_path()), - use_legacy_landlock: false, - windows_sandbox_level: WindowsSandboxLevel::RestrictedToken, }, - }, - codex_home.path(), - ) - .expect("transform for direct spawn"); + Some(codex_home.path()), + ) + .expect("transform for direct spawn") + }; + let exec_request = prepare(/*roots*/ None, &cwd_uri); let inner_env_json = exec_request .command @@ -821,19 +834,54 @@ fn transform_for_direct_spawn_windows_materializes_inner_helper() { 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::>() - ); + let explicit_root = PathUri::from_abs_path(&workspace_roots[1]); + // The wrapper encodes the cwd for an empty root list. Its serialized policy must + // already have removed the workspace write grant. + for (request, expected_roots, expected_writes) in [ + (&exec_request, &workspace_roots[..], None), + ( + &prepare(Some(&[]), &cwd_uri), + &workspace_roots[..1], + Some([false, false]), + ), + ( + // The legacy workspace-write projection always includes its policy cwd. + // Anchor it at the grant and check the command cwd independently. + &prepare(Some(std::slice::from_ref(&explicit_root)), &explicit_root), + &workspace_roots[1..], + Some([false, true]), + ), + ] { + assert_eq!( + request + .command + .windows(2) + .filter_map(|args| { + (args[0] == "--workspace-root").then_some(std::path::PathBuf::from(&args[1])) + }) + .collect::>(), + expected_roots + .iter() + .map(|root| root.as_path().to_path_buf()) + .collect::>() + ); + if let Some(expected_writes) = expected_writes { + let profile_json = request + .command + .windows(2) + .find(|args| args[0] == "--permission-profile") + .expect("serialized wrapper policy"); + let policy = serde_json::from_str::(&profile_json[1]) + .expect("decode wrapper policy") + .file_system_sandbox_policy(); + assert_eq!( + workspace_roots + .each_ref() + .map(|root| policy.can_write_local_path_with_cwd(root, &workspace_roots[0])), + expected_writes + ); + } + } assert_eq!( materialized_helper .parent()