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