Skip to main content

zeph_tools/
permissions.rs

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