1use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12use vtcode_commons::VtCodePaths;
13
14mod seccomp;
15
16pub use seccomp::{BLOCKED_SYSCALLS, FILTERED_SYSCALLS, SECCOMP_PROFILE_VERSION, SeccompProfile};
17
18#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
20
21pub struct WritableRoot {
22 pub root: PathBuf,
24}
25
26impl WritableRoot {
27 #[must_use]
29 pub fn new(path: impl Into<PathBuf>) -> Self {
30 Self { root: path.into() }
31 }
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
38pub struct NetworkAllowlistEntry {
39 pub(crate) domain: String,
41 #[serde(default = "default_https_port")]
43 pub(crate) port: u16,
44 #[serde(default = "default_protocol")]
46 pub(crate) protocol: String,
47}
48
49fn default_https_port() -> u16 {
50 443
51}
52
53fn default_protocol() -> String {
54 "tcp".to_string()
55}
56
57impl NetworkAllowlistEntry {
58 #[must_use]
60 pub fn https(domain: impl Into<String>) -> Self {
61 Self {
62 domain: domain.into(),
63 port: 443,
64 protocol: "tcp".to_string(),
65 }
66 }
67
68 #[must_use]
70 pub fn with_port(domain: impl Into<String>, port: u16) -> Self {
71 Self {
72 domain: domain.into(),
73 port,
74 protocol: "tcp".to_string(),
75 }
76 }
77
78 #[inline]
80 fn matches(&self, domain: &str, port: u16) -> bool {
81 if self.port != port {
82 return false;
83 }
84 if self.domain.starts_with("*.") {
85 let suffix = self.domain.get(1..).unwrap_or_default();
86 let exact = self.domain.get(2..).unwrap_or_default();
87 domain.ends_with(suffix) || domain == exact
88 } else {
89 domain == self.domain
90 }
91 }
92}
93
94pub const DEFAULT_SENSITIVE_PATHS: &[&str] = &[
99 "~/.ssh",
101 "~/.aws",
103 "~/.config/gcloud",
105 "~/.azure",
107 "~/.kube",
109 "~/.docker",
111 "~/.npmrc",
113 "~/.pypirc",
115 "~/.config/gh",
117 "~/.secrets",
119 "~/.gnupg",
121 "~/.config/op",
123 "~/.vault-token",
125 "~/.terraform.d/credentials.tfrc.json",
127 "~/.cargo/credentials.toml",
129 "~/.git-credentials",
131 "~/.netrc",
133];
134
135#[cfg(windows)]
136const USERPROFILE_READ_ROOT_EXCLUSIONS: &[&str] = &[
137 ".ssh",
138 ".gnupg",
139 ".aws",
140 ".azure",
141 ".kube",
142 ".docker",
143 ".config",
144 ".npm",
145 ".pki",
146 ".terraform.d",
147];
148
149#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
151pub struct SensitivePath {
152 path: String,
154 #[serde(default = "default_true")]
156 pub(crate) block_read: bool,
157 #[serde(default = "default_true")]
159 pub(crate) block_write: bool,
160}
161
162fn default_true() -> bool {
163 true
164}
165
166impl SensitivePath {
167 #[must_use]
169 pub fn new(path: impl Into<String>) -> Self {
170 Self {
171 path: path.into(),
172 block_read: true,
173 block_write: true,
174 }
175 }
176
177 #[must_use]
179 fn write_only(path: impl Into<String>) -> Self {
180 Self {
181 path: path.into(),
182 block_read: false,
183 block_write: true,
184 }
185 }
186
187 pub fn expand_path(&self) -> PathBuf {
189 if self.path.starts_with("~/")
190 && let Some(home) = dirs::home_dir()
191 {
192 return home.join(self.path.get(2..).unwrap_or_default());
193 } else if self.path == "~"
194 && let Some(home) = dirs::home_dir()
195 {
196 return home;
197 }
198 PathBuf::from(&self.path)
199 }
200
201 pub(crate) fn matches(&self, path: &Path) -> bool {
203 let expanded = self.expand_path();
204 #[cfg(windows)]
205 {
206 let path_norm = normalize_windows_path(path);
207 let expanded_norm = normalize_windows_path(&expanded);
208 let mut expanded_prefix = expanded_norm.clone();
209 if !expanded_prefix.ends_with('/') {
210 expanded_prefix.push('/');
211 }
212 path_norm == expanded_norm || path_norm.starts_with(&expanded_prefix)
213 }
214 #[cfg(not(windows))]
215 path_starts_with_case_insensitive(path, &expanded)
216 }
217}
218
219#[cfg(not(windows))]
220pub(crate) fn path_starts_with_case_insensitive(path: &Path, prefix: &Path) -> bool {
221 let mut path_components = path.components();
222 prefix.components().all(|prefix_component| {
223 path_components.next().is_some_and(|path_component| {
224 path_component
225 .as_os_str()
226 .to_string_lossy()
227 .eq_ignore_ascii_case(prefix_component.as_os_str().to_string_lossy().as_ref())
228 })
229 })
230}
231
232#[cfg(windows)]
233fn normalize_windows_path(path: &Path) -> String {
234 path.to_string_lossy().replace('\\', "/").to_ascii_lowercase()
235}
236
237pub fn default_sensitive_paths() -> Vec<SensitivePath> {
239 match vtcode_sensitive_paths(&[]) {
240 Ok(paths) => paths,
241 Err(error) => {
242 tracing::warn!(%error, "VT Code path resolution failed; blocking absolute paths fail-closed");
243 let mut paths: Vec<SensitivePath> =
244 DEFAULT_SENSITIVE_PATHS.iter().map(|p| SensitivePath::new(*p)).collect();
245 paths.push(SensitivePath::new("/"));
246 paths
247 }
248 }
249}
250
251fn vtcode_sensitive_paths(environment: &[(&str, &str)]) -> anyhow::Result<Vec<SensitivePath>> {
252 let resolved = if environment.is_empty() {
253 VtCodePaths::resolve()?
254 } else {
255 VtCodePaths::from_environment(environment)?
256 };
257 let mut paths: Vec<SensitivePath> = DEFAULT_SENSITIVE_PATHS.iter().map(|p| SensitivePath::new(*p)).collect();
258 let resolved_roots = [
259 resolved.config_dir().to_path_buf(),
260 resolved.auth_dir(),
261 resolved.data_dir().to_path_buf(),
262 resolved.state_dir().to_path_buf(),
263 resolved.cache_dir().to_path_buf(),
264 resolved.runtime_dir().to_path_buf(),
265 resolved.executable_dir().to_path_buf(),
266 resolved.legacy_dir().to_path_buf(),
267 ];
268 for root in resolved_roots {
269 let path = root.display().to_string();
270 if !paths.iter().any(|existing| existing.path == path) {
271 paths.push(SensitivePath::new(path));
272 }
273 }
274
275 #[cfg(windows)]
276 {
277 for entry in USERPROFILE_READ_ROOT_EXCLUSIONS {
278 let path = format!("~/{}", entry);
279 if !paths.iter().any(|existing| existing.path == path) {
280 paths.push(SensitivePath::new(path));
281 }
282 }
283 Ok(paths)
284 }
285
286 #[cfg(not(windows))]
287 Ok(paths)
288}
289
290const PROTECTED_WRITABLE_ROOT_DIR_NAMES: &[&str] = &[".git", ".vtcode", ".codex", ".agents"];
291
292fn protected_writable_root_sensitive_paths(writable_roots: &[WritableRoot]) -> Vec<SensitivePath> {
293 let mut paths = Vec::new();
294
295 for root in writable_roots {
296 for dir_name in PROTECTED_WRITABLE_ROOT_DIR_NAMES {
297 let protected_path = root.root.join(dir_name).display().to_string();
298 if !paths.iter().any(|existing: &SensitivePath| {
299 existing.path == protected_path && !existing.block_read && existing.block_write
300 }) {
301 paths.push(SensitivePath::write_only(protected_path));
302 }
303 }
304 }
305
306 paths
307}
308
309#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
314pub struct ResourceLimits {
315 #[serde(default)]
317 pub max_memory_mb: u64,
318
319 #[serde(default)]
322 pub max_pids: u32,
323
324 #[serde(default)]
326 pub max_disk_mb: u64,
327
328 #[serde(default)]
330 pub cpu_time_secs: u64,
331
332 #[serde(default)]
334 pub timeout_secs: u64,
335}
336
337impl Default for ResourceLimits {
338 fn default() -> Self {
339 Self {
340 max_memory_mb: 0, max_pids: 0, max_disk_mb: 0, cpu_time_secs: 0, timeout_secs: 300, }
346 }
347}
348
349impl ResourceLimits {
350 #[must_use]
352 pub fn unlimited() -> Self {
353 Self {
354 max_memory_mb: 0,
355 max_pids: 0,
356 max_disk_mb: 0,
357 cpu_time_secs: 0,
358 timeout_secs: 0,
359 }
360 }
361
362 #[must_use]
365 pub fn conservative() -> Self {
366 Self {
367 max_memory_mb: 512,
368 max_pids: 64,
369 max_disk_mb: 1024,
370 cpu_time_secs: 60,
371 timeout_secs: 120,
372 }
373 }
374
375 #[must_use]
377 pub fn moderate() -> Self {
378 Self {
379 max_memory_mb: 2048,
380 max_pids: 256,
381 max_disk_mb: 4096,
382 cpu_time_secs: 300,
383 timeout_secs: 600,
384 }
385 }
386
387 #[must_use]
389 pub fn generous() -> Self {
390 Self {
391 max_memory_mb: 8192,
392 max_pids: 1024,
393 max_disk_mb: 16384,
394 cpu_time_secs: 0,
395 timeout_secs: 3600,
396 }
397 }
398
399 #[must_use]
401 fn with_memory_mb(mut self, mb: u64) -> Self {
402 self.max_memory_mb = mb;
403 self
404 }
405
406 #[must_use]
408 fn with_max_pids(mut self, pids: u32) -> Self {
409 self.max_pids = pids;
410 self
411 }
412
413 #[must_use]
415 pub fn with_disk_mb(mut self, mb: u64) -> Self {
416 self.max_disk_mb = mb;
417 self
418 }
419
420 #[must_use]
422 pub fn with_cpu_time_secs(mut self, secs: u64) -> Self {
423 self.cpu_time_secs = secs;
424 self
425 }
426
427 #[must_use]
429 fn with_timeout_secs(mut self, secs: u64) -> Self {
430 self.timeout_secs = secs;
431 self
432 }
433
434 #[inline]
436 #[must_use]
437 fn has_limits(&self) -> bool {
438 self.max_memory_mb > 0
439 || self.max_pids > 0
440 || self.max_disk_mb > 0
441 || self.cpu_time_secs > 0
442 || self.timeout_secs > 0
443 }
444
445 #[inline]
447 #[must_use]
448 fn effective_timeout_secs(&self) -> u64 {
449 if self.timeout_secs > 0 { self.timeout_secs } else { 300 }
450 }
451}
452
453#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
465#[serde(tag = "type", rename_all = "snake_case")]
466pub enum SandboxPolicy {
467 ReadOnly {
469 #[serde(default)]
471 network_access: bool,
472
473 #[serde(default)]
475 network_allowlist: Vec<NetworkAllowlistEntry>,
476 },
477
478 WorkspaceWrite {
480 writable_roots: Vec<WritableRoot>,
482
483 #[serde(default)]
485 network_access: bool,
486
487 #[serde(default)]
491 network_allowlist: Vec<NetworkAllowlistEntry>,
492
493 #[serde(default)]
497 sensitive_paths: Option<Vec<SensitivePath>>,
498
499 #[serde(default)]
502 resource_limits: ResourceLimits,
503
504 #[serde(default)]
507 seccomp_profile: SeccompProfile,
508
509 #[serde(default)]
511 exclude_tmpdir_env_var: bool,
512
513 #[serde(default)]
515 exclude_slash_tmp: bool,
516 },
517
518 DangerFullAccess,
521
522 ExternalSandbox {
524 description: String,
526 },
527}
528
529impl SandboxPolicy {
530 #[must_use]
532 pub fn read_only() -> Self {
533 Self::ReadOnly {
534 network_access: false,
535 network_allowlist: Vec::new(),
536 }
537 }
538
539 #[must_use]
541 pub fn new_read_only_policy() -> Self {
542 Self::read_only()
543 }
544
545 #[must_use]
547 pub fn read_only_with_network(network_allowlist: Vec<NetworkAllowlistEntry>) -> Self {
548 Self::ReadOnly {
549 network_access: !network_allowlist.is_empty(),
550 network_allowlist,
551 }
552 }
553
554 #[must_use]
556 pub fn read_only_with_full_network() -> Self {
557 Self::ReadOnly {
558 network_access: true,
559 network_allowlist: Vec::new(),
560 }
561 }
562
563 #[must_use]
566 pub fn workspace_write(writable_roots: Vec<PathBuf>) -> Self {
567 Self::WorkspaceWrite {
568 writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
569 network_access: false,
570 network_allowlist: Vec::new(),
571 sensitive_paths: None,
572 resource_limits: ResourceLimits::default(),
573 seccomp_profile: SeccompProfile::strict(),
574 exclude_tmpdir_env_var: true,
575 exclude_slash_tmp: true,
576 }
577 }
578
579 #[must_use]
581 fn workspace_write_with_network(
582 writable_roots: Vec<PathBuf>,
583 network_allowlist: Vec<NetworkAllowlistEntry>,
584 ) -> Self {
585 Self::WorkspaceWrite {
586 writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
587 network_access: !network_allowlist.is_empty(),
588 network_allowlist,
589 sensitive_paths: None,
590 resource_limits: ResourceLimits::default(),
591 seccomp_profile: SeccompProfile::strict().with_network(),
592 exclude_tmpdir_env_var: true,
593 exclude_slash_tmp: true,
594 }
595 }
596
597 #[must_use]
599 pub fn workspace_write_with_sensitive_paths(
600 writable_roots: Vec<PathBuf>,
601 sensitive_paths: Vec<SensitivePath>,
602 ) -> Self {
603 Self::WorkspaceWrite {
604 writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
605 network_access: false,
606 network_allowlist: Vec::new(),
607 sensitive_paths: Some(sensitive_paths),
608 resource_limits: ResourceLimits::default(),
609 seccomp_profile: SeccompProfile::strict(),
610 exclude_tmpdir_env_var: true,
611 exclude_slash_tmp: true,
612 }
613 }
614
615 #[must_use]
617 fn workspace_write_no_sensitive_blocking(writable_roots: Vec<PathBuf>) -> Self {
618 Self::WorkspaceWrite {
619 writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
620 network_access: false,
621 network_allowlist: Vec::new(),
622 sensitive_paths: Some(Vec::new()),
623 resource_limits: ResourceLimits::default(),
624 seccomp_profile: SeccompProfile::strict(),
625 exclude_tmpdir_env_var: true,
626 exclude_slash_tmp: true,
627 }
628 }
629
630 #[must_use]
633 fn workspace_write_with_limits(writable_roots: Vec<PathBuf>, resource_limits: ResourceLimits) -> Self {
634 Self::WorkspaceWrite {
635 writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
636 network_access: false,
637 network_allowlist: Vec::new(),
638 sensitive_paths: None,
639 resource_limits,
640 seccomp_profile: SeccompProfile::strict(),
641 exclude_tmpdir_env_var: true,
642 exclude_slash_tmp: true,
643 }
644 }
645
646 #[must_use]
648 pub fn workspace_write_full(
649 writable_roots: Vec<PathBuf>,
650 network_allowlist: Vec<NetworkAllowlistEntry>,
651 sensitive_paths: Option<Vec<SensitivePath>>,
652 resource_limits: ResourceLimits,
653 seccomp_profile: SeccompProfile,
654 ) -> Self {
655 Self::WorkspaceWrite {
656 writable_roots: writable_roots.into_iter().map(WritableRoot::new).collect(),
657 network_access: !network_allowlist.is_empty(),
658 network_allowlist,
659 sensitive_paths,
660 resource_limits,
661 seccomp_profile,
662 exclude_tmpdir_env_var: true,
663 exclude_slash_tmp: true,
664 }
665 }
666
667 #[must_use]
669 pub fn full_access() -> Self {
670 Self::DangerFullAccess
671 }
672
673 #[inline]
675 #[must_use]
676 pub fn has_full_network_access(&self) -> bool {
677 match self {
678 Self::ReadOnly { network_access, network_allowlist }
679 | Self::WorkspaceWrite { network_access, network_allowlist, .. } => {
680 *network_access && network_allowlist.is_empty()
681 }
682 Self::DangerFullAccess | Self::ExternalSandbox { .. } => true,
683 }
684 }
685
686 #[inline]
688 #[must_use]
689 pub fn has_network_allowlist(&self) -> bool {
690 match self {
691 Self::ReadOnly { network_allowlist, .. } | Self::WorkspaceWrite { network_allowlist, .. } => {
692 !network_allowlist.is_empty()
693 }
694 _ => false,
695 }
696 }
697
698 #[inline]
700 #[must_use]
701 pub fn network_allowlist(&self) -> &[NetworkAllowlistEntry] {
702 match self {
703 Self::ReadOnly { network_allowlist, .. } | Self::WorkspaceWrite { network_allowlist, .. } => {
704 network_allowlist
705 }
706 _ => &[],
707 }
708 }
709
710 #[inline]
712 #[must_use]
713 pub fn is_network_allowed(&self, domain: &str, port: u16) -> bool {
714 match self {
715 Self::ReadOnly { network_access, network_allowlist }
716 | Self::WorkspaceWrite { network_access, network_allowlist, .. } => {
717 if network_allowlist.is_empty() {
718 *network_access
719 } else {
720 network_allowlist.iter().any(|entry| entry.matches(domain, port))
721 }
722 }
723 Self::DangerFullAccess | Self::ExternalSandbox { .. } => true,
724 }
725 }
726
727 #[must_use]
730 fn sensitive_paths(&self) -> Vec<SensitivePath> {
731 match self {
732 Self::ReadOnly { .. } => default_sensitive_paths(),
733 Self::WorkspaceWrite { sensitive_paths, .. } => {
734 sensitive_paths.clone().unwrap_or_else(default_sensitive_paths)
735 }
736 Self::DangerFullAccess | Self::ExternalSandbox { .. } => Vec::new(),
737 }
738 }
739
740 #[must_use]
742 pub(crate) fn sensitive_paths_for_execution(&self, cwd: &Path) -> Vec<SensitivePath> {
743 match self {
744 Self::WorkspaceWrite { .. } => {
745 let mut sensitive_paths = self.sensitive_paths();
746 sensitive_paths.extend(protected_writable_root_sensitive_paths(&self.get_writable_roots_with_cwd(cwd)));
747 sensitive_paths
748 }
749 _ => self.sensitive_paths(),
750 }
751 }
752
753 #[inline]
755 #[must_use]
756 fn is_sensitive_path(&self, path: &Path) -> bool {
757 self.sensitive_paths().iter().any(|sp| sp.matches(path) && sp.block_read)
758 }
759
760 #[inline]
762 #[must_use]
763 fn is_path_write_blocked(&self, path: &Path, cwd: &Path) -> bool {
764 match self {
765 Self::DangerFullAccess | Self::ExternalSandbox { .. } => false,
766 _ => self
767 .sensitive_paths_for_execution(cwd)
768 .iter()
769 .any(|sp| sp.matches(path) && sp.block_write),
770 }
771 }
772
773 #[inline]
775 #[must_use]
776 pub fn is_path_readable(&self, path: &Path) -> bool {
777 match self {
778 Self::DangerFullAccess | Self::ExternalSandbox { .. } => true,
779 _ => !self.is_sensitive_path(path),
780 }
781 }
782
783 #[must_use]
785 pub fn resource_limits(&self) -> ResourceLimits {
786 match self {
787 Self::ReadOnly { .. } => ResourceLimits::conservative(),
788 Self::WorkspaceWrite { resource_limits, .. } => resource_limits.clone(),
789 Self::DangerFullAccess | Self::ExternalSandbox { .. } => ResourceLimits::unlimited(),
790 }
791 }
792
793 #[must_use]
795 pub fn seccomp_profile(&self) -> SeccompProfile {
796 match self {
797 Self::ReadOnly { network_access, network_allowlist } => {
798 let mut profile = SeccompProfile::strict();
799 if *network_access || !network_allowlist.is_empty() {
800 profile = profile.with_network();
801 }
802 profile
803 }
804 Self::WorkspaceWrite { seccomp_profile, .. } => seccomp_profile.clone(),
805 Self::DangerFullAccess | Self::ExternalSandbox { .. } => SeccompProfile::permissive(),
806 }
807 }
808
809 #[inline]
811 #[must_use]
812 fn has_full_disk_write_access(&self) -> bool {
813 matches!(self, Self::DangerFullAccess | Self::ExternalSandbox { .. })
814 }
815
816 #[inline]
818 #[must_use]
819 fn has_full_disk_read_access(&self) -> bool {
820 true
821 }
822
823 #[must_use]
825 pub(crate) fn get_writable_roots_with_cwd(&self, cwd: &Path) -> Vec<WritableRoot> {
826 match self {
827 Self::ReadOnly { .. } => vec![],
828 Self::WorkspaceWrite { writable_roots, .. } => {
829 let mut roots = writable_roots.clone();
830 let cwd_root = WritableRoot::new(cwd);
831 if !roots.contains(&cwd_root) {
832 roots.push(cwd_root);
833 }
834 roots
835 }
836 Self::DangerFullAccess | Self::ExternalSandbox { .. } => {
837 vec![WritableRoot::new(cwd)]
838 }
839 }
840 }
841
842 #[inline]
844 #[must_use]
845 pub fn is_path_writable(&self, path: &Path, cwd: &Path) -> bool {
846 match self {
847 Self::ReadOnly { .. } => false,
848 Self::WorkspaceWrite { .. } => {
849 let writable = self.get_writable_roots_with_cwd(cwd);
850 writable.iter().any(|root| path.starts_with(&root.root)) && !self.is_path_write_blocked(path, cwd)
851 }
852 Self::DangerFullAccess | Self::ExternalSandbox { .. } => true,
853 }
854 }
855
856 fn can_set(&self, new_policy: &SandboxPolicy) -> anyhow::Result<()> {
859 use SandboxPolicy::*;
860
861 match (self, new_policy) {
862 (DangerFullAccess, _) => Ok(()),
864 (ReadOnly { .. }, WorkspaceWrite { .. } | DangerFullAccess) => {
866 Err(anyhow::anyhow!("cannot escalate from read-only to write-capable policy"))
867 }
868 _ => Ok(()),
870 }
871 }
872
873 pub fn description(&self) -> &'static str {
875 match self {
876 Self::ReadOnly { .. } => "read-only access",
877 Self::WorkspaceWrite { .. } => "workspace write access",
878 Self::DangerFullAccess => "full access (dangerous)",
879 Self::ExternalSandbox { .. } => "external sandbox",
880 }
881 }
882}
883
884impl Default for SandboxPolicy {
885 fn default() -> Self {
886 Self::read_only()
887 }
888}
889
890#[cfg(test)]
891mod tests;