use crate::services::mcp::mcp_info_from_string;
use crate::utils::settings::tool_validation_config::{
get_custom_validation, is_bash_prefix_tool, is_file_pattern_tool,
};
fn is_escaped(s: &str, index: usize) -> bool {
let mut backslash_count = 0;
let bytes = s.as_bytes();
let mut j = index;
while j > 0 && bytes[j - 1] == b'\\' {
backslash_count += 1;
j -= 1;
}
backslash_count % 2 != 0
}
fn count_unescaped_char(s: &str, ch: char) -> usize {
let mut count = 0;
let mut i = 0;
for c in s.chars() {
if c == ch && !is_escaped(s, i) {
count += 1;
}
i += c.len_utf8();
}
count
}
fn has_unescaped_empty_parens(s: &str) -> bool {
let bytes = s.as_bytes();
for i in 0..bytes.len().saturating_sub(1) {
if bytes[i] == b'(' && bytes[i + 1] == b')' {
if !is_escaped(s, i) {
return true;
}
}
}
false
}
fn capitalize(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
None => String::new(),
Some(c) => c.to_uppercase().to_string() + chars.as_str(),
}
}
fn parse_permission_rule_value(rule: &str) -> (String, Option<String>) {
let paren_pos = rule.find('(');
let tool_name = match paren_pos {
Some(pos) => rule[..pos].trim().to_string(),
None => rule.trim().to_string(),
};
let rule_content = if let Some(pos) = rule.find('(') {
let rest = &rule[pos + 1..];
if let Some(close) = rest.rfind(')') {
let inner = &rest[..close];
if inner.is_empty() {
None
} else {
Some(inner.to_string())
}
} else {
Some(rest.to_string())
}
} else {
None
};
(tool_name, rule_content)
}
#[derive(Debug, Clone, Default)]
pub struct PermissionRuleResult {
pub valid: bool,
pub error: Option<String>,
pub suggestion: Option<String>,
pub examples: Option<Vec<String>>,
}
pub fn validate_permission_rule(rule: &str) -> PermissionRuleResult {
if rule.is_empty() || rule.trim().is_empty() {
return PermissionRuleResult {
valid: false,
error: Some("Permission rule cannot be empty".into()),
suggestion: None,
examples: None,
};
}
let open_count = count_unescaped_char(rule, '(');
let close_count = count_unescaped_char(rule, ')');
if open_count != close_count {
return PermissionRuleResult {
valid: false,
error: Some("Mismatched parentheses".into()),
suggestion: Some(
"Ensure all opening parentheses have matching closing parentheses".into(),
),
examples: None,
};
}
if has_unescaped_empty_parens(rule) {
let paren_pos = rule.find('(');
let tool_name = paren_pos
.map(|p| rule[..p].trim().to_string())
.unwrap_or_default();
if tool_name.is_empty() {
return PermissionRuleResult {
valid: false,
error: Some("Empty parentheses with no tool name".into()),
suggestion: Some("Specify a tool name before the parentheses".into()),
examples: None,
};
}
return PermissionRuleResult {
valid: false,
error: Some("Empty parentheses".into()),
suggestion: Some(format!(
"Either specify a pattern or use \"{}\" without parentheses",
tool_name
)),
examples: Some(vec![tool_name.clone(), format!("{}(some-pattern)", tool_name)]),
};
}
let (parsed_tool_name, parsed_rule_content) = parse_permission_rule_value(rule);
if let Some(mcp_info) = mcp_info_from_string(&parsed_tool_name) {
if parsed_rule_content.is_some() || count_unescaped_char(rule, '(') > 0 {
return PermissionRuleResult {
valid: false,
error: Some("MCP rules do not support patterns in parentheses".into()),
suggestion: Some(format!(
"Use \"{}\" without parentheses, or use \"mcp__{}__*\" for all tools",
parsed_tool_name, mcp_info.server_name
)),
examples: Some({
let mut ex = vec![
format!("mcp__{}", mcp_info.server_name),
format!("mcp__{}__*", mcp_info.server_name),
];
if let Some(ref tool) = mcp_info.tool_name {
if !tool.is_empty() && *tool != "*" {
ex.push(format!("mcp__{}__{}", mcp_info.server_name, tool));
}
}
ex
}),
};
}
return PermissionRuleResult {
valid: true,
error: None,
suggestion: None,
examples: None,
};
}
if parsed_tool_name.is_empty() {
return PermissionRuleResult {
valid: false,
error: Some("Tool name cannot be empty".into()),
suggestion: None,
examples: None,
};
}
if let Some(first_char) = parsed_tool_name.chars().next() {
if first_char != first_char.to_ascii_uppercase() {
return PermissionRuleResult {
valid: false,
error: Some("Tool names must start with uppercase".into()),
suggestion: Some(format!("\"{}\"", capitalize(&parsed_tool_name))),
examples: None,
};
}
}
if let Some(custom_result) = get_custom_validation(rule) {
if !custom_result.valid {
return PermissionRuleResult {
valid: false,
error: custom_result.error,
suggestion: custom_result.suggestion,
examples: custom_result.examples,
};
}
}
if is_bash_prefix_tool(&parsed_tool_name) {
if let Some(ref content) = parsed_rule_content {
if content.contains(":*") && !content.ends_with(":*") {
return PermissionRuleResult {
valid: false,
error: Some("The :* pattern must be at the end".into()),
suggestion: Some(
"Move :* to the end for prefix matching, or use * for wildcard matching"
.into(),
),
examples: Some(vec![
"Bash(npm run:*) - prefix matching (legacy)".into(),
"Bash(npm run *) - wildcard matching".into(),
]),
};
}
if content == ":*" {
return PermissionRuleResult {
valid: false,
error: Some("Prefix cannot be empty before :*".into()),
suggestion: Some("Specify a command prefix before :*".into()),
examples: Some(vec!["Bash(npm:*)".into(), "Bash(git:*)".into()]),
};
}
}
}
if is_file_pattern_tool(&parsed_tool_name) {
if let Some(ref content) = parsed_rule_content {
if content.contains(":*") {
return PermissionRuleResult {
valid: false,
error: Some("The \":*\" syntax is only for Bash prefix rules".into()),
suggestion: Some("Use glob patterns like \"*\" or \"**\" for file matching".into()),
examples: Some(vec![
format!("{}(*.ts) - matches .ts files", parsed_tool_name),
format!("{}(src/**) - matches all files in src", parsed_tool_name),
format!(
"{}(**/*.test.ts) - matches test files",
parsed_tool_name
),
]),
};
}
}
}
PermissionRuleResult {
valid: true,
error: None,
suggestion: None,
examples: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_rule() {
let result = validate_permission_rule("");
assert!(!result.valid);
let result = validate_permission_rule(" ");
assert!(!result.valid);
}
#[test]
fn test_mismatched_parens() {
let result = validate_permission_rule("Read(*.ts");
assert!(!result.valid);
let result = validate_permission_rule("Read*.ts)");
assert!(!result.valid);
}
#[test]
fn test_empty_parens() {
let result = validate_permission_rule("Read()");
assert!(!result.valid);
assert!(result.suggestion.is_some());
}
#[test]
fn test_lowercase_tool_name() {
let result = validate_permission_rule("read(*.ts)");
assert!(!result.valid);
}
#[test]
fn test_valid_simple_rule() {
let result = validate_permission_rule("Read");
assert!(result.valid);
}
#[test]
fn test_valid_pattern_rule() {
let result = validate_permission_rule("Read(*.ts)");
assert!(result.valid);
}
#[test]
fn test_valid_bash_rule() {
let result = validate_permission_rule("Bash(npm run:*)");
assert!(result.valid);
}
#[test]
fn test_bash_colon_star_at_end() {
let result = validate_permission_rule("Bash(npm:*)");
assert!(result.valid);
}
#[test]
fn test_bash_colon_star_middle() {
let result = validate_permission_rule("Bash(npm:*) install)");
assert!(!result.valid);
}
#[test]
fn test_file_tool_colon_star() {
let result = validate_permission_rule("Read(:*)");
assert!(!result.valid);
}
#[test]
fn test_escaped_parens() {
let result = validate_permission_rule("Bash(grep '\\(test\\')");
assert!(result.valid);
}
#[test]
fn test_mcp_rule_no_parens() {
let result = validate_permission_rule("mcp__my-server");
assert!(result.valid);
}
#[test]
fn test_mcp_rule_wildcard() {
let result = validate_permission_rule("mcp__my-server__*");
assert!(result.valid);
}
#[test]
fn test_is_escaped() {
assert!(!is_escaped("abc(')", 3)); assert!(is_escaped("abc\\(')", 4)); assert!(!is_escaped("abc\\\\(')", 5)); }
#[test]
fn test_count_unescaped_char() {
assert_eq!(count_unescaped_char("a(b)c(", '('), 2);
assert_eq!(count_unescaped_char("a\\(b)c(", '('), 1);
}
}