1use std::collections::{HashMap, HashSet};
5
6use glob::Pattern;
7
8pub(crate) use zeph_config::tools::{
9 AutonomyLevel, PermissionAction, PermissionRule, PermissionsConfig,
10};
11
12pub(crate) use zeph_common::tool_classification::{READONLY_TOOLS, is_readonly_tool};
20
21#[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 #[must_use]
43 pub fn with_autonomy(mut self, level: AutonomyLevel) -> Self {
44 self.autonomy_level = level;
45 self
46 }
47
48 #[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 #[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 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 #[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 #[must_use]
117 pub fn rules(&self) -> &HashMap<String, Vec<PermissionRule>> {
118 &self.rules
119 }
120
121 #[must_use]
123 pub fn autonomy_level(&self) -> AutonomyLevel {
124 self.autonomy_level
125 }
126
127 #[must_use]
172 pub fn effective_tool_allowlist(
173 &self,
174 universe: impl IntoIterator<Item = String>,
175 ) -> Option<HashSet<String>> {
176 if self.autonomy_level != AutonomyLevel::Supervised {
177 return None;
178 }
179
180 let mut normalized_rules: HashMap<String, &Vec<PermissionRule>> = HashMap::new();
181 for (tool_id, rules) in &self.rules {
182 normalized_rules.insert(normalize_tool_id(tool_id), rules);
183 }
184
185 let universe: HashSet<String> = universe
186 .into_iter()
187 .map(|t| normalize_tool_id(&t))
188 .collect();
189 let kept: HashSet<String> = universe
190 .iter()
191 .filter(|tool| {
192 !normalized_rules
193 .get(tool.as_str())
194 .is_some_and(|rules| is_wholesale_denied(rules))
195 })
196 .cloned()
197 .collect();
198
199 if kept == universe { None } else { Some(kept) }
200 }
201}
202
203fn normalize_tool_id(s: &str) -> String {
207 let base = s.split('(').next().unwrap_or(s);
208 base.trim().to_lowercase()
209}
210
211fn is_wholesale_denied(rules: &[PermissionRule]) -> bool {
216 for rule in rules {
217 match rule.action {
218 PermissionAction::Deny if is_catch_all(&rule.pattern) => return true,
219 PermissionAction::Deny => {}
220 _ => return false,
221 }
222 }
223 false
224}
225
226fn is_catch_all(pattern: &str) -> bool {
230 matches!(pattern.trim(), "" | "*" | "**")
231}
232
233impl From<PermissionsConfig> for PermissionPolicy {
234 fn from(config: PermissionsConfig) -> Self {
235 Self {
236 rules: config.tools,
237 autonomy_level: AutonomyLevel::default(),
238 }
239 }
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245
246 fn policy_with_rules(tool_id: &str, rules: Vec<(&str, PermissionAction)>) -> PermissionPolicy {
247 let rules = rules
248 .into_iter()
249 .map(|(pattern, action)| PermissionRule {
250 pattern: pattern.to_owned(),
251 action,
252 })
253 .collect();
254 let mut map = HashMap::new();
255 map.insert(tool_id.to_owned(), rules);
256 PermissionPolicy::new(map)
257 }
258
259 #[test]
260 fn allow_rule_matches_glob() {
261 let policy = policy_with_rules("bash", vec![("echo *", PermissionAction::Allow)]);
262 assert_eq!(policy.check("bash", "echo hello"), PermissionAction::Allow);
263 }
264
265 #[test]
266 fn deny_rule_blocks() {
267 let policy = policy_with_rules("bash", vec![("*rm -rf*", PermissionAction::Deny)]);
268 assert_eq!(policy.check("bash", "rm -rf /tmp"), PermissionAction::Deny);
269 }
270
271 #[test]
272 fn ask_rule_returns_ask() {
273 let policy = policy_with_rules("bash", vec![("*git push*", PermissionAction::Ask)]);
274 assert_eq!(
275 policy.check("bash", "git push origin main"),
276 PermissionAction::Ask
277 );
278 }
279
280 #[test]
281 fn first_matching_rule_wins() {
282 let policy = policy_with_rules(
283 "bash",
284 vec![
285 ("*safe*", PermissionAction::Allow),
286 ("*", PermissionAction::Deny),
287 ],
288 );
289 assert_eq!(
290 policy.check("bash", "safe command"),
291 PermissionAction::Allow
292 );
293 assert_eq!(
294 policy.check("bash", "dangerous command"),
295 PermissionAction::Deny
296 );
297 }
298
299 #[test]
300 fn no_rules_returns_default_ask() {
301 let policy = PermissionPolicy::default();
302 assert_eq!(policy.check("bash", "anything"), PermissionAction::Ask);
303 }
304
305 #[test]
306 fn wildcard_pattern() {
307 let policy = policy_with_rules("bash", vec![("*", PermissionAction::Allow)]);
308 assert_eq!(policy.check("bash", "any command"), PermissionAction::Allow);
309 }
310
311 #[test]
312 fn case_sensitive_tool_id() {
313 let policy = policy_with_rules("bash", vec![("*", PermissionAction::Deny)]);
314 assert_eq!(policy.check("BASH", "cmd"), PermissionAction::Ask);
315 assert_eq!(policy.check("bash", "cmd"), PermissionAction::Deny);
316 }
317
318 #[test]
319 fn no_matching_rule_falls_through_to_ask() {
320 let policy = policy_with_rules("bash", vec![("echo *", PermissionAction::Allow)]);
321 assert_eq!(policy.check("bash", "ls -la"), PermissionAction::Ask);
322 }
323
324 #[test]
325 fn from_legacy_creates_deny_and_ask_rules() {
326 let policy = PermissionPolicy::from_legacy(&["sudo".to_owned()], &["rm ".to_owned()]);
327 assert_eq!(policy.check("bash", "sudo apt"), PermissionAction::Deny);
328 assert_eq!(policy.check("bash", "rm file"), PermissionAction::Ask);
329 assert_eq!(
330 policy.check("bash", "find . -name foo"),
331 PermissionAction::Allow
332 );
333 assert_eq!(policy.check("bash", "ls -la"), PermissionAction::Allow);
334 }
335
336 #[test]
337 fn is_fully_denied_all_deny() {
338 let policy = policy_with_rules("bash", vec![("*", PermissionAction::Deny)]);
339 assert!(policy.is_fully_denied("bash"));
340 }
341
342 #[test]
343 fn is_fully_denied_mixed() {
344 let policy = policy_with_rules(
345 "bash",
346 vec![
347 ("echo *", PermissionAction::Allow),
348 ("*", PermissionAction::Deny),
349 ],
350 );
351 assert!(!policy.is_fully_denied("bash"));
352 }
353
354 #[test]
355 fn is_fully_denied_no_rules() {
356 let policy = PermissionPolicy::default();
357 assert!(!policy.is_fully_denied("bash"));
358 }
359
360 #[test]
361 fn case_insensitive_input_matching() {
362 let policy = policy_with_rules("bash", vec![("*sudo*", PermissionAction::Deny)]);
363 assert_eq!(policy.check("bash", "SUDO apt"), PermissionAction::Deny);
364 assert_eq!(policy.check("bash", "Sudo apt"), PermissionAction::Deny);
365 assert_eq!(policy.check("bash", "sudo apt"), PermissionAction::Deny);
366 }
367
368 #[test]
369 fn permissions_config_deserialize() {
370 let toml_str = r#"
371 [[bash]]
372 pattern = "*sudo*"
373 action = "deny"
374
375 [[bash]]
376 pattern = "*"
377 action = "ask"
378 "#;
379 let config: PermissionsConfig = toml::from_str(toml_str).unwrap();
380 let policy = PermissionPolicy::from(config);
381 assert_eq!(policy.check("bash", "sudo rm"), PermissionAction::Deny);
382 assert_eq!(policy.check("bash", "echo hi"), PermissionAction::Ask);
383 }
384
385 #[test]
386 fn autonomy_level_deserialize() {
387 use serde::Deserialize;
388 #[derive(Deserialize)]
389 struct Wrapper {
390 level: AutonomyLevel,
391 }
392 let w: Wrapper = toml::from_str(r#"level = "readonly""#).unwrap();
393 assert_eq!(w.level, AutonomyLevel::ReadOnly);
394 let w: Wrapper = toml::from_str(r#"level = "supervised""#).unwrap();
395 assert_eq!(w.level, AutonomyLevel::Supervised);
396 let w: Wrapper = toml::from_str(r#"level = "full""#).unwrap();
397 assert_eq!(w.level, AutonomyLevel::Full);
398 }
399
400 #[test]
401 fn autonomy_level_default_is_supervised() {
402 assert_eq!(AutonomyLevel::default(), AutonomyLevel::Supervised);
403 }
404
405 #[test]
406 fn is_readonly_tool_matches_allowlist() {
407 for tool in READONLY_TOOLS {
408 assert!(is_readonly_tool(tool), "{tool} should be a readonly tool");
409 }
410 assert!(!is_readonly_tool("bash"));
411 assert!(!is_readonly_tool("diagnostics"));
412 assert!(!is_readonly_tool("write"));
413 }
414
415 #[test]
416 fn readonly_allows_readonly_tools() {
417 let policy = PermissionPolicy::default().with_autonomy(AutonomyLevel::ReadOnly);
418 for tool in &[
419 "read",
420 "find_path",
421 "grep",
422 "list_directory",
423 "web_scrape",
424 "fetch",
425 ] {
426 assert_eq!(
427 policy.check(tool, "any input"),
428 PermissionAction::Allow,
429 "expected Allow for read-only tool {tool}"
430 );
431 }
432 }
433
434 #[test]
435 fn readonly_denies_write_tools() {
436 let policy = PermissionPolicy::default().with_autonomy(AutonomyLevel::ReadOnly);
437 assert_eq!(policy.check("bash", "rm -rf /"), PermissionAction::Deny);
438 assert_eq!(
439 policy.check("file_write", "foo.txt"),
440 PermissionAction::Deny
441 );
442 }
443
444 #[test]
445 fn full_allows_everything() {
446 let policy = PermissionPolicy::default().with_autonomy(AutonomyLevel::Full);
447 assert_eq!(policy.check("bash", "rm -rf /"), PermissionAction::Allow);
448 assert_eq!(
449 policy.check("file_write", "foo.txt"),
450 PermissionAction::Allow
451 );
452 }
453
454 #[test]
455 fn supervised_uses_rules() {
456 let policy = policy_with_rules("bash", vec![("*sudo*", PermissionAction::Deny)])
457 .with_autonomy(AutonomyLevel::Supervised);
458 assert_eq!(policy.check("bash", "sudo rm"), PermissionAction::Deny);
459 assert_eq!(policy.check("bash", "echo hi"), PermissionAction::Ask);
460 }
461
462 #[test]
463 fn from_legacy_preserves_supervised_behavior() {
464 let policy = PermissionPolicy::from_legacy(&["sudo".to_owned()], &["rm ".to_owned()]);
465 assert_eq!(policy.check("bash", "sudo apt"), PermissionAction::Deny);
466 assert_eq!(policy.check("bash", "rm file"), PermissionAction::Ask);
467 assert_eq!(policy.check("bash", "echo hello"), PermissionAction::Allow);
468 }
469
470 fn universe(tools: &[&str]) -> Vec<String> {
473 tools.iter().map(|s| (*s).to_owned()).collect()
474 }
475
476 #[test]
477 fn effective_allowlist_empty_rules_returns_none() {
478 let policy = PermissionPolicy::default().with_autonomy(AutonomyLevel::Supervised);
479 assert_eq!(
480 policy.effective_tool_allowlist(universe(&["bash", "read"])),
481 None,
482 "no rules at all -> no narrowing needed"
483 );
484 }
485
486 #[test]
487 fn effective_allowlist_star_catch_all_deny_removes_tool() {
488 let policy = policy_with_rules("bash", vec![("*", PermissionAction::Deny)])
492 .with_autonomy(AutonomyLevel::Supervised);
493 let result = policy
494 .effective_tool_allowlist(universe(&["bash", "read"]))
495 .expect("bash must be wholesale-denied");
496 assert!(!result.contains("bash"));
497 assert!(result.contains("read"));
498 }
499
500 #[test]
501 fn effective_allowlist_double_star_and_empty_pattern_are_catch_all() {
502 for pattern in ["**", ""] {
503 let policy = policy_with_rules("bash", vec![(pattern, PermissionAction::Deny)])
504 .with_autonomy(AutonomyLevel::Supervised);
505 let result = policy
506 .effective_tool_allowlist(universe(&["bash", "read"]))
507 .unwrap_or_else(|| panic!("pattern {pattern:?} must be treated as catch-all"));
508 assert!(
509 !result.contains("bash"),
510 "pattern {pattern:?} must deny bash"
511 );
512 }
513 }
514
515 #[test]
516 fn effective_allowlist_narrower_deny_only_keeps_tool() {
517 let policy = policy_with_rules("bash", vec![("*rm -rf*", PermissionAction::Deny)])
521 .with_autonomy(AutonomyLevel::Supervised);
522 assert_eq!(
523 policy.effective_tool_allowlist(universe(&["bash", "read"])),
524 None,
525 "narrower deny alone must not wholesale-deny bash"
526 );
527 }
528
529 #[test]
530 fn effective_allowlist_allow_before_catch_all_deny_keeps_tool() {
531 let policy = policy_with_rules(
532 "bash",
533 vec![
534 ("echo *", PermissionAction::Allow),
535 ("*", PermissionAction::Deny),
536 ],
537 )
538 .with_autonomy(AutonomyLevel::Supervised);
539 assert_eq!(
540 policy.effective_tool_allowlist(universe(&["bash", "read"])),
541 None,
542 "an earlier Allow rule means the tool is not wholesale-denied"
543 );
544 }
545
546 #[test]
547 fn effective_allowlist_ask_before_catch_all_deny_keeps_tool() {
548 let policy = policy_with_rules(
549 "bash",
550 vec![
551 ("*sudo*", PermissionAction::Ask),
552 ("*", PermissionAction::Deny),
553 ],
554 )
555 .with_autonomy(AutonomyLevel::Supervised);
556 assert_eq!(
557 policy.effective_tool_allowlist(universe(&["bash", "read"])),
558 None,
559 "an earlier Ask rule means the tool is not wholesale-denied"
560 );
561 }
562
563 #[test]
564 fn effective_allowlist_full_autonomy_returns_none() {
565 let policy = policy_with_rules("bash", vec![("*", PermissionAction::Deny)])
566 .with_autonomy(AutonomyLevel::Full);
567 assert_eq!(
568 policy.effective_tool_allowlist(universe(&["bash", "read"])),
569 None,
570 "Full autonomy ignores rules entirely"
571 );
572 }
573
574 #[test]
575 fn effective_allowlist_readonly_autonomy_returns_none() {
576 let policy = policy_with_rules("bash", vec![("*", PermissionAction::Deny)])
580 .with_autonomy(AutonomyLevel::ReadOnly);
581 assert_eq!(
582 policy.effective_tool_allowlist(universe(&["bash", "read"])),
583 None
584 );
585 }
586
587 #[test]
588 fn effective_allowlist_no_wholesale_deny_returns_none_not_full_universe() {
589 let policy = policy_with_rules("bash", vec![("*sudo*", PermissionAction::Deny)])
593 .with_autonomy(AutonomyLevel::Supervised);
594 assert_eq!(
595 policy.effective_tool_allowlist(universe(&["bash", "read", "write"])),
596 None
597 );
598 }
599
600 #[test]
601 fn effective_allowlist_normalizes_mixed_case_parenthesized_rule_key() {
602 let mut map = HashMap::new();
606 map.insert(
607 "Bash(cargo *)".to_owned(),
608 vec![PermissionRule {
609 pattern: "*".to_owned(),
610 action: PermissionAction::Deny,
611 }],
612 );
613 let policy = PermissionPolicy::new(map).with_autonomy(AutonomyLevel::Supervised);
614 let result = policy
615 .effective_tool_allowlist(universe(&["bash", "read"]))
616 .expect("mixed-case parenthesized rule key must still match normalized 'bash'");
617 assert!(!result.contains("bash"));
618 assert!(result.contains("read"));
619 }
620
621 #[test]
622 fn effective_allowlist_multiple_tools_mixed_deny() {
623 let mut map = HashMap::new();
624 map.insert(
625 "bash".to_owned(),
626 vec![PermissionRule {
627 pattern: "*".to_owned(),
628 action: PermissionAction::Deny,
629 }],
630 );
631 map.insert(
632 "fetch".to_owned(),
633 vec![PermissionRule {
634 pattern: "*".to_owned(),
635 action: PermissionAction::Deny,
636 }],
637 );
638 let policy = PermissionPolicy::new(map).with_autonomy(AutonomyLevel::Supervised);
639 let result = policy
640 .effective_tool_allowlist(universe(&["bash", "fetch", "read"]))
641 .expect("at least one wholesale-denied tool must narrow the set");
642 assert_eq!(result.len(), 1);
643 assert!(result.contains("read"));
644 }
645}