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