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