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