1use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12use vtcode_commons::VtCodePaths;
13
14#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
16pub struct WritableRoot {
17 pub root: PathBuf,
19}
20
21impl WritableRoot {
22 #[must_use]
24 pub fn new(path: impl Into<PathBuf>) -> Self {
25 Self { root: path.into() }
26 }
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
33pub struct NetworkAllowlistEntry {
34 pub(crate) domain: String,
36 #[serde(default = "default_https_port")]
38 pub(crate) port: u16,
39 #[serde(default = "default_protocol")]
41 pub(crate) protocol: String,
42}
43
44fn default_https_port() -> u16 {
45 443
46}
47
48fn default_protocol() -> String {
49 "tcp".to_string()
50}
51
52impl NetworkAllowlistEntry {
53 #[must_use]
55 pub fn https(domain: impl Into<String>) -> Self {
56 Self {
57 domain: domain.into(),
58 port: 443,
59 protocol: "tcp".to_string(),
60 }
61 }
62
63 #[must_use]
65 pub fn with_port(domain: impl Into<String>, port: u16) -> Self {
66 Self {
67 domain: domain.into(),
68 port,
69 protocol: "tcp".to_string(),
70 }
71 }
72
73 #[inline]
75 fn matches(&self, domain: &str, port: u16) -> bool {
76 if self.port != port {
77 return false;
78 }
79 if self.domain.starts_with("*.") {
80 let suffix = self.domain.get(1..).unwrap_or_default();
81 let exact = self.domain.get(2..).unwrap_or_default();
82 domain.ends_with(suffix) || domain == exact
83 } else {
84 domain == self.domain
85 }
86 }
87}
88
89pub const DEFAULT_SENSITIVE_PATHS: &[&str] = &[
94 "~/.ssh",
96 "~/.aws",
98 "~/.config/gcloud",
100 "~/.azure",
102 "~/.kube",
104 "~/.docker",
106 "~/.npmrc",
108 "~/.pypirc",
110 "~/.config/gh",
112 "~/.secrets",
114 "~/.gnupg",
116 "~/.config/op",
118 "~/.vault-token",
120 "~/.terraform.d/credentials.tfrc.json",
122 "~/.cargo/credentials.toml",
124 "~/.git-credentials",
126 "~/.netrc",
128];
129
130#[cfg(windows)]
131const USERPROFILE_READ_ROOT_EXCLUSIONS: &[&str] = &[
132 ".ssh",
133 ".gnupg",
134 ".aws",
135 ".azure",
136 ".kube",
137 ".docker",
138 ".config",
139 ".npm",
140 ".pki",
141 ".terraform.d",
142];
143
144#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
146pub struct SensitivePath {
147 path: String,
149 #[serde(default = "default_true")]
151 pub(crate) block_read: bool,
152 #[serde(default = "default_true")]
154 pub(crate) block_write: bool,
155}
156
157fn default_true() -> bool {
158 true
159}
160
161impl SensitivePath {
162 #[must_use]
164 pub fn new(path: impl Into<String>) -> Self {
165 Self {
166 path: path.into(),
167 block_read: true,
168 block_write: true,
169 }
170 }
171
172 #[must_use]
174 fn write_only(path: impl Into<String>) -> Self {
175 Self {
176 path: path.into(),
177 block_read: false,
178 block_write: true,
179 }
180 }
181
182 pub fn expand_path(&self) -> PathBuf {
184 if self.path.starts_with("~/")
185 && let Some(home) = dirs::home_dir()
186 {
187 return home.join(self.path.get(2..).unwrap_or_default());
188 } else if self.path == "~"
189 && let Some(home) = dirs::home_dir()
190 {
191 return home;
192 }
193 PathBuf::from(&self.path)
194 }
195
196 fn matches(&self, path: &Path) -> bool {
198 let expanded = self.expand_path();
199 #[cfg(windows)]
200 {
201 let path_norm = normalize_windows_path(path);
202 let expanded_norm = normalize_windows_path(&expanded);
203 let mut expanded_prefix = expanded_norm.clone();
204 if !expanded_prefix.ends_with('/') {
205 expanded_prefix.push('/');
206 }
207 path_norm == expanded_norm || path_norm.starts_with(&expanded_prefix)
208 }
209 #[cfg(not(windows))]
210 path.starts_with(&expanded)
211 }
212}
213
214#[cfg(windows)]
215fn normalize_windows_path(path: &Path) -> String {
216 path.to_string_lossy().replace('\\', "/").to_ascii_lowercase()
217}
218
219pub fn default_sensitive_paths() -> Vec<SensitivePath> {
221 match vtcode_sensitive_paths(&[]) {
222 Ok(paths) => paths,
223 Err(error) => {
224 tracing::warn!(%error, "VT Code path resolution failed; blocking absolute paths fail-closed");
225 let mut paths: Vec<SensitivePath> =
226 DEFAULT_SENSITIVE_PATHS.iter().map(|p| SensitivePath::new(*p)).collect();
227 paths.push(SensitivePath::new("/"));
228 paths
229 }
230 }
231}
232
233fn vtcode_sensitive_paths(environment: &[(&str, &str)]) -> anyhow::Result<Vec<SensitivePath>> {
234 let resolved = if environment.is_empty() {
235 VtCodePaths::resolve()?
236 } else {
237 VtCodePaths::from_environment(environment)?
238 };
239 let mut paths: Vec<SensitivePath> = DEFAULT_SENSITIVE_PATHS.iter().map(|p| SensitivePath::new(*p)).collect();
240 let resolved_roots = [
241 resolved.config_dir().to_path_buf(),
242 resolved.auth_dir(),
243 resolved.data_dir().to_path_buf(),
244 resolved.state_dir().to_path_buf(),
245 resolved.cache_dir().to_path_buf(),
246 resolved.runtime_dir().to_path_buf(),
247 resolved.executable_dir().to_path_buf(),
248 resolved.legacy_dir().to_path_buf(),
249 ];
250 for root in resolved_roots {
251 let path = root.display().to_string();
252 if !paths.iter().any(|existing| existing.path == path) {
253 paths.push(SensitivePath::new(path));
254 }
255 }
256
257 #[cfg(windows)]
258 {
259 for entry in USERPROFILE_READ_ROOT_EXCLUSIONS {
260 let path = format!("~/{}", entry);
261 if !paths.iter().any(|existing| existing.path == path) {
262 paths.push(SensitivePath::new(path));
263 }
264 }
265 Ok(paths)
266 }
267
268 #[cfg(not(windows))]
269 Ok(paths)
270}
271
272const PROTECTED_WRITABLE_ROOT_DIR_NAMES: &[&str] = &[".git", ".vtcode", ".codex", ".agents"];
273
274fn protected_writable_root_sensitive_paths(writable_roots: &[WritableRoot]) -> Vec<SensitivePath> {
275 let mut paths = Vec::new();
276
277 for root in writable_roots {
278 for dir_name in PROTECTED_WRITABLE_ROOT_DIR_NAMES {
279 let protected_path = root.root.join(dir_name).display().to_string();
280 if !paths.iter().any(|existing: &SensitivePath| {
281 existing.path == protected_path && !existing.block_read && existing.block_write
282 }) {
283 paths.push(SensitivePath::write_only(protected_path));
284 }
285 }
286 }
287
288 paths
289}
290
291#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
296pub struct ResourceLimits {
297 #[serde(default)]
299 pub max_memory_mb: u64,
300
301 #[serde(default)]
304 pub max_pids: u32,
305
306 #[serde(default)]
308 pub max_disk_mb: u64,
309
310 #[serde(default)]
312 pub cpu_time_secs: u64,
313
314 #[serde(default)]
316 pub timeout_secs: u64,
317}
318
319impl Default for ResourceLimits {
320 fn default() -> Self {
321 Self {
322 max_memory_mb: 0, max_pids: 0, max_disk_mb: 0, cpu_time_secs: 0, timeout_secs: 300, }
328 }
329}
330
331impl ResourceLimits {
332 #[must_use]
334 pub fn unlimited() -> Self {
335 Self {
336 max_memory_mb: 0,
337 max_pids: 0,
338 max_disk_mb: 0,
339 cpu_time_secs: 0,
340 timeout_secs: 0,
341 }
342 }
343
344 #[must_use]
347 pub fn conservative() -> Self {
348 Self {
349 max_memory_mb: 512,
350 max_pids: 64,
351 max_disk_mb: 1024,
352 cpu_time_secs: 60,
353 timeout_secs: 120,
354 }
355 }
356
357 #[must_use]
359 pub fn moderate() -> Self {
360 Self {
361 max_memory_mb: 2048,
362 max_pids: 256,
363 max_disk_mb: 4096,
364 cpu_time_secs: 300,
365 timeout_secs: 600,
366 }
367 }
368
369 #[must_use]
371 pub fn generous() -> Self {
372 Self {
373 max_memory_mb: 8192,
374 max_pids: 1024,
375 max_disk_mb: 16384,
376 cpu_time_secs: 0,
377 timeout_secs: 3600,
378 }
379 }
380
381 #[must_use]
383 fn with_memory_mb(mut self, mb: u64) -> Self {
384 self.max_memory_mb = mb;
385 self
386 }
387
388 #[must_use]
390 fn with_max_pids(mut self, pids: u32) -> Self {
391 self.max_pids = pids;
392 self
393 }
394
395 #[must_use]
397 pub fn with_disk_mb(mut self, mb: u64) -> Self {
398 self.max_disk_mb = mb;
399 self
400 }
401
402 #[must_use]
404 pub fn with_cpu_time_secs(mut self, secs: u64) -> Self {
405 self.cpu_time_secs = secs;
406 self
407 }
408
409 #[must_use]
411 fn with_timeout_secs(mut self, secs: u64) -> Self {
412 self.timeout_secs = secs;
413 self
414 }
415
416 #[inline]
418 #[must_use]
419 fn has_limits(&self) -> bool {
420 self.max_memory_mb > 0
421 || self.max_pids > 0
422 || self.max_disk_mb > 0
423 || self.cpu_time_secs > 0
424 || self.timeout_secs > 0
425 }
426
427 #[inline]
429 #[must_use]
430 fn effective_timeout_secs(&self) -> u64 {
431 if self.timeout_secs > 0 { self.timeout_secs } else { 300 }
432 }
433}
434
435pub const BLOCKED_SYSCALLS: &[&str] = &[
440 "ptrace",
442 "mount",
444 "umount",
445 "umount2",
446 "init_module",
448 "finit_module",
449 "delete_module",
450 "kexec_load",
452 "kexec_file_load",
453 "bpf",
455 "perf_event_open",
457 "userfaultfd",
459 "process_vm_readv",
461 "process_vm_writev",
462 "reboot",
464 "swapon",
466 "swapoff",
467 "settimeofday",
469 "clock_settime",
470 "adjtimex",
471 "add_key",
473 "request_key",
474 "keyctl",
475 "ioperm",
477 "iopl",
478 "iopl",
480 "acct",
482 "quotactl",
484 "unshare",
486 "setns",
487 "personality",
489];
490
491pub const FILTERED_SYSCALLS: &[&str] = &[
493 "clone", "clone3", "ioctl", "prctl", "socket",
498];
499
500#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
504pub struct SeccompProfile {
505 #[serde(default = "default_blocked_syscalls")]
507 blocked_syscalls: Vec<String>,
508
509 #[serde(default)]
511 allow_namespaces: bool,
512
513 #[serde(default)]
515 allow_network_sockets: bool,
516
517 #[serde(default)]
519 log_only: bool,
520}
521
522fn default_blocked_syscalls() -> Vec<String> {
523 BLOCKED_SYSCALLS.iter().map(|s| s.to_string()).collect()
524}
525
526impl Default for SeccompProfile {
527 fn default() -> Self {
528 Self {
529 blocked_syscalls: default_blocked_syscalls(),
530 allow_namespaces: false,
531 allow_network_sockets: false,
532 log_only: false,
533 }
534 }
535}
536
537impl SeccompProfile {
538 #[must_use]
540 pub fn strict() -> Self {
541 Self {
542 blocked_syscalls: default_blocked_syscalls(),
543 allow_namespaces: false,
544 allow_network_sockets: false,
545 log_only: false,
546 }
547 }
548
549 #[must_use]
551 pub fn permissive() -> Self {
552 Self {
553 blocked_syscalls: vec![
554 "ptrace".to_string(),
555 "kexec_load".to_string(),
556 "kexec_file_load".to_string(),
557 "reboot".to_string(),
558 ],
559 allow_namespaces: false,
560 allow_network_sockets: true,
561 log_only: false,
562 }
563 }
564
565 #[must_use]
567 pub fn logging() -> Self {
568 Self {
569 blocked_syscalls: default_blocked_syscalls(),
570 allow_namespaces: false,
571 allow_network_sockets: false,
572 log_only: true,
573 }
574 }
575
576 #[must_use]
578 pub fn block_syscall(mut self, syscall: impl Into<String>) -> Self {
579 let syscall = syscall.into();
580 if !self.blocked_syscalls.contains(&syscall) {
581 self.blocked_syscalls.push(syscall);
582 }
583 self
584 }
585
586 #[must_use]
588 pub fn with_network(mut self) -> Self {
589 self.allow_network_sockets = true;
590 self
591 }
592
593 #[must_use]
595 pub fn with_logging(mut self) -> Self {
596 self.log_only = true;
597 self
598 }
599
600 #[inline]
602 #[must_use]
603 fn is_blocked(&self, syscall: &str) -> bool {
604 self.blocked_syscalls.iter().any(|s| s == syscall)
605 }
606
607 pub(crate) fn to_json(&self) -> Result<String, serde_json::Error> {
609 serde_json::to_string(self)
610 }
611}
612
613#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
625#[serde(tag = "type", rename_all = "snake_case")]
626pub enum SandboxPolicy {
627 ReadOnly {
629 #[serde(default)]
631 network_access: bool,
632
633 #[serde(default)]
635 network_allowlist: Vec<NetworkAllowlistEntry>,
636 },
637
638 WorkspaceWrite {
640 writable_roots: Vec<WritableRoot>,
642
643 #[serde(default)]
645 network_access: bool,
646
647 #[serde(default)]
651 network_allowlist: Vec<NetworkAllowlistEntry>,
652
653 #[serde(default)]
657 sensitive_paths: Option<Vec<SensitivePath>>,
658
659 #[serde(default)]
662 resource_limits: ResourceLimits,
663
664 #[serde(default)]
667 seccomp_profile: SeccompProfile,
668
669 #[serde(default)]
671 exclude_tmpdir_env_var: bool,
672
673 #[serde(default)]
675 exclude_slash_tmp: bool,
676 },
677
678 DangerFullAccess,
681
682 ExternalSandbox {
684 description: String,
686 },
687}
688
689impl SandboxPolicy {
690 #[must_use]
692 pub fn read_only() -> Self {
693 Self::ReadOnly {
694 network_access: false,
695 network_allowlist: Vec::new(),
696 }
697 }
698
699 #[must_use]
701 pub fn new_read_only_policy() -> Self {
702 Self::read_only()
703 }
704
705 #[must_use]
707 pub fn read_only_with_network(network_allowlist: Vec<NetworkAllowlistEntry>) -> Self {
708 Self::ReadOnly {
709 network_access: !network_allowlist.is_empty(),
710 network_allowlist,
711 }
712 }
713
714 #[must_use]
716 pub fn read_only_with_full_network() -> Self {
717 Self::ReadOnly {
718 network_access: true,
719 network_allowlist: Vec::new(),
720 }
721 }
722
723 #[must_use]
726 pub fn workspace_write(writable_roots: Vec<PathBuf>) -> Self {
727 Self::WorkspaceWrite {
728 writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
729 network_access: false,
730 network_allowlist: Vec::new(),
731 sensitive_paths: None,
732 resource_limits: ResourceLimits::default(),
733 seccomp_profile: SeccompProfile::strict(),
734 exclude_tmpdir_env_var: true,
735 exclude_slash_tmp: true,
736 }
737 }
738
739 #[must_use]
741 fn workspace_write_with_network(
742 writable_roots: Vec<PathBuf>,
743 network_allowlist: Vec<NetworkAllowlistEntry>,
744 ) -> Self {
745 Self::WorkspaceWrite {
746 writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
747 network_access: !network_allowlist.is_empty(),
748 network_allowlist,
749 sensitive_paths: None,
750 resource_limits: ResourceLimits::default(),
751 seccomp_profile: SeccompProfile::strict().with_network(),
752 exclude_tmpdir_env_var: true,
753 exclude_slash_tmp: true,
754 }
755 }
756
757 #[must_use]
759 pub fn workspace_write_with_sensitive_paths(
760 writable_roots: Vec<PathBuf>,
761 sensitive_paths: Vec<SensitivePath>,
762 ) -> Self {
763 Self::WorkspaceWrite {
764 writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
765 network_access: false,
766 network_allowlist: Vec::new(),
767 sensitive_paths: Some(sensitive_paths),
768 resource_limits: ResourceLimits::default(),
769 seccomp_profile: SeccompProfile::strict(),
770 exclude_tmpdir_env_var: true,
771 exclude_slash_tmp: true,
772 }
773 }
774
775 #[must_use]
777 fn workspace_write_no_sensitive_blocking(writable_roots: Vec<PathBuf>) -> Self {
778 Self::WorkspaceWrite {
779 writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
780 network_access: false,
781 network_allowlist: Vec::new(),
782 sensitive_paths: Some(Vec::new()),
783 resource_limits: ResourceLimits::default(),
784 seccomp_profile: SeccompProfile::strict(),
785 exclude_tmpdir_env_var: true,
786 exclude_slash_tmp: true,
787 }
788 }
789
790 #[must_use]
793 fn workspace_write_with_limits(writable_roots: Vec<PathBuf>, resource_limits: ResourceLimits) -> Self {
794 Self::WorkspaceWrite {
795 writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
796 network_access: false,
797 network_allowlist: Vec::new(),
798 sensitive_paths: None,
799 resource_limits,
800 seccomp_profile: SeccompProfile::strict(),
801 exclude_tmpdir_env_var: true,
802 exclude_slash_tmp: true,
803 }
804 }
805
806 #[must_use]
808 pub fn workspace_write_full(
809 writable_roots: Vec<PathBuf>,
810 network_allowlist: Vec<NetworkAllowlistEntry>,
811 sensitive_paths: Option<Vec<SensitivePath>>,
812 resource_limits: ResourceLimits,
813 seccomp_profile: SeccompProfile,
814 ) -> Self {
815 Self::WorkspaceWrite {
816 writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
817 network_access: !network_allowlist.is_empty(),
818 network_allowlist,
819 sensitive_paths,
820 resource_limits,
821 seccomp_profile,
822 exclude_tmpdir_env_var: true,
823 exclude_slash_tmp: true,
824 }
825 }
826
827 #[must_use]
829 pub fn full_access() -> Self {
830 Self::DangerFullAccess
831 }
832
833 #[inline]
835 #[must_use]
836 pub fn has_full_network_access(&self) -> bool {
837 match self {
838 Self::ReadOnly { network_access, network_allowlist }
839 | Self::WorkspaceWrite { network_access, network_allowlist, .. } => {
840 *network_access && network_allowlist.is_empty()
841 }
842 Self::DangerFullAccess | Self::ExternalSandbox { .. } => true,
843 }
844 }
845
846 #[inline]
848 #[must_use]
849 pub fn has_network_allowlist(&self) -> bool {
850 match self {
851 Self::ReadOnly { network_allowlist, .. } | Self::WorkspaceWrite { network_allowlist, .. } => {
852 !network_allowlist.is_empty()
853 }
854 _ => false,
855 }
856 }
857
858 #[inline]
860 #[must_use]
861 pub fn network_allowlist(&self) -> &[NetworkAllowlistEntry] {
862 match self {
863 Self::ReadOnly { network_allowlist, .. } | Self::WorkspaceWrite { network_allowlist, .. } => {
864 network_allowlist
865 }
866 _ => &[],
867 }
868 }
869
870 #[inline]
872 #[must_use]
873 pub fn is_network_allowed(&self, domain: &str, port: u16) -> bool {
874 match self {
875 Self::ReadOnly { network_access, network_allowlist }
876 | Self::WorkspaceWrite { network_access, network_allowlist, .. } => {
877 if network_allowlist.is_empty() {
878 *network_access
879 } else {
880 network_allowlist.iter().any(|entry| entry.matches(domain, port))
881 }
882 }
883 Self::DangerFullAccess | Self::ExternalSandbox { .. } => true,
884 }
885 }
886
887 #[must_use]
890 fn sensitive_paths(&self) -> Vec<SensitivePath> {
891 match self {
892 Self::ReadOnly { .. } => default_sensitive_paths(),
893 Self::WorkspaceWrite { sensitive_paths, .. } => {
894 sensitive_paths.clone().unwrap_or_else(default_sensitive_paths)
895 }
896 Self::DangerFullAccess | Self::ExternalSandbox { .. } => Vec::new(),
897 }
898 }
899
900 #[must_use]
902 pub(crate) fn sensitive_paths_for_execution(&self, cwd: &Path) -> Vec<SensitivePath> {
903 match self {
904 Self::WorkspaceWrite { .. } => {
905 let mut sensitive_paths = self.sensitive_paths();
906 sensitive_paths.extend(protected_writable_root_sensitive_paths(&self.get_writable_roots_with_cwd(cwd)));
907 sensitive_paths
908 }
909 _ => self.sensitive_paths(),
910 }
911 }
912
913 #[inline]
915 #[must_use]
916 fn is_sensitive_path(&self, path: &Path) -> bool {
917 self.sensitive_paths().iter().any(|sp| sp.matches(path) && sp.block_read)
918 }
919
920 #[inline]
922 #[must_use]
923 fn is_path_write_blocked(&self, path: &Path, cwd: &Path) -> bool {
924 match self {
925 Self::DangerFullAccess | Self::ExternalSandbox { .. } => false,
926 _ => self
927 .sensitive_paths_for_execution(cwd)
928 .iter()
929 .any(|sp| sp.matches(path) && sp.block_write),
930 }
931 }
932
933 #[inline]
935 #[must_use]
936 pub fn is_path_readable(&self, path: &Path) -> bool {
937 match self {
938 Self::DangerFullAccess | Self::ExternalSandbox { .. } => true,
939 _ => !self.is_sensitive_path(path),
940 }
941 }
942
943 #[must_use]
945 pub fn resource_limits(&self) -> ResourceLimits {
946 match self {
947 Self::ReadOnly { .. } => ResourceLimits::conservative(),
948 Self::WorkspaceWrite { resource_limits, .. } => resource_limits.clone(),
949 Self::DangerFullAccess | Self::ExternalSandbox { .. } => ResourceLimits::unlimited(),
950 }
951 }
952
953 #[must_use]
955 pub(crate) fn seccomp_profile(&self) -> SeccompProfile {
956 match self {
957 Self::ReadOnly { network_access, network_allowlist } => {
958 let mut profile = SeccompProfile::strict();
959 if *network_access || !network_allowlist.is_empty() {
960 profile = profile.with_network();
961 }
962 profile
963 }
964 Self::WorkspaceWrite { seccomp_profile, .. } => seccomp_profile.clone(),
965 Self::DangerFullAccess | Self::ExternalSandbox { .. } => SeccompProfile::permissive(),
966 }
967 }
968
969 #[inline]
971 #[must_use]
972 fn has_full_disk_write_access(&self) -> bool {
973 matches!(self, Self::DangerFullAccess | Self::ExternalSandbox { .. })
974 }
975
976 #[inline]
978 #[must_use]
979 fn has_full_disk_read_access(&self) -> bool {
980 true
981 }
982
983 #[must_use]
985 pub(crate) fn get_writable_roots_with_cwd(&self, cwd: &Path) -> Vec<WritableRoot> {
986 match self {
987 Self::ReadOnly { .. } => vec![],
988 Self::WorkspaceWrite { writable_roots, .. } => {
989 let mut roots = writable_roots.clone();
990 let cwd_root = WritableRoot::new(cwd);
991 if !roots.contains(&cwd_root) {
992 roots.push(cwd_root);
993 }
994 roots
995 }
996 Self::DangerFullAccess | Self::ExternalSandbox { .. } => {
997 vec![WritableRoot::new(cwd)]
998 }
999 }
1000 }
1001
1002 #[inline]
1004 #[must_use]
1005 pub fn is_path_writable(&self, path: &Path, cwd: &Path) -> bool {
1006 match self {
1007 Self::ReadOnly { .. } => false,
1008 Self::WorkspaceWrite { .. } => {
1009 let writable = self.get_writable_roots_with_cwd(cwd);
1010 writable.iter().any(|root| path.starts_with(&root.root)) && !self.is_path_write_blocked(path, cwd)
1011 }
1012 Self::DangerFullAccess | Self::ExternalSandbox { .. } => true,
1013 }
1014 }
1015
1016 fn can_set(&self, new_policy: &SandboxPolicy) -> anyhow::Result<()> {
1019 use SandboxPolicy::*;
1020
1021 match (self, new_policy) {
1022 (DangerFullAccess, _) => Ok(()),
1024 (ReadOnly { .. }, WorkspaceWrite { .. } | DangerFullAccess) => {
1026 Err(anyhow::anyhow!("cannot escalate from read-only to write-capable policy"))
1027 }
1028 _ => Ok(()),
1030 }
1031 }
1032
1033 pub fn description(&self) -> &'static str {
1035 match self {
1036 Self::ReadOnly { .. } => "read-only access",
1037 Self::WorkspaceWrite { .. } => "workspace write access",
1038 Self::DangerFullAccess => "full access (dangerous)",
1039 Self::ExternalSandbox { .. } => "external sandbox",
1040 }
1041 }
1042}
1043
1044impl Default for SandboxPolicy {
1045 fn default() -> Self {
1046 Self::read_only()
1047 }
1048}
1049
1050#[cfg(test)]
1051mod tests {
1052 use super::*;
1053
1054 #[test]
1055 fn test_read_only_policy() {
1056 let policy = SandboxPolicy::read_only();
1057 assert!(!policy.has_full_network_access());
1058 assert!(!policy.has_network_allowlist());
1059 assert!(!policy.has_full_disk_write_access());
1060 assert!(policy.has_full_disk_read_access());
1061 }
1062
1063 #[test]
1064 fn test_read_only_with_network_allowlist() {
1065 let policy = SandboxPolicy::read_only_with_network(vec![
1066 NetworkAllowlistEntry::https("api.github.com"),
1067 NetworkAllowlistEntry::with_port("registry.npmjs.org", 443),
1068 ]);
1069
1070 assert!(!policy.has_full_network_access());
1071 assert!(policy.has_network_allowlist());
1072 assert!(policy.is_network_allowed("api.github.com", 443));
1073 assert!(policy.is_network_allowed("registry.npmjs.org", 443));
1074 assert!(!policy.is_network_allowed("example.com", 443));
1075 }
1076
1077 #[test]
1078 fn test_read_only_with_full_network_access() {
1079 let policy = SandboxPolicy::read_only_with_full_network();
1080
1081 assert!(policy.has_full_network_access());
1082 assert!(policy.is_network_allowed("example.com", 443));
1083 assert!(policy.seccomp_profile().allow_network_sockets);
1084 }
1085
1086 #[test]
1087 fn test_read_only_deserializes_legacy_shape() {
1088 let policy: SandboxPolicy = serde_json::from_str(r#"{"type":"read_only"}"#).expect("legacy read-only policy");
1089
1090 assert_eq!(policy, SandboxPolicy::read_only());
1091 }
1092
1093 #[test]
1094 fn test_workspace_write_policy() {
1095 let policy = SandboxPolicy::workspace_write(vec![PathBuf::from("/tmp/workspace")]);
1096 assert!(!policy.has_full_network_access());
1097 assert!(!policy.has_full_disk_write_access());
1098
1099 let cwd = PathBuf::from("/tmp/workspace");
1100 assert!(policy.is_path_writable(&cwd, &cwd));
1101 assert!(!policy.is_path_writable(&PathBuf::from("/etc"), &cwd));
1102 }
1103
1104 #[test]
1105 fn test_workspace_write_protects_internal_metadata_dirs() {
1106 let cwd = PathBuf::from("/tmp/workspace");
1107 let policy = SandboxPolicy::workspace_write(vec![cwd.clone()]);
1108
1109 assert!(!policy.is_path_writable(&cwd.join(".git/config"), &cwd));
1110 assert!(!policy.is_path_writable(&cwd.join(".vtcode/cache"), &cwd));
1111 assert!(!policy.is_path_writable(&cwd.join(".codex/state"), &cwd));
1112 assert!(!policy.is_path_writable(&cwd.join(".agents/skills"), &cwd));
1113 assert!(policy.is_path_writable(&cwd.join("src/main.rs"), &cwd));
1114 }
1115
1116 #[test]
1117 fn test_full_access_policy() {
1118 let policy = SandboxPolicy::full_access();
1119 assert!(policy.has_full_network_access());
1120 assert!(policy.has_full_disk_write_access());
1121 }
1122
1123 #[test]
1124 fn test_policy_escalation() {
1125 let read_only = SandboxPolicy::read_only();
1126 let full = SandboxPolicy::full_access();
1127
1128 assert!(read_only.can_set(&full).is_err());
1130
1131 full.can_set(&read_only).unwrap();
1133 }
1134
1135 #[test]
1136 fn test_network_allowlist_entry_matching() {
1137 let entry = NetworkAllowlistEntry::https("api.github.com");
1138 assert!(entry.matches("api.github.com", 443));
1139 assert!(!entry.matches("api.github.com", 80));
1140 assert!(!entry.matches("github.com", 443));
1141 }
1142
1143 #[test]
1144 fn test_network_allowlist_wildcard() {
1145 let entry = NetworkAllowlistEntry::https("*.npmjs.org");
1146 assert!(entry.matches("registry.npmjs.org", 443));
1147 assert!(entry.matches("npmjs.org", 443));
1148 assert!(!entry.matches("npmjs.org.evil.com", 443));
1149 }
1150
1151 #[test]
1152 fn test_workspace_with_network_allowlist() {
1153 let allowlist = vec![
1154 NetworkAllowlistEntry::https("api.github.com"),
1155 NetworkAllowlistEntry::https("*.npmjs.org"),
1156 ];
1157 let policy = SandboxPolicy::workspace_write_with_network(vec![PathBuf::from("/tmp/workspace")], allowlist);
1158
1159 assert!(!policy.has_full_network_access());
1161 assert!(policy.has_network_allowlist());
1162
1163 assert!(policy.is_network_allowed("api.github.com", 443));
1165 assert!(policy.is_network_allowed("registry.npmjs.org", 443));
1166 assert!(!policy.is_network_allowed("evil.com", 443));
1167 assert!(!policy.is_network_allowed("api.github.com", 80));
1168 }
1169
1170 #[test]
1171 fn test_workspace_no_network() {
1172 let policy = SandboxPolicy::workspace_write(vec![PathBuf::from("/tmp/workspace")]);
1173
1174 assert!(!policy.has_full_network_access());
1175 assert!(!policy.has_network_allowlist());
1176 assert!(!policy.is_network_allowed("api.github.com", 443));
1177 }
1178
1179 #[test]
1180 fn test_sensitive_path_expansion() {
1181 let sp = SensitivePath::new("~/.ssh");
1182 let expanded = sp.expand_path();
1183 assert!(expanded.to_string_lossy().contains(".ssh"));
1185 assert!(!expanded.to_string_lossy().starts_with('~'));
1186 }
1187
1188 #[test]
1189 fn test_sensitive_path_matching() {
1190 let sp = SensitivePath::new("~/.ssh");
1191 let expanded = sp.expand_path();
1192 let ssh_key = expanded.join("id_rsa");
1193 assert!(sp.matches(&ssh_key));
1194 assert!(sp.matches(&expanded));
1195 }
1196
1197 #[test]
1198 fn test_default_sensitive_paths() {
1199 let paths = default_sensitive_paths();
1200 assert!(!paths.is_empty());
1201 let path_strings: Vec<&str> = paths.iter().map(|p| p.path.as_str()).collect();
1203 assert!(path_strings.contains(&"~/.ssh"));
1204 assert!(path_strings.contains(&"~/.aws"));
1205 assert!(path_strings.contains(&"~/.kube"));
1206 }
1207
1208 #[test]
1209 fn resolved_vtcode_roots_are_sensitive_without_duplicate_entries() {
1210 let environment = [
1211 ("HOME", "/home/tester"),
1212 ("VTCODE_CONFIG", "/vtcode/shared"),
1213 ("VTCODE_DATA", "/vtcode/shared"),
1214 ("XDG_STATE_HOME", "/xdg/state"),
1215 ("XDG_CACHE_HOME", "/xdg/cache"),
1216 ("XDG_RUNTIME_DIR", "/xdg/runtime"),
1217 ("XDG_BIN_HOME", "/xdg/bin"),
1218 ("VTCODE_HOME", "/legacy/vtcode"),
1219 ];
1220 let resolved =
1221 VtCodePaths::from_environment(&environment).expect("explicit absolute VT Code paths should resolve");
1222 let paths = vtcode_sensitive_paths(&environment).expect("explicit absolute VT Code paths should resolve");
1223 let path_strings: Vec<&str> = paths.iter().map(|path| path.path.as_str()).collect();
1224
1225 for expected in [
1226 resolved.config_dir().to_path_buf(),
1227 resolved.auth_dir(),
1228 resolved.data_dir().to_path_buf(),
1229 resolved.state_dir().to_path_buf(),
1230 resolved.cache_dir().to_path_buf(),
1231 resolved.runtime_dir().to_path_buf(),
1232 resolved.executable_dir().to_path_buf(),
1233 resolved.legacy_dir().to_path_buf(),
1234 ] {
1235 let expected = expected.display().to_string();
1236 assert!(path_strings.contains(&expected.as_str()), "missing sensitive root: {expected}");
1237 }
1238 assert_eq!(path_strings.iter().filter(|path| **path == "/vtcode/shared").count(), 1);
1239 }
1240
1241 #[test]
1242 fn invalid_vtcode_path_resolution_is_rejected_before_policy_construction() {
1243 let error = vtcode_sensitive_paths(&[("HOME", "/home/tester"), ("VTCODE_CONFIG", "relative/config")])
1244 .expect_err("relative VT Code config paths must fail closed");
1245 assert!(error.to_string().contains("VTCODE_CONFIG"));
1246 }
1247
1248 #[cfg(windows)]
1249 #[test]
1250 fn test_windows_userprofile_root_exclusions_are_in_defaults() {
1251 let paths = default_sensitive_paths();
1252 let path_strings: Vec<&str> = paths.iter().map(|p| p.path.as_str()).collect();
1253
1254 for entry in USERPROFILE_READ_ROOT_EXCLUSIONS {
1255 let expected = format!("~/{}", entry);
1256 assert!(path_strings.contains(&expected.as_str()), "missing expected default sensitive path: {expected}");
1257 }
1258 }
1259
1260 #[cfg(windows)]
1261 #[test]
1262 fn test_sensitive_path_matching_is_case_insensitive_on_windows() {
1263 let sp = SensitivePath::new("~/.aws");
1264 let home = dirs::home_dir().expect("home dir");
1265 let mixed_case_candidate = home.join(".AWS").join("credentials");
1266
1267 assert!(sp.matches(&mixed_case_candidate));
1268 }
1269
1270 #[test]
1271 fn test_workspace_blocks_sensitive_by_default() {
1272 let policy = SandboxPolicy::workspace_write(vec![PathBuf::from("/tmp/workspace")]);
1273 let sensitive = policy.sensitive_paths();
1274 assert!(!sensitive.is_empty());
1275
1276 if let Some(home) = dirs::home_dir() {
1278 let ssh_path = home.join(".ssh").join("id_rsa");
1279 assert!(policy.is_sensitive_path(&ssh_path));
1280 assert!(!policy.is_path_readable(&ssh_path));
1281 }
1282 }
1283
1284 #[test]
1285 fn test_workspace_no_sensitive_blocking() {
1286 let policy = SandboxPolicy::workspace_write_no_sensitive_blocking(vec![PathBuf::from("/tmp")]);
1287 let sensitive = policy.sensitive_paths();
1288 assert!(sensitive.is_empty());
1289
1290 if let Some(home) = dirs::home_dir() {
1292 let ssh_path = home.join(".ssh").join("id_rsa");
1293 assert!(!policy.is_sensitive_path(&ssh_path));
1294 assert!(policy.is_path_readable(&ssh_path));
1295 }
1296 }
1297
1298 #[test]
1299 fn test_full_access_no_sensitive_blocking() {
1300 let policy = SandboxPolicy::full_access();
1301 let sensitive = policy.sensitive_paths();
1302 assert!(sensitive.is_empty());
1303
1304 if let Some(home) = dirs::home_dir() {
1306 let ssh_path = home.join(".ssh").join("id_rsa");
1307 assert!(policy.is_path_readable(&ssh_path));
1308 }
1309 }
1310
1311 #[test]
1312 fn test_resource_limits_default() {
1313 let limits = ResourceLimits::default();
1314 assert_eq!(limits.max_memory_mb, 0);
1315 assert_eq!(limits.max_pids, 0);
1316 assert_eq!(limits.timeout_secs, 300);
1317 assert!(limits.has_limits());
1318 }
1319
1320 #[test]
1321 fn test_resource_limits_conservative() {
1322 let limits = ResourceLimits::conservative();
1323 assert_eq!(limits.max_memory_mb, 512);
1324 assert_eq!(limits.max_pids, 64);
1325 assert_eq!(limits.cpu_time_secs, 60);
1326 assert!(limits.has_limits());
1327 }
1328
1329 #[test]
1330 fn test_resource_limits_builder() {
1331 let limits = ResourceLimits::default()
1332 .with_memory_mb(1024)
1333 .with_max_pids(128)
1334 .with_timeout_secs(60);
1335 assert_eq!(limits.max_memory_mb, 1024);
1336 assert_eq!(limits.max_pids, 128);
1337 assert_eq!(limits.effective_timeout_secs(), 60);
1338 }
1339
1340 #[test]
1341 fn test_workspace_with_limits() {
1342 let limits = ResourceLimits::conservative();
1343 let policy = SandboxPolicy::workspace_write_with_limits(vec![PathBuf::from("/tmp/workspace")], limits.clone());
1344
1345 let policy_limits = policy.resource_limits();
1346 assert_eq!(policy_limits.max_memory_mb, limits.max_memory_mb);
1347 assert_eq!(policy_limits.max_pids, limits.max_pids);
1348 }
1349
1350 #[test]
1351 fn test_read_only_conservative_limits() {
1352 let policy = SandboxPolicy::read_only();
1353 let limits = policy.resource_limits();
1354 assert!(limits.has_limits());
1356 assert_eq!(limits.max_memory_mb, 512);
1357 }
1358
1359 #[test]
1360 fn test_full_access_unlimited() {
1361 let policy = SandboxPolicy::full_access();
1362 let limits = policy.resource_limits();
1363 assert!(!limits.has_limits());
1365 }
1366
1367 #[test]
1368 fn test_seccomp_profile_strict() {
1369 let profile = SeccompProfile::strict();
1370 assert!(profile.is_blocked("ptrace"));
1371 assert!(profile.is_blocked("mount"));
1372 assert!(profile.is_blocked("kexec_load"));
1373 assert!(profile.is_blocked("bpf"));
1374 assert!(!profile.allow_network_sockets);
1375 assert!(!profile.allow_namespaces);
1376 }
1377
1378 #[test]
1379 fn test_seccomp_profile_permissive() {
1380 let profile = SeccompProfile::permissive();
1381 assert!(profile.is_blocked("ptrace"));
1383 assert!(profile.is_blocked("kexec_load"));
1384 assert!(profile.allow_network_sockets);
1386 }
1387
1388 #[test]
1389 fn test_seccomp_profile_builder() {
1390 let profile = SeccompProfile::strict().with_network().block_syscall("custom_syscall");
1391 assert!(profile.allow_network_sockets);
1392 assert!(profile.is_blocked("custom_syscall"));
1393 }
1394
1395 #[test]
1396 fn test_workspace_seccomp_profile() {
1397 let policy = SandboxPolicy::workspace_write(vec![PathBuf::from("/tmp")]);
1398 let profile = policy.seccomp_profile();
1399 assert!(profile.is_blocked("ptrace"));
1401 assert!(profile.is_blocked("mount"));
1402 }
1403
1404 #[test]
1405 fn test_workspace_with_network_seccomp() {
1406 let policy = SandboxPolicy::workspace_write_with_network(
1407 vec![PathBuf::from("/tmp")],
1408 vec![NetworkAllowlistEntry::https("api.github.com")],
1409 );
1410 let profile = policy.seccomp_profile();
1411 assert!(profile.allow_network_sockets);
1413 }
1414
1415 #[test]
1416 fn test_seccomp_profile_json() {
1417 let profile = SeccompProfile::strict();
1418 let json = profile.to_json().unwrap();
1419 assert!(json.contains("ptrace"));
1420 assert!(json.contains("blocked_syscalls"));
1421 }
1422
1423 #[test]
1424 fn test_blocked_syscalls_constant() {
1425 assert!(BLOCKED_SYSCALLS.contains(&"ptrace"));
1427 assert!(BLOCKED_SYSCALLS.contains(&"mount"));
1428 assert!(BLOCKED_SYSCALLS.contains(&"kexec_load"));
1429 assert!(BLOCKED_SYSCALLS.contains(&"bpf"));
1430 assert!(BLOCKED_SYSCALLS.contains(&"perf_event_open"));
1431 }
1432}