Skip to repository content

tenant.openagents/omega

No repository description is available.

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

sandbox.rs

1727 lines · 69.3 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.