Skip to main content

vtcode_safety/sandboxing/policy/
mod.rs

1//! Sandbox policy definitions
2//!
3//! Defines the isolation levels for command execution, following the Codex model.
4//! Implements the "three-question model" from the AI sandbox field guide:
5//! - **Boundary**: What is shared between code and host (kernel-enforced via Seatbelt/Landlock)
6//! - **Policy**: What can code touch (files, network, devices, syscalls)
7//! - **Lifecycle**: What survives between runs (session-scoped approvals)
8
9use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12use vtcode_commons::VtCodePaths;
13
14mod seccomp;
15
16pub use seccomp::{BLOCKED_SYSCALLS, FILTERED_SYSCALLS, SECCOMP_PROFILE_VERSION, SeccompProfile};
17
18/// A root directory that may be written to under the sandbox policy.
19#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
20
21pub struct WritableRoot {
22    /// Absolute path to the writable directory.
23    pub root: PathBuf,
24}
25
26impl WritableRoot {
27    /// Create a new writable root from a path.
28    #[must_use]
29    pub fn new(path: impl Into<PathBuf>) -> Self {
30        Self { root: path.into() }
31    }
32}
33
34/// Network allowlist entry for domain-based egress control.
35///
36/// Following the field guide's recommendation: "Default-deny outbound network, then allowlist."
37#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
38pub struct NetworkAllowlistEntry {
39    /// Domain pattern (e.g., "api.github.com", "*.npmjs.org")
40    pub(crate) domain: String,
41    /// Optional port (defaults to 443 for HTTPS)
42    #[serde(default = "default_https_port")]
43    pub(crate) port: u16,
44    /// Protocol (tcp or udp, defaults to tcp)
45    #[serde(default = "default_protocol")]
46    pub(crate) protocol: String,
47}
48
49fn default_https_port() -> u16 {
50    443
51}
52
53fn default_protocol() -> String {
54    "tcp".to_string()
55}
56
57impl NetworkAllowlistEntry {
58    /// Create a new allowlist entry for HTTPS access to a domain.
59    #[must_use]
60    pub fn https(domain: impl Into<String>) -> Self {
61        Self {
62            domain: domain.into(),
63            port: 443,
64            protocol: "tcp".to_string(),
65        }
66    }
67
68    /// Create a new allowlist entry with custom port.
69    #[must_use]
70    pub fn with_port(domain: impl Into<String>, port: u16) -> Self {
71        Self {
72            domain: domain.into(),
73            port,
74            protocol: "tcp".to_string(),
75        }
76    }
77
78    /// Check if a domain matches this entry (supports wildcard prefix).
79    #[inline]
80    fn matches(&self, domain: &str, port: u16) -> bool {
81        if self.port != port {
82            return false;
83        }
84        if self.domain.starts_with("*.") {
85            let suffix = self.domain.get(1..).unwrap_or_default();
86            let exact = self.domain.get(2..).unwrap_or_default();
87            domain.ends_with(suffix) || domain == exact
88        } else {
89            domain == self.domain
90        }
91    }
92}
93
94/// Default sensitive paths that should be blocked from sandboxed processes.
95///
96/// Following the field guide's warning about "policy leakage":
97/// "If your sandbox can read ~/.ssh or mount host volumes, it can leak credentials."
98pub const DEFAULT_SENSITIVE_PATHS: &[&str] = &[
99    // SSH keys and configuration
100    "~/.ssh",
101    // AWS credentials
102    "~/.aws",
103    // Google Cloud credentials
104    "~/.config/gcloud",
105    // Azure credentials
106    "~/.azure",
107    // Kubernetes config (contains cluster credentials)
108    "~/.kube",
109    // Docker config (may contain registry auth)
110    "~/.docker",
111    // NPM tokens
112    "~/.npmrc",
113    // PyPI tokens
114    "~/.pypirc",
115    // GitHub CLI tokens
116    "~/.config/gh",
117    // Generic secrets directory
118    "~/.secrets",
119    // Gnupg keys
120    "~/.gnupg",
121    // 1Password CLI
122    "~/.config/op",
123    // Vault tokens
124    "~/.vault-token",
125    // Terraform credentials
126    "~/.terraform.d/credentials.tfrc.json",
127    // Cargo registry tokens
128    "~/.cargo/credentials.toml",
129    // Git credentials
130    "~/.git-credentials",
131    // Netrc (may contain passwords)
132    "~/.netrc",
133];
134
135#[cfg(windows)]
136const USERPROFILE_READ_ROOT_EXCLUSIONS: &[&str] = &[
137    ".ssh",
138    ".gnupg",
139    ".aws",
140    ".azure",
141    ".kube",
142    ".docker",
143    ".config",
144    ".npm",
145    ".pki",
146    ".terraform.d",
147];
148
149/// Sensitive path entry for blocking access to credential locations.
150#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
151pub struct SensitivePath {
152    /// Path pattern (supports ~ for home directory)
153    path: String,
154    /// Whether to block read access (true by default)
155    #[serde(default = "default_true")]
156    pub(crate) block_read: bool,
157    /// Whether to block write access (true by default)
158    #[serde(default = "default_true")]
159    pub(crate) block_write: bool,
160}
161
162fn default_true() -> bool {
163    true
164}
165
166impl SensitivePath {
167    /// Create a new sensitive path entry that blocks both read and write.
168    #[must_use]
169    pub fn new(path: impl Into<String>) -> Self {
170        Self {
171            path: path.into(),
172            block_read: true,
173            block_write: true,
174        }
175    }
176
177    /// Create a sensitive path entry that only blocks write access.
178    #[must_use]
179    fn write_only(path: impl Into<String>) -> Self {
180        Self {
181            path: path.into(),
182            block_read: false,
183            block_write: true,
184        }
185    }
186
187    /// Expand ~ to the user's home directory.
188    pub fn expand_path(&self) -> PathBuf {
189        if self.path.starts_with("~/")
190            && let Some(home) = dirs::home_dir()
191        {
192            return home.join(self.path.get(2..).unwrap_or_default());
193        } else if self.path == "~"
194            && let Some(home) = dirs::home_dir()
195        {
196            return home;
197        }
198        PathBuf::from(&self.path)
199    }
200
201    /// Check if a given path matches this sensitive path pattern.
202    pub(crate) fn matches(&self, path: &Path) -> bool {
203        let expanded = self.expand_path();
204        #[cfg(windows)]
205        {
206            let path_norm = normalize_windows_path(path);
207            let expanded_norm = normalize_windows_path(&expanded);
208            let mut expanded_prefix = expanded_norm.clone();
209            if !expanded_prefix.ends_with('/') {
210                expanded_prefix.push('/');
211            }
212            path_norm == expanded_norm || path_norm.starts_with(&expanded_prefix)
213        }
214        #[cfg(not(windows))]
215        path_starts_with_case_insensitive(path, &expanded)
216    }
217}
218
219#[cfg(not(windows))]
220pub(crate) fn path_starts_with_case_insensitive(path: &Path, prefix: &Path) -> bool {
221    let mut path_components = path.components();
222    prefix.components().all(|prefix_component| {
223        path_components.next().is_some_and(|path_component| {
224            path_component
225                .as_os_str()
226                .to_string_lossy()
227                .eq_ignore_ascii_case(prefix_component.as_os_str().to_string_lossy().as_ref())
228        })
229    })
230}
231
232#[cfg(windows)]
233fn normalize_windows_path(path: &Path) -> String {
234    path.to_string_lossy().replace('\\', "/").to_ascii_lowercase()
235}
236
237/// Get the default sensitive paths as SensitivePath entries.
238pub fn default_sensitive_paths() -> Vec<SensitivePath> {
239    match vtcode_sensitive_paths(&[]) {
240        Ok(paths) => paths,
241        Err(error) => {
242            tracing::warn!(%error, "VT Code path resolution failed; blocking absolute paths fail-closed");
243            let mut paths: Vec<SensitivePath> =
244                DEFAULT_SENSITIVE_PATHS.iter().map(|p| SensitivePath::new(*p)).collect();
245            paths.push(SensitivePath::new("/"));
246            paths
247        }
248    }
249}
250
251fn vtcode_sensitive_paths(environment: &[(&str, &str)]) -> anyhow::Result<Vec<SensitivePath>> {
252    let resolved = if environment.is_empty() {
253        VtCodePaths::resolve()?
254    } else {
255        VtCodePaths::from_environment(environment)?
256    };
257    let mut paths: Vec<SensitivePath> = DEFAULT_SENSITIVE_PATHS.iter().map(|p| SensitivePath::new(*p)).collect();
258    let resolved_roots = [
259        resolved.config_dir().to_path_buf(),
260        resolved.auth_dir(),
261        resolved.data_dir().to_path_buf(),
262        resolved.state_dir().to_path_buf(),
263        resolved.cache_dir().to_path_buf(),
264        resolved.runtime_dir().to_path_buf(),
265        resolved.executable_dir().to_path_buf(),
266        resolved.legacy_dir().to_path_buf(),
267    ];
268    for root in resolved_roots {
269        let path = root.display().to_string();
270        if !paths.iter().any(|existing| existing.path == path) {
271            paths.push(SensitivePath::new(path));
272        }
273    }
274
275    #[cfg(windows)]
276    {
277        for entry in USERPROFILE_READ_ROOT_EXCLUSIONS {
278            let path = format!("~/{}", entry);
279            if !paths.iter().any(|existing| existing.path == path) {
280                paths.push(SensitivePath::new(path));
281            }
282        }
283        Ok(paths)
284    }
285
286    #[cfg(not(windows))]
287    Ok(paths)
288}
289
290const PROTECTED_WRITABLE_ROOT_DIR_NAMES: &[&str] = &[".git", ".vtcode", ".codex", ".agents"];
291
292fn protected_writable_root_sensitive_paths(writable_roots: &[WritableRoot]) -> Vec<SensitivePath> {
293    let mut paths = Vec::new();
294
295    for root in writable_roots {
296        for dir_name in PROTECTED_WRITABLE_ROOT_DIR_NAMES {
297            let protected_path = root.root.join(dir_name).display().to_string();
298            if !paths.iter().any(|existing: &SensitivePath| {
299                existing.path == protected_path && !existing.block_read && existing.block_write
300            }) {
301                paths.push(SensitivePath::write_only(protected_path));
302            }
303        }
304    }
305
306    paths
307}
308
309/// Resource limits for sandboxed execution.
310///
311/// Following the field guide's recommendation for resource accounting:
312/// "CPU, memory, disk, timeouts, and PIDs."
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
314pub struct ResourceLimits {
315    /// Maximum memory usage in megabytes (0 = unlimited).
316    #[serde(default)]
317    pub max_memory_mb: u64,
318
319    /// Maximum number of processes/threads (0 = unlimited).
320    /// Prevents fork bombs.
321    #[serde(default)]
322    pub max_pids: u32,
323
324    /// Maximum disk write in megabytes (0 = unlimited).
325    #[serde(default)]
326    pub max_disk_mb: u64,
327
328    /// CPU time limit in seconds (0 = unlimited).
329    #[serde(default)]
330    pub cpu_time_secs: u64,
331
332    /// Wall clock timeout in seconds (0 = use default).
333    #[serde(default)]
334    pub timeout_secs: u64,
335}
336
337impl Default for ResourceLimits {
338    fn default() -> Self {
339        Self {
340            max_memory_mb: 0,  // Unlimited by default
341            max_pids: 0,       // Unlimited by default
342            max_disk_mb: 0,    // Unlimited by default
343            cpu_time_secs: 0,  // Unlimited by default
344            timeout_secs: 300, // 5 minute wall clock default
345        }
346    }
347}
348
349impl ResourceLimits {
350    /// Create new resource limits with all values unlimited.
351    #[must_use]
352    pub fn unlimited() -> Self {
353        Self {
354            max_memory_mb: 0,
355            max_pids: 0,
356            max_disk_mb: 0,
357            cpu_time_secs: 0,
358            timeout_secs: 0,
359        }
360    }
361
362    /// Create conservative limits suitable for untrusted code.
363    /// Following field guide: "Resource limits: CPU, memory, disk, timeouts, and PIDs."
364    #[must_use]
365    pub fn conservative() -> Self {
366        Self {
367            max_memory_mb: 512,
368            max_pids: 64,
369            max_disk_mb: 1024,
370            cpu_time_secs: 60,
371            timeout_secs: 120,
372        }
373    }
374
375    /// Create moderate limits for semi-trusted code.
376    #[must_use]
377    pub fn moderate() -> Self {
378        Self {
379            max_memory_mb: 2048,
380            max_pids: 256,
381            max_disk_mb: 4096,
382            cpu_time_secs: 300,
383            timeout_secs: 600,
384        }
385    }
386
387    /// Create generous limits for trusted internal code.
388    #[must_use]
389    pub fn generous() -> Self {
390        Self {
391            max_memory_mb: 8192,
392            max_pids: 1024,
393            max_disk_mb: 16384,
394            cpu_time_secs: 0,
395            timeout_secs: 3600,
396        }
397    }
398
399    /// Builder: set memory limit.
400    #[must_use]
401    fn with_memory_mb(mut self, mb: u64) -> Self {
402        self.max_memory_mb = mb;
403        self
404    }
405
406    /// Builder: set PID limit.
407    #[must_use]
408    fn with_max_pids(mut self, pids: u32) -> Self {
409        self.max_pids = pids;
410        self
411    }
412
413    /// Builder: set disk limit.
414    #[must_use]
415    pub fn with_disk_mb(mut self, mb: u64) -> Self {
416        self.max_disk_mb = mb;
417        self
418    }
419
420    /// Builder: set CPU time limit.
421    #[must_use]
422    pub fn with_cpu_time_secs(mut self, secs: u64) -> Self {
423        self.cpu_time_secs = secs;
424        self
425    }
426
427    /// Builder: set timeout.
428    #[must_use]
429    fn with_timeout_secs(mut self, secs: u64) -> Self {
430        self.timeout_secs = secs;
431        self
432    }
433
434    /// Check if any limits are set.
435    #[inline]
436    #[must_use]
437    fn has_limits(&self) -> bool {
438        self.max_memory_mb > 0
439            || self.max_pids > 0
440            || self.max_disk_mb > 0
441            || self.cpu_time_secs > 0
442            || self.timeout_secs > 0
443    }
444
445    /// Get the effective timeout in seconds.
446    #[inline]
447    #[must_use]
448    fn effective_timeout_secs(&self) -> u64 {
449        if self.timeout_secs > 0 { self.timeout_secs } else { 300 }
450    }
451}
452
453/// Sandbox policy determining what operations are permitted during execution.
454///
455/// This follows the Codex sandboxing model with three main variants:
456/// - **ReadOnly**: Only read operations allowed (safe for viewing files)
457/// - **WorkspaceWrite**: Can write within specified directories
458/// - **DangerFullAccess**: No restrictions (dangerous, requires explicit approval)
459///
460/// The field guide's three-question model:
461/// 1. What is shared between this code and the host? (boundary)
462/// 2. What can the code touch? (policy - this enum)
463/// 3. What survives between runs? (lifecycle)
464#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
465#[serde(tag = "type", rename_all = "snake_case")]
466pub enum SandboxPolicy {
467    /// No write access to the filesystem; network access may be restricted or allowlisted.
468    ReadOnly {
469        /// Whether network access is enabled when no allowlist is set.
470        #[serde(default)]
471        network_access: bool,
472
473        /// Domain-based network egress allowlist.
474        #[serde(default)]
475        network_allowlist: Vec<NetworkAllowlistEntry>,
476    },
477
478    /// Write access limited to the specified roots; network controlled by allowlist.
479    WorkspaceWrite {
480        /// Directories where write access is permitted.
481        writable_roots: Vec<WritableRoot>,
482
483        /// Whether network access is allowed (legacy boolean, use network_allowlist for fine-grained control).
484        #[serde(default)]
485        network_access: bool,
486
487        /// Domain-based network egress allowlist.
488        /// When non-empty, only connections to these destinations are permitted.
489        /// Following field guide: "Default-deny outbound network, then allowlist."
490        #[serde(default)]
491        network_allowlist: Vec<NetworkAllowlistEntry>,
492
493        /// Sensitive paths to block (credentials, SSH keys, cloud configs).
494        /// Following field guide: prevents "policy leakage" of credentials.
495        /// Defaults to DEFAULT_SENSITIVE_PATHS if None.
496        #[serde(default)]
497        sensitive_paths: Option<Vec<SensitivePath>>,
498
499        /// Resource limits (memory, PIDs, disk, CPU).
500        /// Following field guide: prevents fork bombs, memory exhaustion.
501        #[serde(default)]
502        resource_limits: ResourceLimits,
503
504        /// Seccomp-BPF profile for Linux syscall filtering.
505        /// Following field guide: "Landlock + seccomp is the recommended Linux pattern."
506        #[serde(default)]
507        seccomp_profile: SeccompProfile,
508
509        /// Exclude the TMPDIR environment variable from writable roots.
510        #[serde(default)]
511        exclude_tmpdir_env_var: bool,
512
513        /// Exclude /tmp from writable roots.
514        #[serde(default)]
515        exclude_slash_tmp: bool,
516    },
517
518    /// Full access - no sandbox restrictions applied.
519    /// Use with extreme caution.
520    DangerFullAccess,
521
522    /// External sandbox - the caller is responsible for sandbox setup.
523    ExternalSandbox {
524        /// Description of the external sandbox mechanism.
525        description: String,
526    },
527}
528
529impl SandboxPolicy {
530    /// Create a read-only policy.
531    #[must_use]
532    pub fn read_only() -> Self {
533        Self::ReadOnly {
534            network_access: false,
535            network_allowlist: Vec::new(),
536        }
537    }
538
539    /// Create a new read-only policy (alias for backwards compatibility).
540    #[must_use]
541    pub fn new_read_only_policy() -> Self {
542        Self::read_only()
543    }
544
545    /// Create a read-only policy with a network allowlist.
546    #[must_use]
547    pub fn read_only_with_network(network_allowlist: Vec<NetworkAllowlistEntry>) -> Self {
548        Self::ReadOnly {
549            network_access: !network_allowlist.is_empty(),
550            network_allowlist,
551        }
552    }
553
554    /// Create a read-only policy with full network access.
555    #[must_use]
556    pub fn read_only_with_full_network() -> Self {
557        Self::ReadOnly {
558            network_access: true,
559            network_allowlist: Vec::new(),
560        }
561    }
562
563    /// Create a workspace-write policy with specified roots.
564    /// Uses default sensitive path blocking and strict seccomp profile.
565    #[must_use]
566    pub fn workspace_write(writable_roots: Vec<PathBuf>) -> Self {
567        Self::WorkspaceWrite {
568            writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
569            network_access: false,
570            network_allowlist: Vec::new(),
571            sensitive_paths: None,
572            resource_limits: ResourceLimits::default(),
573            seccomp_profile: SeccompProfile::strict(),
574            exclude_tmpdir_env_var: true,
575            exclude_slash_tmp: true,
576        }
577    }
578
579    /// Create a workspace-write policy with network allowlist.
580    #[must_use]
581    fn workspace_write_with_network(
582        writable_roots: Vec<PathBuf>,
583        network_allowlist: Vec<NetworkAllowlistEntry>,
584    ) -> Self {
585        Self::WorkspaceWrite {
586            writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
587            network_access: !network_allowlist.is_empty(),
588            network_allowlist,
589            sensitive_paths: None,
590            resource_limits: ResourceLimits::default(),
591            seccomp_profile: SeccompProfile::strict().with_network(),
592            exclude_tmpdir_env_var: true,
593            exclude_slash_tmp: true,
594        }
595    }
596
597    /// Create a workspace-write policy with custom sensitive path settings.
598    #[must_use]
599    pub fn workspace_write_with_sensitive_paths(
600        writable_roots: Vec<PathBuf>,
601        sensitive_paths: Vec<SensitivePath>,
602    ) -> Self {
603        Self::WorkspaceWrite {
604            writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
605            network_access: false,
606            network_allowlist: Vec::new(),
607            sensitive_paths: Some(sensitive_paths),
608            resource_limits: ResourceLimits::default(),
609            seccomp_profile: SeccompProfile::strict(),
610            exclude_tmpdir_env_var: true,
611            exclude_slash_tmp: true,
612        }
613    }
614
615    /// Create a workspace-write policy without sensitive path blocking (dangerous).
616    #[must_use]
617    fn workspace_write_no_sensitive_blocking(writable_roots: Vec<PathBuf>) -> Self {
618        Self::WorkspaceWrite {
619            writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
620            network_access: false,
621            network_allowlist: Vec::new(),
622            sensitive_paths: Some(Vec::new()),
623            resource_limits: ResourceLimits::default(),
624            seccomp_profile: SeccompProfile::strict(),
625            exclude_tmpdir_env_var: true,
626            exclude_slash_tmp: true,
627        }
628    }
629
630    /// Create a workspace-write policy with resource limits.
631    /// Useful for untrusted code that needs containment.
632    #[must_use]
633    fn workspace_write_with_limits(writable_roots: Vec<PathBuf>, resource_limits: ResourceLimits) -> Self {
634        Self::WorkspaceWrite {
635            writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
636            network_access: false,
637            network_allowlist: Vec::new(),
638            sensitive_paths: None,
639            resource_limits,
640            seccomp_profile: SeccompProfile::strict(),
641            exclude_tmpdir_env_var: true,
642            exclude_slash_tmp: true,
643        }
644    }
645
646    /// Create a fully-configured workspace-write policy.
647    #[must_use]
648    pub fn workspace_write_full(
649        writable_roots: Vec<PathBuf>,
650        network_allowlist: Vec<NetworkAllowlistEntry>,
651        sensitive_paths: Option<Vec<SensitivePath>>,
652        resource_limits: ResourceLimits,
653        seccomp_profile: SeccompProfile,
654    ) -> Self {
655        Self::WorkspaceWrite {
656            writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
657            network_access: !network_allowlist.is_empty(),
658            network_allowlist,
659            sensitive_paths,
660            resource_limits,
661            seccomp_profile,
662            exclude_tmpdir_env_var: true,
663            exclude_slash_tmp: true,
664        }
665    }
666
667    /// Create a full-access policy (dangerous).
668    #[must_use]
669    pub fn full_access() -> Self {
670        Self::DangerFullAccess
671    }
672
673    /// Check if the policy allows full network access (unrestricted).
674    #[inline]
675    #[must_use]
676    pub fn has_full_network_access(&self) -> bool {
677        match self {
678            Self::ReadOnly { network_access, network_allowlist }
679            | Self::WorkspaceWrite { network_access, network_allowlist, .. } => {
680                *network_access && network_allowlist.is_empty()
681            }
682            Self::DangerFullAccess | Self::ExternalSandbox { .. } => true,
683        }
684    }
685
686    /// Check if the policy has a network allowlist (domain-restricted access).
687    #[inline]
688    #[must_use]
689    pub fn has_network_allowlist(&self) -> bool {
690        match self {
691            Self::ReadOnly { network_allowlist, .. } | Self::WorkspaceWrite { network_allowlist, .. } => {
692                !network_allowlist.is_empty()
693            }
694            _ => false,
695        }
696    }
697
698    /// Get the network allowlist entries, if any.
699    #[inline]
700    #[must_use]
701    pub fn network_allowlist(&self) -> &[NetworkAllowlistEntry] {
702        match self {
703            Self::ReadOnly { network_allowlist, .. } | Self::WorkspaceWrite { network_allowlist, .. } => {
704                network_allowlist
705            }
706            _ => &[],
707        }
708    }
709
710    /// Check if network access to a specific domain:port is allowed.
711    #[inline]
712    #[must_use]
713    pub fn is_network_allowed(&self, domain: &str, port: u16) -> bool {
714        match self {
715            Self::ReadOnly { network_access, network_allowlist }
716            | Self::WorkspaceWrite { network_access, network_allowlist, .. } => {
717                if network_allowlist.is_empty() {
718                    *network_access
719                } else {
720                    network_allowlist.iter().any(|entry| entry.matches(domain, port))
721                }
722            }
723            Self::DangerFullAccess | Self::ExternalSandbox { .. } => true,
724        }
725    }
726
727    /// Get the effective sensitive paths to block.
728    /// Returns default paths if not explicitly configured.
729    #[must_use]
730    fn sensitive_paths(&self) -> Vec<SensitivePath> {
731        match self {
732            Self::ReadOnly { .. } => default_sensitive_paths(),
733            Self::WorkspaceWrite { sensitive_paths, .. } => {
734                sensitive_paths.clone().unwrap_or_else(default_sensitive_paths)
735            }
736            Self::DangerFullAccess | Self::ExternalSandbox { .. } => Vec::new(),
737        }
738    }
739
740    /// Get sensitive paths including write-only protected directories for writable roots.
741    #[must_use]
742    pub(crate) fn sensitive_paths_for_execution(&self, cwd: &Path) -> Vec<SensitivePath> {
743        match self {
744            Self::WorkspaceWrite { .. } => {
745                let mut sensitive_paths = self.sensitive_paths();
746                sensitive_paths.extend(protected_writable_root_sensitive_paths(&self.get_writable_roots_with_cwd(cwd)));
747                sensitive_paths
748            }
749            _ => self.sensitive_paths(),
750        }
751    }
752
753    /// Check if a path is a sensitive location that should be blocked.
754    #[inline]
755    #[must_use]
756    fn is_sensitive_path(&self, path: &Path) -> bool {
757        self.sensitive_paths().iter().any(|sp| sp.matches(path) && sp.block_read)
758    }
759
760    /// Check if write access to a path is blocked under this policy.
761    #[inline]
762    #[must_use]
763    fn is_path_write_blocked(&self, path: &Path, cwd: &Path) -> bool {
764        match self {
765            Self::DangerFullAccess | Self::ExternalSandbox { .. } => false,
766            _ => self
767                .sensitive_paths_for_execution(cwd)
768                .iter()
769                .any(|sp| sp.matches(path) && sp.block_write),
770        }
771    }
772
773    /// Check if read access to a path is allowed under this policy.
774    #[inline]
775    #[must_use]
776    pub fn is_path_readable(&self, path: &Path) -> bool {
777        match self {
778            Self::DangerFullAccess | Self::ExternalSandbox { .. } => true,
779            _ => !self.is_sensitive_path(path),
780        }
781    }
782
783    /// Get the resource limits for this policy.
784    #[must_use]
785    pub fn resource_limits(&self) -> ResourceLimits {
786        match self {
787            Self::ReadOnly { .. } => ResourceLimits::conservative(),
788            Self::WorkspaceWrite { resource_limits, .. } => resource_limits.clone(),
789            Self::DangerFullAccess | Self::ExternalSandbox { .. } => ResourceLimits::unlimited(),
790        }
791    }
792
793    /// Get the seccomp profile for this policy (Linux only).
794    #[must_use]
795    pub fn seccomp_profile(&self) -> SeccompProfile {
796        match self {
797            Self::ReadOnly { network_access, network_allowlist } => {
798                let mut profile = SeccompProfile::strict();
799                if *network_access || !network_allowlist.is_empty() {
800                    profile = profile.with_network();
801                }
802                profile
803            }
804            Self::WorkspaceWrite { seccomp_profile, .. } => seccomp_profile.clone(),
805            Self::DangerFullAccess | Self::ExternalSandbox { .. } => SeccompProfile::permissive(),
806        }
807    }
808
809    /// Check if the policy allows full disk write access.
810    #[inline]
811    #[must_use]
812    fn has_full_disk_write_access(&self) -> bool {
813        matches!(self, Self::DangerFullAccess | Self::ExternalSandbox { .. })
814    }
815
816    /// Check if the policy allows full disk read access.
817    #[inline]
818    #[must_use]
819    fn has_full_disk_read_access(&self) -> bool {
820        true
821    }
822
823    /// Get the list of writable roots including the current working directory.
824    #[must_use]
825    pub(crate) fn get_writable_roots_with_cwd(&self, cwd: &Path) -> Vec<WritableRoot> {
826        match self {
827            Self::ReadOnly { .. } => vec![],
828            Self::WorkspaceWrite { writable_roots, .. } => {
829                let mut roots = writable_roots.clone();
830                let cwd_root = WritableRoot::new(cwd);
831                if !roots.contains(&cwd_root) {
832                    roots.push(cwd_root);
833                }
834                roots
835            }
836            Self::DangerFullAccess | Self::ExternalSandbox { .. } => {
837                vec![WritableRoot::new(cwd)]
838            }
839        }
840    }
841
842    /// Check if a path is writable under this policy.
843    #[inline]
844    #[must_use]
845    pub fn is_path_writable(&self, path: &Path, cwd: &Path) -> bool {
846        match self {
847            Self::ReadOnly { .. } => false,
848            Self::WorkspaceWrite { .. } => {
849                let writable = self.get_writable_roots_with_cwd(cwd);
850                writable.iter().any(|root| path.starts_with(&root.root)) && !self.is_path_write_blocked(path, cwd)
851            }
852            Self::DangerFullAccess | Self::ExternalSandbox { .. } => true,
853        }
854    }
855
856    /// Validate that another policy can be set from this one.
857    /// Used to enforce policy escalation restrictions.
858    fn can_set(&self, new_policy: &SandboxPolicy) -> anyhow::Result<()> {
859        use SandboxPolicy::*;
860
861        match (self, new_policy) {
862            // Can always downgrade
863            (DangerFullAccess, _) => Ok(()),
864            // Cannot escalate from ReadOnly to write-capable
865            (ReadOnly { .. }, WorkspaceWrite { .. } | DangerFullAccess) => {
866                Err(anyhow::anyhow!("cannot escalate from read-only to write-capable policy"))
867            }
868            // Other transitions are allowed
869            _ => Ok(()),
870        }
871    }
872
873    /// Get a human-readable description of the policy.
874    pub fn description(&self) -> &'static str {
875        match self {
876            Self::ReadOnly { .. } => "read-only access",
877            Self::WorkspaceWrite { .. } => "workspace write access",
878            Self::DangerFullAccess => "full access (dangerous)",
879            Self::ExternalSandbox { .. } => "external sandbox",
880        }
881    }
882}
883
884impl Default for SandboxPolicy {
885    fn default() -> Self {
886        Self::read_only()
887    }
888}
889
890#[cfg(test)]
891mod tests;