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