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(®ex::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}