Skip to main content

codex_protocol/
permissions.rs

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