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