use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{LazyLock, Mutex};
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub struct JcliConfig {
#[serde(default)]
pub permissions: PermissionConfig,
}
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub struct PermissionConfig {
#[serde(default)]
pub allow_all: bool,
#[serde(default)]
pub allow: Vec<String>,
#[serde(default)]
pub deny: Vec<String>,
}
impl JcliConfig {
pub fn load() -> Self {
if let Some(dir) = Self::find_config_dir() {
let perm_path = dir.join("permissions.yaml");
match std::fs::read_to_string(&perm_path) {
Ok(content) => {
let permissions =
serde_yaml::from_str::<PermissionConfig>(&content).unwrap_or_default();
JcliConfig { permissions }
}
Err(_) => Self::default(),
}
} else {
Self::default()
}
}
pub fn find_config_dir() -> Option<PathBuf> {
let mut dir = std::env::current_dir().ok()?;
loop {
let candidate = dir.join(".jcli");
if candidate.is_dir() {
return Some(candidate);
}
if !dir.pop() {
return None;
}
}
}
pub fn ensure_config_dir() -> Option<PathBuf> {
let dir = std::env::current_dir().ok()?.join(".jcli");
let _ = std::fs::create_dir_all(&dir);
Some(dir)
}
pub fn is_allowed(&self, tool_name: &str, arguments: &str) -> bool {
if self.is_denied(tool_name, arguments) {
return false;
}
if self.permissions.allow_all {
return true;
}
for rule in &self.permissions.allow {
if matches_rule(rule, tool_name, arguments) {
return true;
}
}
false
}
pub fn is_denied(&self, tool_name: &str, arguments: &str) -> bool {
for rule in &self.permissions.deny {
if matches_rule(rule, tool_name, arguments) {
return true;
}
}
false
}
pub fn add_allow_rule(&mut self, rule: &str) {
if self.permissions.allow.contains(&rule.to_string()) {
return;
}
self.permissions.allow.push(rule.to_string());
let config_dir = match Self::ensure_config_dir() {
Some(dir) => dir,
None => return,
};
let perm_path = config_dir.join("permissions.yaml");
let mut permissions = if perm_path.is_file() {
match std::fs::read_to_string(&perm_path) {
Ok(content) => {
serde_yaml::from_str::<PermissionConfig>(&content).unwrap_or_default()
}
Err(_) => PermissionConfig::default(),
}
} else {
PermissionConfig::default()
};
if !permissions.allow.contains(&rule.to_string()) {
permissions.allow.push(rule.to_string());
}
if let Ok(yaml) = serde_yaml::to_string(&permissions) {
let _ = std::fs::write(&perm_path, yaml);
}
}
}
fn matches_rule(rule: &str, tool_name: &str, arguments: &str) -> bool {
let rule = rule.trim();
if rule == "*" {
return true;
}
if let Some(paren_start) = rule.find('(') {
if !rule.ends_with(')') {
return false;
}
let rule_tool = &rule[..paren_start];
if rule_tool != tool_name {
return false;
}
let condition = &rule[paren_start + 1..rule.len() - 1];
return match_condition(tool_name, condition, arguments);
}
rule == tool_name
}
fn match_condition(tool_name: &str, condition: &str, arguments: &str) -> bool {
let parsed: Value = match serde_json::from_str(arguments) {
Ok(v) => v,
Err(_) => return false,
};
if let Some(path_pattern) = condition.strip_prefix("path:") {
let file_path = parsed
.get("file_path")
.or_else(|| parsed.get("path"))
.and_then(|v| v.as_str())
.unwrap_or("");
if is_regex_pattern(path_pattern) {
return match_regex(path_pattern, file_path);
}
return match_glob_prefix(path_pattern, file_path);
}
if let Some(domain) = condition.strip_prefix("domain:") {
let url = parsed.get("url").and_then(|v| v.as_str()).unwrap_or("");
if is_regex_pattern(domain) {
let host = extract_host(url);
return match_regex(domain, &host);
}
return url_matches_domain(url, domain);
}
if tool_name == "ComputerUse" {
let action = parsed.get("action").and_then(|v| v.as_str()).unwrap_or("");
let action_pattern = if let Some(rest) = condition.strip_prefix("action:") {
rest
} else {
condition
};
if is_regex_pattern(action_pattern) {
return match_regex(action_pattern, action);
}
return match_command_prefix(action_pattern, action);
}
if tool_name == "Bash" || tool_name == "Shell" {
let command = parsed.get("command").and_then(|v| v.as_str()).unwrap_or("");
if is_regex_pattern(condition) {
return match_regex(condition, command);
}
return match_command_prefix(condition, command);
}
false
}
static REGEX_CACHE: LazyLock<Mutex<HashMap<String, Regex>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
fn is_regex_pattern(pattern: &str) -> bool {
pattern.starts_with('/') && pattern.ends_with('/') && pattern.len() >= 2
}
fn match_regex(pattern: &str, input: &str) -> bool {
let regex_str = &pattern[1..pattern.len() - 1];
if regex_str.is_empty() {
return false;
}
let mut cache = match REGEX_CACHE.lock() {
Ok(c) => c,
Err(poisoned) => poisoned.into_inner(),
};
let re = cache
.entry(regex_str.to_string())
.or_insert_with(|| match Regex::new(regex_str) {
Ok(r) => r,
Err(_) => Regex::new("^$").expect("静态正则 ^$ 不可能编译失败"),
});
re.is_match(input)
}
fn extract_host(url: &str) -> String {
let url_lower = url.to_lowercase();
let after_scheme = if let Some(pos) = url_lower.find("://") {
&url_lower[pos + 3..]
} else {
&url_lower
};
after_scheme
.split('/')
.next()
.unwrap_or("")
.split(':')
.next()
.unwrap_or("")
.to_string()
}
fn match_command_prefix(pattern: &str, command: &str) -> bool {
let prefix = pattern.strip_suffix(":*").unwrap_or(pattern).trim();
let command = command.trim();
if command == prefix {
return true;
}
if let Some(rest) = command.strip_prefix(prefix) {
return rest.starts_with(' ') || rest.starts_with('\t');
}
false
}
fn match_glob_prefix(pattern: &str, path: &str) -> bool {
if pattern == "*" {
return true;
}
if let Some(prefix) = pattern.strip_suffix('*') {
return path.starts_with(prefix);
}
path == pattern
}
fn url_matches_domain(url: &str, domain: &str) -> bool {
let url_lower = url.to_lowercase();
let domain_lower = domain.to_lowercase();
let after_scheme = if let Some(pos) = url_lower.find("://") {
&url_lower[pos + 3..]
} else {
&url_lower
};
let host = after_scheme
.split('/')
.next()
.unwrap_or("")
.split(':')
.next()
.unwrap_or("");
host == domain_lower || host.ends_with(&format!(".{}", domain_lower))
}
pub fn generate_allow_rule(tool_name: &str, arguments: &str) -> String {
let parsed: Value = serde_json::from_str(arguments).unwrap_or(Value::Null);
match tool_name {
"ComputerUse" => {
let action = parsed.get("action").and_then(|v| v.as_str()).unwrap_or("");
if !action.is_empty() {
format!("ComputerUse({}:*)", action)
} else {
"ComputerUse".to_string()
}
}
"Bash" | "Shell" => {
let command = parsed.get("command").and_then(|v| v.as_str()).unwrap_or("");
let words: Vec<&str> = command.split_whitespace().collect();
let prefix = if words.len() >= 2 {
format!("{} {}", words[0], words[1])
} else if words.len() == 1 {
words[0].to_string()
} else {
return tool_name.to_string();
};
format!("{}({}:*)", tool_name, prefix)
}
"Write" | "Edit" => {
let file_path = parsed
.get("file_path")
.and_then(|v| v.as_str())
.unwrap_or("");
if let Some(dir) = std::path::Path::new(file_path).parent() {
format!("{}(path:{}/*)", tool_name, dir.display())
} else {
tool_name.to_string()
}
}
"WebFetch" => {
let url = parsed.get("url").and_then(|v| v.as_str()).unwrap_or("");
let after_scheme = if let Some(pos) = url.find("://") {
&url[pos + 3..]
} else {
url
};
let host = after_scheme
.split('/')
.next()
.unwrap_or("")
.split(':')
.next()
.unwrap_or("");
if !host.is_empty() {
format!("WebFetch(domain:{})", host)
} else {
"WebFetch".to_string()
}
}
_ => tool_name.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_wildcard_rule() {
assert!(matches_rule("*", "Bash", "{}"));
assert!(matches_rule("*", "Read", "{}"));
}
#[test]
fn test_tool_name_rule() {
assert!(matches_rule("Read", "Read", "{}"));
assert!(matches_rule("Grep", "Grep", "{}"));
assert!(!matches_rule("Read", "Write", "{}"));
}
#[test]
fn test_bash_command_prefix() {
let args = r#"{"command": "cargo build --release"}"#;
assert!(matches_rule("Bash(cargo build:*)", "Bash", args));
assert!(!matches_rule("Bash(cargo test:*)", "Bash", args));
let args2 = r#"{"command": "ls -la"}"#;
assert!(matches_rule("Bash(ls:*)", "Bash", args2));
let args3 = r#"{"command": "cargo fmt"}"#;
assert!(matches_rule("Bash(cargo fmt:*)", "Bash", args3));
}
#[test]
fn test_path_rule() {
let args = r#"{"file_path": "/Users/jack/projects/foo/bar.rs"}"#;
assert!(matches_rule(
"Write(path:/Users/jack/projects/*)",
"Write",
args
));
assert!(!matches_rule("Write(path:/Users/other/*)", "Write", args));
}
#[test]
fn test_domain_rule() {
let args = r#"{"url": "https://docs.rs/serde/latest"}"#;
assert!(matches_rule("WebFetch(domain:docs.rs)", "WebFetch", args));
assert!(!matches_rule(
"WebFetch(domain:github.com)",
"WebFetch",
args
));
}
#[test]
fn test_deny_priority() {
let config = JcliConfig {
permissions: PermissionConfig {
allow_all: false,
allow: vec!["Bash(cargo:*)".to_string()],
deny: vec!["Bash(cargo build:*)".to_string()],
},
..Default::default()
};
let args_test = r#"{"command": "cargo test"}"#;
assert!(config.is_allowed("Bash", args_test));
let args_build = r#"{"command": "cargo build"}"#;
assert!(!config.is_allowed("Bash", args_build));
assert!(config.is_denied("Bash", args_build));
}
#[test]
fn test_allow_all() {
let config = JcliConfig {
permissions: PermissionConfig {
allow_all: true,
allow: vec![],
deny: vec![],
},
..Default::default()
};
assert!(config.is_allowed("Bash", r#"{"command": "rm -rf /"}"#));
let config2 = JcliConfig {
permissions: PermissionConfig {
allow_all: true,
allow: vec![],
deny: vec!["Bash(rm -rf:*)".to_string()],
},
..Default::default()
};
assert!(!config2.is_allowed("Bash", r#"{"command": "rm -rf /"}"#));
}
#[test]
fn test_url_domain_matching() {
assert!(url_matches_domain("https://docs.rs/foo", "docs.rs"));
assert!(url_matches_domain(
"https://api.github.com/repos",
"github.com"
));
assert!(!url_matches_domain("https://evil.com/docs.rs", "docs.rs"));
}
#[test]
fn test_generate_allow_rule_bash() {
let args = r#"{"command": "cargo build --release"}"#;
assert_eq!(generate_allow_rule("Bash", args), "Bash(cargo build:*)");
let args2 = r#"{"command": "ls -la"}"#;
assert_eq!(generate_allow_rule("Bash", args2), "Bash(ls -la:*)");
let args3 = r#"{"command": "ls"}"#;
assert_eq!(generate_allow_rule("Bash", args3), "Bash(ls:*)");
}
#[test]
fn test_generate_allow_rule_write() {
let args = r#"{"file_path": "/Users/jack/projects/foo/bar.rs"}"#;
assert_eq!(
generate_allow_rule("Write", args),
"Write(path:/Users/jack/projects/foo/*)"
);
let args2 = r#"{"file_path": "/Users/jack/projects/foo/src/main.rs"}"#;
assert_eq!(
generate_allow_rule("Edit", args2),
"Edit(path:/Users/jack/projects/foo/src/*)"
);
}
#[test]
fn test_generate_allow_rule_webfetch() {
let args = r#"{"url": "https://docs.rs/serde/latest"}"#;
assert_eq!(
generate_allow_rule("WebFetch", args),
"WebFetch(domain:docs.rs)"
);
}
#[test]
fn test_generate_allow_rule_other() {
assert_eq!(generate_allow_rule("Read", "{}"), "Read");
assert_eq!(generate_allow_rule("Grep", "{}"), "Grep");
assert_eq!(generate_allow_rule("Glob", "{}"), "Glob");
}
#[test]
fn test_regex_bash_command() {
let args_build = r#"{"command": "cargo build --release"}"#;
let args_test = r#"{"command": "cargo test --lib"}"#;
let args_run = r#"{"command": "cargo run"}"#;
assert!(matches_rule(
"Bash(/^cargo (build|test)/)",
"Bash",
args_build
));
assert!(matches_rule(
"Bash(/^cargo (build|test)/)",
"Bash",
args_test
));
assert!(!matches_rule(
"Bash(/^cargo (build|test)/)",
"Bash",
args_run
));
}
#[test]
fn test_regex_path_pattern() {
let args_rs = r#"{"file_path": "/Users/jack/projects/foo/bar.rs"}"#;
let args_ts = r#"{"file_path": "/Users/jack/projects/foo/bar.ts"}"#;
assert!(matches_rule("Write(path:/\\.rs$/)", "Write", args_rs));
assert!(!matches_rule("Write(path:/\\.rs$/)", "Write", args_ts));
}
#[test]
fn test_regex_domain_pattern() {
let args_google = r#"{"url": "https://maps.google.com/api"}"#;
let args_docs = r#"{"url": "https://docs.google.com/document"}"#;
let args_other = r#"{"url": "https://evil.com/google.com"}"#;
assert!(matches_rule(
"WebFetch(domain:/.*\\.google\\.com$/)",
"WebFetch",
args_google
));
assert!(matches_rule(
"WebFetch(domain:/.*\\.google\\.com$/)",
"WebFetch",
args_docs
));
assert!(!matches_rule(
"WebFetch(domain:/.*\\.google\\.com$/)",
"WebFetch",
args_other
));
}
#[test]
fn test_regex_invalid_pattern() {
let args = r#"{"command": "anything"}"#;
assert!(!matches_rule("Bash(/[invalid/)", "Bash", args));
assert!(!matches_rule("Bash(//)", "Bash", args));
}
}