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;
12use vtcode_config::core::permissions::{AgentPermissionsConfig, PermissionDefault};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum PermissionRuleDecision {
16 Allow,
17 Auto,
18 Ask,
19 Deny,
20 NoMatch,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum ResolvedPermissionDecision {
25 Allow,
26 Auto,
27 Ask,
28 Deny,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32pub struct PermissionRuleMatches {
33 pub deny: bool,
34 pub ask: bool,
35 pub auto: bool,
36 pub allow: bool,
37}
38
39impl PermissionRuleMatches {
40 pub const fn decision(self) -> PermissionRuleDecision {
41 if self.deny {
42 PermissionRuleDecision::Deny
43 } else if self.ask {
44 PermissionRuleDecision::Ask
45 } else if self.auto {
46 PermissionRuleDecision::Auto
47 } else if self.allow {
48 PermissionRuleDecision::Allow
49 } else {
50 PermissionRuleDecision::NoMatch
51 }
52 }
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum PermissionRequestKind {
57 Bash { command: String },
58 Read { paths: Vec<PathBuf> },
59 Edit { paths: Vec<PathBuf> },
60 Write { paths: Vec<PathBuf> },
61 WebFetch { domains: Vec<String> },
62 Mcp { server: String, tool: String },
63 Other,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct PermissionRequest {
68 pub exact_tool_name: String,
69 pub kind: PermissionRequestKind,
70 pub builtin_file_mutation: bool,
71 pub protected_write_paths: Vec<PathBuf>,
72}
73
74impl PermissionRequest {
75 pub fn requires_protected_write_prompt(&self) -> bool {
76 !self.protected_write_paths.is_empty()
77 }
78}
79
80pub fn build_permission_request(
81 workspace_root: &Path,
82 current_dir: &Path,
83 normalized_tool_name: &str,
84 tool_args: Option<&Value>,
85) -> PermissionRequest {
86 let kind = build_request_kind(workspace_root, current_dir, normalized_tool_name, tool_args);
87 let protected_write_paths = protected_write_paths(workspace_root, &kind);
88 let builtin_file_mutation = matches!(
89 kind,
90 PermissionRequestKind::Edit { .. } | PermissionRequestKind::Write { .. }
91 );
92
93 PermissionRequest {
94 exact_tool_name: normalized_tool_name.to_string(),
95 kind,
96 builtin_file_mutation,
97 protected_write_paths,
98 }
99}
100
101pub fn evaluate_permissions(
102 config: &PermissionsConfig,
103 workspace_root: &Path,
104 current_dir: &Path,
105 request: &PermissionRequest,
106) -> PermissionRuleMatches {
107 let evaluator = PermissionRuleSet::from_global_config(config, workspace_root, current_dir);
108 evaluator.evaluate_matches(request)
109}
110
111pub fn evaluate_agent_permissions(
112 agent_permissions: &AgentPermissionsConfig,
113 workspace_root: &Path,
114 current_dir: &Path,
115 request: &PermissionRequest,
116) -> ResolvedPermissionDecision {
117 let evaluator =
118 PermissionRuleSet::from_agent_config(agent_permissions, workspace_root, current_dir);
119 evaluator.resolve(request, agent_permissions.default)
120}
121
122pub fn evaluate_effective_permissions(
123 global_config: &PermissionsConfig,
124 agent_permissions: &AgentPermissionsConfig,
125 workspace_root: &Path,
126 current_dir: &Path,
127 request: &PermissionRequest,
128) -> ResolvedPermissionDecision {
129 let global = PermissionRuleSet::from_global_config(global_config, workspace_root, current_dir);
130 let global_matches = global.evaluate_matches(request);
131 if global_matches.deny {
132 return ResolvedPermissionDecision::Deny;
133 }
134
135 let agent_decision =
136 evaluate_agent_permissions(agent_permissions, workspace_root, current_dir, request);
137 if global_matches.ask && agent_decision != ResolvedPermissionDecision::Deny {
138 return ResolvedPermissionDecision::Ask;
139 }
140
141 agent_decision
142}
143
144struct PermissionRuleSet {
145 deny: Vec<CompiledPermissionRule>,
146 ask: Vec<CompiledPermissionRule>,
147 auto: Vec<CompiledPermissionRule>,
148 allow: Vec<CompiledPermissionRule>,
149}
150
151impl PermissionRuleSet {
152 fn from_global_config(
153 config: &PermissionsConfig,
154 workspace_root: &Path,
155 current_dir: &Path,
156 ) -> Self {
157 Self {
158 deny: compile_rules(&config.deny, workspace_root, current_dir),
159 ask: compile_rules(&config.ask, workspace_root, current_dir),
160 auto: Vec::new(),
161 allow: compile_rules(&config.allow, workspace_root, current_dir),
162 }
163 }
164
165 fn from_agent_config(
166 config: &AgentPermissionsConfig,
167 workspace_root: &Path,
168 current_dir: &Path,
169 ) -> Self {
170 Self {
171 deny: compile_rules(&config.deny, workspace_root, current_dir),
172 ask: compile_rules(&config.ask, workspace_root, current_dir),
173 auto: compile_rules(&config.auto, workspace_root, current_dir),
174 allow: compile_rules(&config.allow, workspace_root, current_dir),
175 }
176 }
177
178 fn evaluate_matches(&self, request: &PermissionRequest) -> PermissionRuleMatches {
179 PermissionRuleMatches {
180 deny: self.deny.iter().any(|rule| rule.matches(request)),
181 ask: self.ask.iter().any(|rule| rule.matches(request)),
182 auto: self.auto.iter().any(|rule| rule.matches(request)),
183 allow: self.allow.iter().any(|rule| rule.matches(request)),
184 }
185 }
186
187 fn resolve(
188 &self,
189 request: &PermissionRequest,
190 default: PermissionDefault,
191 ) -> ResolvedPermissionDecision {
192 let matches = self.evaluate_matches(request);
193 if matches.deny {
194 ResolvedPermissionDecision::Deny
195 } else if matches.ask {
196 ResolvedPermissionDecision::Ask
197 } else if matches.auto {
198 ResolvedPermissionDecision::Auto
199 } else if matches.allow {
200 ResolvedPermissionDecision::Allow
201 } else {
202 default.into()
203 }
204 }
205}
206
207impl From<PermissionDefault> for ResolvedPermissionDecision {
208 fn from(default: PermissionDefault) -> Self {
209 match default {
210 PermissionDefault::Ask => Self::Ask,
211 PermissionDefault::Allow => Self::Allow,
212 PermissionDefault::Auto => Self::Auto,
213 PermissionDefault::Deny => Self::Deny,
214 }
215 }
216}
217
218fn compile_rules(
219 rules: &[String],
220 workspace_root: &Path,
221 current_dir: &Path,
222) -> Vec<CompiledPermissionRule> {
223 rules
224 .iter()
225 .filter_map(|rule| CompiledPermissionRule::compile(rule, workspace_root, current_dir))
226 .collect()
227}
228
229#[derive(Debug)]
230enum CompiledPermissionRule {
231 Bash(Option<Pattern>),
232 Read(Option<PathRuleMatcher>),
233 Edit(Option<PathRuleMatcher>),
234 Write(Option<PathRuleMatcher>),
235 WebFetchAll,
236 WebFetchDomain(String),
237 McpServer(String),
238 McpWildcard(String),
239 McpTool { server: String, tool: String },
240 ExactTool(String),
241}
242
243impl CompiledPermissionRule {
244 fn compile(raw: &str, workspace_root: &Path, current_dir: &Path) -> Option<Self> {
245 let rule = raw.trim();
246 if rule.is_empty() {
247 return None;
248 }
249
250 if rule.eq_ignore_ascii_case("bash") || rule.eq_ignore_ascii_case("bash(*)") {
251 return Some(Self::Bash(None));
252 }
253 if let Some(specifier) = parse_tool_specifier(rule, "bash") {
254 return compile_bash_rule(specifier).map(Self::Bash);
255 }
256
257 if rule.eq_ignore_ascii_case("read") || rule.eq_ignore_ascii_case("read(*)") {
258 return Some(Self::Read(None));
259 }
260 if let Some(specifier) = parse_tool_specifier(rule, "read") {
261 return PathRuleMatcher::compile(specifier, workspace_root, current_dir)
262 .map(Some)
263 .map(Self::Read);
264 }
265
266 if rule.eq_ignore_ascii_case("edit") || rule.eq_ignore_ascii_case("edit(*)") {
267 return Some(Self::Edit(None));
268 }
269 if let Some(specifier) = parse_tool_specifier(rule, "edit") {
270 return PathRuleMatcher::compile(specifier, workspace_root, current_dir)
271 .map(Some)
272 .map(Self::Edit);
273 }
274
275 if rule.eq_ignore_ascii_case("write") || rule.eq_ignore_ascii_case("write(*)") {
276 return Some(Self::Write(None));
277 }
278 if let Some(specifier) = parse_tool_specifier(rule, "write") {
279 return PathRuleMatcher::compile(specifier, workspace_root, current_dir)
280 .map(Some)
281 .map(Self::Write);
282 }
283
284 if rule.eq_ignore_ascii_case("webfetch") || rule.eq_ignore_ascii_case("webfetch(*)") {
285 return Some(Self::WebFetchAll);
286 }
287 if let Some(specifier) = parse_tool_specifier(rule, "webfetch") {
288 let domain = specifier
289 .strip_prefix("domain:")?
290 .trim()
291 .to_ascii_lowercase();
292 if domain.is_empty() {
293 return None;
294 }
295 return Some(Self::WebFetchDomain(domain));
296 }
297
298 if let Some(server) = rule.strip_prefix(MCP_QUALIFIED_TOOL_PREFIX) {
299 if let Some((server, tool)) = server.split_once("__") {
300 if tool == "*" {
301 return Some(Self::McpWildcard(server.to_string()));
302 }
303 if !server.is_empty() && !tool.is_empty() {
304 return Some(Self::McpTool {
305 server: server.to_string(),
306 tool: tool.to_string(),
307 });
308 }
309 return None;
310 }
311 if !server.is_empty() {
312 return Some(Self::McpServer(server.to_string()));
313 }
314 return None;
315 }
316
317 if rule.contains('(') || rule.contains(')') {
318 return None;
319 }
320
321 Some(Self::ExactTool(rule.to_string()))
322 }
323
324 fn matches(&self, request: &PermissionRequest) -> bool {
325 match self {
326 Self::Bash(pattern) => match &request.kind {
327 PermissionRequestKind::Bash { command } => pattern
328 .as_ref()
329 .is_none_or(|pattern| pattern.matches(command)),
330 _ => false,
331 },
332 Self::Read(matcher) => match &request.kind {
333 PermissionRequestKind::Read { paths } => matcher
334 .as_ref()
335 .is_none_or(|matcher| paths.iter().any(|path| matcher.matches(path))),
336 _ => false,
337 },
338 Self::Edit(matcher) => match &request.kind {
339 PermissionRequestKind::Edit { paths } => matcher
340 .as_ref()
341 .is_none_or(|matcher| paths.iter().any(|path| matcher.matches(path))),
342 _ => false,
343 },
344 Self::Write(matcher) => match &request.kind {
345 PermissionRequestKind::Write { paths } => matcher
346 .as_ref()
347 .is_none_or(|matcher| paths.iter().any(|path| matcher.matches(path))),
348 _ => false,
349 },
350 Self::WebFetchAll => matches!(request.kind, PermissionRequestKind::WebFetch { .. }),
351 Self::WebFetchDomain(domain) => match &request.kind {
352 PermissionRequestKind::WebFetch { domains } => domains
353 .iter()
354 .any(|candidate| domain_matches_allowed(candidate, domain)),
355 _ => false,
356 },
357 Self::McpServer(server) | Self::McpWildcard(server) => match &request.kind {
358 PermissionRequestKind::Mcp {
359 server: candidate, ..
360 } => candidate == server,
361 _ => false,
362 },
363 Self::McpTool { server, tool } => match &request.kind {
364 PermissionRequestKind::Mcp {
365 server: candidate_server,
366 tool: candidate_tool,
367 } => candidate_server == server && candidate_tool == tool,
368 _ => false,
369 },
370 Self::ExactTool(tool_name) => request.exact_tool_name == *tool_name,
371 }
372 }
373}
374
375fn parse_tool_specifier<'a>(rule: &'a str, tool_name: &str) -> Option<&'a str> {
376 let open = rule.find('(')?;
377 let close = rule.rfind(')')?;
378 if close <= open || close + 1 != rule.len() {
379 return None;
380 }
381 let prefix = &rule[..open];
382 prefix
383 .eq_ignore_ascii_case(tool_name)
384 .then_some(rule[open + 1..close].trim())
385}
386
387fn compile_bash_rule(specifier: &str) -> Option<Option<Pattern>> {
388 if specifier.is_empty() || specifier == "*" {
389 return Some(None);
390 }
391 Pattern::new(specifier).ok().map(Some)
392}
393
394#[derive(Debug)]
395struct PathRuleMatcher {
396 matcher: Gitignore,
397}
398
399impl PathRuleMatcher {
400 fn compile(raw: &str, workspace_root: &Path, current_dir: &Path) -> Option<Self> {
401 let home_dir = dirs::home_dir();
402 let (root, pattern) = if let Some(path) = raw.strip_prefix("//") {
403 (PathBuf::from("/"), format!("/{}", path))
404 } else if let Some(path) = raw.strip_prefix("~/") {
405 (home_dir?, format!("/{}", path))
406 } else if raw.starts_with('/') {
407 (workspace_root.to_path_buf(), raw.to_string())
408 } else if let Some(path) = raw.strip_prefix("./") {
409 (current_dir.to_path_buf(), path.to_string())
410 } else {
411 (current_dir.to_path_buf(), raw.to_string())
412 };
413
414 let mut builder = GitignoreBuilder::new(root);
415 builder.add_line(None, &pattern).ok()?;
416 let matcher = builder.build().ok()?;
417 Some(Self { matcher })
418 }
419
420 fn matches(&self, candidate: &Path) -> bool {
421 self.matcher
422 .matched_path_or_any_parents(candidate, false)
423 .is_ignore()
424 }
425}
426
427fn build_request_kind(
428 workspace_root: &Path,
429 current_dir: &Path,
430 normalized_tool_name: &str,
431 tool_args: Option<&Value>,
432) -> PermissionRequestKind {
433 if let Some((server, tool)) = parse_mcp_request(normalized_tool_name) {
434 return PermissionRequestKind::Mcp { server, tool };
435 }
436
437 let Some(args) = tool_args else {
438 return PermissionRequestKind::Other;
439 };
440
441 if tool_intent::is_command_run_tool_call(normalized_tool_name, args)
442 && let Ok(Some(command)) = command_args::command_text(args)
443 {
444 return PermissionRequestKind::Bash { command };
445 }
446
447 if is_web_fetch_request(normalized_tool_name, args) {
448 let domains = extract_web_domains(args);
449 return PermissionRequestKind::WebFetch { domains };
450 }
451
452 if let Some(kind) = file_request_kind(workspace_root, current_dir, normalized_tool_name, args) {
453 return kind;
454 }
455
456 PermissionRequestKind::Other
457}
458
459fn parse_mcp_request(normalized_tool_name: &str) -> Option<(String, String)> {
460 if let Some((server, tool)) = parse_canonical_mcp_tool_name(normalized_tool_name) {
461 return Some((server.to_string(), tool.to_string()));
462 }
463
464 let stripped = normalized_tool_name.strip_prefix(MCP_QUALIFIED_TOOL_PREFIX)?;
465 let (server, tool) = stripped.split_once("__")?;
466 if server.is_empty() || tool.is_empty() || tool == "*" {
467 return None;
468 }
469 Some((server.to_string(), tool.to_string()))
470}
471
472fn is_web_fetch_request(normalized_tool_name: &str, args: &Value) -> bool {
473 normalized_tool_name == tools::WEB_FETCH
474 || normalized_tool_name == tools::FETCH_URL
475 || (normalized_tool_name == tools::UNIFIED_SEARCH
476 && tool_intent::unified_search_action(args).is_some_and(|action| action == "web"))
477}
478
479fn file_request_kind(
480 workspace_root: &Path,
481 current_dir: &Path,
482 normalized_tool_name: &str,
483 args: &Value,
484) -> Option<PermissionRequestKind> {
485 let paths = extract_candidate_paths(workspace_root, current_dir, normalized_tool_name, args);
486
487 match normalized_tool_name {
488 tools::READ_FILE | tools::GREP_FILE | tools::LIST_FILES => {
489 Some(PermissionRequestKind::Read { paths })
490 }
491 tools::WRITE_FILE
492 | tools::CREATE_FILE
493 | tools::DELETE_FILE
494 | tools::MOVE_FILE
495 | tools::COPY_FILE => Some(PermissionRequestKind::Write { paths }),
496 tools::EDIT_FILE | tools::APPLY_PATCH | tools::SEARCH_REPLACE | tools::FILE_OP => {
497 Some(PermissionRequestKind::Edit { paths })
498 }
499 tools::UNIFIED_SEARCH => {
500 if tool_intent::unified_search_action(args).is_some_and(|action| action == "web") {
501 None
502 } else {
503 Some(PermissionRequestKind::Read { paths })
504 }
505 }
506 tools::UNIFIED_FILE => match tool_intent::unified_file_action(args) {
507 Some("read") => Some(PermissionRequestKind::Read { paths }),
508 Some("edit") | Some("patch") => Some(PermissionRequestKind::Edit { paths }),
509 Some(_) => Some(PermissionRequestKind::Write { paths }),
510 None => None,
511 },
512 _ => None,
513 }
514}
515
516fn extract_candidate_paths(
517 workspace_root: &Path,
518 current_dir: &Path,
519 normalized_tool_name: &str,
520 args: &Value,
521) -> Vec<PathBuf> {
522 let mut paths = Vec::new();
523
524 if let Some(obj) = args.as_object() {
525 for key in [
526 "path",
527 "file_path",
528 "filepath",
529 "target_path",
530 "destination",
531 ] {
532 if let Some(path) = obj.get(key).and_then(Value::as_str) {
533 push_resolved_path(&mut paths, workspace_root, current_dir, path);
534 }
535 }
536 }
537
538 if normalized_tool_name == tools::APPLY_PATCH
539 || tool_intent::unified_file_action(args) == Some("patch")
540 {
541 for patch_path in extract_patch_paths(args) {
542 push_resolved_path(&mut paths, workspace_root, current_dir, &patch_path);
543 }
544 }
545
546 paths.sort();
547 paths.dedup();
548 paths
549}
550
551fn extract_patch_paths(args: &Value) -> Vec<String> {
552 let patch = args
553 .get("patch")
554 .and_then(Value::as_str)
555 .or_else(|| args.get("input").and_then(Value::as_str))
556 .or_else(|| args.as_str());
557 let Some(patch) = patch else {
558 return Vec::new();
559 };
560
561 patch
562 .lines()
563 .filter_map(|line| {
564 for prefix in [
565 "*** Update File: ",
566 "*** Add File: ",
567 "*** Delete File: ",
568 "*** Move to: ",
569 ] {
570 if let Some(path) = line.strip_prefix(prefix) {
571 let trimmed = path.trim();
572 if !trimmed.is_empty() {
573 return Some(trimmed.to_string());
574 }
575 }
576 }
577 None
578 })
579 .collect()
580}
581
582fn push_resolved_path(
583 paths: &mut Vec<PathBuf>,
584 workspace_root: &Path,
585 current_dir: &Path,
586 raw: &str,
587) {
588 let trimmed = raw.trim();
589 if trimmed.is_empty() {
590 return;
591 }
592
593 let resolved = if Path::new(trimmed).is_absolute() {
594 PathBuf::from(trimmed)
595 } else {
596 current_dir
597 .strip_prefix(workspace_root)
598 .ok()
599 .filter(|relative| !relative.as_os_str().is_empty())
600 .map(|relative| workspace_root.join(relative).join(trimmed))
601 .unwrap_or_else(|| workspace_root.join(trimmed))
602 };
603 paths.push(crate::utils::path::normalize_path(&resolved));
604}
605
606fn extract_web_domains(args: &Value) -> Vec<String> {
607 args.get("url")
608 .and_then(Value::as_str)
609 .and_then(extract_url_domain)
610 .into_iter()
611 .collect::<Vec<_>>()
612}
613
614fn extract_url_domain(url: &str) -> Option<String> {
615 let parsed = Url::parse(url).ok()?;
616 parsed
617 .host_str()
618 .map(|host| host.trim_end_matches('.').to_ascii_lowercase())
619}
620
621fn protected_write_paths(workspace_root: &Path, kind: &PermissionRequestKind) -> Vec<PathBuf> {
622 let paths = match kind {
623 PermissionRequestKind::Edit { paths } | PermissionRequestKind::Write { paths } => paths,
624 _ => return Vec::new(),
625 };
626
627 paths
628 .iter()
629 .filter(|path| is_protected_write_path(workspace_root, path))
630 .cloned()
631 .collect()
632}
633
634fn is_protected_write_path(workspace_root: &Path, path: &Path) -> bool {
635 let relative = path.strip_prefix(workspace_root).ok();
636 let Some(relative) = relative else {
637 return false;
638 };
639
640 let as_string = relative.to_string_lossy().replace('\\', "/");
641 if matches!(
642 as_string.as_str(),
643 ".vtcode/commands" | ".vtcode/agents" | ".vtcode/skills"
644 ) || as_string.starts_with(".vtcode/commands/")
645 || as_string.starts_with(".vtcode/agents/")
646 || as_string.starts_with(".vtcode/skills/")
647 {
648 return false;
649 }
650
651 matches!(
652 as_string.split('/').next(),
653 Some(".git" | ".vtcode" | ".vscode" | ".idea")
654 )
655}
656
657fn domain_matches_allowed(domain: &str, allowed: &str) -> bool {
658 let normalized_domain = domain.trim_end_matches('.').to_ascii_lowercase();
659 let normalized_allowed = allowed
660 .trim_start_matches('.')
661 .trim_end_matches('.')
662 .to_ascii_lowercase();
663
664 normalized_domain == normalized_allowed
665 || normalized_domain.ends_with(&format!(".{normalized_allowed}"))
666}
667
668#[cfg(test)]
669mod tests {
670 use super::{
671 PermissionRequest, PermissionRequestKind, PermissionRuleDecision,
672 ResolvedPermissionDecision, build_permission_request, evaluate_agent_permissions,
673 evaluate_effective_permissions, evaluate_permissions,
674 };
675 use crate::config::PermissionsConfig;
676 use serde_json::json;
677 use tempfile::TempDir;
678 use vtcode_config::core::permissions::{AgentPermissionsConfig, PermissionDefault};
679
680 fn workspace_roots() -> (TempDir, std::path::PathBuf, std::path::PathBuf) {
681 let temp = TempDir::new().expect("temp dir");
682 let workspace = temp.path().join("workspace");
683 let cwd = workspace.join("nested");
684 std::fs::create_dir_all(&cwd).expect("create dirs");
685 (temp, workspace, cwd)
686 }
687
688 fn agent_permissions(default: PermissionDefault) -> AgentPermissionsConfig {
689 AgentPermissionsConfig::new(default)
690 }
691
692 fn exact_tool_request(tool_name: &str) -> PermissionRequest {
693 PermissionRequest {
694 exact_tool_name: tool_name.to_string(),
695 kind: PermissionRequestKind::Other,
696 builtin_file_mutation: false,
697 protected_write_paths: Vec::new(),
698 }
699 }
700
701 #[test]
702 fn deny_precedes_ask_and_allow() {
703 let (_temp, workspace, cwd) = workspace_roots();
704 let config = PermissionsConfig {
705 allow: vec!["Read".to_string()],
706 ask: vec!["Read(/docs/**)".to_string()],
707 deny: vec!["Read(/docs/secret.txt)".to_string()],
708 ..PermissionsConfig::default()
709 };
710 let request = PermissionRequest {
711 exact_tool_name: "read_file".to_string(),
712 kind: PermissionRequestKind::Read {
713 paths: vec![workspace.join("docs/secret.txt")],
714 },
715 builtin_file_mutation: false,
716 protected_write_paths: Vec::new(),
717 };
718
719 assert_eq!(
720 evaluate_permissions(&config, &workspace, &cwd, &request).decision(),
721 PermissionRuleDecision::Deny
722 );
723 }
724
725 #[test]
726 fn bash_glob_matches_command_text() {
727 let (_temp, workspace, cwd) = workspace_roots();
728 let config = PermissionsConfig {
729 allow: vec!["Bash(cargo test *)".to_string()],
730 ..PermissionsConfig::default()
731 };
732 let request = PermissionRequest {
733 exact_tool_name: "unified_exec".to_string(),
734 kind: PermissionRequestKind::Bash {
735 command: "cargo test -p vtcode".to_string(),
736 },
737 builtin_file_mutation: false,
738 protected_write_paths: Vec::new(),
739 };
740
741 assert_eq!(
742 evaluate_permissions(&config, &workspace, &cwd, &request).decision(),
743 PermissionRuleDecision::Allow
744 );
745 }
746
747 #[test]
748 fn read_path_rules_use_workspace_relative_matching() {
749 let (_temp, workspace, cwd) = workspace_roots();
750 let config = PermissionsConfig {
751 ask: vec!["Read(/src/**/*.rs)".to_string()],
752 ..PermissionsConfig::default()
753 };
754 let request = PermissionRequest {
755 exact_tool_name: "read_file".to_string(),
756 kind: PermissionRequestKind::Read {
757 paths: vec![workspace.join("src/lib.rs")],
758 },
759 builtin_file_mutation: false,
760 protected_write_paths: Vec::new(),
761 };
762
763 assert_eq!(
764 evaluate_permissions(&config, &workspace, &cwd, &request).decision(),
765 PermissionRuleDecision::Ask
766 );
767 }
768
769 #[test]
770 fn mcp_rules_match_canonical_requests() {
771 let (_temp, workspace, cwd) = workspace_roots();
772 let config = PermissionsConfig {
773 allow: vec!["mcp__context7__*".to_string()],
774 ..PermissionsConfig::default()
775 };
776 let request = PermissionRequest {
777 exact_tool_name: "mcp::context7::search-docs".to_string(),
778 kind: PermissionRequestKind::Mcp {
779 server: "context7".to_string(),
780 tool: "search-docs".to_string(),
781 },
782 builtin_file_mutation: false,
783 protected_write_paths: Vec::new(),
784 };
785
786 assert_eq!(
787 evaluate_permissions(&config, &workspace, &cwd, &request).decision(),
788 PermissionRuleDecision::Allow
789 );
790 }
791
792 #[test]
793 fn protected_directory_exceptions_are_not_flagged() {
794 let (_temp, workspace, cwd) = workspace_roots();
795 let request = build_permission_request(
796 &workspace,
797 &cwd,
798 "unified_file",
799 Some(&json!({
800 "action": "write",
801 "path": "../.vtcode/skills/example.md"
802 })),
803 );
804 assert!(!request.requires_protected_write_prompt());
805
806 let request = build_permission_request(
807 &workspace,
808 &cwd,
809 "unified_file",
810 Some(&json!({
811 "action": "write",
812 "path": "../.vtcode/settings.toml"
813 })),
814 );
815 assert!(request.requires_protected_write_prompt());
816 }
817
818 #[test]
819 fn apply_patch_paths_are_extracted_for_edit_rules() {
820 let (_temp, workspace, cwd) = workspace_roots();
821 let config = PermissionsConfig {
822 ask: vec!["Edit(/src/**)".to_string()],
823 ..PermissionsConfig::default()
824 };
825 let request = build_permission_request(
826 &workspace,
827 &cwd,
828 "apply_patch",
829 Some(&json!({
830 "patch": "*** Begin Patch\n*** Update File: ../src/main.rs\n@@\n-test\n+test\n*** End Patch\n"
831 })),
832 );
833
834 assert_eq!(
835 evaluate_permissions(&config, &workspace, &cwd, &request).decision(),
836 PermissionRuleDecision::Ask
837 );
838 }
839
840 #[test]
841 fn relative_paths_resolve_from_current_directory() {
842 let (_temp, workspace, cwd) = workspace_roots();
843 let config = PermissionsConfig {
844 ask: vec!["Read(./nested-file.rs)".to_string()],
845 ..PermissionsConfig::default()
846 };
847 let request = build_permission_request(
848 &workspace,
849 &cwd,
850 "read_file",
851 Some(&json!({"path": "nested-file.rs"})),
852 );
853
854 assert_eq!(
855 evaluate_permissions(&config, &workspace, &cwd, &request).decision(),
856 PermissionRuleDecision::Ask
857 );
858 }
859
860 #[test]
861 fn exact_tool_rules_feed_rule_tiers() {
862 let (_temp, workspace, cwd) = workspace_roots();
863 let config = PermissionsConfig {
864 allow: vec!["read_file".to_string()],
865 deny: vec!["unified_exec".to_string()],
866 ..PermissionsConfig::default()
867 };
868
869 let read_request = PermissionRequest {
870 exact_tool_name: "read_file".to_string(),
871 kind: PermissionRequestKind::Read { paths: vec![] },
872 builtin_file_mutation: false,
873 protected_write_paths: Vec::new(),
874 };
875 let exec_request = PermissionRequest {
876 exact_tool_name: "unified_exec".to_string(),
877 kind: PermissionRequestKind::Other,
878 builtin_file_mutation: false,
879 protected_write_paths: Vec::new(),
880 };
881
882 assert!(evaluate_permissions(&config, &workspace, &cwd, &read_request).allow);
883 assert!(evaluate_permissions(&config, &workspace, &cwd, &exec_request).deny);
884 }
885
886 #[test]
887 fn agent_deny_wins_over_ask_auto_allow_and_default() {
888 let (_temp, workspace, cwd) = workspace_roots();
889 let request = exact_tool_request("unified_exec");
890 let mut permissions = agent_permissions(PermissionDefault::Allow);
891 permissions.allow = vec!["unified_exec".to_string()];
892 permissions.auto = vec!["unified_exec".to_string()];
893 permissions.ask = vec!["unified_exec".to_string()];
894 permissions.deny = vec!["unified_exec".to_string()];
895
896 assert_eq!(
897 evaluate_agent_permissions(&permissions, &workspace, &cwd, &request),
898 ResolvedPermissionDecision::Deny
899 );
900 }
901
902 #[test]
903 fn agent_ask_wins_over_auto_allow_and_default() {
904 let (_temp, workspace, cwd) = workspace_roots();
905 let request = exact_tool_request("unified_exec");
906 let mut permissions = agent_permissions(PermissionDefault::Deny);
907 permissions.allow = vec!["unified_exec".to_string()];
908 permissions.auto = vec!["unified_exec".to_string()];
909 permissions.ask = vec!["unified_exec".to_string()];
910
911 assert_eq!(
912 evaluate_agent_permissions(&permissions, &workspace, &cwd, &request),
913 ResolvedPermissionDecision::Ask
914 );
915 }
916
917 #[test]
918 fn agent_auto_wins_over_allow_and_default() {
919 let (_temp, workspace, cwd) = workspace_roots();
920 let request = exact_tool_request("unified_exec");
921 let mut permissions = agent_permissions(PermissionDefault::Deny);
922 permissions.allow = vec!["unified_exec".to_string()];
923 permissions.auto = vec!["unified_exec".to_string()];
924
925 assert_eq!(
926 evaluate_agent_permissions(&permissions, &workspace, &cwd, &request),
927 ResolvedPermissionDecision::Auto
928 );
929 }
930
931 #[test]
932 fn agent_allow_wins_over_default() {
933 let (_temp, workspace, cwd) = workspace_roots();
934 let request = exact_tool_request("read_file");
935 let mut permissions = agent_permissions(PermissionDefault::Deny);
936 permissions.allow = vec!["read_file".to_string()];
937
938 assert_eq!(
939 evaluate_agent_permissions(&permissions, &workspace, &cwd, &request),
940 ResolvedPermissionDecision::Allow
941 );
942 }
943
944 #[test]
945 fn unmatched_agent_calls_use_permissions_default() {
946 let (_temp, workspace, cwd) = workspace_roots();
947 let request = exact_tool_request("read_file");
948 let permissions = agent_permissions(PermissionDefault::Auto);
949
950 assert_eq!(
951 evaluate_agent_permissions(&permissions, &workspace, &cwd, &request),
952 ResolvedPermissionDecision::Auto
953 );
954 }
955
956 #[test]
957 fn missing_permissions_default_is_invalid_before_evaluation() {
958 let err = toml::from_str::<AgentPermissionsConfig>(r#"allow = ["read_file"]"#).unwrap_err();
959
960 assert!(err.to_string().contains("missing field `default`"));
961 }
962
963 #[test]
964 fn global_deny_is_hard_ceiling() {
965 let (_temp, workspace, cwd) = workspace_roots();
966 let request = exact_tool_request("unified_exec");
967 let global = PermissionsConfig {
968 deny: vec!["unified_exec".to_string()],
969 ..PermissionsConfig::default()
970 };
971 let permissions = agent_permissions(PermissionDefault::Allow);
972
973 assert_eq!(
974 evaluate_effective_permissions(&global, &permissions, &workspace, &cwd, &request),
975 ResolvedPermissionDecision::Deny
976 );
977 }
978
979 #[test]
980 fn global_ask_forces_prompt_over_agent_allow_or_auto() {
981 let (_temp, workspace, cwd) = workspace_roots();
982 let request = exact_tool_request("unified_exec");
983 let global = PermissionsConfig {
984 ask: vec!["unified_exec".to_string()],
985 ..PermissionsConfig::default()
986 };
987
988 assert_eq!(
989 evaluate_effective_permissions(
990 &global,
991 &agent_permissions(PermissionDefault::Allow),
992 &workspace,
993 &cwd,
994 &request,
995 ),
996 ResolvedPermissionDecision::Ask
997 );
998 assert_eq!(
999 evaluate_effective_permissions(
1000 &global,
1001 &agent_permissions(PermissionDefault::Auto),
1002 &workspace,
1003 &cwd,
1004 &request,
1005 ),
1006 ResolvedPermissionDecision::Ask
1007 );
1008 }
1009
1010 #[test]
1011 fn global_allow_cannot_override_agent_deny_or_auto() {
1012 let (_temp, workspace, cwd) = workspace_roots();
1013 let request = exact_tool_request("unified_exec");
1014 let global = PermissionsConfig {
1015 allow: vec!["unified_exec".to_string()],
1016 ..PermissionsConfig::default()
1017 };
1018
1019 assert_eq!(
1020 evaluate_effective_permissions(
1021 &global,
1022 &agent_permissions(PermissionDefault::Deny),
1023 &workspace,
1024 &cwd,
1025 &request,
1026 ),
1027 ResolvedPermissionDecision::Deny
1028 );
1029 assert_eq!(
1030 evaluate_effective_permissions(
1031 &global,
1032 &agent_permissions(PermissionDefault::Auto),
1033 &workspace,
1034 &cwd,
1035 &request,
1036 ),
1037 ResolvedPermissionDecision::Auto
1038 );
1039 }
1040
1041 #[test]
1042 fn agent_specific_deny_wins_within_agent_scope() {
1043 let (_temp, workspace, cwd) = workspace_roots();
1044 let request = exact_tool_request("write_file");
1045 let global = PermissionsConfig {
1046 allow: vec!["write_file".to_string()],
1047 ..PermissionsConfig::default()
1048 };
1049 let mut permissions = agent_permissions(PermissionDefault::Allow);
1050 permissions.deny = vec!["write_file".to_string()];
1051
1052 assert_eq!(
1053 evaluate_effective_permissions(&global, &permissions, &workspace, &cwd, &request),
1054 ResolvedPermissionDecision::Deny
1055 );
1056 }
1057
1058 #[test]
1059 fn auto_bucket_resolves_to_classifier_backed_decision() {
1060 let (_temp, workspace, cwd) = workspace_roots();
1061 let request = exact_tool_request("unified_exec");
1062 let mut permissions = agent_permissions(PermissionDefault::Ask);
1063 permissions.auto = vec!["unified_exec".to_string()];
1064
1065 assert_eq!(
1066 evaluate_agent_permissions(&permissions, &workspace, &cwd, &request),
1067 ResolvedPermissionDecision::Auto
1068 );
1069 }
1070}