stynx-code-permission 3.6.2

Interactive terminal permission checker
Documentation
use serde_json::Value;

#[derive(Debug, Clone)]
pub struct PermissionRule {
    pub tool: String,
    pub pattern: Option<String>,
}

pub fn parse_rule(rule: &str) -> PermissionRule {
    let rule = rule.trim();
    if let Some(paren_start) = rule.find('(')
        && rule.ends_with(')') {
            let tool = rule[..paren_start].to_string();
            let pattern = rule[paren_start + 1..rule.len() - 1].to_string();
            return PermissionRule {
                tool,
                pattern: Some(pattern),
            };
        }
    PermissionRule {
        tool: rule.to_string(),
        pattern: None,
    }
}

pub fn rule_matches(rule: &PermissionRule, tool_name: &str, input: &Value) -> bool {
    if rule.tool != tool_name {
        return false;
    }

    let Some(pattern) = &rule.pattern else {

        return true;
    };

    let field_value = match tool_name {
        "bash" => input.get("command").and_then(|v| v.as_str()),
        "file_write" | "file_edit" => input.get("file_path").and_then(|v| v.as_str()),
        "read" => input.get("file_path").and_then(|v| v.as_str()),
        "web_fetch" => input.get("url").and_then(|v| v.as_str()),
        _ => {

            None
        }
    };

    let Some(value) = field_value else {
        return false;
    };

    glob_match(pattern, value)
}

fn glob_match(pattern: &str, text: &str) -> bool {
    let parts: Vec<&str> = pattern.split('*').collect();

    if parts.len() == 1 {

        return pattern == text;
    }

    let mut pos = 0;

    if !parts[0].is_empty() {
        if !text.starts_with(parts[0]) {
            return false;
        }
        pos = parts[0].len();
    }

    for part in &parts[1..parts.len() - 1] {
        if part.is_empty() {
            continue;
        }
        match text[pos..].find(part) {
            Some(idx) => pos += idx + part.len(),
            None => return false,
        }
    }

    let last = parts[parts.len() - 1];
    if !last.is_empty() {
        text[pos..].ends_with(last)
    } else {
        true
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn test_parse_rule_simple() {
        let rule = parse_rule("read");
        assert_eq!(rule.tool, "read");
        assert!(rule.pattern.is_none());
    }

    #[test]
    fn test_parse_rule_with_pattern() {
        let rule = parse_rule("bash(git *)");
        assert_eq!(rule.tool, "bash");
        assert_eq!(rule.pattern.as_deref(), Some("git *"));
    }

    #[test]
    fn test_rule_matches_tool_only() {
        let rule = parse_rule("read");
        assert!(rule_matches(&rule, "read", &json!({"file_path": "/any/path"})));
        assert!(!rule_matches(&rule, "bash", &json!({"command": "ls"})));
    }

    #[test]
    fn test_rule_matches_bash_pattern() {
        let rule = parse_rule("bash(git *)");
        assert!(rule_matches(&rule, "bash", &json!({"command": "git status"})));
        assert!(rule_matches(&rule, "bash", &json!({"command": "git diff --cached"})));
        assert!(!rule_matches(&rule, "bash", &json!({"command": "rm -rf /"})));
    }

    #[test]
    fn test_glob_match() {
        assert!(glob_match("git *", "git status"));
        assert!(glob_match("git *", "git diff --cached"));
        assert!(!glob_match("git *", "rm -rf"));
        assert!(glob_match("*", "anything"));
        assert!(glob_match("*.rs", "main.rs"));
        assert!(!glob_match("*.rs", "main.py"));
    }
}