Skip to repository content1163 lines · 46.1 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T03:34:02.109Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
sandboxing.rs
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