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 #[must_use]
149 pub fn autonomy_level(&self) -> AutonomyLevel {
150 self.autonomy_level
151 }
152}
153
154#[derive(Debug, Clone, Deserialize, Serialize, Default)]
156pub struct PermissionsConfig {
157 #[serde(flatten)]
158 pub tools: HashMap<String, Vec<PermissionRule>>,
159}
160
161impl From<PermissionsConfig> for PermissionPolicy {
162 fn from(config: PermissionsConfig) -> Self {
163 Self {
164 rules: config.tools,
165 autonomy_level: AutonomyLevel::default(),
166 }
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 fn policy_with_rules(tool_id: &str, rules: Vec<(&str, PermissionAction)>) -> PermissionPolicy {
175 let rules = rules
176 .into_iter()
177 .map(|(pattern, action)| PermissionRule {
178 pattern: pattern.to_owned(),
179 action,
180 })
181 .collect();
182 let mut map = HashMap::new();
183 map.insert(tool_id.to_owned(), rules);
184 PermissionPolicy::new(map)
185 }
186
187 #[test]
188 fn allow_rule_matches_glob() {
189 let policy = policy_with_rules("bash", vec![("echo *", PermissionAction::Allow)]);
190 assert_eq!(policy.check("bash", "echo hello"), PermissionAction::Allow);
191 }
192
193 #[test]
194 fn deny_rule_blocks() {
195 let policy = policy_with_rules("bash", vec![("*rm -rf*", PermissionAction::Deny)]);
196 assert_eq!(policy.check("bash", "rm -rf /tmp"), PermissionAction::Deny);
197 }
198
199 #[test]
200 fn ask_rule_returns_ask() {
201 let policy = policy_with_rules("bash", vec![("*git push*", PermissionAction::Ask)]);
202 assert_eq!(
203 policy.check("bash", "git push origin main"),
204 PermissionAction::Ask
205 );
206 }
207
208 #[test]
209 fn first_matching_rule_wins() {
210 let policy = policy_with_rules(
211 "bash",
212 vec![
213 ("*safe*", PermissionAction::Allow),
214 ("*", PermissionAction::Deny),
215 ],
216 );
217 assert_eq!(
218 policy.check("bash", "safe command"),
219 PermissionAction::Allow
220 );
221 assert_eq!(
222 policy.check("bash", "dangerous command"),
223 PermissionAction::Deny
224 );
225 }
226
227 #[test]
228 fn no_rules_returns_default_ask() {
229 let policy = PermissionPolicy::default();
230 assert_eq!(policy.check("bash", "anything"), PermissionAction::Ask);
231 }
232
233 #[test]
234 fn wildcard_pattern() {
235 let policy = policy_with_rules("bash", vec![("*", PermissionAction::Allow)]);
236 assert_eq!(policy.check("bash", "any command"), PermissionAction::Allow);
237 }
238
239 #[test]
240 fn case_sensitive_tool_id() {
241 let policy = policy_with_rules("bash", vec![("*", PermissionAction::Deny)]);
242 assert_eq!(policy.check("BASH", "cmd"), PermissionAction::Ask);
243 assert_eq!(policy.check("bash", "cmd"), PermissionAction::Deny);
244 }
245
246 #[test]
247 fn no_matching_rule_falls_through_to_ask() {
248 let policy = policy_with_rules("bash", vec![("echo *", PermissionAction::Allow)]);
249 assert_eq!(policy.check("bash", "ls -la"), PermissionAction::Ask);
250 }
251
252 #[test]
253 fn from_legacy_creates_deny_and_ask_rules() {
254 let policy = PermissionPolicy::from_legacy(&["sudo".to_owned()], &["rm ".to_owned()]);
255 assert_eq!(policy.check("bash", "sudo apt"), PermissionAction::Deny);
256 assert_eq!(policy.check("bash", "rm file"), PermissionAction::Ask);
257 assert_eq!(
258 policy.check("bash", "find . -name foo"),
259 PermissionAction::Allow
260 );
261 assert_eq!(policy.check("bash", "ls -la"), PermissionAction::Allow);
262 }
263
264 #[test]
265 fn is_fully_denied_all_deny() {
266 let policy = policy_with_rules("bash", vec![("*", PermissionAction::Deny)]);
267 assert!(policy.is_fully_denied("bash"));
268 }
269
270 #[test]
271 fn is_fully_denied_mixed() {
272 let policy = policy_with_rules(
273 "bash",
274 vec![
275 ("echo *", PermissionAction::Allow),
276 ("*", PermissionAction::Deny),
277 ],
278 );
279 assert!(!policy.is_fully_denied("bash"));
280 }
281
282 #[test]
283 fn is_fully_denied_no_rules() {
284 let policy = PermissionPolicy::default();
285 assert!(!policy.is_fully_denied("bash"));
286 }
287
288 #[test]
289 fn case_insensitive_input_matching() {
290 let policy = policy_with_rules("bash", vec![("*sudo*", PermissionAction::Deny)]);
291 assert_eq!(policy.check("bash", "SUDO apt"), PermissionAction::Deny);
292 assert_eq!(policy.check("bash", "Sudo apt"), PermissionAction::Deny);
293 assert_eq!(policy.check("bash", "sudo apt"), PermissionAction::Deny);
294 }
295
296 #[test]
297 fn permissions_config_deserialize() {
298 let toml_str = r#"
299 [[bash]]
300 pattern = "*sudo*"
301 action = "deny"
302
303 [[bash]]
304 pattern = "*"
305 action = "ask"
306 "#;
307 let config: PermissionsConfig = toml::from_str(toml_str).unwrap();
308 let policy = PermissionPolicy::from(config);
309 assert_eq!(policy.check("bash", "sudo rm"), PermissionAction::Deny);
310 assert_eq!(policy.check("bash", "echo hi"), PermissionAction::Ask);
311 }
312
313 #[test]
314 fn autonomy_level_deserialize() {
315 #[derive(Deserialize)]
316 struct Wrapper {
317 level: AutonomyLevel,
318 }
319 let w: Wrapper = toml::from_str(r#"level = "readonly""#).unwrap();
320 assert_eq!(w.level, AutonomyLevel::ReadOnly);
321 let w: Wrapper = toml::from_str(r#"level = "supervised""#).unwrap();
322 assert_eq!(w.level, AutonomyLevel::Supervised);
323 let w: Wrapper = toml::from_str(r#"level = "full""#).unwrap();
324 assert_eq!(w.level, AutonomyLevel::Full);
325 }
326
327 #[test]
328 fn autonomy_level_default_is_supervised() {
329 assert_eq!(AutonomyLevel::default(), AutonomyLevel::Supervised);
330 }
331
332 #[test]
333 fn readonly_allows_readonly_tools() {
334 let policy = PermissionPolicy::default().with_autonomy(AutonomyLevel::ReadOnly);
335 for tool in &[
336 "read",
337 "find_path",
338 "grep",
339 "list_directory",
340 "web_scrape",
341 "fetch",
342 ] {
343 assert_eq!(
344 policy.check(tool, "any input"),
345 PermissionAction::Allow,
346 "expected Allow for read-only tool {tool}"
347 );
348 }
349 }
350
351 #[test]
352 fn readonly_denies_write_tools() {
353 let policy = PermissionPolicy::default().with_autonomy(AutonomyLevel::ReadOnly);
354 assert_eq!(policy.check("bash", "rm -rf /"), PermissionAction::Deny);
355 assert_eq!(
356 policy.check("file_write", "foo.txt"),
357 PermissionAction::Deny
358 );
359 }
360
361 #[test]
362 fn full_allows_everything() {
363 let policy = PermissionPolicy::default().with_autonomy(AutonomyLevel::Full);
364 assert_eq!(policy.check("bash", "rm -rf /"), PermissionAction::Allow);
365 assert_eq!(
366 policy.check("file_write", "foo.txt"),
367 PermissionAction::Allow
368 );
369 }
370
371 #[test]
372 fn supervised_uses_rules() {
373 let policy = policy_with_rules("bash", vec![("*sudo*", PermissionAction::Deny)])
374 .with_autonomy(AutonomyLevel::Supervised);
375 assert_eq!(policy.check("bash", "sudo rm"), PermissionAction::Deny);
376 assert_eq!(policy.check("bash", "echo hi"), PermissionAction::Ask);
377 }
378
379 #[test]
380 fn from_legacy_preserves_supervised_behavior() {
381 let policy = PermissionPolicy::from_legacy(&["sudo".to_owned()], &["rm ".to_owned()]);
382 assert_eq!(policy.check("bash", "sudo apt"), PermissionAction::Deny);
383 assert_eq!(policy.check("bash", "rm file"), PermissionAction::Ask);
384 assert_eq!(policy.check("bash", "echo hello"), PermissionAction::Allow);
385 }
386}