1use std::collections::HashSet;
2use std::ffi::OsStr;
3use std::io;
4use std::path::Path;
5use std::path::PathBuf;
6
7use schemars::JsonSchema;
8use serde::Deserialize;
9use serde::Serialize;
10use strum_macros::Display;
11use tracing::error;
12use ts_rs::TS;
13use zerobox_utils_absolute_path::AbsolutePathBuf;
14
15use crate::protocol::NetworkAccess;
16use crate::protocol::ReadOnlyAccess;
17use crate::protocol::SandboxPolicy;
18use crate::protocol::WritableRoot;
19
20#[derive(
21 Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, Default, JsonSchema, TS,
22)]
23#[serde(rename_all = "kebab-case")]
24#[strum(serialize_all = "kebab-case")]
25pub enum NetworkSandboxPolicy {
26 #[default]
27 Restricted,
28 Enabled,
29}
30
31impl NetworkSandboxPolicy {
32 pub fn is_enabled(self) -> bool {
33 matches!(self, NetworkSandboxPolicy::Enabled)
34 }
35}
36
37#[derive(
43 Debug,
44 Clone,
45 Copy,
46 PartialEq,
47 Eq,
48 PartialOrd,
49 Ord,
50 Serialize,
51 Deserialize,
52 Display,
53 JsonSchema,
54 TS,
55)]
56#[serde(rename_all = "lowercase")]
57#[strum(serialize_all = "lowercase")]
58pub enum FileSystemAccessMode {
59 Read,
60 Write,
61 None,
62}
63
64impl FileSystemAccessMode {
65 pub fn can_read(self) -> bool {
66 !matches!(self, FileSystemAccessMode::None)
67 }
68
69 pub fn can_write(self) -> bool {
70 matches!(self, FileSystemAccessMode::Write)
71 }
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
75#[serde(tag = "kind", rename_all = "snake_case")]
76#[ts(tag = "kind")]
77pub enum FileSystemSpecialPath {
78 Root,
79 Minimal,
80 CurrentWorkingDirectory,
81 ProjectRoots {
82 #[serde(default, skip_serializing_if = "Option::is_none")]
83 #[ts(optional)]
84 subpath: Option<PathBuf>,
85 },
86 Tmpdir,
87 SlashTmp,
88 Unknown {
91 path: String,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
93 #[ts(optional)]
94 subpath: Option<PathBuf>,
95 },
96}
97
98impl FileSystemSpecialPath {
99 pub fn project_roots(subpath: Option<PathBuf>) -> Self {
100 Self::ProjectRoots { subpath }
101 }
102
103 pub fn unknown(path: impl Into<String>, subpath: Option<PathBuf>) -> Self {
104 Self::Unknown {
105 path: path.into(),
106 subpath,
107 }
108 }
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
112pub struct FileSystemSandboxEntry {
113 pub path: FileSystemPath,
114 pub access: FileSystemAccessMode,
115}
116
117#[derive(
118 Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, Default, JsonSchema, TS,
119)]
120#[serde(rename_all = "kebab-case")]
121#[strum(serialize_all = "kebab-case")]
122pub enum FileSystemSandboxKind {
123 #[default]
124 Restricted,
125 Unrestricted,
126 ExternalSandbox,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
130pub struct FileSystemSandboxPolicy {
131 pub kind: FileSystemSandboxKind,
132 #[serde(default, skip_serializing_if = "Vec::is_empty")]
133 pub entries: Vec<FileSystemSandboxEntry>,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
137struct ResolvedFileSystemEntry {
138 path: AbsolutePathBuf,
139 access: FileSystemAccessMode,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
143struct FileSystemSemanticSignature {
144 has_full_disk_read_access: bool,
145 has_full_disk_write_access: bool,
146 include_platform_defaults: bool,
147 readable_roots: Vec<AbsolutePathBuf>,
148 writable_roots: Vec<WritableRoot>,
149 unreadable_roots: Vec<AbsolutePathBuf>,
150}
151
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
153#[serde(tag = "type", rename_all = "snake_case")]
154#[ts(tag = "type")]
155pub enum FileSystemPath {
156 Path { path: AbsolutePathBuf },
157 Special { value: FileSystemSpecialPath },
158}
159
160impl Default for FileSystemSandboxPolicy {
161 fn default() -> Self {
162 Self {
163 kind: FileSystemSandboxKind::Restricted,
164 entries: vec![FileSystemSandboxEntry {
165 path: FileSystemPath::Special {
166 value: FileSystemSpecialPath::Root,
167 },
168 access: FileSystemAccessMode::Read,
169 }],
170 }
171 }
172}
173
174impl FileSystemSandboxPolicy {
175 fn has_root_access(&self, predicate: impl Fn(FileSystemAccessMode) -> bool) -> bool {
176 matches!(self.kind, FileSystemSandboxKind::Restricted)
177 && self.entries.iter().any(|entry| {
178 matches!(
179 &entry.path,
180 FileSystemPath::Special { value }
181 if matches!(value, FileSystemSpecialPath::Root) && predicate(entry.access)
182 )
183 })
184 }
185
186 fn has_explicit_deny_entries(&self) -> bool {
187 matches!(self.kind, FileSystemSandboxKind::Restricted)
188 && self
189 .entries
190 .iter()
191 .any(|entry| entry.access == FileSystemAccessMode::None)
192 }
193
194 fn has_write_narrowing_entries(&self) -> bool {
202 matches!(self.kind, FileSystemSandboxKind::Restricted)
203 && self.entries.iter().any(|entry| {
204 if entry.access.can_write() {
205 return false;
206 }
207
208 match &entry.path {
209 FileSystemPath::Path { .. } => !self.has_same_target_write_override(entry),
210 FileSystemPath::Special { value } => match value {
211 FileSystemSpecialPath::Root => entry.access == FileSystemAccessMode::None,
212 FileSystemSpecialPath::Minimal | FileSystemSpecialPath::Unknown { .. } => {
213 false
214 }
215 _ => !self.has_same_target_write_override(entry),
216 },
217 }
218 })
219 }
220
221 fn has_same_target_write_override(&self, entry: &FileSystemSandboxEntry) -> bool {
224 self.entries.iter().any(|candidate| {
225 candidate.access.can_write()
226 && candidate.access > entry.access
227 && file_system_paths_share_target(&candidate.path, &entry.path)
228 })
229 }
230
231 pub fn unrestricted() -> Self {
232 Self {
233 kind: FileSystemSandboxKind::Unrestricted,
234 entries: Vec::new(),
235 }
236 }
237
238 pub fn external_sandbox() -> Self {
239 Self {
240 kind: FileSystemSandboxKind::ExternalSandbox,
241 entries: Vec::new(),
242 }
243 }
244
245 pub fn restricted(entries: Vec<FileSystemSandboxEntry>) -> Self {
246 Self {
247 kind: FileSystemSandboxKind::Restricted,
248 entries,
249 }
250 }
251
252 pub fn from_legacy_sandbox_policy(sandbox_policy: &SandboxPolicy, cwd: &Path) -> Self {
260 let mut file_system_policy = Self::from(sandbox_policy);
261 if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = sandbox_policy {
262 let legacy_writable_roots = sandbox_policy.get_writable_roots_with_cwd(cwd);
263 file_system_policy.entries.retain(|entry| {
264 if entry.access != FileSystemAccessMode::Read {
265 return true;
266 }
267
268 match &entry.path {
269 FileSystemPath::Path { path } => !legacy_writable_roots
270 .iter()
271 .any(|root| root.is_path_writable(path.as_path())),
272 FileSystemPath::Special { .. } => true,
273 }
274 });
275
276 if let Ok(cwd_root) = AbsolutePathBuf::from_absolute_path(cwd) {
277 for protected_path in default_read_only_subpaths_for_writable_root(
278 &cwd_root, true,
279 ) {
280 append_default_read_only_path_if_no_explicit_rule(
281 &mut file_system_policy.entries,
282 protected_path,
283 );
284 }
285 }
286 for writable_root in writable_roots {
287 for protected_path in default_read_only_subpaths_for_writable_root(
288 writable_root,
289 false,
290 ) {
291 append_default_read_only_path_if_no_explicit_rule(
292 &mut file_system_policy.entries,
293 protected_path,
294 );
295 }
296 }
297 }
298
299 file_system_policy
300 }
301
302 pub fn has_full_disk_read_access(&self) -> bool {
304 match self.kind {
305 FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => true,
306 FileSystemSandboxKind::Restricted => {
307 self.has_root_access(FileSystemAccessMode::can_read)
308 && !self.has_explicit_deny_entries()
309 }
310 }
311 }
312
313 pub fn has_full_disk_write_access(&self) -> bool {
315 match self.kind {
316 FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => true,
317 FileSystemSandboxKind::Restricted => {
318 self.has_root_access(FileSystemAccessMode::can_write)
319 && !self.has_write_narrowing_entries()
320 }
321 }
322 }
323
324 pub fn include_platform_defaults(&self) -> bool {
326 !self.has_full_disk_read_access()
327 && matches!(self.kind, FileSystemSandboxKind::Restricted)
328 && self.entries.iter().any(|entry| {
329 matches!(
330 &entry.path,
331 FileSystemPath::Special { value }
332 if matches!(value, FileSystemSpecialPath::Minimal)
333 && entry.access.can_read()
334 )
335 })
336 }
337
338 pub fn resolve_access_with_cwd(&self, path: &Path, cwd: &Path) -> FileSystemAccessMode {
339 match self.kind {
340 FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => {
341 return FileSystemAccessMode::Write;
342 }
343 FileSystemSandboxKind::Restricted => {}
344 }
345
346 let Some(path) = resolve_candidate_path(path, cwd) else {
347 return FileSystemAccessMode::None;
348 };
349
350 self.resolved_entries_with_cwd(cwd)
351 .into_iter()
352 .filter(|entry| path.as_path().starts_with(entry.path.as_path()))
353 .max_by_key(resolved_entry_precedence)
354 .map(|entry| entry.access)
355 .unwrap_or(FileSystemAccessMode::None)
356 }
357
358 pub fn can_read_path_with_cwd(&self, path: &Path, cwd: &Path) -> bool {
359 self.resolve_access_with_cwd(path, cwd).can_read()
360 }
361
362 pub fn can_write_path_with_cwd(&self, path: &Path, cwd: &Path) -> bool {
363 self.resolve_access_with_cwd(path, cwd).can_write()
364 }
365
366 pub fn with_additional_readable_roots(
367 mut self,
368 cwd: &Path,
369 additional_readable_roots: &[AbsolutePathBuf],
370 ) -> Self {
371 if self.has_full_disk_read_access() {
372 return self;
373 }
374
375 for path in additional_readable_roots {
376 if self.can_read_path_with_cwd(path.as_path(), cwd) {
377 continue;
378 }
379
380 self.entries.push(FileSystemSandboxEntry {
381 path: FileSystemPath::Path { path: path.clone() },
382 access: FileSystemAccessMode::Read,
383 });
384 }
385
386 self
387 }
388
389 pub fn with_additional_writable_roots(
390 mut self,
391 cwd: &Path,
392 additional_writable_roots: &[AbsolutePathBuf],
393 ) -> Self {
394 for path in additional_writable_roots {
395 if self.can_write_path_with_cwd(path.as_path(), cwd) {
396 continue;
397 }
398
399 self.entries.push(FileSystemSandboxEntry {
400 path: FileSystemPath::Path { path: path.clone() },
401 access: FileSystemAccessMode::Write,
402 });
403 }
404
405 self
406 }
407
408 pub fn needs_direct_runtime_enforcement(
409 &self,
410 network_policy: NetworkSandboxPolicy,
411 cwd: &Path,
412 ) -> bool {
413 if !matches!(self.kind, FileSystemSandboxKind::Restricted) {
414 return false;
415 }
416
417 let Ok(legacy_policy) = self.to_legacy_sandbox_policy(network_policy, cwd) else {
418 return true;
419 };
420
421 self.semantic_signature(cwd)
422 != FileSystemSandboxPolicy::from_legacy_sandbox_policy(&legacy_policy, cwd)
423 .semantic_signature(cwd)
424 }
425
426 pub fn get_readable_roots_with_cwd(&self, cwd: &Path) -> Vec<AbsolutePathBuf> {
428 if self.has_full_disk_read_access() {
429 return Vec::new();
430 }
431
432 dedup_absolute_paths(
433 self.resolved_entries_with_cwd(cwd)
434 .into_iter()
435 .filter(|entry| entry.access.can_read())
436 .filter(|entry| self.can_read_path_with_cwd(entry.path.as_path(), cwd))
437 .map(|entry| entry.path)
438 .collect(),
439 true,
440 )
441 }
442
443 pub fn get_writable_roots_with_cwd(&self, cwd: &Path) -> Vec<WritableRoot> {
446 if self.has_full_disk_write_access() {
447 return Vec::new();
448 }
449
450 let resolved_entries = self.resolved_entries_with_cwd(cwd);
451 let writable_entries: Vec<AbsolutePathBuf> = resolved_entries
452 .iter()
453 .filter(|entry| entry.access.can_write())
454 .filter(|entry| self.can_write_path_with_cwd(entry.path.as_path(), cwd))
455 .map(|entry| entry.path.clone())
456 .collect();
457
458 dedup_absolute_paths(
459 writable_entries.clone(),
460 true,
461 )
462 .into_iter()
463 .map(|root| {
464 let preserve_raw_carveout_paths = root.as_path().parent().is_some();
469 let raw_writable_roots: Vec<&AbsolutePathBuf> = writable_entries
470 .iter()
471 .filter(|path| normalize_effective_absolute_path((*path).clone()) == root)
472 .collect();
473 let protect_missing_dot_codex = AbsolutePathBuf::from_absolute_path(cwd)
474 .ok()
475 .is_some_and(|cwd| normalize_effective_absolute_path(cwd) == root);
476 let mut read_only_subpaths: Vec<AbsolutePathBuf> =
477 default_read_only_subpaths_for_writable_root(&root, protect_missing_dot_codex)
478 .into_iter()
479 .filter(|path| !has_explicit_resolved_path_entry(&resolved_entries, path))
480 .collect();
481 read_only_subpaths.extend(
489 resolved_entries
490 .iter()
491 .filter(|entry| !entry.access.can_write())
492 .filter(|entry| !self.can_write_path_with_cwd(entry.path.as_path(), cwd))
493 .filter_map(|entry| {
494 let effective_path = normalize_effective_absolute_path(entry.path.clone());
495 let raw_carveout_path = if preserve_raw_carveout_paths {
505 if entry.path == root {
506 None
507 } else if entry.path.as_path().starts_with(root.as_path()) {
508 Some(entry.path.clone())
509 } else {
510 raw_writable_roots.iter().find_map(|raw_root| {
511 let suffix = entry
512 .path
513 .as_path()
514 .strip_prefix(raw_root.as_path())
515 .ok()?;
516 if suffix.as_os_str().is_empty() {
517 return None;
518 }
519 Some(root.join(suffix))
520 })
521 }
522 } else {
523 None
524 };
525
526 if let Some(raw_carveout_path) = raw_carveout_path {
527 return Some(raw_carveout_path);
528 }
529
530 if effective_path == root
531 || !effective_path.as_path().starts_with(root.as_path())
532 {
533 return None;
534 }
535
536 Some(effective_path)
537 }),
538 );
539 WritableRoot {
540 root,
541 read_only_subpaths: dedup_absolute_paths(
545 read_only_subpaths,
546 false,
547 ),
548 }
549 })
550 .collect()
551 }
552
553 pub fn get_unreadable_roots_with_cwd(&self, cwd: &Path) -> Vec<AbsolutePathBuf> {
555 if !matches!(self.kind, FileSystemSandboxKind::Restricted) {
556 return Vec::new();
557 }
558
559 let root = AbsolutePathBuf::from_absolute_path(cwd)
560 .ok()
561 .map(|cwd| absolute_root_path_for_cwd(&cwd));
562
563 dedup_absolute_paths(
564 self.resolved_entries_with_cwd(cwd)
565 .iter()
566 .filter(|entry| entry.access == FileSystemAccessMode::None)
567 .filter(|entry| !self.can_read_path_with_cwd(entry.path.as_path(), cwd))
568 .filter(|entry| root.as_ref() != Some(&entry.path))
572 .map(|entry| entry.path.clone())
573 .collect(),
574 true,
575 )
576 }
577
578 pub fn to_legacy_sandbox_policy(
579 &self,
580 network_policy: NetworkSandboxPolicy,
581 cwd: &Path,
582 ) -> io::Result<SandboxPolicy> {
583 Ok(match self.kind {
584 FileSystemSandboxKind::ExternalSandbox => SandboxPolicy::ExternalSandbox {
585 network_access: if network_policy.is_enabled() {
586 NetworkAccess::Enabled
587 } else {
588 NetworkAccess::Restricted
589 },
590 },
591 FileSystemSandboxKind::Unrestricted => {
592 if network_policy.is_enabled() {
593 SandboxPolicy::DangerFullAccess
594 } else {
595 SandboxPolicy::ExternalSandbox {
596 network_access: NetworkAccess::Restricted,
597 }
598 }
599 }
600 FileSystemSandboxKind::Restricted => {
601 let cwd_absolute = AbsolutePathBuf::from_absolute_path(cwd).ok();
602 let mut include_platform_defaults = false;
603 let mut has_full_disk_read_access = false;
604 let mut has_full_disk_write_access = false;
605 let mut workspace_root_writable = false;
606 let mut writable_roots = Vec::new();
607 let mut readable_roots = Vec::new();
608 let mut tmpdir_writable = false;
609 let mut slash_tmp_writable = false;
610
611 for entry in &self.entries {
612 match &entry.path {
613 FileSystemPath::Path { path } => {
614 if entry.access.can_write() {
615 if cwd_absolute.as_ref().is_some_and(|cwd| cwd == path) {
616 workspace_root_writable = true;
617 } else {
618 writable_roots.push(path.clone());
619 }
620 } else if entry.access.can_read() {
621 readable_roots.push(path.clone());
622 }
623 }
624 FileSystemPath::Special { value } => match value {
625 FileSystemSpecialPath::Root => match entry.access {
626 FileSystemAccessMode::None => {}
627 FileSystemAccessMode::Read => has_full_disk_read_access = true,
628 FileSystemAccessMode::Write => {
629 has_full_disk_read_access = true;
630 has_full_disk_write_access = true;
631 }
632 },
633 FileSystemSpecialPath::Minimal => {
634 if entry.access.can_read() {
635 include_platform_defaults = true;
636 }
637 }
638 FileSystemSpecialPath::CurrentWorkingDirectory => {
639 if entry.access.can_write() {
640 workspace_root_writable = true;
641 } else if entry.access.can_read()
642 && let Some(path) = resolve_file_system_special_path(
643 value,
644 cwd_absolute.as_ref(),
645 )
646 {
647 readable_roots.push(path);
648 }
649 }
650 FileSystemSpecialPath::ProjectRoots { subpath } => {
651 if subpath.is_none() && entry.access.can_write() {
652 workspace_root_writable = true;
653 } else if let Some(path) =
654 resolve_file_system_special_path(value, cwd_absolute.as_ref())
655 {
656 if entry.access.can_write() {
657 writable_roots.push(path);
658 } else if entry.access.can_read() {
659 readable_roots.push(path);
660 }
661 }
662 }
663 FileSystemSpecialPath::Tmpdir => {
664 if entry.access.can_write() {
665 tmpdir_writable = true;
666 } else if entry.access.can_read()
667 && let Some(path) = resolve_file_system_special_path(
668 value,
669 cwd_absolute.as_ref(),
670 )
671 {
672 readable_roots.push(path);
673 }
674 }
675 FileSystemSpecialPath::SlashTmp => {
676 if entry.access.can_write() {
677 slash_tmp_writable = true;
678 } else if entry.access.can_read()
679 && let Some(path) = resolve_file_system_special_path(
680 value,
681 cwd_absolute.as_ref(),
682 )
683 {
684 readable_roots.push(path);
685 }
686 }
687 FileSystemSpecialPath::Unknown { .. } => {}
688 },
689 }
690 }
691
692 if has_full_disk_write_access {
693 return Ok(if network_policy.is_enabled() {
694 SandboxPolicy::DangerFullAccess
695 } else {
696 SandboxPolicy::ExternalSandbox {
697 network_access: NetworkAccess::Restricted,
698 }
699 });
700 }
701
702 let read_only_access = if has_full_disk_read_access {
703 ReadOnlyAccess::FullAccess
704 } else {
705 ReadOnlyAccess::Restricted {
706 include_platform_defaults,
707 readable_roots: dedup_absolute_paths(
708 readable_roots,
709 false,
710 ),
711 }
712 };
713
714 if workspace_root_writable {
715 SandboxPolicy::WorkspaceWrite {
716 writable_roots: dedup_absolute_paths(
717 writable_roots,
718 false,
719 ),
720 read_only_access,
721 network_access: network_policy.is_enabled(),
722 exclude_tmpdir_env_var: !tmpdir_writable,
723 exclude_slash_tmp: !slash_tmp_writable,
724 }
725 } else if !writable_roots.is_empty() || tmpdir_writable || slash_tmp_writable {
726 return Err(io::Error::new(
727 io::ErrorKind::InvalidInput,
728 "permissions profile requests filesystem writes outside the workspace root, which is not supported until the runtime enforces FileSystemSandboxPolicy directly",
729 ));
730 } else {
731 SandboxPolicy::ReadOnly {
732 access: read_only_access,
733 network_access: network_policy.is_enabled(),
734 }
735 }
736 }
737 })
738 }
739
740 fn resolved_entries_with_cwd(&self, cwd: &Path) -> Vec<ResolvedFileSystemEntry> {
741 let cwd_absolute = AbsolutePathBuf::from_absolute_path(cwd).ok();
742 self.entries
743 .iter()
744 .filter_map(|entry| {
745 resolve_entry_path(&entry.path, cwd_absolute.as_ref()).map(|path| {
746 ResolvedFileSystemEntry {
747 path,
748 access: entry.access,
749 }
750 })
751 })
752 .collect()
753 }
754
755 fn semantic_signature(&self, cwd: &Path) -> FileSystemSemanticSignature {
756 FileSystemSemanticSignature {
757 has_full_disk_read_access: self.has_full_disk_read_access(),
758 has_full_disk_write_access: self.has_full_disk_write_access(),
759 include_platform_defaults: self.include_platform_defaults(),
760 readable_roots: self.get_readable_roots_with_cwd(cwd),
761 writable_roots: self.get_writable_roots_with_cwd(cwd),
762 unreadable_roots: self.get_unreadable_roots_with_cwd(cwd),
763 }
764 }
765}
766
767impl From<&SandboxPolicy> for NetworkSandboxPolicy {
768 fn from(value: &SandboxPolicy) -> Self {
769 if value.has_full_network_access() {
770 NetworkSandboxPolicy::Enabled
771 } else {
772 NetworkSandboxPolicy::Restricted
773 }
774 }
775}
776
777impl From<&SandboxPolicy> for FileSystemSandboxPolicy {
778 fn from(value: &SandboxPolicy) -> Self {
779 match value {
780 SandboxPolicy::DangerFullAccess => FileSystemSandboxPolicy::unrestricted(),
781 SandboxPolicy::ExternalSandbox { .. } => FileSystemSandboxPolicy::external_sandbox(),
782 SandboxPolicy::ReadOnly { access, .. } => {
783 let mut entries = Vec::new();
784 match access {
785 ReadOnlyAccess::FullAccess => entries.push(FileSystemSandboxEntry {
786 path: FileSystemPath::Special {
787 value: FileSystemSpecialPath::Root,
788 },
789 access: FileSystemAccessMode::Read,
790 }),
791 ReadOnlyAccess::Restricted {
792 include_platform_defaults,
793 readable_roots,
794 } => {
795 entries.push(FileSystemSandboxEntry {
796 path: FileSystemPath::Special {
797 value: FileSystemSpecialPath::CurrentWorkingDirectory,
798 },
799 access: FileSystemAccessMode::Read,
800 });
801 if *include_platform_defaults {
802 entries.push(FileSystemSandboxEntry {
803 path: FileSystemPath::Special {
804 value: FileSystemSpecialPath::Minimal,
805 },
806 access: FileSystemAccessMode::Read,
807 });
808 }
809 entries.extend(readable_roots.iter().cloned().map(|path| {
810 FileSystemSandboxEntry {
811 path: FileSystemPath::Path { path },
812 access: FileSystemAccessMode::Read,
813 }
814 }));
815 }
816 }
817 FileSystemSandboxPolicy::restricted(entries)
818 }
819 SandboxPolicy::WorkspaceWrite {
820 writable_roots,
821 read_only_access,
822 exclude_tmpdir_env_var,
823 exclude_slash_tmp,
824 ..
825 } => {
826 let mut entries = Vec::new();
827 match read_only_access {
828 ReadOnlyAccess::FullAccess => entries.push(FileSystemSandboxEntry {
829 path: FileSystemPath::Special {
830 value: FileSystemSpecialPath::Root,
831 },
832 access: FileSystemAccessMode::Read,
833 }),
834 ReadOnlyAccess::Restricted {
835 include_platform_defaults,
836 readable_roots,
837 } => {
838 if *include_platform_defaults {
839 entries.push(FileSystemSandboxEntry {
840 path: FileSystemPath::Special {
841 value: FileSystemSpecialPath::Minimal,
842 },
843 access: FileSystemAccessMode::Read,
844 });
845 }
846 entries.extend(readable_roots.iter().cloned().map(|path| {
847 FileSystemSandboxEntry {
848 path: FileSystemPath::Path { path },
849 access: FileSystemAccessMode::Read,
850 }
851 }));
852 }
853 }
854
855 entries.push(FileSystemSandboxEntry {
856 path: FileSystemPath::Special {
857 value: FileSystemSpecialPath::CurrentWorkingDirectory,
858 },
859 access: FileSystemAccessMode::Write,
860 });
861 if !exclude_slash_tmp {
862 entries.push(FileSystemSandboxEntry {
863 path: FileSystemPath::Special {
864 value: FileSystemSpecialPath::SlashTmp,
865 },
866 access: FileSystemAccessMode::Write,
867 });
868 }
869 if !exclude_tmpdir_env_var {
870 entries.push(FileSystemSandboxEntry {
871 path: FileSystemPath::Special {
872 value: FileSystemSpecialPath::Tmpdir,
873 },
874 access: FileSystemAccessMode::Write,
875 });
876 }
877 entries.extend(
878 writable_roots
879 .iter()
880 .cloned()
881 .map(|path| FileSystemSandboxEntry {
882 path: FileSystemPath::Path { path },
883 access: FileSystemAccessMode::Write,
884 }),
885 );
886 FileSystemSandboxPolicy::restricted(entries)
887 }
888 }
889 }
890}
891
892fn resolve_file_system_path(
893 path: &FileSystemPath,
894 cwd: Option<&AbsolutePathBuf>,
895) -> Option<AbsolutePathBuf> {
896 match path {
897 FileSystemPath::Path { path } => Some(path.clone()),
898 FileSystemPath::Special { value } => resolve_file_system_special_path(value, cwd),
899 }
900}
901
902fn resolve_entry_path(
903 path: &FileSystemPath,
904 cwd: Option<&AbsolutePathBuf>,
905) -> Option<AbsolutePathBuf> {
906 match path {
907 FileSystemPath::Special {
908 value: FileSystemSpecialPath::Root,
909 } => cwd.map(absolute_root_path_for_cwd),
910 _ => resolve_file_system_path(path, cwd),
911 }
912}
913
914fn resolve_candidate_path(path: &Path, cwd: &Path) -> Option<AbsolutePathBuf> {
915 if path.is_absolute() {
916 AbsolutePathBuf::from_absolute_path(path).ok()
917 } else {
918 Some(AbsolutePathBuf::resolve_path_against_base(path, cwd))
919 }
920}
921
922fn file_system_paths_share_target(left: &FileSystemPath, right: &FileSystemPath) -> bool {
929 match (left, right) {
930 (FileSystemPath::Path { path: left }, FileSystemPath::Path { path: right }) => {
931 left == right
932 }
933 (FileSystemPath::Special { value: left }, FileSystemPath::Special { value: right }) => {
934 special_paths_share_target(left, right)
935 }
936 (FileSystemPath::Path { path }, FileSystemPath::Special { value })
937 | (FileSystemPath::Special { value }, FileSystemPath::Path { path }) => {
938 special_path_matches_absolute_path(value, path)
939 }
940 }
941}
942
943fn special_paths_share_target(left: &FileSystemSpecialPath, right: &FileSystemSpecialPath) -> bool {
946 match (left, right) {
947 (FileSystemSpecialPath::Root, FileSystemSpecialPath::Root)
948 | (FileSystemSpecialPath::Minimal, FileSystemSpecialPath::Minimal)
949 | (
950 FileSystemSpecialPath::CurrentWorkingDirectory,
951 FileSystemSpecialPath::CurrentWorkingDirectory,
952 )
953 | (FileSystemSpecialPath::Tmpdir, FileSystemSpecialPath::Tmpdir)
954 | (FileSystemSpecialPath::SlashTmp, FileSystemSpecialPath::SlashTmp) => true,
955 (
956 FileSystemSpecialPath::CurrentWorkingDirectory,
957 FileSystemSpecialPath::ProjectRoots { subpath: None },
958 )
959 | (
960 FileSystemSpecialPath::ProjectRoots { subpath: None },
961 FileSystemSpecialPath::CurrentWorkingDirectory,
962 ) => true,
963 (
964 FileSystemSpecialPath::ProjectRoots { subpath: left },
965 FileSystemSpecialPath::ProjectRoots { subpath: right },
966 ) => left == right,
967 (
968 FileSystemSpecialPath::Unknown {
969 path: left,
970 subpath: left_subpath,
971 },
972 FileSystemSpecialPath::Unknown {
973 path: right,
974 subpath: right_subpath,
975 },
976 ) => left == right && left_subpath == right_subpath,
977 _ => false,
978 }
979}
980
981fn special_path_matches_absolute_path(
987 value: &FileSystemSpecialPath,
988 path: &AbsolutePathBuf,
989) -> bool {
990 match value {
991 FileSystemSpecialPath::Root => path.as_path().parent().is_none(),
992 FileSystemSpecialPath::SlashTmp => path.as_path() == Path::new("/tmp"),
993 _ => false,
994 }
995}
996
997fn resolved_entry_precedence(entry: &ResolvedFileSystemEntry) -> (usize, FileSystemAccessMode) {
1000 let specificity = entry.path.as_path().components().count();
1001 (specificity, entry.access)
1002}
1003
1004fn absolute_root_path_for_cwd(cwd: &AbsolutePathBuf) -> AbsolutePathBuf {
1005 let root = cwd
1006 .as_path()
1007 .ancestors()
1008 .last()
1009 .unwrap_or_else(|| panic!("cwd must have a filesystem root"));
1010 AbsolutePathBuf::from_absolute_path(root)
1011 .unwrap_or_else(|err| panic!("cwd root must be an absolute path: {err}"))
1012}
1013
1014fn resolve_file_system_special_path(
1015 value: &FileSystemSpecialPath,
1016 cwd: Option<&AbsolutePathBuf>,
1017) -> Option<AbsolutePathBuf> {
1018 match value {
1019 FileSystemSpecialPath::Root
1020 | FileSystemSpecialPath::Minimal
1021 | FileSystemSpecialPath::Unknown { .. } => None,
1022 FileSystemSpecialPath::CurrentWorkingDirectory => {
1023 let cwd = cwd?;
1024 Some(cwd.clone())
1025 }
1026 FileSystemSpecialPath::ProjectRoots { subpath } => {
1027 let cwd = cwd?;
1028 match subpath.as_ref() {
1029 Some(subpath) => Some(AbsolutePathBuf::resolve_path_against_base(
1030 subpath,
1031 cwd.as_path(),
1032 )),
1033 None => Some(cwd.clone()),
1034 }
1035 }
1036 FileSystemSpecialPath::Tmpdir => {
1037 let tmpdir = std::env::var_os("TMPDIR")?;
1038 if tmpdir.is_empty() {
1039 None
1040 } else {
1041 let tmpdir = AbsolutePathBuf::from_absolute_path(PathBuf::from(tmpdir)).ok()?;
1042 Some(tmpdir)
1043 }
1044 }
1045 FileSystemSpecialPath::SlashTmp => {
1046 #[allow(clippy::expect_used)]
1047 let slash_tmp = AbsolutePathBuf::from_absolute_path("/tmp").expect("/tmp is absolute");
1048 if !slash_tmp.as_path().is_dir() {
1049 return None;
1050 }
1051 Some(slash_tmp)
1052 }
1053 }
1054}
1055
1056fn dedup_absolute_paths(
1057 paths: Vec<AbsolutePathBuf>,
1058 normalize_effective_paths: bool,
1059) -> Vec<AbsolutePathBuf> {
1060 let mut deduped = Vec::with_capacity(paths.len());
1061 let mut seen = HashSet::new();
1062 for path in paths {
1063 let dedup_path = if normalize_effective_paths {
1064 normalize_effective_absolute_path(path)
1065 } else {
1066 path
1067 };
1068 if seen.insert(dedup_path.to_path_buf()) {
1069 deduped.push(dedup_path);
1070 }
1071 }
1072 deduped
1073}
1074
1075fn normalize_effective_absolute_path(path: AbsolutePathBuf) -> AbsolutePathBuf {
1076 let raw_path = path.to_path_buf();
1077 for ancestor in raw_path.ancestors() {
1078 let Ok(canonical_ancestor) = ancestor.canonicalize() else {
1079 continue;
1080 };
1081 let Ok(suffix) = raw_path.strip_prefix(ancestor) else {
1082 continue;
1083 };
1084 if let Ok(normalized_path) =
1085 AbsolutePathBuf::from_absolute_path(canonical_ancestor.join(suffix))
1086 {
1087 return normalized_path;
1088 }
1089 }
1090 path
1091}
1092
1093fn default_read_only_subpaths_for_writable_root(
1094 writable_root: &AbsolutePathBuf,
1095 _protect_missing_dot_codex: bool,
1096) -> Vec<AbsolutePathBuf> {
1097 let mut subpaths: Vec<AbsolutePathBuf> = Vec::new();
1098 let top_level_git = writable_root.join(".git");
1099 let top_level_git_is_file = top_level_git.as_path().is_file();
1103 let top_level_git_is_dir = top_level_git.as_path().is_dir();
1104 if top_level_git_is_dir || top_level_git_is_file {
1105 if top_level_git_is_file
1106 && is_git_pointer_file(&top_level_git)
1107 && let Some(gitdir) = resolve_gitdir_from_file(&top_level_git)
1108 {
1109 subpaths.push(gitdir);
1110 }
1111 subpaths.push(top_level_git);
1112 }
1113
1114 let top_level_agents = writable_root.join(".agents");
1115 if top_level_agents.as_path().is_dir() {
1116 subpaths.push(top_level_agents);
1117 }
1118
1119 let top_level_codex = writable_root.join(".codex");
1121 if top_level_codex.as_path().is_dir() {
1122 subpaths.push(top_level_codex);
1123 }
1124
1125 dedup_absolute_paths(subpaths, false)
1126}
1127
1128fn append_path_entry_if_missing(
1129 entries: &mut Vec<FileSystemSandboxEntry>,
1130 path: AbsolutePathBuf,
1131 access: FileSystemAccessMode,
1132) {
1133 if entries.iter().any(|entry| {
1134 entry.access == access
1135 && matches!(
1136 &entry.path,
1137 FileSystemPath::Path { path: existing } if existing == &path
1138 )
1139 }) {
1140 return;
1141 }
1142
1143 entries.push(FileSystemSandboxEntry {
1144 path: FileSystemPath::Path { path },
1145 access,
1146 });
1147}
1148
1149fn append_default_read_only_path_if_no_explicit_rule(
1150 entries: &mut Vec<FileSystemSandboxEntry>,
1151 path: AbsolutePathBuf,
1152) {
1153 if entries.iter().any(|entry| {
1154 matches!(
1155 &entry.path,
1156 FileSystemPath::Path { path: existing } if existing == &path
1157 )
1158 }) {
1159 return;
1160 }
1161
1162 append_path_entry_if_missing(entries, path, FileSystemAccessMode::Read);
1163}
1164
1165fn has_explicit_resolved_path_entry(
1166 entries: &[ResolvedFileSystemEntry],
1167 path: &AbsolutePathBuf,
1168) -> bool {
1169 entries.iter().any(|entry| &entry.path == path)
1170}
1171
1172fn is_git_pointer_file(path: &AbsolutePathBuf) -> bool {
1173 path.as_path().is_file() && path.as_path().file_name() == Some(OsStr::new(".git"))
1174}
1175
1176fn resolve_gitdir_from_file(dot_git: &AbsolutePathBuf) -> Option<AbsolutePathBuf> {
1177 let contents = match std::fs::read_to_string(dot_git.as_path()) {
1178 Ok(contents) => contents,
1179 Err(err) => {
1180 error!(
1181 "Failed to read {path} for gitdir pointer: {err}",
1182 path = dot_git.as_path().display()
1183 );
1184 return None;
1185 }
1186 };
1187
1188 let trimmed = contents.trim();
1189 let (_, gitdir_raw) = match trimmed.split_once(':') {
1190 Some(parts) => parts,
1191 None => {
1192 error!(
1193 "Expected {path} to contain a gitdir pointer, but it did not match `gitdir: <path>`.",
1194 path = dot_git.as_path().display()
1195 );
1196 return None;
1197 }
1198 };
1199 let gitdir_raw = gitdir_raw.trim();
1200 if gitdir_raw.is_empty() {
1201 error!(
1202 "Expected {path} to contain a gitdir pointer, but it was empty.",
1203 path = dot_git.as_path().display()
1204 );
1205 return None;
1206 }
1207 let base = match dot_git.as_path().parent() {
1208 Some(base) => base,
1209 None => {
1210 error!(
1211 "Unable to resolve parent directory for {path}.",
1212 path = dot_git.as_path().display()
1213 );
1214 return None;
1215 }
1216 };
1217 let gitdir_path = AbsolutePathBuf::resolve_path_against_base(gitdir_raw, base);
1218 if !gitdir_path.as_path().exists() {
1219 error!(
1220 "Resolved gitdir path {path} does not exist.",
1221 path = gitdir_path.as_path().display()
1222 );
1223 return None;
1224 }
1225 Some(gitdir_path)
1226}
1227
1228#[cfg(test)]
1229mod tests {
1230 use super::*;
1231 use pretty_assertions::assert_eq;
1232 #[cfg(unix)]
1233 use std::fs;
1234 use std::path::Path;
1235 use tempfile::TempDir;
1236
1237 #[cfg(unix)]
1238 const SYMLINKED_TMPDIR_TEST_ENV: &str = "CODEX_PROTOCOL_TEST_SYMLINKED_TMPDIR";
1239
1240 #[cfg(unix)]
1241 fn symlink_dir(original: &Path, link: &Path) -> std::io::Result<()> {
1242 std::os::unix::fs::symlink(original, link)
1243 }
1244
1245 #[test]
1246 fn unknown_special_paths_are_ignored_by_legacy_bridge() -> std::io::Result<()> {
1247 let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
1248 path: FileSystemPath::Special {
1249 value: FileSystemSpecialPath::unknown(
1250 ":future_special_path",
1251 None,
1252 ),
1253 },
1254 access: FileSystemAccessMode::Write,
1255 }]);
1256
1257 let sandbox_policy = policy.to_legacy_sandbox_policy(
1258 NetworkSandboxPolicy::Restricted,
1259 Path::new("/tmp/workspace"),
1260 )?;
1261
1262 assert_eq!(
1263 sandbox_policy,
1264 SandboxPolicy::ReadOnly {
1265 access: ReadOnlyAccess::Restricted {
1266 include_platform_defaults: false,
1267 readable_roots: Vec::new(),
1268 },
1269 network_access: false,
1270 }
1271 );
1272 Ok(())
1273 }
1274
1275 #[cfg(unix)]
1276 #[test]
1277 fn writable_roots_proactively_protect_missing_dot_codex() {
1278 let cwd = TempDir::new().expect("tempdir");
1279 let expected_root = AbsolutePathBuf::from_absolute_path(
1280 cwd.path().canonicalize().expect("canonicalize cwd"),
1281 )
1282 .expect("absolute canonical root");
1283 let expected_dot_codex = expected_root.join(".codex");
1284
1285 let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
1286 path: FileSystemPath::Special {
1287 value: FileSystemSpecialPath::CurrentWorkingDirectory,
1288 },
1289 access: FileSystemAccessMode::Write,
1290 }]);
1291
1292 let writable_roots = policy.get_writable_roots_with_cwd(cwd.path());
1293 assert_eq!(writable_roots.len(), 1);
1294 assert_eq!(writable_roots[0].root, expected_root);
1295 assert!(
1296 !writable_roots[0]
1297 .read_only_subpaths
1298 .contains(&expected_dot_codex)
1299 );
1300 }
1301
1302 #[cfg(unix)]
1303 #[test]
1304 fn writable_roots_skip_default_dot_codex_when_explicit_user_rule_exists() {
1305 let cwd = TempDir::new().expect("tempdir");
1306 let expected_root = AbsolutePathBuf::from_absolute_path(
1307 cwd.path().canonicalize().expect("canonicalize cwd"),
1308 )
1309 .expect("absolute canonical root");
1310 let explicit_dot_codex = expected_root.join(".codex");
1311
1312 let policy = FileSystemSandboxPolicy::restricted(vec![
1313 FileSystemSandboxEntry {
1314 path: FileSystemPath::Special {
1315 value: FileSystemSpecialPath::CurrentWorkingDirectory,
1316 },
1317 access: FileSystemAccessMode::Write,
1318 },
1319 FileSystemSandboxEntry {
1320 path: FileSystemPath::Path {
1321 path: explicit_dot_codex.clone(),
1322 },
1323 access: FileSystemAccessMode::Write,
1324 },
1325 ]);
1326
1327 let writable_roots = policy.get_writable_roots_with_cwd(cwd.path());
1328 let workspace_root = writable_roots
1329 .iter()
1330 .find(|root| root.root == expected_root)
1331 .expect("workspace writable root");
1332 assert!(
1333 !workspace_root
1334 .read_only_subpaths
1335 .contains(&explicit_dot_codex),
1336 "explicit .codex rule should win over the default protected carveout"
1337 );
1338 assert!(
1339 policy.can_write_path_with_cwd(
1340 explicit_dot_codex.join("config.toml").as_path(),
1341 cwd.path()
1342 )
1343 );
1344 }
1345
1346 #[test]
1347 fn legacy_workspace_write_projection_blocks_missing_dot_codex_writes() {
1348 let cwd = TempDir::new().expect("tempdir");
1349 let dot_codex_config = cwd.path().join(".codex").join("config.toml");
1350 let policy = SandboxPolicy::WorkspaceWrite {
1351 writable_roots: vec![],
1352 read_only_access: ReadOnlyAccess::Restricted {
1353 include_platform_defaults: false,
1354 readable_roots: vec![],
1355 },
1356 network_access: false,
1357 exclude_tmpdir_env_var: true,
1358 exclude_slash_tmp: true,
1359 };
1360
1361 let file_system_policy =
1362 FileSystemSandboxPolicy::from_legacy_sandbox_policy(&policy, cwd.path());
1363
1364 assert!(file_system_policy.can_write_path_with_cwd(&dot_codex_config, cwd.path()));
1365 }
1366
1367 #[test]
1368 fn legacy_workspace_write_projection_accepts_relative_cwd() {
1369 let relative_cwd = Path::new("workspace");
1370 let policy = SandboxPolicy::WorkspaceWrite {
1371 writable_roots: vec![],
1372 read_only_access: ReadOnlyAccess::Restricted {
1373 include_platform_defaults: false,
1374 readable_roots: vec![],
1375 },
1376 network_access: false,
1377 exclude_tmpdir_env_var: true,
1378 exclude_slash_tmp: true,
1379 };
1380
1381 let file_system_policy =
1382 FileSystemSandboxPolicy::from_legacy_sandbox_policy(&policy, relative_cwd);
1383
1384 assert_eq!(
1385 file_system_policy,
1386 FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
1387 path: FileSystemPath::Special {
1388 value: FileSystemSpecialPath::CurrentWorkingDirectory,
1389 },
1390 access: FileSystemAccessMode::Write,
1391 }])
1392 );
1393 }
1394
1395 #[cfg(unix)]
1396 #[test]
1397 fn effective_runtime_roots_canonicalize_symlinked_paths() {
1398 let cwd = TempDir::new().expect("tempdir");
1399 let real_root = cwd.path().join("real");
1400 let link_root = cwd.path().join("link");
1401 let blocked = real_root.join("blocked");
1402 let codex_dir = real_root.join(".codex");
1403
1404 fs::create_dir_all(&blocked).expect("create blocked");
1405 fs::create_dir_all(&codex_dir).expect("create .codex");
1406 symlink_dir(&real_root, &link_root).expect("create symlinked root");
1407
1408 let link_root =
1409 AbsolutePathBuf::from_absolute_path(&link_root).expect("absolute symlinked root");
1410 let link_blocked = link_root.join("blocked");
1411 let expected_root = AbsolutePathBuf::from_absolute_path(
1412 real_root.canonicalize().expect("canonicalize real root"),
1413 )
1414 .expect("absolute canonical root");
1415 let expected_blocked = AbsolutePathBuf::from_absolute_path(
1416 blocked.canonicalize().expect("canonicalize blocked"),
1417 )
1418 .expect("absolute canonical blocked");
1419 let expected_codex = AbsolutePathBuf::from_absolute_path(
1420 codex_dir.canonicalize().expect("canonicalize .codex"),
1421 )
1422 .expect("absolute canonical .codex");
1423
1424 let policy = FileSystemSandboxPolicy::restricted(vec![
1425 FileSystemSandboxEntry {
1426 path: FileSystemPath::Path { path: link_root },
1427 access: FileSystemAccessMode::Write,
1428 },
1429 FileSystemSandboxEntry {
1430 path: FileSystemPath::Path { path: link_blocked },
1431 access: FileSystemAccessMode::None,
1432 },
1433 ]);
1434
1435 assert_eq!(
1436 policy.get_unreadable_roots_with_cwd(cwd.path()),
1437 vec![expected_blocked.clone()]
1438 );
1439
1440 let writable_roots = policy.get_writable_roots_with_cwd(cwd.path());
1441 assert_eq!(writable_roots.len(), 1);
1442 assert_eq!(writable_roots[0].root, expected_root);
1443 assert!(
1444 writable_roots[0]
1445 .read_only_subpaths
1446 .contains(&expected_blocked)
1447 );
1448 assert!(
1449 writable_roots[0]
1450 .read_only_subpaths
1451 .contains(&expected_codex)
1452 );
1453 }
1454
1455 #[cfg(unix)]
1456 #[test]
1457 fn current_working_directory_special_path_canonicalizes_symlinked_cwd() {
1458 let cwd = TempDir::new().expect("tempdir");
1459 let real_root = cwd.path().join("real");
1460 let link_root = cwd.path().join("link");
1461 let blocked = real_root.join("blocked");
1462 let agents_dir = real_root.join(".agents");
1463 let codex_dir = real_root.join(".codex");
1464
1465 fs::create_dir_all(&blocked).expect("create blocked");
1466 fs::create_dir_all(&agents_dir).expect("create .agents");
1467 fs::create_dir_all(&codex_dir).expect("create .codex");
1468 symlink_dir(&real_root, &link_root).expect("create symlinked cwd");
1469
1470 let link_blocked =
1471 AbsolutePathBuf::from_absolute_path(link_root.join("blocked")).expect("link blocked");
1472 let expected_root = AbsolutePathBuf::from_absolute_path(
1473 real_root.canonicalize().expect("canonicalize real root"),
1474 )
1475 .expect("absolute canonical root");
1476 let expected_blocked = AbsolutePathBuf::from_absolute_path(
1477 blocked.canonicalize().expect("canonicalize blocked"),
1478 )
1479 .expect("absolute canonical blocked");
1480 let expected_agents = AbsolutePathBuf::from_absolute_path(
1481 agents_dir.canonicalize().expect("canonicalize .agents"),
1482 )
1483 .expect("absolute canonical .agents");
1484 let expected_codex = AbsolutePathBuf::from_absolute_path(
1485 codex_dir.canonicalize().expect("canonicalize .codex"),
1486 )
1487 .expect("absolute canonical .codex");
1488
1489 let policy = FileSystemSandboxPolicy::restricted(vec![
1490 FileSystemSandboxEntry {
1491 path: FileSystemPath::Special {
1492 value: FileSystemSpecialPath::Minimal,
1493 },
1494 access: FileSystemAccessMode::Read,
1495 },
1496 FileSystemSandboxEntry {
1497 path: FileSystemPath::Special {
1498 value: FileSystemSpecialPath::CurrentWorkingDirectory,
1499 },
1500 access: FileSystemAccessMode::Write,
1501 },
1502 FileSystemSandboxEntry {
1503 path: FileSystemPath::Path { path: link_blocked },
1504 access: FileSystemAccessMode::None,
1505 },
1506 ]);
1507
1508 assert_eq!(
1509 policy.get_readable_roots_with_cwd(&link_root),
1510 vec![expected_root.clone()]
1511 );
1512 assert_eq!(
1513 policy.get_unreadable_roots_with_cwd(&link_root),
1514 vec![expected_blocked.clone()]
1515 );
1516
1517 let writable_roots = policy.get_writable_roots_with_cwd(&link_root);
1518 assert_eq!(writable_roots.len(), 1);
1519 assert_eq!(writable_roots[0].root, expected_root);
1520 assert!(
1521 writable_roots[0]
1522 .read_only_subpaths
1523 .contains(&expected_blocked)
1524 );
1525 assert!(
1526 writable_roots[0]
1527 .read_only_subpaths
1528 .contains(&expected_agents)
1529 );
1530 assert!(
1531 writable_roots[0]
1532 .read_only_subpaths
1533 .contains(&expected_codex)
1534 );
1535 }
1536
1537 #[cfg(unix)]
1538 #[test]
1539 fn writable_roots_preserve_symlinked_protected_subpaths() {
1540 let cwd = TempDir::new().expect("tempdir");
1541 let root = cwd.path().join("root");
1542 let decoy = root.join("decoy-codex");
1543 let dot_codex = root.join(".codex");
1544 fs::create_dir_all(&decoy).expect("create decoy");
1545 symlink_dir(&decoy, &dot_codex).expect("create .codex symlink");
1546
1547 let root = AbsolutePathBuf::from_absolute_path(&root).expect("absolute root");
1548 let expected_dot_codex = AbsolutePathBuf::from_absolute_path(
1549 root.as_path()
1550 .canonicalize()
1551 .expect("canonicalize root")
1552 .join(".codex"),
1553 )
1554 .expect("absolute .codex symlink");
1555 let unexpected_decoy =
1556 AbsolutePathBuf::from_absolute_path(decoy.canonicalize().expect("canonicalize decoy"))
1557 .expect("absolute canonical decoy");
1558
1559 let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
1560 path: FileSystemPath::Path { path: root },
1561 access: FileSystemAccessMode::Write,
1562 }]);
1563
1564 let writable_roots = policy.get_writable_roots_with_cwd(cwd.path());
1565 assert_eq!(writable_roots.len(), 1);
1566 assert_eq!(
1567 writable_roots[0].read_only_subpaths,
1568 vec![expected_dot_codex]
1569 );
1570 assert!(
1571 !writable_roots[0]
1572 .read_only_subpaths
1573 .contains(&unexpected_decoy)
1574 );
1575 }
1576
1577 #[cfg(unix)]
1578 #[test]
1579 fn writable_roots_preserve_explicit_symlinked_carveouts_under_symlinked_roots() {
1580 let cwd = TempDir::new().expect("tempdir");
1581 let real_root = cwd.path().join("real");
1582 let link_root = cwd.path().join("link");
1583 let decoy = real_root.join("decoy-private");
1584 let linked_private = real_root.join("linked-private");
1585 fs::create_dir_all(&decoy).expect("create decoy");
1586 symlink_dir(&real_root, &link_root).expect("create symlinked root");
1587 symlink_dir(&decoy, &linked_private).expect("create linked-private symlink");
1588
1589 let link_root =
1590 AbsolutePathBuf::from_absolute_path(&link_root).expect("absolute symlinked root");
1591 let link_private = link_root.join("linked-private");
1592 let expected_root = AbsolutePathBuf::from_absolute_path(
1593 real_root.canonicalize().expect("canonicalize real root"),
1594 )
1595 .expect("absolute canonical root");
1596 let expected_linked_private = expected_root.join("linked-private");
1597 let unexpected_decoy =
1598 AbsolutePathBuf::from_absolute_path(decoy.canonicalize().expect("canonicalize decoy"))
1599 .expect("absolute canonical decoy");
1600
1601 let policy = FileSystemSandboxPolicy::restricted(vec![
1602 FileSystemSandboxEntry {
1603 path: FileSystemPath::Path { path: link_root },
1604 access: FileSystemAccessMode::Write,
1605 },
1606 FileSystemSandboxEntry {
1607 path: FileSystemPath::Path { path: link_private },
1608 access: FileSystemAccessMode::None,
1609 },
1610 ]);
1611
1612 let writable_roots = policy.get_writable_roots_with_cwd(cwd.path());
1613 assert_eq!(writable_roots.len(), 1);
1614 assert_eq!(writable_roots[0].root, expected_root);
1615 assert_eq!(
1616 writable_roots[0].read_only_subpaths,
1617 vec![expected_linked_private]
1618 );
1619 assert!(
1620 !writable_roots[0]
1621 .read_only_subpaths
1622 .contains(&unexpected_decoy)
1623 );
1624 }
1625
1626 #[cfg(unix)]
1627 #[test]
1628 fn writable_roots_preserve_explicit_symlinked_carveouts_that_escape_root() {
1629 let cwd = TempDir::new().expect("tempdir");
1630 let real_root = cwd.path().join("real");
1631 let link_root = cwd.path().join("link");
1632 let decoy = cwd.path().join("outside-private");
1633 let linked_private = real_root.join("linked-private");
1634 fs::create_dir_all(&decoy).expect("create decoy");
1635 fs::create_dir_all(&real_root).expect("create real root");
1636 symlink_dir(&real_root, &link_root).expect("create symlinked root");
1637 symlink_dir(&decoy, &linked_private).expect("create linked-private symlink");
1638
1639 let link_root =
1640 AbsolutePathBuf::from_absolute_path(&link_root).expect("absolute symlinked root");
1641 let link_private = link_root.join("linked-private");
1642 let expected_root = AbsolutePathBuf::from_absolute_path(
1643 real_root.canonicalize().expect("canonicalize real root"),
1644 )
1645 .expect("absolute canonical root");
1646 let expected_linked_private = expected_root.join("linked-private");
1647 let unexpected_decoy =
1648 AbsolutePathBuf::from_absolute_path(decoy.canonicalize().expect("canonicalize decoy"))
1649 .expect("absolute canonical decoy");
1650
1651 let policy = FileSystemSandboxPolicy::restricted(vec![
1652 FileSystemSandboxEntry {
1653 path: FileSystemPath::Path { path: link_root },
1654 access: FileSystemAccessMode::Write,
1655 },
1656 FileSystemSandboxEntry {
1657 path: FileSystemPath::Path { path: link_private },
1658 access: FileSystemAccessMode::None,
1659 },
1660 ]);
1661
1662 let writable_roots = policy.get_writable_roots_with_cwd(cwd.path());
1663 assert_eq!(writable_roots.len(), 1);
1664 assert_eq!(writable_roots[0].root, expected_root);
1665 assert_eq!(
1666 writable_roots[0].read_only_subpaths,
1667 vec![expected_linked_private]
1668 );
1669 assert!(
1670 !writable_roots[0]
1671 .read_only_subpaths
1672 .contains(&unexpected_decoy)
1673 );
1674 }
1675
1676 #[cfg(unix)]
1677 #[test]
1678 fn writable_roots_preserve_explicit_symlinked_carveouts_that_alias_root() {
1679 let cwd = TempDir::new().expect("tempdir");
1680 let root = cwd.path().join("root");
1681 let alias = root.join("alias-root");
1682 fs::create_dir_all(&root).expect("create root");
1683 symlink_dir(&root, &alias).expect("create alias symlink");
1684
1685 let root = AbsolutePathBuf::from_absolute_path(&root).expect("absolute root");
1686 let alias = root.join("alias-root");
1687 let expected_root = AbsolutePathBuf::from_absolute_path(
1688 root.as_path().canonicalize().expect("canonicalize root"),
1689 )
1690 .expect("absolute canonical root");
1691 let expected_alias = expected_root.join("alias-root");
1692
1693 let policy = FileSystemSandboxPolicy::restricted(vec![
1694 FileSystemSandboxEntry {
1695 path: FileSystemPath::Path { path: root },
1696 access: FileSystemAccessMode::Write,
1697 },
1698 FileSystemSandboxEntry {
1699 path: FileSystemPath::Path { path: alias },
1700 access: FileSystemAccessMode::None,
1701 },
1702 ]);
1703
1704 let writable_roots = policy.get_writable_roots_with_cwd(cwd.path());
1705 assert_eq!(writable_roots.len(), 1);
1706 assert_eq!(writable_roots[0].root, expected_root);
1707 assert_eq!(writable_roots[0].read_only_subpaths, vec![expected_alias]);
1708 }
1709
1710 #[cfg(unix)]
1711 #[test]
1712 fn tmpdir_special_path_canonicalizes_symlinked_tmpdir() {
1713 if std::env::var_os(SYMLINKED_TMPDIR_TEST_ENV).is_none() {
1714 let output = std::process::Command::new(std::env::current_exe().expect("test binary"))
1715 .env(SYMLINKED_TMPDIR_TEST_ENV, "1")
1716 .arg("--exact")
1717 .arg("permissions::tests::tmpdir_special_path_canonicalizes_symlinked_tmpdir")
1718 .output()
1719 .expect("run tmpdir subprocess test");
1720
1721 assert!(
1722 output.status.success(),
1723 "tmpdir subprocess test failed\nstdout:\n{}\nstderr:\n{}",
1724 String::from_utf8_lossy(&output.stdout),
1725 String::from_utf8_lossy(&output.stderr)
1726 );
1727 return;
1728 }
1729
1730 let cwd = TempDir::new().expect("tempdir");
1731 let real_tmpdir = cwd.path().join("real-tmpdir");
1732 let link_tmpdir = cwd.path().join("link-tmpdir");
1733 let blocked = real_tmpdir.join("blocked");
1734 let codex_dir = real_tmpdir.join(".codex");
1735
1736 fs::create_dir_all(&blocked).expect("create blocked");
1737 fs::create_dir_all(&codex_dir).expect("create .codex");
1738 symlink_dir(&real_tmpdir, &link_tmpdir).expect("create symlinked tmpdir");
1739
1740 let link_blocked =
1741 AbsolutePathBuf::from_absolute_path(link_tmpdir.join("blocked")).expect("link blocked");
1742 let expected_root = AbsolutePathBuf::from_absolute_path(
1743 real_tmpdir
1744 .canonicalize()
1745 .expect("canonicalize real tmpdir"),
1746 )
1747 .expect("absolute canonical tmpdir");
1748 let expected_blocked = AbsolutePathBuf::from_absolute_path(
1749 blocked.canonicalize().expect("canonicalize blocked"),
1750 )
1751 .expect("absolute canonical blocked");
1752 let expected_codex = AbsolutePathBuf::from_absolute_path(
1753 codex_dir.canonicalize().expect("canonicalize .codex"),
1754 )
1755 .expect("absolute canonical .codex");
1756
1757 unsafe {
1758 std::env::set_var("TMPDIR", &link_tmpdir);
1759 }
1760
1761 let policy = FileSystemSandboxPolicy::restricted(vec![
1762 FileSystemSandboxEntry {
1763 path: FileSystemPath::Special {
1764 value: FileSystemSpecialPath::Tmpdir,
1765 },
1766 access: FileSystemAccessMode::Write,
1767 },
1768 FileSystemSandboxEntry {
1769 path: FileSystemPath::Path { path: link_blocked },
1770 access: FileSystemAccessMode::None,
1771 },
1772 ]);
1773
1774 assert_eq!(
1775 policy.get_unreadable_roots_with_cwd(cwd.path()),
1776 vec![expected_blocked.clone()]
1777 );
1778
1779 let writable_roots = policy.get_writable_roots_with_cwd(cwd.path());
1780 assert_eq!(writable_roots.len(), 1);
1781 assert_eq!(writable_roots[0].root, expected_root);
1782 assert!(
1783 writable_roots[0]
1784 .read_only_subpaths
1785 .contains(&expected_blocked)
1786 );
1787 assert!(
1788 writable_roots[0]
1789 .read_only_subpaths
1790 .contains(&expected_codex)
1791 );
1792 }
1793
1794 #[test]
1795 fn resolve_access_with_cwd_uses_most_specific_entry() {
1796 let cwd = TempDir::new().expect("tempdir");
1797 let docs = AbsolutePathBuf::resolve_path_against_base("docs", cwd.path());
1798 let docs_private = AbsolutePathBuf::resolve_path_against_base("docs/private", cwd.path());
1799 let docs_private_public =
1800 AbsolutePathBuf::resolve_path_against_base("docs/private/public", cwd.path());
1801 let policy = FileSystemSandboxPolicy::restricted(vec![
1802 FileSystemSandboxEntry {
1803 path: FileSystemPath::Special {
1804 value: FileSystemSpecialPath::CurrentWorkingDirectory,
1805 },
1806 access: FileSystemAccessMode::Write,
1807 },
1808 FileSystemSandboxEntry {
1809 path: FileSystemPath::Path { path: docs.clone() },
1810 access: FileSystemAccessMode::Read,
1811 },
1812 FileSystemSandboxEntry {
1813 path: FileSystemPath::Path {
1814 path: docs_private.clone(),
1815 },
1816 access: FileSystemAccessMode::None,
1817 },
1818 FileSystemSandboxEntry {
1819 path: FileSystemPath::Path {
1820 path: docs_private_public.clone(),
1821 },
1822 access: FileSystemAccessMode::Write,
1823 },
1824 ]);
1825
1826 assert_eq!(
1827 policy.resolve_access_with_cwd(cwd.path(), cwd.path()),
1828 FileSystemAccessMode::Write
1829 );
1830 assert_eq!(
1831 policy.resolve_access_with_cwd(docs.as_path(), cwd.path()),
1832 FileSystemAccessMode::Read
1833 );
1834 assert_eq!(
1835 policy.resolve_access_with_cwd(docs_private.as_path(), cwd.path()),
1836 FileSystemAccessMode::None
1837 );
1838 assert_eq!(
1839 policy.resolve_access_with_cwd(docs_private_public.as_path(), cwd.path()),
1840 FileSystemAccessMode::Write
1841 );
1842 }
1843
1844 #[test]
1845 fn split_only_nested_carveouts_need_direct_runtime_enforcement() {
1846 let cwd = TempDir::new().expect("tempdir");
1847 let docs = AbsolutePathBuf::resolve_path_against_base("docs", cwd.path());
1848 let policy = FileSystemSandboxPolicy::restricted(vec![
1849 FileSystemSandboxEntry {
1850 path: FileSystemPath::Special {
1851 value: FileSystemSpecialPath::CurrentWorkingDirectory,
1852 },
1853 access: FileSystemAccessMode::Write,
1854 },
1855 FileSystemSandboxEntry {
1856 path: FileSystemPath::Path { path: docs },
1857 access: FileSystemAccessMode::Read,
1858 },
1859 ]);
1860
1861 assert!(
1862 policy.needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path(),)
1863 );
1864
1865 let legacy_workspace_write = FileSystemSandboxPolicy::from_legacy_sandbox_policy(
1866 &SandboxPolicy::new_workspace_write_policy(),
1867 cwd.path(),
1868 );
1869 assert!(
1870 !legacy_workspace_write
1871 .needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path(),)
1872 );
1873 }
1874
1875 #[test]
1876 fn root_write_with_read_only_child_is_not_full_disk_write() {
1877 let cwd = TempDir::new().expect("tempdir");
1878 let docs = AbsolutePathBuf::resolve_path_against_base("docs", cwd.path());
1879 let policy = FileSystemSandboxPolicy::restricted(vec![
1880 FileSystemSandboxEntry {
1881 path: FileSystemPath::Special {
1882 value: FileSystemSpecialPath::Root,
1883 },
1884 access: FileSystemAccessMode::Write,
1885 },
1886 FileSystemSandboxEntry {
1887 path: FileSystemPath::Path { path: docs.clone() },
1888 access: FileSystemAccessMode::Read,
1889 },
1890 ]);
1891
1892 assert!(!policy.has_full_disk_write_access());
1893 assert_eq!(
1894 policy.resolve_access_with_cwd(docs.as_path(), cwd.path()),
1895 FileSystemAccessMode::Read
1896 );
1897 assert!(
1898 policy.needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path(),)
1899 );
1900 }
1901
1902 #[test]
1903 fn root_deny_does_not_materialize_as_unreadable_root() {
1904 let cwd = TempDir::new().expect("tempdir");
1905 let docs = AbsolutePathBuf::resolve_path_against_base("docs", cwd.path());
1906 let expected_docs = AbsolutePathBuf::from_absolute_path(
1907 cwd.path()
1908 .canonicalize()
1909 .expect("canonicalize cwd")
1910 .join("docs"),
1911 )
1912 .expect("canonical docs");
1913 let policy = FileSystemSandboxPolicy::restricted(vec![
1914 FileSystemSandboxEntry {
1915 path: FileSystemPath::Special {
1916 value: FileSystemSpecialPath::Root,
1917 },
1918 access: FileSystemAccessMode::None,
1919 },
1920 FileSystemSandboxEntry {
1921 path: FileSystemPath::Path { path: docs.clone() },
1922 access: FileSystemAccessMode::Read,
1923 },
1924 ]);
1925
1926 assert_eq!(
1927 policy.resolve_access_with_cwd(docs.as_path(), cwd.path()),
1928 FileSystemAccessMode::Read
1929 );
1930 assert_eq!(
1931 policy.get_readable_roots_with_cwd(cwd.path()),
1932 vec![expected_docs]
1933 );
1934 assert!(policy.get_unreadable_roots_with_cwd(cwd.path()).is_empty());
1935 }
1936
1937 #[test]
1938 fn duplicate_root_deny_prevents_full_disk_write_access() {
1939 let cwd = TempDir::new().expect("tempdir");
1940 let root = AbsolutePathBuf::from_absolute_path(cwd.path())
1941 .map(|cwd| absolute_root_path_for_cwd(&cwd))
1942 .expect("resolve filesystem root");
1943 let policy = FileSystemSandboxPolicy::restricted(vec![
1944 FileSystemSandboxEntry {
1945 path: FileSystemPath::Special {
1946 value: FileSystemSpecialPath::Root,
1947 },
1948 access: FileSystemAccessMode::Write,
1949 },
1950 FileSystemSandboxEntry {
1951 path: FileSystemPath::Special {
1952 value: FileSystemSpecialPath::Root,
1953 },
1954 access: FileSystemAccessMode::None,
1955 },
1956 ]);
1957
1958 assert!(!policy.has_full_disk_write_access());
1959 assert_eq!(
1960 policy.resolve_access_with_cwd(root.as_path(), cwd.path()),
1961 FileSystemAccessMode::None
1962 );
1963 }
1964
1965 #[test]
1966 fn same_specificity_write_override_keeps_full_disk_write_access() {
1967 let cwd = TempDir::new().expect("tempdir");
1968 let docs = AbsolutePathBuf::resolve_path_against_base("docs", cwd.path());
1969 let policy = FileSystemSandboxPolicy::restricted(vec![
1970 FileSystemSandboxEntry {
1971 path: FileSystemPath::Special {
1972 value: FileSystemSpecialPath::Root,
1973 },
1974 access: FileSystemAccessMode::Write,
1975 },
1976 FileSystemSandboxEntry {
1977 path: FileSystemPath::Path { path: docs.clone() },
1978 access: FileSystemAccessMode::Read,
1979 },
1980 FileSystemSandboxEntry {
1981 path: FileSystemPath::Path { path: docs.clone() },
1982 access: FileSystemAccessMode::Write,
1983 },
1984 ]);
1985
1986 assert!(policy.has_full_disk_write_access());
1987 assert_eq!(
1988 policy.resolve_access_with_cwd(docs.as_path(), cwd.path()),
1989 FileSystemAccessMode::Write
1990 );
1991 }
1992
1993 #[test]
1994 fn with_additional_readable_roots_skips_existing_effective_access() {
1995 let cwd = TempDir::new().expect("tempdir");
1996 let cwd_root = AbsolutePathBuf::from_absolute_path(cwd.path()).expect("absolute cwd");
1997 let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
1998 path: FileSystemPath::Special {
1999 value: FileSystemSpecialPath::CurrentWorkingDirectory,
2000 },
2001 access: FileSystemAccessMode::Read,
2002 }]);
2003
2004 let actual = policy
2005 .clone()
2006 .with_additional_readable_roots(cwd.path(), std::slice::from_ref(&cwd_root));
2007
2008 assert_eq!(actual, policy);
2009 }
2010
2011 #[test]
2012 fn with_additional_writable_roots_skips_existing_effective_access() {
2013 let cwd = TempDir::new().expect("tempdir");
2014 let cwd_root = AbsolutePathBuf::from_absolute_path(cwd.path()).expect("absolute cwd");
2015 let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
2016 path: FileSystemPath::Special {
2017 value: FileSystemSpecialPath::CurrentWorkingDirectory,
2018 },
2019 access: FileSystemAccessMode::Write,
2020 }]);
2021
2022 let actual = policy
2023 .clone()
2024 .with_additional_writable_roots(cwd.path(), std::slice::from_ref(&cwd_root));
2025
2026 assert_eq!(actual, policy);
2027 }
2028
2029 #[test]
2030 fn with_additional_writable_roots_adds_new_root() {
2031 let temp_dir = TempDir::new().expect("tempdir");
2032 let cwd = temp_dir.path().join("workspace");
2033 let extra = AbsolutePathBuf::from_absolute_path(temp_dir.path().join("extra"))
2034 .expect("resolve extra root");
2035 let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
2036 path: FileSystemPath::Special {
2037 value: FileSystemSpecialPath::CurrentWorkingDirectory,
2038 },
2039 access: FileSystemAccessMode::Write,
2040 }]);
2041
2042 let actual = policy.with_additional_writable_roots(&cwd, std::slice::from_ref(&extra));
2043
2044 assert_eq!(
2045 actual,
2046 FileSystemSandboxPolicy::restricted(vec![
2047 FileSystemSandboxEntry {
2048 path: FileSystemPath::Special {
2049 value: FileSystemSpecialPath::CurrentWorkingDirectory,
2050 },
2051 access: FileSystemAccessMode::Write,
2052 },
2053 FileSystemSandboxEntry {
2054 path: FileSystemPath::Path { path: extra },
2055 access: FileSystemAccessMode::Write,
2056 },
2057 ])
2058 );
2059 }
2060
2061 #[test]
2062 fn file_system_access_mode_orders_by_conflict_precedence() {
2063 assert!(FileSystemAccessMode::Write > FileSystemAccessMode::Read);
2064 assert!(FileSystemAccessMode::None > FileSystemAccessMode::Write);
2065 }
2066}