Skip to main content

vtcode_core/
permissions.rs

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