Skip to main content

a3s_code_core/
permissions.rs

1//! Permission system for tool execution control
2//!
3//! Implements a declarative permission system similar to Claude Code's permissions.
4//! Supports pattern matching with wildcards and three-tier evaluation:
5//! 1. Deny rules - checked first, any match = immediate denial
6//! 2. Allow rules - checked second, any match = auto-approval
7//! 3. Ask rules - checked third, forces confirmation prompt
8//! 4. Default behavior - falls back to HITL policy
9
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12
13/// Trait for checking tool execution permissions.
14///
15/// Implement this trait to provide custom permission logic.
16/// The built-in `PermissionPolicy` implements this trait using
17/// declarative allow/deny/ask rules with pattern matching.
18pub trait PermissionChecker: Send + Sync {
19    /// Check whether a tool invocation is allowed, denied, or requires confirmation.
20    fn check(&self, tool_name: &str, args: &serde_json::Value) -> PermissionDecision;
21}
22
23/// Permission decision result
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum PermissionDecision {
27    /// Automatically allow without user confirmation
28    Allow,
29    /// Deny execution
30    Deny,
31    /// Ask user for confirmation
32    Ask,
33}
34
35/// A permission rule with pattern matching support
36///
37/// Format: `ToolName(pattern)` or `ToolName` (matches all)
38///
39/// Examples:
40/// - `Bash(cargo:*)` - matches all cargo commands
41/// - `Bash(npm run test:*)` - matches npm run test with any args
42/// - `Read(src/**/*.rs)` - matches Rust files in src/
43/// - `Grep(*)` - matches all grep invocations
44/// - `mcp__pencil` - matches all pencil MCP tools
45///
46/// Deserialization supports both plain strings and `{rule: "..."}` objects:
47/// ```yaml
48/// allow:
49///   - read                   # plain string
50///   - rule: "Bash(cargo:*)"  # struct form
51/// ```
52#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
53pub struct PermissionRule {
54    /// The original rule string
55    pub rule: String,
56    /// Parsed tool name
57    #[serde(skip)]
58    tool_name: Option<String>,
59    /// Parsed argument pattern (None means match all)
60    #[serde(skip)]
61    arg_pattern: Option<String>,
62}
63
64impl<'de> Deserialize<'de> for PermissionRule {
65    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
66    where
67        D: serde::Deserializer<'de>,
68    {
69        /// Helper enum to accept both `"read"` and `{rule: "read"}` in YAML/JSON.
70        #[derive(Deserialize)]
71        #[serde(untagged)]
72        enum RuleRepr {
73            Plain(String),
74            Struct { rule: String },
75        }
76
77        let rule_str = match RuleRepr::deserialize(deserializer)? {
78            RuleRepr::Plain(s) => s,
79            RuleRepr::Struct { rule } => rule,
80        };
81        // `new()` calls `parse_rule()` to populate tool_name and arg_pattern.
82        Ok(PermissionRule::new(&rule_str))
83    }
84}
85
86impl PermissionRule {
87    /// Create a new permission rule from a pattern string
88    pub fn new(rule: &str) -> Self {
89        let (tool_name, arg_pattern) = Self::parse_rule(rule);
90        Self {
91            rule: rule.to_string(),
92            tool_name,
93            arg_pattern,
94        }
95    }
96
97    /// Parse rule string into tool name and argument pattern
98    fn parse_rule(rule: &str) -> (Option<String>, Option<String>) {
99        // Handle format: ToolName(pattern) or ToolName
100        if let Some(paren_start) = rule.find('(') {
101            if rule.ends_with(')') {
102                let tool_name = rule[..paren_start].to_string();
103                let pattern = rule[paren_start + 1..rule.len() - 1].to_string();
104                return (Some(tool_name), Some(pattern));
105            }
106        }
107        // No parentheses - tool name only, matches all args
108        (Some(rule.to_string()), None)
109    }
110
111    /// Check if this rule matches a tool invocation
112    pub fn matches(&self, tool_name: &str, args: &serde_json::Value) -> bool {
113        // Check tool name
114        let rule_tool = match &self.tool_name {
115            Some(t) => t,
116            None => return false,
117        };
118
119        if !self.matches_tool_name(rule_tool, tool_name) {
120            return false;
121        }
122
123        // If no argument pattern, match all
124        let pattern = match &self.arg_pattern {
125            Some(p) => p,
126            None => return true,
127        };
128
129        // Match against argument pattern
130        self.matches_args(pattern, tool_name, args)
131    }
132
133    /// Check if tool names match (case-insensitive, wildcard-aware)
134    fn matches_tool_name(&self, rule_tool: &str, actual_tool: &str) -> bool {
135        // If the rule contains wildcards, use glob matching on the tool name directly.
136        // e.g. "mcp__longvt__*" must use glob, not starts_with, because starts_with
137        // treats '*' as a literal character and will never match.
138        if rule_tool.contains('*') || rule_tool.contains('?') {
139            return self.glob_match(rule_tool, actual_tool);
140        }
141
142        // Handle MCP tools: mcp__server matches mcp__server__tool
143        if rule_tool.starts_with("mcp__") && actual_tool.starts_with("mcp__") {
144            // mcp__pencil matches mcp__pencil__batch_design
145            if actual_tool.starts_with(rule_tool) {
146                return true;
147            }
148        }
149        rule_tool.eq_ignore_ascii_case(actual_tool)
150    }
151
152    /// Match argument pattern against tool arguments
153    fn matches_args(&self, pattern: &str, tool_name: &str, args: &serde_json::Value) -> bool {
154        // Handle wildcard pattern "*" - matches everything
155        if pattern == "*" {
156            return true;
157        }
158
159        // Build argument string based on tool type
160        let arg_string = self.build_arg_string(tool_name, args);
161
162        // Perform glob-style matching
163        self.glob_match(pattern, &arg_string)
164    }
165
166    /// Build a string representation of arguments for matching
167    fn build_arg_string(&self, tool_name: &str, args: &serde_json::Value) -> String {
168        match tool_name.to_lowercase().as_str() {
169            "bash" => {
170                // For Bash, use the command field
171                args.get("command")
172                    .and_then(|v| v.as_str())
173                    .unwrap_or("")
174                    .to_string()
175            }
176            "read" | "write" | "edit" => {
177                // For file operations, use the file_path field
178                args.get("file_path")
179                    .and_then(|v| v.as_str())
180                    .unwrap_or("")
181                    .to_string()
182            }
183            "glob" => {
184                // For glob, use the pattern field
185                args.get("pattern")
186                    .and_then(|v| v.as_str())
187                    .unwrap_or("")
188                    .to_string()
189            }
190            "grep" => {
191                // For grep, combine pattern and path
192                let pattern = args.get("pattern").and_then(|v| v.as_str()).unwrap_or("");
193                let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
194                format!("{} {}", pattern, path)
195            }
196            "ls" => {
197                // For ls, use the path field
198                args.get("path")
199                    .and_then(|v| v.as_str())
200                    .unwrap_or("")
201                    .to_string()
202            }
203            _ => {
204                // For other tools, serialize the entire args
205                serde_json::to_string(args).unwrap_or_default()
206            }
207        }
208    }
209
210    /// Perform glob-style pattern matching
211    ///
212    /// Supports:
213    /// - `*` matches any sequence of characters (except /)
214    /// - `**` matches any sequence including /
215    /// - `:*` at the end matches any suffix (including empty)
216    fn glob_match(&self, pattern: &str, text: &str) -> bool {
217        // Handle special `:*` suffix (matches any args after the prefix)
218        if let Some(prefix) = pattern.strip_suffix(":*") {
219            return text.starts_with(prefix);
220        }
221
222        // Normalize Windows backslashes to forward slashes for consistent matching
223        let text = text.replace('\\', "/");
224
225        // Convert glob pattern to regex pattern
226        let regex_pattern = Self::glob_to_regex(pattern);
227        if let Ok(re) = regex::Regex::new(&regex_pattern) {
228            re.is_match(&text)
229        } else {
230            // Fallback to simple prefix match if regex fails
231            text.starts_with(pattern)
232        }
233    }
234
235    /// Convert glob pattern to regex pattern
236    fn glob_to_regex(pattern: &str) -> String {
237        let mut regex = String::from("^");
238        let chars: Vec<char> = pattern.chars().collect();
239        let mut i = 0;
240
241        while i < chars.len() {
242            let c = chars[i];
243            match c {
244                '*' => {
245                    // Check for ** (matches anything including /)
246                    if i + 1 < chars.len() && chars[i + 1] == '*' {
247                        // ** matches any path including /
248                        // Skip optional following /
249                        if i + 2 < chars.len() && chars[i + 2] == '/' {
250                            regex.push_str(".*");
251                            i += 3;
252                        } else {
253                            regex.push_str(".*");
254                            i += 2;
255                        }
256                    } else {
257                        // * matches anything except path separators
258                        regex.push_str("[^/\\\\]*");
259                        i += 1;
260                    }
261                }
262                '?' => {
263                    // ? matches any single character except path separators
264                    regex.push_str("[^/\\\\]");
265                    i += 1;
266                }
267                '.' | '+' | '^' | '$' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '\\' => {
268                    // Escape regex special characters
269                    regex.push('\\');
270                    regex.push(c);
271                    i += 1;
272                }
273                _ => {
274                    regex.push(c);
275                    i += 1;
276                }
277            }
278        }
279
280        regex.push('$');
281        regex
282    }
283}
284
285/// Permission policy configuration
286///
287/// Evaluation order:
288/// 1. Deny rules - any match results in denial
289/// 2. Allow rules - any match results in auto-approval
290/// 3. Ask rules - any match requires user confirmation
291/// 4. Default - falls back to default_decision
292#[derive(Debug, Clone, Serialize, Deserialize)]
293pub struct PermissionPolicy {
294    /// Rules that always deny (checked first)
295    #[serde(default)]
296    pub deny: Vec<PermissionRule>,
297
298    /// Rules that auto-approve without confirmation
299    #[serde(default)]
300    pub allow: Vec<PermissionRule>,
301
302    /// Rules that always require confirmation
303    #[serde(default)]
304    pub ask: Vec<PermissionRule>,
305
306    /// Default decision when no rules match
307    #[serde(default = "default_decision")]
308    pub default_decision: PermissionDecision,
309
310    /// Whether the permission system is enabled
311    #[serde(default = "default_enabled")]
312    pub enabled: bool,
313}
314
315fn default_decision() -> PermissionDecision {
316    PermissionDecision::Ask
317}
318
319fn default_enabled() -> bool {
320    true
321}
322
323impl Default for PermissionPolicy {
324    fn default() -> Self {
325        Self {
326            deny: Vec::new(),
327            allow: Vec::new(),
328            ask: Vec::new(),
329            default_decision: PermissionDecision::Ask,
330            enabled: true,
331        }
332    }
333}
334
335impl PermissionPolicy {
336    /// Create a new permission policy
337    pub fn new() -> Self {
338        Self::default()
339    }
340
341    /// Create a permissive policy that allows everything
342    pub fn permissive() -> Self {
343        Self {
344            deny: Vec::new(),
345            allow: Vec::new(),
346            ask: Vec::new(),
347            default_decision: PermissionDecision::Allow,
348            enabled: true,
349        }
350    }
351
352    /// Create a strict policy that asks for everything
353    pub fn strict() -> Self {
354        Self {
355            deny: Vec::new(),
356            allow: Vec::new(),
357            ask: Vec::new(),
358            default_decision: PermissionDecision::Ask,
359            enabled: true,
360        }
361    }
362
363    /// Add a deny rule
364    pub fn deny(mut self, rule: &str) -> Self {
365        self.deny.push(PermissionRule::new(rule));
366        self
367    }
368
369    /// Add an allow rule
370    pub fn allow(mut self, rule: &str) -> Self {
371        self.allow.push(PermissionRule::new(rule));
372        self
373    }
374
375    /// Add an ask rule
376    pub fn ask(mut self, rule: &str) -> Self {
377        self.ask.push(PermissionRule::new(rule));
378        self
379    }
380
381    /// Add multiple deny rules
382    pub fn deny_all(mut self, rules: &[&str]) -> Self {
383        for rule in rules {
384            self.deny.push(PermissionRule::new(rule));
385        }
386        self
387    }
388
389    /// Add multiple allow rules
390    pub fn allow_all(mut self, rules: &[&str]) -> Self {
391        for rule in rules {
392            self.allow.push(PermissionRule::new(rule));
393        }
394        self
395    }
396
397    /// Add multiple ask rules
398    pub fn ask_all(mut self, rules: &[&str]) -> Self {
399        for rule in rules {
400            self.ask.push(PermissionRule::new(rule));
401        }
402        self
403    }
404
405    /// Check permission for a tool invocation
406    ///
407    /// Returns the permission decision based on rule evaluation order:
408    /// 1. Deny rules (any match = Deny)
409    /// 2. Allow rules (any match = Allow)
410    /// 3. Ask rules (any match = Ask)
411    /// 4. Default decision
412    pub fn check(&self, tool_name: &str, args: &serde_json::Value) -> PermissionDecision {
413        if !self.enabled {
414            return PermissionDecision::Allow;
415        }
416
417        // 1. Check deny rules first
418        for rule in &self.deny {
419            if rule.matches(tool_name, args) {
420                return PermissionDecision::Deny;
421            }
422        }
423
424        // 2. Check allow rules
425        for rule in &self.allow {
426            if rule.matches(tool_name, args) {
427                return PermissionDecision::Allow;
428            }
429        }
430
431        // 3. Check ask rules
432        for rule in &self.ask {
433            if rule.matches(tool_name, args) {
434                return PermissionDecision::Ask;
435            }
436        }
437
438        // 4. Fall back to default
439        self.default_decision
440    }
441
442    /// Check if a tool invocation is allowed (Allow or not Deny)
443    pub fn is_allowed(&self, tool_name: &str, args: &serde_json::Value) -> bool {
444        matches!(self.check(tool_name, args), PermissionDecision::Allow)
445    }
446
447    /// Check if a tool invocation is denied
448    pub fn is_denied(&self, tool_name: &str, args: &serde_json::Value) -> bool {
449        matches!(self.check(tool_name, args), PermissionDecision::Deny)
450    }
451
452    /// Check if a tool invocation requires confirmation
453    pub fn requires_confirmation(&self, tool_name: &str, args: &serde_json::Value) -> bool {
454        matches!(self.check(tool_name, args), PermissionDecision::Ask)
455    }
456
457    /// Get matching rules for debugging/logging
458    pub fn get_matching_rules(&self, tool_name: &str, args: &serde_json::Value) -> MatchingRules {
459        let mut result = MatchingRules::default();
460
461        for rule in &self.deny {
462            if rule.matches(tool_name, args) {
463                result.deny.push(rule.rule.clone());
464            }
465        }
466
467        for rule in &self.allow {
468            if rule.matches(tool_name, args) {
469                result.allow.push(rule.rule.clone());
470            }
471        }
472
473        for rule in &self.ask {
474            if rule.matches(tool_name, args) {
475                result.ask.push(rule.rule.clone());
476            }
477        }
478
479        result
480    }
481}
482
483impl PermissionChecker for PermissionPolicy {
484    fn check(&self, tool_name: &str, args: &serde_json::Value) -> PermissionDecision {
485        self.check(tool_name, args)
486    }
487}
488
489/// Matching rules for debugging
490#[derive(Debug, Default, Clone)]
491pub struct MatchingRules {
492    pub deny: Vec<String>,
493    pub allow: Vec<String>,
494    pub ask: Vec<String>,
495}
496
497impl MatchingRules {
498    pub fn is_empty(&self) -> bool {
499        self.deny.is_empty() && self.allow.is_empty() && self.ask.is_empty()
500    }
501}
502
503/// Permission manager that handles per-session permissions
504#[derive(Debug)]
505pub struct PermissionManager {
506    /// Global policy applied to all sessions
507    global_policy: PermissionPolicy,
508    /// Per-session policy overrides
509    session_policies: HashMap<String, PermissionPolicy>,
510}
511
512impl Default for PermissionManager {
513    fn default() -> Self {
514        Self::new()
515    }
516}
517
518impl PermissionManager {
519    /// Create a new permission manager with default global policy
520    pub fn new() -> Self {
521        Self {
522            global_policy: PermissionPolicy::default(),
523            session_policies: HashMap::new(),
524        }
525    }
526
527    /// Create with a custom global policy
528    pub fn with_global_policy(policy: PermissionPolicy) -> Self {
529        Self {
530            global_policy: policy,
531            session_policies: HashMap::new(),
532        }
533    }
534
535    /// Set the global policy
536    pub fn set_global_policy(&mut self, policy: PermissionPolicy) {
537        self.global_policy = policy;
538    }
539
540    /// Get the global policy
541    pub fn global_policy(&self) -> &PermissionPolicy {
542        &self.global_policy
543    }
544
545    /// Set a session-specific policy
546    pub fn set_session_policy(&mut self, session_id: &str, policy: PermissionPolicy) {
547        self.session_policies.insert(session_id.to_string(), policy);
548    }
549
550    /// Remove a session-specific policy
551    pub fn remove_session_policy(&mut self, session_id: &str) {
552        self.session_policies.remove(session_id);
553    }
554
555    /// Get the effective policy for a session
556    ///
557    /// Session policy takes precedence over global policy for matching rules.
558    /// If no session policy exists, uses global policy.
559    pub fn get_effective_policy(&self, session_id: &str) -> &PermissionPolicy {
560        self.session_policies
561            .get(session_id)
562            .unwrap_or(&self.global_policy)
563    }
564
565    /// Check permission for a tool invocation in a session
566    pub fn check(
567        &self,
568        session_id: &str,
569        tool_name: &str,
570        args: &serde_json::Value,
571    ) -> PermissionDecision {
572        // Get session policy or fall back to global
573        let policy = self.get_effective_policy(session_id);
574
575        // Session deny rules
576        for rule in &policy.deny {
577            if rule.matches(tool_name, args) {
578                return PermissionDecision::Deny;
579            }
580        }
581
582        // Global deny rules (if different policy)
583        if !self.session_policies.contains_key(session_id) {
584            // Already checked global
585        } else {
586            for rule in &self.global_policy.deny {
587                if rule.matches(tool_name, args) {
588                    return PermissionDecision::Deny;
589                }
590            }
591        }
592
593        // Session allow rules
594        for rule in &policy.allow {
595            if rule.matches(tool_name, args) {
596                return PermissionDecision::Allow;
597            }
598        }
599
600        // Session ask rules
601        for rule in &policy.ask {
602            if rule.matches(tool_name, args) {
603                return PermissionDecision::Ask;
604            }
605        }
606
607        // Fall back to policy default
608        policy.default_decision
609    }
610}
611
612#[cfg(test)]
613mod tests {
614    use super::*;
615    use serde_json::json;
616
617    // ========================================================================
618    // PermissionRule Tests
619    // ========================================================================
620
621    #[test]
622    fn test_rule_parse_simple() {
623        let rule = PermissionRule::new("Bash");
624        assert_eq!(rule.tool_name, Some("Bash".to_string()));
625        assert_eq!(rule.arg_pattern, None);
626    }
627
628    #[test]
629    fn test_rule_parse_with_pattern() {
630        let rule = PermissionRule::new("Bash(cargo:*)");
631        assert_eq!(rule.tool_name, Some("Bash".to_string()));
632        assert_eq!(rule.arg_pattern, Some("cargo:*".to_string()));
633    }
634
635    #[test]
636    fn test_rule_parse_wildcard() {
637        let rule = PermissionRule::new("Grep(*)");
638        assert_eq!(rule.tool_name, Some("Grep".to_string()));
639        assert_eq!(rule.arg_pattern, Some("*".to_string()));
640    }
641
642    #[test]
643    fn test_rule_match_tool_only() {
644        let rule = PermissionRule::new("Bash");
645        assert!(rule.matches("Bash", &json!({"command": "ls -la"})));
646        assert!(rule.matches("bash", &json!({"command": "echo hello"})));
647        assert!(!rule.matches("Read", &json!({})));
648    }
649
650    #[test]
651    fn test_rule_match_wildcard() {
652        let rule = PermissionRule::new("Grep(*)");
653        assert!(rule.matches("Grep", &json!({"pattern": "foo", "path": "/tmp"})));
654        assert!(rule.matches("grep", &json!({"pattern": "bar"})));
655    }
656
657    #[test]
658    fn test_rule_match_prefix_wildcard() {
659        let rule = PermissionRule::new("Bash(cargo:*)");
660        assert!(rule.matches("Bash", &json!({"command": "cargo build"})));
661        assert!(rule.matches("Bash", &json!({"command": "cargo test --lib"})));
662        assert!(rule.matches("Bash", &json!({"command": "cargo"})));
663        assert!(!rule.matches("Bash", &json!({"command": "npm install"})));
664    }
665
666    #[test]
667    fn test_rule_match_npm_commands() {
668        let rule = PermissionRule::new("Bash(npm run:*)");
669        assert!(rule.matches("Bash", &json!({"command": "npm run test"})));
670        assert!(rule.matches("Bash", &json!({"command": "npm run build"})));
671        assert!(!rule.matches("Bash", &json!({"command": "npm install"})));
672    }
673
674    #[test]
675    fn test_rule_match_file_path() {
676        let rule = PermissionRule::new("Read(src/*.rs)");
677        assert!(rule.matches("Read", &json!({"file_path": "src/main.rs"})));
678        assert!(rule.matches("Read", &json!({"file_path": "src/lib.rs"})));
679        assert!(!rule.matches("Read", &json!({"file_path": "src/foo/bar.rs"})));
680    }
681
682    #[test]
683    fn test_rule_match_recursive_glob() {
684        let rule = PermissionRule::new("Read(src/**/*.rs)");
685        assert!(rule.matches("Read", &json!({"file_path": "src/main.rs"})));
686        assert!(rule.matches("Read", &json!({"file_path": "src/foo/bar.rs"})));
687        assert!(rule.matches("Read", &json!({"file_path": "src/a/b/c.rs"})));
688    }
689
690    #[test]
691    fn test_rule_match_mcp_tool() {
692        let rule = PermissionRule::new("mcp__pencil");
693        assert!(rule.matches("mcp__pencil", &json!({})));
694        assert!(rule.matches("mcp__pencil__batch_design", &json!({})));
695        assert!(rule.matches("mcp__pencil__batch_get", &json!({})));
696        assert!(!rule.matches("mcp__other", &json!({})));
697    }
698
699    #[test]
700    fn test_rule_match_mcp_tool_wildcard() {
701        // "mcp__longvt__*" must match all tools from the longvt server.
702        // Previously this failed because matches_tool_name treated '*' as a
703        // literal character and starts_with("mcp__longvt__*") always returned false.
704        let rule = PermissionRule::new("mcp__longvt__*");
705        assert!(rule.matches("mcp__longvt__search", &json!({})));
706        assert!(rule.matches("mcp__longvt__create_memory", &json!({})));
707        assert!(rule.matches("mcp__longvt__delete", &json!({})));
708        assert!(!rule.matches("mcp__pencil__batch_design", &json!({})));
709        assert!(!rule.matches("mcp__other__tool", &json!({})));
710
711        // "mcp__*" must match any MCP tool from any server.
712        let rule_all = PermissionRule::new("mcp__*");
713        assert!(rule_all.matches("mcp__longvt__search", &json!({})));
714        assert!(rule_all.matches("mcp__pencil__draw", &json!({})));
715        assert!(!rule_all.matches("bash", &json!({})));
716    }
717
718    #[test]
719    fn test_rule_case_insensitive() {
720        let rule = PermissionRule::new("BASH(cargo:*)");
721        assert!(rule.matches("Bash", &json!({"command": "cargo build"})));
722        assert!(rule.matches("bash", &json!({"command": "cargo test"})));
723        assert!(rule.matches("BASH", &json!({"command": "cargo check"})));
724    }
725
726    // ========================================================================
727    // PermissionPolicy Tests
728    // ========================================================================
729
730    #[test]
731    fn test_policy_default() {
732        let policy = PermissionPolicy::default();
733        assert!(policy.enabled);
734        assert_eq!(policy.default_decision, PermissionDecision::Ask);
735        assert!(policy.allow.is_empty());
736        assert!(policy.deny.is_empty());
737        assert!(policy.ask.is_empty());
738    }
739
740    #[test]
741    fn test_policy_permissive() {
742        let policy = PermissionPolicy::permissive();
743        assert_eq!(policy.default_decision, PermissionDecision::Allow);
744    }
745
746    #[test]
747    fn test_policy_strict() {
748        let policy = PermissionPolicy::strict();
749        assert_eq!(policy.default_decision, PermissionDecision::Ask);
750    }
751
752    #[test]
753    fn test_policy_builder() {
754        let policy = PermissionPolicy::new()
755            .allow("Bash(cargo:*)")
756            .allow("Grep(*)")
757            .deny("Bash(rm -rf:*)")
758            .ask("Write(*)");
759
760        assert_eq!(policy.allow.len(), 2);
761        assert_eq!(policy.deny.len(), 1);
762        assert_eq!(policy.ask.len(), 1);
763    }
764
765    #[test]
766    fn test_policy_check_allow() {
767        let policy = PermissionPolicy::new().allow("Bash(cargo:*)");
768
769        let decision = policy.check("Bash", &json!({"command": "cargo build"}));
770        assert_eq!(decision, PermissionDecision::Allow);
771    }
772
773    #[test]
774    fn test_policy_check_deny() {
775        let policy = PermissionPolicy::new().deny("Bash(rm -rf:*)");
776
777        let decision = policy.check("Bash", &json!({"command": "rm -rf /"}));
778        assert_eq!(decision, PermissionDecision::Deny);
779    }
780
781    #[test]
782    fn test_policy_check_ask() {
783        let policy = PermissionPolicy::new().ask("Write(*)");
784
785        let decision = policy.check("Write", &json!({"file_path": "/tmp/test.txt"}));
786        assert_eq!(decision, PermissionDecision::Ask);
787    }
788
789    #[test]
790    fn test_policy_check_default() {
791        let policy = PermissionPolicy::new();
792
793        let decision = policy.check("Unknown", &json!({}));
794        assert_eq!(decision, PermissionDecision::Ask);
795    }
796
797    #[test]
798    fn test_policy_deny_wins_over_allow() {
799        let policy = PermissionPolicy::new().allow("Bash(*)").deny("Bash(rm:*)");
800
801        // Allow matches, but deny also matches - deny wins
802        let decision = policy.check("Bash", &json!({"command": "rm -rf /tmp"}));
803        assert_eq!(decision, PermissionDecision::Deny);
804
805        // Only allow matches
806        let decision = policy.check("Bash", &json!({"command": "ls -la"}));
807        assert_eq!(decision, PermissionDecision::Allow);
808    }
809
810    #[test]
811    fn test_policy_allow_wins_over_ask() {
812        let policy = PermissionPolicy::new()
813            .allow("Bash(cargo:*)")
814            .ask("Bash(*)");
815
816        // Both match, but allow is checked before ask
817        let decision = policy.check("Bash", &json!({"command": "cargo build"}));
818        assert_eq!(decision, PermissionDecision::Allow);
819
820        // Only ask matches
821        let decision = policy.check("Bash", &json!({"command": "npm install"}));
822        assert_eq!(decision, PermissionDecision::Ask);
823    }
824
825    #[test]
826    fn test_policy_disabled() {
827        let mut policy = PermissionPolicy::new().deny("Bash(rm:*)").ask("Bash(*)");
828        policy.enabled = false;
829
830        // When disabled, everything is allowed
831        let decision = policy.check("Bash", &json!({"command": "rm -rf /"}));
832        assert_eq!(decision, PermissionDecision::Allow);
833    }
834
835    #[test]
836    fn test_policy_is_allowed() {
837        let policy = PermissionPolicy::new().allow("Bash(cargo:*)");
838
839        assert!(policy.is_allowed("Bash", &json!({"command": "cargo build"})));
840        assert!(!policy.is_allowed("Bash", &json!({"command": "npm install"})));
841    }
842
843    #[test]
844    fn test_policy_is_denied() {
845        let policy = PermissionPolicy::new().deny("Bash(rm:*)");
846
847        assert!(policy.is_denied("Bash", &json!({"command": "rm -rf /"})));
848        assert!(!policy.is_denied("Bash", &json!({"command": "ls -la"})));
849    }
850
851    #[test]
852    fn test_policy_requires_confirmation() {
853        // Create a policy that explicitly allows Read but asks for Write
854        let mut policy = PermissionPolicy::new().allow("Read(*)").ask("Write(*)");
855        policy.default_decision = PermissionDecision::Deny; // Make default deny to test ask rule
856
857        assert!(policy.requires_confirmation("Write", &json!({"file_path": "/tmp/test"})));
858        assert!(!policy.requires_confirmation("Read", &json!({"file_path": "/tmp/test"})));
859    }
860
861    #[test]
862    fn test_policy_matching_rules() {
863        let policy = PermissionPolicy::new()
864            .allow("Bash(cargo:*)")
865            .deny("Bash(cargo fmt:*)")
866            .ask("Bash(*)");
867
868        let matching = policy.get_matching_rules("Bash", &json!({"command": "cargo fmt"}));
869        assert_eq!(matching.deny.len(), 1);
870        assert_eq!(matching.allow.len(), 1);
871        assert_eq!(matching.ask.len(), 1);
872    }
873
874    #[test]
875    fn test_policy_allow_all() {
876        let policy =
877            PermissionPolicy::new().allow_all(&["Bash(cargo:*)", "Bash(npm:*)", "Grep(*)"]);
878
879        assert_eq!(policy.allow.len(), 3);
880        assert!(policy.is_allowed("Bash", &json!({"command": "cargo build"})));
881        assert!(policy.is_allowed("Bash", &json!({"command": "npm run test"})));
882        assert!(policy.is_allowed("Grep", &json!({"pattern": "foo"})));
883    }
884
885    // ========================================================================
886    // PermissionRule Deserialization Tests
887    // ========================================================================
888
889    #[test]
890    fn test_rule_deserialize_plain_string() {
891        // YAML: `- read`
892        let rule: PermissionRule = serde_yaml::from_str("read").unwrap();
893        assert_eq!(rule.rule, "read");
894        assert!(rule.matches("read", &json!({})));
895        assert!(!rule.matches("write", &json!({})));
896    }
897
898    #[test]
899    fn test_rule_deserialize_plain_string_with_pattern() {
900        // YAML: `- "Bash(cargo:*)"`
901        let rule: PermissionRule = serde_yaml::from_str("\"Bash(cargo:*)\"").unwrap();
902        assert_eq!(rule.rule, "Bash(cargo:*)");
903        assert!(rule.matches("Bash", &json!({"command": "cargo build"})));
904    }
905
906    #[test]
907    fn test_rule_deserialize_struct_form() {
908        // YAML: `- rule: read`
909        let rule: PermissionRule = serde_yaml::from_str("rule: read").unwrap();
910        assert_eq!(rule.rule, "read");
911        assert!(rule.matches("read", &json!({})));
912    }
913
914    #[test]
915    fn test_rule_deserialize_in_policy() {
916        // Full policy YAML with mixed formats
917        let yaml = r#"
918allow:
919  - read
920  - "Bash(cargo:*)"
921  - rule: grep
922deny:
923  - write
924"#;
925        let policy: PermissionPolicy = serde_yaml::from_str(yaml).unwrap();
926        assert_eq!(policy.allow.len(), 3);
927        assert_eq!(policy.deny.len(), 1);
928        assert!(policy.is_allowed("read", &json!({})));
929        assert!(policy.is_allowed("Bash", &json!({"command": "cargo build"})));
930        assert!(policy.is_allowed("grep", &json!({})));
931        assert!(policy.is_denied("write", &json!({})));
932    }
933
934    // ========================================================================
935    // PermissionManager Tests
936    // ========================================================================
937
938    #[test]
939    fn test_manager_default() {
940        let manager = PermissionManager::new();
941        assert_eq!(
942            manager.global_policy().default_decision,
943            PermissionDecision::Ask
944        );
945    }
946
947    #[test]
948    fn test_manager_with_global_policy() {
949        let policy = PermissionPolicy::permissive();
950        let manager = PermissionManager::with_global_policy(policy);
951        assert_eq!(
952            manager.global_policy().default_decision,
953            PermissionDecision::Allow
954        );
955    }
956
957    #[test]
958    fn test_manager_session_policy() {
959        let mut manager = PermissionManager::new();
960
961        let session_policy = PermissionPolicy::new().allow("Bash(cargo:*)");
962        manager.set_session_policy("session-1", session_policy);
963
964        // Session 1 has custom policy
965        let decision = manager.check("session-1", "Bash", &json!({"command": "cargo build"}));
966        assert_eq!(decision, PermissionDecision::Allow);
967
968        // Session 2 uses global policy
969        let decision = manager.check("session-2", "Bash", &json!({"command": "cargo build"}));
970        assert_eq!(decision, PermissionDecision::Ask);
971    }
972
973    #[test]
974    fn test_manager_remove_session_policy() {
975        let mut manager = PermissionManager::new();
976
977        let session_policy = PermissionPolicy::permissive();
978        manager.set_session_policy("session-1", session_policy);
979
980        // Before removal
981        let decision = manager.check("session-1", "Bash", &json!({"command": "anything"}));
982        assert_eq!(decision, PermissionDecision::Allow);
983
984        manager.remove_session_policy("session-1");
985
986        // After removal - falls back to global
987        let decision = manager.check("session-1", "Bash", &json!({"command": "anything"}));
988        assert_eq!(decision, PermissionDecision::Ask);
989    }
990
991    #[test]
992    fn test_manager_global_deny_overrides_session_allow() {
993        let mut manager =
994            PermissionManager::with_global_policy(PermissionPolicy::new().deny("Bash(rm:*)"));
995
996        let session_policy = PermissionPolicy::new().allow("Bash(*)");
997        manager.set_session_policy("session-1", session_policy);
998
999        // Session allows all Bash, but global denies rm
1000        let decision = manager.check("session-1", "Bash", &json!({"command": "rm -rf /"}));
1001        assert_eq!(decision, PermissionDecision::Deny);
1002
1003        // Other commands are allowed
1004        let decision = manager.check("session-1", "Bash", &json!({"command": "ls -la"}));
1005        assert_eq!(decision, PermissionDecision::Allow);
1006    }
1007
1008    // ========================================================================
1009    // Integration Tests
1010    // ========================================================================
1011
1012    #[test]
1013    fn test_realistic_dev_policy() {
1014        let policy = PermissionPolicy::new()
1015            // Allow common dev commands
1016            .allow_all(&[
1017                "Bash(cargo:*)",
1018                "Bash(npm:*)",
1019                "Bash(pnpm:*)",
1020                "Bash(just:*)",
1021                "Bash(git status:*)",
1022                "Bash(git diff:*)",
1023                "Bash(echo:*)",
1024                "Grep(*)",
1025                "Glob(*)",
1026                "Ls(*)",
1027            ])
1028            // Deny dangerous commands
1029            .deny_all(&["Bash(rm -rf:*)", "Bash(sudo:*)", "Bash(curl | sh:*)"])
1030            // Always ask for writes
1031            .ask_all(&["Write(*)", "Edit(*)"]);
1032
1033        // Allowed
1034        assert!(policy.is_allowed("Bash", &json!({"command": "cargo build"})));
1035        assert!(policy.is_allowed("Bash", &json!({"command": "npm run test"})));
1036        assert!(policy.is_allowed("Grep", &json!({"pattern": "TODO"})));
1037
1038        // Denied
1039        assert!(policy.is_denied("Bash", &json!({"command": "rm -rf /"})));
1040        assert!(policy.is_denied("Bash", &json!({"command": "sudo apt install"})));
1041
1042        // Ask
1043        assert!(policy.requires_confirmation("Write", &json!({"file_path": "/tmp/test.rs"})));
1044        assert!(policy.requires_confirmation("Edit", &json!({"file_path": "src/main.rs"})));
1045    }
1046
1047    #[test]
1048    fn test_mcp_tool_permissions() {
1049        let policy = PermissionPolicy::new()
1050            .allow("mcp__pencil")
1051            .deny("mcp__dangerous");
1052
1053        assert!(policy.is_allowed("mcp__pencil__batch_design", &json!({})));
1054        assert!(policy.is_allowed("mcp__pencil__batch_get", &json!({})));
1055        assert!(policy.is_denied("mcp__dangerous__execute", &json!({})));
1056    }
1057
1058    #[test]
1059    fn test_permissive_with_mcp_wildcard_deny() {
1060        // Regression test: permissive=true + permissive_deny=["mcp__longvt__*"]
1061        // must block all longvt tools even in permissive mode.
1062        let policy = PermissionPolicy::permissive().deny("mcp__longvt__*");
1063
1064        // longvt tools are denied
1065        assert_eq!(
1066            policy.check("mcp__longvt__search", &json!({})),
1067            PermissionDecision::Deny
1068        );
1069        assert_eq!(
1070            policy.check("mcp__longvt__create_memory", &json!({})),
1071            PermissionDecision::Deny
1072        );
1073        // other MCP tools are still allowed (permissive default)
1074        assert_eq!(
1075            policy.check("mcp__pencil__draw", &json!({})),
1076            PermissionDecision::Allow
1077        );
1078        assert_eq!(
1079            policy.check("bash", &json!({"command": "ls"})),
1080            PermissionDecision::Allow
1081        );
1082    }
1083
1084    #[test]
1085    fn test_serialization() {
1086        let policy = PermissionPolicy::new()
1087            .allow("Bash(cargo:*)")
1088            .deny("Bash(rm:*)");
1089
1090        let json = serde_json::to_string(&policy).unwrap();
1091        let deserialized: PermissionPolicy = serde_json::from_str(&json).unwrap();
1092
1093        assert_eq!(deserialized.allow.len(), 1);
1094        assert_eq!(deserialized.deny.len(), 1);
1095    }
1096
1097    #[test]
1098    fn test_matching_rules_is_empty() {
1099        let rules = MatchingRules {
1100            deny: vec![],
1101            allow: vec![],
1102            ask: vec![],
1103        };
1104        assert!(rules.is_empty());
1105
1106        let rules = MatchingRules {
1107            deny: vec!["Bash".to_string()],
1108            allow: vec![],
1109            ask: vec![],
1110        };
1111        assert!(!rules.is_empty());
1112
1113        let rules = MatchingRules {
1114            deny: vec![],
1115            allow: vec!["Read".to_string()],
1116            ask: vec![],
1117        };
1118        assert!(!rules.is_empty());
1119
1120        let rules = MatchingRules {
1121            deny: vec![],
1122            allow: vec![],
1123            ask: vec!["Write".to_string()],
1124        };
1125        assert!(!rules.is_empty());
1126    }
1127
1128    #[test]
1129    fn test_permission_manager_default() {
1130        let pm = PermissionManager::default();
1131        let policy = pm.global_policy();
1132        assert!(policy.allow.is_empty());
1133        assert!(policy.deny.is_empty());
1134        assert!(policy.ask.is_empty());
1135    }
1136
1137    #[test]
1138    fn test_permission_manager_set_global_policy() {
1139        let mut pm = PermissionManager::new();
1140        let policy = PermissionPolicy::new().allow("Bash(*)");
1141        pm.set_global_policy(policy);
1142        assert_eq!(pm.global_policy().allow.len(), 1);
1143    }
1144
1145    #[test]
1146    fn test_permission_manager_session_policy() {
1147        let mut pm = PermissionManager::new();
1148        let policy = PermissionPolicy::new().deny("Bash(rm:*)");
1149        pm.set_session_policy("s1", policy);
1150
1151        let effective = pm.get_effective_policy("s1");
1152        assert_eq!(effective.deny.len(), 1);
1153
1154        // Non-existent session falls back to global
1155        let global = pm.get_effective_policy("s2");
1156        assert!(global.deny.is_empty());
1157    }
1158
1159    #[test]
1160    fn test_permission_manager_remove_session_policy() {
1161        let mut pm = PermissionManager::new();
1162        pm.set_session_policy("s1", PermissionPolicy::new().deny("Bash(*)"));
1163        assert_eq!(pm.get_effective_policy("s1").deny.len(), 1);
1164
1165        pm.remove_session_policy("s1");
1166        assert!(pm.get_effective_policy("s1").deny.is_empty());
1167    }
1168
1169    #[test]
1170    fn test_permission_manager_check_deny() {
1171        let mut pm = PermissionManager::new();
1172        pm.set_global_policy(PermissionPolicy::new().deny("Bash(rm:*)"));
1173
1174        let decision = pm.check("s1", "Bash", &json!({"command": "rm -rf /"}));
1175        assert_eq!(decision, PermissionDecision::Deny);
1176    }
1177
1178    #[test]
1179    fn test_permission_manager_check_allow() {
1180        let mut pm = PermissionManager::new();
1181        pm.set_global_policy(PermissionPolicy::new().allow("Bash(cargo:*)"));
1182
1183        let decision = pm.check("s1", "Bash", &json!({"command": "cargo build"}));
1184        assert_eq!(decision, PermissionDecision::Allow);
1185    }
1186
1187    #[test]
1188    fn test_permission_manager_check_session_override() {
1189        let mut pm = PermissionManager::new();
1190        pm.set_global_policy(PermissionPolicy::new().allow("Bash(*)"));
1191        pm.set_session_policy("s1", PermissionPolicy::new().deny("Bash(rm:*)"));
1192
1193        // Session policy denies rm
1194        let decision = pm.check("s1", "Bash", &json!({"command": "rm -rf /"}));
1195        assert_eq!(decision, PermissionDecision::Deny);
1196
1197        // Other session uses global (allow all)
1198        let decision = pm.check("s2", "Bash", &json!({"command": "rm -rf /"}));
1199        assert_eq!(decision, PermissionDecision::Allow);
1200    }
1201
1202    #[test]
1203    fn test_permission_manager_with_global_policy() {
1204        let policy = PermissionPolicy::new().allow("Read(*)").deny("Write(*)");
1205        let pm = PermissionManager::with_global_policy(policy);
1206        assert_eq!(pm.global_policy().allow.len(), 1);
1207        assert_eq!(pm.global_policy().deny.len(), 1);
1208    }
1209}