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