Skip to repository content1727 lines · 69.3 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T05:53:43.577Z 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
sandbox.rs
1//! Cross-platform sandboxing for commands run on behalf of the agent.
2//!
3//! The public API is intentionally platform-neutral. Internally, macOS uses
4//! Seatbelt (`sandbox-exec`), Linux uses Bubblewrap (`bwrap`), and Windows uses
5//! Bubblewrap inside WSL. Restricted-network policies are enforced by an
6//! in-process HTTP/HTTPS proxy (the `http_proxy` crate) that this crate
7//! constructs and owns; callers only describe intent via [`SandboxPolicy`].
8
9use std::{
10 collections::HashMap,
11 fmt,
12 path::{Path, PathBuf},
13 process::Output,
14};
15
16use http_proxy::ProxyHandle;
17#[cfg(not(target_os = "windows"))]
18use http_proxy::{Allowlist, HostPattern, ProxyConfig, ProxyEvent, UpstreamProxy};
19#[cfg(target_os = "linux")]
20use std::os::fd::AsRawFd as _;
21
22#[cfg(target_os = "linux")]
23mod linux_bubblewrap;
24
25#[cfg(target_os = "macos")]
26mod macos_seatbelt;
27
28#[cfg(target_os = "windows")]
29mod windows_wsl;
30
31#[cfg(target_os = "windows")]
32pub(crate) const WSL_SANDBOX_UNAVAILABLE_PREFIX: &str = "Windows sandboxing via WSL is unavailable";
33
34/// An opaque handle to a location on the **host** filesystem the sandbox may
35/// grant access to or protect (for example, a writable or protected subtree).
36///
37/// The entire purpose of this type is to capture the *security-relevant identity*
38/// of a host location once, up front, in a form the enforcement layer can use
39/// without re-resolving a path string later. Re-resolving a path at enforcement
40/// time is the classic time-of-check-to-time-of-use hole: a path that was
41/// verified as safe can be swapped for a symlink before the sandbox actually
42/// binds/allows it, redirecting the grant to an arbitrary host location.
43///
44/// What is captured is platform-specific:
45/// - **macOS**: the fully-canonicalized path, used verbatim as the Seatbelt rule
46/// literal. Seatbelt matches the *resolved* access path against this literal,
47/// so a post-capture swap of a path component fails closed (denied) rather
48/// than redirecting the grant.
49/// - **Linux**: an `O_PATH` file descriptor pinned to the target inode. bwrap is
50/// launched by a PTY that can't inherit extra fds, so we can't use bwrap's own
51/// `--bind-fd`; instead the bind uses an ordinary `--bind <path>` and an
52/// in-sandbox validator compares `fstat` of this descriptor against `lstat` of
53/// the mounted path after the mounts, failing closed on a post-capture swap
54/// (see `linux_bubblewrap::validate_binds` and `README.md`).
55/// - **Windows**: nothing — a Windows process holds no Linux fds, so the real
56/// capture-at-validation happens inside WSL (in the `--wsl-sandbox-helper`),
57/// and the value here carries only the requested path as untrusted intent.
58///
59/// The type is deliberately **opaque**: it does not `Deref`, and it never hands
60/// back its trusted value. The only thing readable is a *display-only* path via
61/// [`HostFilesystemLocation::untrusted_path_display`], suitable for showing a
62/// human but which must never be passed back into a sandbox API as the
63/// location's identity. Equality reflects the actual filesystem object (same
64/// inode), not the textual path.
65#[derive(Clone)]
66pub struct HostFilesystemLocation {
67 /// macOS: the canonicalized path, resolved exactly once at capture time and
68 /// used directly as the Seatbelt rule literal.
69 #[cfg(target_os = "macos")]
70 canonical_path: PathBuf,
71 /// Linux: an `O_PATH` descriptor pinned to the captured inode. Wrapped in an
72 /// `Arc` only so the surrounding policy types can stay `Clone`; cloning
73 /// shares the same underlying descriptor.
74 #[cfg(target_os = "linux")]
75 fd: std::sync::Arc<std::os::fd::OwnedFd>,
76 /// The path exactly as the caller requested it. Kept **only** so the UI can
77 /// show the user which location is being granted. This is never consulted by
78 /// any enforcement path — treat it as untrusted, attacker-influenced text.
79 untrusted_path_for_display: PathBuf,
80}
81
82impl HostFilesystemLocation {
83 /// Capture `path` as a host sandbox location, resolving its identity up front.
84 ///
85 /// On macOS this canonicalizes the path; on Linux it opens an `O_PATH`
86 /// descriptor to it; on Windows it records nothing. The caller is
87 /// responsible for having already *validated* `path` (e.g. confirmed it is
88 /// inside the project, or safe to treat as a protected path) — capturing it here
89 /// pins that decision against later tampering. To be race-free, capture
90 /// should happen as part of, or immediately after, that validation, and the
91 /// resulting value should be passed around unchanged from then on (never
92 /// re-derived from a path).
93 pub fn new(path: impl AsRef<Path>) -> std::io::Result<Self> {
94 let path = path.as_ref();
95 let untrusted_path_for_display = path.to_path_buf();
96
97 #[cfg(target_os = "macos")]
98 {
99 // `canonicalize_allowing_missing_leaf` resolves through the existing
100 // parent so a not-yet-created leaf still yields the real path
101 // Seatbelt will match against.
102 let canonical_path = canonicalize_allowing_missing_leaf(path);
103 Ok(Self {
104 canonical_path,
105 untrusted_path_for_display,
106 })
107 }
108 #[cfg(target_os = "linux")]
109 {
110 use std::os::unix::fs::OpenOptionsExt as _;
111 // `O_PATH` opens a handle that refers to the inode without granting
112 // read/write on its contents, which is exactly what a bind source
113 // needs. `O_CLOEXEC` keeps the descriptor from leaking into
114 // unrelated children; the bind step re-publishes it deliberately
115 // when launching bwrap.
116 let file = std::fs::OpenOptions::new()
117 .read(true)
118 .custom_flags(libc::O_PATH | libc::O_CLOEXEC)
119 .open(path)?;
120 Ok(Self {
121 fd: std::sync::Arc::new(std::os::fd::OwnedFd::from(file)),
122 untrusted_path_for_display,
123 })
124 }
125 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
126 {
127 Ok(Self {
128 untrusted_path_for_display,
129 })
130 }
131 }
132
133 /// The requested path, for **display only** (e.g. the permission-request UI).
134 ///
135 /// This intentionally returns the untrusted, as-requested path — never the
136 /// captured trusted identity. Do not feed the result back into any sandbox
137 /// API as if it identified this location.
138 pub fn untrusted_path_display(&self) -> std::path::Display<'_> {
139 self.untrusted_path_for_display.display()
140 }
141
142 /// macOS: the canonical path captured once at construction, used verbatim as
143 /// the Seatbelt rule literal. Trusted — never re-resolved. Falls back to the
144 /// requested path for a display-only location (which must never reach
145 /// enforcement).
146 #[cfg(target_os = "macos")]
147 pub(crate) fn macos_canonical_path(&self) -> &Path {
148 &self.canonical_path
149 }
150
151 /// Linux: a borrowed handle to the pinned inode, for deriving a bind source
152 /// path and for `fstat`-based identity checks.
153 #[cfg(target_os = "linux")]
154 pub(crate) fn linux_fd(&self) -> std::os::fd::BorrowedFd<'_> {
155 use std::os::fd::AsFd as _;
156 self.fd.as_fd()
157 }
158
159 /// Linux: an independent `O_PATH` descriptor to the same pinned inode,
160 /// duplicated (with `O_CLOEXEC`) so the validation server can own and send it
161 /// over `SCM_RIGHTS` without affecting this location's descriptor.
162 #[cfg(target_os = "linux")]
163 pub(crate) fn linux_dup_fd(&self) -> std::io::Result<std::os::fd::OwnedFd> {
164 use std::os::fd::AsFd as _;
165 self.fd.as_fd().try_clone_to_owned()
166 }
167
168 /// Windows: the requested path, to be mapped into WSL and handed to the
169 /// in-WSL helper. Windows captures no identity itself (it holds no Linux
170 /// fds); the real capture-at-validation happens WSL-side in the helper, so
171 /// here the requested path *is* the location.
172 #[cfg(target_os = "windows")]
173 pub(crate) fn windows_path(&self) -> &Path {
174 &self.untrusted_path_for_display
175 }
176}
177
178impl fmt::Debug for HostFilesystemLocation {
179 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
180 // Only the display path is shown; the trusted identity stays opaque.
181 formatter
182 .debug_struct("HostFilesystemLocation")
183 .field(
184 "untrusted_path_for_display",
185 &self.untrusted_path_for_display,
186 )
187 .finish_non_exhaustive()
188 }
189}
190
191impl PartialEq for HostFilesystemLocation {
192 /// Two locations are equal when they refer to the **same filesystem object**,
193 /// determined from the captured identity (the inode behind the `O_PATH` fd on
194 /// Linux, the canonical path on macOS) — never from the textual
195 /// display path. This is what lets policy bookkeeping dedupe "the same
196 /// location named two different ways," and refuse to treat "two different
197 /// objects that happen to share a path string" as one.
198 fn eq(&self, other: &Self) -> bool {
199 #[cfg(target_os = "linux")]
200 {
201 match (
202 linux_fd_identity(self.fd.as_raw_fd()),
203 linux_fd_identity(other.fd.as_raw_fd()),
204 ) {
205 (Some(a), Some(b)) => a == b,
206 // An `fstat` on an `O_PATH` fd we own should never fail; if it
207 // somehow does we can't prove identity, so report "not equal"
208 // (the safe answer) and leave a trace.
209 _ => {
210 log::error!(
211 "failed to fstat an O_PATH descriptor while comparing sandbox locations"
212 );
213 false
214 }
215 }
216 }
217 #[cfg(target_os = "macos")]
218 {
219 // Canonicalization is a bijection on real paths, so equal canonical
220 // paths mean the same directory/file.
221 self.canonical_path == other.canonical_path
222 }
223 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
224 {
225 // No enforcement and no captured identity on these platforms; fall
226 // back to the requested path purely so the type can still be used in
227 // collections.
228 self.untrusted_path_for_display == other.untrusted_path_for_display
229 }
230 }
231}
232
233impl Eq for HostFilesystemLocation {}
234
235/// The `(device, inode)` pair behind an `O_PATH` descriptor, used to decide
236/// whether two [`HostFilesystemLocation`]s refer to the same filesystem object.
237#[cfg(target_os = "linux")]
238fn linux_fd_identity(fd: std::os::fd::RawFd) -> Option<(u64, u64)> {
239 let stat = nix::sys::stat::fstat(fd).ok()?;
240 Some((stat.st_dev as u64, stat.st_ino as u64))
241}
242
243/// A path *inside the sandbox* — i.e. where a host location is exposed in the
244/// sandboxed process's view of the filesystem (for example, a bind-mount
245/// destination on Linux).
246///
247/// Unlike [`HostFilesystemLocation`], this needs no hardening and is just a thin
248/// wrapper around a [`PathBuf`]. It only names a location within the sandbox's
249/// own namespace: the worst a tampered sandbox-side path can do is expose the
250/// (already-granted) host files at a *different* path inside the sandbox — it can
251/// never widen which host files are reachable. It is therefore fine to build one
252/// from an ordinary, even attacker-influenced, path.
253#[derive(Clone, Debug, Eq, PartialEq, Hash)]
254pub struct SandboxFilesystemLocation(PathBuf);
255
256impl SandboxFilesystemLocation {
257 /// Name a location inside the sandbox's filesystem view.
258 pub fn new(path: impl Into<PathBuf>) -> Self {
259 Self(path.into())
260 }
261
262 /// The in-sandbox path. Safe to read: this is not a trusted host identity.
263 pub fn as_path(&self) -> &Path {
264 &self.0
265 }
266
267 /// Consume this wrapper, yielding the underlying in-sandbox path.
268 pub fn into_path_buf(self) -> PathBuf {
269 self.0
270 }
271}
272
273impl From<PathBuf> for SandboxFilesystemLocation {
274 fn from(path: PathBuf) -> Self {
275 Self(path)
276 }
277}
278
279/// What a command is allowed to do, expressed as intent. This is the entire
280/// public configuration surface; how each policy is enforced (Seatbelt rules,
281/// Bubblewrap flags, a loopback proxy, …) is an implementation detail.
282#[derive(Clone, Debug, Eq, PartialEq)]
283pub struct SandboxPolicy {
284 pub fs: SandboxFsPolicy,
285 pub network: SandboxNetPolicy,
286}
287
288/// Filesystem policy for a sandboxed command.
289#[derive(Clone, Debug, Eq, PartialEq)]
290pub enum SandboxFsPolicy {
291 /// Allow unrestricted filesystem writes except for protected paths, which
292 /// remain readable but not writable.
293 Unrestricted {
294 protected_paths: Vec<HostFilesystemLocation>,
295 },
296 /// Reads are allowed everywhere; writes are confined to these locations
297 /// (and the standard ephemeral locations the platform provides). Each is a
298 /// [`HostFilesystemLocation`] captured at validation time, never a bare path
299 /// the enforcement layer would re-resolve.
300 Restricted {
301 writable_paths: Vec<HostFilesystemLocation>,
302 protected_paths: Vec<HostFilesystemLocation>,
303 },
304}
305
306/// Outbound-network policy for a sandboxed command.
307#[derive(Clone, Debug, Eq, PartialEq)]
308pub enum SandboxNetPolicy {
309 /// Allow unrestricted outbound network access.
310 Unrestricted,
311 /// Block all outbound network access.
312 Blocked,
313 /// Allow outbound HTTP(S) only to these hostnames (exact hosts or
314 /// leading-`*.` subdomain wildcards), enforced by an in-process proxy.
315 Restricted { allowed_domains: Vec<String> },
316}
317
318/// Host paths that should remain readable but not writable, even when they fall
319/// under a writable subtree.
320///
321/// The caller computes this list because the sandbox layer does not know which
322/// application-specific paths need stronger protection.
323pub type ProtectedPaths = Vec<HostFilesystemLocation>;
324
325impl SandboxPolicy {
326 /// Combine two policy layers. Filesystem/network grants are unioned into the
327 /// least-restrictive policy that satisfies both layers, while protected paths
328 /// within restricted filesystem policies are unioned so every layer's
329 /// protected subtrees remain protected.
330 pub fn merge(self, other: SandboxPolicy) -> SandboxPolicy {
331 SandboxPolicy {
332 fs: self.fs.merge(other.fs),
333 network: self.network.merge(other.network),
334 }
335 }
336
337 /// Replace the protected paths in the filesystem policy, keeping writable
338 /// paths and network policy unchanged.
339 pub fn with_protected_paths(mut self, protected_paths: Vec<HostFilesystemLocation>) -> Self {
340 match &mut self.fs {
341 SandboxFsPolicy::Unrestricted {
342 protected_paths: existing,
343 }
344 | SandboxFsPolicy::Restricted {
345 protected_paths: existing,
346 ..
347 } => *existing = protected_paths,
348 }
349 self
350 }
351}
352
353fn merge_locations(
354 mut locations: Vec<HostFilesystemLocation>,
355 other: Vec<HostFilesystemLocation>,
356) -> Vec<HostFilesystemLocation> {
357 for location in other {
358 if !locations.contains(&location) {
359 locations.push(location);
360 }
361 }
362 locations
363}
364
365fn validate_writable_paths_do_not_overlap_protected_paths(
366 writable_paths: &[HostFilesystemLocation],
367 protected_paths: &[HostFilesystemLocation],
368) -> Result<(), SandboxError> {
369 for writable_path in writable_paths {
370 for protected_path in protected_paths {
371 if writable_path_overlaps_protected_path(writable_path, protected_path) {
372 return Err(SandboxError::InvalidRequest(format!(
373 "writable sandbox path `{}` overlaps protected path `{}`",
374 writable_path.untrusted_path_display(),
375 protected_path.untrusted_path_display()
376 )));
377 }
378 }
379 }
380 Ok(())
381}
382
383#[cfg(target_os = "linux")]
384fn writable_path_overlaps_protected_path(
385 writable_path: &HostFilesystemLocation,
386 protected_path: &HostFilesystemLocation,
387) -> bool {
388 linux_location_is_equal_or_descendant(writable_path, protected_path)
389}
390
391#[cfg(target_os = "macos")]
392fn writable_path_overlaps_protected_path(
393 writable_path: &HostFilesystemLocation,
394 protected_path: &HostFilesystemLocation,
395) -> bool {
396 writable_path
397 .macos_canonical_path()
398 .starts_with(protected_path.macos_canonical_path())
399}
400
401#[cfg(not(any(target_os = "linux", target_os = "macos")))]
402fn writable_path_overlaps_protected_path(
403 writable_path: &HostFilesystemLocation,
404 protected_path: &HostFilesystemLocation,
405) -> bool {
406 writable_path
407 .untrusted_path_for_display
408 .starts_with(&protected_path.untrusted_path_for_display)
409}
410
411#[cfg(target_os = "linux")]
412fn linux_location_is_equal_or_descendant(
413 location: &HostFilesystemLocation,
414 ancestor: &HostFilesystemLocation,
415) -> bool {
416 use std::os::fd::{AsRawFd as _, FromRawFd as _, OwnedFd};
417
418 if location == ancestor {
419 return true;
420 }
421
422 let Ok(mut current) = location.linux_dup_fd() else {
423 log::warn!("failed to duplicate sandbox location fd while checking protected path overlap");
424 return false;
425 };
426
427 loop {
428 let parent = unsafe {
429 let fd = libc::openat(
430 current.as_raw_fd(),
431 c"..".as_ptr(),
432 libc::O_PATH | libc::O_DIRECTORY | libc::O_CLOEXEC,
433 );
434 if fd < 0 {
435 return false;
436 }
437 OwnedFd::from_raw_fd(fd)
438 };
439
440 let Some(parent_identity) = linux_fd_identity(parent.as_raw_fd()) else {
441 log::warn!("failed to fstat parent fd while checking protected path overlap");
442 return false;
443 };
444 let Some(current_identity) = linux_fd_identity(current.as_raw_fd()) else {
445 log::warn!("failed to fstat current fd while checking protected path overlap");
446 return false;
447 };
448 let Some(ancestor_identity) = linux_fd_identity(ancestor.linux_fd().as_raw_fd()) else {
449 log::warn!("failed to fstat protected fd while checking protected path overlap");
450 return false;
451 };
452
453 if parent_identity == ancestor_identity {
454 return true;
455 }
456 if parent_identity == current_identity {
457 return false;
458 }
459 current = parent;
460 }
461}
462
463impl SandboxFsPolicy {
464 /// Unrestricted access dominates; otherwise the writable subtrees union.
465 pub fn merge(self, other: SandboxFsPolicy) -> SandboxFsPolicy {
466 match (self, other) {
467 (
468 SandboxFsPolicy::Unrestricted {
469 protected_paths: protected_a,
470 },
471 SandboxFsPolicy::Unrestricted {
472 protected_paths: protected_b,
473 },
474 ) => SandboxFsPolicy::Unrestricted {
475 protected_paths: merge_locations(protected_a, protected_b),
476 },
477 (
478 SandboxFsPolicy::Unrestricted {
479 protected_paths: protected_a,
480 },
481 SandboxFsPolicy::Restricted {
482 protected_paths: protected_b,
483 ..
484 },
485 )
486 | (
487 SandboxFsPolicy::Restricted {
488 protected_paths: protected_a,
489 ..
490 },
491 SandboxFsPolicy::Unrestricted {
492 protected_paths: protected_b,
493 },
494 ) => SandboxFsPolicy::Unrestricted {
495 protected_paths: merge_locations(protected_a, protected_b),
496 },
497 (
498 SandboxFsPolicy::Restricted {
499 writable_paths: writable_a,
500 protected_paths: protected_a,
501 },
502 SandboxFsPolicy::Restricted {
503 writable_paths: writable_b,
504 protected_paths: protected_b,
505 },
506 ) => SandboxFsPolicy::Restricted {
507 writable_paths: merge_locations(writable_a, writable_b),
508 protected_paths: merge_locations(protected_a, protected_b),
509 },
510 }
511 }
512}
513
514impl SandboxNetPolicy {
515 /// Unrestricted access dominates and `Blocked` is the identity; otherwise the
516 /// allowed domains union.
517 pub fn merge(self, other: SandboxNetPolicy) -> SandboxNetPolicy {
518 match (self, other) {
519 (SandboxNetPolicy::Unrestricted, _) | (_, SandboxNetPolicy::Unrestricted) => {
520 SandboxNetPolicy::Unrestricted
521 }
522 (SandboxNetPolicy::Blocked, other) | (other, SandboxNetPolicy::Blocked) => other,
523 (
524 SandboxNetPolicy::Restricted {
525 allowed_domains: mut a,
526 },
527 SandboxNetPolicy::Restricted { allowed_domains: b },
528 ) => {
529 for domain in b {
530 if !a.contains(&domain) {
531 a.push(domain);
532 }
533 }
534 SandboxNetPolicy::Restricted { allowed_domains: a }
535 }
536 }
537 }
538}
539
540/// A command and its execution environment, before sandbox wrapping.
541#[derive(Clone, Debug, Default, Eq, PartialEq)]
542pub struct CommandAndArgs {
543 /// Program to execute.
544 pub program: String,
545 /// Arguments passed to `program`.
546 pub args: Vec<String>,
547 /// Environment variables for the spawned process.
548 pub env: HashMap<String, String>,
549 /// Working directory for the spawned process.
550 pub cwd: Option<PathBuf>,
551}
552
553/// A command transformed to run inside the platform sandbox. Plain data: spawn
554/// it however you like (PTY, `std::process`, …). The resources that must
555/// outlive the spawned process live in the [`Sandbox`] that produced this, not
556/// here — keep the `Sandbox` alive for the command's duration.
557#[derive(Clone, Debug, Default, Eq, PartialEq)]
558pub struct WrappedCommand {
559 pub program: String,
560 pub args: Vec<String>,
561 pub env: HashMap<String, String>,
562 pub cwd: Option<PathBuf>,
563}
564
565/// Errors returned by the sandbox abstraction.
566#[derive(Debug, Clone, Eq, PartialEq)]
567pub enum SandboxError {
568 /// No sandbox implementation is available for this platform.
569 UnsupportedPlatform,
570 /// No usable Bubblewrap executable was found.
571 BwrapNotFound,
572 /// The only Bubblewrap executable found is setuid-root, which is refused.
573 BwrapSetuidRejected,
574 /// Bubblewrap was found but failed to create the sandbox.
575 SandboxProbeFailed,
576 /// The sandbox bridge executable path could not be resolved.
577 BridgeExecutableUnavailable(String),
578 /// Windows sandboxing through WSL is unavailable.
579 WslUnavailable(String),
580 /// The requested sandbox policy is not supported on this platform.
581 UnsupportedPolicy(String),
582 /// The sandbox request is invalid (e.g. a malformed allowed-domain).
583 InvalidRequest(String),
584 /// An I/O error occurred (e.g. spawning the network proxy).
585 Io(String),
586 /// Any other sandbox setup failure.
587 Other(String),
588}
589
590impl fmt::Display for SandboxError {
591 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
592 match self {
593 SandboxError::UnsupportedPlatform => {
594 write!(formatter, "sandboxing is not supported on this platform")
595 }
596 SandboxError::BwrapNotFound => {
597 write!(formatter, "no usable `bwrap` binary was found on PATH")
598 }
599 SandboxError::BwrapSetuidRejected => write!(
600 formatter,
601 "the only available `bwrap` is setuid-root, which Omega refuses to run"
602 ),
603 SandboxError::SandboxProbeFailed => {
604 write!(
605 formatter,
606 "`bwrap` is present but failed to create a sandbox"
607 )
608 }
609 SandboxError::BridgeExecutableUnavailable(message) => write!(
610 formatter,
611 "failed to resolve sandbox bridge executable: {message}"
612 ),
613 SandboxError::WslUnavailable(message) => write!(formatter, "{message}"),
614 SandboxError::UnsupportedPolicy(message) => write!(formatter, "{message}"),
615 SandboxError::InvalidRequest(message) => write!(formatter, "{message}"),
616 SandboxError::Io(message) => write!(formatter, "{message}"),
617 SandboxError::Other(message) => write!(formatter, "{message}"),
618 }
619 }
620}
621
622impl std::error::Error for SandboxError {}
623
624/// Resolved filesystem setup derived from [`SandboxFsPolicy`].
625struct FsSetup {
626 allow_fs_write: bool,
627 writable_paths: Vec<HostFilesystemLocation>,
628 protected_paths: Vec<HostFilesystemLocation>,
629}
630
631/// Resolved network plan derived from [`SandboxNetPolicy`]. For the restricted
632/// case the allowlist is parsed up front; the proxy itself is spawned lazily on
633/// the first [`Sandbox::wrap`] so its upstream-proxy chaining can read the
634/// command's environment.
635enum NetSetup {
636 Unrestricted,
637 Blocked,
638 // Restricted networking is rejected up front on Windows, so this variant is
639 // never constructed there.
640 #[cfg(not(target_os = "windows"))]
641 Restricted {
642 allowlist: Allowlist,
643 },
644}
645
646/// A live sandbox: it owns the per-policy resources (the network proxy, and on
647/// macOS the temporary Seatbelt policy file) and produces sandboxed command
648/// invocations via [`Sandbox::wrap`] / [`Sandbox::execute`].
649///
650/// Keep it alive for as long as commands wrapped by it are running; dropping it
651/// tears down the proxy.
652pub struct Sandbox {
653 fs: FsSetup,
654 network: NetSetup,
655 /// In-process network proxy for the restricted-network case, spawned on the
656 /// first `wrap`. Dropped on a background thread (the join blocks).
657 proxy: Option<ProxyHandle>,
658 /// Linux only: the host endpoint that hands the in-sandbox validator the
659 /// captured `O_PATH` fds over a unix socket. Runs entirely in-process (a
660 /// short-lived background thread, never a separate process) and is owned by
661 /// this `Sandbox` — which is created per command — so it comes up when the
662 /// command is wrapped and is torn down (thread stopped, socket removed) when
663 /// the command finishes. Holds the fds, keeping their inodes pinned until
664 /// then. Created lazily on the wrap that first needs it (a restricted-fs run
665 /// with writable binds); a `Sandbox` normally wraps a single command.
666 #[cfg(target_os = "linux")]
667 validation_fd_sender: Option<linux_bubblewrap::ValidationFdSender>,
668 /// Windows only: `(release channel, version)` of the Linux `zed` to
669 /// provision inside WSL as the `--wsl-sandbox-helper` (version `latest` for
670 /// dev builds). Set by the caller (which has the running release info);
671 /// `None` falls back to exec'ing bwrap directly without in-sandbox bind
672 /// validation.
673 #[cfg(target_os = "windows")]
674 wsl_zed_release: Option<(String, String)>,
675 #[cfg(target_os = "macos")]
676 seatbelt_config: Option<macos_seatbelt::SeatbeltConfigFile>,
677}
678
679impl Sandbox {
680 /// Create a sandbox for `policy`, validating it for the current platform.
681 ///
682 /// This does not spawn the network proxy or probe `bwrap`; the proxy is
683 /// created lazily on the first [`Sandbox::wrap`] (so it can chain through an
684 /// upstream proxy named in the command's environment), and `bwrap`
685 /// availability is checked separately via [`Sandbox::can_create`].
686 pub fn new(policy: SandboxPolicy) -> Result<Self, SandboxError> {
687 let fs = match policy.fs {
688 SandboxFsPolicy::Unrestricted { protected_paths } => FsSetup {
689 allow_fs_write: true,
690 writable_paths: Vec::new(),
691 protected_paths,
692 },
693 SandboxFsPolicy::Restricted {
694 writable_paths,
695 protected_paths,
696 } => {
697 validate_writable_paths_do_not_overlap_protected_paths(
698 &writable_paths,
699 &protected_paths,
700 )?;
701 FsSetup {
702 allow_fs_write: false,
703 writable_paths,
704 protected_paths,
705 }
706 }
707 };
708
709 let network = match policy.network {
710 SandboxNetPolicy::Unrestricted => NetSetup::Unrestricted,
711 SandboxNetPolicy::Blocked => NetSetup::Blocked,
712 SandboxNetPolicy::Restricted { allowed_domains } => {
713 resolve_restricted_network(&allowed_domains)?
714 }
715 };
716
717 Ok(Self {
718 fs,
719 network,
720 proxy: None,
721 #[cfg(target_os = "linux")]
722 validation_fd_sender: None,
723 #[cfg(target_os = "windows")]
724 wsl_zed_release: None,
725 #[cfg(target_os = "macos")]
726 seatbelt_config: None,
727 })
728 }
729
730 /// Windows only: record the `(release channel, version)` of the Linux `zed`
731 /// to provision inside WSL as the sandbox helper (version `latest` for dev
732 /// builds). The caller resolves these from the running app's release info
733 /// (which this low-level crate can't read) and sets them before `wrap`. When
734 /// unset, the WSL backend falls back to exec'ing bwrap directly without
735 /// in-sandbox bind validation.
736 #[cfg(target_os = "windows")]
737 pub fn set_wsl_zed_release(&mut self, channel: String, version: String) {
738 self.wsl_zed_release = Some((channel, version));
739 }
740
741 /// Check whether the platform sandbox can be created on this host without
742 /// actually building a command or spawning the proxy. On Linux this runs a
743 /// brief `bwrap` probe (call it off the main thread).
744 ///
745 /// This answers a purely *environmental* question — is a bwrap sandbox
746 /// possible here at all (a usable `bwrap`, plus the unprivileged user
747 /// namespace and mount scaffolding we rely on)? It deliberately does **not**
748 /// depend on any command's writable grants or working directory: the probe
749 /// runs a bare, representative sandbox and runs `true` in it. (Unlike the
750 /// former Landlock probe, bwrap needs no ABI-matching against the real
751 /// ruleset, so there's no reason to mirror the real command's mounts.)
752 pub fn can_create(policy: &SandboxPolicy) -> Result<(), SandboxError> {
753 #[cfg(target_os = "linux")]
754 {
755 let permissions = linux_bubblewrap::SandboxPermissions {
756 network: linux_probe_network(&policy.network),
757 allow_fs_write: matches!(policy.fs, SandboxFsPolicy::Unrestricted { .. }),
758 };
759 linux_bubblewrap::check_can_create_sandbox(permissions).map_err(map_linux_status)
760 }
761 #[cfg(target_os = "windows")]
762 {
763 if matches!(policy.network, SandboxNetPolicy::Restricted { .. }) {
764 return Err(unsupported_restricted_network_on_windows());
765 }
766 Ok(())
767 }
768 #[cfg(not(any(target_os = "linux", target_os = "windows")))]
769 {
770 let _ = policy;
771 Ok(())
772 }
773 }
774
775 /// Transform `command` into the invocation that runs it inside this sandbox.
776 /// The returned [`WrappedCommand`] is plain data; keep `self` alive while it
777 /// runs (it owns the proxy and any per-command policy file).
778 pub async fn wrap(&mut self, command: &CommandAndArgs) -> Result<WrappedCommand, SandboxError> {
779 #[cfg(target_os = "linux")]
780 {
781 self.wrap_linux(command)
782 }
783 #[cfg(target_os = "macos")]
784 {
785 self.wrap_macos(command)
786 }
787 #[cfg(target_os = "windows")]
788 {
789 self.wrap_windows(command).await
790 }
791 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
792 {
793 let _ = command;
794 Err(SandboxError::UnsupportedPlatform)
795 }
796 }
797
798 /// Run `command` inside the sandbox to completion and collect its output.
799 /// Convenience for non-interactive callers; interactive callers (PTYs) use
800 /// [`Sandbox::wrap`].
801 #[allow(
802 clippy::disallowed_methods,
803 reason = "this is the blocking convenience API; interactive callers use `wrap`"
804 )]
805 pub async fn execute(&mut self, command: &CommandAndArgs) -> Result<Output, SandboxError> {
806 let wrapped = self.wrap(command).await?;
807 let mut process = std::process::Command::new(&wrapped.program);
808 process.args(&wrapped.args).envs(&wrapped.env);
809 if let Some(cwd) = &wrapped.cwd {
810 process.current_dir(cwd);
811 }
812 process
813 .output()
814 .map_err(|error| SandboxError::Io(error.to_string()))
815 }
816
817 /// Drop this sandbox on the *current* thread, tearing down the network proxy
818 /// inline (its `Drop` joins a listener thread after a loopback wakeup
819 /// connect).
820 ///
821 /// The blanket [`Drop`] impl instead offloads that join to a fresh thread,
822 /// so an accidental drop on a latency-sensitive thread (e.g. the UI thread)
823 /// can never block. Call this only when you are *already* on a background
824 /// thread or executor and want the teardown to finish before the surrounding
825 /// task completes — it avoids spawning a throwaway thread to do work the
826 /// current one can do.
827 pub fn drop_on_current_thread(mut self) {
828 // Drop the proxy here, synchronously, so the `Drop` impl below sees
829 // `None` and doesn't spawn a thread to repeat the work.
830 drop(self.proxy.take());
831 }
832
833 /// Spawn the restricted-network proxy if it isn't running yet, point the
834 /// command env at it, and return its `(port, host socket path)`. Returns
835 /// `None` for non-restricted network policies.
836 #[cfg(not(target_os = "windows"))]
837 fn ensure_restricted_proxy(
838 &mut self,
839 env: &mut HashMap<String, String>,
840 ) -> Result<Option<(u16, Option<PathBuf>)>, SandboxError> {
841 let NetSetup::Restricted { allowlist } = &self.network else {
842 return Ok(None);
843 };
844
845 if self.proxy.is_none() {
846 let upstream = upstream_proxy_from_env(env);
847 let (events_tx, events_rx) = futures::channel::mpsc::unbounded();
848 let config = ProxyConfig {
849 allowlist: allowlist.clone(),
850 upstream,
851 events: events_tx,
852 };
853 #[cfg(target_os = "linux")]
854 let handle = ProxyHandle::spawn_unix_temp(config);
855 #[cfg(not(target_os = "linux"))]
856 let handle = ProxyHandle::spawn(config);
857 let handle = handle.map_err(|error| {
858 SandboxError::Io(format!("failed to start network proxy: {error:#}"))
859 })?;
860 spawn_proxy_event_logger(events_rx);
861 self.proxy = Some(handle);
862 }
863
864 let proxy = self
865 .proxy
866 .as_ref()
867 .expect("proxy was just ensured to be present");
868 let port = proxy.port();
869 apply_proxy_env(env, port);
870 Ok(Some((port, proxy.socket_path().map(PathBuf::from))))
871 }
872
873 /// Return the protected paths for the enforcement layer. Even with broad
874 /// filesystem writes, protected paths remain readable but not writable.
875 #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
876 fn protected_paths(&self) -> Vec<HostFilesystemLocation> {
877 self.fs.protected_paths.clone()
878 }
879
880 #[cfg(target_os = "linux")]
881 fn wrap_linux(&mut self, command: &CommandAndArgs) -> Result<WrappedCommand, SandboxError> {
882 let mut env = command.env.clone();
883 let proxy = self.ensure_restricted_proxy(&mut env)?;
884
885 let network = match &self.network {
886 NetSetup::Unrestricted => linux_bubblewrap::NetworkAccess::All,
887 NetSetup::Blocked => linux_bubblewrap::NetworkAccess::None,
888 NetSetup::Restricted { .. } => linux_bubblewrap::NetworkAccess::LocalhostPort(
889 proxy.as_ref().map(|(port, _)| *port).unwrap_or(0),
890 ),
891 };
892 let proxy_socket_path = proxy.as_ref().and_then(|(_, socket)| socket.clone());
893
894 let permissions = linux_bubblewrap::SandboxPermissions {
895 network,
896 allow_fs_write: self.fs.allow_fs_write,
897 };
898 let protected_paths = self.protected_paths();
899 // Build the writable binds as (captured fd, bind path) pairs in lockstep.
900 // The bind *path* is derived from the pinned inode (readlink of the
901 // captured `O_PATH` fd), never from an attacker-influenceable string; the
902 // *fd* is what the in-sandbox validator compares the mounted inode
903 // against. The two lists stay in the same order so each fd lines up with
904 // its path on the validator side.
905 let mut writable_owned: Vec<PathBuf> = Vec::new();
906 let mut writable_fds: Vec<std::os::fd::OwnedFd> = Vec::new();
907 for location in &self.fs.writable_paths {
908 let Some(path) = linux_location_path(location) else {
909 continue;
910 };
911 match location.linux_dup_fd() {
912 Ok(fd) => {
913 writable_owned.push(path);
914 writable_fds.push(fd);
915 }
916 Err(error) => {
917 // Fail closed: a bind we can't pin a verifiable fd for is
918 // dropped rather than bound unverified.
919 log::warn!(
920 "[sandbox] could not duplicate fd for writable bind {}: {error}",
921 path.display()
922 );
923 }
924 }
925 }
926 let writable: Vec<&Path> = writable_owned.iter().map(PathBuf::as_path).collect();
927 let protected_owned: Vec<PathBuf> = protected_paths
928 .iter()
929 .filter_map(linux_location_path)
930 .collect();
931 let protected_paths: Vec<&Path> = protected_owned.iter().map(PathBuf::as_path).collect();
932
933 // Stand up the host endpoint that sends the captured fds to the
934 // in-sandbox validator, when this run has writable binds to verify. It's
935 // an in-process background thread owned by this (per-command) `Sandbox`,
936 // so it lives only for the command's duration. The sender serves its
937 // descriptors to exactly one client and then tears itself down, so each
938 // wrap creates a fresh one (replacing any from a prior wrap); a
939 // `Sandbox` normally wraps a single command.
940 if !self.fs.allow_fs_write && !writable_fds.is_empty() {
941 let sender =
942 linux_bubblewrap::ValidationFdSender::spawn(writable_fds).map_err(|error| {
943 SandboxError::Io(format!("failed to start sandbox bind validator: {error}"))
944 })?;
945 self.validation_fd_sender = Some(sender);
946 }
947 let validation_socket =
948 self.validation_fd_sender
949 .as_ref()
950 .map(|sender| linux_bubblewrap::ValidationSocket {
951 host_socket_path: sender.host_socket_path(),
952 sandbox_socket_path: sender.sandbox_socket_path(),
953 });
954
955 let bridge_program = std::env::current_exe()
956 .map_err(|error| SandboxError::BridgeExecutableUnavailable(error.to_string()))?;
957 let bridge_program = bridge_program.to_str().ok_or_else(|| {
958 SandboxError::BridgeExecutableUnavailable(format!(
959 "current executable path contains invalid UTF-8: {}",
960 bridge_program.display()
961 ))
962 })?;
963
964 let (program, args) = linux_bubblewrap::wrap_invocation(
965 bridge_program,
966 permissions,
967 &writable,
968 &protected_paths,
969 command.cwd.as_deref(),
970 &command.program,
971 &command.args,
972 proxy_socket_path.as_deref(),
973 validation_socket,
974 )
975 .map_err(map_anyhow_error)?;
976
977 Ok(WrappedCommand {
978 program,
979 args,
980 env,
981 cwd: command.cwd.clone(),
982 })
983 }
984
985 #[cfg(target_os = "macos")]
986 fn wrap_macos(&mut self, command: &CommandAndArgs) -> Result<WrappedCommand, SandboxError> {
987 let mut env = command.env.clone();
988 let proxy = self.ensure_restricted_proxy(&mut env)?;
989
990 let network = match &self.network {
991 NetSetup::Unrestricted => macos_seatbelt::NetworkAccess::All,
992 NetSetup::Blocked => macos_seatbelt::NetworkAccess::None,
993 NetSetup::Restricted { .. } => macos_seatbelt::NetworkAccess::LocalhostPort(
994 proxy.as_ref().map(|(port, _)| *port).unwrap_or(0),
995 ),
996 };
997
998 let permissions = macos_seatbelt::SandboxPermissions {
999 network,
1000 allow_fs_write: self.fs.allow_fs_write,
1001 };
1002 let protected_paths = self.protected_paths();
1003 // Each location's canonical path was resolved exactly once at capture
1004 // time; pass it straight through as the Seatbelt rule literal. The
1005 // profile generator must NOT re-canonicalize (that reopened the
1006 // verify-vs-enforce gap); see `generate_seatbelt_config`.
1007 let writable: Vec<&Path> = self
1008 .fs
1009 .writable_paths
1010 .iter()
1011 .map(HostFilesystemLocation::macos_canonical_path)
1012 .collect();
1013 let protected: Vec<&Path> = protected_paths
1014 .iter()
1015 .map(HostFilesystemLocation::macos_canonical_path)
1016 .collect();
1017
1018 // SSH-agent socket handling (commit signing) is deferred, so no unix
1019 // sockets are allowed for now.
1020 let (program, args, config) = macos_seatbelt::wrap_invocation(
1021 &command.program,
1022 &command.args,
1023 &writable,
1024 &protected,
1025 &[],
1026 permissions,
1027 )
1028 .map_err(map_anyhow_error)?;
1029 // Keep the temporary Seatbelt policy file alive for the command's life.
1030 self.seatbelt_config = Some(config);
1031
1032 Ok(WrappedCommand {
1033 program,
1034 args,
1035 env,
1036 cwd: command.cwd.clone(),
1037 })
1038 }
1039
1040 #[cfg(target_os = "windows")]
1041 async fn wrap_windows(
1042 &mut self,
1043 command: &CommandAndArgs,
1044 ) -> Result<WrappedCommand, SandboxError> {
1045 // Restricted host network access is rejected at `new` time on Windows.
1046 let permissions = windows_wsl::SandboxPermissions {
1047 allow_network: matches!(self.network, NetSetup::Unrestricted),
1048 allow_fs_write: self.fs.allow_fs_write,
1049 };
1050 // On Windows the location carries only the requested path; the in-WSL
1051 // helper performs the real capture-at-validation. These are mapped into
1052 // WSL by `wrap_invocation`.
1053 let writable_paths: Vec<PathBuf> = self
1054 .fs
1055 .writable_paths
1056 .iter()
1057 .map(|location| location.windows_path().to_path_buf())
1058 .collect();
1059 let protected_paths: Vec<PathBuf> = self
1060 .protected_paths()
1061 .iter()
1062 .map(|location| location.windows_path().to_path_buf())
1063 .collect();
1064 let (program, args) = windows_wsl::wrap_invocation(
1065 command.program.clone(),
1066 command.args.clone(),
1067 writable_paths,
1068 protected_paths,
1069 permissions,
1070 command.cwd.clone(),
1071 command.env.clone(),
1072 self.wsl_zed_release.clone(),
1073 )
1074 .await
1075 .map_err(map_anyhow_error)?;
1076
1077 Ok(WrappedCommand {
1078 program,
1079 args,
1080 env: command.env.clone(),
1081 cwd: command.cwd.clone(),
1082 })
1083 }
1084}
1085
1086impl Drop for Sandbox {
1087 fn drop(&mut self) {
1088 // Dropping a `ProxyHandle` joins its listener thread after a loopback
1089 // wakeup connect; do that off whatever (possibly UI) thread is dropping
1090 // the sandbox so a slow shutdown can't stall it. Callers already on a
1091 // background executor should prefer `drop_on_current_thread` to avoid
1092 // this throwaway thread.
1093 if let Some(proxy) = self.proxy.take() {
1094 std::thread::spawn(move || drop(proxy));
1095 }
1096 }
1097}
1098
1099/// Argv flag that marks the WSL-side sandbox-helper re-exec. Shared so the
1100/// Windows side (`windows_wsl`, which builds the `wsl.exe` invocation) and the
1101/// Linux side (`linux_bubblewrap`, which parses it inside WSL) can't drift.
1102///
1103/// Only referenced by those two cfg-gated modules, so it's gated to match;
1104/// otherwise it's dead code on macOS.
1105#[cfg(any(target_os = "linux", target_os = "windows"))]
1106pub(crate) const WSL_SANDBOX_HELPER_FLAG: &str = "--wsl-sandbox-helper";
1107
1108/// Handle a possible re-exec of this binary as a sandbox helper.
1109///
1110/// Two Linux re-exec modes funnel through here, neither of which returns if it
1111/// matches:
1112/// - the in-sandbox launcher (bind validator + restricted-network bridge), run
1113/// by bwrap before the real command; and
1114/// - the WSL-side helper, run inside WSL to capture fds + drive bwrap (the moral
1115/// equivalent of `Sandbox::wrap` on native Linux).
1116///
1117/// Call this at the top of `main`, before normal argument parsing.
1118#[doc(hidden)]
1119pub fn run_sandbox_launcher_if_invoked() {
1120 #[cfg(target_os = "linux")]
1121 {
1122 linux_bubblewrap::run_launcher_if_invoked();
1123 linux_bubblewrap::run_wsl_helper_if_invoked();
1124 }
1125}
1126
1127// The createability probe only needs to know whether a sandbox *can* be built
1128// (namespaces, `bwrap` availability); it does not enforce any grant, so it runs
1129// with no writable binds rather than re-deriving paths from the opaque
1130// locations.
1131
1132/// The current path of the inode pinned by a [`HostFilesystemLocation`]'s
1133/// `O_PATH` fd, via `/proc/self/fd`. This resolves to the *pinned inode*, so it
1134/// reflects the object captured at validation even if its name was changed,
1135/// rather than re-resolving an attacker-influenceable path string.
1136#[cfg(target_os = "linux")]
1137fn linux_location_path(location: &HostFilesystemLocation) -> Option<PathBuf> {
1138 use std::os::fd::AsRawFd as _;
1139 std::fs::read_link(format!("/proc/self/fd/{}", location.linux_fd().as_raw_fd())).ok()
1140}
1141
1142#[cfg(not(target_os = "windows"))]
1143fn resolve_restricted_network(allowed_domains: &[String]) -> Result<NetSetup, SandboxError> {
1144 let mut patterns = Vec::with_capacity(allowed_domains.len());
1145 for domain in allowed_domains {
1146 let pattern = HostPattern::parse(domain).map_err(|error| {
1147 SandboxError::InvalidRequest(format!("invalid network host '{domain}': {error}"))
1148 })?;
1149 patterns.push(pattern);
1150 }
1151 Ok(NetSetup::Restricted {
1152 allowlist: Allowlist::from_patterns(patterns),
1153 })
1154}
1155
1156#[cfg(target_os = "windows")]
1157fn resolve_restricted_network(_allowed_domains: &[String]) -> Result<NetSetup, SandboxError> {
1158 Err(unsupported_restricted_network_on_windows())
1159}
1160
1161#[cfg(target_os = "windows")]
1162fn unsupported_restricted_network_on_windows() -> SandboxError {
1163 SandboxError::UnsupportedPolicy(
1164 "restricted host network access is not yet supported for Windows sandboxes".to_string(),
1165 )
1166}
1167
1168#[cfg(target_os = "linux")]
1169fn linux_probe_network(network: &SandboxNetPolicy) -> linux_bubblewrap::NetworkAccess {
1170 match network {
1171 SandboxNetPolicy::Unrestricted => linux_bubblewrap::NetworkAccess::All,
1172 SandboxNetPolicy::Blocked => linux_bubblewrap::NetworkAccess::None,
1173 // The probe only needs the network namespace flag, not a real port.
1174 SandboxNetPolicy::Restricted { .. } => linux_bubblewrap::NetworkAccess::LocalhostPort(0),
1175 }
1176}
1177
1178#[cfg(not(target_os = "windows"))]
1179fn upstream_proxy_from_env(env: &HashMap<String, String>) -> Option<UpstreamProxy> {
1180 let url = first_nonempty_env_value(
1181 env,
1182 &[
1183 "HTTPS_PROXY",
1184 "https_proxy",
1185 "ALL_PROXY",
1186 "all_proxy",
1187 "HTTP_PROXY",
1188 "http_proxy",
1189 ],
1190 );
1191 let no_proxy = first_nonempty_env_value(env, &["NO_PROXY", "no_proxy"]);
1192 match UpstreamProxy::parse(url, no_proxy) {
1193 Ok(upstream) => upstream,
1194 Err(error) => {
1195 log::warn!("[sandbox/network] ignoring upstream proxy env: {error:#}");
1196 None
1197 }
1198 }
1199}
1200
1201#[cfg(not(target_os = "windows"))]
1202fn first_nonempty_env_value<'a>(
1203 env: &'a HashMap<String, String>,
1204 names: &[&str],
1205) -> Option<&'a str> {
1206 for name in names {
1207 if let Some(value) = env.get(*name)
1208 && !value.trim().is_empty()
1209 {
1210 return Some(value.as_str());
1211 }
1212 }
1213 None
1214}
1215
1216/// Point the child's proxy env vars at the in-process proxy and strip any
1217/// inherited `NO_PROXY`.
1218///
1219/// Both upper- and lower-case forms are set because some clients (notably curl
1220/// on macOS) only honor the lowercase variant. `NO_PROXY` is blanked so all
1221/// egress goes through our proxy unconditionally: an inherited `NO_PROXY`
1222/// matching an allowlisted host would make the client attempt a direct
1223/// connection, which the sandbox blocks — surfacing as a confusing "connection
1224/// refused" instead of a clean policy decision.
1225#[cfg(not(target_os = "windows"))]
1226fn apply_proxy_env(env: &mut HashMap<String, String>, port: u16) {
1227 let url = format!("http://127.0.0.1:{port}");
1228 for key in [
1229 "HTTPS_PROXY",
1230 "https_proxy",
1231 "HTTP_PROXY",
1232 "http_proxy",
1233 "ALL_PROXY",
1234 "all_proxy",
1235 ] {
1236 env.insert(key.to_string(), url.clone());
1237 }
1238 for key in ["NO_PROXY", "no_proxy"] {
1239 env.insert(key.to_string(), String::new());
1240 }
1241}
1242
1243/// Drain the proxy's event channel on a background thread, logging each event.
1244/// v1 surfacing only; future integrations (UI, telemetry) can replace this.
1245/// The thread exits when the proxy is dropped and the channel closes.
1246#[cfg(not(target_os = "windows"))]
1247fn spawn_proxy_event_logger(events: futures::channel::mpsc::UnboundedReceiver<ProxyEvent>) {
1248 std::thread::spawn(move || {
1249 futures::executor::block_on(async move {
1250 use futures::StreamExt as _;
1251 let mut events = events;
1252 while let Some(event) = events.next().await {
1253 log_proxy_event(&event);
1254 }
1255 });
1256 });
1257}
1258
1259#[cfg(not(target_os = "windows"))]
1260fn log_proxy_event(event: &ProxyEvent) {
1261 match event {
1262 ProxyEvent::Ready { .. } => {}
1263 ProxyEvent::RequestAttempt {
1264 host,
1265 port,
1266 method,
1267 outcome,
1268 } => {
1269 log::debug!(
1270 "[sandbox/network] {} {host}:{port} → {outcome:?}",
1271 method.as_str()
1272 );
1273 }
1274 ProxyEvent::RequestCompleted {
1275 host,
1276 port,
1277 method,
1278 bytes_to_remote,
1279 bytes_from_remote,
1280 duration_ms,
1281 } => {
1282 log::debug!(
1283 "[sandbox/network] completed {} {host}:{port} sent={bytes_to_remote} recv={bytes_from_remote} duration={duration_ms}ms",
1284 method.as_str(),
1285 );
1286 }
1287 }
1288}
1289
1290#[cfg(target_os = "linux")]
1291fn map_linux_status(status: linux_bubblewrap::LauncherStatus) -> SandboxError {
1292 match status {
1293 linux_bubblewrap::LauncherStatus::BwrapNotFound => SandboxError::BwrapNotFound,
1294 linux_bubblewrap::LauncherStatus::SetuidRejected => SandboxError::BwrapSetuidRejected,
1295 linux_bubblewrap::LauncherStatus::SandboxProbeFailed => SandboxError::SandboxProbeFailed,
1296 }
1297}
1298
1299#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1300fn map_anyhow_error(error: anyhow::Error) -> SandboxError {
1301 #[cfg(target_os = "windows")]
1302 if let Some(error) = error.downcast_ref::<windows_wsl::WslSandboxUnavailable>() {
1303 return SandboxError::WslUnavailable(error.to_string());
1304 }
1305
1306 SandboxError::Other(format!("{error:#}"))
1307}
1308
1309#[cfg(all(test, not(target_os = "windows")))]
1310mod tests {
1311 use super::*;
1312
1313 #[test]
1314 fn restricted_fs_rejects_writable_paths_inside_protected_paths() {
1315 let temp_dir = tempfile::tempdir().expect("create temp dir");
1316 let repo_dir = temp_dir.path().join("repo");
1317 let git_dir = repo_dir.join(".git");
1318 let hooks_dir = git_dir.join("hooks");
1319 std::fs::create_dir_all(&hooks_dir).expect("create git hooks dir");
1320
1321 let protected_git = HostFilesystemLocation::new(&git_dir).expect("capture git dir");
1322 let writable_git = HostFilesystemLocation::new(&git_dir).expect("capture git dir");
1323 let writable_hooks = HostFilesystemLocation::new(&hooks_dir).expect("capture hooks dir");
1324
1325 for writable_path in [writable_git, writable_hooks] {
1326 let result = Sandbox::new(SandboxPolicy {
1327 fs: SandboxFsPolicy::Restricted {
1328 writable_paths: vec![writable_path],
1329 protected_paths: vec![protected_git.clone()],
1330 },
1331 network: SandboxNetPolicy::Blocked,
1332 });
1333
1334 assert!(matches!(result, Err(SandboxError::InvalidRequest(_))));
1335 }
1336 }
1337
1338 #[cfg(target_os = "linux")]
1339 #[test]
1340 fn restricted_fs_protected_overlap_check_uses_captured_fds_not_path_text() {
1341 use std::os::unix::fs as unix_fs;
1342
1343 let temp_dir = tempfile::tempdir().expect("create temp dir");
1344 let repo_dir = temp_dir.path().join("repo");
1345 let git_dir = repo_dir.join(".git");
1346 let real_git_dir = repo_dir.join("real-git");
1347 let outside_dir = temp_dir.path().join("outside");
1348 let outside_hooks_dir = outside_dir.join("hooks");
1349 std::fs::create_dir_all(&git_dir).expect("create git dir");
1350 std::fs::create_dir_all(&outside_hooks_dir).expect("create outside hooks dir");
1351
1352 let protected_git = HostFilesystemLocation::new(&git_dir).expect("capture git dir");
1353 std::fs::rename(&git_dir, &real_git_dir).expect("move git dir aside");
1354 unix_fs::symlink(&outside_dir, &git_dir).expect("replace git dir with symlink");
1355 let writable_displayed_inside_git =
1356 HostFilesystemLocation::new(git_dir.join("hooks")).expect("capture symlink target");
1357
1358 Sandbox::new(SandboxPolicy {
1359 fs: SandboxFsPolicy::Restricted {
1360 writable_paths: vec![writable_displayed_inside_git],
1361 protected_paths: vec![protected_git],
1362 },
1363 network: SandboxNetPolicy::Blocked,
1364 })
1365 .expect("captured writable fd is outside protected metadata");
1366 }
1367
1368 #[test]
1369 fn fs_merge_unrestricted_dominates_else_unions_paths() {
1370 // Writable paths are captured as real `HostFilesystemLocation`s (keyed on
1371 // the inode), so the union/dedup test needs three distinct real dirs.
1372 let dir_a = tempfile::tempdir().expect("create temp dir a");
1373 let dir_b = tempfile::tempdir().expect("create temp dir b");
1374 let dir_c = tempfile::tempdir().expect("create temp dir c");
1375 let dir_d = tempfile::tempdir().expect("create temp dir d");
1376 let location = |dir: &tempfile::TempDir| {
1377 HostFilesystemLocation::new(dir.path()).expect("capture temp dir")
1378 };
1379
1380 let a = SandboxFsPolicy::Restricted {
1381 writable_paths: vec![location(&dir_a), location(&dir_b)],
1382 protected_paths: vec![location(&dir_c)],
1383 };
1384 let b = SandboxFsPolicy::Restricted {
1385 writable_paths: vec![location(&dir_b), location(&dir_c)],
1386 protected_paths: vec![location(&dir_c), location(&dir_d)],
1387 };
1388 assert_eq!(
1389 a.clone().merge(b),
1390 SandboxFsPolicy::Restricted {
1391 writable_paths: vec![location(&dir_a), location(&dir_b), location(&dir_c)],
1392 protected_paths: vec![location(&dir_c), location(&dir_d)],
1393 }
1394 );
1395 assert_eq!(
1396 a.merge(SandboxFsPolicy::Unrestricted {
1397 protected_paths: vec![location(&dir_a)],
1398 }),
1399 SandboxFsPolicy::Unrestricted {
1400 protected_paths: vec![location(&dir_c), location(&dir_a)],
1401 }
1402 );
1403 assert_eq!(
1404 SandboxFsPolicy::Unrestricted {
1405 protected_paths: vec![location(&dir_d)],
1406 }
1407 .merge(SandboxFsPolicy::Restricted {
1408 writable_paths: vec![location(&dir_a)],
1409 protected_paths: vec![location(&dir_c)],
1410 }),
1411 SandboxFsPolicy::Unrestricted {
1412 protected_paths: vec![location(&dir_d), location(&dir_c)],
1413 }
1414 );
1415 }
1416
1417 #[test]
1418 fn net_merge_unrestricted_dominates_blocked_is_identity_else_unions() {
1419 let hosts = |list: &[&str]| SandboxNetPolicy::Restricted {
1420 allowed_domains: list.iter().map(|s| s.to_string()).collect(),
1421 };
1422 assert_eq!(
1423 hosts(&["a.com", "b.com"]).merge(hosts(&["b.com", "c.com"])),
1424 hosts(&["a.com", "b.com", "c.com"])
1425 );
1426 assert_eq!(
1427 SandboxNetPolicy::Blocked.merge(hosts(&["a.com"])),
1428 hosts(&["a.com"])
1429 );
1430 assert_eq!(
1431 hosts(&["a.com"]).merge(SandboxNetPolicy::Blocked),
1432 hosts(&["a.com"])
1433 );
1434 assert_eq!(
1435 SandboxNetPolicy::Blocked.merge(SandboxNetPolicy::Blocked),
1436 SandboxNetPolicy::Blocked
1437 );
1438 assert_eq!(
1439 hosts(&["a.com"]).merge(SandboxNetPolicy::Unrestricted),
1440 SandboxNetPolicy::Unrestricted
1441 );
1442 }
1443
1444 #[test]
1445 fn upstream_proxy_from_env_uses_precedence_and_no_proxy() {
1446 let mut env = HashMap::new();
1447 env.insert("HTTPS_PROXY".to_string(), " ".to_string());
1448 env.insert("https_proxy".to_string(), "http://lower:1111".to_string());
1449 env.insert("ALL_PROXY".to_string(), "http://all:2222".to_string());
1450 env.insert("HTTP_PROXY".to_string(), "http://http:3333".to_string());
1451 env.insert("NO_PROXY".to_string(), "".to_string());
1452 env.insert("no_proxy".to_string(), "internal.example".to_string());
1453
1454 let upstream = upstream_proxy_from_env(&env).expect("should configure an upstream");
1455 assert_eq!(upstream.host, "lower");
1456 assert_eq!(upstream.port, 1111);
1457 assert!(upstream.bypasses("internal.example", 443));
1458 assert!(!upstream.bypasses("zed.dev", 443));
1459 }
1460
1461 #[test]
1462 fn apply_proxy_env_points_vars_at_proxy_and_blanks_no_proxy() {
1463 let mut env = HashMap::new();
1464 env.insert("HTTPS_PROXY".to_string(), "http://corp:3128".to_string());
1465 env.insert("NO_PROXY".to_string(), "internal.example".to_string());
1466 env.insert("PATH".to_string(), "/usr/bin".to_string());
1467
1468 apply_proxy_env(&mut env, 54321);
1469
1470 for key in [
1471 "HTTPS_PROXY",
1472 "https_proxy",
1473 "HTTP_PROXY",
1474 "http_proxy",
1475 "ALL_PROXY",
1476 "all_proxy",
1477 ] {
1478 assert_eq!(
1479 env.get(key).map(String::as_str),
1480 Some("http://127.0.0.1:54321")
1481 );
1482 }
1483 for key in ["NO_PROXY", "no_proxy"] {
1484 assert_eq!(env.get(key).map(String::as_str), Some(""));
1485 }
1486 assert_eq!(env.get("PATH").map(String::as_str), Some("/usr/bin"));
1487 }
1488}
1489
1490/// A directory that is about to be granted as a sandbox write path but may not
1491/// exist yet. Preparing one resolves the platform difference in how a
1492/// not-yet-existing write grant is materialized, while keeping the same security
1493/// property: the caller shows [`Self::canonical_path`] to the user and records
1494/// *that* as the grant, so approval is always against the real, symlink-resolved
1495/// target.
1496///
1497/// - **Linux/WSL**: bubblewrap can only bind an existing inode, so the missing
1498/// directory (and any missing parents) is created **eagerly**, per component,
1499/// and the leaf's inode is pinned to read back its canonical path. If the
1500/// grant is denied, [`Self::discard`] removes exactly the directories that were
1501/// created, deepest-first, following no symlinks (`rmdir` only removes empty
1502/// dirs and never traverses a swapped-in symlink).
1503/// - **macOS**: Seatbelt resolves paths at syscall time and can grant a missing
1504/// path, so nothing is created here; the directory is materialized only after
1505/// approval via [`Self::finalize`].
1506///
1507/// The eventual bind is still protected by the usual capture-and-revalidate path
1508/// (`HostFilesystemLocation`), which re-pins the inode when the command runs.
1509pub struct GrantableWriteDir {
1510 canonical_path: PathBuf,
1511 /// Directories created eagerly to pin the inode, shallowest-first. Empty on
1512 /// platforms that defer creation to [`Self::finalize`].
1513 eagerly_created: Vec<PathBuf>,
1514}
1515
1516impl GrantableWriteDir {
1517 /// Prepare `path` for use as a sandbox write grant. `path` must be absolute.
1518 pub fn prepare(path: &Path) -> std::io::Result<Self> {
1519 #[cfg(target_os = "linux")]
1520 {
1521 let mut eagerly_created = Vec::new();
1522 if let Err(error) = create_missing_dirs(path, &mut eagerly_created) {
1523 // Roll back any partial creation so a failure leaves no litter.
1524 for dir in eagerly_created.iter().rev() {
1525 let _ = std::fs::remove_dir(dir);
1526 }
1527 return Err(error);
1528 }
1529 let canonical_path = pinned_canonical_path(path)?;
1530 Ok(Self {
1531 canonical_path,
1532 eagerly_created,
1533 })
1534 }
1535 #[cfg(target_os = "macos")]
1536 {
1537 Ok(Self {
1538 canonical_path: canonicalize_allowing_missing_leaf(path),
1539 eagerly_created: Vec::new(),
1540 })
1541 }
1542 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
1543 {
1544 let _ = path;
1545 Err(std::io::Error::new(
1546 std::io::ErrorKind::Unsupported,
1547 "granting a not-yet-existing write directory is not supported on this platform",
1548 ))
1549 }
1550 }
1551
1552 /// The canonical, symlink-resolved path to show the user and record as the
1553 /// grant.
1554 pub fn canonical_path(&self) -> &Path {
1555 &self.canonical_path
1556 }
1557
1558 /// Materialize the directory once the grant is approved. A no-op on platforms
1559 /// that already created it eagerly.
1560 pub fn finalize(&self) -> std::io::Result<()> {
1561 #[cfg(target_os = "macos")]
1562 {
1563 std::fs::create_dir_all(&self.canonical_path)?;
1564 }
1565 Ok(())
1566 }
1567
1568 /// Remove exactly the directories we created (deepest-first) when the grant
1569 /// is denied. Best-effort; `rmdir` leaves non-empty dirs and swapped-in
1570 /// symlinks untouched.
1571 pub fn discard(self) {
1572 for dir in self.eagerly_created.iter().rev() {
1573 let _ = std::fs::remove_dir(dir);
1574 }
1575 }
1576}
1577
1578/// Create each missing component of `path` with `create_dir` (never
1579/// `create_dir_all`), recording exactly the directories created so they can be
1580/// removed again if the grant is denied. Components that already exist are left
1581/// alone.
1582#[cfg(target_os = "linux")]
1583fn create_missing_dirs(path: &Path, created: &mut Vec<PathBuf>) -> std::io::Result<()> {
1584 let mut cur = PathBuf::new();
1585 for component in path.components() {
1586 cur.push(component);
1587 match std::fs::create_dir(&cur) {
1588 Ok(()) => created.push(cur.clone()),
1589 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
1590 Err(error) => return Err(error),
1591 }
1592 }
1593 Ok(())
1594}
1595
1596/// Open an `O_PATH` handle to `path` and read back the canonical path of the
1597/// inode it pins, so the value shown to the user reflects the real target even
1598/// when a component is a symlink.
1599#[cfg(target_os = "linux")]
1600fn pinned_canonical_path(path: &Path) -> std::io::Result<PathBuf> {
1601 use std::os::fd::AsRawFd as _;
1602 use std::os::unix::fs::OpenOptionsExt as _;
1603 let handle = std::fs::OpenOptions::new()
1604 .read(true)
1605 .custom_flags(libc::O_PATH | libc::O_CLOEXEC)
1606 .open(path)?;
1607 std::fs::read_link(format!("/proc/self/fd/{}", handle.as_raw_fd()))
1608}
1609
1610#[cfg(all(test, target_os = "linux"))]
1611mod grantable_write_dir_tests {
1612 use super::GrantableWriteDir;
1613 use std::fs;
1614
1615 #[test]
1616 fn creates_missing_dirs_and_discard_removes_only_those() {
1617 let root = tempfile::tempdir().unwrap();
1618 let existing = root.path().join("existing");
1619 fs::create_dir(&existing).unwrap();
1620 let target = existing.join("a").join("b").join("c");
1621
1622 let prepared = GrantableWriteDir::prepare(&target).unwrap();
1623 assert!(target.is_dir());
1624 assert_eq!(prepared.canonical_path(), target.canonicalize().unwrap());
1625
1626 prepared.discard();
1627 // Everything we created is gone...
1628 assert!(!existing.join("a").exists());
1629 // ...but the pre-existing ancestor is untouched.
1630 assert!(existing.is_dir());
1631 }
1632
1633 #[test]
1634 fn existing_dir_is_left_alone_and_not_removed_on_discard() {
1635 let root = tempfile::tempdir().unwrap();
1636 let target = root.path().join("already");
1637 fs::create_dir(&target).unwrap();
1638
1639 let prepared = GrantableWriteDir::prepare(&target).unwrap();
1640 assert!(target.is_dir());
1641 // We created nothing, so discard removes nothing.
1642 prepared.discard();
1643 assert!(target.is_dir());
1644 }
1645
1646 #[test]
1647 fn canonical_path_resolves_a_symlinked_parent() {
1648 let root = tempfile::tempdir().unwrap();
1649 let real = root.path().join("real");
1650 fs::create_dir(&real).unwrap();
1651 let link = root.path().join("link");
1652 std::os::unix::fs::symlink(&real, &link).unwrap();
1653
1654 // Granting `link/child` where `link` legitimately points at `real` must
1655 // succeed and show the user the *resolved* `real/child`, not fail.
1656 let prepared = GrantableWriteDir::prepare(&link.join("child")).unwrap();
1657 assert_eq!(
1658 prepared.canonical_path(),
1659 real.canonicalize().unwrap().join("child")
1660 );
1661 assert!(real.join("child").is_dir());
1662
1663 prepared.discard();
1664 assert!(!real.join("child").exists());
1665 }
1666}
1667
1668/// Canonicalize `path`, resolving symlinks, even when its final component
1669/// doesn't exist yet.
1670///
1671/// `std::fs::canonicalize` fails if any component is missing, which would leave
1672/// a not-yet-created path in a non-canonical form. The sandbox layers
1673/// canonicalize the writable parent
1674/// (the worktree root) but, with a plain `canonicalize`, fall back to the raw
1675/// path for a missing child; the two then disagree when a component is a
1676/// symlink (`/tmp` -> `/private/tmp` on macOS), and the protection rule for the
1677/// child misses the real path the command ends up writing. Canonicalizing the
1678/// existing parent and re-appending the final component keeps the child
1679/// consistent with its parent. If neither the path nor its parent can be
1680/// canonicalized, the path is returned unchanged.
1681//
1682// Only the macOS Seatbelt layer uses this (Linux skips not-yet-existing
1683// protected paths rather than emitting a rule for them), so it's gated to macOS
1684// to avoid a dead-code warning elsewhere.
1685#[cfg(target_os = "macos")]
1686pub(crate) fn canonicalize_allowing_missing_leaf(path: &std::path::Path) -> std::path::PathBuf {
1687 if let Ok(canonical) = path.canonicalize() {
1688 return canonical;
1689 }
1690 if let (Some(parent), Some(file_name)) = (path.parent(), path.file_name())
1691 && let Ok(canonical_parent) = parent.canonicalize()
1692 {
1693 return canonical_parent.join(file_name);
1694 }
1695 path.to_path_buf()
1696}
1697
1698#[cfg(all(test, target_os = "macos"))]
1699mod macos_tests {
1700 use super::canonicalize_allowing_missing_leaf;
1701
1702 #[test]
1703 fn canonicalize_allowing_missing_leaf_resolves_existing_parent() {
1704 let dir = tempfile::tempdir().unwrap();
1705 let canonical_dir = dir.path().canonicalize().unwrap();
1706
1707 // A fully existing path is canonicalized outright.
1708 assert_eq!(
1709 canonicalize_allowing_missing_leaf(dir.path()),
1710 canonical_dir
1711 );
1712
1713 // A path whose leaf doesn't exist yet still resolves through its parent,
1714 // so it stays consistent with how the parent directory canonicalizes
1715 // (for example, when protecting a not-yet-created child path).
1716 let missing = dir.path().join("not-created-yet");
1717 assert_eq!(
1718 canonicalize_allowing_missing_leaf(&missing),
1719 canonical_dir.join("not-created-yet"),
1720 );
1721
1722 // A path whose parent also doesn't exist is returned unchanged.
1723 let deeper = missing.join(".git");
1724 assert_eq!(canonicalize_allowing_missing_leaf(&deeper), deeper);
1725 }
1726}
1727