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