Skip to main content

ai_agent/utils/permissions/
shell_rule_matching.rs

1// Source: ~/claudecode/openclaudecode/src/utils/permissions/shellRuleMatching.ts
2#![allow(dead_code)]
3
4//! Shared permission rule matching utilities for shell tools.
5//!
6//! Extracts common logic for:
7//! - Parsing permission rules (exact, prefix, wildcard)
8//! - Matching commands against rules
9//! - Generating permission suggestions
10
11use crate::types::permissions::PermissionUpdate;
12
13/// Null-byte sentinel placeholders for wildcard pattern escaping.
14const ESCAPED_STAR_PLACEHOLDER: &str = "\x00ESCAPED_STAR\x00";
15const ESCAPED_BACKSLASH_PLACEHOLDER: &str = "\x00ESCAPED_BACKSLASH\x00";
16
17/// Parsed permission rule.
18#[derive(Debug, Clone)]
19pub enum ShellPermissionRule {
20    Exact { command: String },
21    Prefix { prefix: String },
22    Wildcard { pattern: String },
23}
24
25/// Extract prefix from legacy :* syntax (e.g., "npm:*" -> "npm").
26pub fn permission_rule_extract_prefix(permission_rule: &str) -> Option<String> {
27    if permission_rule.ends_with(":*") {
28        Some(permission_rule[..permission_rule.len() - 2].to_string())
29    } else {
30        None
31    }
32}
33
34/// Checks if a pattern contains unescaped wildcards.
35pub fn has_wildcards(pattern: &str) -> bool {
36    if pattern.ends_with(":*") {
37        return false;
38    }
39
40    let chars: Vec<char> = pattern.chars().collect();
41    for (i, &c) in chars.iter().enumerate() {
42        if c == '*' {
43            let mut backslash_count = 0;
44            let mut j = i as i32 - 1;
45            while j >= 0 && chars[j as usize] == '\\' {
46                backslash_count += 1;
47                j -= 1;
48            }
49            if backslash_count % 2 == 0 {
50                return true;
51            }
52        }
53    }
54    false
55}
56
57/// Matches a command against a wildcard pattern.
58pub fn match_wildcard_pattern(pattern: &str, command: &str, case_insensitive: bool) -> bool {
59    let trimmed = pattern.trim();
60
61    // Process escape sequences
62    let mut processed = String::new();
63    let chars: Vec<char> = trimmed.chars().collect();
64    let mut i = 0;
65
66    while i < chars.len() {
67        if chars[i] == '\\' && i + 1 < chars.len() {
68            match chars[i + 1] {
69                '*' => {
70                    processed.push_str(ESCAPED_STAR_PLACEHOLDER);
71                    i += 2;
72                    continue;
73                }
74                '\\' => {
75                    processed.push_str(ESCAPED_BACKSLASH_PLACEHOLDER);
76                    i += 2;
77                    continue;
78                }
79                _ => {}
80            }
81        }
82        processed.push(chars[i]);
83        i += 1;
84    }
85
86    // Escape regex special characters except *
87    let escaped = processed
88        .replace('.', "\\.")
89        .replace('+', "\\+")
90        .replace('?', "\\?")
91        .replace('^', "\\^")
92        .replace('$', "\\$")
93        .replace('{', "\\{")
94        .replace('}', "\\}")
95        .replace('(', "\\(")
96        .replace(')', "\\)")
97        .replace('[', "\\[")
98        .replace(']', "\\]")
99        .replace('\\', "\\\\")
100        .replace('\'', "\\'")
101        .replace('"', "\\\"");
102
103    // Convert unescaped * to .*
104    let with_wildcards = escaped.replace('*', ".*");
105
106    // Convert placeholders back
107    let regex_pattern = with_wildcards
108        .replace(ESCAPED_STAR_PLACEHOLDER, "\\*")
109        .replace(ESCAPED_BACKSLASH_PLACEHOLDER, "\\\\");
110
111    // Handle trailing ' *' (space + single wildcard) — make args optional
112    let unescaped_star_count = processed.matches('*').count();
113    let mut final_pattern = regex_pattern;
114    if final_pattern.ends_with(" .*") && unescaped_star_count == 1 {
115        let without_trailing = &final_pattern[..final_pattern.len() - 3];
116        final_pattern = format!("{}( .*)?", without_trailing);
117    }
118
119    let flags = if case_insensitive { "(?i)" } else { "" };
120    let regex_str = format!("{}^{}$", flags, final_pattern);
121
122    match regex::Regex::new(&regex_str) {
123        Ok(re) => re.is_match(command),
124        Err(_) => false,
125    }
126}
127
128/// Parses a permission rule string into a structured rule.
129pub fn parse_permission_rule(permission_rule: &str) -> ShellPermissionRule {
130    // Check legacy :* prefix syntax
131    if let Some(prefix) = permission_rule_extract_prefix(permission_rule) {
132        return ShellPermissionRule::Prefix { prefix };
133    }
134
135    // Check wildcard syntax
136    if has_wildcards(permission_rule) {
137        return ShellPermissionRule::Wildcard {
138            pattern: permission_rule.to_string(),
139        };
140    }
141
142    // Exact match
143    ShellPermissionRule::Exact {
144        command: permission_rule.to_string(),
145    }
146}
147
148/// Generates permission update suggestion for an exact command match.
149pub fn suggestion_for_exact_command(tool_name: &str, command: &str) -> Vec<PermissionUpdate> {
150    vec![PermissionUpdate::AddRules {
151        rules: vec![crate::types::permissions::PermissionRuleValue {
152            tool_name: tool_name.to_string(),
153            rule_content: Some(command.to_string()),
154        }],
155        behavior: crate::types::permissions::PermissionBehavior::Allow,
156        destination: crate::types::permissions::PermissionUpdateDestination::LocalSettings,
157    }]
158}
159
160/// Generates permission update suggestion for a prefix match.
161pub fn suggestion_for_prefix(tool_name: &str, prefix: &str) -> Vec<PermissionUpdate> {
162    vec![PermissionUpdate::AddRules {
163        rules: vec![crate::types::permissions::PermissionRuleValue {
164            tool_name: tool_name.to_string(),
165            rule_content: Some(format!("{}:*", prefix)),
166        }],
167        behavior: crate::types::permissions::PermissionBehavior::Allow,
168        destination: crate::types::permissions::PermissionUpdateDestination::LocalSettings,
169    }]
170}