Skip to main content

vtcode_core/
permissions.rs

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    /// Converts these matches into a single [`PermissionRuleDecision`] using deny > ask > auto > allow precedence.
41    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/// Categorizes a permission request by the type of operation being performed.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum PermissionRequestKind {
59    /// A shell command execution request.
60    Bash { command: String },
61    /// A file read request targeting the given paths.
62    Read { paths: Vec<PathBuf> },
63    /// A file edit request targeting the given paths.
64    Edit { paths: Vec<PathBuf> },
65    /// A file write/create/delete request targeting the given paths.
66    Write { paths: Vec<PathBuf> },
67    /// A web fetch request targeting the given domains.
68    WebFetch { domains: Vec<String> },
69    /// An MCP tool invocation for the given server and tool.
70    Mcp { server: String, tool: String },
71    /// A tool call that does not fit the categories above.
72    Other,
73}
74
75/// A fully described permission request carrying the tool name, operation kind,
76/// and any protected-path metadata needed for prompting.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct PermissionRequest {
79    /// Exact (normalized) tool name that originated this request.
80    pub exact_tool_name: String,
81    /// Categorized operation kind for rule matching.
82    pub kind: PermissionRequestKind,
83    /// Whether this request mutates a file via a builtin file tool.
84    pub builtin_file_mutation: bool,
85    /// Paths within protected directories (`.git`, `.vtcode`, etc.) that require extra confirmation.
86    pub protected_write_paths: Vec<PathBuf>,
87}
88
89impl PermissionRequest {
90    /// Returns `true` if this request targets one or more protected write paths.
91    pub fn requires_protected_write_prompt(&self) -> bool {
92        !self.protected_write_paths.is_empty()
93    }
94}
95
96/// Constructs a [`PermissionRequest`] from a normalized tool name and its arguments.
97pub 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
116/// Build representative permission requests for advertised tool availability.
117///
118/// Runtime dispatch still evaluates the concrete call arguments. Advertisement cannot know future
119/// arguments, so multi-action tools include each category they can expose and callers should treat
120/// any denied representative request as a reason not to grant provider-native availability.
121pub 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
142/// Evaluates a permission request against the global permission configuration and
143/// returns which rule tiers matched.
144pub 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
154/// Evaluates a permission request against an agent's permission configuration,
155/// returning the resolved decision using the agent's default when no rule matches.
156pub 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
166/// Evaluates a permission request by combining global and agent-level rules.
167/// Global deny is a hard ceiling; global ask forces a prompt unless the agent denies.
168pub 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        // Use semantic rules which are the recommended approach
865        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        // Use a custom tool name that won't be normalized to a semantic rule
892        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        // Use a custom tool name that won't be normalized to a semantic rule
909        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        // Use a custom tool name that won't be normalized to a semantic rule
925        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        // Use a custom tool name that won't be normalized to a semantic rule
940        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        // Use a custom tool name that won't be normalized to a semantic rule
1115        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        // Use a custom tool name that won't be normalized to a semantic rule
1132        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        // Use a custom tool name that won't be normalized to a semantic rule
1195        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        // Use a custom tool name that won't be normalized to a semantic rule
1213        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    /// Turn_912/913 regression: the built-in plan agent's permission rules
1224    /// must keep every tool in the planning wire catalog visible — the wire
1225    /// shaper hides a tool only when ALL advertised permission requests are
1226    /// denied, and the old read-only allow list denied `exec_command` (Bash)
1227    /// and `request_user_input` (Other), collapsing the planning catalog to
1228    /// bare `code_search`. Mutation tools must stay fully denied.
1229    #[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}