Cameron Mcloughlinagent: Re-enable sandbox (#61711)12a19dc3 months agoHistory
1224 lines · 48.2 KB · Rust
RawDownload
//! Agent-side glue for the [`sandbox`] crate.//!//! Centralizes the "should agent-run terminal commands be sandboxed for this//! process?" check so the system prompt, the terminal tool, and any other//! caller see the same answer (and so the `target_os` gate lives in one//! place instead of scattered across the agent crate).//!//! The current policy is: enabled iff the user has the `sandboxing` feature//! flag turned on, the project is local, the platform has an integration, and//! the user has not persistently allowed unsandboxed execution (the//! `allow_unsandboxed` sandbox setting). Setting `allow_unsandboxed`//! persistently turns sandboxing off for the model-facing surface entirely://! the plain (non-sandboxed) `terminal` tool is exposed and the system prompt//! omits the sandbox section, since every command would run without a wrap//! anyway. The model-requested `unsandboxed: true` escape approved "once" or//! "for this thread" does NOT change the prompt/tool set — the sandboxed tool//! stays exposed and only the individual command runs without a wrap. See//! `sandboxing_enabled_for_project` and `ThreadSandboxGrants`.//!//! macOS (Seatbelt), Linux (Bubblewrap), and Windows (Bubblewrap via WSL)//! have real sandbox integrations; on platforms without one the per-command//! wrap is a no-op, so commands run with the agent's ambient permissions even//! when the flag is on.//!//! Naming note: this module is about agent terminal sandboxing specifically.//! Other agent operations (e.g. file edits) are gated separately. use agent_settings::{AgentSettings, SandboxPermissions};use feature_flags::{FeatureFlagAppExt as _, SandboxingFeatureFlag};use gpui::App;use http_proxy::HostPattern;use project::Project;use sandbox::{HostFilesystemLocation, SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy};use settings::{GrantedWritePath, Settings};use std::path::PathBuf; /// The directory subtrees the sandbox always grants write access to for a/// project: its worktree roots. This is the single source of truth shared by/// the terminal tool (which hands these to the sandbox as/// [`acp_thread::SandboxWrap::writable_paths`]) and the status UI (which lists/// them), so the two can't drift if the set ever changes.pub fn sandbox_worktree_writable_paths(project: &Project, cx: &App) -> Vec<PathBuf> {    project        .worktrees(cx)        .map(|worktree| worktree.read(cx).abs_path().to_path_buf())        .collect()} /// The candidate `.git` paths the sandbox protects for a project. Locating these/// requires Git knowledge the sandbox layer can't derive itself: a worktree's/// `.git`, a linked worktree's common dir (which lives outside the worktree),/// and every discovered repository's git/common dirs.pub fn sandbox_git_dirs(project: &Project, cx: &App) -> Vec<PathBuf> {    let mut git_dirs = Vec::new();     for worktree in project.worktrees(cx) {        let worktree = worktree.read(cx);        let worktree_abs_path = worktree.abs_path();        // Protect `<worktree>/.git` even when it doesn't exist yet, so a command        // can't `git init` and then write to the freshly created metadata.        //        // We don't gate this on the worktree's scanned root being a directory:        // that state can be stale/pending, and for a *single-file* worktree        // (e.g. `settings.json` opened on its own) this synthesizes        // `settings.json/.git`, which can never exist. That impossible path is        // resolved authoritatively at capture time — the real filesystem        // reports `NotADirectory`, and `SandboxWrap::to_policy` skips a        // protected path that can't exist — so it never reaches enforcement.        git_dirs.push(worktree_abs_path.join(".git"));        if let Some(root_repo_common_dir) = worktree.root_repo_common_dir() {            git_dirs.push(root_repo_common_dir.to_path_buf());        }    }     for repository in project.git_store().read(cx).repositories().values() {        let repository = repository.read(cx);        git_dirs.push(repository.dot_git_abs_path.to_path_buf());        git_dirs.push(repository.repository_dir_abs_path.to_path_buf());        git_dirs.push(repository.common_dir_abs_path.to_path_buf());    }     git_dirs.sort();    git_dirs.dedup();    git_dirs} /// What sandbox a thread applies to agent terminal commands, as one value the/// UI renders and enforcement builds from. "No sandbox" is its own variant/// rather than a maximally-permissive [`SandboxPolicy`] so that a wide-open but/// real sandbox (e.g. `allow_fs_write_all` + `allow_all_hosts`) stays/// distinguishable from running with no sandbox at all. The sandbox still/// enforces invariants such as read-only Git metadata, while unsandboxed commands/// run with ambient permissions.#[derive(Clone, Debug, Eq, PartialEq)]pub enum ThreadSandbox {    /// No OS sandbox is applied; commands run with ambient permissions.    Unsandboxed,    /// A sandbox is applied with this scope.    Sandboxed(SandboxPolicy),} impl ThreadSandbox {    /// Combine two layers (e.g. the persistent settings and this thread's    /// grants).    ///    /// This is treated as an allowlist - i.e. merging a sandbox that allows    /// resource A with a sandbox that allows resource B creates a sandbox with    /// access to both resource A and resource B.    pub fn merge(self, other: ThreadSandbox) -> ThreadSandbox {        match (self, other) {            (ThreadSandbox::Unsandboxed, _) | (_, ThreadSandbox::Unsandboxed) => {                ThreadSandbox::Unsandboxed            }            (ThreadSandbox::Sandboxed(a), ThreadSandbox::Sandboxed(b)) => {                ThreadSandbox::Sandboxed(a.merge(b))            }        }    }     /// Whether no OS sandbox is applied.    pub fn is_unsandboxed(&self) -> bool {        matches!(self, ThreadSandbox::Unsandboxed)    }     /// Attach the project's protected paths to a sandboxed layer. The settings    /// and grants don't know the project's `.git` locations, so the caller    /// computes them via [`sandbox_git_dirs`]. A no-op for `Unsandboxed`.    pub fn with_protected_paths(self, protected_paths: Vec<PathBuf>) -> ThreadSandbox {        match self {            ThreadSandbox::Unsandboxed => ThreadSandbox::Unsandboxed,            ThreadSandbox::Sandboxed(policy) => {                // Capture each protected location (pinning its inode / canonical                // path). A location that can't be captured is dropped — fail-closed.                let protected_paths = protected_paths                    .into_iter()                    .filter_map(|path| HostFilesystemLocation::capture(path).ok())                    .collect();                ThreadSandbox::Sandboxed(policy.with_protected_paths(protected_paths))            }        }    }} /// The sandbox the user's persistent settings establish for every thread, as a/// [`ThreadSandbox`]. The persistent `allow_unsandboxed` setting removes the/// sandbox entirely; otherwise the writable-path and host grants form its/// scope. The per-thread overrides come from [`ThreadSandboxGrants::thread_sandbox`].pub fn settings_thread_sandbox(persistent: &SandboxPermissions) -> ThreadSandbox {    if persistent.allow_unsandboxed {        ThreadSandbox::Unsandboxed    } else {        ThreadSandbox::Sandboxed(settings_sandbox_policy(persistent))    }} /// Translate the persistent "allow always" sandbox settings into the/// cross-platform [`SandboxPolicy`] used for display. This is the "from your/// settings" half of the sandbox status surface; the per-thread overrides come/// from [`ThreadSandboxGrants::to_policy`].pub fn settings_sandbox_policy(persistent: &SandboxPermissions) -> SandboxPolicy {    let fs = if persistent.allow_fs_write_all {        SandboxFsPolicy::Unrestricted {            protected_paths: Vec::new(),        }    } else {        SandboxFsPolicy::Restricted {            // Route each grant through the verifying reopen (or a fresh capture            // for legacy bare-string grants) so the enforced path is the one            // vetted at approval time. A grant that fails to reopen is logged            // and dropped (fail-closed) rather than silently swallowed, then the            // survivors are reduced to a minimal cover on their canonical paths.            writable_paths: sandbox::normalize_host_filesystem_locations(                persistent                    .write_paths                    .iter()                    .filter_map(acp_thread::granted_write_path_to_location_or_log),            ),            protected_paths: Vec::new(),        }    };    let network = if persistent.allow_all_hosts {        SandboxNetPolicy::Unrestricted    } else if persistent.network_hosts.is_empty() {        SandboxNetPolicy::Blocked    } else {        SandboxNetPolicy::Restricted {            allowed_domains: persistent.network_hosts.clone(),        }    };    SandboxPolicy { fs, network }} /// Whether the sandboxed terminal can be exposed for this project.////// The persistent `allow_unsandboxed` setting turns sandboxing off for the/// model-facing surface: when it's set we expose the plain `terminal` tool and/// omit the sandbox section from the system prompt, because every command would/// run without a wrap regardless. This is deliberately keyed off the/// *persistent* setting only. A model-requested `unsandboxed: true` escape that/// the user approves "once" or "for this thread" keeps the sandboxed tool and/// prompt in place, since the model is still operating in the sandbox model and/// only escaping individual commands (tracked in `ThreadSandboxGrants`).pub(crate) fn sandboxing_enabled_for_project(project: &Project, cx: &App) -> bool {    sandboxing_available_for_project(project, cx)        && !AgentSettings::get_global(cx)            .sandbox_permissions            .allow_unsandboxed} /// Whether agent-run terminal commands should be wrapped in an OS-level/// sandbox for this process. See module docs for the policy.pub(crate) fn sandboxing_enabled(cx: &App) -> bool {    cx.has_flag::<SandboxingFeatureFlag>()} /// Whether sandboxing is *applicable* for this project at all — the feature is/// enabled, the project is local, and the platform has a sandbox integration —/// independent of the persistent `allow_unsandboxed` setting. Used by the UI to/// distinguish "sandboxing isn't relevant here" (don't show the indicator) from/// "sandboxing is available but turned off in settings" (show it, struck out).pub(crate) fn sandboxing_available_for_project(project: &Project, cx: &App) -> bool {    sandboxing_enabled(cx)        && project.is_local()        && cfg!(any(            target_os = "macos",            target_os = "linux",            target_os = "windows"        ))} /// Network escalation requested for (or granted to) a sandboxed command.////// Network access in the sandbox is allowlisted by hostname: by default/// commands have no outbound network, and an escalation lifts that for a/// specific set of hosts (or, as a broad escape hatch, every host). The host/// patterns are exact hostnames (`github.com`) or leading-`*.` subdomain/// wildcards (`*.npmjs.org`); they're validated when constructed so the/// variants here always hold well-formed patterns.#[derive(Clone, Debug, Default, PartialEq, Eq)]pub(crate) enum NetworkRequest {    /// No network escalation — the conversation's default (blocked) applies.    #[default]    None,    /// Allow connections only to these host patterns.    Hosts(Vec<HostPattern>),    /// Allow connections to any host ("arbitrary network access").    AnyHost,} impl NetworkRequest {    /// Whether this asks for any network access beyond the default (blocked).    pub fn is_requested(&self) -> bool {        !matches!(self, NetworkRequest::None)    }     /// The host patterns this request names, or an empty slice for the    /// `None`/`AnyHost` variants.    fn host_patterns(&self) -> &[HostPattern] {        match self {            NetworkRequest::Hosts(hosts) => hosts,            NetworkRequest::None | NetworkRequest::AnyHost => &[],        }    }} /// A request for elevated sandbox permissions for a single terminal command.////// Built from the model-controlled `terminal` tool input after the user has/// authorized the baseline command. All paths here have already been resolved/// to absolute, canonicalized paths by the caller — never raw, model-provided/// strings, and never the model-controlled working directory.#[derive(Clone, Debug, Default, PartialEq, Eq)]pub(crate) struct SandboxRequest {    /// Outbound network access requested for this command.    pub network: NetworkRequest,     /// Allow unrestricted filesystem writes (the broad escape hatch).    pub allow_fs_write_all: bool,    /// Run the command fully outside the sandbox.    pub unsandboxed: bool,    /// Concrete paths the command needs to write to, each paired with the    /// canonical target resolved at approval time. Each grants its whole    /// subtree. These are never globs — write access is always a concrete path subtree    pub write_paths: Vec<GrantedWritePath>,} impl SandboxRequest {    /// Whether this request asks for anything beyond the default sandbox    /// scope, and therefore needs user approval.    pub fn needs_escalation(&self) -> bool {        self.network.is_requested()            || self.allow_fs_write_all            || self.unsandboxed            || !self.write_paths.is_empty()    }} /// In-memory record of the sandbox permissions the user approved "for the/// rest of the thread".////// Lives on the `Thread` and is shared (via `Rc<RefCell<…>>`) with each tool/// call's event stream so a later command requesting an already-granted/// permission can skip the approval prompt. Persistent "allow always" grants/// are stored separately in [`SandboxPermissions`].#[derive(Default)]pub(crate) struct ThreadSandboxGrants {    /// Whether arbitrary-host network access has been granted for the thread.    network_any_host: bool,    /// Host patterns granted network access for the thread. Each covers its    /// whole subdomain space; redundant entries are pruned on insert.    network_hosts: Vec<HostPattern>,    allow_fs_write_all: bool,    unsandboxed: bool,    /// Whether the user approved running commands *without* a sandbox for the    /// rest of the thread when the OS sandbox could not be created (the    /// fallback prompt's "Allow for this thread"). Distinct from    /// `unsandboxed`, which records a model-requested escape; this is a    /// user-acknowledged degradation because the sandbox is unavailable.    sandbox_fallback: bool,    /// Paths granted write access for the thread, each paired with the canonical    /// target resolved at approval time. Each covers its whole subtree; redundant    /// children are pruned on insert (by canonical target).    write_paths: Vec<GrantedWritePath>,} impl ThreadSandboxGrants {    /// Whether the union of thread grants and persistent "allow always" grants    /// covers everything `request` asks for, so the command can run without    /// prompting again.    ///    /// Network coverage uses host-pattern subsumption (`*.foo.com` covers    /// `api.foo.com`); write coverage is pure subtree containment. Both are    /// fully deterministic and never widen scope, because grants are concrete    /// patterns/paths rather than globs.    pub fn covers_with_persistent(        &self,        request: &SandboxRequest,        persistent: &SandboxPermissions,    ) -> bool {        if request.unsandboxed {            // The persistent `allow_unsandboxed` setting is intentionally not            // consulted here: when it's set, sandboxing is removed from the            // model-facing surface (the plain `terminal` tool is exposed            // instead of the sandboxed one), so the model can't issue an            // `unsandboxed: true` request at all. Only a "for this thread"            // grant suppresses the re-prompt while the sandboxed tool is            // active — see `sandboxing_enabled_for_project`.            return self.unsandboxed;        }        if !self.network_covered(&request.network, persistent) {            return false;        }         if request.allow_fs_write_all && !(self.allow_fs_write_all || persistent.allow_fs_write_all)        {            return false;        }        // A full-access write grant covers any concrete write request at the        // authorization layer; protected paths are enforced by the sandbox.        if self.allow_fs_write_all || persistent.allow_fs_write_all {            return true;        }        request.write_paths.iter().all(|request| {            util::paths::path_within_subtree(                request.canonical_or_requested(),                self.write_paths                    .iter()                    .chain(persistent.write_paths.iter())                    .map(|granted| granted.canonical_or_requested()),            )        })    }     /// Whether the requested network escalation is already granted by the    /// thread grants unioned with persistent "allow always" grants.    fn network_covered(&self, request: &NetworkRequest, persistent: &SandboxPermissions) -> bool {        let any_host_granted = self.network_any_host || persistent.allow_all_hosts;        match request {            NetworkRequest::None => true,            NetworkRequest::AnyHost => any_host_granted,            NetworkRequest::Hosts(requested) => {                if any_host_granted {                    return true;                }                let persistent_hosts = parse_persistent_hosts(&persistent.network_hosts);                requested.iter().all(|requested| {                    self.network_hosts                        .iter()                        .chain(persistent_hosts.iter())                        .any(|granted| granted.covers(requested))                })            }        }    }     /// Whether the user allowed running commands unsandboxed for the rest of    /// the thread (the fallback prompt's "Allow for this thread"). Distinct    /// from the persistent `allow_unsandboxed` setting.    pub fn fallback_granted_for_thread(&self) -> bool {        self.sandbox_fallback    }     /// Whether the user approved running model-requested `unsandboxed: true`    /// commands for the rest of the thread. Once granted, every command in the    /// thread runs without a sandbox (the model can no longer scope access),    /// mirroring the `sandbox_fallback` grant.    pub fn unsandboxed_granted(&self) -> bool {        self.unsandboxed    }     /// Record that the user approved running commands unsandboxed for the rest    /// of the thread when the sandbox can't be created. Only the Bubblewrap    /// sandboxes (Linux directly, Windows via WSL) can fail to create a    /// sandbox, so this is gated to those platforms.    #[cfg(any(target_os = "linux", target_os = "windows"))]    pub fn record_fallback(&mut self) {        self.sandbox_fallback = true;    }     /// The sandbox this thread's grants establish on top of the settings, as a    /// [`ThreadSandbox`]. A standing "run unsandboxed" grant (a model-requested    /// escape approved for the thread, or the sandbox-creation fallback) removes    /// the sandbox entirely; otherwise the granted writable paths and hosts form    /// its scope. This is the "overridden in this thread" half of the sandbox    /// status surface; the persistent half comes from [`settings_thread_sandbox`].    pub fn thread_sandbox(&self) -> ThreadSandbox {        if self.unsandboxed || self.sandbox_fallback {            ThreadSandbox::Unsandboxed        } else {            ThreadSandbox::Sandboxed(self.to_policy())        }    }     /// Translate the per-thread overrides into the cross-platform    /// [`SandboxPolicy`] used for display. This is the "overridden in this    /// thread" half of the sandbox status surface; the persistent half comes    /// from [`settings_sandbox_policy`].    pub fn to_policy(&self) -> SandboxPolicy {        let fs = if self.allow_fs_write_all {            SandboxFsPolicy::Unrestricted {                protected_paths: Vec::new(),            }        } else {            SandboxFsPolicy::Restricted {                // Route each grant through the verifying reopen (or a fresh                // capture for legacy bare-string grants) so the enforced path is                // the one vetted at approval time. A grant that fails to reopen                // is logged and dropped (fail-closed) rather than silently                // swallowed, then the survivors are reduced to a minimal cover                // on their canonical paths.                writable_paths: sandbox::normalize_host_filesystem_locations(                    self.write_paths                        .iter()                        .filter_map(acp_thread::granted_write_path_to_location_or_log),                ),                protected_paths: Vec::new(),            }        };        let network = if self.network_any_host {            SandboxNetPolicy::Unrestricted        } else if self.network_hosts.is_empty() {            SandboxNetPolicy::Blocked        } else {            SandboxNetPolicy::Restricted {                allowed_domains: self                    .network_hosts                    .iter()                    .map(|host| host.to_string())                    .collect(),            }        };        SandboxPolicy { fs, network }    }     /// Serialize these grants for persistence in the thread's database row.    /// Host patterns are written in canonical string form so they round-trip    /// through [`HostPattern::parse`] on load.    pub fn to_db(&self) -> crate::db::DbSandboxGrants {        crate::db::DbSandboxGrants {            write_paths: self.write_paths.clone(),            network_hosts: self                .network_hosts                .iter()                .map(|host| host.to_string())                .collect(),            network_any_host: self.network_any_host,            allow_fs_write_all: self.allow_fs_write_all,            unsandboxed: self.unsandboxed,            sandbox_fallback: self.sandbox_fallback,        }    }     /// Rebuild thread grants from the persisted form. Host patterns that no    /// longer parse (e.g. after a hand-edit) are dropped with a warning rather    /// than failing the whole thread load.    pub fn from_db(db: &crate::db::DbSandboxGrants) -> Self {        let mut network_hosts = Vec::new();        for raw in &db.network_hosts {            match HostPattern::parse(raw) {                Ok(pattern) => insert_host_pattern(&mut network_hosts, pattern),

Lines 1–500 of 1224.