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