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