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                                    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                // 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        Some(AbsolutePathBuf::resolve_path_against_base(path, cwd))
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) => 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    // This applies to typical repos (directory .git), worktrees/submodules
1100    // (file .git with gitdir pointer), and bare repos when the gitdir is the
1101    // writable root itself.
1102    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    // Protect .codex directory as read-only when it exists.
1120    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, /*normalize_effective_paths*/ 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                    /*subpath*/ 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}