Skip to main content

everruns_core/
workspace_policy.rs

1//! Backend-independent policy for session workspace access.
2//!
3//! A policy speaks only in the model-facing `/workspace` namespace. Concrete
4//! providers remain responsible for mapping that namespace to storage and, for
5//! host filesystems, for preventing traversal and symlink escapes during I/O.
6
7use std::fmt;
8
9const SENSITIVE_COMPONENTS: &[&str] = &[
10    ".aws",
11    ".azure",
12    ".docker",
13    ".git",
14    ".git-credentials",
15    ".gnupg",
16    ".kube",
17    ".netrc",
18    ".npmrc",
19    ".pypirc",
20    ".ssh",
21    "credentials",
22    "credentials.json",
23    "gcloud",
24];
25
26const DEFAULT_WRITE_DENY_COMPONENTS: &[&str] = &[
27    "node_modules",
28    "target",
29    "dist",
30    "build",
31    ".next",
32    ".venv",
33    "venv",
34    ".tox",
35    ".gradle",
36];
37
38/// A validated, composable workspace access policy.
39///
40/// [`Default`] is intentionally read-only: ordinary non-hidden files are
41/// readable throughout `/workspace`, while writes, hidden paths (except the
42/// framework-managed `.agents` tree), and common credential locations are
43/// denied. Use [`WorkspacePolicy::builder`] for
44/// narrower scopes or [`WorkspacePolicy::read_write`] for an explicit opt-in
45/// to ordinary writes.
46///
47/// Denies always win over allows. Composed policies are restrictive: an
48/// operation must be allowed by every layer, so adding a policy can never
49/// broaden access.
50///
51/// Protected path names are defense in depth, not content-based secret
52/// detection. Keep credentials outside the workspace and add application
53/// denies for any project-specific secret locations.
54#[derive(Clone, Debug, PartialEq, Eq)]
55pub struct WorkspacePolicy {
56    layers: Vec<PolicyLayer>,
57}
58
59/// Builder for a custom [`WorkspacePolicy`].
60///
61/// A custom builder starts with no readable or writable paths. Hidden and
62/// sensitive paths remain protected unless explicitly opted in.
63#[derive(Clone, Debug, Default)]
64pub struct WorkspacePolicyBuilder {
65    read: Vec<String>,
66    write: Vec<String>,
67    deny_read: Vec<String>,
68    deny_write: Vec<String>,
69    deny_write_components: Vec<String>,
70    hidden: Vec<String>,
71    sensitive: Vec<String>,
72    recursive_delete: bool,
73}
74
75/// Invalid policy configuration or a denied workspace operation.
76#[derive(Clone, Debug, PartialEq, Eq)]
77pub struct WorkspacePolicyError {
78    message: String,
79}
80
81impl WorkspacePolicyError {
82    fn new(message: impl Into<String>) -> Self {
83        Self {
84            message: message.into(),
85        }
86    }
87}
88
89impl fmt::Display for WorkspacePolicyError {
90    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91        f.write_str(&self.message)
92    }
93}
94
95impl std::error::Error for WorkspacePolicyError {}
96
97#[derive(Clone, Debug, PartialEq, Eq)]
98struct PolicyLayer {
99    read: Vec<PolicyPath>,
100    write: Vec<PolicyPath>,
101    deny_read: Vec<PolicyPath>,
102    deny_write: Vec<PolicyPath>,
103    deny_write_components: Vec<String>,
104    hidden: Vec<PolicyPath>,
105    sensitive: Vec<PolicyPath>,
106    recursive_delete: bool,
107}
108
109#[derive(Clone, Debug, PartialEq, Eq)]
110struct PolicyPath(Vec<String>);
111
112impl WorkspacePolicy {
113    /// Return the secure default policy.
114    ///
115    /// Ordinary files are readable, writes are denied, and hidden or common
116    /// sensitive paths are inaccessible. The framework-managed `.agents` tree
117    /// remains readable so configured skills and instructions continue to work.
118    pub fn read_only() -> Self {
119        Self {
120            layers: vec![PolicyLayer {
121                read: vec![PolicyPath::root()],
122                write: Vec::new(),
123                deny_read: Vec::new(),
124                deny_write: Vec::new(),
125                deny_write_components: Vec::new(),
126                hidden: vec![PolicyPath(vec![".agents".to_string()])],
127                sensitive: Vec::new(),
128                recursive_delete: false,
129            }],
130        }
131    }
132
133    /// Allow reads and writes throughout the ordinary workspace.
134    ///
135    /// This is an explicit opt-in. Hidden and common sensitive paths remain
136    /// denied, common dependency/build directory names remain non-writable at
137    /// every depth, and recursive directory deletion remains disabled. Build a
138    /// custom policy to choose different component restrictions.
139    pub fn read_write() -> Self {
140        Self {
141            layers: vec![PolicyLayer {
142                read: vec![PolicyPath::root()],
143                write: vec![PolicyPath::root()],
144                deny_read: Vec::new(),
145                deny_write: vec![PolicyPath(vec![".agents".to_string()])],
146                deny_write_components: DEFAULT_WRITE_DENY_COMPONENTS
147                    .iter()
148                    .map(|component| (*component).to_string())
149                    .collect(),
150                hidden: vec![PolicyPath(vec![".agents".to_string()])],
151                sensitive: Vec::new(),
152                recursive_delete: false,
153            }],
154        }
155    }
156
157    /// Start a custom, deny-by-default policy.
158    pub fn builder() -> WorkspacePolicyBuilder {
159        WorkspacePolicyBuilder::default()
160    }
161
162    /// Compose an additional restriction with this policy.
163    ///
164    /// Both policies must allow an operation. This makes composition safe for
165    /// libraries that add constraints to an application-supplied policy.
166    pub fn compose(mut self, additional: Self) -> Self {
167        self.layers.extend(additional.layers);
168        self
169    }
170
171    /// Whether `path` may be read.
172    ///
173    /// Invalid paths, including traversal attempts, are denied.
174    pub fn permits_read(&self, path: &str) -> bool {
175        PolicyPath::parse(path)
176            .is_ok_and(|path| self.layers.iter().all(|layer| layer.permits_read(&path)))
177    }
178
179    /// Whether `path` may be written, created, or deleted.
180    ///
181    /// Invalid paths, including traversal attempts, are denied.
182    pub fn permits_write(&self, path: &str) -> bool {
183        PolicyPath::parse(path)
184            .is_ok_and(|path| self.layers.iter().all(|layer| layer.permits_write(&path)))
185    }
186
187    /// Whether a directory may be traversed to reach a readable descendant.
188    ///
189    /// This is useful to filesystem providers implementing filtered directory
190    /// listings for a narrowly scoped policy.
191    pub fn permits_read_traversal(&self, path: &str) -> bool {
192        PolicyPath::parse(path).is_ok_and(|path| {
193            self.layers
194                .iter()
195                .all(|layer| layer.permits_read_traversal(&path))
196        })
197    }
198
199    /// Validate workspace path syntax without making an access decision.
200    ///
201    /// Filesystem providers can use this before resolving provider-specific
202    /// aliases, then authorize the resolved canonical path with
203    /// [`check_read`](Self::check_read) or [`check_write`](Self::check_write).
204    pub fn validate_path(path: &str) -> Result<(), WorkspacePolicyError> {
205        PolicyPath::parse(path).map(|_| ())
206    }
207
208    /// Validate a read and return a diagnostic suitable for a tool error.
209    pub fn check_read(&self, path: &str) -> Result<(), WorkspacePolicyError> {
210        let normalized = PolicyPath::parse(path)?;
211        if self
212            .layers
213            .iter()
214            .all(|layer| layer.permits_read(&normalized))
215        {
216            Ok(())
217        } else {
218            Err(WorkspacePolicyError::new(format!(
219                "workspace policy denied read of `{}`",
220                normalized.display()
221            )))
222        }
223    }
224
225    /// Validate a write, create, or delete and return a tool-safe diagnostic.
226    pub fn check_write(&self, path: &str) -> Result<(), WorkspacePolicyError> {
227        let normalized = PolicyPath::parse(path)?;
228        if self
229            .layers
230            .iter()
231            .all(|layer| layer.permits_write(&normalized))
232        {
233            Ok(())
234        } else {
235            Err(WorkspacePolicyError::new(format!(
236                "workspace policy denied write to `{}`",
237                normalized.display()
238            )))
239        }
240    }
241
242    /// Whether recursive directory deletion is explicitly allowed by every
243    /// composed policy layer.
244    pub fn permits_recursive_delete(&self) -> bool {
245        self.layers.iter().all(|layer| layer.recursive_delete)
246    }
247}
248
249impl Default for WorkspacePolicy {
250    fn default() -> Self {
251        Self::read_only()
252    }
253}
254
255impl WorkspacePolicyBuilder {
256    /// Add a readable path scope.
257    ///
258    /// Paths may be relative to `/workspace`, workspace-absolute
259    /// (`/workspace/src`), or session-absolute (`/src`).
260    pub fn allow_read(mut self, path: impl Into<String>) -> Self {
261        self.read.push(path.into());
262        self
263    }
264
265    /// Add a writable path scope.
266    pub fn allow_write(mut self, path: impl Into<String>) -> Self {
267        self.write.push(path.into());
268        self
269    }
270
271    /// Deny reads at and below a path, even when an allow scope matches.
272    pub fn deny_read(mut self, path: impl Into<String>) -> Self {
273        self.deny_read.push(path.into());
274        self
275    }
276
277    /// Deny writes at and below a path, even when an allow scope matches.
278    pub fn deny_write(mut self, path: impl Into<String>) -> Self {
279        self.deny_write.push(path.into());
280        self
281    }
282
283    /// Deny writes when any path segment has exactly this name.
284    ///
285    /// Unlike [`deny_write`](Self::deny_write), this applies at every depth.
286    /// It is useful for dependency or build directories such as
287    /// `node_modules` and `target`.
288    pub fn deny_write_component(mut self, component: impl Into<String>) -> Self {
289        self.deny_write_components.push(component.into());
290        self
291    }
292
293    /// Permit hidden path components at and below a specific scope.
294    ///
295    /// This does not override the sensitive-path protection. For example,
296    /// allowing `.github` is safe without also exposing `.git` or `.env`.
297    pub fn allow_hidden(mut self, path: impl Into<String>) -> Self {
298        self.hidden.push(path.into());
299        self
300    }
301
302    /// Permit a common sensitive path at and below a specific scope.
303    ///
304    /// This is the strongest filesystem-policy opt-in and also permits hidden
305    /// components within that scope. Prefer the narrowest possible path.
306    pub fn allow_sensitive(mut self, path: impl Into<String>) -> Self {
307        self.sensitive.push(path.into());
308        self
309    }
310
311    /// Explicitly permit recursive directory deletion.
312    ///
313    /// The default is `false`, because deleting an allowed parent could also
314    /// remove denied or sensitive descendants that a lexical path check cannot
315    /// see.
316    pub fn allow_recursive_delete(mut self, allow: bool) -> Self {
317        self.recursive_delete = allow;
318        self
319    }
320
321    /// Validate every configured scope and build the policy.
322    pub fn build(self) -> Result<WorkspacePolicy, WorkspacePolicyError> {
323        Ok(WorkspacePolicy {
324            layers: vec![PolicyLayer {
325                read: parse_paths("read allow", self.read)?,
326                write: parse_paths("write allow", self.write)?,
327                deny_read: parse_paths("read deny", self.deny_read)?,
328                deny_write: parse_paths("write deny", self.deny_write)?,
329                deny_write_components: parse_components(
330                    "write deny component",
331                    self.deny_write_components,
332                )?,
333                hidden: parse_paths("hidden allow", self.hidden)?,
334                sensitive: parse_paths("sensitive allow", self.sensitive)?,
335                recursive_delete: self.recursive_delete,
336            }],
337        })
338    }
339}
340
341impl PolicyLayer {
342    fn permits_read(&self, path: &PolicyPath) -> bool {
343        self.read.iter().any(|scope| scope.contains(path))
344            && !self
345                .deny_read
346                .iter()
347                .any(|scope| scope.contains_ignoring_ascii_case(path))
348            && self.permits_protected_components(path)
349    }
350
351    fn permits_read_traversal(&self, path: &PolicyPath) -> bool {
352        (self
353            .read
354            .iter()
355            .any(|scope| scope.contains(path) || path.contains(scope)))
356            && !self
357                .deny_read
358                .iter()
359                .any(|scope| scope.contains_ignoring_ascii_case(path))
360            && self.permits_protected_traversal(path)
361    }
362
363    fn permits_write(&self, path: &PolicyPath) -> bool {
364        self.write.iter().any(|scope| scope.contains(path))
365            && !self
366                .deny_write
367                .iter()
368                .any(|scope| scope.contains_ignoring_ascii_case(path))
369            && !path.has_any_component(&self.deny_write_components)
370            && self.permits_protected_components(path)
371    }
372
373    fn permits_protected_components(&self, path: &PolicyPath) -> bool {
374        if self.sensitive.iter().any(|scope| scope.contains(path)) {
375            return true;
376        }
377        if path.has_sensitive_component() {
378            return false;
379        }
380        !path.has_hidden_component() || self.hidden.iter().any(|scope| scope.contains(path))
381    }
382
383    fn permits_protected_traversal(&self, path: &PolicyPath) -> bool {
384        if path.has_sensitive_component() {
385            return self
386                .sensitive
387                .iter()
388                .any(|scope| scope.contains(path) || path.contains(scope));
389        }
390        !path.has_hidden_component()
391            || self
392                .hidden
393                .iter()
394                .any(|scope| scope.contains(path) || path.contains(scope))
395    }
396}
397
398impl PolicyPath {
399    fn root() -> Self {
400        Self(Vec::new())
401    }
402
403    fn parse(input: &str) -> Result<Self, WorkspacePolicyError> {
404        let trimmed = input.trim();
405        if trimmed.contains('\0') {
406            return Err(WorkspacePolicyError::new(format!(
407                "workspace path contains a NUL byte: {input:?}"
408            )));
409        }
410        if trimmed.contains('\\') {
411            return Err(WorkspacePolicyError::new(format!(
412                "workspace paths must use forward slashes: {input:?}"
413            )));
414        }
415
416        let canonical = crate::session_path::to_session_path(trimmed);
417        let without_alias = canonical.trim_start_matches('/');
418
419        let mut components = Vec::new();
420        for component in without_alias.split('/') {
421            match component {
422                "" | "." => {}
423                ".." => {
424                    return Err(WorkspacePolicyError::new(format!(
425                        "workspace path traversal is not allowed: {input:?}"
426                    )));
427                }
428                value => components.push(value.to_string()),
429            }
430        }
431        Ok(Self(components))
432    }
433
434    fn contains(&self, candidate: &Self) -> bool {
435        // Allows stay case-exact so a scope cannot cover a distinct Unix path.
436        candidate.0.starts_with(&self.0)
437    }
438
439    fn contains_ignoring_ascii_case(&self, candidate: &Self) -> bool {
440        // Denies fail closed for providers where case variants alias one object.
441        self.0.len() <= candidate.0.len()
442            && self
443                .0
444                .iter()
445                .zip(&candidate.0)
446                .all(|(scope, component)| scope.eq_ignore_ascii_case(component))
447    }
448
449    fn has_hidden_component(&self) -> bool {
450        self.0.iter().any(|component| component.starts_with('.'))
451    }
452
453    fn has_any_component(&self, denied: &[String]) -> bool {
454        self.0.iter().any(|component| {
455            denied
456                .iter()
457                .any(|denied| denied.eq_ignore_ascii_case(component))
458        })
459    }
460
461    fn has_sensitive_component(&self) -> bool {
462        self.0.iter().any(|component| {
463            let component = component.to_ascii_lowercase();
464            component == ".env"
465                || component.starts_with(".env.")
466                || SENSITIVE_COMPONENTS.contains(&component.as_str())
467        })
468    }
469
470    fn display(&self) -> String {
471        if self.0.is_empty() {
472            "/workspace".to_string()
473        } else {
474            format!("/workspace/{}", self.0.join("/"))
475        }
476    }
477}
478
479fn parse_paths(kind: &str, paths: Vec<String>) -> Result<Vec<PolicyPath>, WorkspacePolicyError> {
480    paths
481        .into_iter()
482        .map(|path| {
483            if path.trim().is_empty() {
484                return Err(WorkspacePolicyError::new(format!(
485                    "invalid {kind} scope {path:?}: path must not be empty"
486                )));
487            }
488            PolicyPath::parse(&path).map_err(|error| {
489                WorkspacePolicyError::new(format!("invalid {kind} scope {path:?}: {error}"))
490            })
491        })
492        .collect()
493}
494
495fn parse_components(
496    kind: &str,
497    components: Vec<String>,
498) -> Result<Vec<String>, WorkspacePolicyError> {
499    components
500        .into_iter()
501        .map(|component| {
502            let trimmed = component.trim();
503            if trimmed.is_empty()
504                || matches!(trimmed, "." | "..")
505                || trimmed.contains(['/', '\\', '\0'])
506            {
507                return Err(WorkspacePolicyError::new(format!(
508                    "invalid {kind} {component:?}: expected one path component"
509                )));
510            }
511            Ok(trimmed.to_ascii_lowercase())
512        })
513        .collect()
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519
520    #[test]
521    fn default_is_read_only_and_hides_sensitive_paths() {
522        let policy = WorkspacePolicy::default();
523        assert!(policy.permits_read("/workspace/src/lib.rs"));
524        assert!(!policy.permits_write("/workspace/src/lib.rs"));
525        assert!(!policy.permits_read("/workspace/.env"));
526        assert!(!policy.permits_read("/workspace/.git/config"));
527        assert!(policy.permits_read("/workspace/.agents/skills/example/SKILL.md"));
528        assert!(!policy.permits_recursive_delete());
529    }
530
531    #[test]
532    fn read_write_keeps_framework_managed_content_read_only() {
533        let policy = WorkspacePolicy::read_write();
534
535        assert!(policy.permits_read("/.agents/skills/example/SKILL.md"));
536        assert!(!policy.permits_write("/.agents/skills/example/SKILL.md"));
537        assert!(policy.permits_write("/generated/report.md"));
538        assert!(!policy.permits_write("/crates/example/target/output"));
539        assert!(!policy.permits_write("/web/node_modules/package/index.js"));
540    }
541
542    #[test]
543    fn custom_component_write_deny_applies_at_every_depth() {
544        let policy = WorkspacePolicy::builder()
545            .allow_write("/")
546            .deny_write_component("vendor")
547            .build()
548            .unwrap();
549
550        assert!(policy.permits_write("/src/generated.rs"));
551        assert!(policy.permits_write("/src/vendorish/generated.rs"));
552        assert!(!policy.permits_write("/src/vendor/generated.rs"));
553        assert!(!policy.permits_write("/VENDOR/generated.rs"));
554    }
555
556    #[test]
557    fn deny_wins_over_allow() {
558        let policy = WorkspacePolicy::builder()
559            .allow_read("/")
560            .allow_write("/workspace/output")
561            .deny_read("private")
562            .deny_write("output/locked")
563            .build()
564            .unwrap();
565
566        assert!(policy.permits_read("notes.txt"));
567        assert!(!policy.permits_read("private/notes.txt"));
568        assert!(policy.permits_write("output/result.txt"));
569        assert!(!policy.permits_write("output/locked/result.txt"));
570    }
571
572    #[test]
573    fn scoped_policy_allows_parent_traversal_but_not_parent_reads() {
574        let policy = WorkspacePolicy::builder()
575            .allow_read("src/generated")
576            .build()
577            .unwrap();
578
579        assert!(policy.permits_read_traversal("/workspace"));
580        assert!(policy.permits_read_traversal("/workspace/src"));
581        assert!(!policy.permits_read("/workspace/src"));
582        assert!(policy.permits_read("/workspace/src/generated/file.rs"));
583        assert!(!policy.permits_read("/workspace/tests/test.rs"));
584        assert!(!policy.permits_read_traversal("/workspace/tests"));
585    }
586
587    #[test]
588    fn hidden_and_sensitive_paths_require_separate_explicit_opt_ins() {
589        let hidden = WorkspacePolicy::builder()
590            .allow_read("/")
591            .allow_hidden(".github")
592            .allow_hidden(".git")
593            .build()
594            .unwrap();
595        assert!(hidden.permits_read(".github/workflows/ci.yml"));
596        assert!(!hidden.permits_read(".git/config"));
597
598        let sensitive = WorkspacePolicy::builder()
599            .allow_read("/")
600            .allow_sensitive(".env.example")
601            .build()
602            .unwrap();
603        assert!(sensitive.permits_read(".env.example"));
604        assert!(!sensitive.permits_read(".env"));
605    }
606
607    #[test]
608    fn narrow_protected_scope_allows_only_ancestor_traversal() {
609        let policy = WorkspacePolicy::builder()
610            .allow_read(".ssh/id_ed25519")
611            .allow_sensitive(".ssh/id_ed25519")
612            .build()
613            .unwrap();
614
615        assert!(policy.permits_read_traversal("/.ssh"));
616        assert!(!policy.permits_read("/.ssh"));
617        assert!(policy.permits_read("/.ssh/id_ed25519"));
618        assert!(!policy.permits_read("/.ssh/config"));
619        assert!(!policy.permits_read_traversal("/.ssh/config"));
620    }
621
622    #[test]
623    fn composition_can_only_restrict() {
624        let application = WorkspacePolicy::builder()
625            .allow_read("/")
626            .allow_write("/")
627            .deny_read("src/private")
628            .deny_write("src/generated/locked")
629            .allow_recursive_delete(true)
630            .build()
631            .unwrap();
632        let library = WorkspacePolicy::builder()
633            .allow_read("src")
634            .allow_write("src/generated")
635            .build()
636            .unwrap();
637        for policy in [
638            application.clone().compose(library.clone()),
639            library.compose(application),
640        ] {
641            assert!(policy.permits_read("src/lib.rs"));
642            assert!(!policy.permits_read("Cargo.toml"));
643            assert!(!policy.permits_read("src/private/secret.rs"));
644            assert!(policy.permits_write("src/generated/mod.rs"));
645            assert!(!policy.permits_write("src/lib.rs"));
646            assert!(!policy.permits_write("src/generated/locked/mod.rs"));
647            assert!(!policy.permits_recursive_delete());
648        }
649        let recursive = WorkspacePolicy::builder()
650            .allow_recursive_delete(true)
651            .build()
652            .unwrap();
653        assert!(
654            recursive
655                .clone()
656                .compose(recursive)
657                .permits_recursive_delete()
658        );
659    }
660
661    #[test]
662    fn traversal_nul_and_platform_separators_fail_closed() {
663        let policy = WorkspacePolicy::read_write();
664        assert!(policy.permits_read("src/ordinary.txt"));
665        assert!(policy.permits_write("src/ordinary.txt"));
666        assert!(policy.check_read("src/ordinary.txt").is_ok());
667        assert!(policy.check_write("src/ordinary.txt").is_ok());
668        for path in [
669            "src/../ordinary.txt",
670            "../outside",
671            "src\\..\\ordinary.txt",
672            "bad\0path",
673        ] {
674            assert!(!policy.permits_read(path), "read: {path:?}");
675            assert!(!policy.permits_write(path), "write: {path:?}");
676            assert!(!policy.permits_read_traversal(path), "traverse: {path:?}");
677            assert!(policy.check_read(path).is_err(), "check_read: {path:?}");
678            assert!(policy.check_write(path).is_err(), "check_write: {path:?}");
679        }
680    }
681
682    #[test]
683    fn workspace_and_session_absolute_paths_share_one_namespace() {
684        let policy = WorkspacePolicy::builder()
685            .allow_read("/workspace/src")
686            .allow_write("/output")
687            .build()
688            .unwrap();
689        assert!(policy.permits_read("src/lib.rs"));
690        assert!(policy.permits_read("/src/lib.rs"));
691        assert!(policy.permits_read("/workspace/src/lib.rs"));
692        assert!(policy.permits_write("/workspace/output/report.md"));
693    }
694
695    #[test]
696    fn repeated_slashes_cannot_bypass_a_deny_scope() {
697        let policy = WorkspacePolicy::builder()
698            .allow_read("/")
699            .deny_read("private")
700            .build()
701            .unwrap();
702        assert!(policy.permits_read("//workspace//public//readme.txt"));
703        assert!(!policy.permits_read("//workspace//private//secret.txt"));
704    }
705
706    #[test]
707    fn alternate_ascii_case_cannot_bypass_a_deny_or_sensitive_path() {
708        let policy = WorkspacePolicy::builder()
709            .allow_read("/")
710            .allow_write("/")
711            .allow_hidden("/")
712            .deny_read("Private")
713            .deny_write("Private")
714            .build()
715            .unwrap();
716
717        assert!(policy.permits_read("/.ordinary"));
718        assert!(policy.permits_write("/.ordinary"));
719        for path in ["/private/secret.txt", "/.ENV", "/.Git/config"] {
720            assert!(!policy.permits_read(path), "read: {path}");
721            assert!(!policy.permits_write(path), "write: {path}");
722        }
723    }
724
725    #[test]
726    fn alternate_ascii_case_cannot_broaden_an_allow_scope() {
727        let policy = WorkspacePolicy::builder()
728            .allow_read("public")
729            .allow_write("generated")
730            .build()
731            .unwrap();
732
733        assert!(policy.permits_read("public/index.html"));
734        assert!(!policy.permits_read("Public/secret.txt"));
735        assert!(!policy.permits_read_traversal("Public"));
736        assert!(policy.permits_write("generated/report.md"));
737        assert!(!policy.permits_write("Generated/secret.txt"));
738    }
739
740    #[test]
741    fn invalid_custom_scope_is_reported_at_build_time() {
742        let error = WorkspacePolicy::builder()
743            .allow_read("../outside")
744            .build()
745            .unwrap_err();
746        assert!(error.to_string().contains("traversal"));
747
748        let error = WorkspacePolicy::builder()
749            .deny_write_component("nested/vendor")
750            .build()
751            .unwrap_err();
752        assert!(error.to_string().contains("one path component"));
753
754        let error = WorkspacePolicy::builder()
755            .allow_write("  ")
756            .build()
757            .unwrap_err();
758        assert!(error.to_string().contains("must not be empty"));
759    }
760}