Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T01:34:16.676Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

sandboxing.rs

1163 lines · 46.1 KB · rust
1//! Agent-side glue for the [`sandbox`] crate.
2//!
3//! Centralizes the "should agent-run terminal commands be sandboxed for this
4//! process?" check so the system prompt, the terminal tool, and any other
5//! caller see the same answer (and so the `target_os` gate lives in one
6//! place instead of scattered across the agent crate).
7//!
8//! The current policy is: enabled iff the user has the `sandboxing` feature
9//! flag turned on, the project is local, the platform has an integration, and
10//! the user has not persistently allowed unsandboxed execution (the
11//! `allow_unsandboxed` sandbox setting). Setting `allow_unsandboxed`
12//! persistently turns sandboxing off for the model-facing surface entirely:
13//! the plain (non-sandboxed) `terminal` tool is exposed and the system prompt
14//! omits the sandbox section, since every command would run without a wrap
15//! anyway. The model-requested `unsandboxed: true` escape approved "once" or
16//! "for this thread" does NOT change the prompt/tool set — the sandboxed tool
17//! stays exposed and only the individual command runs without a wrap. See
18//! `sandboxing_enabled_for_project` and `ThreadSandboxGrants`.
19//!
20//! macOS (Seatbelt), Linux (Bubblewrap), and Windows (Bubblewrap via WSL)
21//! have real sandbox integrations; on platforms without one the per-command
22//! wrap is a no-op, so commands run with the agent's ambient permissions even
23//! when the flag is on.
24//!
25//! Naming note: this module is about agent terminal sandboxing specifically.
26//! Other agent operations (e.g. file edits) are gated separately.
27
28use agent_settings::{AgentSettings, SandboxPermissions};
29use feature_flags::{FeatureFlagAppExt as _, SandboxingFeatureFlag};
30use gpui::App;
31use http_proxy::HostPattern;
32use project::Project;
33use sandbox::{HostFilesystemLocation, SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy};
34use settings::Settings;
35use std::path::PathBuf;
36
37/// The directory subtrees the sandbox always grants write access to for a
38/// project: its worktree roots. This is the single source of truth shared by
39/// the terminal tool (which hands these to the sandbox as
40/// [`acp_thread::SandboxWrap::writable_paths`]) and the status UI (which lists
41/// them), so the two can't drift if the set ever changes.
42pub fn sandbox_worktree_writable_paths(project: &Project, cx: &App) -> Vec<PathBuf> {
43    project
44        .worktrees(cx)
45        .map(|worktree| worktree.read(cx).abs_path().to_path_buf())
46        .collect()
47}
48
49/// The candidate `.git` paths the sandbox protects for a project. Locating these
50/// requires Git knowledge the sandbox layer can't derive itself: a worktree's
51/// `.git`, a linked worktree's common dir (which lives outside the worktree),
52/// and every discovered repository's git/common dirs.
53pub fn sandbox_git_dirs(project: &Project, cx: &App) -> Vec<PathBuf> {
54    let mut git_dirs = Vec::new();
55
56    for worktree in project.worktrees(cx) {
57        let worktree = worktree.read(cx);
58        let worktree_abs_path = worktree.abs_path();
59        // Protect `<worktree>/.git` even when it doesn't exist yet, so a command
60        // can't `git init` and then write to the freshly created metadata.
61        git_dirs.push(worktree_abs_path.join(".git"));
62        if let Some(root_repo_common_dir) = worktree.root_repo_common_dir() {
63            git_dirs.push(root_repo_common_dir.to_path_buf());
64        }
65    }
66
67    for repository in project.git_store().read(cx).repositories().values() {
68        let repository = repository.read(cx);
69        git_dirs.push(repository.dot_git_abs_path.to_path_buf());
70        git_dirs.push(repository.repository_dir_abs_path.to_path_buf());
71        git_dirs.push(repository.common_dir_abs_path.to_path_buf());
72    }
73
74    git_dirs.sort();
75    git_dirs.dedup();
76    git_dirs
77}
78
79/// What sandbox a thread applies to agent terminal commands, as one value the
80/// UI renders and enforcement builds from. "No sandbox" is its own variant
81/// rather than a maximally-permissive [`SandboxPolicy`] so that a wide-open but
82/// real sandbox (e.g. `allow_fs_write_all` + `allow_all_hosts`) stays
83/// distinguishable from running with no sandbox at all. The sandbox still
84/// enforces invariants such as read-only Git metadata, while unsandboxed commands
85/// run with ambient permissions.
86#[derive(Clone, Debug, Eq, PartialEq)]
87pub enum ThreadSandbox {
88    /// No OS sandbox is applied; commands run with ambient permissions.
89    Unsandboxed,
90    /// A sandbox is applied with this scope.
91    Sandboxed(SandboxPolicy),
92}
93
94impl ThreadSandbox {
95    /// Combine two layers (e.g. the persistent settings and this thread's
96    /// grants).
97    ///
98    /// This is treated as an allowlist - i.e. merging a sandbox that allows
99    /// resource A with a sandbox that allows resource B creates a sandbox with
100    /// access to both resource A and resource B.
101    pub fn merge(self, other: ThreadSandbox) -> ThreadSandbox {
102        match (self, other) {
103            (ThreadSandbox::Unsandboxed, _) | (_, ThreadSandbox::Unsandboxed) => {
104                ThreadSandbox::Unsandboxed
105            }
106            (ThreadSandbox::Sandboxed(a), ThreadSandbox::Sandboxed(b)) => {
107                ThreadSandbox::Sandboxed(a.merge(b))
108            }
109        }
110    }
111
112    /// Whether no OS sandbox is applied.
113    pub fn is_unsandboxed(&self) -> bool {
114        matches!(self, ThreadSandbox::Unsandboxed)
115    }
116
117    /// Attach the project's protected paths to a sandboxed layer. The settings
118    /// and grants don't know the project's `.git` locations, so the caller
119    /// computes them via [`sandbox_git_dirs`]. A no-op for `Unsandboxed`.
120    pub fn with_protected_paths(self, protected_paths: Vec<PathBuf>) -> ThreadSandbox {
121        match self {
122            ThreadSandbox::Unsandboxed => ThreadSandbox::Unsandboxed,
123            ThreadSandbox::Sandboxed(policy) => {
124                // Capture each protected location (pinning its inode / canonical
125                // path). A location that can't be captured is dropped — fail-closed.
126                let protected_paths = protected_paths
127                    .into_iter()
128                    .filter_map(|path| HostFilesystemLocation::new(path).ok())
129                    .collect();
130                ThreadSandbox::Sandboxed(policy.with_protected_paths(protected_paths))
131            }
132        }
133    }
134}
135
136/// The sandbox the user's persistent settings establish for every thread, as a
137/// [`ThreadSandbox`]. The persistent `allow_unsandboxed` setting removes the
138/// sandbox entirely; otherwise the writable-path and host grants form its
139/// scope. The per-thread overrides come from [`ThreadSandboxGrants::thread_sandbox`].
140pub fn settings_thread_sandbox(persistent: &SandboxPermissions) -> ThreadSandbox {
141    if persistent.allow_unsandboxed {
142        ThreadSandbox::Unsandboxed
143    } else {
144        ThreadSandbox::Sandboxed(settings_sandbox_policy(persistent))
145    }
146}
147
148/// Translate the persistent "allow always" sandbox settings into the
149/// cross-platform [`SandboxPolicy`] used for display. This is the "from your
150/// settings" half of the sandbox status surface; the per-thread overrides come
151/// from [`ThreadSandboxGrants::to_policy`].
152pub fn settings_sandbox_policy(persistent: &SandboxPermissions) -> SandboxPolicy {
153    let fs = if persistent.allow_fs_write_all {
154        SandboxFsPolicy::Unrestricted {
155            protected_paths: Vec::new(),
156        }
157    } else {
158        SandboxFsPolicy::Restricted {
159            writable_paths: persistent
160                .write_paths
161                .iter()
162                .filter_map(|path| HostFilesystemLocation::new(path).ok())
163                .collect(),
164            protected_paths: Vec::new(),
165        }
166    };
167    let network = if persistent.allow_all_hosts {
168        SandboxNetPolicy::Unrestricted
169    } else if persistent.network_hosts.is_empty() {
170        SandboxNetPolicy::Blocked
171    } else {
172        SandboxNetPolicy::Restricted {
173            allowed_domains: persistent.network_hosts.clone(),
174        }
175    };
176    SandboxPolicy { fs, network }
177}
178
179/// Whether the sandboxed terminal can be exposed for this project.
180///
181/// The persistent `allow_unsandboxed` setting turns sandboxing off for the
182/// model-facing surface: when it's set we expose the plain `terminal` tool and
183/// omit the sandbox section from the system prompt, because every command would
184/// run without a wrap regardless. This is deliberately keyed off the
185/// *persistent* setting only. A model-requested `unsandboxed: true` escape that
186/// the user approves "once" or "for this thread" keeps the sandboxed tool and
187/// prompt in place, since the model is still operating in the sandbox model and
188/// only escaping individual commands (tracked in `ThreadSandboxGrants`).
189pub(crate) fn sandboxing_enabled_for_project(project: &Project, cx: &App) -> bool {
190    sandboxing_available_for_project(project, cx)
191        && !AgentSettings::get_global(cx)
192            .sandbox_permissions
193            .allow_unsandboxed
194}
195
196/// Whether agent-run terminal commands should be wrapped in an OS-level
197/// sandbox for this process. See module docs for the policy.
198pub(crate) fn sandboxing_enabled(cx: &App) -> bool {
199    cx.has_flag::<SandboxingFeatureFlag>()
200}
201
202/// Whether sandboxing is *applicable* for this project at all — the feature is
203/// enabled, the project is local, and the platform has a sandbox integration —
204/// independent of the persistent `allow_unsandboxed` setting. Used by the UI to
205/// distinguish "sandboxing isn't relevant here" (don't show the indicator) from
206/// "sandboxing is available but turned off in settings" (show it, struck out).
207pub(crate) fn sandboxing_available_for_project(project: &Project, cx: &App) -> bool {
208    sandboxing_enabled(cx)
209        && project.is_local()
210        && cfg!(any(
211            target_os = "macos",
212            target_os = "linux",
213            target_os = "windows"
214        ))
215}
216
217/// Network escalation requested for (or granted to) a sandboxed command.
218///
219/// Network access in the sandbox is allowlisted by hostname: by default
220/// commands have no outbound network, and an escalation lifts that for a
221/// specific set of hosts (or, as a broad escape hatch, every host). The host
222/// patterns are exact hostnames (`github.com`) or leading-`*.` subdomain
223/// wildcards (`*.npmjs.org`); they're validated when constructed so the
224/// variants here always hold well-formed patterns.
225#[derive(Clone, Debug, Default, PartialEq, Eq)]
226pub(crate) enum NetworkRequest {
227    /// No network escalation — the conversation's default (blocked) applies.
228    #[default]
229    None,
230    /// Allow connections only to these host patterns.
231    Hosts(Vec<HostPattern>),
232    /// Allow connections to any host ("arbitrary network access").
233    AnyHost,
234}
235
236impl NetworkRequest {
237    /// Whether this asks for any network access beyond the default (blocked).
238    pub fn is_requested(&self) -> bool {
239        !matches!(self, NetworkRequest::None)
240    }
241
242    /// The host patterns this request names, or an empty slice for the
243    /// `None`/`AnyHost` variants.
244    fn host_patterns(&self) -> &[HostPattern] {
245        match self {
246            NetworkRequest::Hosts(hosts) => hosts,
247            NetworkRequest::None | NetworkRequest::AnyHost => &[],
248        }
249    }
250}
251
252/// A request for elevated sandbox permissions for a single terminal command.
253///
254/// Built from the model-controlled `terminal` tool input after the user has
255/// authorized the baseline command. All paths here have already been resolved
256/// to absolute, canonicalized paths by the caller — never raw, model-provided
257/// strings, and never the model-controlled working directory.
258#[derive(Clone, Debug, Default, PartialEq, Eq)]
259pub(crate) struct SandboxRequest {
260    /// Outbound network access requested for this command.
261    pub network: NetworkRequest,
262
263    /// Allow unrestricted filesystem writes (the broad escape hatch).
264    pub allow_fs_write_all: bool,
265    /// Run the command fully outside the sandbox.
266    pub unsandboxed: bool,
267    /// Concrete paths the command needs to write to. Each grants its whole
268    /// subtree. These are never globs — write access is always a concrete path subtree
269    pub write_paths: Vec<PathBuf>,
270}
271
272impl SandboxRequest {
273    /// Whether this request asks for anything beyond the default sandbox
274    /// scope, and therefore needs user approval.
275    pub fn needs_escalation(&self) -> bool {
276        self.network.is_requested()
277            || self.allow_fs_write_all
278            || self.unsandboxed
279            || !self.write_paths.is_empty()
280    }
281}
282
283/// In-memory record of the sandbox permissions the user approved "for the
284/// rest of the thread".
285///
286/// Lives on the `Thread` and is shared (via `Rc<RefCell<…>>`) with each tool
287/// call's event stream so a later command requesting an already-granted
288/// permission can skip the approval prompt. Persistent "allow always" grants
289/// are stored separately in [`SandboxPermissions`].
290#[derive(Default)]
291pub(crate) struct ThreadSandboxGrants {
292    /// Whether arbitrary-host network access has been granted for the thread.
293    network_any_host: bool,
294    /// Host patterns granted network access for the thread. Each covers its
295    /// whole subdomain space; redundant entries are pruned on insert.
296    network_hosts: Vec<HostPattern>,
297    allow_fs_write_all: bool,
298    unsandboxed: bool,
299    /// Whether the user approved running commands *without* a sandbox for the
300    /// rest of the thread when the OS sandbox could not be created (the
301    /// fallback prompt's "Allow for this thread"). Distinct from
302    /// `unsandboxed`, which records a model-requested escape; this is a
303    /// user-acknowledged degradation because the sandbox is unavailable.
304    sandbox_fallback: bool,
305    /// Canonicalized paths granted write access for the thread. Each covers its
306    /// whole subtree; redundant children are pruned on insert.
307    write_paths: Vec<PathBuf>,
308}
309
310impl ThreadSandboxGrants {
311    /// Whether the union of thread grants and persistent "allow always" grants
312    /// covers everything `request` asks for, so the command can run without
313    /// prompting again.
314    ///
315    /// Network coverage uses host-pattern subsumption (`*.foo.com` covers
316    /// `api.foo.com`); write coverage is pure subtree containment. Both are
317    /// fully deterministic and never widen scope, because grants are concrete
318    /// patterns/paths rather than globs.
319    pub fn covers_with_persistent(
320        &self,
321        request: &SandboxRequest,
322        persistent: &SandboxPermissions,
323    ) -> bool {
324        if request.unsandboxed {
325            // The persistent `allow_unsandboxed` setting is intentionally not
326            // consulted here: when it's set, sandboxing is removed from the
327            // model-facing surface (the plain `terminal` tool is exposed
328            // instead of the sandboxed one), so the model can't issue an
329            // `unsandboxed: true` request at all. Only a "for this thread"
330            // grant suppresses the re-prompt while the sandboxed tool is
331            // active — see `sandboxing_enabled_for_project`.
332            return self.unsandboxed;
333        }
334        if !self.network_covered(&request.network, persistent) {
335            return false;
336        }
337
338        if request.allow_fs_write_all && !(self.allow_fs_write_all || persistent.allow_fs_write_all)
339        {
340            return false;
341        }
342        // A full-access write grant covers any concrete write request at the
343        // authorization layer; protected paths are enforced by the sandbox.
344        if self.allow_fs_write_all || persistent.allow_fs_write_all {
345            return true;
346        }
347        request.write_paths.iter().all(|requested| {
348            util::paths::path_within_subtree(
349                requested,
350                self.write_paths
351                    .iter()
352                    .chain(persistent.write_paths.iter())
353                    .map(PathBuf::as_path),
354            )
355        })
356    }
357
358    /// Whether the requested network escalation is already granted by the
359    /// thread grants unioned with persistent "allow always" grants.
360    fn network_covered(&self, request: &NetworkRequest, persistent: &SandboxPermissions) -> bool {
361        let any_host_granted = self.network_any_host || persistent.allow_all_hosts;
362        match request {
363            NetworkRequest::None => true,
364            NetworkRequest::AnyHost => any_host_granted,
365            NetworkRequest::Hosts(requested) => {
366                if any_host_granted {
367                    return true;
368                }
369                let persistent_hosts = parse_persistent_hosts(&persistent.network_hosts);
370                requested.iter().all(|requested| {
371                    self.network_hosts
372                        .iter()
373                        .chain(persistent_hosts.iter())
374                        .any(|granted| granted.covers(requested))
375                })
376            }
377        }
378    }
379
380    /// Whether the user allowed running commands unsandboxed for the rest of
381    /// the thread (the fallback prompt's "Allow for this thread"). Distinct
382    /// from the persistent `allow_unsandboxed` setting.
383    pub fn fallback_granted_for_thread(&self) -> bool {
384        self.sandbox_fallback
385    }
386
387    /// Whether the user approved running model-requested `unsandboxed: true`
388    /// commands for the rest of the thread. Once granted, every command in the
389    /// thread runs without a sandbox (the model can no longer scope access),
390    /// mirroring the `sandbox_fallback` grant.
391    pub fn unsandboxed_granted(&self) -> bool {
392        self.unsandboxed
393    }
394
395    /// Record that the user approved running commands unsandboxed for the rest
396    /// of the thread when the sandbox can't be created. Only the Bubblewrap
397    /// sandboxes (Linux directly, Windows via WSL) can fail to create a
398    /// sandbox, so this is gated to those platforms.
399    #[cfg(any(target_os = "linux", target_os = "windows"))]
400    pub fn record_fallback(&mut self) {
401        self.sandbox_fallback = true;
402    }
403
404    /// The sandbox this thread's grants establish on top of the settings, as a
405    /// [`ThreadSandbox`]. A standing "run unsandboxed" grant (a model-requested
406    /// escape approved for the thread, or the sandbox-creation fallback) removes
407    /// the sandbox entirely; otherwise the granted writable paths and hosts form
408    /// its scope. This is the "overridden in this thread" half of the sandbox
409    /// status surface; the persistent half comes from [`settings_thread_sandbox`].
410    pub fn thread_sandbox(&self) -> ThreadSandbox {
411        if self.unsandboxed || self.sandbox_fallback {
412            ThreadSandbox::Unsandboxed
413        } else {
414            ThreadSandbox::Sandboxed(self.to_policy())
415        }
416    }
417
418    /// Translate the per-thread overrides into the cross-platform
419    /// [`SandboxPolicy`] used for display. This is the "overridden in this
420    /// thread" half of the sandbox status surface; the persistent half comes
421    /// from [`settings_sandbox_policy`].
422    pub fn to_policy(&self) -> SandboxPolicy {
423        let fs = if self.allow_fs_write_all {
424            SandboxFsPolicy::Unrestricted {
425                protected_paths: Vec::new(),
426            }
427        } else {
428            SandboxFsPolicy::Restricted {
429                writable_paths: self
430                    .write_paths
431                    .iter()
432                    .filter_map(|path| HostFilesystemLocation::new(path).ok())
433                    .collect(),
434                protected_paths: Vec::new(),
435            }
436        };
437        let network = if self.network_any_host {
438            SandboxNetPolicy::Unrestricted
439        } else if self.network_hosts.is_empty() {
440            SandboxNetPolicy::Blocked
441        } else {
442            SandboxNetPolicy::Restricted {
443                allowed_domains: self
444                    .network_hosts
445                    .iter()
446                    .map(|host| host.to_string())
447                    .collect(),
448            }
449        };
450        SandboxPolicy { fs, network }
451    }
452
453    /// Serialize these grants for persistence in the thread's database row.
454    /// Host patterns are written in canonical string form so they round-trip
455    /// through [`HostPattern::parse`] on load.
456    pub fn to_db(&self) -> crate::db::DbSandboxGrants {
457        crate::db::DbSandboxGrants {
458            write_paths: self.write_paths.clone(),
459            network_hosts: self
460                .network_hosts
461                .iter()
462                .map(|host| host.to_string())
463                .collect(),
464            network_any_host: self.network_any_host,
465            allow_fs_write_all: self.allow_fs_write_all,
466            unsandboxed: self.unsandboxed,
467            sandbox_fallback: self.sandbox_fallback,
468        }
469    }
470
471    /// Rebuild thread grants from the persisted form. Host patterns that no
472    /// longer parse (e.g. after a hand-edit) are dropped with a warning rather
473    /// than failing the whole thread load.
474    pub fn from_db(db: &crate::db::DbSandboxGrants) -> Self {
475        let mut network_hosts = Vec::new();
476        for raw in &db.network_hosts {
477            match HostPattern::parse(raw) {
478                Ok(pattern) => insert_host_pattern(&mut network_hosts, pattern),
479                Err(error) => {
480                    log::warn!("ignoring invalid persisted sandbox network host '{raw}': {error}")
481                }
482            }
483        }
484        Self {
485            network_any_host: db.network_any_host,
486            network_hosts,
487            allow_fs_write_all: db.allow_fs_write_all,
488            unsandboxed: db.unsandboxed,
489            sandbox_fallback: db.sandbox_fallback,
490            write_paths: db.write_paths.clone(),
491        }
492    }
493
494    /// Record everything in `request` as granted for the rest of the thread,
495    /// pruning entries that become redundant.
496    pub fn record(&mut self, request: &SandboxRequest) {
497        match &request.network {
498            NetworkRequest::None => {}
499            NetworkRequest::AnyHost => self.network_any_host = true,
500            NetworkRequest::Hosts(hosts) => {
501                for host in hosts {
502                    insert_host_pattern(&mut self.network_hosts, host.clone());
503                }
504            }
505        }
506        self.allow_fs_write_all |= request.allow_fs_write_all;
507        self.unsandboxed |= request.unsandboxed;
508        for path in &request.write_paths {
509            util::paths::insert_subtree(&mut self.write_paths, path.clone());
510        }
511    }
512
513    /// Compute the effective sandbox permissions to enforce for a command: the
514    /// union of persistent "allow always" grants, thread grants, and this
515    /// specific command's request.
516    ///
517    /// This is what makes standing grants "stick": every sandboxed command
518    /// applies the accumulated grants, so the model can write to a previously
519    /// approved path (or reach a previously approved host) without
520    /// re-requesting it. Passing the current `request` in also covers "allow
521    /// once" grants, which are enforced for this command without being recorded
522    /// for the thread.
523    pub fn effective_with_persistent(
524        &self,
525        request: &SandboxRequest,
526        persistent: &SandboxPermissions,
527    ) -> SandboxRequest {
528        let network = if self.network_any_host
529            || persistent.allow_all_hosts
530            || matches!(request.network, NetworkRequest::AnyHost)
531        {
532            NetworkRequest::AnyHost
533        } else {
534            let mut hosts = Vec::new();
535            for host in self
536                .network_hosts
537                .iter()
538                .cloned()
539                .chain(parse_persistent_hosts(&persistent.network_hosts))
540                .chain(request.network.host_patterns().iter().cloned())
541            {
542                insert_host_pattern(&mut hosts, host);
543            }
544            if hosts.is_empty() {
545                NetworkRequest::None
546            } else {
547                NetworkRequest::Hosts(hosts)
548            }
549        };
550
551        let mut write_paths = persistent.write_paths.clone();
552        for path in self.write_paths.iter().chain(request.write_paths.iter()) {
553            util::paths::insert_subtree(&mut write_paths, path.clone());
554        }
555        SandboxRequest {
556            network,
557            allow_fs_write_all: persistent.allow_fs_write_all
558                || self.allow_fs_write_all
559                || request.allow_fs_write_all,
560            unsandboxed: request.unsandboxed,
561            write_paths,
562        }
563    }
564}
565
566/// Parse persisted host strings into patterns, dropping (and logging) any
567/// that fail to validate. Persisted strings are written in canonical form
568/// (see `persist_sandbox_always_permission`), so this normally succeeds; the
569/// filter is defensive against hand-edited settings.
570fn parse_persistent_hosts(raw: &[String]) -> Vec<HostPattern> {
571    raw.iter()
572        .filter_map(|host| match HostPattern::parse(host) {
573            Ok(pattern) => Some(pattern),
574            Err(error) => {
575                log::warn!(
576                    "ignoring invalid network host pattern '{host}' in sandbox settings: {error}"
577                );
578                None
579            }
580        })
581        .collect()
582}
583
584/// Insert `pattern` into a host-pattern set, keeping it minimal: skip it if an
585/// existing entry already subsumes it, and drop existing entries it subsumes.
586/// The host-pattern analogue of [`util::paths::insert_subtree`].
587pub(crate) fn insert_host_pattern(set: &mut Vec<HostPattern>, pattern: HostPattern) {
588    if set.iter().any(|existing| existing.covers(&pattern)) {
589        return;
590    }
591    set.retain(|existing| !pattern.covers(existing));
592    set.push(pattern);
593}
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598    use std::path::Path;
599
600    fn hosts(list: &[&str]) -> NetworkRequest {
601        NetworkRequest::Hosts(
602            list.iter()
603                .map(|h| HostPattern::parse(h).unwrap())
604                .collect(),
605        )
606    }
607
608    fn request(network: NetworkRequest, all: bool, paths: &[&str]) -> SandboxRequest {
609        SandboxRequest {
610            network,
611            allow_fs_write_all: all,
612            unsandboxed: false,
613            write_paths: paths.iter().map(PathBuf::from).collect(),
614        }
615    }
616
617    fn unsandboxed_request() -> SandboxRequest {
618        SandboxRequest {
619            network: NetworkRequest::None,
620            allow_fs_write_all: false,
621            unsandboxed: true,
622            write_paths: Vec::new(),
623        }
624    }
625
626    #[test]
627    fn thread_sandbox_merge_unsandboxed_wins_else_unions_scopes() {
628        // Writable paths are captured as real `HostFilesystemLocation`s (which
629        // open an fd and key on the inode), so the test uses real directories.
630        let dir_a = tempfile::tempdir().expect("create temp dir a");
631        let dir_b = tempfile::tempdir().expect("create temp dir b");
632        let path_a = dir_a.path();
633        let path_b = dir_b.path();
634
635        let policy = |paths: &[&Path], hosts: &[&str]| SandboxPolicy {
636            fs: SandboxFsPolicy::Restricted {
637                writable_paths: paths
638                    .iter()
639                    .map(|p| HostFilesystemLocation::new(p).expect("capture temp dir"))
640                    .collect(),
641                protected_paths: Vec::new(),
642            },
643            network: if hosts.is_empty() {
644                SandboxNetPolicy::Blocked
645            } else {
646                SandboxNetPolicy::Restricted {
647                    allowed_domains: hosts.iter().map(|h| h.to_string()).collect(),
648                }
649            },
650        };
651
652        // Unsandboxed on either side wins — the agent runs with ambient access.
653        assert!(
654            ThreadSandbox::Unsandboxed
655                .merge(ThreadSandbox::Sandboxed(policy(&[path_a], &["a.com"])))
656                .is_unsandboxed()
657        );
658        assert!(
659            ThreadSandbox::Sandboxed(policy(&[path_a], &["a.com"]))
660                .merge(ThreadSandbox::Unsandboxed)
661                .is_unsandboxed()
662        );
663
664        // Two sandboxed layers union their scopes.
665        assert_eq!(
666            ThreadSandbox::Sandboxed(policy(&[path_a], &["a.com"]))
667                .merge(ThreadSandbox::Sandboxed(policy(&[path_b], &["b.com"]))),
668            ThreadSandbox::Sandboxed(policy(&[path_a, path_b], &["a.com", "b.com"]))
669        );
670    }
671
672    #[test]
673    fn settings_thread_sandbox_reflects_allow_unsandboxed() {
674        let unsandboxed = SandboxPermissions {
675            allow_unsandboxed: true,
676            ..Default::default()
677        };
678        assert!(settings_thread_sandbox(&unsandboxed).is_unsandboxed());
679        assert!(matches!(
680            settings_thread_sandbox(&SandboxPermissions::default()),
681            ThreadSandbox::Sandboxed(_)
682        ));
683    }
684
685    #[test]
686    fn thread_grants_sandbox_reflects_unsandboxed_grant() {
687        let mut grants = ThreadSandboxGrants::default();
688        assert!(matches!(
689            grants.thread_sandbox(),
690            ThreadSandbox::Sandboxed(_)
691        ));
692        grants.record(&unsandboxed_request());
693        assert!(grants.thread_sandbox().is_unsandboxed());
694    }
695
696    #[test]
697    fn grants_roundtrip_through_db_form() {
698        let mut grants = ThreadSandboxGrants::default();
699        grants.record(&request(
700            hosts(&["github.com", "*.npmjs.org"]),
701            false,
702            &["/tmp/build"],
703        ));
704        grants.record(&unsandboxed_request());
705
706        let restored = ThreadSandboxGrants::from_db(&grants.to_db());
707
708        // The restored grants cover exactly what the originals did.
709        assert!(covers(
710            &restored,
711            &request(hosts(&["api.npmjs.org"]), false, &["/tmp/build/cache"])
712        ));
713        assert!(covers(&restored, &unsandboxed_request()));
714        assert_eq!(restored.network_hosts, grants.network_hosts);
715        assert_eq!(restored.write_paths, grants.write_paths);
716        assert_eq!(restored.unsandboxed, grants.unsandboxed);
717    }
718
719    #[test]
720    fn db_form_preserves_any_host_and_write_all() {
721        let mut grants = ThreadSandboxGrants::default();
722        grants.record(&request(NetworkRequest::AnyHost, true, &[]));
723
724        let restored = ThreadSandboxGrants::from_db(&grants.to_db());
725        assert!(restored.network_any_host);
726        assert!(restored.allow_fs_write_all);
727        assert!(covers(
728            &restored,
729            &request(NetworkRequest::AnyHost, true, &["/anywhere"])
730        ));
731    }
732
733    #[test]
734    fn thread_grants_to_policy_maps_paths_and_domains() {
735        use sandbox::{SandboxFsPolicy, SandboxNetPolicy};
736
737        // `to_policy` captures real `HostFilesystemLocation`s, so use a real dir.
738        let build_dir = tempfile::tempdir().expect("create temp build dir");
739        let build_path = build_dir.path().to_str().expect("utf-8 temp path");
740
741        let mut grants = ThreadSandboxGrants::default();
742        grants.record(&request(hosts(&["github.com"]), false, &[build_path]));
743        let policy = grants.to_policy();
744        assert_eq!(
745            policy.fs,
746            SandboxFsPolicy::Restricted {
747                writable_paths: vec![
748                    HostFilesystemLocation::new(build_dir.path()).expect("capture temp dir")
749                ],
750                protected_paths: Vec::new(),
751            }
752        );
753        assert_eq!(
754            policy.network,
755            SandboxNetPolicy::Restricted {
756                allowed_domains: vec!["github.com".to_string()]
757            }
758        );
759
760        // No grants at all: writes restricted to nothing, network blocked.
761        let empty = ThreadSandboxGrants::default().to_policy();
762        assert_eq!(
763            empty.fs,
764            SandboxFsPolicy::Restricted {
765                writable_paths: Vec::new(),
766                protected_paths: Vec::new(),
767            }
768        );
769        assert_eq!(empty.network, SandboxNetPolicy::Blocked);
770
771        // The broad escapes map to the unrestricted variants.
772        let mut broad = ThreadSandboxGrants::default();
773        broad.record(&request(NetworkRequest::AnyHost, true, &[]));
774        let policy = broad.to_policy();
775        assert_eq!(
776            policy.fs,
777            SandboxFsPolicy::Unrestricted {
778                protected_paths: Vec::new(),
779            }
780        );
781        assert_eq!(policy.network, SandboxNetPolicy::Unrestricted);
782    }
783
784    #[test]
785    fn settings_policy_maps_persistent_permissions() {
786        use sandbox::{SandboxFsPolicy, SandboxNetPolicy};
787
788        // `settings_sandbox_policy` captures real `HostFilesystemLocation`s.
789        let log_dir = tempfile::tempdir().expect("create temp log dir");
790        let persistent = SandboxPermissions {
791            write_paths: vec![log_dir.path().to_path_buf()],
792            network_hosts: vec!["*.npmjs.org".to_string()],
793            ..Default::default()
794        };
795        let policy = settings_sandbox_policy(&persistent);
796        assert_eq!(
797            policy.fs,
798            SandboxFsPolicy::Restricted {
799                writable_paths: vec![
800                    HostFilesystemLocation::new(log_dir.path()).expect("capture temp dir")
801                ],
802                protected_paths: Vec::new(),
803            }
804        );
805        assert_eq!(
806            policy.network,
807            SandboxNetPolicy::Restricted {
808                allowed_domains: vec!["*.npmjs.org".to_string()]
809            }
810        );
811
812        let unrestricted = SandboxPermissions {
813            allow_all_hosts: true,
814            allow_fs_write_all: true,
815            ..Default::default()
816        };
817        let policy = settings_sandbox_policy(&unrestricted);
818        assert_eq!(
819            policy.fs,
820            SandboxFsPolicy::Unrestricted {
821                protected_paths: Vec::new(),
822            }
823        );
824        assert_eq!(policy.network, SandboxNetPolicy::Unrestricted);
825    }
826
827    #[test]
828    fn db_form_drops_unparsable_persisted_hosts() {
829        let db = crate::db::DbSandboxGrants {
830            // IP literals are explicitly rejected by the host-pattern parser.
831            network_hosts: vec!["github.com".to_string(), "10.0.0.1".to_string()],
832            ..Default::default()
833        };
834        let restored = ThreadSandboxGrants::from_db(&db);
835        assert_eq!(
836            restored.network_hosts,
837            vec![HostPattern::parse("github.com").unwrap()]
838        );
839    }
840
841    fn covers(grants: &ThreadSandboxGrants, request: &SandboxRequest) -> bool {
842        grants.covers_with_persistent(request, &SandboxPermissions::default())
843    }
844
845    fn effective(grants: &ThreadSandboxGrants, request: &SandboxRequest) -> SandboxRequest {
846        grants.effective_with_persistent(request, &SandboxPermissions::default())
847    }
848
849    #[cfg(any(target_os = "linux", target_os = "windows"))]
850    #[test]
851    fn fallback_granted_for_thread_tracks_record_fallback() {
852        let mut grants = ThreadSandboxGrants::default();
853        assert!(!grants.fallback_granted_for_thread());
854
855        // The thread-scoped fallback grant is independent of the
856        // model-requested `unsandboxed` grant.
857        grants.record_fallback();
858        assert!(grants.fallback_granted_for_thread());
859        assert!(!covers(&grants, &unsandboxed_request()));
860    }
861
862    #[test]
863    fn empty_grants_cover_nothing() {
864        let grants = ThreadSandboxGrants::default();
865        assert!(!covers(
866            &grants,
867            &request(NetworkRequest::AnyHost, false, &[])
868        ));
869        assert!(!covers(
870            &grants,
871            &request(hosts(&["github.com"]), false, &[])
872        ));
873        assert!(!covers(&grants, &request(NetworkRequest::None, true, &[])));
874        assert!(!covers(&grants, &unsandboxed_request()));
875        assert!(!covers(
876            &grants,
877            &request(NetworkRequest::None, false, &["/tmp/build"])
878        ));
879    }
880
881    #[test]
882    fn subtree_containment_covers_children() {
883        let mut grants = ThreadSandboxGrants::default();
884        grants.record(&request(NetworkRequest::None, false, &["/tmp/build"]));
885
886        // Exact match and any descendant are covered.
887        assert!(covers(
888            &grants,
889            &request(NetworkRequest::None, false, &["/tmp/build"])
890        ));
891        assert!(covers(
892            &grants,
893            &request(NetworkRequest::None, false, &["/tmp/build/cache"])
894        ));
895        // A sibling / parent is not.
896        assert!(!covers(
897            &grants,
898            &request(NetworkRequest::None, false, &["/tmp/other"])
899        ));
900        assert!(!covers(
901            &grants,
902            &request(NetworkRequest::None, false, &["/tmp"])
903        ));
904    }
905
906    #[test]
907    fn record_prunes_redundant_children() {
908        let mut grants = ThreadSandboxGrants::default();
909        grants.record(&request(NetworkRequest::None, false, &["/tmp/build/cache"]));
910        grants.record(&request(NetworkRequest::None, false, &["/tmp/build"]));
911        assert_eq!(grants.write_paths, vec![PathBuf::from("/tmp/build")]);
912    }
913
914    #[test]
915    fn record_keeps_existing_broader_grant() {
916        let mut grants = ThreadSandboxGrants::default();
917        grants.record(&request(NetworkRequest::None, false, &["/tmp/build"]));
918        grants.record(&request(NetworkRequest::None, false, &["/tmp/build/cache"]));
919        assert_eq!(grants.write_paths, vec![PathBuf::from("/tmp/build")]);
920    }
921
922    #[test]
923    fn all_access_covers_any_concrete_write() {
924        let mut grants = ThreadSandboxGrants::default();
925        grants.record(&request(NetworkRequest::None, true, &[]));
926        assert!(covers(
927            &grants,
928            &request(NetworkRequest::None, false, &["/anywhere/at/all"])
929        ));
930        // But not network, which wasn't granted.
931        assert!(!covers(
932            &grants,
933            &request(NetworkRequest::AnyHost, false, &[])
934        ));
935    }
936
937    #[test]
938    fn any_host_grant_covers_specific_and_any_host() {
939        let mut grants = ThreadSandboxGrants::default();
940        grants.record(&request(NetworkRequest::AnyHost, false, &[]));
941        assert!(covers(
942            &grants,
943            &request(NetworkRequest::AnyHost, false, &[])
944        ));
945        assert!(covers(
946            &grants,
947            &request(hosts(&["github.com"]), false, &[])
948        ));
949        // ...but not an orthogonal write request.
950        assert!(!covers(
951            &grants,
952            &request(NetworkRequest::AnyHost, false, &["/tmp/build"])
953        ));
954    }
955
956    #[test]
957    fn host_grant_covers_subdomains_but_not_any_host() {
958        let mut grants = ThreadSandboxGrants::default();
959        grants.record(&request(hosts(&["*.github.com"]), false, &[]));
960
961        assert!(covers(
962            &grants,
963            &request(hosts(&["api.github.com"]), false, &[])
964        ));
965        assert!(covers(
966            &grants,
967            &request(hosts(&["*.github.com"]), false, &[])
968        ));
969        // The bare parent isn't a subdomain, so it isn't covered.
970        assert!(!covers(
971            &grants,
972            &request(hosts(&["github.com"]), false, &[])
973        ));
974        // A different host isn't covered.
975        assert!(!covers(
976            &grants,
977            &request(hosts(&["npmjs.org"]), false, &[])
978        ));
979        // A specific grant never satisfies an any-host request.
980        assert!(!covers(
981            &grants,
982            &request(NetworkRequest::AnyHost, false, &[])
983        ));
984    }
985
986    #[test]
987    fn record_prunes_redundant_hosts() {
988        let mut grants = ThreadSandboxGrants::default();
989        grants.record(&request(hosts(&["api.github.com"]), false, &[]));
990        grants.record(&request(hosts(&["*.github.com"]), false, &[]));
991        assert_eq!(
992            grants.network_hosts,
993            vec![HostPattern::parse("*.github.com").unwrap()]
994        );
995    }
996
997    #[test]
998    fn unsandboxed_grant_tracked_independently() {
999        let mut grants = ThreadSandboxGrants::default();
1000        grants.record(&unsandboxed_request());
1001        assert!(covers(&grants, &unsandboxed_request()));
1002        assert!(!covers(
1003            &grants,
1004            &request(NetworkRequest::AnyHost, false, &[])
1005        ));
1006        assert!(!covers(&grants, &request(NetworkRequest::None, true, &[])));
1007    }
1008
1009    #[test]
1010    fn persistent_grants_combine_with_thread_grants() {
1011        let mut grants = ThreadSandboxGrants::default();
1012        grants.record(&request(hosts(&["github.com"]), false, &[]));
1013        let persistent = SandboxPermissions {
1014            write_paths: vec![PathBuf::from("/tmp/build")],
1015            ..Default::default()
1016        };
1017
1018        assert!(grants.covers_with_persistent(
1019            &request(hosts(&["github.com"]), false, &["/tmp/build/cache"]),
1020            &persistent
1021        ));
1022        assert!(!grants.covers_with_persistent(
1023            &request(hosts(&["github.com"]), false, &["/tmp/other"]),
1024            &persistent
1025        ));
1026    }
1027
1028    #[test]
1029    fn persistent_network_hosts_are_honored() {
1030        let grants = ThreadSandboxGrants::default();
1031        let persistent = SandboxPermissions {
1032            network_hosts: vec!["*.npmjs.org".to_string()],
1033            ..Default::default()
1034        };
1035
1036        assert!(grants.covers_with_persistent(
1037            &request(hosts(&["registry.npmjs.org"]), false, &[]),
1038            &persistent
1039        ));
1040        assert!(
1041            !grants
1042                .covers_with_persistent(&request(hosts(&["github.com"]), false, &[]), &persistent)
1043        );
1044    }
1045
1046    #[test]
1047    fn persistent_all_access_covers_concrete_writes() {
1048        let grants = ThreadSandboxGrants::default();
1049        let persistent = SandboxPermissions {
1050            allow_fs_write_all: true,
1051            ..Default::default()
1052        };
1053
1054        assert!(grants.covers_with_persistent(
1055            &request(NetworkRequest::None, false, &["/anywhere"]),
1056            &persistent
1057        ));
1058        assert!(
1059            grants.covers_with_persistent(&request(NetworkRequest::None, true, &[]), &persistent)
1060        );
1061        assert!(
1062            !grants
1063                .covers_with_persistent(&request(NetworkRequest::AnyHost, false, &[]), &persistent)
1064        );
1065    }
1066
1067    #[test]
1068    fn thread_grant_covers_unsandboxed_requests() {
1069        // A "for this thread" grant suppresses the re-prompt for later
1070        // `unsandboxed: true` requests within the same thread.
1071        let mut grants = ThreadSandboxGrants::default();
1072        assert!(!covers(&grants, &unsandboxed_request()));
1073        grants.record(&unsandboxed_request());
1074        assert!(covers(&grants, &unsandboxed_request()));
1075
1076        // A thread-wide unsandboxed grant only covers unsandboxed requests; it
1077        // does not widen network or filesystem scope.
1078        assert!(!covers(
1079            &grants,
1080            &request(NetworkRequest::AnyHost, false, &[])
1081        ));
1082        assert!(!covers(&grants, &request(NetworkRequest::None, true, &[])));
1083    }
1084
1085    #[test]
1086    fn persistent_allow_unsandboxed_does_not_cover_here() {
1087        // The persistent setting is handled by removing the sandboxed tool (see
1088        // `sandboxing_enabled_for_project`), not by covering requests, so on
1089        // its own it never makes an `unsandboxed: true` request "covered".
1090        let grants = ThreadSandboxGrants::default();
1091        let persistent = SandboxPermissions {
1092            allow_unsandboxed: true,
1093            ..Default::default()
1094        };
1095        assert!(!grants.covers_with_persistent(&unsandboxed_request(), &persistent));
1096    }
1097
1098    #[test]
1099    fn effective_applies_thread_grants_to_empty_request() {
1100        // The core fix: a command that requests nothing still gets the
1101        // thread's granted write paths in its enforced policy.
1102        let mut grants = ThreadSandboxGrants::default();
1103        grants.record(&request(NetworkRequest::None, false, &["/tmp/build"]));
1104
1105        let effective = effective(&grants, &request(NetworkRequest::None, false, &[]));
1106        assert_eq!(effective.write_paths, vec![PathBuf::from("/tmp/build")]);
1107    }
1108
1109    #[test]
1110    fn effective_unions_grants_with_once_request() {
1111        // An "allow once" path (passed via `request`, never recorded) is
1112        // enforced for this command alongside the standing grants.
1113        let mut grants = ThreadSandboxGrants::default();
1114        grants.record(&request(hosts(&["github.com"]), false, &["/tmp/build"]));
1115
1116        let effective = effective(
1117            &grants,
1118            &request(hosts(&["npmjs.org"]), false, &["/tmp/once"]),
1119        );
1120        assert_eq!(effective.network, hosts(&["github.com", "npmjs.org"]));
1121        assert_eq!(
1122            effective.write_paths,
1123            vec![PathBuf::from("/tmp/build"), PathBuf::from("/tmp/once")]
1124        );
1125    }
1126
1127    #[test]
1128    fn effective_any_host_subsumes_specific_hosts() {
1129        let mut grants = ThreadSandboxGrants::default();
1130        grants.record(&request(hosts(&["github.com"]), false, &[]));
1131
1132        let effective = effective(&grants, &request(NetworkRequest::AnyHost, false, &[]));
1133        assert_eq!(effective.network, NetworkRequest::AnyHost);
1134    }
1135
1136    #[test]
1137    fn effective_applies_persistent_grants_to_empty_request() {
1138        let grants = ThreadSandboxGrants::default();
1139        let persistent = SandboxPermissions {
1140            allow_all_hosts: true,
1141            write_paths: vec![PathBuf::from("/tmp/always")],
1142            ..Default::default()
1143        };
1144
1145        let effective = grants
1146            .effective_with_persistent(&request(NetworkRequest::None, false, &[]), &persistent);
1147        assert_eq!(effective.network, NetworkRequest::AnyHost);
1148        assert_eq!(effective.write_paths, vec![PathBuf::from("/tmp/always")]);
1149    }
1150
1151    #[test]
1152    fn effective_dedupes_request_already_covered_by_grant() {
1153        let mut grants = ThreadSandboxGrants::default();
1154        grants.record(&request(NetworkRequest::None, false, &["/tmp/build"]));
1155
1156        let effective = effective(
1157            &grants,
1158            &request(NetworkRequest::None, false, &["/tmp/build/cache"]),
1159        );
1160        assert_eq!(effective.write_paths, vec![PathBuf::from("/tmp/build")]);
1161    }
1162}
1163
Served at tenant.openagents/omega Member data and write actions are omitted.