1use std::collections::HashMap;
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
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}