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