Skip to main content

zeph_tools/
permissions.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::collections::{HashMap, HashSet};
5
6use glob::Pattern;
7
8pub(crate) use zeph_config::tools::{
9    AutonomyLevel, PermissionAction, PermissionRule, PermissionsConfig,
10};
11
12/// Read-only tool allowlist and its `is_readonly_tool` predicate.
13///
14/// Canonical definition lives in `zeph_common::tool_classification` — shared with
15/// `zeph-orchestration`, which uses it to classify tool calls in a task's real execution
16/// trace as read vs. write-type (#6397). Re-exported here under the historical names so
17/// existing call sites in this crate (`permissions::READONLY_TOOLS`,
18/// `permissions::is_readonly_tool`) keep working unchanged.
19pub(crate) use zeph_common::tool_classification::{READONLY_TOOLS, is_readonly_tool};
20
21/// Tool permission policy: maps `tool_id` → ordered list of rules.
22/// First matching rule wins; default is `Ask`.
23///
24/// Runtime enforcement is currently implemented for `bash` (`ShellExecutor`).
25/// Other tools rely on prompt filtering via `ToolRegistry::format_for_prompt_filtered`.
26#[derive(Debug, Clone, Default)]
27pub struct PermissionPolicy {
28    rules: HashMap<String, Vec<PermissionRule>>,
29    autonomy_level: AutonomyLevel,
30}
31
32impl PermissionPolicy {
33    #[must_use]
34    pub fn new(rules: HashMap<String, Vec<PermissionRule>>) -> Self {
35        Self {
36            rules,
37            autonomy_level: AutonomyLevel::default(),
38        }
39    }
40
41    /// Set autonomy level (builder pattern).
42    #[must_use]
43    pub fn with_autonomy(mut self, level: AutonomyLevel) -> Self {
44        self.autonomy_level = level;
45        self
46    }
47
48    /// Check permission for a tool invocation. First matching glob wins.
49    #[must_use]
50    pub fn check(&self, tool_id: &str, input: &str) -> PermissionAction {
51        match self.autonomy_level {
52            AutonomyLevel::ReadOnly => {
53                if READONLY_TOOLS.contains(&tool_id) {
54                    PermissionAction::Allow
55                } else {
56                    PermissionAction::Deny
57                }
58            }
59            AutonomyLevel::Full => PermissionAction::Allow,
60            AutonomyLevel::Supervised => {
61                let Some(rules) = self.rules.get(tool_id) else {
62                    return PermissionAction::Ask;
63                };
64                let normalized = input.to_lowercase();
65                for rule in rules {
66                    if let Ok(pat) = Pattern::new(&rule.pattern.to_lowercase())
67                        && pat.matches(&normalized)
68                    {
69                        return rule.action;
70                    }
71                }
72                PermissionAction::Ask
73            }
74            _ => PermissionAction::Deny,
75        }
76    }
77
78    /// Build policy from legacy `blocked_commands` / `confirm_patterns` for "bash" tool.
79    #[must_use]
80    pub fn from_legacy(blocked: &[String], confirm: &[String]) -> Self {
81        let mut rules = Vec::with_capacity(blocked.len() + confirm.len());
82        for cmd in blocked {
83            rules.push(PermissionRule {
84                pattern: format!("*{cmd}*"),
85                action: PermissionAction::Deny,
86            });
87        }
88        for pat in confirm {
89            rules.push(PermissionRule {
90                pattern: format!("*{pat}*"),
91                action: PermissionAction::Ask,
92            });
93        }
94        // Allow everything not explicitly blocked or requiring confirmation.
95        rules.push(PermissionRule {
96            pattern: "*".to_owned(),
97            action: PermissionAction::Allow,
98        });
99        let mut map = HashMap::new();
100        map.insert("bash".to_owned(), rules);
101        Self {
102            rules: map,
103            autonomy_level: AutonomyLevel::default(),
104        }
105    }
106
107    /// Returns true if all rules for a `tool_id` are Deny.
108    #[must_use]
109    pub fn is_fully_denied(&self, tool_id: &str) -> bool {
110        self.rules.get(tool_id).is_some_and(|rules| {
111            !rules.is_empty() && rules.iter().all(|r| r.action == PermissionAction::Deny)
112        })
113    }
114
115    /// Returns a reference to the internal rules map.
116    #[must_use]
117    pub fn rules(&self) -> &HashMap<String, Vec<PermissionRule>> {
118        &self.rules
119    }
120
121    /// Returns the configured autonomy level.
122    #[must_use]
123    pub fn autonomy_level(&self) -> AutonomyLevel {
124        self.autonomy_level
125    }
126
127    /// Derive the subset of `universe` this policy does not wholesale-deny.
128    ///
129    /// Intended as a defense-in-depth / tool-visibility narrowing signal for sub-agent
130    /// spawns (#6527): a session whose own `[tool.permissions]` rules wholesale-deny a
131    /// tool (a catch-all `Deny` not preceded by an `Allow`/`Ask` rule for that tool)
132    /// should not hand that tool's definition to a spawned sub-agent either. This is
133    /// **not** the runtime security boundary — every sub-agent tool call already passes
134    /// through the parent's own `TrustGate`-wrapped executor transitively, so a
135    /// wholesale-denied tool is already blocked for the child at call time regardless of
136    /// this method's output. This method only controls what the child's LLM *sees* in
137    /// its tool catalog, avoiding wasted turns on tools that would be denied anyway.
138    ///
139    /// Returns `None` when no narrowing is warranted:
140    /// - `autonomy_level` is not [`AutonomyLevel::Supervised`] (`Full` ignores rules
141    ///   entirely; `ReadOnly` also ignores the rules map — see below).
142    /// - No tool in `universe` is actually wholesale-denied.
143    ///
144    /// Returning `Some(universe)` when nothing is denied would be a behavior change for
145    /// callers that replace [`ToolPolicy::InheritAll`](https://docs.rs/zeph-subagent) with
146    /// the returned set: an unrestricted child's tool list would be frozen at spawn time,
147    /// making dynamically-added MCP tools invisible. So `None` is returned unless at
148    /// least one tool is genuinely narrowed.
149    ///
150    /// `ReadOnly` asymmetry: under `ReadOnly`, [`check`][Self::check] ignores the rules
151    /// map entirely (only [`READONLY_TOOLS`] matters), so there is nothing in the rules to
152    /// propagate here — this method returns `None`, leaving the child's tool *list*
153    /// un-narrowed. This is inconsistent with the `Supervised` path (which does narrow the
154    /// list), but not a security gap: the parent's `TrustGate` still denies non-read-only
155    /// tool calls for the child at runtime, exactly as it does for the parent itself.
156    ///
157    /// A tool is "wholesale-denied" when the first rule that would match ANY input for
158    /// that tool is a catch-all `Deny` (pattern `""`, `"*"`, or `"**"`), mirroring
159    /// [`check`][Self::check]'s first-match-wins semantics. This deliberately treats `*`
160    /// as catch-all even though `glob::Pattern`'s `*` does not cross `/` at the
161    /// enforcement layer (so a `("*", Deny)` rule technically still lets a `/`-bearing
162    /// input like `rm -rf /home` fall through to `Ask` in `check`) — operator intent
163    /// ("deny this tool") wins over that glob quirk for allowlist derivation. This can
164    /// over-restrict the child's visible tool list relative to what the parent could
165    /// still do for `/`-bearing inputs, which is acceptable for a hygiene-only signal.
166    ///
167    /// Rule-map keys are matched case-insensitively against `normalize_tool_id`-style
168    /// bare tool ids (lowercased, stripped of any `(...)` argument suffix) so a
169    /// differently-cased or parenthesized config key like `"Bash(cargo *)"` still matches
170    /// the runtime tool id `"bash"` in `universe`.
171    #[must_use]
172    pub fn effective_tool_allowlist(
173        &self,
174        universe: impl IntoIterator<Item = String>,
175    ) -> Option<HashSet<String>> {
176        if self.autonomy_level != AutonomyLevel::Supervised {
177            return None;
178        }
179
180        let mut normalized_rules: HashMap<String, &Vec<PermissionRule>> = HashMap::new();
181        for (tool_id, rules) in &self.rules {
182            normalized_rules.insert(normalize_tool_id(tool_id), rules);
183        }
184
185        let universe: HashSet<String> = universe
186            .into_iter()
187            .map(|t| normalize_tool_id(&t))
188            .collect();
189        let kept: HashSet<String> = universe
190            .iter()
191            .filter(|tool| {
192                !normalized_rules
193                    .get(tool.as_str())
194                    .is_some_and(|rules| is_wholesale_denied(rules))
195            })
196            .cloned()
197            .collect();
198
199        if kept == universe { None } else { Some(kept) }
200    }
201}
202
203/// Lowercase and strip any `(...)` argument suffix, mirroring
204/// `zeph_subagent::filter::normalize_tool_id` without introducing a `zeph-tools` →
205/// `zeph-subagent` dependency (the reverse dependency already exists).
206fn normalize_tool_id(s: &str) -> String {
207    let base = s.split('(').next().unwrap_or(s);
208    base.trim().to_lowercase()
209}
210
211/// Returns `true` if the first rule that would match any input for this tool is a
212/// catch-all [`PermissionAction::Deny`] — i.e. no input can reach `Allow` or `Ask`.
213/// Mirrors [`PermissionPolicy::check`]'s first-match-wins glob evaluation without
214/// depending on a specific probe input.
215fn is_wholesale_denied(rules: &[PermissionRule]) -> bool {
216    for rule in rules {
217        match rule.action {
218            PermissionAction::Deny if is_catch_all(&rule.pattern) => return true,
219            PermissionAction::Deny => {}
220            _ => return false,
221        }
222    }
223    false
224}
225
226/// A pattern that matches every input, regardless of `glob::Pattern`'s `/`-crossing
227/// quirk — see [`PermissionPolicy::effective_tool_allowlist`] doc comment for why `*` is
228/// treated as catch-all here despite not being one at the `check()` enforcement layer.
229fn is_catch_all(pattern: &str) -> bool {
230    matches!(pattern.trim(), "" | "*" | "**")
231}
232
233impl From<PermissionsConfig> for PermissionPolicy {
234    fn from(config: PermissionsConfig) -> Self {
235        Self {
236            rules: config.tools,
237            autonomy_level: AutonomyLevel::default(),
238        }
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    fn policy_with_rules(tool_id: &str, rules: Vec<(&str, PermissionAction)>) -> PermissionPolicy {
247        let rules = rules
248            .into_iter()
249            .map(|(pattern, action)| PermissionRule {
250                pattern: pattern.to_owned(),
251                action,
252            })
253            .collect();
254        let mut map = HashMap::new();
255        map.insert(tool_id.to_owned(), rules);
256        PermissionPolicy::new(map)
257    }
258
259    #[test]
260    fn allow_rule_matches_glob() {
261        let policy = policy_with_rules("bash", vec![("echo *", PermissionAction::Allow)]);
262        assert_eq!(policy.check("bash", "echo hello"), PermissionAction::Allow);
263    }
264
265    #[test]
266    fn deny_rule_blocks() {
267        let policy = policy_with_rules("bash", vec![("*rm -rf*", PermissionAction::Deny)]);
268        assert_eq!(policy.check("bash", "rm -rf /tmp"), PermissionAction::Deny);
269    }
270
271    #[test]
272    fn ask_rule_returns_ask() {
273        let policy = policy_with_rules("bash", vec![("*git push*", PermissionAction::Ask)]);
274        assert_eq!(
275            policy.check("bash", "git push origin main"),
276            PermissionAction::Ask
277        );
278    }
279
280    #[test]
281    fn first_matching_rule_wins() {
282        let policy = policy_with_rules(
283            "bash",
284            vec![
285                ("*safe*", PermissionAction::Allow),
286                ("*", PermissionAction::Deny),
287            ],
288        );
289        assert_eq!(
290            policy.check("bash", "safe command"),
291            PermissionAction::Allow
292        );
293        assert_eq!(
294            policy.check("bash", "dangerous command"),
295            PermissionAction::Deny
296        );
297    }
298
299    #[test]
300    fn no_rules_returns_default_ask() {
301        let policy = PermissionPolicy::default();
302        assert_eq!(policy.check("bash", "anything"), PermissionAction::Ask);
303    }
304
305    #[test]
306    fn wildcard_pattern() {
307        let policy = policy_with_rules("bash", vec![("*", PermissionAction::Allow)]);
308        assert_eq!(policy.check("bash", "any command"), PermissionAction::Allow);
309    }
310
311    #[test]
312    fn case_sensitive_tool_id() {
313        let policy = policy_with_rules("bash", vec![("*", PermissionAction::Deny)]);
314        assert_eq!(policy.check("BASH", "cmd"), PermissionAction::Ask);
315        assert_eq!(policy.check("bash", "cmd"), PermissionAction::Deny);
316    }
317
318    #[test]
319    fn no_matching_rule_falls_through_to_ask() {
320        let policy = policy_with_rules("bash", vec![("echo *", PermissionAction::Allow)]);
321        assert_eq!(policy.check("bash", "ls -la"), PermissionAction::Ask);
322    }
323
324    #[test]
325    fn from_legacy_creates_deny_and_ask_rules() {
326        let policy = PermissionPolicy::from_legacy(&["sudo".to_owned()], &["rm ".to_owned()]);
327        assert_eq!(policy.check("bash", "sudo apt"), PermissionAction::Deny);
328        assert_eq!(policy.check("bash", "rm file"), PermissionAction::Ask);
329        assert_eq!(
330            policy.check("bash", "find . -name foo"),
331            PermissionAction::Allow
332        );
333        assert_eq!(policy.check("bash", "ls -la"), PermissionAction::Allow);
334    }
335
336    #[test]
337    fn is_fully_denied_all_deny() {
338        let policy = policy_with_rules("bash", vec![("*", PermissionAction::Deny)]);
339        assert!(policy.is_fully_denied("bash"));
340    }
341
342    #[test]
343    fn is_fully_denied_mixed() {
344        let policy = policy_with_rules(
345            "bash",
346            vec![
347                ("echo *", PermissionAction::Allow),
348                ("*", PermissionAction::Deny),
349            ],
350        );
351        assert!(!policy.is_fully_denied("bash"));
352    }
353
354    #[test]
355    fn is_fully_denied_no_rules() {
356        let policy = PermissionPolicy::default();
357        assert!(!policy.is_fully_denied("bash"));
358    }
359
360    #[test]
361    fn case_insensitive_input_matching() {
362        let policy = policy_with_rules("bash", vec![("*sudo*", PermissionAction::Deny)]);
363        assert_eq!(policy.check("bash", "SUDO apt"), PermissionAction::Deny);
364        assert_eq!(policy.check("bash", "Sudo apt"), PermissionAction::Deny);
365        assert_eq!(policy.check("bash", "sudo apt"), PermissionAction::Deny);
366    }
367
368    #[test]
369    fn permissions_config_deserialize() {
370        let toml_str = r#"
371            [[bash]]
372            pattern = "*sudo*"
373            action = "deny"
374
375            [[bash]]
376            pattern = "*"
377            action = "ask"
378        "#;
379        let config: PermissionsConfig = toml::from_str(toml_str).unwrap();
380        let policy = PermissionPolicy::from(config);
381        assert_eq!(policy.check("bash", "sudo rm"), PermissionAction::Deny);
382        assert_eq!(policy.check("bash", "echo hi"), PermissionAction::Ask);
383    }
384
385    #[test]
386    fn autonomy_level_deserialize() {
387        use serde::Deserialize;
388        #[derive(Deserialize)]
389        struct Wrapper {
390            level: AutonomyLevel,
391        }
392        let w: Wrapper = toml::from_str(r#"level = "readonly""#).unwrap();
393        assert_eq!(w.level, AutonomyLevel::ReadOnly);
394        let w: Wrapper = toml::from_str(r#"level = "supervised""#).unwrap();
395        assert_eq!(w.level, AutonomyLevel::Supervised);
396        let w: Wrapper = toml::from_str(r#"level = "full""#).unwrap();
397        assert_eq!(w.level, AutonomyLevel::Full);
398    }
399
400    #[test]
401    fn autonomy_level_default_is_supervised() {
402        assert_eq!(AutonomyLevel::default(), AutonomyLevel::Supervised);
403    }
404
405    #[test]
406    fn is_readonly_tool_matches_allowlist() {
407        for tool in READONLY_TOOLS {
408            assert!(is_readonly_tool(tool), "{tool} should be a readonly tool");
409        }
410        assert!(!is_readonly_tool("bash"));
411        assert!(!is_readonly_tool("diagnostics"));
412        assert!(!is_readonly_tool("write"));
413    }
414
415    #[test]
416    fn readonly_allows_readonly_tools() {
417        let policy = PermissionPolicy::default().with_autonomy(AutonomyLevel::ReadOnly);
418        for tool in &[
419            "read",
420            "find_path",
421            "grep",
422            "list_directory",
423            "web_scrape",
424            "fetch",
425        ] {
426            assert_eq!(
427                policy.check(tool, "any input"),
428                PermissionAction::Allow,
429                "expected Allow for read-only tool {tool}"
430            );
431        }
432    }
433
434    #[test]
435    fn readonly_denies_write_tools() {
436        let policy = PermissionPolicy::default().with_autonomy(AutonomyLevel::ReadOnly);
437        assert_eq!(policy.check("bash", "rm -rf /"), PermissionAction::Deny);
438        assert_eq!(
439            policy.check("file_write", "foo.txt"),
440            PermissionAction::Deny
441        );
442    }
443
444    #[test]
445    fn full_allows_everything() {
446        let policy = PermissionPolicy::default().with_autonomy(AutonomyLevel::Full);
447        assert_eq!(policy.check("bash", "rm -rf /"), PermissionAction::Allow);
448        assert_eq!(
449            policy.check("file_write", "foo.txt"),
450            PermissionAction::Allow
451        );
452    }
453
454    #[test]
455    fn supervised_uses_rules() {
456        let policy = policy_with_rules("bash", vec![("*sudo*", PermissionAction::Deny)])
457            .with_autonomy(AutonomyLevel::Supervised);
458        assert_eq!(policy.check("bash", "sudo rm"), PermissionAction::Deny);
459        assert_eq!(policy.check("bash", "echo hi"), PermissionAction::Ask);
460    }
461
462    #[test]
463    fn from_legacy_preserves_supervised_behavior() {
464        let policy = PermissionPolicy::from_legacy(&["sudo".to_owned()], &["rm ".to_owned()]);
465        assert_eq!(policy.check("bash", "sudo apt"), PermissionAction::Deny);
466        assert_eq!(policy.check("bash", "rm file"), PermissionAction::Ask);
467        assert_eq!(policy.check("bash", "echo hello"), PermissionAction::Allow);
468    }
469
470    // ── effective_tool_allowlist tests (#6527) ─────────────────────────────
471
472    fn universe(tools: &[&str]) -> Vec<String> {
473        tools.iter().map(|s| (*s).to_owned()).collect()
474    }
475
476    #[test]
477    fn effective_allowlist_empty_rules_returns_none() {
478        let policy = PermissionPolicy::default().with_autonomy(AutonomyLevel::Supervised);
479        assert_eq!(
480            policy.effective_tool_allowlist(universe(&["bash", "read"])),
481            None,
482            "no rules at all -> no narrowing needed"
483        );
484    }
485
486    #[test]
487    fn effective_allowlist_star_catch_all_deny_removes_tool() {
488        // Deliberate divergence from glob::Pattern's `*`-does-not-cross-`/` semantics
489        // (M2, critic): operator intent for "deny this tool" wins over literal glob
490        // matching. Do not "fix" this back to exact glob semantics.
491        let policy = policy_with_rules("bash", vec![("*", PermissionAction::Deny)])
492            .with_autonomy(AutonomyLevel::Supervised);
493        let result = policy
494            .effective_tool_allowlist(universe(&["bash", "read"]))
495            .expect("bash must be wholesale-denied");
496        assert!(!result.contains("bash"));
497        assert!(result.contains("read"));
498    }
499
500    #[test]
501    fn effective_allowlist_double_star_and_empty_pattern_are_catch_all() {
502        for pattern in ["**", ""] {
503            let policy = policy_with_rules("bash", vec![(pattern, PermissionAction::Deny)])
504                .with_autonomy(AutonomyLevel::Supervised);
505            let result = policy
506                .effective_tool_allowlist(universe(&["bash", "read"]))
507                .unwrap_or_else(|| panic!("pattern {pattern:?} must be treated as catch-all"));
508            assert!(
509                !result.contains("bash"),
510                "pattern {pattern:?} must deny bash"
511            );
512        }
513    }
514
515    #[test]
516    fn effective_allowlist_narrower_deny_only_keeps_tool() {
517        // A single narrower Deny (not catch-all) must not wholesale-deny the tool —
518        // reusing is_fully_denied here would over-restrict (architect §2, rejected
519        // alternative).
520        let policy = policy_with_rules("bash", vec![("*rm -rf*", PermissionAction::Deny)])
521            .with_autonomy(AutonomyLevel::Supervised);
522        assert_eq!(
523            policy.effective_tool_allowlist(universe(&["bash", "read"])),
524            None,
525            "narrower deny alone must not wholesale-deny bash"
526        );
527    }
528
529    #[test]
530    fn effective_allowlist_allow_before_catch_all_deny_keeps_tool() {
531        let policy = policy_with_rules(
532            "bash",
533            vec![
534                ("echo *", PermissionAction::Allow),
535                ("*", PermissionAction::Deny),
536            ],
537        )
538        .with_autonomy(AutonomyLevel::Supervised);
539        assert_eq!(
540            policy.effective_tool_allowlist(universe(&["bash", "read"])),
541            None,
542            "an earlier Allow rule means the tool is not wholesale-denied"
543        );
544    }
545
546    #[test]
547    fn effective_allowlist_ask_before_catch_all_deny_keeps_tool() {
548        let policy = policy_with_rules(
549            "bash",
550            vec![
551                ("*sudo*", PermissionAction::Ask),
552                ("*", PermissionAction::Deny),
553            ],
554        )
555        .with_autonomy(AutonomyLevel::Supervised);
556        assert_eq!(
557            policy.effective_tool_allowlist(universe(&["bash", "read"])),
558            None,
559            "an earlier Ask rule means the tool is not wholesale-denied"
560        );
561    }
562
563    #[test]
564    fn effective_allowlist_full_autonomy_returns_none() {
565        let policy = policy_with_rules("bash", vec![("*", PermissionAction::Deny)])
566            .with_autonomy(AutonomyLevel::Full);
567        assert_eq!(
568            policy.effective_tool_allowlist(universe(&["bash", "read"])),
569            None,
570            "Full autonomy ignores rules entirely"
571        );
572    }
573
574    #[test]
575    fn effective_allowlist_readonly_autonomy_returns_none() {
576        // M1 (critic, decision made): ReadOnly's check() ignores the rules map entirely,
577        // so there is nothing to propagate — this is a documented asymmetry vs.
578        // Supervised, not a security gap (TrustGate backstops the child at runtime).
579        let policy = policy_with_rules("bash", vec![("*", PermissionAction::Deny)])
580            .with_autonomy(AutonomyLevel::ReadOnly);
581        assert_eq!(
582            policy.effective_tool_allowlist(universe(&["bash", "read"])),
583            None
584        );
585    }
586
587    #[test]
588    fn effective_allowlist_no_wholesale_deny_returns_none_not_full_universe() {
589        // §2a (architect, critic-confirmed): returning Some(universe) when nothing is
590        // denied would freeze InheritAll children's tool list at spawn time, hiding
591        // dynamically-added MCP tools. Must return None instead.
592        let policy = policy_with_rules("bash", vec![("*sudo*", PermissionAction::Deny)])
593            .with_autonomy(AutonomyLevel::Supervised);
594        assert_eq!(
595            policy.effective_tool_allowlist(universe(&["bash", "read", "write"])),
596            None
597        );
598    }
599
600    #[test]
601    fn effective_allowlist_normalizes_mixed_case_parenthesized_rule_key() {
602        // M3 (critic, must fix): rules() is keyed by raw config tool_ids; the universe is
603        // normalized. A rule key like "Bash(cargo *)" must still match the runtime id
604        // "bash", or the deny is silently missed (under-restriction).
605        let mut map = HashMap::new();
606        map.insert(
607            "Bash(cargo *)".to_owned(),
608            vec![PermissionRule {
609                pattern: "*".to_owned(),
610                action: PermissionAction::Deny,
611            }],
612        );
613        let policy = PermissionPolicy::new(map).with_autonomy(AutonomyLevel::Supervised);
614        let result = policy
615            .effective_tool_allowlist(universe(&["bash", "read"]))
616            .expect("mixed-case parenthesized rule key must still match normalized 'bash'");
617        assert!(!result.contains("bash"));
618        assert!(result.contains("read"));
619    }
620
621    #[test]
622    fn effective_allowlist_multiple_tools_mixed_deny() {
623        let mut map = HashMap::new();
624        map.insert(
625            "bash".to_owned(),
626            vec![PermissionRule {
627                pattern: "*".to_owned(),
628                action: PermissionAction::Deny,
629            }],
630        );
631        map.insert(
632            "fetch".to_owned(),
633            vec![PermissionRule {
634                pattern: "*".to_owned(),
635                action: PermissionAction::Deny,
636            }],
637        );
638        let policy = PermissionPolicy::new(map).with_autonomy(AutonomyLevel::Supervised);
639        let result = policy
640            .effective_tool_allowlist(universe(&["bash", "fetch", "read"]))
641            .expect("at least one wholesale-denied tool must narrow the set");
642        assert_eq!(result.len(), 1);
643        assert!(result.contains("read"));
644    }
645}