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;
5
6use glob::Pattern;
7
8pub(crate) use zeph_config::tools::{
9    AutonomyLevel, PermissionAction, PermissionRule, PermissionsConfig,
10};
11
12/// Read-only tool allowlist (available in `ReadOnly` autonomy mode).
13///
14/// Also used, via [`is_readonly_tool`], to bypass the `Supervised`-mode confirmation
15/// default for unconfigured tools — see #5575. A tool added here is trusted to run
16/// without confirmation in *both* modes; do not add anything that mutates state or
17/// executes code.
18const READONLY_TOOLS: &[&str] = &[
19    "read",
20    "find_path",
21    "grep",
22    "list_directory",
23    "web_scrape",
24    "fetch",
25    "load_skill",
26    "invoke_skill",
27];
28
29/// Returns `true` if `tool_id` is a native read-only tool.
30///
31/// Reuses the same [`READONLY_TOOLS`] allowlist that gates `ReadOnly` autonomy mode, so
32/// `Supervised` mode's unconfigured-tool bypass (`TrustGateExecutor::check_trust`) cannot
33/// silently diverge from it — see #5575.
34#[must_use]
35pub(crate) fn is_readonly_tool(tool_id: &str) -> bool {
36    READONLY_TOOLS.contains(&tool_id)
37}
38
39/// Tool permission policy: maps `tool_id` → ordered list of rules.
40/// First matching rule wins; default is `Ask`.
41///
42/// Runtime enforcement is currently implemented for `bash` (`ShellExecutor`).
43/// Other tools rely on prompt filtering via `ToolRegistry::format_for_prompt_filtered`.
44#[derive(Debug, Clone, Default)]
45pub struct PermissionPolicy {
46    rules: HashMap<String, Vec<PermissionRule>>,
47    autonomy_level: AutonomyLevel,
48}
49
50impl PermissionPolicy {
51    #[must_use]
52    pub fn new(rules: HashMap<String, Vec<PermissionRule>>) -> Self {
53        Self {
54            rules,
55            autonomy_level: AutonomyLevel::default(),
56        }
57    }
58
59    /// Set autonomy level (builder pattern).
60    #[must_use]
61    pub fn with_autonomy(mut self, level: AutonomyLevel) -> Self {
62        self.autonomy_level = level;
63        self
64    }
65
66    /// Check permission for a tool invocation. First matching glob wins.
67    #[must_use]
68    pub fn check(&self, tool_id: &str, input: &str) -> PermissionAction {
69        match self.autonomy_level {
70            AutonomyLevel::ReadOnly => {
71                if READONLY_TOOLS.contains(&tool_id) {
72                    PermissionAction::Allow
73                } else {
74                    PermissionAction::Deny
75                }
76            }
77            AutonomyLevel::Full => PermissionAction::Allow,
78            AutonomyLevel::Supervised => {
79                let Some(rules) = self.rules.get(tool_id) else {
80                    return PermissionAction::Ask;
81                };
82                let normalized = input.to_lowercase();
83                for rule in rules {
84                    if let Ok(pat) = Pattern::new(&rule.pattern.to_lowercase())
85                        && pat.matches(&normalized)
86                    {
87                        return rule.action;
88                    }
89                }
90                PermissionAction::Ask
91            }
92            _ => PermissionAction::Deny,
93        }
94    }
95
96    /// Build policy from legacy `blocked_commands` / `confirm_patterns` for "bash" tool.
97    #[must_use]
98    pub fn from_legacy(blocked: &[String], confirm: &[String]) -> Self {
99        let mut rules = Vec::with_capacity(blocked.len() + confirm.len());
100        for cmd in blocked {
101            rules.push(PermissionRule {
102                pattern: format!("*{cmd}*"),
103                action: PermissionAction::Deny,
104            });
105        }
106        for pat in confirm {
107            rules.push(PermissionRule {
108                pattern: format!("*{pat}*"),
109                action: PermissionAction::Ask,
110            });
111        }
112        // Allow everything not explicitly blocked or requiring confirmation.
113        rules.push(PermissionRule {
114            pattern: "*".to_owned(),
115            action: PermissionAction::Allow,
116        });
117        let mut map = HashMap::new();
118        map.insert("bash".to_owned(), rules);
119        Self {
120            rules: map,
121            autonomy_level: AutonomyLevel::default(),
122        }
123    }
124
125    /// Returns true if all rules for a `tool_id` are Deny.
126    #[must_use]
127    pub fn is_fully_denied(&self, tool_id: &str) -> bool {
128        self.rules.get(tool_id).is_some_and(|rules| {
129            !rules.is_empty() && rules.iter().all(|r| r.action == PermissionAction::Deny)
130        })
131    }
132
133    /// Returns a reference to the internal rules map.
134    #[must_use]
135    pub fn rules(&self) -> &HashMap<String, Vec<PermissionRule>> {
136        &self.rules
137    }
138
139    /// Returns the configured autonomy level.
140    #[must_use]
141    pub fn autonomy_level(&self) -> AutonomyLevel {
142        self.autonomy_level
143    }
144}
145
146impl From<PermissionsConfig> for PermissionPolicy {
147    fn from(config: PermissionsConfig) -> Self {
148        Self {
149            rules: config.tools,
150            autonomy_level: AutonomyLevel::default(),
151        }
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    fn policy_with_rules(tool_id: &str, rules: Vec<(&str, PermissionAction)>) -> PermissionPolicy {
160        let rules = rules
161            .into_iter()
162            .map(|(pattern, action)| PermissionRule {
163                pattern: pattern.to_owned(),
164                action,
165            })
166            .collect();
167        let mut map = HashMap::new();
168        map.insert(tool_id.to_owned(), rules);
169        PermissionPolicy::new(map)
170    }
171
172    #[test]
173    fn allow_rule_matches_glob() {
174        let policy = policy_with_rules("bash", vec![("echo *", PermissionAction::Allow)]);
175        assert_eq!(policy.check("bash", "echo hello"), PermissionAction::Allow);
176    }
177
178    #[test]
179    fn deny_rule_blocks() {
180        let policy = policy_with_rules("bash", vec![("*rm -rf*", PermissionAction::Deny)]);
181        assert_eq!(policy.check("bash", "rm -rf /tmp"), PermissionAction::Deny);
182    }
183
184    #[test]
185    fn ask_rule_returns_ask() {
186        let policy = policy_with_rules("bash", vec![("*git push*", PermissionAction::Ask)]);
187        assert_eq!(
188            policy.check("bash", "git push origin main"),
189            PermissionAction::Ask
190        );
191    }
192
193    #[test]
194    fn first_matching_rule_wins() {
195        let policy = policy_with_rules(
196            "bash",
197            vec![
198                ("*safe*", PermissionAction::Allow),
199                ("*", PermissionAction::Deny),
200            ],
201        );
202        assert_eq!(
203            policy.check("bash", "safe command"),
204            PermissionAction::Allow
205        );
206        assert_eq!(
207            policy.check("bash", "dangerous command"),
208            PermissionAction::Deny
209        );
210    }
211
212    #[test]
213    fn no_rules_returns_default_ask() {
214        let policy = PermissionPolicy::default();
215        assert_eq!(policy.check("bash", "anything"), PermissionAction::Ask);
216    }
217
218    #[test]
219    fn wildcard_pattern() {
220        let policy = policy_with_rules("bash", vec![("*", PermissionAction::Allow)]);
221        assert_eq!(policy.check("bash", "any command"), PermissionAction::Allow);
222    }
223
224    #[test]
225    fn case_sensitive_tool_id() {
226        let policy = policy_with_rules("bash", vec![("*", PermissionAction::Deny)]);
227        assert_eq!(policy.check("BASH", "cmd"), PermissionAction::Ask);
228        assert_eq!(policy.check("bash", "cmd"), PermissionAction::Deny);
229    }
230
231    #[test]
232    fn no_matching_rule_falls_through_to_ask() {
233        let policy = policy_with_rules("bash", vec![("echo *", PermissionAction::Allow)]);
234        assert_eq!(policy.check("bash", "ls -la"), PermissionAction::Ask);
235    }
236
237    #[test]
238    fn from_legacy_creates_deny_and_ask_rules() {
239        let policy = PermissionPolicy::from_legacy(&["sudo".to_owned()], &["rm ".to_owned()]);
240        assert_eq!(policy.check("bash", "sudo apt"), PermissionAction::Deny);
241        assert_eq!(policy.check("bash", "rm file"), PermissionAction::Ask);
242        assert_eq!(
243            policy.check("bash", "find . -name foo"),
244            PermissionAction::Allow
245        );
246        assert_eq!(policy.check("bash", "ls -la"), PermissionAction::Allow);
247    }
248
249    #[test]
250    fn is_fully_denied_all_deny() {
251        let policy = policy_with_rules("bash", vec![("*", PermissionAction::Deny)]);
252        assert!(policy.is_fully_denied("bash"));
253    }
254
255    #[test]
256    fn is_fully_denied_mixed() {
257        let policy = policy_with_rules(
258            "bash",
259            vec![
260                ("echo *", PermissionAction::Allow),
261                ("*", PermissionAction::Deny),
262            ],
263        );
264        assert!(!policy.is_fully_denied("bash"));
265    }
266
267    #[test]
268    fn is_fully_denied_no_rules() {
269        let policy = PermissionPolicy::default();
270        assert!(!policy.is_fully_denied("bash"));
271    }
272
273    #[test]
274    fn case_insensitive_input_matching() {
275        let policy = policy_with_rules("bash", vec![("*sudo*", PermissionAction::Deny)]);
276        assert_eq!(policy.check("bash", "SUDO apt"), PermissionAction::Deny);
277        assert_eq!(policy.check("bash", "Sudo apt"), PermissionAction::Deny);
278        assert_eq!(policy.check("bash", "sudo apt"), PermissionAction::Deny);
279    }
280
281    #[test]
282    fn permissions_config_deserialize() {
283        let toml_str = r#"
284            [[bash]]
285            pattern = "*sudo*"
286            action = "deny"
287
288            [[bash]]
289            pattern = "*"
290            action = "ask"
291        "#;
292        let config: PermissionsConfig = toml::from_str(toml_str).unwrap();
293        let policy = PermissionPolicy::from(config);
294        assert_eq!(policy.check("bash", "sudo rm"), PermissionAction::Deny);
295        assert_eq!(policy.check("bash", "echo hi"), PermissionAction::Ask);
296    }
297
298    #[test]
299    fn autonomy_level_deserialize() {
300        use serde::Deserialize;
301        #[derive(Deserialize)]
302        struct Wrapper {
303            level: AutonomyLevel,
304        }
305        let w: Wrapper = toml::from_str(r#"level = "readonly""#).unwrap();
306        assert_eq!(w.level, AutonomyLevel::ReadOnly);
307        let w: Wrapper = toml::from_str(r#"level = "supervised""#).unwrap();
308        assert_eq!(w.level, AutonomyLevel::Supervised);
309        let w: Wrapper = toml::from_str(r#"level = "full""#).unwrap();
310        assert_eq!(w.level, AutonomyLevel::Full);
311    }
312
313    #[test]
314    fn autonomy_level_default_is_supervised() {
315        assert_eq!(AutonomyLevel::default(), AutonomyLevel::Supervised);
316    }
317
318    #[test]
319    fn is_readonly_tool_matches_allowlist() {
320        for tool in READONLY_TOOLS {
321            assert!(is_readonly_tool(tool), "{tool} should be a readonly tool");
322        }
323        assert!(!is_readonly_tool("bash"));
324        assert!(!is_readonly_tool("diagnostics"));
325        assert!(!is_readonly_tool("write"));
326    }
327
328    #[test]
329    fn readonly_allows_readonly_tools() {
330        let policy = PermissionPolicy::default().with_autonomy(AutonomyLevel::ReadOnly);
331        for tool in &[
332            "read",
333            "find_path",
334            "grep",
335            "list_directory",
336            "web_scrape",
337            "fetch",
338        ] {
339            assert_eq!(
340                policy.check(tool, "any input"),
341                PermissionAction::Allow,
342                "expected Allow for read-only tool {tool}"
343            );
344        }
345    }
346
347    #[test]
348    fn readonly_denies_write_tools() {
349        let policy = PermissionPolicy::default().with_autonomy(AutonomyLevel::ReadOnly);
350        assert_eq!(policy.check("bash", "rm -rf /"), PermissionAction::Deny);
351        assert_eq!(
352            policy.check("file_write", "foo.txt"),
353            PermissionAction::Deny
354        );
355    }
356
357    #[test]
358    fn full_allows_everything() {
359        let policy = PermissionPolicy::default().with_autonomy(AutonomyLevel::Full);
360        assert_eq!(policy.check("bash", "rm -rf /"), PermissionAction::Allow);
361        assert_eq!(
362            policy.check("file_write", "foo.txt"),
363            PermissionAction::Allow
364        );
365    }
366
367    #[test]
368    fn supervised_uses_rules() {
369        let policy = policy_with_rules("bash", vec![("*sudo*", PermissionAction::Deny)])
370            .with_autonomy(AutonomyLevel::Supervised);
371        assert_eq!(policy.check("bash", "sudo rm"), PermissionAction::Deny);
372        assert_eq!(policy.check("bash", "echo hi"), PermissionAction::Ask);
373    }
374
375    #[test]
376    fn from_legacy_preserves_supervised_behavior() {
377        let policy = PermissionPolicy::from_legacy(&["sudo".to_owned()], &["rm ".to_owned()]);
378        assert_eq!(policy.check("bash", "sudo apt"), PermissionAction::Deny);
379        assert_eq!(policy.check("bash", "rm file"), PermissionAction::Ask);
380        assert_eq!(policy.check("bash", "echo hello"), PermissionAction::Allow);
381    }
382}