claude_code_acp/settings/
permission_checker.rs1use std::path::{Path, PathBuf};
6
7use super::manager::Settings;
8use super::rule::{ParsedRule, PermissionCheckResult};
9
10#[derive(Debug)]
12pub struct PermissionChecker {
13 settings: Settings,
15 cwd: PathBuf,
17 allow_rules: Vec<(String, ParsedRule)>,
19 deny_rules: Vec<(String, ParsedRule)>,
21 ask_rules: Vec<(String, ParsedRule)>,
23}
24
25impl PermissionChecker {
26 pub fn new(settings: Settings, cwd: impl AsRef<Path>) -> Self {
28 let cwd = cwd.as_ref().to_path_buf();
29
30 let allow_rules = Self::parse_rules(
32 settings.permissions.as_ref().and_then(|p| p.allow.as_ref()),
33 &cwd,
34 );
35 let deny_rules = Self::parse_rules(
36 settings.permissions.as_ref().and_then(|p| p.deny.as_ref()),
37 &cwd,
38 );
39 let ask_rules = Self::parse_rules(
40 settings.permissions.as_ref().and_then(|p| p.ask.as_ref()),
41 &cwd,
42 );
43
44 Self {
45 settings,
46 cwd,
47 allow_rules,
48 deny_rules,
49 ask_rules,
50 }
51 }
52
53 fn parse_rules(rules: Option<&Vec<String>>, cwd: &Path) -> Vec<(String, ParsedRule)> {
55 rules
56 .map(|rules| {
57 rules
58 .iter()
59 .map(|rule| (rule.clone(), ParsedRule::parse_with_glob(rule, cwd)))
60 .collect()
61 })
62 .unwrap_or_default()
63 }
64
65 pub fn check_permission(
71 &self,
72 tool_name: &str,
73 tool_input: &serde_json::Value,
74 ) -> PermissionCheckResult {
75 for (rule_str, parsed) in &self.deny_rules {
77 if parsed.matches(tool_name, tool_input, &self.cwd) {
78 tracing::debug!("Tool {} denied by rule: {}", tool_name, rule_str);
79 return PermissionCheckResult::deny(rule_str);
80 }
81 }
82
83 for (rule_str, parsed) in &self.allow_rules {
85 if parsed.matches(tool_name, tool_input, &self.cwd) {
86 tracing::debug!("Tool {} allowed by rule: {}", tool_name, rule_str);
87 return PermissionCheckResult::allow(rule_str);
88 }
89 }
90
91 for (rule_str, parsed) in &self.ask_rules {
93 if parsed.matches(tool_name, tool_input, &self.cwd) {
94 tracing::debug!(
95 "Tool {} requires permission (ask rule): {}",
96 tool_name,
97 rule_str
98 );
99 return PermissionCheckResult::ask_with_rule(rule_str);
100 }
101 }
102
103 tracing::debug!("Tool {} has no matching rule, defaulting to ask", tool_name);
105 PermissionCheckResult::ask()
106 }
107
108 pub fn settings(&self) -> &Settings {
110 &self.settings
111 }
112
113 pub fn cwd(&self) -> &Path {
115 &self.cwd
116 }
117
118 pub fn has_rules(&self) -> bool {
120 !self.allow_rules.is_empty() || !self.deny_rules.is_empty() || !self.ask_rules.is_empty()
121 }
122
123 pub fn add_allow_rule(&mut self, rule: &str) {
125 let parsed = ParsedRule::parse_with_glob(rule, &self.cwd);
126 self.allow_rules.push((rule.to_string(), parsed));
127 }
128
129 pub fn add_allow_rule_for_tool_call(
136 &mut self,
137 tool_name: &str,
138 tool_input: &serde_json::Value,
139 ) {
140 let rule = self.generate_rule_from_call(tool_name, tool_input);
141 tracing::info!(
142 tool_name = %tool_name,
143 generated_rule = %rule,
144 "Adding fine-grained allow rule"
145 );
146 let parsed = ParsedRule::parse_with_glob(&rule, &self.cwd);
147 self.allow_rules.push((rule, parsed));
148 }
149
150 fn generate_rule_from_call(&self, tool_name: &str, tool_input: &serde_json::Value) -> String {
152 let stripped = tool_name.strip_prefix("mcp__acp__").unwrap_or(tool_name);
153
154 match stripped {
155 "Bash" => {
156 if let Some(cmd) = tool_input.get("command").and_then(|v| v.as_str()) {
158 let prefix = Self::extract_command_prefix(cmd);
159 if !prefix.is_empty() {
160 format!("Bash({}:*)", prefix) } else {
162 stripped.to_string()
163 }
164 } else {
165 stripped.to_string()
166 }
167 }
168 "Read" | "Grep" | "Glob" | "LS" => {
169 Self::generate_file_rule("Read", tool_input, &self.cwd)
171 }
172 "Edit" | "Write" => {
173 Self::generate_file_rule(stripped, tool_input, &self.cwd)
175 }
176 _ => stripped.to_string(),
177 }
178 }
179
180 fn extract_command_prefix(cmd: &str) -> String {
182 let parts: Vec<&str> = cmd.split_whitespace().take(2).collect();
183 parts.join(" ")
184 }
185
186 fn generate_file_rule(tool_name: &str, tool_input: &serde_json::Value, cwd: &Path) -> String {
188 if let Some(path) = tool_input.get("file_path").and_then(|v| v.as_str()) {
189 let path = Path::new(path);
190
191 if let Some(dir) = path.parent() {
193 let dir_str = if let Ok(relative) = dir.strip_prefix(cwd) {
195 format!("./{}", relative.display())
196 } else {
197 dir.to_string_lossy().to_string()
198 };
199
200 if dir_str.is_empty() || dir_str == "." {
202 format!("{}(./*)", tool_name)
203 } else {
204 format!("{}({}/**)", tool_name, dir_str)
205 }
206 } else {
207 tool_name.to_string()
208 }
209 } else {
210 tool_name.to_string()
211 }
212 }
213
214 pub fn add_deny_rule(&mut self, rule: &str) {
216 let parsed = ParsedRule::parse_with_glob(rule, &self.cwd);
217 self.deny_rules.push((rule.to_string(), parsed));
218 }
219
220 pub fn default_mode(&self) -> Option<&str> {
222 self.settings
223 .permissions
224 .as_ref()
225 .and_then(|p| p.default_mode.as_deref())
226 }
227
228 pub fn additional_directories(&self) -> Option<&Vec<String>> {
230 self.settings
231 .permissions
232 .as_ref()
233 .and_then(|p| p.additional_directories.as_ref())
234 }
235}
236
237impl Default for PermissionChecker {
238 fn default() -> Self {
239 Self::new(Settings::default(), PathBuf::from("."))
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246 use crate::settings::{PermissionDecision, PermissionSettings};
247 use serde_json::json;
248
249 fn settings_with_permissions(permissions: PermissionSettings) -> Settings {
250 Settings {
251 permissions: Some(permissions),
252 ..Default::default()
253 }
254 }
255
256 #[test]
257 fn test_empty_rules_default_to_ask() {
258 let checker = PermissionChecker::default();
259 let result = checker.check_permission("Read", &json!({"file_path": "/tmp/test.txt"}));
260
261 assert_eq!(result.decision, PermissionDecision::Ask);
262 assert!(result.rule.is_none());
263 }
264
265 #[test]
266 fn test_allow_rule() {
267 let permissions = PermissionSettings {
268 allow: Some(vec!["Read".to_string()]),
269 ..Default::default()
270 };
271 let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
272
273 let result = checker.check_permission("Read", &json!({"file_path": "/tmp/test.txt"}));
274 assert_eq!(result.decision, PermissionDecision::Allow);
275 assert_eq!(result.rule, Some("Read".to_string()));
276 }
277
278 #[test]
279 fn test_deny_rule() {
280 let permissions = PermissionSettings {
281 deny: Some(vec!["Bash".to_string()]),
282 ..Default::default()
283 };
284 let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
285
286 let result = checker.check_permission("Bash", &json!({"command": "rm -rf /"}));
287 assert_eq!(result.decision, PermissionDecision::Deny);
288 }
289
290 #[test]
291 fn test_deny_takes_priority_over_allow() {
292 let permissions = PermissionSettings {
293 allow: Some(vec!["Bash".to_string()]),
294 deny: Some(vec!["Bash".to_string()]),
295 ..Default::default()
296 };
297 let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
298
299 let result = checker.check_permission("Bash", &json!({"command": "ls"}));
300 assert_eq!(result.decision, PermissionDecision::Deny);
301 }
302
303 #[test]
304 fn test_allow_takes_priority_over_ask() {
305 let permissions = PermissionSettings {
306 allow: Some(vec!["Read".to_string()]),
307 ask: Some(vec!["Read".to_string()]),
308 ..Default::default()
309 };
310 let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
311
312 let result = checker.check_permission("Read", &json!({}));
313 assert_eq!(result.decision, PermissionDecision::Allow);
314 }
315
316 #[test]
317 fn test_bash_wildcard_rule() {
318 let permissions = PermissionSettings {
319 allow: Some(vec!["Bash(npm run:*)".to_string()]),
320 ..Default::default()
321 };
322 let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
323
324 assert_eq!(
326 checker
327 .check_permission("Bash", &json!({"command": "npm run build"}))
328 .decision,
329 PermissionDecision::Allow
330 );
331
332 assert_eq!(
334 checker
335 .check_permission("Bash", &json!({"command": "npm install"}))
336 .decision,
337 PermissionDecision::Ask
338 );
339
340 assert_eq!(
342 checker
343 .check_permission("Bash", &json!({"command": "npm run build && rm -rf /"}))
344 .decision,
345 PermissionDecision::Ask
346 );
347 }
348
349 #[test]
350 fn test_read_group_matching() {
351 let permissions = PermissionSettings {
352 allow: Some(vec!["Read".to_string()]),
353 ..Default::default()
354 };
355 let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
356
357 assert_eq!(
359 checker.check_permission("Read", &json!({})).decision,
360 PermissionDecision::Allow
361 );
362 assert_eq!(
363 checker.check_permission("Grep", &json!({})).decision,
364 PermissionDecision::Allow
365 );
366 assert_eq!(
367 checker.check_permission("Glob", &json!({})).decision,
368 PermissionDecision::Allow
369 );
370 assert_eq!(
371 checker.check_permission("LS", &json!({})).decision,
372 PermissionDecision::Allow
373 );
374
375 assert_eq!(
377 checker.check_permission("Write", &json!({})).decision,
378 PermissionDecision::Ask
379 );
380 }
381
382 #[test]
383 fn test_add_runtime_rule() {
384 let mut checker = PermissionChecker::default();
385
386 assert_eq!(
388 checker.check_permission("Read", &json!({})).decision,
389 PermissionDecision::Ask
390 );
391
392 checker.add_allow_rule("Read");
394
395 assert_eq!(
397 checker.check_permission("Read", &json!({})).decision,
398 PermissionDecision::Allow
399 );
400 }
401
402 #[test]
403 fn test_acp_prefix_stripped() {
404 let permissions = PermissionSettings {
405 allow: Some(vec!["Read".to_string()]),
406 ..Default::default()
407 };
408 let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
409
410 assert_eq!(
412 checker.check_permission("Read", &json!({})).decision,
413 PermissionDecision::Allow
414 );
415 assert_eq!(
416 checker
417 .check_permission("mcp__acp__Read", &json!({}))
418 .decision,
419 PermissionDecision::Allow
420 );
421 }
422
423 #[test]
424 fn test_has_rules() {
425 let checker = PermissionChecker::default();
426 assert!(!checker.has_rules());
427
428 let permissions = PermissionSettings {
429 allow: Some(vec!["Read".to_string()]),
430 ..Default::default()
431 };
432 let checker = PermissionChecker::new(settings_with_permissions(permissions), "/tmp");
433 assert!(checker.has_rules());
434 }
435
436 #[test]
437 fn test_add_allow_rule_for_bash_command() {
438 let mut checker = PermissionChecker::new(Settings::default(), "/tmp");
439
440 checker.add_allow_rule_for_tool_call("Bash", &json!({"command": "cargo build --release"}));
442
443 assert_eq!(
445 checker
446 .check_permission("Bash", &json!({"command": "cargo build --release"}))
447 .decision,
448 PermissionDecision::Allow
449 );
450
451 assert_eq!(
453 checker
454 .check_permission("Bash", &json!({"command": "cargo build --debug"}))
455 .decision,
456 PermissionDecision::Allow
457 );
458
459 assert_eq!(
461 checker
462 .check_permission("Bash", &json!({"command": "rm -rf /"}))
463 .decision,
464 PermissionDecision::Ask
465 );
466 }
467
468 #[test]
469 fn test_add_allow_rule_for_file_operation() {
470 let mut checker = PermissionChecker::new(Settings::default(), "/tmp");
471
472 checker.add_allow_rule_for_tool_call(
474 "Read",
475 &json!({"file_path": "/tmp/project/src/main.rs"}),
476 );
477
478 assert_eq!(
480 checker
481 .check_permission("Read", &json!({"file_path": "/tmp/project/src/lib.rs"}))
482 .decision,
483 PermissionDecision::Allow
484 );
485
486 assert_eq!(
488 checker
489 .check_permission(
490 "Read",
491 &json!({"file_path": "/tmp/project/src/utils/helper.rs"})
492 )
493 .decision,
494 PermissionDecision::Allow
495 );
496
497 assert_eq!(
499 checker
500 .check_permission("Read", &json!({"file_path": "/etc/passwd"}))
501 .decision,
502 PermissionDecision::Ask
503 );
504 }
505
506 #[test]
507 fn test_add_allow_rule_for_mcp_prefixed_tool() {
508 let mut checker = PermissionChecker::new(Settings::default(), "/tmp");
509
510 checker
512 .add_allow_rule_for_tool_call("mcp__acp__Bash", &json!({"command": "npm run build"}));
513
514 assert_eq!(
516 checker
517 .check_permission("Bash", &json!({"command": "npm run test"}))
518 .decision,
519 PermissionDecision::Allow
520 );
521 assert_eq!(
522 checker
523 .check_permission("mcp__acp__Bash", &json!({"command": "npm run lint"}))
524 .decision,
525 PermissionDecision::Allow
526 );
527 }
528
529 #[test]
530 fn test_extract_command_prefix() {
531 assert_eq!(
533 PermissionChecker::extract_command_prefix("cargo build --release"),
534 "cargo build"
535 );
536 assert_eq!(
537 PermissionChecker::extract_command_prefix("npm run"),
538 "npm run"
539 );
540 assert_eq!(PermissionChecker::extract_command_prefix("ls"), "ls");
541 assert_eq!(PermissionChecker::extract_command_prefix(""), "");
542 }
543}