1use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
15pub struct WritableRoot {
16 pub root: PathBuf,
18}
19
20impl WritableRoot {
21 #[must_use]
23 pub fn new(path: impl Into<PathBuf>) -> Self {
24 Self { root: path.into() }
25 }
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
32pub struct NetworkAllowlistEntry {
33 pub(crate) domain: String,
35 #[serde(default = "default_https_port")]
37 pub(crate) port: u16,
38 #[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 #[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 #[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 #[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..]; domain.ends_with(suffix) || domain == &self.domain[2..]
81 } else {
82 domain == self.domain
83 }
84 }
85}
86
87pub const DEFAULT_SENSITIVE_PATHS: &[&str] = &[
92 "~/.ssh",
94 "~/.aws",
96 "~/.config/gcloud",
98 "~/.azure",
100 "~/.kube",
102 "~/.docker",
104 "~/.npmrc",
106 "~/.pypirc",
108 "~/.config/gh",
110 "~/.secrets",
112 "~/.gnupg",
114 "~/.config/op",
116 "~/.vault-token",
118 "~/.terraform.d/credentials.tfrc.json",
120 "~/.cargo/credentials.toml",
122 "~/.git-credentials",
124 "~/.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#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
144pub struct SensitivePath {
145 path: String,
147 #[serde(default = "default_true")]
149 pub(crate) block_read: bool,
150 #[serde(default = "default_true")]
152 pub(crate) block_write: bool,
153}
154
155fn default_true() -> bool {
156 true
157}
158
159impl SensitivePath {
160 #[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 #[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 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 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
217pub 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
261pub struct ResourceLimits {
262 #[serde(default)]
264 pub max_memory_mb: u64,
265
266 #[serde(default)]
269 pub max_pids: u32,
270
271 #[serde(default)]
273 pub max_disk_mb: u64,
274
275 #[serde(default)]
277 pub cpu_time_secs: u64,
278
279 #[serde(default)]
281 pub timeout_secs: u64,
282}
283
284impl Default for ResourceLimits {
285 fn default() -> Self {
286 Self {
287 max_memory_mb: 0, max_pids: 0, max_disk_mb: 0, cpu_time_secs: 0, timeout_secs: 300, }
293 }
294}
295
296impl ResourceLimits {
297 #[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 #[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 #[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 #[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 #[must_use]
348 fn with_memory_mb(mut self, mb: u64) -> Self {
349 self.max_memory_mb = mb;
350 self
351 }
352
353 #[must_use]
355 fn with_max_pids(mut self, pids: u32) -> Self {
356 self.max_pids = pids;
357 self
358 }
359
360 #[must_use]
362 pub fn with_disk_mb(mut self, mb: u64) -> Self {
363 self.max_disk_mb = mb;
364 self
365 }
366
367 #[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 #[must_use]
376 fn with_timeout_secs(mut self, secs: u64) -> Self {
377 self.timeout_secs = secs;
378 self
379 }
380
381 #[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 #[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
400pub const BLOCKED_SYSCALLS: &[&str] = &[
405 "ptrace",
407 "mount",
409 "umount",
410 "umount2",
411 "init_module",
413 "finit_module",
414 "delete_module",
415 "kexec_load",
417 "kexec_file_load",
418 "bpf",
420 "perf_event_open",
422 "userfaultfd",
424 "process_vm_readv",
426 "process_vm_writev",
427 "reboot",
429 "swapon",
431 "swapoff",
432 "settimeofday",
434 "clock_settime",
435 "adjtimex",
436 "add_key",
438 "request_key",
439 "keyctl",
440 "ioperm",
442 "iopl",
443 "iopl",
445 "acct",
447 "quotactl",
449 "unshare",
451 "setns",
452 "personality",
454];
455
456pub const FILTERED_SYSCALLS: &[&str] = &[
458 "clone", "clone3", "ioctl", "prctl", "socket",
463];
464
465#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
469pub struct SeccompProfile {
470 #[serde(default = "default_blocked_syscalls")]
472 blocked_syscalls: Vec<String>,
473
474 #[serde(default)]
476 allow_namespaces: bool,
477
478 #[serde(default)]
480 allow_network_sockets: bool,
481
482 #[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 #[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 #[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 #[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 #[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 #[must_use]
553 pub fn with_network(mut self) -> Self {
554 self.allow_network_sockets = true;
555 self
556 }
557
558 #[must_use]
560 pub fn with_logging(mut self) -> Self {
561 self.log_only = true;
562 self
563 }
564
565 #[inline]
567 #[must_use]
568 fn is_blocked(&self, syscall: &str) -> bool {
569 self.blocked_syscalls.iter().any(|s| s == syscall)
570 }
571
572 pub(crate) fn to_json(&self) -> Result<String, serde_json::Error> {
574 serde_json::to_string(self)
575 }
576}
577
578#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
590#[serde(tag = "type", rename_all = "snake_case")]
591pub enum SandboxPolicy {
592 ReadOnly {
594 #[serde(default)]
596 network_access: bool,
597
598 #[serde(default)]
600 network_allowlist: Vec<NetworkAllowlistEntry>,
601 },
602
603 WorkspaceWrite {
605 writable_roots: Vec<WritableRoot>,
607
608 #[serde(default)]
610 network_access: bool,
611
612 #[serde(default)]
616 network_allowlist: Vec<NetworkAllowlistEntry>,
617
618 #[serde(default)]
622 sensitive_paths: Option<Vec<SensitivePath>>,
623
624 #[serde(default)]
627 resource_limits: ResourceLimits,
628
629 #[serde(default)]
632 seccomp_profile: SeccompProfile,
633
634 #[serde(default)]
636 exclude_tmpdir_env_var: bool,
637
638 #[serde(default)]
640 exclude_slash_tmp: bool,
641 },
642
643 DangerFullAccess,
646
647 ExternalSandbox {
649 description: String,
651 },
652}
653
654impl SandboxPolicy {
655 #[must_use]
657 pub fn read_only() -> Self {
658 Self::ReadOnly {
659 network_access: false,
660 network_allowlist: Vec::new(),
661 }
662 }
663
664 #[must_use]
666 pub fn new_read_only_policy() -> Self {
667 Self::read_only()
668 }
669
670 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[must_use]
794 pub fn full_access() -> Self {
795 Self::DangerFullAccess
796 }
797
798 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[inline]
936 #[must_use]
937 fn has_full_disk_write_access(&self) -> bool {
938 matches!(self, Self::DangerFullAccess | Self::ExternalSandbox { .. })
939 }
940
941 #[inline]
943 #[must_use]
944 fn has_full_disk_read_access(&self) -> bool {
945 true
946 }
947
948 #[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 #[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 fn can_set(&self, new_policy: &SandboxPolicy) -> anyhow::Result<()> {
984 use SandboxPolicy::*;
985
986 match (self, new_policy) {
987 (DangerFullAccess, _) => Ok(()),
989 (ReadOnly { .. }, WorkspaceWrite { .. } | DangerFullAccess) => {
991 Err(anyhow::anyhow!("cannot escalate from read-only to write-capable policy"))
992 }
993 _ => Ok(()),
995 }
996 }
997
998 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 assert!(read_only.can_set(&full).is_err());
1095
1096 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 assert!(!policy.has_full_network_access());
1126 assert!(policy.has_network_allowlist());
1127
1128 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 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 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 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 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 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 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 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 assert!(profile.is_blocked("ptrace"));
1308 assert!(profile.is_blocked("kexec_load"));
1309 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 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 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 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}