claude_code_acp/settings/
permission_checker.rs1use std::path::{Path, PathBuf};
6
7use super::manager::Settings;
8use super::rule::{ParsedRule, PermissionCheckResult};
9use crate::command_safety::extract_command_basename;
10
11#[derive(Debug)]
13pub struct PermissionChecker {
14 settings: Settings,
16 cwd: PathBuf,
18 allow_rules: Vec<(String, ParsedRule)>,
20 deny_rules: Vec<(String, ParsedRule)>,
22 ask_rules: Vec<(String, ParsedRule)>,
24}
25
26impl PermissionChecker {
27 pub fn new(settings: Settings, cwd: impl AsRef<Path>) -> Self {
29 let cwd = cwd.as_ref().to_path_buf();
30
31 let allow_rules = Self::parse_rules(
33 settings.permissions.as_ref().and_then(|p| p.allow.as_ref()),
34 &cwd,
35 );
36 let deny_rules = Self::parse_rules(
37 settings.permissions.as_ref().and_then(|p| p.deny.as_ref()),
38 &cwd,
39 );
40 let ask_rules = Self::parse_rules(
41 settings.permissions.as_ref().and_then(|p| p.ask.as_ref()),
42 &cwd,
43 );
44
45 Self {
46 settings,
47 cwd,
48 allow_rules,
49 deny_rules,
50 ask_rules,
51 }
52 }
53
54 fn parse_rules(rules: Option<&Vec<String>>, cwd: &Path) -> Vec<(String, ParsedRule)> {
56 rules
57 .map(|rules| {
58 rules
59 .iter()
60 .map(|rule| (rule.clone(), ParsedRule::parse_with_glob(rule, cwd)))
61 .collect()
62 })
63 .unwrap_or_default()
64 }
65
66 pub fn check_permission(
72 &self,
73 tool_name: &str,
74 tool_input: &serde_json::Value,
75 ) -> PermissionCheckResult {
76 for (rule_str, parsed) in &self.deny_rules {
78 if parsed.matches(tool_name, tool_input, &self.cwd) {
79 tracing::debug!("Tool {} denied by rule: {}", tool_name, rule_str);
80 return PermissionCheckResult::deny(rule_str);
81 }
82 }
83
84 for (rule_str, parsed) in &self.allow_rules {
86 if parsed.matches(tool_name, tool_input, &self.cwd) {
87 tracing::debug!("Tool {} allowed by rule: {}", tool_name, rule_str);
88 return PermissionCheckResult::allow(rule_str);
89 }
90 }
91
92 for (rule_str, parsed) in &self.ask_rules {
94 if parsed.matches(tool_name, tool_input, &self.cwd) {
95 tracing::debug!(
96 "Tool {} requires permission (ask rule): {}",
97 tool_name,
98 rule_str
99 );
100 return PermissionCheckResult::ask_with_rule(rule_str);
101 }
102 }
103
104 tracing::debug!("Tool {} has no matching rule, defaulting to ask", tool_name);
106 PermissionCheckResult::ask()
107 }
108
109 pub fn settings(&self) -> &Settings {
111 &self.settings
112 }
113
114 pub fn cwd(&self) -> &Path {
116 &self.cwd
117 }
118
119 pub fn has_rules(&self) -> bool {
121 !self.allow_rules.is_empty() || !self.deny_rules.is_empty() || !self.ask_rules.is_empty()
122 }
123
124 pub fn add_allow_rule(&mut self, rule: &str) {
126 let parsed = ParsedRule::parse_with_glob(rule, &self.cwd);
127 self.allow_rules.push((rule.to_string(), parsed));
128 }
129
130 pub fn add_allow_rule_for_tool_call(
143 &mut self,
144 tool_name: &str,
145 tool_input: &serde_json::Value,
146 ) {
147 let stripped = tool_name.strip_prefix("mcp__acp__").unwrap_or(tool_name);
149
150 let rule = match stripped {
151 "Bash" => {
152 if let Some(cmd) = tool_input.get("command").and_then(|v| v.as_str()) {
154 let cmd_name = Self::extract_command_name(cmd);
155 if cmd_name.is_empty() {
156 stripped.to_string()
157 } else {
158 format!("Bash({}:*)", cmd_name) }
160 } else {
161 stripped.to_string()
162 }
163 }
164 "Read" | "Grep" | "Glob" | "LS" => {
165 Self::generate_file_rule("Read", tool_input, &self.cwd)
167 }
168 "Edit" | "Write" => {
169 Self::generate_file_rule(stripped, tool_input, &self.cwd)
171 }
172 _ => stripped.to_string(),
173 };
174
175 tracing::info!(
176 tool_name = %tool_name,
177 generated_rule = %rule,
178 "Adding allow rule for Always Allow"
179 );
180
181 let parsed = ParsedRule::parse_with_glob(&rule, &self.cwd);
182 self.allow_rules.push((rule, parsed));
183 }
184
185 fn extract_command_name(cmd: &str) -> String {
192 extract_command_basename(cmd).to_string()
193 }
194
195 fn generate_file_rule(tool_name: &str, tool_input: &serde_json::Value, cwd: &Path) -> String {
197 if let Some(path) = tool_input.get("file_path").and_then(|v| v.as_str()) {
198 let path = Path::new(path);
199
200 if let Some(dir) = path.parent() {
202 let dir_str = if let Ok(relative) = dir.strip_prefix(cwd) {
204 format!("./{}", relative.display())
205 } else {
206 dir.to_string_lossy().to_string()
207 };
208
209 if dir_str.is_empty() || dir_str == "." {
211 format!("{}(./*)", tool_name)
212 } else {
213 format!("{}({}/**)", tool_name, dir_str)
214 }
215 } else {
216 tool_name.to_string()
217 }
218 } else {
219 tool_name.to_string()
220 }
221 }
222
223 pub fn add_deny_rule(&mut self, rule: &str) {
225 let parsed = ParsedRule::parse_with_glob(rule, &self.cwd);
226 self.deny_rules.push((rule.to_string(), parsed));
227 }
228
229 pub fn default_mode(&self) -> Option<&str> {
231 self.settings
232 .permissions
233 .as_ref()
234 .and_then(|p| p.default_mode.as_deref())
235 }
236
237 pub fn additional_directories(&self) -> Option<&Vec<String>> {
239 self.settings
240 .permissions
241 .as_ref()
242 .and_then(|p| p.additional_directories.as_ref())
243 }
244}
245
246impl Default for PermissionChecker {
247 fn default() -> Self {
248 Self::new(Settings::default(), PathBuf::from("."))
249 }
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255 use crate::settings::{PermissionDecision, PermissionSettings};
256 use serde_json::json;
257
258 fn settings_with_permissions(permissions: PermissionSettings) -> Settings {
259 Settings {
260 permissions: Some(permissions),
261 ..Default::default()
262 }
263 }
264
265 #[test]
266 fn test_empty_rules_default_to_ask() {
267 let checker = PermissionChecker::default();
268 let result = checker.check_permission("Read", &json!({"file_path": "/tmp/test.txt"}));
269
270 assert_eq!(result.decision, PermissionDecision::Ask);
271 assert!(result.rule.is_none());
272 }
273
274 #[test]
275 fn test_allow_rule() {
276 let permissions = PermissionSettings {
277 allow: Some(vec!["Read".to_string()]),
278 ..Default::default()
279 };
280 let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
281
282 let result = checker.check_permission("Read", &json!({"file_path": "/tmp/test.txt"}));
283 assert_eq!(result.decision, PermissionDecision::Allow);
284 assert_eq!(result.rule, Some("Read".to_string()));
285 }
286
287 #[test]
288 fn test_deny_rule() {
289 let permissions = PermissionSettings {
290 deny: Some(vec!["Bash".to_string()]),
291 ..Default::default()
292 };
293 let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
294
295 let result = checker.check_permission("Bash", &json!({"command": "rm -rf /"}));
296 assert_eq!(result.decision, PermissionDecision::Deny);
297 }
298
299 #[test]
300 fn test_deny_takes_priority_over_allow() {
301 let permissions = PermissionSettings {
302 allow: Some(vec!["Bash".to_string()]),
303 deny: Some(vec!["Bash".to_string()]),
304 ..Default::default()
305 };
306 let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
307
308 let result = checker.check_permission("Bash", &json!({"command": "ls"}));
309 assert_eq!(result.decision, PermissionDecision::Deny);
310 }
311
312 #[test]
313 fn test_allow_takes_priority_over_ask() {
314 let permissions = PermissionSettings {
315 allow: Some(vec!["Read".to_string()]),
316 ask: Some(vec!["Read".to_string()]),
317 ..Default::default()
318 };
319 let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
320
321 let result = checker.check_permission("Read", &json!({}));
322 assert_eq!(result.decision, PermissionDecision::Allow);
323 }
324
325 #[test]
326 fn test_bash_wildcard_rule() {
327 let permissions = PermissionSettings {
328 allow: Some(vec!["Bash(npm run:*)".to_string()]),
329 ..Default::default()
330 };
331 let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
332
333 assert_eq!(
335 checker
336 .check_permission("Bash", &json!({"command": "npm run build"}))
337 .decision,
338 PermissionDecision::Allow
339 );
340
341 assert_eq!(
343 checker
344 .check_permission("Bash", &json!({"command": "npm install"}))
345 .decision,
346 PermissionDecision::Ask
347 );
348
349 assert_eq!(
351 checker
352 .check_permission("Bash", &json!({"command": "npm run build && rm -rf /"}))
353 .decision,
354 PermissionDecision::Ask
355 );
356 }
357
358 #[test]
359 fn test_read_group_matching() {
360 let permissions = PermissionSettings {
361 allow: Some(vec!["Read".to_string()]),
362 ..Default::default()
363 };
364 let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
365
366 assert_eq!(
368 checker.check_permission("Read", &json!({})).decision,
369 PermissionDecision::Allow
370 );
371 assert_eq!(
372 checker.check_permission("Grep", &json!({})).decision,
373 PermissionDecision::Allow
374 );
375 assert_eq!(
376 checker.check_permission("Glob", &json!({})).decision,
377 PermissionDecision::Allow
378 );
379 assert_eq!(
380 checker.check_permission("LS", &json!({})).decision,
381 PermissionDecision::Allow
382 );
383
384 assert_eq!(
386 checker.check_permission("Write", &json!({})).decision,
387 PermissionDecision::Ask
388 );
389 }
390
391 #[test]
392 fn test_add_runtime_rule() {
393 let mut checker = PermissionChecker::default();
394
395 assert_eq!(
397 checker.check_permission("Read", &json!({})).decision,
398 PermissionDecision::Ask
399 );
400
401 checker.add_allow_rule("Read");
403
404 assert_eq!(
406 checker.check_permission("Read", &json!({})).decision,
407 PermissionDecision::Allow
408 );
409 }
410
411 #[test]
412 fn test_acp_prefix_stripped() {
413 let permissions = PermissionSettings {
414 allow: Some(vec!["Read".to_string()]),
415 ..Default::default()
416 };
417 let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
418
419 assert_eq!(
421 checker.check_permission("Read", &json!({})).decision,
422 PermissionDecision::Allow
423 );
424 assert_eq!(
425 checker
426 .check_permission("mcp__acp__Read", &json!({}))
427 .decision,
428 PermissionDecision::Allow
429 );
430 }
431
432 #[test]
433 fn test_has_rules() {
434 let checker = PermissionChecker::default();
435 assert!(!checker.has_rules());
436
437 let permissions = PermissionSettings {
438 allow: Some(vec!["Read".to_string()]),
439 ..Default::default()
440 };
441 let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
442 assert!(checker.has_rules());
443 }
444
445 #[test]
446 fn test_add_allow_rule_for_bash_command() {
447 let mut checker = PermissionChecker::new(Settings::default(), "/tmp");
448
449 checker.add_allow_rule_for_tool_call("Bash", &json!({"command": "find /path1 -name '*.rs'"}));
451
452 assert_eq!(
454 checker
455 .check_permission("Bash", &json!({"command": "find /different/path -type f"}))
456 .decision,
457 PermissionDecision::Allow
458 );
459
460 assert_eq!(
462 checker
463 .check_permission("Bash", &json!({"command": "find . -name '*.txt' -delete"}))
464 .decision,
465 PermissionDecision::Allow
466 );
467
468 assert_eq!(
470 checker
471 .check_permission("Bash", &json!({"command": "ls -la /tmp"}))
472 .decision,
473 PermissionDecision::Ask
474 );
475
476 assert_eq!(
477 checker
478 .check_permission("Bash", &json!({"command": "rm -rf /"}))
479 .decision,
480 PermissionDecision::Ask
481 );
482 }
483
484 #[test]
485 fn test_add_allow_rule_for_file_operation() {
486 let mut checker = PermissionChecker::new(Settings::default(), "/tmp");
487
488 checker.add_allow_rule_for_tool_call(
490 "Read",
491 &json!({"file_path": "/tmp/project/src/main.rs"}),
492 );
493
494 assert_eq!(
496 checker
497 .check_permission("Read", &json!({"file_path": "/tmp/project/src/lib.rs"}))
498 .decision,
499 PermissionDecision::Allow
500 );
501
502 assert_eq!(
504 checker
505 .check_permission(
506 "Read",
507 &json!({"file_path": "/tmp/project/src/utils/helper.rs"})
508 )
509 .decision,
510 PermissionDecision::Allow
511 );
512
513 assert_eq!(
515 checker
516 .check_permission("Read", &json!({"file_path": "/etc/passwd"}))
517 .decision,
518 PermissionDecision::Ask
519 );
520 }
521
522 #[test]
523 fn test_add_allow_rule_for_mcp_prefixed_tool() {
524 let mut checker = PermissionChecker::new(Settings::default(), "/tmp");
525
526 checker
528 .add_allow_rule_for_tool_call("mcp__acp__Bash", &json!({"command": "npm run build"}));
529
530 assert_eq!(
532 checker
533 .check_permission("Bash", &json!({"command": "npm run test"}))
534 .decision,
535 PermissionDecision::Allow
536 );
537 assert_eq!(
538 checker
539 .check_permission("mcp__acp__Bash", &json!({"command": "npm run lint"}))
540 .decision,
541 PermissionDecision::Allow
542 );
543 }
544
545 #[test]
546 fn test_extract_command_name() {
547 assert_eq!(
549 PermissionChecker::extract_command_name("cargo build --release"),
550 "cargo"
551 );
552 assert_eq!(
553 PermissionChecker::extract_command_name("find /path -name '*.rs'"),
554 "find"
555 );
556 assert_eq!(
557 PermissionChecker::extract_command_name("ls -la /tmp"),
558 "ls"
559 );
560 assert_eq!(PermissionChecker::extract_command_name("npm"), "npm");
561 assert_eq!(PermissionChecker::extract_command_name(""), "");
562 assert_eq!(
564 PermissionChecker::extract_command_name("/usr/bin/find . -name '*.rs'"),
565 "find"
566 );
567 assert_eq!(
568 PermissionChecker::extract_command_name("/usr/local/bin/cargo build"),
569 "cargo"
570 );
571 }
572}