Handle Windows sandbox mapped-drive workspaces

This commit is contained in:
David Wiesen
2026-04-25 20:43:23 -07:00
parent f54ba811cd
commit 6d4c2ae753
7 changed files with 113 additions and 77 deletions

View File

@@ -80,6 +80,7 @@ features = [
"Win32_Security_Cryptography",
"Win32_Security_Authentication_Identity",
"Win32_Graphics_Gdi",
"Win32_NetworkManagement_WNet",
"Win32_System_StationsAndDesktops",
"Win32_UI_WindowsAndMessaging",
"Win32_UI_Shell",

View File

@@ -38,6 +38,7 @@ mod windows_impl {
use crate::logging::log_note;
use crate::logging::log_start;
use crate::logging::log_success;
use crate::path_normalization::normalize_command_cwd;
use crate::policy::SandboxPolicy;
use crate::policy::parse_policy;
use crate::token::convert_string_sid_to_sid;
@@ -241,11 +242,12 @@ mod windows_impl {
write_roots_override,
deny_write_paths_override,
} = request;
let normalized_cwd = normalize_command_cwd(cwd);
let policy = parse_policy(policy_json_or_preset)?;
normalize_null_device_env(&mut env_map);
ensure_non_interactive_pager(&mut env_map);
inherit_path_env(&mut env_map);
inject_git_safe_directory(&mut env_map, cwd, None);
inject_git_safe_directory(&mut env_map, &normalized_cwd, None);
// Use a temp-based log dir that the sandbox user can write.
let sandbox_base = codex_home.join(".sandbox");
ensure_codex_home_exists(&sandbox_base)?;
@@ -255,7 +257,7 @@ mod windows_impl {
let sandbox_creds = require_logon_sandbox_creds(
&policy,
sandbox_policy_cwd,
cwd,
&normalized_cwd,
&env_map,
codex_home,
read_roots_override,
@@ -289,7 +291,7 @@ mod windows_impl {
psid,
vec![
caps.workspace,
crate::cap::workspace_cap_sid_for_cwd(codex_home, cwd)?,
crate::cap::workspace_cap_sid_for_cwd(codex_home, &normalized_cwd)?,
],
)
}
@@ -321,7 +323,7 @@ mod windows_impl {
);
let mut cmdline_vec: Vec<u16> = to_wide(&runner_full_cmd);
let exe_w: Vec<u16> = to_wide(&runner_cmdline);
let cwd_w: Vec<u16> = to_wide(cwd);
let cwd_w: Vec<u16> = to_wide(sandbox_base.as_path());
// Minimal CPWL launch: inherit env, no desktop override, no handle inheritance.
let env_block: Option<Vec<u16>> = None;
@@ -339,7 +341,7 @@ mod windows_impl {
"runner launch: exe={} cmdline={} cwd={}",
runner_exe.display(),
runner_full_cmd,
cwd.display()
sandbox_base.display()
),
logs_base_dir,
);
@@ -413,7 +415,7 @@ mod windows_impl {
message: Message::SpawnRequest {
payload: Box::new(SpawnRequest {
command: command.clone(),
cwd: cwd.to_path_buf(),
cwd: normalized_cwd.clone(),
env: env_map.clone(),
policy_json_or_preset: policy_json_or_preset.to_string(),
sandbox_policy_cwd: sandbox_policy_cwd.to_path_buf(),

View File

@@ -1,10 +1,10 @@
use anyhow::Result;
use std::path::Path;
use std::path::PathBuf;
use windows_sys::Win32::Storage::FileSystem::DRIVE_REMOTE;
use windows_sys::Win32::Storage::FileSystem::GetDriveTypeW;
use crate::winutil::to_wide;
use windows_sys::Win32::Foundation::ERROR_MORE_DATA;
use windows_sys::Win32::Foundation::NO_ERROR;
use windows_sys::Win32::NetworkManagement::WNet::WNetGetConnectionW;
pub fn canonicalize_path(path: &Path) -> PathBuf {
dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
@@ -17,41 +17,54 @@ pub fn canonical_path_key(path: &Path) -> String {
.to_ascii_lowercase()
}
pub fn ensure_windows_sandbox_local_path(path: &Path, context: &str) -> Result<()> {
let raw = path.to_string_lossy();
let normalized = normalize_windows_device_path(&raw).unwrap_or_else(|| raw.into_owned());
if normalized.starts_with(r"\\") {
anyhow::bail!(
"windows sandbox does not support {context} on UNC or mapped network paths: {}. Use a local drive workspace or run without the Windows sandbox for this session.",
path.display()
);
}
if let Some(root) = windows_drive_root(&normalized) {
let drive_type = unsafe { GetDriveTypeW(to_wide(&root).as_ptr()) };
if drive_type == DRIVE_REMOTE {
anyhow::bail!(
"windows sandbox does not support {context} on mapped network drives: {}. Use the underlying local drive path or run without the Windows sandbox for this session.",
path.display()
);
}
}
Ok(())
pub fn normalize_command_cwd(path: &Path) -> PathBuf {
let simplified = dunce::simplified(path).to_path_buf();
normalize_mapped_drive_path_with(&simplified, mapped_drive_remote_root).unwrap_or(simplified)
}
fn windows_drive_root(path: &str) -> Option<String> {
let bytes = path.as_bytes();
if bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
return Some(format!("{}:\\", path[..1].to_ascii_uppercase()));
fn normalize_mapped_drive_path_with<F>(path: &Path, resolve_remote_root: F) -> Option<PathBuf>
where
F: Fn(&str) -> Option<String>,
{
let raw = path.to_string_lossy();
let bytes = raw.as_bytes();
if bytes.len() < 2 || !bytes[0].is_ascii_alphabetic() || bytes[1] != b':' {
return None;
}
let drive = raw[..2].to_ascii_uppercase();
let remote_root = resolve_remote_root(&drive)?;
let suffix = raw[2..].trim_start_matches(['\\', '/']);
let mut normalized = PathBuf::from(remote_root);
if !suffix.is_empty() {
normalized.push(suffix);
}
Some(normalized)
}
fn mapped_drive_remote_root(drive: &str) -> Option<String> {
let drive_wide = to_wide(drive);
let mut len = 260u32;
loop {
let mut buf = vec![0u16; len as usize];
let status = unsafe { WNetGetConnectionW(drive_wide.as_ptr(), buf.as_mut_ptr(), &mut len) };
match status {
NO_ERROR => {
let end = buf.iter().position(|ch| *ch == 0).unwrap_or(buf.len());
return String::from_utf16(&buf[..end]).ok();
}
ERROR_MORE_DATA => continue,
_ => return None,
}
}
None
}
#[cfg(test)]
mod tests {
use super::canonical_path_key;
use super::windows_drive_root;
use super::normalize_command_cwd;
use super::normalize_mapped_drive_path_with;
use pretty_assertions::assert_eq;
use std::path::Path;
@@ -67,9 +80,23 @@ mod tests {
}
#[test]
fn windows_drive_root_extracts_drive_prefix() {
assert_eq!(windows_drive_root(r"l:\cs-web"), Some(r"L:\".to_string()));
assert_eq!(windows_drive_root(r"C:/repo"), Some(r"C:\".to_string()));
assert_eq!(windows_drive_root(r"\\video1\node\cs-web"), None);
fn mapped_drive_paths_expand_to_unc_roots() {
let path = Path::new(r"L:\cs-web\context");
let normalized = normalize_mapped_drive_path_with(path, |drive| {
(drive == "L:").then(|| r"\\video1\node".to_string())
});
assert_eq!(
normalized,
Some(PathBuf::from(r"\\video1\node\cs-web\context"))
);
}
#[test]
fn local_paths_are_left_alone() {
let path = Path::new(r"C:\Users\Dev\Repo");
assert_eq!(
normalize_command_cwd(path),
PathBuf::from(r"C:\Users\Dev\Repo")
);
}
}

View File

@@ -15,7 +15,7 @@ use crate::allow::compute_allow_paths;
use crate::helper_materialization::helper_bin_dir;
use crate::logging::log_note;
use crate::path_normalization::canonical_path_key;
use crate::path_normalization::ensure_windows_sandbox_local_path;
use crate::path_normalization::normalize_command_cwd;
use crate::policy::SandboxPolicy;
use crate::setup_error::SetupErrorCode;
use crate::setup_error::SetupFailure;
@@ -166,24 +166,30 @@ fn run_setup_refresh_inner(
) {
return Ok(());
}
let (read_roots, write_roots) = build_payload_roots(&request, &overrides);
let deny_write_paths = build_payload_deny_write_paths(&request, overrides.deny_write_paths);
ensure_windows_sandbox_local_path(request.command_cwd, "sandbox working directory")?;
for root in &write_roots {
ensure_windows_sandbox_local_path(root, "sandbox writable root")?;
}
for path in &deny_write_paths {
ensure_windows_sandbox_local_path(path, "sandbox protected path")?;
}
let network_identity =
SandboxNetworkIdentity::from_policy(request.policy, request.proxy_enforced);
let offline_proxy_settings = offline_proxy_settings_from_env(request.env_map, network_identity);
let normalized_command_cwd = normalize_command_cwd(request.command_cwd);
let normalized_request = SandboxSetupRequest {
policy: request.policy,
policy_cwd: request.policy_cwd,
command_cwd: &normalized_command_cwd,
env_map: request.env_map,
codex_home: request.codex_home,
proxy_enforced: request.proxy_enforced,
};
let (read_roots, write_roots) = build_payload_roots(&normalized_request, &overrides);
let deny_write_paths =
build_payload_deny_write_paths(&normalized_request, overrides.deny_write_paths);
let network_identity = SandboxNetworkIdentity::from_policy(
normalized_request.policy,
normalized_request.proxy_enforced,
);
let offline_proxy_settings =
offline_proxy_settings_from_env(normalized_request.env_map, network_identity);
let payload = ElevationPayload {
version: SETUP_VERSION,
offline_username: OFFLINE_USERNAME.to_string(),
online_username: ONLINE_USERNAME.to_string(),
codex_home: request.codex_home.to_path_buf(),
command_cwd: request.command_cwd.to_path_buf(),
codex_home: normalized_request.codex_home.to_path_buf(),
command_cwd: normalized_request.command_cwd.to_path_buf(),
read_roots,
write_roots,
deny_write_paths,
@@ -206,14 +212,14 @@ fn run_setup_refresh_inner(
cwd.display(),
b64.len()
),
Some(&sandbox_dir(request.codex_home)),
Some(&sandbox_dir(normalized_request.codex_home)),
);
let status = cmd
.status()
.map_err(|e| {
log_note(
&format!("setup refresh: failed to spawn {}: {e}", exe.display()),
Some(&sandbox_dir(request.codex_home)),
Some(&sandbox_dir(normalized_request.codex_home)),
);
e
})
@@ -221,7 +227,7 @@ fn run_setup_refresh_inner(
if !status.success() {
log_note(
&format!("setup refresh: exited with status {status:?}"),
Some(&sandbox_dir(request.codex_home)),
Some(&sandbox_dir(normalized_request.codex_home)),
);
return Err(anyhow!("setup refresh failed with status {status}"));
}

View File

@@ -13,7 +13,7 @@ use crate::identity::SandboxCreds;
use crate::identity::require_logon_sandbox_creds;
use crate::logging::log_start;
use crate::path_normalization::canonicalize_path;
use crate::path_normalization::ensure_windows_sandbox_local_path;
use crate::path_normalization::normalize_command_cwd;
use crate::policy::SandboxPolicy;
use crate::policy::parse_policy;
use crate::sandbox_utils::ensure_codex_home_exists;
@@ -104,7 +104,7 @@ fn prepare_spawn_context_common(
anyhow::bail!("DangerFullAccess and ExternalSandbox are not supported for sandboxing")
}
ensure_windows_sandbox_local_path(cwd, "sandbox working directory")?;
let normalized_cwd = normalize_command_cwd(cwd);
normalize_null_device_env(env_map);
ensure_non_interactive_pager(env_map);
@@ -112,7 +112,7 @@ fn prepare_spawn_context_common(
inherit_path_env(env_map);
}
if add_git_safe_directory {
inject_git_safe_directory(env_map, cwd);
inject_git_safe_directory(env_map, &normalized_cwd);
}
ensure_codex_home_exists(codex_home)?;
@@ -125,7 +125,7 @@ fn prepare_spawn_context_common(
Ok(SpawnContext {
policy,
current_dir: cwd.to_path_buf(),
current_dir: normalized_cwd,
sandbox_base,
logs_base_dir,
is_workspace_write,
@@ -171,7 +171,8 @@ pub(crate) fn prepare_legacy_session_security(
}
SandboxPolicy::WorkspaceWrite { .. } => {
let psid_generic = LocalSid::from_string(&caps.workspace)?;
let workspace_sid = workspace_cap_sid_for_cwd(codex_home, cwd)?;
let workspace_sid =
workspace_cap_sid_for_cwd(codex_home, &normalize_command_cwd(cwd))?;
let psid_workspace = LocalSid::from_string(&workspace_sid)?;
let base = get_current_token_for_restriction()?;
let h_token = create_workspace_write_token_with_caps_from(
@@ -285,12 +286,6 @@ pub(crate) fn prepare_elevated_spawn_context(
);
let write_roots: Vec<PathBuf> = allow.into_iter().collect();
let deny_write_paths: Vec<PathBuf> = deny.into_iter().collect();
for root in &write_roots {
ensure_windows_sandbox_local_path(root, "sandbox writable root")?;
}
for path in &deny_write_paths {
ensure_windows_sandbox_local_path(path, "sandbox protected path")?;
}
let write_roots_override = if common.is_workspace_write {
Some(write_roots.as_slice())
} else {
@@ -299,7 +294,7 @@ pub(crate) fn prepare_elevated_spawn_context(
let sandbox_creds = require_logon_sandbox_creds(
&common.policy,
sandbox_policy_cwd,
cwd,
&common.current_dir,
env_map,
codex_home,
/*read_roots_override*/ None,
@@ -314,7 +309,7 @@ pub(crate) fn prepare_elevated_spawn_context(
vec![caps.readonly.clone()],
),
SandboxPolicy::WorkspaceWrite { .. } => {
let cap_sid = workspace_cap_sid_for_cwd(codex_home, cwd)?;
let cap_sid = workspace_cap_sid_for_cwd(codex_home, &common.current_dir)?;
(
LocalSid::from_string(&caps.workspace)?,
vec![caps.workspace.clone(), cap_sid],

View File

@@ -42,7 +42,7 @@ pub(crate) async fn spawn_windows_sandbox_session_elevated(
let spawn_request = SpawnRequest {
command: command.clone(),
cwd: cwd.to_path_buf(),
cwd: elevated.common.current_dir.clone(),
env: env_map.clone(),
policy_json_or_preset: policy_json_or_preset.to_string(),
sandbox_policy_cwd: sandbox_policy_cwd.to_path_buf(),
@@ -55,12 +55,16 @@ pub(crate) async fn spawn_windows_sandbox_session_elevated(
use_private_desktop,
};
let codex_home = codex_home.to_path_buf();
let cwd = cwd.to_path_buf();
let runner_cwd = elevated.common.sandbox_base.clone();
let sandbox_creds = elevated.sandbox_creds.clone();
let logs_base_dir = elevated.common.logs_base_dir.clone();
let transport = tokio::task::spawn_blocking(move || -> Result<_> {
let mut transport =
spawn_runner_transport(&codex_home, &cwd, &sandbox_creds, logs_base_dir.as_deref())?;
let mut transport = spawn_runner_transport(
&codex_home,
&runner_cwd,
&sandbox_creds,
logs_base_dir.as_deref(),
)?;
transport.send_spawn_request(spawn_request)?;
transport.read_spawn_ready()?;
Ok(transport)

View File

@@ -300,7 +300,8 @@ pub(crate) async fn spawn_windows_sandbox_session_legacy(
if !common.policy.has_full_disk_read_access() {
anyhow::bail!("Restricted read-only access requires the elevated Windows sandbox backend");
}
let security = prepare_legacy_session_security(&common.policy, codex_home, cwd)?;
let security =
prepare_legacy_session_security(&common.policy, codex_home, &common.current_dir)?;
allow_null_device_for_workspace_write(common.is_workspace_write);
let persist_aces = common.is_workspace_write;
@@ -333,7 +334,7 @@ pub(crate) async fn spawn_windows_sandbox_session_legacy(
} = match spawn_legacy_process(
security.h_token,
&command,
cwd,
&common.current_dir,
&env_map,
use_private_desktop,
tty,