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_case_insensitive(path, &expanded)
211 }
212}
213
214#[cfg(not(windows))]
215fn path_starts_with_case_insensitive(path: &Path, prefix: &Path) -> bool {
216 let mut path_components = path.components();
217 prefix.components().all(|prefix_component| {
218 path_components.next().is_some_and(|path_component| {
219 path_component
220 .as_os_str()
221 .to_string_lossy()
222 .eq_ignore_ascii_case(prefix_component.as_os_str().to_string_lossy().as_ref())
223 })
224 })
225}
226
227#[cfg(windows)]
228fn normalize_windows_path(path: &Path) -> String {
229 path.to_string_lossy().replace('\\', "/").to_ascii_lowercase()
230}
231
232pub fn default_sensitive_paths() -> Vec<SensitivePath> {
234 match vtcode_sensitive_paths(&[]) {
235 Ok(paths) => paths,
236 Err(error) => {
237 tracing::warn!(%error, "VT Code path resolution failed; blocking absolute paths fail-closed");
238 let mut paths: Vec<SensitivePath> =
239 DEFAULT_SENSITIVE_PATHS.iter().map(|p| SensitivePath::new(*p)).collect();
240 paths.push(SensitivePath::new("/"));
241 paths
242 }
243 }
244}
245
246fn vtcode_sensitive_paths(environment: &[(&str, &str)]) -> anyhow::Result<Vec<SensitivePath>> {
247 let resolved = if environment.is_empty() {
248 VtCodePaths::resolve()?
249 } else {
250 VtCodePaths::from_environment(environment)?
251 };
252 let mut paths: Vec<SensitivePath> = DEFAULT_SENSITIVE_PATHS.iter().map(|p| SensitivePath::new(*p)).collect();
253 let resolved_roots = [
254 resolved.config_dir().to_path_buf(),
255 resolved.auth_dir(),
256 resolved.data_dir().to_path_buf(),
257 resolved.state_dir().to_path_buf(),
258 resolved.cache_dir().to_path_buf(),
259 resolved.runtime_dir().to_path_buf(),
260 resolved.executable_dir().to_path_buf(),
261 resolved.legacy_dir().to_path_buf(),
262 ];
263 for root in resolved_roots {
264 let path = root.display().to_string();
265 if !paths.iter().any(|existing| existing.path == path) {
266 paths.push(SensitivePath::new(path));
267 }
268 }
269
270 #[cfg(windows)]
271 {
272 for entry in USERPROFILE_READ_ROOT_EXCLUSIONS {
273 let path = format!("~/{}", entry);
274 if !paths.iter().any(|existing| existing.path == path) {
275 paths.push(SensitivePath::new(path));
276 }
277 }
278 Ok(paths)
279 }
280
281 #[cfg(not(windows))]
282 Ok(paths)
283}
284
285const PROTECTED_WRITABLE_ROOT_DIR_NAMES: &[&str] = &[".git", ".vtcode", ".codex", ".agents"];
286
287fn protected_writable_root_sensitive_paths(writable_roots: &[WritableRoot]) -> Vec<SensitivePath> {
288 let mut paths = Vec::new();
289
290 for root in writable_roots {
291 for dir_name in PROTECTED_WRITABLE_ROOT_DIR_NAMES {
292 let protected_path = root.root.join(dir_name).display().to_string();
293 if !paths.iter().any(|existing: &SensitivePath| {
294 existing.path == protected_path && !existing.block_read && existing.block_write
295 }) {
296 paths.push(SensitivePath::write_only(protected_path));
297 }
298 }
299 }
300
301 paths
302}
303
304#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
309pub struct ResourceLimits {
310 #[serde(default)]
312 pub max_memory_mb: u64,
313
314 #[serde(default)]
317 pub max_pids: u32,
318
319 #[serde(default)]
321 pub max_disk_mb: u64,
322
323 #[serde(default)]
325 pub cpu_time_secs: u64,
326
327 #[serde(default)]
329 pub timeout_secs: u64,
330}
331
332impl Default for ResourceLimits {
333 fn default() -> Self {
334 Self {
335 max_memory_mb: 0, max_pids: 0, max_disk_mb: 0, cpu_time_secs: 0, timeout_secs: 300, }
341 }
342}
343
344impl ResourceLimits {
345 #[must_use]
347 pub fn unlimited() -> Self {
348 Self {
349 max_memory_mb: 0,
350 max_pids: 0,
351 max_disk_mb: 0,
352 cpu_time_secs: 0,
353 timeout_secs: 0,
354 }
355 }
356
357 #[must_use]
360 pub fn conservative() -> Self {
361 Self {
362 max_memory_mb: 512,
363 max_pids: 64,
364 max_disk_mb: 1024,
365 cpu_time_secs: 60,
366 timeout_secs: 120,
367 }
368 }
369
370 #[must_use]
372 pub fn moderate() -> Self {
373 Self {
374 max_memory_mb: 2048,
375 max_pids: 256,
376 max_disk_mb: 4096,
377 cpu_time_secs: 300,
378 timeout_secs: 600,
379 }
380 }
381
382 #[must_use]
384 pub fn generous() -> Self {
385 Self {
386 max_memory_mb: 8192,
387 max_pids: 1024,
388 max_disk_mb: 16384,
389 cpu_time_secs: 0,
390 timeout_secs: 3600,
391 }
392 }
393
394 #[must_use]
396 fn with_memory_mb(mut self, mb: u64) -> Self {
397 self.max_memory_mb = mb;
398 self
399 }
400
401 #[must_use]
403 fn with_max_pids(mut self, pids: u32) -> Self {
404 self.max_pids = pids;
405 self
406 }
407
408 #[must_use]
410 pub fn with_disk_mb(mut self, mb: u64) -> Self {
411 self.max_disk_mb = mb;
412 self
413 }
414
415 #[must_use]
417 pub fn with_cpu_time_secs(mut self, secs: u64) -> Self {
418 self.cpu_time_secs = secs;
419 self
420 }
421
422 #[must_use]
424 fn with_timeout_secs(mut self, secs: u64) -> Self {
425 self.timeout_secs = secs;
426 self
427 }
428
429 #[inline]
431 #[must_use]
432 fn has_limits(&self) -> bool {
433 self.max_memory_mb > 0
434 || self.max_pids > 0
435 || self.max_disk_mb > 0
436 || self.cpu_time_secs > 0
437 || self.timeout_secs > 0
438 }
439
440 #[inline]
442 #[must_use]
443 fn effective_timeout_secs(&self) -> u64 {
444 if self.timeout_secs > 0 { self.timeout_secs } else { 300 }
445 }
446}
447
448pub const BLOCKED_SYSCALLS: &[&str] = &[
453 "ptrace",
455 "mount",
457 "umount",
458 "umount2",
459 "init_module",
461 "finit_module",
462 "delete_module",
463 "kexec_load",
465 "kexec_file_load",
466 "bpf",
468 "perf_event_open",
470 "userfaultfd",
472 "process_vm_readv",
474 "process_vm_writev",
475 "reboot",
477 "swapon",
479 "swapoff",
480 "settimeofday",
482 "clock_settime",
483 "adjtimex",
484 "add_key",
486 "request_key",
487 "keyctl",
488 "ioperm",
490 "iopl",
491 "iopl",
493 "acct",
495 "quotactl",
497 "unshare",
499 "setns",
500 "personality",
502];
503
504pub const FILTERED_SYSCALLS: &[&str] = &[
506 "clone", "clone3", "ioctl", "prctl", "socket",
511];
512
513#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
517pub struct SeccompProfile {
518 #[serde(default = "default_blocked_syscalls")]
520 blocked_syscalls: Vec<String>,
521
522 #[serde(default)]
524 allow_namespaces: bool,
525
526 #[serde(default)]
528 allow_network_sockets: bool,
529
530 #[serde(default)]
532 log_only: bool,
533}
534
535fn default_blocked_syscalls() -> Vec<String> {
536 BLOCKED_SYSCALLS.iter().map(|s| s.to_string()).collect()
537}
538
539impl Default for SeccompProfile {
540 fn default() -> 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
550impl SeccompProfile {
551 #[must_use]
553 pub fn strict() -> Self {
554 Self {
555 blocked_syscalls: default_blocked_syscalls(),
556 allow_namespaces: false,
557 allow_network_sockets: false,
558 log_only: false,
559 }
560 }
561
562 #[must_use]
564 pub fn permissive() -> Self {
565 Self {
566 blocked_syscalls: vec![
567 "ptrace".to_string(),
568 "kexec_load".to_string(),
569 "kexec_file_load".to_string(),
570 "reboot".to_string(),
571 ],
572 allow_namespaces: false,
573 allow_network_sockets: true,
574 log_only: false,
575 }
576 }
577
578 #[must_use]
580 pub fn logging() -> Self {
581 Self {
582 blocked_syscalls: default_blocked_syscalls(),
583 allow_namespaces: false,
584 allow_network_sockets: false,
585 log_only: true,
586 }
587 }
588
589 #[must_use]
591 pub fn block_syscall(mut self, syscall: impl Into<String>) -> Self {
592 let syscall = syscall.into();
593 if !self.blocked_syscalls.contains(&syscall) {
594 self.blocked_syscalls.push(syscall);
595 }
596 self
597 }
598
599 #[must_use]
601 pub fn with_network(mut self) -> Self {
602 self.allow_network_sockets = true;
603 self
604 }
605
606 #[must_use]
608 pub fn with_logging(mut self) -> Self {
609 self.log_only = true;
610 self
611 }
612
613 #[inline]
615 #[must_use]
616 fn is_blocked(&self, syscall: &str) -> bool {
617 self.blocked_syscalls.iter().any(|s| s == syscall)
618 }
619
620 pub(crate) fn to_json(&self) -> Result<String, serde_json::Error> {
622 serde_json::to_string(self)
623 }
624}
625
626#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
638#[serde(tag = "type", rename_all = "snake_case")]
639pub enum SandboxPolicy {
640 ReadOnly {
642 #[serde(default)]
644 network_access: bool,
645
646 #[serde(default)]
648 network_allowlist: Vec<NetworkAllowlistEntry>,
649 },
650
651 WorkspaceWrite {
653 writable_roots: Vec<WritableRoot>,
655
656 #[serde(default)]
658 network_access: bool,
659
660 #[serde(default)]
664 network_allowlist: Vec<NetworkAllowlistEntry>,
665
666 #[serde(default)]
670 sensitive_paths: Option<Vec<SensitivePath>>,
671
672 #[serde(default)]
675 resource_limits: ResourceLimits,
676
677 #[serde(default)]
680 seccomp_profile: SeccompProfile,
681
682 #[serde(default)]
684 exclude_tmpdir_env_var: bool,
685
686 #[serde(default)]
688 exclude_slash_tmp: bool,
689 },
690
691 DangerFullAccess,
694
695 ExternalSandbox {
697 description: String,
699 },
700}
701
702impl SandboxPolicy {
703 #[must_use]
705 pub fn read_only() -> Self {
706 Self::ReadOnly {
707 network_access: false,
708 network_allowlist: Vec::new(),
709 }
710 }
711
712 #[must_use]
714 pub fn new_read_only_policy() -> Self {
715 Self::read_only()
716 }
717
718 #[must_use]
720 pub fn read_only_with_network(network_allowlist: Vec<NetworkAllowlistEntry>) -> Self {
721 Self::ReadOnly {
722 network_access: !network_allowlist.is_empty(),
723 network_allowlist,
724 }
725 }
726
727 #[must_use]
729 pub fn read_only_with_full_network() -> Self {
730 Self::ReadOnly {
731 network_access: true,
732 network_allowlist: Vec::new(),
733 }
734 }
735
736 #[must_use]
739 pub fn workspace_write(writable_roots: Vec<PathBuf>) -> Self {
740 Self::WorkspaceWrite {
741 writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
742 network_access: false,
743 network_allowlist: Vec::new(),
744 sensitive_paths: None,
745 resource_limits: ResourceLimits::default(),
746 seccomp_profile: SeccompProfile::strict(),
747 exclude_tmpdir_env_var: true,
748 exclude_slash_tmp: true,
749 }
750 }
751
752 #[must_use]
754 fn workspace_write_with_network(
755 writable_roots: Vec<PathBuf>,
756 network_allowlist: Vec<NetworkAllowlistEntry>,
757 ) -> Self {
758 Self::WorkspaceWrite {
759 writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
760 network_access: !network_allowlist.is_empty(),
761 network_allowlist,
762 sensitive_paths: None,
763 resource_limits: ResourceLimits::default(),
764 seccomp_profile: SeccompProfile::strict().with_network(),
765 exclude_tmpdir_env_var: true,
766 exclude_slash_tmp: true,
767 }
768 }
769
770 #[must_use]
772 pub fn workspace_write_with_sensitive_paths(
773 writable_roots: Vec<PathBuf>,
774 sensitive_paths: Vec<SensitivePath>,
775 ) -> Self {
776 Self::WorkspaceWrite {
777 writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
778 network_access: false,
779 network_allowlist: Vec::new(),
780 sensitive_paths: Some(sensitive_paths),
781 resource_limits: ResourceLimits::default(),
782 seccomp_profile: SeccompProfile::strict(),
783 exclude_tmpdir_env_var: true,
784 exclude_slash_tmp: true,
785 }
786 }
787
788 #[must_use]
790 fn workspace_write_no_sensitive_blocking(writable_roots: Vec<PathBuf>) -> Self {
791 Self::WorkspaceWrite {
792 writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
793 network_access: false,
794 network_allowlist: Vec::new(),
795 sensitive_paths: Some(Vec::new()),
796 resource_limits: ResourceLimits::default(),
797 seccomp_profile: SeccompProfile::strict(),
798 exclude_tmpdir_env_var: true,
799 exclude_slash_tmp: true,
800 }
801 }
802
803 #[must_use]
806 fn workspace_write_with_limits(writable_roots: Vec<PathBuf>, resource_limits: ResourceLimits) -> Self {
807 Self::WorkspaceWrite {
808 writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
809 network_access: false,
810 network_allowlist: Vec::new(),
811 sensitive_paths: None,
812 resource_limits,
813 seccomp_profile: SeccompProfile::strict(),
814 exclude_tmpdir_env_var: true,
815 exclude_slash_tmp: true,
816 }
817 }
818
819 #[must_use]
821 pub fn workspace_write_full(
822 writable_roots: Vec<PathBuf>,
823 network_allowlist: Vec<NetworkAllowlistEntry>,
824 sensitive_paths: Option<Vec<SensitivePath>>,
825 resource_limits: ResourceLimits,
826 seccomp_profile: SeccompProfile,
827 ) -> Self {
828 Self::WorkspaceWrite {
829 writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
830 network_access: !network_allowlist.is_empty(),
831 network_allowlist,
832 sensitive_paths,
833 resource_limits,
834 seccomp_profile,
835 exclude_tmpdir_env_var: true,
836 exclude_slash_tmp: true,
837 }
838 }
839
840 #[must_use]
842 pub fn full_access() -> Self {
843 Self::DangerFullAccess
844 }
845
846 #[inline]
848 #[must_use]
849 pub fn has_full_network_access(&self) -> bool {
850 match self {
851 Self::ReadOnly { network_access, network_allowlist }
852 | Self::WorkspaceWrite { network_access, network_allowlist, .. } => {
853 *network_access && network_allowlist.is_empty()
854 }
855 Self::DangerFullAccess | Self::ExternalSandbox { .. } => true,
856 }
857 }
858
859 #[inline]
861 #[must_use]
862 pub fn has_network_allowlist(&self) -> bool {
863 match self {
864 Self::ReadOnly { network_allowlist, .. } | Self::WorkspaceWrite { network_allowlist, .. } => {
865 !network_allowlist.is_empty()
866 }
867 _ => false,
868 }
869 }
870
871 #[inline]
873 #[must_use]
874 pub fn network_allowlist(&self) -> &[NetworkAllowlistEntry] {
875 match self {
876 Self::ReadOnly { network_allowlist, .. } | Self::WorkspaceWrite { network_allowlist, .. } => {
877 network_allowlist
878 }
879 _ => &[],
880 }
881 }
882
883 #[inline]
885 #[must_use]
886 pub fn is_network_allowed(&self, domain: &str, port: u16) -> bool {
887 match self {
888 Self::ReadOnly { network_access, network_allowlist }
889 | Self::WorkspaceWrite { network_access, network_allowlist, .. } => {
890 if network_allowlist.is_empty() {
891 *network_access
892 } else {
893 network_allowlist.iter().any(|entry| entry.matches(domain, port))
894 }
895 }
896 Self::DangerFullAccess | Self::ExternalSandbox { .. } => true,
897 }
898 }
899
900 #[must_use]
903 fn sensitive_paths(&self) -> Vec<SensitivePath> {
904 match self {
905 Self::ReadOnly { .. } => default_sensitive_paths(),
906 Self::WorkspaceWrite { sensitive_paths, .. } => {
907 sensitive_paths.clone().unwrap_or_else(default_sensitive_paths)
908 }
909 Self::DangerFullAccess | Self::ExternalSandbox { .. } => Vec::new(),
910 }
911 }
912
913 #[must_use]
915 pub(crate) fn sensitive_paths_for_execution(&self, cwd: &Path) -> Vec<SensitivePath> {
916 match self {
917 Self::WorkspaceWrite { .. } => {
918 let mut sensitive_paths = self.sensitive_paths();
919 sensitive_paths.extend(protected_writable_root_sensitive_paths(&self.get_writable_roots_with_cwd(cwd)));
920 sensitive_paths
921 }
922 _ => self.sensitive_paths(),
923 }
924 }
925
926 #[inline]
928 #[must_use]
929 fn is_sensitive_path(&self, path: &Path) -> bool {
930 self.sensitive_paths().iter().any(|sp| sp.matches(path) && sp.block_read)
931 }
932
933 #[inline]
935 #[must_use]
936 fn is_path_write_blocked(&self, path: &Path, cwd: &Path) -> bool {
937 match self {
938 Self::DangerFullAccess | Self::ExternalSandbox { .. } => false,
939 _ => self
940 .sensitive_paths_for_execution(cwd)
941 .iter()
942 .any(|sp| sp.matches(path) && sp.block_write),
943 }
944 }
945
946 #[inline]
948 #[must_use]
949 pub fn is_path_readable(&self, path: &Path) -> bool {
950 match self {
951 Self::DangerFullAccess | Self::ExternalSandbox { .. } => true,
952 _ => !self.is_sensitive_path(path),
953 }
954 }
955
956 #[must_use]
958 pub fn resource_limits(&self) -> ResourceLimits {
959 match self {
960 Self::ReadOnly { .. } => ResourceLimits::conservative(),
961 Self::WorkspaceWrite { resource_limits, .. } => resource_limits.clone(),
962 Self::DangerFullAccess | Self::ExternalSandbox { .. } => ResourceLimits::unlimited(),
963 }
964 }
965
966 #[must_use]
968 pub(crate) fn seccomp_profile(&self) -> SeccompProfile {
969 match self {
970 Self::ReadOnly { network_access, network_allowlist } => {
971 let mut profile = SeccompProfile::strict();
972 if *network_access || !network_allowlist.is_empty() {
973 profile = profile.with_network();
974 }
975 profile
976 }
977 Self::WorkspaceWrite { seccomp_profile, .. } => seccomp_profile.clone(),
978 Self::DangerFullAccess | Self::ExternalSandbox { .. } => SeccompProfile::permissive(),
979 }
980 }
981
982 #[inline]
984 #[must_use]
985 fn has_full_disk_write_access(&self) -> bool {
986 matches!(self, Self::DangerFullAccess | Self::ExternalSandbox { .. })
987 }
988
989 #[inline]
991 #[must_use]
992 fn has_full_disk_read_access(&self) -> bool {
993 true
994 }
995
996 #[must_use]
998 pub(crate) fn get_writable_roots_with_cwd(&self, cwd: &Path) -> Vec<WritableRoot> {
999 match self {
1000 Self::ReadOnly { .. } => vec![],
1001 Self::WorkspaceWrite { writable_roots, .. } => {
1002 let mut roots = writable_roots.clone();
1003 let cwd_root = WritableRoot::new(cwd);
1004 if !roots.contains(&cwd_root) {
1005 roots.push(cwd_root);
1006 }
1007 roots
1008 }
1009 Self::DangerFullAccess | Self::ExternalSandbox { .. } => {
1010 vec![WritableRoot::new(cwd)]
1011 }
1012 }
1013 }
1014
1015 #[inline]
1017 #[must_use]
1018 pub fn is_path_writable(&self, path: &Path, cwd: &Path) -> bool {
1019 match self {
1020 Self::ReadOnly { .. } => false,
1021 Self::WorkspaceWrite { .. } => {
1022 let writable = self.get_writable_roots_with_cwd(cwd);
1023 writable.iter().any(|root| path.starts_with(&root.root)) && !self.is_path_write_blocked(path, cwd)
1024 }
1025 Self::DangerFullAccess | Self::ExternalSandbox { .. } => true,
1026 }
1027 }
1028
1029 fn can_set(&self, new_policy: &SandboxPolicy) -> anyhow::Result<()> {
1032 use SandboxPolicy::*;
1033
1034 match (self, new_policy) {
1035 (DangerFullAccess, _) => Ok(()),
1037 (ReadOnly { .. }, WorkspaceWrite { .. } | DangerFullAccess) => {
1039 Err(anyhow::anyhow!("cannot escalate from read-only to write-capable policy"))
1040 }
1041 _ => Ok(()),
1043 }
1044 }
1045
1046 pub fn description(&self) -> &'static str {
1048 match self {
1049 Self::ReadOnly { .. } => "read-only access",
1050 Self::WorkspaceWrite { .. } => "workspace write access",
1051 Self::DangerFullAccess => "full access (dangerous)",
1052 Self::ExternalSandbox { .. } => "external sandbox",
1053 }
1054 }
1055}
1056
1057impl Default for SandboxPolicy {
1058 fn default() -> Self {
1059 Self::read_only()
1060 }
1061}
1062
1063#[cfg(test)]
1064mod tests {
1065 use super::*;
1066
1067 #[test]
1068 fn test_read_only_policy() {
1069 let policy = SandboxPolicy::read_only();
1070 assert!(!policy.has_full_network_access());
1071 assert!(!policy.has_network_allowlist());
1072 assert!(!policy.has_full_disk_write_access());
1073 assert!(policy.has_full_disk_read_access());
1074 }
1075
1076 #[test]
1077 fn test_read_only_with_network_allowlist() {
1078 let policy = SandboxPolicy::read_only_with_network(vec![
1079 NetworkAllowlistEntry::https("api.github.com"),
1080 NetworkAllowlistEntry::with_port("registry.npmjs.org", 443),
1081 ]);
1082
1083 assert!(!policy.has_full_network_access());
1084 assert!(policy.has_network_allowlist());
1085 assert!(policy.is_network_allowed("api.github.com", 443));
1086 assert!(policy.is_network_allowed("registry.npmjs.org", 443));
1087 assert!(!policy.is_network_allowed("example.com", 443));
1088 }
1089
1090 #[test]
1091 fn test_read_only_with_full_network_access() {
1092 let policy = SandboxPolicy::read_only_with_full_network();
1093
1094 assert!(policy.has_full_network_access());
1095 assert!(policy.is_network_allowed("example.com", 443));
1096 assert!(policy.seccomp_profile().allow_network_sockets);
1097 }
1098
1099 #[test]
1100 fn test_read_only_deserializes_legacy_shape() {
1101 let policy: SandboxPolicy = serde_json::from_str(r#"{"type":"read_only"}"#).expect("legacy read-only policy");
1102
1103 assert_eq!(policy, SandboxPolicy::read_only());
1104 }
1105
1106 #[test]
1107 fn test_workspace_write_policy() {
1108 let policy = SandboxPolicy::workspace_write(vec![PathBuf::from("/tmp/workspace")]);
1109 assert!(!policy.has_full_network_access());
1110 assert!(!policy.has_full_disk_write_access());
1111
1112 let cwd = PathBuf::from("/tmp/workspace");
1113 assert!(policy.is_path_writable(&cwd, &cwd));
1114 assert!(!policy.is_path_writable(&PathBuf::from("/etc"), &cwd));
1115 }
1116
1117 #[test]
1118 fn test_workspace_write_protects_internal_metadata_dirs() {
1119 let cwd = PathBuf::from("/tmp/workspace");
1120 let policy = SandboxPolicy::workspace_write(vec![cwd.clone()]);
1121
1122 assert!(!policy.is_path_writable(&cwd.join(".git/config"), &cwd));
1123 assert!(!policy.is_path_writable(&cwd.join(".vtcode/cache"), &cwd));
1124 assert!(!policy.is_path_writable(&cwd.join(".codex/state"), &cwd));
1125 assert!(!policy.is_path_writable(&cwd.join(".agents/skills"), &cwd));
1126 assert!(policy.is_path_writable(&cwd.join("src/main.rs"), &cwd));
1127 }
1128
1129 #[test]
1130 fn test_full_access_policy() {
1131 let policy = SandboxPolicy::full_access();
1132 assert!(policy.has_full_network_access());
1133 assert!(policy.has_full_disk_write_access());
1134 }
1135
1136 #[test]
1137 fn test_policy_escalation() {
1138 let read_only = SandboxPolicy::read_only();
1139 let full = SandboxPolicy::full_access();
1140
1141 assert!(read_only.can_set(&full).is_err());
1143
1144 full.can_set(&read_only).unwrap();
1146 }
1147
1148 #[test]
1149 fn test_network_allowlist_entry_matching() {
1150 let entry = NetworkAllowlistEntry::https("api.github.com");
1151 assert!(entry.matches("api.github.com", 443));
1152 assert!(!entry.matches("api.github.com", 80));
1153 assert!(!entry.matches("github.com", 443));
1154 }
1155
1156 #[test]
1157 fn test_network_allowlist_wildcard() {
1158 let entry = NetworkAllowlistEntry::https("*.npmjs.org");
1159 assert!(entry.matches("registry.npmjs.org", 443));
1160 assert!(entry.matches("npmjs.org", 443));
1161 assert!(!entry.matches("npmjs.org.evil.com", 443));
1162 }
1163
1164 #[test]
1165 fn test_workspace_with_network_allowlist() {
1166 let allowlist = vec![
1167 NetworkAllowlistEntry::https("api.github.com"),
1168 NetworkAllowlistEntry::https("*.npmjs.org"),
1169 ];
1170 let policy = SandboxPolicy::workspace_write_with_network(vec![PathBuf::from("/tmp/workspace")], allowlist);
1171
1172 assert!(!policy.has_full_network_access());
1174 assert!(policy.has_network_allowlist());
1175
1176 assert!(policy.is_network_allowed("api.github.com", 443));
1178 assert!(policy.is_network_allowed("registry.npmjs.org", 443));
1179 assert!(!policy.is_network_allowed("evil.com", 443));
1180 assert!(!policy.is_network_allowed("api.github.com", 80));
1181 }
1182
1183 #[test]
1184 fn test_workspace_no_network() {
1185 let policy = SandboxPolicy::workspace_write(vec![PathBuf::from("/tmp/workspace")]);
1186
1187 assert!(!policy.has_full_network_access());
1188 assert!(!policy.has_network_allowlist());
1189 assert!(!policy.is_network_allowed("api.github.com", 443));
1190 }
1191
1192 #[test]
1193 fn test_sensitive_path_expansion() {
1194 let sp = SensitivePath::new("~/.ssh");
1195 let expanded = sp.expand_path();
1196 assert!(expanded.to_string_lossy().contains(".ssh"));
1198 assert!(!expanded.to_string_lossy().starts_with('~'));
1199 }
1200
1201 #[test]
1202 fn test_sensitive_path_matching() {
1203 let sp = SensitivePath::new("~/.ssh");
1204 let expanded = sp.expand_path();
1205 let ssh_key = expanded.join("id_rsa");
1206 assert!(sp.matches(&ssh_key));
1207 assert!(sp.matches(&expanded));
1208 }
1209
1210 #[cfg(not(windows))]
1211 #[test]
1212 fn test_sensitive_path_matching_is_case_insensitive_with_component_boundaries() {
1213 let sp = SensitivePath::new("/tmp/Workspace/.env");
1214
1215 assert!(sp.matches(Path::new("/tmp/workspace/.ENV")));
1216 assert!(sp.matches(Path::new("/tmp/workspace/.ENV/child")));
1217 assert!(!sp.matches(Path::new("/tmp/workspace/.environment")));
1218 assert!(!sp.matches(Path::new("/tmp/workspaces/.ENV")));
1219 }
1220
1221 #[test]
1222 fn test_default_sensitive_paths() {
1223 let paths = default_sensitive_paths();
1224 assert!(!paths.is_empty());
1225 let path_strings: Vec<&str> = paths.iter().map(|p| p.path.as_str()).collect();
1227 assert!(path_strings.contains(&"~/.ssh"));
1228 assert!(path_strings.contains(&"~/.aws"));
1229 assert!(path_strings.contains(&"~/.kube"));
1230 }
1231
1232 #[test]
1233 fn resolved_vtcode_roots_are_sensitive_without_duplicate_entries() {
1234 let environment = [
1235 ("HOME", "/home/tester"),
1236 ("VTCODE_CONFIG", "/vtcode/shared"),
1237 ("VTCODE_DATA", "/vtcode/shared"),
1238 ("XDG_STATE_HOME", "/xdg/state"),
1239 ("XDG_CACHE_HOME", "/xdg/cache"),
1240 ("XDG_RUNTIME_DIR", "/xdg/runtime"),
1241 ("XDG_BIN_HOME", "/xdg/bin"),
1242 ("VTCODE_HOME", "/legacy/vtcode"),
1243 ];
1244 let resolved =
1245 VtCodePaths::from_environment(&environment).expect("explicit absolute VT Code paths should resolve");
1246 let paths = vtcode_sensitive_paths(&environment).expect("explicit absolute VT Code paths should resolve");
1247 let path_strings: Vec<&str> = paths.iter().map(|path| path.path.as_str()).collect();
1248
1249 for expected in [
1250 resolved.config_dir().to_path_buf(),
1251 resolved.auth_dir(),
1252 resolved.data_dir().to_path_buf(),
1253 resolved.state_dir().to_path_buf(),
1254 resolved.cache_dir().to_path_buf(),
1255 resolved.runtime_dir().to_path_buf(),
1256 resolved.executable_dir().to_path_buf(),
1257 resolved.legacy_dir().to_path_buf(),
1258 ] {
1259 let expected = expected.display().to_string();
1260 assert!(path_strings.contains(&expected.as_str()), "missing sensitive root: {expected}");
1261 }
1262 assert_eq!(path_strings.iter().filter(|path| **path == "/vtcode/shared").count(), 1);
1263 }
1264
1265 #[test]
1266 fn invalid_vtcode_path_resolution_is_rejected_before_policy_construction() {
1267 let error = vtcode_sensitive_paths(&[("HOME", "/home/tester"), ("VTCODE_CONFIG", "relative/config")])
1268 .expect_err("relative VT Code config paths must fail closed");
1269 assert!(error.to_string().contains("VTCODE_CONFIG"));
1270 }
1271
1272 #[cfg(windows)]
1273 #[test]
1274 fn test_windows_userprofile_root_exclusions_are_in_defaults() {
1275 let paths = default_sensitive_paths();
1276 let path_strings: Vec<&str> = paths.iter().map(|p| p.path.as_str()).collect();
1277
1278 for entry in USERPROFILE_READ_ROOT_EXCLUSIONS {
1279 let expected = format!("~/{}", entry);
1280 assert!(path_strings.contains(&expected.as_str()), "missing expected default sensitive path: {expected}");
1281 }
1282 }
1283
1284 #[cfg(windows)]
1285 #[test]
1286 fn test_sensitive_path_matching_is_case_insensitive_on_windows() {
1287 let sp = SensitivePath::new("~/.aws");
1288 let home = dirs::home_dir().expect("home dir");
1289 let mixed_case_candidate = home.join(".AWS").join("credentials");
1290
1291 assert!(sp.matches(&mixed_case_candidate));
1292 }
1293
1294 #[test]
1295 fn test_workspace_blocks_sensitive_by_default() {
1296 let policy = SandboxPolicy::workspace_write(vec![PathBuf::from("/tmp/workspace")]);
1297 let sensitive = policy.sensitive_paths();
1298 assert!(!sensitive.is_empty());
1299
1300 if let Some(home) = dirs::home_dir() {
1302 let ssh_path = home.join(".ssh").join("id_rsa");
1303 assert!(policy.is_sensitive_path(&ssh_path));
1304 assert!(!policy.is_path_readable(&ssh_path));
1305 }
1306 }
1307
1308 #[test]
1309 fn test_workspace_no_sensitive_blocking() {
1310 let policy = SandboxPolicy::workspace_write_no_sensitive_blocking(vec![PathBuf::from("/tmp")]);
1311 let sensitive = policy.sensitive_paths();
1312 assert!(sensitive.is_empty());
1313
1314 if let Some(home) = dirs::home_dir() {
1316 let ssh_path = home.join(".ssh").join("id_rsa");
1317 assert!(!policy.is_sensitive_path(&ssh_path));
1318 assert!(policy.is_path_readable(&ssh_path));
1319 }
1320 }
1321
1322 #[test]
1323 fn test_full_access_no_sensitive_blocking() {
1324 let policy = SandboxPolicy::full_access();
1325 let sensitive = policy.sensitive_paths();
1326 assert!(sensitive.is_empty());
1327
1328 if let Some(home) = dirs::home_dir() {
1330 let ssh_path = home.join(".ssh").join("id_rsa");
1331 assert!(policy.is_path_readable(&ssh_path));
1332 }
1333 }
1334
1335 #[test]
1336 fn test_resource_limits_default() {
1337 let limits = ResourceLimits::default();
1338 assert_eq!(limits.max_memory_mb, 0);
1339 assert_eq!(limits.max_pids, 0);
1340 assert_eq!(limits.timeout_secs, 300);
1341 assert!(limits.has_limits());
1342 }
1343
1344 #[test]
1345 fn test_resource_limits_conservative() {
1346 let limits = ResourceLimits::conservative();
1347 assert_eq!(limits.max_memory_mb, 512);
1348 assert_eq!(limits.max_pids, 64);
1349 assert_eq!(limits.cpu_time_secs, 60);
1350 assert!(limits.has_limits());
1351 }
1352
1353 #[test]
1354 fn test_resource_limits_builder() {
1355 let limits = ResourceLimits::default()
1356 .with_memory_mb(1024)
1357 .with_max_pids(128)
1358 .with_timeout_secs(60);
1359 assert_eq!(limits.max_memory_mb, 1024);
1360 assert_eq!(limits.max_pids, 128);
1361 assert_eq!(limits.effective_timeout_secs(), 60);
1362 }
1363
1364 #[test]
1365 fn test_workspace_with_limits() {
1366 let limits = ResourceLimits::conservative();
1367 let policy = SandboxPolicy::workspace_write_with_limits(vec![PathBuf::from("/tmp/workspace")], limits.clone());
1368
1369 let policy_limits = policy.resource_limits();
1370 assert_eq!(policy_limits.max_memory_mb, limits.max_memory_mb);
1371 assert_eq!(policy_limits.max_pids, limits.max_pids);
1372 }
1373
1374 #[test]
1375 fn test_read_only_conservative_limits() {
1376 let policy = SandboxPolicy::read_only();
1377 let limits = policy.resource_limits();
1378 assert!(limits.has_limits());
1380 assert_eq!(limits.max_memory_mb, 512);
1381 }
1382
1383 #[test]
1384 fn test_full_access_unlimited() {
1385 let policy = SandboxPolicy::full_access();
1386 let limits = policy.resource_limits();
1387 assert!(!limits.has_limits());
1389 }
1390
1391 #[test]
1392 fn test_seccomp_profile_strict() {
1393 let profile = SeccompProfile::strict();
1394 assert!(profile.is_blocked("ptrace"));
1395 assert!(profile.is_blocked("mount"));
1396 assert!(profile.is_blocked("kexec_load"));
1397 assert!(profile.is_blocked("bpf"));
1398 assert!(!profile.allow_network_sockets);
1399 assert!(!profile.allow_namespaces);
1400 }
1401
1402 #[test]
1403 fn test_seccomp_profile_permissive() {
1404 let profile = SeccompProfile::permissive();
1405 assert!(profile.is_blocked("ptrace"));
1407 assert!(profile.is_blocked("kexec_load"));
1408 assert!(profile.allow_network_sockets);
1410 }
1411
1412 #[test]
1413 fn test_seccomp_profile_builder() {
1414 let profile = SeccompProfile::strict().with_network().block_syscall("custom_syscall");
1415 assert!(profile.allow_network_sockets);
1416 assert!(profile.is_blocked("custom_syscall"));
1417 }
1418
1419 #[test]
1420 fn test_workspace_seccomp_profile() {
1421 let policy = SandboxPolicy::workspace_write(vec![PathBuf::from("/tmp")]);
1422 let profile = policy.seccomp_profile();
1423 assert!(profile.is_blocked("ptrace"));
1425 assert!(profile.is_blocked("mount"));
1426 }
1427
1428 #[test]
1429 fn test_workspace_with_network_seccomp() {
1430 let policy = SandboxPolicy::workspace_write_with_network(
1431 vec![PathBuf::from("/tmp")],
1432 vec![NetworkAllowlistEntry::https("api.github.com")],
1433 );
1434 let profile = policy.seccomp_profile();
1435 assert!(profile.allow_network_sockets);
1437 }
1438
1439 #[test]
1440 fn test_seccomp_profile_json() {
1441 let profile = SeccompProfile::strict();
1442 let json = profile.to_json().unwrap();
1443 assert!(json.contains("ptrace"));
1444 assert!(json.contains("blocked_syscalls"));
1445 }
1446
1447 #[test]
1448 fn test_blocked_syscalls_constant() {
1449 assert!(BLOCKED_SYSCALLS.contains(&"ptrace"));
1451 assert!(BLOCKED_SYSCALLS.contains(&"mount"));
1452 assert!(BLOCKED_SYSCALLS.contains(&"kexec_load"));
1453 assert!(BLOCKED_SYSCALLS.contains(&"bpf"));
1454 assert!(BLOCKED_SYSCALLS.contains(&"perf_event_open"));
1455 }
1456}