Add Windows terminal sandboxing via WSL (#58971)

Summary

- Adds Windows agent terminal sandboxing by routing commands through WSL
and Bubblewrap.
- Supports native Windows and WSL project paths, including elevated
write grants for WSL paths.
- Shows a confirmation prompt to turn off sandboxing when WSL sandbox
setup is unavailable.

This builds on the work in the sandbox-linux branch.

Closes AI-376

Release Notes:

- Added Windows terminal sandboxing for agent commands when sandboxing
is enabled.

---------

Co-authored-by: cameron <cameron.studdstreet@gmail.com>
Co-authored-by: Richard Feldman <oss@rtfeldman.com>
Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>
This commit is contained in:
MartinYe1234 2026-06-17 14:07:29 -07:00 • committed by GitHub
parent 1bc9d8e80e
commit dfd44a45dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 3010 additions and 111 deletions

3
Cargo.lock generated
View file

@ -288,6 +288,7 @@ dependencies = [
"regex",
"reqwest_client",
"rust-embed",
"sandbox",
"schemars 1.0.4",
"serde",
"serde_json",
@ -15910,6 +15911,8 @@ version = "0.1.0"
dependencies = [
"anyhow",
"libc",
"log",
"smol",
"tempfile",
]

View file

@ -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()
},

View file

@ -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<String>,
@ -346,18 +351,7 @@ 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")]
{
// No sandbox integration on Windows; 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"
);
}
let _ = (sandbox_wrap, cwd);
Ok((program, args, None))
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
// No sandbox integration available; run with ambient permissions.
if let NetworkPolicy::Proxied(port) = network_policy {
@ -370,6 +364,72 @@ pub(crate) fn apply_sandbox_wrap(
}
}
/// 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<String>,
cwd: Option<std::path::PathBuf>,
sandbox_wrap: SandboxWrap,
network_policy: NetworkPolicy,
env: collections::HashMap<String, String>,
) -> anyhow::Result<(String, Vec<String>, Option<SandboxConfigHandle>)> {
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}; bubblewrap in WSL can't confine to a loopback port"
);
false
}
};
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<std::path::PathBuf> = 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
/// network access, and wire the child's environment to route through it.
///

View file

@ -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

View file

@ -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/<drive>/...` 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

View file

@ -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::<SandboxingFeatureFlag>()
}
/// 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

View file

@ -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();

View file

@ -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/<drive>/...` 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.

View file

@ -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<Self>,
@ -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")
@ -5004,9 +5009,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`).
@ -5550,13 +5556,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<String>,
@ -5671,7 +5678,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<Arc<dyn Fs>>, cx: &AsyncApp) {
let Some(fs) = fs else {
log::error!(

View file

@ -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)?

View file

@ -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())?
};

View file

@ -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)?

View file

@ -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::<sandbox::windows_wsl::WslSandboxUnavailable>()
.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<PathBuf> {
///
/// On Windows, raw paths the model expressed in WSL terms (a `/mnt/<drive>/...`
/// 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<PathBuf> {
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<PathBuf> {
.collect()
}
fn wsl_drive_mount_path_to_windows_path(raw: &str) -> Option<PathBuf> {
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<PathBuf> {
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")

View file

@ -422,7 +422,12 @@ pub struct SandboxPermissions {
/// consumed (`agent::sandboxing`).
pub network_hosts: Vec<String>,
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<PathBuf>,
}
@ -809,6 +814,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,
}
}
@ -1112,6 +1118,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")]

View file

@ -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

View file

@ -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.

File diff suppressed because it is too large Load diff

View file

@ -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/<drive>` 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 <script>` under the sandbox with the given writable paths
/// and permissions.
fn run_in_sandbox(
script: &str,
writable_paths: &[PathBuf],
permissions: SandboxPermissions,
) -> Result<Outcome> {
drive_sandbox(
"/bin/sh",
&["-c", script],
writable_paths,
permissions,
&HashMap::new(),
)
}
/// Drive the real sandbox the way Zed's terminal integration does: wrap the
/// invocation, then spawn the resulting `wsl.exe` command and collect its
/// result. `wrap_invocation` errors are classified into [`Outcome`] by the
/// shared unavailable-prefix marker rather than bubbling up, so callers can
/// assert on the classification.
fn drive_sandbox(
program: &str,
args: &[&str],
writable_paths: &[PathBuf],
permissions: SandboxPermissions,
env: &HashMap<String, String>,
) -> Result<Outcome> {
let wrapped = smol::block_on(windows_wsl::wrap_invocation(
program.to_string(),
args.iter().map(|arg| arg.to_string()).collect(),
writable_paths.to_vec(),
permissions,
None,
env.clone(),
));
let (wsl_exe, wsl_args) = match wrapped {
Ok(wrapped) => wrapped,
Err(error) => {
let message = format!("{error:#}");
return Ok(
if message.contains(sandbox::WSL_SANDBOX_UNAVAILABLE_PREFIX) {
Outcome::Unavailable(message)
} else {
Outcome::BadRequest(message)
},
);
}
};
let output = Command::new(&wsl_exe)
.args(&wsl_args)
.creation_flags(CREATE_NO_WINDOW)
.output()
.context("failed to spawn the wrapped wsl.exe sandbox command")?;
Ok(Outcome::Ran {
command_succeeded: output.status.success(),
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
})
}
/// Find a TCP peer reachable from inside WSL so the network checks have a
/// real endpoint. Honors `ZED_TEST_ECHO_ADDR` (a `host:port`) if set;
/// otherwise binds a local listener on the Windows side and finds an address
/// WSL can use to reach the Windows host. Returns `None` (so the caller
/// skips, rather than fails, the network checks) when nothing is reachable.
fn discover_peer(wsl: &Wsl) -> Result<Option<String>> {
if let Some(address) = std::env::var("ZED_TEST_ECHO_ADDR")
.ok()
.filter(|address| !address.is_empty())
{
ensure_host_port(&address)?;
return Ok(Some(address));
}
// A listener on the Windows side that accepts and immediately drops
// connections, so an in-WSL connect succeeds. It lives for the rest of
// the process (the thread owns it and never closes it).
let listener =
TcpListener::bind(("0.0.0.0", 0)).context("failed to bind a TCP listener")?;
let port = listener
.local_addr()
.context("local listener has no address")?
.port();
std::thread::spawn(move || {
for stream in listener.incoming() {
match stream {
Ok(stream) => drop(stream),
Err(_) => break,
}
}
});
// Addresses WSL might use to reach the Windows host: the default
// gateway (WSL2 NAT mode) and loopback (mirrored networking mode).
let mut candidates = Vec::new();
if let Ok(output) =
wsl.run_sh("ip route show default 2>/dev/null | awk '/default/{print $3; exit}'")
{
let gateway = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !gateway.is_empty() {
candidates.push(gateway);
}
}
candidates.push("127.0.0.1".to_string());
for host in candidates {
let address = format!("{host}:{port}");
if wsl.run_sh(&connect_script(&address))?.status.success() {
return Ok(Some(address));
}
}
Ok(None)
}
/// A shell snippet that attempts an outbound TCP connection to `host:port`,
/// exiting 0 on success. Uses bash's `/dev/tcp` (the most widely available
/// option in default WSL distros) under `timeout` so a blocked connect can't
/// hang. If bash or timeout is missing the snippet simply fails, which the
/// caller treats as "unreachable" and skips.
fn connect_script(address: &str) -> String {
format!(
"timeout 5 bash -c 'exec 3<>/dev/tcp/{}'",
address.replace(':', "/")
)
}
fn ensure_host_port(address: &str) -> Result<()> {
let (host, port) = address
.rsplit_once(':')
.with_context(|| format!("ZED_TEST_ECHO_ADDR must be host:port, got {address:?}"))?;
ensure!(
!host.is_empty() && port.parse::<u16>().is_ok(),
"ZED_TEST_ECHO_ADDR must be host:port, got {address:?}"
);
Ok(())
}
/// Thin wrapper over `wsl.exe` for the helper's own (unsandboxed) WSL calls:
/// path translation, the interop control run, and peer discovery. Targets
/// the default distro, matching `wrap_invocation`.
struct Wsl {
exe: PathBuf,
}
impl Wsl {
fn detect() -> Self {
let exe = std::env::var_os("SystemRoot")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(r"C:\Windows"))
.join("System32")
.join("wsl.exe");
Self { exe }
}
/// Run a Linux `program` (with `args`) unsandboxed via `wsl.exe --exec`.
fn run<I, S>(&self, program: &str, args: I) -> Result<Output>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
Command::new(&self.exe)
.arg("--exec")
.arg(program)
.args(args)
.creation_flags(CREATE_NO_WINDOW)
.output()
.with_context(|| format!("failed to run `wsl.exe --exec {program}`"))
}
/// Run `/bin/sh -c <script>` unsandboxed.
fn run_sh(&self, script: &str) -> Result<Output> {
self.run("/bin/sh", ["-c", script])
}
/// Whether `name` resolves to a program inside WSL.
fn has_program(&self, name: &str) -> Result<bool> {
Ok(self.run_sh(&format!("command -v {name}"))?.status.success())
}
/// Whether `linux_path` exists in the (unsandboxed) WSL distro — used to
/// confirm that sandboxed writes did or didn't reach the host.
fn exists(&self, linux_path: &str) -> Result<bool> {
Ok(self
.run_sh(&format!("[ -e {} ]", shell_quote(linux_path)))?
.status
.success())
}
/// Translate Windows paths to their WSL paths with `wslpath -u`, in one
/// round-trip. Paths are passed as argv (not interpolated) so quoting is
/// never a concern.
fn wsl_paths(&self, paths: &[&Path]) -> Result<Vec<String>> {
let mut args: Vec<std::ffi::OsString> = vec![
"-c".into(),
"for path; do wslpath -u \"$path\" || exit 9; done".into(),
"zed-wslpath".into(),
];
args.extend(paths.iter().map(|path| path.as_os_str().to_os_string()));
let output = self.run("/bin/sh", args)?;
ensure!(
output.status.success(),
"wslpath translation failed{}",
failure_details(&output)
);
let stdout = String::from_utf8_lossy(&output.stdout);
let resolved: Vec<String> = stdout
.lines()
.map(|line| line.trim_end_matches('\r').to_string())
.collect();
ensure!(
resolved.len() == paths.len(),
"expected {} wslpath results, got {}: {stdout:?}",
paths.len(),
resolved.len()
);
Ok(resolved)
}
}
/// Tracks assertion results so the helper can report a summary and a single
/// exit code, mirroring `bwrap_test_helper`.
struct Checks {
passed: usize,
failed: usize,
}
impl Checks {
fn new() -> Self {
Self {
passed: 0,
failed: 0,
}
}
fn expect(&mut self, description: &str, condition: bool) {
if condition {
self.passed += 1;
println!("{RESULT_TAG} PASS: {description}");
} else {
self.failed += 1;
println!("{RESULT_TAG} FAIL: {description}");
}
}
/// Expect the wrapped command to have run and succeeded.
fn expect_succeeded(&mut self, description: &str, outcome: &Outcome) {
let ok = matches!(
outcome,
Outcome::Ran {
command_succeeded: true,
..
}
);
self.report(description, ok, outcome);
}
/// Expect the wrapped command to have run but been blocked (non-zero
/// exit) — the sandbox imposed a restriction.
fn expect_blocked(&mut self, description: &str, outcome: &Outcome) {
let ok = matches!(
outcome,
Outcome::Ran {
command_succeeded: false,
..
}
);
self.report(description, ok, outcome);
}
fn report(&mut self, description: &str, ok: bool, outcome: &Outcome) {
if ok {
self.passed += 1;
println!("{RESULT_TAG} PASS: {description}");
} else {
self.failed += 1;
println!("{RESULT_TAG} FAIL: {description}");
match outcome {
Outcome::Ran {
command_succeeded,
stdout,
stderr,
} => println!(
"{RESULT_TAG} (command {}, stdout: {:?}, stderr: {:?})",
if *command_succeeded {
"succeeded"
} else {
"failed"
},
truncate(stdout),
truncate(stderr),
),
Outcome::Unavailable(message) => {
println!("{RESULT_TAG} (sandbox reported unavailable: {message})");
}
Outcome::BadRequest(message) => {
println!("{RESULT_TAG} (sandbox reported bad request: {message})");
}
}
}
}
fn finish(self) -> Result<()> {
println!(
"{RESULT_TAG} summary: {} passed, {} failed",
self.passed, self.failed
);
if self.failed > 0 {
bail!("{} sandbox assertion(s) failed", self.failed);
}
Ok(())
}
}
fn failure_details(output: &Output) -> String {
let stderr = String::from_utf8_lossy(&output.stderr);
let stderr = stderr.trim();
let status = match output.status.code() {
Some(code) => format!("exit code {code}"),
None => "terminated by signal".to_string(),
};
if stderr.is_empty() {
format!(" ({status})")
} else {
format!(" ({status}; stderr: {stderr})")
}
}
/// Single-quote a value for safe interpolation into an `sh -c` script.
fn shell_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\\''"))
}
fn truncate(value: &str) -> String {
const LIMIT: usize = 200;
let trimmed = value.trim();
match trimmed.char_indices().nth(LIMIT) {
// `index` is a char boundary, so this slice can't split a code point.
Some((index, _)) => format!("{}…", &trimmed[..index]),
None => trimmed.to_string(),
}
}
fn env_flag(name: &str) -> bool {
std::env::var(name)
.map(|value| value == "1")
.unwrap_or(false)
}
/// Removes the Windows-side scratch tree on drop, so it doesn't pile up
/// across runs.
struct Cleanup(PathBuf);
impl Drop for Cleanup {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// Removes a WSL-side scratch tree on drop (best effort).
struct WslCleanup {
exe: PathBuf,
path: String,
}
impl Drop for WslCleanup {
fn drop(&mut self) {
let _ = Command::new(&self.exe)
.arg("--exec")
.arg("rm")
.arg("-rf")
.arg(&self.path)
.creation_flags(CREATE_NO_WINDOW)
.status();
}
}
}

View file

@ -487,6 +487,10 @@ impl AgentSettingsContent {
.allow_unsandboxed = Some(true);
}
pub fn disable_sandbox(&mut self) {
self.sandbox_permissions.get_or_insert_default().disabled = Some(true);
}
pub fn add_sandbox_write_path(&mut self, path: PathBuf) {
let write_paths = &mut self
.sandbox_permissions
@ -749,6 +753,11 @@ pub struct SandboxPermissionsContent {
/// Default: false
pub allow_unsandboxed: Option<bool>,
/// Whether terminal sandboxing is turned off entirely, so agent terminal
/// commands always run outside the sandbox.
/// Default: false
pub disabled: Option<bool>,
/// Directory subtrees that sandboxed terminal commands may always write
/// to without prompting. Paths written by Zed are absolute.
/// Default: []
@ -1036,6 +1045,7 @@ mod tests {
);
settings.allow_sandbox_fs_write_all();
settings.allow_sandbox_unsandboxed();
settings.disable_sandbox();
settings.add_sandbox_write_path(PathBuf::from("/tmp/build"));
let sandbox_permissions = settings.sandbox_permissions.as_ref().unwrap();
@ -1051,6 +1061,7 @@ mod tests {
);
assert_eq!(sandbox_permissions.allow_fs_write_all, Some(true));
assert_eq!(sandbox_permissions.allow_unsandboxed, Some(true));
assert_eq!(sandbox_permissions.disabled, Some(true));
assert_eq!(
sandbox_permissions
.write_paths

View file

@ -46,6 +46,24 @@ intent; see `sandbox-bubblewrap-migration.md` for the original rationale.
`nixos-test` feature) drives the real launcher and reads the status; an
enforcement probe **skips** (not fails) when the VM can't enforce. Checks
wired into the flake (`packages.nix`); xtask prefix is now `sandbox-`.
- **Windows WSL behavior tests.** Parity with the NixOS bwrap tests for the
Windows WSL sandbox (`windows_wsl`). New `wsl_sandbox_test_helper` bin (sandbox
crate, `wsl-test` feature) drives the real `wrap_invocation`, spawns the
produced `wsl.exe` command, and asserts the same grants/restrictions hold
end-to-end (writable binds land on host, reads allowed, `/tmp` ephemeral,
network on/off, fs-write escape hatch) plus the Windows-specific ones: a
native `C:\` writable path is translated and bound, and **WSL interop is
blocked** so a sandboxed process can't exec a Windows binary to escape bwrap.
Core FS checks run against the WSL rootfs (robust regardless of how drvfs
`/mnt` submounts behave under the recursive root bind). Like the Linux helper
it **skips** when the environment can't enforce; set
`ZED_TEST_SANDBOX_REQUIRE_ENFORCED=1` to turn that into a failure. Run with
`cargo xtask wsl-sandbox-tests` or `script/test-wsl-sandbox.ps1` (the latter
provisions `bubblewrap` + unprivileged user namespaces in the default distro
first). Not wired into CI — matching the NixOS tests, which aren't either.
Degraded scenarios (no bwrap / userns disabled) have no boot-level toggle in
WSL, so that coverage stays at the unit level (probe parsing + the
bad-request-vs-unavailable error classification, also re-checked end-to-end).
## Remaining

101
script/test-wsl-sandbox.ps1 Normal file
View file

@ -0,0 +1,101 @@
#!/usr/bin/env pwsh
# Provision the default WSL distro for the Windows sandbox behavior tests and
# run them. The Windows analog of running `cargo xtask sandbox-tests` on Linux.
#
# What it does:
# 1. Checks that WSL is installed.
# 2. (Unless -NoProvision) installs `bubblewrap` into the default distro and
# enables unprivileged user namespaces, so the sandbox can actually be
# enforced. Without both, the helper can only SKIP the enforcement checks —
# it can't verify a sandbox that was never set up. On Ubuntu 24.04 (the
# current default WSL distro) user namespaces are restricted by AppArmor by
# default, which is exactly why this step is needed.
# 3. Runs `cargo xtask wsl-sandbox-tests`, which builds and runs
# `wsl_sandbox_test_helper` against the real WSL/Bubblewrap sandbox.
#
# By default it requires the sandbox to actually be enforced (so a broken setup
# fails loudly instead of silently skipping). Pass -AllowSkip to keep the
# helper's default skip-when-unenforceable behavior.
#
# Notes:
# * Provisioning runs as root via `wsl -u root`, so it needs no sudo password.
# * Auto-provisioning only supports apt-based distros (Ubuntu/Debian). For
# others, install `bubblewrap` yourself and re-run with -NoProvision.
# * The user-namespace sysctl is set for the running WSL VM only; it resets
# after `wsl --shutdown`.
# * If the network checks are skipped, Windows Firewall may be blocking the
# test's local listener; that only affects the network assertions.
#
# Usage:
# ./script/test-wsl-sandbox.ps1
# ./script/test-wsl-sandbox.ps1 -NoProvision
# ./script/test-wsl-sandbox.ps1 -Release
# ./script/test-wsl-sandbox.ps1 -AllowSkip
[CmdletBinding()]
param(
[switch]$NoProvision,
[switch]$Release,
[switch]$AllowSkip
)
$ErrorActionPreference = "Stop"
if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) {
Write-Error "wsl.exe was not found. Install WSL (https://learn.microsoft.com/windows/wsl/install) and try again."
}
# Run a command in the default WSL distro as root, throwing on failure.
function Invoke-WslRoot {
param([Parameter(Mandatory = $true)][string]$Script)
& wsl.exe -u root -- sh -c $Script
if ($LASTEXITCODE -ne 0) {
throw "WSL command failed (exit $LASTEXITCODE): $Script"
}
}
# Run a command in the default WSL distro as the default user, returning its
# exit code instead of throwing.
function Test-Wsl {
param([Parameter(Mandatory = $true)][string]$Script)
& wsl.exe -- sh -lc $Script *> $null
return $LASTEXITCODE
}
if (-not $NoProvision) {
Write-Host "==> Provisioning the default WSL distro for sandbox testing"
if ((Test-Wsl "command -v apt-get >/dev/null 2>&1") -eq 0) {
Write-Host " Installing bubblewrap (apt-get)..."
Invoke-WslRoot "apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y bubblewrap"
}
else {
Write-Warning "Auto-provisioning only supports apt-based distros. Install 'bubblewrap' in your default WSL distro manually, then re-run with -NoProvision."
}
Write-Host " Enabling unprivileged user namespaces (for this WSL VM session)..."
# Older kernels lack this key (the AppArmor restriction simply isn't
# present), so don't treat a missing key as an error.
Invoke-WslRoot "sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 2>/dev/null || true"
}
if ((Test-Wsl "command -v bwrap >/dev/null 2>&1") -ne 0) {
Write-Warning "bwrap is not installed in the default WSL distro; the sandbox cannot be enforced. Install 'bubblewrap' (or run without -NoProvision)."
}
$repoRoot = Split-Path -Parent $PSScriptRoot
Push-Location $repoRoot
try {
$xtaskArgs = @("xtask", "wsl-sandbox-tests")
if (-not $AllowSkip) { $xtaskArgs += "--require-enforced" }
if ($Release) { $xtaskArgs += "--release" }
Write-Host "==> Running: cargo $($xtaskArgs -join ' ')"
& cargo @xtaskArgs
$exitCode = $LASTEXITCODE
}
finally {
Pop-Location
}
exit $exitCode

View file

@ -23,6 +23,8 @@ enum CliCommand {
PublishGpui(tasks::publish_gpui::PublishGpuiArgs),
/// Runs the Linux Bubblewrap sandboxing NixOS VM tests.
SandboxTests(tasks::sandbox_tests::SandboxTestsArgs),
/// Runs the Windows WSL Bubblewrap sandbox behavior tests.
WslSandboxTests(tasks::wsl_sandbox_tests::WslSandboxTestsArgs),
/// Downloads the pinned `webrtc-sys` release and configures `LK_CUSTOM_WEBRTC`.
SetupWebrtc(tasks::setup_webrtc::SetupWebrtcArgs),
/// Builds GPUI web examples and serves them.
@ -43,6 +45,7 @@ fn main() -> Result<()> {
}
CliCommand::PublishGpui(args) => tasks::publish_gpui::run_publish_gpui(args),
CliCommand::SandboxTests(args) => tasks::sandbox_tests::run_sandbox_tests(args),
CliCommand::WslSandboxTests(args) => tasks::wsl_sandbox_tests::run_wsl_sandbox_tests(args),
CliCommand::SetupWebrtc(args) => tasks::setup_webrtc::run_setup_webrtc(args),
CliCommand::WebExamples(args) => tasks::web_examples::run_web_examples(args),
CliCommand::Workflows(args) => tasks::workflows::run_workflows(args),

View file

@ -8,3 +8,4 @@ pub mod setup_webrtc;
pub mod web_examples;
pub mod workflow_checks;
pub mod workflows;
pub mod wsl_sandbox_tests;

View file

@ -0,0 +1,65 @@
#![allow(clippy::disallowed_methods, reason = "tooling is exempt")]
use std::process::Command;
use anyhow::{Context as _, Result, bail};
use clap::Parser;
/// Runs the Windows WSL Bubblewrap sandbox behavior tests — the Windows analog
/// of `cargo xtask sandbox-tests`.
///
/// Where the Linux tests boot a NixOS VM, here we build and run
/// `wsl_sandbox_test_helper` (sandbox crate, `wsl-test` feature), which drives
/// the real `windows_wsl::wrap_invocation`, spawns the resulting `wsl.exe`
/// command, and asserts the sandbox's grants and restrictions hold end-to-end.
///
/// This must run on Windows with WSL, against a default distro that has
/// `bubblewrap` installed and unprivileged user namespaces enabled.
/// `script/test-wsl-sandbox.ps1` can provision that environment and invoke this.
#[derive(Parser)]
pub struct WslSandboxTestsArgs {
/// Fail (rather than skip the enforcement checks) when the sandbox can't
/// actually be enforced. Use this once the WSL environment is provisioned,
/// so a broken sandbox is caught instead of silently skipped.
#[arg(long)]
require_enforced: bool,
/// Build and run the helper in release mode.
#[arg(long)]
release: bool,
}
pub fn run_wsl_sandbox_tests(args: WslSandboxTestsArgs) -> Result<()> {
if !cfg!(target_os = "windows") {
bail!(
"wsl-sandbox-tests drives the Windows WSL sandbox and must run on Windows with WSL \
installed. See script/test-wsl-sandbox.ps1."
);
}
let mut command = Command::new("cargo");
command.args([
"run",
"-p",
"sandbox",
"--features",
"wsl-test",
"--bin",
"wsl_sandbox_test_helper",
]);
if args.release {
command.arg("--release");
}
if args.require_enforced {
command.env("ZED_TEST_SANDBOX_REQUIRE_ENFORCED", "1");
}
eprintln!("Running WSL sandbox behavior tests");
let status = command
.status()
.context("failed to run `cargo run` for the WSL sandbox helper")?;
if !status.success() {
bail!("WSL sandbox tests failed");
}
Ok(())
}