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 pub(crate) 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))]
215pub(crate) fn 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 SECCOMP_PROFILE_VERSION: u32 = 1;
454
455pub const BLOCKED_SYSCALLS: &[&str] = &[
473 "ptrace",
475 "kcmp",
476 "pidfd_getfd",
477 "process_madvise",
478 "process_mrelease",
479 "mount",
481 "umount",
482 "umount2",
483 "open_by_handle_at",
485 "name_to_handle_at",
486 "init_module",
488 "finit_module",
489 "delete_module",
490 "kexec_load",
492 "kexec_file_load",
493 "bpf",
495 "perf_event_open",
497 "userfaultfd",
499 "io_uring_setup",
502 "io_uring_enter",
503 "io_uring_register",
504 "process_vm_readv",
506 "process_vm_writev",
507 "reboot",
509 "swapon",
511 "swapoff",
512 "settimeofday",
514 "clock_settime",
515 "adjtimex",
516 "add_key",
518 "request_key",
519 "keyctl",
520 "ioperm",
522 "iopl",
523 "acct",
525 "quotactl",
527 "unshare",
529 "setns",
530 "personality",
532];
533
534pub const FILTERED_SYSCALLS: &[&str] = &[
536 "clone", "clone3", "ioctl", "prctl", "socket",
541];
542
543#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
547pub struct SeccompProfile {
548 #[serde(default = "default_blocked_syscalls")]
550 blocked_syscalls: Vec<String>,
551
552 #[serde(default)]
554 allow_namespaces: bool,
555
556 #[serde(default)]
558 allow_network_sockets: bool,
559
560 #[serde(default)]
562 log_only: bool,
563}
564
565fn default_blocked_syscalls() -> Vec<String> {
566 BLOCKED_SYSCALLS.iter().map(|s| s.to_string()).collect()
567}
568
569impl Default for SeccompProfile {
570 fn default() -> Self {
571 Self {
572 blocked_syscalls: default_blocked_syscalls(),
573 allow_namespaces: false,
574 allow_network_sockets: false,
575 log_only: false,
576 }
577 }
578}
579
580impl SeccompProfile {
581 #[must_use]
583 pub(crate) fn blocked_syscalls(&self) -> &[String] {
584 &self.blocked_syscalls
585 }
586
587 #[must_use]
589 pub(crate) fn allow_namespaces(&self) -> bool {
590 self.allow_namespaces
591 }
592
593 #[must_use]
595 pub(crate) fn allow_network_sockets(&self) -> bool {
596 self.allow_network_sockets
597 }
598
599 #[must_use]
601 pub(crate) fn log_only(&self) -> bool {
602 self.log_only
603 }
604
605 #[must_use]
607 pub fn strict() -> Self {
608 Self {
609 blocked_syscalls: default_blocked_syscalls(),
610 allow_namespaces: false,
611 allow_network_sockets: false,
612 log_only: false,
613 }
614 }
615
616 #[must_use]
618 pub fn permissive() -> Self {
619 Self {
620 blocked_syscalls: vec![
621 "ptrace".to_string(),
622 "kexec_load".to_string(),
623 "kexec_file_load".to_string(),
624 "reboot".to_string(),
625 ],
626 allow_namespaces: false,
627 allow_network_sockets: true,
628 log_only: false,
629 }
630 }
631
632 #[must_use]
634 pub fn logging() -> Self {
635 Self {
636 blocked_syscalls: default_blocked_syscalls(),
637 allow_namespaces: false,
638 allow_network_sockets: false,
639 log_only: true,
640 }
641 }
642
643 #[must_use]
645 pub fn block_syscall(mut self, syscall: impl Into<String>) -> Self {
646 let syscall = syscall.into();
647 if !self.blocked_syscalls.contains(&syscall) {
648 self.blocked_syscalls.push(syscall);
649 }
650 self
651 }
652
653 #[must_use]
655 pub fn with_network(mut self) -> Self {
656 self.allow_network_sockets = true;
657 self
658 }
659
660 #[must_use]
662 pub fn with_logging(mut self) -> Self {
663 self.log_only = true;
664 self
665 }
666
667 #[inline]
669 #[must_use]
670 fn is_blocked(&self, syscall: &str) -> bool {
671 self.blocked_syscalls.iter().any(|s| s == syscall)
672 }
673
674 pub(crate) fn to_json(&self) -> Result<String, serde_json::Error> {
676 serde_json::to_string(self)
677 }
678}
679
680#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
692#[serde(tag = "type", rename_all = "snake_case")]
693pub enum SandboxPolicy {
694 ReadOnly {
696 #[serde(default)]
698 network_access: bool,
699
700 #[serde(default)]
702 network_allowlist: Vec<NetworkAllowlistEntry>,
703 },
704
705 WorkspaceWrite {
707 writable_roots: Vec<WritableRoot>,
709
710 #[serde(default)]
712 network_access: bool,
713
714 #[serde(default)]
718 network_allowlist: Vec<NetworkAllowlistEntry>,
719
720 #[serde(default)]
724 sensitive_paths: Option<Vec<SensitivePath>>,
725
726 #[serde(default)]
729 resource_limits: ResourceLimits,
730
731 #[serde(default)]
734 seccomp_profile: SeccompProfile,
735
736 #[serde(default)]
738 exclude_tmpdir_env_var: bool,
739
740 #[serde(default)]
742 exclude_slash_tmp: bool,
743 },
744
745 DangerFullAccess,
748
749 ExternalSandbox {
751 description: String,
753 },
754}
755
756impl SandboxPolicy {
757 #[must_use]
759 pub fn read_only() -> Self {
760 Self::ReadOnly {
761 network_access: false,
762 network_allowlist: Vec::new(),
763 }
764 }
765
766 #[must_use]
768 pub fn new_read_only_policy() -> Self {
769 Self::read_only()
770 }
771
772 #[must_use]
774 pub fn read_only_with_network(network_allowlist: Vec<NetworkAllowlistEntry>) -> Self {
775 Self::ReadOnly {
776 network_access: !network_allowlist.is_empty(),
777 network_allowlist,
778 }
779 }
780
781 #[must_use]
783 pub fn read_only_with_full_network() -> Self {
784 Self::ReadOnly {
785 network_access: true,
786 network_allowlist: Vec::new(),
787 }
788 }
789
790 #[must_use]
793 pub fn workspace_write(writable_roots: Vec<PathBuf>) -> 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: ResourceLimits::default(),
800 seccomp_profile: SeccompProfile::strict(),
801 exclude_tmpdir_env_var: true,
802 exclude_slash_tmp: true,
803 }
804 }
805
806 #[must_use]
808 fn workspace_write_with_network(
809 writable_roots: Vec<PathBuf>,
810 network_allowlist: Vec<NetworkAllowlistEntry>,
811 ) -> Self {
812 Self::WorkspaceWrite {
813 writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
814 network_access: !network_allowlist.is_empty(),
815 network_allowlist,
816 sensitive_paths: None,
817 resource_limits: ResourceLimits::default(),
818 seccomp_profile: SeccompProfile::strict().with_network(),
819 exclude_tmpdir_env_var: true,
820 exclude_slash_tmp: true,
821 }
822 }
823
824 #[must_use]
826 pub fn workspace_write_with_sensitive_paths(
827 writable_roots: Vec<PathBuf>,
828 sensitive_paths: Vec<SensitivePath>,
829 ) -> Self {
830 Self::WorkspaceWrite {
831 writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
832 network_access: false,
833 network_allowlist: Vec::new(),
834 sensitive_paths: Some(sensitive_paths),
835 resource_limits: ResourceLimits::default(),
836 seccomp_profile: SeccompProfile::strict(),
837 exclude_tmpdir_env_var: true,
838 exclude_slash_tmp: true,
839 }
840 }
841
842 #[must_use]
844 fn workspace_write_no_sensitive_blocking(writable_roots: Vec<PathBuf>) -> Self {
845 Self::WorkspaceWrite {
846 writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
847 network_access: false,
848 network_allowlist: Vec::new(),
849 sensitive_paths: Some(Vec::new()),
850 resource_limits: ResourceLimits::default(),
851 seccomp_profile: SeccompProfile::strict(),
852 exclude_tmpdir_env_var: true,
853 exclude_slash_tmp: true,
854 }
855 }
856
857 #[must_use]
860 fn workspace_write_with_limits(writable_roots: Vec<PathBuf>, resource_limits: ResourceLimits) -> Self {
861 Self::WorkspaceWrite {
862 writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
863 network_access: false,
864 network_allowlist: Vec::new(),
865 sensitive_paths: None,
866 resource_limits,
867 seccomp_profile: SeccompProfile::strict(),
868 exclude_tmpdir_env_var: true,
869 exclude_slash_tmp: true,
870 }
871 }
872
873 #[must_use]
875 pub fn workspace_write_full(
876 writable_roots: Vec<PathBuf>,
877 network_allowlist: Vec<NetworkAllowlistEntry>,
878 sensitive_paths: Option<Vec<SensitivePath>>,
879 resource_limits: ResourceLimits,
880 seccomp_profile: SeccompProfile,
881 ) -> Self {
882 Self::WorkspaceWrite {
883 writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
884 network_access: !network_allowlist.is_empty(),
885 network_allowlist,
886 sensitive_paths,
887 resource_limits,
888 seccomp_profile,
889 exclude_tmpdir_env_var: true,
890 exclude_slash_tmp: true,
891 }
892 }
893
894 #[must_use]
896 pub fn full_access() -> Self {
897 Self::DangerFullAccess
898 }
899
900 #[inline]
902 #[must_use]
903 pub fn has_full_network_access(&self) -> bool {
904 match self {
905 Self::ReadOnly { network_access, network_allowlist }
906 | Self::WorkspaceWrite { network_access, network_allowlist, .. } => {
907 *network_access && network_allowlist.is_empty()
908 }
909 Self::DangerFullAccess | Self::ExternalSandbox { .. } => true,
910 }
911 }
912
913 #[inline]
915 #[must_use]
916 pub fn has_network_allowlist(&self) -> bool {
917 match self {
918 Self::ReadOnly { network_allowlist, .. } | Self::WorkspaceWrite { network_allowlist, .. } => {
919 !network_allowlist.is_empty()
920 }
921 _ => false,
922 }
923 }
924
925 #[inline]
927 #[must_use]
928 pub fn network_allowlist(&self) -> &[NetworkAllowlistEntry] {
929 match self {
930 Self::ReadOnly { network_allowlist, .. } | Self::WorkspaceWrite { network_allowlist, .. } => {
931 network_allowlist
932 }
933 _ => &[],
934 }
935 }
936
937 #[inline]
939 #[must_use]
940 pub fn is_network_allowed(&self, domain: &str, port: u16) -> bool {
941 match self {
942 Self::ReadOnly { network_access, network_allowlist }
943 | Self::WorkspaceWrite { network_access, network_allowlist, .. } => {
944 if network_allowlist.is_empty() {
945 *network_access
946 } else {
947 network_allowlist.iter().any(|entry| entry.matches(domain, port))
948 }
949 }
950 Self::DangerFullAccess | Self::ExternalSandbox { .. } => true,
951 }
952 }
953
954 #[must_use]
957 fn sensitive_paths(&self) -> Vec<SensitivePath> {
958 match self {
959 Self::ReadOnly { .. } => default_sensitive_paths(),
960 Self::WorkspaceWrite { sensitive_paths, .. } => {
961 sensitive_paths.clone().unwrap_or_else(default_sensitive_paths)
962 }
963 Self::DangerFullAccess | Self::ExternalSandbox { .. } => Vec::new(),
964 }
965 }
966
967 #[must_use]
969 pub(crate) fn sensitive_paths_for_execution(&self, cwd: &Path) -> Vec<SensitivePath> {
970 match self {
971 Self::WorkspaceWrite { .. } => {
972 let mut sensitive_paths = self.sensitive_paths();
973 sensitive_paths.extend(protected_writable_root_sensitive_paths(&self.get_writable_roots_with_cwd(cwd)));
974 sensitive_paths
975 }
976 _ => self.sensitive_paths(),
977 }
978 }
979
980 #[inline]
982 #[must_use]
983 fn is_sensitive_path(&self, path: &Path) -> bool {
984 self.sensitive_paths().iter().any(|sp| sp.matches(path) && sp.block_read)
985 }
986
987 #[inline]
989 #[must_use]
990 fn is_path_write_blocked(&self, path: &Path, cwd: &Path) -> bool {
991 match self {
992 Self::DangerFullAccess | Self::ExternalSandbox { .. } => false,
993 _ => self
994 .sensitive_paths_for_execution(cwd)
995 .iter()
996 .any(|sp| sp.matches(path) && sp.block_write),
997 }
998 }
999
1000 #[inline]
1002 #[must_use]
1003 pub fn is_path_readable(&self, path: &Path) -> bool {
1004 match self {
1005 Self::DangerFullAccess | Self::ExternalSandbox { .. } => true,
1006 _ => !self.is_sensitive_path(path),
1007 }
1008 }
1009
1010 #[must_use]
1012 pub fn resource_limits(&self) -> ResourceLimits {
1013 match self {
1014 Self::ReadOnly { .. } => ResourceLimits::conservative(),
1015 Self::WorkspaceWrite { resource_limits, .. } => resource_limits.clone(),
1016 Self::DangerFullAccess | Self::ExternalSandbox { .. } => ResourceLimits::unlimited(),
1017 }
1018 }
1019
1020 #[must_use]
1022 pub fn seccomp_profile(&self) -> SeccompProfile {
1023 match self {
1024 Self::ReadOnly { network_access, network_allowlist } => {
1025 let mut profile = SeccompProfile::strict();
1026 if *network_access || !network_allowlist.is_empty() {
1027 profile = profile.with_network();
1028 }
1029 profile
1030 }
1031 Self::WorkspaceWrite { seccomp_profile, .. } => seccomp_profile.clone(),
1032 Self::DangerFullAccess | Self::ExternalSandbox { .. } => SeccompProfile::permissive(),
1033 }
1034 }
1035
1036 #[inline]
1038 #[must_use]
1039 fn has_full_disk_write_access(&self) -> bool {
1040 matches!(self, Self::DangerFullAccess | Self::ExternalSandbox { .. })
1041 }
1042
1043 #[inline]
1045 #[must_use]
1046 fn has_full_disk_read_access(&self) -> bool {
1047 true
1048 }
1049
1050 #[must_use]
1052 pub(crate) fn get_writable_roots_with_cwd(&self, cwd: &Path) -> Vec<WritableRoot> {
1053 match self {
1054 Self::ReadOnly { .. } => vec![],
1055 Self::WorkspaceWrite { writable_roots, .. } => {
1056 let mut roots = writable_roots.clone();
1057 let cwd_root = WritableRoot::new(cwd);
1058 if !roots.contains(&cwd_root) {
1059 roots.push(cwd_root);
1060 }
1061 roots
1062 }
1063 Self::DangerFullAccess | Self::ExternalSandbox { .. } => {
1064 vec![WritableRoot::new(cwd)]
1065 }
1066 }
1067 }
1068
1069 #[inline]
1071 #[must_use]
1072 pub fn is_path_writable(&self, path: &Path, cwd: &Path) -> bool {
1073 match self {
1074 Self::ReadOnly { .. } => false,
1075 Self::WorkspaceWrite { .. } => {
1076 let writable = self.get_writable_roots_with_cwd(cwd);
1077 writable.iter().any(|root| path.starts_with(&root.root)) && !self.is_path_write_blocked(path, cwd)
1078 }
1079 Self::DangerFullAccess | Self::ExternalSandbox { .. } => true,
1080 }
1081 }
1082
1083 fn can_set(&self, new_policy: &SandboxPolicy) -> anyhow::Result<()> {
1086 use SandboxPolicy::*;
1087
1088 match (self, new_policy) {
1089 (DangerFullAccess, _) => Ok(()),
1091 (ReadOnly { .. }, WorkspaceWrite { .. } | DangerFullAccess) => {
1093 Err(anyhow::anyhow!("cannot escalate from read-only to write-capable policy"))
1094 }
1095 _ => Ok(()),
1097 }
1098 }
1099
1100 pub fn description(&self) -> &'static str {
1102 match self {
1103 Self::ReadOnly { .. } => "read-only access",
1104 Self::WorkspaceWrite { .. } => "workspace write access",
1105 Self::DangerFullAccess => "full access (dangerous)",
1106 Self::ExternalSandbox { .. } => "external sandbox",
1107 }
1108 }
1109}
1110
1111impl Default for SandboxPolicy {
1112 fn default() -> Self {
1113 Self::read_only()
1114 }
1115}
1116
1117#[cfg(test)]
1118mod tests {
1119 use super::*;
1120
1121 #[test]
1122 fn test_read_only_policy() {
1123 let policy = SandboxPolicy::read_only();
1124 assert!(!policy.has_full_network_access());
1125 assert!(!policy.has_network_allowlist());
1126 assert!(!policy.has_full_disk_write_access());
1127 assert!(policy.has_full_disk_read_access());
1128 }
1129
1130 #[test]
1131 fn test_read_only_with_network_allowlist() {
1132 let policy = SandboxPolicy::read_only_with_network(vec![
1133 NetworkAllowlistEntry::https("api.github.com"),
1134 NetworkAllowlistEntry::with_port("registry.npmjs.org", 443),
1135 ]);
1136
1137 assert!(!policy.has_full_network_access());
1138 assert!(policy.has_network_allowlist());
1139 assert!(policy.is_network_allowed("api.github.com", 443));
1140 assert!(policy.is_network_allowed("registry.npmjs.org", 443));
1141 assert!(!policy.is_network_allowed("example.com", 443));
1142 }
1143
1144 #[test]
1145 fn test_read_only_with_full_network_access() {
1146 let policy = SandboxPolicy::read_only_with_full_network();
1147
1148 assert!(policy.has_full_network_access());
1149 assert!(policy.is_network_allowed("example.com", 443));
1150 assert!(policy.seccomp_profile().allow_network_sockets);
1151 }
1152
1153 #[test]
1154 fn test_read_only_deserializes_legacy_shape() {
1155 let policy: SandboxPolicy = serde_json::from_str(r#"{"type":"read_only"}"#).expect("legacy read-only policy");
1156
1157 assert_eq!(policy, SandboxPolicy::read_only());
1158 }
1159
1160 #[test]
1161 fn test_workspace_write_policy() {
1162 let policy = SandboxPolicy::workspace_write(vec![PathBuf::from("/tmp/workspace")]);
1163 assert!(!policy.has_full_network_access());
1164 assert!(!policy.has_full_disk_write_access());
1165
1166 let cwd = PathBuf::from("/tmp/workspace");
1167 assert!(policy.is_path_writable(&cwd, &cwd));
1168 assert!(!policy.is_path_writable(&PathBuf::from("/etc"), &cwd));
1169 }
1170
1171 #[test]
1172 fn test_workspace_write_protects_internal_metadata_dirs() {
1173 let cwd = PathBuf::from("/tmp/workspace");
1174 let policy = SandboxPolicy::workspace_write(vec![cwd.clone()]);
1175
1176 assert!(!policy.is_path_writable(&cwd.join(".git/config"), &cwd));
1177 assert!(!policy.is_path_writable(&cwd.join(".vtcode/cache"), &cwd));
1178 assert!(!policy.is_path_writable(&cwd.join(".codex/state"), &cwd));
1179 assert!(!policy.is_path_writable(&cwd.join(".agents/skills"), &cwd));
1180 assert!(policy.is_path_writable(&cwd.join("src/main.rs"), &cwd));
1181 }
1182
1183 #[test]
1184 fn test_full_access_policy() {
1185 let policy = SandboxPolicy::full_access();
1186 assert!(policy.has_full_network_access());
1187 assert!(policy.has_full_disk_write_access());
1188 }
1189
1190 #[test]
1191 fn test_policy_escalation() {
1192 let read_only = SandboxPolicy::read_only();
1193 let full = SandboxPolicy::full_access();
1194
1195 assert!(read_only.can_set(&full).is_err());
1197
1198 full.can_set(&read_only).unwrap();
1200 }
1201
1202 #[test]
1203 fn test_network_allowlist_entry_matching() {
1204 let entry = NetworkAllowlistEntry::https("api.github.com");
1205 assert!(entry.matches("api.github.com", 443));
1206 assert!(!entry.matches("api.github.com", 80));
1207 assert!(!entry.matches("github.com", 443));
1208 }
1209
1210 #[test]
1211 fn test_network_allowlist_wildcard() {
1212 let entry = NetworkAllowlistEntry::https("*.npmjs.org");
1213 assert!(entry.matches("registry.npmjs.org", 443));
1214 assert!(entry.matches("npmjs.org", 443));
1215 assert!(!entry.matches("npmjs.org.evil.com", 443));
1216 }
1217
1218 #[test]
1219 fn test_workspace_with_network_allowlist() {
1220 let allowlist = vec![
1221 NetworkAllowlistEntry::https("api.github.com"),
1222 NetworkAllowlistEntry::https("*.npmjs.org"),
1223 ];
1224 let policy = SandboxPolicy::workspace_write_with_network(vec![PathBuf::from("/tmp/workspace")], allowlist);
1225
1226 assert!(!policy.has_full_network_access());
1228 assert!(policy.has_network_allowlist());
1229
1230 assert!(policy.is_network_allowed("api.github.com", 443));
1232 assert!(policy.is_network_allowed("registry.npmjs.org", 443));
1233 assert!(!policy.is_network_allowed("evil.com", 443));
1234 assert!(!policy.is_network_allowed("api.github.com", 80));
1235 }
1236
1237 #[test]
1238 fn test_workspace_no_network() {
1239 let policy = SandboxPolicy::workspace_write(vec![PathBuf::from("/tmp/workspace")]);
1240
1241 assert!(!policy.has_full_network_access());
1242 assert!(!policy.has_network_allowlist());
1243 assert!(!policy.is_network_allowed("api.github.com", 443));
1244 }
1245
1246 #[test]
1247 fn test_sensitive_path_expansion() {
1248 let sp = SensitivePath::new("~/.ssh");
1249 let expanded = sp.expand_path();
1250 assert!(expanded.to_string_lossy().contains(".ssh"));
1252 assert!(!expanded.to_string_lossy().starts_with('~'));
1253 }
1254
1255 #[test]
1256 fn test_sensitive_path_matching() {
1257 let sp = SensitivePath::new("~/.ssh");
1258 let expanded = sp.expand_path();
1259 let ssh_key = expanded.join("id_rsa");
1260 assert!(sp.matches(&ssh_key));
1261 assert!(sp.matches(&expanded));
1262 }
1263
1264 #[cfg(not(windows))]
1265 #[test]
1266 fn test_sensitive_path_matching_is_case_insensitive_with_component_boundaries() {
1267 let sp = SensitivePath::new("/tmp/Workspace/.env");
1268
1269 assert!(sp.matches(Path::new("/tmp/workspace/.ENV")));
1270 assert!(sp.matches(Path::new("/tmp/workspace/.ENV/child")));
1271 assert!(!sp.matches(Path::new("/tmp/workspace/.environment")));
1272 assert!(!sp.matches(Path::new("/tmp/workspaces/.ENV")));
1273 }
1274
1275 #[test]
1276 fn test_default_sensitive_paths() {
1277 let paths = default_sensitive_paths();
1278 assert!(!paths.is_empty());
1279 let path_strings: Vec<&str> = paths.iter().map(|p| p.path.as_str()).collect();
1281 assert!(path_strings.contains(&"~/.ssh"));
1282 assert!(path_strings.contains(&"~/.aws"));
1283 assert!(path_strings.contains(&"~/.kube"));
1284 }
1285
1286 #[test]
1287 fn resolved_vtcode_roots_are_sensitive_without_duplicate_entries() {
1288 let environment = [
1289 ("HOME", "/home/tester"),
1290 ("VTCODE_CONFIG", "/vtcode/shared"),
1291 ("VTCODE_DATA", "/vtcode/shared"),
1292 ("XDG_STATE_HOME", "/xdg/state"),
1293 ("XDG_CACHE_HOME", "/xdg/cache"),
1294 ("XDG_RUNTIME_DIR", "/xdg/runtime"),
1295 ("XDG_BIN_HOME", "/xdg/bin"),
1296 ("VTCODE_HOME", "/legacy/vtcode"),
1297 ];
1298 let resolved =
1299 VtCodePaths::from_environment(&environment).expect("explicit absolute VT Code paths should resolve");
1300 let paths = vtcode_sensitive_paths(&environment).expect("explicit absolute VT Code paths should resolve");
1301 let path_strings: Vec<&str> = paths.iter().map(|path| path.path.as_str()).collect();
1302
1303 for expected in [
1304 resolved.config_dir().to_path_buf(),
1305 resolved.auth_dir(),
1306 resolved.data_dir().to_path_buf(),
1307 resolved.state_dir().to_path_buf(),
1308 resolved.cache_dir().to_path_buf(),
1309 resolved.runtime_dir().to_path_buf(),
1310 resolved.executable_dir().to_path_buf(),
1311 resolved.legacy_dir().to_path_buf(),
1312 ] {
1313 let expected = expected.display().to_string();
1314 assert!(path_strings.contains(&expected.as_str()), "missing sensitive root: {expected}");
1315 }
1316 assert_eq!(path_strings.iter().filter(|path| **path == "/vtcode/shared").count(), 1);
1317 }
1318
1319 #[test]
1320 fn invalid_vtcode_path_resolution_is_rejected_before_policy_construction() {
1321 let error = vtcode_sensitive_paths(&[("HOME", "/home/tester"), ("VTCODE_CONFIG", "relative/config")])
1322 .expect_err("relative VT Code config paths must fail closed");
1323 assert!(error.to_string().contains("VTCODE_CONFIG"));
1324 }
1325
1326 #[cfg(windows)]
1327 #[test]
1328 fn test_windows_userprofile_root_exclusions_are_in_defaults() {
1329 let paths = default_sensitive_paths();
1330 let path_strings: Vec<&str> = paths.iter().map(|p| p.path.as_str()).collect();
1331
1332 for entry in USERPROFILE_READ_ROOT_EXCLUSIONS {
1333 let expected = format!("~/{}", entry);
1334 assert!(path_strings.contains(&expected.as_str()), "missing expected default sensitive path: {expected}");
1335 }
1336 }
1337
1338 #[cfg(windows)]
1339 #[test]
1340 fn test_sensitive_path_matching_is_case_insensitive_on_windows() {
1341 let sp = SensitivePath::new("~/.aws");
1342 let home = dirs::home_dir().expect("home dir");
1343 let mixed_case_candidate = home.join(".AWS").join("credentials");
1344
1345 assert!(sp.matches(&mixed_case_candidate));
1346 }
1347
1348 #[test]
1349 fn test_workspace_blocks_sensitive_by_default() {
1350 let policy = SandboxPolicy::workspace_write(vec![PathBuf::from("/tmp/workspace")]);
1351 let sensitive = policy.sensitive_paths();
1352 assert!(!sensitive.is_empty());
1353
1354 if let Some(home) = dirs::home_dir() {
1356 let ssh_path = home.join(".ssh").join("id_rsa");
1357 assert!(policy.is_sensitive_path(&ssh_path));
1358 assert!(!policy.is_path_readable(&ssh_path));
1359 }
1360 }
1361
1362 #[test]
1363 fn test_workspace_no_sensitive_blocking() {
1364 let policy = SandboxPolicy::workspace_write_no_sensitive_blocking(vec![PathBuf::from("/tmp")]);
1365 let sensitive = policy.sensitive_paths();
1366 assert!(sensitive.is_empty());
1367
1368 if let Some(home) = dirs::home_dir() {
1370 let ssh_path = home.join(".ssh").join("id_rsa");
1371 assert!(!policy.is_sensitive_path(&ssh_path));
1372 assert!(policy.is_path_readable(&ssh_path));
1373 }
1374 }
1375
1376 #[test]
1377 fn test_full_access_no_sensitive_blocking() {
1378 let policy = SandboxPolicy::full_access();
1379 let sensitive = policy.sensitive_paths();
1380 assert!(sensitive.is_empty());
1381
1382 if let Some(home) = dirs::home_dir() {
1384 let ssh_path = home.join(".ssh").join("id_rsa");
1385 assert!(policy.is_path_readable(&ssh_path));
1386 }
1387 }
1388
1389 #[test]
1390 fn test_resource_limits_default() {
1391 let limits = ResourceLimits::default();
1392 assert_eq!(limits.max_memory_mb, 0);
1393 assert_eq!(limits.max_pids, 0);
1394 assert_eq!(limits.timeout_secs, 300);
1395 assert!(limits.has_limits());
1396 }
1397
1398 #[test]
1399 fn test_resource_limits_conservative() {
1400 let limits = ResourceLimits::conservative();
1401 assert_eq!(limits.max_memory_mb, 512);
1402 assert_eq!(limits.max_pids, 64);
1403 assert_eq!(limits.cpu_time_secs, 60);
1404 assert!(limits.has_limits());
1405 }
1406
1407 #[test]
1408 fn test_resource_limits_builder() {
1409 let limits = ResourceLimits::default()
1410 .with_memory_mb(1024)
1411 .with_max_pids(128)
1412 .with_timeout_secs(60);
1413 assert_eq!(limits.max_memory_mb, 1024);
1414 assert_eq!(limits.max_pids, 128);
1415 assert_eq!(limits.effective_timeout_secs(), 60);
1416 }
1417
1418 #[test]
1419 fn test_workspace_with_limits() {
1420 let limits = ResourceLimits::conservative();
1421 let policy = SandboxPolicy::workspace_write_with_limits(vec![PathBuf::from("/tmp/workspace")], limits.clone());
1422
1423 let policy_limits = policy.resource_limits();
1424 assert_eq!(policy_limits.max_memory_mb, limits.max_memory_mb);
1425 assert_eq!(policy_limits.max_pids, limits.max_pids);
1426 }
1427
1428 #[test]
1429 fn test_read_only_conservative_limits() {
1430 let policy = SandboxPolicy::read_only();
1431 let limits = policy.resource_limits();
1432 assert!(limits.has_limits());
1434 assert_eq!(limits.max_memory_mb, 512);
1435 }
1436
1437 #[test]
1438 fn test_full_access_unlimited() {
1439 let policy = SandboxPolicy::full_access();
1440 let limits = policy.resource_limits();
1441 assert!(!limits.has_limits());
1443 }
1444
1445 #[test]
1446 fn test_seccomp_profile_strict() {
1447 let profile = SeccompProfile::strict();
1448 assert!(profile.is_blocked("ptrace"));
1449 assert!(profile.is_blocked("mount"));
1450 assert!(profile.is_blocked("kexec_load"));
1451 assert!(profile.is_blocked("bpf"));
1452 assert!(!profile.allow_network_sockets);
1453 assert!(!profile.allow_namespaces);
1454 }
1455
1456 #[test]
1457 fn test_seccomp_profile_permissive() {
1458 let profile = SeccompProfile::permissive();
1459 assert!(profile.is_blocked("ptrace"));
1461 assert!(profile.is_blocked("kexec_load"));
1462 assert!(profile.allow_network_sockets);
1464 }
1465
1466 #[test]
1467 fn test_seccomp_profile_builder() {
1468 let profile = SeccompProfile::strict().with_network().block_syscall("custom_syscall");
1469 assert!(profile.allow_network_sockets);
1470 assert!(profile.is_blocked("custom_syscall"));
1471 }
1472
1473 #[test]
1474 fn test_workspace_seccomp_profile() {
1475 let policy = SandboxPolicy::workspace_write(vec![PathBuf::from("/tmp")]);
1476 let profile = policy.seccomp_profile();
1477 assert!(profile.is_blocked("ptrace"));
1479 assert!(profile.is_blocked("mount"));
1480 }
1481
1482 #[test]
1483 fn test_workspace_with_network_seccomp() {
1484 let policy = SandboxPolicy::workspace_write_with_network(
1485 vec![PathBuf::from("/tmp")],
1486 vec![NetworkAllowlistEntry::https("api.github.com")],
1487 );
1488 let profile = policy.seccomp_profile();
1489 assert!(profile.allow_network_sockets);
1491 }
1492
1493 #[test]
1494 fn test_seccomp_profile_json() {
1495 let profile = SeccompProfile::strict();
1496 let json = profile.to_json().unwrap();
1497 assert!(json.contains("ptrace"));
1498 assert!(json.contains("blocked_syscalls"));
1499 }
1500
1501 #[test]
1502 fn test_blocked_syscalls_constant() {
1503 for must_block in [
1508 "ptrace",
1509 "kcmp",
1510 "pidfd_getfd",
1511 "mount",
1512 "open_by_handle_at",
1513 "name_to_handle_at",
1514 "kexec_load",
1515 "bpf",
1516 "perf_event_open",
1517 "userfaultfd",
1518 "io_uring_setup",
1519 "io_uring_enter",
1520 "io_uring_register",
1521 "process_vm_readv",
1522 "process_madvise",
1523 "unshare",
1524 "setns",
1525 ] {
1526 assert!(BLOCKED_SYSCALLS.contains(&must_block), "missing {must_block}");
1527 }
1528 }
1529}