mirror of
https://github.com/zed-industries/zed.git
synced 2026-10-21 05:31:11 +00:00
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:
parent
1bc9d8e80e
commit
dfd44a45dd
24 changed files with 3010 additions and 111 deletions
3
Cargo.lock
generated
3
Cargo.lock
generated
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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)?
|
||||
|
|
|
|||
|
|
@ -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())?
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)?
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
1457
crates/sandbox/src/windows_wsl.rs
Normal file
1457
crates/sandbox/src/windows_wsl.rs
Normal file
File diff suppressed because it is too large
Load diff
842
crates/sandbox/src/wsl_sandbox_test_helper.rs
Normal file
842
crates/sandbox/src/wsl_sandbox_test_helper.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
101
script/test-wsl-sandbox.ps1
Normal 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
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -8,3 +8,4 @@ pub mod setup_webrtc;
|
|||
pub mod web_examples;
|
||||
pub mod workflow_checks;
|
||||
pub mod workflows;
|
||||
pub mod wsl_sandbox_tests;
|
||||
|
|
|
|||
65
tooling/xtask/src/tasks/wsl_sandbox_tests.rs
Normal file
65
tooling/xtask/src/tasks/wsl_sandbox_tests.rs
Normal 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(())
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue