//! 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:<port>` 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<PathBuf> { 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<PathBuf> { 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<PathBuf> { 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 `-- <command>`) 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<String> { 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<String> { 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<AtomicBool>,} 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<OwnedFd>) -> std::io::Result<Self> { 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<RawFd> = 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_pathLines 1–500 of 1978. Load more
//! 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:<port>` 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<PathBuf> { 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<PathBuf> { 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<PathBuf> { 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 `-- <command>`) 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<String> { 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<String> { 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<AtomicBool>, } 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<OwnedFd>) -> std::io::Result<Self> { 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<RawFd> = 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