1use std::collections::HashSet;
2use std::ffi::OsStr;
3use std::io;
4use std::path::Path;
5use std::path::PathBuf;
6
7use codex_utils_absolute_path::AbsolutePathBuf;
8use codex_utils_absolute_path::canonicalize_preserving_symlinks;
9use globset::GlobBuilder;
10use globset::GlobMatcher;
11use schemars::JsonSchema;
12use serde::Deserialize;
13use serde::Serialize;
14use strum_macros::Display;
15use tracing::error;
16use ts_rs::TS;
17
18use crate::protocol::NetworkAccess;
19use crate::protocol::SandboxPolicy;
20use crate::protocol::WritableRoot;
21
22const PROTECTED_METADATA_GIT_PATH_NAME: &str = ".git";
23const PROTECTED_METADATA_AGENTS_PATH_NAME: &str = ".agents";
24const PROTECTED_METADATA_CODEX_PATH_NAME: &str = ".codex";
25
26pub const PROTECTED_METADATA_PATH_NAMES: &[&str] = &[
28 PROTECTED_METADATA_GIT_PATH_NAME,
29 PROTECTED_METADATA_AGENTS_PATH_NAME,
30 PROTECTED_METADATA_CODEX_PATH_NAME,
31];
32
33pub fn is_protected_metadata_name(name: &OsStr) -> bool {
35 PROTECTED_METADATA_PATH_NAMES
36 .iter()
37 .any(|metadata_name| name == OsStr::new(metadata_name))
38}
39
40pub fn forbidden_agent_metadata_write(
43 path: &Path,
44 cwd: &Path,
45 file_system_sandbox_policy: &FileSystemSandboxPolicy,
46) -> Option<&'static str> {
47 if !matches!(
48 file_system_sandbox_policy.kind,
49 FileSystemSandboxKind::Restricted
50 ) {
51 return None;
52 }
53
54 let target = resolve_candidate_path(path, cwd)?;
55 let (protected_metadata_path, metadata_name) =
56 metadata_child_of_writable_root(file_system_sandbox_policy, target.as_path(), cwd)?;
57 if has_explicit_write_entry_for_metadata_path(
58 file_system_sandbox_policy,
59 &protected_metadata_path,
60 target.as_path(),
61 cwd,
62 ) {
63 return None;
64 }
65
66 if !file_system_sandbox_policy.can_write_path_with_cwd(target.as_path(), cwd) {
67 return Some(metadata_name);
68 }
69
70 None
71}
72
73#[derive(
74 Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, Default, JsonSchema, TS,
75)]
76#[serde(rename_all = "kebab-case")]
77#[strum(serialize_all = "kebab-case")]
78pub enum NetworkSandboxPolicy {
79 #[default]
80 Restricted,
81 Enabled,
82}
83
84impl NetworkSandboxPolicy {
85 pub fn is_enabled(self) -> bool {
86 matches!(self, NetworkSandboxPolicy::Enabled)
87 }
88}
89
90#[derive(
96 Debug,
97 Clone,
98 Copy,
99 Hash,
100 PartialEq,
101 Eq,
102 PartialOrd,
103 Ord,
104 Serialize,
105 Deserialize,
106 Display,
107 JsonSchema,
108 TS,
109)]
110#[serde(rename_all = "lowercase")]
111#[strum(serialize_all = "lowercase")]
112pub enum FileSystemAccessMode {
113 Read,
114 Write,
115 #[serde(alias = "none")]
117 Deny,
118}
119
120impl FileSystemAccessMode {
121 pub fn can_read(self) -> bool {
122 !matches!(self, FileSystemAccessMode::Deny)
123 }
124
125 pub fn can_write(self) -> bool {
126 matches!(self, FileSystemAccessMode::Write)
127 }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, TS)]
131#[serde(tag = "kind", rename_all = "snake_case")]
132#[ts(tag = "kind")]
133pub enum FileSystemSpecialPath {
134 Root,
135 Minimal,
136 #[serde(alias = "current_working_directory")]
137 ProjectRoots {
138 #[serde(default, skip_serializing_if = "Option::is_none")]
139 #[ts(optional)]
140 subpath: Option<String>,
141 },
142 Tmpdir,
143 SlashTmp,
144 Unknown {
153 path: String,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
155 #[ts(optional)]
156 subpath: Option<String>,
157 },
158}
159
160impl FileSystemSpecialPath {
161 pub fn project_roots(subpath: Option<String>) -> Self {
162 Self::ProjectRoots { subpath }
163 }
164
165 pub fn unknown(path: impl Into<String>, subpath: Option<String>) -> Self {
166 Self::Unknown {
167 path: path.into(),
168 subpath,
169 }
170 }
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, TS)]
174pub struct FileSystemSandboxEntry {
175 pub path: FileSystemPath,
176 pub access: FileSystemAccessMode,
177 #[serde(default, skip_serializing_if = "Option::is_none")]
178 #[ts(optional)]
179 pub missing_path_behavior: Option<FileSystemSandboxEntryMissingPathBehavior>,
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, TS)]
183#[serde(rename_all = "snake_case")]
184pub enum FileSystemSandboxEntryMissingPathBehavior {
185 Skip,
186}
187
188impl FileSystemSandboxEntry {
189 pub fn new(path: FileSystemPath, access: FileSystemAccessMode) -> Self {
190 Self {
191 path,
192 access,
193 missing_path_behavior: None,
194 }
195 }
196
197 pub fn skip_missing_path(path: FileSystemPath, access: FileSystemAccessMode) -> Self {
198 Self {
199 path,
200 access,
201 missing_path_behavior: Some(FileSystemSandboxEntryMissingPathBehavior::Skip),
202 }
203 }
204
205 pub fn skips_missing_path(&self) -> bool {
206 self.missing_path_behavior == Some(FileSystemSandboxEntryMissingPathBehavior::Skip)
207 }
208}
209
210#[derive(
211 Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, Default, JsonSchema, TS,
212)]
213#[serde(rename_all = "kebab-case")]
214#[strum(serialize_all = "kebab-case")]
215pub enum FileSystemSandboxKind {
216 #[default]
217 Restricted,
218 Unrestricted,
219 ExternalSandbox,
220}
221
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
223pub struct FileSystemSandboxPolicy {
224 pub kind: FileSystemSandboxKind,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
226 #[ts(optional)]
227 pub glob_scan_max_depth: Option<usize>,
228 #[serde(default, skip_serializing_if = "Vec::is_empty")]
229 pub entries: Vec<FileSystemSandboxEntry>,
230}
231
232#[derive(Debug, Clone, PartialEq, Eq)]
233struct ResolvedFileSystemEntry {
234 path: AbsolutePathBuf,
235 access: FileSystemAccessMode,
236}
237
238#[derive(Debug, Clone, PartialEq, Eq)]
239struct FileSystemSemanticSignature {
240 has_full_disk_read_access: bool,
241 has_full_disk_write_access: bool,
242 include_platform_defaults: bool,
243 readable_roots: Vec<AbsolutePathBuf>,
244 writable_roots: Vec<WritableRoot>,
245 unreadable_roots: Vec<AbsolutePathBuf>,
246 unreadable_globs: Vec<String>,
247}
248
249pub struct ReadDenyMatcher {
251 denied_candidates: Vec<Vec<PathBuf>>,
252 deny_read_matchers: Vec<GlobMatcher>,
253 invalid_pattern: bool,
254}
255
256impl ReadDenyMatcher {
257 pub fn new(file_system_sandbox_policy: &FileSystemSandboxPolicy, cwd: &Path) -> Option<Self> {
263 match Self::build(
264 file_system_sandbox_policy,
265 cwd,
266 InvalidDenyReadGlobBehavior::FailClosed,
267 ) {
268 Ok(matcher) => matcher,
269 Err(_) => unreachable!("fail-closed glob handling does not return errors"),
270 }
271 }
272
273 pub fn try_new(
279 file_system_sandbox_policy: &FileSystemSandboxPolicy,
280 cwd: &Path,
281 ) -> Result<Option<Self>, String> {
282 Self::build(
283 file_system_sandbox_policy,
284 cwd,
285 InvalidDenyReadGlobBehavior::ReturnError,
286 )
287 }
288
289 fn build(
290 file_system_sandbox_policy: &FileSystemSandboxPolicy,
291 cwd: &Path,
292 invalid_glob_behavior: InvalidDenyReadGlobBehavior,
293 ) -> Result<Option<Self>, String> {
294 if !file_system_sandbox_policy.has_denied_read_restrictions() {
295 return Ok(None);
296 }
297
298 let denied_candidates = file_system_sandbox_policy
302 .get_unreadable_roots_with_cwd(cwd)
303 .into_iter()
304 .map(|path| normalized_and_canonical_candidates(path.as_path()))
305 .collect();
306 let mut invalid_pattern = false;
309 let mut deny_read_matchers = Vec::new();
310 for pattern in file_system_sandbox_policy.get_unreadable_globs_with_cwd(cwd) {
311 match build_glob_matcher(&pattern) {
312 Ok(matcher) => deny_read_matchers.push(matcher),
313 Err(err) => match invalid_glob_behavior {
314 InvalidDenyReadGlobBehavior::FailClosed => invalid_pattern = true,
315 InvalidDenyReadGlobBehavior::ReturnError => {
316 return Err(format!("invalid deny-read glob pattern `{pattern}`: {err}"));
317 }
318 },
319 }
320 }
321 Ok(Some(Self {
322 denied_candidates,
323 deny_read_matchers,
324 invalid_pattern,
325 }))
326 }
327
328 pub fn is_read_denied(&self, path: &Path) -> bool {
330 if self.invalid_pattern {
331 return true;
334 }
335
336 let path_candidates = normalized_and_canonical_candidates(path);
340 if self.denied_candidates.iter().any(|denied_candidates| {
341 path_candidates.iter().any(|candidate| {
342 denied_candidates.iter().any(|denied_candidate| {
343 candidate == denied_candidate || candidate.starts_with(denied_candidate)
344 })
345 })
346 }) {
347 return true;
348 }
349
350 self.deny_read_matchers.iter().any(|matcher| {
351 path_candidates
352 .iter()
353 .any(|candidate| matcher.is_match(candidate))
354 })
355 }
356}
357
358#[derive(Clone, Copy)]
359enum InvalidDenyReadGlobBehavior {
360 FailClosed,
361 ReturnError,
362}
363
364#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, TS)]
365#[serde(tag = "type", rename_all = "snake_case")]
366#[ts(tag = "type")]
367pub enum FileSystemPath {
368 Path {
369 path: AbsolutePathBuf,
371 },
372 GlobPattern {
375 pattern: String,
376 },
377 Special {
378 value: FileSystemSpecialPath,
379 },
380}
381
382const PROJECT_ROOTS_GLOB_PATTERN_PREFIX: &str = "codex-project-roots://";
383
384pub fn project_roots_glob_pattern(subpath: &Path) -> String {
385 format!("{PROJECT_ROOTS_GLOB_PATTERN_PREFIX}{}", subpath.display())
386}
387
388fn read_only_file_system_entries() -> Vec<FileSystemSandboxEntry> {
389 vec![FileSystemSandboxEntry::new(
390 FileSystemPath::Special {
391 value: FileSystemSpecialPath::Root,
392 },
393 FileSystemAccessMode::Read,
394 )]
395}
396
397impl Default for FileSystemSandboxPolicy {
398 fn default() -> Self {
399 Self::read_only()
400 }
401}
402
403impl FileSystemSandboxPolicy {
404 pub fn read_only() -> Self {
405 Self::restricted(read_only_file_system_entries())
406 }
407
408 pub fn unrestricted() -> Self {
409 Self {
410 kind: FileSystemSandboxKind::Unrestricted,
411 glob_scan_max_depth: None,
412 entries: Vec::new(),
413 }
414 }
415
416 pub fn external_sandbox() -> Self {
417 Self {
418 kind: FileSystemSandboxKind::ExternalSandbox,
419 glob_scan_max_depth: None,
420 entries: Vec::new(),
421 }
422 }
423
424 pub fn restricted(entries: Vec<FileSystemSandboxEntry>) -> Self {
425 Self {
426 kind: FileSystemSandboxKind::Restricted,
427 glob_scan_max_depth: None,
428 entries,
429 }
430 }
431
432 pub fn remove_skip_missing_path_entries(&mut self) {
437 self.entries.retain(|entry| !entry.skips_missing_path());
438 }
439
440 pub fn has_explicit_non_write_entry_for_path_with_cwd(&self, path: &Path, cwd: &Path) -> bool {
441 let Some(path) = resolve_candidate_path(path, cwd) else {
442 return false;
443 };
444 let cwd = AbsolutePathBuf::from_absolute_path(cwd).ok();
445 self.entries.iter().any(|entry| {
446 !entry.skips_missing_path()
447 && !entry.access.can_write()
448 && resolve_entry_path(&entry.path, cwd.as_ref()).as_ref() == Some(&path)
449 })
450 }
451
452 fn has_root_access(&self, predicate: impl Fn(FileSystemAccessMode) -> bool) -> bool {
453 matches!(self.kind, FileSystemSandboxKind::Restricted)
454 && self.entries.iter().any(|entry| {
455 matches!(
456 &entry.path,
457 FileSystemPath::Special { value }
458 if matches!(value, FileSystemSpecialPath::Root) && predicate(entry.access)
459 )
460 })
461 }
462
463 pub fn has_denied_read_restrictions(&self) -> bool {
464 matches!(self.kind, FileSystemSandboxKind::Restricted)
465 && self
466 .entries
467 .iter()
468 .any(|entry| entry.access == FileSystemAccessMode::Deny)
469 }
470
471 pub fn from_legacy_sandbox_policy_preserving_deny_entries(
472 sandbox_policy: &SandboxPolicy,
473 cwd: &Path,
474 existing: &Self,
475 ) -> Self {
476 let mut rebuilt = Self::from_legacy_sandbox_policy_for_cwd(sandbox_policy, cwd);
477 if !matches!(rebuilt.kind, FileSystemSandboxKind::Restricted) {
478 return rebuilt;
479 }
480 rebuilt.glob_scan_max_depth = existing.glob_scan_max_depth;
481
482 for deny_entry in existing
483 .entries
484 .iter()
485 .filter(|entry| entry.access == FileSystemAccessMode::Deny)
486 {
487 if !rebuilt.entries.iter().any(|entry| entry == deny_entry) {
488 rebuilt.entries.push(deny_entry.clone());
489 }
490 }
491
492 rebuilt
493 }
494
495 pub fn preserve_deny_read_restrictions_from(&mut self, existing: &Self) {
498 let has_deny_read_entries = existing
499 .entries
500 .iter()
501 .any(|entry| entry.access == FileSystemAccessMode::Deny);
502 if matches!(self.kind, FileSystemSandboxKind::Unrestricted) && has_deny_read_entries {
503 *self = Self::restricted(vec![FileSystemSandboxEntry::new(
504 FileSystemPath::Special {
505 value: FileSystemSpecialPath::Root,
506 },
507 FileSystemAccessMode::Write,
508 )]);
509 }
510
511 if !matches!(self.kind, FileSystemSandboxKind::Restricted) {
512 return;
513 }
514
515 if self.glob_scan_max_depth.is_none() {
516 self.glob_scan_max_depth = existing.glob_scan_max_depth;
517 }
518
519 for deny_entry in existing
520 .entries
521 .iter()
522 .filter(|entry| entry.access == FileSystemAccessMode::Deny)
523 {
524 if !self.entries.iter().any(|entry| entry == deny_entry) {
525 self.entries.push(deny_entry.clone());
526 }
527 }
528 }
529
530 fn has_write_narrowing_entries(&self) -> bool {
538 matches!(self.kind, FileSystemSandboxKind::Restricted)
539 && self.entries.iter().any(|entry| {
540 if entry.access.can_write() {
541 return false;
542 }
543
544 match &entry.path {
545 FileSystemPath::Path { .. } => !self.has_same_target_write_override(entry),
546 FileSystemPath::GlobPattern { .. } => true,
547 FileSystemPath::Special { value } => match value {
548 FileSystemSpecialPath::Root => entry.access == FileSystemAccessMode::Deny,
549 FileSystemSpecialPath::Minimal | FileSystemSpecialPath::Unknown { .. } => {
550 false
551 }
552 _ => !self.has_same_target_write_override(entry),
553 },
554 }
555 })
556 }
557
558 fn has_same_target_write_override(&self, entry: &FileSystemSandboxEntry) -> bool {
561 self.entries.iter().any(|candidate| {
562 candidate.access.can_write()
563 && candidate.access > entry.access
564 && file_system_paths_share_target(&candidate.path, &entry.path)
565 })
566 }
567
568 pub fn workspace_write(
571 writable_roots: &[AbsolutePathBuf],
572 exclude_tmpdir_env_var: bool,
573 exclude_slash_tmp: bool,
574 ) -> Self {
575 let mut entries = vec![FileSystemSandboxEntry::new(
576 FileSystemPath::Special {
577 value: FileSystemSpecialPath::Root,
578 },
579 FileSystemAccessMode::Read,
580 )];
581
582 entries.push(FileSystemSandboxEntry::new(
583 FileSystemPath::Special {
584 value: FileSystemSpecialPath::project_roots(None),
585 },
586 FileSystemAccessMode::Write,
587 ));
588 if !exclude_slash_tmp {
589 entries.push(FileSystemSandboxEntry::new(
590 FileSystemPath::Special {
591 value: FileSystemSpecialPath::SlashTmp,
592 },
593 FileSystemAccessMode::Write,
594 ));
595 }
596 if !exclude_tmpdir_env_var {
597 entries.push(FileSystemSandboxEntry::new(
598 FileSystemPath::Special {
599 value: FileSystemSpecialPath::Tmpdir,
600 },
601 FileSystemAccessMode::Write,
602 ));
603 }
604 entries.extend(writable_roots.iter().cloned().map(|path| {
605 FileSystemSandboxEntry::new(FileSystemPath::Path { path }, FileSystemAccessMode::Write)
606 }));
607
608 append_default_read_only_project_root_subpath_if_no_explicit_rule(&mut entries, ".git");
609 append_default_read_only_project_root_subpath_if_no_explicit_rule(&mut entries, ".agents");
610 append_default_read_only_project_root_subpath_if_no_explicit_rule(&mut entries, ".codex");
611 for writable_root in writable_roots {
612 for protected_path in default_read_only_subpaths_for_writable_root(
613 writable_root,
614 false,
615 ) {
616 append_default_read_only_path_if_no_explicit_rule(&mut entries, protected_path);
617 }
618 }
619
620 FileSystemSandboxPolicy::restricted(entries)
621 }
622
623 pub fn from_legacy_sandbox_policy_for_cwd(sandbox_policy: &SandboxPolicy, cwd: &Path) -> Self {
631 let mut file_system_policy = Self::from(sandbox_policy);
632 if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = sandbox_policy {
633 if let Ok(cwd_root) = AbsolutePathBuf::from_absolute_path(cwd) {
634 for protected_path in default_read_only_subpaths_for_writable_root(
635 &cwd_root, true,
636 ) {
637 append_default_read_only_path_if_no_explicit_rule(
638 &mut file_system_policy.entries,
639 protected_path,
640 );
641 }
642 }
643 for writable_root in writable_roots {
644 for protected_path in default_read_only_subpaths_for_writable_root(
645 writable_root,
646 false,
647 ) {
648 append_default_read_only_path_if_no_explicit_rule(
649 &mut file_system_policy.entries,
650 protected_path,
651 );
652 }
653 }
654 }
655
656 file_system_policy
657 }
658
659 pub fn has_full_disk_read_access(&self) -> bool {
661 match self.kind {
662 FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => true,
663 FileSystemSandboxKind::Restricted => {
664 self.has_root_access(FileSystemAccessMode::can_read)
665 && !self.has_denied_read_restrictions()
666 }
667 }
668 }
669
670 pub fn has_full_disk_write_access(&self) -> bool {
672 match self.kind {
673 FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => true,
674 FileSystemSandboxKind::Restricted => {
675 self.has_root_access(FileSystemAccessMode::can_write)
676 && !self.has_write_narrowing_entries()
677 }
678 }
679 }
680
681 pub fn include_platform_defaults(&self) -> bool {
683 !self.has_full_disk_read_access()
684 && matches!(self.kind, FileSystemSandboxKind::Restricted)
685 && self.entries.iter().any(|entry| {
686 matches!(
687 &entry.path,
688 FileSystemPath::Special { value }
689 if matches!(value, FileSystemSpecialPath::Minimal)
690 && entry.access.can_read()
691 )
692 })
693 }
694
695 pub fn resolve_access_with_cwd(&self, path: &Path, cwd: &Path) -> FileSystemAccessMode {
696 match self.kind {
697 FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => {
698 return FileSystemAccessMode::Write;
699 }
700 FileSystemSandboxKind::Restricted => {}
701 }
702
703 let Some(path) = resolve_candidate_path(path, cwd) else {
704 return FileSystemAccessMode::Deny;
705 };
706
707 self.resolved_entries_with_cwd(cwd)
708 .into_iter()
709 .filter(|entry| path.as_path().starts_with(entry.path.as_path()))
710 .max_by_key(resolved_entry_precedence)
711 .map(|entry| entry.access)
712 .unwrap_or(FileSystemAccessMode::Deny)
713 }
714
715 pub fn can_read_path_with_cwd(&self, path: &Path, cwd: &Path) -> bool {
716 self.resolve_access_with_cwd(path, cwd).can_read()
717 }
718
719 pub fn can_write_path_with_cwd(&self, path: &Path, cwd: &Path) -> bool {
720 if !self.resolve_access_with_cwd(path, cwd).can_write() {
721 return false;
722 }
723 if self.has_full_disk_write_access() {
724 return true;
725 }
726 !self.is_metadata_write_denied(path, cwd)
727 }
728
729 fn is_metadata_write_denied(&self, path: &Path, cwd: &Path) -> bool {
730 if !matches!(self.kind, FileSystemSandboxKind::Restricted) {
731 return false;
732 }
733
734 let Some(target) = resolve_candidate_path(path, cwd) else {
735 return true;
736 };
737 let Some((protected_metadata_path, _)) =
738 metadata_child_of_writable_root(self, target.as_path(), cwd)
739 else {
740 return false;
741 };
742
743 !has_explicit_write_entry_for_metadata_path(
744 self,
745 &protected_metadata_path,
746 target.as_path(),
747 cwd,
748 )
749 }
750
751 pub fn materialize_project_roots_with_cwd(mut self, cwd: &Path) -> Self {
757 let cwd = AbsolutePathBuf::from_absolute_path(cwd).ok();
758 for entry in &mut self.entries {
759 match &entry.path {
760 FileSystemPath::Special {
761 value: FileSystemSpecialPath::ProjectRoots { .. },
762 } => {
763 if let Some(path) = resolve_file_system_path(&entry.path, cwd.as_ref()) {
764 entry.path = FileSystemPath::Path { path };
765 }
766 }
767 FileSystemPath::GlobPattern { pattern } => {
768 if let (Some(cwd), Some(subpath)) =
769 (cwd.as_ref(), parse_project_roots_glob_pattern(pattern))
770 {
771 entry.path = FileSystemPath::GlobPattern {
772 pattern: resolve_project_roots_glob_pattern(subpath, cwd),
773 };
774 }
775 }
776 FileSystemPath::Special { value: _ } => {}
777 FileSystemPath::Path { .. } => {}
778 }
779 }
780 self
781 }
782
783 pub fn materialize_project_roots_with_workspace_roots(
786 mut self,
787 workspace_roots: &[AbsolutePathBuf],
788 ) -> Self {
789 let mut entries = Vec::with_capacity(self.entries.len());
790 for entry in self.entries {
791 match entry.path {
792 FileSystemPath::Special {
793 value: FileSystemSpecialPath::ProjectRoots { subpath },
794 } => {
795 entries.extend(workspace_roots.iter().map(|root| FileSystemSandboxEntry {
796 path: FileSystemPath::Path {
797 path: match subpath.as_ref() {
798 Some(subpath) => AbsolutePathBuf::resolve_path_against_base(
799 subpath,
800 root.as_path(),
801 ),
802 None => root.clone(),
803 },
804 },
805 access: entry.access,
806 missing_path_behavior: entry.missing_path_behavior,
807 }));
808 }
809 FileSystemPath::GlobPattern { pattern } => {
810 if let Some(subpath) = parse_project_roots_glob_pattern(&pattern) {
811 entries.extend(workspace_roots.iter().map(|root| FileSystemSandboxEntry {
812 path: FileSystemPath::GlobPattern {
813 pattern: resolve_project_roots_glob_pattern(subpath, root),
814 },
815 access: entry.access,
816 missing_path_behavior: entry.missing_path_behavior,
817 }));
818 } else {
819 entries.push(FileSystemSandboxEntry {
820 path: FileSystemPath::GlobPattern { pattern },
821 access: entry.access,
822 missing_path_behavior: entry.missing_path_behavior,
823 });
824 }
825 }
826 FileSystemPath::Path { path } => {
827 entries.push(FileSystemSandboxEntry {
828 path: FileSystemPath::Path { path },
829 access: entry.access,
830 missing_path_behavior: entry.missing_path_behavior,
831 });
832 }
833 FileSystemPath::Special { value } => {
834 entries.push(FileSystemSandboxEntry {
835 path: FileSystemPath::Special { value },
836 access: entry.access,
837 missing_path_behavior: entry.missing_path_behavior,
838 });
839 }
840 }
841 }
842 self.entries = entries;
843 self
844 }
845
846 pub fn with_materialized_project_roots_for_workspace_roots(
849 mut self,
850 workspace_roots: &[AbsolutePathBuf],
851 ) -> Self {
852 let materialized = self
853 .clone()
854 .materialize_project_roots_with_workspace_roots(workspace_roots);
855 for entry in materialized.entries {
856 if !self.entries.contains(&entry) {
857 self.entries.push(entry);
858 }
859 }
860 self
861 }
862
863 pub fn with_additional_readable_roots(
864 mut self,
865 cwd: &Path,
866 additional_readable_roots: &[AbsolutePathBuf],
867 ) -> Self {
868 if self.has_full_disk_read_access() {
869 return self;
870 }
871
872 for path in additional_readable_roots {
873 if self.can_read_path_with_cwd(path.as_path(), cwd) {
874 continue;
875 }
876
877 self.entries.push(FileSystemSandboxEntry::new(
878 FileSystemPath::Path { path: path.clone() },
879 FileSystemAccessMode::Read,
880 ));
881 }
882
883 self
884 }
885
886 pub fn with_additional_writable_roots(
887 mut self,
888 cwd: &Path,
889 additional_writable_roots: &[AbsolutePathBuf],
890 ) -> Self {
891 for path in additional_writable_roots {
892 if self.can_write_path_with_cwd(path.as_path(), cwd) {
893 continue;
894 }
895
896 self.entries.push(FileSystemSandboxEntry::new(
897 FileSystemPath::Path { path: path.clone() },
898 FileSystemAccessMode::Write,
899 ));
900 }
901
902 self
903 }
904
905 pub fn with_additional_legacy_workspace_writable_roots(
912 mut self,
913 additional_writable_roots: &[AbsolutePathBuf],
914 ) -> Self {
915 if !matches!(self.kind, FileSystemSandboxKind::Restricted) {
916 return self;
917 }
918
919 for path in additional_writable_roots {
920 if !self.entries.iter().any(|entry| {
921 entry.access.can_write()
922 && matches!(&entry.path, FileSystemPath::Path { path: existing } if existing == path)
923 }) {
924 self.entries.push(FileSystemSandboxEntry::new(
925 FileSystemPath::Path { path: path.clone() },
926 FileSystemAccessMode::Write,
927 ));
928 }
929
930 for protected_path in default_read_only_subpaths_for_writable_root(
931 path, false,
932 ) {
933 append_default_read_only_path_if_no_explicit_rule(
934 &mut self.entries,
935 protected_path,
936 );
937 }
938 }
939
940 self
941 }
942
943 pub fn needs_direct_runtime_enforcement(
944 &self,
945 network_policy: NetworkSandboxPolicy,
946 cwd: &Path,
947 ) -> bool {
948 if !matches!(self.kind, FileSystemSandboxKind::Restricted) {
949 return false;
950 }
951
952 let Ok(legacy_policy) = self.to_legacy_sandbox_policy(network_policy, cwd) else {
953 return true;
954 };
955
956 if protected_metadata_names_need_direct_runtime_enforcement(self, &legacy_policy, cwd) {
957 return true;
958 }
959
960 self.semantic_signature(cwd)
961 != legacy_runtime_file_system_policy_for_cwd(&legacy_policy, cwd)
962 .semantic_signature(cwd)
963 }
964
965 pub fn is_semantically_equivalent_to(&self, other: &Self, cwd: &Path) -> bool {
968 self.semantic_signature(cwd) == other.semantic_signature(cwd)
969 }
970
971 pub fn get_readable_roots_with_cwd(&self, cwd: &Path) -> Vec<AbsolutePathBuf> {
973 if self.has_full_disk_read_access() {
974 return Vec::new();
975 }
976
977 dedup_absolute_paths(
978 self.resolved_entries_with_cwd(cwd)
979 .into_iter()
980 .filter(|entry| entry.access.can_read())
981 .filter(|entry| self.can_read_path_with_cwd(entry.path.as_path(), cwd))
982 .map(|entry| entry.path)
983 .collect(),
984 true,
985 )
986 }
987
988 pub fn get_writable_roots_with_cwd(&self, cwd: &Path) -> Vec<WritableRoot> {
991 if self.has_full_disk_write_access() {
992 return Vec::new();
993 }
994
995 let resolved_entries = self.resolved_entries_with_cwd(cwd);
996 let writable_entries: Vec<AbsolutePathBuf> = resolved_entries
997 .iter()
998 .filter(|entry| entry.access.can_write())
999 .filter(|entry| self.can_write_path_with_cwd(entry.path.as_path(), cwd))
1000 .map(|entry| entry.path.clone())
1001 .collect();
1002
1003 dedup_absolute_paths(
1004 writable_entries.clone(),
1005 true,
1006 )
1007 .into_iter()
1008 .map(|root| {
1009 let preserve_raw_carveout_paths = root.as_path().parent().is_some();
1017 let raw_writable_roots: Vec<&AbsolutePathBuf> = writable_entries
1018 .iter()
1019 .filter(|path| normalize_effective_absolute_path((*path).clone()) == root)
1020 .collect();
1021 let protected_metadata_names =
1022 protected_metadata_names_for_writable_root(self, &root, &raw_writable_roots, cwd);
1023 let protect_missing_dot_codex = AbsolutePathBuf::from_absolute_path(cwd)
1024 .ok()
1025 .is_some_and(|cwd| normalize_effective_absolute_path(cwd) == root);
1026 let mut read_only_subpaths: Vec<AbsolutePathBuf> =
1027 default_read_only_subpaths_for_writable_root(&root, protect_missing_dot_codex)
1028 .into_iter()
1029 .filter(|path| !has_explicit_resolved_path_entry(&resolved_entries, path))
1030 .collect();
1031 read_only_subpaths.extend(
1039 resolved_entries
1040 .iter()
1041 .filter(|entry| !entry.access.can_write())
1042 .filter(|entry| !self.can_write_path_with_cwd(entry.path.as_path(), cwd))
1043 .filter_map(|entry| {
1044 let effective_path = normalize_effective_absolute_path(entry.path.clone());
1045 let raw_carveout_path = if preserve_raw_carveout_paths {
1055 if entry.path == root {
1056 None
1057 } else if entry.path.as_path().starts_with(root.as_path()) {
1058 Some(entry.path.clone())
1059 } else {
1060 raw_writable_roots.iter().find_map(|raw_root| {
1061 let suffix = entry
1062 .path
1063 .as_path()
1064 .strip_prefix(raw_root.as_path())
1065 .ok()?;
1066 if suffix.as_os_str().is_empty() {
1067 return None;
1068 }
1069 Some(root.join(suffix))
1070 })
1071 }
1072 } else {
1073 None
1074 };
1075
1076 if let Some(raw_carveout_path) = raw_carveout_path {
1077 return Some(raw_carveout_path);
1078 }
1079
1080 if effective_path == root
1081 || !effective_path.as_path().starts_with(root.as_path())
1082 {
1083 return None;
1084 }
1085
1086 Some(effective_path)
1087 }),
1088 );
1089 WritableRoot {
1090 protected_metadata_names,
1091 root,
1092 read_only_subpaths: dedup_absolute_paths(
1096 read_only_subpaths,
1097 false,
1098 ),
1099 }
1100 })
1101 .collect()
1102 }
1103
1104 pub fn get_unreadable_roots_with_cwd(&self, cwd: &Path) -> Vec<AbsolutePathBuf> {
1106 if !matches!(self.kind, FileSystemSandboxKind::Restricted) {
1107 return Vec::new();
1108 }
1109
1110 let root = AbsolutePathBuf::from_absolute_path(cwd)
1111 .ok()
1112 .map(|cwd| absolute_root_path_for_cwd(&cwd));
1113
1114 dedup_absolute_paths(
1115 self.resolved_entries_with_cwd(cwd)
1116 .iter()
1117 .filter(|entry| entry.access == FileSystemAccessMode::Deny)
1118 .filter(|entry| !self.can_read_path_with_cwd(entry.path.as_path(), cwd))
1119 .filter(|entry| root.as_ref() != Some(&entry.path))
1123 .map(|entry| entry.path.clone())
1124 .collect(),
1125 true,
1126 )
1127 }
1128
1129 pub fn get_unreadable_globs_with_cwd(&self, cwd: &Path) -> Vec<String> {
1131 if !matches!(self.kind, FileSystemSandboxKind::Restricted) {
1132 return Vec::new();
1133 }
1134
1135 let mut patterns = self
1136 .entries
1137 .iter()
1138 .filter(|entry| entry.access == FileSystemAccessMode::Deny)
1139 .filter_map(|entry| match &entry.path {
1140 FileSystemPath::GlobPattern { pattern } => {
1141 Some(AbsolutePathBuf::resolve_path_against_base(pattern, cwd))
1142 }
1143 FileSystemPath::Path { .. } | FileSystemPath::Special { .. } => None,
1144 })
1145 .map(|pattern| pattern.to_string_lossy().into_owned())
1146 .collect::<Vec<_>>();
1147 patterns.sort();
1148 patterns.dedup();
1149 patterns
1150 }
1151
1152 pub fn to_legacy_sandbox_policy(
1153 &self,
1154 network_policy: NetworkSandboxPolicy,
1155 cwd: &Path,
1156 ) -> io::Result<SandboxPolicy> {
1157 Ok(match self.kind {
1158 FileSystemSandboxKind::ExternalSandbox => SandboxPolicy::ExternalSandbox {
1159 network_access: if network_policy.is_enabled() {
1160 NetworkAccess::Enabled
1161 } else {
1162 NetworkAccess::Restricted
1163 },
1164 },
1165 FileSystemSandboxKind::Unrestricted => {
1166 if network_policy.is_enabled() {
1167 SandboxPolicy::DangerFullAccess
1168 } else {
1169 SandboxPolicy::ExternalSandbox {
1170 network_access: NetworkAccess::Restricted,
1171 }
1172 }
1173 }
1174 FileSystemSandboxKind::Restricted => {
1175 let cwd_absolute = AbsolutePathBuf::from_absolute_path(cwd).ok();
1176 let has_full_disk_write_access = self.has_full_disk_write_access();
1177 let mut workspace_root_writable = false;
1178 let mut writable_roots = Vec::new();
1179 let mut tmpdir_writable = false;
1180 let mut slash_tmp_writable = false;
1181 let mut unbridgeable_root_write = false;
1182
1183 for entry in &self.entries {
1184 match &entry.path {
1185 FileSystemPath::GlobPattern { .. } => {}
1186 FileSystemPath::Path { path } => {
1187 if entry.access.can_write() {
1188 if cwd_absolute.as_ref().is_some_and(|cwd| cwd == path) {
1189 workspace_root_writable = true;
1190 } else {
1191 writable_roots.push(path.clone());
1192 }
1193 }
1194 }
1195 FileSystemPath::Special { value } => match value {
1196 FileSystemSpecialPath::Root => match entry.access {
1197 FileSystemAccessMode::Deny => {}
1198 FileSystemAccessMode::Read => {}
1199 FileSystemAccessMode::Write => {
1200 unbridgeable_root_write = true;
1201 }
1202 },
1203 FileSystemSpecialPath::Minimal => {}
1204 FileSystemSpecialPath::ProjectRoots { subpath } => {
1205 if subpath.is_none() && entry.access.can_write() {
1206 workspace_root_writable = true;
1207 } else if let Some(path) =
1208 resolve_file_system_special_path(value, cwd_absolute.as_ref())
1209 && entry.access.can_write()
1210 {
1211 writable_roots.push(path);
1212 }
1213 }
1214 FileSystemSpecialPath::Tmpdir => {
1215 if entry.access.can_write() {
1216 tmpdir_writable = true;
1217 }
1218 }
1219 FileSystemSpecialPath::SlashTmp => {
1220 if entry.access.can_write() {
1221 slash_tmp_writable = true;
1222 }
1223 }
1224 FileSystemSpecialPath::Unknown { .. } => {}
1225 },
1226 }
1227 }
1228
1229 if has_full_disk_write_access {
1230 return Ok(if network_policy.is_enabled() {
1231 SandboxPolicy::DangerFullAccess
1232 } else {
1233 SandboxPolicy::ExternalSandbox {
1234 network_access: NetworkAccess::Restricted,
1235 }
1236 });
1237 }
1238
1239 if workspace_root_writable {
1240 SandboxPolicy::WorkspaceWrite {
1241 writable_roots: dedup_absolute_paths(
1242 writable_roots,
1243 false,
1244 ),
1245 network_access: network_policy.is_enabled(),
1246 exclude_tmpdir_env_var: !tmpdir_writable,
1247 exclude_slash_tmp: !slash_tmp_writable,
1248 }
1249 } else if unbridgeable_root_write
1250 || !writable_roots.is_empty()
1251 || tmpdir_writable
1252 || slash_tmp_writable
1253 {
1254 return Err(io::Error::new(
1255 io::ErrorKind::InvalidInput,
1256 "permissions profile requests filesystem writes outside the workspace root, which is not supported until the runtime enforces FileSystemSandboxPolicy directly",
1257 ));
1258 } else {
1259 SandboxPolicy::ReadOnly {
1260 network_access: network_policy.is_enabled(),
1261 }
1262 }
1263 }
1264 })
1265 }
1266
1267 fn resolved_entries_with_cwd(&self, cwd: &Path) -> Vec<ResolvedFileSystemEntry> {
1268 let cwd_absolute = AbsolutePathBuf::from_absolute_path(cwd).ok();
1269 self.entries
1270 .iter()
1271 .filter_map(|entry| {
1272 resolve_entry_path(&entry.path, cwd_absolute.as_ref()).map(|path| {
1273 ResolvedFileSystemEntry {
1274 path,
1275 access: entry.access,
1276 }
1277 })
1278 })
1279 .collect()
1280 }
1281
1282 fn semantic_signature(&self, cwd: &Path) -> FileSystemSemanticSignature {
1283 FileSystemSemanticSignature {
1284 has_full_disk_read_access: self.has_full_disk_read_access(),
1285 has_full_disk_write_access: self.has_full_disk_write_access(),
1286 include_platform_defaults: self.include_platform_defaults(),
1287 readable_roots: sorted_absolute_paths(self.get_readable_roots_with_cwd(cwd)),
1288 writable_roots: sorted_writable_roots(self.get_writable_roots_with_cwd(cwd)),
1289 unreadable_roots: sorted_absolute_paths(self.get_unreadable_roots_with_cwd(cwd)),
1290 unreadable_globs: self.get_unreadable_globs_with_cwd(cwd),
1291 }
1292 }
1293}
1294
1295impl From<&SandboxPolicy> for NetworkSandboxPolicy {
1296 fn from(value: &SandboxPolicy) -> Self {
1297 if value.has_full_network_access() {
1298 NetworkSandboxPolicy::Enabled
1299 } else {
1300 NetworkSandboxPolicy::Restricted
1301 }
1302 }
1303}
1304
1305impl From<&SandboxPolicy> for FileSystemSandboxPolicy {
1306 fn from(value: &SandboxPolicy) -> Self {
1307 match value {
1308 SandboxPolicy::DangerFullAccess => FileSystemSandboxPolicy::unrestricted(),
1309 SandboxPolicy::ExternalSandbox { .. } => FileSystemSandboxPolicy::external_sandbox(),
1310 SandboxPolicy::ReadOnly { .. } => {
1311 FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry::new(
1312 FileSystemPath::Special {
1313 value: FileSystemSpecialPath::Root,
1314 },
1315 FileSystemAccessMode::Read,
1316 )])
1317 }
1318 SandboxPolicy::WorkspaceWrite {
1319 writable_roots,
1320 exclude_tmpdir_env_var,
1321 exclude_slash_tmp,
1322 ..
1323 } => FileSystemSandboxPolicy::workspace_write(
1324 writable_roots,
1325 *exclude_tmpdir_env_var,
1326 *exclude_slash_tmp,
1327 ),
1328 }
1329 }
1330}
1331
1332fn resolve_file_system_path(
1333 path: &FileSystemPath,
1334 cwd: Option<&AbsolutePathBuf>,
1335) -> Option<AbsolutePathBuf> {
1336 match path {
1337 FileSystemPath::Path { path } => Some(path.clone()),
1338 FileSystemPath::GlobPattern { .. } => None,
1339 FileSystemPath::Special { value } => resolve_file_system_special_path(value, cwd),
1340 }
1341}
1342
1343fn resolve_entry_path(
1344 path: &FileSystemPath,
1345 cwd: Option<&AbsolutePathBuf>,
1346) -> Option<AbsolutePathBuf> {
1347 match path {
1348 FileSystemPath::Special {
1349 value: FileSystemSpecialPath::Root,
1350 } => cwd.map(absolute_root_path_for_cwd),
1351 _ => resolve_file_system_path(path, cwd),
1352 }
1353}
1354
1355fn parse_project_roots_glob_pattern(pattern: &str) -> Option<&Path> {
1356 pattern
1357 .strip_prefix(PROJECT_ROOTS_GLOB_PATTERN_PREFIX)
1358 .map(Path::new)
1359}
1360
1361fn resolve_project_roots_glob_pattern(subpath: &Path, root: &AbsolutePathBuf) -> String {
1362 AbsolutePathBuf::resolve_path_against_base(subpath, root.as_path())
1363 .to_string_lossy()
1364 .into_owned()
1365}
1366
1367fn resolve_candidate_path(path: &Path, cwd: &Path) -> Option<AbsolutePathBuf> {
1368 if path.is_absolute() {
1369 AbsolutePathBuf::from_absolute_path(path).ok()
1370 } else {
1371 Some(AbsolutePathBuf::from_absolute_path(cwd).ok()?.join(path))
1372 }
1373}
1374
1375fn file_system_paths_share_target(left: &FileSystemPath, right: &FileSystemPath) -> bool {
1382 match (left, right) {
1383 (FileSystemPath::Path { path: left }, FileSystemPath::Path { path: right }) => {
1384 left == right
1385 }
1386 (FileSystemPath::Special { value: left }, FileSystemPath::Special { value: right }) => {
1387 special_paths_share_target(left, right)
1388 }
1389 (FileSystemPath::Path { path }, FileSystemPath::Special { value })
1390 | (FileSystemPath::Special { value }, FileSystemPath::Path { path }) => {
1391 special_path_matches_absolute_path(value, path)
1392 }
1393 (
1394 FileSystemPath::GlobPattern { pattern: left },
1395 FileSystemPath::GlobPattern { pattern: right },
1396 ) => left == right,
1397 (FileSystemPath::GlobPattern { .. }, _) | (_, FileSystemPath::GlobPattern { .. }) => false,
1398 }
1399}
1400
1401fn special_paths_share_target(left: &FileSystemSpecialPath, right: &FileSystemSpecialPath) -> bool {
1404 match (left, right) {
1405 (FileSystemSpecialPath::Root, FileSystemSpecialPath::Root)
1406 | (FileSystemSpecialPath::Minimal, FileSystemSpecialPath::Minimal)
1407 | (FileSystemSpecialPath::Tmpdir, FileSystemSpecialPath::Tmpdir)
1408 | (FileSystemSpecialPath::SlashTmp, FileSystemSpecialPath::SlashTmp) => true,
1409 (
1410 FileSystemSpecialPath::ProjectRoots { subpath: left },
1411 FileSystemSpecialPath::ProjectRoots { subpath: right },
1412 ) => left == right,
1413 (
1414 FileSystemSpecialPath::Unknown {
1415 path: left,
1416 subpath: left_subpath,
1417 },
1418 FileSystemSpecialPath::Unknown {
1419 path: right,
1420 subpath: right_subpath,
1421 },
1422 ) => left == right && left_subpath == right_subpath,
1423 _ => false,
1424 }
1425}
1426
1427fn special_path_matches_absolute_path(
1433 value: &FileSystemSpecialPath,
1434 path: &AbsolutePathBuf,
1435) -> bool {
1436 match value {
1437 FileSystemSpecialPath::Root => path.as_path().parent().is_none(),
1438 FileSystemSpecialPath::SlashTmp => path.as_path() == Path::new("/tmp"),
1439 _ => false,
1440 }
1441}
1442
1443fn resolved_entry_precedence(entry: &ResolvedFileSystemEntry) -> (usize, FileSystemAccessMode) {
1446 let specificity = entry.path.as_path().components().count();
1447 (specificity, entry.access)
1448}
1449
1450fn absolute_root_path_for_cwd(cwd: &AbsolutePathBuf) -> AbsolutePathBuf {
1451 let root = cwd
1452 .as_path()
1453 .ancestors()
1454 .last()
1455 .unwrap_or_else(|| panic!("cwd must have a filesystem root"));
1456 AbsolutePathBuf::from_absolute_path(root)
1457 .unwrap_or_else(|err| panic!("cwd root must be an absolute path: {err}"))
1458}
1459
1460fn normalized_and_canonical_candidates(path: &Path) -> Vec<PathBuf> {
1461 let mut candidates = Vec::new();
1465
1466 if let Ok(normalized) = AbsolutePathBuf::from_absolute_path(path) {
1467 push_unique(&mut candidates, normalized.to_path_buf());
1468 } else {
1469 push_unique(&mut candidates, path.to_path_buf());
1470 }
1471
1472 if let Ok(canonical) = path.canonicalize()
1473 && let Ok(canonical_absolute) = AbsolutePathBuf::from_absolute_path(canonical)
1474 {
1475 push_unique(&mut candidates, canonical_absolute.to_path_buf());
1476 }
1477
1478 candidates
1479}
1480
1481fn push_unique(candidates: &mut Vec<PathBuf>, candidate: PathBuf) {
1482 if !candidates.iter().any(|existing| existing == &candidate) {
1483 candidates.push(candidate);
1484 }
1485}
1486
1487fn build_glob_matcher(pattern: &str) -> Result<GlobMatcher, String> {
1488 GlobBuilder::new(pattern)
1491 .literal_separator(true)
1492 .allow_unclosed_class(true)
1493 .build()
1494 .map(|glob| glob.compile_matcher())
1495 .map_err(|err| err.to_string())
1496}
1497
1498fn resolve_file_system_special_path(
1499 value: &FileSystemSpecialPath,
1500 cwd: Option<&AbsolutePathBuf>,
1501) -> Option<AbsolutePathBuf> {
1502 match value {
1503 FileSystemSpecialPath::Root
1504 | FileSystemSpecialPath::Minimal
1505 | FileSystemSpecialPath::Unknown { .. } => None,
1506 FileSystemSpecialPath::ProjectRoots { subpath } => {
1507 let cwd = cwd?;
1508 match subpath.as_ref() {
1509 Some(subpath) => Some(AbsolutePathBuf::resolve_path_against_base(
1510 subpath,
1511 cwd.as_path(),
1512 )),
1513 None => Some(cwd.clone()),
1514 }
1515 }
1516 FileSystemSpecialPath::Tmpdir => {
1517 let tmpdir = std::env::var_os("TMPDIR")?;
1518 if tmpdir.is_empty() {
1519 None
1520 } else {
1521 let tmpdir = AbsolutePathBuf::from_absolute_path(PathBuf::from(tmpdir)).ok()?;
1522 Some(tmpdir)
1523 }
1524 }
1525 FileSystemSpecialPath::SlashTmp => {
1526 #[allow(clippy::expect_used)]
1527 let slash_tmp = AbsolutePathBuf::from_absolute_path("/tmp").expect("/tmp is absolute");
1528 if !slash_tmp.as_path().is_dir() {
1529 return None;
1530 }
1531 Some(slash_tmp)
1532 }
1533 }
1534}
1535
1536fn dedup_absolute_paths(
1537 paths: Vec<AbsolutePathBuf>,
1538 normalize_effective_paths: bool,
1539) -> Vec<AbsolutePathBuf> {
1540 let mut deduped = Vec::with_capacity(paths.len());
1541 let mut seen = HashSet::new();
1542 for path in paths {
1543 let dedup_path = if normalize_effective_paths {
1544 normalize_effective_absolute_path(path)
1545 } else {
1546 path
1547 };
1548 if seen.insert(dedup_path.to_path_buf()) {
1549 deduped.push(dedup_path);
1550 }
1551 }
1552 deduped
1553}
1554
1555fn sorted_absolute_paths(mut paths: Vec<AbsolutePathBuf>) -> Vec<AbsolutePathBuf> {
1556 paths.sort_by(|left, right| left.as_path().cmp(right.as_path()));
1557 paths
1558}
1559
1560fn sorted_writable_roots(mut roots: Vec<WritableRoot>) -> Vec<WritableRoot> {
1561 for root in &mut roots {
1562 root.read_only_subpaths =
1563 sorted_absolute_paths(std::mem::take(&mut root.read_only_subpaths));
1564 root.protected_metadata_names.sort();
1565 root.protected_metadata_names.dedup();
1566 }
1567 roots.sort_by(|left, right| left.root.as_path().cmp(right.root.as_path()));
1568 roots
1569}
1570
1571fn normalize_effective_absolute_path(path: AbsolutePathBuf) -> AbsolutePathBuf {
1572 let raw_path = path.to_path_buf();
1573 for ancestor in raw_path.ancestors() {
1574 if std::fs::symlink_metadata(ancestor).is_err() {
1575 continue;
1576 }
1577 let Ok(normalized_ancestor) = canonicalize_preserving_symlinks(ancestor) else {
1578 continue;
1579 };
1580 let Ok(suffix) = raw_path.strip_prefix(ancestor) else {
1581 continue;
1582 };
1583 if let Ok(normalized_path) =
1584 AbsolutePathBuf::from_absolute_path(normalized_ancestor.join(suffix))
1585 {
1586 return normalized_path;
1587 }
1588 }
1589 path
1590}
1591
1592pub(crate) fn default_read_only_subpaths_for_writable_root(
1593 writable_root: &AbsolutePathBuf,
1594 protect_missing_dot_codex: bool,
1595) -> Vec<AbsolutePathBuf> {
1596 let mut subpaths: Vec<AbsolutePathBuf> = Vec::new();
1597 let top_level_git = writable_root.join(PROTECTED_METADATA_GIT_PATH_NAME);
1598 let top_level_git_is_file = top_level_git.as_path().is_file();
1602 let top_level_git_is_dir = top_level_git.as_path().is_dir();
1603 let should_protect_top_level = top_level_git_is_dir || top_level_git_is_file;
1604 if should_protect_top_level {
1605 if top_level_git_is_file
1606 && is_git_pointer_file(&top_level_git)
1607 && let Some(gitdir) = resolve_gitdir_from_file(&top_level_git)
1608 {
1609 subpaths.push(gitdir);
1610 }
1611 subpaths.push(top_level_git);
1612 }
1613
1614 let top_level_agents = writable_root.join(PROTECTED_METADATA_AGENTS_PATH_NAME);
1615 if top_level_agents.as_path().is_dir() {
1616 subpaths.push(top_level_agents);
1617 }
1618
1619 let top_level_codex = writable_root.join(PROTECTED_METADATA_CODEX_PATH_NAME);
1624 if protect_missing_dot_codex || top_level_codex.as_path().is_dir() {
1625 subpaths.push(top_level_codex);
1626 }
1627
1628 dedup_absolute_paths(subpaths, false)
1629}
1630
1631fn legacy_runtime_file_system_policy_for_cwd(
1639 sandbox_policy: &SandboxPolicy,
1640 cwd: &Path,
1641) -> FileSystemSandboxPolicy {
1642 let SandboxPolicy::WorkspaceWrite {
1643 writable_roots,
1644 exclude_tmpdir_env_var,
1645 exclude_slash_tmp,
1646 ..
1647 } = sandbox_policy
1648 else {
1649 return FileSystemSandboxPolicy::from(sandbox_policy);
1650 };
1651
1652 let mut entries = vec![
1653 FileSystemSandboxEntry::new(
1654 FileSystemPath::Special {
1655 value: FileSystemSpecialPath::Root,
1656 },
1657 FileSystemAccessMode::Read,
1658 ),
1659 FileSystemSandboxEntry::new(
1660 FileSystemPath::Special {
1661 value: FileSystemSpecialPath::project_roots(None),
1662 },
1663 FileSystemAccessMode::Write,
1664 ),
1665 ];
1666
1667 if !*exclude_slash_tmp {
1668 entries.push(FileSystemSandboxEntry::new(
1669 FileSystemPath::Special {
1670 value: FileSystemSpecialPath::SlashTmp,
1671 },
1672 FileSystemAccessMode::Write,
1673 ));
1674 }
1675 if !*exclude_tmpdir_env_var {
1676 entries.push(FileSystemSandboxEntry::new(
1677 FileSystemPath::Special {
1678 value: FileSystemSpecialPath::Tmpdir,
1679 },
1680 FileSystemAccessMode::Write,
1681 ));
1682 }
1683 entries.extend(writable_roots.iter().cloned().map(|path| {
1684 FileSystemSandboxEntry::new(FileSystemPath::Path { path }, FileSystemAccessMode::Write)
1685 }));
1686
1687 if let Ok(cwd_root) = AbsolutePathBuf::from_absolute_path(cwd) {
1688 for protected_path in default_read_only_subpaths_for_writable_root(
1689 &cwd_root, true,
1690 ) {
1691 append_default_read_only_path_if_no_explicit_rule(&mut entries, protected_path);
1692 }
1693 }
1694 for writable_root in writable_roots {
1695 for protected_path in default_read_only_subpaths_for_writable_root(
1696 writable_root,
1697 false,
1698 ) {
1699 append_default_read_only_path_if_no_explicit_rule(&mut entries, protected_path);
1700 }
1701 }
1702
1703 FileSystemSandboxPolicy::restricted(entries)
1704}
1705
1706fn append_default_read_only_project_root_subpath_if_no_explicit_rule(
1707 entries: &mut Vec<FileSystemSandboxEntry>,
1708 subpath: impl Into<String>,
1709) {
1710 append_default_read_only_entry_if_no_explicit_rule(
1711 entries,
1712 FileSystemPath::Special {
1713 value: FileSystemSpecialPath::project_roots(Some(subpath.into())),
1714 },
1715 );
1716}
1717
1718fn append_default_read_only_path_if_no_explicit_rule(
1719 entries: &mut Vec<FileSystemSandboxEntry>,
1720 path: AbsolutePathBuf,
1721) {
1722 append_default_read_only_entry_if_no_explicit_rule(entries, FileSystemPath::Path { path });
1723}
1724
1725fn append_default_read_only_entry_if_no_explicit_rule(
1726 entries: &mut Vec<FileSystemSandboxEntry>,
1727 path: FileSystemPath,
1728) {
1729 if entries
1730 .iter()
1731 .any(|entry| file_system_paths_share_target(&entry.path, &path))
1732 {
1733 return;
1734 }
1735
1736 entries.push(FileSystemSandboxEntry::skip_missing_path(
1737 path,
1738 FileSystemAccessMode::Read,
1739 ));
1740}
1741
1742fn has_explicit_resolved_path_entry(
1743 entries: &[ResolvedFileSystemEntry],
1744 path: &AbsolutePathBuf,
1745) -> bool {
1746 entries.iter().any(|entry| &entry.path == path)
1747}
1748
1749fn metadata_path_name(name: &OsStr) -> Option<&'static str> {
1750 PROTECTED_METADATA_PATH_NAMES
1751 .iter()
1752 .copied()
1753 .find(|metadata_name| name == OsStr::new(metadata_name))
1754}
1755
1756fn metadata_child_of_writable_root(
1757 policy: &FileSystemSandboxPolicy,
1758 target: &Path,
1759 cwd: &Path,
1760) -> Option<(AbsolutePathBuf, &'static str)> {
1761 policy
1762 .resolved_entries_with_cwd(cwd)
1763 .iter()
1764 .filter(|entry| entry.access.can_write())
1765 .filter_map(|entry| {
1766 let relative_path = target.strip_prefix(entry.path.as_path()).ok()?;
1767 let first_component = relative_path.components().next()?;
1768 let metadata_name = metadata_path_name(first_component.as_os_str())?;
1769 Some((entry.path.join(metadata_name), metadata_name))
1770 })
1771 .next()
1772}
1773
1774fn protected_metadata_names_for_writable_root(
1775 policy: &FileSystemSandboxPolicy,
1776 root: &AbsolutePathBuf,
1777 raw_writable_roots: &[&AbsolutePathBuf],
1778 cwd: &Path,
1779) -> Vec<String> {
1780 let mut protected_names = Vec::new();
1781 for metadata_name in PROTECTED_METADATA_PATH_NAMES {
1782 let mut metadata_paths = vec![root.join(*metadata_name)];
1783 metadata_paths.extend(
1784 raw_writable_roots
1785 .iter()
1786 .map(|raw_root| raw_root.join(*metadata_name)),
1787 );
1788
1789 if metadata_paths
1790 .iter()
1791 .all(|metadata_path| !policy.can_write_path_with_cwd(metadata_path.as_path(), cwd))
1792 {
1793 protected_names.push((*metadata_name).to_string());
1794 }
1795 }
1796 protected_names
1797}
1798
1799fn protected_metadata_names_need_direct_runtime_enforcement(
1800 policy: &FileSystemSandboxPolicy,
1801 legacy_policy: &SandboxPolicy,
1802 cwd: &Path,
1803) -> bool {
1804 let legacy_roots = legacy_policy.get_writable_roots_with_cwd(cwd);
1805 policy
1806 .get_writable_roots_with_cwd(cwd)
1807 .into_iter()
1808 .any(|writable_root| {
1809 let Some(legacy_root) = legacy_roots
1810 .iter()
1811 .find(|candidate| candidate.root == writable_root.root)
1812 else {
1813 return !writable_root.protected_metadata_names.is_empty();
1814 };
1815
1816 writable_root
1817 .protected_metadata_names
1818 .iter()
1819 .any(|metadata_name| {
1820 let metadata_path = writable_root.root.join(metadata_name);
1821 !legacy_root
1822 .read_only_subpaths
1823 .iter()
1824 .any(|subpath| subpath == &metadata_path)
1825 })
1826 })
1827}
1828
1829fn has_explicit_write_entry_for_metadata_path(
1830 policy: &FileSystemSandboxPolicy,
1831 protected_metadata_path: &AbsolutePathBuf,
1832 target: &Path,
1833 cwd: &Path,
1834) -> bool {
1835 policy.resolved_entries_with_cwd(cwd).iter().any(|entry| {
1836 entry.access.can_write()
1837 && target.starts_with(entry.path.as_path())
1838 && entry
1839 .path
1840 .as_path()
1841 .starts_with(protected_metadata_path.as_path())
1842 })
1843}
1844
1845fn is_git_pointer_file(path: &AbsolutePathBuf) -> bool {
1846 path.as_path().is_file()
1847 && path.as_path().file_name() == Some(OsStr::new(PROTECTED_METADATA_GIT_PATH_NAME))
1848}
1849
1850fn resolve_gitdir_from_file(dot_git: &AbsolutePathBuf) -> Option<AbsolutePathBuf> {
1851 let contents = match std::fs::read_to_string(dot_git.as_path()) {
1852 Ok(contents) => contents,
1853 Err(err) => {
1854 error!(
1855 "Failed to read {path} for gitdir pointer: {err}",
1856 path = dot_git.as_path().display()
1857 );
1858 return None;
1859 }
1860 };
1861
1862 let trimmed = contents.trim();
1863 let (_, gitdir_raw) = match trimmed.split_once(':') {
1864 Some((prefix, gitdir_raw)) if prefix.trim() == "gitdir" => (prefix, gitdir_raw),
1865 Some(_) => {
1866 error!(
1867 "Expected {path} to contain a gitdir pointer, but it did not match `gitdir: <path>`.",
1868 path = dot_git.as_path().display()
1869 );
1870 return None;
1871 }
1872 None => {
1873 error!(
1874 "Expected {path} to contain a gitdir pointer, but it did not match `gitdir: <path>`.",
1875 path = dot_git.as_path().display()
1876 );
1877 return None;
1878 }
1879 };
1880 let gitdir_raw = gitdir_raw.trim();
1881 if gitdir_raw.is_empty() {
1882 error!(
1883 "Expected {path} to contain a gitdir pointer, but it was empty.",
1884 path = dot_git.as_path().display()
1885 );
1886 return None;
1887 }
1888 let base = match dot_git.as_path().parent() {
1889 Some(base) => base,
1890 None => {
1891 error!(
1892 "Unable to resolve parent directory for {path}.",
1893 path = dot_git.as_path().display()
1894 );
1895 return None;
1896 }
1897 };
1898 let gitdir_path = AbsolutePathBuf::resolve_path_against_base(gitdir_raw, base);
1899 if !gitdir_path.as_path().exists() {
1900 error!(
1901 "Resolved gitdir path {path} does not exist.",
1902 path = gitdir_path.as_path().display()
1903 );
1904 return None;
1905 }
1906 Some(gitdir_path)
1907}
1908
1909#[cfg(test)]
1910mod tests {
1911 use super::*;
1912 use pretty_assertions::assert_eq;
1913 #[cfg(unix)]
1914 use std::fs;
1915 use std::path::Path;
1916 use tempfile::TempDir;
1917
1918 #[cfg(unix)]
1919 const SYMLINKED_TMPDIR_TEST_ENV: &str = "CODEX_PROTOCOL_TEST_SYMLINKED_TMPDIR";
1920
1921 #[cfg(unix)]
1922 fn symlink_dir(original: &Path, link: &Path) -> std::io::Result<()> {
1923 std::os::unix::fs::symlink(original, link)
1924 }
1925
1926 #[test]
1927 fn unknown_special_paths_are_ignored_by_legacy_bridge() -> std::io::Result<()> {
1928 let policy = FileSystemSandboxPolicy::restricted(vec![
1929 FileSystemSandboxEntry {
1930 path: FileSystemPath::Special {
1931 value: FileSystemSpecialPath::Root,
1932 },
1933 access: FileSystemAccessMode::Read,
1934 missing_path_behavior: None,
1935 },
1936 FileSystemSandboxEntry {
1937 path: FileSystemPath::Special {
1938 value: FileSystemSpecialPath::unknown(
1939 ":future_special_path",
1940 None,
1941 ),
1942 },
1943 access: FileSystemAccessMode::Write,
1944 missing_path_behavior: None,
1945 },
1946 ]);
1947
1948 let sandbox_policy = policy.to_legacy_sandbox_policy(
1949 NetworkSandboxPolicy::Restricted,
1950 Path::new("/tmp/workspace"),
1951 )?;
1952
1953 assert_eq!(
1954 sandbox_policy,
1955 SandboxPolicy::ReadOnly {
1956 network_access: false,
1957 }
1958 );
1959 Ok(())
1960 }
1961
1962 #[cfg(unix)]
1963 #[test]
1964 fn writable_roots_proactively_protect_missing_dot_codex() {
1965 let cwd = TempDir::new().expect("tempdir");
1966 let expected_root = AbsolutePathBuf::from_absolute_path(
1967 cwd.path().canonicalize().expect("canonicalize cwd"),
1968 )
1969 .expect("absolute canonical root");
1970 let expected_dot_codex = expected_root.join(".codex");
1971
1972 let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
1973 path: FileSystemPath::Special {
1974 value: FileSystemSpecialPath::project_roots(None),
1975 },
1976 access: FileSystemAccessMode::Write,
1977 missing_path_behavior: None,
1978 }]);
1979
1980 let writable_roots = policy.get_writable_roots_with_cwd(cwd.path());
1981 assert_eq!(writable_roots.len(), 1);
1982 assert_eq!(writable_roots[0].root, expected_root);
1983 assert!(
1984 writable_roots[0]
1985 .read_only_subpaths
1986 .contains(&expected_dot_codex)
1987 );
1988 }
1989
1990 #[test]
1991 fn legacy_workspace_write_projection_preserves_symbolic_project_root() {
1992 let policy = SandboxPolicy::WorkspaceWrite {
1993 writable_roots: Vec::new(),
1994 network_access: false,
1995 exclude_tmpdir_env_var: true,
1996 exclude_slash_tmp: true,
1997 };
1998
1999 assert_eq!(
2000 FileSystemSandboxPolicy::from(&policy),
2001 FileSystemSandboxPolicy::restricted(vec![
2002 FileSystemSandboxEntry {
2003 path: FileSystemPath::Special {
2004 value: FileSystemSpecialPath::Root,
2005 },
2006 access: FileSystemAccessMode::Read,
2007 missing_path_behavior: None,
2008 },
2009 FileSystemSandboxEntry {
2010 path: FileSystemPath::Special {
2011 value: FileSystemSpecialPath::project_roots(None),
2012 },
2013 access: FileSystemAccessMode::Write,
2014 missing_path_behavior: None,
2015 },
2016 FileSystemSandboxEntry::skip_missing_path(
2017 FileSystemPath::Special {
2018 value: FileSystemSpecialPath::project_roots(Some(".git".into())),
2019 },
2020 FileSystemAccessMode::Read,
2021 ),
2022 FileSystemSandboxEntry::skip_missing_path(
2023 FileSystemPath::Special {
2024 value: FileSystemSpecialPath::project_roots(Some(".agents".into())),
2025 },
2026 FileSystemAccessMode::Read,
2027 ),
2028 FileSystemSandboxEntry::skip_missing_path(
2029 FileSystemPath::Special {
2030 value: FileSystemSpecialPath::project_roots(Some(".codex".into())),
2031 },
2032 FileSystemAccessMode::Read,
2033 ),
2034 ])
2035 );
2036 }
2037
2038 #[test]
2039 fn legacy_current_working_directory_special_path_deserializes_as_project_roots()
2040 -> serde_json::Result<()> {
2041 let value = serde_json::json!({
2042 "kind": "current_working_directory",
2043 });
2044
2045 let special_path = serde_json::from_value::<FileSystemSpecialPath>(value)?;
2046 assert_eq!(
2047 special_path,
2048 FileSystemSpecialPath::project_roots(None)
2049 );
2050 assert_eq!(
2051 serde_json::to_value(&special_path)?,
2052 serde_json::json!({
2053 "kind": "project_roots",
2054 })
2055 );
2056 Ok(())
2057 }
2058
2059 #[cfg(unix)]
2060 #[test]
2061 fn writable_roots_skip_default_dot_codex_when_explicit_user_rule_exists() {
2062 let cwd = TempDir::new().expect("tempdir");
2063 let expected_root = AbsolutePathBuf::from_absolute_path(
2064 cwd.path().canonicalize().expect("canonicalize cwd"),
2065 )
2066 .expect("absolute canonical root");
2067 let explicit_dot_codex = expected_root.join(".codex");
2068
2069 let policy = FileSystemSandboxPolicy::restricted(vec![
2070 FileSystemSandboxEntry {
2071 path: FileSystemPath::Special {
2072 value: FileSystemSpecialPath::project_roots(None),
2073 },
2074 access: FileSystemAccessMode::Write,
2075 missing_path_behavior: None,
2076 },
2077 FileSystemSandboxEntry {
2078 path: FileSystemPath::Path {
2079 path: explicit_dot_codex.clone(),
2080 },
2081 access: FileSystemAccessMode::Write,
2082 missing_path_behavior: None,
2083 },
2084 ]);
2085
2086 let writable_roots = policy.get_writable_roots_with_cwd(cwd.path());
2087 let workspace_root = writable_roots
2088 .iter()
2089 .find(|root| root.root == expected_root)
2090 .expect("workspace writable root");
2091 assert!(
2092 !workspace_root
2093 .protected_metadata_names
2094 .contains(&".codex".to_string()),
2095 "explicit .codex rule should remove the metadata-name protection"
2096 );
2097 assert!(
2098 !workspace_root
2099 .read_only_subpaths
2100 .contains(&explicit_dot_codex),
2101 "explicit .codex rule should win over the default protected carveout"
2102 );
2103 assert!(
2104 policy.can_write_path_with_cwd(
2105 explicit_dot_codex.join("config.toml").as_path(),
2106 cwd.path()
2107 )
2108 );
2109 }
2110
2111 #[test]
2112 fn filesystem_policy_blocks_protected_metadata_path_writes_by_default() {
2113 let cwd = TempDir::new().expect("tempdir");
2114 let dot_git_config = cwd.path().join(".git").join("config");
2115 let dot_agents_config = cwd.path().join(".agents").join("config");
2116 let dot_codex_config = cwd.path().join(".codex").join("config.toml");
2117 let root = AbsolutePathBuf::from_absolute_path(cwd.path()).expect("absolute cwd");
2118 let file_system_policy =
2119 FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
2120 path: FileSystemPath::Path { path: root },
2121 access: FileSystemAccessMode::Write,
2122 missing_path_behavior: None,
2123 }]);
2124
2125 assert!(!file_system_policy.can_write_path_with_cwd(&dot_git_config, cwd.path()));
2126 assert!(!file_system_policy.can_write_path_with_cwd(&dot_agents_config, cwd.path()));
2127 assert!(!file_system_policy.can_write_path_with_cwd(&dot_codex_config, cwd.path()));
2128
2129 let writable_roots = file_system_policy.get_writable_roots_with_cwd(cwd.path());
2130 assert_eq!(writable_roots.len(), 1);
2131 assert_eq!(
2132 writable_roots[0].protected_metadata_names,
2133 vec![
2134 ".git".to_string(),
2135 ".agents".to_string(),
2136 ".codex".to_string(),
2137 ]
2138 );
2139 assert!(!writable_roots[0].is_path_writable(&dot_git_config));
2140 assert!(!writable_roots[0].is_path_writable(&dot_agents_config));
2141 assert!(!writable_roots[0].is_path_writable(&dot_codex_config));
2142 }
2143
2144 #[test]
2145 fn legacy_workspace_write_projection_accepts_relative_cwd() {
2146 let relative_cwd = Path::new("workspace");
2147 let expected_root = AbsolutePathBuf::from_absolute_path(
2148 std::env::current_dir()
2149 .expect("current dir")
2150 .join(relative_cwd),
2151 )
2152 .expect("absolute root");
2153 let policy = SandboxPolicy::WorkspaceWrite {
2154 writable_roots: vec![],
2155 network_access: false,
2156 exclude_tmpdir_env_var: true,
2157 exclude_slash_tmp: true,
2158 };
2159
2160 let file_system_policy =
2161 FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&policy, relative_cwd);
2162
2163 let mut expected_entries = vec![
2164 FileSystemSandboxEntry {
2165 path: FileSystemPath::Special {
2166 value: FileSystemSpecialPath::Root,
2167 },
2168 access: FileSystemAccessMode::Read,
2169 missing_path_behavior: None,
2170 },
2171 FileSystemSandboxEntry {
2172 path: FileSystemPath::Special {
2173 value: FileSystemSpecialPath::project_roots(None),
2174 },
2175 access: FileSystemAccessMode::Write,
2176 missing_path_behavior: None,
2177 },
2178 ];
2179 expected_entries.extend(PROTECTED_METADATA_PATH_NAMES.iter().map(|name| {
2180 FileSystemSandboxEntry::skip_missing_path(
2181 FileSystemPath::Special {
2182 value: FileSystemSpecialPath::project_roots(Some((*name).into())),
2183 },
2184 FileSystemAccessMode::Read,
2185 )
2186 }));
2187 expected_entries.extend(
2188 default_read_only_subpaths_for_writable_root(
2189 &expected_root,
2190 true,
2191 )
2192 .into_iter()
2193 .map(|path| {
2194 FileSystemSandboxEntry::skip_missing_path(
2195 FileSystemPath::Path { path },
2196 FileSystemAccessMode::Read,
2197 )
2198 }),
2199 );
2200
2201 assert_eq!(
2202 file_system_policy,
2203 FileSystemSandboxPolicy::restricted(expected_entries)
2204 );
2205 assert_eq!(
2206 forbidden_agent_metadata_write(
2207 Path::new(".git/config"),
2208 relative_cwd,
2209 &file_system_policy,
2210 ),
2211 Some(".git")
2212 );
2213 assert!(
2214 !file_system_policy
2215 .can_write_path_with_cwd(Path::new(".codex/config.toml"), relative_cwd,)
2216 );
2217 assert!(
2218 !file_system_policy.can_write_path_with_cwd(
2219 Path::new(".agents/skills/example/SKILL.md"),
2220 relative_cwd,
2221 )
2222 );
2223 }
2224
2225 #[cfg(unix)]
2226 #[test]
2227 fn effective_runtime_roots_preserve_symlinked_paths() {
2228 let cwd = TempDir::new().expect("tempdir");
2229 let real_root = cwd.path().join("real");
2230 let link_root = cwd.path().join("link");
2231 let blocked = real_root.join("blocked");
2232 let codex_dir = real_root.join(".codex");
2233
2234 fs::create_dir_all(&blocked).expect("create blocked");
2235 fs::create_dir_all(&codex_dir).expect("create .codex");
2236 symlink_dir(&real_root, &link_root).expect("create symlinked root");
2237
2238 let link_root =
2239 AbsolutePathBuf::from_absolute_path(&link_root).expect("absolute symlinked root");
2240 let link_blocked = link_root.join("blocked");
2241 let expected_root = link_root.clone();
2242 let expected_blocked = link_blocked.clone();
2243 let expected_codex = link_root.join(".codex");
2244
2245 let policy = FileSystemSandboxPolicy::restricted(vec![
2246 FileSystemSandboxEntry {
2247 path: FileSystemPath::Path { path: link_root },
2248 access: FileSystemAccessMode::Write,
2249 missing_path_behavior: None,
2250 },
2251 FileSystemSandboxEntry {
2252 path: FileSystemPath::Path { path: link_blocked },
2253 access: FileSystemAccessMode::Deny,
2254 missing_path_behavior: None,
2255 },
2256 ]);
2257
2258 assert_eq!(
2259 policy.get_unreadable_roots_with_cwd(cwd.path()),
2260 vec![expected_blocked.clone()]
2261 );
2262
2263 let writable_roots = policy.get_writable_roots_with_cwd(cwd.path());
2264 assert_eq!(writable_roots.len(), 1);
2265 assert_eq!(writable_roots[0].root, expected_root);
2266 assert!(
2267 writable_roots[0]
2268 .read_only_subpaths
2269 .contains(&expected_blocked)
2270 );
2271 assert!(
2272 writable_roots[0]
2273 .read_only_subpaths
2274 .contains(&expected_codex)
2275 );
2276 }
2277
2278 #[cfg(unix)]
2279 #[test]
2280 fn project_roots_special_path_preserves_symlinked_root() {
2281 let cwd = TempDir::new().expect("tempdir");
2282 let real_root = cwd.path().join("real");
2283 let link_root = cwd.path().join("link");
2284 let blocked = real_root.join("blocked");
2285 let agents_dir = real_root.join(".agents");
2286 let codex_dir = real_root.join(".codex");
2287
2288 fs::create_dir_all(&blocked).expect("create blocked");
2289 fs::create_dir_all(&agents_dir).expect("create .agents");
2290 fs::create_dir_all(&codex_dir).expect("create .codex");
2291 symlink_dir(&real_root, &link_root).expect("create symlinked cwd");
2292
2293 let link_blocked =
2294 AbsolutePathBuf::from_absolute_path(link_root.join("blocked")).expect("link blocked");
2295 let expected_root =
2296 AbsolutePathBuf::from_absolute_path(&link_root).expect("absolute symlinked root");
2297 let expected_blocked = link_blocked.clone();
2298 let expected_agents = expected_root.join(".agents");
2299 let expected_codex = expected_root.join(".codex");
2300
2301 let policy = FileSystemSandboxPolicy::restricted(vec![
2302 FileSystemSandboxEntry {
2303 path: FileSystemPath::Special {
2304 value: FileSystemSpecialPath::Minimal,
2305 },
2306 access: FileSystemAccessMode::Read,
2307 missing_path_behavior: None,
2308 },
2309 FileSystemSandboxEntry {
2310 path: FileSystemPath::Special {
2311 value: FileSystemSpecialPath::project_roots(None),
2312 },
2313 access: FileSystemAccessMode::Write,
2314 missing_path_behavior: None,
2315 },
2316 FileSystemSandboxEntry {
2317 path: FileSystemPath::Path { path: link_blocked },
2318 access: FileSystemAccessMode::Deny,
2319 missing_path_behavior: None,
2320 },
2321 ]);
2322
2323 assert_eq!(
2324 policy.get_readable_roots_with_cwd(&link_root),
2325 vec![expected_root.clone()]
2326 );
2327 assert_eq!(
2328 policy.get_unreadable_roots_with_cwd(&link_root),
2329 vec![expected_blocked.clone()]
2330 );
2331
2332 let writable_roots = policy.get_writable_roots_with_cwd(&link_root);
2333 assert_eq!(writable_roots.len(), 1);
2334 assert_eq!(writable_roots[0].root, expected_root);
2335 assert!(
2336 writable_roots[0]
2337 .read_only_subpaths
2338 .contains(&expected_blocked)
2339 );
2340 assert!(
2341 writable_roots[0]
2342 .read_only_subpaths
2343 .contains(&expected_agents)
2344 );
2345 assert!(
2346 writable_roots[0]
2347 .read_only_subpaths
2348 .contains(&expected_codex)
2349 );
2350 }
2351
2352 #[cfg(unix)]
2353 #[test]
2354 fn writable_roots_preserve_symlinked_protected_subpaths() {
2355 let cwd = TempDir::new().expect("tempdir");
2356 let root = cwd.path().join("root");
2357 let decoy = root.join("decoy-codex");
2358 let dot_codex = root.join(".codex");
2359 fs::create_dir_all(&decoy).expect("create decoy");
2360 symlink_dir(&decoy, &dot_codex).expect("create .codex symlink");
2361
2362 let root = AbsolutePathBuf::from_absolute_path(&root).expect("absolute root");
2363 let expected_dot_codex = AbsolutePathBuf::from_absolute_path(
2364 root.as_path()
2365 .canonicalize()
2366 .expect("canonicalize root")
2367 .join(".codex"),
2368 )
2369 .expect("absolute .codex symlink");
2370 let unexpected_decoy =
2371 AbsolutePathBuf::from_absolute_path(decoy.canonicalize().expect("canonicalize decoy"))
2372 .expect("absolute canonical decoy");
2373
2374 let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
2375 path: FileSystemPath::Path { path: root },
2376 access: FileSystemAccessMode::Write,
2377 missing_path_behavior: None,
2378 }]);
2379
2380 let writable_roots = policy.get_writable_roots_with_cwd(cwd.path());
2381 assert_eq!(writable_roots.len(), 1);
2382 assert_eq!(
2383 writable_roots[0].read_only_subpaths,
2384 vec![expected_dot_codex]
2385 );
2386 assert!(
2387 !writable_roots[0]
2388 .read_only_subpaths
2389 .contains(&unexpected_decoy)
2390 );
2391 }
2392
2393 #[cfg(unix)]
2394 #[test]
2395 fn writable_roots_preserve_explicit_symlinked_carveouts_under_symlinked_roots() {
2396 let cwd = TempDir::new().expect("tempdir");
2397 let real_root = cwd.path().join("real");
2398 let link_root = cwd.path().join("link");
2399 let decoy = real_root.join("decoy-private");
2400 let linked_private = real_root.join("linked-private");
2401 fs::create_dir_all(&decoy).expect("create decoy");
2402 symlink_dir(&real_root, &link_root).expect("create symlinked root");
2403 symlink_dir(&decoy, &linked_private).expect("create linked-private symlink");
2404
2405 let link_root =
2406 AbsolutePathBuf::from_absolute_path(&link_root).expect("absolute symlinked root");
2407 let link_private = link_root.join("linked-private");
2408 let expected_root = link_root.clone();
2409 let expected_linked_private = link_private.clone();
2410 let unexpected_decoy =
2411 AbsolutePathBuf::from_absolute_path(decoy.canonicalize().expect("canonicalize decoy"))
2412 .expect("absolute canonical decoy");
2413
2414 let policy = FileSystemSandboxPolicy::restricted(vec![
2415 FileSystemSandboxEntry {
2416 path: FileSystemPath::Path { path: link_root },
2417 access: FileSystemAccessMode::Write,
2418 missing_path_behavior: None,
2419 },
2420 FileSystemSandboxEntry {
2421 path: FileSystemPath::Path { path: link_private },
2422 access: FileSystemAccessMode::Deny,
2423 missing_path_behavior: None,
2424 },
2425 ]);
2426
2427 let writable_roots = policy.get_writable_roots_with_cwd(cwd.path());
2428 assert_eq!(writable_roots.len(), 1);
2429 assert_eq!(writable_roots[0].root, expected_root);
2430 assert_eq!(
2431 writable_roots[0].read_only_subpaths,
2432 vec![expected_linked_private]
2433 );
2434 assert!(
2435 !writable_roots[0]
2436 .read_only_subpaths
2437 .contains(&unexpected_decoy)
2438 );
2439 }
2440
2441 #[cfg(unix)]
2442 #[test]
2443 fn writable_roots_preserve_explicit_symlinked_carveouts_that_escape_root() {
2444 let cwd = TempDir::new().expect("tempdir");
2445 let real_root = cwd.path().join("real");
2446 let link_root = cwd.path().join("link");
2447 let decoy = cwd.path().join("outside-private");
2448 let linked_private = real_root.join("linked-private");
2449 fs::create_dir_all(&decoy).expect("create decoy");
2450 fs::create_dir_all(&real_root).expect("create real root");
2451 symlink_dir(&real_root, &link_root).expect("create symlinked root");
2452 symlink_dir(&decoy, &linked_private).expect("create linked-private symlink");
2453
2454 let link_root =
2455 AbsolutePathBuf::from_absolute_path(&link_root).expect("absolute symlinked root");
2456 let link_private = link_root.join("linked-private");
2457 let expected_root = link_root.clone();
2458 let expected_linked_private = link_private.clone();
2459 let unexpected_decoy =
2460 AbsolutePathBuf::from_absolute_path(decoy.canonicalize().expect("canonicalize decoy"))
2461 .expect("absolute canonical decoy");
2462
2463 let policy = FileSystemSandboxPolicy::restricted(vec![
2464 FileSystemSandboxEntry {
2465 path: FileSystemPath::Path { path: link_root },
2466 access: FileSystemAccessMode::Write,
2467 missing_path_behavior: None,
2468 },
2469 FileSystemSandboxEntry {
2470 path: FileSystemPath::Path { path: link_private },
2471 access: FileSystemAccessMode::Deny,
2472 missing_path_behavior: None,
2473 },
2474 ]);
2475
2476 let writable_roots = policy.get_writable_roots_with_cwd(cwd.path());
2477 assert_eq!(writable_roots.len(), 1);
2478 assert_eq!(writable_roots[0].root, expected_root);
2479 assert_eq!(
2480 writable_roots[0].read_only_subpaths,
2481 vec![expected_linked_private]
2482 );
2483 assert!(
2484 !writable_roots[0]
2485 .read_only_subpaths
2486 .contains(&unexpected_decoy)
2487 );
2488 }
2489
2490 #[cfg(unix)]
2491 #[test]
2492 fn writable_roots_preserve_explicit_symlinked_carveouts_that_alias_root() {
2493 let cwd = TempDir::new().expect("tempdir");
2494 let root = cwd.path().join("root");
2495 let alias = root.join("alias-root");
2496 fs::create_dir_all(&root).expect("create root");
2497 symlink_dir(&root, &alias).expect("create alias symlink");
2498
2499 let root = AbsolutePathBuf::from_absolute_path(&root).expect("absolute root");
2500 let alias = root.join("alias-root");
2501 let expected_root = AbsolutePathBuf::from_absolute_path(
2502 root.as_path().canonicalize().expect("canonicalize root"),
2503 )
2504 .expect("absolute canonical root");
2505 let expected_alias = expected_root.join("alias-root");
2506
2507 let policy = FileSystemSandboxPolicy::restricted(vec![
2508 FileSystemSandboxEntry {
2509 path: FileSystemPath::Path { path: root },
2510 access: FileSystemAccessMode::Write,
2511 missing_path_behavior: None,
2512 },
2513 FileSystemSandboxEntry {
2514 path: FileSystemPath::Path { path: alias },
2515 access: FileSystemAccessMode::Deny,
2516 missing_path_behavior: None,
2517 },
2518 ]);
2519
2520 let writable_roots = policy.get_writable_roots_with_cwd(cwd.path());
2521 assert_eq!(writable_roots.len(), 1);
2522 assert_eq!(writable_roots[0].root, expected_root);
2523 assert_eq!(writable_roots[0].read_only_subpaths, vec![expected_alias]);
2524 }
2525
2526 #[cfg(unix)]
2527 #[test]
2528 fn tmpdir_special_path_preserves_symlinked_tmpdir() {
2529 if std::env::var_os(SYMLINKED_TMPDIR_TEST_ENV).is_none() {
2530 let output = std::process::Command::new(std::env::current_exe().expect("test binary"))
2531 .env(SYMLINKED_TMPDIR_TEST_ENV, "1")
2532 .arg("--exact")
2533 .arg("permissions::tests::tmpdir_special_path_preserves_symlinked_tmpdir")
2534 .output()
2535 .expect("run tmpdir subprocess test");
2536
2537 assert!(
2538 output.status.success(),
2539 "tmpdir subprocess test failed\nstdout:\n{}\nstderr:\n{}",
2540 String::from_utf8_lossy(&output.stdout),
2541 String::from_utf8_lossy(&output.stderr)
2542 );
2543 return;
2544 }
2545
2546 let cwd = TempDir::new().expect("tempdir");
2547 let real_tmpdir = cwd.path().join("real-tmpdir");
2548 let link_tmpdir = cwd.path().join("link-tmpdir");
2549 let blocked = real_tmpdir.join("blocked");
2550 let codex_dir = real_tmpdir.join(".codex");
2551
2552 fs::create_dir_all(&blocked).expect("create blocked");
2553 fs::create_dir_all(&codex_dir).expect("create .codex");
2554 symlink_dir(&real_tmpdir, &link_tmpdir).expect("create symlinked tmpdir");
2555
2556 let link_blocked =
2557 AbsolutePathBuf::from_absolute_path(link_tmpdir.join("blocked")).expect("link blocked");
2558 let expected_root =
2559 AbsolutePathBuf::from_absolute_path(&link_tmpdir).expect("absolute symlinked tmpdir");
2560 let expected_blocked = link_blocked.clone();
2561 let expected_codex = expected_root.join(".codex");
2562
2563 unsafe {
2564 std::env::set_var("TMPDIR", &link_tmpdir);
2565 }
2566
2567 let policy = FileSystemSandboxPolicy::restricted(vec![
2568 FileSystemSandboxEntry {
2569 path: FileSystemPath::Special {
2570 value: FileSystemSpecialPath::Tmpdir,
2571 },
2572 access: FileSystemAccessMode::Write,
2573 missing_path_behavior: None,
2574 },
2575 FileSystemSandboxEntry {
2576 path: FileSystemPath::Path { path: link_blocked },
2577 access: FileSystemAccessMode::Deny,
2578 missing_path_behavior: None,
2579 },
2580 ]);
2581
2582 assert_eq!(
2583 policy.get_unreadable_roots_with_cwd(cwd.path()),
2584 vec![expected_blocked.clone()]
2585 );
2586
2587 let writable_roots = policy.get_writable_roots_with_cwd(cwd.path());
2588 assert_eq!(writable_roots.len(), 1);
2589 assert_eq!(writable_roots[0].root, expected_root);
2590 assert!(
2591 writable_roots[0]
2592 .read_only_subpaths
2593 .contains(&expected_blocked)
2594 );
2595 assert!(
2596 writable_roots[0]
2597 .read_only_subpaths
2598 .contains(&expected_codex)
2599 );
2600 }
2601
2602 #[test]
2603 fn resolve_access_with_cwd_uses_most_specific_entry() {
2604 let cwd = TempDir::new().expect("tempdir");
2605 let docs = AbsolutePathBuf::resolve_path_against_base("docs", cwd.path());
2606 let docs_private = AbsolutePathBuf::resolve_path_against_base("docs/private", cwd.path());
2607 let docs_private_public =
2608 AbsolutePathBuf::resolve_path_against_base("docs/private/public", cwd.path());
2609 let policy = FileSystemSandboxPolicy::restricted(vec![
2610 FileSystemSandboxEntry {
2611 path: FileSystemPath::Special {
2612 value: FileSystemSpecialPath::project_roots(None),
2613 },
2614 access: FileSystemAccessMode::Write,
2615 missing_path_behavior: None,
2616 },
2617 FileSystemSandboxEntry {
2618 path: FileSystemPath::Path { path: docs.clone() },
2619 access: FileSystemAccessMode::Read,
2620 missing_path_behavior: None,
2621 },
2622 FileSystemSandboxEntry {
2623 path: FileSystemPath::Path {
2624 path: docs_private.clone(),
2625 },
2626 access: FileSystemAccessMode::Deny,
2627 missing_path_behavior: None,
2628 },
2629 FileSystemSandboxEntry {
2630 path: FileSystemPath::Path {
2631 path: docs_private_public.clone(),
2632 },
2633 access: FileSystemAccessMode::Write,
2634 missing_path_behavior: None,
2635 },
2636 ]);
2637
2638 assert_eq!(
2639 policy.resolve_access_with_cwd(cwd.path(), cwd.path()),
2640 FileSystemAccessMode::Write
2641 );
2642 assert_eq!(
2643 policy.resolve_access_with_cwd(docs.as_path(), cwd.path()),
2644 FileSystemAccessMode::Read
2645 );
2646 assert_eq!(
2647 policy.resolve_access_with_cwd(docs_private.as_path(), cwd.path()),
2648 FileSystemAccessMode::Deny
2649 );
2650 assert_eq!(
2651 policy.resolve_access_with_cwd(docs_private_public.as_path(), cwd.path()),
2652 FileSystemAccessMode::Write
2653 );
2654 }
2655
2656 #[test]
2657 fn split_only_nested_carveouts_need_direct_runtime_enforcement() {
2658 let cwd = TempDir::new().expect("tempdir");
2659 let docs = AbsolutePathBuf::resolve_path_against_base("docs", cwd.path());
2660 let policy = FileSystemSandboxPolicy::restricted(vec![
2661 FileSystemSandboxEntry {
2662 path: FileSystemPath::Special {
2663 value: FileSystemSpecialPath::project_roots(None),
2664 },
2665 access: FileSystemAccessMode::Write,
2666 missing_path_behavior: None,
2667 },
2668 FileSystemSandboxEntry {
2669 path: FileSystemPath::Path { path: docs },
2670 access: FileSystemAccessMode::Read,
2671 missing_path_behavior: None,
2672 },
2673 ]);
2674
2675 assert!(
2676 policy.needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path(),)
2677 );
2678
2679 let legacy_workspace_write = legacy_runtime_file_system_policy_for_cwd(
2680 &SandboxPolicy::new_workspace_write_policy(),
2681 cwd.path(),
2682 );
2683 assert!(
2684 legacy_workspace_write
2685 .needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path(),),
2686 "metadata-name protections must stay in the direct enforcement path even when legacy concrete read-only paths match"
2687 );
2688 }
2689
2690 #[test]
2691 fn legacy_projection_runtime_enforcement_ignores_entry_order() {
2692 let cwd = TempDir::new().expect("tempdir");
2693 let legacy_policy = SandboxPolicy::WorkspaceWrite {
2694 writable_roots: Vec::new(),
2695 network_access: false,
2696 exclude_tmpdir_env_var: true,
2697 exclude_slash_tmp: true,
2698 };
2699 let legacy_order = legacy_runtime_file_system_policy_for_cwd(&legacy_policy, cwd.path());
2700 let mut reordered_entries = legacy_order.entries.clone();
2701 reordered_entries.reverse();
2702 let reordered = FileSystemSandboxPolicy::restricted(reordered_entries);
2703
2704 assert!(
2705 legacy_order.is_semantically_equivalent_to(&reordered, cwd.path()),
2706 "entry order should not affect filesystem semantics"
2707 );
2708 assert_eq!(
2709 legacy_order
2710 .needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path()),
2711 reordered
2712 .needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path()),
2713 "entry order should not affect direct-enforcement classification"
2714 );
2715 }
2716
2717 #[test]
2718 fn missing_symbolic_metadata_carveouts_need_direct_runtime_enforcement() {
2719 let cwd = TempDir::new().expect("tempdir");
2720 let legacy_policy = SandboxPolicy::WorkspaceWrite {
2721 writable_roots: Vec::new(),
2722 network_access: false,
2723 exclude_tmpdir_env_var: true,
2724 exclude_slash_tmp: true,
2725 };
2726
2727 let profile_projection =
2728 FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&legacy_policy, cwd.path());
2729 assert!(
2730 profile_projection
2731 .needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path()),
2732 "symbolic .git/.agents carveouts protect missing paths that legacy sandboxes cannot represent"
2733 );
2734
2735 let legacy_runtime_projection =
2736 legacy_runtime_file_system_policy_for_cwd(&legacy_policy, cwd.path());
2737 assert!(
2738 legacy_runtime_projection
2739 .needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path()),
2740 "metadata-name protections are outside the legacy SandboxPolicy writable-root contract"
2741 );
2742 }
2743
2744 #[test]
2745 fn root_write_with_read_only_child_is_not_full_disk_write() {
2746 let cwd = TempDir::new().expect("tempdir");
2747 let docs = AbsolutePathBuf::resolve_path_against_base("docs", cwd.path());
2748 let policy = FileSystemSandboxPolicy::restricted(vec![
2749 FileSystemSandboxEntry {
2750 path: FileSystemPath::Special {
2751 value: FileSystemSpecialPath::Root,
2752 },
2753 access: FileSystemAccessMode::Write,
2754 missing_path_behavior: None,
2755 },
2756 FileSystemSandboxEntry {
2757 path: FileSystemPath::Path { path: docs.clone() },
2758 access: FileSystemAccessMode::Read,
2759 missing_path_behavior: None,
2760 },
2761 ]);
2762
2763 assert!(!policy.has_full_disk_write_access());
2764 assert_eq!(
2765 policy.resolve_access_with_cwd(docs.as_path(), cwd.path()),
2766 FileSystemAccessMode::Read
2767 );
2768 assert!(
2769 policy.needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path(),)
2770 );
2771 assert!(
2772 policy
2773 .to_legacy_sandbox_policy(NetworkSandboxPolicy::Restricted, cwd.path())
2774 .is_err()
2775 );
2776 }
2777
2778 #[test]
2779 fn root_deny_does_not_materialize_as_unreadable_root() {
2780 let cwd = TempDir::new().expect("tempdir");
2781 let docs = AbsolutePathBuf::resolve_path_against_base("docs", cwd.path());
2782 let expected_docs = AbsolutePathBuf::from_absolute_path(
2783 canonicalize_preserving_symlinks(cwd.path())
2784 .expect("canonicalize cwd")
2785 .join("docs"),
2786 )
2787 .expect("canonical docs");
2788 let policy = FileSystemSandboxPolicy::restricted(vec![
2789 FileSystemSandboxEntry {
2790 path: FileSystemPath::Special {
2791 value: FileSystemSpecialPath::Root,
2792 },
2793 access: FileSystemAccessMode::Deny,
2794 missing_path_behavior: None,
2795 },
2796 FileSystemSandboxEntry {
2797 path: FileSystemPath::Path { path: docs.clone() },
2798 access: FileSystemAccessMode::Read,
2799 missing_path_behavior: None,
2800 },
2801 ]);
2802
2803 assert_eq!(
2804 policy.resolve_access_with_cwd(docs.as_path(), cwd.path()),
2805 FileSystemAccessMode::Read
2806 );
2807 assert_eq!(
2808 policy.get_readable_roots_with_cwd(cwd.path()),
2809 vec![expected_docs]
2810 );
2811 assert!(policy.get_unreadable_roots_with_cwd(cwd.path()).is_empty());
2812 }
2813
2814 #[test]
2815 fn duplicate_root_deny_prevents_full_disk_write_access() {
2816 let cwd = TempDir::new().expect("tempdir");
2817 let root = AbsolutePathBuf::from_absolute_path(cwd.path())
2818 .map(|cwd| absolute_root_path_for_cwd(&cwd))
2819 .expect("resolve filesystem root");
2820 let policy = FileSystemSandboxPolicy::restricted(vec![
2821 FileSystemSandboxEntry {
2822 path: FileSystemPath::Special {
2823 value: FileSystemSpecialPath::Root,
2824 },
2825 access: FileSystemAccessMode::Write,
2826 missing_path_behavior: None,
2827 },
2828 FileSystemSandboxEntry {
2829 path: FileSystemPath::Special {
2830 value: FileSystemSpecialPath::Root,
2831 },
2832 access: FileSystemAccessMode::Deny,
2833 missing_path_behavior: None,
2834 },
2835 ]);
2836
2837 assert!(!policy.has_full_disk_write_access());
2838 assert_eq!(
2839 policy.resolve_access_with_cwd(root.as_path(), cwd.path()),
2840 FileSystemAccessMode::Deny
2841 );
2842 }
2843
2844 #[test]
2845 fn same_specificity_write_override_keeps_full_disk_write_access() {
2846 let cwd = TempDir::new().expect("tempdir");
2847 let docs = AbsolutePathBuf::resolve_path_against_base("docs", cwd.path());
2848 let policy = FileSystemSandboxPolicy::restricted(vec![
2849 FileSystemSandboxEntry {
2850 path: FileSystemPath::Special {
2851 value: FileSystemSpecialPath::Root,
2852 },
2853 access: FileSystemAccessMode::Write,
2854 missing_path_behavior: None,
2855 },
2856 FileSystemSandboxEntry {
2857 path: FileSystemPath::Path { path: docs.clone() },
2858 access: FileSystemAccessMode::Read,
2859 missing_path_behavior: None,
2860 },
2861 FileSystemSandboxEntry {
2862 path: FileSystemPath::Path { path: docs.clone() },
2863 access: FileSystemAccessMode::Write,
2864 missing_path_behavior: None,
2865 },
2866 ]);
2867
2868 assert!(policy.has_full_disk_write_access());
2869 assert_eq!(
2870 policy.resolve_access_with_cwd(docs.as_path(), cwd.path()),
2871 FileSystemAccessMode::Write
2872 );
2873 }
2874
2875 #[test]
2876 fn with_additional_readable_roots_skips_existing_effective_access() {
2877 let cwd = TempDir::new().expect("tempdir");
2878 let cwd_root = AbsolutePathBuf::from_absolute_path(cwd.path()).expect("absolute cwd");
2879 let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
2880 path: FileSystemPath::Special {
2881 value: FileSystemSpecialPath::project_roots(None),
2882 },
2883 access: FileSystemAccessMode::Read,
2884 missing_path_behavior: None,
2885 }]);
2886
2887 let actual = policy
2888 .clone()
2889 .with_additional_readable_roots(cwd.path(), std::slice::from_ref(&cwd_root));
2890
2891 assert_eq!(actual, policy);
2892 }
2893
2894 #[test]
2895 fn with_additional_writable_roots_skips_existing_effective_access() {
2896 let cwd = TempDir::new().expect("tempdir");
2897 let cwd_root = AbsolutePathBuf::from_absolute_path(cwd.path()).expect("absolute cwd");
2898 let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
2899 path: FileSystemPath::Special {
2900 value: FileSystemSpecialPath::project_roots(None),
2901 },
2902 access: FileSystemAccessMode::Write,
2903 missing_path_behavior: None,
2904 }]);
2905
2906 let actual = policy
2907 .clone()
2908 .with_additional_writable_roots(cwd.path(), std::slice::from_ref(&cwd_root));
2909
2910 assert_eq!(actual, policy);
2911 }
2912
2913 #[test]
2914 fn with_additional_writable_roots_adds_new_root() {
2915 let temp_dir = TempDir::new().expect("tempdir");
2916 let cwd = temp_dir.path().join("workspace");
2917 let extra = AbsolutePathBuf::from_absolute_path(temp_dir.path().join("extra"))
2918 .expect("resolve extra root");
2919 let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
2920 path: FileSystemPath::Special {
2921 value: FileSystemSpecialPath::project_roots(None),
2922 },
2923 access: FileSystemAccessMode::Write,
2924 missing_path_behavior: None,
2925 }]);
2926
2927 let actual = policy.with_additional_writable_roots(&cwd, std::slice::from_ref(&extra));
2928
2929 assert_eq!(
2930 actual,
2931 FileSystemSandboxPolicy::restricted(vec![
2932 FileSystemSandboxEntry {
2933 path: FileSystemPath::Special {
2934 value: FileSystemSpecialPath::project_roots(None),
2935 },
2936 access: FileSystemAccessMode::Write,
2937 missing_path_behavior: None,
2938 },
2939 FileSystemSandboxEntry {
2940 path: FileSystemPath::Path { path: extra },
2941 access: FileSystemAccessMode::Write,
2942 missing_path_behavior: None,
2943 },
2944 ])
2945 );
2946 }
2947
2948 #[test]
2949 fn materialize_project_roots_with_workspace_roots_expands_exact_and_glob_entries() {
2950 let temp_dir = TempDir::new().expect("tempdir");
2951 let first = AbsolutePathBuf::from_absolute_path(temp_dir.path().join("first"))
2952 .expect("resolve first root");
2953 let second = AbsolutePathBuf::from_absolute_path(temp_dir.path().join("second"))
2954 .expect("resolve second root");
2955 let policy = FileSystemSandboxPolicy::restricted(vec![
2956 FileSystemSandboxEntry {
2957 path: FileSystemPath::Special {
2958 value: FileSystemSpecialPath::project_roots(None),
2959 },
2960 access: FileSystemAccessMode::Write,
2961 missing_path_behavior: None,
2962 },
2963 FileSystemSandboxEntry {
2964 path: FileSystemPath::Special {
2965 value: FileSystemSpecialPath::project_roots(Some(".git".into())),
2966 },
2967 access: FileSystemAccessMode::Read,
2968 missing_path_behavior: None,
2969 },
2970 FileSystemSandboxEntry {
2971 path: FileSystemPath::GlobPattern {
2972 pattern: project_roots_glob_pattern(Path::new("**/*.env")),
2973 },
2974 access: FileSystemAccessMode::Deny,
2975 missing_path_behavior: None,
2976 },
2977 ]);
2978
2979 let actual =
2980 policy.materialize_project_roots_with_workspace_roots(&[first.clone(), second.clone()]);
2981
2982 assert_eq!(
2983 actual,
2984 FileSystemSandboxPolicy::restricted(vec![
2985 FileSystemSandboxEntry {
2986 path: FileSystemPath::Path {
2987 path: first.clone(),
2988 },
2989 access: FileSystemAccessMode::Write,
2990 missing_path_behavior: None,
2991 },
2992 FileSystemSandboxEntry {
2993 path: FileSystemPath::Path {
2994 path: second.clone(),
2995 },
2996 access: FileSystemAccessMode::Write,
2997 missing_path_behavior: None,
2998 },
2999 FileSystemSandboxEntry {
3000 path: FileSystemPath::Path {
3001 path: first.join(".git"),
3002 },
3003 access: FileSystemAccessMode::Read,
3004 missing_path_behavior: None,
3005 },
3006 FileSystemSandboxEntry {
3007 path: FileSystemPath::Path {
3008 path: second.join(".git"),
3009 },
3010 access: FileSystemAccessMode::Read,
3011 missing_path_behavior: None,
3012 },
3013 FileSystemSandboxEntry {
3014 path: FileSystemPath::GlobPattern {
3015 pattern: AbsolutePathBuf::resolve_path_against_base(
3016 "**/*.env",
3017 first.as_path(),
3018 )
3019 .to_string_lossy()
3020 .into_owned(),
3021 },
3022 access: FileSystemAccessMode::Deny,
3023 missing_path_behavior: None,
3024 },
3025 FileSystemSandboxEntry {
3026 path: FileSystemPath::GlobPattern {
3027 pattern: AbsolutePathBuf::resolve_path_against_base(
3028 "**/*.env",
3029 second.as_path(),
3030 )
3031 .to_string_lossy()
3032 .into_owned(),
3033 },
3034 access: FileSystemAccessMode::Deny,
3035 missing_path_behavior: None,
3036 },
3037 ])
3038 );
3039 }
3040
3041 #[test]
3042 fn materialize_project_roots_with_cwd_expands_symbolic_glob_entries() {
3043 let cwd = TempDir::new().expect("tempdir");
3044 let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
3045 path: FileSystemPath::GlobPattern {
3046 pattern: project_roots_glob_pattern(Path::new("**/*.env")),
3047 },
3048 access: FileSystemAccessMode::Deny,
3049 missing_path_behavior: None,
3050 }]);
3051
3052 let actual = policy.materialize_project_roots_with_cwd(cwd.path());
3053
3054 assert_eq!(
3055 actual,
3056 FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
3057 path: FileSystemPath::GlobPattern {
3058 pattern: AbsolutePathBuf::resolve_path_against_base("**/*.env", cwd.path())
3059 .to_string_lossy()
3060 .into_owned(),
3061 },
3062 access: FileSystemAccessMode::Deny,
3063 missing_path_behavior: None,
3064 }])
3065 );
3066 }
3067
3068 #[test]
3069 fn with_additional_legacy_workspace_writable_roots_protects_metadata() {
3070 let temp_dir = TempDir::new().expect("tempdir");
3071 let extra = AbsolutePathBuf::from_absolute_path(temp_dir.path().join("extra"))
3072 .expect("resolve extra root");
3073 std::fs::create_dir_all(extra.join(".git")).expect("create .git dir");
3074 let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
3075 path: FileSystemPath::Special {
3076 value: FileSystemSpecialPath::project_roots(None),
3077 },
3078 access: FileSystemAccessMode::Write,
3079 missing_path_behavior: None,
3080 }]);
3081
3082 let actual =
3083 policy.with_additional_legacy_workspace_writable_roots(std::slice::from_ref(&extra));
3084
3085 assert_eq!(
3086 actual,
3087 FileSystemSandboxPolicy::restricted(vec![
3088 FileSystemSandboxEntry {
3089 path: FileSystemPath::Special {
3090 value: FileSystemSpecialPath::project_roots(None),
3091 },
3092 access: FileSystemAccessMode::Write,
3093 missing_path_behavior: None,
3094 },
3095 FileSystemSandboxEntry {
3096 path: FileSystemPath::Path {
3097 path: extra.clone()
3098 },
3099 access: FileSystemAccessMode::Write,
3100 missing_path_behavior: None,
3101 },
3102 FileSystemSandboxEntry::skip_missing_path(
3103 FileSystemPath::Path {
3104 path: extra.join(".git")
3105 },
3106 FileSystemAccessMode::Read,
3107 ),
3108 ])
3109 );
3110 }
3111
3112 #[test]
3113 fn file_system_access_mode_orders_by_conflict_precedence() {
3114 assert!(FileSystemAccessMode::Write > FileSystemAccessMode::Read);
3115 assert!(FileSystemAccessMode::Deny > FileSystemAccessMode::Write);
3116 }
3117
3118 #[test]
3119 fn legacy_bridge_preserves_explicit_deny_entries() {
3120 let denied = AbsolutePathBuf::try_from("/tmp/private").expect("absolute path");
3121 let existing = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
3122 path: FileSystemPath::Path {
3123 path: denied.clone(),
3124 },
3125 access: FileSystemAccessMode::Deny,
3126 missing_path_behavior: None,
3127 }]);
3128
3129 let rebuilt = FileSystemSandboxPolicy::from_legacy_sandbox_policy_preserving_deny_entries(
3130 &SandboxPolicy::new_workspace_write_policy(),
3131 Path::new("/tmp/workspace"),
3132 &existing,
3133 );
3134
3135 assert!(
3136 rebuilt.entries.iter().any(|entry| {
3137 entry.path
3138 == FileSystemPath::Path {
3139 path: denied.clone(),
3140 }
3141 && entry.access == FileSystemAccessMode::Deny
3142 }),
3143 "expected explicit deny entry to be preserved"
3144 );
3145 }
3146
3147 #[test]
3148 fn preserving_deny_entries_keeps_unrestricted_policy_enforceable() {
3149 let deny_entry = unreadable_glob_entry("/tmp/project/**/*.env".to_string());
3150 let mut existing = FileSystemSandboxPolicy::restricted(vec![deny_entry.clone()]);
3151 existing.glob_scan_max_depth = Some(2);
3152 let mut replacement = FileSystemSandboxPolicy::unrestricted();
3153
3154 replacement.preserve_deny_read_restrictions_from(&existing);
3155
3156 let mut expected = FileSystemSandboxPolicy::restricted(vec![
3157 FileSystemSandboxEntry {
3158 path: FileSystemPath::Special {
3159 value: FileSystemSpecialPath::Root,
3160 },
3161 access: FileSystemAccessMode::Write,
3162 missing_path_behavior: None,
3163 },
3164 deny_entry,
3165 ]);
3166 expected.glob_scan_max_depth = Some(2);
3167 assert_eq!(replacement, expected);
3168 }
3169
3170 fn deny_policy(path: &Path) -> FileSystemSandboxPolicy {
3171 FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
3172 path: FileSystemPath::Path {
3173 path: AbsolutePathBuf::try_from(path).expect("absolute deny path"),
3174 },
3175 access: FileSystemAccessMode::Deny,
3176 missing_path_behavior: None,
3177 }])
3178 }
3179
3180 fn unreadable_glob_entry(pattern: String) -> FileSystemSandboxEntry {
3181 FileSystemSandboxEntry {
3182 path: FileSystemPath::GlobPattern { pattern },
3183 access: FileSystemAccessMode::Deny,
3184 missing_path_behavior: None,
3185 }
3186 }
3187
3188 fn default_policy_with_unreadable_glob(pattern: String) -> FileSystemSandboxPolicy {
3189 let mut policy = FileSystemSandboxPolicy::default();
3190 policy.entries.push(unreadable_glob_entry(pattern));
3191 policy
3192 }
3193
3194 fn is_read_denied(
3195 path: &Path,
3196 file_system_sandbox_policy: &FileSystemSandboxPolicy,
3197 cwd: &Path,
3198 ) -> bool {
3199 ReadDenyMatcher::new(file_system_sandbox_policy, cwd)
3200 .is_some_and(|matcher| matcher.is_read_denied(path))
3201 }
3202
3203 #[test]
3204 fn exact_path_and_descendants_are_denied() {
3205 let temp = TempDir::new().expect("tempdir");
3206 let denied_dir = temp.path().join("denied");
3207 let nested = denied_dir.join("nested.txt");
3208 std::fs::create_dir_all(&denied_dir).expect("create denied dir");
3209 std::fs::write(&nested, "secret").expect("write secret");
3210
3211 let policy = deny_policy(&denied_dir);
3212 assert!(is_read_denied(&denied_dir, &policy, temp.path()));
3213 assert!(is_read_denied(&nested, &policy, temp.path()));
3214 assert!(!is_read_denied(
3215 &temp.path().join("other.txt"),
3216 &policy,
3217 temp.path()
3218 ));
3219 }
3220
3221 #[cfg(unix)]
3222 #[test]
3223 fn canonical_target_matches_denied_symlink_alias() {
3224 let temp = TempDir::new().expect("tempdir");
3225 let real_dir = temp.path().join("real");
3226 let alias_dir = temp.path().join("alias");
3227 std::fs::create_dir_all(&real_dir).expect("create real dir");
3228 symlink_dir(&real_dir, &alias_dir).expect("symlink alias");
3229
3230 let secret = real_dir.join("secret.txt");
3231 std::fs::write(&secret, "secret").expect("write secret");
3232 let alias_secret = alias_dir.join("secret.txt");
3233
3234 let policy = deny_policy(&real_dir);
3235 assert!(is_read_denied(&alias_secret, &policy, temp.path()));
3236 }
3237
3238 #[test]
3239 fn literal_patterns_and_globs_are_denied() {
3240 let temp = TempDir::new().expect("tempdir");
3241 let literal = temp.path().join("private");
3242 let other = temp.path().join("notes.txt");
3243 std::fs::create_dir_all(&literal).expect("create literal dir");
3244 std::fs::write(&other, "notes").expect("write notes");
3245
3246 let mut policy = deny_policy(&literal);
3247 policy.entries.push(unreadable_glob_entry(format!(
3248 "{}/**/*.txt",
3249 temp.path().display()
3250 )));
3251
3252 assert!(is_read_denied(&literal, &policy, temp.path()));
3253 assert!(is_read_denied(&other, &policy, temp.path()));
3254 }
3255
3256 #[test]
3257 fn glob_patterns_deny_matching_paths() {
3258 let temp = TempDir::new().expect("tempdir");
3259 let denied = temp.path().join("private").join("secret1.txt");
3260 std::fs::create_dir_all(denied.parent().expect("parent")).expect("create parent");
3261 std::fs::write(&denied, "secret").expect("write secret");
3262
3263 let policy = default_policy_with_unreadable_glob(format!(
3264 "{}/private/secret?.txt",
3265 temp.path().display()
3266 ));
3267
3268 assert!(is_read_denied(&denied, &policy, temp.path()));
3269 }
3270
3271 #[test]
3272 fn glob_patterns_do_not_cross_path_separators() {
3273 let temp = TempDir::new().expect("tempdir");
3274 let matching = temp.path().join("app").join("file42.txt");
3275 let nested = temp.path().join("app").join("nested").join("file42.txt");
3276 let short = temp.path().join("app").join("file4.txt");
3277 let letters = temp.path().join("app").join("fileab.txt");
3278 std::fs::create_dir_all(nested.parent().expect("parent")).expect("create parent");
3279 std::fs::write(&matching, "secret").expect("write matching");
3280 std::fs::write(&nested, "secret").expect("write nested");
3281 std::fs::write(&short, "secret").expect("write short");
3282 std::fs::write(&letters, "secret").expect("write letters");
3283
3284 let policy = default_policy_with_unreadable_glob(format!(
3285 "{}/*/file[0-9]?.txt",
3286 temp.path().display()
3287 ));
3288
3289 assert!(is_read_denied(&matching, &policy, temp.path()));
3290 assert!(!is_read_denied(&nested, &policy, temp.path()));
3291 assert!(!is_read_denied(&short, &policy, temp.path()));
3292 assert!(!is_read_denied(&letters, &policy, temp.path()));
3293 }
3294
3295 #[test]
3296 fn globstar_patterns_deny_root_and_nested_matches() {
3297 let temp = TempDir::new().expect("tempdir");
3298 let root_env = temp.path().join(".env");
3299 let nested_env = temp.path().join("app").join(".env");
3300 let other = temp.path().join("app").join("notes.txt");
3301 std::fs::create_dir_all(nested_env.parent().expect("parent")).expect("create parent");
3302 std::fs::write(&root_env, "secret").expect("write root env");
3303 std::fs::write(&nested_env, "secret").expect("write nested env");
3304 std::fs::write(&other, "notes").expect("write notes");
3305
3306 let policy =
3307 default_policy_with_unreadable_glob(format!("{}/**/*.env", temp.path().display()));
3308
3309 assert!(is_read_denied(&root_env, &policy, temp.path()));
3310 assert!(is_read_denied(&nested_env, &policy, temp.path()));
3311 assert!(!is_read_denied(&other, &policy, temp.path()));
3312 }
3313
3314 #[test]
3315 fn unclosed_character_classes_match_literal_brackets() {
3316 let temp = TempDir::new().expect("tempdir");
3317 let bracket_file = temp.path().join("[");
3318 let other = temp.path().join("notes.txt");
3319 std::fs::write(&bracket_file, "secret").expect("write bracket file");
3320 std::fs::write(&other, "notes").expect("write notes");
3321 let policy = default_policy_with_unreadable_glob(format!("{}/[", temp.path().display()));
3322
3323 assert!(is_read_denied(&bracket_file, &policy, temp.path()));
3324 assert!(!is_read_denied(&other, &policy, temp.path()));
3325 }
3326}