Skip to main content

stynx_code_permission/application/
pattern_matcher.rs

1use serde_json::Value;
2
3/// A parsed permission rule, e.g. "bash(git *)" → tool="bash", pattern=Some("git *")
4#[derive(Debug, Clone)]
5pub struct PermissionRule {
6    pub tool: String,
7    pub pattern: Option<String>,
8}
9
10/// Parse a permission rule string like "bash(git *)", "read", "glob".
11pub fn parse_rule(rule: &str) -> PermissionRule {
12    let rule = rule.trim();
13    if let Some(paren_start) = rule.find('(')
14        && rule.ends_with(')') {
15            let tool = rule[..paren_start].to_string();
16            let pattern = rule[paren_start + 1..rule.len() - 1].to_string();
17            return PermissionRule {
18                tool,
19                pattern: Some(pattern),
20            };
21        }
22    PermissionRule {
23        tool: rule.to_string(),
24        pattern: None,
25    }
26}
27
28/// Check if a permission rule matches a given tool invocation.
29pub fn rule_matches(rule: &PermissionRule, tool_name: &str, input: &Value) -> bool {
30    if rule.tool != tool_name {
31        return false;
32    }
33
34    let Some(pattern) = &rule.pattern else {
35        // Tool name only match — matches any input
36        return true;
37    };
38
39    // Get the relevant input field based on tool type
40    let field_value = match tool_name {
41        "bash" => input.get("command").and_then(|v| v.as_str()),
42        "file_write" | "file_edit" => input.get("file_path").and_then(|v| v.as_str()),
43        "read" => input.get("file_path").and_then(|v| v.as_str()),
44        "web_fetch" => input.get("url").and_then(|v| v.as_str()),
45        _ => {
46            // For other tools, try to match against the full input JSON string
47            None
48        }
49    };
50
51    let Some(value) = field_value else {
52        return false;
53    };
54
55    glob_match(pattern, value)
56}
57
58/// Simple glob matching: `*` matches any sequence of chars.
59fn glob_match(pattern: &str, text: &str) -> bool {
60    let parts: Vec<&str> = pattern.split('*').collect();
61
62    if parts.len() == 1 {
63        // No wildcards — exact match
64        return pattern == text;
65    }
66
67    let mut pos = 0;
68
69    // First part must match from the start
70    if !parts[0].is_empty() {
71        if !text.starts_with(parts[0]) {
72            return false;
73        }
74        pos = parts[0].len();
75    }
76
77    // Middle parts must appear in order
78    for part in &parts[1..parts.len() - 1] {
79        if part.is_empty() {
80            continue;
81        }
82        match text[pos..].find(part) {
83            Some(idx) => pos += idx + part.len(),
84            None => return false,
85        }
86    }
87
88    // Last part must match at the end
89    let last = parts[parts.len() - 1];
90    if !last.is_empty() {
91        text[pos..].ends_with(last)
92    } else {
93        true
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use serde_json::json;
101
102    #[test]
103    fn test_parse_rule_simple() {
104        let rule = parse_rule("read");
105        assert_eq!(rule.tool, "read");
106        assert!(rule.pattern.is_none());
107    }
108
109    #[test]
110    fn test_parse_rule_with_pattern() {
111        let rule = parse_rule("bash(git *)");
112        assert_eq!(rule.tool, "bash");
113        assert_eq!(rule.pattern.as_deref(), Some("git *"));
114    }
115
116    #[test]
117    fn test_rule_matches_tool_only() {
118        let rule = parse_rule("read");
119        assert!(rule_matches(&rule, "read", &json!({"file_path": "/any/path"})));
120        assert!(!rule_matches(&rule, "bash", &json!({"command": "ls"})));
121    }
122
123    #[test]
124    fn test_rule_matches_bash_pattern() {
125        let rule = parse_rule("bash(git *)");
126        assert!(rule_matches(&rule, "bash", &json!({"command": "git status"})));
127        assert!(rule_matches(&rule, "bash", &json!({"command": "git diff --cached"})));
128        assert!(!rule_matches(&rule, "bash", &json!({"command": "rm -rf /"})));
129    }
130
131    #[test]
132    fn test_glob_match() {
133        assert!(glob_match("git *", "git status"));
134        assert!(glob_match("git *", "git diff --cached"));
135        assert!(!glob_match("git *", "rm -rf"));
136        assert!(glob_match("*", "anything"));
137        assert!(glob_match("*.rs", "main.rs"));
138        assert!(!glob_match("*.rs", "main.py"));
139    }
140}