Skip to main content

vtcode_safety/sandboxing/
policy.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
14/// A root directory that may be written to under the sandbox policy.
15#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
16pub struct WritableRoot {
17    /// Absolute path to the writable directory.
18    pub root: PathBuf,
19}
20
21impl WritableRoot {
22    /// Create a new writable root from a path.
23    #[must_use]
24    pub fn new(path: impl Into<PathBuf>) -> Self {
25        Self { root: path.into() }
26    }
27}
28
29/// Network allowlist entry for domain-based egress control.
30///
31/// Following the field guide's recommendation: "Default-deny outbound network, then allowlist."
32#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
33pub struct NetworkAllowlistEntry {
34    /// Domain pattern (e.g., "api.github.com", "*.npmjs.org")
35    pub(crate) domain: String,
36    /// Optional port (defaults to 443 for HTTPS)
37    #[serde(default = "default_https_port")]
38    pub(crate) port: u16,
39    /// Protocol (tcp or udp, defaults to tcp)
40    #[serde(default = "default_protocol")]
41    pub(crate) protocol: String,
42}
43
44fn default_https_port() -> u16 {
45    443
46}
47
48fn default_protocol() -> String {
49    "tcp".to_string()
50}
51
52impl NetworkAllowlistEntry {
53    /// Create a new allowlist entry for HTTPS access to a domain.
54    #[must_use]
55    pub fn https(domain: impl Into<String>) -> Self {
56        Self {
57            domain: domain.into(),
58            port: 443,
59            protocol: "tcp".to_string(),
60        }
61    }
62
63    /// Create a new allowlist entry with custom port.
64    #[must_use]
65    pub fn with_port(domain: impl Into<String>, port: u16) -> Self {
66        Self {
67            domain: domain.into(),
68            port,
69            protocol: "tcp".to_string(),
70        }
71    }
72
73    /// Check if a domain matches this entry (supports wildcard prefix).
74    #[inline]
75    fn matches(&self, domain: &str, port: u16) -> bool {
76        if self.port != port {
77            return false;
78        }
79        if self.domain.starts_with("*.") {
80            let suffix = self.domain.get(1..).unwrap_or_default();
81            let exact = self.domain.get(2..).unwrap_or_default();
82            domain.ends_with(suffix) || domain == exact
83        } else {
84            domain == self.domain
85        }
86    }
87}
88
89/// Default sensitive paths that should be blocked from sandboxed processes.
90///
91/// Following the field guide's warning about "policy leakage":
92/// "If your sandbox can read ~/.ssh or mount host volumes, it can leak credentials."
93pub const DEFAULT_SENSITIVE_PATHS: &[&str] = &[
94    // SSH keys and configuration
95    "~/.ssh",
96    // AWS credentials
97    "~/.aws",
98    // Google Cloud credentials
99    "~/.config/gcloud",
100    // Azure credentials
101    "~/.azure",
102    // Kubernetes config (contains cluster credentials)
103    "~/.kube",
104    // Docker config (may contain registry auth)
105    "~/.docker",
106    // NPM tokens
107    "~/.npmrc",
108    // PyPI tokens
109    "~/.pypirc",
110    // GitHub CLI tokens
111    "~/.config/gh",
112    // Generic secrets directory
113    "~/.secrets",
114    // Gnupg keys
115    "~/.gnupg",
116    // 1Password CLI
117    "~/.config/op",
118    // Vault tokens
119    "~/.vault-token",
120    // Terraform credentials
121    "~/.terraform.d/credentials.tfrc.json",
122    // Cargo registry tokens
123    "~/.cargo/credentials.toml",
124    // Git credentials
125    "~/.git-credentials",
126    // Netrc (may contain passwords)
127    "~/.netrc",
128];
129
130#[cfg(windows)]
131const USERPROFILE_READ_ROOT_EXCLUSIONS: &[&str] = &[
132    ".ssh",
133    ".gnupg",
134    ".aws",
135    ".azure",
136    ".kube",
137    ".docker",
138    ".config",
139    ".npm",
140    ".pki",
141    ".terraform.d",
142];
143
144/// Sensitive path entry for blocking access to credential locations.
145#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
146pub struct SensitivePath {
147    /// Path pattern (supports ~ for home directory)
148    path: String,
149    /// Whether to block read access (true by default)
150    #[serde(default = "default_true")]
151    pub(crate) block_read: bool,
152    /// Whether to block write access (true by default)
153    #[serde(default = "default_true")]
154    pub(crate) block_write: bool,
155}
156
157fn default_true() -> bool {
158    true
159}
160
161impl SensitivePath {
162    /// Create a new sensitive path entry that blocks both read and write.
163    #[must_use]
164    pub fn new(path: impl Into<String>) -> Self {
165        Self {
166            path: path.into(),
167            block_read: true,
168            block_write: true,
169        }
170    }
171
172    /// Create a sensitive path entry that only blocks write access.
173    #[must_use]
174    fn write_only(path: impl Into<String>) -> Self {
175        Self {
176            path: path.into(),
177            block_read: false,
178            block_write: true,
179        }
180    }
181
182    /// Expand ~ to the user's home directory.
183    pub fn expand_path(&self) -> PathBuf {
184        if self.path.starts_with("~/")
185            && let Some(home) = dirs::home_dir()
186        {
187            return home.join(self.path.get(2..).unwrap_or_default());
188        } else if self.path == "~"
189            && let Some(home) = dirs::home_dir()
190        {
191            return home;
192        }
193        PathBuf::from(&self.path)
194    }
195
196    /// Check if a given path matches this sensitive path pattern.
197    pub(crate) fn matches(&self, path: &Path) -> bool {
198        let expanded = self.expand_path();
199        #[cfg(windows)]
200        {
201            let path_norm = normalize_windows_path(path);
202            let expanded_norm = normalize_windows_path(&expanded);
203            let mut expanded_prefix = expanded_norm.clone();
204            if !expanded_prefix.ends_with('/') {
205                expanded_prefix.push('/');
206            }
207            path_norm == expanded_norm || path_norm.starts_with(&expanded_prefix)
208        }
209        #[cfg(not(windows))]
210        path_starts_with_case_insensitive(path, &expanded)
211    }
212}
213
214#[cfg(not(windows))]
215pub(crate) fn path_starts_with_case_insensitive(path: &Path, prefix: &Path) -> bool {
216    let mut path_components = path.components();
217    prefix.components().all(|prefix_component| {
218        path_components.next().is_some_and(|path_component| {
219            path_component
220                .as_os_str()
221                .to_string_lossy()
222                .eq_ignore_ascii_case(prefix_component.as_os_str().to_string_lossy().as_ref())
223        })
224    })
225}
226
227#[cfg(windows)]
228fn normalize_windows_path(path: &Path) -> String {
229    path.to_string_lossy().replace('\\', "/").to_ascii_lowercase()
230}
231
232/// Get the default sensitive paths as SensitivePath entries.
233pub fn default_sensitive_paths() -> Vec<SensitivePath> {
234    match vtcode_sensitive_paths(&[]) {
235        Ok(paths) => paths,
236        Err(error) => {
237            tracing::warn!(%error, "VT Code path resolution failed; blocking absolute paths fail-closed");
238            let mut paths: Vec<SensitivePath> =
239                DEFAULT_SENSITIVE_PATHS.iter().map(|p| SensitivePath::new(*p)).collect();
240            paths.push(SensitivePath::new("/"));
241            paths
242        }
243    }
244}
245
246fn vtcode_sensitive_paths(environment: &[(&str, &str)]) -> anyhow::Result<Vec<SensitivePath>> {
247    let resolved = if environment.is_empty() {
248        VtCodePaths::resolve()?
249    } else {
250        VtCodePaths::from_environment(environment)?
251    };
252    let mut paths: Vec<SensitivePath> = DEFAULT_SENSITIVE_PATHS.iter().map(|p| SensitivePath::new(*p)).collect();
253    let resolved_roots = [
254        resolved.config_dir().to_path_buf(),
255        resolved.auth_dir(),
256        resolved.data_dir().to_path_buf(),
257        resolved.state_dir().to_path_buf(),
258        resolved.cache_dir().to_path_buf(),
259        resolved.runtime_dir().to_path_buf(),
260        resolved.executable_dir().to_path_buf(),
261        resolved.legacy_dir().to_path_buf(),
262    ];
263    for root in resolved_roots {
264        let path = root.display().to_string();
265        if !paths.iter().any(|existing| existing.path == path) {
266            paths.push(SensitivePath::new(path));
267        }
268    }
269
270    #[cfg(windows)]
271    {
272        for entry in USERPROFILE_READ_ROOT_EXCLUSIONS {
273            let path = format!("~/{}", entry);
274            if !paths.iter().any(|existing| existing.path == path) {
275                paths.push(SensitivePath::new(path));
276            }
277        }
278        Ok(paths)
279    }
280
281    #[cfg(not(windows))]
282    Ok(paths)
283}
284
285const PROTECTED_WRITABLE_ROOT_DIR_NAMES: &[&str] = &[".git", ".vtcode", ".codex", ".agents"];
286
287fn protected_writable_root_sensitive_paths(writable_roots: &[WritableRoot]) -> Vec<SensitivePath> {
288    let mut paths = Vec::new();
289
290    for root in writable_roots {
291        for dir_name in PROTECTED_WRITABLE_ROOT_DIR_NAMES {
292            let protected_path = root.root.join(dir_name).display().to_string();
293            if !paths.iter().any(|existing: &SensitivePath| {
294                existing.path == protected_path && !existing.block_read && existing.block_write
295            }) {
296                paths.push(SensitivePath::write_only(protected_path));
297            }
298        }
299    }
300
301    paths
302}
303
304/// Resource limits for sandboxed execution.
305///
306/// Following the field guide's recommendation for resource accounting:
307/// "CPU, memory, disk, timeouts, and PIDs."
308#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
309pub struct ResourceLimits {
310    /// Maximum memory usage in megabytes (0 = unlimited).
311    #[serde(default)]
312    pub max_memory_mb: u64,
313
314    /// Maximum number of processes/threads (0 = unlimited).
315    /// Prevents fork bombs.
316    #[serde(default)]
317    pub max_pids: u32,
318
319    /// Maximum disk write in megabytes (0 = unlimited).
320    #[serde(default)]
321    pub max_disk_mb: u64,
322
323    /// CPU time limit in seconds (0 = unlimited).
324    #[serde(default)]
325    pub cpu_time_secs: u64,
326
327    /// Wall clock timeout in seconds (0 = use default).
328    #[serde(default)]
329    pub timeout_secs: u64,
330}
331
332impl Default for ResourceLimits {
333    fn default() -> Self {
334        Self {
335            max_memory_mb: 0,  // Unlimited by default
336            max_pids: 0,       // Unlimited by default
337            max_disk_mb: 0,    // Unlimited by default
338            cpu_time_secs: 0,  // Unlimited by default
339            timeout_secs: 300, // 5 minute wall clock default
340        }
341    }
342}
343
344impl ResourceLimits {
345    /// Create new resource limits with all values unlimited.
346    #[must_use]
347    pub fn unlimited() -> Self {
348        Self {
349            max_memory_mb: 0,
350            max_pids: 0,
351            max_disk_mb: 0,
352            cpu_time_secs: 0,
353            timeout_secs: 0,
354        }
355    }
356
357    /// Create conservative limits suitable for untrusted code.
358    /// Following field guide: "Resource limits: CPU, memory, disk, timeouts, and PIDs."
359    #[must_use]
360    pub fn conservative() -> Self {
361        Self {
362            max_memory_mb: 512,
363            max_pids: 64,
364            max_disk_mb: 1024,
365            cpu_time_secs: 60,
366            timeout_secs: 120,
367        }
368    }
369
370    /// Create moderate limits for semi-trusted code.
371    #[must_use]
372    pub fn moderate() -> Self {
373        Self {
374            max_memory_mb: 2048,
375            max_pids: 256,
376            max_disk_mb: 4096,
377            cpu_time_secs: 300,
378            timeout_secs: 600,
379        }
380    }
381
382    /// Create generous limits for trusted internal code.
383    #[must_use]
384    pub fn generous() -> Self {
385        Self {
386            max_memory_mb: 8192,
387            max_pids: 1024,
388            max_disk_mb: 16384,
389            cpu_time_secs: 0,
390            timeout_secs: 3600,
391        }
392    }
393
394    /// Builder: set memory limit.
395    #[must_use]
396    fn with_memory_mb(mut self, mb: u64) -> Self {
397        self.max_memory_mb = mb;
398        self
399    }
400
401    /// Builder: set PID limit.
402    #[must_use]
403    fn with_max_pids(mut self, pids: u32) -> Self {
404        self.max_pids = pids;
405        self
406    }
407
408    /// Builder: set disk limit.
409    #[must_use]
410    pub fn with_disk_mb(mut self, mb: u64) -> Self {
411        self.max_disk_mb = mb;
412        self
413    }
414
415    /// Builder: set CPU time limit.
416    #[must_use]
417    pub fn with_cpu_time_secs(mut self, secs: u64) -> Self {
418        self.cpu_time_secs = secs;
419        self
420    }
421
422    /// Builder: set timeout.
423    #[must_use]
424    fn with_timeout_secs(mut self, secs: u64) -> Self {
425        self.timeout_secs = secs;
426        self
427    }
428
429    /// Check if any limits are set.
430    #[inline]
431    #[must_use]
432    fn has_limits(&self) -> bool {
433        self.max_memory_mb > 0
434            || self.max_pids > 0
435            || self.max_disk_mb > 0
436            || self.cpu_time_secs > 0
437            || self.timeout_secs > 0
438    }
439
440    /// Get the effective timeout in seconds.
441    #[inline]
442    #[must_use]
443    fn effective_timeout_secs(&self) -> u64 {
444        if self.timeout_secs > 0 { self.timeout_secs } else { 300 }
445    }
446}
447
448/// Version of the seccomp blocklist schema compiled into this binary.
449///
450/// Bump when `BLOCKED_SYSCALLS` gains or loses an entry so launcher logs
451/// (see `linux_seccomp::apply_seccomp_filter`) can distinguish which policy
452/// a trace came from.
453pub const SECCOMP_PROFILE_VERSION: u32 = 1;
454
455/// Syscalls that should be blocked in seccomp-bpf profiles.
456///
457/// Following the field guide: "A tight seccomp profile blocks syscalls that expand
458/// kernel attack surface or enable escalation."
459///
460/// This is a blocklist (deny-list), not a whitelist. Per the sandboxing-basics
461/// analysis (Emilua 2025), blocklists are inherently fragile: new kernel syscalls,
462/// multiarch numberings, and the x32 ABI (`__X32_SYSCALL_BIT`) can bypass naive
463/// filters. `linux_seccomp::primary_rules` therefore installs both the native
464/// number and its x32-aliased variant on x86_64, and the launcher kills
465/// mismatched architectures outright (`SECCOMP_RET_KILL_PROCESS` via
466/// seccompiler arch validation). Prefer Landlock for filesystem policy and keep
467/// this list focused on escalation/escape primitives.
468///
469/// Group labels mirror the Kafel-inspired families from that analysis
470/// (`Debug`, `FilesystemHandle`, `IoUring`, `ProcessVm`, ...) so future
471/// whitelist work can promote one family at a time.
472pub const BLOCKED_SYSCALLS: &[&str] = &[
473    // Debug/inspection - can be used to escape sandboxes or leak process state
474    "ptrace",
475    "kcmp",
476    "pidfd_getfd",
477    "process_madvise",
478    "process_mrelease",
479    // Mounting - can change filesystem namespace
480    "mount",
481    "umount",
482    "umount2",
483    // FilesystemHandle - file-handle escapes around path-based policy
484    "open_by_handle_at",
485    "name_to_handle_at",
486    // Kernel module loading
487    "init_module",
488    "finit_module",
489    "delete_module",
490    // Kernel replacement
491    "kexec_load",
492    "kexec_file_load",
493    // BPF - can be used for sandbox escape
494    "bpf",
495    // Performance events - information leakage risk
496    "perf_event_open",
497    // Userfaultfd - can be used for race conditions
498    "userfaultfd",
499    // IoUring - widely distrusted for untrusted code; see
500    // https://security.googleblog.com/2023/06/learnings-from-kctf-vrps-42-linux.html
501    "io_uring_setup",
502    "io_uring_enter",
503    "io_uring_register",
504    // ProcessVm - cross-process memory inspection/writes
505    "process_vm_readv",
506    "process_vm_writev",
507    // Reboot/power
508    "reboot",
509    // Swap manipulation
510    "swapon",
511    "swapoff",
512    // System time manipulation
513    "settimeofday",
514    "clock_settime",
515    "adjtimex",
516    // Keyring manipulation
517    "add_key",
518    "request_key",
519    "keyctl",
520    // IO permission
521    "ioperm",
522    "iopl",
523    // Acct - process accounting manipulation
524    "acct",
525    // Quota manipulation
526    "quotactl",
527    // Namespace creation (can bypass restrictions)
528    "unshare",
529    "setns",
530    // Personality - can enable legacy modes
531    "personality",
532];
533
534/// Syscalls that require argument filtering (not fully blocked).
535pub const FILTERED_SYSCALLS: &[&str] = &[
536    // clone/clone3: filter to prevent new namespaces
537    "clone", "clone3", // ioctl: filter to block dangerous device ioctls
538    "ioctl",  // prctl: filter to block dangerous operations
539    "prctl",  // socket: filter to enforce network policy
540    "socket",
541];
542
543/// Seccomp profile configuration for Linux sandboxing.
544///
545/// Used alongside Landlock for defense-in-depth.
546#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
547pub struct SeccompProfile {
548    /// Syscalls to block entirely.
549    #[serde(default = "default_blocked_syscalls")]
550    blocked_syscalls: Vec<String>,
551
552    /// Whether to allow new namespace creation (usually false for sandboxes).
553    #[serde(default)]
554    allow_namespaces: bool,
555
556    /// Whether to allow network socket creation (controlled separately).
557    #[serde(default)]
558    allow_network_sockets: bool,
559
560    /// Whether to log blocked syscalls instead of killing the process.
561    #[serde(default)]
562    log_only: bool,
563}
564
565fn default_blocked_syscalls() -> Vec<String> {
566    BLOCKED_SYSCALLS.iter().map(|s| s.to_string()).collect()
567}
568
569impl Default for SeccompProfile {
570    fn default() -> Self {
571        Self {
572            blocked_syscalls: default_blocked_syscalls(),
573            allow_namespaces: false,
574            allow_network_sockets: false,
575            log_only: false,
576        }
577    }
578}
579
580impl SeccompProfile {
581    /// Syscalls this profile blocks outright.
582    #[must_use]
583    pub(crate) fn blocked_syscalls(&self) -> &[String] {
584        &self.blocked_syscalls
585    }
586
587    /// Whether new namespace creation is permitted.
588    #[must_use]
589    pub(crate) fn allow_namespaces(&self) -> bool {
590        self.allow_namespaces
591    }
592
593    /// Whether network socket creation is permitted.
594    #[must_use]
595    pub(crate) fn allow_network_sockets(&self) -> bool {
596        self.allow_network_sockets
597    }
598
599    /// Whether the profile is log-only (no filter installed).
600    #[must_use]
601    pub(crate) fn log_only(&self) -> bool {
602        self.log_only
603    }
604
605    /// Create a strict profile blocking all dangerous syscalls.
606    #[must_use]
607    pub fn strict() -> Self {
608        Self {
609            blocked_syscalls: default_blocked_syscalls(),
610            allow_namespaces: false,
611            allow_network_sockets: false,
612            log_only: false,
613        }
614    }
615
616    /// Create a permissive profile for semi-trusted code.
617    #[must_use]
618    pub fn permissive() -> Self {
619        Self {
620            blocked_syscalls: vec![
621                "ptrace".to_string(),
622                "kexec_load".to_string(),
623                "kexec_file_load".to_string(),
624                "reboot".to_string(),
625            ],
626            allow_namespaces: false,
627            allow_network_sockets: true,
628            log_only: false,
629        }
630    }
631
632    /// Create a logging-only profile for debugging.
633    #[must_use]
634    pub fn logging() -> Self {
635        Self {
636            blocked_syscalls: default_blocked_syscalls(),
637            allow_namespaces: false,
638            allow_network_sockets: false,
639            log_only: true,
640        }
641    }
642
643    /// Builder: add a syscall to block.
644    #[must_use]
645    pub fn block_syscall(mut self, syscall: impl Into<String>) -> Self {
646        let syscall = syscall.into();
647        if !self.blocked_syscalls.contains(&syscall) {
648            self.blocked_syscalls.push(syscall);
649        }
650        self
651    }
652
653    /// Builder: allow network sockets.
654    #[must_use]
655    pub fn with_network(mut self) -> Self {
656        self.allow_network_sockets = true;
657        self
658    }
659
660    /// Builder: enable log-only mode.
661    #[must_use]
662    pub fn with_logging(mut self) -> Self {
663        self.log_only = true;
664        self
665    }
666
667    /// Check if a syscall is blocked by this profile.
668    #[inline]
669    #[must_use]
670    fn is_blocked(&self, syscall: &str) -> bool {
671        self.blocked_syscalls.iter().any(|s| s == syscall)
672    }
673
674    /// Generate a JSON representation for the sandbox helper.
675    pub(crate) fn to_json(&self) -> Result<String, serde_json::Error> {
676        serde_json::to_string(self)
677    }
678}
679
680/// Sandbox policy determining what operations are permitted during execution.
681///
682/// This follows the Codex sandboxing model with three main variants:
683/// - **ReadOnly**: Only read operations allowed (safe for viewing files)
684/// - **WorkspaceWrite**: Can write within specified directories
685/// - **DangerFullAccess**: No restrictions (dangerous, requires explicit approval)
686///
687/// The field guide's three-question model:
688/// 1. What is shared between this code and the host? (boundary)
689/// 2. What can the code touch? (policy - this enum)
690/// 3. What survives between runs? (lifecycle)
691#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
692#[serde(tag = "type", rename_all = "snake_case")]
693pub enum SandboxPolicy {
694    /// No write access to the filesystem; network access may be restricted or allowlisted.
695    ReadOnly {
696        /// Whether network access is enabled when no allowlist is set.
697        #[serde(default)]
698        network_access: bool,
699
700        /// Domain-based network egress allowlist.
701        #[serde(default)]
702        network_allowlist: Vec<NetworkAllowlistEntry>,
703    },
704
705    /// Write access limited to the specified roots; network controlled by allowlist.
706    WorkspaceWrite {
707        /// Directories where write access is permitted.
708        writable_roots: Vec<WritableRoot>,
709
710        /// Whether network access is allowed (legacy boolean, use network_allowlist for fine-grained control).
711        #[serde(default)]
712        network_access: bool,
713
714        /// Domain-based network egress allowlist.
715        /// When non-empty, only connections to these destinations are permitted.
716        /// Following field guide: "Default-deny outbound network, then allowlist."
717        #[serde(default)]
718        network_allowlist: Vec<NetworkAllowlistEntry>,
719
720        /// Sensitive paths to block (credentials, SSH keys, cloud configs).
721        /// Following field guide: prevents "policy leakage" of credentials.
722        /// Defaults to DEFAULT_SENSITIVE_PATHS if None.
723        #[serde(default)]
724        sensitive_paths: Option<Vec<SensitivePath>>,
725
726        /// Resource limits (memory, PIDs, disk, CPU).
727        /// Following field guide: prevents fork bombs, memory exhaustion.
728        #[serde(default)]
729        resource_limits: ResourceLimits,
730
731        /// Seccomp-BPF profile for Linux syscall filtering.
732        /// Following field guide: "Landlock + seccomp is the recommended Linux pattern."
733        #[serde(default)]
734        seccomp_profile: SeccompProfile,
735
736        /// Exclude the TMPDIR environment variable from writable roots.
737        #[serde(default)]
738        exclude_tmpdir_env_var: bool,
739
740        /// Exclude /tmp from writable roots.
741        #[serde(default)]
742        exclude_slash_tmp: bool,
743    },
744
745    /// Full access - no sandbox restrictions applied.
746    /// Use with extreme caution.
747    DangerFullAccess,
748
749    /// External sandbox - the caller is responsible for sandbox setup.
750    ExternalSandbox {
751        /// Description of the external sandbox mechanism.
752        description: String,
753    },
754}
755
756impl SandboxPolicy {
757    /// Create a read-only policy.
758    #[must_use]
759    pub fn read_only() -> Self {
760        Self::ReadOnly {
761            network_access: false,
762            network_allowlist: Vec::new(),
763        }
764    }
765
766    /// Create a new read-only policy (alias for backwards compatibility).
767    #[must_use]
768    pub fn new_read_only_policy() -> Self {
769        Self::read_only()
770    }
771
772    /// Create a read-only policy with a network allowlist.
773    #[must_use]
774    pub fn read_only_with_network(network_allowlist: Vec<NetworkAllowlistEntry>) -> Self {
775        Self::ReadOnly {
776            network_access: !network_allowlist.is_empty(),
777            network_allowlist,
778        }
779    }
780
781    /// Create a read-only policy with full network access.
782    #[must_use]
783    pub fn read_only_with_full_network() -> Self {
784        Self::ReadOnly {
785            network_access: true,
786            network_allowlist: Vec::new(),
787        }
788    }
789
790    /// Create a workspace-write policy with specified roots.
791    /// Uses default sensitive path blocking and strict seccomp profile.
792    #[must_use]
793    pub fn workspace_write(writable_roots: Vec<PathBuf>) -> Self {
794        Self::WorkspaceWrite {
795            writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
796            network_access: false,
797            network_allowlist: Vec::new(),
798            sensitive_paths: None,
799            resource_limits: ResourceLimits::default(),
800            seccomp_profile: SeccompProfile::strict(),
801            exclude_tmpdir_env_var: true,
802            exclude_slash_tmp: true,
803        }
804    }
805
806    /// Create a workspace-write policy with network allowlist.
807    #[must_use]
808    fn workspace_write_with_network(
809        writable_roots: Vec<PathBuf>,
810        network_allowlist: Vec<NetworkAllowlistEntry>,
811    ) -> Self {
812        Self::WorkspaceWrite {
813            writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
814            network_access: !network_allowlist.is_empty(),
815            network_allowlist,
816            sensitive_paths: None,
817            resource_limits: ResourceLimits::default(),
818            seccomp_profile: SeccompProfile::strict().with_network(),
819            exclude_tmpdir_env_var: true,
820            exclude_slash_tmp: true,
821        }
822    }
823
824    /// Create a workspace-write policy with custom sensitive path settings.
825    #[must_use]
826    pub fn workspace_write_with_sensitive_paths(
827        writable_roots: Vec<PathBuf>,
828        sensitive_paths: Vec<SensitivePath>,
829    ) -> Self {
830        Self::WorkspaceWrite {
831            writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
832            network_access: false,
833            network_allowlist: Vec::new(),
834            sensitive_paths: Some(sensitive_paths),
835            resource_limits: ResourceLimits::default(),
836            seccomp_profile: SeccompProfile::strict(),
837            exclude_tmpdir_env_var: true,
838            exclude_slash_tmp: true,
839        }
840    }
841
842    /// Create a workspace-write policy without sensitive path blocking (dangerous).
843    #[must_use]
844    fn workspace_write_no_sensitive_blocking(writable_roots: Vec<PathBuf>) -> Self {
845        Self::WorkspaceWrite {
846            writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
847            network_access: false,
848            network_allowlist: Vec::new(),
849            sensitive_paths: Some(Vec::new()),
850            resource_limits: ResourceLimits::default(),
851            seccomp_profile: SeccompProfile::strict(),
852            exclude_tmpdir_env_var: true,
853            exclude_slash_tmp: true,
854        }
855    }
856
857    /// Create a workspace-write policy with resource limits.
858    /// Useful for untrusted code that needs containment.
859    #[must_use]
860    fn workspace_write_with_limits(writable_roots: Vec<PathBuf>, resource_limits: ResourceLimits) -> Self {
861        Self::WorkspaceWrite {
862            writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
863            network_access: false,
864            network_allowlist: Vec::new(),
865            sensitive_paths: None,
866            resource_limits,
867            seccomp_profile: SeccompProfile::strict(),
868            exclude_tmpdir_env_var: true,
869            exclude_slash_tmp: true,
870        }
871    }
872
873    /// Create a fully-configured workspace-write policy.
874    #[must_use]
875    pub fn workspace_write_full(
876        writable_roots: Vec<PathBuf>,
877        network_allowlist: Vec<NetworkAllowlistEntry>,
878        sensitive_paths: Option<Vec<SensitivePath>>,
879        resource_limits: ResourceLimits,
880        seccomp_profile: SeccompProfile,
881    ) -> Self {
882        Self::WorkspaceWrite {
883            writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
884            network_access: !network_allowlist.is_empty(),
885            network_allowlist,
886            sensitive_paths,
887            resource_limits,
888            seccomp_profile,
889            exclude_tmpdir_env_var: true,
890            exclude_slash_tmp: true,
891        }
892    }
893
894    /// Create a full-access policy (dangerous).
895    #[must_use]
896    pub fn full_access() -> Self {
897        Self::DangerFullAccess
898    }
899
900    /// Check if the policy allows full network access (unrestricted).
901    #[inline]
902    #[must_use]
903    pub fn has_full_network_access(&self) -> bool {
904        match self {
905            Self::ReadOnly { network_access, network_allowlist }
906            | Self::WorkspaceWrite { network_access, network_allowlist, .. } => {
907                *network_access && network_allowlist.is_empty()
908            }
909            Self::DangerFullAccess | Self::ExternalSandbox { .. } => true,
910        }
911    }
912
913    /// Check if the policy has a network allowlist (domain-restricted access).
914    #[inline]
915    #[must_use]
916    pub fn has_network_allowlist(&self) -> bool {
917        match self {
918            Self::ReadOnly { network_allowlist, .. } | Self::WorkspaceWrite { network_allowlist, .. } => {
919                !network_allowlist.is_empty()
920            }
921            _ => false,
922        }
923    }
924
925    /// Get the network allowlist entries, if any.
926    #[inline]
927    #[must_use]
928    pub fn network_allowlist(&self) -> &[NetworkAllowlistEntry] {
929        match self {
930            Self::ReadOnly { network_allowlist, .. } | Self::WorkspaceWrite { network_allowlist, .. } => {
931                network_allowlist
932            }
933            _ => &[],
934        }
935    }
936
937    /// Check if network access to a specific domain:port is allowed.
938    #[inline]
939    #[must_use]
940    pub fn is_network_allowed(&self, domain: &str, port: u16) -> bool {
941        match self {
942            Self::ReadOnly { network_access, network_allowlist }
943            | Self::WorkspaceWrite { network_access, network_allowlist, .. } => {
944                if network_allowlist.is_empty() {
945                    *network_access
946                } else {
947                    network_allowlist.iter().any(|entry| entry.matches(domain, port))
948                }
949            }
950            Self::DangerFullAccess | Self::ExternalSandbox { .. } => true,
951        }
952    }
953
954    /// Get the effective sensitive paths to block.
955    /// Returns default paths if not explicitly configured.
956    #[must_use]
957    fn sensitive_paths(&self) -> Vec<SensitivePath> {
958        match self {
959            Self::ReadOnly { .. } => default_sensitive_paths(),
960            Self::WorkspaceWrite { sensitive_paths, .. } => {
961                sensitive_paths.clone().unwrap_or_else(default_sensitive_paths)
962            }
963            Self::DangerFullAccess | Self::ExternalSandbox { .. } => Vec::new(),
964        }
965    }
966
967    /// Get sensitive paths including write-only protected directories for writable roots.
968    #[must_use]
969    pub(crate) fn sensitive_paths_for_execution(&self, cwd: &Path) -> Vec<SensitivePath> {
970        match self {
971            Self::WorkspaceWrite { .. } => {
972                let mut sensitive_paths = self.sensitive_paths();
973                sensitive_paths.extend(protected_writable_root_sensitive_paths(&self.get_writable_roots_with_cwd(cwd)));
974                sensitive_paths
975            }
976            _ => self.sensitive_paths(),
977        }
978    }
979
980    /// Check if a path is a sensitive location that should be blocked.
981    #[inline]
982    #[must_use]
983    fn is_sensitive_path(&self, path: &Path) -> bool {
984        self.sensitive_paths().iter().any(|sp| sp.matches(path) && sp.block_read)
985    }
986
987    /// Check if write access to a path is blocked under this policy.
988    #[inline]
989    #[must_use]
990    fn is_path_write_blocked(&self, path: &Path, cwd: &Path) -> bool {
991        match self {
992            Self::DangerFullAccess | Self::ExternalSandbox { .. } => false,
993            _ => self
994                .sensitive_paths_for_execution(cwd)
995                .iter()
996                .any(|sp| sp.matches(path) && sp.block_write),
997        }
998    }
999
1000    /// Check if read access to a path is allowed under this policy.
1001    #[inline]
1002    #[must_use]
1003    pub fn is_path_readable(&self, path: &Path) -> bool {
1004        match self {
1005            Self::DangerFullAccess | Self::ExternalSandbox { .. } => true,
1006            _ => !self.is_sensitive_path(path),
1007        }
1008    }
1009
1010    /// Get the resource limits for this policy.
1011    #[must_use]
1012    pub fn resource_limits(&self) -> ResourceLimits {
1013        match self {
1014            Self::ReadOnly { .. } => ResourceLimits::conservative(),
1015            Self::WorkspaceWrite { resource_limits, .. } => resource_limits.clone(),
1016            Self::DangerFullAccess | Self::ExternalSandbox { .. } => ResourceLimits::unlimited(),
1017        }
1018    }
1019
1020    /// Get the seccomp profile for this policy (Linux only).
1021    #[must_use]
1022    pub fn seccomp_profile(&self) -> SeccompProfile {
1023        match self {
1024            Self::ReadOnly { network_access, network_allowlist } => {
1025                let mut profile = SeccompProfile::strict();
1026                if *network_access || !network_allowlist.is_empty() {
1027                    profile = profile.with_network();
1028                }
1029                profile
1030            }
1031            Self::WorkspaceWrite { seccomp_profile, .. } => seccomp_profile.clone(),
1032            Self::DangerFullAccess | Self::ExternalSandbox { .. } => SeccompProfile::permissive(),
1033        }
1034    }
1035
1036    /// Check if the policy allows full disk write access.
1037    #[inline]
1038    #[must_use]
1039    fn has_full_disk_write_access(&self) -> bool {
1040        matches!(self, Self::DangerFullAccess | Self::ExternalSandbox { .. })
1041    }
1042
1043    /// Check if the policy allows full disk read access.
1044    #[inline]
1045    #[must_use]
1046    fn has_full_disk_read_access(&self) -> bool {
1047        true
1048    }
1049
1050    /// Get the list of writable roots including the current working directory.
1051    #[must_use]
1052    pub(crate) fn get_writable_roots_with_cwd(&self, cwd: &Path) -> Vec<WritableRoot> {
1053        match self {
1054            Self::ReadOnly { .. } => vec![],
1055            Self::WorkspaceWrite { writable_roots, .. } => {
1056                let mut roots = writable_roots.clone();
1057                let cwd_root = WritableRoot::new(cwd);
1058                if !roots.contains(&cwd_root) {
1059                    roots.push(cwd_root);
1060                }
1061                roots
1062            }
1063            Self::DangerFullAccess | Self::ExternalSandbox { .. } => {
1064                vec![WritableRoot::new(cwd)]
1065            }
1066        }
1067    }
1068
1069    /// Check if a path is writable under this policy.
1070    #[inline]
1071    #[must_use]
1072    pub fn is_path_writable(&self, path: &Path, cwd: &Path) -> bool {
1073        match self {
1074            Self::ReadOnly { .. } => false,
1075            Self::WorkspaceWrite { .. } => {
1076                let writable = self.get_writable_roots_with_cwd(cwd);
1077                writable.iter().any(|root| path.starts_with(&root.root)) && !self.is_path_write_blocked(path, cwd)
1078            }
1079            Self::DangerFullAccess | Self::ExternalSandbox { .. } => true,
1080        }
1081    }
1082
1083    /// Validate that another policy can be set from this one.
1084    /// Used to enforce policy escalation restrictions.
1085    fn can_set(&self, new_policy: &SandboxPolicy) -> anyhow::Result<()> {
1086        use SandboxPolicy::*;
1087
1088        match (self, new_policy) {
1089            // Can always downgrade
1090            (DangerFullAccess, _) => Ok(()),
1091            // Cannot escalate from ReadOnly to write-capable
1092            (ReadOnly { .. }, WorkspaceWrite { .. } | DangerFullAccess) => {
1093                Err(anyhow::anyhow!("cannot escalate from read-only to write-capable policy"))
1094            }
1095            // Other transitions are allowed
1096            _ => Ok(()),
1097        }
1098    }
1099
1100    /// Get a human-readable description of the policy.
1101    pub fn description(&self) -> &'static str {
1102        match self {
1103            Self::ReadOnly { .. } => "read-only access",
1104            Self::WorkspaceWrite { .. } => "workspace write access",
1105            Self::DangerFullAccess => "full access (dangerous)",
1106            Self::ExternalSandbox { .. } => "external sandbox",
1107        }
1108    }
1109}
1110
1111impl Default for SandboxPolicy {
1112    fn default() -> Self {
1113        Self::read_only()
1114    }
1115}
1116
1117#[cfg(test)]
1118mod tests {
1119    use super::*;
1120
1121    #[test]
1122    fn test_read_only_policy() {
1123        let policy = SandboxPolicy::read_only();
1124        assert!(!policy.has_full_network_access());
1125        assert!(!policy.has_network_allowlist());
1126        assert!(!policy.has_full_disk_write_access());
1127        assert!(policy.has_full_disk_read_access());
1128    }
1129
1130    #[test]
1131    fn test_read_only_with_network_allowlist() {
1132        let policy = SandboxPolicy::read_only_with_network(vec![
1133            NetworkAllowlistEntry::https("api.github.com"),
1134            NetworkAllowlistEntry::with_port("registry.npmjs.org", 443),
1135        ]);
1136
1137        assert!(!policy.has_full_network_access());
1138        assert!(policy.has_network_allowlist());
1139        assert!(policy.is_network_allowed("api.github.com", 443));
1140        assert!(policy.is_network_allowed("registry.npmjs.org", 443));
1141        assert!(!policy.is_network_allowed("example.com", 443));
1142    }
1143
1144    #[test]
1145    fn test_read_only_with_full_network_access() {
1146        let policy = SandboxPolicy::read_only_with_full_network();
1147
1148        assert!(policy.has_full_network_access());
1149        assert!(policy.is_network_allowed("example.com", 443));
1150        assert!(policy.seccomp_profile().allow_network_sockets);
1151    }
1152
1153    #[test]
1154    fn test_read_only_deserializes_legacy_shape() {
1155        let policy: SandboxPolicy = serde_json::from_str(r#"{"type":"read_only"}"#).expect("legacy read-only policy");
1156
1157        assert_eq!(policy, SandboxPolicy::read_only());
1158    }
1159
1160    #[test]
1161    fn test_workspace_write_policy() {
1162        let policy = SandboxPolicy::workspace_write(vec![PathBuf::from("/tmp/workspace")]);
1163        assert!(!policy.has_full_network_access());
1164        assert!(!policy.has_full_disk_write_access());
1165
1166        let cwd = PathBuf::from("/tmp/workspace");
1167        assert!(policy.is_path_writable(&cwd, &cwd));
1168        assert!(!policy.is_path_writable(&PathBuf::from("/etc"), &cwd));
1169    }
1170
1171    #[test]
1172    fn test_workspace_write_protects_internal_metadata_dirs() {
1173        let cwd = PathBuf::from("/tmp/workspace");
1174        let policy = SandboxPolicy::workspace_write(vec![cwd.clone()]);
1175
1176        assert!(!policy.is_path_writable(&cwd.join(".git/config"), &cwd));
1177        assert!(!policy.is_path_writable(&cwd.join(".vtcode/cache"), &cwd));
1178        assert!(!policy.is_path_writable(&cwd.join(".codex/state"), &cwd));
1179        assert!(!policy.is_path_writable(&cwd.join(".agents/skills"), &cwd));
1180        assert!(policy.is_path_writable(&cwd.join("src/main.rs"), &cwd));
1181    }
1182
1183    #[test]
1184    fn test_full_access_policy() {
1185        let policy = SandboxPolicy::full_access();
1186        assert!(policy.has_full_network_access());
1187        assert!(policy.has_full_disk_write_access());
1188    }
1189
1190    #[test]
1191    fn test_policy_escalation() {
1192        let read_only = SandboxPolicy::read_only();
1193        let full = SandboxPolicy::full_access();
1194
1195        // Cannot escalate from read-only
1196        assert!(read_only.can_set(&full).is_err());
1197
1198        // Can downgrade from full
1199        full.can_set(&read_only).unwrap();
1200    }
1201
1202    #[test]
1203    fn test_network_allowlist_entry_matching() {
1204        let entry = NetworkAllowlistEntry::https("api.github.com");
1205        assert!(entry.matches("api.github.com", 443));
1206        assert!(!entry.matches("api.github.com", 80));
1207        assert!(!entry.matches("github.com", 443));
1208    }
1209
1210    #[test]
1211    fn test_network_allowlist_wildcard() {
1212        let entry = NetworkAllowlistEntry::https("*.npmjs.org");
1213        assert!(entry.matches("registry.npmjs.org", 443));
1214        assert!(entry.matches("npmjs.org", 443));
1215        assert!(!entry.matches("npmjs.org.evil.com", 443));
1216    }
1217
1218    #[test]
1219    fn test_workspace_with_network_allowlist() {
1220        let allowlist = vec![
1221            NetworkAllowlistEntry::https("api.github.com"),
1222            NetworkAllowlistEntry::https("*.npmjs.org"),
1223        ];
1224        let policy = SandboxPolicy::workspace_write_with_network(vec![PathBuf::from("/tmp/workspace")], allowlist);
1225
1226        // Has allowlist, not full access
1227        assert!(!policy.has_full_network_access());
1228        assert!(policy.has_network_allowlist());
1229
1230        // Domain checks
1231        assert!(policy.is_network_allowed("api.github.com", 443));
1232        assert!(policy.is_network_allowed("registry.npmjs.org", 443));
1233        assert!(!policy.is_network_allowed("evil.com", 443));
1234        assert!(!policy.is_network_allowed("api.github.com", 80));
1235    }
1236
1237    #[test]
1238    fn test_workspace_no_network() {
1239        let policy = SandboxPolicy::workspace_write(vec![PathBuf::from("/tmp/workspace")]);
1240
1241        assert!(!policy.has_full_network_access());
1242        assert!(!policy.has_network_allowlist());
1243        assert!(!policy.is_network_allowed("api.github.com", 443));
1244    }
1245
1246    #[test]
1247    fn test_sensitive_path_expansion() {
1248        let sp = SensitivePath::new("~/.ssh");
1249        let expanded = sp.expand_path();
1250        // Should expand to home directory
1251        assert!(expanded.to_string_lossy().contains(".ssh"));
1252        assert!(!expanded.to_string_lossy().starts_with('~'));
1253    }
1254
1255    #[test]
1256    fn test_sensitive_path_matching() {
1257        let sp = SensitivePath::new("~/.ssh");
1258        let expanded = sp.expand_path();
1259        let ssh_key = expanded.join("id_rsa");
1260        assert!(sp.matches(&ssh_key));
1261        assert!(sp.matches(&expanded));
1262    }
1263
1264    #[cfg(not(windows))]
1265    #[test]
1266    fn test_sensitive_path_matching_is_case_insensitive_with_component_boundaries() {
1267        let sp = SensitivePath::new("/tmp/Workspace/.env");
1268
1269        assert!(sp.matches(Path::new("/tmp/workspace/.ENV")));
1270        assert!(sp.matches(Path::new("/tmp/workspace/.ENV/child")));
1271        assert!(!sp.matches(Path::new("/tmp/workspace/.environment")));
1272        assert!(!sp.matches(Path::new("/tmp/workspaces/.ENV")));
1273    }
1274
1275    #[test]
1276    fn test_default_sensitive_paths() {
1277        let paths = default_sensitive_paths();
1278        assert!(!paths.is_empty());
1279        // Should include common credential locations
1280        let path_strings: Vec<&str> = paths.iter().map(|p| p.path.as_str()).collect();
1281        assert!(path_strings.contains(&"~/.ssh"));
1282        assert!(path_strings.contains(&"~/.aws"));
1283        assert!(path_strings.contains(&"~/.kube"));
1284    }
1285
1286    #[test]
1287    fn resolved_vtcode_roots_are_sensitive_without_duplicate_entries() {
1288        let environment = [
1289            ("HOME", "/home/tester"),
1290            ("VTCODE_CONFIG", "/vtcode/shared"),
1291            ("VTCODE_DATA", "/vtcode/shared"),
1292            ("XDG_STATE_HOME", "/xdg/state"),
1293            ("XDG_CACHE_HOME", "/xdg/cache"),
1294            ("XDG_RUNTIME_DIR", "/xdg/runtime"),
1295            ("XDG_BIN_HOME", "/xdg/bin"),
1296            ("VTCODE_HOME", "/legacy/vtcode"),
1297        ];
1298        let resolved =
1299            VtCodePaths::from_environment(&environment).expect("explicit absolute VT Code paths should resolve");
1300        let paths = vtcode_sensitive_paths(&environment).expect("explicit absolute VT Code paths should resolve");
1301        let path_strings: Vec<&str> = paths.iter().map(|path| path.path.as_str()).collect();
1302
1303        for expected in [
1304            resolved.config_dir().to_path_buf(),
1305            resolved.auth_dir(),
1306            resolved.data_dir().to_path_buf(),
1307            resolved.state_dir().to_path_buf(),
1308            resolved.cache_dir().to_path_buf(),
1309            resolved.runtime_dir().to_path_buf(),
1310            resolved.executable_dir().to_path_buf(),
1311            resolved.legacy_dir().to_path_buf(),
1312        ] {
1313            let expected = expected.display().to_string();
1314            assert!(path_strings.contains(&expected.as_str()), "missing sensitive root: {expected}");
1315        }
1316        assert_eq!(path_strings.iter().filter(|path| **path == "/vtcode/shared").count(), 1);
1317    }
1318
1319    #[test]
1320    fn invalid_vtcode_path_resolution_is_rejected_before_policy_construction() {
1321        let error = vtcode_sensitive_paths(&[("HOME", "/home/tester"), ("VTCODE_CONFIG", "relative/config")])
1322            .expect_err("relative VT Code config paths must fail closed");
1323        assert!(error.to_string().contains("VTCODE_CONFIG"));
1324    }
1325
1326    #[cfg(windows)]
1327    #[test]
1328    fn test_windows_userprofile_root_exclusions_are_in_defaults() {
1329        let paths = default_sensitive_paths();
1330        let path_strings: Vec<&str> = paths.iter().map(|p| p.path.as_str()).collect();
1331
1332        for entry in USERPROFILE_READ_ROOT_EXCLUSIONS {
1333            let expected = format!("~/{}", entry);
1334            assert!(path_strings.contains(&expected.as_str()), "missing expected default sensitive path: {expected}");
1335        }
1336    }
1337
1338    #[cfg(windows)]
1339    #[test]
1340    fn test_sensitive_path_matching_is_case_insensitive_on_windows() {
1341        let sp = SensitivePath::new("~/.aws");
1342        let home = dirs::home_dir().expect("home dir");
1343        let mixed_case_candidate = home.join(".AWS").join("credentials");
1344
1345        assert!(sp.matches(&mixed_case_candidate));
1346    }
1347
1348    #[test]
1349    fn test_workspace_blocks_sensitive_by_default() {
1350        let policy = SandboxPolicy::workspace_write(vec![PathBuf::from("/tmp/workspace")]);
1351        let sensitive = policy.sensitive_paths();
1352        assert!(!sensitive.is_empty());
1353
1354        // Check that SSH keys are blocked
1355        if let Some(home) = dirs::home_dir() {
1356            let ssh_path = home.join(".ssh").join("id_rsa");
1357            assert!(policy.is_sensitive_path(&ssh_path));
1358            assert!(!policy.is_path_readable(&ssh_path));
1359        }
1360    }
1361
1362    #[test]
1363    fn test_workspace_no_sensitive_blocking() {
1364        let policy = SandboxPolicy::workspace_write_no_sensitive_blocking(vec![PathBuf::from("/tmp")]);
1365        let sensitive = policy.sensitive_paths();
1366        assert!(sensitive.is_empty());
1367
1368        // Nothing should be blocked
1369        if let Some(home) = dirs::home_dir() {
1370            let ssh_path = home.join(".ssh").join("id_rsa");
1371            assert!(!policy.is_sensitive_path(&ssh_path));
1372            assert!(policy.is_path_readable(&ssh_path));
1373        }
1374    }
1375
1376    #[test]
1377    fn test_full_access_no_sensitive_blocking() {
1378        let policy = SandboxPolicy::full_access();
1379        let sensitive = policy.sensitive_paths();
1380        assert!(sensitive.is_empty());
1381
1382        // Full access should allow everything
1383        if let Some(home) = dirs::home_dir() {
1384            let ssh_path = home.join(".ssh").join("id_rsa");
1385            assert!(policy.is_path_readable(&ssh_path));
1386        }
1387    }
1388
1389    #[test]
1390    fn test_resource_limits_default() {
1391        let limits = ResourceLimits::default();
1392        assert_eq!(limits.max_memory_mb, 0);
1393        assert_eq!(limits.max_pids, 0);
1394        assert_eq!(limits.timeout_secs, 300);
1395        assert!(limits.has_limits());
1396    }
1397
1398    #[test]
1399    fn test_resource_limits_conservative() {
1400        let limits = ResourceLimits::conservative();
1401        assert_eq!(limits.max_memory_mb, 512);
1402        assert_eq!(limits.max_pids, 64);
1403        assert_eq!(limits.cpu_time_secs, 60);
1404        assert!(limits.has_limits());
1405    }
1406
1407    #[test]
1408    fn test_resource_limits_builder() {
1409        let limits = ResourceLimits::default()
1410            .with_memory_mb(1024)
1411            .with_max_pids(128)
1412            .with_timeout_secs(60);
1413        assert_eq!(limits.max_memory_mb, 1024);
1414        assert_eq!(limits.max_pids, 128);
1415        assert_eq!(limits.effective_timeout_secs(), 60);
1416    }
1417
1418    #[test]
1419    fn test_workspace_with_limits() {
1420        let limits = ResourceLimits::conservative();
1421        let policy = SandboxPolicy::workspace_write_with_limits(vec![PathBuf::from("/tmp/workspace")], limits.clone());
1422
1423        let policy_limits = policy.resource_limits();
1424        assert_eq!(policy_limits.max_memory_mb, limits.max_memory_mb);
1425        assert_eq!(policy_limits.max_pids, limits.max_pids);
1426    }
1427
1428    #[test]
1429    fn test_read_only_conservative_limits() {
1430        let policy = SandboxPolicy::read_only();
1431        let limits = policy.resource_limits();
1432        // ReadOnly should get conservative limits
1433        assert!(limits.has_limits());
1434        assert_eq!(limits.max_memory_mb, 512);
1435    }
1436
1437    #[test]
1438    fn test_full_access_unlimited() {
1439        let policy = SandboxPolicy::full_access();
1440        let limits = policy.resource_limits();
1441        // Full access should have no limits
1442        assert!(!limits.has_limits());
1443    }
1444
1445    #[test]
1446    fn test_seccomp_profile_strict() {
1447        let profile = SeccompProfile::strict();
1448        assert!(profile.is_blocked("ptrace"));
1449        assert!(profile.is_blocked("mount"));
1450        assert!(profile.is_blocked("kexec_load"));
1451        assert!(profile.is_blocked("bpf"));
1452        assert!(!profile.allow_network_sockets);
1453        assert!(!profile.allow_namespaces);
1454    }
1455
1456    #[test]
1457    fn test_seccomp_profile_permissive() {
1458        let profile = SeccompProfile::permissive();
1459        // Still blocks the most dangerous syscalls
1460        assert!(profile.is_blocked("ptrace"));
1461        assert!(profile.is_blocked("kexec_load"));
1462        // But allows network
1463        assert!(profile.allow_network_sockets);
1464    }
1465
1466    #[test]
1467    fn test_seccomp_profile_builder() {
1468        let profile = SeccompProfile::strict().with_network().block_syscall("custom_syscall");
1469        assert!(profile.allow_network_sockets);
1470        assert!(profile.is_blocked("custom_syscall"));
1471    }
1472
1473    #[test]
1474    fn test_workspace_seccomp_profile() {
1475        let policy = SandboxPolicy::workspace_write(vec![PathBuf::from("/tmp")]);
1476        let profile = policy.seccomp_profile();
1477        // Should get strict profile by default
1478        assert!(profile.is_blocked("ptrace"));
1479        assert!(profile.is_blocked("mount"));
1480    }
1481
1482    #[test]
1483    fn test_workspace_with_network_seccomp() {
1484        let policy = SandboxPolicy::workspace_write_with_network(
1485            vec![PathBuf::from("/tmp")],
1486            vec![NetworkAllowlistEntry::https("api.github.com")],
1487        );
1488        let profile = policy.seccomp_profile();
1489        // Should allow network sockets when network is enabled
1490        assert!(profile.allow_network_sockets);
1491    }
1492
1493    #[test]
1494    fn test_seccomp_profile_json() {
1495        let profile = SeccompProfile::strict();
1496        let json = profile.to_json().unwrap();
1497        assert!(json.contains("ptrace"));
1498        assert!(json.contains("blocked_syscalls"));
1499    }
1500
1501    #[test]
1502    fn test_blocked_syscalls_constant() {
1503        // Verify key dangerous syscalls are in the list, including the
1504        // sandboxing-basics additions (ptrace-adjacent inspection, io_uring,
1505        // file-handle escapes). Asymmetric: both a classic entry and each new
1506        // family must be present.
1507        for must_block in [
1508            "ptrace",
1509            "kcmp",
1510            "pidfd_getfd",
1511            "mount",
1512            "open_by_handle_at",
1513            "name_to_handle_at",
1514            "kexec_load",
1515            "bpf",
1516            "perf_event_open",
1517            "userfaultfd",
1518            "io_uring_setup",
1519            "io_uring_enter",
1520            "io_uring_register",
1521            "process_vm_readv",
1522            "process_madvise",
1523            "unshare",
1524            "setns",
1525        ] {
1526            assert!(BLOCKED_SYSCALLS.contains(&must_block), "missing {must_block}");
1527        }
1528    }
1529}