Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
b9e9ba2
linux sandboxing implementation
cameron1024 Jun 2, 2026
533950a
add reason field to sandbox escape request
cameron1024 Jun 2, 2026
6737383
fmt and remove todo
cameron1024 Jun 2, 2026
dbace76
landlock -> bwrap
cameron1024 Jun 9, 2026
81ad2cf
sandboxing for windows
MartinYe1234 Jun 9, 2026
8e75216
windows native to wsl
MartinYe1234 Jun 9, 2026
8041649
better error messages
MartinYe1234 Jun 9, 2026
41c3257
fix
MartinYe1234 Jun 9, 2026
d0ffe40
make non interactive
MartinYe1234 Jun 9, 2026
721efc1
evelated perms
MartinYe1234 Jun 9, 2026
ddde028
linux to windows path
MartinYe1234 Jun 9, 2026
dae3a8c
Fix Windows WSL sandbox preflight clippy
MartinYe1234 Jun 9, 2026
3799e24
Autofix
zed-zippy[bot] Jun 9, 2026
9f12b67
Avoid unused sandbox wrapper on Windows
MartinYe1234 Jun 9, 2026
b5f960f
plug escape hole via interop
MartinYe1234 Jun 10, 2026
8883e4c
better agent facing error
MartinYe1234 Jun 10, 2026
442e420
pass in env into wsl
MartinYe1234 Jun 10, 2026
fb13921
type mismatch
MartinYe1234 Jun 10, 2026
3bc54ef
fix
MartinYe1234 Jun 10, 2026
6cd81c5
Translate Windows paths into WSL with wslpath
MartinYe1234 Jun 10, 2026
13c0e04
clippy fix
MartinYe1234 Jun 10, 2026
f5b73aa
condense probes
MartinYe1234 Jun 11, 2026
17eab6e
memoisation for paths
MartinYe1234 Jun 11, 2026
34eed2e
fixes
MartinYe1234 Jun 11, 2026
c506864
Run WSL sandbox wrap off the UI thread with a timeout
MartinYe1234 Jun 11, 2026
d954f4e
Hide console window when probing WSL
MartinYe1234 Jun 11, 2026
4fb3910
fixes to match linux more closely
MartinYe1234 Jun 15, 2026
40e8ca6
Merge remote-tracking branch 'origin/main' into sandbox-windows
MartinYe1234 Jun 16, 2026
e684c15
Merge remote-tracking branch 'origin/main' into sandbox-windows
MartinYe1234 Jun 16, 2026
7b0028d
sandbox: Add Windows WSL sandbox behavior tests
MartinYe1234 Jun 16, 2026
41ece66
ui for errors
MartinYe1234 Jun 17, 2026
054b5c4
fix
MartinYe1234 Jun 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

77 changes: 64 additions & 13 deletions crates/acp_thread/src/acp_thread.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
},
Expand Down
86 changes: 73 additions & 13 deletions crates/acp_thread/src/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>,
Expand Down Expand Up @@ -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"
Expand All @@ -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<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} 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<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
Expand Down
1 change: 1 addition & 0 deletions crates/agent/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 10 additions & 6 deletions crates/agent/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 33 additions & 29 deletions crates/agent/src/sandboxing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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));
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading