1use glob::Pattern;
2use ignore::gitignore::{Gitignore, GitignoreBuilder};
3use serde_json::Value;
4use std::path::{Path, PathBuf};
5use url::Url;
6
7use crate::config::PermissionsConfig;
8use crate::config::constants::tools;
9use crate::tools::command_args;
10use crate::tools::mcp::{MCP_QUALIFIED_TOOL_PREFIX, parse_canonical_mcp_tool_name};
11use crate::tools::tool_intent;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum PermissionRuleDecision {
15 Allow,
16 Ask,
17 Deny,
18 NoMatch,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22pub struct PermissionRuleMatches {
23 pub deny: bool,
24 pub ask: bool,
25 pub allow: bool,
26}
27
28impl PermissionRuleMatches {
29 pub const fn decision(self) -> PermissionRuleDecision {
30 if self.deny {
31 PermissionRuleDecision::Deny
32 } else if self.ask {
33 PermissionRuleDecision::Ask
34 } else if self.allow {
35 PermissionRuleDecision::Allow
36 } else {
37 PermissionRuleDecision::NoMatch
38 }
39 }
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum PermissionRequestKind {
44 Bash { command: String },
45 Read { paths: Vec<PathBuf> },
46 Edit { paths: Vec<PathBuf> },
47 Write { paths: Vec<PathBuf> },
48 WebFetch { domains: Vec<String> },
49 Mcp { server: String, tool: String },
50 Other,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct PermissionRequest {
55 pub exact_tool_name: String,
56 pub kind: PermissionRequestKind,
57 pub builtin_file_mutation: bool,
58 pub protected_write_paths: Vec<PathBuf>,
59}
60
61impl PermissionRequest {
62 pub fn requires_protected_write_prompt(&self) -> bool {
63 !self.protected_write_paths.is_empty()
64 }
65}
66
67pub fn build_permission_request(
68 workspace_root: &Path,
69 current_dir: &Path,
70 normalized_tool_name: &str,
71 tool_args: Option<&Value>,
72) -> PermissionRequest {
73 let kind = build_request_kind(workspace_root, current_dir, normalized_tool_name, tool_args);
74 let protected_write_paths = protected_write_paths(workspace_root, &kind);
75 let builtin_file_mutation = matches!(
76 kind,
77 PermissionRequestKind::Edit { .. } | PermissionRequestKind::Write { .. }
78 );
79
80 PermissionRequest {
81 exact_tool_name: normalized_tool_name.to_string(),
82 kind,
83 builtin_file_mutation,
84 protected_write_paths,
85 }
86}
87
88pub fn evaluate_permissions(
89 config: &PermissionsConfig,
90 workspace_root: &Path,
91 current_dir: &Path,
92 request: &PermissionRequest,
93) -> PermissionRuleMatches {
94 let evaluator = PermissionEvaluator::new(config, workspace_root, current_dir);
95 evaluator.evaluate(request)
96}
97
98struct PermissionEvaluator {
99 deny: Vec<CompiledPermissionRule>,
100 ask: Vec<CompiledPermissionRule>,
101 allow: Vec<CompiledPermissionRule>,
102}
103
104impl PermissionEvaluator {
105 fn new(config: &PermissionsConfig, workspace_root: &Path, current_dir: &Path) -> Self {
106 Self {
107 deny: compile_rules(&config.deny, workspace_root, current_dir),
108 ask: compile_rules(&config.ask, workspace_root, current_dir),
109 allow: compile_rules(&config.allow, workspace_root, current_dir),
110 }
111 }
112
113 fn evaluate(&self, request: &PermissionRequest) -> PermissionRuleMatches {
114 PermissionRuleMatches {
115 deny: self.deny.iter().any(|rule| rule.matches(request)),
116 ask: self.ask.iter().any(|rule| rule.matches(request)),
117 allow: self.allow.iter().any(|rule| rule.matches(request)),
118 }
119 }
120}
121
122fn compile_rules(
123 rules: &[String],
124 workspace_root: &Path,
125 current_dir: &Path,
126) -> Vec<CompiledPermissionRule> {
127 rules
128 .iter()
129 .filter_map(|rule| CompiledPermissionRule::compile(rule, workspace_root, current_dir))
130 .collect()
131}
132
133#[derive(Debug)]
134enum CompiledPermissionRule {
135 Bash(Option<Pattern>),
136 Read(Option<PathRuleMatcher>),
137 Edit(Option<PathRuleMatcher>),
138 Write(Option<PathRuleMatcher>),
139 WebFetchAll,
140 WebFetchDomain(String),
141 McpServer(String),
142 McpWildcard(String),
143 McpTool { server: String, tool: String },
144 ExactTool(String),
145}
146
147impl CompiledPermissionRule {
148 fn compile(raw: &str, workspace_root: &Path, current_dir: &Path) -> Option<Self> {
149 let rule = raw.trim();
150 if rule.is_empty() {
151 return None;
152 }
153
154 if rule.eq_ignore_ascii_case("bash") || rule.eq_ignore_ascii_case("bash(*)") {
155 return Some(Self::Bash(None));
156 }
157 if let Some(specifier) = parse_tool_specifier(rule, "bash") {
158 return compile_bash_rule(specifier).map(Self::Bash);
159 }
160
161 if rule.eq_ignore_ascii_case("read") || rule.eq_ignore_ascii_case("read(*)") {
162 return Some(Self::Read(None));
163 }
164 if let Some(specifier) = parse_tool_specifier(rule, "read") {
165 return PathRuleMatcher::compile(specifier, workspace_root, current_dir)
166 .map(Some)
167 .map(Self::Read);
168 }
169
170 if rule.eq_ignore_ascii_case("edit") || rule.eq_ignore_ascii_case("edit(*)") {
171 return Some(Self::Edit(None));
172 }
173 if let Some(specifier) = parse_tool_specifier(rule, "edit") {
174 return PathRuleMatcher::compile(specifier, workspace_root, current_dir)
175 .map(Some)
176 .map(Self::Edit);
177 }
178
179 if rule.eq_ignore_ascii_case("write") || rule.eq_ignore_ascii_case("write(*)") {
180 return Some(Self::Write(None));
181 }
182 if let Some(specifier) = parse_tool_specifier(rule, "write") {
183 return PathRuleMatcher::compile(specifier, workspace_root, current_dir)
184 .map(Some)
185 .map(Self::Write);
186 }
187
188 if rule.eq_ignore_ascii_case("webfetch") || rule.eq_ignore_ascii_case("webfetch(*)") {
189 return Some(Self::WebFetchAll);
190 }
191 if let Some(specifier) = parse_tool_specifier(rule, "webfetch") {
192 let domain = specifier
193 .strip_prefix("domain:")?
194 .trim()
195 .to_ascii_lowercase();
196 if domain.is_empty() {
197 return None;
198 }
199 return Some(Self::WebFetchDomain(domain));
200 }
201
202 if let Some(server) = rule.strip_prefix(MCP_QUALIFIED_TOOL_PREFIX) {
203 if let Some((server, tool)) = server.split_once("__") {
204 if tool == "*" {
205 return Some(Self::McpWildcard(server.to_string()));
206 }
207 if !server.is_empty() && !tool.is_empty() {
208 return Some(Self::McpTool {
209 server: server.to_string(),
210 tool: tool.to_string(),
211 });
212 }
213 return None;
214 }
215 if !server.is_empty() {
216 return Some(Self::McpServer(server.to_string()));
217 }
218 return None;
219 }
220
221 if rule.contains('(') || rule.contains(')') {
222 return None;
223 }
224
225 Some(Self::ExactTool(rule.to_string()))
226 }
227
228 fn matches(&self, request: &PermissionRequest) -> bool {
229 match self {
230 Self::Bash(pattern) => match &request.kind {
231 PermissionRequestKind::Bash { command } => pattern
232 .as_ref()
233 .is_none_or(|pattern| pattern.matches(command)),
234 _ => false,
235 },
236 Self::Read(matcher) => match &request.kind {
237 PermissionRequestKind::Read { paths } => matcher
238 .as_ref()
239 .is_none_or(|matcher| paths.iter().any(|path| matcher.matches(path))),
240 _ => false,
241 },
242 Self::Edit(matcher) => match &request.kind {
243 PermissionRequestKind::Edit { paths } => matcher
244 .as_ref()
245 .is_none_or(|matcher| paths.iter().any(|path| matcher.matches(path))),
246 _ => false,
247 },
248 Self::Write(matcher) => match &request.kind {
249 PermissionRequestKind::Write { paths } => matcher
250 .as_ref()
251 .is_none_or(|matcher| paths.iter().any(|path| matcher.matches(path))),
252 _ => false,
253 },
254 Self::WebFetchAll => matches!(request.kind, PermissionRequestKind::WebFetch { .. }),
255 Self::WebFetchDomain(domain) => match &request.kind {
256 PermissionRequestKind::WebFetch { domains } => domains
257 .iter()
258 .any(|candidate| domain_matches_allowed(candidate, domain)),
259 _ => false,
260 },
261 Self::McpServer(server) | Self::McpWildcard(server) => match &request.kind {
262 PermissionRequestKind::Mcp {
263 server: candidate, ..
264 } => candidate == server,
265 _ => false,
266 },
267 Self::McpTool { server, tool } => match &request.kind {
268 PermissionRequestKind::Mcp {
269 server: candidate_server,
270 tool: candidate_tool,
271 } => candidate_server == server && candidate_tool == tool,
272 _ => false,
273 },
274 Self::ExactTool(tool_name) => request.exact_tool_name == *tool_name,
275 }
276 }
277}
278
279fn parse_tool_specifier<'a>(rule: &'a str, tool_name: &str) -> Option<&'a str> {
280 let open = rule.find('(')?;
281 let close = rule.rfind(')')?;
282 if close <= open || close + 1 != rule.len() {
283 return None;
284 }
285 let prefix = &rule[..open];
286 prefix
287 .eq_ignore_ascii_case(tool_name)
288 .then_some(rule[open + 1..close].trim())
289}
290
291fn compile_bash_rule(specifier: &str) -> Option<Option<Pattern>> {
292 if specifier.is_empty() || specifier == "*" {
293 return Some(None);
294 }
295 Pattern::new(specifier).ok().map(Some)
296}
297
298#[derive(Debug)]
299struct PathRuleMatcher {
300 matcher: Gitignore,
301}
302
303impl PathRuleMatcher {
304 fn compile(raw: &str, workspace_root: &Path, current_dir: &Path) -> Option<Self> {
305 let home_dir = dirs::home_dir();
306 let (root, pattern) = if let Some(path) = raw.strip_prefix("//") {
307 (PathBuf::from("/"), format!("/{}", path))
308 } else if let Some(path) = raw.strip_prefix("~/") {
309 (home_dir?, format!("/{}", path))
310 } else if raw.starts_with('/') {
311 (workspace_root.to_path_buf(), raw.to_string())
312 } else if let Some(path) = raw.strip_prefix("./") {
313 (current_dir.to_path_buf(), path.to_string())
314 } else {
315 (current_dir.to_path_buf(), raw.to_string())
316 };
317
318 let mut builder = GitignoreBuilder::new(root);
319 builder.add_line(None, &pattern).ok()?;
320 let matcher = builder.build().ok()?;
321 Some(Self { matcher })
322 }
323
324 fn matches(&self, candidate: &Path) -> bool {
325 self.matcher
326 .matched_path_or_any_parents(candidate, false)
327 .is_ignore()
328 }
329}
330
331fn build_request_kind(
332 workspace_root: &Path,
333 current_dir: &Path,
334 normalized_tool_name: &str,
335 tool_args: Option<&Value>,
336) -> PermissionRequestKind {
337 if let Some((server, tool)) = parse_mcp_request(normalized_tool_name) {
338 return PermissionRequestKind::Mcp { server, tool };
339 }
340
341 let Some(args) = tool_args else {
342 return PermissionRequestKind::Other;
343 };
344
345 if tool_intent::is_command_run_tool_call(normalized_tool_name, args)
346 && let Ok(Some(command)) = command_args::command_text(args)
347 {
348 return PermissionRequestKind::Bash { command };
349 }
350
351 if is_web_fetch_request(normalized_tool_name, args) {
352 let domains = extract_web_domains(args);
353 return PermissionRequestKind::WebFetch { domains };
354 }
355
356 if let Some(kind) = file_request_kind(workspace_root, current_dir, normalized_tool_name, args) {
357 return kind;
358 }
359
360 PermissionRequestKind::Other
361}
362
363fn parse_mcp_request(normalized_tool_name: &str) -> Option<(String, String)> {
364 if let Some((server, tool)) = parse_canonical_mcp_tool_name(normalized_tool_name) {
365 return Some((server.to_string(), tool.to_string()));
366 }
367
368 let stripped = normalized_tool_name.strip_prefix(MCP_QUALIFIED_TOOL_PREFIX)?;
369 let (server, tool) = stripped.split_once("__")?;
370 if server.is_empty() || tool.is_empty() || tool == "*" {
371 return None;
372 }
373 Some((server.to_string(), tool.to_string()))
374}
375
376fn is_web_fetch_request(normalized_tool_name: &str, args: &Value) -> bool {
377 normalized_tool_name == tools::WEB_FETCH
378 || normalized_tool_name == tools::FETCH_URL
379 || (normalized_tool_name == tools::UNIFIED_SEARCH
380 && tool_intent::unified_search_action(args).is_some_and(|action| action == "web"))
381}
382
383fn file_request_kind(
384 workspace_root: &Path,
385 current_dir: &Path,
386 normalized_tool_name: &str,
387 args: &Value,
388) -> Option<PermissionRequestKind> {
389 let paths = extract_candidate_paths(workspace_root, current_dir, normalized_tool_name, args);
390
391 match normalized_tool_name {
392 tools::READ_FILE | tools::GREP_FILE | tools::LIST_FILES => {
393 Some(PermissionRequestKind::Read { paths })
394 }
395 tools::WRITE_FILE
396 | tools::CREATE_FILE
397 | tools::DELETE_FILE
398 | tools::MOVE_FILE
399 | tools::COPY_FILE => Some(PermissionRequestKind::Write { paths }),
400 tools::EDIT_FILE | tools::APPLY_PATCH | tools::SEARCH_REPLACE | tools::FILE_OP => {
401 Some(PermissionRequestKind::Edit { paths })
402 }
403 tools::UNIFIED_SEARCH => {
404 if tool_intent::unified_search_action(args).is_some_and(|action| action == "web") {
405 None
406 } else {
407 Some(PermissionRequestKind::Read { paths })
408 }
409 }
410 tools::UNIFIED_FILE => match tool_intent::unified_file_action(args) {
411 Some("read") => Some(PermissionRequestKind::Read { paths }),
412 Some("edit") | Some("patch") => Some(PermissionRequestKind::Edit { paths }),
413 Some(_) => Some(PermissionRequestKind::Write { paths }),
414 None => None,
415 },
416 _ => None,
417 }
418}
419
420fn extract_candidate_paths(
421 workspace_root: &Path,
422 current_dir: &Path,
423 normalized_tool_name: &str,
424 args: &Value,
425) -> Vec<PathBuf> {
426 let mut paths = Vec::new();
427
428 if let Some(obj) = args.as_object() {
429 for key in [
430 "path",
431 "file_path",
432 "filepath",
433 "target_path",
434 "destination",
435 ] {
436 if let Some(path) = obj.get(key).and_then(Value::as_str) {
437 push_resolved_path(&mut paths, workspace_root, current_dir, path);
438 }
439 }
440 }
441
442 if normalized_tool_name == tools::APPLY_PATCH
443 || tool_intent::unified_file_action(args) == Some("patch")
444 {
445 for patch_path in extract_patch_paths(args) {
446 push_resolved_path(&mut paths, workspace_root, current_dir, &patch_path);
447 }
448 }
449
450 paths.sort();
451 paths.dedup();
452 paths
453}
454
455fn extract_patch_paths(args: &Value) -> Vec<String> {
456 let patch = args
457 .get("patch")
458 .and_then(Value::as_str)
459 .or_else(|| args.get("input").and_then(Value::as_str))
460 .or_else(|| args.as_str());
461 let Some(patch) = patch else {
462 return Vec::new();
463 };
464
465 patch
466 .lines()
467 .filter_map(|line| {
468 for prefix in [
469 "*** Update File: ",
470 "*** Add File: ",
471 "*** Delete File: ",
472 "*** Move to: ",
473 ] {
474 if let Some(path) = line.strip_prefix(prefix) {
475 let trimmed = path.trim();
476 if !trimmed.is_empty() {
477 return Some(trimmed.to_string());
478 }
479 }
480 }
481 None
482 })
483 .collect()
484}
485
486fn push_resolved_path(
487 paths: &mut Vec<PathBuf>,
488 workspace_root: &Path,
489 current_dir: &Path,
490 raw: &str,
491) {
492 let trimmed = raw.trim();
493 if trimmed.is_empty() {
494 return;
495 }
496
497 let resolved = if Path::new(trimmed).is_absolute() {
498 PathBuf::from(trimmed)
499 } else {
500 current_dir
501 .strip_prefix(workspace_root)
502 .ok()
503 .filter(|relative| !relative.as_os_str().is_empty())
504 .map(|relative| workspace_root.join(relative).join(trimmed))
505 .unwrap_or_else(|| workspace_root.join(trimmed))
506 };
507 paths.push(crate::utils::path::normalize_path(&resolved));
508}
509
510fn extract_web_domains(args: &Value) -> Vec<String> {
511 args.get("url")
512 .and_then(Value::as_str)
513 .and_then(extract_url_domain)
514 .into_iter()
515 .collect::<Vec<_>>()
516}
517
518fn extract_url_domain(url: &str) -> Option<String> {
519 let parsed = Url::parse(url).ok()?;
520 parsed
521 .host_str()
522 .map(|host| host.trim_end_matches('.').to_ascii_lowercase())
523}
524
525fn protected_write_paths(workspace_root: &Path, kind: &PermissionRequestKind) -> Vec<PathBuf> {
526 let paths = match kind {
527 PermissionRequestKind::Edit { paths } | PermissionRequestKind::Write { paths } => paths,
528 _ => return Vec::new(),
529 };
530
531 paths
532 .iter()
533 .filter(|path| is_protected_write_path(workspace_root, path))
534 .cloned()
535 .collect()
536}
537
538fn is_protected_write_path(workspace_root: &Path, path: &Path) -> bool {
539 let relative = path.strip_prefix(workspace_root).ok();
540 let Some(relative) = relative else {
541 return false;
542 };
543
544 let as_string = relative.to_string_lossy().replace('\\', "/");
545 if matches!(
546 as_string.as_str(),
547 ".vtcode/commands" | ".vtcode/agents" | ".vtcode/skills"
548 ) || as_string.starts_with(".vtcode/commands/")
549 || as_string.starts_with(".vtcode/agents/")
550 || as_string.starts_with(".vtcode/skills/")
551 {
552 return false;
553 }
554
555 matches!(
556 as_string.split('/').next(),
557 Some(".git" | ".vtcode" | ".vscode" | ".idea")
558 )
559}
560
561fn domain_matches_allowed(domain: &str, allowed: &str) -> bool {
562 let normalized_domain = domain.trim_end_matches('.').to_ascii_lowercase();
563 let normalized_allowed = allowed
564 .trim_start_matches('.')
565 .trim_end_matches('.')
566 .to_ascii_lowercase();
567
568 normalized_domain == normalized_allowed
569 || normalized_domain.ends_with(&format!(".{normalized_allowed}"))
570}
571
572#[cfg(test)]
573mod tests {
574 use super::{
575 PermissionRequest, PermissionRequestKind, PermissionRuleDecision, build_permission_request,
576 evaluate_permissions,
577 };
578 use crate::config::{PermissionMode, PermissionsConfig};
579 use serde_json::json;
580 use tempfile::TempDir;
581
582 fn workspace_roots() -> (TempDir, std::path::PathBuf, std::path::PathBuf) {
583 let temp = TempDir::new().expect("temp dir");
584 let workspace = temp.path().join("workspace");
585 let cwd = workspace.join("nested");
586 std::fs::create_dir_all(&cwd).expect("create dirs");
587 (temp, workspace, cwd)
588 }
589
590 #[test]
591 fn deny_precedes_ask_and_allow() {
592 let (_temp, workspace, cwd) = workspace_roots();
593 let config = PermissionsConfig {
594 allow: vec!["Read".to_string()],
595 ask: vec!["Read(/docs/**)".to_string()],
596 deny: vec!["Read(/docs/secret.txt)".to_string()],
597 ..PermissionsConfig::default()
598 };
599 let request = PermissionRequest {
600 exact_tool_name: "read_file".to_string(),
601 kind: PermissionRequestKind::Read {
602 paths: vec![workspace.join("docs/secret.txt")],
603 },
604 builtin_file_mutation: false,
605 protected_write_paths: Vec::new(),
606 };
607
608 assert_eq!(
609 evaluate_permissions(&config, &workspace, &cwd, &request).decision(),
610 PermissionRuleDecision::Deny
611 );
612 }
613
614 #[test]
615 fn bash_glob_matches_command_text() {
616 let (_temp, workspace, cwd) = workspace_roots();
617 let config = PermissionsConfig {
618 allow: vec!["Bash(cargo test *)".to_string()],
619 ..PermissionsConfig::default()
620 };
621 let request = PermissionRequest {
622 exact_tool_name: "unified_exec".to_string(),
623 kind: PermissionRequestKind::Bash {
624 command: "cargo test -p vtcode".to_string(),
625 },
626 builtin_file_mutation: false,
627 protected_write_paths: Vec::new(),
628 };
629
630 assert_eq!(
631 evaluate_permissions(&config, &workspace, &cwd, &request).decision(),
632 PermissionRuleDecision::Allow
633 );
634 }
635
636 #[test]
637 fn read_path_rules_use_workspace_relative_matching() {
638 let (_temp, workspace, cwd) = workspace_roots();
639 let config = PermissionsConfig {
640 ask: vec!["Read(/src/**/*.rs)".to_string()],
641 ..PermissionsConfig::default()
642 };
643 let request = PermissionRequest {
644 exact_tool_name: "read_file".to_string(),
645 kind: PermissionRequestKind::Read {
646 paths: vec![workspace.join("src/lib.rs")],
647 },
648 builtin_file_mutation: false,
649 protected_write_paths: Vec::new(),
650 };
651
652 assert_eq!(
653 evaluate_permissions(&config, &workspace, &cwd, &request).decision(),
654 PermissionRuleDecision::Ask
655 );
656 }
657
658 #[test]
659 fn mcp_rules_match_canonical_requests() {
660 let (_temp, workspace, cwd) = workspace_roots();
661 let config = PermissionsConfig {
662 allow: vec!["mcp__context7__*".to_string()],
663 ..PermissionsConfig::default()
664 };
665 let request = PermissionRequest {
666 exact_tool_name: "mcp::context7::search-docs".to_string(),
667 kind: PermissionRequestKind::Mcp {
668 server: "context7".to_string(),
669 tool: "search-docs".to_string(),
670 },
671 builtin_file_mutation: false,
672 protected_write_paths: Vec::new(),
673 };
674
675 assert_eq!(
676 evaluate_permissions(&config, &workspace, &cwd, &request).decision(),
677 PermissionRuleDecision::Allow
678 );
679 }
680
681 #[test]
682 fn protected_directory_exceptions_are_not_flagged() {
683 let (_temp, workspace, cwd) = workspace_roots();
684 let request = build_permission_request(
685 &workspace,
686 &cwd,
687 "unified_file",
688 Some(&json!({
689 "action": "write",
690 "path": "../.vtcode/skills/example.md"
691 })),
692 );
693 assert!(!request.requires_protected_write_prompt());
694
695 let request = build_permission_request(
696 &workspace,
697 &cwd,
698 "unified_file",
699 Some(&json!({
700 "action": "write",
701 "path": "../.vtcode/settings.toml"
702 })),
703 );
704 assert!(request.requires_protected_write_prompt());
705 }
706
707 #[test]
708 fn apply_patch_paths_are_extracted_for_edit_rules() {
709 let (_temp, workspace, cwd) = workspace_roots();
710 let config = PermissionsConfig {
711 ask: vec!["Edit(/src/**)".to_string()],
712 ..PermissionsConfig::default()
713 };
714 let request = build_permission_request(
715 &workspace,
716 &cwd,
717 "apply_patch",
718 Some(&json!({
719 "patch": "*** Begin Patch\n*** Update File: ../src/main.rs\n@@\n-test\n+test\n*** End Patch\n"
720 })),
721 );
722
723 assert_eq!(
724 evaluate_permissions(&config, &workspace, &cwd, &request).decision(),
725 PermissionRuleDecision::Ask
726 );
727 }
728
729 #[test]
730 fn mode_defaults_to_standard_behavior() {
731 assert_eq!(PermissionMode::default(), PermissionMode::Default);
732 }
733
734 #[test]
735 fn relative_paths_resolve_from_current_directory() {
736 let (_temp, workspace, cwd) = workspace_roots();
737 let config = PermissionsConfig {
738 ask: vec!["Read(./nested-file.rs)".to_string()],
739 ..PermissionsConfig::default()
740 };
741 let request = build_permission_request(
742 &workspace,
743 &cwd,
744 "read_file",
745 Some(&json!({"path": "nested-file.rs"})),
746 );
747
748 assert_eq!(
749 evaluate_permissions(&config, &workspace, &cwd, &request).decision(),
750 PermissionRuleDecision::Ask
751 );
752 }
753
754 #[test]
755 fn exact_tool_rules_feed_rule_tiers() {
756 let (_temp, workspace, cwd) = workspace_roots();
757 let config = PermissionsConfig {
758 allow: vec!["read_file".to_string()],
759 deny: vec!["unified_exec".to_string()],
760 ..PermissionsConfig::default()
761 };
762
763 let read_request = PermissionRequest {
764 exact_tool_name: "read_file".to_string(),
765 kind: PermissionRequestKind::Read { paths: vec![] },
766 builtin_file_mutation: false,
767 protected_write_paths: Vec::new(),
768 };
769 let exec_request = PermissionRequest {
770 exact_tool_name: "unified_exec".to_string(),
771 kind: PermissionRequestKind::Other,
772 builtin_file_mutation: false,
773 protected_write_paths: Vec::new(),
774 };
775
776 assert!(evaluate_permissions(&config, &workspace, &cwd, &read_request).allow);
777 assert!(evaluate_permissions(&config, &workspace, &cwd, &exec_request).deny);
778 }
779}