Files
zed/crates/sandbox/src/linux_bubblewrap.rs
T
Cameron Mcloughlin fcde9d2384 sadbox: Fix bundling (#61873)
The type of this value varies across glibc and musl, this works on both

---

Release Notes:

- N/A or Added/Fixed/Improved ...
2026-07-29 16:40:38 +00:00

1979 lines
77 KiB
Rust

//! 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
}
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<String>, 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<PathBuf, LauncherStatus> {
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<String>), 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<ValidationSocket<'_>>,
) -> Result<(String, Vec<String>)> {
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<PathBuf>,
/// Writable bind-destination paths to validate, in the order the host sends
/// their fds. Empty when validation isn't required.
validation_paths: Vec<PathBuf>,
/// `(in-sandbox proxy socket path, loopback port)` if the restricted-network
/// bridge is required.
bridge: Option<(PathBuf, u16)>,
program: OsString,
args: Vec<OsString>,
}
fn parse_launcher_args(
args: impl IntoIterator<Item = OsString>,
) -> Option<Result<LauncherInvocation>> {
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<OsString> {
if value == LAUNCHER_NONE {
None
} else {
Some(value)
}
}
fn decode_launcher_args(mut args: impl Iterator<Item = OsString>) -> Result<LauncherInvocation> {
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::<u16>()
.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::<usize>()
.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<Option<seccompiler::BpfProgram>> {
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<i64, Vec<SeccompRule>> = 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<Vec<OwnedFd>> {
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<OsString>,
/// 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<PathBuf>,
program: OsString,
args: Vec<OsString>,
}
/// 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<OsString> = 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/<letter>`) 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<Item = OsString>,
) -> Option<Result<WslHelperInvocation>> {
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<Item = OsString>) -> Result<WslHelperInvocation> {
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<usize> {
value
.to_str()
.with_context(|| format!("{what} is not valid UTF-8"))?
.parse::<usize>()
.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<OsString> {
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<String> {
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<String> {
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"
);
}
}