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 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(crate) fn blocked_syscalls(&self) -> &[String] {
554 &self.blocked_syscalls
555 }
556
557 #[must_use]
559 pub(crate) fn allow_namespaces(&self) -> bool {
560 self.allow_namespaces
561 }
562
563 #[must_use]
565 pub(crate) fn allow_network_sockets(&self) -> bool {
566 self.allow_network_sockets
567 }
568
569 #[must_use]
571 pub(crate) fn log_only(&self) -> bool {
572 self.log_only
573 }
574
575 #[must_use]
577 pub fn strict() -> Self {
578 Self {
579 blocked_syscalls: default_blocked_syscalls(),
580 allow_namespaces: false,
581 allow_network_sockets: false,
582 log_only: false,
583 }
584 }
585
586 #[must_use]
588 pub fn permissive() -> Self {
589 Self {
590 blocked_syscalls: vec![
591 "ptrace".to_string(),
592 "kexec_load".to_string(),
593 "kexec_file_load".to_string(),
594 "reboot".to_string(),
595 ],
596 allow_namespaces: false,
597 allow_network_sockets: true,
598 log_only: false,
599 }
600 }
601
602 #[must_use]
604 pub fn logging() -> Self {
605 Self {
606 blocked_syscalls: default_blocked_syscalls(),
607 allow_namespaces: false,
608 allow_network_sockets: false,
609 log_only: true,
610 }
611 }
612
613 #[must_use]
615 pub fn block_syscall(mut self, syscall: impl Into<String>) -> Self {
616 let syscall = syscall.into();
617 if !self.blocked_syscalls.contains(&syscall) {
618 self.blocked_syscalls.push(syscall);
619 }
620 self
621 }
622
623 #[must_use]
625 pub fn with_network(mut self) -> Self {
626 self.allow_network_sockets = true;
627 self
628 }
629
630 #[must_use]
632 pub fn with_logging(mut self) -> Self {
633 self.log_only = true;
634 self
635 }
636
637 #[inline]
639 #[must_use]
640 fn is_blocked(&self, syscall: &str) -> bool {
641 self.blocked_syscalls.iter().any(|s| s == syscall)
642 }
643
644 pub(crate) fn to_json(&self) -> Result<String, serde_json::Error> {
646 serde_json::to_string(self)
647 }
648}
649
650#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
662#[serde(tag = "type", rename_all = "snake_case")]
663pub enum SandboxPolicy {
664 ReadOnly {
666 #[serde(default)]
668 network_access: bool,
669
670 #[serde(default)]
672 network_allowlist: Vec<NetworkAllowlistEntry>,
673 },
674
675 WorkspaceWrite {
677 writable_roots: Vec<WritableRoot>,
679
680 #[serde(default)]
682 network_access: bool,
683
684 #[serde(default)]
688 network_allowlist: Vec<NetworkAllowlistEntry>,
689
690 #[serde(default)]
694 sensitive_paths: Option<Vec<SensitivePath>>,
695
696 #[serde(default)]
699 resource_limits: ResourceLimits,
700
701 #[serde(default)]
704 seccomp_profile: SeccompProfile,
705
706 #[serde(default)]
708 exclude_tmpdir_env_var: bool,
709
710 #[serde(default)]
712 exclude_slash_tmp: bool,
713 },
714
715 DangerFullAccess,
718
719 ExternalSandbox {
721 description: String,
723 },
724}
725
726impl SandboxPolicy {
727 #[must_use]
729 pub fn read_only() -> Self {
730 Self::ReadOnly {
731 network_access: false,
732 network_allowlist: Vec::new(),
733 }
734 }
735
736 #[must_use]
738 pub fn new_read_only_policy() -> Self {
739 Self::read_only()
740 }
741
742 #[must_use]
744 pub fn read_only_with_network(network_allowlist: Vec<NetworkAllowlistEntry>) -> Self {
745 Self::ReadOnly {
746 network_access: !network_allowlist.is_empty(),
747 network_allowlist,
748 }
749 }
750
751 #[must_use]
753 pub fn read_only_with_full_network() -> Self {
754 Self::ReadOnly {
755 network_access: true,
756 network_allowlist: Vec::new(),
757 }
758 }
759
760 #[must_use]
763 pub fn workspace_write(writable_roots: Vec<PathBuf>) -> Self {
764 Self::WorkspaceWrite {
765 writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
766 network_access: false,
767 network_allowlist: Vec::new(),
768 sensitive_paths: None,
769 resource_limits: ResourceLimits::default(),
770 seccomp_profile: SeccompProfile::strict(),
771 exclude_tmpdir_env_var: true,
772 exclude_slash_tmp: true,
773 }
774 }
775
776 #[must_use]
778 fn workspace_write_with_network(
779 writable_roots: Vec<PathBuf>,
780 network_allowlist: Vec<NetworkAllowlistEntry>,
781 ) -> Self {
782 Self::WorkspaceWrite {
783 writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
784 network_access: !network_allowlist.is_empty(),
785 network_allowlist,
786 sensitive_paths: None,
787 resource_limits: ResourceLimits::default(),
788 seccomp_profile: SeccompProfile::strict().with_network(),
789 exclude_tmpdir_env_var: true,
790 exclude_slash_tmp: true,
791 }
792 }
793
794 #[must_use]
796 pub fn workspace_write_with_sensitive_paths(
797 writable_roots: Vec<PathBuf>,
798 sensitive_paths: Vec<SensitivePath>,
799 ) -> Self {
800 Self::WorkspaceWrite {
801 writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
802 network_access: false,
803 network_allowlist: Vec::new(),
804 sensitive_paths: Some(sensitive_paths),
805 resource_limits: ResourceLimits::default(),
806 seccomp_profile: SeccompProfile::strict(),
807 exclude_tmpdir_env_var: true,
808 exclude_slash_tmp: true,
809 }
810 }
811
812 #[must_use]
814 fn workspace_write_no_sensitive_blocking(writable_roots: Vec<PathBuf>) -> Self {
815 Self::WorkspaceWrite {
816 writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
817 network_access: false,
818 network_allowlist: Vec::new(),
819 sensitive_paths: Some(Vec::new()),
820 resource_limits: ResourceLimits::default(),
821 seccomp_profile: SeccompProfile::strict(),
822 exclude_tmpdir_env_var: true,
823 exclude_slash_tmp: true,
824 }
825 }
826
827 #[must_use]
830 fn workspace_write_with_limits(writable_roots: Vec<PathBuf>, resource_limits: ResourceLimits) -> Self {
831 Self::WorkspaceWrite {
832 writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
833 network_access: false,
834 network_allowlist: Vec::new(),
835 sensitive_paths: None,
836 resource_limits,
837 seccomp_profile: SeccompProfile::strict(),
838 exclude_tmpdir_env_var: true,
839 exclude_slash_tmp: true,
840 }
841 }
842
843 #[must_use]
845 pub fn workspace_write_full(
846 writable_roots: Vec<PathBuf>,
847 network_allowlist: Vec<NetworkAllowlistEntry>,
848 sensitive_paths: Option<Vec<SensitivePath>>,
849 resource_limits: ResourceLimits,
850 seccomp_profile: SeccompProfile,
851 ) -> Self {
852 Self::WorkspaceWrite {
853 writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
854 network_access: !network_allowlist.is_empty(),
855 network_allowlist,
856 sensitive_paths,
857 resource_limits,
858 seccomp_profile,
859 exclude_tmpdir_env_var: true,
860 exclude_slash_tmp: true,
861 }
862 }
863
864 #[must_use]
866 pub fn full_access() -> Self {
867 Self::DangerFullAccess
868 }
869
870 #[inline]
872 #[must_use]
873 pub fn has_full_network_access(&self) -> bool {
874 match self {
875 Self::ReadOnly { network_access, network_allowlist }
876 | Self::WorkspaceWrite { network_access, network_allowlist, .. } => {
877 *network_access && network_allowlist.is_empty()
878 }
879 Self::DangerFullAccess | Self::ExternalSandbox { .. } => true,
880 }
881 }
882
883 #[inline]
885 #[must_use]
886 pub fn has_network_allowlist(&self) -> bool {
887 match self {
888 Self::ReadOnly { network_allowlist, .. } | Self::WorkspaceWrite { network_allowlist, .. } => {
889 !network_allowlist.is_empty()
890 }
891 _ => false,
892 }
893 }
894
895 #[inline]
897 #[must_use]
898 pub fn network_allowlist(&self) -> &[NetworkAllowlistEntry] {
899 match self {
900 Self::ReadOnly { network_allowlist, .. } | Self::WorkspaceWrite { network_allowlist, .. } => {
901 network_allowlist
902 }
903 _ => &[],
904 }
905 }
906
907 #[inline]
909 #[must_use]
910 pub fn is_network_allowed(&self, domain: &str, port: u16) -> bool {
911 match self {
912 Self::ReadOnly { network_access, network_allowlist }
913 | Self::WorkspaceWrite { network_access, network_allowlist, .. } => {
914 if network_allowlist.is_empty() {
915 *network_access
916 } else {
917 network_allowlist.iter().any(|entry| entry.matches(domain, port))
918 }
919 }
920 Self::DangerFullAccess | Self::ExternalSandbox { .. } => true,
921 }
922 }
923
924 #[must_use]
927 fn sensitive_paths(&self) -> Vec<SensitivePath> {
928 match self {
929 Self::ReadOnly { .. } => default_sensitive_paths(),
930 Self::WorkspaceWrite { sensitive_paths, .. } => {
931 sensitive_paths.clone().unwrap_or_else(default_sensitive_paths)
932 }
933 Self::DangerFullAccess | Self::ExternalSandbox { .. } => Vec::new(),
934 }
935 }
936
937 #[must_use]
939 pub(crate) fn sensitive_paths_for_execution(&self, cwd: &Path) -> Vec<SensitivePath> {
940 match self {
941 Self::WorkspaceWrite { .. } => {
942 let mut sensitive_paths = self.sensitive_paths();
943 sensitive_paths.extend(protected_writable_root_sensitive_paths(&self.get_writable_roots_with_cwd(cwd)));
944 sensitive_paths
945 }
946 _ => self.sensitive_paths(),
947 }
948 }
949
950 #[inline]
952 #[must_use]
953 fn is_sensitive_path(&self, path: &Path) -> bool {
954 self.sensitive_paths().iter().any(|sp| sp.matches(path) && sp.block_read)
955 }
956
957 #[inline]
959 #[must_use]
960 fn is_path_write_blocked(&self, path: &Path, cwd: &Path) -> bool {
961 match self {
962 Self::DangerFullAccess | Self::ExternalSandbox { .. } => false,
963 _ => self
964 .sensitive_paths_for_execution(cwd)
965 .iter()
966 .any(|sp| sp.matches(path) && sp.block_write),
967 }
968 }
969
970 #[inline]
972 #[must_use]
973 pub fn is_path_readable(&self, path: &Path) -> bool {
974 match self {
975 Self::DangerFullAccess | Self::ExternalSandbox { .. } => true,
976 _ => !self.is_sensitive_path(path),
977 }
978 }
979
980 #[must_use]
982 pub fn resource_limits(&self) -> ResourceLimits {
983 match self {
984 Self::ReadOnly { .. } => ResourceLimits::conservative(),
985 Self::WorkspaceWrite { resource_limits, .. } => resource_limits.clone(),
986 Self::DangerFullAccess | Self::ExternalSandbox { .. } => ResourceLimits::unlimited(),
987 }
988 }
989
990 #[must_use]
992 pub fn seccomp_profile(&self) -> SeccompProfile {
993 match self {
994 Self::ReadOnly { network_access, network_allowlist } => {
995 let mut profile = SeccompProfile::strict();
996 if *network_access || !network_allowlist.is_empty() {
997 profile = profile.with_network();
998 }
999 profile
1000 }
1001 Self::WorkspaceWrite { seccomp_profile, .. } => seccomp_profile.clone(),
1002 Self::DangerFullAccess | Self::ExternalSandbox { .. } => SeccompProfile::permissive(),
1003 }
1004 }
1005
1006 #[inline]
1008 #[must_use]
1009 fn has_full_disk_write_access(&self) -> bool {
1010 matches!(self, Self::DangerFullAccess | Self::ExternalSandbox { .. })
1011 }
1012
1013 #[inline]
1015 #[must_use]
1016 fn has_full_disk_read_access(&self) -> bool {
1017 true
1018 }
1019
1020 #[must_use]
1022 pub(crate) fn get_writable_roots_with_cwd(&self, cwd: &Path) -> Vec<WritableRoot> {
1023 match self {
1024 Self::ReadOnly { .. } => vec![],
1025 Self::WorkspaceWrite { writable_roots, .. } => {
1026 let mut roots = writable_roots.clone();
1027 let cwd_root = WritableRoot::new(cwd);
1028 if !roots.contains(&cwd_root) {
1029 roots.push(cwd_root);
1030 }
1031 roots
1032 }
1033 Self::DangerFullAccess | Self::ExternalSandbox { .. } => {
1034 vec![WritableRoot::new(cwd)]
1035 }
1036 }
1037 }
1038
1039 #[inline]
1041 #[must_use]
1042 pub fn is_path_writable(&self, path: &Path, cwd: &Path) -> bool {
1043 match self {
1044 Self::ReadOnly { .. } => false,
1045 Self::WorkspaceWrite { .. } => {
1046 let writable = self.get_writable_roots_with_cwd(cwd);
1047 writable.iter().any(|root| path.starts_with(&root.root)) && !self.is_path_write_blocked(path, cwd)
1048 }
1049 Self::DangerFullAccess | Self::ExternalSandbox { .. } => true,
1050 }
1051 }
1052
1053 fn can_set(&self, new_policy: &SandboxPolicy) -> anyhow::Result<()> {
1056 use SandboxPolicy::*;
1057
1058 match (self, new_policy) {
1059 (DangerFullAccess, _) => Ok(()),
1061 (ReadOnly { .. }, WorkspaceWrite { .. } | DangerFullAccess) => {
1063 Err(anyhow::anyhow!("cannot escalate from read-only to write-capable policy"))
1064 }
1065 _ => Ok(()),
1067 }
1068 }
1069
1070 pub fn description(&self) -> &'static str {
1072 match self {
1073 Self::ReadOnly { .. } => "read-only access",
1074 Self::WorkspaceWrite { .. } => "workspace write access",
1075 Self::DangerFullAccess => "full access (dangerous)",
1076 Self::ExternalSandbox { .. } => "external sandbox",
1077 }
1078 }
1079}
1080
1081impl Default for SandboxPolicy {
1082 fn default() -> Self {
1083 Self::read_only()
1084 }
1085}
1086
1087#[cfg(test)]
1088mod tests {
1089 use super::*;
1090
1091 #[test]
1092 fn test_read_only_policy() {
1093 let policy = SandboxPolicy::read_only();
1094 assert!(!policy.has_full_network_access());
1095 assert!(!policy.has_network_allowlist());
1096 assert!(!policy.has_full_disk_write_access());
1097 assert!(policy.has_full_disk_read_access());
1098 }
1099
1100 #[test]
1101 fn test_read_only_with_network_allowlist() {
1102 let policy = SandboxPolicy::read_only_with_network(vec![
1103 NetworkAllowlistEntry::https("api.github.com"),
1104 NetworkAllowlistEntry::with_port("registry.npmjs.org", 443),
1105 ]);
1106
1107 assert!(!policy.has_full_network_access());
1108 assert!(policy.has_network_allowlist());
1109 assert!(policy.is_network_allowed("api.github.com", 443));
1110 assert!(policy.is_network_allowed("registry.npmjs.org", 443));
1111 assert!(!policy.is_network_allowed("example.com", 443));
1112 }
1113
1114 #[test]
1115 fn test_read_only_with_full_network_access() {
1116 let policy = SandboxPolicy::read_only_with_full_network();
1117
1118 assert!(policy.has_full_network_access());
1119 assert!(policy.is_network_allowed("example.com", 443));
1120 assert!(policy.seccomp_profile().allow_network_sockets);
1121 }
1122
1123 #[test]
1124 fn test_read_only_deserializes_legacy_shape() {
1125 let policy: SandboxPolicy = serde_json::from_str(r#"{"type":"read_only"}"#).expect("legacy read-only policy");
1126
1127 assert_eq!(policy, SandboxPolicy::read_only());
1128 }
1129
1130 #[test]
1131 fn test_workspace_write_policy() {
1132 let policy = SandboxPolicy::workspace_write(vec![PathBuf::from("/tmp/workspace")]);
1133 assert!(!policy.has_full_network_access());
1134 assert!(!policy.has_full_disk_write_access());
1135
1136 let cwd = PathBuf::from("/tmp/workspace");
1137 assert!(policy.is_path_writable(&cwd, &cwd));
1138 assert!(!policy.is_path_writable(&PathBuf::from("/etc"), &cwd));
1139 }
1140
1141 #[test]
1142 fn test_workspace_write_protects_internal_metadata_dirs() {
1143 let cwd = PathBuf::from("/tmp/workspace");
1144 let policy = SandboxPolicy::workspace_write(vec![cwd.clone()]);
1145
1146 assert!(!policy.is_path_writable(&cwd.join(".git/config"), &cwd));
1147 assert!(!policy.is_path_writable(&cwd.join(".vtcode/cache"), &cwd));
1148 assert!(!policy.is_path_writable(&cwd.join(".codex/state"), &cwd));
1149 assert!(!policy.is_path_writable(&cwd.join(".agents/skills"), &cwd));
1150 assert!(policy.is_path_writable(&cwd.join("src/main.rs"), &cwd));
1151 }
1152
1153 #[test]
1154 fn test_full_access_policy() {
1155 let policy = SandboxPolicy::full_access();
1156 assert!(policy.has_full_network_access());
1157 assert!(policy.has_full_disk_write_access());
1158 }
1159
1160 #[test]
1161 fn test_policy_escalation() {
1162 let read_only = SandboxPolicy::read_only();
1163 let full = SandboxPolicy::full_access();
1164
1165 assert!(read_only.can_set(&full).is_err());
1167
1168 full.can_set(&read_only).unwrap();
1170 }
1171
1172 #[test]
1173 fn test_network_allowlist_entry_matching() {
1174 let entry = NetworkAllowlistEntry::https("api.github.com");
1175 assert!(entry.matches("api.github.com", 443));
1176 assert!(!entry.matches("api.github.com", 80));
1177 assert!(!entry.matches("github.com", 443));
1178 }
1179
1180 #[test]
1181 fn test_network_allowlist_wildcard() {
1182 let entry = NetworkAllowlistEntry::https("*.npmjs.org");
1183 assert!(entry.matches("registry.npmjs.org", 443));
1184 assert!(entry.matches("npmjs.org", 443));
1185 assert!(!entry.matches("npmjs.org.evil.com", 443));
1186 }
1187
1188 #[test]
1189 fn test_workspace_with_network_allowlist() {
1190 let allowlist = vec![
1191 NetworkAllowlistEntry::https("api.github.com"),
1192 NetworkAllowlistEntry::https("*.npmjs.org"),
1193 ];
1194 let policy = SandboxPolicy::workspace_write_with_network(vec![PathBuf::from("/tmp/workspace")], allowlist);
1195
1196 assert!(!policy.has_full_network_access());
1198 assert!(policy.has_network_allowlist());
1199
1200 assert!(policy.is_network_allowed("api.github.com", 443));
1202 assert!(policy.is_network_allowed("registry.npmjs.org", 443));
1203 assert!(!policy.is_network_allowed("evil.com", 443));
1204 assert!(!policy.is_network_allowed("api.github.com", 80));
1205 }
1206
1207 #[test]
1208 fn test_workspace_no_network() {
1209 let policy = SandboxPolicy::workspace_write(vec![PathBuf::from("/tmp/workspace")]);
1210
1211 assert!(!policy.has_full_network_access());
1212 assert!(!policy.has_network_allowlist());
1213 assert!(!policy.is_network_allowed("api.github.com", 443));
1214 }
1215
1216 #[test]
1217 fn test_sensitive_path_expansion() {
1218 let sp = SensitivePath::new("~/.ssh");
1219 let expanded = sp.expand_path();
1220 assert!(expanded.to_string_lossy().contains(".ssh"));
1222 assert!(!expanded.to_string_lossy().starts_with('~'));
1223 }
1224
1225 #[test]
1226 fn test_sensitive_path_matching() {
1227 let sp = SensitivePath::new("~/.ssh");
1228 let expanded = sp.expand_path();
1229 let ssh_key = expanded.join("id_rsa");
1230 assert!(sp.matches(&ssh_key));
1231 assert!(sp.matches(&expanded));
1232 }
1233
1234 #[cfg(not(windows))]
1235 #[test]
1236 fn test_sensitive_path_matching_is_case_insensitive_with_component_boundaries() {
1237 let sp = SensitivePath::new("/tmp/Workspace/.env");
1238
1239 assert!(sp.matches(Path::new("/tmp/workspace/.ENV")));
1240 assert!(sp.matches(Path::new("/tmp/workspace/.ENV/child")));
1241 assert!(!sp.matches(Path::new("/tmp/workspace/.environment")));
1242 assert!(!sp.matches(Path::new("/tmp/workspaces/.ENV")));
1243 }
1244
1245 #[test]
1246 fn test_default_sensitive_paths() {
1247 let paths = default_sensitive_paths();
1248 assert!(!paths.is_empty());
1249 let path_strings: Vec<&str> = paths.iter().map(|p| p.path.as_str()).collect();
1251 assert!(path_strings.contains(&"~/.ssh"));
1252 assert!(path_strings.contains(&"~/.aws"));
1253 assert!(path_strings.contains(&"~/.kube"));
1254 }
1255
1256 #[test]
1257 fn resolved_vtcode_roots_are_sensitive_without_duplicate_entries() {
1258 let environment = [
1259 ("HOME", "/home/tester"),
1260 ("VTCODE_CONFIG", "/vtcode/shared"),
1261 ("VTCODE_DATA", "/vtcode/shared"),
1262 ("XDG_STATE_HOME", "/xdg/state"),
1263 ("XDG_CACHE_HOME", "/xdg/cache"),
1264 ("XDG_RUNTIME_DIR", "/xdg/runtime"),
1265 ("XDG_BIN_HOME", "/xdg/bin"),
1266 ("VTCODE_HOME", "/legacy/vtcode"),
1267 ];
1268 let resolved =
1269 VtCodePaths::from_environment(&environment).expect("explicit absolute VT Code paths should resolve");
1270 let paths = vtcode_sensitive_paths(&environment).expect("explicit absolute VT Code paths should resolve");
1271 let path_strings: Vec<&str> = paths.iter().map(|path| path.path.as_str()).collect();
1272
1273 for expected in [
1274 resolved.config_dir().to_path_buf(),
1275 resolved.auth_dir(),
1276 resolved.data_dir().to_path_buf(),
1277 resolved.state_dir().to_path_buf(),
1278 resolved.cache_dir().to_path_buf(),
1279 resolved.runtime_dir().to_path_buf(),
1280 resolved.executable_dir().to_path_buf(),
1281 resolved.legacy_dir().to_path_buf(),
1282 ] {
1283 let expected = expected.display().to_string();
1284 assert!(path_strings.contains(&expected.as_str()), "missing sensitive root: {expected}");
1285 }
1286 assert_eq!(path_strings.iter().filter(|path| **path == "/vtcode/shared").count(), 1);
1287 }
1288
1289 #[test]
1290 fn invalid_vtcode_path_resolution_is_rejected_before_policy_construction() {
1291 let error = vtcode_sensitive_paths(&[("HOME", "/home/tester"), ("VTCODE_CONFIG", "relative/config")])
1292 .expect_err("relative VT Code config paths must fail closed");
1293 assert!(error.to_string().contains("VTCODE_CONFIG"));
1294 }
1295
1296 #[cfg(windows)]
1297 #[test]
1298 fn test_windows_userprofile_root_exclusions_are_in_defaults() {
1299 let paths = default_sensitive_paths();
1300 let path_strings: Vec<&str> = paths.iter().map(|p| p.path.as_str()).collect();
1301
1302 for entry in USERPROFILE_READ_ROOT_EXCLUSIONS {
1303 let expected = format!("~/{}", entry);
1304 assert!(path_strings.contains(&expected.as_str()), "missing expected default sensitive path: {expected}");
1305 }
1306 }
1307
1308 #[cfg(windows)]
1309 #[test]
1310 fn test_sensitive_path_matching_is_case_insensitive_on_windows() {
1311 let sp = SensitivePath::new("~/.aws");
1312 let home = dirs::home_dir().expect("home dir");
1313 let mixed_case_candidate = home.join(".AWS").join("credentials");
1314
1315 assert!(sp.matches(&mixed_case_candidate));
1316 }
1317
1318 #[test]
1319 fn test_workspace_blocks_sensitive_by_default() {
1320 let policy = SandboxPolicy::workspace_write(vec![PathBuf::from("/tmp/workspace")]);
1321 let sensitive = policy.sensitive_paths();
1322 assert!(!sensitive.is_empty());
1323
1324 if let Some(home) = dirs::home_dir() {
1326 let ssh_path = home.join(".ssh").join("id_rsa");
1327 assert!(policy.is_sensitive_path(&ssh_path));
1328 assert!(!policy.is_path_readable(&ssh_path));
1329 }
1330 }
1331
1332 #[test]
1333 fn test_workspace_no_sensitive_blocking() {
1334 let policy = SandboxPolicy::workspace_write_no_sensitive_blocking(vec![PathBuf::from("/tmp")]);
1335 let sensitive = policy.sensitive_paths();
1336 assert!(sensitive.is_empty());
1337
1338 if let Some(home) = dirs::home_dir() {
1340 let ssh_path = home.join(".ssh").join("id_rsa");
1341 assert!(!policy.is_sensitive_path(&ssh_path));
1342 assert!(policy.is_path_readable(&ssh_path));
1343 }
1344 }
1345
1346 #[test]
1347 fn test_full_access_no_sensitive_blocking() {
1348 let policy = SandboxPolicy::full_access();
1349 let sensitive = policy.sensitive_paths();
1350 assert!(sensitive.is_empty());
1351
1352 if let Some(home) = dirs::home_dir() {
1354 let ssh_path = home.join(".ssh").join("id_rsa");
1355 assert!(policy.is_path_readable(&ssh_path));
1356 }
1357 }
1358
1359 #[test]
1360 fn test_resource_limits_default() {
1361 let limits = ResourceLimits::default();
1362 assert_eq!(limits.max_memory_mb, 0);
1363 assert_eq!(limits.max_pids, 0);
1364 assert_eq!(limits.timeout_secs, 300);
1365 assert!(limits.has_limits());
1366 }
1367
1368 #[test]
1369 fn test_resource_limits_conservative() {
1370 let limits = ResourceLimits::conservative();
1371 assert_eq!(limits.max_memory_mb, 512);
1372 assert_eq!(limits.max_pids, 64);
1373 assert_eq!(limits.cpu_time_secs, 60);
1374 assert!(limits.has_limits());
1375 }
1376
1377 #[test]
1378 fn test_resource_limits_builder() {
1379 let limits = ResourceLimits::default()
1380 .with_memory_mb(1024)
1381 .with_max_pids(128)
1382 .with_timeout_secs(60);
1383 assert_eq!(limits.max_memory_mb, 1024);
1384 assert_eq!(limits.max_pids, 128);
1385 assert_eq!(limits.effective_timeout_secs(), 60);
1386 }
1387
1388 #[test]
1389 fn test_workspace_with_limits() {
1390 let limits = ResourceLimits::conservative();
1391 let policy = SandboxPolicy::workspace_write_with_limits(vec![PathBuf::from("/tmp/workspace")], limits.clone());
1392
1393 let policy_limits = policy.resource_limits();
1394 assert_eq!(policy_limits.max_memory_mb, limits.max_memory_mb);
1395 assert_eq!(policy_limits.max_pids, limits.max_pids);
1396 }
1397
1398 #[test]
1399 fn test_read_only_conservative_limits() {
1400 let policy = SandboxPolicy::read_only();
1401 let limits = policy.resource_limits();
1402 assert!(limits.has_limits());
1404 assert_eq!(limits.max_memory_mb, 512);
1405 }
1406
1407 #[test]
1408 fn test_full_access_unlimited() {
1409 let policy = SandboxPolicy::full_access();
1410 let limits = policy.resource_limits();
1411 assert!(!limits.has_limits());
1413 }
1414
1415 #[test]
1416 fn test_seccomp_profile_strict() {
1417 let profile = SeccompProfile::strict();
1418 assert!(profile.is_blocked("ptrace"));
1419 assert!(profile.is_blocked("mount"));
1420 assert!(profile.is_blocked("kexec_load"));
1421 assert!(profile.is_blocked("bpf"));
1422 assert!(!profile.allow_network_sockets);
1423 assert!(!profile.allow_namespaces);
1424 }
1425
1426 #[test]
1427 fn test_seccomp_profile_permissive() {
1428 let profile = SeccompProfile::permissive();
1429 assert!(profile.is_blocked("ptrace"));
1431 assert!(profile.is_blocked("kexec_load"));
1432 assert!(profile.allow_network_sockets);
1434 }
1435
1436 #[test]
1437 fn test_seccomp_profile_builder() {
1438 let profile = SeccompProfile::strict().with_network().block_syscall("custom_syscall");
1439 assert!(profile.allow_network_sockets);
1440 assert!(profile.is_blocked("custom_syscall"));
1441 }
1442
1443 #[test]
1444 fn test_workspace_seccomp_profile() {
1445 let policy = SandboxPolicy::workspace_write(vec![PathBuf::from("/tmp")]);
1446 let profile = policy.seccomp_profile();
1447 assert!(profile.is_blocked("ptrace"));
1449 assert!(profile.is_blocked("mount"));
1450 }
1451
1452 #[test]
1453 fn test_workspace_with_network_seccomp() {
1454 let policy = SandboxPolicy::workspace_write_with_network(
1455 vec![PathBuf::from("/tmp")],
1456 vec![NetworkAllowlistEntry::https("api.github.com")],
1457 );
1458 let profile = policy.seccomp_profile();
1459 assert!(profile.allow_network_sockets);
1461 }
1462
1463 #[test]
1464 fn test_seccomp_profile_json() {
1465 let profile = SeccompProfile::strict();
1466 let json = profile.to_json().unwrap();
1467 assert!(json.contains("ptrace"));
1468 assert!(json.contains("blocked_syscalls"));
1469 }
1470
1471 #[test]
1472 fn test_blocked_syscalls_constant() {
1473 assert!(BLOCKED_SYSCALLS.contains(&"ptrace"));
1475 assert!(BLOCKED_SYSCALLS.contains(&"mount"));
1476 assert!(BLOCKED_SYSCALLS.contains(&"kexec_load"));
1477 assert!(BLOCKED_SYSCALLS.contains(&"bpf"));
1478 assert!(BLOCKED_SYSCALLS.contains(&"perf_event_open"));
1479 }
1480}