//! 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 { 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 { 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 `/.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) -> 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::() } /// 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), /// 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, } 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>`) 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, 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, } 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), Err(error) => { log::warn!("ignoring invalid persisted sandbox network host '{raw}': {error}") } } } Self { network_any_host: db.network_any_host, network_hosts, allow_fs_write_all: db.allow_fs_write_all, unsandboxed: db.unsandboxed, sandbox_fallback: db.sandbox_fallback, write_paths: db.write_paths.clone(), } } /// Record everything in `request` as granted for the rest of the thread, /// pruning entries that become redundant. pub fn record(&mut self, request: &SandboxRequest) { match &request.network { NetworkRequest::None => {} NetworkRequest::AnyHost => self.network_any_host = true, NetworkRequest::Hosts(hosts) => { for host in hosts { insert_host_pattern(&mut self.network_hosts, host.clone()); } } } self.allow_fs_write_all |= request.allow_fs_write_all; self.unsandboxed |= request.unsandboxed; for granted in &request.write_paths { insert_granted_subtree(&mut self.write_paths, granted.clone()); } } /// Compute the effective sandbox permissions to enforce for a command: the /// union of persistent "allow always" grants, thread grants, and this /// specific command's request. /// /// This is what makes standing grants "stick": every sandboxed command /// applies the accumulated grants, so the model can write to a previously /// approved path (or reach a previously approved host) without /// re-requesting it. Passing the current `request` in also covers "allow /// once" grants, which are enforced for this command without being recorded /// for the thread. pub fn effective_with_persistent( &self, request: &SandboxRequest, persistent: &SandboxPermissions, ) -> SandboxRequest { let network = if self.network_any_host || persistent.allow_all_hosts || matches!(request.network, NetworkRequest::AnyHost) { NetworkRequest::AnyHost } else { let mut hosts = Vec::new(); for host in self .network_hosts .iter() .cloned() .chain(parse_persistent_hosts(&persistent.network_hosts)) .chain(request.network.host_patterns().iter().cloned()) { insert_host_pattern(&mut hosts, host); } if hosts.is_empty() { NetworkRequest::None } else { NetworkRequest::Hosts(hosts) } }; let mut write_paths = persistent.write_paths.clone(); for granted in self.write_paths.iter().chain(request.write_paths.iter()) { insert_granted_subtree(&mut write_paths, granted.clone()); } SandboxRequest { network, allow_fs_write_all: persistent.allow_fs_write_all || self.allow_fs_write_all || request.allow_fs_write_all, unsandboxed: request.unsandboxed, write_paths, } } } /// Parse persisted host strings into patterns, dropping (and logging) any /// that fail to validate. Persisted strings are written in canonical form /// (see `persist_sandbox_always_permission`), so this normally succeeds; the /// filter is defensive against hand-edited settings. fn parse_persistent_hosts(raw: &[String]) -> Vec { raw.iter() .filter_map(|host| match HostPattern::parse(host) { Ok(pattern) => Some(pattern), Err(error) => { log::warn!( "ignoring invalid network host pattern '{host}' in sandbox settings: {error}" ); None } }) .collect() } /// Insert `granted` into a write-grant set, keeping it minimal by comparing each /// entry's canonical (resolved) target. Mirrors [`util::paths::insert_subtree`]: /// skip the new grant if an existing entry's canonical is an ancestor-or-equal /// of it, and prune existing entries whose canonical descends from the new one. fn insert_granted_subtree(subtrees: &mut Vec, granted: GrantedWritePath) { if subtrees.iter().any(|existing| { granted .canonical_or_requested() .starts_with(existing.canonical_or_requested()) }) { return; } subtrees.retain(|existing| { !existing .canonical_or_requested() .starts_with(granted.canonical_or_requested()) }); subtrees.push(granted); } /// Insert `pattern` into a host-pattern set, keeping it minimal: skip it if an /// existing entry already subsumes it, and drop existing entries it subsumes. /// The host-pattern analogue of [`util::paths::insert_subtree`]. pub(crate) fn insert_host_pattern(set: &mut Vec, pattern: HostPattern) { if set.iter().any(|existing| existing.covers(&pattern)) { return; } set.retain(|existing| !pattern.covers(existing)); set.push(pattern); } #[cfg(test)] mod tests { use super::*; use std::path::Path; fn hosts(list: &[&str]) -> NetworkRequest { NetworkRequest::Hosts( list.iter() .map(|h| HostPattern::parse(h).unwrap()) .collect(), ) } fn request(network: NetworkRequest, all: bool, paths: &[&str]) -> SandboxRequest { SandboxRequest { network, allow_fs_write_all: all, unsandboxed: false, write_paths: granted(paths), } } /// Bare-string grants (no resolved canonical) for the lexical /// coverage/record/dedup tests, which never build a real policy. fn granted(paths: &[&str]) -> Vec { paths .iter() .map(|p| GrantedWritePath::from_requested(PathBuf::from(p))) .collect() } /// A grant carrying the resolved canonical of a real directory, for the /// tests that build an actual policy (which reopens/captures real fds). fn resolved_grant(dir: &Path) -> GrantedWritePath { GrantedWritePath::resolved( dir.to_path_buf(), sandbox::resolve_canonical(dir).expect("resolve canonical temp dir"), ) } fn unsandboxed_request() -> SandboxRequest { SandboxRequest { network: NetworkRequest::None, allow_fs_write_all: false, unsandboxed: true, write_paths: Vec::new(), } } #[test] fn thread_sandbox_merge_unsandboxed_wins_else_unions_scopes() { // Writable paths are captured as real `HostFilesystemLocation`s (which // open an fd and key on the inode), so the test uses real directories. let dir_a = tempfile::tempdir().expect("create temp dir a"); let dir_b = tempfile::tempdir().expect("create temp dir b"); let path_a = dir_a.path(); let path_b = dir_b.path(); let policy = |paths: &[&Path], hosts: &[&str]| SandboxPolicy { fs: SandboxFsPolicy::Restricted { writable_paths: paths .iter() .map(|p| HostFilesystemLocation::capture(p).expect("capture temp dir")) .collect(), protected_paths: Vec::new(), }, network: if hosts.is_empty() { SandboxNetPolicy::Blocked } else { SandboxNetPolicy::Restricted { allowed_domains: hosts.iter().map(|h| h.to_string()).collect(), } }, }; // Unsandboxed on either side wins — the agent runs with ambient access. assert!( ThreadSandbox::Unsandboxed .merge(ThreadSandbox::Sandboxed(policy(&[path_a], &["a.com"]))) .is_unsandboxed() ); assert!( ThreadSandbox::Sandboxed(policy(&[path_a], &["a.com"])) .merge(ThreadSandbox::Unsandboxed) .is_unsandboxed() ); // Two sandboxed layers union their scopes. assert_eq!( ThreadSandbox::Sandboxed(policy(&[path_a], &["a.com"])) .merge(ThreadSandbox::Sandboxed(policy(&[path_b], &["b.com"]))), ThreadSandbox::Sandboxed(policy(&[path_a, path_b], &["a.com", "b.com"])) ); } #[test] fn settings_thread_sandbox_reflects_allow_unsandboxed() { let unsandboxed = SandboxPermissions { allow_unsandboxed: true, ..Default::default() }; assert!(settings_thread_sandbox(&unsandboxed).is_unsandboxed()); assert!(matches!( settings_thread_sandbox(&SandboxPermissions::default()), ThreadSandbox::Sandboxed(_) )); } #[test] fn thread_grants_sandbox_reflects_unsandboxed_grant() { let mut grants = ThreadSandboxGrants::default(); assert!(matches!( grants.thread_sandbox(), ThreadSandbox::Sandboxed(_) )); grants.record(&unsandboxed_request()); assert!(grants.thread_sandbox().is_unsandboxed()); } #[test] fn grants_roundtrip_through_db_form() { let mut grants = ThreadSandboxGrants::default(); grants.record(&request( hosts(&["github.com", "*.npmjs.org"]), false, &["/tmp/build"], )); grants.record(&unsandboxed_request()); let restored = ThreadSandboxGrants::from_db(&grants.to_db()); // The restored grants cover exactly what the originals did. assert!(covers( &restored, &request(hosts(&["api.npmjs.org"]), false, &["/tmp/build/cache"]) )); assert!(covers(&restored, &unsandboxed_request())); assert_eq!(restored.network_hosts, grants.network_hosts); assert_eq!(restored.write_paths, grants.write_paths); assert_eq!(restored.unsandboxed, grants.unsandboxed); } #[test] fn db_form_preserves_any_host_and_write_all() { let mut grants = ThreadSandboxGrants::default(); grants.record(&request(NetworkRequest::AnyHost, true, &[])); let restored = ThreadSandboxGrants::from_db(&grants.to_db()); assert!(restored.network_any_host); assert!(restored.allow_fs_write_all); assert!(covers( &restored, &request(NetworkRequest::AnyHost, true, &["/anywhere"]) )); } #[test] fn thread_grants_to_policy_maps_paths_and_domains() { use sandbox::{SandboxFsPolicy, SandboxNetPolicy}; // `to_policy` reopens/captures real `HostFilesystemLocation`s, so use a // real dir and a grant carrying its resolved canonical. let build_dir = tempfile::tempdir().expect("create temp build dir"); let mut grants = ThreadSandboxGrants::default(); grants.record(&SandboxRequest { network: hosts(&["github.com"]), allow_fs_write_all: false, unsandboxed: false, write_paths: vec![resolved_grant(build_dir.path())], }); let policy = grants.to_policy(); assert_eq!( policy.fs, SandboxFsPolicy::Restricted { writable_paths: vec![ HostFilesystemLocation::capture(build_dir.path()).expect("capture temp dir") ], protected_paths: Vec::new(), } ); assert_eq!( policy.network, SandboxNetPolicy::Restricted { allowed_domains: vec!["github.com".to_string()] } ); // No grants at all: writes restricted to nothing, network blocked. let empty = ThreadSandboxGrants::default().to_policy(); assert_eq!( empty.fs, SandboxFsPolicy::Restricted { writable_paths: Vec::new(), protected_paths: Vec::new(), } ); assert_eq!(empty.network, SandboxNetPolicy::Blocked); // The broad escapes map to the unrestricted variants. let mut broad = ThreadSandboxGrants::default(); broad.record(&request(NetworkRequest::AnyHost, true, &[])); let policy = broad.to_policy(); assert_eq!( policy.fs, SandboxFsPolicy::Unrestricted { protected_paths: Vec::new(), } ); assert_eq!(policy.network, SandboxNetPolicy::Unrestricted); } #[test] fn settings_policy_maps_persistent_permissions() { use sandbox::{SandboxFsPolicy, SandboxNetPolicy}; // `settings_sandbox_policy` captures real `HostFilesystemLocation`s. let log_dir = tempfile::tempdir().expect("create temp log dir"); let persistent = SandboxPermissions { write_paths: vec![resolved_grant(log_dir.path())], network_hosts: vec!["*.npmjs.org".to_string()], ..Default::default() }; let policy = settings_sandbox_policy(&persistent); assert_eq!( policy.fs, SandboxFsPolicy::Restricted { writable_paths: vec![ HostFilesystemLocation::capture(log_dir.path()).expect("capture temp dir") ], protected_paths: Vec::new(), } ); assert_eq!( policy.network, SandboxNetPolicy::Restricted { allowed_domains: vec!["*.npmjs.org".to_string()] } ); let unrestricted = SandboxPermissions { allow_all_hosts: true, allow_fs_write_all: true, ..Default::default() }; let policy = settings_sandbox_policy(&unrestricted); assert_eq!( policy.fs, SandboxFsPolicy::Unrestricted { protected_paths: Vec::new(), } ); assert_eq!(policy.network, SandboxNetPolicy::Unrestricted); } #[test] fn db_form_drops_unparsable_persisted_hosts() { let db = crate::db::DbSandboxGrants { // IP literals are explicitly rejected by the host-pattern parser. network_hosts: vec!["github.com".to_string(), "10.0.0.1".to_string()], ..Default::default() }; let restored = ThreadSandboxGrants::from_db(&db); assert_eq!( restored.network_hosts, vec![HostPattern::parse("github.com").unwrap()] ); } fn covers(grants: &ThreadSandboxGrants, request: &SandboxRequest) -> bool { grants.covers_with_persistent(request, &SandboxPermissions::default()) } fn effective(grants: &ThreadSandboxGrants, request: &SandboxRequest) -> SandboxRequest { grants.effective_with_persistent(request, &SandboxPermissions::default()) } #[cfg(any(target_os = "linux", target_os = "windows"))] #[test] fn fallback_granted_for_thread_tracks_record_fallback() { let mut grants = ThreadSandboxGrants::default(); assert!(!grants.fallback_granted_for_thread()); // The thread-scoped fallback grant is independent of the // model-requested `unsandboxed` grant. grants.record_fallback(); assert!(grants.fallback_granted_for_thread()); assert!(!covers(&grants, &unsandboxed_request())); } #[test] fn empty_grants_cover_nothing() { let grants = ThreadSandboxGrants::default(); assert!(!covers( &grants, &request(NetworkRequest::AnyHost, false, &[]) )); assert!(!covers( &grants, &request(hosts(&["github.com"]), false, &[]) )); assert!(!covers(&grants, &request(NetworkRequest::None, true, &[]))); assert!(!covers(&grants, &unsandboxed_request())); assert!(!covers( &grants, &request(NetworkRequest::None, false, &["/tmp/build"]) )); } #[test] fn subtree_containment_covers_children() { let mut grants = ThreadSandboxGrants::default(); grants.record(&request(NetworkRequest::None, false, &["/tmp/build"])); // Exact match and any descendant are covered. assert!(covers( &grants, &request(NetworkRequest::None, false, &["/tmp/build"]) )); assert!(covers( &grants, &request(NetworkRequest::None, false, &["/tmp/build/cache"]) )); // A sibling / parent is not. assert!(!covers( &grants, &request(NetworkRequest::None, false, &["/tmp/other"]) )); assert!(!covers( &grants, &request(NetworkRequest::None, false, &["/tmp"]) )); } #[test] fn record_prunes_redundant_children() { let mut grants = ThreadSandboxGrants::default(); grants.record(&request(NetworkRequest::None, false, &["/tmp/build/cache"])); grants.record(&request(NetworkRequest::None, false, &["/tmp/build"])); assert_eq!(grants.write_paths, granted(&["/tmp/build"])); } #[test] fn record_keeps_existing_broader_grant() { let mut grants = ThreadSandboxGrants::default(); grants.record(&request(NetworkRequest::None, false, &["/tmp/build"])); grants.record(&request(NetworkRequest::None, false, &["/tmp/build/cache"])); assert_eq!(grants.write_paths, granted(&["/tmp/build"])); } #[test] fn all_access_covers_any_concrete_write() { let mut grants = ThreadSandboxGrants::default(); grants.record(&request(NetworkRequest::None, true, &[])); assert!(covers( &grants, &request(NetworkRequest::None, false, &["/anywhere/at/all"]) )); // But not network, which wasn't granted. assert!(!covers( &grants, &request(NetworkRequest::AnyHost, false, &[]) )); } #[test] fn any_host_grant_covers_specific_and_any_host() { let mut grants = ThreadSandboxGrants::default(); grants.record(&request(NetworkRequest::AnyHost, false, &[])); assert!(covers( &grants, &request(NetworkRequest::AnyHost, false, &[]) )); assert!(covers( &grants, &request(hosts(&["github.com"]), false, &[]) )); // ...but not an orthogonal write request. assert!(!covers( &grants, &request(NetworkRequest::AnyHost, false, &["/tmp/build"]) )); } #[test] fn host_grant_covers_subdomains_but_not_any_host() { let mut grants = ThreadSandboxGrants::default(); grants.record(&request(hosts(&["*.github.com"]), false, &[])); assert!(covers( &grants, &request(hosts(&["api.github.com"]), false, &[]) )); assert!(covers( &grants, &request(hosts(&["*.github.com"]), false, &[]) )); // The bare parent isn't a subdomain, so it isn't covered. assert!(!covers( &grants, &request(hosts(&["github.com"]), false, &[]) )); // A different host isn't covered. assert!(!covers( &grants, &request(hosts(&["npmjs.org"]), false, &[]) )); // A specific grant never satisfies an any-host request. assert!(!covers( &grants, &request(NetworkRequest::AnyHost, false, &[]) )); } #[test] fn record_prunes_redundant_hosts() { let mut grants = ThreadSandboxGrants::default(); grants.record(&request(hosts(&["api.github.com"]), false, &[])); grants.record(&request(hosts(&["*.github.com"]), false, &[])); assert_eq!( grants.network_hosts, vec![HostPattern::parse("*.github.com").unwrap()] ); } #[test] fn unsandboxed_grant_tracked_independently() { let mut grants = ThreadSandboxGrants::default(); grants.record(&unsandboxed_request()); assert!(covers(&grants, &unsandboxed_request())); assert!(!covers( &grants, &request(NetworkRequest::AnyHost, false, &[]) )); assert!(!covers(&grants, &request(NetworkRequest::None, true, &[]))); } #[test] fn persistent_grants_combine_with_thread_grants() { let mut grants = ThreadSandboxGrants::default(); grants.record(&request(hosts(&["github.com"]), false, &[])); let persistent = SandboxPermissions { write_paths: granted(&["/tmp/build"]), ..Default::default() }; assert!(grants.covers_with_persistent( &request(hosts(&["github.com"]), false, &["/tmp/build/cache"]), &persistent )); assert!(!grants.covers_with_persistent( &request(hosts(&["github.com"]), false, &["/tmp/other"]), &persistent )); } #[test] fn persistent_network_hosts_are_honored() { let grants = ThreadSandboxGrants::default(); let persistent = SandboxPermissions { network_hosts: vec!["*.npmjs.org".to_string()], ..Default::default() }; assert!(grants.covers_with_persistent( &request(hosts(&["registry.npmjs.org"]), false, &[]), &persistent )); assert!( !grants .covers_with_persistent(&request(hosts(&["github.com"]), false, &[]), &persistent) ); } #[test] fn persistent_all_access_covers_concrete_writes() { let grants = ThreadSandboxGrants::default(); let persistent = SandboxPermissions { allow_fs_write_all: true, ..Default::default() }; assert!(grants.covers_with_persistent( &request(NetworkRequest::None, false, &["/anywhere"]), &persistent )); assert!( grants.covers_with_persistent(&request(NetworkRequest::None, true, &[]), &persistent) ); assert!( !grants .covers_with_persistent(&request(NetworkRequest::AnyHost, false, &[]), &persistent) ); } #[test] fn thread_grant_covers_unsandboxed_requests() { // A "for this thread" grant suppresses the re-prompt for later // `unsandboxed: true` requests within the same thread. let mut grants = ThreadSandboxGrants::default(); assert!(!covers(&grants, &unsandboxed_request())); grants.record(&unsandboxed_request()); assert!(covers(&grants, &unsandboxed_request())); // A thread-wide unsandboxed grant only covers unsandboxed requests; it // does not widen network or filesystem scope. assert!(!covers( &grants, &request(NetworkRequest::AnyHost, false, &[]) )); assert!(!covers(&grants, &request(NetworkRequest::None, true, &[]))); } #[test] fn persistent_allow_unsandboxed_does_not_cover_here() { // The persistent setting is handled by removing the sandboxed tool (see // `sandboxing_enabled_for_project`), not by covering requests, so on // its own it never makes an `unsandboxed: true` request "covered". let grants = ThreadSandboxGrants::default(); let persistent = SandboxPermissions { allow_unsandboxed: true, ..Default::default() }; assert!(!grants.covers_with_persistent(&unsandboxed_request(), &persistent)); } #[test] fn effective_applies_thread_grants_to_empty_request() { // The core fix: a command that requests nothing still gets the // thread's granted write paths in its enforced policy. let mut grants = ThreadSandboxGrants::default(); grants.record(&request(NetworkRequest::None, false, &["/tmp/build"])); let effective = effective(&grants, &request(NetworkRequest::None, false, &[])); assert_eq!(effective.write_paths, granted(&["/tmp/build"])); } #[test] fn effective_unions_grants_with_once_request() { // An "allow once" path (passed via `request`, never recorded) is // enforced for this command alongside the standing grants. let mut grants = ThreadSandboxGrants::default(); grants.record(&request(hosts(&["github.com"]), false, &["/tmp/build"])); let effective = effective( &grants, &request(hosts(&["npmjs.org"]), false, &["/tmp/once"]), ); assert_eq!(effective.network, hosts(&["github.com", "npmjs.org"])); assert_eq!(effective.write_paths, granted(&["/tmp/build", "/tmp/once"])); } #[test] fn effective_any_host_subsumes_specific_hosts() { let mut grants = ThreadSandboxGrants::default(); grants.record(&request(hosts(&["github.com"]), false, &[])); let effective = effective(&grants, &request(NetworkRequest::AnyHost, false, &[])); assert_eq!(effective.network, NetworkRequest::AnyHost); } #[test] fn effective_applies_persistent_grants_to_empty_request() { let grants = ThreadSandboxGrants::default(); let persistent = SandboxPermissions { allow_all_hosts: true, write_paths: granted(&["/tmp/always"]), ..Default::default() }; let effective = grants .effective_with_persistent(&request(NetworkRequest::None, false, &[]), &persistent); assert_eq!(effective.network, NetworkRequest::AnyHost); assert_eq!(effective.write_paths, granted(&["/tmp/always"])); } #[test] fn effective_dedupes_request_already_covered_by_grant() { let mut grants = ThreadSandboxGrants::default(); grants.record(&request(NetworkRequest::None, false, &["/tmp/build"])); let effective = effective( &grants, &request(NetworkRequest::None, false, &["/tmp/build/cache"]), ); assert_eq!(effective.write_paths, granted(&["/tmp/build"])); } }