//! 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. Load more
//! 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),