Skip to main content

io_harness/
policy.rs

1//! The permission policy: what the agent may read, write, and execute.
2//!
3//! A [`Policy`] is a stack of named [`Layer`]s plus a per-action default. It is
4//! evaluated deny-first: a deny in *any* layer wins over an allow in any other,
5//! so an overlay can add capability but can never re-allow what a layer beneath
6//! it denied. That single rule is what makes a shared base policy trustworthy
7//! when io-cli and io-studio each stack their own config over it.
8//!
9//! [`Policy::check`] and [`Policy::explain`] are the same function — `check` is
10//! `explain` — so an explanation can never describe a boundary different from
11//! the one enforced.
12
13use std::borrow::Cow;
14use std::collections::HashMap;
15use std::sync::{OnceLock, PoisonError, RwLock};
16
17use serde::{Deserialize, Serialize};
18
19use crate::error::{Error, Result};
20
21/// What an action wants to do. Search tools (`grep`, `find`) filter their
22/// results with [`Act::Read`], so a denied path cannot reach the model.
23///
24/// Four acts, and every checked thing in the crate maps onto exactly one of
25/// them. Knowing which is how you write a rule for something whose name never
26/// appears in the policy — a registered [`Tool`](crate::Tool) and an MCP tool are
27/// both [`Act::Exec`] on their *name*, and a spreadsheet tool is
28/// [`Act::Read`]/[`Act::Write`] on the path the model gave it:
29///
30/// ```
31/// use io_harness::{Act, Effect, Policy};
32///
33/// let policy = Policy::permissive()
34///     .layer("app")
35///     // Files: the path, relative to the workspace root.
36///     .deny_read("secrets/*")
37///     .allow_write("src/*")
38///     // Binaries the verification gate spawns, by name — and the same act
39///     // decides whether a registered or MCP tool may be *called*.
40///     .allow_exec("rustc")
41///     .deny_exec("charge_credit_card")
42///     // Outbound connections, by host or `host:port`. Naming the host alone
43///     // covers whichever port a URL resolved to.
44///     .allow_net("api.example.com");
45///
46/// assert_eq!(policy.check(Act::Read, "secrets/prod.env").effect, Effect::Deny);
47/// assert_eq!(policy.check(Act::Exec, "charge_credit_card").effect, Effect::Deny);
48/// assert_eq!(policy.check(Act::Net, "api.example.com:443").effect, Effect::Allow);
49/// ```
50///
51/// What the exec act does *not* govern is what a thing does once it is running:
52/// a registered tool runs in the harness's process with the embedding program's
53/// privileges, and a stdio MCP server is a separate process that then dials what
54/// it likes. The policy decides what starts, not what a started thing does.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum Act {
58    /// Read a file's contents into context.
59    Read,
60    /// Create or overwrite a file.
61    Write,
62    /// Spawn a binary (the verification layer's compile/test commands).
63    Exec,
64    /// Open an outbound connection. The target is a host, normally in
65    /// `host:port` form — see [`Policy::check`] for how a rule matches one.
66    Net,
67}
68
69/// What a rule does. Ordered by strictness: `Allow` < `Ask` < `Deny`.
70///
71/// The ordering is not decoration — it is how stacking two policies is defined.
72/// [`Policy::merge`] and [`Policy::contain`] take the `max` of the two defaults
73/// per act, so combining policies can only ever tighten them:
74///
75/// ```
76/// use io_harness::{Act, Effect, Policy};
77///
78/// assert!(Effect::Allow < Effect::Ask && Effect::Ask < Effect::Deny);
79///
80/// // A permissive overlay cannot loosen a base's asking default.
81/// let combined = Policy::default().merge(Policy::permissive());
82/// assert_eq!(combined.check(Act::Write, "anything-unmatched").effect, Effect::Ask);
83/// ```
84///
85/// The three effects also mean three different things at run time, and the
86/// middle one is the only one a human ever sees: `Allow` proceeds silently,
87/// `Ask` routes to the [`Approver`](crate::Approver), and `Deny` refuses without
88/// consulting anyone — a denied action never reaches an approver, so an
89/// [`ApproveAll`](crate::ApproveAll) cannot wave it through.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
91#[serde(rename_all = "snake_case")]
92pub enum Effect {
93    /// Proceed without asking.
94    Allow,
95    /// Proceed only after a human approves.
96    Ask,
97    /// Never proceed. Absolute across layers.
98    Deny,
99}
100
101/// One rule: an effect applied to an action whose target matches `pattern`.
102///
103/// `pattern` is a glob (`*` any run including `/`, `?` one character) matched
104/// against the target's full relative path *or* its basename, the same way the
105/// `find` tool matches. That is what lets `.env` deny `config/.env`.
106///
107/// The basename half is why a bare filename is a *recursive* deny, and it is the
108/// reason to construct a `Rule` deliberately rather than reaching for the
109/// nearest-looking builder:
110///
111/// ```
112/// use io_harness::{Act, Effect, Policy, Rule};
113///
114/// // `*` spans `/`, so this is not "one directory down".
115/// let deny_anywhere = Rule { act: Act::Read, effect: Effect::Deny, pattern: ".env".into() };
116/// let deny_one_tree = Rule { act: Act::Read, effect: Effect::Deny, pattern: "vendor/*".into() };
117///
118/// let policy = Policy::permissive()
119///     .layer("secrets")
120///     .rule(deny_anywhere.act, deny_anywhere.effect, deny_anywhere.pattern.clone())
121///     .rule(deny_one_tree.act, deny_one_tree.effect, deny_one_tree.pattern.clone());
122///
123/// // Matched on the basename: every `.env` at every depth.
124/// assert_eq!(policy.check(Act::Read, "deploy/staging/.env").effect, Effect::Deny);
125/// // Matched on the full relative path, and `*` crosses directories.
126/// assert_eq!(policy.check(Act::Read, "vendor/lib/src/main.rs").effect, Effect::Deny);
127/// // Neither form matches, so the tier default decides.
128/// assert_eq!(policy.check(Act::Read, "src/main.rs").effect, Effect::Allow);
129/// ```
130///
131/// A `Rule` is also what an approver hands back in
132/// [`Decision::Approve`](crate::Decision::Approve)`::remember`, and what a
133/// deserialized operator config is made of — it is `Serialize`/`Deserialize` for
134/// exactly that.
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136pub struct Rule {
137    /// Which kind of action this rule governs.
138    pub act: Act,
139    /// What to do when it matches.
140    pub effect: Effect,
141    /// The glob matched against the path (or binary name, for [`Act::Exec`]).
142    pub pattern: String,
143}
144
145/// A named group of rules. The name is what [`Policy::explain`] and the trace
146/// report, so a refusal from a shared base is attributable to that base.
147///
148/// Name layers after *who wrote them*, not after what they do. When a run is
149/// refused six weeks later, the trace names the layer, and "ops-baseline" sends
150/// the reader to the right file while "denies" sends them nowhere:
151///
152/// ```
153/// use io_harness::{Act, Effect, Layer, Policy, Rule};
154///
155/// // A layer built directly — what deserializing an operator's config file
156/// // produces, as opposed to the builder methods a program writes inline.
157/// let baseline = Layer {
158///     name: "ops-baseline".into(),
159///     rules: vec![
160///         Rule { act: Act::Read, effect: Effect::Deny, pattern: "infra/*".into() },
161///         Rule { act: Act::Write, effect: Effect::Deny, pattern: "infra/*".into() },
162///     ],
163/// };
164///
165/// let mut policy = Policy::permissive();
166/// policy.layers.push(baseline);
167/// let policy = policy.layer("app").allow_read("*").allow_write("*");
168///
169/// // The app's blanket allows do not lift the baseline's denies, and the verdict
170/// // says whose rule stopped it.
171/// let verdict = policy.explain(Act::Write, "infra/main.tf");
172/// assert_eq!(verdict.effect, Effect::Deny);
173/// assert_eq!(verdict.layer.as_deref(), Some("ops-baseline"));
174/// ```
175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
176pub struct Layer {
177    /// Human-readable name, surfaced in explanations and the trace.
178    pub name: String,
179    /// The rules in this layer.
180    pub rules: Vec<Rule>,
181}
182
183/// The default effect for an action no rule mentions.
184///
185/// This is the part of a policy that decides what happens to everything you did
186/// *not* think of, so it is the part worth setting on purpose. Deny-by-default
187/// makes the rule list exhaustive — anything unnamed is refused — which is the
188/// shape an unattended run wants:
189///
190/// ```
191/// use io_harness::{Act, Defaults, Effect, Policy};
192///
193/// let mut policy = Policy::permissive().layer("job").allow_read("src/*").allow_write("out/*");
194/// policy.defaults = Defaults {
195///     read: Effect::Deny,
196///     write: Effect::Deny,
197///     exec: Effect::Deny,
198///     net: Effect::Deny,
199/// };
200///
201/// // Named: allowed. Unnamed: refused, without ever asking a human.
202/// assert_eq!(policy.check(Act::Read, "src/lib.rs").effect, Effect::Allow);
203/// assert_eq!(policy.check(Act::Read, "/etc/passwd").effect, Effect::Deny);
204/// ```
205///
206/// Two defaults it is easy to be surprised by. `Policy::default()` sets `write`
207/// and `exec` to [`Effect::Ask`], so a run with no approver behind it stalls on
208/// its first write unless a rule allows it outright. And `net` defaults to
209/// [`Effect::Deny`] everywhere, including for a policy deserialized from a 0.7.0
210/// config that has no `net` field — the harness contributes the configured
211/// provider's host as its own layer, so the model is still reachable and nothing
212/// else is.
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
214pub struct Defaults {
215    /// Default for reads. Reads inside the allow list are the permissive tier.
216    pub read: Effect,
217    /// Default for writes. Every write asks, including an in-policy overwrite.
218    pub write: Effect,
219    /// Default for spawning a binary.
220    pub exec: Effect,
221    /// Default for opening an outbound connection.
222    ///
223    /// `#[serde(default)]` is load-bearing: a policy serialized by 0.7.0 or
224    /// earlier has no `net` field at all, and it deserializes here to `Deny`.
225    /// That is a deliberate behaviour change — an old config that made outbound
226    /// calls stops making them until it carries a `net` allow — chosen because
227    /// the alternative silently leaves egress ungoverned for exactly the callers
228    /// who upgraded to govern it.
229    #[serde(default = "deny")]
230    pub net: Effect,
231}
232
233/// The `net` default for a policy that predates the field. See [`Defaults::net`].
234fn deny() -> Effect {
235    Effect::Deny
236}
237
238/// The outcome of evaluating a policy, with the rule and layer that produced it.
239///
240/// The two `Option`s are the useful part: they distinguish "a rule someone wrote
241/// stopped this" from "nothing matched, so the tier default did". Those need
242/// different fixes — one edits a line of config, the other adds one — and a
243/// refusal message that cannot tell them apart sends the reader to the wrong
244/// file:
245///
246/// ```
247/// use io_harness::{Act, Effect, Policy, Verdict};
248///
249/// fn explain_refusal(v: &Verdict, target: &str) -> String {
250///     match (&v.rule, &v.layer) {
251///         (Some(rule), Some(layer)) => format!("{target}: refused by `{rule}` in layer {layer}"),
252///         // No rule mentioned it at all — the answer is in `Defaults`.
253///         _ => format!("{target}: no rule matched; the tier default is {:?}", v.effect),
254///     }
255/// }
256///
257/// let policy = Policy::default().layer("app").deny_read("vendor/*");
258///
259/// assert_eq!(
260///     explain_refusal(&policy.explain(Act::Read, "vendor/x.rs"), "vendor/x.rs"),
261///     "vendor/x.rs: refused by `vendor/*` in layer app",
262/// );
263/// assert_eq!(
264///     explain_refusal(&policy.explain(Act::Write, "src/x.rs"), "src/x.rs"),
265///     "src/x.rs: no rule matched; the tier default is Ask",
266/// );
267/// ```
268///
269/// The same `Verdict` is what enforcement acts on — [`Policy::check`] *is*
270/// [`Policy::explain`] — so an explanation can never describe a boundary
271/// different from the one enforced. These fields are also what the trace records
272/// on a refusal, so an audit six weeks later reads the same attribution.
273#[derive(Debug, Clone, PartialEq, Eq)]
274pub struct Verdict {
275    /// What to do.
276    pub effect: Effect,
277    /// The glob that decided, or `None` when the tier default decided.
278    pub rule: Option<String>,
279    /// The layer the deciding rule came from, or `None` for the tier default.
280    pub layer: Option<String>,
281}
282
283/// Secret-bearing paths denied out of the box by [`Policy::default`], following
284/// OpenCode's `.env` default. A read allow list alone does not protect secrets
285/// that live *inside* the workspace. Writes are denied too — nothing the agent
286/// legitimately does rewrites a private key.
287const SECRET_PATTERNS: &[&str] = &[".env", "*.pem", "id_rsa", "id_ed25519", "*.key"];
288
289/// A permission policy: a stack of layers plus per-action defaults.
290///
291/// The reason it is a *stack* rather than one rule list is that the rules
292/// normally come from two people. An operator writes a base, an application
293/// stacks its own needs on top, and the base has to keep holding — which it
294/// does, because evaluation is deny-first across the whole stack and specificity
295/// does not enter into it:
296///
297/// ```
298/// use io_harness::{Act, Effect, Policy};
299///
300/// // Whoever runs the fleet. Shipped once, reused by every job.
301/// let ops = Policy::permissive()
302///     .layer("ops")
303///     .deny_read("infra/*")
304///     .deny_write("infra/*")
305///     .deny_exec("kubectl");
306///
307/// // Whoever wrote this job. It asks for everything, as applications do.
308/// let app = Policy::permissive()
309///     .layer("app")
310///     .allow_read("*")
311///     .allow_write("*")
312///     .allow_exec("rustc");
313///
314/// let policy = ops.merge(app);
315///
316/// // The app's `allow_read("*")` is broader and later, and still loses. A deny
317/// // in any layer beats an allow in any other, so handing out `ops` is safe:
318/// // nothing stacked on top can take it back.
319/// assert_eq!(policy.check(Act::Read, "infra/prod.tf").effect, Effect::Deny);
320/// assert_eq!(policy.check(Act::Exec, "kubectl").effect, Effect::Deny);
321/// assert_eq!(policy.check(Act::Write, "src/lib.rs").effect, Effect::Allow);
322/// ```
323///
324/// Which constructor to start from is a real decision, not a default:
325///
326/// * [`Policy::default`] — reads allowed, writes and execs [`Effect::Ask`], the
327///   secret paths (`.env`, `*.pem`, `id_rsa`, `id_ed25519`, `*.key`) denied
328///   outright, egress denied. The tiered starting point for an interactive run.
329/// * [`Policy::permissive`] — enforces nothing, and is what
330///   [`run`](crate::run) applies when the caller passes no policy at all. Start
331///   here when you intend to write the whole boundary yourself.
332///
333/// For a [`run_tree`](crate::run_tree), [`Policy::contain`] is the one that
334/// matters: a child inherits the parent's rules and may only narrow them, so no
335/// descendant at any depth holds an allow the root did not.
336#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
337pub struct Policy {
338    /// Layers in stacking order. Later layers may add capability, never
339    /// re-allow a deny from an earlier one.
340    pub layers: Vec<Layer>,
341    /// What applies when no rule matches.
342    pub defaults: Defaults,
343}
344
345impl Default for Policy {
346    /// The tiered default a caller gets when they construct a policy without
347    /// specifying tiers — reads allowed, writes and execs gated on approval,
348    /// and the secret paths denied outright.
349    ///
350    /// This is *not* what applies when a caller passes no policy at all; that
351    /// is [`Policy::permissive`], which enforces nothing.
352    fn default() -> Self {
353        let mut rules = Vec::new();
354        for pat in SECRET_PATTERNS {
355            for act in [Act::Read, Act::Write] {
356                rules.push(Rule {
357                    act,
358                    effect: Effect::Deny,
359                    pattern: (*pat).to_string(),
360                });
361            }
362        }
363        // Verification's own spawns are allowed by name so a constructed policy
364        // does not break the verify gate; anything else must be allowed
365        // explicitly, since verification has no approver to prompt.
366        let exec = Layer {
367            name: "builtin-exec".into(),
368            rules: ["rustc", "<test-binary>"]
369                .iter()
370                .map(|p| Rule {
371                    act: Act::Exec,
372                    effect: Effect::Allow,
373                    pattern: (*p).to_string(),
374                })
375                .collect(),
376        };
377        Self {
378            layers: vec![
379                Layer {
380                    name: "builtin-secrets".into(),
381                    rules,
382                },
383                exec,
384            ],
385            defaults: Defaults {
386                read: Effect::Allow,
387                write: Effect::Ask,
388                exec: Effect::Ask,
389                // Deny, not Ask: an outbound host is not a thing a human can
390                // meaningfully approve on sight mid-run without knowing why it
391                // is being dialled, and the caller naming its hosts up front is
392                // the whole point of the act.
393                net: Effect::Deny,
394            },
395        }
396    }
397}
398
399impl Policy {
400    /// A policy that enforces nothing — what [`crate::run`] applies when the
401    /// caller passes none, preserving 0.3.0 behaviour. The boundary is opt-in.
402    pub fn permissive() -> Self {
403        Self {
404            layers: Vec::new(),
405            defaults: Defaults {
406                read: Effect::Allow,
407                write: Effect::Allow,
408                exec: Effect::Allow,
409                net: Effect::Allow,
410            },
411        }
412    }
413
414    /// Does this policy enforce nothing? True for [`Policy::permissive`] — no
415    /// rules and every default `Allow`.
416    pub fn is_permissive(&self) -> bool {
417        self.layers.iter().all(|l| l.rules.is_empty())
418            && self.defaults.read == Effect::Allow
419            && self.defaults.write == Effect::Allow
420            && self.defaults.exec == Effect::Allow
421            && self.defaults.net == Effect::Allow
422    }
423
424    /// Start a new named layer. Subsequent rule builders append to it.
425    pub fn layer(mut self, name: impl Into<String>) -> Self {
426        self.layers.push(Layer {
427            name: name.into(),
428            rules: Vec::new(),
429        });
430        self
431    }
432
433    /// Append a rule to the current layer, starting one if none exists.
434    pub fn rule(mut self, act: Act, effect: Effect, pattern: impl Into<String>) -> Self {
435        if self.layers.is_empty() {
436            self = self.layer("policy");
437        }
438        let pattern = pattern.into();
439        let layer = self.layers.last_mut().expect("a layer exists");
440        layer.rules.push(Rule {
441            act,
442            effect,
443            pattern,
444        });
445        self
446    }
447
448    /// Allow reads matching `pattern`.
449    pub fn allow_read(self, pattern: impl Into<String>) -> Self {
450        self.rule(Act::Read, Effect::Allow, pattern)
451    }
452    /// Deny reads matching `pattern`.
453    pub fn deny_read(self, pattern: impl Into<String>) -> Self {
454        self.rule(Act::Read, Effect::Deny, pattern)
455    }
456    /// Allow writes matching `pattern` without asking.
457    pub fn allow_write(self, pattern: impl Into<String>) -> Self {
458        self.rule(Act::Write, Effect::Allow, pattern)
459    }
460    /// Deny writes matching `pattern`.
461    pub fn deny_write(self, pattern: impl Into<String>) -> Self {
462        self.rule(Act::Write, Effect::Deny, pattern)
463    }
464    /// Require approval for writes matching `pattern`.
465    pub fn ask_write(self, pattern: impl Into<String>) -> Self {
466        self.rule(Act::Write, Effect::Ask, pattern)
467    }
468    /// Allow spawning a binary matching `pattern`.
469    pub fn allow_exec(self, pattern: impl Into<String>) -> Self {
470        self.rule(Act::Exec, Effect::Allow, pattern)
471    }
472    /// Deny spawning a binary matching `pattern`.
473    pub fn deny_exec(self, pattern: impl Into<String>) -> Self {
474        self.rule(Act::Exec, Effect::Deny, pattern)
475    }
476    /// Allow outbound connections to hosts matching `pattern`.
477    pub fn allow_net(self, pattern: impl Into<String>) -> Self {
478        self.rule(Act::Net, Effect::Allow, pattern)
479    }
480    /// Deny outbound connections to hosts matching `pattern`.
481    pub fn deny_net(self, pattern: impl Into<String>) -> Self {
482        self.rule(Act::Net, Effect::Deny, pattern)
483    }
484    /// Require approval for outbound connections to hosts matching `pattern`.
485    pub fn ask_net(self, pattern: impl Into<String>) -> Self {
486        self.rule(Act::Net, Effect::Ask, pattern)
487    }
488
489    /// Stack `overlay` on top of this policy.
490    ///
491    /// Layers concatenate, so every deny in either policy still applies —
492    /// evaluation is deny-first across the whole stack, which is what makes an
493    /// overlay unable to re-allow a base deny. Defaults tighten only: the
494    /// stricter of the two wins per action.
495    pub fn merge(mut self, overlay: Policy) -> Self {
496        self.layers.extend(overlay.layers);
497        self.defaults = Defaults {
498            read: self.defaults.read.max(overlay.defaults.read),
499            write: self.defaults.write.max(overlay.defaults.write),
500            exec: self.defaults.exec.max(overlay.defaults.exec),
501            net: self.defaults.net.max(overlay.defaults.net),
502        };
503        self
504    }
505
506    /// Derive a child agent's effective policy from this parent policy under
507    /// *containment*: the child inherits every parent rule and may only narrow.
508    ///
509    /// The child's own deny/ask rules are added (denies union downward), but its
510    /// allow rules are dropped — allows *intersect* downward, so a child can
511    /// never grant itself read, write, or execute the parent lacked. Defaults
512    /// tighten to the stricter of the two per action.
513    ///
514    /// This is deliberately *not* [`Policy::merge`], where an overlay may add
515    /// allows to widen a base. Containment flows one way: no descendant, at any
516    /// depth, can hold an effective allow the root did not — because `contain`
517    /// only ever appends denies and tightens defaults, applying it again for a
518    /// grandchild preserves the invariant.
519    pub fn contain(&self, child: &Policy) -> Policy {
520        let mut layers = self.layers.clone();
521        for l in &child.layers {
522            // Keep only the child's tightening rules; its allows grant nothing.
523            let rules: Vec<Rule> = l
524                .rules
525                .iter()
526                .filter(|r| r.effect != Effect::Allow)
527                .cloned()
528                .collect();
529            if !rules.is_empty() {
530                layers.push(Layer {
531                    name: l.name.clone(),
532                    rules,
533                });
534            }
535        }
536        Policy {
537            layers,
538            defaults: Defaults {
539                read: self.defaults.read.max(child.defaults.read),
540                write: self.defaults.write.max(child.defaults.write),
541                exec: self.defaults.exec.max(child.defaults.exec),
542                net: self.defaults.net.max(child.defaults.net),
543            },
544        }
545    }
546
547    /// Evaluate `act` against `target`, returning the effect with the rule and
548    /// layer that produced it.
549    ///
550    /// Deny-first across all layers, then ask, then allow, then the tier
551    /// default. Specificity does not matter — a broad deny beats a narrow
552    /// allow, matching Claude Code's precedence.
553    pub fn explain(&self, act: Act, target: &str) -> Verdict {
554        // A network target arrives as `host:port`; a rule may name either form.
555        // Trying both is what lets `allow_net("api.example.com")` cover whatever
556        // port the URL resolved to, while `allow_net("api.example.com:443")`
557        // still means that port and no other.
558        let forms: Vec<&str> = match (act, target.rsplit_once(':')) {
559            (Act::Net, Some((host, port)))
560                if !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()) =>
561            {
562                vec![target, host]
563            }
564            _ => vec![target],
565        };
566        for effect in [Effect::Deny, Effect::Ask, Effect::Allow] {
567            for layer in &self.layers {
568                for rule in &layer.rules {
569                    if rule.act == act
570                        && rule.effect == effect
571                        && forms.iter().any(|t| matches(act, &rule.pattern, t))
572                    {
573                        return Verdict {
574                            effect,
575                            rule: Some(rule.pattern.clone()),
576                            layer: Some(layer.name.clone()),
577                        };
578                    }
579                }
580            }
581        }
582        Verdict {
583            effect: match act {
584                Act::Read => self.defaults.read,
585                Act::Write => self.defaults.write,
586                Act::Exec => self.defaults.exec,
587                Act::Net => self.defaults.net,
588            },
589            rule: None,
590            layer: None,
591        }
592    }
593
594    /// Evaluate `act` against `target`. This *is* [`Policy::explain`] — the
595    /// enforcement path and the explanation path are one function, so they
596    /// cannot drift apart.
597    pub fn check(&self, act: Act, target: &str) -> Verdict {
598        self.explain(act, target)
599    }
600}
601
602/// Fold a path-valued pattern or target into the one form both sides are
603/// compared in. Windows only, and applied identically to pattern and target so
604/// there is a single definition of "the same path":
605///
606/// * a `\\?\` verbatim prefix is stripped (`\\?\UNC\srv\share` becomes
607///   `\\srv\share`), because that prefix is something [`std::fs::canonicalize`]
608///   adds and no human writes in a rule; then
609/// * `\` becomes `/`, because on Windows both are directory separators.
610///
611/// Without this a deny built from a canonicalized [`std::path::Path`] — which is
612/// what the harness itself and any caller doing `deny_read(format!("{}/*", p))`
613/// produce — never matched the backslash target it was meant to cover, and the
614/// read was *allowed*. A permission rule that misses fails open, so this is the
615/// security-relevant half of matching, not a cosmetic tidy-up.
616///
617/// Deliberately a no-op on unix, where `\` is an ordinary character in a file
618/// name: folding it to `/` there would make two genuinely different paths match,
619/// which is the same fail-open bug pointed the other way.
620#[cfg(windows)]
621fn normalise_path(s: &str) -> Cow<'_, str> {
622    let stripped = match s.strip_prefix(r"\\?\UNC\") {
623        Some(rest) => Cow::Owned(format!(r"\\{rest}")),
624        None => Cow::Borrowed(s.strip_prefix(r"\\?\").unwrap_or(s)),
625    };
626    match stripped.contains('\\') {
627        true => Cow::Owned(stripped.replace('\\', "/")),
628        false => stripped,
629    }
630}
631
632/// See the Windows definition. On unix `\` is a legal filename character, so
633/// normalising it would silently merge distinct paths — matching stays literal.
634#[cfg(not(windows))]
635fn normalise_path(s: &str) -> Cow<'_, str> {
636    Cow::Borrowed(s)
637}
638
639/// Compiled globs, keyed by the whole (normalised) pattern text.
640///
641/// The key is whatever text [`matches`] hands over — already run through
642/// [`normalise_path`] for a path act — so the key is always exactly the string
643/// that was compiled. Two raw patterns that normalise to one form share an
644/// entry, which is correct: they denote the same location.
645///
646/// Safe as a process-wide cache because compilation is a pure function of that
647/// text and nothing else: no act, no effect, no layer, no target enters
648/// [`glob_to_regex`], and the cache stores the regex, never a verdict. So a hit
649/// can only ever hand back the regex that same pattern would have compiled to,
650/// and a pattern appearing in two layers — or in a parent and the child that
651/// [`Policy::contain`] narrows — still decides through its own rule's act,
652/// effect, and layer position. A failed compile is cached as `None`, keeping a
653/// malformed glob matching nothing exactly as before.
654// ponytail: unbounded — one entry per distinct pattern text ever checked.
655// Patterns come from policy configs, not from the model, so the set is small and
656// fixed; bound it (LRU, or a per-Policy cache) only if a caller ever generates
657// pattern text at runtime.
658static GLOBS: OnceLock<RwLock<HashMap<String, Option<regex::Regex>>>> = OnceLock::new();
659
660/// [`glob_to_regex`] memoised on the pattern text. `None` is a malformed glob.
661fn compiled(pattern: &str) -> Option<regex::Regex> {
662    let cache = GLOBS.get_or_init(Default::default);
663    if let Some(hit) = cache
664        .read()
665        .unwrap_or_else(PoisonError::into_inner)
666        .get(pattern)
667    {
668        return hit.clone();
669    }
670    // The error is dropped here as it always was — a bad glob is silent and
671    // matches nothing; surfacing it would be a behaviour change, not a fix.
672    let re = glob_to_regex(pattern).ok();
673    cache
674        .write()
675        .unwrap_or_else(PoisonError::into_inner)
676        .insert(pattern.to_string(), re.clone());
677    re
678}
679
680/// Does `pattern` match `target`, by full relative path or by basename?
681///
682/// Basename matching is what lets a bare `.env` deny `config/.env`, and mirrors
683/// how the `find` tool already matches globs.
684///
685/// `act` decides whether the two sides are paths. [`Act::Read`] and
686/// [`Act::Write`] target paths and are normalised (see [`normalise_path`]);
687/// [`Act::Exec`] targets a binary *name* and [`Act::Net`] a host, where `\` is
688/// not a separator and rewriting it would change what a rule means.
689fn matches(act: Act, pattern: &str, target: &str) -> bool {
690    let (pattern, target) = match act {
691        Act::Read | Act::Write => (normalise_path(pattern), normalise_path(target)),
692        Act::Exec | Act::Net => (Cow::Borrowed(pattern), Cow::Borrowed(target)),
693    };
694    let Some(re) = compiled(&pattern) else {
695        return false; // a malformed glob matches nothing rather than everything
696    };
697    if re.is_match(&target) {
698        return true;
699    }
700    match target.rsplit('/').next() {
701        Some(base) if base != target => re.is_match(base),
702        _ => false,
703    }
704}
705
706/// Compile a glob (`*` any run including `/`, `?` one char) to an anchored regex.
707fn glob_to_regex(glob: &str) -> Result<regex::Regex> {
708    let mut re = String::from("(?s)^");
709    for ch in glob.chars() {
710        match ch {
711            '*' => re.push_str(".*"),
712            '?' => re.push('.'),
713            c => re.push_str(&regex::escape(&c.to_string())),
714        }
715    }
716    re.push('$');
717    regex::Regex::new(&re).map_err(|e| Error::Config(format!("bad policy glob: {e}")))
718}
719
720#[cfg(test)]
721mod tests {
722    use super::*;
723
724    /// A base layer that allows the workspace but denies a secrets tree.
725    fn base() -> Policy {
726        Policy::default()
727            .layer("base")
728            .allow_read("src/*")
729            .allow_write("src/*")
730            .deny_read("secrets/*")
731            .deny_write("secrets/*")
732    }
733
734    #[test]
735    fn deny_beats_allow_even_when_both_match() {
736        let p = Policy::default()
737            .layer("l")
738            .allow_write("src/*")
739            .deny_write("src/generated/*");
740        assert_eq!(p.check(Act::Write, "src/a.rs").effect, Effect::Allow);
741        assert_eq!(
742            p.check(Act::Write, "src/generated/x.rs").effect,
743            Effect::Deny
744        );
745    }
746
747    #[test]
748    fn default_policy_denies_dotenv_read_inside_a_readable_tree() {
749        let p = Policy::default().layer("l").allow_read("*");
750        assert_eq!(p.check(Act::Read, "src/a.rs").effect, Effect::Allow);
751        assert_eq!(p.check(Act::Read, ".env").effect, Effect::Deny);
752        assert_eq!(p.check(Act::Read, "config/.env").effect, Effect::Deny);
753        assert_eq!(p.check(Act::Read, "keys/id_rsa").effect, Effect::Deny);
754    }
755
756    #[test]
757    fn serde_roundtrip_enforces_identically() {
758        let p = base();
759        let json = serde_json::to_string(&p).unwrap();
760        let back: Policy = serde_json::from_str(&json).unwrap();
761        for (act, path) in [
762            (Act::Write, "src/a.rs"),
763            (Act::Write, "secrets/key.txt"),
764            (Act::Read, "secrets/key.txt"),
765            (Act::Read, ".env"),
766            (Act::Exec, "rustc"),
767        ] {
768            assert_eq!(
769                p.check(act, path).effect,
770                back.check(act, path).effect,
771                "{act:?} {path}"
772            );
773        }
774    }
775
776    #[test]
777    fn an_overlay_cannot_reallow_what_the_base_denied() {
778        let overlay = Policy::default().layer("app").allow_write("secrets/*");
779        let merged = base().merge(overlay);
780        // The overlay allows it, the base denies it — deny is absolute.
781        assert_eq!(
782            merged.check(Act::Write, "secrets/key.txt").effect,
783            Effect::Deny
784        );
785    }
786
787    #[test]
788    fn an_overlay_only_allow_grants_capability_the_base_lacked() {
789        let overlay = Policy::default().layer("app").allow_write("docs/*");
790        let merged = base().merge(overlay);
791        assert_eq!(merged.check(Act::Write, "docs/x.md").effect, Effect::Allow);
792        // and the base's own rules survive the merge
793        assert_eq!(merged.check(Act::Write, "src/a.rs").effect, Effect::Allow);
794    }
795
796    #[test]
797    fn merging_is_deterministic_and_merging_a_layer_with_itself_changes_nothing() {
798        let a = base();
799        let probes = [
800            (Act::Write, "src/a.rs"),
801            (Act::Write, "secrets/k"),
802            (Act::Read, "docs/x"),
803        ];
804        let once = a.clone().merge(base());
805        let twice = a.clone().merge(base()).merge(base());
806        for (act, path) in probes {
807            assert_eq!(a.check(act, path).effect, once.check(act, path).effect);
808            assert_eq!(once.check(act, path).effect, twice.check(act, path).effect);
809        }
810    }
811
812    #[test]
813    fn an_empty_overlay_leaves_the_base_unchanged() {
814        let merged = base().merge(Policy::default());
815        for (act, path) in [(Act::Write, "src/a.rs"), (Act::Write, "secrets/k")] {
816            assert_eq!(
817                base().check(act, path).effect,
818                merged.check(act, path).effect
819            );
820        }
821    }
822
823    #[test]
824    fn explain_names_the_rule_and_the_layer_that_decided() {
825        let merged = base().merge(Policy::default().layer("app").allow_write("docs/*"));
826
827        let denied = merged.explain(Act::Write, "secrets/key.txt");
828        assert_eq!(denied.effect, Effect::Deny);
829        assert_eq!(denied.layer.as_deref(), Some("base"));
830        assert_eq!(denied.rule.as_deref(), Some("secrets/*"));
831
832        let allowed = merged.explain(Act::Write, "docs/x.md");
833        assert_eq!(allowed.effect, Effect::Allow);
834        assert_eq!(allowed.layer.as_deref(), Some("app"));
835
836        // A path no rule mentions falls to the tier default, attributed to no layer.
837        let fallback = merged.explain(Act::Write, "elsewhere/x");
838        assert_eq!(fallback.effect, Effect::Ask);
839        assert_eq!(fallback.layer, None);
840    }
841
842    // --- 0.5.0 containment merge: inherit-and-narrow only, downward. ---
843
844    #[test]
845    fn a_child_overlay_allow_cannot_reach_a_path_the_parent_denies() {
846        // Parent denies secrets. A child that tries to allow it gains nothing —
847        // allows intersect downward, so the deny still stands.
848        let parent = base(); // denies secrets/*
849        let child = Policy::permissive()
850            .layer("child")
851            .allow_read("secrets/*")
852            .allow_write("secrets/*");
853        let contained = parent.contain(&child);
854        assert_eq!(
855            contained.check(Act::Write, "secrets/key.txt").effect,
856            Effect::Deny
857        );
858        assert_eq!(
859            contained.check(Act::Read, "secrets/key.txt").effect,
860            Effect::Deny
861        );
862    }
863
864    #[test]
865    fn a_child_overlay_allow_cannot_widen_a_parent_default() {
866        // The real teeth of containment vs merge: the parent never allowed
867        // docs/* writes (they fall to the Ask default). merge() would let a
868        // child allow widen it to Allow; contain() must not.
869        let parent = Policy::default().layer("parent").allow_write("src/*");
870        let child = Policy::permissive().layer("child").allow_write("docs/*");
871        assert_eq!(
872            parent
873                .clone()
874                .merge(child.clone())
875                .check(Act::Write, "docs/x.md")
876                .effect,
877            Effect::Allow,
878            "merge widens (0.4.0 behaviour)"
879        );
880        assert_eq!(
881            parent.contain(&child).check(Act::Write, "docs/x.md").effect,
882            Effect::Ask,
883            "contain does not widen"
884        );
885    }
886
887    #[test]
888    fn a_child_overlay_deny_narrows_the_parent() {
889        // A child adds a deny the parent lacked; denies union downward, and the
890        // child may narrow. Paths the child did not deny still follow the parent.
891        let parent = Policy::default().layer("parent").allow_write("src/*");
892        let child = Policy::permissive()
893            .layer("child")
894            .deny_write("src/generated/*");
895        let contained = parent.contain(&child);
896        assert_eq!(
897            contained.check(Act::Write, "src/a.rs").effect,
898            Effect::Allow
899        );
900        assert_eq!(
901            contained.check(Act::Write, "src/generated/x.rs").effect,
902            Effect::Deny
903        );
904    }
905
906    #[test]
907    fn containment_holds_downward_through_depth() {
908        // A grandchild cannot re-open what the root denied, nor widen a root
909        // default — the invariant holds at depth > 1, not just parent->child.
910        let root = base(); // denies secrets/*, allows src/*
911        let child = Policy::permissive()
912            .layer("child")
913            .deny_write("src/vendor/*");
914        let grandchild = Policy::permissive()
915            .layer("grandchild")
916            .allow_write("secrets/*") // try to re-allow a root deny
917            .allow_write("docs/*"); // try to widen past the root default
918        let effective = root.contain(&child).contain(&grandchild);
919        assert_eq!(
920            effective.check(Act::Write, "secrets/key.txt").effect,
921            Effect::Deny
922        );
923        assert_eq!(effective.check(Act::Write, "docs/x.md").effect, Effect::Ask);
924        assert_eq!(
925            effective.check(Act::Write, "src/vendor/x.rs").effect,
926            Effect::Deny
927        );
928        assert_eq!(
929            effective.check(Act::Write, "src/a.rs").effect,
930            Effect::Allow
931        );
932    }
933
934    #[test]
935    fn net_is_denied_by_default_and_allowed_only_where_a_rule_says_so() {
936        let p = Policy::default()
937            .layer("egress")
938            .allow_net("api.example.com");
939        assert_eq!(
940            p.check(Act::Net, "api.example.com:443").effect,
941            Effect::Allow
942        );
943        assert_eq!(
944            p.check(Act::Net, "evil.example.com:443").effect,
945            Effect::Deny
946        );
947        // Deny is absolute across layers, network included.
948        let tighter = p.layer("lockdown").deny_net("api.example.com");
949        assert_eq!(
950            tighter.check(Act::Net, "api.example.com:443").effect,
951            Effect::Deny
952        );
953    }
954
955    #[test]
956    fn a_net_rule_matches_with_or_without_the_port() {
957        let bare = Policy::default().layer("l").allow_net("api.example.com");
958        assert_eq!(
959            bare.check(Act::Net, "api.example.com:443").effect,
960            Effect::Allow
961        );
962        assert_eq!(
963            bare.check(Act::Net, "api.example.com:8080").effect,
964            Effect::Allow
965        );
966
967        // A rule that names a port is honoured as written: that port only.
968        let ported = Policy::default()
969            .layer("l")
970            .allow_net("api.example.com:443");
971        assert_eq!(
972            ported.check(Act::Net, "api.example.com:443").effect,
973            Effect::Allow
974        );
975        assert_eq!(
976            ported.check(Act::Net, "api.example.com:8080").effect,
977            Effect::Deny
978        );
979
980        // Wildcards work on hosts the way they work on paths.
981        let wild = Policy::default().layer("l").allow_net("*.example.com");
982        assert_eq!(
983            wild.check(Act::Net, "api.example.com:443").effect,
984            Effect::Allow
985        );
986        assert_eq!(wild.check(Act::Net, "example.org:443").effect, Effect::Deny);
987    }
988
989    #[test]
990    fn net_narrows_downward_and_never_widens() {
991        let root = Policy::default().layer("root").allow_net("api.example.com");
992        let child = Policy::permissive()
993            .layer("child")
994            .allow_net("evil.example.com") // a child allow grants nothing
995            .deny_net("api.example.com"); // a child deny binds
996        let effective = root.contain(&child);
997        assert_eq!(
998            effective.check(Act::Net, "evil.example.com:443").effect,
999            Effect::Deny
1000        );
1001        assert_eq!(
1002            effective.check(Act::Net, "api.example.com:443").effect,
1003            Effect::Deny
1004        );
1005    }
1006
1007    #[test]
1008    fn a_pre_0_8_policy_deserialises_with_network_denied() {
1009        // Exactly what 0.7.0 wrote: defaults with no `net` field at all.
1010        let old = r#"{"layers":[],"defaults":{"read":"allow","write":"ask","exec":"ask"}}"#;
1011        let p: Policy = serde_json::from_str(old).unwrap();
1012        assert_eq!(p.defaults.net, Effect::Deny);
1013        assert_eq!(
1014            p.check(Act::Net, "anywhere.example.com:443").effect,
1015            Effect::Deny
1016        );
1017    }
1018
1019    #[test]
1020    fn net_rules_survive_a_serde_roundtrip() {
1021        let p = Policy::default()
1022            .layer("egress")
1023            .allow_net("api.example.com")
1024            .deny_net("evil.example.com")
1025            .ask_net("maybe.example.com");
1026        let back: Policy = serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap();
1027        for host in [
1028            "api.example.com:443",
1029            "evil.example.com:443",
1030            "maybe.example.com:443",
1031        ] {
1032            assert_eq!(p.check(Act::Net, host), back.check(Act::Net, host));
1033        }
1034    }
1035
1036    #[test]
1037    fn permissive_still_enforces_nothing_including_the_network() {
1038        let p = Policy::permissive();
1039        assert!(p.is_permissive());
1040        assert_eq!(
1041            p.check(Act::Net, "anywhere.example.com:443").effect,
1042            Effect::Allow
1043        );
1044    }
1045
1046    #[test]
1047    fn the_glob_cache_cannot_make_one_pattern_answer_another_pattern_s_question() {
1048        // Patterns a sloppy key — a prefix, a basename, a normalised form, an
1049        // invented hash — would conflate. Each pair must keep deciding apart,
1050        // and must decide the same on the second pass, which runs off the cache.
1051        let p = Policy::permissive()
1052            .layer("l")
1053            .deny_read("secrets/*")
1054            .allow_read("secrets-public/*")
1055            .deny_read("a?.txt")
1056            .deny_read("build/*/out")
1057            .allow_read("build/x/out.bak");
1058        let probes = [
1059            ("secrets/k", Effect::Deny),
1060            ("secrets-public/k", Effect::Allow),
1061            ("ab.txt", Effect::Deny),
1062            ("abc.txt", Effect::Allow), // `?` is one char, `*` is any run
1063            ("build/x/out", Effect::Deny),
1064            ("build/x/out.bak", Effect::Allow),
1065        ];
1066        for pass in 0..2 {
1067            for (path, want) in probes {
1068                assert_eq!(p.check(Act::Read, path).effect, want, "pass {pass}: {path}");
1069            }
1070        }
1071
1072        // The same pattern text in two layers with opposite effects: the cache
1073        // holds a regex, not a verdict, so deny-first still decides regardless
1074        // of which layer compiled the text first, in either stacking order.
1075        let allow_first = Policy::permissive()
1076            .layer("a")
1077            .allow_exec("tool")
1078            .layer("b")
1079            .deny_exec("tool");
1080        let deny_first = Policy::permissive()
1081            .layer("b")
1082            .deny_exec("tool")
1083            .layer("a")
1084            .allow_exec("tool");
1085        for p in [&allow_first, &deny_first] {
1086            let v = p.check(Act::Exec, "tool");
1087            assert_eq!(v.effect, Effect::Deny);
1088            assert_eq!(v.layer.as_deref(), Some("b"));
1089        }
1090        // And a layer whose only rule allows that same text still allows it —
1091        // it did not inherit the other stack's deny along with the regex.
1092        let alone = Policy::permissive().layer("a").allow_exec("tool");
1093        assert_eq!(alone.check(Act::Exec, "tool").effect, Effect::Allow);
1094
1095        // Same text again across a containment boundary: the child's allow is
1096        // dropped, the parent's deny stands, and the parent alone is unchanged.
1097        let parent = Policy::permissive().layer("parent").deny_write("shared/*");
1098        let child = Policy::permissive().layer("child").allow_write("shared/*");
1099        assert_eq!(
1100            parent.contain(&child).check(Act::Write, "shared/f").effect,
1101            Effect::Deny
1102        );
1103        assert_eq!(child.check(Act::Write, "shared/f").effect, Effect::Allow);
1104    }
1105
1106    // --- 0.9.1: separator-agnostic path matching on Windows (fail-open fix). ---
1107
1108    #[cfg(windows)]
1109    #[test]
1110    fn a_path_rule_matches_whichever_separator_either_side_spelled() {
1111        // The shape that failed open: a pattern built from a canonicalized Path
1112        // (backslashes, verbatim prefix) with a `/*` suffix appended by the
1113        // caller, against the backslash target canonicalize hands back.
1114        let p = Policy::permissive()
1115            .layer("base")
1116            .deny_read(r"\\?\C:\Users\me\skills/*");
1117        assert_eq!(
1118            p.check(Act::Read, r"\\?\C:\Users\me\skills\beta\SKILL.md")
1119                .effect,
1120            Effect::Deny
1121        );
1122        // A human writing the same rule in the plain, forward-slash form covers
1123        // that verbatim target too.
1124        let plain = Policy::permissive().layer("base").deny_read("C:/secrets/*");
1125        assert_eq!(
1126            plain.check(Act::Read, r"\\?\C:\secrets\token.txt").effect,
1127            Effect::Deny
1128        );
1129        // And backslash-for-backslash, the form 0.4.0 through 0.9.0 missed.
1130        let backslash = Policy::permissive()
1131            .layer("base")
1132            .deny_write(r"C:\secrets\*");
1133        assert_eq!(
1134            backslash.check(Act::Write, r"C:\secrets\token.txt").effect,
1135            Effect::Deny
1136        );
1137        // Symmetric: the same rule catches the forward-slash spelling too.
1138        assert_eq!(
1139            backslash.check(Act::Write, "C:/secrets/token.txt").effect,
1140            Effect::Deny
1141        );
1142    }
1143
1144    #[cfg(windows)]
1145    #[test]
1146    fn a_unc_share_matches_with_or_without_the_verbatim_prefix() {
1147        let p = Policy::permissive()
1148            .layer("base")
1149            .deny_read(r"\\srv\share\*");
1150        assert_eq!(
1151            p.check(Act::Read, r"\\?\UNC\srv\share\secret.txt").effect,
1152            Effect::Deny
1153        );
1154    }
1155
1156    #[cfg(windows)]
1157    #[test]
1158    fn normalising_separators_does_not_make_two_different_paths_match() {
1159        // The negative half: folding `\` to `/` must not widen a rule onto a
1160        // path it never covered. If any of these turned Deny, normalisation is
1161        // over-matching and the fix is worse than the bug.
1162        let p = Policy::permissive()
1163            .layer("base")
1164            .deny_read(r"C:\secrets\*")
1165            .deny_read(r"C:\a\b.txt");
1166        for allowed in [
1167            r"C:\secrets-public\k",  // prefix, not the same directory
1168            r"D:\secrets\k",         // different volume
1169            r"C:\other\secrets.txt", // basename is not the directory
1170            r"C:\a\b.txt.bak",       // suffix past the pattern
1171            r"C:\ab.txt",            // the separator is not nothing
1172        ] {
1173            assert_eq!(
1174                p.check(Act::Read, allowed).effect,
1175                Effect::Allow,
1176                "{allowed} must not be caught by a rule for a different path"
1177            );
1178        }
1179        // Exec and net are names, not paths: a `\` in one stays a literal
1180        // character, so 0.9.0's routing of them through check() is untouched.
1181        let names = Policy::permissive()
1182            .layer("base")
1183            .deny_exec(r"tools\build.exe")
1184            .deny_net(r"a\b.example.com");
1185        assert_eq!(
1186            names.check(Act::Exec, "tools/build.exe").effect,
1187            Effect::Allow
1188        );
1189        assert_eq!(
1190            names.check(Act::Exec, r"tools\build.exe").effect,
1191            Effect::Deny
1192        );
1193        assert_eq!(
1194            names.check(Act::Net, "a/b.example.com:443").effect,
1195            Effect::Allow
1196        );
1197    }
1198
1199    #[cfg(not(windows))]
1200    #[test]
1201    fn a_backslash_is_an_ordinary_filename_character_on_unix() {
1202        // `a\b.txt` is one file here, not `a/b.txt`. Normalising would merge
1203        // two distinct paths — a fail-open bug in the other direction — so the
1204        // Windows folding is cfg'd out and matching stays literal.
1205        let p = Policy::permissive().layer("base").deny_read(r"a/b.txt");
1206        assert_eq!(p.check(Act::Read, r"a\b.txt").effect, Effect::Allow);
1207        assert_eq!(p.check(Act::Read, "a/b.txt").effect, Effect::Deny);
1208
1209        // And a rule naming the literal backslash catches only it.
1210        let literal = Policy::permissive().layer("base").deny_read(r"a\b.txt");
1211        assert_eq!(literal.check(Act::Read, r"a\b.txt").effect, Effect::Deny);
1212        assert_eq!(literal.check(Act::Read, "a/b.txt").effect, Effect::Allow);
1213    }
1214
1215    #[test]
1216    fn explain_and_check_never_disagree() {
1217        let p = base();
1218        for (act, path) in [
1219            (Act::Read, "src/a.rs"),
1220            (Act::Write, "secrets/k"),
1221            (Act::Write, "elsewhere/x"),
1222            (Act::Read, ".env"),
1223            (Act::Exec, "rustc"),
1224        ] {
1225            assert_eq!(p.check(act, path).effect, p.explain(act, path).effect);
1226        }
1227    }
1228}