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 strict policy that asks for everything
342    pub fn strict() -> Self {
343        Self {
344            deny: Vec::new(),
345            allow: Vec::new(),
346            ask: Vec::new(),
347            default_decision: PermissionDecision::Ask,
348            enabled: true,
349        }
350    }
351
352    /// Add a deny rule
353    pub fn deny(mut self, rule: &str) -> Self {
354        self.deny.push(PermissionRule::new(rule));
355        self
356    }
357
358    /// Add an allow rule
359    pub fn allow(mut self, rule: &str) -> Self {
360        self.allow.push(PermissionRule::new(rule));
361        self
362    }
363
364    /// Add an ask rule
365    pub fn ask(mut self, rule: &str) -> Self {
366        self.ask.push(PermissionRule::new(rule));
367        self
368    }
369
370    /// Add multiple deny rules
371    pub fn deny_all(mut self, rules: &[&str]) -> Self {
372        for rule in rules {
373            self.deny.push(PermissionRule::new(rule));
374        }
375        self
376    }
377
378    /// Add multiple allow rules
379    pub fn allow_all(mut self, rules: &[&str]) -> Self {
380        for rule in rules {
381            self.allow.push(PermissionRule::new(rule));
382        }
383        self
384    }
385
386    /// Add multiple ask rules
387    pub fn ask_all(mut self, rules: &[&str]) -> Self {
388        for rule in rules {
389            self.ask.push(PermissionRule::new(rule));
390        }
391        self
392    }
393
394    /// Check permission for a tool invocation
395    ///
396    /// Returns the permission decision based on rule evaluation order:
397    /// 1. Deny rules (any match = Deny)
398    /// 2. Allow rules (any match = Allow)
399    /// 3. Ask rules (any match = Ask)
400    /// 4. Default decision
401    pub fn check(&self, tool_name: &str, args: &serde_json::Value) -> PermissionDecision {
402        if !self.enabled {
403            return PermissionDecision::Allow;
404        }
405
406        // 1. Check deny rules first
407        for rule in &self.deny {
408            if rule.matches(tool_name, args) {
409                return PermissionDecision::Deny;
410            }
411        }
412
413        // 2. Check allow rules
414        for rule in &self.allow {
415            if rule.matches(tool_name, args) {
416                return PermissionDecision::Allow;
417            }
418        }
419
420        // 3. Check ask rules
421        for rule in &self.ask {
422            if rule.matches(tool_name, args) {
423                return PermissionDecision::Ask;
424            }
425        }
426
427        // 4. Fall back to default
428        self.default_decision
429    }
430
431    /// Check if a tool invocation is allowed (Allow or not Deny)
432    pub fn is_allowed(&self, tool_name: &str, args: &serde_json::Value) -> bool {
433        matches!(self.check(tool_name, args), PermissionDecision::Allow)
434    }
435
436    /// Check if a tool invocation is denied
437    pub fn is_denied(&self, tool_name: &str, args: &serde_json::Value) -> bool {
438        matches!(self.check(tool_name, args), PermissionDecision::Deny)
439    }
440
441    /// Check if a tool invocation requires confirmation
442    pub fn requires_confirmation(&self, tool_name: &str, args: &serde_json::Value) -> bool {
443        matches!(self.check(tool_name, args), PermissionDecision::Ask)
444    }
445
446    /// Get matching rules for debugging/logging
447    pub fn get_matching_rules(&self, tool_name: &str, args: &serde_json::Value) -> MatchingRules {
448        let mut result = MatchingRules::default();
449
450        for rule in &self.deny {
451            if rule.matches(tool_name, args) {
452                result.deny.push(rule.rule.clone());
453            }
454        }
455
456        for rule in &self.allow {
457            if rule.matches(tool_name, args) {
458                result.allow.push(rule.rule.clone());
459            }
460        }
461
462        for rule in &self.ask {
463            if rule.matches(tool_name, args) {
464                result.ask.push(rule.rule.clone());
465            }
466        }
467
468        result
469    }
470}
471
472impl PermissionChecker for PermissionPolicy {
473    fn check(&self, tool_name: &str, args: &serde_json::Value) -> PermissionDecision {
474        self.check(tool_name, args)
475    }
476}
477
478/// Matching rules for debugging
479#[derive(Debug, Default, Clone)]
480pub struct MatchingRules {
481    pub deny: Vec<String>,
482    pub allow: Vec<String>,
483    pub ask: Vec<String>,
484}
485
486impl MatchingRules {
487    pub fn is_empty(&self) -> bool {
488        self.deny.is_empty() && self.allow.is_empty() && self.ask.is_empty()
489    }
490}
491
492/// Permission manager that handles per-session permissions
493#[derive(Debug)]
494pub struct PermissionManager {
495    /// Global policy applied to all sessions
496    global_policy: PermissionPolicy,
497    /// Per-session policy overrides
498    session_policies: HashMap<String, PermissionPolicy>,
499}
500
501impl Default for PermissionManager {
502    fn default() -> Self {
503        Self::new()
504    }
505}
506
507impl PermissionManager {
508    /// Create a new permission manager with default global policy
509    pub fn new() -> Self {
510        Self {
511            global_policy: PermissionPolicy::default(),
512            session_policies: HashMap::new(),
513        }
514    }
515
516    /// Create with a custom global policy
517    pub fn with_global_policy(policy: PermissionPolicy) -> Self {
518        Self {
519            global_policy: policy,
520            session_policies: HashMap::new(),
521        }
522    }
523
524    /// Set the global policy
525    pub fn set_global_policy(&mut self, policy: PermissionPolicy) {
526        self.global_policy = policy;
527    }
528
529    /// Get the global policy
530    pub fn global_policy(&self) -> &PermissionPolicy {
531        &self.global_policy
532    }
533
534    /// Set a session-specific policy
535    pub fn set_session_policy(&mut self, session_id: &str, policy: PermissionPolicy) {
536        self.session_policies.insert(session_id.to_string(), policy);
537    }
538
539    /// Remove a session-specific policy
540    pub fn remove_session_policy(&mut self, session_id: &str) {
541        self.session_policies.remove(session_id);
542    }
543
544    /// Get the effective policy for a session
545    ///
546    /// Session policy takes precedence over global policy for matching rules.
547    /// If no session policy exists, uses global policy.
548    pub fn get_effective_policy(&self, session_id: &str) -> &PermissionPolicy {
549        self.session_policies
550            .get(session_id)
551            .unwrap_or(&self.global_policy)
552    }
553
554    /// Check permission for a tool invocation in a session
555    pub fn check(
556        &self,
557        session_id: &str,
558        tool_name: &str,
559        args: &serde_json::Value,
560    ) -> PermissionDecision {
561        // Get session policy or fall back to global
562        let policy = self.get_effective_policy(session_id);
563
564        // Session deny rules
565        for rule in &policy.deny {
566            if rule.matches(tool_name, args) {
567                return PermissionDecision::Deny;
568            }
569        }
570
571        // Global deny rules (if different policy)
572        if !self.session_policies.contains_key(session_id) {
573            // Already checked global
574        } else {
575            for rule in &self.global_policy.deny {
576                if rule.matches(tool_name, args) {
577                    return PermissionDecision::Deny;
578                }
579            }
580        }
581
582        // Session allow rules
583        for rule in &policy.allow {
584            if rule.matches(tool_name, args) {
585                return PermissionDecision::Allow;
586            }
587        }
588
589        // Session ask rules
590        for rule in &policy.ask {
591            if rule.matches(tool_name, args) {
592                return PermissionDecision::Ask;
593            }
594        }
595
596        // Fall back to policy default
597        policy.default_decision
598    }
599}
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604    use serde_json::json;
605
606    // ========================================================================
607    // PermissionRule Tests
608    // ========================================================================
609
610    #[test]
611    fn test_rule_parse_simple() {
612        let rule = PermissionRule::new("Bash");
613        assert_eq!(rule.tool_name, Some("Bash".to_string()));
614        assert_eq!(rule.arg_pattern, None);
615    }
616
617    #[test]
618    fn test_rule_parse_with_pattern() {
619        let rule = PermissionRule::new("Bash(cargo:*)");
620        assert_eq!(rule.tool_name, Some("Bash".to_string()));
621        assert_eq!(rule.arg_pattern, Some("cargo:*".to_string()));
622    }
623
624    #[test]
625    fn test_rule_parse_wildcard() {
626        let rule = PermissionRule::new("Grep(*)");
627        assert_eq!(rule.tool_name, Some("Grep".to_string()));
628        assert_eq!(rule.arg_pattern, Some("*".to_string()));
629    }
630
631    #[test]
632    fn test_rule_match_tool_only() {
633        let rule = PermissionRule::new("Bash");
634        assert!(rule.matches("Bash", &json!({"command": "ls -la"})));
635        assert!(rule.matches("bash", &json!({"command": "echo hello"})));
636        assert!(!rule.matches("Read", &json!({})));
637    }
638
639    #[test]
640    fn test_rule_match_wildcard() {
641        let rule = PermissionRule::new("Grep(*)");
642        assert!(rule.matches("Grep", &json!({"pattern": "foo", "path": "/tmp"})));
643        assert!(rule.matches("grep", &json!({"pattern": "bar"})));
644    }
645
646    #[test]
647    fn test_rule_match_prefix_wildcard() {
648        let rule = PermissionRule::new("Bash(cargo:*)");
649        assert!(rule.matches("Bash", &json!({"command": "cargo build"})));
650        assert!(rule.matches("Bash", &json!({"command": "cargo test --lib"})));
651        assert!(rule.matches("Bash", &json!({"command": "cargo"})));
652        assert!(!rule.matches("Bash", &json!({"command": "npm install"})));
653    }
654
655    #[test]
656    fn test_rule_match_npm_commands() {
657        let rule = PermissionRule::new("Bash(npm run:*)");
658        assert!(rule.matches("Bash", &json!({"command": "npm run test"})));
659        assert!(rule.matches("Bash", &json!({"command": "npm run build"})));
660        assert!(!rule.matches("Bash", &json!({"command": "npm install"})));
661    }
662
663    #[test]
664    fn test_rule_match_file_path() {
665        let rule = PermissionRule::new("Read(src/*.rs)");
666        assert!(rule.matches("Read", &json!({"file_path": "src/main.rs"})));
667        assert!(rule.matches("Read", &json!({"file_path": "src/lib.rs"})));
668        assert!(!rule.matches("Read", &json!({"file_path": "src/foo/bar.rs"})));
669    }
670
671    #[test]
672    fn test_rule_match_recursive_glob() {
673        let rule = PermissionRule::new("Read(src/**/*.rs)");
674        assert!(rule.matches("Read", &json!({"file_path": "src/main.rs"})));
675        assert!(rule.matches("Read", &json!({"file_path": "src/foo/bar.rs"})));
676        assert!(rule.matches("Read", &json!({"file_path": "src/a/b/c.rs"})));
677    }
678
679    #[test]
680    fn test_rule_match_mcp_tool() {
681        let rule = PermissionRule::new("mcp__pencil");
682        assert!(rule.matches("mcp__pencil", &json!({})));
683        assert!(rule.matches("mcp__pencil__batch_design", &json!({})));
684        assert!(rule.matches("mcp__pencil__batch_get", &json!({})));
685        assert!(!rule.matches("mcp__other", &json!({})));
686    }
687
688    #[test]
689    fn test_rule_match_mcp_tool_wildcard() {
690        // "mcp__longvt__*" must match all tools from the longvt server.
691        // Previously this failed because matches_tool_name treated '*' as a
692        // literal character and starts_with("mcp__longvt__*") always returned false.
693        let rule = PermissionRule::new("mcp__longvt__*");
694        assert!(rule.matches("mcp__longvt__search", &json!({})));
695        assert!(rule.matches("mcp__longvt__create_memory", &json!({})));
696        assert!(rule.matches("mcp__longvt__delete", &json!({})));
697        assert!(!rule.matches("mcp__pencil__batch_design", &json!({})));
698        assert!(!rule.matches("mcp__other__tool", &json!({})));
699
700        // "mcp__*" must match any MCP tool from any server.
701        let rule_all = PermissionRule::new("mcp__*");
702        assert!(rule_all.matches("mcp__longvt__search", &json!({})));
703        assert!(rule_all.matches("mcp__pencil__draw", &json!({})));
704        assert!(!rule_all.matches("bash", &json!({})));
705    }
706
707    #[test]
708    fn test_rule_case_insensitive() {
709        let rule = PermissionRule::new("BASH(cargo:*)");
710        assert!(rule.matches("Bash", &json!({"command": "cargo build"})));
711        assert!(rule.matches("bash", &json!({"command": "cargo test"})));
712        assert!(rule.matches("BASH", &json!({"command": "cargo check"})));
713    }
714
715    // ========================================================================
716    // PermissionPolicy Tests
717    // ========================================================================
718
719    #[test]
720    fn test_policy_default() {
721        let policy = PermissionPolicy::default();
722        assert!(policy.enabled);
723        assert_eq!(policy.default_decision, PermissionDecision::Ask);
724        assert!(policy.allow.is_empty());
725        assert!(policy.deny.is_empty());
726        assert!(policy.ask.is_empty());
727    }
728
729    #[test]
730    fn test_policy_explicit_allow_default() {
731        let policy = PermissionPolicy {
732            default_decision: PermissionDecision::Allow,
733            ..PermissionPolicy::default()
734        };
735        assert_eq!(policy.default_decision, PermissionDecision::Allow);
736    }
737
738    #[test]
739    fn test_policy_strict() {
740        let policy = PermissionPolicy::strict();
741        assert_eq!(policy.default_decision, PermissionDecision::Ask);
742    }
743
744    #[test]
745    fn test_policy_builder() {
746        let policy = PermissionPolicy::new()
747            .allow("Bash(cargo:*)")
748            .allow("Grep(*)")
749            .deny("Bash(rm -rf:*)")
750            .ask("Write(*)");
751
752        assert_eq!(policy.allow.len(), 2);
753        assert_eq!(policy.deny.len(), 1);
754        assert_eq!(policy.ask.len(), 1);
755    }
756
757    #[test]
758    fn test_policy_check_allow() {
759        let policy = PermissionPolicy::new().allow("Bash(cargo:*)");
760
761        let decision = policy.check("Bash", &json!({"command": "cargo build"}));
762        assert_eq!(decision, PermissionDecision::Allow);
763    }
764
765    #[test]
766    fn test_policy_check_deny() {
767        let policy = PermissionPolicy::new().deny("Bash(rm -rf:*)");
768
769        let decision = policy.check("Bash", &json!({"command": "rm -rf /"}));
770        assert_eq!(decision, PermissionDecision::Deny);
771    }
772
773    #[test]
774    fn test_policy_check_ask() {
775        let policy = PermissionPolicy::new().ask("Write(*)");
776
777        let decision = policy.check("Write", &json!({"file_path": "/tmp/test.txt"}));
778        assert_eq!(decision, PermissionDecision::Ask);
779    }
780
781    #[test]
782    fn test_policy_check_default() {
783        let policy = PermissionPolicy::new();
784
785        let decision = policy.check("Unknown", &json!({}));
786        assert_eq!(decision, PermissionDecision::Ask);
787    }
788
789    #[test]
790    fn test_policy_deny_wins_over_allow() {
791        let policy = PermissionPolicy::new().allow("Bash(*)").deny("Bash(rm:*)");
792
793        // Allow matches, but deny also matches - deny wins
794        let decision = policy.check("Bash", &json!({"command": "rm -rf /tmp"}));
795        assert_eq!(decision, PermissionDecision::Deny);
796
797        // Only allow matches
798        let decision = policy.check("Bash", &json!({"command": "ls -la"}));
799        assert_eq!(decision, PermissionDecision::Allow);
800    }
801
802    #[test]
803    fn test_policy_allow_wins_over_ask() {
804        let policy = PermissionPolicy::new()
805            .allow("Bash(cargo:*)")
806            .ask("Bash(*)");
807
808        // Both match, but allow is checked before ask
809        let decision = policy.check("Bash", &json!({"command": "cargo build"}));
810        assert_eq!(decision, PermissionDecision::Allow);
811
812        // Only ask matches
813        let decision = policy.check("Bash", &json!({"command": "npm install"}));
814        assert_eq!(decision, PermissionDecision::Ask);
815    }
816
817    #[test]
818    fn test_policy_disabled() {
819        let mut policy = PermissionPolicy::new().deny("Bash(rm:*)").ask("Bash(*)");
820        policy.enabled = false;
821
822        // When disabled, everything is allowed
823        let decision = policy.check("Bash", &json!({"command": "rm -rf /"}));
824        assert_eq!(decision, PermissionDecision::Allow);
825    }
826
827    #[test]
828    fn test_policy_is_allowed() {
829        let policy = PermissionPolicy::new().allow("Bash(cargo:*)");
830
831        assert!(policy.is_allowed("Bash", &json!({"command": "cargo build"})));
832        assert!(!policy.is_allowed("Bash", &json!({"command": "npm install"})));
833    }
834
835    #[test]
836    fn test_policy_is_denied() {
837        let policy = PermissionPolicy::new().deny("Bash(rm:*)");
838
839        assert!(policy.is_denied("Bash", &json!({"command": "rm -rf /"})));
840        assert!(!policy.is_denied("Bash", &json!({"command": "ls -la"})));
841    }
842
843    #[test]
844    fn test_policy_requires_confirmation() {
845        // Create a policy that explicitly allows Read but asks for Write
846        let mut policy = PermissionPolicy::new().allow("Read(*)").ask("Write(*)");
847        policy.default_decision = PermissionDecision::Deny; // Make default deny to test ask rule
848
849        assert!(policy.requires_confirmation("Write", &json!({"file_path": "/tmp/test"})));
850        assert!(!policy.requires_confirmation("Read", &json!({"file_path": "/tmp/test"})));
851    }
852
853    #[test]
854    fn test_policy_matching_rules() {
855        let policy = PermissionPolicy::new()
856            .allow("Bash(cargo:*)")
857            .deny("Bash(cargo fmt:*)")
858            .ask("Bash(*)");
859
860        let matching = policy.get_matching_rules("Bash", &json!({"command": "cargo fmt"}));
861        assert_eq!(matching.deny.len(), 1);
862        assert_eq!(matching.allow.len(), 1);
863        assert_eq!(matching.ask.len(), 1);
864    }
865
866    #[test]
867    fn test_policy_allow_all() {
868        let policy =
869            PermissionPolicy::new().allow_all(&["Bash(cargo:*)", "Bash(npm:*)", "Grep(*)"]);
870
871        assert_eq!(policy.allow.len(), 3);
872        assert!(policy.is_allowed("Bash", &json!({"command": "cargo build"})));
873        assert!(policy.is_allowed("Bash", &json!({"command": "npm run test"})));
874        assert!(policy.is_allowed("Grep", &json!({"pattern": "foo"})));
875    }
876
877    // ========================================================================
878    // PermissionRule Deserialization Tests
879    // ========================================================================
880
881    #[test]
882    fn test_rule_deserialize_plain_string() {
883        // YAML: `- read`
884        let rule: PermissionRule = serde_yaml::from_str("read").unwrap();
885        assert_eq!(rule.rule, "read");
886        assert!(rule.matches("read", &json!({})));
887        assert!(!rule.matches("write", &json!({})));
888    }
889
890    #[test]
891    fn test_rule_deserialize_plain_string_with_pattern() {
892        // YAML: `- "Bash(cargo:*)"`
893        let rule: PermissionRule = serde_yaml::from_str("\"Bash(cargo:*)\"").unwrap();
894        assert_eq!(rule.rule, "Bash(cargo:*)");
895        assert!(rule.matches("Bash", &json!({"command": "cargo build"})));
896    }
897
898    #[test]
899    fn test_rule_deserialize_struct_form() {
900        // YAML: `- rule: read`
901        let rule: PermissionRule = serde_yaml::from_str("rule: read").unwrap();
902        assert_eq!(rule.rule, "read");
903        assert!(rule.matches("read", &json!({})));
904    }
905
906    #[test]
907    fn test_rule_deserialize_in_policy() {
908        // Full policy YAML with mixed formats
909        let yaml = r#"
910allow:
911  - read
912  - "Bash(cargo:*)"
913  - rule: grep
914deny:
915  - write
916"#;
917        let policy: PermissionPolicy = serde_yaml::from_str(yaml).unwrap();
918        assert_eq!(policy.allow.len(), 3);
919        assert_eq!(policy.deny.len(), 1);
920        assert!(policy.is_allowed("read", &json!({})));
921        assert!(policy.is_allowed("Bash", &json!({"command": "cargo build"})));
922        assert!(policy.is_allowed("grep", &json!({})));
923        assert!(policy.is_denied("write", &json!({})));
924    }
925
926    // ========================================================================
927    // PermissionManager Tests
928    // ========================================================================
929
930    #[test]
931    fn test_manager_default() {
932        let manager = PermissionManager::new();
933        assert_eq!(
934            manager.global_policy().default_decision,
935            PermissionDecision::Ask
936        );
937    }
938
939    #[test]
940    fn test_manager_with_global_policy() {
941        let policy = PermissionPolicy {
942            default_decision: PermissionDecision::Allow,
943            ..PermissionPolicy::default()
944        };
945        let manager = PermissionManager::with_global_policy(policy);
946        assert_eq!(
947            manager.global_policy().default_decision,
948            PermissionDecision::Allow
949        );
950    }
951
952    #[test]
953    fn test_manager_session_policy() {
954        let mut manager = PermissionManager::new();
955
956        let session_policy = PermissionPolicy::new().allow("Bash(cargo:*)");
957        manager.set_session_policy("session-1", session_policy);
958
959        // Session 1 has custom policy
960        let decision = manager.check("session-1", "Bash", &json!({"command": "cargo build"}));
961        assert_eq!(decision, PermissionDecision::Allow);
962
963        // Session 2 uses global policy
964        let decision = manager.check("session-2", "Bash", &json!({"command": "cargo build"}));
965        assert_eq!(decision, PermissionDecision::Ask);
966    }
967
968    #[test]
969    fn test_manager_remove_session_policy() {
970        let mut manager = PermissionManager::new();
971
972        let session_policy = PermissionPolicy {
973            default_decision: PermissionDecision::Allow,
974            ..PermissionPolicy::default()
975        };
976        manager.set_session_policy("session-1", session_policy);
977
978        // Before removal
979        let decision = manager.check("session-1", "Bash", &json!({"command": "anything"}));
980        assert_eq!(decision, PermissionDecision::Allow);
981
982        manager.remove_session_policy("session-1");
983
984        // After removal - falls back to global
985        let decision = manager.check("session-1", "Bash", &json!({"command": "anything"}));
986        assert_eq!(decision, PermissionDecision::Ask);
987    }
988
989    #[test]
990    fn test_manager_global_deny_overrides_session_allow() {
991        let mut manager =
992            PermissionManager::with_global_policy(PermissionPolicy::new().deny("Bash(rm:*)"));
993
994        let session_policy = PermissionPolicy::new().allow("Bash(*)");
995        manager.set_session_policy("session-1", session_policy);
996
997        // Session allows all Bash, but global denies rm
998        let decision = manager.check("session-1", "Bash", &json!({"command": "rm -rf /"}));
999        assert_eq!(decision, PermissionDecision::Deny);
1000
1001        // Other commands are allowed
1002        let decision = manager.check("session-1", "Bash", &json!({"command": "ls -la"}));
1003        assert_eq!(decision, PermissionDecision::Allow);
1004    }
1005
1006    // ========================================================================
1007    // Integration Tests
1008    // ========================================================================
1009
1010    #[test]
1011    fn test_realistic_dev_policy() {
1012        let policy = PermissionPolicy::new()
1013            // Allow common dev commands
1014            .allow_all(&[
1015                "Bash(cargo:*)",
1016                "Bash(npm:*)",
1017                "Bash(pnpm:*)",
1018                "Bash(just:*)",
1019                "Bash(git status:*)",
1020                "Bash(git diff:*)",
1021                "Bash(echo:*)",
1022                "Grep(*)",
1023                "Glob(*)",
1024                "Ls(*)",
1025            ])
1026            // Deny dangerous commands
1027            .deny_all(&["Bash(rm -rf:*)", "Bash(sudo:*)", "Bash(curl | sh:*)"])
1028            // Always ask for writes
1029            .ask_all(&["Write(*)", "Edit(*)"]);
1030
1031        // Allowed
1032        assert!(policy.is_allowed("Bash", &json!({"command": "cargo build"})));
1033        assert!(policy.is_allowed("Bash", &json!({"command": "npm run test"})));
1034        assert!(policy.is_allowed("Grep", &json!({"pattern": "TODO"})));
1035
1036        // Denied
1037        assert!(policy.is_denied("Bash", &json!({"command": "rm -rf /"})));
1038        assert!(policy.is_denied("Bash", &json!({"command": "sudo apt install"})));
1039
1040        // Ask
1041        assert!(policy.requires_confirmation("Write", &json!({"file_path": "/tmp/test.rs"})));
1042        assert!(policy.requires_confirmation("Edit", &json!({"file_path": "src/main.rs"})));
1043    }
1044
1045    #[test]
1046    fn test_mcp_tool_permissions() {
1047        let policy = PermissionPolicy::new()
1048            .allow("mcp__pencil")
1049            .deny("mcp__dangerous");
1050
1051        assert!(policy.is_allowed("mcp__pencil__batch_design", &json!({})));
1052        assert!(policy.is_allowed("mcp__pencil__batch_get", &json!({})));
1053        assert!(policy.is_denied("mcp__dangerous__execute", &json!({})));
1054    }
1055
1056    #[test]
1057    fn test_allow_by_default_with_mcp_wildcard_deny() {
1058        // Allow-by-default policies can still carry explicit deny rules.
1059        let policy = PermissionPolicy {
1060            default_decision: PermissionDecision::Allow,
1061            ..PermissionPolicy::default()
1062        }
1063        .deny("mcp__longvt__*");
1064
1065        // longvt tools are denied
1066        assert_eq!(
1067            policy.check("mcp__longvt__search", &json!({})),
1068            PermissionDecision::Deny
1069        );
1070        assert_eq!(
1071            policy.check("mcp__longvt__create_memory", &json!({})),
1072            PermissionDecision::Deny
1073        );
1074        // other MCP tools are still allowed by the explicit default decision
1075        assert_eq!(
1076            policy.check("mcp__pencil__draw", &json!({})),
1077            PermissionDecision::Allow
1078        );
1079        assert_eq!(
1080            policy.check("bash", &json!({"command": "ls"})),
1081            PermissionDecision::Allow
1082        );
1083    }
1084
1085    #[test]
1086    fn test_serialization() {
1087        let policy = PermissionPolicy::new()
1088            .allow("Bash(cargo:*)")
1089            .deny("Bash(rm:*)");
1090
1091        let json = serde_json::to_string(&policy).unwrap();
1092        let deserialized: PermissionPolicy = serde_json::from_str(&json).unwrap();
1093
1094        assert_eq!(deserialized.allow.len(), 1);
1095        assert_eq!(deserialized.deny.len(), 1);
1096    }
1097
1098    #[test]
1099    fn test_matching_rules_is_empty() {
1100        let rules = MatchingRules {
1101            deny: vec![],
1102            allow: vec![],
1103            ask: vec![],
1104        };
1105        assert!(rules.is_empty());
1106
1107        let rules = MatchingRules {
1108            deny: vec!["Bash".to_string()],
1109            allow: vec![],
1110            ask: vec![],
1111        };
1112        assert!(!rules.is_empty());
1113
1114        let rules = MatchingRules {
1115            deny: vec![],
1116            allow: vec!["Read".to_string()],
1117            ask: vec![],
1118        };
1119        assert!(!rules.is_empty());
1120
1121        let rules = MatchingRules {
1122            deny: vec![],
1123            allow: vec![],
1124            ask: vec!["Write".to_string()],
1125        };
1126        assert!(!rules.is_empty());
1127    }
1128
1129    #[test]
1130    fn test_permission_manager_default() {
1131        let pm = PermissionManager::default();
1132        let policy = pm.global_policy();
1133        assert!(policy.allow.is_empty());
1134        assert!(policy.deny.is_empty());
1135        assert!(policy.ask.is_empty());
1136    }
1137
1138    #[test]
1139    fn test_permission_manager_set_global_policy() {
1140        let mut pm = PermissionManager::new();
1141        let policy = PermissionPolicy::new().allow("Bash(*)");
1142        pm.set_global_policy(policy);
1143        assert_eq!(pm.global_policy().allow.len(), 1);
1144    }
1145
1146    #[test]
1147    fn test_permission_manager_session_policy() {
1148        let mut pm = PermissionManager::new();
1149        let policy = PermissionPolicy::new().deny("Bash(rm:*)");
1150        pm.set_session_policy("s1", policy);
1151
1152        let effective = pm.get_effective_policy("s1");
1153        assert_eq!(effective.deny.len(), 1);
1154
1155        // Non-existent session falls back to global
1156        let global = pm.get_effective_policy("s2");
1157        assert!(global.deny.is_empty());
1158    }
1159
1160    #[test]
1161    fn test_permission_manager_remove_session_policy() {
1162        let mut pm = PermissionManager::new();
1163        pm.set_session_policy("s1", PermissionPolicy::new().deny("Bash(*)"));
1164        assert_eq!(pm.get_effective_policy("s1").deny.len(), 1);
1165
1166        pm.remove_session_policy("s1");
1167        assert!(pm.get_effective_policy("s1").deny.is_empty());
1168    }
1169
1170    #[test]
1171    fn test_permission_manager_check_deny() {
1172        let mut pm = PermissionManager::new();
1173        pm.set_global_policy(PermissionPolicy::new().deny("Bash(rm:*)"));
1174
1175        let decision = pm.check("s1", "Bash", &json!({"command": "rm -rf /"}));
1176        assert_eq!(decision, PermissionDecision::Deny);
1177    }
1178
1179    #[test]
1180    fn test_permission_manager_check_allow() {
1181        let mut pm = PermissionManager::new();
1182        pm.set_global_policy(PermissionPolicy::new().allow("Bash(cargo:*)"));
1183
1184        let decision = pm.check("s1", "Bash", &json!({"command": "cargo build"}));
1185        assert_eq!(decision, PermissionDecision::Allow);
1186    }
1187
1188    #[test]
1189    fn test_permission_manager_check_session_override() {
1190        let mut pm = PermissionManager::new();
1191        pm.set_global_policy(PermissionPolicy::new().allow("Bash(*)"));
1192        pm.set_session_policy("s1", PermissionPolicy::new().deny("Bash(rm:*)"));
1193
1194        // Session policy denies rm
1195        let decision = pm.check("s1", "Bash", &json!({"command": "rm -rf /"}));
1196        assert_eq!(decision, PermissionDecision::Deny);
1197
1198        // Other session uses global (allow all)
1199        let decision = pm.check("s2", "Bash", &json!({"command": "rm -rf /"}));
1200        assert_eq!(decision, PermissionDecision::Allow);
1201    }
1202
1203    #[test]
1204    fn test_permission_manager_with_global_policy() {
1205        let policy = PermissionPolicy::new().allow("Read(*)").deny("Write(*)");
1206        let pm = PermissionManager::with_global_policy(policy);
1207        assert_eq!(pm.global_policy().allow.len(), 1);
1208        assert_eq!(pm.global_policy().deny.len(), 1);
1209    }
1210}