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