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