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.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
88pub const DEFAULT_SENSITIVE_PATHS: &[&str] = &[
93 "~/.ssh",
95 "~/.aws",
97 "~/.config/gcloud",
99 "~/.azure",
101 "~/.kube",
103 "~/.docker",
105 "~/.npmrc",
107 "~/.pypirc",
109 "~/.config/gh",
111 "~/.secrets",
113 "~/.gnupg",
115 "~/.config/op",
117 "~/.vault-token",
119 "~/.terraform.d/credentials.tfrc.json",
121 "~/.cargo/credentials.toml",
123 "~/.git-credentials",
125 "~/.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#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
145pub struct SensitivePath {
146 path: String,
148 #[serde(default = "default_true")]
150 pub(crate) block_read: bool,
151 #[serde(default = "default_true")]
153 pub(crate) block_write: bool,
154}
155
156fn default_true() -> bool {
157 true
158}
159
160impl SensitivePath {
161 #[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 #[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 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 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
218pub 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
262pub struct ResourceLimits {
263 #[serde(default)]
265 pub max_memory_mb: u64,
266
267 #[serde(default)]
270 pub max_pids: u32,
271
272 #[serde(default)]
274 pub max_disk_mb: u64,
275
276 #[serde(default)]
278 pub cpu_time_secs: u64,
279
280 #[serde(default)]
282 pub timeout_secs: u64,
283}
284
285impl Default for ResourceLimits {
286 fn default() -> Self {
287 Self {
288 max_memory_mb: 0, max_pids: 0, max_disk_mb: 0, cpu_time_secs: 0, timeout_secs: 300, }
294 }
295}
296
297impl ResourceLimits {
298 #[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 #[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 #[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 #[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 #[must_use]
349 fn with_memory_mb(mut self, mb: u64) -> Self {
350 self.max_memory_mb = mb;
351 self
352 }
353
354 #[must_use]
356 fn with_max_pids(mut self, pids: u32) -> Self {
357 self.max_pids = pids;
358 self
359 }
360
361 #[must_use]
363 pub fn with_disk_mb(mut self, mb: u64) -> Self {
364 self.max_disk_mb = mb;
365 self
366 }
367
368 #[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 #[must_use]
377 fn with_timeout_secs(mut self, secs: u64) -> Self {
378 self.timeout_secs = secs;
379 self
380 }
381
382 #[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 #[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
401pub const BLOCKED_SYSCALLS: &[&str] = &[
406 "ptrace",
408 "mount",
410 "umount",
411 "umount2",
412 "init_module",
414 "finit_module",
415 "delete_module",
416 "kexec_load",
418 "kexec_file_load",
419 "bpf",
421 "perf_event_open",
423 "userfaultfd",
425 "process_vm_readv",
427 "process_vm_writev",
428 "reboot",
430 "swapon",
432 "swapoff",
433 "settimeofday",
435 "clock_settime",
436 "adjtimex",
437 "add_key",
439 "request_key",
440 "keyctl",
441 "ioperm",
443 "iopl",
444 "iopl",
446 "acct",
448 "quotactl",
450 "unshare",
452 "setns",
453 "personality",
455];
456
457pub const FILTERED_SYSCALLS: &[&str] = &[
459 "clone", "clone3", "ioctl", "prctl", "socket",
464];
465
466#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
470pub struct SeccompProfile {
471 #[serde(default = "default_blocked_syscalls")]
473 blocked_syscalls: Vec<String>,
474
475 #[serde(default)]
477 allow_namespaces: bool,
478
479 #[serde(default)]
481 allow_network_sockets: bool,
482
483 #[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 #[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 #[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 #[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 #[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 #[must_use]
554 pub fn with_network(mut self) -> Self {
555 self.allow_network_sockets = true;
556 self
557 }
558
559 #[must_use]
561 pub fn with_logging(mut self) -> Self {
562 self.log_only = true;
563 self
564 }
565
566 #[inline]
568 #[must_use]
569 fn is_blocked(&self, syscall: &str) -> bool {
570 self.blocked_syscalls.iter().any(|s| s == syscall)
571 }
572
573 pub(crate) fn to_json(&self) -> Result<String, serde_json::Error> {
575 serde_json::to_string(self)
576 }
577}
578
579#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
591#[serde(tag = "type", rename_all = "snake_case")]
592pub enum SandboxPolicy {
593 ReadOnly {
595 #[serde(default)]
597 network_access: bool,
598
599 #[serde(default)]
601 network_allowlist: Vec<NetworkAllowlistEntry>,
602 },
603
604 WorkspaceWrite {
606 writable_roots: Vec<WritableRoot>,
608
609 #[serde(default)]
611 network_access: bool,
612
613 #[serde(default)]
617 network_allowlist: Vec<NetworkAllowlistEntry>,
618
619 #[serde(default)]
623 sensitive_paths: Option<Vec<SensitivePath>>,
624
625 #[serde(default)]
628 resource_limits: ResourceLimits,
629
630 #[serde(default)]
633 seccomp_profile: SeccompProfile,
634
635 #[serde(default)]
637 exclude_tmpdir_env_var: bool,
638
639 #[serde(default)]
641 exclude_slash_tmp: bool,
642 },
643
644 DangerFullAccess,
647
648 ExternalSandbox {
650 description: String,
652 },
653}
654
655impl SandboxPolicy {
656 #[must_use]
658 pub fn read_only() -> Self {
659 Self::ReadOnly {
660 network_access: false,
661 network_allowlist: Vec::new(),
662 }
663 }
664
665 #[must_use]
667 pub fn new_read_only_policy() -> Self {
668 Self::read_only()
669 }
670
671 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[must_use]
795 pub fn full_access() -> Self {
796 Self::DangerFullAccess
797 }
798
799 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[inline]
937 #[must_use]
938 fn has_full_disk_write_access(&self) -> bool {
939 matches!(self, Self::DangerFullAccess | Self::ExternalSandbox { .. })
940 }
941
942 #[inline]
944 #[must_use]
945 fn has_full_disk_read_access(&self) -> bool {
946 true
947 }
948
949 #[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 #[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 fn can_set(&self, new_policy: &SandboxPolicy) -> anyhow::Result<()> {
985 use SandboxPolicy::*;
986
987 match (self, new_policy) {
988 (DangerFullAccess, _) => Ok(()),
990 (ReadOnly { .. }, WorkspaceWrite { .. } | DangerFullAccess) => {
992 Err(anyhow::anyhow!("cannot escalate from read-only to write-capable policy"))
993 }
994 _ => Ok(()),
996 }
997 }
998
999 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 assert!(read_only.can_set(&full).is_err());
1096
1097 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 assert!(!policy.has_full_network_access());
1127 assert!(policy.has_network_allowlist());
1128
1129 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 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 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 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 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 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 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 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 assert!(profile.is_blocked("ptrace"));
1309 assert!(profile.is_blocked("kexec_load"));
1310 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 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 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 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}