diff --git a/Cargo.lock b/Cargo.lock index 6098df115fcd77..4edc83916c5f44 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -278,6 +278,7 @@ dependencies = [ "regex", "reqwest_client", "rust-embed", + "sandbox", "schemars 1.0.4", "serde", "serde_json", @@ -15899,6 +15900,8 @@ version = "0.1.0" dependencies = [ "anyhow", "libc", + "log", + "smol", "tempfile", ] diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 87ef3a5080809c..7f89dad049583e 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -3483,30 +3483,81 @@ impl AcpThread { .and_then(|r| r.read(cx).default_system_shell()) }) .unwrap_or_else(|| get_default_system_shell_preferring_bash()); - let (task_command, task_args) = - ShellBuilder::new(&Shell::Program(shell), is_windows) - .redirect_stdin_to_dev_null() - .build(Some(command.clone()), &args); // Spawn the network proxy (if the wrap requests network) before // generating the sandbox policy, since the policy must pin the // child to the proxy's loopback port. This also injects the - // child's proxy env vars. + // child's proxy env vars. On Windows the WSL sandbox can only + // toggle the network wholesale, so this never spawns a proxy + // there, but it still resolves the allow/deny `network_policy`. let (proxy_handle, network_policy) = setup_network_proxy(sandbox_wrap.as_ref(), &mut env, cx)?; - let (task_command, task_args, sandbox_config) = apply_sandbox_wrap( - task_command, - task_args, - cwd.as_deref(), - sandbox_wrap, - network_policy, - )?; + + #[cfg(target_os = "windows")] + let (task_command, task_args, sandbox_config, spawn_cwd) = + if let Some(sandbox_wrap) = sandbox_wrap { + // Run the wrap on a background task: it probes WSL + // (possibly booting its VM) and stats UNC paths, + // either of which can take seconds and must not block + // the foreground thread this task runs on. Bound it + // with a timeout so a wedged `wsl.exe` can't stall + // this command forever; on timeout, dropping the task + // cancels the wrap future, which kills any in-flight + // `wsl.exe` child (see `windows_wsl::wrap_invocation`). + let wrap = cx.background_spawn(apply_windows_wsl_sandbox_wrap( + command.clone(), + args.clone(), + cwd.clone(), + sandbox_wrap, + network_policy, + env.clone(), + )); + let timeout = cx.background_executor().timer(WSL_SANDBOX_WRAP_TIMEOUT); + let (task_command, task_args, sandbox_config) = futures::select_biased! { + result = wrap.fuse() => result?, + // A wedged `wsl.exe` is an environment failure, so + // surface it as `WslSandboxUnavailable` (like the + // probe failures inside `wrap_invocation`) so the + // agent offers the run-unsandboxed fallback rather + // than returning a bad request to the model. + _ = timeout.fuse() => return Err(anyhow::Error::new( + sandbox::windows_wsl::WslSandboxUnavailable::new(format!( + "WSL did not respond within {} seconds while preparing the \ + sandboxed command", + WSL_SANDBOX_WRAP_TIMEOUT.as_secs() + )), + )), + }; + (task_command, task_args, sandbox_config, None) + } else { + let (task_command, task_args) = + ShellBuilder::new(&Shell::Program(shell), is_windows) + .redirect_stdin_to_dev_null() + .build(Some(command.clone()), &args); + (task_command, task_args, None, cwd.clone()) + }; + + #[cfg(not(target_os = "windows"))] + let (task_command, task_args, sandbox_config, spawn_cwd) = { + let (task_command, task_args) = + ShellBuilder::new(&Shell::Program(shell), is_windows) + .redirect_stdin_to_dev_null() + .build(Some(command.clone()), &args); + let (task_command, task_args, sandbox_config) = apply_sandbox_wrap( + task_command, + task_args, + cwd.as_deref(), + sandbox_wrap, + network_policy, + )?; + (task_command, task_args, sandbox_config, cwd.clone()) + }; let terminal = project .update(cx, |project, cx| { project.create_terminal_task( task::SpawnInTerminal { command: Some(task_command), args: task_args, - cwd: cwd.clone(), + cwd: spawn_cwd, env, ..Default::default() }, diff --git a/crates/acp_thread/src/terminal.rs b/crates/acp_thread/src/terminal.rs index 84880758888ba6..d8b501b82691b4 100644 --- a/crates/acp_thread/src/terminal.rs +++ b/crates/acp_thread/src/terminal.rs @@ -225,9 +225,14 @@ pub(crate) enum NetworkPolicy { /// back over a status channel whether it could enforce the sandbox, and when /// it can't (no usable `bwrap`, user namespaces disabled, …) it runs the /// command unsandboxed and the parent logs a warning rather than failing. -/// * Windows and all other platforms pass the command through unchanged — -/// we have no sandbox integration there, so the command runs with the -/// agent's ambient permissions. +/// * Windows routes the command through WSL and runs it under Bubblewrap +/// there, but that path is async (it performs `wsl.exe` round-trips), so it +/// lives in [`apply_windows_wsl_sandbox_wrap`] rather than this synchronous +/// function. +/// * All other platforms pass the command through unchanged — we have no +/// sandbox integration there, so the command runs with the agent's ambient +/// permissions. +#[cfg(not(target_os = "windows"))] pub(crate) fn apply_sandbox_wrap( program: String, args: Vec, @@ -346,9 +351,9 @@ pub(crate) fn apply_sandbox_wrap( // there's no on-disk resource to keep alive. Ok((new_program, new_args, None)) } - #[cfg(target_os = "windows")] + #[cfg(not(any(target_os = "macos", target_os = "linux")))] { - // No sandbox integration on Windows; run with ambient permissions. + // No sandbox integration available; run with ambient permissions. if let NetworkPolicy::Proxied(port) = network_policy { log::debug!( "[sandbox/network] ignoring proxy port {port} because this platform has no sandbox integration" @@ -357,17 +362,72 @@ pub(crate) fn apply_sandbox_wrap( let _ = (sandbox_wrap, cwd); Ok((program, args, None)) } - #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] - { - // No sandbox integration available; run with ambient permissions. - if let NetworkPolicy::Proxied(port) = network_policy { +} + +/// Upper bound on preparing a WSL-sandboxed command (the probe and path +/// resolution `wsl.exe` round-trips in [`apply_windows_wsl_sandbox_wrap`]). +/// Deliberately generous: the first invocation after the WSL utility VM has +/// shut down (or after boot) has to start the VM and the distro, which +/// routinely takes 10-30 seconds on slow disks or under antivirus scanning. +/// The point is not latency policing but turning a wedged `wsl.exe` (a real +/// failure mode when the WSL service is unhealthy) into an actionable error +/// instead of a terminal command that never starts. +#[cfg(target_os = "windows")] +pub(crate) const WSL_SANDBOX_WRAP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); + +/// Wrap a terminal command so it runs under Bubblewrap inside WSL (see +/// [`sandbox::windows_wsl`]). +/// +/// Async because it performs `wsl.exe` round-trips and UNC-path stats that +/// can take seconds when the WSL VM is cold; callers must run it on a +/// background executor so the UI thread is never blocked, and should bound +/// it with [`WSL_SANDBOX_WRAP_TIMEOUT`]. Parameters are owned so the future +/// is `Send + 'static`. Dropping the future (timeout or caller cancellation) +/// kills any in-flight `wsl.exe` child rather than leaking it. +/// +/// The Windows sandbox (Bubblewrap inside WSL) can only toggle network access +/// wholesale, so `network_policy` collapses to allow/deny here just as it does +/// on Linux. `setup_network_proxy` never resolves to `Proxied` on Windows. +#[cfg(target_os = "windows")] +pub(crate) async fn apply_windows_wsl_sandbox_wrap( + command: String, + args: Vec, + cwd: Option, + sandbox_wrap: SandboxWrap, + network_policy: NetworkPolicy, + env: collections::HashMap, +) -> anyhow::Result<(String, Vec, Option)> { + let allow_network = match network_policy { + NetworkPolicy::Denied => false, + NetworkPolicy::Unrestricted => true, + NetworkPolicy::Proxied(port) => { + // Bubblewrap (in WSL) can only toggle network access wholesale, so + // it can't confine egress to the proxy's loopback port. + // `setup_network_proxy` never resolves to `Proxied` on Windows; + // deny network rather than silently widening access. log::debug!( - "[sandbox/network] ignoring proxy port {port} because this platform has no sandbox integration" + "[sandbox/network] ignoring proxy port {port}; bubblewrap in WSL can't confine to a loopback port" ); + false } - let _ = (sandbox_wrap, cwd); - Ok((program, args, None)) - } + }; + let (program, args) = task::ShellBuilder::new(&Shell::Program("/bin/sh".to_string()), false) + .non_interactive() + .redirect_stdin_to_dev_null() + .build(Some(command), &args); + let writable: Vec = sandbox_wrap + .writable_paths + .into_iter() + .chain(sandbox_wrap.extra_write_paths) + .collect(); + let permissions = sandbox::SandboxPermissions { + allow_network, + allow_fs_write: sandbox_wrap.allow_fs_write, + }; + let (program, args) = + sandbox::windows_wsl::wrap_invocation(program, args, writable, permissions, cwd, env) + .await?; + Ok((program, args, None)) } /// Spawn the in-process network proxy for a sandboxed command with restricted diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 5b65d092d0a87d..8420cbede15273 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -55,6 +55,7 @@ prompt_store.workspace = true quick-xml.workspace = true regex.workspace = true rust-embed.workspace = true +sandbox.workspace = true schemars.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 052af69847c6c0..eed1f118db3cab 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -3107,15 +3107,19 @@ impl ThreadEnvironment for NativeThreadEnvironment { // scope) at a client-side path would leak client environment into the // remote terminal and reference a directory that doesn't exist there. // - // Linux is excluded: the bwrap sandbox already mounts a fresh, - // writable `tmpfs` over `/tmp`, so the environment looks like a normal + // Linux and Windows are excluded: the bwrap sandbox (run directly on + // Linux, and via WSL on Windows) already mounts a fresh, writable + // `tmpfs` over `/tmp`, so the environment looks like a normal // filesystem with no special `$TMPDIR` (which would only make the - // sandbox more obviously Zed-specific). - #[cfg_attr(target_os = "linux", allow(unused_mut))] + // sandbox more obviously Zed-specific). On Windows a per-thread + // `$TMPDIR` would also be a Windows path that's meaningless inside + // WSL, and adding it to the writable scope would bind a stray + // `/mnt//...` path. + #[cfg_attr(any(target_os = "linux", target_os = "windows"), allow(unused_mut))] let mut extra_env = extra_env; - #[cfg_attr(target_os = "linux", allow(unused_mut))] + #[cfg_attr(any(target_os = "linux", target_os = "windows"), allow(unused_mut))] let mut sandbox_wrap = sandbox_wrap; - #[cfg(not(target_os = "linux"))] + #[cfg(not(any(target_os = "linux", target_os = "windows")))] { let temp_dir = self.thread.update(cx, |thread, cx| { thread diff --git a/crates/agent/src/sandboxing.rs b/crates/agent/src/sandboxing.rs index e907a992a1563c..be32c552655fe1 100644 --- a/crates/agent/src/sandboxing.rs +++ b/crates/agent/src/sandboxing.rs @@ -6,20 +6,25 @@ //! place instead of scattered across the agent crate). //! //! The current policy is: enabled iff the user has the `sandboxing` feature -//! flag turned on. There's deliberately no settings or env-var override yet — -//! the flag is the only switch. +//! flag turned on, the project is local, the platform has an integration, and +//! the user has not turned sandboxing off entirely (the `disabled` sandbox +//! setting; the `allow_unsandboxed` grant only auto-approves commands that +//! explicitly request to run unsandboxed and doesn't turn the sandbox off). //! -//! macOS (Seatbelt) and Linux (Bubblewrap) have real sandbox integrations; on -//! platforms without one the per-command wrap is a no-op, so commands run with -//! the agent's ambient permissions even when the flag is on. +//! macOS (Seatbelt), Linux (Bubblewrap), and Windows (Bubblewrap via WSL) +//! have real sandbox integrations; on platforms without one the per-command +//! wrap is a no-op, so commands run with the agent's ambient permissions even +//! when the flag is on. //! //! Naming note: this module is about agent terminal sandboxing specifically. //! Other agent operations (e.g. file edits) are gated separately. -use agent_settings::SandboxPermissions; +use agent_settings::{AgentSettings, SandboxPermissions}; use feature_flags::{FeatureFlagAppExt as _, SandboxingFeatureFlag}; use gpui::App; use http_proxy::HostPattern; +use project::Project; +use settings::Settings; use std::path::PathBuf; /// Whether agent-run terminal commands should be wrapped in an OS-level @@ -28,6 +33,18 @@ pub(crate) fn sandboxing_enabled(cx: &App) -> bool { cx.has_flag::() } +/// Whether the sandboxed terminal can be exposed for this project. +pub(crate) fn sandboxing_enabled_for_project(project: &Project, cx: &App) -> bool { + sandboxing_enabled(cx) + && project.is_local() + && !AgentSettings::get_global(cx).sandbox_permissions.disabled + && cfg!(any( + target_os = "macos", + target_os = "linux", + target_os = "windows" + )) +} + /// Network escalation requested for (or granted to) a sandboxed command. /// /// Network access in the sandbox is allowlisted by hostname: by default @@ -189,9 +206,10 @@ impl ThreadSandboxGrants { } /// Record that the user approved running commands unsandboxed for the rest - /// of the thread when the sandbox can't be created. Only Linux can fail to - /// create a sandbox, so this is Linux-only. - #[cfg(target_os = "linux")] + /// of the thread when the sandbox can't be created. Only the Bubblewrap + /// sandboxes (Linux directly, Windows via WSL) can fail to create a + /// sandbox, so this is gated to those platforms. + #[cfg(any(target_os = "linux", target_os = "windows"))] pub fn record_fallback(&mut self) { self.sandbox_fallback = true; } @@ -335,7 +353,7 @@ mod tests { grants.effective_with_persistent(request, &SandboxPermissions::default()) } - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "windows"))] #[test] fn fallback_granted_for_thread_tracks_record_fallback() { let mut grants = ThreadSandboxGrants::default(); @@ -500,11 +518,8 @@ mod tests { let mut grants = ThreadSandboxGrants::default(); grants.record(&request(hosts(&["github.com"]), false, &[])); let persistent = SandboxPermissions { - allow_all_hosts: false, - network_hosts: Vec::new(), - allow_fs_write_all: false, - allow_unsandboxed: false, write_paths: vec![PathBuf::from("/tmp/build")], + ..Default::default() }; assert!(grants.covers_with_persistent( @@ -521,11 +536,8 @@ mod tests { fn persistent_network_hosts_are_honored() { let grants = ThreadSandboxGrants::default(); let persistent = SandboxPermissions { - allow_all_hosts: false, network_hosts: vec!["*.npmjs.org".to_string()], - allow_fs_write_all: false, - allow_unsandboxed: false, - write_paths: Vec::new(), + ..Default::default() }; assert!(grants.covers_with_persistent( @@ -542,11 +554,8 @@ mod tests { fn persistent_all_access_covers_concrete_writes() { let grants = ThreadSandboxGrants::default(); let persistent = SandboxPermissions { - allow_all_hosts: false, - network_hosts: Vec::new(), allow_fs_write_all: true, - allow_unsandboxed: false, - write_paths: Vec::new(), + ..Default::default() }; assert!(grants.covers_with_persistent( @@ -566,11 +575,8 @@ mod tests { fn persistent_unsandboxed_covers_unsandboxed_requests_only() { let grants = ThreadSandboxGrants::default(); let persistent = SandboxPermissions { - allow_all_hosts: false, - network_hosts: Vec::new(), - allow_fs_write_all: false, allow_unsandboxed: true, - write_paths: Vec::new(), + ..Default::default() }; assert!(grants.covers_with_persistent(&unsandboxed_request(), &persistent)); @@ -626,10 +632,8 @@ mod tests { let grants = ThreadSandboxGrants::default(); let persistent = SandboxPermissions { allow_all_hosts: true, - network_hosts: Vec::new(), - allow_fs_write_all: false, - allow_unsandboxed: false, write_paths: vec![PathBuf::from("/tmp/always")], + ..Default::default() }; let effective = grants diff --git a/crates/agent/src/templates.rs b/crates/agent/src/templates.rs index c08aeb91025577..a88c5171d7a5d1 100644 --- a/crates/agent/src/templates.rs +++ b/crates/agent/src/templates.rs @@ -55,6 +55,8 @@ pub struct SystemPromptTemplate<'a> { /// section describes the right one rather than advertising a `$TMPDIR` /// that doesn't behave as stated. pub is_linux: bool, + /// Whether sandboxed terminal commands run through WSL on Windows. + pub is_windows: bool, } impl Template for SystemPromptTemplate<'_> { @@ -101,6 +103,7 @@ mod tests { user_agents_md: None, sandboxing: false, is_linux: false, + is_windows: false, }; let templates = Templates::new(); let rendered = template.render(&templates).unwrap(); @@ -133,6 +136,7 @@ mod tests { user_agents_md: Some("always be concise".into()), sandboxing: false, is_linux: false, + is_windows: false, }; let templates = Templates::new(); let rendered = template.render(&templates).unwrap(); @@ -161,6 +165,7 @@ mod tests { user_agents_md: None, sandboxing: false, is_linux: false, + is_windows: false, }; let templates = Templates::new(); let rendered = template.render(&templates).unwrap(); @@ -193,6 +198,7 @@ mod tests { user_agents_md: None, sandboxing: true, is_linux: false, + is_windows: false, }; let templates = Templates::new(); let rendered = template.render(&templates).unwrap(); @@ -226,6 +232,7 @@ mod tests { user_agents_md: None, sandboxing: true, is_linux: true, + is_windows: false, }; let templates = Templates::new(); let rendered = template.render(&templates).unwrap(); @@ -248,6 +255,7 @@ mod tests { user_agents_md: None, sandboxing: true, is_linux: false, + is_windows: false, }; let templates = Templates::new(); let rendered = template.render(&templates).unwrap(); @@ -267,6 +275,7 @@ mod tests { user_agents_md: None, sandboxing: false, is_linux: false, + is_windows: false, }; let templates = Templates::new(); let rendered = template.render(&templates).unwrap(); @@ -284,6 +293,7 @@ mod tests { user_agents_md: None, sandboxing: false, is_linux: false, + is_windows: false, }; let templates = Templates::new(); let rendered = template.render(&templates).unwrap(); diff --git a/crates/agent/src/templates/system_prompt.hbs b/crates/agent/src/templates/system_prompt.hbs index 5bcd18760322d6..30a22792e5aa6e 100644 --- a/crates/agent/src/templates/system_prompt.hbs +++ b/crates/agent/src/templates/system_prompt.hbs @@ -166,12 +166,21 @@ The `terminal` tool runs commands inside a sandbox with these permissions: {{/each}} Writes anywhere else on the filesystem are blocked.{{else}}. No project directories are currently writable.{{/if}} {{else}} +{{#if is_windows}} +- Execution: commands run inside WSL under Bubblewrap. Native Windows project paths are routed through WSL's `/mnt//...` filesystem view. +- Writes: `/tmp` inside WSL is writable but is cleared between `terminal` calls{{#if worktrees}}. These project directories are also writable and persist across calls: +{{#each worktrees}} + - `{{abs_path}}` +{{/each}} + Writes anywhere else on the WSL filesystem and mounted Windows drives are blocked.{{else}}. No project directories are currently writable.{{/if}} +{{else}} - Writes: a per-thread temporary directory exposed via `$TMPDIR`, `$TMP`, and `$TEMP` is writable and persists across `terminal` calls in this thread{{#if worktrees}}, along with these project directories: {{#each worktrees}} - `{{abs_path}}` {{/each}} Writes anywhere else on the filesystem are blocked.{{else}}. No project directories are currently writable.{{/if}} {{/if}} +{{/if}} - Network: outbound network access is blocked. {{#if is_linux}} @@ -179,6 +188,14 @@ The sandbox can only allow or block outbound network access as a whole — it ca You can request elevated permissions on individual `terminal` calls: +- `allow_all_hosts: true` — allow unrestricted outbound network access. On this platform this is the only way to grant network access. +- `allow_hosts: ["github.com", ...]` — accepted, but on this platform listing hosts grants unrestricted outbound network access (identical to `allow_all_hosts`), because per-host restriction can't be enforced. Prefer `allow_all_hosts` so the request is explicit. +{{else}} +{{#if is_windows}} +The sandbox can only allow or block outbound network access as a whole — it cannot restrict access to specific hosts. There is no HTTP/HTTPS proxy, so once network access is granted SSH, FTP, and raw sockets work too. + +You can request elevated permissions on individual `terminal` calls: + - `allow_all_hosts: true` — allow unrestricted outbound network access. On this platform this is the only way to grant network access. - `allow_hosts: ["github.com", ...]` — accepted, but on this platform listing hosts grants unrestricted outbound network access (identical to `allow_all_hosts`), because per-host restriction can't be enforced. Prefer `allow_all_hosts` so the request is explicit. {{else}} @@ -189,6 +206,7 @@ You can request elevated permissions on individual `terminal` calls: - `allow_hosts: ["github.com", "*.npmjs.org"]` — allow outbound HTTP/HTTPS to specific hosts (exact hostnames or leading-`*.` subdomain wildcards; no IP literals). Prefer this whenever you know which hosts the command needs. - `allow_all_hosts: true` — allow outbound HTTP/HTTPS to any host. Use only when the specific hosts can't be enumerated up front. {{/if}} +{{/if}} - `fs_write_paths: ["/abs/or/worktree-relative/path", ...]` — allow writes to specific paths (each directory grants its whole subtree). Prefer this whenever you know which paths the command needs to write. - `allow_fs_write_all: true` — allow unrestricted filesystem writes. Only use this when the specific paths can't be enumerated up front. - `unsandboxed: true` — run the command with no sandbox at all. Use only when none of the above suffice. diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 07359eeb012941..6bd34297f1b6b0 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -11,7 +11,7 @@ use acp_thread::{MentionUri, UserMessageId}; use action_log::ActionLog; use agent_settings::UserAgentsMd; -use crate::sandboxing::{SandboxRequest, ThreadSandboxGrants, sandboxing_enabled}; +use crate::sandboxing::{SandboxRequest, ThreadSandboxGrants, sandboxing_enabled_for_project}; use agent_client_protocol::schema as acp; use agent_settings::{ AgentProfileId, AgentSettings, AutoCompactThreshold, COMPACTION_PROMPT, @@ -1367,9 +1367,10 @@ impl Thread { &self.id } - // Only used by Seatbelt-style sandboxes; Linux relies on bwrap's tmpfs - // `/tmp` and never needs a per-thread temp directory. - #[cfg(not(target_os = "linux"))] + // Only used by Seatbelt-style sandboxes (macOS); Linux relies on bwrap's + // tmpfs `/tmp` and Windows on the WSL bwrap tmpfs, so neither needs a + // per-thread temp directory. + #[cfg(not(any(target_os = "linux", target_os = "windows")))] pub(crate) fn sandboxed_terminal_temp_dir( &mut self, cx: &mut Context, @@ -3738,7 +3739,7 @@ impl Thread { // Terminal variants are configured by users under the canonical // `terminal` name. Expose the one matching the current sandbox state // to the model under that name. - let use_sandboxed_terminal = sandboxing_enabled(cx); + let use_sandboxed_terminal = sandboxing_enabled_for_project(self.project.read(cx), cx); let mut tools = self .tools @@ -3909,8 +3910,12 @@ impl Thread { model_name: self.model.as_ref().map(|m| m.name().0.to_string()), date: Local::now().format("%Y-%m-%d").to_string(), user_agents_md, - sandboxing: crate::sandboxing::sandboxing_enabled(cx), + sandboxing: crate::sandboxing::sandboxing_enabled_for_project( + self.project.read(cx), + cx, + ), is_linux: cfg!(target_os = "linux"), + is_windows: cfg!(target_os = "windows"), } .render(&self.templates) .context("failed to build system prompt") @@ -5001,9 +5006,10 @@ impl ThreadEventStream { } /// The user's choice when the OS sandbox could not be created for a command -/// (see [`ToolCallEventStream::authorize_sandbox_fallback`]). Only Linux can -/// fail to create a sandbox, so this is Linux-only. -#[cfg(target_os = "linux")] +/// (see [`ToolCallEventStream::authorize_sandbox_fallback`]). Only the +/// Bubblewrap sandboxes (Linux directly, Windows via WSL) can fail to create a +/// sandbox, so this is gated to those platforms. +#[cfg(any(target_os = "linux", target_os = "windows"))] #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum SandboxFallbackDecision { /// Try creating the sandbox again (e.g. after the user installed `bwrap`). @@ -5524,13 +5530,14 @@ impl ToolCallEventStream { /// so the prompt explains why (`reason`) and lets the user retry, run the /// command unsandboxed (once / for this thread / always), or deny it. The /// "for this thread" choice is recorded in the in-memory thread grants and - /// "always" is persisted as the `allow_unsandboxed` setting. Only Linux can - /// fail to create a sandbox, so this is Linux-only. + /// "always" is persisted as the `allow_unsandboxed` setting. Only the + /// Bubblewrap sandboxes (Linux directly, Windows via WSL) can fail to + /// create a sandbox, so this is gated to those platforms. /// /// `retries` is how many times the user has already pressed Retry for this /// command; it's shown on the button so repeated presses visibly advance /// ("Retry", then "Retry (attempt 1)", "Retry (attempt 2)", …). - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "windows"))] pub(crate) fn authorize_sandbox_fallback( &self, command: Option, @@ -5645,7 +5652,7 @@ impl ToolCallEventStream { /// Persist the `allow_unsandboxed` setting so future commands skip the /// sandbox when it can't be created, without prompting again. - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "windows"))] fn persist_sandbox_unsandboxed_permission(fs: Option>, cx: &AsyncApp) { let Some(fs) = fs else { log::error!( diff --git a/crates/agent/src/tools/evals/edit_file.rs b/crates/agent/src/tools/evals/edit_file.rs index 449b912dfab1a7..eb690cdcdf08b3 100644 --- a/crates/agent/src/tools/evals/edit_file.rs +++ b/crates/agent/src/tools/evals/edit_file.rs @@ -374,6 +374,7 @@ impl EditToolTest { user_agents_md: None, sandboxing: false, is_linux: cfg!(target_os = "linux"), + is_windows: cfg!(target_os = "windows"), }; let templates = Templates::new(); template.render(&templates)? diff --git a/crates/agent/src/tools/evals/terminal_tool.rs b/crates/agent/src/tools/evals/terminal_tool.rs index 4cd0af86cecf37..d5c3ac1ba87be1 100644 --- a/crates/agent/src/tools/evals/terminal_tool.rs +++ b/crates/agent/src/tools/evals/terminal_tool.rs @@ -233,6 +233,7 @@ impl TerminalToolTest { user_agents_md: None, sandboxing: false, is_linux: cfg!(target_os = "linux"), + is_windows: cfg!(target_os = "windows"), }; template.render(&Templates::new())? }; diff --git a/crates/agent/src/tools/evals/write_file.rs b/crates/agent/src/tools/evals/write_file.rs index 04c00c91bdd4f9..3fce2b04047728 100644 --- a/crates/agent/src/tools/evals/write_file.rs +++ b/crates/agent/src/tools/evals/write_file.rs @@ -204,6 +204,7 @@ impl WriteToolTest { user_agents_md: None, sandboxing: false, is_linux: cfg!(target_os = "linux"), + is_windows: cfg!(target_os = "windows"), }; let templates = Templates::new(); template.render(&templates)? diff --git a/crates/agent/src/tools/terminal_tool.rs b/crates/agent/src/tools/terminal_tool.rs index 4e1c8f488309eb..21ef85a614b559 100644 --- a/crates/agent/src/tools/terminal_tool.rs +++ b/crates/agent/src/tools/terminal_tool.rs @@ -13,9 +13,9 @@ use std::{ time::Duration, }; -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "windows"))] use crate::SandboxFallbackDecision; -use crate::sandboxing::{NetworkRequest, sandboxing_enabled}; +use crate::sandboxing::{NetworkRequest, sandboxing_enabled_for_project}; use crate::{AgentTool, ThreadEnvironment, ToolCallEventStream, ToolInput}; const COMMAND_OUTPUT_LIMIT: u64 = 16 * 1024; @@ -365,7 +365,8 @@ async fn run_terminal_tool( crate::ToolPermissionContext::new(TerminalTool::NAME, vec![input.command.clone()]); let authorize = event_stream.authorize(SharedString::new(input.command.clone()), context, cx); - let sandboxing = input.sandbox.is_some() && sandboxing_enabled(cx); + let sandboxing = + input.sandbox.is_some() && sandboxing_enabled_for_project(project.read(cx), cx); let is_local_project = project.read(cx).is_local(); Result::<_, String>::Ok((working_dir, authorize, sandboxing, is_local_project)) })?; @@ -489,8 +490,9 @@ async fn run_terminal_tool( None } else if event_stream.sandbox_fallback_granted_for_thread() { // The user allowed unsandboxed execution for the rest of this - // thread after an earlier sandbox failure (Linux only). - #[cfg(target_os = "linux")] + // thread after an earlier sandbox failure (Linux and Windows, which + // share the `authorize_sandbox_fallback` flow). + #[cfg(any(target_os = "linux", target_os = "windows"))] { sandbox_not_applied = Some(acp_thread::SandboxNotAppliedReason::DisabledForThisThread); @@ -598,9 +600,104 @@ async fn run_terminal_tool( None }; - // When sandboxing was active but we ran without a sandbox, tell the agent - // so it can account for the weaker isolation. The message is self-contained - // per reason, so every affected command communicates the state. + let output_byte_limit = if selection.is_enabled() { + None + } else { + Some(COMMAND_OUTPUT_LIMIT) + }; + + // Create the terminal. On Windows the WSL sandbox can only report whether + // it set up the environment once `wsl.exe` actually runs (its probe is + // async), so — unlike Linux's up-front `can_create_sandbox` loop above — + // the sandbox-creation fallback happens here, around `create_terminal`. The + // user gets the same choices via `authorize_sandbox_fallback` (retry / run + // unsandboxed once / for this thread / always / deny), and a chosen + // "run unsandboxed" is recorded in `sandbox_not_applied` exactly as on + // Linux so the model and UI are told the command ran without a sandbox. + #[cfg(target_os = "windows")] + let terminal = { + let mut retries = 0usize; + let mut effective_wrap = sandbox_wrap.clone(); + loop { + let error = match environment + .create_terminal( + input.command.clone(), + extra_env.clone(), + working_dir.clone(), + output_byte_limit, + effective_wrap.clone(), + cx, + ) + .await + { + Ok(terminal) => break terminal, + Err(error) => error, + }; + + // Only an *environment*-unavailable failure of the WSL sandbox is a + // sandbox-creation problem the user can act on. A bad request (a + // missing writable path, mixed distros) — or any failure once we're + // already running unsandboxed — goes straight back to the model. + let Some(message) = effective_wrap.as_ref().and_then(|_| { + error + .downcast_ref::() + .map(|unavailable| unavailable.message().to_string()) + }) else { + return Err(format!("{error:#}")); + }; + let sandbox_error = acp_thread::LinuxWslSandboxError::Other(message); + log::warn!("Failed to create a WSL sandbox for an agent terminal command: {error:?}"); + + let decision = cx + .update(|cx| { + event_stream.authorize_sandbox_fallback( + Some(input.command.clone()), + sandbox_error.user_facing_message(), + retries, + cx, + ) + }) + .await; + match decision { + Ok(SandboxFallbackDecision::Retry) => { + // WSL probe failures aren't cached, so retrying re-probes + // the current environment (e.g. after installing `bwrap`). + retries += 1; + } + Ok(SandboxFallbackDecision::RunUnsandboxed) => { + sandbox_not_applied = Some(acp_thread::SandboxNotAppliedReason::ErrorLinuxWsl( + sandbox_error, + )); + effective_wrap = None; + } + Ok(SandboxFallbackDecision::Deny) | Err(_) => { + return Ok(format!( + "Command cancelled: the sandbox could not be created ({}) and the \ + user declined to run it without one.", + sandbox_error.user_facing_message() + )); + } + } + } + }; + #[cfg(not(target_os = "windows"))] + let terminal = environment + .create_terminal( + input.command.clone(), + extra_env, + working_dir.clone(), + output_byte_limit, + sandbox_wrap.clone(), + cx, + ) + .await + .map_err(|e| format!("{e:#}"))?; + + // When sandboxing was active but the command ran without a sandbox (a + // settings opt-out, a thread grant, or a sandbox-creation failure the user + // chose to run through), tell the agent so it can account for the weaker + // isolation. Computed here — after the Windows fallback above may have set + // the reason — so every affected command communicates the state. let sandbox_note = sandbox_not_applied.as_ref().map(|reason| match reason { acp_thread::SandboxNotAppliedReason::DisabledForever => { "Note: this command ran WITHOUT an OS sandbox because unsandboxed execution is \ @@ -619,24 +716,6 @@ async fn run_terminal_tool( ), }); - let output_byte_limit = if selection.is_enabled() { - None - } else { - Some(COMMAND_OUTPUT_LIMIT) - }; - - let terminal = environment - .create_terminal( - input.command.clone(), - extra_env, - working_dir, - output_byte_limit, - sandbox_wrap, - cx, - ) - .await - .map_err(|e| e.to_string())?; - let terminal_id = terminal.id(cx).map_err(|e| e.to_string())?; let fields = acp::ToolCallUpdateFields::new().content(vec![acp::ToolCallContent::Terminal( acp::Terminal::new(terminal_id), @@ -715,14 +794,15 @@ fn resolve_write_paths( if raw_paths.is_empty() { return Vec::new(); } + let project = project.read(cx); + let windows_paths = project.path_style(cx).is_windows(); let base = working_dir.map(Path::to_path_buf).or_else(|| { project - .read(cx) .worktrees(cx) .next() .map(|worktree| worktree.read(cx).abs_path().to_path_buf()) }); - join_write_paths(raw_paths, base.as_deref()) + join_write_paths(raw_paths, base.as_deref(), windows_paths) } /// Pure path-joining step of [`resolve_write_paths`], split out so it can be @@ -732,10 +812,27 @@ fn resolve_write_paths( /// subtree-containment checks and the user-facing approval prompt operate on /// the same path the sandbox will ultimately enforce. Relative paths with no /// base, and paths that traverse above the filesystem root, are dropped. -fn join_write_paths(raw_paths: &[String], base: Option<&Path>) -> Vec { +/// +/// On Windows, raw paths the model expressed in WSL terms (a `/mnt//...` +/// automount path, or a WSL-absolute `/home/...` path) are mapped back to the +/// form the sandbox machinery expects before normalization. +fn join_write_paths( + raw_paths: &[String], + base: Option<&Path>, + windows_paths: bool, +) -> Vec { raw_paths .iter() .filter_map(|raw| { + if windows_paths { + if let Some(path) = wsl_drive_mount_path_to_windows_path(raw) { + return Some(path); + } + if let Some(path) = wsl_absolute_path(raw) { + return Some(path); + } + } + let path = Path::new(raw); let absolute = if path.is_absolute() { path.to_path_buf() @@ -747,6 +844,34 @@ fn join_write_paths(raw_paths: &[String], base: Option<&Path>) -> Vec { .collect() } +fn wsl_drive_mount_path_to_windows_path(raw: &str) -> Option { + let raw = raw.replace('\\', "/"); + let remainder = raw.strip_prefix("/mnt/")?; + let (drive, rest) = remainder + .split_once('/') + .map_or((remainder, ""), |(drive, rest)| (drive, rest)); + let mut drive_chars = drive.chars(); + let drive = drive_chars.next()?.to_ascii_uppercase(); + if !drive.is_ascii_alphabetic() || drive_chars.next().is_some() { + return None; + } + + let mut windows_path = format!("{drive}:\\"); + if !rest.is_empty() { + windows_path.push_str(&rest.replace('/', "\\")); + } + Some(PathBuf::from(windows_path)) +} + +fn wsl_absolute_path(raw: &str) -> Option { + let raw = raw.replace('\\', "/"); + if raw.starts_with('/') && !raw.starts_with("//") { + Some(PathBuf::from(raw)) + } else { + None + } +} + /// Convert a (validated) network request into the access mode enforced by the /// terminal sandbox. fn network_request_to_sandbox_network_access( @@ -902,6 +1027,24 @@ fn select_terminal_output_lines(output: &str, selection: TerminalOutputSelection } } +/// Explanation appended to the model-facing result when a sandboxed command +/// fails because it tried to use WSL's Windows interop (see +/// [`wsl_interop_blocked`]). +const WSL_INTEROP_BLOCKED_NOTE: &str = "This command tried to launch a Windows \ +executable, which the sandbox blocks: WSL Windows interop is disabled so \ +sandboxed commands can't escape to the Windows host. The noisy `WSL ... ERROR` \ +lines below are from that blocked attempt, not a bug in the command. If you \ +genuinely need to run a Windows program, re-run with `unsandboxed: true`."; + +/// Whether terminal output contains the kernel-style diagnostics WSL prints +/// when a Windows executable is launched inside our pid-namespaced sandbox +/// (interop init fails to parse `/proc/1/stat`, which is now `bwrap`). These +/// markers don't appear for ordinary Linux commands. +#[cfg(target_os = "windows")] +fn wsl_interop_blocked(content: &str) -> bool { + content.contains("UtilGetPpid") || content.contains("Failed to parse: /proc/1/stat") +} + fn process_content( output: acp::TerminalOutputResponse, command: &str, @@ -913,6 +1056,15 @@ fn process_content( let content = select_terminal_output_lines(content, selection); let is_empty = content.is_empty(); + // On Windows, recognize the kernel-style diagnostics WSL prints when a + // command tries to launch a Windows executable inside the sandbox (where + // interop is deliberately disabled). They're noise the model can't act on, + // so we explain what actually happened. + #[cfg(target_os = "windows")] + let interop_blocked = wsl_interop_blocked(&content); + #[cfg(not(target_os = "windows"))] + let interop_blocked = false; + let content = format!("```\n{content}\n```"); let content = if output.truncated { format!( @@ -955,6 +1107,11 @@ fn process_content( content } } + Some(exit_code) if interop_blocked => { + format!( + "Command \"{command}\" failed with exit code {exit_code}. {WSL_INTEROP_BLOCKED_NOTE}\n\n{content}" + ) + } Some(exit_code) => { if is_empty { format!("Command \"{command}\" failed with exit code {}.", exit_code) @@ -2599,6 +2756,7 @@ mod tests { "file.txt".to_string(), ], Some(base.as_path()), + cfg!(windows), ); assert_eq!( joined, @@ -2619,10 +2777,44 @@ mod tests { } else { "/abs/keep" }; - let joined = join_write_paths(&[abs.to_string(), "relative/drop".to_string()], None); + let joined = join_write_paths( + &[abs.to_string(), "relative/drop".to_string()], + None, + cfg!(windows), + ); assert_eq!(joined, vec![PathBuf::from(abs)]); } + #[test] + fn test_join_write_paths_converts_wsl_drive_mounts_on_windows() { + let joined = join_write_paths( + &["/mnt/c/example/write-root".to_string()], + Some(Path::new("C:\\project")), + true, + ); + assert_eq!(joined, vec![PathBuf::from("C:\\example\\write-root")]); + } + + #[test] + fn test_join_write_paths_only_converts_wsl_drive_mounts_for_windows_paths() { + let joined = join_write_paths( + &["/mnt/c/example/write-root".to_string()], + Some(Path::new("/project")), + false, + ); + assert_eq!(joined, vec![PathBuf::from("/mnt/c/example/write-root")]); + } + + #[test] + fn test_join_write_paths_preserves_wsl_absolute_paths_on_windows() { + let joined = join_write_paths( + &["/home/example".to_string()], + Some(Path::new("C:\\project")), + true, + ); + assert_eq!(joined, vec![PathBuf::from("/home/example")]); + } + #[test] fn test_join_write_paths_normalizes_parent_traversal() { let base = PathBuf::from(if cfg!(windows) { @@ -2643,6 +2835,7 @@ mod tests { }, ], Some(base.as_path()), + cfg!(windows), ); let expected_escape = if cfg!(windows) { PathBuf::from("C:\\escape") diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index 62dbb16b78f5b3..dadcfd12661a17 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -421,7 +421,12 @@ pub struct SandboxPermissions { /// consumed (`agent::sandboxing`). pub network_hosts: Vec, pub allow_fs_write_all: bool, + /// Auto-approve commands that request `unsandboxed: true`. Unlike + /// `disabled`, the sandbox stays on for commands that don't ask. pub allow_unsandboxed: bool, + /// Turn terminal sandboxing off entirely: the sandboxed terminal tool is + /// not exposed and every command runs outside the sandbox. + pub disabled: bool, pub write_paths: Vec, } @@ -805,6 +810,7 @@ fn compile_sandbox_permissions( network_hosts, allow_fs_write_all: content.allow_fs_write_all.unwrap_or(false), allow_unsandboxed: content.allow_unsandboxed.unwrap_or(false), + disabled: content.disabled.unwrap_or(false), write_paths, } } @@ -1058,6 +1064,9 @@ mod tests { ); assert!(!permissions.allow_fs_write_all); assert!(permissions.allow_unsandboxed); + // `allow_unsandboxed` is a per-request grant; it must not imply that + // sandboxing is disabled. + assert!(!permissions.disabled); assert_eq!( permissions.write_paths, vec![PathBuf::from("/tmp/build"), PathBuf::from("/var/log")] diff --git a/crates/sandbox/Cargo.toml b/crates/sandbox/Cargo.toml index cafdd75b5dcde0..7e98d4b3608d97 100644 --- a/crates/sandbox/Cargo.toml +++ b/crates/sandbox/Cargo.toml @@ -17,6 +17,12 @@ path = "src/sandbox.rs" # produce the test binary. nixos-test = [] +# Builds `wsl_sandbox_test_helper`, the Windows analog of `bwrap_test_helper`. +# It drives the real WSL/Bubblewrap sandbox end-to-end (see +# `script/test-wsl-sandbox.ps1` / `cargo xtask wsl-sandbox-tests`). Off by +# default so normal builds and the workspace test run don't produce it. +wsl-test = [] + # Behavior test helper for the Linux Bubblewrap sandbox. Only built when the # `nixos-test` feature is enabled (and only meaningful on Linux). [[bin]] @@ -24,6 +30,13 @@ name = "bwrap_test_helper" path = "src/bwrap_test_helper.rs" required-features = ["nixos-test"] +# Behavior test helper for the Windows WSL/Bubblewrap sandbox. Only built when +# the `wsl-test` feature is enabled (and only meaningful on Windows). +[[bin]] +name = "wsl_sandbox_test_helper" +path = "src/wsl_sandbox_test_helper.rs" +required-features = ["wsl-test"] + [target.'cfg(target_os = "linux")'.dependencies] anyhow.workspace = true libc.workspace = true @@ -34,3 +47,8 @@ tempfile.workspace = true [target.'cfg(target_os = "macos")'.dependencies] anyhow.workspace = true tempfile.workspace = true + +[target.'cfg(target_os = "windows")'.dependencies] +anyhow.workspace = true +log.workspace = true +smol.workspace = true diff --git a/crates/sandbox/src/sandbox.rs b/crates/sandbox/src/sandbox.rs index 22c23f04e3783a..f5923cfd34a98c 100644 --- a/crates/sandbox/src/sandbox.rs +++ b/crates/sandbox/src/sandbox.rs @@ -7,7 +7,9 @@ //! //! macOS has an integration ([`macos_seatbelt`]) wrapping Apple's Seatbelt //! / `sandbox-exec` framework, and Linux has one ([`linux_bubblewrap`]) built -//! on Bubblewrap (`bwrap`) for both the filesystem and the network. +//! on Bubblewrap (`bwrap`) for both the filesystem and the network. Windows +//! routes commands through WSL and runs them under Bubblewrap there (see +//! [`windows_wsl`]). #[cfg(target_os = "linux")] pub mod linux_bubblewrap; @@ -15,6 +17,23 @@ pub mod linux_bubblewrap; #[cfg(target_os = "macos")] pub mod macos_seatbelt; +#[cfg(target_os = "windows")] +pub mod windows_wsl; + +/// Marker prefix for [`windows_wsl`] errors that mean the sandboxing +/// *environment* is unavailable (WSL missing or failing to start, no usable +/// `bwrap`, the probe/path-resolution protocol breaking down) — as opposed +/// to per-request errors such as a writable path that doesn't exist, which +/// never carry this prefix. +/// +/// The agent matches on this prefix to decide whether a failed sandboxed +/// command should offer the user the option of turning sandboxing off +/// (an environment that can't sandbox at all) or simply report the error +/// back to the model (a fixable bad request). Defined here rather than in +/// [`windows_wsl`] so non-Windows builds of the agent can still reference +/// it. +pub const WSL_SANDBOX_UNAVAILABLE_PREFIX: &str = "Windows sandboxing via WSL is unavailable"; + /// Per-command relaxations of the default Bubblewrap (Linux) sandbox. /// /// All-false is the default, fully-sandboxed run. Setting any field @@ -24,10 +43,11 @@ pub mod macos_seatbelt; /// enforce it wholesale (an `--unshare-net` namespace, loopback only). macOS /// can additionally confine egress to an allowlist via Seatbelt and the /// in-process proxy, so it uses its own richer -/// [`macos_seatbelt::SandboxPermissions`] instead of this type. Some baseline -/// operations remain denied regardless of these flags; the only way to lift -/// those is to skip the sandbox entirely, which these integrations -/// deliberately don't expose. +/// [`macos_seatbelt::SandboxPermissions`] instead of this type. Windows reuses +/// this type, mapping it onto Bubblewrap inside WSL. Some baseline operations +/// remain denied regardless of these flags; the only way to lift those is to +/// skip the sandbox entirely, which these integrations deliberately don't +/// expose. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct SandboxPermissions { /// Allow network access for the command. diff --git a/crates/sandbox/src/windows_wsl.rs b/crates/sandbox/src/windows_wsl.rs new file mode 100644 index 00000000000000..eefee912b71435 --- /dev/null +++ b/crates/sandbox/src/windows_wsl.rs @@ -0,0 +1,1457 @@ +//! Windows sandbox integration via WSL. +//! +//! Sandboxed Windows terminal commands are routed through WSL and then executed +//! under Bubblewrap inside Linux. Projects may be opened either from native +//! Windows paths (`C:\Users\...`) or WSL UNC paths +//! (`\\wsl.localhost\Ubuntu\home\...`). Native drive-letter paths are +//! translated into the distro's filesystem view with `wslpath` (falling back +//! to the conventional `/mnt//...` mapping if that fails) and use the +//! user's default WSL distro unless a WSL UNC path in the request pins a +//! specific distro. +//! +//! Errors fall into two classes the agent treats differently: +//! +//! * **Environment unavailable** — WSL missing or failing to start, no +//! usable `bwrap`, or the probe/path-resolution stdout protocol breaking +//! down. These are returned as a [`WslSandboxUnavailable`] (whose `Display` +//! carries +//! [`WSL_SANDBOX_UNAVAILABLE_PREFIX`](crate::WSL_SANDBOX_UNAVAILABLE_PREFIX)), +//! so the agent recognizes them *by type* and offers the same +//! retry / run-unsandboxed fallback it offers on Linux, rather than matching +//! on message text. +//! * **Bad request** — a specific path that doesn't exist or can't be mapped +//! into WSL, or a request mixing distros. These are ordinary `anyhow` errors +//! *without* [`WslSandboxUnavailable`], and are reported back to the model, +//! which can fix the request and retry. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; + +use smol::process::{Command, Stdio}; + +use anyhow::{Context as _, Result, bail, ensure}; + +use crate::{SandboxPermissions, WSL_SANDBOX_UNAVAILABLE_PREFIX}; + +/// Exit code the environment probe script uses to signal that `bwrap` is not +/// installed, distinguishing that from WSL itself failing to start a shell. +/// Chosen to be unlikely to collide with `wsl.exe`'s own failure codes. +const BWRAP_MISSING_EXIT_CODE: i32 = 41; + +/// Exit code the environment probe script uses to signal that `bwrap` is +/// installed but failed the sandbox smoke test — typically because the +/// distro restricts unprivileged user namespaces (e.g. Ubuntu 24.04's +/// default AppArmor policy), which every namespace flag we pass depends on. +const BWRAP_UNUSABLE_EXIT_CODE: i32 = 42; + +/// Prefix of the probe script's single result line, so it can be picked out +/// of any stdout noise printed by the login shell's profile scripts. +const PROBE_RESULT_PREFIX: &str = "zed-wsl-probe:"; + +/// Marks a failure of the Windows WSL sandboxing *environment*: WSL is missing +/// or won't start, there's no usable `bwrap`, or the probe / path-resolution +/// stdout protocol broke down. Returned as the root of the `anyhow::Error` so +/// callers classify it by type ([`anyhow::Error::downcast_ref`]) instead of by +/// matching message text. Per-request failures (a missing writable path, paths +/// mixing distros) are ordinary `anyhow` errors *without* this type, so they +/// never match — the agent returns those to the model rather than offering to +/// run unsandboxed. +#[derive(Debug, Clone)] +pub struct WslSandboxUnavailable(String); + +impl WslSandboxUnavailable { + /// Build an environment-unavailable error from a human-readable reason + /// (without the [`WSL_SANDBOX_UNAVAILABLE_PREFIX`], which `Display` adds). + pub fn new(message: impl Into) -> Self { + Self(message.into()) + } + + /// The reason, without the leading [`WSL_SANDBOX_UNAVAILABLE_PREFIX`]. + pub fn message(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for WslSandboxUnavailable { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{WSL_SANDBOX_UNAVAILABLE_PREFIX}: {}", self.0) + } +} + +impl std::error::Error for WslSandboxUnavailable {} + +/// Shorthand for an [`anyhow::Error`] wrapping a [`WslSandboxUnavailable`]. +fn unavailable(message: impl Into) -> anyhow::Error { + anyhow::Error::new(WslSandboxUnavailable::new(message)) +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct WslPath { + distro: Option, + path: String, +} + +/// A path mapped for use inside WSL. +/// +/// WSL UNC and WSL-absolute paths can be mapped structurally up front. Native +/// drive-letter paths depend on the distro's automount configuration +/// (`/etc/wsl.conf` can move the `/mnt` root), so they are translated with +/// `wslpath` inside the distro — but a distro can only be chosen after every +/// path has been parsed (WSL UNC paths pin one), hence this two-stage shape: +/// parse structurally first, then resolve native paths via [`resolve_paths`] +/// once the distro is known. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +enum PathMapping { + Wsl(WslPath), + NativeDrive { + /// The `\\?\`-stripped, forward-slashed form that `wslpath -u` + /// accepts (`wslpath` is a Linux binary and doesn't understand + /// backslash separators). + windows_path: String, + /// The conventional `/mnt//...` mapping, used when `wslpath` + /// translation fails. + fallback: WslPath, + }, +} + +impl PathMapping { + fn distro(&self) -> Option<&str> { + match self { + PathMapping::Wsl(path) => path.distro.as_deref(), + PathMapping::NativeDrive { .. } => None, + } + } +} + +/// Wrap a Linux process invocation so it runs under Bubblewrap inside WSL. +/// +/// `program` and `args` must name a Linux executable and Linux argv, not a +/// Windows executable. The caller is expected to convert the model's command +/// into a Linux shell invocation (typically `/bin/sh -c ...`) before calling +/// this function. +/// +/// All writable paths and the cwd must be paths that can be mapped into WSL. +/// WSL UNC paths may specify a distro; native drive-letter paths are +/// translated with `wslpath` inside either that distro or the default distro +/// (falling back to `/mnt//...` if translation fails). +/// +/// `env` is forwarded into the sandboxed command via `bwrap --setenv` rather +/// than being set on the `wsl.exe` process. Windows environment variables +/// don't cross the WSL boundary unless they're listed in `WSLENV`, so without +/// this the command would lose `PAGER` (used to stop `git` from paging into +/// the PTY) and the rest of the project environment. Variables whose Windows +/// values are meaningless or harmful inside Linux are dropped (see +/// [`is_forwardable_env_var`]). +/// +/// This function performs up to two `wsl.exe` round-trips (environment probe +/// and path resolution, each cached) plus filesystem stats of WSL UNC paths, +/// any of which can take seconds when the WSL VM is cold (and the stats can +/// stall on a slow `\\wsl.localhost` filesystem). Run it on a background +/// executor, never on the UI thread, and bound it with a timeout — a wedged +/// `wsl.exe` (a real failure mode when the WSL service is unhealthy) +/// otherwise stalls the returned future forever. This crate deliberately has +/// no timer of its own (timers come from the caller's executor so tests stay +/// deterministic); instead it guarantees that dropping the future kills any +/// in-flight `wsl.exe` child, so a caller-side timeout that drops the future +/// also reaps the process. Parameters are owned so the returned future is +/// `Send + 'static`. +pub async fn wrap_invocation( + program: String, + args: Vec, + writable_paths: Vec, + permissions: SandboxPermissions, + cwd: Option, + env: HashMap, +) -> Result<(String, Vec)> { + // Mapping failures are bad requests (a path that doesn't exist or has a + // shape WSL can't address), not environment problems, so no + // `WSL_SANDBOX_UNAVAILABLE_PREFIX` here. + let cwd_mapping = + match &cwd { + Some(cwd) => Some(directory_to_wsl(cwd).with_context(|| { + format!("failed to map terminal cwd `{}` into WSL", cwd.display()) + })?), + None => None, + }; + + let writable_mappings = writable_paths + .iter() + .map(|path| { + path_to_wsl(path).with_context(|| { + format!("failed to map writable path `{}` into WSL", path.display()) + }) + }) + .collect::>>()?; + + let distro = select_distro(cwd_mapping.as_ref(), &writable_mappings)?; + let wsl_exe = wsl_exe_path(); + if !wsl_exe.is_file() { + return Err(unavailable(format!( + "WSL (`wsl.exe`) was not found at `{}`", + wsl_exe.display() + ))); + } + let environment = probe_environment(&wsl_exe, distro.as_deref()).await?; + + // Resolve all paths (translating native drive-letter paths with `wslpath` + // now that the distro is known) and confirm they exist, in a single WSL + // round-trip. + let has_cwd = cwd_mapping.is_some(); + let mut mappings = Vec::with_capacity(writable_mappings.len() + 1); + if let Some(mapping) = cwd_mapping { + mappings.push((mapping, "terminal cwd")); + } + mappings.extend( + writable_mappings + .into_iter() + .map(|mapping| (mapping, "writable path")), + ); + let mut resolved = resolve_paths(&wsl_exe, distro.as_deref(), &mappings) + .await? + .into_iter(); + let cwd = if has_cwd { resolved.next() } else { None }; + let writable_paths: Vec = resolved.collect(); + + let mut wsl_args = Vec::new(); + if let Some(distro) = distro.as_deref() { + wsl_args.extend(["-d".to_string(), distro.to_string()]); + } + if let Some(cwd) = &cwd { + wsl_args.extend(["--cd".to_string(), cwd.clone()]); + } + // Use the absolute path the probe validated: `wsl --exec` searches only + // the default WSL PATH, which may not include a profile-managed location + // where the probe's login shell found `bwrap`. + wsl_args.extend(["--exec".to_string(), environment.bwrap_path.clone()]); + wsl_args.extend(build_bwrap_args( + &writable_paths, + permissions, + cwd.as_deref(), + environment.mask_interop_dir, + &env, + )); + wsl_args.push("--".to_string()); + wsl_args.push(program); + wsl_args.extend(args); + + Ok((wsl_exe.to_string_lossy().into_owned(), wsl_args)) +} + +fn select_distro( + cwd: Option<&PathMapping>, + writable_paths: &[PathMapping], +) -> Result> { + let mut distro = cwd.and_then(|mapping| mapping.distro().map(str::to_string)); + for mapping in writable_paths { + let Some(path_distro) = mapping.distro() else { + continue; + }; + match distro.as_deref() { + // A bad request, not an environment problem: the model (or + // project layout) asked for paths spanning two distros, which a + // single bwrap invocation can't serve. + Some(distro) => ensure!( + distro == path_distro, + "cannot sandbox a command whose paths mix WSL distros `{}` and `{}`", + distro, + path_distro + ), + None => distro = Some(path_distro.to_string()), + } + } + Ok(distro) +} + +/// What [`probe_environment`] learned about a WSL distro. +#[derive(Clone, Debug, Eq, PartialEq)] +struct EnvironmentProbe { + /// Whether the WSL interop socket directory (`/run/WSL`) exists and so + /// must (and can) be masked — see [`build_bwrap_args`]. + mask_interop_dir: bool, + /// Absolute path of the `bwrap` binary the smoke test validated. The real + /// invocation must exec this exact path: `wsl --exec` searches only the + /// default WSL PATH, so a bare `bwrap` could miss (or differ from) the + /// binary the probe's login shell found. + bwrap_path: String, +} + +/// Shell script run by [`probe_environment`]. Resolves `bwrap` to an absolute +/// path (exit [`BWRAP_MISSING_EXIT_CODE`] if absent), rejects setuid-root +/// binaries, then smoke-tests a real minimal sandbox (exit +/// [`BWRAP_UNUSABLE_EXIT_CODE`] on failure) using the same mount and namespace +/// flags as [`build_bwrap_args`] — presence isn't +/// enough, because unprivileged user namespaces can be disabled by the +/// distro's kernel, sysctl, or AppArmor policy (notably Ubuntu 24.04, the +/// current default WSL distro), in which case `bwrap` exists but every +/// sandboxed command would fail. The interop mask is included in the smoke +/// test when `/run/WSL` exists so the exact mount we later perform is +/// exercised too. On success, one [`PROBE_RESULT_PREFIX`]-marked result line +/// reports the interop state and the resolved `bwrap` path. +fn probe_script() -> String { + format!( + "bwrap_path=$(command -v bwrap) || exit {BWRAP_MISSING_EXIT_CODE}; \ + if [ -u \"$bwrap_path\" ] && [ \"$(stat -c %u \"$bwrap_path\" 2>/dev/null)\" = 0 ]; then \ + echo 'setuid-root bwrap is not supported' >&2; \ + exit {BWRAP_UNUSABLE_EXIT_CODE}; fi; \ + if [ -d /run/WSL ]; then interop=interop; mask='--tmpfs /run/WSL'; \ + else interop=no-interop; mask=''; fi; \ + \"$bwrap_path\" --ro-bind / / --tmpfs /tmp $mask --dev /dev --proc /proc \ + --unshare-net --unshare-user --unshare-ipc --unshare-uts --unshare-pid \ + --unshare-cgroup-try --die-with-parent -- true >/dev/null \ + || exit {BWRAP_UNUSABLE_EXIT_CODE}; \ + printf '{PROBE_RESULT_PREFIX} %s %s\\n' \"$interop\" \"$bwrap_path\"" + ) +} + +/// Probe a distro's sandbox environment in one `wsl.exe` round-trip: confirm +/// a shell starts, confirm `bwrap` is installed *and can actually set up an +/// unprivileged sandbox* (see [`probe_script`]), and report whether the +/// interop socket directory exists. +/// +/// Successful results are cached per distro for the life of the process — +/// like `linux_bubblewrap::is_available`, the answers can't realistically +/// change while Zed runs. Failures are deliberately *not* cached so a user +/// who installs `bwrap` (or lifts a user-namespace restriction) after seeing +/// the error can retry the command without restarting Zed. +async fn probe_environment(wsl_exe: &Path, distro: Option<&str>) -> Result { + static CACHE: OnceLock, EnvironmentProbe>>> = OnceLock::new(); + let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); + + let key = distro.map(str::to_string); + if let Some(probe) = cache + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .get(&key) + { + return Ok(probe.clone()); + } + + // A login shell (`-lc`) is used so a `bwrap` reachable only through a + // profile-managed PATH is still found; the resolved absolute path is + // reported back so the real invocation execs the same binary. + let script = probe_script(); + let output = run_wsl_command( + wsl_exe, + distro, + ["--exec", "sh", "-lc", &script], + "probe the sandbox environment", + ) + .await?; + if output.status.code() == Some(BWRAP_MISSING_EXIT_CODE) { + return Err(unavailable(format!( + "Bubblewrap (`bwrap`) is not installed in {}", + wsl_distro_label(distro) + ))); + } + if output.status.code() == Some(BWRAP_UNUSABLE_EXIT_CODE) { + let stderr = String::from_utf8_lossy(&output.stderr); + let stderr = stderr.trim(); + return Err(unavailable(format!( + "Bubblewrap (`bwrap`) is installed in {} but could not set up a sandbox — the \ + distro may restrict unprivileged user namespaces (as Ubuntu 24.04's default \ + AppArmor policy does){}", + wsl_distro_label(distro), + if stderr.is_empty() { + String::new() + } else { + format!(": {stderr}") + } + ))); + } + if !output.status.success() { + return Err(unavailable(format!( + "failed to start a shell in {}{}", + wsl_distro_label(distro), + command_failure_details(output.status.code(), &output.stderr) + ))); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let probe = parse_probe_output(&stdout).map_err(|error| { + unavailable(format!( + "unexpected sandbox probe output from {}: {error:#}", + wsl_distro_label(distro) + )) + })?; + cache + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert(key, probe.clone()); + Ok(probe) +} + +/// Parse [`probe_script`] output: the last [`PROBE_RESULT_PREFIX`]-marked +/// line wins, so stdout noise from login-shell profile scripts (which runs +/// before the script body) is ignored. +fn parse_probe_output(stdout: &str) -> Result { + let line = stdout + .lines() + .rev() + .find_map(|line| line.strip_prefix(PROBE_RESULT_PREFIX)) + .with_context(|| format!("no probe result line in: {stdout:?}"))?; + let (interop, bwrap_path) = line + .trim_start() + .split_once(' ') + .with_context(|| format!("malformed probe result line: {line:?}"))?; + let mask_interop_dir = match interop { + "interop" => true, + "no-interop" => false, + _ => bail!("malformed probe result line: {line:?}"), + }; + ensure!( + bwrap_path.starts_with('/'), + "`bwrap` resolved to {bwrap_path:?} rather than an absolute path; a shell \ + alias or function named `bwrap` cannot be run with `wsl --exec`" + ); + Ok(EnvironmentProbe { + mask_interop_dir, + bwrap_path: bwrap_path.to_string(), + }) +} + +/// Shell script that resolves and existence-checks paths in a single WSL +/// round-trip. Arguments come in triples `(kind, path, fallback)`: kind `W` +/// is a native Windows path to translate with `wslpath -u` (falling back to +/// the precomputed `/mnt//...` mapping when translation fails), kind +/// `L` is an already-Linux path with an empty fallback. One result line is +/// printed per triple: ` `. +const PATH_RESOLUTION_SCRIPT: &str = "\ + while [ \"$#\" -ge 3 ]; do \ + kind=$1; path=$2; fallback=$3; shift 3; translate=ok; \ + if [ \"$kind\" = W ]; then \ + resolved=$(wslpath -u \"$path\" 2>/dev/null) || { resolved=$fallback; translate=fallback; }; \ + else resolved=$path; fi; \ + exists=ok; [ -e \"$resolved\" ] || exists=missing; \ + printf '%s %s %s\\n' \"$translate\" \"$exists\" \"$resolved\"; \ + done"; + +/// A line of [`PATH_RESOLUTION_SCRIPT`] output, parsed. +#[derive(Debug, Eq, PartialEq)] +struct ResolvedPath { + path: String, + used_fallback: bool, + exists: bool, +} + +/// Resolve path mappings into final WSL paths and confirm they exist. +/// Native drive-letter paths are translated with `wslpath -u` inside the +/// chosen distro so its actual automount configuration is honored, falling +/// back to the structural `/mnt/` mapping when translation fails +/// (e.g. a distro without `wslpath`); a wrong fallback is still caught by +/// the existence check. +/// +/// Successful resolutions are memoized per `(distro, mapping)` for the life +/// of the process, so a steady-state command whose paths have all been seen +/// before resolves with zero `wsl.exe` round-trips; at most one round-trip +/// handles all cache misses ([`resolve_uncached_paths`]). A hit reuses the +/// translation — which only changes if the distro's automount configuration +/// is edited and the distro restarted — and also skips the WSL-side +/// existence re-check. That staleness is acceptable: native and UNC paths +/// are still stat'ed on the Windows side on every command (see +/// [`path_to_wsl`] / [`directory_to_wsl`]), and if a cached path disappears +/// mid-session bwrap fails closed on the missing bind source rather than +/// running the command unsandboxed. Failures are not cached, so a missing +/// path can be created and retried. +/// +/// Each mapping is paired with a human-readable description used in errors. +/// The returned paths are in the same order as `mappings`. +async fn resolve_paths( + wsl_exe: &Path, + distro: Option<&str>, + mappings: &[(PathMapping, &str)], +) -> Result> { + type ResolutionCache = HashMap, HashMap>; + static CACHE: OnceLock> = OnceLock::new(); + let cache = CACHE.get_or_init(Default::default); + + let distro_key = distro.map(str::to_string); + let mut resolved: Vec> = { + let cache = cache + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let per_distro = cache.get(&distro_key); + mappings + .iter() + .map(|(mapping, _)| per_distro.and_then(|cached| cached.get(mapping)).cloned()) + .collect() + }; + + let misses: Vec = (0..mappings.len()) + .filter(|&index| resolved[index].is_none()) + .collect(); + if !misses.is_empty() { + let miss_mappings: Vec<&(PathMapping, &str)> = + misses.iter().map(|&index| &mappings[index]).collect(); + let miss_resolved = resolve_uncached_paths(wsl_exe, distro, &miss_mappings).await?; + let mut cache = cache + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let per_distro = cache.entry(distro_key).or_default(); + for (&index, path) in misses.iter().zip(miss_resolved) { + per_distro.insert(mappings[index].0.clone(), path.clone()); + resolved[index] = Some(path); + } + } + + resolved + .into_iter() + .collect::>>() + .context("bug: a path mapping was left unresolved") +} + +/// Resolve and existence-check mappings that weren't in the cache, in a +/// single `wsl.exe` round-trip. A non-login shell runs the script so profile +/// scripts can't pollute the stdout protocol. +async fn resolve_uncached_paths( + wsl_exe: &Path, + distro: Option<&str>, + mappings: &[&(PathMapping, &str)], +) -> Result> { + let mut args = vec![ + "--exec".to_string(), + "sh".to_string(), + "-c".to_string(), + PATH_RESOLUTION_SCRIPT.to_string(), + // argv[0] for the script; the path triples follow as "$@". + "zed-resolve-paths".to_string(), + ]; + args.extend(path_resolution_args( + mappings.iter().map(|(mapping, _)| mapping), + )); + let output = run_wsl_command(wsl_exe, distro, &args, "resolve sandbox paths").await?; + if !output.status.success() { + return Err(unavailable(format!( + "failed to resolve sandbox paths in {}{}", + wsl_distro_label(distro), + command_failure_details(output.status.code(), &output.stderr) + ))); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let resolved = parse_path_resolution_output(&stdout, mappings.len()).map_err(|error| { + unavailable(format!( + "failed to resolve sandbox paths in {}: {error:#}", + wsl_distro_label(distro) + )) + })?; + + mappings + .iter() + .zip(resolved) + .map(|((mapping, description), resolved)| { + if resolved.used_fallback + && let PathMapping::NativeDrive { windows_path, .. } = mapping + { + log::warn!( + "failed to translate `{windows_path}` with wslpath in {}; \ + falling back to `{}`", + wsl_distro_label(distro), + resolved.path + ); + } + // A bad request (the path simply isn't there), not an + // environment problem — the model can create it or fix the path + // and retry, so no `WSL_SANDBOX_UNAVAILABLE_PREFIX`. + ensure!( + resolved.exists, + "mapped {description} `{}` does not exist in {}", + resolved.path, + wsl_distro_label(distro) + ); + Ok(resolved.path) + }) + .collect() +} + +/// Flatten path mappings into the `(kind, path, fallback)` argument triples +/// consumed by [`PATH_RESOLUTION_SCRIPT`]. +fn path_resolution_args<'a>(mappings: impl Iterator) -> Vec { + let mut args = Vec::new(); + for mapping in mappings { + match mapping { + PathMapping::Wsl(path) => { + args.extend(["L".to_string(), path.path.clone(), String::new()]); + } + PathMapping::NativeDrive { + windows_path, + fallback, + } => { + args.extend(["W".to_string(), windows_path.clone(), fallback.path.clone()]); + } + } + } + args +} + +/// Parse [`PATH_RESOLUTION_SCRIPT`] output: one strictly-formatted line per +/// input triple. Anything else (wrong line count, unknown status words, a +/// non-absolute path) means the stdout protocol was corrupted and is an error. +fn parse_path_resolution_output(stdout: &str, expected: usize) -> Result> { + let lines: Vec<&str> = stdout.lines().collect(); + ensure!( + lines.len() == expected, + "expected {expected} result lines from the path resolution script, got {}: {stdout:?}", + lines.len() + ); + lines + .into_iter() + .map(|line| { + let mut parts = line.splitn(3, ' '); + let (Some(translate), Some(exists), Some(path)) = + (parts.next(), parts.next(), parts.next()) + else { + bail!("malformed line from the path resolution script: {line:?}"); + }; + let used_fallback = match translate { + "ok" => false, + "fallback" => true, + _ => bail!("malformed line from the path resolution script: {line:?}"), + }; + let exists = match exists { + "ok" => true, + "missing" => false, + _ => bail!("malformed line from the path resolution script: {line:?}"), + }; + ensure!( + path.starts_with('/'), + "unexpected resolved path from the path resolution script: {path:?}" + ); + Ok(ResolvedPath { + path: path.to_string(), + used_fallback, + exists, + }) + }) + .collect() +} + +/// `CREATE_NO_WINDOW` process creation flag. `wsl.exe` is a console-subsystem +/// binary, so spawning it from a GUI process without this flag flashes a +/// console window. Defined locally because this crate doesn't depend on +/// `util` (whose command helpers normally take care of this). +const CREATE_NO_WINDOW: u32 = 0x0800_0000; + +/// Invoke `wsl.exe` with the given args and return its raw output. +/// +/// Only spawn failures become errors here; callers interpret the exit status +/// themselves. stdout, when used, is decoded as UTF-8 (lossily) — that's +/// only valid for `--exec`'d programs whose output we control, not for +/// `wsl.exe`'s own diagnostics (which are UTF-16LE). +/// +/// `output()` spawns the child eagerly and the returned future owns it, so +/// with `kill_on_drop` the child can't outlive this future: a caller-side +/// timeout or cancellation that drops us also terminates a wedged `wsl.exe` +/// instead of leaking it. +async fn run_wsl_command( + wsl_exe: &Path, + distro: Option<&str>, + args: impl IntoIterator>, + description: &str, +) -> Result { + use smol::process::windows::CommandExt as _; + + let mut command = Command::new(wsl_exe); + if let Some(distro) = distro { + command.args(["-d", distro]); + } + command + .args(args) + .stdin(Stdio::null()) + .kill_on_drop(true) + .creation_flags(CREATE_NO_WINDOW); + + command.output().await.map_err(|error| { + unavailable(format!( + "failed to invoke WSL while trying to {description}: {error:#}" + )) + }) +} + +fn command_failure_details(exit_code: Option, stderr: &[u8]) -> String { + let stderr = String::from_utf8_lossy(stderr); + let stderr = stderr.trim(); + let exit_status = match exit_code { + Some(code) => format!("exit code {code}"), + None => "terminated by signal".to_string(), + }; + if stderr.is_empty() { + format!(" ({exit_status})") + } else { + format!(" ({exit_status}; stderr: {stderr})") + } +} + +fn wsl_distro_label(distro: Option<&str>) -> String { + match distro { + Some(distro) => format!("WSL distro `{distro}`"), + None => "the default WSL distro".to_string(), + } +} + +fn wsl_exe_path() -> PathBuf { + std::env::var_os("SystemRoot") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(r"C:\Windows")) + .join("System32") + .join("wsl.exe") +} + +fn build_bwrap_args( + writable_paths: &[String], + permissions: SandboxPermissions, + cwd: Option<&str>, + mask_interop_dir: bool, + env: &HashMap, +) -> Vec { + let mut args = Vec::new(); + + if permissions.allow_fs_write { + push_bind(&mut args, "--bind", "/", "/"); + } else { + push_bind(&mut args, "--ro-bind", "/", "/"); + args.extend(["--tmpfs".to_string(), "/tmp".to_string()]); + for path in writable_paths { + push_bind(&mut args, "--bind", path, path); + } + } + + // Block WSL's Windows interop, regardless of the requested permissions. + // Without this, a sandboxed process can exec a Windows binary (e.g. + // /mnt/c/Windows/System32/cmd.exe), which the kernel's binfmt handler + // (`/init`) hands off to the Windows host over an AF_UNIX socket — running + // fully outside bwrap and defeating both the filesystem and the network + // restrictions. `/init` locates that socket via the $WSL_INTEROP + // environment variable, so we drop it; and we mask the socket directory + // (when it exists) so the value can't be rediscovered by listing + // /run/WSL and re-exporting it. Both steps are required: unsetting the + // variable alone is bypassable, and masking alone leaves the inherited + // variable usable. + args.extend(["--unsetenv".to_string(), "WSL_INTEROP".to_string()]); + args.extend(["--unsetenv".to_string(), "WSLENV".to_string()]); + if mask_interop_dir { + args.extend(["--tmpfs".to_string(), "/run/WSL".to_string()]); + } + + args.extend([ + "--dev".to_string(), + "/dev".to_string(), + "--proc".to_string(), + "/proc".to_string(), + ]); + + if !permissions.allow_network { + args.push("--unshare-net".to_string()); + } + + args.extend([ + "--unshare-user".to_string(), + "--unshare-ipc".to_string(), + "--unshare-uts".to_string(), + "--unshare-pid".to_string(), + "--unshare-cgroup-try".to_string(), + "--die-with-parent".to_string(), + ]); + + // Forward the caller-provided environment into the command. Windows env + // set on the `wsl.exe` process doesn't reach the Linux command, so we + // re-apply it here on the sandbox's child instead. + for (name, value) in env { + if is_forwardable_env_var(name) { + args.extend(["--setenv".to_string(), name.clone(), value.clone()]); + } + } + + if let Some(cwd) = cwd { + args.extend(["--chdir".to_string(), cwd.to_string()]); + } + + args +} + +/// Whether an environment variable should be forwarded into the Linux sandbox. +/// +/// `bwrap --setenv` calls `setenv(3)`, which rejects names that are empty or +/// contain `=`. Windows process environments include such entries — most +/// notably the per-drive current-directory pseudo-variables (`=C:`, `=D:`, +/// ...) Windows keeps in the environment block — so they must be skipped or +/// bwrap aborts with "setenv failed". +/// +/// Beyond that, a few variables hold Windows-specific values that would be +/// meaningless or actively break the command inside WSL: `PATH` would shadow +/// WSL's own `PATH` and stop the shell from finding Linux executables, the +/// temp-dir variables point at Windows paths that don't exist in WSL (bwrap +/// provides a fresh tmpfs `/tmp` instead), and WSL interop variables would +/// undermine the explicit interop block above. Matched case-insensitively +/// because Windows environment variable names are. +fn is_forwardable_env_var(name: &str) -> bool { + if name.is_empty() || name.contains('=') { + return false; + } + const BLOCKED: [&str; 6] = ["PATH", "TMPDIR", "TMP", "TEMP", "WSL_INTEROP", "WSLENV"]; + !BLOCKED + .iter() + .any(|blocked| name.eq_ignore_ascii_case(blocked)) +} + +fn push_bind(args: &mut Vec, flag: &str, source: &str, destination: &str) { + args.extend([ + flag.to_string(), + source.to_string(), + destination.to_string(), + ]); +} + +fn directory_to_wsl(path: &Path) -> Result { + ensure!( + path.is_dir(), + "Windows sandboxing via WSL can only use an existing directory as cwd: {}", + path.display() + ); + map_path_to_wsl(path) +} + +fn path_to_wsl(path: &Path) -> Result { + let path_string = path.to_string_lossy(); + if let Ok(path) = parse_wsl_absolute_path(&path_string) { + return Ok(PathMapping::Wsl(path)); + } + + ensure!( + path.is_dir() || path.is_file(), + "Windows sandboxing via WSL can only grant existing files or directories: {}", + path.display() + ); + map_path_to_wsl(path) +} + +fn map_path_to_wsl(path: &Path) -> Result { + let path_string = path.to_string_lossy(); + if let Ok(path) = parse_wsl_unc_path(&path_string) { + return Ok(PathMapping::Wsl(path)); + } + let fallback = parse_native_drive_path(&path_string)?; + let windows_path = path_string + .strip_prefix(r"\\?\") + .unwrap_or(&path_string) + .replace('\\', "/"); + Ok(PathMapping::NativeDrive { + windows_path, + fallback, + }) +} + +fn parse_wsl_absolute_path(path: &str) -> Result { + let path = path.replace('\\', "/"); + ensure!( + path.starts_with('/') && !path.starts_with("//"), + "path is not a WSL absolute path: {path}" + ); + Ok(WslPath { distro: None, path }) +} + +fn parse_wsl_unc_path(path: &str) -> Result { + let path = path.replace('/', "\\"); + let remainder = path + .strip_prefix("\\\\wsl.localhost\\") + .or_else(|| path.strip_prefix("\\\\wsl$\\")) + .or_else(|| path.strip_prefix("\\\\?\\UNC\\wsl.localhost\\")) + .or_else(|| path.strip_prefix("\\\\?\\UNC\\wsl$\\")) + .with_context(|| format!("path is not a WSL UNC path: {path}"))?; + + let (distro, rest) = remainder + .split_once('\\') + .map(|(distro, rest)| (distro, Some(rest))) + .unwrap_or((remainder, None)); + ensure!( + !distro.is_empty(), + "WSL UNC path is missing a distro name: {path}" + ); + + let linux_path = match rest { + Some(rest) if !rest.is_empty() => format!("/{}", rest.replace('\\', "/")), + _ => "/".to_string(), + }; + + Ok(WslPath { + distro: Some(distro.to_string()), + path: linux_path, + }) +} + +fn parse_native_drive_path(path: &str) -> Result { + let path = path + .strip_prefix("\\\\?\\") + .unwrap_or(path) + .replace('\\', "/"); + let mut chars = path.chars(); + let Some(drive) = chars.next().filter(|drive| drive.is_ascii_alphabetic()) else { + bail!("path is not a drive-letter Windows path: {path}"); + }; + ensure!(chars.next() == Some(':'), "path is not absolute: {path}"); + let rest = chars.as_str().trim_start_matches('/'); + let drive = drive.to_ascii_lowercase(); + let linux_path = if rest.is_empty() { + format!("/mnt/{drive}") + } else { + format!("/mnt/{drive}/{rest}") + }; + Ok(WslPath { + distro: None, + path: linux_path, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wrap_invocation_future_is_send() { + // Callers run `wrap_invocation` via `background_spawn`, which + // requires a `Send` future. This fails to compile if, for example, a + // cache `MutexGuard` is ever held across an await point. + fn assert_send(_: T) {} + assert_send(wrap_invocation( + String::new(), + Vec::new(), + Vec::new(), + SandboxPermissions::default(), + None, + HashMap::::new(), + )); + } + + #[test] + fn parse_wsl_localhost_path() { + let path = parse_wsl_unc_path(r"\\wsl.localhost\Ubuntu\home\me\project").unwrap(); + assert_eq!(path.distro.as_deref(), Some("Ubuntu")); + assert_eq!(path.path, "/home/me/project"); + } + + #[test] + fn parse_wsl_dollar_path() { + let path = parse_wsl_unc_path(r"\\wsl$\Debian\tmp").unwrap(); + assert_eq!(path.distro.as_deref(), Some("Debian")); + assert_eq!(path.path, "/tmp"); + } + + #[test] + fn parse_native_windows_path() { + let path = parse_native_drive_path(r"C:\Users\me\project").unwrap(); + assert_eq!(path.distro, None); + assert_eq!(path.path, "/mnt/c/Users/me/project"); + } + + #[test] + fn parse_wsl_absolute_path_keeps_linux_path() { + let path = parse_wsl_absolute_path("/home/me").unwrap(); + assert_eq!(path.distro, None); + assert_eq!(path.path, "/home/me"); + } + + #[test] + fn parse_wsl_absolute_path_rejects_unc_paths() { + assert!(parse_wsl_absolute_path(r"\\server\share").is_err()); + } + + #[test] + fn parse_verbatim_native_windows_path() { + let path = parse_native_drive_path(r"\\?\D:\workspace").unwrap(); + assert_eq!(path.distro, None); + assert_eq!(path.path, "/mnt/d/workspace"); + } + + #[test] + fn rejects_unc_non_wsl_path() { + assert!(parse_native_drive_path(r"\\server\share\project").is_err()); + } + + #[test] + fn probe_output_reports_interop_and_bwrap_path() { + let probe = parse_probe_output("zed-wsl-probe: interop /usr/bin/bwrap\n").unwrap(); + assert_eq!( + probe, + EnvironmentProbe { + mask_interop_dir: true, + bwrap_path: "/usr/bin/bwrap".to_string(), + } + ); + + let probe = + parse_probe_output("zed-wsl-probe: no-interop /home/me/.nix-profile/bin/bwrap\n") + .unwrap(); + assert_eq!( + probe, + EnvironmentProbe { + mask_interop_dir: false, + bwrap_path: "/home/me/.nix-profile/bin/bwrap".to_string(), + } + ); + } + + #[test] + fn probe_output_ignores_profile_noise_even_mentioning_interop() { + // Login-shell profile scripts run before the probe body and may print + // arbitrary text; only the marked result line counts. + let probe = parse_probe_output( + "welcome to my shell, interop fans\nzed-wsl-probe: no-interop /usr/bin/bwrap\n", + ) + .unwrap(); + assert!(!probe.mask_interop_dir); + } + + #[test] + fn probe_output_rejects_missing_or_malformed_result_line() { + assert!(parse_probe_output("").is_err()); + assert!(parse_probe_output("profile noise only\n").is_err()); + assert!(parse_probe_output("zed-wsl-probe: interop\n").is_err()); + assert!(parse_probe_output("zed-wsl-probe: maybe /usr/bin/bwrap\n").is_err()); + } + + #[test] + fn probe_output_rejects_non_absolute_bwrap_path() { + // `command -v` reports a bare name for shell functions and aliases, + // which `wsl --exec` could never run. + assert!(parse_probe_output("zed-wsl-probe: interop bwrap\n").is_err()); + } + + #[test] + fn probe_script_smoke_tests_the_namespaces_the_real_invocation_uses() { + // Presence isn't enough: unprivileged user namespaces can be + // restricted (e.g. Ubuntu 24.04's AppArmor policy), so the probe must + // actually exercise the namespace flags `build_bwrap_args` emits. + let script = probe_script(); + for flag in [ + "--unshare-user", + "--unshare-net", + "--unshare-ipc", + "--unshare-uts", + "--unshare-pid", + "--unshare-cgroup-try", + "--ro-bind / /", + ] { + assert!(script.contains(flag), "probe script must contain {flag}"); + } + assert!(script.contains("exit 41")); + assert!(script.contains("exit 42")); + } + + #[test] + fn probe_script_rejects_setuid_root_bwrap_before_smoke_test() { + let script = probe_script(); + let guard = + "[ -u \"$bwrap_path\" ] && [ \"$(stat -c %u \"$bwrap_path\" 2>/dev/null)\" = 0 ]"; + let smoke_test = "\"$bwrap_path\" --ro-bind / /"; + let Some(guard_index) = script.find(guard) else { + panic!("probe script must contain setuid-root guard: {script}"); + }; + let Some(smoke_test_index) = script.find(smoke_test) else { + panic!("probe script must contain bwrap smoke test: {script}"); + }; + + assert!(script.contains("setuid-root bwrap is not supported")); + assert!(script.contains(&format!("exit {BWRAP_UNUSABLE_EXIT_CODE}; fi"))); + assert!(guard_index < smoke_test_index); + } + + #[test] + fn bwrap_denies_network_by_default() { + let args = build_bwrap_args( + &["/home/me/project".to_string()], + SandboxPermissions::default(), + Some("/home/me/project"), + true, + &HashMap::new(), + ); + assert!(args.iter().any(|arg| arg == "--unshare-net")); + assert!( + args.windows(3) + .any(|window| window == ["--bind", "/home/me/project", "/home/me/project"]) + ); + } + + #[test] + fn bwrap_allows_network_when_requested() { + let args = build_bwrap_args( + &[], + SandboxPermissions { + allow_network: true, + allow_fs_write: false, + }, + None, + true, + &HashMap::new(), + ); + assert!(!args.iter().any(|arg| arg == "--unshare-net")); + } + + #[test] + fn bwrap_binds_explicit_writable_file_paths() { + let args = build_bwrap_args( + &["/mnt/c/Users/me/AppData/Roaming/Zed/AGENTS.md".to_string()], + SandboxPermissions::default(), + None, + true, + &HashMap::new(), + ); + assert!(args.windows(3).any(|window| window + == [ + "--bind", + "/mnt/c/Users/me/AppData/Roaming/Zed/AGENTS.md", + "/mnt/c/Users/me/AppData/Roaming/Zed/AGENTS.md" + ])); + } + + #[test] + fn bwrap_blocks_wsl_interop_by_default() { + let args = build_bwrap_args( + &["/home/me/project".to_string()], + SandboxPermissions::default(), + Some("/home/me/project"), + true, + &HashMap::new(), + ); + assert!( + args.windows(2) + .any(|window| window == ["--unsetenv", "WSL_INTEROP"]) + ); + assert!( + args.windows(2) + .any(|window| window == ["--tmpfs", "/run/WSL"]) + ); + } + + #[test] + fn bwrap_blocks_wsl_interop_even_with_fs_write() { + let args = build_bwrap_args( + &[], + SandboxPermissions { + allow_network: true, + allow_fs_write: true, + }, + None, + true, + &HashMap::new(), + ); + // Interop is host code execution, not just a filesystem write, so it + // stays blocked even when the user has granted unrestricted writes + // and network. + assert!( + args.windows(2) + .any(|window| window == ["--unsetenv", "WSL_INTEROP"]) + ); + assert!( + args.windows(2) + .any(|window| window == ["--tmpfs", "/run/WSL"]) + ); + } + + #[test] + fn bwrap_skips_interop_dir_mask_when_absent() { + // When the interop socket directory doesn't exist (interop disabled), + // there's nothing to mask and a `--tmpfs /run/WSL` would abort bwrap, + // so the mount must be omitted. Unsetting the variable is harmless and + // stays. + let args = build_bwrap_args( + &[], + SandboxPermissions::default(), + None, + false, + &HashMap::new(), + ); + assert!( + args.windows(2) + .any(|window| window == ["--unsetenv", "WSL_INTEROP"]) + ); + assert!(!args.iter().any(|arg| arg == "/run/WSL")); + } + + #[test] + fn bwrap_forwards_env_via_setenv() { + let env = HashMap::from([ + ("PAGER".to_string(), String::new()), + ("CARGO_TERM_COLOR".to_string(), "always".to_string()), + ]); + let args = build_bwrap_args(&[], SandboxPermissions::default(), None, false, &env); + assert!( + args.windows(3) + .any(|window| window == ["--setenv", "PAGER", ""]) + ); + assert!( + args.windows(3) + .any(|window| window == ["--setenv", "CARGO_TERM_COLOR", "always"]) + ); + } + + #[test] + fn bwrap_does_not_forward_wsl_interop_env() { + let env = HashMap::from([ + ( + "WSL_INTEROP".to_string(), + "/run/WSL/123_interop".to_string(), + ), + ("WsLeNv".to_string(), "WSL_INTEROP/u".to_string()), + ("PAGER".to_string(), String::new()), + ]); + let args = build_bwrap_args(&[], SandboxPermissions::default(), None, false, &env); + + assert!( + args.windows(2) + .any(|window| window == ["--unsetenv", "WSL_INTEROP"]) + ); + assert!( + args.windows(2) + .any(|window| window == ["--unsetenv", "WSLENV"]) + ); + assert!( + args.windows(3) + .any(|window| window == ["--setenv", "PAGER", ""]) + ); + assert!(!args.windows(3).any(|window| { + matches!(window, [flag, name, _] + if flag.as_str() == "--setenv" + && name.eq_ignore_ascii_case("WSL_INTEROP")) + })); + assert!(!args.windows(3).any(|window| { + matches!(window, [flag, name, _] + if flag.as_str() == "--setenv" + && name.eq_ignore_ascii_case("WSLENV")) + })); + } + + #[test] + fn bwrap_does_not_forward_windows_specific_env() { + // These hold Windows paths/values that would break or be meaningless + // inside WSL, so they must never cross the boundary. Names are matched + // case-insensitively, as Windows env var names are. + let env = HashMap::from([ + ("Path".to_string(), r"C:\Windows\System32".to_string()), + ( + "TEMP".to_string(), + r"C:\Users\me\AppData\Local\Temp".to_string(), + ), + ( + "Tmp".to_string(), + r"C:\Users\me\AppData\Local\Temp".to_string(), + ), + ("TMPDIR".to_string(), r"C:\tmp".to_string()), + ]); + let args = build_bwrap_args(&[], SandboxPermissions::default(), None, false, &env); + assert!(!args.iter().any(|arg| arg == "--setenv")); + } + + #[test] + fn bwrap_skips_env_names_setenv_would_reject() { + // bwrap's `--setenv` calls `setenv(3)`, which rejects empty names and + // names containing `=`. Windows environments include the per-drive + // current-directory pseudo-variables (`=C:`, ...); forwarding them + // would abort bwrap with "setenv failed". + let env = HashMap::from([ + ("=C:".to_string(), r"C:\Users\me".to_string()), + (String::new(), "value".to_string()), + ("OK".to_string(), "value".to_string()), + ]); + let args = build_bwrap_args(&[], SandboxPermissions::default(), None, false, &env); + assert!( + args.windows(3) + .any(|window| window == ["--setenv", "OK", "value"]) + ); + assert_eq!(args.iter().filter(|arg| *arg == "--setenv").count(), 1); + } + + #[test] + fn select_distro_uses_wsl_distro_when_present() { + let distro = select_distro( + None, + &[ + PathMapping::NativeDrive { + windows_path: "C:/project".to_string(), + fallback: WslPath { + distro: None, + path: "/mnt/c/project".to_string(), + }, + }, + PathMapping::Wsl(WslPath { + distro: Some("Ubuntu".to_string()), + path: "/home/me/project".to_string(), + }), + ], + ) + .unwrap(); + assert_eq!(distro.as_deref(), Some("Ubuntu")); + } + + #[test] + fn bad_request_errors_do_not_claim_sandboxing_is_unavailable() { + // Mixed distros and missing/unmappable paths are model-fixable bad + // requests. They must not be typed as `WslSandboxUnavailable` (nor + // carry its prefix), since the agent uses that type to offer the + // run-unsandboxed fallback only for genuine environment failures. + let mixed_distros = select_distro( + Some(&PathMapping::Wsl(WslPath { + distro: Some("Ubuntu".to_string()), + path: "/home/me".to_string(), + })), + &[PathMapping::Wsl(WslPath { + distro: Some("Debian".to_string()), + path: "/home/me".to_string(), + })], + ) + .unwrap_err(); + assert!( + mixed_distros + .downcast_ref::() + .is_none() + ); + assert!(!format!("{mixed_distros:#}").contains(WSL_SANDBOX_UNAVAILABLE_PREFIX)); + + let missing_path = + path_to_wsl(Path::new(r"C:\zed-test\definitely\does\not\exist-2769")).unwrap_err(); + assert!( + missing_path + .downcast_ref::() + .is_none() + ); + assert!(!format!("{missing_path:#}").contains(WSL_SANDBOX_UNAVAILABLE_PREFIX)); + + let unmappable_cwd = directory_to_wsl(Path::new(r"\\server\share\project")).unwrap_err(); + assert!( + unmappable_cwd + .downcast_ref::() + .is_none() + ); + assert!(!format!("{unmappable_cwd:#}").contains(WSL_SANDBOX_UNAVAILABLE_PREFIX)); + } + + #[test] + fn unavailable_errors_are_typed_and_prefixed() { + // Environment failures are recognizable by type (so the agent doesn't + // depend on message text) and still render with the shared prefix. + let error = unavailable("Bubblewrap (`bwrap`) is not installed in the default WSL distro"); + let typed = error + .downcast_ref::() + .expect("environment failure should downcast to WslSandboxUnavailable"); + assert_eq!( + typed.message(), + "Bubblewrap (`bwrap`) is not installed in the default WSL distro" + ); + assert!(format!("{error:#}").starts_with(WSL_SANDBOX_UNAVAILABLE_PREFIX)); + } + + #[test] + fn map_path_to_wsl_keeps_unc_paths_structural() { + let mapping = map_path_to_wsl(Path::new(r"\\wsl.localhost\Ubuntu\home\me")).unwrap(); + assert_eq!( + mapping, + PathMapping::Wsl(WslPath { + distro: Some("Ubuntu".to_string()), + path: "/home/me".to_string(), + }) + ); + } + + #[test] + fn map_path_to_wsl_defers_native_paths_to_wslpath() { + let mapping = map_path_to_wsl(Path::new(r"C:\Users\me\project")).unwrap(); + assert_eq!( + mapping, + PathMapping::NativeDrive { + windows_path: "C:/Users/me/project".to_string(), + fallback: WslPath { + distro: None, + path: "/mnt/c/Users/me/project".to_string(), + }, + } + ); + } + + #[test] + fn map_path_to_wsl_strips_verbatim_prefix_for_wslpath() { + let mapping = map_path_to_wsl(Path::new(r"\\?\D:\workspace")).unwrap(); + assert_eq!( + mapping, + PathMapping::NativeDrive { + windows_path: "D:/workspace".to_string(), + fallback: WslPath { + distro: None, + path: "/mnt/d/workspace".to_string(), + }, + } + ); + } + + #[test] + fn path_resolution_args_flattens_mappings_into_triples() { + let mappings = [ + PathMapping::NativeDrive { + windows_path: "C:/Users/me/project".to_string(), + fallback: WslPath { + distro: None, + path: "/mnt/c/Users/me/project".to_string(), + }, + }, + PathMapping::Wsl(WslPath { + distro: Some("Ubuntu".to_string()), + path: "/home/me/project".to_string(), + }), + ]; + assert_eq!( + path_resolution_args(mappings.iter()), + [ + "W", + "C:/Users/me/project", + "/mnt/c/Users/me/project", + "L", + "/home/me/project", + "", + ] + ); + } + + #[test] + fn parse_path_resolution_output_reads_one_line_per_path() { + let resolved = parse_path_resolution_output( + "ok ok /mnt/c/Users/me/project\nfallback missing /mnt/d/workspace\n", + 2, + ) + .unwrap(); + assert_eq!( + resolved, + [ + ResolvedPath { + path: "/mnt/c/Users/me/project".to_string(), + used_fallback: false, + exists: true, + }, + ResolvedPath { + path: "/mnt/d/workspace".to_string(), + used_fallback: true, + exists: false, + }, + ] + ); + } + + #[test] + fn parse_path_resolution_output_keeps_spaces_in_paths() { + let resolved = + parse_path_resolution_output("ok ok /mnt/c/Users/me/My Documents/project\n", 1) + .unwrap(); + assert_eq!(resolved[0].path, "/mnt/c/Users/me/My Documents/project"); + } + + #[test] + fn parse_path_resolution_output_rejects_wrong_line_count() { + assert!(parse_path_resolution_output("ok ok /a\n", 2).is_err()); + assert!(parse_path_resolution_output("ok ok /a\nok ok /b\n", 1).is_err()); + } + + #[test] + fn parse_path_resolution_output_rejects_corrupted_lines() { + assert!(parse_path_resolution_output("garbage\n", 1).is_err()); + assert!(parse_path_resolution_output("weird ok /a\n", 1).is_err()); + assert!(parse_path_resolution_output("ok weird /a\n", 1).is_err()); + assert!(parse_path_resolution_output("ok ok not-absolute\n", 1).is_err()); + } +} diff --git a/crates/sandbox/src/wsl_sandbox_test_helper.rs b/crates/sandbox/src/wsl_sandbox_test_helper.rs new file mode 100644 index 00000000000000..5fa13cb2280dac --- /dev/null +++ b/crates/sandbox/src/wsl_sandbox_test_helper.rs @@ -0,0 +1,842 @@ +//! Behavior test helper for the Windows WSL Bubblewrap sandbox — the Windows +//! analog of `bwrap_test_helper`. +//! +//! Where the Linux helper is the sandboxed process itself (it re-execs under +//! the launcher), here the sandboxed process is a *Linux* program inside WSL +//! while this helper runs on Windows. So instead of a status channel and a +//! launcher, the helper drives the real [`sandbox::windows_wsl::wrap_invocation`], +//! spawns the `wsl.exe` command line it produces, and inspects exit codes and +//! host-side filesystem effects to confirm every grant the sandbox makes and +//! every restriction it imposes actually holds — including the Windows-specific +//! one: that a sandboxed process cannot escape via WSL interop by exec'ing a +//! Windows binary. +//! +//! It targets the **default** WSL distro (matching real Zed usage for native +//! Windows paths); provision that distro before running (see +//! `script/test-wsl-sandbox.ps1`). Like the Linux helper, it **skips** (rather +//! than fails) the enforcement assertions when the environment can't actually +//! enforce a sandbox, so a misconfigured WSL doesn't masquerade as a sandbox +//! regression. Set `ZED_TEST_SANDBOX_REQUIRE_ENFORCED=1` to turn that skip into +//! a failure once you've provisioned an environment that *should* enforce. +//! +//! Run it with `cargo xtask wsl-sandbox-tests` or `script/test-wsl-sandbox.ps1`. + +#![allow( + clippy::disallowed_methods, + reason = "a single-threaded test helper that intentionally blocks on child processes" +)] + +#[cfg(not(target_os = "windows"))] +fn main() { + eprintln!("wsl_sandbox_test_helper is only supported on Windows"); + std::process::exit(1); +} + +#[cfg(target_os = "windows")] +fn main() { + imp::main(); +} + +#[cfg(target_os = "windows")] +mod imp { + use std::collections::HashMap; + use std::ffi::OsStr; + use std::net::TcpListener; + use std::os::windows::process::CommandExt as _; + use std::path::{Path, PathBuf}; + use std::process::{Command, Output}; + + use anyhow::{Context as _, Result, bail, ensure}; + use sandbox::SandboxPermissions; + use sandbox::windows_wsl; + + /// Tag prefixed to every result line, matching `bwrap_test_helper` so both + /// helpers' output reads the same. + const RESULT_TAG: &str = "[sandbox_test]:"; + + /// `CREATE_NO_WINDOW`: keep `wsl.exe` (a console-subsystem binary) from + /// flashing a console window when spawned. + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + + pub fn main() { + if let Err(error) = run() { + eprintln!("{RESULT_TAG} FAILED: {error:#}"); + std::process::exit(1); + } + } + + fn run() -> Result<()> { + let require_enforced = env_flag("ZED_TEST_SANDBOX_REQUIRE_ENFORCED"); + let wsl = Wsl::detect(); + println!("{RESULT_TAG} starting (require_enforced={require_enforced})"); + + // `wrap_invocation` performs the real environment probe (locate `bwrap`, + // reject setuid-root, smoke-test the exact namespaces) before it builds + // any command. So a default-permissions run of `true` doubles as our + // enforcement probe: `Ok` means the sandbox is enforceable here, an + // `Unavailable` error means it is not. + let probe = run_in_sandbox("true", &[], SandboxPermissions::default())?; + match &probe { + Outcome::Ran { + command_succeeded: true, + .. + } => {} + Outcome::Unavailable(message) => return not_enforced(require_enforced, message), + other => { + return not_enforced( + require_enforced, + &format!("the sandbox probe did not run cleanly: {other:?}"), + ); + } + } + + run_enforced(&wsl) + } + + /// The environment can't enforce a sandbox. Skip the enforcement checks + /// unless the caller asserted (via `ZED_TEST_SANDBOX_REQUIRE_ENFORCED`) that + /// it should be able to, in which case this is a real failure. + fn not_enforced(require_enforced: bool, reason: &str) -> Result<()> { + if require_enforced { + bail!( + "ZED_TEST_SANDBOX_REQUIRE_ENFORCED is set, but the WSL sandbox could not be \ + enforced: {reason}" + ); + } + println!( + "{RESULT_TAG} SKIP: this environment cannot enforce a WSL bwrap sandbox: {reason}" + ); + Ok(()) + } + + /// Enforced scenario: `bwrap` is present and a sandbox can be set up, so the + /// sandbox must actually be enforced. Assert every grant and every + /// restriction end-to-end against the real WSL distro. + fn run_enforced(wsl: &Wsl) -> Result<()> { + let mut checks = Checks::new(); + let pid = std::process::id(); + + // The core filesystem checks use a scratch tree on the WSL distro's own + // rootfs (under `/var/tmp`, which the sandbox leaves read-only rather + // than overlaying like `/tmp`). This mirrors the Linux helper and is + // robust: it doesn't depend on how drvfs `/mnt/` submounts behave + // under bwrap's recursive root bind. The Windows-drive translation path + // (the realistic Zed-on-`C:` case) gets its own dedicated check below. + let root_base = format!("/var/tmp/zed-wsl-sandbox-test-{pid}"); + let writable_wsl = format!("{root_base}/writable"); + let forbidden_wsl = format!("{root_base}/forbidden"); + let readable_wsl = format!("{root_base}/readable"); + let mkdir = wsl.run_sh(&format!( + "mkdir -p {} {} {}", + shell_quote(&writable_wsl), + shell_quote(&forbidden_wsl), + shell_quote(&readable_wsl), + ))?; + ensure!( + mkdir.status.success(), + "failed to create the WSL scratch tree{}", + failure_details(&mkdir) + ); + let _root_cleanup = WslCleanup { + exe: wsl.exe.clone(), + path: root_base, + }; + + let default = SandboxPermissions::default(); + let fs_write_all = SandboxPermissions { + allow_network: false, + allow_fs_write: true, + }; + let network_allowed = SandboxPermissions { + allow_network: true, + allow_fs_write: false, + }; + + // GRANT: writing into a writable bind succeeds and lands on the host. + let writable_file = format!("{writable_wsl}/from-sandbox.txt"); + let write_writable = run_in_sandbox( + &format!("echo zed > {}", shell_quote(&writable_file)), + &[PathBuf::from(writable_wsl)], + default, + )?; + checks.expect_succeeded("GRANT: write into a writable dir succeeds", &write_writable); + checks.expect( + "GRANT: write into a writable dir lands on the host", + wsl.exists(&writable_file)?, + ); + + // RESTRICT: writing outside any writable bind is denied by the read-only + // root, and must not leak to the host. + let forbidden_file = format!("{forbidden_wsl}/escaped.txt"); + let write_forbidden = run_in_sandbox( + &format!("echo zed > {}", shell_quote(&forbidden_file)), + &[], + default, + )?; + checks.expect_blocked( + "RESTRICT: write outside writable dirs is denied", + &write_forbidden, + ); + checks.expect( + "RESTRICT: denied write did not leak to the host", + !wsl.exists(&forbidden_file)?, + ); + + // GRANT: the whole filesystem is readable (root is bound read-only), so + // a host file outside every writable dir can still be read. + let readable_file = format!("{readable_wsl}/host-data.txt"); + let seed = wsl.run_sh(&format!( + "printf 'host data' > {}", + shell_quote(&readable_file) + ))?; + ensure!( + seed.status.success(), + "failed to seed the readable file{}", + failure_details(&seed) + ); + let read_host = run_in_sandbox( + &format!("cat {}", shell_quote(&readable_file)), + &[], + default, + )?; + checks.expect_succeeded( + "GRANT: host files outside writable dirs are still readable", + &read_host, + ); + + // GRANT + RESTRICT: `/tmp` is a writable tmpfs, but ephemeral — it must + // not leak to the WSL distro's real `/tmp`. + let tmp_path = format!("/tmp/zed-sandbox-ephemeral-{pid}"); + let write_tmp = run_in_sandbox( + &format!("echo zed > {}", shell_quote(&tmp_path)), + &[], + default, + )?; + checks.expect_succeeded("GRANT: writing to /tmp succeeds", &write_tmp); + checks.expect( + "RESTRICT: /tmp writes are ephemeral (do not leak to the WSL host /tmp)", + !wsl.exists(&tmp_path)?, + ); + + // RESTRICT + GRANT: outbound TCP is denied by the network namespace, but + // works when network access is explicitly granted. We discover a peer + // reachable from WSL first; that same reachability check proves the + // denial below is the sandbox's doing. + match discover_peer(wsl)? { + Some(peer) => { + let connect = connect_script(&peer); + let net_denied = run_in_sandbox(&connect, &[], default)?; + checks.expect_blocked( + "RESTRICT: outbound TCP is blocked when network is denied", + &net_denied, + ); + let net_allowed = run_in_sandbox(&connect, &[], network_allowed)?; + checks.expect_succeeded( + "GRANT: outbound TCP works when network is allowed", + &net_allowed, + ); + // RESTRICT: permissions are independent — granting filesystem + // writes must not also grant network access. + let net_with_fs_write = run_in_sandbox(&connect, &[], fs_write_all)?; + checks.expect_blocked( + "RESTRICT: allow_fs_write does not also grant network access", + &net_with_fs_write, + ); + } + None => println!( + "{RESULT_TAG} SKIP: no TCP peer reachable from WSL; skipping network checks" + ), + } + + // GRANT: local AF_UNIX IPC keeps working even while IP networking is + // denied. Needs python3 to create the socket; skip if it isn't present. + if wsl.has_program("python3")? { + let unix_ok = run_in_sandbox( + "python3 -c 'import socket; socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)'", + &[], + default, + )?; + checks.expect_succeeded( + "GRANT: AF_UNIX sockets still work while network is denied", + &unix_ok, + ); + } else { + println!("{RESULT_TAG} SKIP: no python3 in WSL; skipping AF_UNIX check"); + } + + // GRANT (escape hatch): `allow_fs_write` lets the command write + // anywhere, and the write reaches the host. + let escape_file = format!("{forbidden_wsl}/escape-hatch.txt"); + let write_escape = run_in_sandbox( + &format!("echo zed > {}", shell_quote(&escape_file)), + &[], + fs_write_all, + )?; + checks.expect_succeeded( + "GRANT: allow_fs_write lets the command write outside writable dirs", + &write_escape, + ); + checks.expect( + "GRANT: allow_fs_write write lands on the host", + wsl.exists(&escape_file)?, + ); + + // GRANT + RESTRICT (Windows-specific): a writable directory given as a + // native `C:\` path is translated into WSL and bound read-write, and the + // write lands back on the Windows filesystem. + check_windows_drive_writable(wsl, &mut checks)?; + + // RESTRICT (Windows-specific): WSL interop must be blocked, so a + // sandboxed process can't exec a Windows binary and escape bwrap. + check_interop_blocked(wsl, &mut checks)?; + + // GRANT + RESTRICT: the caller's environment is forwarded into the + // command, but Windows-specific values like PATH are not (which would + // otherwise shadow WSL's PATH and break the shell). + check_env_forwarding(&mut checks)?; + + // Degraded (bad request): a non-existent writable path is the model's + // mistake, not a broken sandbox environment, so it must be reported + // *without* the "sandboxing is unavailable" marker (which would wrongly + // prompt the user to disable sandboxing globally). + let missing = std::env::temp_dir().join(format!("zed-wsl-missing-{pid}")); + let bad_request = drive_sandbox( + "true", + &[], + std::slice::from_ref(&missing), + default, + &HashMap::new(), + )?; + checks.expect( + "a non-existent writable path is a bad request, not an unavailable-environment error", + matches!(bad_request, Outcome::BadRequest(_)), + ); + if !matches!(bad_request, Outcome::BadRequest(_)) { + println!("{RESULT_TAG} (got {bad_request:?})"); + } + + checks.finish() + } + + /// Windows-specific GRANT: a writable directory passed as a native `C:\` + /// path is translated into WSL with `wslpath`, bound read-write, and a write + /// inside the sandbox lands back on the Windows filesystem. Exercises the + /// native-drive path translation end-to-end (the realistic case of Zed on + /// Windows sandboxing a command in a project under `C:\`). + fn check_windows_drive_writable(wsl: &Wsl, checks: &mut Checks) -> Result<()> { + let base = + std::env::temp_dir().join(format!("zed-wsl-sandbox-drive-{}", std::process::id())); + let writable = base.join("writable"); + std::fs::create_dir_all(&writable) + .with_context(|| format!("failed to create scratch dir `{}`", writable.display()))?; + let _cleanup = Cleanup(base); + + let mapped = wsl.wsl_paths(&[&writable]).context( + "failed to translate the scratch dir into a WSL path (is the C: drive automounted?)", + )?; + let Some(writable_wsl) = mapped.into_iter().next() else { + bail!("wslpath returned no result for the scratch dir"); + }; + + let write = run_in_sandbox( + &format!( + "echo zed > {}", + shell_quote(&format!("{writable_wsl}/from-sandbox.txt")) + ), + std::slice::from_ref(&writable), + SandboxPermissions::default(), + )?; + checks.expect_succeeded( + "GRANT: write into a writable C:\\ dir succeeds (native path translated into WSL)", + &write, + ); + checks.expect( + "GRANT: write into a writable C:\\ dir lands on the Windows host", + writable.join("from-sandbox.txt").exists(), + ); + Ok(()) + } + + /// Assert that a sandboxed process cannot reach the Windows host through WSL + /// interop. Without the sandbox's interop block, a command could exec a + /// Windows binary (e.g. `cmd.exe`), which the WSL binfmt handler runs on the + /// host — fully outside bwrap. + fn check_interop_blocked(wsl: &Wsl, checks: &mut Checks) -> Result<()> { + // `$WSL_INTEROP` must be unset inside the sandbox (the variable `/init` + // uses to find the interop socket). + let interop_env = run_in_sandbox( + "[ -z \"$WSL_INTEROP\" ]", + &[], + SandboxPermissions::default(), + )?; + checks.expect_succeeded( + "RESTRICT: $WSL_INTEROP is unset inside the sandbox", + &interop_env, + ); + + // Resolve cmd.exe's path inside WSL; skip the exec check if we can't + // (e.g. a non-standard automount root). + let cmd = match wsl.wsl_paths(&[Path::new(r"C:\Windows\System32\cmd.exe")]) { + Ok(mut paths) => paths.pop(), + Err(_) => None, + }; + let Some(cmd) = cmd else { + println!( + "{RESULT_TAG} SKIP: could not resolve cmd.exe inside WSL; skipping interop exec check" + ); + return Ok(()); + }; + + // Control: unsandboxed, interop should let WSL exec a Windows binary. If + // even this fails, interop isn't available here, so the sandboxed denial + // below would prove nothing — skip. + let control = wsl.run(&cmd, ["/C", "exit"])?; + if !control.status.success() { + println!( + "{RESULT_TAG} SKIP: WSL interop is unavailable in this environment (the unsandboxed \ + control run failed); skipping interop exec check" + ); + return Ok(()); + } + + // Sandboxed: exec'ing the same Windows binary must fail, because interop + // is blocked. + let escape = run_in_sandbox( + &format!("{} /C exit", shell_quote(&cmd)), + &[], + SandboxPermissions::default(), + )?; + checks.expect_blocked( + "RESTRICT: cannot exec a Windows binary via WSL interop (sandbox escape blocked)", + &escape, + ); + Ok(()) + } + + /// Assert the caller's environment is forwarded into the sandbox, while + /// Windows-specific values like PATH are dropped rather than overriding + /// WSL's own. + fn check_env_forwarding(checks: &mut Checks) -> Result<()> { + let mut env = HashMap::new(); + env.insert("ZED_TEST_FORWARDED".to_string(), "yes".to_string()); + // If PATH were forwarded it would replace WSL's PATH with this bogus + // value; it must not be. + env.insert( + "PATH".to_string(), + "/zed-sentinel-should-not-win".to_string(), + ); + let outcome = drive_sandbox( + "/bin/sh", + &[ + "-c", + "[ \"$ZED_TEST_FORWARDED\" = yes ] && [ \"$PATH\" != /zed-sentinel-should-not-win ]", + ], + &[], + SandboxPermissions::default(), + &env, + )?; + checks.expect_succeeded( + "GRANT: caller env is forwarded into the sandbox; RESTRICT: PATH is not overridden", + &outcome, + ); + Ok(()) + } + + /// The outcome of asking the sandbox to run a command. + #[derive(Debug)] + enum Outcome { + /// `wrap_invocation` succeeded and the wrapped `wsl.exe` command ran; + /// `command_succeeded` is its exit success. + Ran { + command_succeeded: bool, + stdout: String, + stderr: String, + }, + /// `wrap_invocation` reported the sandbox *environment* as unavailable + /// (carried the shared unavailable-prefix marker). + Unavailable(String), + /// `wrap_invocation` reported a bad request (a mappable-path / distro + /// problem); no unavailable-prefix marker. + BadRequest(String), + } + + /// Run `/bin/sh -c