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