Skip to main content

zerobox_protocol/
permissions.rs

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/// Access mode for a filesystem entry.
38///
39/// When two equally specific entries target the same path, we compare these by
40/// conflict precedence rather than by capability breadth: `none` beats
41/// `write`, and `write` beats `read`.
42#[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    /// Preserves unknown special-path tokens so older runtimes can ignore
89    /// them without rejecting config authored by a newer release.
90    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    /// Returns true when a restricted policy contains any entry that really
195    /// reduces a broader `:root = write` grant.
196    ///
197    /// Raw entry presence is not enough here: an equally specific `write`
198    /// entry for the same target wins under the normal precedence rules, so a
199    /// shadowed `read` entry must not downgrade the policy out of full-disk
200    /// write mode.
201    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    /// Returns true when a higher-priority `write` entry targets the same
222    /// location as `entry`, so `entry` cannot narrow effective write access.
223    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    /// Converts a legacy sandbox policy into an equivalent filesystem policy
253    /// for the provided cwd.
254    ///
255    /// Legacy `WorkspaceWrite` policies may list readable roots that live
256    /// under an already-writable root. Those paths were redundant in the
257    /// legacy model and should not become read-only carveouts when projected
258    /// into split filesystem policy.
259    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, /*protect_missing_dot_codex*/ 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                    /*protect_missing_dot_codex*/ 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    /// Returns true when filesystem reads are unrestricted.
303    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    /// Returns true when filesystem writes are unrestricted.
314    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    /// Returns true when platform-default readable roots should be included.
325    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    /// Returns the explicit readable roots resolved against the provided cwd.
427    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            /*normalize_effective_paths*/ true,
440        )
441    }
442
443    /// Returns the writable roots together with read-only carveouts resolved
444    /// against the provided cwd.
445    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            /*normalize_effective_paths*/ true,
461        )
462        .into_iter()
463        .map(|root| {
464            // Filesystem-root policies stay in their effective canonical form
465            // so root-wide aliases do not create duplicate top-level masks.
466            // Example: keep `/var/...` normalized under `/` instead of
467            // materializing both `/var/...` and `/private/var/...`.
468            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            // Narrower explicit non-write entries carve out broader writable roots.
482            // More specific write entries still remain writable because they appear
483            // as separate WritableRoot values and are checked independently.
484            // Preserve symlink path components that live under the writable root
485            // so downstream sandboxes can still mask the symlink inode itself.
486            // Example: if `<root>/.codex -> <root>/decoy`, bwrap must still see
487            // `<root>/.codex`, not only the resolved `<root>/decoy`.
488            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                        // Preserve the literal in-root path whenever the
496                        // carveout itself lives under this writable root, even
497                        // if following symlinks would resolve back to the root
498                        // or escape outside it. Downstream sandboxes need that
499                        // raw path so they can mask the symlink inode itself.
500                        // Examples:
501                        // - `<root>/linked-private -> <root>/decoy-private`
502                        // - `<root>/linked-private -> /tmp/outside-private`
503                        // - `<root>/alias-root -> <root>`
504                        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                                    root.join(suffix).ok()
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                // Preserve literal in-root protected paths like `.git` and
542                // `.codex` so downstream sandboxes can still detect and mask
543                // the symlink itself instead of only its resolved target.
544                read_only_subpaths: dedup_absolute_paths(
545                    read_only_subpaths,
546                    /*normalize_effective_paths*/ false,
547                ),
548            }
549        })
550        .collect()
551    }
552
553    /// Returns explicit unreadable roots resolved against the provided cwd.
554    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                // Restricted policies already deny reads outside explicit allow roots,
569                // so materializing the filesystem root here would erase narrower
570                // readable carveouts when downstream sandboxes apply deny masks last.
571                .filter(|entry| root.as_ref() != Some(&entry.path))
572                .map(|entry| entry.path.clone())
573                .collect(),
574            /*normalize_effective_paths*/ 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                            /*normalize_effective_paths*/ false,
710                        ),
711                    }
712                };
713
714                if workspace_root_writable {
715                    SandboxPolicy::WorkspaceWrite {
716                        writable_roots: dedup_absolute_paths(
717                            writable_roots,
718                            /*normalize_effective_paths*/ 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        AbsolutePathBuf::resolve_path_against_base(path, cwd).ok()
919    }
920}
921
922/// Returns true when two config paths refer to the same exact target before
923/// any prefix matching is applied.
924///
925/// This is intentionally narrower than full path resolution: it only answers
926/// the "can one entry shadow another at the same specificity?" question used
927/// by `has_write_narrowing_entries`.
928fn 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
943/// Compares special-path tokens that resolve to the same concrete target
944/// without needing a cwd.
945fn 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
981/// Matches cwd-independent special paths against absolute `Path` entries when
982/// they name the same location.
983///
984/// We intentionally only fold the special paths whose concrete meaning is
985/// stable without a cwd, such as `/` and `/tmp`.
986fn 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
997/// Orders resolved entries so the most specific path wins first, then applies
998/// the access tie-breaker from [`FileSystemAccessMode`].
999fn 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) => {
1030                    AbsolutePathBuf::resolve_path_against_base(subpath, cwd.as_path()).ok()
1031                }
1032                None => Some(cwd.clone()),
1033            }
1034        }
1035        FileSystemSpecialPath::Tmpdir => {
1036            let tmpdir = std::env::var_os("TMPDIR")?;
1037            if tmpdir.is_empty() {
1038                None
1039            } else {
1040                let tmpdir = AbsolutePathBuf::from_absolute_path(PathBuf::from(tmpdir)).ok()?;
1041                Some(tmpdir)
1042            }
1043        }
1044        FileSystemSpecialPath::SlashTmp => {
1045            #[allow(clippy::expect_used)]
1046            let slash_tmp = AbsolutePathBuf::from_absolute_path("/tmp").expect("/tmp is absolute");
1047            if !slash_tmp.as_path().is_dir() {
1048                return None;
1049            }
1050            Some(slash_tmp)
1051        }
1052    }
1053}
1054
1055fn dedup_absolute_paths(
1056    paths: Vec<AbsolutePathBuf>,
1057    normalize_effective_paths: bool,
1058) -> Vec<AbsolutePathBuf> {
1059    let mut deduped = Vec::with_capacity(paths.len());
1060    let mut seen = HashSet::new();
1061    for path in paths {
1062        let dedup_path = if normalize_effective_paths {
1063            normalize_effective_absolute_path(path)
1064        } else {
1065            path
1066        };
1067        if seen.insert(dedup_path.to_path_buf()) {
1068            deduped.push(dedup_path);
1069        }
1070    }
1071    deduped
1072}
1073
1074fn normalize_effective_absolute_path(path: AbsolutePathBuf) -> AbsolutePathBuf {
1075    let raw_path = path.to_path_buf();
1076    for ancestor in raw_path.ancestors() {
1077        let Ok(canonical_ancestor) = ancestor.canonicalize() else {
1078            continue;
1079        };
1080        let Ok(suffix) = raw_path.strip_prefix(ancestor) else {
1081            continue;
1082        };
1083        if let Ok(normalized_path) =
1084            AbsolutePathBuf::from_absolute_path(canonical_ancestor.join(suffix))
1085        {
1086            return normalized_path;
1087        }
1088    }
1089    path
1090}
1091
1092fn default_read_only_subpaths_for_writable_root(
1093    writable_root: &AbsolutePathBuf,
1094    _protect_missing_dot_codex: bool,
1095) -> Vec<AbsolutePathBuf> {
1096    let mut subpaths: Vec<AbsolutePathBuf> = Vec::new();
1097    #[allow(clippy::expect_used)]
1098    let top_level_git = writable_root
1099        .join(".git")
1100        .expect(".git is a valid relative path");
1101    // This applies to typical repos (directory .git), worktrees/submodules
1102    // (file .git with gitdir pointer), and bare repos when the gitdir is the
1103    // writable root itself.
1104    let top_level_git_is_file = top_level_git.as_path().is_file();
1105    let top_level_git_is_dir = top_level_git.as_path().is_dir();
1106    if top_level_git_is_dir || top_level_git_is_file {
1107        if top_level_git_is_file
1108            && is_git_pointer_file(&top_level_git)
1109            && let Some(gitdir) = resolve_gitdir_from_file(&top_level_git)
1110        {
1111            subpaths.push(gitdir);
1112        }
1113        subpaths.push(top_level_git);
1114    }
1115
1116    #[allow(clippy::expect_used)]
1117    let top_level_agents = writable_root.join(".agents").expect("valid relative path");
1118    if top_level_agents.as_path().is_dir() {
1119        subpaths.push(top_level_agents);
1120    }
1121
1122    // Protect .codex directory as read-only when it exists.
1123    #[allow(clippy::expect_used)]
1124    let top_level_codex = writable_root.join(".codex").expect("valid relative path");
1125    if top_level_codex.as_path().is_dir() {
1126        subpaths.push(top_level_codex);
1127    }
1128
1129    dedup_absolute_paths(subpaths, /*normalize_effective_paths*/ false)
1130}
1131
1132fn append_path_entry_if_missing(
1133    entries: &mut Vec<FileSystemSandboxEntry>,
1134    path: AbsolutePathBuf,
1135    access: FileSystemAccessMode,
1136) {
1137    if entries.iter().any(|entry| {
1138        entry.access == access
1139            && matches!(
1140                &entry.path,
1141                FileSystemPath::Path { path: existing } if existing == &path
1142            )
1143    }) {
1144        return;
1145    }
1146
1147    entries.push(FileSystemSandboxEntry {
1148        path: FileSystemPath::Path { path },
1149        access,
1150    });
1151}
1152
1153fn append_default_read_only_path_if_no_explicit_rule(
1154    entries: &mut Vec<FileSystemSandboxEntry>,
1155    path: AbsolutePathBuf,
1156) {
1157    if entries.iter().any(|entry| {
1158        matches!(
1159            &entry.path,
1160            FileSystemPath::Path { path: existing } if existing == &path
1161        )
1162    }) {
1163        return;
1164    }
1165
1166    append_path_entry_if_missing(entries, path, FileSystemAccessMode::Read);
1167}
1168
1169fn has_explicit_resolved_path_entry(
1170    entries: &[ResolvedFileSystemEntry],
1171    path: &AbsolutePathBuf,
1172) -> bool {
1173    entries.iter().any(|entry| &entry.path == path)
1174}
1175
1176fn is_git_pointer_file(path: &AbsolutePathBuf) -> bool {
1177    path.as_path().is_file() && path.as_path().file_name() == Some(OsStr::new(".git"))
1178}
1179
1180fn resolve_gitdir_from_file(dot_git: &AbsolutePathBuf) -> Option<AbsolutePathBuf> {
1181    let contents = match std::fs::read_to_string(dot_git.as_path()) {
1182        Ok(contents) => contents,
1183        Err(err) => {
1184            error!(
1185                "Failed to read {path} for gitdir pointer: {err}",
1186                path = dot_git.as_path().display()
1187            );
1188            return None;
1189        }
1190    };
1191
1192    let trimmed = contents.trim();
1193    let (_, gitdir_raw) = match trimmed.split_once(':') {
1194        Some(parts) => parts,
1195        None => {
1196            error!(
1197                "Expected {path} to contain a gitdir pointer, but it did not match `gitdir: <path>`.",
1198                path = dot_git.as_path().display()
1199            );
1200            return None;
1201        }
1202    };
1203    let gitdir_raw = gitdir_raw.trim();
1204    if gitdir_raw.is_empty() {
1205        error!(
1206            "Expected {path} to contain a gitdir pointer, but it was empty.",
1207            path = dot_git.as_path().display()
1208        );
1209        return None;
1210    }
1211    let base = match dot_git.as_path().parent() {
1212        Some(base) => base,
1213        None => {
1214            error!(
1215                "Unable to resolve parent directory for {path}.",
1216                path = dot_git.as_path().display()
1217            );
1218            return None;
1219        }
1220    };
1221    let gitdir_path = match AbsolutePathBuf::resolve_path_against_base(gitdir_raw, base) {
1222        Ok(path) => path,
1223        Err(err) => {
1224            error!(
1225                "Failed to resolve gitdir path {gitdir_raw} from {path}: {err}",
1226                path = dot_git.as_path().display()
1227            );
1228            return None;
1229        }
1230    };
1231    if !gitdir_path.as_path().exists() {
1232        error!(
1233            "Resolved gitdir path {path} does not exist.",
1234            path = gitdir_path.as_path().display()
1235        );
1236        return None;
1237    }
1238    Some(gitdir_path)
1239}
1240
1241#[cfg(test)]
1242mod tests {
1243    use super::*;
1244    use pretty_assertions::assert_eq;
1245    #[cfg(unix)]
1246    use std::fs;
1247    use std::path::Path;
1248    use tempfile::TempDir;
1249
1250    #[cfg(unix)]
1251    const SYMLINKED_TMPDIR_TEST_ENV: &str = "CODEX_PROTOCOL_TEST_SYMLINKED_TMPDIR";
1252
1253    #[cfg(unix)]
1254    fn symlink_dir(original: &Path, link: &Path) -> std::io::Result<()> {
1255        std::os::unix::fs::symlink(original, link)
1256    }
1257
1258    #[test]
1259    fn unknown_special_paths_are_ignored_by_legacy_bridge() -> std::io::Result<()> {
1260        let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
1261            path: FileSystemPath::Special {
1262                value: FileSystemSpecialPath::unknown(
1263                    ":future_special_path",
1264                    /*subpath*/ None,
1265                ),
1266            },
1267            access: FileSystemAccessMode::Write,
1268        }]);
1269
1270        let sandbox_policy = policy.to_legacy_sandbox_policy(
1271            NetworkSandboxPolicy::Restricted,
1272            Path::new("/tmp/workspace"),
1273        )?;
1274
1275        assert_eq!(
1276            sandbox_policy,
1277            SandboxPolicy::ReadOnly {
1278                access: ReadOnlyAccess::Restricted {
1279                    include_platform_defaults: false,
1280                    readable_roots: Vec::new(),
1281                },
1282                network_access: false,
1283            }
1284        );
1285        Ok(())
1286    }
1287
1288    #[cfg(unix)]
1289    #[test]
1290    fn writable_roots_proactively_protect_missing_dot_codex() {
1291        let cwd = TempDir::new().expect("tempdir");
1292        let expected_root = AbsolutePathBuf::from_absolute_path(
1293            cwd.path().canonicalize().expect("canonicalize cwd"),
1294        )
1295        .expect("absolute canonical root");
1296        let expected_dot_codex = expected_root.join(".codex").expect("expected .codex path");
1297
1298        let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
1299            path: FileSystemPath::Special {
1300                value: FileSystemSpecialPath::CurrentWorkingDirectory,
1301            },
1302            access: FileSystemAccessMode::Write,
1303        }]);
1304
1305        let writable_roots = policy.get_writable_roots_with_cwd(cwd.path());
1306        assert_eq!(writable_roots.len(), 1);
1307        assert_eq!(writable_roots[0].root, expected_root);
1308        assert!(
1309            !writable_roots[0]
1310                .read_only_subpaths
1311                .contains(&expected_dot_codex)
1312        );
1313    }
1314
1315    #[cfg(unix)]
1316    #[test]
1317    fn writable_roots_skip_default_dot_codex_when_explicit_user_rule_exists() {
1318        let cwd = TempDir::new().expect("tempdir");
1319        let expected_root = AbsolutePathBuf::from_absolute_path(
1320            cwd.path().canonicalize().expect("canonicalize cwd"),
1321        )
1322        .expect("absolute canonical root");
1323        let explicit_dot_codex = expected_root.join(".codex").expect("expected .codex path");
1324
1325        let policy = FileSystemSandboxPolicy::restricted(vec![
1326            FileSystemSandboxEntry {
1327                path: FileSystemPath::Special {
1328                    value: FileSystemSpecialPath::CurrentWorkingDirectory,
1329                },
1330                access: FileSystemAccessMode::Write,
1331            },
1332            FileSystemSandboxEntry {
1333                path: FileSystemPath::Path {
1334                    path: explicit_dot_codex.clone(),
1335                },
1336                access: FileSystemAccessMode::Write,
1337            },
1338        ]);
1339
1340        let writable_roots = policy.get_writable_roots_with_cwd(cwd.path());
1341        let workspace_root = writable_roots
1342            .iter()
1343            .find(|root| root.root == expected_root)
1344            .expect("workspace writable root");
1345        assert!(
1346            !workspace_root
1347                .read_only_subpaths
1348                .contains(&explicit_dot_codex),
1349            "explicit .codex rule should win over the default protected carveout"
1350        );
1351        assert!(
1352            policy.can_write_path_with_cwd(
1353                explicit_dot_codex
1354                    .join("config.toml")
1355                    .expect("config.toml")
1356                    .as_path(),
1357                cwd.path()
1358            )
1359        );
1360    }
1361
1362    #[test]
1363    fn legacy_workspace_write_projection_blocks_missing_dot_codex_writes() {
1364        let cwd = TempDir::new().expect("tempdir");
1365        let dot_codex_config = cwd.path().join(".codex").join("config.toml");
1366        let policy = SandboxPolicy::WorkspaceWrite {
1367            writable_roots: vec![],
1368            read_only_access: ReadOnlyAccess::Restricted {
1369                include_platform_defaults: false,
1370                readable_roots: vec![],
1371            },
1372            network_access: false,
1373            exclude_tmpdir_env_var: true,
1374            exclude_slash_tmp: true,
1375        };
1376
1377        let file_system_policy =
1378            FileSystemSandboxPolicy::from_legacy_sandbox_policy(&policy, cwd.path());
1379
1380        assert!(file_system_policy.can_write_path_with_cwd(&dot_codex_config, cwd.path()));
1381    }
1382
1383    #[test]
1384    fn legacy_workspace_write_projection_accepts_relative_cwd() {
1385        let relative_cwd = Path::new("workspace");
1386        let policy = SandboxPolicy::WorkspaceWrite {
1387            writable_roots: vec![],
1388            read_only_access: ReadOnlyAccess::Restricted {
1389                include_platform_defaults: false,
1390                readable_roots: vec![],
1391            },
1392            network_access: false,
1393            exclude_tmpdir_env_var: true,
1394            exclude_slash_tmp: true,
1395        };
1396
1397        let file_system_policy =
1398            FileSystemSandboxPolicy::from_legacy_sandbox_policy(&policy, relative_cwd);
1399
1400        assert_eq!(
1401            file_system_policy,
1402            FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
1403                path: FileSystemPath::Special {
1404                    value: FileSystemSpecialPath::CurrentWorkingDirectory,
1405                },
1406                access: FileSystemAccessMode::Write,
1407            }])
1408        );
1409    }
1410
1411    #[cfg(unix)]
1412    #[test]
1413    fn effective_runtime_roots_canonicalize_symlinked_paths() {
1414        let cwd = TempDir::new().expect("tempdir");
1415        let real_root = cwd.path().join("real");
1416        let link_root = cwd.path().join("link");
1417        let blocked = real_root.join("blocked");
1418        let codex_dir = real_root.join(".codex");
1419
1420        fs::create_dir_all(&blocked).expect("create blocked");
1421        fs::create_dir_all(&codex_dir).expect("create .codex");
1422        symlink_dir(&real_root, &link_root).expect("create symlinked root");
1423
1424        let link_root =
1425            AbsolutePathBuf::from_absolute_path(&link_root).expect("absolute symlinked root");
1426        let link_blocked = link_root.join("blocked").expect("symlinked blocked path");
1427        let expected_root = AbsolutePathBuf::from_absolute_path(
1428            real_root.canonicalize().expect("canonicalize real root"),
1429        )
1430        .expect("absolute canonical root");
1431        let expected_blocked = AbsolutePathBuf::from_absolute_path(
1432            blocked.canonicalize().expect("canonicalize blocked"),
1433        )
1434        .expect("absolute canonical blocked");
1435        let expected_codex = AbsolutePathBuf::from_absolute_path(
1436            codex_dir.canonicalize().expect("canonicalize .codex"),
1437        )
1438        .expect("absolute canonical .codex");
1439
1440        let policy = FileSystemSandboxPolicy::restricted(vec![
1441            FileSystemSandboxEntry {
1442                path: FileSystemPath::Path { path: link_root },
1443                access: FileSystemAccessMode::Write,
1444            },
1445            FileSystemSandboxEntry {
1446                path: FileSystemPath::Path { path: link_blocked },
1447                access: FileSystemAccessMode::None,
1448            },
1449        ]);
1450
1451        assert_eq!(
1452            policy.get_unreadable_roots_with_cwd(cwd.path()),
1453            vec![expected_blocked.clone()]
1454        );
1455
1456        let writable_roots = policy.get_writable_roots_with_cwd(cwd.path());
1457        assert_eq!(writable_roots.len(), 1);
1458        assert_eq!(writable_roots[0].root, expected_root);
1459        assert!(
1460            writable_roots[0]
1461                .read_only_subpaths
1462                .contains(&expected_blocked)
1463        );
1464        assert!(
1465            writable_roots[0]
1466                .read_only_subpaths
1467                .contains(&expected_codex)
1468        );
1469    }
1470
1471    #[cfg(unix)]
1472    #[test]
1473    fn current_working_directory_special_path_canonicalizes_symlinked_cwd() {
1474        let cwd = TempDir::new().expect("tempdir");
1475        let real_root = cwd.path().join("real");
1476        let link_root = cwd.path().join("link");
1477        let blocked = real_root.join("blocked");
1478        let agents_dir = real_root.join(".agents");
1479        let codex_dir = real_root.join(".codex");
1480
1481        fs::create_dir_all(&blocked).expect("create blocked");
1482        fs::create_dir_all(&agents_dir).expect("create .agents");
1483        fs::create_dir_all(&codex_dir).expect("create .codex");
1484        symlink_dir(&real_root, &link_root).expect("create symlinked cwd");
1485
1486        let link_blocked =
1487            AbsolutePathBuf::from_absolute_path(link_root.join("blocked")).expect("link blocked");
1488        let expected_root = AbsolutePathBuf::from_absolute_path(
1489            real_root.canonicalize().expect("canonicalize real root"),
1490        )
1491        .expect("absolute canonical root");
1492        let expected_blocked = AbsolutePathBuf::from_absolute_path(
1493            blocked.canonicalize().expect("canonicalize blocked"),
1494        )
1495        .expect("absolute canonical blocked");
1496        let expected_agents = AbsolutePathBuf::from_absolute_path(
1497            agents_dir.canonicalize().expect("canonicalize .agents"),
1498        )
1499        .expect("absolute canonical .agents");
1500        let expected_codex = AbsolutePathBuf::from_absolute_path(
1501            codex_dir.canonicalize().expect("canonicalize .codex"),
1502        )
1503        .expect("absolute canonical .codex");
1504
1505        let policy = FileSystemSandboxPolicy::restricted(vec![
1506            FileSystemSandboxEntry {
1507                path: FileSystemPath::Special {
1508                    value: FileSystemSpecialPath::Minimal,
1509                },
1510                access: FileSystemAccessMode::Read,
1511            },
1512            FileSystemSandboxEntry {
1513                path: FileSystemPath::Special {
1514                    value: FileSystemSpecialPath::CurrentWorkingDirectory,
1515                },
1516                access: FileSystemAccessMode::Write,
1517            },
1518            FileSystemSandboxEntry {
1519                path: FileSystemPath::Path { path: link_blocked },
1520                access: FileSystemAccessMode::None,
1521            },
1522        ]);
1523
1524        assert_eq!(
1525            policy.get_readable_roots_with_cwd(&link_root),
1526            vec![expected_root.clone()]
1527        );
1528        assert_eq!(
1529            policy.get_unreadable_roots_with_cwd(&link_root),
1530            vec![expected_blocked.clone()]
1531        );
1532
1533        let writable_roots = policy.get_writable_roots_with_cwd(&link_root);
1534        assert_eq!(writable_roots.len(), 1);
1535        assert_eq!(writable_roots[0].root, expected_root);
1536        assert!(
1537            writable_roots[0]
1538                .read_only_subpaths
1539                .contains(&expected_blocked)
1540        );
1541        assert!(
1542            writable_roots[0]
1543                .read_only_subpaths
1544                .contains(&expected_agents)
1545        );
1546        assert!(
1547            writable_roots[0]
1548                .read_only_subpaths
1549                .contains(&expected_codex)
1550        );
1551    }
1552
1553    #[cfg(unix)]
1554    #[test]
1555    fn writable_roots_preserve_symlinked_protected_subpaths() {
1556        let cwd = TempDir::new().expect("tempdir");
1557        let root = cwd.path().join("root");
1558        let decoy = root.join("decoy-codex");
1559        let dot_codex = root.join(".codex");
1560        fs::create_dir_all(&decoy).expect("create decoy");
1561        symlink_dir(&decoy, &dot_codex).expect("create .codex symlink");
1562
1563        let root = AbsolutePathBuf::from_absolute_path(&root).expect("absolute root");
1564        let expected_dot_codex = AbsolutePathBuf::from_absolute_path(
1565            root.as_path()
1566                .canonicalize()
1567                .expect("canonicalize root")
1568                .join(".codex"),
1569        )
1570        .expect("absolute .codex symlink");
1571        let unexpected_decoy =
1572            AbsolutePathBuf::from_absolute_path(decoy.canonicalize().expect("canonicalize decoy"))
1573                .expect("absolute canonical decoy");
1574
1575        let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
1576            path: FileSystemPath::Path { path: root },
1577            access: FileSystemAccessMode::Write,
1578        }]);
1579
1580        let writable_roots = policy.get_writable_roots_with_cwd(cwd.path());
1581        assert_eq!(writable_roots.len(), 1);
1582        assert_eq!(
1583            writable_roots[0].read_only_subpaths,
1584            vec![expected_dot_codex]
1585        );
1586        assert!(
1587            !writable_roots[0]
1588                .read_only_subpaths
1589                .contains(&unexpected_decoy)
1590        );
1591    }
1592
1593    #[cfg(unix)]
1594    #[test]
1595    fn writable_roots_preserve_explicit_symlinked_carveouts_under_symlinked_roots() {
1596        let cwd = TempDir::new().expect("tempdir");
1597        let real_root = cwd.path().join("real");
1598        let link_root = cwd.path().join("link");
1599        let decoy = real_root.join("decoy-private");
1600        let linked_private = real_root.join("linked-private");
1601        fs::create_dir_all(&decoy).expect("create decoy");
1602        symlink_dir(&real_root, &link_root).expect("create symlinked root");
1603        symlink_dir(&decoy, &linked_private).expect("create linked-private symlink");
1604
1605        let link_root =
1606            AbsolutePathBuf::from_absolute_path(&link_root).expect("absolute symlinked root");
1607        let link_private = link_root
1608            .join("linked-private")
1609            .expect("symlinked linked-private path");
1610        let expected_root = AbsolutePathBuf::from_absolute_path(
1611            real_root.canonicalize().expect("canonicalize real root"),
1612        )
1613        .expect("absolute canonical root");
1614        let expected_linked_private = expected_root
1615            .join("linked-private")
1616            .expect("expected linked-private path");
1617        let unexpected_decoy =
1618            AbsolutePathBuf::from_absolute_path(decoy.canonicalize().expect("canonicalize decoy"))
1619                .expect("absolute canonical decoy");
1620
1621        let policy = FileSystemSandboxPolicy::restricted(vec![
1622            FileSystemSandboxEntry {
1623                path: FileSystemPath::Path { path: link_root },
1624                access: FileSystemAccessMode::Write,
1625            },
1626            FileSystemSandboxEntry {
1627                path: FileSystemPath::Path { path: link_private },
1628                access: FileSystemAccessMode::None,
1629            },
1630        ]);
1631
1632        let writable_roots = policy.get_writable_roots_with_cwd(cwd.path());
1633        assert_eq!(writable_roots.len(), 1);
1634        assert_eq!(writable_roots[0].root, expected_root);
1635        assert_eq!(
1636            writable_roots[0].read_only_subpaths,
1637            vec![expected_linked_private]
1638        );
1639        assert!(
1640            !writable_roots[0]
1641                .read_only_subpaths
1642                .contains(&unexpected_decoy)
1643        );
1644    }
1645
1646    #[cfg(unix)]
1647    #[test]
1648    fn writable_roots_preserve_explicit_symlinked_carveouts_that_escape_root() {
1649        let cwd = TempDir::new().expect("tempdir");
1650        let real_root = cwd.path().join("real");
1651        let link_root = cwd.path().join("link");
1652        let decoy = cwd.path().join("outside-private");
1653        let linked_private = real_root.join("linked-private");
1654        fs::create_dir_all(&decoy).expect("create decoy");
1655        fs::create_dir_all(&real_root).expect("create real root");
1656        symlink_dir(&real_root, &link_root).expect("create symlinked root");
1657        symlink_dir(&decoy, &linked_private).expect("create linked-private symlink");
1658
1659        let link_root =
1660            AbsolutePathBuf::from_absolute_path(&link_root).expect("absolute symlinked root");
1661        let link_private = link_root
1662            .join("linked-private")
1663            .expect("symlinked linked-private path");
1664        let expected_root = AbsolutePathBuf::from_absolute_path(
1665            real_root.canonicalize().expect("canonicalize real root"),
1666        )
1667        .expect("absolute canonical root");
1668        let expected_linked_private = expected_root
1669            .join("linked-private")
1670            .expect("expected linked-private path");
1671        let unexpected_decoy =
1672            AbsolutePathBuf::from_absolute_path(decoy.canonicalize().expect("canonicalize decoy"))
1673                .expect("absolute canonical decoy");
1674
1675        let policy = FileSystemSandboxPolicy::restricted(vec![
1676            FileSystemSandboxEntry {
1677                path: FileSystemPath::Path { path: link_root },
1678                access: FileSystemAccessMode::Write,
1679            },
1680            FileSystemSandboxEntry {
1681                path: FileSystemPath::Path { path: link_private },
1682                access: FileSystemAccessMode::None,
1683            },
1684        ]);
1685
1686        let writable_roots = policy.get_writable_roots_with_cwd(cwd.path());
1687        assert_eq!(writable_roots.len(), 1);
1688        assert_eq!(writable_roots[0].root, expected_root);
1689        assert_eq!(
1690            writable_roots[0].read_only_subpaths,
1691            vec![expected_linked_private]
1692        );
1693        assert!(
1694            !writable_roots[0]
1695                .read_only_subpaths
1696                .contains(&unexpected_decoy)
1697        );
1698    }
1699
1700    #[cfg(unix)]
1701    #[test]
1702    fn writable_roots_preserve_explicit_symlinked_carveouts_that_alias_root() {
1703        let cwd = TempDir::new().expect("tempdir");
1704        let root = cwd.path().join("root");
1705        let alias = root.join("alias-root");
1706        fs::create_dir_all(&root).expect("create root");
1707        symlink_dir(&root, &alias).expect("create alias symlink");
1708
1709        let root = AbsolutePathBuf::from_absolute_path(&root).expect("absolute root");
1710        let alias = root.join("alias-root").expect("alias root path");
1711        let expected_root = AbsolutePathBuf::from_absolute_path(
1712            root.as_path().canonicalize().expect("canonicalize root"),
1713        )
1714        .expect("absolute canonical root");
1715        let expected_alias = expected_root
1716            .join("alias-root")
1717            .expect("expected alias path");
1718
1719        let policy = FileSystemSandboxPolicy::restricted(vec![
1720            FileSystemSandboxEntry {
1721                path: FileSystemPath::Path { path: root },
1722                access: FileSystemAccessMode::Write,
1723            },
1724            FileSystemSandboxEntry {
1725                path: FileSystemPath::Path { path: alias },
1726                access: FileSystemAccessMode::None,
1727            },
1728        ]);
1729
1730        let writable_roots = policy.get_writable_roots_with_cwd(cwd.path());
1731        assert_eq!(writable_roots.len(), 1);
1732        assert_eq!(writable_roots[0].root, expected_root);
1733        assert_eq!(writable_roots[0].read_only_subpaths, vec![expected_alias]);
1734    }
1735
1736    #[cfg(unix)]
1737    #[test]
1738    fn tmpdir_special_path_canonicalizes_symlinked_tmpdir() {
1739        if std::env::var_os(SYMLINKED_TMPDIR_TEST_ENV).is_none() {
1740            let output = std::process::Command::new(std::env::current_exe().expect("test binary"))
1741                .env(SYMLINKED_TMPDIR_TEST_ENV, "1")
1742                .arg("--exact")
1743                .arg("permissions::tests::tmpdir_special_path_canonicalizes_symlinked_tmpdir")
1744                .output()
1745                .expect("run tmpdir subprocess test");
1746
1747            assert!(
1748                output.status.success(),
1749                "tmpdir subprocess test failed\nstdout:\n{}\nstderr:\n{}",
1750                String::from_utf8_lossy(&output.stdout),
1751                String::from_utf8_lossy(&output.stderr)
1752            );
1753            return;
1754        }
1755
1756        let cwd = TempDir::new().expect("tempdir");
1757        let real_tmpdir = cwd.path().join("real-tmpdir");
1758        let link_tmpdir = cwd.path().join("link-tmpdir");
1759        let blocked = real_tmpdir.join("blocked");
1760        let codex_dir = real_tmpdir.join(".codex");
1761
1762        fs::create_dir_all(&blocked).expect("create blocked");
1763        fs::create_dir_all(&codex_dir).expect("create .codex");
1764        symlink_dir(&real_tmpdir, &link_tmpdir).expect("create symlinked tmpdir");
1765
1766        let link_blocked =
1767            AbsolutePathBuf::from_absolute_path(link_tmpdir.join("blocked")).expect("link blocked");
1768        let expected_root = AbsolutePathBuf::from_absolute_path(
1769            real_tmpdir
1770                .canonicalize()
1771                .expect("canonicalize real tmpdir"),
1772        )
1773        .expect("absolute canonical tmpdir");
1774        let expected_blocked = AbsolutePathBuf::from_absolute_path(
1775            blocked.canonicalize().expect("canonicalize blocked"),
1776        )
1777        .expect("absolute canonical blocked");
1778        let expected_codex = AbsolutePathBuf::from_absolute_path(
1779            codex_dir.canonicalize().expect("canonicalize .codex"),
1780        )
1781        .expect("absolute canonical .codex");
1782
1783        unsafe {
1784            std::env::set_var("TMPDIR", &link_tmpdir);
1785        }
1786
1787        let policy = FileSystemSandboxPolicy::restricted(vec![
1788            FileSystemSandboxEntry {
1789                path: FileSystemPath::Special {
1790                    value: FileSystemSpecialPath::Tmpdir,
1791                },
1792                access: FileSystemAccessMode::Write,
1793            },
1794            FileSystemSandboxEntry {
1795                path: FileSystemPath::Path { path: link_blocked },
1796                access: FileSystemAccessMode::None,
1797            },
1798        ]);
1799
1800        assert_eq!(
1801            policy.get_unreadable_roots_with_cwd(cwd.path()),
1802            vec![expected_blocked.clone()]
1803        );
1804
1805        let writable_roots = policy.get_writable_roots_with_cwd(cwd.path());
1806        assert_eq!(writable_roots.len(), 1);
1807        assert_eq!(writable_roots[0].root, expected_root);
1808        assert!(
1809            writable_roots[0]
1810                .read_only_subpaths
1811                .contains(&expected_blocked)
1812        );
1813        assert!(
1814            writable_roots[0]
1815                .read_only_subpaths
1816                .contains(&expected_codex)
1817        );
1818    }
1819
1820    #[test]
1821    fn resolve_access_with_cwd_uses_most_specific_entry() {
1822        let cwd = TempDir::new().expect("tempdir");
1823        let docs =
1824            AbsolutePathBuf::resolve_path_against_base("docs", cwd.path()).expect("resolve docs");
1825        let docs_private = AbsolutePathBuf::resolve_path_against_base("docs/private", cwd.path())
1826            .expect("resolve docs/private");
1827        let docs_private_public =
1828            AbsolutePathBuf::resolve_path_against_base("docs/private/public", cwd.path())
1829                .expect("resolve docs/private/public");
1830        let policy = FileSystemSandboxPolicy::restricted(vec![
1831            FileSystemSandboxEntry {
1832                path: FileSystemPath::Special {
1833                    value: FileSystemSpecialPath::CurrentWorkingDirectory,
1834                },
1835                access: FileSystemAccessMode::Write,
1836            },
1837            FileSystemSandboxEntry {
1838                path: FileSystemPath::Path { path: docs.clone() },
1839                access: FileSystemAccessMode::Read,
1840            },
1841            FileSystemSandboxEntry {
1842                path: FileSystemPath::Path {
1843                    path: docs_private.clone(),
1844                },
1845                access: FileSystemAccessMode::None,
1846            },
1847            FileSystemSandboxEntry {
1848                path: FileSystemPath::Path {
1849                    path: docs_private_public.clone(),
1850                },
1851                access: FileSystemAccessMode::Write,
1852            },
1853        ]);
1854
1855        assert_eq!(
1856            policy.resolve_access_with_cwd(cwd.path(), cwd.path()),
1857            FileSystemAccessMode::Write
1858        );
1859        assert_eq!(
1860            policy.resolve_access_with_cwd(docs.as_path(), cwd.path()),
1861            FileSystemAccessMode::Read
1862        );
1863        assert_eq!(
1864            policy.resolve_access_with_cwd(docs_private.as_path(), cwd.path()),
1865            FileSystemAccessMode::None
1866        );
1867        assert_eq!(
1868            policy.resolve_access_with_cwd(docs_private_public.as_path(), cwd.path()),
1869            FileSystemAccessMode::Write
1870        );
1871    }
1872
1873    #[test]
1874    fn split_only_nested_carveouts_need_direct_runtime_enforcement() {
1875        let cwd = TempDir::new().expect("tempdir");
1876        let docs =
1877            AbsolutePathBuf::resolve_path_against_base("docs", cwd.path()).expect("resolve docs");
1878        let policy = FileSystemSandboxPolicy::restricted(vec![
1879            FileSystemSandboxEntry {
1880                path: FileSystemPath::Special {
1881                    value: FileSystemSpecialPath::CurrentWorkingDirectory,
1882                },
1883                access: FileSystemAccessMode::Write,
1884            },
1885            FileSystemSandboxEntry {
1886                path: FileSystemPath::Path { path: docs },
1887                access: FileSystemAccessMode::Read,
1888            },
1889        ]);
1890
1891        assert!(
1892            policy.needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path(),)
1893        );
1894
1895        let legacy_workspace_write = FileSystemSandboxPolicy::from_legacy_sandbox_policy(
1896            &SandboxPolicy::new_workspace_write_policy(),
1897            cwd.path(),
1898        );
1899        assert!(
1900            !legacy_workspace_write
1901                .needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path(),)
1902        );
1903    }
1904
1905    #[test]
1906    fn root_write_with_read_only_child_is_not_full_disk_write() {
1907        let cwd = TempDir::new().expect("tempdir");
1908        let docs =
1909            AbsolutePathBuf::resolve_path_against_base("docs", cwd.path()).expect("resolve docs");
1910        let policy = FileSystemSandboxPolicy::restricted(vec![
1911            FileSystemSandboxEntry {
1912                path: FileSystemPath::Special {
1913                    value: FileSystemSpecialPath::Root,
1914                },
1915                access: FileSystemAccessMode::Write,
1916            },
1917            FileSystemSandboxEntry {
1918                path: FileSystemPath::Path { path: docs.clone() },
1919                access: FileSystemAccessMode::Read,
1920            },
1921        ]);
1922
1923        assert!(!policy.has_full_disk_write_access());
1924        assert_eq!(
1925            policy.resolve_access_with_cwd(docs.as_path(), cwd.path()),
1926            FileSystemAccessMode::Read
1927        );
1928        assert!(
1929            policy.needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path(),)
1930        );
1931    }
1932
1933    #[test]
1934    fn root_deny_does_not_materialize_as_unreadable_root() {
1935        let cwd = TempDir::new().expect("tempdir");
1936        let docs =
1937            AbsolutePathBuf::resolve_path_against_base("docs", cwd.path()).expect("resolve docs");
1938        let expected_docs = AbsolutePathBuf::from_absolute_path(
1939            cwd.path()
1940                .canonicalize()
1941                .expect("canonicalize cwd")
1942                .join("docs"),
1943        )
1944        .expect("canonical docs");
1945        let policy = FileSystemSandboxPolicy::restricted(vec![
1946            FileSystemSandboxEntry {
1947                path: FileSystemPath::Special {
1948                    value: FileSystemSpecialPath::Root,
1949                },
1950                access: FileSystemAccessMode::None,
1951            },
1952            FileSystemSandboxEntry {
1953                path: FileSystemPath::Path { path: docs.clone() },
1954                access: FileSystemAccessMode::Read,
1955            },
1956        ]);
1957
1958        assert_eq!(
1959            policy.resolve_access_with_cwd(docs.as_path(), cwd.path()),
1960            FileSystemAccessMode::Read
1961        );
1962        assert_eq!(
1963            policy.get_readable_roots_with_cwd(cwd.path()),
1964            vec![expected_docs]
1965        );
1966        assert!(policy.get_unreadable_roots_with_cwd(cwd.path()).is_empty());
1967    }
1968
1969    #[test]
1970    fn duplicate_root_deny_prevents_full_disk_write_access() {
1971        let cwd = TempDir::new().expect("tempdir");
1972        let root = AbsolutePathBuf::from_absolute_path(cwd.path())
1973            .map(|cwd| absolute_root_path_for_cwd(&cwd))
1974            .expect("resolve filesystem root");
1975        let policy = FileSystemSandboxPolicy::restricted(vec![
1976            FileSystemSandboxEntry {
1977                path: FileSystemPath::Special {
1978                    value: FileSystemSpecialPath::Root,
1979                },
1980                access: FileSystemAccessMode::Write,
1981            },
1982            FileSystemSandboxEntry {
1983                path: FileSystemPath::Special {
1984                    value: FileSystemSpecialPath::Root,
1985                },
1986                access: FileSystemAccessMode::None,
1987            },
1988        ]);
1989
1990        assert!(!policy.has_full_disk_write_access());
1991        assert_eq!(
1992            policy.resolve_access_with_cwd(root.as_path(), cwd.path()),
1993            FileSystemAccessMode::None
1994        );
1995    }
1996
1997    #[test]
1998    fn same_specificity_write_override_keeps_full_disk_write_access() {
1999        let cwd = TempDir::new().expect("tempdir");
2000        let docs =
2001            AbsolutePathBuf::resolve_path_against_base("docs", cwd.path()).expect("resolve docs");
2002        let policy = FileSystemSandboxPolicy::restricted(vec![
2003            FileSystemSandboxEntry {
2004                path: FileSystemPath::Special {
2005                    value: FileSystemSpecialPath::Root,
2006                },
2007                access: FileSystemAccessMode::Write,
2008            },
2009            FileSystemSandboxEntry {
2010                path: FileSystemPath::Path { path: docs.clone() },
2011                access: FileSystemAccessMode::Read,
2012            },
2013            FileSystemSandboxEntry {
2014                path: FileSystemPath::Path { path: docs.clone() },
2015                access: FileSystemAccessMode::Write,
2016            },
2017        ]);
2018
2019        assert!(policy.has_full_disk_write_access());
2020        assert_eq!(
2021            policy.resolve_access_with_cwd(docs.as_path(), cwd.path()),
2022            FileSystemAccessMode::Write
2023        );
2024    }
2025
2026    #[test]
2027    fn with_additional_readable_roots_skips_existing_effective_access() {
2028        let cwd = TempDir::new().expect("tempdir");
2029        let cwd_root = AbsolutePathBuf::from_absolute_path(cwd.path()).expect("absolute cwd");
2030        let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
2031            path: FileSystemPath::Special {
2032                value: FileSystemSpecialPath::CurrentWorkingDirectory,
2033            },
2034            access: FileSystemAccessMode::Read,
2035        }]);
2036
2037        let actual = policy
2038            .clone()
2039            .with_additional_readable_roots(cwd.path(), std::slice::from_ref(&cwd_root));
2040
2041        assert_eq!(actual, policy);
2042    }
2043
2044    #[test]
2045    fn with_additional_writable_roots_skips_existing_effective_access() {
2046        let cwd = TempDir::new().expect("tempdir");
2047        let cwd_root = AbsolutePathBuf::from_absolute_path(cwd.path()).expect("absolute cwd");
2048        let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
2049            path: FileSystemPath::Special {
2050                value: FileSystemSpecialPath::CurrentWorkingDirectory,
2051            },
2052            access: FileSystemAccessMode::Write,
2053        }]);
2054
2055        let actual = policy
2056            .clone()
2057            .with_additional_writable_roots(cwd.path(), std::slice::from_ref(&cwd_root));
2058
2059        assert_eq!(actual, policy);
2060    }
2061
2062    #[test]
2063    fn with_additional_writable_roots_adds_new_root() {
2064        let temp_dir = TempDir::new().expect("tempdir");
2065        let cwd = temp_dir.path().join("workspace");
2066        let extra = AbsolutePathBuf::from_absolute_path(temp_dir.path().join("extra"))
2067            .expect("resolve extra root");
2068        let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
2069            path: FileSystemPath::Special {
2070                value: FileSystemSpecialPath::CurrentWorkingDirectory,
2071            },
2072            access: FileSystemAccessMode::Write,
2073        }]);
2074
2075        let actual = policy.with_additional_writable_roots(&cwd, std::slice::from_ref(&extra));
2076
2077        assert_eq!(
2078            actual,
2079            FileSystemSandboxPolicy::restricted(vec![
2080                FileSystemSandboxEntry {
2081                    path: FileSystemPath::Special {
2082                        value: FileSystemSpecialPath::CurrentWorkingDirectory,
2083                    },
2084                    access: FileSystemAccessMode::Write,
2085                },
2086                FileSystemSandboxEntry {
2087                    path: FileSystemPath::Path { path: extra },
2088                    access: FileSystemAccessMode::Write,
2089                },
2090            ])
2091        );
2092    }
2093
2094    #[test]
2095    fn file_system_access_mode_orders_by_conflict_precedence() {
2096        assert!(FileSystemAccessMode::Write > FileSystemAccessMode::Read);
2097        assert!(FileSystemAccessMode::None > FileSystemAccessMode::Write);
2098    }
2099}