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