Skip to main content

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