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