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;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum PermissionRuleDecision {
15    Allow,
16    Ask,
17    Deny,
18    NoMatch,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22pub struct PermissionRuleMatches {
23    pub deny: bool,
24    pub ask: bool,
25    pub allow: bool,
26}
27
28impl PermissionRuleMatches {
29    pub const fn decision(self) -> PermissionRuleDecision {
30        if self.deny {
31            PermissionRuleDecision::Deny
32        } else if self.ask {
33            PermissionRuleDecision::Ask
34        } else if self.allow {
35            PermissionRuleDecision::Allow
36        } else {
37            PermissionRuleDecision::NoMatch
38        }
39    }
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum PermissionRequestKind {
44    Bash { command: String },
45    Read { paths: Vec<PathBuf> },
46    Edit { paths: Vec<PathBuf> },
47    Write { paths: Vec<PathBuf> },
48    WebFetch { domains: Vec<String> },
49    Mcp { server: String, tool: String },
50    Other,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct PermissionRequest {
55    pub exact_tool_name: String,
56    pub kind: PermissionRequestKind,
57    pub builtin_file_mutation: bool,
58    pub protected_write_paths: Vec<PathBuf>,
59}
60
61impl PermissionRequest {
62    pub fn requires_protected_write_prompt(&self) -> bool {
63        !self.protected_write_paths.is_empty()
64    }
65}
66
67pub fn build_permission_request(
68    workspace_root: &Path,
69    current_dir: &Path,
70    normalized_tool_name: &str,
71    tool_args: Option<&Value>,
72) -> PermissionRequest {
73    let kind = build_request_kind(workspace_root, current_dir, normalized_tool_name, tool_args);
74    let protected_write_paths = protected_write_paths(workspace_root, &kind);
75    let builtin_file_mutation = matches!(
76        kind,
77        PermissionRequestKind::Edit { .. } | PermissionRequestKind::Write { .. }
78    );
79
80    PermissionRequest {
81        exact_tool_name: normalized_tool_name.to_string(),
82        kind,
83        builtin_file_mutation,
84        protected_write_paths,
85    }
86}
87
88pub fn evaluate_permissions(
89    config: &PermissionsConfig,
90    workspace_root: &Path,
91    current_dir: &Path,
92    request: &PermissionRequest,
93) -> PermissionRuleMatches {
94    let evaluator = PermissionEvaluator::new(config, workspace_root, current_dir);
95    evaluator.evaluate(request)
96}
97
98struct PermissionEvaluator {
99    deny: Vec<CompiledPermissionRule>,
100    ask: Vec<CompiledPermissionRule>,
101    allow: Vec<CompiledPermissionRule>,
102}
103
104impl PermissionEvaluator {
105    fn new(config: &PermissionsConfig, workspace_root: &Path, current_dir: &Path) -> Self {
106        Self {
107            deny: compile_rules(&config.deny, workspace_root, current_dir),
108            ask: compile_rules(&config.ask, workspace_root, current_dir),
109            allow: compile_rules(&config.allow, workspace_root, current_dir),
110        }
111    }
112
113    fn evaluate(&self, request: &PermissionRequest) -> PermissionRuleMatches {
114        PermissionRuleMatches {
115            deny: self.deny.iter().any(|rule| rule.matches(request)),
116            ask: self.ask.iter().any(|rule| rule.matches(request)),
117            allow: self.allow.iter().any(|rule| rule.matches(request)),
118        }
119    }
120}
121
122fn compile_rules(
123    rules: &[String],
124    workspace_root: &Path,
125    current_dir: &Path,
126) -> Vec<CompiledPermissionRule> {
127    rules
128        .iter()
129        .filter_map(|rule| CompiledPermissionRule::compile(rule, workspace_root, current_dir))
130        .collect()
131}
132
133#[derive(Debug)]
134enum CompiledPermissionRule {
135    Bash(Option<Pattern>),
136    Read(Option<PathRuleMatcher>),
137    Edit(Option<PathRuleMatcher>),
138    Write(Option<PathRuleMatcher>),
139    WebFetchAll,
140    WebFetchDomain(String),
141    McpServer(String),
142    McpWildcard(String),
143    McpTool { server: String, tool: String },
144    ExactTool(String),
145}
146
147impl CompiledPermissionRule {
148    fn compile(raw: &str, workspace_root: &Path, current_dir: &Path) -> Option<Self> {
149        let rule = raw.trim();
150        if rule.is_empty() {
151            return None;
152        }
153
154        if rule.eq_ignore_ascii_case("bash") || rule.eq_ignore_ascii_case("bash(*)") {
155            return Some(Self::Bash(None));
156        }
157        if let Some(specifier) = parse_tool_specifier(rule, "bash") {
158            return compile_bash_rule(specifier).map(Self::Bash);
159        }
160
161        if rule.eq_ignore_ascii_case("read") || rule.eq_ignore_ascii_case("read(*)") {
162            return Some(Self::Read(None));
163        }
164        if let Some(specifier) = parse_tool_specifier(rule, "read") {
165            return PathRuleMatcher::compile(specifier, workspace_root, current_dir)
166                .map(Some)
167                .map(Self::Read);
168        }
169
170        if rule.eq_ignore_ascii_case("edit") || rule.eq_ignore_ascii_case("edit(*)") {
171            return Some(Self::Edit(None));
172        }
173        if let Some(specifier) = parse_tool_specifier(rule, "edit") {
174            return PathRuleMatcher::compile(specifier, workspace_root, current_dir)
175                .map(Some)
176                .map(Self::Edit);
177        }
178
179        if rule.eq_ignore_ascii_case("write") || rule.eq_ignore_ascii_case("write(*)") {
180            return Some(Self::Write(None));
181        }
182        if let Some(specifier) = parse_tool_specifier(rule, "write") {
183            return PathRuleMatcher::compile(specifier, workspace_root, current_dir)
184                .map(Some)
185                .map(Self::Write);
186        }
187
188        if rule.eq_ignore_ascii_case("webfetch") || rule.eq_ignore_ascii_case("webfetch(*)") {
189            return Some(Self::WebFetchAll);
190        }
191        if let Some(specifier) = parse_tool_specifier(rule, "webfetch") {
192            let domain = specifier
193                .strip_prefix("domain:")?
194                .trim()
195                .to_ascii_lowercase();
196            if domain.is_empty() {
197                return None;
198            }
199            return Some(Self::WebFetchDomain(domain));
200        }
201
202        if let Some(server) = rule.strip_prefix(MCP_QUALIFIED_TOOL_PREFIX) {
203            if let Some((server, tool)) = server.split_once("__") {
204                if tool == "*" {
205                    return Some(Self::McpWildcard(server.to_string()));
206                }
207                if !server.is_empty() && !tool.is_empty() {
208                    return Some(Self::McpTool {
209                        server: server.to_string(),
210                        tool: tool.to_string(),
211                    });
212                }
213                return None;
214            }
215            if !server.is_empty() {
216                return Some(Self::McpServer(server.to_string()));
217            }
218            return None;
219        }
220
221        if rule.contains('(') || rule.contains(')') {
222            return None;
223        }
224
225        Some(Self::ExactTool(rule.to_string()))
226    }
227
228    fn matches(&self, request: &PermissionRequest) -> bool {
229        match self {
230            Self::Bash(pattern) => match &request.kind {
231                PermissionRequestKind::Bash { command } => pattern
232                    .as_ref()
233                    .is_none_or(|pattern| pattern.matches(command)),
234                _ => false,
235            },
236            Self::Read(matcher) => match &request.kind {
237                PermissionRequestKind::Read { paths } => matcher
238                    .as_ref()
239                    .is_none_or(|matcher| paths.iter().any(|path| matcher.matches(path))),
240                _ => false,
241            },
242            Self::Edit(matcher) => match &request.kind {
243                PermissionRequestKind::Edit { paths } => matcher
244                    .as_ref()
245                    .is_none_or(|matcher| paths.iter().any(|path| matcher.matches(path))),
246                _ => false,
247            },
248            Self::Write(matcher) => match &request.kind {
249                PermissionRequestKind::Write { paths } => matcher
250                    .as_ref()
251                    .is_none_or(|matcher| paths.iter().any(|path| matcher.matches(path))),
252                _ => false,
253            },
254            Self::WebFetchAll => matches!(request.kind, PermissionRequestKind::WebFetch { .. }),
255            Self::WebFetchDomain(domain) => match &request.kind {
256                PermissionRequestKind::WebFetch { domains } => domains
257                    .iter()
258                    .any(|candidate| domain_matches_allowed(candidate, domain)),
259                _ => false,
260            },
261            Self::McpServer(server) | Self::McpWildcard(server) => match &request.kind {
262                PermissionRequestKind::Mcp {
263                    server: candidate, ..
264                } => candidate == server,
265                _ => false,
266            },
267            Self::McpTool { server, tool } => match &request.kind {
268                PermissionRequestKind::Mcp {
269                    server: candidate_server,
270                    tool: candidate_tool,
271                } => candidate_server == server && candidate_tool == tool,
272                _ => false,
273            },
274            Self::ExactTool(tool_name) => request.exact_tool_name == *tool_name,
275        }
276    }
277}
278
279fn parse_tool_specifier<'a>(rule: &'a str, tool_name: &str) -> Option<&'a str> {
280    let open = rule.find('(')?;
281    let close = rule.rfind(')')?;
282    if close <= open || close + 1 != rule.len() {
283        return None;
284    }
285    let prefix = &rule[..open];
286    prefix
287        .eq_ignore_ascii_case(tool_name)
288        .then_some(rule[open + 1..close].trim())
289}
290
291fn compile_bash_rule(specifier: &str) -> Option<Option<Pattern>> {
292    if specifier.is_empty() || specifier == "*" {
293        return Some(None);
294    }
295    Pattern::new(specifier).ok().map(Some)
296}
297
298#[derive(Debug)]
299struct PathRuleMatcher {
300    matcher: Gitignore,
301}
302
303impl PathRuleMatcher {
304    fn compile(raw: &str, workspace_root: &Path, current_dir: &Path) -> Option<Self> {
305        let home_dir = dirs::home_dir();
306        let (root, pattern) = if let Some(path) = raw.strip_prefix("//") {
307            (PathBuf::from("/"), format!("/{}", path))
308        } else if let Some(path) = raw.strip_prefix("~/") {
309            (home_dir?, format!("/{}", path))
310        } else if raw.starts_with('/') {
311            (workspace_root.to_path_buf(), raw.to_string())
312        } else if let Some(path) = raw.strip_prefix("./") {
313            (current_dir.to_path_buf(), path.to_string())
314        } else {
315            (current_dir.to_path_buf(), raw.to_string())
316        };
317
318        let mut builder = GitignoreBuilder::new(root);
319        builder.add_line(None, &pattern).ok()?;
320        let matcher = builder.build().ok()?;
321        Some(Self { matcher })
322    }
323
324    fn matches(&self, candidate: &Path) -> bool {
325        self.matcher
326            .matched_path_or_any_parents(candidate, false)
327            .is_ignore()
328    }
329}
330
331fn build_request_kind(
332    workspace_root: &Path,
333    current_dir: &Path,
334    normalized_tool_name: &str,
335    tool_args: Option<&Value>,
336) -> PermissionRequestKind {
337    if let Some((server, tool)) = parse_mcp_request(normalized_tool_name) {
338        return PermissionRequestKind::Mcp { server, tool };
339    }
340
341    let Some(args) = tool_args else {
342        return PermissionRequestKind::Other;
343    };
344
345    if tool_intent::is_command_run_tool_call(normalized_tool_name, args)
346        && let Ok(Some(command)) = command_args::command_text(args)
347    {
348        return PermissionRequestKind::Bash { command };
349    }
350
351    if is_web_fetch_request(normalized_tool_name, args) {
352        let domains = extract_web_domains(args);
353        return PermissionRequestKind::WebFetch { domains };
354    }
355
356    if let Some(kind) = file_request_kind(workspace_root, current_dir, normalized_tool_name, args) {
357        return kind;
358    }
359
360    PermissionRequestKind::Other
361}
362
363fn parse_mcp_request(normalized_tool_name: &str) -> Option<(String, String)> {
364    if let Some((server, tool)) = parse_canonical_mcp_tool_name(normalized_tool_name) {
365        return Some((server.to_string(), tool.to_string()));
366    }
367
368    let stripped = normalized_tool_name.strip_prefix(MCP_QUALIFIED_TOOL_PREFIX)?;
369    let (server, tool) = stripped.split_once("__")?;
370    if server.is_empty() || tool.is_empty() || tool == "*" {
371        return None;
372    }
373    Some((server.to_string(), tool.to_string()))
374}
375
376fn is_web_fetch_request(normalized_tool_name: &str, args: &Value) -> bool {
377    normalized_tool_name == tools::WEB_FETCH
378        || normalized_tool_name == tools::FETCH_URL
379        || (normalized_tool_name == tools::UNIFIED_SEARCH
380            && tool_intent::unified_search_action(args).is_some_and(|action| action == "web"))
381}
382
383fn file_request_kind(
384    workspace_root: &Path,
385    current_dir: &Path,
386    normalized_tool_name: &str,
387    args: &Value,
388) -> Option<PermissionRequestKind> {
389    let paths = extract_candidate_paths(workspace_root, current_dir, normalized_tool_name, args);
390
391    match normalized_tool_name {
392        tools::READ_FILE | tools::GREP_FILE | tools::LIST_FILES => {
393            Some(PermissionRequestKind::Read { paths })
394        }
395        tools::WRITE_FILE
396        | tools::CREATE_FILE
397        | tools::DELETE_FILE
398        | tools::MOVE_FILE
399        | tools::COPY_FILE => Some(PermissionRequestKind::Write { paths }),
400        tools::EDIT_FILE | tools::APPLY_PATCH | tools::SEARCH_REPLACE | tools::FILE_OP => {
401            Some(PermissionRequestKind::Edit { paths })
402        }
403        tools::UNIFIED_SEARCH => {
404            if tool_intent::unified_search_action(args).is_some_and(|action| action == "web") {
405                None
406            } else {
407                Some(PermissionRequestKind::Read { paths })
408            }
409        }
410        tools::UNIFIED_FILE => match tool_intent::unified_file_action(args) {
411            Some("read") => Some(PermissionRequestKind::Read { paths }),
412            Some("edit") | Some("patch") => Some(PermissionRequestKind::Edit { paths }),
413            Some(_) => Some(PermissionRequestKind::Write { paths }),
414            None => None,
415        },
416        _ => None,
417    }
418}
419
420fn extract_candidate_paths(
421    workspace_root: &Path,
422    current_dir: &Path,
423    normalized_tool_name: &str,
424    args: &Value,
425) -> Vec<PathBuf> {
426    let mut paths = Vec::new();
427
428    if let Some(obj) = args.as_object() {
429        for key in [
430            "path",
431            "file_path",
432            "filepath",
433            "target_path",
434            "destination",
435        ] {
436            if let Some(path) = obj.get(key).and_then(Value::as_str) {
437                push_resolved_path(&mut paths, workspace_root, current_dir, path);
438            }
439        }
440    }
441
442    if normalized_tool_name == tools::APPLY_PATCH
443        || tool_intent::unified_file_action(args) == Some("patch")
444    {
445        for patch_path in extract_patch_paths(args) {
446            push_resolved_path(&mut paths, workspace_root, current_dir, &patch_path);
447        }
448    }
449
450    paths.sort();
451    paths.dedup();
452    paths
453}
454
455fn extract_patch_paths(args: &Value) -> Vec<String> {
456    let patch = args
457        .get("patch")
458        .and_then(Value::as_str)
459        .or_else(|| args.get("input").and_then(Value::as_str))
460        .or_else(|| args.as_str());
461    let Some(patch) = patch else {
462        return Vec::new();
463    };
464
465    patch
466        .lines()
467        .filter_map(|line| {
468            for prefix in [
469                "*** Update File: ",
470                "*** Add File: ",
471                "*** Delete File: ",
472                "*** Move to: ",
473            ] {
474                if let Some(path) = line.strip_prefix(prefix) {
475                    let trimmed = path.trim();
476                    if !trimmed.is_empty() {
477                        return Some(trimmed.to_string());
478                    }
479                }
480            }
481            None
482        })
483        .collect()
484}
485
486fn push_resolved_path(
487    paths: &mut Vec<PathBuf>,
488    workspace_root: &Path,
489    current_dir: &Path,
490    raw: &str,
491) {
492    let trimmed = raw.trim();
493    if trimmed.is_empty() {
494        return;
495    }
496
497    let resolved = if Path::new(trimmed).is_absolute() {
498        PathBuf::from(trimmed)
499    } else {
500        current_dir
501            .strip_prefix(workspace_root)
502            .ok()
503            .filter(|relative| !relative.as_os_str().is_empty())
504            .map(|relative| workspace_root.join(relative).join(trimmed))
505            .unwrap_or_else(|| workspace_root.join(trimmed))
506    };
507    paths.push(crate::utils::path::normalize_path(&resolved));
508}
509
510fn extract_web_domains(args: &Value) -> Vec<String> {
511    args.get("url")
512        .and_then(Value::as_str)
513        .and_then(extract_url_domain)
514        .into_iter()
515        .collect::<Vec<_>>()
516}
517
518fn extract_url_domain(url: &str) -> Option<String> {
519    let parsed = Url::parse(url).ok()?;
520    parsed
521        .host_str()
522        .map(|host| host.trim_end_matches('.').to_ascii_lowercase())
523}
524
525fn protected_write_paths(workspace_root: &Path, kind: &PermissionRequestKind) -> Vec<PathBuf> {
526    let paths = match kind {
527        PermissionRequestKind::Edit { paths } | PermissionRequestKind::Write { paths } => paths,
528        _ => return Vec::new(),
529    };
530
531    paths
532        .iter()
533        .filter(|path| is_protected_write_path(workspace_root, path))
534        .cloned()
535        .collect()
536}
537
538fn is_protected_write_path(workspace_root: &Path, path: &Path) -> bool {
539    let relative = path.strip_prefix(workspace_root).ok();
540    let Some(relative) = relative else {
541        return false;
542    };
543
544    let as_string = relative.to_string_lossy().replace('\\', "/");
545    if matches!(
546        as_string.as_str(),
547        ".vtcode/commands" | ".vtcode/agents" | ".vtcode/skills"
548    ) || as_string.starts_with(".vtcode/commands/")
549        || as_string.starts_with(".vtcode/agents/")
550        || as_string.starts_with(".vtcode/skills/")
551    {
552        return false;
553    }
554
555    matches!(
556        as_string.split('/').next(),
557        Some(".git" | ".vtcode" | ".vscode" | ".idea")
558    )
559}
560
561fn domain_matches_allowed(domain: &str, allowed: &str) -> bool {
562    let normalized_domain = domain.trim_end_matches('.').to_ascii_lowercase();
563    let normalized_allowed = allowed
564        .trim_start_matches('.')
565        .trim_end_matches('.')
566        .to_ascii_lowercase();
567
568    normalized_domain == normalized_allowed
569        || normalized_domain.ends_with(&format!(".{normalized_allowed}"))
570}
571
572#[cfg(test)]
573mod tests {
574    use super::{
575        PermissionRequest, PermissionRequestKind, PermissionRuleDecision, build_permission_request,
576        evaluate_permissions,
577    };
578    use crate::config::{PermissionMode, PermissionsConfig};
579    use serde_json::json;
580    use tempfile::TempDir;
581
582    fn workspace_roots() -> (TempDir, std::path::PathBuf, std::path::PathBuf) {
583        let temp = TempDir::new().expect("temp dir");
584        let workspace = temp.path().join("workspace");
585        let cwd = workspace.join("nested");
586        std::fs::create_dir_all(&cwd).expect("create dirs");
587        (temp, workspace, cwd)
588    }
589
590    #[test]
591    fn deny_precedes_ask_and_allow() {
592        let (_temp, workspace, cwd) = workspace_roots();
593        let config = PermissionsConfig {
594            allow: vec!["Read".to_string()],
595            ask: vec!["Read(/docs/**)".to_string()],
596            deny: vec!["Read(/docs/secret.txt)".to_string()],
597            ..PermissionsConfig::default()
598        };
599        let request = PermissionRequest {
600            exact_tool_name: "read_file".to_string(),
601            kind: PermissionRequestKind::Read {
602                paths: vec![workspace.join("docs/secret.txt")],
603            },
604            builtin_file_mutation: false,
605            protected_write_paths: Vec::new(),
606        };
607
608        assert_eq!(
609            evaluate_permissions(&config, &workspace, &cwd, &request).decision(),
610            PermissionRuleDecision::Deny
611        );
612    }
613
614    #[test]
615    fn bash_glob_matches_command_text() {
616        let (_temp, workspace, cwd) = workspace_roots();
617        let config = PermissionsConfig {
618            allow: vec!["Bash(cargo test *)".to_string()],
619            ..PermissionsConfig::default()
620        };
621        let request = PermissionRequest {
622            exact_tool_name: "unified_exec".to_string(),
623            kind: PermissionRequestKind::Bash {
624                command: "cargo test -p vtcode".to_string(),
625            },
626            builtin_file_mutation: false,
627            protected_write_paths: Vec::new(),
628        };
629
630        assert_eq!(
631            evaluate_permissions(&config, &workspace, &cwd, &request).decision(),
632            PermissionRuleDecision::Allow
633        );
634    }
635
636    #[test]
637    fn read_path_rules_use_workspace_relative_matching() {
638        let (_temp, workspace, cwd) = workspace_roots();
639        let config = PermissionsConfig {
640            ask: vec!["Read(/src/**/*.rs)".to_string()],
641            ..PermissionsConfig::default()
642        };
643        let request = PermissionRequest {
644            exact_tool_name: "read_file".to_string(),
645            kind: PermissionRequestKind::Read {
646                paths: vec![workspace.join("src/lib.rs")],
647            },
648            builtin_file_mutation: false,
649            protected_write_paths: Vec::new(),
650        };
651
652        assert_eq!(
653            evaluate_permissions(&config, &workspace, &cwd, &request).decision(),
654            PermissionRuleDecision::Ask
655        );
656    }
657
658    #[test]
659    fn mcp_rules_match_canonical_requests() {
660        let (_temp, workspace, cwd) = workspace_roots();
661        let config = PermissionsConfig {
662            allow: vec!["mcp__context7__*".to_string()],
663            ..PermissionsConfig::default()
664        };
665        let request = PermissionRequest {
666            exact_tool_name: "mcp::context7::search-docs".to_string(),
667            kind: PermissionRequestKind::Mcp {
668                server: "context7".to_string(),
669                tool: "search-docs".to_string(),
670            },
671            builtin_file_mutation: false,
672            protected_write_paths: Vec::new(),
673        };
674
675        assert_eq!(
676            evaluate_permissions(&config, &workspace, &cwd, &request).decision(),
677            PermissionRuleDecision::Allow
678        );
679    }
680
681    #[test]
682    fn protected_directory_exceptions_are_not_flagged() {
683        let (_temp, workspace, cwd) = workspace_roots();
684        let request = build_permission_request(
685            &workspace,
686            &cwd,
687            "unified_file",
688            Some(&json!({
689                "action": "write",
690                "path": "../.vtcode/skills/example.md"
691            })),
692        );
693        assert!(!request.requires_protected_write_prompt());
694
695        let request = build_permission_request(
696            &workspace,
697            &cwd,
698            "unified_file",
699            Some(&json!({
700                "action": "write",
701                "path": "../.vtcode/settings.toml"
702            })),
703        );
704        assert!(request.requires_protected_write_prompt());
705    }
706
707    #[test]
708    fn apply_patch_paths_are_extracted_for_edit_rules() {
709        let (_temp, workspace, cwd) = workspace_roots();
710        let config = PermissionsConfig {
711            ask: vec!["Edit(/src/**)".to_string()],
712            ..PermissionsConfig::default()
713        };
714        let request = build_permission_request(
715            &workspace,
716            &cwd,
717            "apply_patch",
718            Some(&json!({
719                "patch": "*** Begin Patch\n*** Update File: ../src/main.rs\n@@\n-test\n+test\n*** End Patch\n"
720            })),
721        );
722
723        assert_eq!(
724            evaluate_permissions(&config, &workspace, &cwd, &request).decision(),
725            PermissionRuleDecision::Ask
726        );
727    }
728
729    #[test]
730    fn mode_defaults_to_standard_behavior() {
731        assert_eq!(PermissionMode::default(), PermissionMode::Default);
732    }
733
734    #[test]
735    fn relative_paths_resolve_from_current_directory() {
736        let (_temp, workspace, cwd) = workspace_roots();
737        let config = PermissionsConfig {
738            ask: vec!["Read(./nested-file.rs)".to_string()],
739            ..PermissionsConfig::default()
740        };
741        let request = build_permission_request(
742            &workspace,
743            &cwd,
744            "read_file",
745            Some(&json!({"path": "nested-file.rs"})),
746        );
747
748        assert_eq!(
749            evaluate_permissions(&config, &workspace, &cwd, &request).decision(),
750            PermissionRuleDecision::Ask
751        );
752    }
753
754    #[test]
755    fn exact_tool_rules_feed_rule_tiers() {
756        let (_temp, workspace, cwd) = workspace_roots();
757        let config = PermissionsConfig {
758            allow: vec!["read_file".to_string()],
759            deny: vec!["unified_exec".to_string()],
760            ..PermissionsConfig::default()
761        };
762
763        let read_request = PermissionRequest {
764            exact_tool_name: "read_file".to_string(),
765            kind: PermissionRequestKind::Read { paths: vec![] },
766            builtin_file_mutation: false,
767            protected_write_paths: Vec::new(),
768        };
769        let exec_request = PermissionRequest {
770            exact_tool_name: "unified_exec".to_string(),
771            kind: PermissionRequestKind::Other,
772            builtin_file_mutation: false,
773            protected_write_paths: Vec::new(),
774        };
775
776        assert!(evaluate_permissions(&config, &workspace, &cwd, &read_request).allow);
777        assert!(evaluate_permissions(&config, &workspace, &cwd, &exec_request).deny);
778    }
779}