Cameron Mcloughlinagent: Re-enable sandbox (#61711)12a19dc3 months agoHistory
1481 lines · 56.8 KB · Rust
RawDownload
//! Cross-platform sandboxing for commands run on behalf of the agent.//!//! The public API is intentionally platform-neutral. Internally, macOS uses//! Seatbelt (`sandbox-exec`), Linux uses Bubblewrap (`bwrap`), and Windows uses//! Bubblewrap inside WSL. Restricted-network policies are enforced by an//! in-process HTTP/HTTPS proxy (the `http_proxy` crate) that this crate//! constructs and owns; callers only describe intent via [`SandboxPolicy`]. use std::{    collections::HashMap,    fmt,    path::{Path, PathBuf},    process::Output,}; use http_proxy::ProxyHandle;#[cfg(not(target_os = "windows"))]use http_proxy::{Allowlist, HostPattern, ProxyConfig, ProxyEvent, UpstreamProxy}; #[cfg(target_os = "linux")]mod linux_bubblewrap; #[cfg(target_os = "macos")]mod macos_seatbelt; #[cfg(target_os = "windows")]mod windows_wsl; mod util; #[cfg(target_os = "macos")]use util::canonicalize_allowing_missing_leaf;#[cfg(target_os = "linux")]use util::{CanonicalPathBuf, linux_fd_identity};pub use util::{    HostFilesystemLocation, HostFilesystemLocationDisplay, normalize_host_filesystem_locations,    resolve_canonical,};#[cfg(target_os = "windows")]pub use windows_wsl::{ResolvedGrant, resolve_canonical_for_grant}; #[cfg(target_os = "windows")]pub(crate) const WSL_SANDBOX_UNAVAILABLE_PREFIX: &str = "Windows sandboxing via WSL is unavailable"; /// A path *inside the sandbox* — i.e. where a host location is exposed in the/// sandboxed process's view of the filesystem (for example, a bind-mount/// destination on Linux).////// Unlike [`HostFilesystemLocation`], this needs no hardening and is just a thin/// wrapper around a [`PathBuf`]. It only names a location within the sandbox's/// own namespace: the worst a tampered sandbox-side path can do is expose the/// (already-granted) host files at a *different* path inside the sandbox — it can/// never widen which host files are reachable. It is therefore fine to build one/// from an ordinary, even attacker-influenced, path.#[derive(Clone, Debug, Eq, PartialEq, Hash)]pub struct SandboxFilesystemLocation(PathBuf); impl SandboxFilesystemLocation {    /// Name a location inside the sandbox's filesystem view.    pub fn new(path: impl Into<PathBuf>) -> Self {        Self(path.into())    }     /// The in-sandbox path. Safe to read: this is not a trusted host identity.    pub fn as_path(&self) -> &Path {        &self.0    }     /// Consume this wrapper, yielding the underlying in-sandbox path.    pub fn into_path_buf(self) -> PathBuf {        self.0    }} impl From<PathBuf> for SandboxFilesystemLocation {    fn from(path: PathBuf) -> Self {        Self(path)    }} /// What a command is allowed to do, expressed as intent. This is the entire/// public configuration surface; how each policy is enforced (Seatbelt rules,/// Bubblewrap flags, a loopback proxy, …) is an implementation detail.#[derive(Clone, Debug, Eq, PartialEq)]pub struct SandboxPolicy {    pub fs: SandboxFsPolicy,    pub network: SandboxNetPolicy,} /// Filesystem policy for a sandboxed command.#[derive(Clone, Debug, Eq, PartialEq)]pub enum SandboxFsPolicy {    /// Allow unrestricted filesystem writes except for protected paths, which    /// remain readable but not writable.    Unrestricted {        protected_paths: Vec<HostFilesystemLocation>,    },    /// Reads are allowed everywhere; writes are confined to these locations    /// (and the standard ephemeral locations the platform provides). Each is a    /// [`HostFilesystemLocation`] captured at validation time, never a bare path    /// the enforcement layer would re-resolve.    Restricted {        writable_paths: Vec<HostFilesystemLocation>,        protected_paths: Vec<HostFilesystemLocation>,    },} /// Outbound-network policy for a sandboxed command.#[derive(Clone, Debug, Eq, PartialEq)]pub enum SandboxNetPolicy {    /// Allow unrestricted outbound network access.    Unrestricted,    /// Block all outbound network access.    Blocked,    /// Allow outbound HTTP(S) only to these hostnames (exact hosts or    /// leading-`*.` subdomain wildcards), enforced by an in-process proxy.    Restricted { allowed_domains: Vec<String> },} /// Host paths that should remain readable but not writable, even when they fall/// under a writable subtree.////// The caller computes this list because the sandbox layer does not know which/// application-specific paths need stronger protection.pub type ProtectedPaths = Vec<HostFilesystemLocation>; /// Whether `path` (a host path) resides on a Windows drive reached through WSL's/// DrvFs, where sandbox-integrity guarantees are weaker than on the distro's/// native filesystem. Always `false` off Windows, where no such filesystem/// exists. This is a cheap structural check on the path shape; the authoritative/// per-grant classification is done in WSL by [`resolve_canonical_for_grant`].pub fn path_is_on_windows_drive(path: &Path) -> bool {    #[cfg(target_os = "windows")]    {        windows_wsl::path_is_on_windows_drive(path)    }    #[cfg(not(target_os = "windows"))]    {        let _ = path;        false    }} impl SandboxPolicy {    /// Combine two policy layers. Filesystem/network grants are unioned into the    /// least-restrictive policy that satisfies both layers, while protected paths    /// within restricted filesystem policies are unioned so every layer's    /// protected subtrees remain protected.    pub fn merge(self, other: SandboxPolicy) -> SandboxPolicy {        SandboxPolicy {            fs: self.fs.merge(other.fs),            network: self.network.merge(other.network),        }    }     /// Replace the protected paths in the filesystem policy, keeping writable    /// paths and network policy unchanged.    pub fn with_protected_paths(mut self, protected_paths: Vec<HostFilesystemLocation>) -> Self {        match &mut self.fs {            SandboxFsPolicy::Unrestricted {                protected_paths: existing,            }            | SandboxFsPolicy::Restricted {                protected_paths: existing,                ..            } => *existing = protected_paths,        }        self    }} fn merge_locations(    locations: Vec<HostFilesystemLocation>,    other: Vec<HostFilesystemLocation>,) -> Vec<HostFilesystemLocation> {    // Union the two sets and reduce to a minimal cover, so a location nested    // under another (in either input) is dropped rather than bound redundantly.    normalize_host_filesystem_locations(locations.into_iter().chain(other))} fn validate_writable_paths_do_not_overlap_protected_paths(    writable_paths: &[HostFilesystemLocation],    protected_paths: &[HostFilesystemLocation],) -> Result<(), SandboxError> {    for writable_path in writable_paths {        for protected_path in protected_paths {            if writable_path_overlaps_protected_path(writable_path, protected_path) {                return Err(SandboxError::InvalidRequest(format!(                    "writable sandbox path `{}` overlaps protected path `{}`",                    writable_path.untrusted_path_display(),                    protected_path.untrusted_path_display()                )));            }        }    }    Ok(())} #[cfg(target_os = "linux")]fn writable_path_overlaps_protected_path(    writable_path: &HostFilesystemLocation,    protected_path: &HostFilesystemLocation,) -> bool {    linux_location_is_equal_or_descendant(writable_path, protected_path)} #[cfg(target_os = "macos")]fn writable_path_overlaps_protected_path(    writable_path: &HostFilesystemLocation,    protected_path: &HostFilesystemLocation,) -> bool {    writable_path        .macos_canonical_path()        .starts_with(protected_path.macos_canonical_path())} #[cfg(not(any(target_os = "linux", target_os = "macos")))]fn writable_path_overlaps_protected_path(    writable_path: &HostFilesystemLocation,    protected_path: &HostFilesystemLocation,) -> bool {    writable_path        .untrusted_raw_path()        .starts_with(protected_path.untrusted_raw_path())} #[cfg(target_os = "linux")]fn linux_location_is_equal_or_descendant(    location: &HostFilesystemLocation,    ancestor: &HostFilesystemLocation,) -> bool {    use std::os::fd::{AsRawFd as _, FromRawFd as _, OwnedFd};     if location == ancestor {        return true;    }     let Ok(mut current) = location.linux_dup_fd() else {        log::warn!("failed to duplicate sandbox location fd while checking protected path overlap");        return false;    };     loop {        let parent = unsafe {            let fd = libc::openat(                current.as_raw_fd(),                c"..".as_ptr(),                libc::O_PATH | libc::O_DIRECTORY | libc::O_CLOEXEC,            );            if fd < 0 {                return false;            }            OwnedFd::from_raw_fd(fd)        };         let Some(parent_identity) = linux_fd_identity(parent.as_raw_fd()) else {            log::warn!("failed to fstat parent fd while checking protected path overlap");            return false;        };        let Some(current_identity) = linux_fd_identity(current.as_raw_fd()) else {            log::warn!("failed to fstat current fd while checking protected path overlap");            return false;        };        let Some(ancestor_identity) = linux_fd_identity(ancestor.linux_fd().as_raw_fd()) else {            log::warn!("failed to fstat protected fd while checking protected path overlap");            return false;        };         if parent_identity == ancestor_identity {            return true;        }        if parent_identity == current_identity {            return false;        }        current = parent;    }} impl SandboxFsPolicy {    /// Unrestricted access dominates; otherwise the writable subtrees union.    pub fn merge(self, other: SandboxFsPolicy) -> SandboxFsPolicy {        match (self, other) {            (                SandboxFsPolicy::Unrestricted {                    protected_paths: protected_a,                },                SandboxFsPolicy::Unrestricted {                    protected_paths: protected_b,                },            ) => SandboxFsPolicy::Unrestricted {                protected_paths: merge_locations(protected_a, protected_b),            },            (                SandboxFsPolicy::Unrestricted {                    protected_paths: protected_a,                },                SandboxFsPolicy::Restricted {                    protected_paths: protected_b,                    ..                },            )            | (                SandboxFsPolicy::Restricted {                    protected_paths: protected_a,                    ..                },                SandboxFsPolicy::Unrestricted {                    protected_paths: protected_b,                },            ) => SandboxFsPolicy::Unrestricted {                protected_paths: merge_locations(protected_a, protected_b),            },            (                SandboxFsPolicy::Restricted {                    writable_paths: writable_a,                    protected_paths: protected_a,                },                SandboxFsPolicy::Restricted {                    writable_paths: writable_b,                    protected_paths: protected_b,                },            ) => SandboxFsPolicy::Restricted {                writable_paths: merge_locations(writable_a, writable_b),                protected_paths: merge_locations(protected_a, protected_b),            },        }    }} impl SandboxNetPolicy {    /// Unrestricted access dominates and `Blocked` is the identity; otherwise the    /// allowed domains union.    pub fn merge(self, other: SandboxNetPolicy) -> SandboxNetPolicy {        match (self, other) {            (SandboxNetPolicy::Unrestricted, _) | (_, SandboxNetPolicy::Unrestricted) => {                SandboxNetPolicy::Unrestricted            }            (SandboxNetPolicy::Blocked, other) | (other, SandboxNetPolicy::Blocked) => other,            (                SandboxNetPolicy::Restricted {                    allowed_domains: mut a,                },                SandboxNetPolicy::Restricted { allowed_domains: b },            ) => {                for domain in b {                    if !a.contains(&domain) {                        a.push(domain);                    }                }                SandboxNetPolicy::Restricted { allowed_domains: a }            }        }    }} /// A command and its execution environment, before sandbox wrapping.#[derive(Clone, Debug, Default, Eq, PartialEq)]pub struct CommandAndArgs {    /// Program to execute.    pub program: String,    /// Arguments passed to `program`.    pub args: Vec<String>,    /// Environment variables for the spawned process.    pub env: HashMap<String, String>,    /// Working directory for the spawned process.    pub cwd: Option<PathBuf>,} /// A command transformed to run inside the platform sandbox. Plain data: spawn/// it however you like (PTY, `std::process`, …). The resources that must/// outlive the spawned process live in the [`Sandbox`] that produced this, not/// here — keep the `Sandbox` alive for the command's duration.#[derive(Clone, Debug, Default, Eq, PartialEq)]pub struct WrappedCommand {    pub program: String,    pub args: Vec<String>,    pub env: HashMap<String, String>,    pub cwd: Option<PathBuf>,} /// Errors returned by the sandbox abstraction.#[derive(Debug, Clone, Eq, PartialEq)]pub enum SandboxError {    /// No sandbox implementation is available for this platform.    UnsupportedPlatform,    /// No usable Bubblewrap executable was found.    BwrapNotFound,    /// The only Bubblewrap executable found is setuid-root, which is refused.    BwrapSetuidRejected,    /// Bubblewrap was found but failed to create the sandbox.    SandboxProbeFailed,    /// The sandbox bridge executable path could not be resolved.    BridgeExecutableUnavailable(String),    /// Windows sandboxing through WSL is unavailable.    WslUnavailable(String),    /// The requested sandbox policy is not supported on this platform.    UnsupportedPolicy(String),    /// The sandbox request is invalid (e.g. a malformed allowed-domain).    InvalidRequest(String),    /// An I/O error occurred (e.g. spawning the network proxy).    Io(String),    /// Any other sandbox setup failure.    Other(String),} impl fmt::Display for SandboxError {    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {        match self {            SandboxError::UnsupportedPlatform => {                write!(formatter, "sandboxing is not supported on this platform")            }            SandboxError::BwrapNotFound => {                write!(formatter, "no usable `bwrap` binary was found on PATH")            }            SandboxError::BwrapSetuidRejected => write!(                formatter,                "the only available `bwrap` is setuid-root, which Zed refuses to run"            ),            SandboxError::SandboxProbeFailed => {                write!(                    formatter,                    "`bwrap` is present but failed to create a sandbox"                )            }            SandboxError::BridgeExecutableUnavailable(message) => write!(                formatter,                "failed to resolve sandbox bridge executable: {message}"            ),            SandboxError::WslUnavailable(message) => write!(formatter, "{message}"),            SandboxError::UnsupportedPolicy(message) => write!(formatter, "{message}"),            SandboxError::InvalidRequest(message) => write!(formatter, "{message}"),            SandboxError::Io(message) => write!(formatter, "{message}"),            SandboxError::Other(message) => write!(formatter, "{message}"),        }    }} impl std::error::Error for SandboxError {} /// Resolved filesystem setup derived from [`SandboxFsPolicy`].struct FsSetup {    allow_fs_write: bool,    writable_paths: Vec<HostFilesystemLocation>,    protected_paths: Vec<HostFilesystemLocation>,} /// Resolved network plan derived from [`SandboxNetPolicy`]. For the restricted/// case the allowlist is parsed up front; the proxy itself is spawned lazily on/// the first [`Sandbox::wrap`] so its upstream-proxy chaining can read the/// command's environment.enum NetSetup {    Unrestricted,    Blocked,    // Restricted networking is rejected up front on Windows, so this variant is    // never constructed there.    #[cfg(not(target_os = "windows"))]    Restricted {        allowlist: Allowlist,    },} /// A live sandbox: it owns the per-policy resources (the network proxy, and on/// macOS the temporary Seatbelt policy file) and produces sandboxed command/// invocations via [`Sandbox::wrap`] / [`Sandbox::execute`].////// Keep it alive for as long as commands wrapped by it are running; dropping it/// tears down the proxy.pub struct Sandbox {    fs: FsSetup,    network: NetSetup,    /// In-process network proxy for the restricted-network case, spawned on the    /// first `wrap`. Dropped on a background thread (the join blocks).    proxy: Option<ProxyHandle>,    /// Linux only: the host endpoint that hands the in-sandbox validator the    /// captured `O_PATH` fds over a unix socket. Runs entirely in-process (a    /// short-lived background thread, never a separate process) and is owned by    /// this `Sandbox` — which is created per command — so it comes up when the    /// command is wrapped and is torn down (thread stopped, socket removed) when    /// the command finishes. Holds the fds, keeping their inodes pinned until    /// then. Created lazily on the wrap that first needs it (a restricted-fs run    /// with writable binds); a `Sandbox` normally wraps a single command.    #[cfg(target_os = "linux")]    validation_fd_sender: Option<linux_bubblewrap::ValidationFdSender>,    /// Windows only: `(release channel, version)` of the Linux `zed` to    /// provision inside WSL as the `--wsl-sandbox-helper` (version `latest` for    /// dev builds). Set by the caller (which has the running release info);    /// `None` falls back to exec'ing bwrap directly without in-sandbox bind    /// validation.    #[cfg(target_os = "windows")]    wsl_zed_release: Option<(String, String)>,    #[cfg(target_os = "macos")]    seatbelt_config: Option<macos_seatbelt::SeatbeltConfigFile>,} impl Sandbox {    /// Create a sandbox for `policy`, validating it for the current platform.    ///    /// This does not spawn the network proxy or probe `bwrap`; the proxy is    /// created lazily on the first [`Sandbox::wrap`] (so it can chain through an    /// upstream proxy named in the command's environment), and `bwrap`

Lines 1–500 of 1481.

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t