Skip to main content

zerobox_protocol/
permissions.rs

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