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