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