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::{
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
103/// Build representative permission requests for advertised tool availability.
104///
105/// Runtime dispatch still evaluates the concrete call arguments. Advertisement cannot know future
106/// arguments, so multi-action tools include each category they can expose and callers should treat
107/// any denied representative request as a reason not to grant provider-native availability.
108pub 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        // Use semantic rules which are the recommended approach
939        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        // Use a custom tool name that won't be normalized to a semantic rule
968        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        // Use a custom tool name that won't be normalized to a semantic rule
985        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        // Use a custom tool name that won't be normalized to a semantic rule
1001        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        // Use a custom tool name that won't be normalized to a semantic rule
1016        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        // Use a custom tool name that won't be normalized to a semantic rule
1078        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        // Use a custom tool name that won't be normalized to a semantic rule
1095        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        // Use a custom tool name that won't be normalized to a semantic rule
1158        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        // Use a custom tool name that won't be normalized to a semantic rule
1176        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}