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