Cameron Mcloughlinagent: Re-enable sandbox (#61711)12a19dc3 months agoHistory
754 lines · 31.5 KB · Rust
RawDownload
use agent_client_protocol::schema::v1 as acp;use anyhow::Result;use collections::HashMap;use futures::{FutureExt as _, future::Shared};use gpui::{App, AppContext, AsyncApp, Context, Entity, Task};use http_proxy::Allowlist;use language::LanguageRegistry;use markdown::Markdown;use project::Project;use serde::{Deserialize, Serialize};use std::{    collections::HashMap as StdHashMap,    path::PathBuf,    process::ExitStatus,    sync::{        Arc,        atomic::{AtomicBool, Ordering},    },    time::Instant,};use task::Shell;use util::get_default_system_shell_preferring_bash; /// Request to run a terminal command inside an OS-level sandbox.////// Passed to [`super::AcpThread::create_terminal`]. The actual sandboxing/// mechanism is platform-specific (macOS Seatbelt; Linux Bubblewrap; Windows/// via Bubblewrap inside WSL), so callers describe the *intent* with plain data/// here rather than constructing platform-specific types directly.////// Default is the fully-sandboxed run (no network, project-only writes)./// Setting `network` / `allow_fs_write` requests a relaxation; the caller is/// responsible for having obtained user approval before reaching this point.#[derive(Clone, Debug, Default)]pub struct SandboxWrap {    /// Directory subtrees the sandbox should allow writes to. Pass the    /// project's worktree paths (and any per-command scratch directory)    /// here — *not* the command's working directory, which is model-    /// controlled and would let the model widen its own writable scope.    pub writable_paths: Vec<PathBuf>,    /// Additional write subtrees the user explicitly approved for this    /// command (per-path write grants). Kept separate from `writable_paths`    /// to make the trust boundary explicit: these originate from    /// model-requested paths that passed a user-approval prompt. They are    /// merged with `writable_paths` when generating the sandbox policy.    ///    /// Each grant carries the canonical target it resolved to at approval    /// time; enforcement rebuilds the location via a verifying reopen (see    /// [`granted_write_path_to_location`]) rather than re-resolving the bare    /// requested path, which closes a symlink TOCTOU.    pub extra_write_paths: Vec<settings::GrantedWritePath>,    /// Outbound network access explicitly approved for this command.    pub network: SandboxNetworkAccess,    /// Additional paths that should remain readable but not writable, even when    /// they fall under writable paths.    pub protected_paths: Vec<PathBuf>,    /// Allow unrestricted filesystem writes except for protected paths (ignores    /// ordinary writable paths).    pub allow_fs_write: bool,    /// Whether the project (and therefore this terminal) is local. The    /// enforcing proxy binds a loopback port on this host, so it can only    /// confine local commands; a remote terminal can't reach it.    pub is_local: bool,    /// Windows/WSL only: `(release channel, version)` of the Linux `zed` to    /// provision inside WSL as the sandbox helper (version `latest` for dev    /// builds). Resolved by the agent (which can read the running app's release    /// info) and forwarded to the sandbox. `None` on other platforms, or when    /// the release can't be determined, in which case the WSL backend falls back    /// to running bwrap without in-sandbox bind validation.    pub wsl_zed_release: Option<(String, String)>,} #[derive(Clone, Debug, Default)]pub enum SandboxNetworkAccess {    /// Block all outbound network access.    #[default]    None,    /// Allow only hosts in this allowlist, enforced by routing HTTP/HTTPS    /// through an in-process proxy and confining the command to the proxy's    /// loopback port.    Restricted(Allowlist),    /// Allow unrestricted outbound network access.    All,} /// A structured, serializable reason the OS sandbox could not be created for a/// command. Mirrors the Linux/WSL Bubblewrap failure modes; surfaced to the user/// (and persisted in tool-call metadata) so the UI can/// explain what went wrong.#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]pub enum LinuxWslSandboxError {    /// No usable `bwrap` binary was found on `PATH`.    BwrapNotFound,    /// The only `bwrap` found is setuid-root, which Zed refuses to run.    SetuidRejected,    /// `bwrap` is present but couldn't set up the sandbox (typically because    /// unprivileged user namespaces are disabled).    SandboxProbeFailed,    /// Any other failure, with a human-readable description.    Other(String),} impl From<sandbox::SandboxError> for LinuxWslSandboxError {    fn from(error: sandbox::SandboxError) -> Self {        match error {            sandbox::SandboxError::BwrapNotFound => Self::BwrapNotFound,            sandbox::SandboxError::BwrapSetuidRejected => Self::SetuidRejected,            sandbox::SandboxError::SandboxProbeFailed => Self::SandboxProbeFailed,            error => Self::Other(error.to_string()),        }    }} impl LinuxWslSandboxError {    /// A short, user-facing explanation of why the sandbox couldn't be created,    /// suitable for display in the agent panel.    pub fn user_facing_message(&self) -> String {        match self {            LinuxWslSandboxError::BwrapNotFound => {                "No usable `bwrap` binary was found on your PATH. Install Bubblewrap to let \                 the agent sandbox terminal commands."                    .to_string()            }            LinuxWslSandboxError::SetuidRejected => {                "The only `bwrap` available is setuid-root, which Zed refuses to run. Install \                 a non-setuid Bubblewrap to let the agent sandbox terminal commands."                    .to_string()            }            LinuxWslSandboxError::SandboxProbeFailed => {                "`bwrap` is installed but couldn't create a sandbox, likely because \                 unprivileged user namespaces are disabled on this system."                    .to_string()            }            LinuxWslSandboxError::Other(message) => message.clone(),        }    }     /// The slug of the sandboxing docs section that best explains how to resolve    /// this failure, for deep-linking from the UI. Pair with    /// `client::zed_urls::sandboxing_docs`.    pub fn docs_section(&self) -> &'static str {        match self {            // Both "no bwrap" and "only a setuid-root bwrap" are resolved by            // installing a non-setuid Bubblewrap.            LinuxWslSandboxError::BwrapNotFound | LinuxWslSandboxError::SetuidRejected => {                "installing-bubblewrap"            }            // A failed probe on Linux is almost always disabled unprivileged            // user namespaces, which the Ubuntu-specific section covers.            LinuxWslSandboxError::SandboxProbeFailed => "installing-bubblewrap-ubuntu",            // Catch-all (includes WSL/Windows messages): point at the platform            // overview for the current OS.            LinuxWslSandboxError::Other(_) => {                if cfg!(target_os = "windows") {                    "windows"                } else {                    "linux"                }            }        }    }} /// Rebuild a user-approved write grant into an enforceable/// [`sandbox::HostFilesystemLocation`].////// When the grant carries a resolved canonical (the normal case, established at/// approval time), the location is rebuilt via a verifying/// [`sandbox::HostFilesystemLocation::reopen`] — the load-bearing step of the/// TOCTOU fix. A legacy bare-string grant (no resolved canonical) falls back to/// a fresh [`sandbox::HostFilesystemLocation::capture`].pub fn granted_write_path_to_location(    granted: &settings::GrantedWritePath,) -> std::io::Result<sandbox::HostFilesystemLocation> {    match &granted.resolved {        Some(resolved) => sandbox::HostFilesystemLocation::reopen(&granted.requested, resolved),        None => sandbox::HostFilesystemLocation::capture(&granted.requested),    }} /// Rebuild a grant for enforcement, or log and drop it (fail-closed) if it/// can't be verified.////// A failure here is frequently the symlink-TOCTOU defense firing: the grant's/// canonical was redirected or replaced by a symlink since approval, so/// [`sandbox::HostFilesystemLocation::reopen`] refuses it. That is a/// security-relevant event, so it must be logged rather than silently/// swallowed. The grant is dropped (the command runs without it) rather than/// bound unverified.////// Only for **display** policies (the sandbox-status UI), where a stale grant/// should simply not be shown. Enforcement must not drop grants silently — a/// command would run with less access than the user approved with no signal —/// so [`SandboxWrap::to_policy`] uses the erroring/// [`granted_write_path_to_location`] instead.pub fn granted_write_path_to_location_or_log(    granted: &settings::GrantedWritePath,) -> Option<sandbox::HostFilesystemLocation> {    granted_write_path_to_location(granted)        .inspect_err(|error| {            log::warn!(                "dropping sandbox write grant {}: {error}",                granted.requested.display()            );        })        .ok()} impl SandboxWrap {    /// Whether the OS sandbox for this request can actually be created right now,    /// returning a structured [`LinuxWslSandboxError`] when it can't.    ///    /// The sandbox implementation never runs a command unsandboxed on its own —    /// it aborts if it can't create the sandbox. This lets a caller decide, up    /// front, whether to run sandboxed, fall back to an unsandboxed run    /// (fail-open), or refuse (fail-closed). It runs a brief probe subprocess on    /// Linux, so call it off the main thread. On platforms whose sandbox can't    /// fail to set up this way it always returns `Ok`.    pub fn can_create_sandbox(&self) -> Result<(), LinuxWslSandboxError> {        let policy = self            .to_policy()            .map_err(|error| LinuxWslSandboxError::Other(format!("{error:#}")))?;        sandbox::Sandbox::can_create(&policy).map_err(LinuxWslSandboxError::from)    }     /// Translate this request into the cross-platform [`sandbox::SandboxPolicy`].    ///    /// This is the enforcement-policy construction point, so it **captures** each    /// grant as a [`sandbox::HostFilesystemLocation`] (pinning the inode / canonical    /// path) rather than passing a re-resolvable path.    ///    /// This function has **no filesystem side effects**: it never creates paths,    /// and it **fails** (rather than silently narrowing the policy) when a    /// writable path or approved grant can't be captured — running anyway would    /// give the command silently less access than the model and user were told    /// it has. On Linux a writable grant that doesn't exist can't be captured    /// (bwrap can't bind a missing path); the sanctioned way to get a grant to a    /// new directory is the `create_directory` tool, which creates it (pinning    /// the inode) before the grant is recorded. On macOS a missing leaf still    /// canonicalizes, so such grants are captured directly.    ///    /// It is used both by the side-effect-free [`Self::can_create_sandbox`] probe    /// and by real sandbox construction, and must behave identically.    ///    /// A grant failure here can also be the symlink-TOCTOU defense firing: the    /// grant's canonical was redirected or replaced by a symlink since approval,    /// so the verifying reopen refuses it. Failing the command surfaces that    /// security-relevant event instead of running with the grant quietly    /// missing.    ///    /// Protected paths, by contrast, are **best-effort**: we protect only the    /// ones that exist at creation time (`capture` succeeding *is* the existence    /// check), and silently drop the rest. Unlike a writable grant, a protection    /// can't be materialized — you can't pin the inode of a path that isn't    /// there — and there is an inherent, *accepted* loophole regardless: a    /// command in a non-git directory can `git init` and write hooks into a    /// `.git` that didn't exist when the sandbox was built. Since the protection    /// is defeatable that way no matter what, failing sandbox creation over a    /// currently-absent (or otherwise uncapturable) `.git` would only break    /// legitimate cases — non-git projects, single-file worktrees whose    /// synthesized `settings.json/.git` routes through a file — without closing    /// the hole. So we drop and move on.    fn to_policy(&self) -> Result<sandbox::SandboxPolicy> {        let protected_paths = self            .protected_paths            .iter()            .filter_map(|path| sandbox::HostFilesystemLocation::capture(path).ok())            .collect::<Vec<_>>();        let fs = if self.allow_fs_write {            sandbox::SandboxFsPolicy::Unrestricted { protected_paths }        } else {            // Project worktree paths are captured fresh; user-approved grants are            // rebuilt via the verifying reopen (or captured when legacy bare            // strings) through `granted_write_path_to_location`. A path that            // can't be captured fails the whole construction (never created).            let mut locations = Vec::new();            for path in &self.writable_paths {                let location = sandbox::HostFilesystemLocation::capture(path).map_err(|error| {                    anyhow::anyhow!(error).context(format!(                        "cannot capture writable sandbox path `{}`",                        path.display()                    ))                })?;                locations.push(location);            }            for granted in &self.extra_write_paths {                let location = granted_write_path_to_location(granted).map_err(|error| {                    anyhow::anyhow!(error).context(format!(                        "cannot re-verify approved sandbox write grant `{}` (if the \                         directory was removed, remove the grant or recreate the \                         directory)",                        granted.requested.display()                    ))                })?;                locations.push(location);            }            // Dedupe to a minimal cover on the captured canonical paths, so a            // grant nested under a worktree root (or another grant) is dropped            // rather than bound redundantly.            let writable_paths =                sandbox::normalize_host_filesystem_locations(locations.into_iter());            sandbox::SandboxFsPolicy::Restricted {                writable_paths,                protected_paths,            }        };        let network = match &self.network {            SandboxNetworkAccess::None => sandbox::SandboxNetPolicy::Blocked,            SandboxNetworkAccess::All => sandbox::SandboxNetPolicy::Unrestricted,            SandboxNetworkAccess::Restricted(allowlist) => sandbox::SandboxNetPolicy::Restricted {                allowed_domains: allowlist                    .patterns()                    .iter()                    .map(|pattern| pattern.to_string())                    .collect(),            },        };        Ok(sandbox::SandboxPolicy { fs, network })    }} /// Why the OS sandbox was *not* applied to a terminal command, even though/// sandboxing is active for the thread. Persisted in tool-call metadata so the/// UI can explain the situation after the fact.////// This is deliberately platform-agnostic — every variant exists on every/// platform — so the serialized form stored in the thread database never/// depends on which OS wrote it. Today only Linux/WSL can fail to create a/// sandbox (`ErrorLinuxWsl`), but the variant is named so macOS/Windows can/// grow their own failure cases later without a migration.#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]pub enum SandboxNotAppliedReason {    /// The user disabled the sandbox for the rest of this thread, so the command    /// ran without one. This happens either when the user approved a    /// model-requested `unsandboxed: true` escape "for this thread", or when    /// they chose to run unsandboxed for the thread after a sandbox-creation    /// failure (in which case a preceding tool call's reason is    /// [`SandboxNotAppliedReason::ErrorLinuxWsl`]).    DisabledForThisThread,    /// The Linux/WSL (Bubblewrap) sandbox could not be created for this command.    ErrorLinuxWsl(LinuxWslSandboxError),} /// The live sandbox kept alive for its per-command resources (the network proxy/// and, on macOS, the Seatbelt policy file) until the terminal exits.type SandboxConfigHandle = sandbox::Sandbox; /// Upper bound on preparing a WSL-sandboxed command. Deliberately generous:/// the first invocation after the WSL utility VM has shut down (or after boot)/// has to start the VM and the distro, which routinely takes 10-30 seconds on/// slow disks or under antivirus scanning.#[cfg(target_os = "windows")]pub(crate) const WSL_SANDBOX_WRAP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); /// Wrap `(program, args)` for sandboxed execution, returning the wrapped/// invocation (program, argv, env) plus the live [`sandbox::Sandbox`] that must/// be kept alive for the command's duration. When `sandbox_wrap` is `None` the/// command is returned unchanged.////// The sandbox owns the network proxy (for restricted-network policies) and any/// per-command policy file; the env it returns already routes through that/// proxy when applicable.pub(crate) async fn prepare_sandbox_wrap(    program: String,    args: Vec<String>,    cwd: Option<PathBuf>,    sandbox_wrap: Option<SandboxWrap>,    env: HashMap<String, String>,) -> anyhow::Result<(    String,    Vec<String>,    HashMap<String, String>,    Option<SandboxConfigHandle>,)> {    let Some(sandbox_wrap) = sandbox_wrap else {        return Ok((program, args, env, None));    };     let mut sandbox =        sandbox::Sandbox::new(sandbox_wrap.to_policy()?).map_err(anyhow::Error::new)?;    // Windows/WSL only: tell the sandbox which Linux `zed` to provision inside    // WSL as its `--wsl-sandbox-helper`. A no-op (and a no-op setter) elsewhere.    #[cfg(target_os = "windows")]    if let Some((channel, version)) = sandbox_wrap.wsl_zed_release.clone() {        sandbox.set_wsl_zed_release(channel, version);    }    let command = sandbox::CommandAndArgs {        program,        args,        env: env.into_iter().collect::<StdHashMap<_, _>>(),        cwd,    };    let wrapped = sandbox.wrap(&command).await.map_err(anyhow::Error::new)?;    Ok((        wrapped.program,        wrapped.args,        wrapped.env.into_iter().collect(),        Some(sandbox),    ))} pub struct Terminal {    id: acp::TerminalId,    command: Entity<Markdown>,    working_dir: Option<PathBuf>,    terminal: Entity<terminal::Terminal>,    started_at: Instant,    output: Option<TerminalOutput>,    output_byte_limit: Option<usize>,    _output_task: Shared<Task<acp::TerminalExitStatus>>,    /// Flag indicating whether this terminal was stopped by explicit user action    /// (e.g., clicking the Stop button). This is set before kill() is called    /// so that code awaiting wait_for_exit() can check it deterministically.    user_stopped: Arc<AtomicBool>,    /// The live sandbox (Seatbelt policy file and/or network proxy) kept alive    /// until the sandboxed command exits. `None` when the command isn't    /// sandboxed or after it finishes. Dropping it tears down the proxy on a    /// background thread (see `sandbox::Sandbox`'s `Drop`).    _sandbox: Option<SandboxConfigHandle>,} pub struct TerminalOutput {    pub ended_at: Instant,    pub exit_status: Option<ExitStatus>,    pub content: String,    pub original_content_len: usize,    pub content_line_count: usize,} impl Terminal {    pub fn new(        id: acp::TerminalId,        command_label: &str,        working_dir: Option<PathBuf>,        output_byte_limit: Option<usize>,        terminal: Entity<terminal::Terminal>,        language_registry: Arc<LanguageRegistry>,        sandbox: Option<SandboxConfigHandle>,        cx: &mut Context<Self>,    ) -> Self {        let command_task = terminal.read(cx).wait_for_completed_task(cx);        // Tear the sandbox down on a GPUI background thread when this entity is        // released, rather than relying on `Sandbox`'s `Drop` (which would spawn        // a throwaway thread) on whatever thread releases us. `on_release` hands        // us an `App`, so we can drive the teardown through the background        // executor with `drop_on_current_thread`.        cx.on_release(|this, cx| {            if let Some(sandbox) = this._sandbox.take() {                cx.background_executor()                    .spawn(async move { sandbox.drop_on_current_thread() })                    .detach();            }        })        .detach();        Self {            id,            _sandbox: sandbox,            command: cx.new(|cx| {                Markdown::new(                    format!("```\n{}\n```", command_label).into(),                    Some(language_registry.clone()),                    None,                    cx,                )            }),            working_dir,            terminal,            started_at: Instant::now(),            output: None,            output_byte_limit,            user_stopped: Arc::new(AtomicBool::new(false)),            _output_task: cx                .spawn(async move |this, cx| {                    let exit_status = command_task.await;                     this.update(cx, |this, cx| {                        let (content, original_content_len) = this.truncated_output(cx);                        let content_line_count = this.terminal.read(cx).total_lines();                         this.output = Some(TerminalOutput {                            ended_at: Instant::now(),                            exit_status,                            content,                            original_content_len,                            content_line_count,                        });                        // Free the sandbox (and its network proxy) as soon as                        // the command finishes, rather than holding it until                        // this entity is released. The proxy's teardown joins a                        // listener thread, so run it on the background executor                        // to keep it off the foreground thread.                        if let Some(sandbox) = this._sandbox.take() {                            cx.background_executor()                                .spawn(async move { sandbox.drop_on_current_thread() })                                .detach();                        }                        cx.notify();                    })                    .ok(); 

Lines 1–500 of 754.