Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T02:33:24.640Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

linux_bubblewrap.rs

1924 lines · 76.2 KB · rust
1//! Linux sandbox integration built on Bubblewrap (`bwrap`) for filesystem and
2//! network confinement.
3//!
4//! We can use `--bind` and `--ro-bind` (read-only) to bind host filesystem
5//! paths to paths in the sandbox. If networking is restricted, we also set
6//! `--unshare-net` to disable *all* network access.
7//!
8//! When restricting network access, we:
9//! - set `--unshare-net` - any requests to `example.com` will fail
10//!   - requests to `localhost` will succeed, but it will be an isolated localhost
11//!     from the host system.
12//! - create a unix socket, and mount it in the sandbox with `--bind`
13//! - run a bridge process inside the sandbox that:
14//!   - listens on `localhost:<port>` and forwards reads/writes to the socket
15//!   - then, runs the untrusted command
16//! - on the zed side, we listen to the socket and forward reads/writes to the
17//!   internal HTTP proxy
18//!
19//! If networking is fully blocked or fully allowed, we don't bother with the
20//! proxy/socket at all (and simply set/unset `--unshare-net`).
21//!
22//! This design for networking avoids needing seccomp, a fork/exec dance, and
23//! eliminates a race condition involving BPF user notifications.
24
25use anyhow::{Context as _, Result, anyhow, bail};
26use std::ffi::{OsStr, OsString};
27use std::io::{Read, Write};
28use std::net::{Ipv4Addr, Shutdown, TcpListener, TcpStream};
29use std::os::fd::{AsRawFd as _, FromRawFd as _, OwnedFd, RawFd};
30use std::os::unix::fs::MetadataExt as _;
31use std::os::unix::net::{UnixListener, UnixStream};
32use std::os::unix::process::{CommandExt as _, ExitStatusExt as _};
33use std::path::{Path, PathBuf};
34use std::process::{Command, Stdio};
35use std::sync::Arc;
36use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
37use std::thread;
38
39/// Re-exec marker for the in-sandbox launcher: it runs inside the sandbox before
40/// the real command to (a) validate that bwrap bound the writable grants to the
41/// inodes we captured (the bind-source TOCTOU backstop) and (b) run the
42/// restricted-network HTTP bridge. See `README.md` for the design.
43const LAUNCHER_FLAG: &str = "--zed-linux-sandbox-launcher";
44/// Re-exec marker for the WSL-side helper. This runs *inside WSL* (a Linux
45/// process) and does what `Sandbox::wrap` + the validation-fd sender do
46/// in-process on native Linux: capture the writable binds' `O_PATH` fds, stand
47/// up the validation socket, assemble the bwrap invocation, and spawn it. The
48/// capture must happen WSL-side because a Windows process holds no Linux fds.
49/// See `README.md`. Shared with the Windows side via `crate::WSL_SANDBOX_HELPER_FLAG`.
50const WSL_HELPER_FLAG: &str = crate::WSL_SANDBOX_HELPER_FLAG;
51/// Sentinel argv token meaning "this optional field is absent".
52const LAUNCHER_NONE: &str = "-";
53const PROXY_SOCKET_SANDBOX_PATH_PREFIX: &str = "/tmp/zed-sandbox";
54const VALIDATION_SOCKET_SANDBOX_PATH_PREFIX: &str = "/tmp/zed-sandbox-validate";
55const SANDBOX_SETUP_FAILED_EXIT_CODE: i32 = 126;
56const PUMP_BUFFER_SIZE: usize = 64 * 1024;
57/// Upper bound on writable binds validated in a single `SCM_RIGHTS` message,
58/// kept comfortably below the kernel's per-message fd limit (`SCM_MAX_FD`, 253).
59/// Exceeding it fails closed rather than silently validating a subset.
60const MAX_VALIDATED_BINDS: usize = 200;
61
62/// Network-access setting for a sandboxed command.
63///
64/// Mirrors [`crate::macos_seatbelt::NetworkAccess`] so Linux and macOS expose
65/// the same public shape.
66#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
67pub enum NetworkAccess {
68    /// All outbound network blocked (own network namespace).
69    #[default]
70    None,
71    /// Outbound HTTP(S) is available through a loopback proxy port inside the
72    /// sandbox. The port is bridged to a host-side Unix socket proxy that
73    /// enforces the hostname allowlist.
74    LocalhostPort(u16),
75    /// All outbound network allowed.
76    All,
77}
78
79/// Per-command relaxations of the default Bubblewrap (Linux) sandbox.
80///
81/// Mirrors [`crate::macos_seatbelt::SandboxPermissions`].
82#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
83pub struct SandboxPermissions {
84    /// Network access policy for the command.
85    pub network: NetworkAccess,
86    /// Allow unrestricted filesystem writes.
87    pub allow_fs_write: bool,
88}
89
90/// The outcome of preparing a Linux sandbox.
91#[derive(Clone, Copy, Debug, Eq, PartialEq)]
92pub enum LauncherStatus {
93    /// No usable `bwrap` binary was found on `PATH` (or bundled).
94    BwrapNotFound,
95    /// The only `bwrap` found is setuid-root, which we refuse to execute.
96    SetuidRejected,
97    /// `bwrap` is present but failed to set up the sandbox with our arguments.
98    SandboxProbeFailed,
99}
100
101impl LauncherStatus {
102    /// A human-readable explanation suitable for diagnostics.
103    pub fn describe(self) -> &'static str {
104        match self {
105            LauncherStatus::BwrapNotFound => "no usable `bwrap` binary was found on PATH",
106            LauncherStatus::SetuidRejected => {
107                "the only available `bwrap` is setuid-root, which Omega refuses to run"
108            }
109            LauncherStatus::SandboxProbeFailed => {
110                "`bwrap` is present but failed to create a sandbox (unprivileged user \
111                 namespaces may be disabled)"
112            }
113        }
114    }
115}
116
117/// Where a `bwrap` lookup ended up.
118enum BwrapLocation {
119    /// A usable, non-setuid `bwrap` binary.
120    Found(PathBuf),
121    /// `bwrap` exists but every candidate is setuid-root (which we won't run).
122    OnlySetuid,
123    /// No `bwrap` binary was found at all.
124    NotFound,
125}
126
127fn locate_bwrap() -> BwrapLocation {
128    let mut saw_setuid = false;
129    for candidate in candidate_bwrap_paths() {
130        if !candidate.is_file() {
131            continue;
132        }
133        if is_setuid_root(&candidate) {
134            saw_setuid = true;
135            continue;
136        }
137        return BwrapLocation::Found(candidate);
138    }
139    if saw_setuid {
140        BwrapLocation::OnlySetuid
141    } else {
142        BwrapLocation::NotFound
143    }
144}
145
146fn candidate_bwrap_paths() -> Vec<PathBuf> {
147    let mut candidates = Vec::new();
148    if let Some(system) = system_bwrap_path() {
149        candidates.push(system);
150    }
151    if let Some(bundled) = bundled_bwrap_path() {
152        candidates.push(bundled);
153    }
154    candidates
155}
156
157fn system_bwrap_path() -> Option<PathBuf> {
158    let path = std::env::var_os("PATH")?;
159    std::env::split_paths(&path)
160        .map(|directory| directory.join("bwrap"))
161        .find(|candidate| candidate.is_file())
162}
163
164fn bundled_bwrap_path() -> Option<PathBuf> {
165    None
166}
167
168fn is_setuid_root(path: &Path) -> bool {
169    match std::fs::metadata(path) {
170        Ok(metadata) => (metadata.mode() & libc::S_ISUID != 0) && metadata.uid() == 0,
171        Err(_) => false,
172    }
173}
174
175#[allow(
176    clippy::disallowed_methods,
177    reason = "the probe is a short-lived background operation that must block on bwrap"
178)]
179fn probe_bwrap(bwrap: &Path, bwrap_args: &[String]) -> bool {
180    // Capture stderr (rather than discarding it) so a failed probe can report
181    // *why* bwrap refused — the difference between "user namespaces disabled",
182    // "chdir target missing", "no permission", etc. — instead of a bare
183    // `SandboxProbeFailed`.
184    let output = Command::new(bwrap)
185        .args(bwrap_args)
186        .arg("--")
187        .arg("true")
188        .stdin(Stdio::null())
189        .stdout(Stdio::null())
190        .stderr(Stdio::piped())
191        .output();
192
193    match output {
194        Ok(output) if output.status.success() => true,
195        Ok(output) => {
196            log::warn!(
197                "[sandbox] bwrap probe failed ({}). command: {} {}\nbwrap stderr: {}",
198                output.status,
199                bwrap.display(),
200                bwrap_args.join(" "),
201                String::from_utf8_lossy(&output.stderr).trim()
202            );
203            false
204        }
205        Err(error) => {
206            log::warn!(
207                "[sandbox] bwrap probe could not be spawned: {error}. command: {} {}",
208                bwrap.display(),
209                bwrap_args.join(" ")
210            );
211            false
212        }
213    }
214}
215
216/// Build the `bwrap` argument list (everything after the `bwrap` program and
217/// before the trailing `-- <command>`) for the given policy.
218///
219/// `proxy_socket_path` is the host pathname Unix socket used for
220/// [`NetworkAccess::LocalhostPort`]. It is bind-mounted to a unique path inside
221/// the sandbox where the bridge connects to it.
222pub fn build_bwrap_args(
223    writable_directories: &[&Path],
224    protected_paths: &[&Path],
225    permissions: SandboxPermissions,
226    cwd: Option<&Path>,
227    proxy_socket_path: Option<&Path>,
228) -> Vec<String> {
229    let proxy_socket_sandbox_path = proxy_socket_path
230        .filter(|_| matches!(permissions.network, NetworkAccess::LocalhostPort(_)))
231        .map(|_| unique_proxy_socket_sandbox_path());
232    build_bwrap_args_with_sandbox_paths(
233        writable_directories,
234        protected_paths,
235        permissions,
236        cwd,
237        proxy_socket_path,
238        proxy_socket_sandbox_path.as_deref(),
239        None,
240        None,
241    )
242}
243
244#[allow(
245    clippy::too_many_arguments,
246    reason = "a flat arg list mirrors the bwrap flags this assembles"
247)]
248fn build_bwrap_args_with_sandbox_paths(
249    writable_directories: &[&Path],
250    protected_paths: &[&Path],
251    permissions: SandboxPermissions,
252    cwd: Option<&Path>,
253    proxy_socket_path: Option<&Path>,
254    proxy_socket_sandbox_path: Option<&Path>,
255    validation_socket_path: Option<&Path>,
256    validation_socket_sandbox_path: Option<&Path>,
257) -> Vec<String> {
258    let mut args = Vec::new();
259
260    let root_bind = if permissions.allow_fs_write {
261        "--bind"
262    } else {
263        "--ro-bind"
264    };
265    push_bind(&mut args, root_bind, "/", "/");
266
267    args.push("--dev".to_string());
268    args.push("/dev".to_string());
269    args.push("--proc".to_string());
270    args.push("/proc".to_string());
271
272    if !permissions.allow_fs_write {
273        args.push("--tmpfs".to_string());
274        args.push("/tmp".to_string());
275
276        for directory in writable_directories {
277            // Bind each writable directory at its *exact* path, **verbatim** —
278            // never re-`canonicalize`d here. The path was already resolved once,
279            // at capture time, to the pinned inode's current path (`readlink` of
280            // the `O_PATH` fd); re-resolving it now would reopen the
281            // time-of-check-to-time-of-use gap a malicious swap exploits. We must
282            // also never widen to an existing ancestor, so a path that doesn't
283            // exist is skipped rather than falling back to a parent. Whatever
284            // inode bwrap actually binds is verified after the mounts by the
285            // in-sandbox launcher (see `validate_binds`), which fails closed on a
286            // mismatch.
287            if !directory.exists() {
288                continue;
289            }
290            let path = directory.to_string_lossy().into_owned();
291            push_bind(&mut args, "--bind", &path, &path);
292        }
293    }
294
295    // Protect requested paths by re-binding them read-only *over* any broader rw
296    // bind (order matters: later binds win). Unlike Seatbelt, bwrap can't deny
297    // writes while allowing content reads with a policy rule, so a protected path
298    // is read-only — its contents stay readable but can't be written. A
299    // not-yet-existing path can't be bound, so it's skipped.
300    for protected_path in protected_paths {
301        if !protected_path.exists() {
302            continue;
303        }
304        let path = protected_path.to_string_lossy().into_owned();
305        push_bind(&mut args, "--ro-bind", &path, &path);
306    }
307
308    for flag in [
309        "--unshare-user",
310        "--unshare-ipc",
311        "--unshare-uts",
312        "--unshare-pid",
313        "--unshare-cgroup-try",
314        "--die-with-parent",
315    ] {
316        args.push(flag.to_string());
317    }
318
319    match permissions.network {
320        NetworkAccess::None => args.push("--unshare-net".to_string()),
321        NetworkAccess::LocalhostPort(_) => {
322            args.push("--unshare-net".to_string());
323            if let Some((proxy_socket_path, proxy_socket_sandbox_path)) =
324                proxy_socket_path.zip(proxy_socket_sandbox_path)
325            {
326                let source = proxy_socket_path.to_string_lossy().into_owned();
327                let destination = proxy_socket_sandbox_path.to_string_lossy().into_owned();
328                push_bind(&mut args, "--bind", &source, &destination);
329            }
330        }
331        NetworkAccess::All => {}
332    }
333
334    // The validation socket is filesystem-based, so it works regardless of the
335    // network policy (an `--unshare-net`'d sandbox can't reach an abstract
336    // socket, but a bind-mounted one is fine). Bind it after the `/tmp` tmpfs so
337    // it isn't shadowed by the overlay.
338    if let Some((source, destination)) = validation_socket_path.zip(validation_socket_sandbox_path)
339    {
340        let source = source.to_string_lossy().into_owned();
341        let destination = destination.to_string_lossy().into_owned();
342        push_bind(&mut args, "--bind", &source, &destination);
343    }
344
345    if let Some(cwd) = cwd {
346        args.push("--chdir".to_string());
347        args.push(cwd.to_string_lossy().into_owned());
348    }
349
350    args
351}
352
353/// A unique destination path for the proxy socket bind mount *inside* the
354/// sandbox. Each sandboxed command runs in its own mount namespace, so the path
355/// only needs to be unique enough to avoid colliding with anything a previous
356/// command in the same namespace lineage may have created; combining the pid
357/// with a monotonic counter is sufficient and keeps the path predictable for
358/// diagnostics.
359fn unique_proxy_socket_sandbox_path() -> PathBuf {
360    static COUNTER: AtomicU64 = AtomicU64::new(0);
361    let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
362    PathBuf::from(format!(
363        "{PROXY_SOCKET_SANDBOX_PATH_PREFIX}-{}-{counter}.sock",
364        std::process::id()
365    ))
366}
367
368/// In-sandbox bind destination for the validation socket. Each sandboxed command
369/// runs in its own mount namespace, so this only needs to avoid colliding with
370/// other binds in the same namespace.
371fn unique_validation_socket_sandbox_path() -> PathBuf {
372    static COUNTER: AtomicU64 = AtomicU64::new(0);
373    let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
374    PathBuf::from(format!(
375        "{VALIDATION_SOCKET_SANDBOX_PATH_PREFIX}-{}-{counter}.sock",
376        std::process::id()
377    ))
378}
379
380/// Create a private, owner-only directory for the validation listener socket and
381/// return `(directory, socket path)`. The socket is a host path (it lives
382/// outside the sandbox's `/tmp` tmpfs) and is bind-mounted in at
383/// [`unique_validation_socket_sandbox_path`].
384///
385/// The socket carries the captured `O_PATH` descriptors over `SCM_RIGHTS`, so
386/// connect access must be confined to us. Rather than bind in shared `/tmp` and
387/// `chmod` after (which leaves a window where another user could connect before
388/// the `chmod` lands), we create a `0700` directory up front: `create` is atomic
389/// and fails if the path already exists, and `mode(0o700)` makes it
390/// owner-only from creation, so no other user can even traverse to the socket.
391fn unique_validation_socket_host_path() -> std::io::Result<(PathBuf, PathBuf)> {
392    use std::os::unix::fs::DirBuilderExt as _;
393    static COUNTER: AtomicU64 = AtomicU64::new(0);
394    let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
395    let dir = std::env::temp_dir().join(format!(
396        "zed-sandbox-validate-{}-{counter}",
397        std::process::id()
398    ));
399    // Clear a stale directory left by a previous run that reused this pid; this
400    // only succeeds for a directory we own, so it can't be used to displace
401    // another user's squatted path (in which case `create` below fails closed).
402    let _ = std::fs::remove_dir_all(&dir);
403    std::fs::DirBuilder::new().mode(0o700).create(&dir)?;
404    let socket_path = dir.join("validate.sock");
405    Ok((dir, socket_path))
406}
407
408/// Host endpoint that hands the in-sandbox validator the captured `O_PATH`
409/// descriptors for the writable binds.
410///
411/// Runs in-process: `spawn` starts a short-lived background **thread** (never a
412/// separate process) that listens on an owner-only `AF_UNIX` socket and serves
413/// the descriptors to **exactly one** client (the in-sandbox validator) via
414/// `SCM_RIGHTS`, then closes and removes the socket. The validator connects
415/// once, after bwrap has mounted the binds; `SCM_RIGHTS` keeps the descriptors
416/// alive in flight, so once the single send completes there is nothing left to
417/// serve. Holding the descriptors until that send keeps the captured inodes
418/// pinned (so they can't be recycled) up to the moment they're handed off.
419///
420/// It is owned by the per-command [`Sandbox`](crate::Sandbox); `Drop` wakes a
421/// still-blocked accept and removes the socket and its private directory (a
422/// no-op if the thread already tore them down). The socket is bind-mounted into
423/// the sandbox at [`Self::sandbox_socket_path`].
424pub(crate) struct ValidationFdSender {
425    host_socket_dir: PathBuf,
426    host_socket_path: PathBuf,
427    sandbox_socket_path: PathBuf,
428    shutdown: Arc<AtomicBool>,
429}
430
431impl ValidationFdSender {
432    /// Start serving `fds` (one per writable bind, in bind order). The caller
433    /// must pass the same order to the launcher so each fd lines up with its
434    /// bind-destination path.
435    pub(crate) fn spawn(fds: Vec<OwnedFd>) -> std::io::Result<Self> {
436        use std::os::unix::fs::PermissionsExt as _;
437
438        if fds.len() > MAX_VALIDATED_BINDS {
439            return Err(std::io::Error::other(format!(
440                "too many writable binds to validate ({} > {MAX_VALIDATED_BINDS})",
441                fds.len()
442            )));
443        }
444        let (host_socket_dir, host_socket_path) = unique_validation_socket_host_path()?;
445        let listener = UnixListener::bind(&host_socket_path)?;
446        // The enclosing directory is already `0700`, so this is defense in depth:
447        // connect permission on an `AF_UNIX` socket is governed by write
448        // permission on the socket file, so keep it owner-only too.
449        std::fs::set_permissions(&host_socket_path, std::fs::Permissions::from_mode(0o600))?;
450        let sandbox_socket_path = unique_validation_socket_sandbox_path();
451        let shutdown = Arc::new(AtomicBool::new(false));
452
453        // We use std threading APIs here because this code is run from both the
454        // Linux zed binary and also the WSL helper, which does not have a GPUI
455        // runtime.
456        thread::Builder::new()
457            .name("zed-sandbox-validation".to_string())
458            .spawn({
459                let shutdown = shutdown.clone();
460                let host_socket_path = host_socket_path.clone();
461                move || {
462                    // `fds` is held until the single send below, keeping the
463                    // pinned inodes alive until they're handed to the validator.
464                    let raw_fds: Vec<RawFd> = fds.iter().map(|fd| fd.as_raw_fd()).collect();
465                    // Serve exactly one client: the in-sandbox validator connects
466                    // once. A second connection (e.g. the wake from `Drop`) is
467                    // distinguished by the `shutdown` flag and never served.
468                    match listener.accept() {
469                        Ok((stream, _)) => {
470                            if !shutdown.load(Ordering::SeqCst) {
471                                if let Err(error) = send_fds(&stream, &raw_fds) {
472                                    log::warn!("[sandbox] failed to send validation fds: {error}");
473                                }
474                            }
475                        }
476                        Err(error) => {
477                            if !shutdown.load(Ordering::SeqCst) {
478                                log::warn!("[sandbox] sandbox validation accept failed: {error}");
479                            }
480                        }
481                    }
482                    drop(fds);
483                    // Close the listener and remove the socket so it can't be
484                    // connected to again. The directory is removed by `Drop`.
485                    drop(listener);
486                    let _ = std::fs::remove_file(&host_socket_path);
487                }
488            })?;
489
490        Ok(Self {
491            host_socket_dir,
492            host_socket_path,
493            sandbox_socket_path,
494            shutdown,
495        })
496    }
497
498    pub(crate) fn host_socket_path(&self) -> &Path {
499        &self.host_socket_path
500    }
501
502    pub(crate) fn sandbox_socket_path(&self) -> &Path {
503        &self.sandbox_socket_path
504    }
505}
506
507impl Drop for ValidationFdSender {
508    fn drop(&mut self) {
509        // If the validator never connected (e.g. bwrap failed before the
510        // launcher ran), the thread is still blocked in `accept`. Flag shutdown
511        // and poke the socket so the accept returns and the thread exits without
512        // serving the descriptors, then remove the socket and its private
513        // directory (a no-op if the thread already removed the socket after its
514        // single send).
515        self.shutdown.store(true, Ordering::SeqCst);
516        let _ = UnixStream::connect(&self.host_socket_path);
517        let _ = std::fs::remove_file(&self.host_socket_path);
518        let _ = std::fs::remove_dir_all(&self.host_socket_dir);
519    }
520}
521
522fn push_bind(args: &mut Vec<String>, flag: &str, source: &str, destination: &str) {
523    args.push(flag.to_string());
524    args.push(source.to_string());
525    args.push(destination.to_string());
526}
527
528fn resolve_bwrap() -> std::result::Result<PathBuf, LauncherStatus> {
529    match locate_bwrap() {
530        BwrapLocation::Found(path) => Ok(path),
531        BwrapLocation::OnlySetuid => Err(LauncherStatus::SetuidRejected),
532        BwrapLocation::NotFound => Err(LauncherStatus::BwrapNotFound),
533    }
534}
535
536fn prepare_sandbox(
537    permissions: SandboxPermissions,
538) -> std::result::Result<(PathBuf, Vec<String>), LauncherStatus> {
539    let bwrap = match resolve_bwrap() {
540        Ok(bwrap) => bwrap,
541        Err(status) => {
542            log::warn!("[sandbox] cannot create sandbox: {}", status.describe());
543            return Err(status);
544        }
545    };
546    // The probe only answers "can a sandbox be created on this host at all", so
547    // it runs a bare, representative sandbox: no writable grants, no protected
548    // Git dirs, no proxy socket, and no `--chdir` into a command's working
549    // directory. None of those affect createability, and binding them would make
550    // the probe depend on per-command layout (e.g. a worktree under the `/tmp`
551    // tmpfs that `--chdir` then can't reach).
552    let bwrap_args = build_bwrap_args(&[], &[], permissions, None, None);
553    if !probe_bwrap(&bwrap, &bwrap_args) {
554        return Err(LauncherStatus::SandboxProbeFailed);
555    }
556    Ok((bwrap, bwrap_args))
557}
558
559/// Check whether an OS sandbox can be created on this host for this policy.
560pub fn check_can_create_sandbox(
561    permissions: SandboxPermissions,
562) -> std::result::Result<(), LauncherStatus> {
563    prepare_sandbox(permissions).map(|_| ())
564}
565
566/// The host (Zed-side) socket paths for the in-sandbox bind validator.
567#[derive(Clone, Copy)]
568pub struct ValidationSocket<'a> {
569    /// Host pathname of the listener the validator connects back to.
570    pub host_socket_path: &'a Path,
571    /// In-sandbox path the host socket is bind-mounted to (where the validator
572    /// actually connects).
573    pub sandbox_socket_path: &'a Path,
574}
575
576/// Build the final command line that runs `program` inside Bubblewrap.
577///
578/// `bridge_program` should be the current Zed executable; it is re-exec'd inside
579/// the sandbox as the launcher whenever bind validation and/or the
580/// restricted-network bridge are needed, running before the real command.
581///
582/// The host `proxy_socket_path` and `validation_socket` are each bind-mounted to
583/// a per-invocation path inside the sandbox, and those in-sandbox paths are
584/// handed to the launcher.
585#[allow(
586    clippy::too_many_arguments,
587    reason = "assembling a bwrap command line is inherently parameter-heavy"
588)]
589pub fn wrap_invocation(
590    bridge_program: &str,
591    permissions: SandboxPermissions,
592    writable_dirs: &[&Path],
593    protected_paths: &[&Path],
594    cwd: Option<&Path>,
595    program: &str,
596    args: &[String],
597    proxy_socket_path: Option<&Path>,
598    validation_socket: Option<ValidationSocket<'_>>,
599) -> Result<(String, Vec<String>)> {
600    if matches!(permissions.network, NetworkAccess::LocalhostPort(_)) && proxy_socket_path.is_none()
601    {
602        bail!("restricted Linux network access requires a proxy Unix socket path");
603    }
604    if writable_dirs.len() > MAX_VALIDATED_BINDS {
605        bail!(
606            "too many writable binds to validate ({} > {MAX_VALIDATED_BINDS})",
607            writable_dirs.len()
608        );
609    }
610
611    // Create the requested writable directories up front, with the agent's
612    // ambient permissions, so each can be bind-mounted at its exact path (see
613    // `build_bwrap_args`): `bwrap` can't bind a nonexistent source, and the
614    // command can't create it either (its parent is read-only inside the
615    // sandbox). If a path still doesn't exist afterwards we can't grant the
616    // write access the agent asked for, and running anyway would give the
617    // command silently less access than it believes it has — so fail closed with
618    // a clear error instead. (An existing *file* makes `create_dir_all` error
619    // but is fine: it exists and the `--bind` below handles it.)
620    if !permissions.allow_fs_write {
621        for directory in writable_dirs {
622            if let Err(error) = std::fs::create_dir_all(directory)
623                && !directory.exists()
624            {
625                bail!(
626                    "failed to provide writable sandbox path {}: {error}",
627                    directory.display()
628                );
629            }
630        }
631    }
632
633    let bwrap = resolve_bwrap().map_err(|status| anyhow!(status.describe()))?;
634    let proxy_socket_sandbox_path = match permissions.network {
635        NetworkAccess::LocalhostPort(_) => Some(unique_proxy_socket_sandbox_path()),
636        NetworkAccess::None | NetworkAccess::All => None,
637    };
638    let mut bwrap_args = build_bwrap_args_with_sandbox_paths(
639        writable_dirs,
640        protected_paths,
641        permissions,
642        cwd,
643        proxy_socket_path,
644        proxy_socket_sandbox_path.as_deref(),
645        validation_socket.map(|socket| socket.host_socket_path),
646        validation_socket.map(|socket| socket.sandbox_socket_path),
647    );
648    bwrap_args.push("--".to_string());
649
650    let bridge = match permissions.network {
651        NetworkAccess::LocalhostPort(port) => {
652            let proxy_socket_sandbox_path = proxy_socket_sandbox_path
653                .as_ref()
654                .context("missing in-sandbox proxy socket path")?;
655            Some((proxy_socket_sandbox_path.clone(), port))
656        }
657        NetworkAccess::None | NetworkAccess::All => None,
658    };
659
660    // Always route through the in-sandbox launcher, even when there are no
661    // writable binds to validate and no restricted-network bridge: the launcher
662    // is where the seccomp filter is installed on the untrusted command (see
663    // `exec_command` / `run_bridge`). Absent fields are passed as the `-`
664    // sentinel, and `run_launcher` then just installs the filter and `exec`s.
665    bwrap_args.push(bridge_program.to_string());
666    bwrap_args.push(LAUNCHER_FLAG.to_string());
667    // Field 1: validation socket (in-sandbox path) or sentinel.
668    bwrap_args.push(match validation_socket {
669        Some(socket) => socket.sandbox_socket_path.to_string_lossy().into_owned(),
670        None => LAUNCHER_NONE.to_string(),
671    });
672    // Fields 2-3: bridge socket (in-sandbox path) + port, or sentinels.
673    match &bridge {
674        Some((socket, port)) => {
675            bwrap_args.push(socket.to_string_lossy().into_owned());
676            bwrap_args.push(port.to_string());
677        }
678        None => {
679            bwrap_args.push(LAUNCHER_NONE.to_string());
680            bwrap_args.push(LAUNCHER_NONE.to_string());
681        }
682    }
683    // Field 4: the writable bind-destination paths to validate (count, then
684    // the paths), in the same order the host sends their fds.
685    let validation_paths: &[&Path] = if validation_socket.is_some() {
686        writable_dirs
687    } else {
688        &[]
689    };
690    bwrap_args.push(validation_paths.len().to_string());
691    for path in validation_paths {
692        bwrap_args.push(path.to_string_lossy().into_owned());
693    }
694    bwrap_args.push("--".to_string());
695
696    bwrap_args.push(program.to_string());
697    bwrap_args.extend(args.iter().cloned());
698
699    let bwrap = bwrap
700        .to_str()
701        .with_context(|| format!("bwrap path contains invalid UTF-8: {}", bwrap.display()))?;
702    Ok((bwrap.to_string(), bwrap_args))
703}
704
705/// Handle a possible re-exec of this binary as the in-sandbox launcher (bind
706/// validator + network bridge). Does not return if it was invoked as one.
707pub fn run_launcher_if_invoked() {
708    let Some(invocation) = parse_launcher_args(std::env::args_os()) else {
709        return;
710    };
711    let invocation = match invocation {
712        Ok(invocation) => invocation,
713        Err(error) => {
714            eprintln!("omega: malformed sandbox launcher invocation: {error:#}");
715            std::process::exit(127);
716        }
717    };
718    run_launcher(invocation);
719}
720
721/// A decoded in-sandbox launcher invocation (the `--zed-linux-sandbox-launcher`
722/// re-exec). All fields are produced by the trusted host side and parsed before
723/// any untrusted command runs.
724struct LauncherInvocation {
725    /// In-sandbox path of the validation socket, if bind validation is required.
726    validation_socket: Option<PathBuf>,
727    /// Writable bind-destination paths to validate, in the order the host sends
728    /// their fds. Empty when validation isn't required.
729    validation_paths: Vec<PathBuf>,
730    /// `(in-sandbox proxy socket path, loopback port)` if the restricted-network
731    /// bridge is required.
732    bridge: Option<(PathBuf, u16)>,
733    program: OsString,
734    args: Vec<OsString>,
735}
736
737fn parse_launcher_args(
738    args: impl IntoIterator<Item = OsString>,
739) -> Option<Result<LauncherInvocation>> {
740    let mut args = args.into_iter();
741    args.next()?;
742    if args.next()?.to_str() != Some(LAUNCHER_FLAG) {
743        return None;
744    }
745    Some(decode_launcher_args(args))
746}
747
748/// Parse an optional field encoded as either a real value or the `-` sentinel.
749fn optional_field(value: OsString) -> Option<OsString> {
750    if value == LAUNCHER_NONE {
751        None
752    } else {
753        Some(value)
754    }
755}
756
757fn decode_launcher_args(mut args: impl Iterator<Item = OsString>) -> Result<LauncherInvocation> {
758    let validation_socket =
759        optional_field(args.next().context("missing validation socket field")?).map(PathBuf::from);
760    let bridge_socket =
761        optional_field(args.next().context("missing bridge socket field")?).map(PathBuf::from);
762    let bridge_port = optional_field(args.next().context("missing bridge port field")?)
763        .map(|value| {
764            value
765                .to_str()
766                .context("bridge port is not valid UTF-8")?
767                .parse::<u16>()
768                .context("invalid bridge port")
769        })
770        .transpose()?;
771    let bridge = match (bridge_socket, bridge_port) {
772        (Some(socket), Some(port)) => Some((socket, port)),
773        (None, None) => None,
774        _ => bail!("bridge socket and port must be set together"),
775    };
776
777    let path_count = args
778        .next()
779        .context("missing validation path count")?
780        .to_str()
781        .context("validation path count is not valid UTF-8")?
782        .parse::<usize>()
783        .context("invalid validation path count")?;
784    if path_count > MAX_VALIDATED_BINDS {
785        bail!("validation path count {path_count} exceeds {MAX_VALIDATED_BINDS}");
786    }
787    let mut validation_paths = Vec::with_capacity(path_count);
788    for _ in 0..path_count {
789        validation_paths.push(PathBuf::from(
790            args.next().context("missing validation path")?,
791        ));
792    }
793
794    let separator = args.next().context("missing launcher argument separator")?;
795    if separator != "--" {
796        bail!("missing launcher argument separator");
797    }
798    let program = args.next().context("missing program to run")?;
799    let args = args.collect();
800    Ok(LauncherInvocation {
801        validation_socket,
802        validation_paths,
803        bridge,
804        program,
805        args,
806    })
807}
808
809/// The in-sandbox launcher entry point. Runs after bwrap's mounts and before the
810/// real command: it verifies the writable binds weren't redirected, optionally
811/// starts the restricted-network bridge, then runs the command. Never returns.
812fn run_launcher(invocation: LauncherInvocation) -> ! {
813    if let Some(socket) = &invocation.validation_socket {
814        if let Err(error) = validate_binds(socket, &invocation.validation_paths) {
815            // Fail closed: a redirected (or unverifiable) writable bind means the
816            // command must not run at all.
817            eprintln!("omega: sandbox bind validation failed: {error:#}");
818            std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE);
819        }
820    }
821
822    match invocation.bridge {
823        Some((socket_path, port)) => {
824            run_bridge(socket_path, port, &invocation.program, &invocation.args)
825        }
826        // No bridge to keep alive, so `exec` the command directly rather than
827        // lingering as a parent process.
828        None => exec_command(&invocation.program, &invocation.args),
829    }
830}
831
832/// Verify each writable bind resolves, inside the sandbox, to the exact inode the
833/// host captured. Receives the captured `O_PATH` fds over `socket_path` via
834/// `SCM_RIGHTS` (in the same order as `paths`), then compares `fstat(received
835/// fd)` against `lstat(mounted path)`. Both stats run in this process inside the
836/// sandbox, so the comparison needs no cross-namespace assumption about device
837/// numbers. Any mismatch — or any failure to obtain the expected number of fds —
838/// is an error (the caller fails closed).
839fn validate_binds(socket_path: &Path, paths: &[PathBuf]) -> Result<()> {
840    let stream = UnixStream::connect(socket_path)
841        .with_context(|| format!("connecting to validation socket {}", socket_path.display()))?;
842    let fds = recv_fds(&stream).context("receiving validation descriptors")?;
843    if fds.len() != paths.len() {
844        bail!(
845            "expected {} validation descriptor(s), received {}",
846            paths.len(),
847            fds.len()
848        );
849    }
850    for (fd, path) in fds.iter().zip(paths) {
851        let expected = fd_dev_ino(fd.as_raw_fd())
852            .with_context(|| format!("fstat of captured descriptor for {}", path.display()))?;
853        let mounted = lstat_dev_ino(path)
854            .with_context(|| format!("lstat of mounted bind {}", path.display()))?;
855        if expected != mounted {
856            bail!(
857                "writable bind {} was redirected (captured inode {:?}, mounted inode {:?})",
858                path.display(),
859                expected,
860                mounted
861            );
862        }
863    }
864    Ok(())
865}
866
867/// Build the seccomp-BPF program installed on the untrusted command before it
868/// runs. This is the syscall-level half of preventing session-IPC-socket
869/// sandbox escapes: a read-only bind mount does not stop `connect()` to a unix
870/// socket, so instead we stop the command from ever *obtaining* a non-IP socket.
871///
872/// The program (default action: allow):
873/// - `socket()` is denied (`EPERM`) unless the family is `AF_INET`/`AF_INET6`/
874///   `AF_NETLINK` — so no `AF_UNIX` (session IPC) or `AF_VSOCK` (VM host) sockets.
875/// - `socketpair()` is allowed only for `AF_UNIX` (a process-local pair that
876///   cannot reach anything outside the sandbox).
877/// - `io_uring_*` is denied, so its ring ops can't create/connect sockets
878///   without going through the filtered syscalls.
879/// - `ptrace`/`process_vm_*` are denied.
880///
881/// `connect`/`recvmsg`/`sendmsg`/`bind`/`listen`/`accept` stay allowed: with no
882/// way to create a forbidden socket (and — by fd hygiene — no forbidden fd
883/// inherited), there is nothing dangerous for them to act on, and blocking
884/// `connect` would break legitimate loopback/proxy use. Foreign-architecture
885/// syscalls are killed by seccompiler's arch check, closing the 32-bit-ABI
886/// (`socketcall`) bypass.
887///
888/// Returns `None` on an architecture seccompiler can't target (we ship on
889/// x86_64/aarch64, so that is not a configuration we run in practice).
890fn build_command_seccomp_program() -> Result<Option<seccompiler::BpfProgram>> {
891    use seccompiler::{
892        BpfProgram, SeccompAction, SeccompCmpArgLen, SeccompCmpOp, SeccompCondition, SeccompFilter,
893        SeccompRule, TargetArch,
894    };
895    use std::collections::BTreeMap;
896
897    let Ok(target_arch) = TargetArch::try_from(std::env::consts::ARCH) else {
898        return Ok(None);
899    };
900
901    // `socket(domain, ...)`: deny unless `domain` (arg0, an `int` — compare the
902    // low 32 bits) is an allowed IP/netlink family. The rule matches when the
903    // family is none of the allowed ones, and a matched rule takes the deny
904    // action; an allowed family matches no rule and falls through to `Allow`.
905    let socket_deny = SeccompRule::new(vec![
906        SeccompCondition::new(
907            0,
908            SeccompCmpArgLen::Dword,
909            SeccompCmpOp::Ne,
910            libc::AF_INET as u64,
911        )?,
912        SeccompCondition::new(
913            0,
914            SeccompCmpArgLen::Dword,
915            SeccompCmpOp::Ne,
916            libc::AF_INET6 as u64,
917        )?,
918        SeccompCondition::new(
919            0,
920            SeccompCmpArgLen::Dword,
921            SeccompCmpOp::Ne,
922            libc::AF_NETLINK as u64,
923        )?,
924    ])?;
925    // `socketpair(domain, ...)`: allow only `AF_UNIX`.
926    let socketpair_deny = SeccompRule::new(vec![SeccompCondition::new(
927        0,
928        SeccompCmpArgLen::Dword,
929        SeccompCmpOp::Ne,
930        libc::AF_UNIX as u64,
931    )?])?;
932
933    let mut rules: BTreeMap<i64, Vec<SeccompRule>> = BTreeMap::new();
934    rules.insert(libc::SYS_socket, vec![socket_deny]);
935    rules.insert(libc::SYS_socketpair, vec![socketpair_deny]);
936    // Unconditional denials (an empty rule chain always takes the match action).
937    for syscall in [
938        libc::SYS_io_uring_setup,
939        libc::SYS_io_uring_enter,
940        libc::SYS_io_uring_register,
941        libc::SYS_ptrace,
942        libc::SYS_process_vm_readv,
943        libc::SYS_process_vm_writev,
944    ] {
945        rules.insert(syscall, Vec::new());
946    }
947
948    let filter = SeccompFilter::new(
949        rules,
950        SeccompAction::Allow,
951        SeccompAction::Errno(libc::EPERM as u32),
952        target_arch,
953    )
954    .context("building sandbox command seccomp filter")?;
955    let program =
956        BpfProgram::try_from(filter).context("compiling sandbox command seccomp filter")?;
957    Ok(Some(program))
958}
959
960/// Install [`build_command_seccomp_program`] on the calling thread, which is
961/// about to become (or `exec` into) the untrusted command; the filter survives
962/// `exec`. `apply_filter` also sets `PR_SET_NO_NEW_PRIVS`. On an unsupported
963/// architecture there is no filter to install — log and proceed rather than
964/// break the sandbox on a platform we don't ship.
965fn install_command_seccomp_filter() -> Result<()> {
966    match build_command_seccomp_program()? {
967        Some(program) => seccompiler::apply_filter(&program)
968            .context("installing sandbox command seccomp filter")?,
969        None => log::warn!(
970            "[sandbox] seccomp is unavailable on {}; the unix-socket syscall guard \
971             was not installed",
972            std::env::consts::ARCH
973        ),
974    }
975    Ok(())
976}
977
978/// Replace this process with the sandboxed command. Only returns (after logging)
979/// if `exec` itself fails.
980fn exec_command(program: &OsStr, args: &[OsString]) -> ! {
981    // Lock down socket/io_uring/ptrace syscalls right before handing control to
982    // the untrusted command; the filter survives `exec`.
983    if let Err(error) = install_command_seccomp_filter() {
984        eprintln!("omega: failed to install sandbox seccomp filter: {error:#}");
985        std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE);
986    }
987    let error = Command::new(program).args(args).exec();
988    eprintln!("omega: failed to exec sandboxed command: {error}");
989    std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE);
990}
991
992#[allow(
993    clippy::disallowed_methods,
994    reason = "the bridge is an in-sandbox process that must synchronously spawn and wait for the command"
995)]
996fn run_bridge(socket_path: PathBuf, port: u16, program: &OsStr, program_args: &[OsString]) -> ! {
997    let listener = match TcpListener::bind((Ipv4Addr::LOCALHOST, port)) {
998        Ok(listener) => listener,
999        Err(error) => {
1000            eprintln!("omega: failed to bind sandbox proxy bridge: {error}");
1001            std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE);
1002        }
1003    };
1004
1005    if let Err(error) = thread::Builder::new()
1006        .name("zed-sandbox-bridge".to_string())
1007        .stack_size(128 * 1024)
1008        .spawn(move || run_bridge_listener(listener, socket_path))
1009    {
1010        eprintln!("omega: failed to spawn sandbox proxy bridge: {error}");
1011        std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE);
1012    }
1013
1014    // The command runs under the syscall filter, installed in the child via
1015    // `pre_exec` — *this* bridge process must NOT be filtered, since it keeps
1016    // using `AF_UNIX` to reach the host proxy for every request the command
1017    // makes. Build the program before the fork; the child only applies it.
1018    let seccomp_program = match build_command_seccomp_program() {
1019        Ok(program) => program,
1020        Err(error) => {
1021            eprintln!("omega: failed to build sandbox seccomp filter: {error:#}");
1022            std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE);
1023        }
1024    };
1025    let mut command = Command::new(program);
1026    command.args(program_args);
1027    // SAFETY: the closure runs in the forked child after `fork` and before
1028    // `exec`. It only calls `seccompiler::apply_filter` (a `prctl` on a program
1029    // built before the fork) — async-signal-safe and allocation-free.
1030    unsafe {
1031        command.pre_exec(move || {
1032            if let Some(program) = &seccomp_program {
1033                seccompiler::apply_filter(program)
1034                    .map_err(|error| std::io::Error::other(format!("seccomp: {error}")))?;
1035            }
1036            Ok(())
1037        });
1038    }
1039    let mut child = match command.spawn() {
1040        Ok(child) => child,
1041        Err(error) => {
1042            eprintln!("omega: failed to spawn sandboxed command: {error}");
1043            std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE);
1044        }
1045    };
1046
1047    match child.wait() {
1048        Ok(status) => {
1049            if let Some(code) = status.code() {
1050                std::process::exit(code);
1051            }
1052            let signal = status.signal().unwrap_or(1);
1053            std::process::exit(128 + signal);
1054        }
1055        Err(error) => {
1056            eprintln!("omega: failed to wait for sandboxed command: {error}");
1057            std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE);
1058        }
1059    }
1060}
1061
1062/// Send `fds` over `stream` in a single message carrying one byte of payload and
1063/// the descriptors as `SCM_RIGHTS` ancillary data.
1064fn send_fds(stream: &UnixStream, fds: &[RawFd]) -> std::io::Result<()> {
1065    use nix::sys::socket::{ControlMessage, MsgFlags, sendmsg};
1066    let payload = [0u8; 1];
1067    let iov = [std::io::IoSlice::new(&payload)];
1068    let control = [ControlMessage::ScmRights(fds)];
1069    sendmsg::<()>(
1070        stream.as_raw_fd(),
1071        &iov,
1072        &control,
1073        MsgFlags::MSG_NOSIGNAL,
1074        None,
1075    )
1076    .map_err(std::io::Error::from)?;
1077    Ok(())
1078}
1079
1080/// Receive the descriptors sent via `SCM_RIGHTS` on `stream`. They arrive with
1081/// `O_CLOEXEC` (via `MSG_CMSG_CLOEXEC`) so they don't leak into the command
1082/// that's `exec`'d after validation. The ancillary buffer is sized for the
1083/// protocol maximum ([`MAX_VALIDATED_BINDS`]); a sender exceeding it trips
1084/// `MSG_CTRUNC` and a mismatched count is rejected by the caller.
1085fn recv_fds(stream: &UnixStream) -> std::io::Result<Vec<OwnedFd>> {
1086    use nix::sys::socket::{ControlMessageOwned, MsgFlags, recvmsg};
1087    let mut payload = [0u8; 1];
1088    let mut iov = [std::io::IoSliceMut::new(&mut payload)];
1089    let mut control = nix::cmsg_space!([RawFd; MAX_VALIDATED_BINDS]);
1090    let message = recvmsg::<()>(
1091        stream.as_raw_fd(),
1092        &mut iov,
1093        Some(&mut control),
1094        MsgFlags::MSG_CMSG_CLOEXEC,
1095    )
1096    .map_err(std::io::Error::from)?;
1097    if message.flags.contains(MsgFlags::MSG_CTRUNC) {
1098        return Err(std::io::Error::other(
1099            "validation descriptors were truncated in transit",
1100        ));
1101    }
1102
1103    let mut fds = Vec::new();
1104    for control_message in message.cmsgs().map_err(std::io::Error::from)? {
1105        if let ControlMessageOwned::ScmRights(raw_fds) = control_message {
1106            for raw in raw_fds {
1107                // SAFETY: the kernel installs each `SCM_RIGHTS` descriptor as a
1108                // freshly-allocated, open fd in *our* table for this message
1109                // (with `O_CLOEXEC` via `MSG_CMSG_CLOEXEC`), so it's valid and
1110                // can't alias an fd we already hold. `nix`'s `ScmRights` does not
1111                // take ownership of received fds (closing them is the caller's
1112                // responsibility), so wrapping each exactly once here makes us
1113                // their sole owner. Both hold for any peer, which is why this
1114                // (and `recv_fds`) is sound as a safe fn rather than needing an
1115                // `unsafe fn` precondition on the caller.
1116                fds.push(unsafe { OwnedFd::from_raw_fd(raw) });
1117            }
1118        }
1119    }
1120    Ok(fds)
1121}
1122
1123/// `(device, inode)` of the object an already-open descriptor refers to.
1124fn fd_dev_ino(fd: RawFd) -> std::io::Result<(u64, u64)> {
1125    let stat = nix::sys::stat::fstat(fd).map_err(std::io::Error::from)?;
1126    Ok((stat.st_dev as u64, stat.st_ino as u64))
1127}
1128
1129/// `(device, inode)` of `path` without following a final symlink.
1130fn lstat_dev_ino(path: &Path) -> std::io::Result<(u64, u64)> {
1131    // `symlink_metadata` is `lstat(2)` (it does not follow a final symlink).
1132    let metadata = std::fs::symlink_metadata(path)?;
1133    Ok((metadata.dev(), metadata.ino()))
1134}
1135
1136/// Open an `O_PATH` descriptor pinning `path`'s inode (read/write on contents is
1137/// not granted), the same capture the native-Linux policy layer performs in
1138/// `HostFilesystemLocation::new`.
1139fn open_o_path_fd(path: &Path) -> std::io::Result<OwnedFd> {
1140    use std::os::unix::fs::OpenOptionsExt as _;
1141    let file = std::fs::OpenOptions::new()
1142        .read(true)
1143        .custom_flags(libc::O_PATH | libc::O_CLOEXEC)
1144        .open(path)?;
1145    Ok(OwnedFd::from(file))
1146}
1147
1148/// A decoded WSL-helper invocation (`--wsl-sandbox-helper`). All fields are
1149/// produced by the trusted Windows side and parsed before any untrusted command
1150/// runs.
1151struct WslHelperInvocation {
1152    /// Absolute path of `bwrap` inside WSL (located by the Windows-side probe).
1153    bwrap_path: PathBuf,
1154    /// Pre-built bwrap *options* — everything before the trailing `-- cmd` —
1155    /// assembled on the Windows side (`windows_wsl::build_bwrap_args`): root
1156    /// bind, `/tmp` tmpfs, writable binds, Windows-interop blocking, `--setenv`s,
1157    /// `--chdir`, namespace flags, etc.
1158    base_args: Vec<OsString>,
1159    /// The writable bind destinations to validate — exactly the ones `base_args`
1160    /// binds read-write. Each is captured here (WSL-side) and checked in-sandbox.
1161    writable_paths: Vec<PathBuf>,
1162    program: OsString,
1163    args: Vec<OsString>,
1164}
1165
1166/// Handle a possible re-exec of this binary as the WSL-side sandbox helper. Does
1167/// not return if it was invoked as one.
1168pub fn run_wsl_helper_if_invoked() {
1169    let Some(invocation) = parse_wsl_helper_args(std::env::args_os()) else {
1170        return;
1171    };
1172    let invocation = match invocation {
1173        Ok(invocation) => invocation,
1174        Err(error) => {
1175            eprintln!("omega: malformed WSL sandbox helper invocation: {error:#}");
1176            std::process::exit(127);
1177        }
1178    };
1179    run_wsl_helper(invocation);
1180}
1181
1182fn parse_wsl_helper_args(
1183    args: impl IntoIterator<Item = OsString>,
1184) -> Option<Result<WslHelperInvocation>> {
1185    let mut args = args.into_iter();
1186    args.next()?;
1187    if args.next()?.to_str() != Some(WSL_HELPER_FLAG) {
1188        return None;
1189    }
1190    Some(decode_wsl_helper_args(args))
1191}
1192
1193fn decode_wsl_helper_args(mut args: impl Iterator<Item = OsString>) -> Result<WslHelperInvocation> {
1194    let bwrap_path = PathBuf::from(args.next().context("missing bwrap path")?);
1195    let base_count = parse_count(
1196        args.next().context("missing base-arg count")?,
1197        "base-arg count",
1198    )?;
1199    let mut base_args = Vec::with_capacity(base_count);
1200    for _ in 0..base_count {
1201        base_args.push(args.next().context("missing base arg")?);
1202    }
1203    let writable_count = parse_count(
1204        args.next().context("missing writable count")?,
1205        "writable count",
1206    )?;
1207    if writable_count > MAX_VALIDATED_BINDS {
1208        bail!("writable count {writable_count} exceeds {MAX_VALIDATED_BINDS}");
1209    }
1210    let mut writable_paths = Vec::with_capacity(writable_count);
1211    for _ in 0..writable_count {
1212        writable_paths.push(PathBuf::from(args.next().context("missing writable path")?));
1213    }
1214    let separator = args.next().context("missing argument separator")?;
1215    if separator != "--" {
1216        bail!("missing argument separator");
1217    }
1218    let program = args.next().context("missing program to run")?;
1219    let args = args.collect();
1220    Ok(WslHelperInvocation {
1221        bwrap_path,
1222        base_args,
1223        writable_paths,
1224        program,
1225        args,
1226    })
1227}
1228
1229fn parse_count(value: OsString, what: &str) -> Result<usize> {
1230    value
1231        .to_str()
1232        .with_context(|| format!("{what} is not valid UTF-8"))?
1233        .parse::<usize>()
1234        .with_context(|| format!("invalid {what}"))
1235}
1236
1237/// The WSL-side helper entry point. Mirrors the native-Linux host side: capture
1238/// the writable binds' inodes (here, inside WSL), stand up the validation socket,
1239/// finish assembling the bwrap invocation (validation socket bind + in-sandbox
1240/// validator re-exec), spawn bwrap, and propagate its exit. Never returns.
1241#[allow(
1242    clippy::disallowed_methods,
1243    reason = "the WSL helper is a dedicated per-command process that must spawn and wait for bwrap"
1244)]
1245fn run_wsl_helper(invocation: WslHelperInvocation) -> ! {
1246    // Capture an `O_PATH` fd per writable bind *here*, inside WSL — this is the
1247    // capture-at-validation step that on native Linux happens in the Zed process.
1248    let mut fds = Vec::with_capacity(invocation.writable_paths.len());
1249    for path in &invocation.writable_paths {
1250        match open_o_path_fd(path) {
1251            Ok(fd) => fds.push(fd),
1252            Err(error) => {
1253                // Fail closed: a writable bind we can't pin can't be verified.
1254                eprintln!(
1255                    "omega: WSL sandbox helper could not open writable bind {}: {error}",
1256                    path.display()
1257                );
1258                std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE);
1259            }
1260        }
1261    }
1262
1263    let validation = if fds.is_empty() {
1264        None
1265    } else {
1266        match ValidationFdSender::spawn(fds) {
1267            Ok(sender) => Some(sender),
1268            Err(error) => {
1269                eprintln!("omega: WSL sandbox helper could not start the bind validator: {error}");
1270                std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE);
1271            }
1272        }
1273    };
1274
1275    let current_exe = match std::env::current_exe() {
1276        Ok(path) => path,
1277        Err(error) => {
1278            eprintln!("omega: WSL sandbox helper could not resolve its own path: {error}");
1279            std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE);
1280        }
1281    };
1282
1283    let mut args = invocation.base_args.clone();
1284    // Always re-exec ourselves as the in-sandbox launcher, even when there is
1285    // nothing to validate: the launcher is where the seccomp filter is installed
1286    // on the untrusted command (see `exec_command`). When there are writable
1287    // binds, also bind the validation socket so the launcher can verify them.
1288    // WSL has no restricted-network bridge, so both bridge fields are the absent
1289    // sentinel.
1290    if let Some(sender) = &validation {
1291        // Bind the validation socket in, after the base args' tmpfs and writable
1292        // binds so it isn't shadowed.
1293        args.push(OsString::from("--bind"));
1294        args.push(sender.host_socket_path().as_os_str().to_os_string());
1295        args.push(sender.sandbox_socket_path().as_os_str().to_os_string());
1296    }
1297    args.push(OsString::from("--"));
1298    args.push(current_exe.into_os_string());
1299    args.push(OsString::from(LAUNCHER_FLAG));
1300    // Field 1: validation socket (in-sandbox path) or sentinel.
1301    match &validation {
1302        Some(sender) => args.push(sender.sandbox_socket_path().as_os_str().to_os_string()),
1303        None => args.push(OsString::from(LAUNCHER_NONE)),
1304    }
1305    // Fields 2-3: bridge socket + port (WSL has no bridge).
1306    args.push(OsString::from(LAUNCHER_NONE));
1307    args.push(OsString::from(LAUNCHER_NONE));
1308    // Field 4: writable bind-destination paths to validate (count, then paths);
1309    // empty when there is nothing to validate.
1310    let validation_paths: &[PathBuf] = if validation.is_some() {
1311        &invocation.writable_paths
1312    } else {
1313        &[]
1314    };
1315    args.push(OsString::from(validation_paths.len().to_string()));
1316    for path in validation_paths {
1317        args.push(path.clone().into_os_string());
1318    }
1319    args.push(OsString::from("--"));
1320    args.push(invocation.program.clone());
1321    args.extend(invocation.args.iter().cloned());
1322
1323    let mut child = match Command::new(&invocation.bwrap_path).args(&args).spawn() {
1324        Ok(child) => child,
1325        Err(error) => {
1326            eprintln!("omega: WSL sandbox helper could not spawn bwrap: {error}");
1327            std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE);
1328        }
1329    };
1330
1331    let status = child.wait();
1332    // Hold the validator open until bwrap (and thus the in-sandbox validator that
1333    // connects to it during startup) has finished.
1334    drop(validation);
1335    match status {
1336        Ok(status) => {
1337            if let Some(code) = status.code() {
1338                std::process::exit(code);
1339            }
1340            let signal = status.signal().unwrap_or(1);
1341            std::process::exit(128 + signal);
1342        }
1343        Err(error) => {
1344            eprintln!("omega: WSL sandbox helper failed waiting for bwrap: {error}");
1345            std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE);
1346        }
1347    }
1348}
1349
1350fn run_bridge_listener(listener: TcpListener, socket_path: PathBuf) {
1351    for stream in listener.incoming() {
1352        match stream {
1353            Ok(stream) => {
1354                let socket_path = socket_path.clone();
1355                if let Err(error) = thread::Builder::new()
1356                    .name("zed-sandbox-bridge-conn".to_string())
1357                    .stack_size(128 * 1024)
1358                    .spawn(move || forward_bridge_connection(stream, socket_path))
1359                {
1360                    eprintln!("omega: failed to spawn sandbox bridge connection thread: {error}");
1361                }
1362            }
1363            Err(error) => eprintln!("omega: sandbox bridge accept failed: {error}"),
1364        }
1365    }
1366}
1367
1368fn forward_bridge_connection(tcp_stream: TcpStream, socket_path: PathBuf) {
1369    let unix_stream = match UnixStream::connect(&socket_path) {
1370        Ok(stream) => stream,
1371        Err(error) => {
1372            eprintln!(
1373                "omega: sandbox bridge failed to connect to proxy socket {}: {error}",
1374                socket_path.display()
1375            );
1376            return;
1377        }
1378    };
1379    copy_bidirectional(tcp_stream, unix_stream);
1380}
1381
1382fn copy_bidirectional(tcp_stream: TcpStream, unix_stream: UnixStream) {
1383    let tcp_read = match tcp_stream.try_clone() {
1384        Ok(stream) => stream,
1385        Err(error) => {
1386            eprintln!("omega: sandbox bridge failed to clone TCP stream: {error}");
1387            return;
1388        }
1389    };
1390    let unix_read = match unix_stream.try_clone() {
1391        Ok(stream) => stream,
1392        Err(error) => {
1393            eprintln!("omega: sandbox bridge failed to clone Unix stream: {error}");
1394            return;
1395        }
1396    };
1397
1398    let tcp_write = tcp_stream;
1399    let unix_write = unix_stream;
1400    let to_proxy = match thread::Builder::new()
1401        .name("zed-sandbox-bridge-out".to_string())
1402        .stack_size(128 * 1024)
1403        .spawn(move || copy_one_way(tcp_read, unix_write))
1404    {
1405        Ok(handle) => handle,
1406        Err(error) => {
1407            eprintln!("omega: failed to spawn sandbox bridge pump thread: {error}");
1408            return;
1409        }
1410    };
1411    copy_one_way(unix_read, tcp_write);
1412    if to_proxy.join().is_err() {
1413        eprintln!("omega: sandbox bridge pump thread panicked");
1414    }
1415}
1416
1417trait BridgeStream: Read + Write {
1418    fn shutdown(&self, how: Shutdown) -> std::io::Result<()>;
1419}
1420
1421impl BridgeStream for TcpStream {
1422    fn shutdown(&self, how: Shutdown) -> std::io::Result<()> {
1423        TcpStream::shutdown(self, how)
1424    }
1425}
1426
1427impl BridgeStream for UnixStream {
1428    fn shutdown(&self, how: Shutdown) -> std::io::Result<()> {
1429        UnixStream::shutdown(self, how)
1430    }
1431}
1432
1433fn copy_one_way(mut from: impl Read, mut to: impl BridgeStream) {
1434    let mut buffer = vec![0; PUMP_BUFFER_SIZE];
1435    loop {
1436        let count = match from.read(&mut buffer) {
1437            Ok(0) => break,
1438            Ok(count) => count,
1439            Err(_) => break,
1440        };
1441        if to.write_all(&buffer[..count]).is_err() {
1442            break;
1443        }
1444    }
1445    let _ = to.shutdown(Shutdown::Write);
1446}
1447
1448#[cfg(test)]
1449mod tests {
1450    use super::*;
1451
1452    fn launcher_argv(program: &str, args: Vec<&str>) -> Vec<OsString> {
1453        std::iter::once(program)
1454            .chain(args)
1455            .map(OsString::from)
1456            .collect()
1457    }
1458
1459    #[test]
1460    fn test_build_bwrap_args_binds_exact_path_never_widens_to_ancestor() {
1461        // A requested writable path that doesn't exist must NOT be bound, and in
1462        // particular must never cause an existing ancestor (here the tempdir) to
1463        // be bound read-write — that was a sandbox-escape (scope widening).
1464        let dir = tempfile::tempdir().unwrap();
1465        let existing = dir.path().canonicalize().unwrap();
1466        let missing = existing.join("does-not-exist").join("nested");
1467
1468        let args = build_bwrap_args(
1469            &[missing.as_path()],
1470            &[],
1471            SandboxPermissions::default(),
1472            None,
1473            None,
1474        );
1475
1476        let existing_str = existing.to_string_lossy().into_owned();
1477        assert!(
1478            !windows_contains(&args, &["--bind", &existing_str, &existing_str]),
1479            "a missing writable path must not widen the bind to an existing ancestor: {args:?}"
1480        );
1481        let missing_str = missing.to_string_lossy().into_owned();
1482        assert!(
1483            !windows_contains(&args, &["--bind", &missing_str, &missing_str]),
1484            "a missing writable path must not be bound: {args:?}"
1485        );
1486    }
1487
1488    #[test]
1489    fn test_build_bwrap_args_default_binds_writable_dirs_read_write() {
1490        let writable = tempfile::tempdir().unwrap();
1491        let args = build_bwrap_args(
1492            &[writable.path()],
1493            &[],
1494            SandboxPermissions::default(),
1495            Some(writable.path()),
1496            None,
1497        );
1498
1499        assert!(windows_contains(&args, &["--ro-bind", "/", "/"]));
1500        // The writable dir is bound verbatim at the exact path given (never
1501        // re-canonicalized, which would reopen the bind-source TOCTOU gap).
1502        let writable_str = writable.path().to_string_lossy().into_owned();
1503        assert!(windows_contains(
1504            &args,
1505            &["--bind", &writable_str, &writable_str]
1506        ));
1507        assert!(windows_contains(&args, &["--tmpfs", "/tmp"]));
1508        assert!(args.iter().any(|arg| arg == "--chdir"));
1509        assert!(args.iter().any(|arg| arg == "--unshare-net"));
1510    }
1511
1512    #[test]
1513    fn test_build_bwrap_args_network_namespace_follows_permission() {
1514        let denied = build_bwrap_args(&[], &[], SandboxPermissions::default(), None, None);
1515        assert!(denied.iter().any(|arg| arg == "--unshare-net"));
1516
1517        let allowed = build_bwrap_args(
1518            &[],
1519            &[],
1520            SandboxPermissions {
1521                network: NetworkAccess::All,
1522                allow_fs_write: false,
1523            },
1524            None,
1525            None,
1526        );
1527        assert!(!allowed.iter().any(|arg| arg == "--unshare-net"));
1528
1529        let socket = PathBuf::from("/tmp/zed-proxy.sock");
1530        let restricted = build_bwrap_args(
1531            &[],
1532            &[],
1533            SandboxPermissions {
1534                network: NetworkAccess::LocalhostPort(8080),
1535                allow_fs_write: false,
1536            },
1537            None,
1538            Some(socket.as_path()),
1539        );
1540        assert!(restricted.iter().any(|arg| arg == "--unshare-net"));
1541        let sandbox_destination = proxy_socket_bind_destination(&restricted)
1542            .expect("restricted run should bind the proxy socket into the sandbox");
1543        assert!(sandbox_destination.starts_with(PROXY_SOCKET_SANDBOX_PATH_PREFIX));
1544    }
1545
1546    #[test]
1547    fn test_build_bwrap_args_allow_fs_write_binds_root_read_write() {
1548        let permissions = SandboxPermissions {
1549            network: NetworkAccess::None,
1550            allow_fs_write: true,
1551        };
1552        let protected = Path::new("/tmp");
1553        let args = build_bwrap_args(&[], &[protected], permissions, None, None);
1554        assert!(windows_contains(&args, &["--bind", "/", "/"]));
1555        assert!(!windows_contains(&args, &["--ro-bind", "/", "/"]));
1556        assert!(windows_contains(&args, &["--ro-bind", "/tmp", "/tmp"]));
1557        assert!(!windows_contains(&args, &["--tmpfs", "/tmp"]));
1558    }
1559
1560    #[test]
1561    fn test_launcher_args_round_trip_bridge_and_validation() {
1562        let bridge_socket = "/tmp/zed-sandbox-1234-0.sock";
1563        let validate_socket = "/tmp/zed-sandbox-validate-1234-0.sock";
1564        let argv = launcher_argv(
1565            "/path/to/zed",
1566            vec![
1567                LAUNCHER_FLAG,
1568                validate_socket,
1569                bridge_socket,
1570                "8080",
1571                "2",
1572                "/work/a",
1573                "/work/b",
1574                "--",
1575                "/bin/sh",
1576                "-c",
1577                "echo hi there",
1578            ],
1579        );
1580
1581        let decoded = parse_launcher_args(argv)
1582            .expect("should be recognized as launcher invocation")
1583            .expect("should decode successfully");
1584
1585        assert_eq!(
1586            decoded.validation_socket,
1587            Some(PathBuf::from(validate_socket))
1588        );
1589        assert_eq!(
1590            decoded.validation_paths,
1591            vec![PathBuf::from("/work/a"), PathBuf::from("/work/b")]
1592        );
1593        assert_eq!(
1594            decoded.bridge,
1595            Some((PathBuf::from(bridge_socket), 8080u16))
1596        );
1597        assert_eq!(decoded.program, OsString::from("/bin/sh"));
1598        assert_eq!(
1599            decoded.args,
1600            vec![OsString::from("-c"), OsString::from("echo hi there")]
1601        );
1602    }
1603
1604    #[test]
1605    fn test_wsl_helper_args_round_trip() {
1606        let argv = launcher_argv(
1607            "/path/to/zed",
1608            vec![
1609                WSL_HELPER_FLAG,
1610                "/usr/bin/bwrap",
1611                // base bwrap options (3 tokens)
1612                "3",
1613                "--ro-bind",
1614                "/",
1615                "/",
1616                // writable binds to validate (1)
1617                "1",
1618                "/work/a",
1619                "--",
1620                "/bin/sh",
1621                "-c",
1622                "echo hi",
1623            ],
1624        );
1625
1626        let decoded = parse_wsl_helper_args(argv)
1627            .expect("should be recognized as a WSL helper invocation")
1628            .expect("should decode successfully");
1629
1630        assert_eq!(decoded.bwrap_path, PathBuf::from("/usr/bin/bwrap"));
1631        assert_eq!(
1632            decoded.base_args,
1633            vec![
1634                OsString::from("--ro-bind"),
1635                OsString::from("/"),
1636                OsString::from("/")
1637            ]
1638        );
1639        assert_eq!(decoded.writable_paths, vec![PathBuf::from("/work/a")]);
1640        assert_eq!(decoded.program, OsString::from("/bin/sh"));
1641        assert_eq!(
1642            decoded.args,
1643            vec![OsString::from("-c"), OsString::from("echo hi")]
1644        );
1645    }
1646
1647    #[test]
1648    fn test_launcher_args_round_trip_no_bridge() {
1649        let validate_socket = "/tmp/zed-sandbox-validate-1234-0.sock";
1650        let argv = launcher_argv(
1651            "/path/to/zed",
1652            vec![
1653                LAUNCHER_FLAG,
1654                validate_socket,
1655                LAUNCHER_NONE,
1656                LAUNCHER_NONE,
1657                "1",
1658                "/work/a",
1659                "--",
1660                "/bin/true",
1661            ],
1662        );
1663
1664        let decoded = parse_launcher_args(argv)
1665            .expect("should be recognized as launcher invocation")
1666            .expect("should decode successfully");
1667
1668        assert_eq!(
1669            decoded.validation_socket,
1670            Some(PathBuf::from(validate_socket))
1671        );
1672        assert_eq!(decoded.validation_paths, vec![PathBuf::from("/work/a")]);
1673        assert_eq!(decoded.bridge, None);
1674        assert_eq!(decoded.program, OsString::from("/bin/true"));
1675        assert!(decoded.args.is_empty());
1676    }
1677
1678    #[test]
1679    fn test_wrap_invocation_uses_bridge_for_restricted_network() {
1680        let socket = PathBuf::from("/tmp/zed-proxy.sock");
1681        let permissions = SandboxPermissions {
1682            network: NetworkAccess::LocalhostPort(8080),
1683            allow_fs_write: false,
1684        };
1685        let args = build_wrapped_args_for_test(
1686            "/path/to/zed",
1687            permissions,
1688            "/bin/sh",
1689            &["-c".to_string(), "echo hi".to_string()],
1690            Some(socket.as_path()),
1691        );
1692
1693        // The bind destination inside the sandbox and the path handed to the
1694        // launcher's bridge fields must be the same unique path. With no writable
1695        // binds, the validation field is the `-` sentinel and the path count is 0.
1696        let sandbox_destination = proxy_socket_bind_destination(&args)
1697            .expect("restricted run should bind the proxy socket into the sandbox");
1698        assert!(sandbox_destination.starts_with(PROXY_SOCKET_SANDBOX_PATH_PREFIX));
1699        assert!(windows_contains(
1700            &args,
1701            &[
1702                "/path/to/zed",
1703                LAUNCHER_FLAG,
1704                LAUNCHER_NONE,
1705                &sandbox_destination,
1706                "8080",
1707                "0",
1708                "--",
1709            ]
1710        ));
1711    }
1712
1713    /// Reconstruct the argv `wrap_invocation` would produce for the bridge-only
1714    /// (no writable binds, no validation socket) case, without needing a real
1715    /// `bwrap` on the test host.
1716    fn build_wrapped_args_for_test(
1717        bridge_program: &str,
1718        permissions: SandboxPermissions,
1719        program: &str,
1720        program_args: &[String],
1721        proxy_socket_path: Option<&Path>,
1722    ) -> Vec<String> {
1723        let proxy_socket_sandbox_path = match permissions.network {
1724            NetworkAccess::LocalhostPort(_) => Some(unique_proxy_socket_sandbox_path()),
1725            NetworkAccess::None | NetworkAccess::All => None,
1726        };
1727        let mut bwrap_args = build_bwrap_args_with_sandbox_paths(
1728            &[],
1729            &[],
1730            permissions,
1731            None,
1732            proxy_socket_path,
1733            proxy_socket_sandbox_path.as_deref(),
1734            None,
1735            None,
1736        );
1737        bwrap_args.push("--".to_string());
1738        if let NetworkAccess::LocalhostPort(port) = permissions.network {
1739            let proxy_socket_sandbox_path =
1740                proxy_socket_sandbox_path.expect("restricted network needs a sandbox socket path");
1741            bwrap_args.push(bridge_program.to_string());
1742            bwrap_args.push(LAUNCHER_FLAG.to_string());
1743            bwrap_args.push(LAUNCHER_NONE.to_string());
1744            bwrap_args.push(proxy_socket_sandbox_path.to_string_lossy().into_owned());
1745            bwrap_args.push(port.to_string());
1746            bwrap_args.push("0".to_string());
1747            bwrap_args.push("--".to_string());
1748        }
1749        bwrap_args.push(program.to_string());
1750        bwrap_args.extend(program_args.iter().cloned());
1751        bwrap_args
1752    }
1753
1754    /// Returns the in-sandbox destination of the proxy socket `--bind`, if any.
1755    fn proxy_socket_bind_destination(args: &[String]) -> Option<String> {
1756        args.windows(3).find_map(|window| {
1757            if window[0] == "--bind" && window[1] == "/tmp/zed-proxy.sock" {
1758                Some(window[2].clone())
1759            } else {
1760                None
1761            }
1762        })
1763    }
1764
1765    fn windows_contains(haystack: &[String], needle: &[&str]) -> bool {
1766        haystack
1767            .windows(needle.len())
1768            .any(|window| window.iter().map(String::as_str).eq(needle.iter().copied()))
1769    }
1770
1771    /// Open an `O_PATH` descriptor to `path`, mirroring how the policy layer
1772    /// captures a `HostFilesystemLocation`.
1773    fn open_o_path(path: &Path) -> OwnedFd {
1774        use std::os::unix::fs::OpenOptionsExt as _;
1775        let file = std::fs::OpenOptions::new()
1776            .read(true)
1777            .custom_flags(libc::O_PATH | libc::O_CLOEXEC)
1778            .open(path)
1779            .expect("open O_PATH");
1780        OwnedFd::from(file)
1781    }
1782
1783    // End-to-end check of the bind validator's core, without a real sandbox:
1784    // the server hands over the captured fd via SCM_RIGHTS and `validate_binds`
1785    // compares it against the path it's told was mounted. A matching path passes;
1786    // a *different* directory (as a redirected bind would produce) is rejected,
1787    // proving the validator fails closed rather than no-ops.
1788    //
1789    // Each `ValidationFdSender` serves a single client and then tears down, so
1790    // the two cases use separate senders.
1791    #[test]
1792    fn test_validate_binds_accepts_match_and_rejects_redirect() {
1793        let captured = tempfile::tempdir().unwrap();
1794        let other = tempfile::tempdir().unwrap();
1795
1796        // The mounted path is the captured inode -> validation passes.
1797        let sender = ValidationFdSender::spawn(vec![open_o_path(captured.path())])
1798            .expect("spawn validation fd sender");
1799        validate_binds(sender.host_socket_path(), &[captured.path().to_path_buf()])
1800            .expect("matching bind must validate");
1801        drop(sender);
1802
1803        // The mounted path is a *different* inode (a redirected bind) -> rejected.
1804        let sender = ValidationFdSender::spawn(vec![open_o_path(captured.path())])
1805            .expect("spawn validation fd sender");
1806        let error = validate_binds(sender.host_socket_path(), &[other.path().to_path_buf()])
1807            .expect_err("a redirected bind must be rejected");
1808        assert!(
1809            error.to_string().contains("redirected"),
1810            "unexpected error: {error:#}"
1811        );
1812    }
1813
1814    // A wrong fd count from the server (here: zero fds for one path) must fail
1815    // closed too — the validator never assumes an unvalidated bind is fine.
1816    #[test]
1817    fn test_validate_binds_rejects_missing_descriptors() {
1818        let captured = tempfile::tempdir().unwrap();
1819        let sender = ValidationFdSender::spawn(Vec::new()).expect("spawn validation fd sender");
1820        let error = validate_binds(sender.host_socket_path(), &[captured.path().to_path_buf()])
1821            .expect_err("missing descriptors must be rejected");
1822        assert!(
1823            error.to_string().contains("descriptor"),
1824            "unexpected error: {error:#}"
1825        );
1826    }
1827
1828    // The filter compiles to a non-empty BPF program on architectures
1829    // seccompiler can target (the ones we ship). On others it's `None`, which is
1830    // acceptable — no filter is installed there.
1831    #[test]
1832    fn test_command_seccomp_program_builds() {
1833        let program = build_command_seccomp_program().expect("build seccomp program");
1834        if let Some(program) = program {
1835            assert!(!program.is_empty(), "seccomp program must not be empty");
1836        }
1837    }
1838
1839    // Actually enforce the filter: in a child process, apply it and confirm that
1840    // `socket(AF_UNIX)` is denied while `socket(AF_INET)` and
1841    // `socketpair(AF_UNIX)` still work — the exact guarantee that closes the
1842    // unix-socket sandbox escape.
1843    #[test]
1844    fn test_command_seccomp_filter_blocks_unix_but_allows_ip() {
1845        // Build in the parent (this allocates); the child only applies the
1846        // prebuilt program and makes raw syscalls.
1847        let Some(program) = build_command_seccomp_program().expect("build seccomp program") else {
1848            return; // unsupported arch: nothing to enforce
1849        };
1850
1851        // SAFETY: after `fork`, the child calls only async-signal-safe
1852        // libc/`prctl` (via `apply_filter` on a program built before the fork)
1853        // and `_exit`; it never returns to Rust or allocates.
1854        let pid = unsafe { libc::fork() };
1855        assert!(pid >= 0, "fork failed");
1856        if pid == 0 {
1857            if seccompiler::apply_filter(&program).is_err() {
1858                unsafe { libc::_exit(10) };
1859            }
1860            // AF_UNIX socket creation must be denied.
1861            if unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_STREAM, 0) } >= 0 {
1862                unsafe { libc::_exit(11) };
1863            }
1864            // AF_INET socket creation must still work.
1865            let inet = unsafe { libc::socket(libc::AF_INET, libc::SOCK_STREAM, 0) };
1866            if inet < 0 {
1867                unsafe { libc::_exit(12) };
1868            }
1869            // AF_UNIX socketpair (process-local) must still work.
1870            let mut fds = [0i32; 2];
1871            if unsafe { libc::socketpair(libc::AF_UNIX, libc::SOCK_STREAM, 0, fds.as_mut_ptr()) }
1872                != 0
1873            {
1874                unsafe { libc::_exit(13) };
1875            }
1876            unsafe { libc::_exit(0) };
1877        }
1878
1879        let mut status = 0i32;
1880        let waited = unsafe { libc::waitpid(pid, &mut status, 0) };
1881        assert_eq!(waited, pid, "waitpid failed");
1882        assert!(
1883            libc::WIFEXITED(status),
1884            "child did not exit normally: {status:#x}"
1885        );
1886        let code = libc::WEXITSTATUS(status);
1887        assert_eq!(
1888            code, 0,
1889            "child reported a seccomp mismatch (exit {code}): 10=apply failed, \
1890             11=AF_UNIX allowed, 12=AF_INET blocked, 13=socketpair blocked"
1891        );
1892    }
1893
1894    // A requested writable path that can't be created (here, under an existing
1895    // file, so `create_dir_all` errors and the path never exists) must fail the
1896    // whole invocation — not run the command with silently less write access
1897    // than the agent asked for. This check runs before `resolve_bwrap`, so the
1898    // test needs no real `bwrap`.
1899    #[test]
1900    fn test_wrap_invocation_fails_when_writable_path_cannot_be_provided() {
1901        let file = tempfile::NamedTempFile::new().unwrap();
1902        let unbindable = file.path().join("subdir");
1903        let result = wrap_invocation(
1904            "/proc/self/exe",
1905            SandboxPermissions {
1906                network: NetworkAccess::None,
1907                allow_fs_write: false,
1908            },
1909            &[unbindable.as_path()],
1910            &[],
1911            None,
1912            "/bin/true",
1913            &[],
1914            None,
1915            None,
1916        );
1917        let error = result.expect_err("must fail closed when a writable path can't be provided");
1918        assert!(
1919            error.to_string().contains("writable sandbox path"),
1920            "unexpected error: {error:#}"
1921        );
1922    }
1923}
1924
Served at tenant.openagents/omega Member data and write actions are omitted.