//! Linux sandbox integration built on Bubblewrap (`bwrap`) for filesystem and //! network confinement. //! //! We can use `--bind` and `--ro-bind` (read-only) to bind host filesystem //! paths to paths in the sandbox. If networking is restricted, we also set //! `--unshare-net` to disable *all* network access. //! //! When restricting network access, we: //! - set `--unshare-net` - any requests to `example.com` will fail //! - requests to `localhost` will succeed, but it will be an isolated localhost //! from the host system. //! - create a unix socket, and mount it in the sandbox with `--bind` //! - run a bridge process inside the sandbox that: //! - listens on `localhost:` and forwards reads/writes to the socket //! - then, runs the untrusted command //! - on the zed side, we listen to the socket and forward reads/writes to the //! internal HTTP proxy //! //! If networking is fully blocked or fully allowed, we don't bother with the //! proxy/socket at all (and simply set/unset `--unshare-net`). //! //! This design for networking avoids needing seccomp, a fork/exec dance, and //! eliminates a race condition involving BPF user notifications. use anyhow::{Context as _, Result, anyhow, bail}; use std::ffi::{OsStr, OsString}; use std::io::{Read, Write}; use std::net::{Ipv4Addr, Shutdown, TcpListener, TcpStream}; use std::os::fd::{AsRawFd as _, FromRawFd as _, OwnedFd, RawFd}; use std::os::unix::fs::MetadataExt as _; use std::os::unix::net::{UnixListener, UnixStream}; use std::os::unix::process::{CommandExt as _, ExitStatusExt as _}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::thread; /// Re-exec marker for the in-sandbox launcher: it runs inside the sandbox before /// the real command to (a) validate that bwrap bound the writable grants to the /// inodes we captured (the bind-source TOCTOU backstop) and (b) run the /// restricted-network HTTP bridge. See `README.md` for the design. const LAUNCHER_FLAG: &str = "--zed-linux-sandbox-launcher"; /// Re-exec marker for the WSL-side helper. This runs *inside WSL* (a Linux /// process) and does what `Sandbox::wrap` + the validation-fd sender do /// in-process on native Linux: capture the writable binds' `O_PATH` fds, stand /// up the validation socket, assemble the bwrap invocation, and spawn it. The /// capture must happen WSL-side because a Windows process holds no Linux fds. /// See `README.md`. Shared with the Windows side via `crate::WSL_SANDBOX_HELPER_FLAG`. const WSL_HELPER_FLAG: &str = crate::WSL_SANDBOX_HELPER_FLAG; const WSL_RESOLVE_FLAG: &str = crate::WSL_SANDBOX_RESOLVE_FLAG; /// Sentinel argv token meaning "this optional field is absent". const LAUNCHER_NONE: &str = "-"; const PROXY_SOCKET_SANDBOX_PATH_PREFIX: &str = "/tmp/zed-sandbox"; const VALIDATION_SOCKET_SANDBOX_PATH_PREFIX: &str = "/tmp/zed-sandbox-validate"; const SANDBOX_SETUP_FAILED_EXIT_CODE: i32 = 126; const PUMP_BUFFER_SIZE: usize = 64 * 1024; /// Upper bound on writable binds validated in a single `SCM_RIGHTS` message, /// kept comfortably below the kernel's per-message fd limit (`SCM_MAX_FD`, 253). /// Exceeding it fails closed rather than silently validating a subset. const MAX_VALIDATED_BINDS: usize = 200; /// Network-access setting for a sandboxed command. /// /// Mirrors [`crate::macos_seatbelt::NetworkAccess`] so Linux and macOS expose /// the same public shape. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum NetworkAccess { /// All outbound network blocked (own network namespace). #[default] None, /// Outbound HTTP(S) is available through a loopback proxy port inside the /// sandbox. The port is bridged to a host-side Unix socket proxy that /// enforces the hostname allowlist. LocalhostPort(u16), /// All outbound network allowed. All, } /// Per-command relaxations of the default Bubblewrap (Linux) sandbox. /// /// Mirrors [`crate::macos_seatbelt::SandboxPermissions`]. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct SandboxPermissions { /// Network access policy for the command. pub network: NetworkAccess, /// Allow unrestricted filesystem writes. pub allow_fs_write: bool, } /// The outcome of preparing a Linux sandbox. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum LauncherStatus { /// No usable `bwrap` binary was found on `PATH` (or bundled). BwrapNotFound, /// The only `bwrap` found is setuid-root, which we refuse to execute. SetuidRejected, /// `bwrap` is present but failed to set up the sandbox with our arguments. SandboxProbeFailed, } impl LauncherStatus { /// A human-readable explanation suitable for diagnostics. pub fn describe(self) -> &'static str { match self { LauncherStatus::BwrapNotFound => "no usable `bwrap` binary was found on PATH", LauncherStatus::SetuidRejected => { "the only available `bwrap` is setuid-root, which Zed refuses to run" } LauncherStatus::SandboxProbeFailed => { "`bwrap` is present but failed to create a sandbox (unprivileged user \ namespaces may be disabled)" } } } } /// Where a `bwrap` lookup ended up. enum BwrapLocation { /// A usable, non-setuid `bwrap` binary. Found(PathBuf), /// `bwrap` exists but every candidate is setuid-root (which we won't run). OnlySetuid, /// No `bwrap` binary was found at all. NotFound, } fn locate_bwrap() -> BwrapLocation { let mut saw_setuid = false; for candidate in candidate_bwrap_paths() { if !candidate.is_file() { continue; } if is_setuid_root(&candidate) { saw_setuid = true; continue; } return BwrapLocation::Found(candidate); } if saw_setuid { BwrapLocation::OnlySetuid } else { BwrapLocation::NotFound } } fn candidate_bwrap_paths() -> Vec { let mut candidates = Vec::new(); if let Some(system) = system_bwrap_path() { candidates.push(system); } if let Some(bundled) = bundled_bwrap_path() { candidates.push(bundled); } candidates } fn system_bwrap_path() -> Option { let path = std::env::var_os("PATH")?; std::env::split_paths(&path) .map(|directory| directory.join("bwrap")) .find(|candidate| candidate.is_file()) } fn bundled_bwrap_path() -> Option { None } fn is_setuid_root(path: &Path) -> bool { match std::fs::metadata(path) { Ok(metadata) => (metadata.mode() & libc::S_ISUID != 0) && metadata.uid() == 0, Err(_) => false, } } #[allow( clippy::disallowed_methods, reason = "the probe is a short-lived background operation that must block on bwrap" )] fn probe_bwrap(bwrap: &Path, bwrap_args: &[String]) -> bool { // Capture stderr (rather than discarding it) so a failed probe can report // *why* bwrap refused — the difference between "user namespaces disabled", // "chdir target missing", "no permission", etc. — instead of a bare // `SandboxProbeFailed`. let output = Command::new(bwrap) .args(bwrap_args) .arg("--") .arg("true") .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::piped()) .output(); match output { Ok(output) if output.status.success() => true, Ok(output) => { log::warn!( "[sandbox] bwrap probe failed ({}). command: {} {}\nbwrap stderr: {}", output.status, bwrap.display(), bwrap_args.join(" "), String::from_utf8_lossy(&output.stderr).trim() ); false } Err(error) => { log::warn!( "[sandbox] bwrap probe could not be spawned: {error}. command: {} {}", bwrap.display(), bwrap_args.join(" ") ); false } } } /// Build the `bwrap` argument list (everything after the `bwrap` program and /// before the trailing `-- `) for the given policy. /// /// `proxy_socket_path` is the host pathname Unix socket used for /// [`NetworkAccess::LocalhostPort`]. It is bind-mounted to a unique path inside /// the sandbox where the bridge connects to it. pub fn build_bwrap_args( writable_directories: &[&Path], protected_paths: &[&Path], permissions: SandboxPermissions, cwd: Option<&Path>, proxy_socket_path: Option<&Path>, ) -> Vec { let proxy_socket_sandbox_path = proxy_socket_path .filter(|_| matches!(permissions.network, NetworkAccess::LocalhostPort(_))) .map(|_| unique_proxy_socket_sandbox_path()); build_bwrap_args_with_sandbox_paths( writable_directories, protected_paths, permissions, cwd, proxy_socket_path, proxy_socket_sandbox_path.as_deref(), None, None, ) } #[allow( clippy::too_many_arguments, reason = "a flat arg list mirrors the bwrap flags this assembles" )] fn build_bwrap_args_with_sandbox_paths( writable_directories: &[&Path], protected_paths: &[&Path], permissions: SandboxPermissions, cwd: Option<&Path>, proxy_socket_path: Option<&Path>, proxy_socket_sandbox_path: Option<&Path>, validation_socket_path: Option<&Path>, validation_socket_sandbox_path: Option<&Path>, ) -> Vec { let mut args = Vec::new(); let root_bind = if permissions.allow_fs_write { "--bind" } else { "--ro-bind" }; push_bind(&mut args, root_bind, "/", "/"); args.push("--dev".to_string()); args.push("/dev".to_string()); args.push("--proc".to_string()); args.push("/proc".to_string()); if !permissions.allow_fs_write { args.push("--tmpfs".to_string()); args.push("/tmp".to_string()); for directory in writable_directories { // Bind each writable directory at its *exact* path, **verbatim** — // never re-`canonicalize`d here. The path was already resolved once, // at capture time, to the pinned inode's current path (`readlink` of // the `O_PATH` fd); re-resolving it now would reopen the // time-of-check-to-time-of-use gap a malicious swap exploits. We must // also never widen to an existing ancestor, so a path that doesn't // exist is skipped rather than falling back to a parent. Whatever // inode bwrap actually binds is verified after the mounts by the // in-sandbox launcher (see `validate_binds`), which fails closed on a // mismatch. if !directory.exists() { continue; } let path = directory.to_string_lossy().into_owned(); push_bind(&mut args, "--bind", &path, &path); } } // Protect requested paths by re-binding them read-only *over* any broader rw // bind (order matters: later binds win). Unlike Seatbelt, bwrap can't deny // writes while allowing content reads with a policy rule, so a protected path // is read-only — its contents stay readable but can't be written. A // not-yet-existing path can't be bound, so it's skipped. for protected_path in protected_paths { if !protected_path.exists() { continue; } let path = protected_path.to_string_lossy().into_owned(); push_bind(&mut args, "--ro-bind", &path, &path); } for flag in [ "--unshare-user", "--unshare-ipc", "--unshare-uts", "--unshare-pid", "--unshare-cgroup-try", "--die-with-parent", ] { args.push(flag.to_string()); } match permissions.network { NetworkAccess::None => args.push("--unshare-net".to_string()), NetworkAccess::LocalhostPort(_) => { args.push("--unshare-net".to_string()); if let Some((proxy_socket_path, proxy_socket_sandbox_path)) = proxy_socket_path.zip(proxy_socket_sandbox_path) { let source = proxy_socket_path.to_string_lossy().into_owned(); let destination = proxy_socket_sandbox_path.to_string_lossy().into_owned(); push_bind(&mut args, "--bind", &source, &destination); } } NetworkAccess::All => {} } // The validation socket is filesystem-based, so it works regardless of the // network policy (an `--unshare-net`'d sandbox can't reach an abstract // socket, but a bind-mounted one is fine). Bind it after the `/tmp` tmpfs so // it isn't shadowed by the overlay. if let Some((source, destination)) = validation_socket_path.zip(validation_socket_sandbox_path) { let source = source.to_string_lossy().into_owned(); let destination = destination.to_string_lossy().into_owned(); push_bind(&mut args, "--bind", &source, &destination); } if let Some(cwd) = cwd { args.push("--chdir".to_string()); args.push(cwd.to_string_lossy().into_owned()); } args } /// A unique destination path for the proxy socket bind mount *inside* the /// sandbox. Each sandboxed command runs in its own mount namespace, so the path /// only needs to be unique enough to avoid colliding with anything a previous /// command in the same namespace lineage may have created; combining the pid /// with a monotonic counter is sufficient and keeps the path predictable for /// diagnostics. fn unique_proxy_socket_sandbox_path() -> PathBuf { static COUNTER: AtomicU64 = AtomicU64::new(0); let counter = COUNTER.fetch_add(1, Ordering::Relaxed); PathBuf::from(format!( "{PROXY_SOCKET_SANDBOX_PATH_PREFIX}-{}-{counter}.sock", std::process::id() )) } /// In-sandbox bind destination for the validation socket. Each sandboxed command /// runs in its own mount namespace, so this only needs to avoid colliding with /// other binds in the same namespace. fn unique_validation_socket_sandbox_path() -> PathBuf { static COUNTER: AtomicU64 = AtomicU64::new(0); let counter = COUNTER.fetch_add(1, Ordering::Relaxed); PathBuf::from(format!( "{VALIDATION_SOCKET_SANDBOX_PATH_PREFIX}-{}-{counter}.sock", std::process::id() )) } /// Create a private, owner-only directory for the validation listener socket and /// return `(directory, socket path)`. The socket is a host path (it lives /// outside the sandbox's `/tmp` tmpfs) and is bind-mounted in at /// [`unique_validation_socket_sandbox_path`]. /// /// The socket carries the captured `O_PATH` descriptors over `SCM_RIGHTS`, so /// connect access must be confined to us. Rather than bind in shared `/tmp` and /// `chmod` after (which leaves a window where another user could connect before /// the `chmod` lands), we create a `0700` directory up front: `create` is atomic /// and fails if the path already exists, and `mode(0o700)` makes it /// owner-only from creation, so no other user can even traverse to the socket. fn unique_validation_socket_host_path() -> std::io::Result<(PathBuf, PathBuf)> { use std::os::unix::fs::DirBuilderExt as _; static COUNTER: AtomicU64 = AtomicU64::new(0); let counter = COUNTER.fetch_add(1, Ordering::Relaxed); let dir = std::env::temp_dir().join(format!( "zed-sandbox-validate-{}-{counter}", std::process::id() )); // Clear a stale directory left by a previous run that reused this pid; this // only succeeds for a directory we own, so it can't be used to displace // another user's squatted path (in which case `create` below fails closed). let _ = std::fs::remove_dir_all(&dir); std::fs::DirBuilder::new().mode(0o700).create(&dir)?; let socket_path = dir.join("validate.sock"); Ok((dir, socket_path)) } /// Host endpoint that hands the in-sandbox validator the captured `O_PATH` /// descriptors for the writable binds. /// /// Runs in-process: `spawn` starts a short-lived background **thread** (never a /// separate process) that listens on an owner-only `AF_UNIX` socket and serves /// the descriptors to **exactly one** client (the in-sandbox validator) via /// `SCM_RIGHTS`, then closes and removes the socket. The validator connects /// once, after bwrap has mounted the binds; `SCM_RIGHTS` keeps the descriptors /// alive in flight, so once the single send completes there is nothing left to /// serve. Holding the descriptors until that send keeps the captured inodes /// pinned (so they can't be recycled) up to the moment they're handed off. /// /// It is owned by the per-command [`Sandbox`](crate::Sandbox); `Drop` wakes a /// still-blocked accept and removes the socket and its private directory (a /// no-op if the thread already tore them down). The socket is bind-mounted into /// the sandbox at [`Self::sandbox_socket_path`]. pub(crate) struct ValidationFdSender { host_socket_dir: PathBuf, host_socket_path: PathBuf, sandbox_socket_path: PathBuf, shutdown: Arc, } impl ValidationFdSender { /// Start serving `fds` (one per writable bind, in bind order). The caller /// must pass the same order to the launcher so each fd lines up with its /// bind-destination path. pub(crate) fn spawn(fds: Vec) -> std::io::Result { use std::os::unix::fs::PermissionsExt as _; if fds.len() > MAX_VALIDATED_BINDS { return Err(std::io::Error::other(format!( "too many writable binds to validate ({} > {MAX_VALIDATED_BINDS})", fds.len() ))); } let (host_socket_dir, host_socket_path) = unique_validation_socket_host_path()?; let listener = UnixListener::bind(&host_socket_path)?; // The enclosing directory is already `0700`, so this is defense in depth: // connect permission on an `AF_UNIX` socket is governed by write // permission on the socket file, so keep it owner-only too. std::fs::set_permissions(&host_socket_path, std::fs::Permissions::from_mode(0o600))?; let sandbox_socket_path = unique_validation_socket_sandbox_path(); let shutdown = Arc::new(AtomicBool::new(false)); // We use std threading APIs here because this code is run from both the // Linux zed binary and also the WSL helper, which does not have a GPUI // runtime. thread::Builder::new() .name("zed-sandbox-validation".to_string()) .spawn({ let shutdown = shutdown.clone(); let host_socket_path = host_socket_path.clone(); move || { // `fds` is held until the single send below, keeping the // pinned inodes alive until they're handed to the validator. let raw_fds: Vec = fds.iter().map(|fd| fd.as_raw_fd()).collect(); // Serve exactly one client: the in-sandbox validator connects // once. A second connection (e.g. the wake from `Drop`) is // distinguished by the `shutdown` flag and never served. match listener.accept() { Ok((stream, _)) => { if !shutdown.load(Ordering::SeqCst) { if let Err(error) = send_fds(&stream, &raw_fds) { log::warn!("[sandbox] failed to send validation fds: {error}"); } } } Err(error) => { if !shutdown.load(Ordering::SeqCst) { log::warn!("[sandbox] sandbox validation accept failed: {error}"); } } } drop(fds); // Close the listener and remove the socket so it can't be // connected to again. The directory is removed by `Drop`. drop(listener); let _ = std::fs::remove_file(&host_socket_path); } })?; Ok(Self { host_socket_dir, host_socket_path, sandbox_socket_path, shutdown, }) } pub(crate) fn host_socket_path(&self) -> &Path { &self.host_socket_path } pub(crate) fn sandbox_socket_path(&self) -> &Path { &self.sandbox_socket_path } } impl Drop for ValidationFdSender { fn drop(&mut self) { // If the validator never connected (e.g. bwrap failed before the // launcher ran), the thread is still blocked in `accept`. Flag shutdown // and poke the socket so the accept returns and the thread exits without // serving the descriptors, then remove the socket and its private // directory (a no-op if the thread already removed the socket after its // single send). self.shutdown.store(true, Ordering::SeqCst); let _ = UnixStream::connect(&self.host_socket_path); let _ = std::fs::remove_file(&self.host_socket_path); let _ = std::fs::remove_dir_all(&self.host_socket_dir); } } fn push_bind(args: &mut Vec, flag: &str, source: &str, destination: &str) { args.push(flag.to_string()); args.push(source.to_string()); args.push(destination.to_string()); } fn resolve_bwrap() -> std::result::Result { match locate_bwrap() { BwrapLocation::Found(path) => Ok(path), BwrapLocation::OnlySetuid => Err(LauncherStatus::SetuidRejected), BwrapLocation::NotFound => Err(LauncherStatus::BwrapNotFound), } } fn prepare_sandbox( permissions: SandboxPermissions, ) -> std::result::Result<(PathBuf, Vec), LauncherStatus> { let bwrap = match resolve_bwrap() { Ok(bwrap) => bwrap, Err(status) => { log::warn!("[sandbox] cannot create sandbox: {}", status.describe()); return Err(status); } }; // The probe only answers "can a sandbox be created on this host at all", so // it runs a bare, representative sandbox: no writable grants, no protected // Git dirs, no proxy socket, and no `--chdir` into a command's working // directory. None of those affect createability, and binding them would make // the probe depend on per-command layout (e.g. a worktree under the `/tmp` // tmpfs that `--chdir` then can't reach). let bwrap_args = build_bwrap_args(&[], &[], permissions, None, None); if !probe_bwrap(&bwrap, &bwrap_args) { return Err(LauncherStatus::SandboxProbeFailed); } Ok((bwrap, bwrap_args)) } /// Check whether an OS sandbox can be created on this host for this policy. pub fn check_can_create_sandbox( permissions: SandboxPermissions, ) -> std::result::Result<(), LauncherStatus> { prepare_sandbox(permissions).map(|_| ()) } /// The host (Zed-side) socket paths for the in-sandbox bind validator. #[derive(Clone, Copy)] pub struct ValidationSocket<'a> { /// Host pathname of the listener the validator connects back to. pub host_socket_path: &'a Path, /// In-sandbox path the host socket is bind-mounted to (where the validator /// actually connects). pub sandbox_socket_path: &'a Path, } /// Build the final command line that runs `program` inside Bubblewrap. /// /// `bridge_program` should be the current Zed executable; it is re-exec'd inside /// the sandbox as the launcher whenever bind validation and/or the /// restricted-network bridge are needed, running before the real command. /// /// The host `proxy_socket_path` and `validation_socket` are each bind-mounted to /// a per-invocation path inside the sandbox, and those in-sandbox paths are /// handed to the launcher. #[allow( clippy::too_many_arguments, reason = "assembling a bwrap command line is inherently parameter-heavy" )] pub fn wrap_invocation( bridge_program: &str, permissions: SandboxPermissions, writable_dirs: &[&Path], protected_paths: &[&Path], cwd: Option<&Path>, program: &str, args: &[String], proxy_socket_path: Option<&Path>, validation_socket: Option>, ) -> Result<(String, Vec)> { if matches!(permissions.network, NetworkAccess::LocalhostPort(_)) && proxy_socket_path.is_none() { bail!("restricted Linux network access requires a proxy Unix socket path"); } if writable_dirs.len() > MAX_VALIDATED_BINDS { bail!( "too many writable binds to validate ({} > {MAX_VALIDATED_BINDS})", writable_dirs.len() ); } // Every writable path must already exist: `bwrap` can't bind a nonexistent // source, and the command can't create it either (its parent is read-only // inside the sandbox). Callers hand us captured canonical paths whose // inodes were pinned at policy-construction time, so a missing path here // means the location vanished since capture. Never create it with the // agent's ambient permissions — recreating from path text would bind a // fresh, unapproved object in place of the pinned one (and historically // this materialized bogus paths, e.g. a granted *file* path springing into // existence as a directory). Running anyway would give the command silently // less access than it believes it has — so fail closed with a clear error. if !permissions.allow_fs_write { for directory in writable_dirs { if !directory.exists() { bail!( "failed to provide writable sandbox path {}: it no longer exists", directory.display() ); } } } let bwrap = resolve_bwrap().map_err(|status| anyhow!(status.describe()))?; let proxy_socket_sandbox_path = match permissions.network { NetworkAccess::LocalhostPort(_) => Some(unique_proxy_socket_sandbox_path()), NetworkAccess::None | NetworkAccess::All => None, }; let mut bwrap_args = build_bwrap_args_with_sandbox_paths( writable_dirs, protected_paths, permissions, cwd, proxy_socket_path, proxy_socket_sandbox_path.as_deref(), validation_socket.map(|socket| socket.host_socket_path), validation_socket.map(|socket| socket.sandbox_socket_path), ); bwrap_args.push("--".to_string()); let bridge = match permissions.network { NetworkAccess::LocalhostPort(port) => { let proxy_socket_sandbox_path = proxy_socket_sandbox_path .as_ref() .context("missing in-sandbox proxy socket path")?; Some((proxy_socket_sandbox_path.clone(), port)) } NetworkAccess::None | NetworkAccess::All => None, }; // Always route through the in-sandbox launcher, even when there are no // writable binds to validate and no restricted-network bridge: the launcher // is where the seccomp filter is installed on the untrusted command (see // `exec_command` / `run_bridge`). Absent fields are passed as the `-` // sentinel, and `run_launcher` then just installs the filter and `exec`s. bwrap_args.push(bridge_program.to_string()); bwrap_args.push(LAUNCHER_FLAG.to_string()); // Field 1: validation socket (in-sandbox path) or sentinel. bwrap_args.push(match validation_socket { Some(socket) => socket.sandbox_socket_path.to_string_lossy().into_owned(), None => LAUNCHER_NONE.to_string(), }); // Fields 2-3: bridge socket (in-sandbox path) + port, or sentinels. match &bridge { Some((socket, port)) => { bwrap_args.push(socket.to_string_lossy().into_owned()); bwrap_args.push(port.to_string()); } None => { bwrap_args.push(LAUNCHER_NONE.to_string()); bwrap_args.push(LAUNCHER_NONE.to_string()); } } // Field 4: the writable bind-destination paths to validate (count, then // the paths), in the same order the host sends their fds. let validation_paths: &[&Path] = if validation_socket.is_some() { writable_dirs } else { &[] }; bwrap_args.push(validation_paths.len().to_string()); for path in validation_paths { bwrap_args.push(path.to_string_lossy().into_owned()); } bwrap_args.push("--".to_string()); bwrap_args.push(program.to_string()); bwrap_args.extend(args.iter().cloned()); let bwrap = bwrap .to_str() .with_context(|| format!("bwrap path contains invalid UTF-8: {}", bwrap.display()))?; Ok((bwrap.to_string(), bwrap_args)) } /// Handle a possible re-exec of this binary as the in-sandbox launcher (bind /// validator + network bridge). Does not return if it was invoked as one. pub fn run_launcher_if_invoked() { let Some(invocation) = parse_launcher_args(std::env::args_os()) else { return; }; let invocation = match invocation { Ok(invocation) => invocation, Err(error) => { eprintln!("zed: malformed sandbox launcher invocation: {error:#}"); std::process::exit(127); } }; run_launcher(invocation); } /// A decoded in-sandbox launcher invocation (the `--zed-linux-sandbox-launcher` /// re-exec). All fields are produced by the trusted host side and parsed before /// any untrusted command runs. struct LauncherInvocation { /// In-sandbox path of the validation socket, if bind validation is required. validation_socket: Option, /// Writable bind-destination paths to validate, in the order the host sends /// their fds. Empty when validation isn't required. validation_paths: Vec, /// `(in-sandbox proxy socket path, loopback port)` if the restricted-network /// bridge is required. bridge: Option<(PathBuf, u16)>, program: OsString, args: Vec, } fn parse_launcher_args( args: impl IntoIterator, ) -> Option> { let mut args = args.into_iter(); args.next()?; if args.next()?.to_str() != Some(LAUNCHER_FLAG) { return None; } Some(decode_launcher_args(args)) } /// Parse an optional field encoded as either a real value or the `-` sentinel. fn optional_field(value: OsString) -> Option { if value == LAUNCHER_NONE { None } else { Some(value) } } fn decode_launcher_args(mut args: impl Iterator) -> Result { let validation_socket = optional_field(args.next().context("missing validation socket field")?).map(PathBuf::from); let bridge_socket = optional_field(args.next().context("missing bridge socket field")?).map(PathBuf::from); let bridge_port = optional_field(args.next().context("missing bridge port field")?) .map(|value| { value .to_str() .context("bridge port is not valid UTF-8")? .parse::() .context("invalid bridge port") }) .transpose()?; let bridge = match (bridge_socket, bridge_port) { (Some(socket), Some(port)) => Some((socket, port)), (None, None) => None, _ => bail!("bridge socket and port must be set together"), }; let path_count = args .next() .context("missing validation path count")? .to_str() .context("validation path count is not valid UTF-8")? .parse::() .context("invalid validation path count")?; if path_count > MAX_VALIDATED_BINDS { bail!("validation path count {path_count} exceeds {MAX_VALIDATED_BINDS}"); } let mut validation_paths = Vec::with_capacity(path_count); for _ in 0..path_count { validation_paths.push(PathBuf::from( args.next().context("missing validation path")?, )); } let separator = args.next().context("missing launcher argument separator")?; if separator != "--" { bail!("missing launcher argument separator"); } let program = args.next().context("missing program to run")?; let args = args.collect(); Ok(LauncherInvocation { validation_socket, validation_paths, bridge, program, args, }) } /// The in-sandbox launcher entry point. Runs after bwrap's mounts and before the /// real command: it verifies the writable binds weren't redirected, optionally /// starts the restricted-network bridge, then runs the command. Never returns. fn run_launcher(invocation: LauncherInvocation) -> ! { if let Some(socket) = &invocation.validation_socket { if let Err(error) = validate_binds(socket, &invocation.validation_paths) { // Fail closed: a redirected (or unverifiable) writable bind means the // command must not run at all. eprintln!("zed: sandbox bind validation failed: {error:#}"); std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE); } } match invocation.bridge { Some((socket_path, port)) => { run_bridge(socket_path, port, &invocation.program, &invocation.args) } // No bridge to keep alive, so `exec` the command directly rather than // lingering as a parent process. None => exec_command(&invocation.program, &invocation.args), } } /// Verify each writable bind resolves, inside the sandbox, to the exact inode the /// host captured. Receives the captured `O_PATH` fds over `socket_path` via /// `SCM_RIGHTS` (in the same order as `paths`), then compares `fstat(received /// fd)` against `lstat(mounted path)`. Both stats run in this process inside the /// sandbox, so the comparison needs no cross-namespace assumption about device /// numbers. Any mismatch — or any failure to obtain the expected number of fds — /// is an error (the caller fails closed). fn validate_binds(socket_path: &Path, paths: &[PathBuf]) -> Result<()> { let stream = UnixStream::connect(socket_path) .with_context(|| format!("connecting to validation socket {}", socket_path.display()))?; let fds = recv_fds(&stream).context("receiving validation descriptors")?; if fds.len() != paths.len() { bail!( "expected {} validation descriptor(s), received {}", paths.len(), fds.len() ); } for (fd, path) in fds.iter().zip(paths) { let expected = fd_dev_ino(fd.as_raw_fd()) .with_context(|| format!("fstat of captured descriptor for {}", path.display()))?; let mounted = lstat_dev_ino(path) .with_context(|| format!("lstat of mounted bind {}", path.display()))?; if expected != mounted { bail!( "writable bind {} was redirected (captured inode {:?}, mounted inode {:?})", path.display(), expected, mounted ); } } Ok(()) } /// Build the seccomp-BPF program installed on the untrusted command before it /// runs. This is the syscall-level half of preventing session-IPC-socket /// sandbox escapes: a read-only bind mount does not stop `connect()` to a unix /// socket, so instead we stop the command from ever *obtaining* a non-IP socket. /// /// The program (default action: allow): /// - `socket()` is denied (`EPERM`) unless the family is `AF_INET`/`AF_INET6`/ /// `AF_NETLINK` — so no `AF_UNIX` (session IPC) or `AF_VSOCK` (VM host) sockets. /// - `socketpair()` is allowed only for `AF_UNIX` (a process-local pair that /// cannot reach anything outside the sandbox). /// - `io_uring_*` is denied, so its ring ops can't create/connect sockets /// without going through the filtered syscalls. /// - `ptrace`/`process_vm_*` are denied. /// /// `connect`/`recvmsg`/`sendmsg`/`bind`/`listen`/`accept` stay allowed: with no /// way to create a forbidden socket (and — by fd hygiene — no forbidden fd /// inherited), there is nothing dangerous for them to act on, and blocking /// `connect` would break legitimate loopback/proxy use. Foreign-architecture /// syscalls are killed by seccompiler's arch check, closing the 32-bit-ABI /// (`socketcall`) bypass. /// /// Returns `None` on an architecture seccompiler can't target (we ship on /// x86_64/aarch64, so that is not a configuration we run in practice). fn build_command_seccomp_program() -> Result> { use seccompiler::{ BpfProgram, SeccompAction, SeccompCmpArgLen, SeccompCmpOp, SeccompCondition, SeccompFilter, SeccompRule, TargetArch, }; use std::collections::BTreeMap; let Ok(target_arch) = TargetArch::try_from(std::env::consts::ARCH) else { return Ok(None); }; // `socket(domain, ...)`: deny unless `domain` (arg0, an `int` — compare the // low 32 bits) is an allowed IP/netlink family. The rule matches when the // family is none of the allowed ones, and a matched rule takes the deny // action; an allowed family matches no rule and falls through to `Allow`. let socket_deny = SeccompRule::new(vec![ SeccompCondition::new( 0, SeccompCmpArgLen::Dword, SeccompCmpOp::Ne, libc::AF_INET as u64, )?, SeccompCondition::new( 0, SeccompCmpArgLen::Dword, SeccompCmpOp::Ne, libc::AF_INET6 as u64, )?, SeccompCondition::new( 0, SeccompCmpArgLen::Dword, SeccompCmpOp::Ne, libc::AF_NETLINK as u64, )?, ])?; // `socketpair(domain, ...)`: allow only `AF_UNIX`. let socketpair_deny = SeccompRule::new(vec![SeccompCondition::new( 0, SeccompCmpArgLen::Dword, SeccompCmpOp::Ne, libc::AF_UNIX as u64, )?])?; let mut rules: BTreeMap> = BTreeMap::new(); rules.insert(libc::SYS_socket, vec![socket_deny]); rules.insert(libc::SYS_socketpair, vec![socketpair_deny]); // Unconditional denials (an empty rule chain always takes the match action). for syscall in [ libc::SYS_io_uring_setup, libc::SYS_io_uring_enter, libc::SYS_io_uring_register, libc::SYS_ptrace, libc::SYS_process_vm_readv, libc::SYS_process_vm_writev, ] { rules.insert(syscall, Vec::new()); } let filter = SeccompFilter::new( rules, SeccompAction::Allow, SeccompAction::Errno(libc::EPERM as u32), target_arch, ) .context("building sandbox command seccomp filter")?; let program = BpfProgram::try_from(filter).context("compiling sandbox command seccomp filter")?; Ok(Some(program)) } /// Install [`build_command_seccomp_program`] on the calling thread, which is /// about to become (or `exec` into) the untrusted command; the filter survives /// `exec`. `apply_filter` also sets `PR_SET_NO_NEW_PRIVS`. On an unsupported /// architecture there is no filter to install — log and proceed rather than /// break the sandbox on a platform we don't ship. fn install_command_seccomp_filter() -> Result<()> { match build_command_seccomp_program()? { Some(program) => seccompiler::apply_filter(&program) .context("installing sandbox command seccomp filter")?, None => log::warn!( "[sandbox] seccomp is unavailable on {}; the unix-socket syscall guard \ was not installed", std::env::consts::ARCH ), } Ok(()) } /// Replace this process with the sandboxed command. Only returns (after logging) /// if `exec` itself fails. fn exec_command(program: &OsStr, args: &[OsString]) -> ! { // Lock down socket/io_uring/ptrace syscalls right before handing control to // the untrusted command; the filter survives `exec`. if let Err(error) = install_command_seccomp_filter() { eprintln!("zed: failed to install sandbox seccomp filter: {error:#}"); std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE); } let error = Command::new(program).args(args).exec(); eprintln!("zed: failed to exec sandboxed command: {error}"); std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE); } #[allow( clippy::disallowed_methods, reason = "the bridge is an in-sandbox process that must synchronously spawn and wait for the command" )] fn run_bridge(socket_path: PathBuf, port: u16, program: &OsStr, program_args: &[OsString]) -> ! { let listener = match TcpListener::bind((Ipv4Addr::LOCALHOST, port)) { Ok(listener) => listener, Err(error) => { eprintln!("zed: failed to bind sandbox proxy bridge: {error}"); std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE); } }; if let Err(error) = thread::Builder::new() .name("zed-sandbox-bridge".to_string()) .stack_size(128 * 1024) .spawn(move || run_bridge_listener(listener, socket_path)) { eprintln!("zed: failed to spawn sandbox proxy bridge: {error}"); std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE); } // The command runs under the syscall filter, installed in the child via // `pre_exec` — *this* bridge process must NOT be filtered, since it keeps // using `AF_UNIX` to reach the host proxy for every request the command // makes. Build the program before the fork; the child only applies it. let seccomp_program = match build_command_seccomp_program() { Ok(program) => program, Err(error) => { eprintln!("zed: failed to build sandbox seccomp filter: {error:#}"); std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE); } }; let mut command = Command::new(program); command.args(program_args); // SAFETY: the closure runs in the forked child after `fork` and before // `exec`. It only calls `seccompiler::apply_filter` (a `prctl` on a program // built before the fork) — async-signal-safe and allocation-free. unsafe { command.pre_exec(move || { if let Some(program) = &seccomp_program { seccompiler::apply_filter(program) .map_err(|error| std::io::Error::other(format!("seccomp: {error}")))?; } Ok(()) }); } let mut child = match command.spawn() { Ok(child) => child, Err(error) => { eprintln!("zed: failed to spawn sandboxed command: {error}"); std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE); } }; match child.wait() { Ok(status) => { if let Some(code) = status.code() { std::process::exit(code); } let signal = status.signal().unwrap_or(1); std::process::exit(128 + signal); } Err(error) => { eprintln!("zed: failed to wait for sandboxed command: {error}"); std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE); } } } /// Send `fds` over `stream` in a single message carrying one byte of payload and /// the descriptors as `SCM_RIGHTS` ancillary data. fn send_fds(stream: &UnixStream, fds: &[RawFd]) -> std::io::Result<()> { use nix::sys::socket::{ControlMessage, MsgFlags, sendmsg}; let payload = [0u8; 1]; let iov = [std::io::IoSlice::new(&payload)]; let control = [ControlMessage::ScmRights(fds)]; sendmsg::<()>( stream.as_raw_fd(), &iov, &control, MsgFlags::MSG_NOSIGNAL, None, ) .map_err(std::io::Error::from)?; Ok(()) } /// Receive the descriptors sent via `SCM_RIGHTS` on `stream`. They arrive with /// `O_CLOEXEC` (via `MSG_CMSG_CLOEXEC`) so they don't leak into the command /// that's `exec`'d after validation. The ancillary buffer is sized for the /// protocol maximum ([`MAX_VALIDATED_BINDS`]); a sender exceeding it trips /// `MSG_CTRUNC` and a mismatched count is rejected by the caller. fn recv_fds(stream: &UnixStream) -> std::io::Result> { use nix::sys::socket::{ControlMessageOwned, MsgFlags, recvmsg}; let mut payload = [0u8; 1]; let mut iov = [std::io::IoSliceMut::new(&mut payload)]; let mut control = nix::cmsg_space!([RawFd; MAX_VALIDATED_BINDS]); let message = recvmsg::<()>( stream.as_raw_fd(), &mut iov, Some(&mut control), MsgFlags::MSG_CMSG_CLOEXEC, ) .map_err(std::io::Error::from)?; if message.flags.contains(MsgFlags::MSG_CTRUNC) { return Err(std::io::Error::other( "validation descriptors were truncated in transit", )); } let mut fds = Vec::new(); for control_message in message.cmsgs().map_err(std::io::Error::from)? { if let ControlMessageOwned::ScmRights(raw_fds) = control_message { for raw in raw_fds { // SAFETY: the kernel installs each `SCM_RIGHTS` descriptor as a // freshly-allocated, open fd in *our* table for this message // (with `O_CLOEXEC` via `MSG_CMSG_CLOEXEC`), so it's valid and // can't alias an fd we already hold. `nix`'s `ScmRights` does not // take ownership of received fds (closing them is the caller's // responsibility), so wrapping each exactly once here makes us // their sole owner. Both hold for any peer, which is why this // (and `recv_fds`) is sound as a safe fn rather than needing an // `unsafe fn` precondition on the caller. fds.push(unsafe { OwnedFd::from_raw_fd(raw) }); } } } Ok(fds) } /// `(device, inode)` of the object an already-open descriptor refers to. fn fd_dev_ino(fd: RawFd) -> std::io::Result<(u64, u64)> { let stat = nix::sys::stat::fstat(fd).map_err(std::io::Error::from)?; Ok((stat.st_dev as u64, stat.st_ino as u64)) } /// `(device, inode)` of `path` without following a final symlink. fn lstat_dev_ino(path: &Path) -> std::io::Result<(u64, u64)> { // `symlink_metadata` is `lstat(2)` (it does not follow a final symlink). let metadata = std::fs::symlink_metadata(path)?; Ok((metadata.dev(), metadata.ino())) } /// A decoded WSL-helper invocation (`--wsl-sandbox-helper`). All fields are /// produced by the trusted Windows side and parsed before any untrusted command /// runs. struct WslHelperInvocation { /// Absolute path of `bwrap` inside WSL (located by the Windows-side probe). bwrap_path: PathBuf, /// Pre-built bwrap *options* — everything before the trailing `-- cmd` — /// assembled on the Windows side (`windows_wsl::build_bwrap_args`): root /// bind, `/tmp` tmpfs, writable binds, Windows-interop blocking, `--setenv`s, /// `--chdir`, namespace flags, etc. base_args: Vec, /// The writable bind destinations to validate — exactly the ones `base_args` /// binds read-write. Each is captured here (WSL-side) and checked in-sandbox. writable_paths: Vec, program: OsString, args: Vec, } /// Handle a possible re-exec of this binary as the WSL-side sandbox helper. Does /// not return if it was invoked as one. pub fn run_wsl_helper_if_invoked() { let args: Vec = std::env::args_os().collect(); if args.get(1).and_then(|arg| arg.to_str()) == Some(WSL_RESOLVE_FLAG) { let Some(path) = args.get(2) else { eprintln!("zed: malformed WSL canonical-path resolver invocation"); std::process::exit(127); }; if args.len() != 3 { eprintln!("zed: malformed WSL canonical-path resolver invocation"); std::process::exit(127); } match crate::util::CanonicalPathBuf::resolve(PathBuf::from(path.as_os_str())) { Ok(canonical) => { let Some(canonical) = canonical.path().to_str() else { eprintln!("zed: canonical WSL sandbox grant is not valid UTF-8"); std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE); }; let Ok(distro) = std::env::var("WSL_DISTRO_NAME") else { eprintln!("zed: WSL_DISTRO_NAME is unavailable while resolving sandbox grant"); std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE); }; // Classify the backing filesystem of the *canonical* target (the // path that will actually be bound), so the Windows side can // persist whether this grant lives on a Windows-hosted (DrvFs) // filesystem, whose sandbox-integrity guarantees are weaker. let fs_class = if path_is_on_windows_fs(Path::new(canonical)) { "windows-fs" } else { "native-fs" }; println!("{distro}"); println!("{canonical}"); println!("{fs_class}"); std::process::exit(0); } Err(error) => { eprintln!("zed: could not resolve WSL sandbox grant: {error}"); std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE); } } } let Some(invocation) = parse_wsl_helper_args(args) else { return; }; let invocation = match invocation { Ok(invocation) => invocation, Err(error) => { eprintln!("zed: malformed WSL sandbox helper invocation: {error:#}"); std::process::exit(127); } }; run_wsl_helper(invocation); } /// Whether `path` resides on a Windows-hosted filesystem exposed to WSL via /// DrvFs. Inside WSL the Windows drives (`/mnt/`) are served by either /// 9p or virtiofs, whereas the distro's own filesystems are native (ext4/btrfs/ /// overlay/tmpfs). We use the backing filesystem type rather than a `/mnt/` /// path prefix so a reconfigured automount root, or a symlink that lands on a /// Windows drive, is still classified correctly. Fails safe: if the type can't /// be determined we report `true` (weaker guarantees ⇒ warn) rather than /// silently treating it as native. fn path_is_on_windows_fs(path: &Path) -> bool { // `statfs.f_type` magics for the DrvFs transports. virtiofs is FUSE-backed. const V9FS_MAGIC: u64 = 0x0102_1997; const FUSE_SUPER_MAGIC: u64 = 0x6573_5546; match nix::sys::statfs::statfs(path) { Ok(stat) => { // `f_type` is `i64` on glibc but `u64` on musl, so widen it // losslessly instead of comparing against a fixed-width constant. u64::try_from(stat.filesystem_type().0) .is_ok_and(|fs_type| fs_type == V9FS_MAGIC || fs_type == FUSE_SUPER_MAGIC) } Err(_) => true, } } fn parse_wsl_helper_args( args: impl IntoIterator, ) -> Option> { let mut args = args.into_iter(); args.next()?; if args.next()?.to_str() != Some(WSL_HELPER_FLAG) { return None; } Some(decode_wsl_helper_args(args)) } fn decode_wsl_helper_args(mut args: impl Iterator) -> Result { let bwrap_path = PathBuf::from(args.next().context("missing bwrap path")?); let base_count = parse_count( args.next().context("missing base-arg count")?, "base-arg count", )?; let mut base_args = Vec::with_capacity(base_count); for _ in 0..base_count { base_args.push(args.next().context("missing base arg")?); } let writable_count = parse_count( args.next().context("missing writable count")?, "writable count", )?; if writable_count > MAX_VALIDATED_BINDS { bail!("writable count {writable_count} exceeds {MAX_VALIDATED_BINDS}"); } let mut writable_paths = Vec::with_capacity(writable_count); for _ in 0..writable_count { writable_paths.push(PathBuf::from(args.next().context("missing writable path")?)); } let separator = args.next().context("missing argument separator")?; if separator != "--" { bail!("missing argument separator"); } let program = args.next().context("missing program to run")?; let args = args.collect(); Ok(WslHelperInvocation { bwrap_path, base_args, writable_paths, program, args, }) } fn parse_count(value: OsString, what: &str) -> Result { value .to_str() .with_context(|| format!("{what} is not valid UTF-8"))? .parse::() .with_context(|| format!("invalid {what}")) } /// The WSL-side helper entry point. Mirrors the native-Linux host side: capture /// the writable binds' inodes (here, inside WSL), stand up the validation socket, /// finish assembling the bwrap invocation (validation socket bind + in-sandbox /// validator re-exec), spawn bwrap, and propagate its exit. Never returns. #[allow( clippy::disallowed_methods, reason = "the WSL helper is a dedicated per-command process that must spawn and wait for bwrap" )] fn run_wsl_helper(invocation: WslHelperInvocation) -> ! { let mut fds = Vec::with_capacity(invocation.writable_paths.len()); for path in &invocation.writable_paths { let fd = crate::util::CanonicalPathBuf::from_canonical(path.clone()) .and_then(|canonical| canonical.dup_fd()); match fd { Ok(fd) => fds.push(fd), Err(error) => { eprintln!( "zed: WSL sandbox helper could not verify canonical writable bind {}: {error}", path.display() ); std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE); } } } let validation = if fds.is_empty() { None } else { match ValidationFdSender::spawn(fds) { Ok(sender) => Some(sender), Err(error) => { eprintln!("zed: WSL sandbox helper could not start the bind validator: {error}"); std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE); } } }; let current_exe = match std::env::current_exe() { Ok(path) => path, Err(error) => { eprintln!("zed: WSL sandbox helper could not resolve its own path: {error}"); std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE); } }; let mut args = invocation.base_args.clone(); // Always re-exec ourselves as the in-sandbox launcher, even when there is // nothing to validate: the launcher is where the seccomp filter is installed // on the untrusted command (see `exec_command`). When there are writable // binds, also bind the validation socket so the launcher can verify them. // WSL has no restricted-network bridge, so both bridge fields are the absent // sentinel. if let Some(sender) = &validation { // Bind the validation socket in, after the base args' tmpfs and writable // binds so it isn't shadowed. args.push(OsString::from("--bind")); args.push(sender.host_socket_path().as_os_str().to_os_string()); args.push(sender.sandbox_socket_path().as_os_str().to_os_string()); } args.push(OsString::from("--")); args.push(current_exe.into_os_string()); args.push(OsString::from(LAUNCHER_FLAG)); // Field 1: validation socket (in-sandbox path) or sentinel. match &validation { Some(sender) => args.push(sender.sandbox_socket_path().as_os_str().to_os_string()), None => args.push(OsString::from(LAUNCHER_NONE)), } // Fields 2-3: bridge socket + port (WSL has no bridge). args.push(OsString::from(LAUNCHER_NONE)); args.push(OsString::from(LAUNCHER_NONE)); // Field 4: writable bind-destination paths to validate (count, then paths); // empty when there is nothing to validate. let validation_paths: &[PathBuf] = if validation.is_some() { &invocation.writable_paths } else { &[] }; args.push(OsString::from(validation_paths.len().to_string())); for path in validation_paths { args.push(path.clone().into_os_string()); } args.push(OsString::from("--")); args.push(invocation.program.clone()); args.extend(invocation.args.iter().cloned()); let mut child = match Command::new(&invocation.bwrap_path).args(&args).spawn() { Ok(child) => child, Err(error) => { eprintln!("zed: WSL sandbox helper could not spawn bwrap: {error}"); std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE); } }; let status = child.wait(); // Hold the validator open until bwrap (and thus the in-sandbox validator that // connects to it during startup) has finished. drop(validation); match status { Ok(status) => { if let Some(code) = status.code() { std::process::exit(code); } let signal = status.signal().unwrap_or(1); std::process::exit(128 + signal); } Err(error) => { eprintln!("zed: WSL sandbox helper failed waiting for bwrap: {error}"); std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE); } } } fn run_bridge_listener(listener: TcpListener, socket_path: PathBuf) { for stream in listener.incoming() { match stream { Ok(stream) => { let socket_path = socket_path.clone(); if let Err(error) = thread::Builder::new() .name("zed-sandbox-bridge-conn".to_string()) .stack_size(128 * 1024) .spawn(move || forward_bridge_connection(stream, socket_path)) { eprintln!("zed: failed to spawn sandbox bridge connection thread: {error}"); } } Err(error) => eprintln!("zed: sandbox bridge accept failed: {error}"), } } } fn forward_bridge_connection(tcp_stream: TcpStream, socket_path: PathBuf) { let unix_stream = match UnixStream::connect(&socket_path) { Ok(stream) => stream, Err(error) => { eprintln!( "zed: sandbox bridge failed to connect to proxy socket {}: {error}", socket_path.display() ); return; } }; copy_bidirectional(tcp_stream, unix_stream); } fn copy_bidirectional(tcp_stream: TcpStream, unix_stream: UnixStream) { let tcp_read = match tcp_stream.try_clone() { Ok(stream) => stream, Err(error) => { eprintln!("zed: sandbox bridge failed to clone TCP stream: {error}"); return; } }; let unix_read = match unix_stream.try_clone() { Ok(stream) => stream, Err(error) => { eprintln!("zed: sandbox bridge failed to clone Unix stream: {error}"); return; } }; let tcp_write = tcp_stream; let unix_write = unix_stream; let to_proxy = match thread::Builder::new() .name("zed-sandbox-bridge-out".to_string()) .stack_size(128 * 1024) .spawn(move || copy_one_way(tcp_read, unix_write)) { Ok(handle) => handle, Err(error) => { eprintln!("zed: failed to spawn sandbox bridge pump thread: {error}"); return; } }; copy_one_way(unix_read, tcp_write); if to_proxy.join().is_err() { eprintln!("zed: sandbox bridge pump thread panicked"); } } trait BridgeStream: Read + Write { fn shutdown(&self, how: Shutdown) -> std::io::Result<()>; } impl BridgeStream for TcpStream { fn shutdown(&self, how: Shutdown) -> std::io::Result<()> { TcpStream::shutdown(self, how) } } impl BridgeStream for UnixStream { fn shutdown(&self, how: Shutdown) -> std::io::Result<()> { UnixStream::shutdown(self, how) } } fn copy_one_way(mut from: impl Read, mut to: impl BridgeStream) { let mut buffer = vec![0; PUMP_BUFFER_SIZE]; loop { let count = match from.read(&mut buffer) { Ok(0) => break, Ok(count) => count, Err(_) => break, }; if to.write_all(&buffer[..count]).is_err() { break; } } let _ = to.shutdown(Shutdown::Write); } #[cfg(test)] mod tests { use super::*; fn launcher_argv(program: &str, args: Vec<&str>) -> Vec { std::iter::once(program) .chain(args) .map(OsString::from) .collect() } #[test] fn test_build_bwrap_args_binds_exact_path_never_widens_to_ancestor() { // A requested writable path that doesn't exist must NOT be bound, and in // particular must never cause an existing ancestor (here the tempdir) to // be bound read-write — that was a sandbox-escape (scope widening). let dir = tempfile::tempdir().unwrap(); let existing = dir.path().canonicalize().unwrap(); let missing = existing.join("does-not-exist").join("nested"); let args = build_bwrap_args( &[missing.as_path()], &[], SandboxPermissions::default(), None, None, ); let existing_str = existing.to_string_lossy().into_owned(); assert!( !windows_contains(&args, &["--bind", &existing_str, &existing_str]), "a missing writable path must not widen the bind to an existing ancestor: {args:?}" ); let missing_str = missing.to_string_lossy().into_owned(); assert!( !windows_contains(&args, &["--bind", &missing_str, &missing_str]), "a missing writable path must not be bound: {args:?}" ); } #[test] fn test_build_bwrap_args_default_binds_writable_dirs_read_write() { let writable = tempfile::tempdir().unwrap(); let args = build_bwrap_args( &[writable.path()], &[], SandboxPermissions::default(), Some(writable.path()), None, ); assert!(windows_contains(&args, &["--ro-bind", "/", "/"])); // The writable dir is bound verbatim at the exact path given (never // re-canonicalized, which would reopen the bind-source TOCTOU gap). let writable_str = writable.path().to_string_lossy().into_owned(); assert!(windows_contains( &args, &["--bind", &writable_str, &writable_str] )); assert!(windows_contains(&args, &["--tmpfs", "/tmp"])); assert!(args.iter().any(|arg| arg == "--chdir")); assert!(args.iter().any(|arg| arg == "--unshare-net")); } #[test] fn test_build_bwrap_args_network_namespace_follows_permission() { let denied = build_bwrap_args(&[], &[], SandboxPermissions::default(), None, None); assert!(denied.iter().any(|arg| arg == "--unshare-net")); let allowed = build_bwrap_args( &[], &[], SandboxPermissions { network: NetworkAccess::All, allow_fs_write: false, }, None, None, ); assert!(!allowed.iter().any(|arg| arg == "--unshare-net")); let socket = PathBuf::from("/tmp/zed-proxy.sock"); let restricted = build_bwrap_args( &[], &[], SandboxPermissions { network: NetworkAccess::LocalhostPort(8080), allow_fs_write: false, }, None, Some(socket.as_path()), ); assert!(restricted.iter().any(|arg| arg == "--unshare-net")); let sandbox_destination = proxy_socket_bind_destination(&restricted) .expect("restricted run should bind the proxy socket into the sandbox"); assert!(sandbox_destination.starts_with(PROXY_SOCKET_SANDBOX_PATH_PREFIX)); } #[test] fn test_build_bwrap_args_allow_fs_write_binds_root_read_write() { let permissions = SandboxPermissions { network: NetworkAccess::None, allow_fs_write: true, }; let protected = Path::new("/tmp"); let args = build_bwrap_args(&[], &[protected], permissions, None, None); assert!(windows_contains(&args, &["--bind", "/", "/"])); assert!(!windows_contains(&args, &["--ro-bind", "/", "/"])); assert!(windows_contains(&args, &["--ro-bind", "/tmp", "/tmp"])); assert!(!windows_contains(&args, &["--tmpfs", "/tmp"])); } #[test] fn test_launcher_args_round_trip_bridge_and_validation() { let bridge_socket = "/tmp/zed-sandbox-1234-0.sock"; let validate_socket = "/tmp/zed-sandbox-validate-1234-0.sock"; let argv = launcher_argv( "/path/to/zed", vec![ LAUNCHER_FLAG, validate_socket, bridge_socket, "8080", "2", "/work/a", "/work/b", "--", "/bin/sh", "-c", "echo hi there", ], ); let decoded = parse_launcher_args(argv) .expect("should be recognized as launcher invocation") .expect("should decode successfully"); assert_eq!( decoded.validation_socket, Some(PathBuf::from(validate_socket)) ); assert_eq!( decoded.validation_paths, vec![PathBuf::from("/work/a"), PathBuf::from("/work/b")] ); assert_eq!( decoded.bridge, Some((PathBuf::from(bridge_socket), 8080u16)) ); assert_eq!(decoded.program, OsString::from("/bin/sh")); assert_eq!( decoded.args, vec![OsString::from("-c"), OsString::from("echo hi there")] ); } #[test] fn test_wsl_helper_args_round_trip() { let argv = launcher_argv( "/path/to/zed", vec![ WSL_HELPER_FLAG, "/usr/bin/bwrap", // base bwrap options (3 tokens) "3", "--ro-bind", "/", "/", // writable binds to validate (1) "1", "/work/a", "--", "/bin/sh", "-c", "echo hi", ], ); let decoded = parse_wsl_helper_args(argv) .expect("should be recognized as a WSL helper invocation") .expect("should decode successfully"); assert_eq!(decoded.bwrap_path, PathBuf::from("/usr/bin/bwrap")); assert_eq!( decoded.base_args, vec![ OsString::from("--ro-bind"), OsString::from("/"), OsString::from("/") ] ); assert_eq!(decoded.writable_paths, vec![PathBuf::from("/work/a")]); assert_eq!(decoded.program, OsString::from("/bin/sh")); assert_eq!( decoded.args, vec![OsString::from("-c"), OsString::from("echo hi")] ); } #[test] fn test_launcher_args_round_trip_no_bridge() { let validate_socket = "/tmp/zed-sandbox-validate-1234-0.sock"; let argv = launcher_argv( "/path/to/zed", vec![ LAUNCHER_FLAG, validate_socket, LAUNCHER_NONE, LAUNCHER_NONE, "1", "/work/a", "--", "/bin/true", ], ); let decoded = parse_launcher_args(argv) .expect("should be recognized as launcher invocation") .expect("should decode successfully"); assert_eq!( decoded.validation_socket, Some(PathBuf::from(validate_socket)) ); assert_eq!(decoded.validation_paths, vec![PathBuf::from("/work/a")]); assert_eq!(decoded.bridge, None); assert_eq!(decoded.program, OsString::from("/bin/true")); assert!(decoded.args.is_empty()); } #[test] fn test_wrap_invocation_uses_bridge_for_restricted_network() { let socket = PathBuf::from("/tmp/zed-proxy.sock"); let permissions = SandboxPermissions { network: NetworkAccess::LocalhostPort(8080), allow_fs_write: false, }; let args = build_wrapped_args_for_test( "/path/to/zed", permissions, "/bin/sh", &["-c".to_string(), "echo hi".to_string()], Some(socket.as_path()), ); // The bind destination inside the sandbox and the path handed to the // launcher's bridge fields must be the same unique path. With no writable // binds, the validation field is the `-` sentinel and the path count is 0. let sandbox_destination = proxy_socket_bind_destination(&args) .expect("restricted run should bind the proxy socket into the sandbox"); assert!(sandbox_destination.starts_with(PROXY_SOCKET_SANDBOX_PATH_PREFIX)); assert!(windows_contains( &args, &[ "/path/to/zed", LAUNCHER_FLAG, LAUNCHER_NONE, &sandbox_destination, "8080", "0", "--", ] )); } /// Reconstruct the argv `wrap_invocation` would produce for the bridge-only /// (no writable binds, no validation socket) case, without needing a real /// `bwrap` on the test host. fn build_wrapped_args_for_test( bridge_program: &str, permissions: SandboxPermissions, program: &str, program_args: &[String], proxy_socket_path: Option<&Path>, ) -> Vec { let proxy_socket_sandbox_path = match permissions.network { NetworkAccess::LocalhostPort(_) => Some(unique_proxy_socket_sandbox_path()), NetworkAccess::None | NetworkAccess::All => None, }; let mut bwrap_args = build_bwrap_args_with_sandbox_paths( &[], &[], permissions, None, proxy_socket_path, proxy_socket_sandbox_path.as_deref(), None, None, ); bwrap_args.push("--".to_string()); if let NetworkAccess::LocalhostPort(port) = permissions.network { let proxy_socket_sandbox_path = proxy_socket_sandbox_path.expect("restricted network needs a sandbox socket path"); bwrap_args.push(bridge_program.to_string()); bwrap_args.push(LAUNCHER_FLAG.to_string()); bwrap_args.push(LAUNCHER_NONE.to_string()); bwrap_args.push(proxy_socket_sandbox_path.to_string_lossy().into_owned()); bwrap_args.push(port.to_string()); bwrap_args.push("0".to_string()); bwrap_args.push("--".to_string()); } bwrap_args.push(program.to_string()); bwrap_args.extend(program_args.iter().cloned()); bwrap_args } /// Returns the in-sandbox destination of the proxy socket `--bind`, if any. fn proxy_socket_bind_destination(args: &[String]) -> Option { args.windows(3).find_map(|window| { if window[0] == "--bind" && window[1] == "/tmp/zed-proxy.sock" { Some(window[2].clone()) } else { None } }) } fn windows_contains(haystack: &[String], needle: &[&str]) -> bool { haystack .windows(needle.len()) .any(|window| window.iter().map(String::as_str).eq(needle.iter().copied())) } /// Open an `O_PATH` descriptor to `path`, mirroring how the policy layer /// captures a `HostFilesystemLocation`. fn open_o_path(path: &Path) -> OwnedFd { use std::os::unix::fs::OpenOptionsExt as _; let file = std::fs::OpenOptions::new() .read(true) .custom_flags(libc::O_PATH | libc::O_CLOEXEC) .open(path) .expect("open O_PATH"); OwnedFd::from(file) } // End-to-end check of the bind validator's core, without a real sandbox: // the server hands over the captured fd via SCM_RIGHTS and `validate_binds` // compares it against the path it's told was mounted. A matching path passes; // a *different* directory (as a redirected bind would produce) is rejected, // proving the validator fails closed rather than no-ops. // // Each `ValidationFdSender` serves a single client and then tears down, so // the two cases use separate senders. #[test] fn test_validate_binds_accepts_match_and_rejects_redirect() { let captured = tempfile::tempdir().unwrap(); let other = tempfile::tempdir().unwrap(); // The mounted path is the captured inode -> validation passes. let sender = ValidationFdSender::spawn(vec![open_o_path(captured.path())]) .expect("spawn validation fd sender"); validate_binds(sender.host_socket_path(), &[captured.path().to_path_buf()]) .expect("matching bind must validate"); drop(sender); // The mounted path is a *different* inode (a redirected bind) -> rejected. let sender = ValidationFdSender::spawn(vec![open_o_path(captured.path())]) .expect("spawn validation fd sender"); let error = validate_binds(sender.host_socket_path(), &[other.path().to_path_buf()]) .expect_err("a redirected bind must be rejected"); assert!( error.to_string().contains("redirected"), "unexpected error: {error:#}" ); } // A wrong fd count from the server (here: zero fds for one path) must fail // closed too — the validator never assumes an unvalidated bind is fine. #[test] fn test_validate_binds_rejects_missing_descriptors() { let captured = tempfile::tempdir().unwrap(); let sender = ValidationFdSender::spawn(Vec::new()).expect("spawn validation fd sender"); let error = validate_binds(sender.host_socket_path(), &[captured.path().to_path_buf()]) .expect_err("missing descriptors must be rejected"); assert!( error.to_string().contains("descriptor"), "unexpected error: {error:#}" ); } // The filter compiles to a non-empty BPF program on architectures // seccompiler can target (the ones we ship). On others it's `None`, which is // acceptable — no filter is installed there. #[test] fn test_command_seccomp_program_builds() { let program = build_command_seccomp_program().expect("build seccomp program"); if let Some(program) = program { assert!(!program.is_empty(), "seccomp program must not be empty"); } } // Actually enforce the filter: in a child process, apply it and confirm that // `socket(AF_UNIX)` is denied while `socket(AF_INET)` and // `socketpair(AF_UNIX)` still work — the exact guarantee that closes the // unix-socket sandbox escape. #[test] fn test_command_seccomp_filter_blocks_unix_but_allows_ip() { // Build in the parent (this allocates); the child only applies the // prebuilt program and makes raw syscalls. let Some(program) = build_command_seccomp_program().expect("build seccomp program") else { return; // unsupported arch: nothing to enforce }; // SAFETY: after `fork`, the child calls only async-signal-safe // libc/`prctl` (via `apply_filter` on a program built before the fork) // and `_exit`; it never returns to Rust or allocates. let pid = unsafe { libc::fork() }; assert!(pid >= 0, "fork failed"); if pid == 0 { if seccompiler::apply_filter(&program).is_err() { unsafe { libc::_exit(10) }; } // AF_UNIX socket creation must be denied. if unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_STREAM, 0) } >= 0 { unsafe { libc::_exit(11) }; } // AF_INET socket creation must still work. let inet = unsafe { libc::socket(libc::AF_INET, libc::SOCK_STREAM, 0) }; if inet < 0 { unsafe { libc::_exit(12) }; } // AF_UNIX socketpair (process-local) must still work. let mut fds = [0i32; 2]; if unsafe { libc::socketpair(libc::AF_UNIX, libc::SOCK_STREAM, 0, fds.as_mut_ptr()) } != 0 { unsafe { libc::_exit(13) }; } unsafe { libc::_exit(0) }; } let mut status = 0i32; let waited = unsafe { libc::waitpid(pid, &mut status, 0) }; assert_eq!(waited, pid, "waitpid failed"); assert!( libc::WIFEXITED(status), "child did not exit normally: {status:#x}" ); let code = libc::WEXITSTATUS(status); assert_eq!( code, 0, "child reported a seccomp mismatch (exit {code}): 10=apply failed, \ 11=AF_UNIX allowed, 12=AF_INET blocked, 13=socketpair blocked" ); } // A requested writable path that doesn't exist must fail the whole // invocation — never be created with the agent's ambient permissions, and // never run the command with silently less write access than the agent // asked for. This check runs before `resolve_bwrap`, so the test needs no // real `bwrap`. #[test] fn test_wrap_invocation_fails_when_writable_path_cannot_be_provided() { let file = tempfile::NamedTempFile::new().unwrap(); let unbindable = file.path().join("subdir"); let result = wrap_invocation( "/proc/self/exe", SandboxPermissions { network: NetworkAccess::None, allow_fs_write: false, }, &[unbindable.as_path()], &[], None, "/bin/true", &[], None, None, ); let error = result.expect_err("must fail closed when a writable path can't be provided"); assert!( error.to_string().contains("writable sandbox path"), "unexpected error: {error:#}" ); assert!( !unbindable.exists(), "wrap_invocation must never create a missing writable path" ); } }