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