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