Skip to repository content1924 lines · 76.2 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:29:44.006Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
linux_bubblewrap.rs
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