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