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    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))]
215fn 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/// Syscalls that should be blocked in seccomp-bpf profiles.
449///
450/// Following the field guide: "A tight seccomp profile blocks syscalls that expand
451/// kernel attack surface or enable escalation."
452pub const BLOCKED_SYSCALLS: &[&str] = &[
453    // Debugging/tracing - can be used to escape sandboxes
454    "ptrace",
455    // Mounting - can change filesystem namespace
456    "mount",
457    "umount",
458    "umount2",
459    // Kernel module loading
460    "init_module",
461    "finit_module",
462    "delete_module",
463    // Kernel replacement
464    "kexec_load",
465    "kexec_file_load",
466    // BPF - can be used for sandbox escape
467    "bpf",
468    // Performance events - information leakage risk
469    "perf_event_open",
470    // Userfaultfd - can be used for race conditions
471    "userfaultfd",
472    // Process VM operations
473    "process_vm_readv",
474    "process_vm_writev",
475    // Reboot/power
476    "reboot",
477    // Swap manipulation
478    "swapon",
479    "swapoff",
480    // System time manipulation
481    "settimeofday",
482    "clock_settime",
483    "adjtimex",
484    // Keyring manipulation
485    "add_key",
486    "request_key",
487    "keyctl",
488    // IO permission
489    "ioperm",
490    "iopl",
491    // Raw I/O port access
492    "iopl",
493    // Acct - process accounting manipulation
494    "acct",
495    // Quota manipulation
496    "quotactl",
497    // Namespace creation (can bypass restrictions)
498    "unshare",
499    "setns",
500    // Personality - can enable legacy modes
501    "personality",
502];
503
504/// Syscalls that require argument filtering (not fully blocked).
505pub const FILTERED_SYSCALLS: &[&str] = &[
506    // clone/clone3: filter to prevent new namespaces
507    "clone", "clone3", // ioctl: filter to block dangerous device ioctls
508    "ioctl",  // prctl: filter to block dangerous operations
509    "prctl",  // socket: filter to enforce network policy
510    "socket",
511];
512
513/// Seccomp profile configuration for Linux sandboxing.
514///
515/// Used alongside Landlock for defense-in-depth.
516#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
517pub struct SeccompProfile {
518    /// Syscalls to block entirely.
519    #[serde(default = "default_blocked_syscalls")]
520    blocked_syscalls: Vec<String>,
521
522    /// Whether to allow new namespace creation (usually false for sandboxes).
523    #[serde(default)]
524    allow_namespaces: bool,
525
526    /// Whether to allow network socket creation (controlled separately).
527    #[serde(default)]
528    allow_network_sockets: bool,
529
530    /// Whether to log blocked syscalls instead of killing the process.
531    #[serde(default)]
532    log_only: bool,
533}
534
535fn default_blocked_syscalls() -> Vec<String> {
536    BLOCKED_SYSCALLS.iter().map(|s| s.to_string()).collect()
537}
538
539impl Default for SeccompProfile {
540    fn default() -> Self {
541        Self {
542            blocked_syscalls: default_blocked_syscalls(),
543            allow_namespaces: false,
544            allow_network_sockets: false,
545            log_only: false,
546        }
547    }
548}
549
550impl SeccompProfile {
551    /// Create a strict profile blocking all dangerous syscalls.
552    #[must_use]
553    pub fn strict() -> Self {
554        Self {
555            blocked_syscalls: default_blocked_syscalls(),
556            allow_namespaces: false,
557            allow_network_sockets: false,
558            log_only: false,
559        }
560    }
561
562    /// Create a permissive profile for semi-trusted code.
563    #[must_use]
564    pub fn permissive() -> Self {
565        Self {
566            blocked_syscalls: vec![
567                "ptrace".to_string(),
568                "kexec_load".to_string(),
569                "kexec_file_load".to_string(),
570                "reboot".to_string(),
571            ],
572            allow_namespaces: false,
573            allow_network_sockets: true,
574            log_only: false,
575        }
576    }
577
578    /// Create a logging-only profile for debugging.
579    #[must_use]
580    pub fn logging() -> Self {
581        Self {
582            blocked_syscalls: default_blocked_syscalls(),
583            allow_namespaces: false,
584            allow_network_sockets: false,
585            log_only: true,
586        }
587    }
588
589    /// Builder: add a syscall to block.
590    #[must_use]
591    pub fn block_syscall(mut self, syscall: impl Into<String>) -> Self {
592        let syscall = syscall.into();
593        if !self.blocked_syscalls.contains(&syscall) {
594            self.blocked_syscalls.push(syscall);
595        }
596        self
597    }
598
599    /// Builder: allow network sockets.
600    #[must_use]
601    pub fn with_network(mut self) -> Self {
602        self.allow_network_sockets = true;
603        self
604    }
605
606    /// Builder: enable log-only mode.
607    #[must_use]
608    pub fn with_logging(mut self) -> Self {
609        self.log_only = true;
610        self
611    }
612
613    /// Check if a syscall is blocked by this profile.
614    #[inline]
615    #[must_use]
616    fn is_blocked(&self, syscall: &str) -> bool {
617        self.blocked_syscalls.iter().any(|s| s == syscall)
618    }
619
620    /// Generate a JSON representation for the sandbox helper.
621    pub(crate) fn to_json(&self) -> Result<String, serde_json::Error> {
622        serde_json::to_string(self)
623    }
624}
625
626/// Sandbox policy determining what operations are permitted during execution.
627///
628/// This follows the Codex sandboxing model with three main variants:
629/// - **ReadOnly**: Only read operations allowed (safe for viewing files)
630/// - **WorkspaceWrite**: Can write within specified directories
631/// - **DangerFullAccess**: No restrictions (dangerous, requires explicit approval)
632///
633/// The field guide's three-question model:
634/// 1. What is shared between this code and the host? (boundary)
635/// 2. What can the code touch? (policy - this enum)
636/// 3. What survives between runs? (lifecycle)
637#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
638#[serde(tag = "type", rename_all = "snake_case")]
639pub enum SandboxPolicy {
640    /// No write access to the filesystem; network access may be restricted or allowlisted.
641    ReadOnly {
642        /// Whether network access is enabled when no allowlist is set.
643        #[serde(default)]
644        network_access: bool,
645
646        /// Domain-based network egress allowlist.
647        #[serde(default)]
648        network_allowlist: Vec<NetworkAllowlistEntry>,
649    },
650
651    /// Write access limited to the specified roots; network controlled by allowlist.
652    WorkspaceWrite {
653        /// Directories where write access is permitted.
654        writable_roots: Vec<WritableRoot>,
655
656        /// Whether network access is allowed (legacy boolean, use network_allowlist for fine-grained control).
657        #[serde(default)]
658        network_access: bool,
659
660        /// Domain-based network egress allowlist.
661        /// When non-empty, only connections to these destinations are permitted.
662        /// Following field guide: "Default-deny outbound network, then allowlist."
663        #[serde(default)]
664        network_allowlist: Vec<NetworkAllowlistEntry>,
665
666        /// Sensitive paths to block (credentials, SSH keys, cloud configs).
667        /// Following field guide: prevents "policy leakage" of credentials.
668        /// Defaults to DEFAULT_SENSITIVE_PATHS if None.
669        #[serde(default)]
670        sensitive_paths: Option<Vec<SensitivePath>>,
671
672        /// Resource limits (memory, PIDs, disk, CPU).
673        /// Following field guide: prevents fork bombs, memory exhaustion.
674        #[serde(default)]
675        resource_limits: ResourceLimits,
676
677        /// Seccomp-BPF profile for Linux syscall filtering.
678        /// Following field guide: "Landlock + seccomp is the recommended Linux pattern."
679        #[serde(default)]
680        seccomp_profile: SeccompProfile,
681
682        /// Exclude the TMPDIR environment variable from writable roots.
683        #[serde(default)]
684        exclude_tmpdir_env_var: bool,
685
686        /// Exclude /tmp from writable roots.
687        #[serde(default)]
688        exclude_slash_tmp: bool,
689    },
690
691    /// Full access - no sandbox restrictions applied.
692    /// Use with extreme caution.
693    DangerFullAccess,
694
695    /// External sandbox - the caller is responsible for sandbox setup.
696    ExternalSandbox {
697        /// Description of the external sandbox mechanism.
698        description: String,
699    },
700}
701
702impl SandboxPolicy {
703    /// Create a read-only policy.
704    #[must_use]
705    pub fn read_only() -> Self {
706        Self::ReadOnly {
707            network_access: false,
708            network_allowlist: Vec::new(),
709        }
710    }
711
712    /// Create a new read-only policy (alias for backwards compatibility).
713    #[must_use]
714    pub fn new_read_only_policy() -> Self {
715        Self::read_only()
716    }
717
718    /// Create a read-only policy with a network allowlist.
719    #[must_use]
720    pub fn read_only_with_network(network_allowlist: Vec<NetworkAllowlistEntry>) -> Self {
721        Self::ReadOnly {
722            network_access: !network_allowlist.is_empty(),
723            network_allowlist,
724        }
725    }
726
727    /// Create a read-only policy with full network access.
728    #[must_use]
729    pub fn read_only_with_full_network() -> Self {
730        Self::ReadOnly {
731            network_access: true,
732            network_allowlist: Vec::new(),
733        }
734    }
735
736    /// Create a workspace-write policy with specified roots.
737    /// Uses default sensitive path blocking and strict seccomp profile.
738    #[must_use]
739    pub fn workspace_write(writable_roots: Vec<PathBuf>) -> Self {
740        Self::WorkspaceWrite {
741            writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
742            network_access: false,
743            network_allowlist: Vec::new(),
744            sensitive_paths: None,
745            resource_limits: ResourceLimits::default(),
746            seccomp_profile: SeccompProfile::strict(),
747            exclude_tmpdir_env_var: true,
748            exclude_slash_tmp: true,
749        }
750    }
751
752    /// Create a workspace-write policy with network allowlist.
753    #[must_use]
754    fn workspace_write_with_network(
755        writable_roots: Vec<PathBuf>,
756        network_allowlist: Vec<NetworkAllowlistEntry>,
757    ) -> Self {
758        Self::WorkspaceWrite {
759            writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
760            network_access: !network_allowlist.is_empty(),
761            network_allowlist,
762            sensitive_paths: None,
763            resource_limits: ResourceLimits::default(),
764            seccomp_profile: SeccompProfile::strict().with_network(),
765            exclude_tmpdir_env_var: true,
766            exclude_slash_tmp: true,
767        }
768    }
769
770    /// Create a workspace-write policy with custom sensitive path settings.
771    #[must_use]
772    pub fn workspace_write_with_sensitive_paths(
773        writable_roots: Vec<PathBuf>,
774        sensitive_paths: Vec<SensitivePath>,
775    ) -> Self {
776        Self::WorkspaceWrite {
777            writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
778            network_access: false,
779            network_allowlist: Vec::new(),
780            sensitive_paths: Some(sensitive_paths),
781            resource_limits: ResourceLimits::default(),
782            seccomp_profile: SeccompProfile::strict(),
783            exclude_tmpdir_env_var: true,
784            exclude_slash_tmp: true,
785        }
786    }
787
788    /// Create a workspace-write policy without sensitive path blocking (dangerous).
789    #[must_use]
790    fn workspace_write_no_sensitive_blocking(writable_roots: Vec<PathBuf>) -> Self {
791        Self::WorkspaceWrite {
792            writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
793            network_access: false,
794            network_allowlist: Vec::new(),
795            sensitive_paths: Some(Vec::new()),
796            resource_limits: ResourceLimits::default(),
797            seccomp_profile: SeccompProfile::strict(),
798            exclude_tmpdir_env_var: true,
799            exclude_slash_tmp: true,
800        }
801    }
802
803    /// Create a workspace-write policy with resource limits.
804    /// Useful for untrusted code that needs containment.
805    #[must_use]
806    fn workspace_write_with_limits(writable_roots: Vec<PathBuf>, resource_limits: ResourceLimits) -> Self {
807        Self::WorkspaceWrite {
808            writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
809            network_access: false,
810            network_allowlist: Vec::new(),
811            sensitive_paths: None,
812            resource_limits,
813            seccomp_profile: SeccompProfile::strict(),
814            exclude_tmpdir_env_var: true,
815            exclude_slash_tmp: true,
816        }
817    }
818
819    /// Create a fully-configured workspace-write policy.
820    #[must_use]
821    pub fn workspace_write_full(
822        writable_roots: Vec<PathBuf>,
823        network_allowlist: Vec<NetworkAllowlistEntry>,
824        sensitive_paths: Option<Vec<SensitivePath>>,
825        resource_limits: ResourceLimits,
826        seccomp_profile: SeccompProfile,
827    ) -> Self {
828        Self::WorkspaceWrite {
829            writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
830            network_access: !network_allowlist.is_empty(),
831            network_allowlist,
832            sensitive_paths,
833            resource_limits,
834            seccomp_profile,
835            exclude_tmpdir_env_var: true,
836            exclude_slash_tmp: true,
837        }
838    }
839
840    /// Create a full-access policy (dangerous).
841    #[must_use]
842    pub fn full_access() -> Self {
843        Self::DangerFullAccess
844    }
845
846    /// Check if the policy allows full network access (unrestricted).
847    #[inline]
848    #[must_use]
849    pub fn has_full_network_access(&self) -> bool {
850        match self {
851            Self::ReadOnly { network_access, network_allowlist }
852            | Self::WorkspaceWrite { network_access, network_allowlist, .. } => {
853                *network_access && network_allowlist.is_empty()
854            }
855            Self::DangerFullAccess | Self::ExternalSandbox { .. } => true,
856        }
857    }
858
859    /// Check if the policy has a network allowlist (domain-restricted access).
860    #[inline]
861    #[must_use]
862    pub fn has_network_allowlist(&self) -> bool {
863        match self {
864            Self::ReadOnly { network_allowlist, .. } | Self::WorkspaceWrite { network_allowlist, .. } => {
865                !network_allowlist.is_empty()
866            }
867            _ => false,
868        }
869    }
870
871    /// Get the network allowlist entries, if any.
872    #[inline]
873    #[must_use]
874    pub fn network_allowlist(&self) -> &[NetworkAllowlistEntry] {
875        match self {
876            Self::ReadOnly { network_allowlist, .. } | Self::WorkspaceWrite { network_allowlist, .. } => {
877                network_allowlist
878            }
879            _ => &[],
880        }
881    }
882
883    /// Check if network access to a specific domain:port is allowed.
884    #[inline]
885    #[must_use]
886    pub fn is_network_allowed(&self, domain: &str, port: u16) -> bool {
887        match self {
888            Self::ReadOnly { network_access, network_allowlist }
889            | Self::WorkspaceWrite { network_access, network_allowlist, .. } => {
890                if network_allowlist.is_empty() {
891                    *network_access
892                } else {
893                    network_allowlist.iter().any(|entry| entry.matches(domain, port))
894                }
895            }
896            Self::DangerFullAccess | Self::ExternalSandbox { .. } => true,
897        }
898    }
899
900    /// Get the effective sensitive paths to block.
901    /// Returns default paths if not explicitly configured.
902    #[must_use]
903    fn sensitive_paths(&self) -> Vec<SensitivePath> {
904        match self {
905            Self::ReadOnly { .. } => default_sensitive_paths(),
906            Self::WorkspaceWrite { sensitive_paths, .. } => {
907                sensitive_paths.clone().unwrap_or_else(default_sensitive_paths)
908            }
909            Self::DangerFullAccess | Self::ExternalSandbox { .. } => Vec::new(),
910        }
911    }
912
913    /// Get sensitive paths including write-only protected directories for writable roots.
914    #[must_use]
915    pub(crate) fn sensitive_paths_for_execution(&self, cwd: &Path) -> Vec<SensitivePath> {
916        match self {
917            Self::WorkspaceWrite { .. } => {
918                let mut sensitive_paths = self.sensitive_paths();
919                sensitive_paths.extend(protected_writable_root_sensitive_paths(&self.get_writable_roots_with_cwd(cwd)));
920                sensitive_paths
921            }
922            _ => self.sensitive_paths(),
923        }
924    }
925
926    /// Check if a path is a sensitive location that should be blocked.
927    #[inline]
928    #[must_use]
929    fn is_sensitive_path(&self, path: &Path) -> bool {
930        self.sensitive_paths().iter().any(|sp| sp.matches(path) && sp.block_read)
931    }
932
933    /// Check if write access to a path is blocked under this policy.
934    #[inline]
935    #[must_use]
936    fn is_path_write_blocked(&self, path: &Path, cwd: &Path) -> bool {
937        match self {
938            Self::DangerFullAccess | Self::ExternalSandbox { .. } => false,
939            _ => self
940                .sensitive_paths_for_execution(cwd)
941                .iter()
942                .any(|sp| sp.matches(path) && sp.block_write),
943        }
944    }
945
946    /// Check if read access to a path is allowed under this policy.
947    #[inline]
948    #[must_use]
949    pub fn is_path_readable(&self, path: &Path) -> bool {
950        match self {
951            Self::DangerFullAccess | Self::ExternalSandbox { .. } => true,
952            _ => !self.is_sensitive_path(path),
953        }
954    }
955
956    /// Get the resource limits for this policy.
957    #[must_use]
958    pub fn resource_limits(&self) -> ResourceLimits {
959        match self {
960            Self::ReadOnly { .. } => ResourceLimits::conservative(),
961            Self::WorkspaceWrite { resource_limits, .. } => resource_limits.clone(),
962            Self::DangerFullAccess | Self::ExternalSandbox { .. } => ResourceLimits::unlimited(),
963        }
964    }
965
966    /// Get the seccomp profile for this policy (Linux only).
967    #[must_use]
968    pub(crate) fn seccomp_profile(&self) -> SeccompProfile {
969        match self {
970            Self::ReadOnly { network_access, network_allowlist } => {
971                let mut profile = SeccompProfile::strict();
972                if *network_access || !network_allowlist.is_empty() {
973                    profile = profile.with_network();
974                }
975                profile
976            }
977            Self::WorkspaceWrite { seccomp_profile, .. } => seccomp_profile.clone(),
978            Self::DangerFullAccess | Self::ExternalSandbox { .. } => SeccompProfile::permissive(),
979        }
980    }
981
982    /// Check if the policy allows full disk write access.
983    #[inline]
984    #[must_use]
985    fn has_full_disk_write_access(&self) -> bool {
986        matches!(self, Self::DangerFullAccess | Self::ExternalSandbox { .. })
987    }
988
989    /// Check if the policy allows full disk read access.
990    #[inline]
991    #[must_use]
992    fn has_full_disk_read_access(&self) -> bool {
993        true
994    }
995
996    /// Get the list of writable roots including the current working directory.
997    #[must_use]
998    pub(crate) fn get_writable_roots_with_cwd(&self, cwd: &Path) -> Vec<WritableRoot> {
999        match self {
1000            Self::ReadOnly { .. } => vec![],
1001            Self::WorkspaceWrite { writable_roots, .. } => {
1002                let mut roots = writable_roots.clone();
1003                let cwd_root = WritableRoot::new(cwd);
1004                if !roots.contains(&cwd_root) {
1005                    roots.push(cwd_root);
1006                }
1007                roots
1008            }
1009            Self::DangerFullAccess | Self::ExternalSandbox { .. } => {
1010                vec![WritableRoot::new(cwd)]
1011            }
1012        }
1013    }
1014
1015    /// Check if a path is writable under this policy.
1016    #[inline]
1017    #[must_use]
1018    pub fn is_path_writable(&self, path: &Path, cwd: &Path) -> bool {
1019        match self {
1020            Self::ReadOnly { .. } => false,
1021            Self::WorkspaceWrite { .. } => {
1022                let writable = self.get_writable_roots_with_cwd(cwd);
1023                writable.iter().any(|root| path.starts_with(&root.root)) && !self.is_path_write_blocked(path, cwd)
1024            }
1025            Self::DangerFullAccess | Self::ExternalSandbox { .. } => true,
1026        }
1027    }
1028
1029    /// Validate that another policy can be set from this one.
1030    /// Used to enforce policy escalation restrictions.
1031    fn can_set(&self, new_policy: &SandboxPolicy) -> anyhow::Result<()> {
1032        use SandboxPolicy::*;
1033
1034        match (self, new_policy) {
1035            // Can always downgrade
1036            (DangerFullAccess, _) => Ok(()),
1037            // Cannot escalate from ReadOnly to write-capable
1038            (ReadOnly { .. }, WorkspaceWrite { .. } | DangerFullAccess) => {
1039                Err(anyhow::anyhow!("cannot escalate from read-only to write-capable policy"))
1040            }
1041            // Other transitions are allowed
1042            _ => Ok(()),
1043        }
1044    }
1045
1046    /// Get a human-readable description of the policy.
1047    pub fn description(&self) -> &'static str {
1048        match self {
1049            Self::ReadOnly { .. } => "read-only access",
1050            Self::WorkspaceWrite { .. } => "workspace write access",
1051            Self::DangerFullAccess => "full access (dangerous)",
1052            Self::ExternalSandbox { .. } => "external sandbox",
1053        }
1054    }
1055}
1056
1057impl Default for SandboxPolicy {
1058    fn default() -> Self {
1059        Self::read_only()
1060    }
1061}
1062
1063#[cfg(test)]
1064mod tests {
1065    use super::*;
1066
1067    #[test]
1068    fn test_read_only_policy() {
1069        let policy = SandboxPolicy::read_only();
1070        assert!(!policy.has_full_network_access());
1071        assert!(!policy.has_network_allowlist());
1072        assert!(!policy.has_full_disk_write_access());
1073        assert!(policy.has_full_disk_read_access());
1074    }
1075
1076    #[test]
1077    fn test_read_only_with_network_allowlist() {
1078        let policy = SandboxPolicy::read_only_with_network(vec![
1079            NetworkAllowlistEntry::https("api.github.com"),
1080            NetworkAllowlistEntry::with_port("registry.npmjs.org", 443),
1081        ]);
1082
1083        assert!(!policy.has_full_network_access());
1084        assert!(policy.has_network_allowlist());
1085        assert!(policy.is_network_allowed("api.github.com", 443));
1086        assert!(policy.is_network_allowed("registry.npmjs.org", 443));
1087        assert!(!policy.is_network_allowed("example.com", 443));
1088    }
1089
1090    #[test]
1091    fn test_read_only_with_full_network_access() {
1092        let policy = SandboxPolicy::read_only_with_full_network();
1093
1094        assert!(policy.has_full_network_access());
1095        assert!(policy.is_network_allowed("example.com", 443));
1096        assert!(policy.seccomp_profile().allow_network_sockets);
1097    }
1098
1099    #[test]
1100    fn test_read_only_deserializes_legacy_shape() {
1101        let policy: SandboxPolicy = serde_json::from_str(r#"{"type":"read_only"}"#).expect("legacy read-only policy");
1102
1103        assert_eq!(policy, SandboxPolicy::read_only());
1104    }
1105
1106    #[test]
1107    fn test_workspace_write_policy() {
1108        let policy = SandboxPolicy::workspace_write(vec![PathBuf::from("/tmp/workspace")]);
1109        assert!(!policy.has_full_network_access());
1110        assert!(!policy.has_full_disk_write_access());
1111
1112        let cwd = PathBuf::from("/tmp/workspace");
1113        assert!(policy.is_path_writable(&cwd, &cwd));
1114        assert!(!policy.is_path_writable(&PathBuf::from("/etc"), &cwd));
1115    }
1116
1117    #[test]
1118    fn test_workspace_write_protects_internal_metadata_dirs() {
1119        let cwd = PathBuf::from("/tmp/workspace");
1120        let policy = SandboxPolicy::workspace_write(vec![cwd.clone()]);
1121
1122        assert!(!policy.is_path_writable(&cwd.join(".git/config"), &cwd));
1123        assert!(!policy.is_path_writable(&cwd.join(".vtcode/cache"), &cwd));
1124        assert!(!policy.is_path_writable(&cwd.join(".codex/state"), &cwd));
1125        assert!(!policy.is_path_writable(&cwd.join(".agents/skills"), &cwd));
1126        assert!(policy.is_path_writable(&cwd.join("src/main.rs"), &cwd));
1127    }
1128
1129    #[test]
1130    fn test_full_access_policy() {
1131        let policy = SandboxPolicy::full_access();
1132        assert!(policy.has_full_network_access());
1133        assert!(policy.has_full_disk_write_access());
1134    }
1135
1136    #[test]
1137    fn test_policy_escalation() {
1138        let read_only = SandboxPolicy::read_only();
1139        let full = SandboxPolicy::full_access();
1140
1141        // Cannot escalate from read-only
1142        assert!(read_only.can_set(&full).is_err());
1143
1144        // Can downgrade from full
1145        full.can_set(&read_only).unwrap();
1146    }
1147
1148    #[test]
1149    fn test_network_allowlist_entry_matching() {
1150        let entry = NetworkAllowlistEntry::https("api.github.com");
1151        assert!(entry.matches("api.github.com", 443));
1152        assert!(!entry.matches("api.github.com", 80));
1153        assert!(!entry.matches("github.com", 443));
1154    }
1155
1156    #[test]
1157    fn test_network_allowlist_wildcard() {
1158        let entry = NetworkAllowlistEntry::https("*.npmjs.org");
1159        assert!(entry.matches("registry.npmjs.org", 443));
1160        assert!(entry.matches("npmjs.org", 443));
1161        assert!(!entry.matches("npmjs.org.evil.com", 443));
1162    }
1163
1164    #[test]
1165    fn test_workspace_with_network_allowlist() {
1166        let allowlist = vec![
1167            NetworkAllowlistEntry::https("api.github.com"),
1168            NetworkAllowlistEntry::https("*.npmjs.org"),
1169        ];
1170        let policy = SandboxPolicy::workspace_write_with_network(vec![PathBuf::from("/tmp/workspace")], allowlist);
1171
1172        // Has allowlist, not full access
1173        assert!(!policy.has_full_network_access());
1174        assert!(policy.has_network_allowlist());
1175
1176        // Domain checks
1177        assert!(policy.is_network_allowed("api.github.com", 443));
1178        assert!(policy.is_network_allowed("registry.npmjs.org", 443));
1179        assert!(!policy.is_network_allowed("evil.com", 443));
1180        assert!(!policy.is_network_allowed("api.github.com", 80));
1181    }
1182
1183    #[test]
1184    fn test_workspace_no_network() {
1185        let policy = SandboxPolicy::workspace_write(vec![PathBuf::from("/tmp/workspace")]);
1186
1187        assert!(!policy.has_full_network_access());
1188        assert!(!policy.has_network_allowlist());
1189        assert!(!policy.is_network_allowed("api.github.com", 443));
1190    }
1191
1192    #[test]
1193    fn test_sensitive_path_expansion() {
1194        let sp = SensitivePath::new("~/.ssh");
1195        let expanded = sp.expand_path();
1196        // Should expand to home directory
1197        assert!(expanded.to_string_lossy().contains(".ssh"));
1198        assert!(!expanded.to_string_lossy().starts_with('~'));
1199    }
1200
1201    #[test]
1202    fn test_sensitive_path_matching() {
1203        let sp = SensitivePath::new("~/.ssh");
1204        let expanded = sp.expand_path();
1205        let ssh_key = expanded.join("id_rsa");
1206        assert!(sp.matches(&ssh_key));
1207        assert!(sp.matches(&expanded));
1208    }
1209
1210    #[cfg(not(windows))]
1211    #[test]
1212    fn test_sensitive_path_matching_is_case_insensitive_with_component_boundaries() {
1213        let sp = SensitivePath::new("/tmp/Workspace/.env");
1214
1215        assert!(sp.matches(Path::new("/tmp/workspace/.ENV")));
1216        assert!(sp.matches(Path::new("/tmp/workspace/.ENV/child")));
1217        assert!(!sp.matches(Path::new("/tmp/workspace/.environment")));
1218        assert!(!sp.matches(Path::new("/tmp/workspaces/.ENV")));
1219    }
1220
1221    #[test]
1222    fn test_default_sensitive_paths() {
1223        let paths = default_sensitive_paths();
1224        assert!(!paths.is_empty());
1225        // Should include common credential locations
1226        let path_strings: Vec<&str> = paths.iter().map(|p| p.path.as_str()).collect();
1227        assert!(path_strings.contains(&"~/.ssh"));
1228        assert!(path_strings.contains(&"~/.aws"));
1229        assert!(path_strings.contains(&"~/.kube"));
1230    }
1231
1232    #[test]
1233    fn resolved_vtcode_roots_are_sensitive_without_duplicate_entries() {
1234        let environment = [
1235            ("HOME", "/home/tester"),
1236            ("VTCODE_CONFIG", "/vtcode/shared"),
1237            ("VTCODE_DATA", "/vtcode/shared"),
1238            ("XDG_STATE_HOME", "/xdg/state"),
1239            ("XDG_CACHE_HOME", "/xdg/cache"),
1240            ("XDG_RUNTIME_DIR", "/xdg/runtime"),
1241            ("XDG_BIN_HOME", "/xdg/bin"),
1242            ("VTCODE_HOME", "/legacy/vtcode"),
1243        ];
1244        let resolved =
1245            VtCodePaths::from_environment(&environment).expect("explicit absolute VT Code paths should resolve");
1246        let paths = vtcode_sensitive_paths(&environment).expect("explicit absolute VT Code paths should resolve");
1247        let path_strings: Vec<&str> = paths.iter().map(|path| path.path.as_str()).collect();
1248
1249        for expected in [
1250            resolved.config_dir().to_path_buf(),
1251            resolved.auth_dir(),
1252            resolved.data_dir().to_path_buf(),
1253            resolved.state_dir().to_path_buf(),
1254            resolved.cache_dir().to_path_buf(),
1255            resolved.runtime_dir().to_path_buf(),
1256            resolved.executable_dir().to_path_buf(),
1257            resolved.legacy_dir().to_path_buf(),
1258        ] {
1259            let expected = expected.display().to_string();
1260            assert!(path_strings.contains(&expected.as_str()), "missing sensitive root: {expected}");
1261        }
1262        assert_eq!(path_strings.iter().filter(|path| **path == "/vtcode/shared").count(), 1);
1263    }
1264
1265    #[test]
1266    fn invalid_vtcode_path_resolution_is_rejected_before_policy_construction() {
1267        let error = vtcode_sensitive_paths(&[("HOME", "/home/tester"), ("VTCODE_CONFIG", "relative/config")])
1268            .expect_err("relative VT Code config paths must fail closed");
1269        assert!(error.to_string().contains("VTCODE_CONFIG"));
1270    }
1271
1272    #[cfg(windows)]
1273    #[test]
1274    fn test_windows_userprofile_root_exclusions_are_in_defaults() {
1275        let paths = default_sensitive_paths();
1276        let path_strings: Vec<&str> = paths.iter().map(|p| p.path.as_str()).collect();
1277
1278        for entry in USERPROFILE_READ_ROOT_EXCLUSIONS {
1279            let expected = format!("~/{}", entry);
1280            assert!(path_strings.contains(&expected.as_str()), "missing expected default sensitive path: {expected}");
1281        }
1282    }
1283
1284    #[cfg(windows)]
1285    #[test]
1286    fn test_sensitive_path_matching_is_case_insensitive_on_windows() {
1287        let sp = SensitivePath::new("~/.aws");
1288        let home = dirs::home_dir().expect("home dir");
1289        let mixed_case_candidate = home.join(".AWS").join("credentials");
1290
1291        assert!(sp.matches(&mixed_case_candidate));
1292    }
1293
1294    #[test]
1295    fn test_workspace_blocks_sensitive_by_default() {
1296        let policy = SandboxPolicy::workspace_write(vec![PathBuf::from("/tmp/workspace")]);
1297        let sensitive = policy.sensitive_paths();
1298        assert!(!sensitive.is_empty());
1299
1300        // Check that SSH keys are blocked
1301        if let Some(home) = dirs::home_dir() {
1302            let ssh_path = home.join(".ssh").join("id_rsa");
1303            assert!(policy.is_sensitive_path(&ssh_path));
1304            assert!(!policy.is_path_readable(&ssh_path));
1305        }
1306    }
1307
1308    #[test]
1309    fn test_workspace_no_sensitive_blocking() {
1310        let policy = SandboxPolicy::workspace_write_no_sensitive_blocking(vec![PathBuf::from("/tmp")]);
1311        let sensitive = policy.sensitive_paths();
1312        assert!(sensitive.is_empty());
1313
1314        // Nothing should be blocked
1315        if let Some(home) = dirs::home_dir() {
1316            let ssh_path = home.join(".ssh").join("id_rsa");
1317            assert!(!policy.is_sensitive_path(&ssh_path));
1318            assert!(policy.is_path_readable(&ssh_path));
1319        }
1320    }
1321
1322    #[test]
1323    fn test_full_access_no_sensitive_blocking() {
1324        let policy = SandboxPolicy::full_access();
1325        let sensitive = policy.sensitive_paths();
1326        assert!(sensitive.is_empty());
1327
1328        // Full access should allow everything
1329        if let Some(home) = dirs::home_dir() {
1330            let ssh_path = home.join(".ssh").join("id_rsa");
1331            assert!(policy.is_path_readable(&ssh_path));
1332        }
1333    }
1334
1335    #[test]
1336    fn test_resource_limits_default() {
1337        let limits = ResourceLimits::default();
1338        assert_eq!(limits.max_memory_mb, 0);
1339        assert_eq!(limits.max_pids, 0);
1340        assert_eq!(limits.timeout_secs, 300);
1341        assert!(limits.has_limits());
1342    }
1343
1344    #[test]
1345    fn test_resource_limits_conservative() {
1346        let limits = ResourceLimits::conservative();
1347        assert_eq!(limits.max_memory_mb, 512);
1348        assert_eq!(limits.max_pids, 64);
1349        assert_eq!(limits.cpu_time_secs, 60);
1350        assert!(limits.has_limits());
1351    }
1352
1353    #[test]
1354    fn test_resource_limits_builder() {
1355        let limits = ResourceLimits::default()
1356            .with_memory_mb(1024)
1357            .with_max_pids(128)
1358            .with_timeout_secs(60);
1359        assert_eq!(limits.max_memory_mb, 1024);
1360        assert_eq!(limits.max_pids, 128);
1361        assert_eq!(limits.effective_timeout_secs(), 60);
1362    }
1363
1364    #[test]
1365    fn test_workspace_with_limits() {
1366        let limits = ResourceLimits::conservative();
1367        let policy = SandboxPolicy::workspace_write_with_limits(vec![PathBuf::from("/tmp/workspace")], limits.clone());
1368
1369        let policy_limits = policy.resource_limits();
1370        assert_eq!(policy_limits.max_memory_mb, limits.max_memory_mb);
1371        assert_eq!(policy_limits.max_pids, limits.max_pids);
1372    }
1373
1374    #[test]
1375    fn test_read_only_conservative_limits() {
1376        let policy = SandboxPolicy::read_only();
1377        let limits = policy.resource_limits();
1378        // ReadOnly should get conservative limits
1379        assert!(limits.has_limits());
1380        assert_eq!(limits.max_memory_mb, 512);
1381    }
1382
1383    #[test]
1384    fn test_full_access_unlimited() {
1385        let policy = SandboxPolicy::full_access();
1386        let limits = policy.resource_limits();
1387        // Full access should have no limits
1388        assert!(!limits.has_limits());
1389    }
1390
1391    #[test]
1392    fn test_seccomp_profile_strict() {
1393        let profile = SeccompProfile::strict();
1394        assert!(profile.is_blocked("ptrace"));
1395        assert!(profile.is_blocked("mount"));
1396        assert!(profile.is_blocked("kexec_load"));
1397        assert!(profile.is_blocked("bpf"));
1398        assert!(!profile.allow_network_sockets);
1399        assert!(!profile.allow_namespaces);
1400    }
1401
1402    #[test]
1403    fn test_seccomp_profile_permissive() {
1404        let profile = SeccompProfile::permissive();
1405        // Still blocks the most dangerous syscalls
1406        assert!(profile.is_blocked("ptrace"));
1407        assert!(profile.is_blocked("kexec_load"));
1408        // But allows network
1409        assert!(profile.allow_network_sockets);
1410    }
1411
1412    #[test]
1413    fn test_seccomp_profile_builder() {
1414        let profile = SeccompProfile::strict().with_network().block_syscall("custom_syscall");
1415        assert!(profile.allow_network_sockets);
1416        assert!(profile.is_blocked("custom_syscall"));
1417    }
1418
1419    #[test]
1420    fn test_workspace_seccomp_profile() {
1421        let policy = SandboxPolicy::workspace_write(vec![PathBuf::from("/tmp")]);
1422        let profile = policy.seccomp_profile();
1423        // Should get strict profile by default
1424        assert!(profile.is_blocked("ptrace"));
1425        assert!(profile.is_blocked("mount"));
1426    }
1427
1428    #[test]
1429    fn test_workspace_with_network_seccomp() {
1430        let policy = SandboxPolicy::workspace_write_with_network(
1431            vec![PathBuf::from("/tmp")],
1432            vec![NetworkAllowlistEntry::https("api.github.com")],
1433        );
1434        let profile = policy.seccomp_profile();
1435        // Should allow network sockets when network is enabled
1436        assert!(profile.allow_network_sockets);
1437    }
1438
1439    #[test]
1440    fn test_seccomp_profile_json() {
1441        let profile = SeccompProfile::strict();
1442        let json = profile.to_json().unwrap();
1443        assert!(json.contains("ptrace"));
1444        assert!(json.contains("blocked_syscalls"));
1445    }
1446
1447    #[test]
1448    fn test_blocked_syscalls_constant() {
1449        // Verify key dangerous syscalls are in the list
1450        assert!(BLOCKED_SYSCALLS.contains(&"ptrace"));
1451        assert!(BLOCKED_SYSCALLS.contains(&"mount"));
1452        assert!(BLOCKED_SYSCALLS.contains(&"kexec_load"));
1453        assert!(BLOCKED_SYSCALLS.contains(&"bpf"));
1454        assert!(BLOCKED_SYSCALLS.contains(&"perf_event_open"));
1455    }
1456}