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 domain: String,
35 #[serde(default = "default_https_port")]
37 pub port: u16,
38 #[serde(default = "default_protocol")]
40 pub protocol: String,
41}
42
43fn default_https_port() -> u16 {
44 443
45}
46
47fn default_protocol() -> String {
48 "tcp".to_string()
49}
50
51impl NetworkAllowlistEntry {
52 #[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 pub fn matches(&self, domain: &str, port: u16) -> bool {
75 if self.port != port {
76 return false;
77 }
78 if self.domain.starts_with("*.") {
79 let suffix = &self.domain[1..]; 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 pub path: String,
147 #[serde(default = "default_true")]
149 pub block_read: bool,
150 #[serde(default = "default_true")]
152 pub 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 pub fn write_only(path: impl Into<String>) -> Self {
173 Self {
174 path: path.into(),
175 block_read: false,
176 block_write: true,
177 }
178 }
179
180 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 pub fn matches(&self, path: &Path) -> bool {
196 let expanded = self.expand_path();
197 #[cfg(windows)]
198 {
199 let path_norm = normalize_windows_path(path);
200 let expanded_norm = normalize_windows_path(&expanded);
201 let mut expanded_prefix = expanded_norm.clone();
202 if !expanded_prefix.ends_with('/') {
203 expanded_prefix.push('/');
204 }
205 return path_norm == expanded_norm || path_norm.starts_with(&expanded_prefix);
206 }
207 path.starts_with(&expanded)
208 }
209}
210
211#[cfg(windows)]
212fn normalize_windows_path(path: &Path) -> String {
213 path.to_string_lossy()
214 .replace('\\', "/")
215 .to_ascii_lowercase()
216}
217
218pub fn default_sensitive_paths() -> Vec<SensitivePath> {
220 let paths: Vec<SensitivePath> = DEFAULT_SENSITIVE_PATHS
221 .iter()
222 .map(|p| SensitivePath::new(*p))
223 .collect();
224
225 #[cfg(windows)]
226 {
227 let mut paths = paths;
228 for entry in USERPROFILE_READ_ROOT_EXCLUSIONS {
229 let path = format!("~/{}", entry);
230 if !paths.iter().any(|existing| existing.path == path) {
231 paths.push(SensitivePath::new(path));
232 }
233 }
234 paths
235 }
236
237 #[cfg(not(windows))]
238 paths
239}
240
241const PROTECTED_WRITABLE_ROOT_DIR_NAMES: &[&str] = &[".git", ".vtcode", ".codex", ".agents"];
242
243fn protected_writable_root_sensitive_paths(writable_roots: &[WritableRoot]) -> Vec<SensitivePath> {
244 let mut paths = Vec::new();
245
246 for root in writable_roots {
247 for dir_name in PROTECTED_WRITABLE_ROOT_DIR_NAMES {
248 let protected_path = root.root.join(dir_name).display().to_string();
249 if !paths.iter().any(|existing: &SensitivePath| {
250 existing.path == protected_path && !existing.block_read && existing.block_write
251 }) {
252 paths.push(SensitivePath::write_only(protected_path));
253 }
254 }
255 }
256
257 paths
258}
259
260#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
265pub struct ResourceLimits {
266 #[serde(default)]
268 pub max_memory_mb: u64,
269
270 #[serde(default)]
273 pub max_pids: u32,
274
275 #[serde(default)]
277 pub max_disk_mb: u64,
278
279 #[serde(default)]
281 pub cpu_time_secs: u64,
282
283 #[serde(default)]
285 pub timeout_secs: u64,
286}
287
288impl Default for ResourceLimits {
289 fn default() -> Self {
290 Self {
291 max_memory_mb: 0, max_pids: 0, max_disk_mb: 0, cpu_time_secs: 0, timeout_secs: 300, }
297 }
298}
299
300impl ResourceLimits {
301 #[must_use]
303 pub fn unlimited() -> Self {
304 Self {
305 max_memory_mb: 0,
306 max_pids: 0,
307 max_disk_mb: 0,
308 cpu_time_secs: 0,
309 timeout_secs: 0,
310 }
311 }
312
313 #[must_use]
316 pub fn conservative() -> Self {
317 Self {
318 max_memory_mb: 512,
319 max_pids: 64,
320 max_disk_mb: 1024,
321 cpu_time_secs: 60,
322 timeout_secs: 120,
323 }
324 }
325
326 #[must_use]
328 pub fn moderate() -> Self {
329 Self {
330 max_memory_mb: 2048,
331 max_pids: 256,
332 max_disk_mb: 4096,
333 cpu_time_secs: 300,
334 timeout_secs: 600,
335 }
336 }
337
338 #[must_use]
340 pub fn generous() -> Self {
341 Self {
342 max_memory_mb: 8192,
343 max_pids: 1024,
344 max_disk_mb: 16384,
345 cpu_time_secs: 0,
346 timeout_secs: 3600,
347 }
348 }
349
350 #[must_use]
352 pub fn with_memory_mb(mut self, mb: u64) -> Self {
353 self.max_memory_mb = mb;
354 self
355 }
356
357 #[must_use]
359 pub fn with_max_pids(mut self, pids: u32) -> Self {
360 self.max_pids = pids;
361 self
362 }
363
364 #[must_use]
366 pub fn with_disk_mb(mut self, mb: u64) -> Self {
367 self.max_disk_mb = mb;
368 self
369 }
370
371 #[must_use]
373 pub fn with_cpu_time_secs(mut self, secs: u64) -> Self {
374 self.cpu_time_secs = secs;
375 self
376 }
377
378 #[must_use]
380 pub fn with_timeout_secs(mut self, secs: u64) -> Self {
381 self.timeout_secs = secs;
382 self
383 }
384
385 #[inline]
387 #[must_use]
388 pub fn has_limits(&self) -> bool {
389 self.max_memory_mb > 0
390 || self.max_pids > 0
391 || self.max_disk_mb > 0
392 || self.cpu_time_secs > 0
393 || self.timeout_secs > 0
394 }
395
396 #[inline]
398 #[must_use]
399 pub fn effective_timeout_secs(&self) -> u64 {
400 if self.timeout_secs > 0 {
401 self.timeout_secs
402 } else {
403 300
404 }
405 }
406}
407
408pub const BLOCKED_SYSCALLS: &[&str] = &[
413 "ptrace",
415 "mount",
417 "umount",
418 "umount2",
419 "init_module",
421 "finit_module",
422 "delete_module",
423 "kexec_load",
425 "kexec_file_load",
426 "bpf",
428 "perf_event_open",
430 "userfaultfd",
432 "process_vm_readv",
434 "process_vm_writev",
435 "reboot",
437 "swapon",
439 "swapoff",
440 "settimeofday",
442 "clock_settime",
443 "adjtimex",
444 "add_key",
446 "request_key",
447 "keyctl",
448 "ioperm",
450 "iopl",
451 "iopl",
453 "acct",
455 "quotactl",
457 "unshare",
459 "setns",
460 "personality",
462];
463
464pub const FILTERED_SYSCALLS: &[&str] = &[
466 "clone", "clone3", "ioctl", "prctl", "socket",
471];
472
473#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
477pub struct SeccompProfile {
478 #[serde(default = "default_blocked_syscalls")]
480 pub blocked_syscalls: Vec<String>,
481
482 #[serde(default)]
484 pub allow_namespaces: bool,
485
486 #[serde(default)]
488 pub allow_network_sockets: bool,
489
490 #[serde(default)]
492 pub log_only: bool,
493}
494
495fn default_blocked_syscalls() -> Vec<String> {
496 BLOCKED_SYSCALLS.iter().map(|s| s.to_string()).collect()
497}
498
499impl Default for SeccompProfile {
500 fn default() -> Self {
501 Self {
502 blocked_syscalls: default_blocked_syscalls(),
503 allow_namespaces: false,
504 allow_network_sockets: false,
505 log_only: false,
506 }
507 }
508}
509
510impl SeccompProfile {
511 #[must_use]
513 pub fn strict() -> Self {
514 Self {
515 blocked_syscalls: default_blocked_syscalls(),
516 allow_namespaces: false,
517 allow_network_sockets: false,
518 log_only: false,
519 }
520 }
521
522 #[must_use]
524 pub fn permissive() -> Self {
525 Self {
526 blocked_syscalls: vec![
527 "ptrace".to_string(),
528 "kexec_load".to_string(),
529 "kexec_file_load".to_string(),
530 "reboot".to_string(),
531 ],
532 allow_namespaces: false,
533 allow_network_sockets: true,
534 log_only: false,
535 }
536 }
537
538 #[must_use]
540 pub fn logging() -> Self {
541 Self {
542 blocked_syscalls: default_blocked_syscalls(),
543 allow_namespaces: false,
544 allow_network_sockets: false,
545 log_only: true,
546 }
547 }
548
549 #[must_use]
551 pub fn block_syscall(mut self, syscall: impl Into<String>) -> Self {
552 let syscall = syscall.into();
553 if !self.blocked_syscalls.contains(&syscall) {
554 self.blocked_syscalls.push(syscall);
555 }
556 self
557 }
558
559 #[must_use]
561 pub fn with_network(mut self) -> Self {
562 self.allow_network_sockets = true;
563 self
564 }
565
566 #[must_use]
568 pub fn with_logging(mut self) -> Self {
569 self.log_only = true;
570 self
571 }
572
573 #[inline]
575 #[must_use]
576 pub fn is_blocked(&self, syscall: &str) -> bool {
577 self.blocked_syscalls.iter().any(|s| s == syscall)
578 }
579
580 pub fn to_json(&self) -> Result<String, serde_json::Error> {
582 serde_json::to_string(self)
583 }
584}
585
586#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
598#[serde(tag = "type", rename_all = "snake_case")]
599pub enum SandboxPolicy {
600 ReadOnly {
602 #[serde(default)]
604 network_access: bool,
605
606 #[serde(default)]
608 network_allowlist: Vec<NetworkAllowlistEntry>,
609 },
610
611 WorkspaceWrite {
613 writable_roots: Vec<WritableRoot>,
615
616 #[serde(default)]
618 network_access: bool,
619
620 #[serde(default)]
624 network_allowlist: Vec<NetworkAllowlistEntry>,
625
626 #[serde(default)]
630 sensitive_paths: Option<Vec<SensitivePath>>,
631
632 #[serde(default)]
635 resource_limits: ResourceLimits,
636
637 #[serde(default)]
640 seccomp_profile: SeccompProfile,
641
642 #[serde(default)]
644 exclude_tmpdir_env_var: bool,
645
646 #[serde(default)]
648 exclude_slash_tmp: bool,
649 },
650
651 DangerFullAccess,
654
655 ExternalSandbox {
657 description: String,
659 },
660}
661
662impl SandboxPolicy {
663 #[must_use]
665 pub fn read_only() -> Self {
666 Self::ReadOnly {
667 network_access: false,
668 network_allowlist: Vec::new(),
669 }
670 }
671
672 #[must_use]
674 pub fn new_read_only_policy() -> Self {
675 Self::read_only()
676 }
677
678 #[must_use]
680 pub fn read_only_with_network(network_allowlist: Vec<NetworkAllowlistEntry>) -> Self {
681 Self::ReadOnly {
682 network_access: !network_allowlist.is_empty(),
683 network_allowlist,
684 }
685 }
686
687 #[must_use]
689 pub fn read_only_with_full_network() -> Self {
690 Self::ReadOnly {
691 network_access: true,
692 network_allowlist: Vec::new(),
693 }
694 }
695
696 #[must_use]
699 pub fn workspace_write(writable_roots: Vec<PathBuf>) -> Self {
700 Self::WorkspaceWrite {
701 writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
702 network_access: false,
703 network_allowlist: Vec::new(),
704 sensitive_paths: None,
705 resource_limits: ResourceLimits::default(),
706 seccomp_profile: SeccompProfile::strict(),
707 exclude_tmpdir_env_var: true,
708 exclude_slash_tmp: true,
709 }
710 }
711
712 #[must_use]
714 pub fn workspace_write_with_network(
715 writable_roots: Vec<PathBuf>,
716 network_allowlist: Vec<NetworkAllowlistEntry>,
717 ) -> Self {
718 Self::WorkspaceWrite {
719 writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
720 network_access: !network_allowlist.is_empty(),
721 network_allowlist,
722 sensitive_paths: None,
723 resource_limits: ResourceLimits::default(),
724 seccomp_profile: SeccompProfile::strict().with_network(),
725 exclude_tmpdir_env_var: true,
726 exclude_slash_tmp: true,
727 }
728 }
729
730 #[must_use]
732 pub fn workspace_write_with_sensitive_paths(
733 writable_roots: Vec<PathBuf>,
734 sensitive_paths: Vec<SensitivePath>,
735 ) -> Self {
736 Self::WorkspaceWrite {
737 writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
738 network_access: false,
739 network_allowlist: Vec::new(),
740 sensitive_paths: Some(sensitive_paths),
741 resource_limits: ResourceLimits::default(),
742 seccomp_profile: SeccompProfile::strict(),
743 exclude_tmpdir_env_var: true,
744 exclude_slash_tmp: true,
745 }
746 }
747
748 #[must_use]
750 pub fn workspace_write_no_sensitive_blocking(writable_roots: Vec<PathBuf>) -> Self {
751 Self::WorkspaceWrite {
752 writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
753 network_access: false,
754 network_allowlist: Vec::new(),
755 sensitive_paths: Some(Vec::new()),
756 resource_limits: ResourceLimits::default(),
757 seccomp_profile: SeccompProfile::strict(),
758 exclude_tmpdir_env_var: true,
759 exclude_slash_tmp: true,
760 }
761 }
762
763 #[must_use]
766 pub fn workspace_write_with_limits(
767 writable_roots: Vec<PathBuf>,
768 resource_limits: ResourceLimits,
769 ) -> Self {
770 Self::WorkspaceWrite {
771 writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
772 network_access: false,
773 network_allowlist: Vec::new(),
774 sensitive_paths: None,
775 resource_limits,
776 seccomp_profile: SeccompProfile::strict(),
777 exclude_tmpdir_env_var: true,
778 exclude_slash_tmp: true,
779 }
780 }
781
782 #[must_use]
784 pub fn workspace_write_full(
785 writable_roots: Vec<PathBuf>,
786 network_allowlist: Vec<NetworkAllowlistEntry>,
787 sensitive_paths: Option<Vec<SensitivePath>>,
788 resource_limits: ResourceLimits,
789 seccomp_profile: SeccompProfile,
790 ) -> Self {
791 Self::WorkspaceWrite {
792 writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
793 network_access: !network_allowlist.is_empty(),
794 network_allowlist,
795 sensitive_paths,
796 resource_limits,
797 seccomp_profile,
798 exclude_tmpdir_env_var: true,
799 exclude_slash_tmp: true,
800 }
801 }
802
803 #[must_use]
805 pub fn full_access() -> Self {
806 Self::DangerFullAccess
807 }
808
809 #[inline]
811 #[must_use]
812 pub fn has_full_network_access(&self) -> bool {
813 match self {
814 Self::ReadOnly {
815 network_access,
816 network_allowlist,
817 }
818 | Self::WorkspaceWrite {
819 network_access,
820 network_allowlist,
821 ..
822 } => *network_access && network_allowlist.is_empty(),
823 Self::DangerFullAccess | Self::ExternalSandbox { .. } => true,
824 }
825 }
826
827 #[inline]
829 #[must_use]
830 pub fn has_network_allowlist(&self) -> bool {
831 match self {
832 Self::ReadOnly {
833 network_allowlist, ..
834 }
835 | Self::WorkspaceWrite {
836 network_allowlist, ..
837 } => !network_allowlist.is_empty(),
838 _ => false,
839 }
840 }
841
842 #[inline]
844 #[must_use]
845 pub fn network_allowlist(&self) -> &[NetworkAllowlistEntry] {
846 match self {
847 Self::ReadOnly {
848 network_allowlist, ..
849 }
850 | Self::WorkspaceWrite {
851 network_allowlist, ..
852 } => network_allowlist,
853 _ => &[],
854 }
855 }
856
857 #[inline]
859 #[must_use]
860 pub fn is_network_allowed(&self, domain: &str, port: u16) -> bool {
861 match self {
862 Self::ReadOnly {
863 network_access,
864 network_allowlist,
865 }
866 | Self::WorkspaceWrite {
867 network_access,
868 network_allowlist,
869 ..
870 } => {
871 if network_allowlist.is_empty() {
872 *network_access
873 } else {
874 network_allowlist
875 .iter()
876 .any(|entry| entry.matches(domain, port))
877 }
878 }
879 Self::DangerFullAccess | Self::ExternalSandbox { .. } => true,
880 }
881 }
882
883 #[must_use]
886 pub fn sensitive_paths(&self) -> Vec<SensitivePath> {
887 match self {
888 Self::ReadOnly { .. } => default_sensitive_paths(),
889 Self::WorkspaceWrite {
890 sensitive_paths, ..
891 } => sensitive_paths
892 .clone()
893 .unwrap_or_else(default_sensitive_paths),
894 Self::DangerFullAccess | Self::ExternalSandbox { .. } => Vec::new(),
895 }
896 }
897
898 #[must_use]
900 pub fn sensitive_paths_for_execution(&self, cwd: &Path) -> Vec<SensitivePath> {
901 match self {
902 Self::WorkspaceWrite { .. } => {
903 let mut sensitive_paths = self.sensitive_paths();
904 sensitive_paths.extend(protected_writable_root_sensitive_paths(
905 &self.get_writable_roots_with_cwd(cwd),
906 ));
907 sensitive_paths
908 }
909 _ => self.sensitive_paths(),
910 }
911 }
912
913 #[inline]
915 #[must_use]
916 pub fn is_sensitive_path(&self, path: &Path) -> bool {
917 self.sensitive_paths()
918 .iter()
919 .any(|sp| sp.matches(path) && sp.block_read)
920 }
921
922 #[inline]
924 #[must_use]
925 pub fn is_path_write_blocked(&self, path: &Path, cwd: &Path) -> bool {
926 match self {
927 Self::DangerFullAccess | Self::ExternalSandbox { .. } => false,
928 _ => self
929 .sensitive_paths_for_execution(cwd)
930 .iter()
931 .any(|sp| sp.matches(path) && sp.block_write),
932 }
933 }
934
935 #[inline]
937 #[must_use]
938 pub fn is_path_readable(&self, path: &Path) -> bool {
939 match self {
940 Self::DangerFullAccess | Self::ExternalSandbox { .. } => true,
941 _ => !self.is_sensitive_path(path),
942 }
943 }
944
945 #[must_use]
947 pub fn resource_limits(&self) -> ResourceLimits {
948 match self {
949 Self::ReadOnly { .. } => ResourceLimits::conservative(),
950 Self::WorkspaceWrite {
951 resource_limits, ..
952 } => resource_limits.clone(),
953 Self::DangerFullAccess | Self::ExternalSandbox { .. } => ResourceLimits::unlimited(),
954 }
955 }
956
957 #[must_use]
959 pub fn seccomp_profile(&self) -> SeccompProfile {
960 match self {
961 Self::ReadOnly {
962 network_access,
963 network_allowlist,
964 } => {
965 let mut profile = SeccompProfile::strict();
966 if *network_access || !network_allowlist.is_empty() {
967 profile = profile.with_network();
968 }
969 profile
970 }
971 Self::WorkspaceWrite {
972 seccomp_profile, ..
973 } => seccomp_profile.clone(),
974 Self::DangerFullAccess | Self::ExternalSandbox { .. } => SeccompProfile::permissive(),
975 }
976 }
977
978 #[inline]
980 #[must_use]
981 pub fn has_full_disk_write_access(&self) -> bool {
982 matches!(self, Self::DangerFullAccess | Self::ExternalSandbox { .. })
983 }
984
985 #[inline]
987 #[must_use]
988 pub fn has_full_disk_read_access(&self) -> bool {
989 true
990 }
991
992 #[must_use]
994 pub fn get_writable_roots_with_cwd(&self, cwd: &Path) -> Vec<WritableRoot> {
995 match self {
996 Self::ReadOnly { .. } => vec![],
997 Self::WorkspaceWrite { writable_roots, .. } => {
998 let mut roots = writable_roots.clone();
999 let cwd_root = WritableRoot::new(cwd);
1000 if !roots.contains(&cwd_root) {
1001 roots.push(cwd_root);
1002 }
1003 roots
1004 }
1005 Self::DangerFullAccess | Self::ExternalSandbox { .. } => {
1006 vec![WritableRoot::new(cwd)]
1007 }
1008 }
1009 }
1010
1011 #[inline]
1013 #[must_use]
1014 pub fn is_path_writable(&self, path: &Path, cwd: &Path) -> bool {
1015 match self {
1016 Self::ReadOnly { .. } => false,
1017 Self::WorkspaceWrite { .. } => {
1018 let writable = self.get_writable_roots_with_cwd(cwd);
1019 writable.iter().any(|root| path.starts_with(&root.root))
1020 && !self.is_path_write_blocked(path, cwd)
1021 }
1022 Self::DangerFullAccess | Self::ExternalSandbox { .. } => true,
1023 }
1024 }
1025
1026 pub fn can_set(&self, new_policy: &SandboxPolicy) -> anyhow::Result<()> {
1029 use SandboxPolicy::*;
1030
1031 match (self, new_policy) {
1032 (DangerFullAccess, _) => Ok(()),
1034 (ReadOnly { .. }, WorkspaceWrite { .. } | DangerFullAccess) => Err(anyhow::anyhow!(
1036 "cannot escalate from read-only to write-capable policy"
1037 )),
1038 _ => Ok(()),
1040 }
1041 }
1042
1043 pub fn description(&self) -> &'static str {
1045 match self {
1046 Self::ReadOnly { .. } => "read-only access",
1047 Self::WorkspaceWrite { .. } => "workspace write access",
1048 Self::DangerFullAccess => "full access (dangerous)",
1049 Self::ExternalSandbox { .. } => "external sandbox",
1050 }
1051 }
1052}
1053
1054impl Default for SandboxPolicy {
1055 fn default() -> Self {
1056 Self::read_only()
1057 }
1058}
1059
1060#[cfg(test)]
1061mod tests {
1062 use super::*;
1063
1064 #[test]
1065 fn test_read_only_policy() {
1066 let policy = SandboxPolicy::read_only();
1067 assert!(!policy.has_full_network_access());
1068 assert!(!policy.has_network_allowlist());
1069 assert!(!policy.has_full_disk_write_access());
1070 assert!(policy.has_full_disk_read_access());
1071 }
1072
1073 #[test]
1074 fn test_read_only_with_network_allowlist() {
1075 let policy = SandboxPolicy::read_only_with_network(vec![
1076 NetworkAllowlistEntry::https("api.github.com"),
1077 NetworkAllowlistEntry::with_port("registry.npmjs.org", 443),
1078 ]);
1079
1080 assert!(!policy.has_full_network_access());
1081 assert!(policy.has_network_allowlist());
1082 assert!(policy.is_network_allowed("api.github.com", 443));
1083 assert!(policy.is_network_allowed("registry.npmjs.org", 443));
1084 assert!(!policy.is_network_allowed("example.com", 443));
1085 }
1086
1087 #[test]
1088 fn test_read_only_with_full_network_access() {
1089 let policy = SandboxPolicy::read_only_with_full_network();
1090
1091 assert!(policy.has_full_network_access());
1092 assert!(policy.is_network_allowed("example.com", 443));
1093 assert!(policy.seccomp_profile().allow_network_sockets);
1094 }
1095
1096 #[test]
1097 fn test_read_only_deserializes_legacy_shape() {
1098 let policy: SandboxPolicy =
1099 serde_json::from_str(r#"{"type":"read_only"}"#).expect("legacy read-only policy");
1100
1101 assert_eq!(policy, SandboxPolicy::read_only());
1102 }
1103
1104 #[test]
1105 fn test_workspace_write_policy() {
1106 let policy = SandboxPolicy::workspace_write(vec![PathBuf::from("/tmp/workspace")]);
1107 assert!(!policy.has_full_network_access());
1108 assert!(!policy.has_full_disk_write_access());
1109
1110 let cwd = PathBuf::from("/tmp/workspace");
1111 assert!(policy.is_path_writable(&cwd, &cwd));
1112 assert!(!policy.is_path_writable(&PathBuf::from("/etc"), &cwd));
1113 }
1114
1115 #[test]
1116 fn test_workspace_write_protects_internal_metadata_dirs() {
1117 let cwd = PathBuf::from("/tmp/workspace");
1118 let policy = SandboxPolicy::workspace_write(vec![cwd.clone()]);
1119
1120 assert!(!policy.is_path_writable(&cwd.join(".git/config"), &cwd));
1121 assert!(!policy.is_path_writable(&cwd.join(".vtcode/cache"), &cwd));
1122 assert!(!policy.is_path_writable(&cwd.join(".codex/state"), &cwd));
1123 assert!(!policy.is_path_writable(&cwd.join(".agents/skills"), &cwd));
1124 assert!(policy.is_path_writable(&cwd.join("src/main.rs"), &cwd));
1125 }
1126
1127 #[test]
1128 fn test_full_access_policy() {
1129 let policy = SandboxPolicy::full_access();
1130 assert!(policy.has_full_network_access());
1131 assert!(policy.has_full_disk_write_access());
1132 }
1133
1134 #[test]
1135 fn test_policy_escalation() {
1136 let read_only = SandboxPolicy::read_only();
1137 let full = SandboxPolicy::full_access();
1138
1139 assert!(read_only.can_set(&full).is_err());
1141
1142 full.can_set(&read_only).unwrap();
1144 }
1145
1146 #[test]
1147 fn test_network_allowlist_entry_matching() {
1148 let entry = NetworkAllowlistEntry::https("api.github.com");
1149 assert!(entry.matches("api.github.com", 443));
1150 assert!(!entry.matches("api.github.com", 80));
1151 assert!(!entry.matches("github.com", 443));
1152 }
1153
1154 #[test]
1155 fn test_network_allowlist_wildcard() {
1156 let entry = NetworkAllowlistEntry::https("*.npmjs.org");
1157 assert!(entry.matches("registry.npmjs.org", 443));
1158 assert!(entry.matches("npmjs.org", 443));
1159 assert!(!entry.matches("npmjs.org.evil.com", 443));
1160 }
1161
1162 #[test]
1163 fn test_workspace_with_network_allowlist() {
1164 let allowlist = vec![
1165 NetworkAllowlistEntry::https("api.github.com"),
1166 NetworkAllowlistEntry::https("*.npmjs.org"),
1167 ];
1168 let policy = SandboxPolicy::workspace_write_with_network(
1169 vec![PathBuf::from("/tmp/workspace")],
1170 allowlist,
1171 );
1172
1173 assert!(!policy.has_full_network_access());
1175 assert!(policy.has_network_allowlist());
1176
1177 assert!(policy.is_network_allowed("api.github.com", 443));
1179 assert!(policy.is_network_allowed("registry.npmjs.org", 443));
1180 assert!(!policy.is_network_allowed("evil.com", 443));
1181 assert!(!policy.is_network_allowed("api.github.com", 80));
1182 }
1183
1184 #[test]
1185 fn test_workspace_no_network() {
1186 let policy = SandboxPolicy::workspace_write(vec![PathBuf::from("/tmp/workspace")]);
1187
1188 assert!(!policy.has_full_network_access());
1189 assert!(!policy.has_network_allowlist());
1190 assert!(!policy.is_network_allowed("api.github.com", 443));
1191 }
1192
1193 #[test]
1194 fn test_sensitive_path_expansion() {
1195 let sp = SensitivePath::new("~/.ssh");
1196 let expanded = sp.expand_path();
1197 assert!(expanded.to_string_lossy().contains(".ssh"));
1199 assert!(!expanded.to_string_lossy().starts_with('~'));
1200 }
1201
1202 #[test]
1203 fn test_sensitive_path_matching() {
1204 let sp = SensitivePath::new("~/.ssh");
1205 let expanded = sp.expand_path();
1206 let ssh_key = expanded.join("id_rsa");
1207 assert!(sp.matches(&ssh_key));
1208 assert!(sp.matches(&expanded));
1209 }
1210
1211 #[test]
1212 fn test_default_sensitive_paths() {
1213 let paths = default_sensitive_paths();
1214 assert!(!paths.is_empty());
1215 let path_strings: Vec<&str> = paths.iter().map(|p| p.path.as_str()).collect();
1217 assert!(path_strings.contains(&"~/.ssh"));
1218 assert!(path_strings.contains(&"~/.aws"));
1219 assert!(path_strings.contains(&"~/.kube"));
1220 }
1221
1222 #[cfg(windows)]
1223 #[test]
1224 fn test_windows_userprofile_root_exclusions_are_in_defaults() {
1225 let paths = default_sensitive_paths();
1226 let path_strings: Vec<&str> = paths.iter().map(|p| p.path.as_str()).collect();
1227
1228 for entry in USERPROFILE_READ_ROOT_EXCLUSIONS {
1229 let expected = format!("~/{}", entry);
1230 assert!(
1231 path_strings.contains(&expected.as_str()),
1232 "missing expected default sensitive path: {expected}"
1233 );
1234 }
1235 }
1236
1237 #[cfg(windows)]
1238 #[test]
1239 fn test_sensitive_path_matching_is_case_insensitive_on_windows() {
1240 let sp = SensitivePath::new("~/.aws");
1241 let home = dirs::home_dir().expect("home dir");
1242 let mixed_case_candidate = home.join(".AWS").join("credentials");
1243
1244 assert!(sp.matches(&mixed_case_candidate));
1245 }
1246
1247 #[test]
1248 fn test_workspace_blocks_sensitive_by_default() {
1249 let policy = SandboxPolicy::workspace_write(vec![PathBuf::from("/tmp/workspace")]);
1250 let sensitive = policy.sensitive_paths();
1251 assert!(!sensitive.is_empty());
1252
1253 if let Some(home) = dirs::home_dir() {
1255 let ssh_path = home.join(".ssh").join("id_rsa");
1256 assert!(policy.is_sensitive_path(&ssh_path));
1257 assert!(!policy.is_path_readable(&ssh_path));
1258 }
1259 }
1260
1261 #[test]
1262 fn test_workspace_no_sensitive_blocking() {
1263 let policy =
1264 SandboxPolicy::workspace_write_no_sensitive_blocking(vec![PathBuf::from("/tmp")]);
1265 let sensitive = policy.sensitive_paths();
1266 assert!(sensitive.is_empty());
1267
1268 if let Some(home) = dirs::home_dir() {
1270 let ssh_path = home.join(".ssh").join("id_rsa");
1271 assert!(!policy.is_sensitive_path(&ssh_path));
1272 assert!(policy.is_path_readable(&ssh_path));
1273 }
1274 }
1275
1276 #[test]
1277 fn test_full_access_no_sensitive_blocking() {
1278 let policy = SandboxPolicy::full_access();
1279 let sensitive = policy.sensitive_paths();
1280 assert!(sensitive.is_empty());
1281
1282 if let Some(home) = dirs::home_dir() {
1284 let ssh_path = home.join(".ssh").join("id_rsa");
1285 assert!(policy.is_path_readable(&ssh_path));
1286 }
1287 }
1288
1289 #[test]
1290 fn test_resource_limits_default() {
1291 let limits = ResourceLimits::default();
1292 assert_eq!(limits.max_memory_mb, 0);
1293 assert_eq!(limits.max_pids, 0);
1294 assert_eq!(limits.timeout_secs, 300);
1295 assert!(limits.has_limits());
1296 }
1297
1298 #[test]
1299 fn test_resource_limits_conservative() {
1300 let limits = ResourceLimits::conservative();
1301 assert_eq!(limits.max_memory_mb, 512);
1302 assert_eq!(limits.max_pids, 64);
1303 assert_eq!(limits.cpu_time_secs, 60);
1304 assert!(limits.has_limits());
1305 }
1306
1307 #[test]
1308 fn test_resource_limits_builder() {
1309 let limits = ResourceLimits::default()
1310 .with_memory_mb(1024)
1311 .with_max_pids(128)
1312 .with_timeout_secs(60);
1313 assert_eq!(limits.max_memory_mb, 1024);
1314 assert_eq!(limits.max_pids, 128);
1315 assert_eq!(limits.effective_timeout_secs(), 60);
1316 }
1317
1318 #[test]
1319 fn test_workspace_with_limits() {
1320 let limits = ResourceLimits::conservative();
1321 let policy = SandboxPolicy::workspace_write_with_limits(
1322 vec![PathBuf::from("/tmp/workspace")],
1323 limits.clone(),
1324 );
1325
1326 let policy_limits = policy.resource_limits();
1327 assert_eq!(policy_limits.max_memory_mb, limits.max_memory_mb);
1328 assert_eq!(policy_limits.max_pids, limits.max_pids);
1329 }
1330
1331 #[test]
1332 fn test_read_only_conservative_limits() {
1333 let policy = SandboxPolicy::read_only();
1334 let limits = policy.resource_limits();
1335 assert!(limits.has_limits());
1337 assert_eq!(limits.max_memory_mb, 512);
1338 }
1339
1340 #[test]
1341 fn test_full_access_unlimited() {
1342 let policy = SandboxPolicy::full_access();
1343 let limits = policy.resource_limits();
1344 assert!(!limits.has_limits());
1346 }
1347
1348 #[test]
1349 fn test_seccomp_profile_strict() {
1350 let profile = SeccompProfile::strict();
1351 assert!(profile.is_blocked("ptrace"));
1352 assert!(profile.is_blocked("mount"));
1353 assert!(profile.is_blocked("kexec_load"));
1354 assert!(profile.is_blocked("bpf"));
1355 assert!(!profile.allow_network_sockets);
1356 assert!(!profile.allow_namespaces);
1357 }
1358
1359 #[test]
1360 fn test_seccomp_profile_permissive() {
1361 let profile = SeccompProfile::permissive();
1362 assert!(profile.is_blocked("ptrace"));
1364 assert!(profile.is_blocked("kexec_load"));
1365 assert!(profile.allow_network_sockets);
1367 }
1368
1369 #[test]
1370 fn test_seccomp_profile_builder() {
1371 let profile = SeccompProfile::strict()
1372 .with_network()
1373 .block_syscall("custom_syscall");
1374 assert!(profile.allow_network_sockets);
1375 assert!(profile.is_blocked("custom_syscall"));
1376 }
1377
1378 #[test]
1379 fn test_workspace_seccomp_profile() {
1380 let policy = SandboxPolicy::workspace_write(vec![PathBuf::from("/tmp")]);
1381 let profile = policy.seccomp_profile();
1382 assert!(profile.is_blocked("ptrace"));
1384 assert!(profile.is_blocked("mount"));
1385 }
1386
1387 #[test]
1388 fn test_workspace_with_network_seccomp() {
1389 let policy = SandboxPolicy::workspace_write_with_network(
1390 vec![PathBuf::from("/tmp")],
1391 vec![NetworkAllowlistEntry::https("api.github.com")],
1392 );
1393 let profile = policy.seccomp_profile();
1394 assert!(profile.allow_network_sockets);
1396 }
1397
1398 #[test]
1399 fn test_seccomp_profile_json() {
1400 let profile = SeccompProfile::strict();
1401 let json = profile.to_json().unwrap();
1402 assert!(json.contains("ptrace"));
1403 assert!(json.contains("blocked_syscalls"));
1404 }
1405
1406 #[test]
1407 fn test_blocked_syscalls_constant() {
1408 assert!(BLOCKED_SYSCALLS.contains(&"ptrace"));
1410 assert!(BLOCKED_SYSCALLS.contains(&"mount"));
1411 assert!(BLOCKED_SYSCALLS.contains(&"kexec_load"));
1412 assert!(BLOCKED_SYSCALLS.contains(&"bpf"));
1413 assert!(BLOCKED_SYSCALLS.contains(&"perf_event_open"));
1414 }
1415}