use regex::Regex;
use serde_json::Value;
use std::env;
const DEFAULT_PROTECTED_PATTERNS: &[&str] = &[
".git",
".env",
".env.",
".claude",
".claude/",
".vscode",
".idea",
".husky",
".zshrc",
".bashrc",
".bash_profile",
".profile",
".ssh",
"authorized_keys",
"id_rsa",
"id_ed25519",
];
#[derive(Debug, Clone)]
pub enum ProtectedPathResult {
Safe,
Protected {
matched_pattern: String,
path: String,
},
}
#[derive(Debug, Clone)]
pub struct ProtectedPathChecker {
patterns: Vec<String>,
}
impl ProtectedPathChecker {
pub fn new() -> Self {
Self {
patterns: DEFAULT_PROTECTED_PATTERNS
.iter()
.map(|s| s.to_string())
.collect(),
}
}
pub fn with_pattern(mut self, pattern: impl Into<String>) -> Self {
self.patterns.push(pattern.into());
self
}
pub fn with_patterns(mut self, patterns: Vec<String>) -> Self {
self.patterns.extend(patterns);
self
}
pub fn disabled() -> Self {
Self {
patterns: Vec::new(),
}
}
pub fn check(&self, tool_name: &str, input: &Value) -> ProtectedPathResult {
if self.patterns.is_empty() {
return ProtectedPathResult::Safe;
}
let paths = extract_paths(tool_name, input);
for path in paths {
let normalized = path.replace('\\', "/");
for pattern in &self.patterns {
if Self::path_matches_pattern(pattern, &normalized) {
return ProtectedPathResult::Protected {
matched_pattern: pattern.clone(),
path: path.clone(),
};
}
}
}
ProtectedPathResult::Safe
}
fn path_matches_pattern(pattern: &str, path: &str) -> bool {
let p_lower = path.to_lowercase();
let pat_lower = pattern.to_lowercase();
if p_lower.ends_with(&pat_lower) {
let suffix_start = p_lower.len() - pat_lower.len();
if suffix_start == 0 || p_lower.as_bytes()[suffix_start - 1] == b'/' {
return true;
}
}
if p_lower.contains(&pat_lower) {
let search = format!("/{pat_lower}");
if p_lower.contains(&search) || p_lower.starts_with(&pat_lower) {
return true;
}
}
if pattern.ends_with('.') && p_lower.contains(&pat_lower) {
return true;
}
false
}
}
impl Default for ProtectedPathChecker {
fn default() -> Self {
Self::new()
}
}
fn extract_paths(tool_name: &str, input: &Value) -> Vec<String> {
let mut paths = Vec::new();
match tool_name {
"Read" | "Write" | "Edit" => {
if let Some(path) = input.get("path").and_then(|v| v.as_str()) {
paths.push(path.to_string());
}
if let Some(path) = input.get("file_path").and_then(|v| v.as_str()) {
paths.push(path.to_string());
}
}
"Bash" => {
if let Some(cmd) = input.get("command").and_then(|v| v.as_str()) {
paths.extend(extract_paths_from_bash_command(cmd));
}
}
_ => {
extract_paths_from_value(input, &mut paths);
}
}
paths
}
fn extract_paths_from_bash_command(cmd: &str) -> Vec<String> {
let mut paths = Vec::new();
let expanded = expand_env_vars_and_tilde(cmd);
let quoted_re = Regex::new(r#""([^"]*)"|'([^']*)'"#).unwrap();
for cap in quoted_re.captures_iter(&expanded) {
if let Some(quoted) = cap.get(1).or_else(|| cap.get(2)) {
let quoted_str = quoted.as_str();
if looks_like_path(quoted_str) {
paths.push(quoted_str.to_string());
}
}
}
let path_re =
Regex::new(r#"(?:^|\s)((?:/|\./|~/|\.)[^\s"'`$(){}|;&<>]*|/[^\s"'`$(){}|;&<>]+)"#).unwrap();
for cap in path_re.captures_iter(&expanded) {
if let Some(matched) = cap.get(1) {
let path = matched.as_str().trim();
if !path.is_empty() && path != "." && path != ".." && !paths.contains(&path.to_string())
{
paths.push(path.to_string());
}
}
}
extract_paths_from_command_substitution(&expanded, &mut paths);
paths
}
fn looks_like_path(s: &str) -> bool {
s.contains('/') || s.starts_with('.') || s.starts_with('~') || s.starts_with('/')
}
fn expand_env_vars_and_tilde(cmd: &str) -> String {
let mut result = cmd.to_string();
if result.contains('~')
&& let Ok(home) = env::var("HOME")
{
result = result.replace("~/", &format!("{}/", home));
result = result.replace(" ~ ", &format!(" {} ", home));
if result == "~" {
result = home;
}
}
let re = Regex::new(r#"\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)"#).unwrap();
result = re
.replace_all(&result, |caps: ®ex::Captures| {
let var_name = caps.get(1).or_else(|| caps.get(2)).unwrap().as_str();
env::var(var_name).unwrap_or_else(|_| caps[0].to_string())
})
.to_string();
result
}
fn extract_paths_from_command_substitution(cmd: &str, paths: &mut Vec<String>) {
let mut stack = vec![(cmd.to_string(), 0)];
let re = Regex::new(r#"\$\(([^)]+)\)|`([^`]+)`"#).unwrap();
while let Some((current_cmd, depth)) = stack.pop() {
if depth >= 5 {
continue; }
for cap in re.captures_iter(¤t_cmd) {
if let Some(subcmd) = cap.get(1).or_else(|| cap.get(2)) {
let subcmd_str = subcmd.as_str();
let expanded_subcmd = expand_env_vars_and_tilde(subcmd_str);
let sub_paths = extract_direct_paths_from_command(&expanded_subcmd);
paths.extend(sub_paths);
stack.push((expanded_subcmd, depth + 1));
}
}
}
}
fn extract_direct_paths_from_command(cmd: &str) -> Vec<String> {
let mut paths = Vec::new();
for word in cmd.split_whitespace() {
let word = word.trim_matches(|c| c == '"' || c == '\'');
if looks_like_path(word) {
paths.push(word.to_string());
}
}
paths
}
fn extract_paths_from_value(value: &Value, paths: &mut Vec<String>) {
match value {
Value::String(s) if s.contains('/') || s.starts_with('.') => {
paths.push(s.clone());
}
Value::String(_) => {}
Value::Object(map) => {
for (key, v) in map {
if matches!(
key.as_str(),
"path" | "file_path" | "directory" | "dir" | "dest" | "destination"
) && let Some(s) = v.as_str()
{
paths.push(s.to_string());
}
extract_paths_from_value(v, paths);
}
}
Value::Array(arr) => {
for v in arr {
extract_paths_from_value(v, paths);
}
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_safe_path() {
let checker = ProtectedPathChecker::new();
let result = checker.check("Read", &json!({"path": "/home/user/project/src/main.rs"}));
assert!(matches!(result, ProtectedPathResult::Safe));
}
#[test]
fn test_git_protected() {
let checker = ProtectedPathChecker::new();
let result = checker.check("Write", &json!({"path": "/project/.git/config"}));
assert!(matches!(result, ProtectedPathResult::Protected { .. }));
}
#[test]
fn test_env_protected() {
let checker = ProtectedPathChecker::new();
let result = checker.check("Write", &json!({"path": ".env"}));
assert!(matches!(result, ProtectedPathResult::Protected { .. }));
}
#[test]
fn test_env_production_protected() {
let checker = ProtectedPathChecker::new();
let result = checker.check("Write", &json!({"path": ".env.production"}));
assert!(matches!(result, ProtectedPathResult::Protected { .. }));
}
#[test]
fn test_ssh_protected() {
let checker = ProtectedPathChecker::new();
let result = checker.check("Read", &json!({"path": "/home/user/.ssh/id_rsa"}));
assert!(matches!(result, ProtectedPathResult::Protected { .. }));
}
#[test]
fn test_bash_with_protected_path() {
let checker = ProtectedPathChecker::new();
let result = checker.check("Bash", &json!({"command": "rm -rf .git"}));
assert!(matches!(result, ProtectedPathResult::Protected { .. }));
}
#[test]
fn test_disabled_checker() {
let checker = ProtectedPathChecker::disabled();
let result = checker.check("Write", &json!({"path": ".env"}));
assert!(matches!(result, ProtectedPathResult::Safe));
}
#[test]
fn test_custom_pattern() {
let checker = ProtectedPathChecker::new().with_pattern("secret/");
let result = checker.check("Write", &json!({"path": "/project/secret/keys.pem"}));
assert!(matches!(result, ProtectedPathResult::Protected { .. }));
}
#[test]
fn test_case_insensitive() {
let checker = ProtectedPathChecker::new();
let result = checker.check("Write", &json!({"path": "/project/.ENV"}));
assert!(matches!(result, ProtectedPathResult::Protected { .. }));
let result = checker.check("Write", &json!({"path": "/project/.Git/config"}));
assert!(matches!(result, ProtectedPathResult::Protected { .. }));
}
#[test]
fn test_command_substitution_bypass() {
let checker = ProtectedPathChecker::new();
let result = checker.check("Bash", &json!({"command": "rm $(echo /.git)"}));
assert!(matches!(result, ProtectedPathResult::Protected { .. }));
}
#[test]
fn test_backtick_command_substitution() {
let checker = ProtectedPathChecker::new();
let result = checker.check("Bash", &json!({"command": "rm `echo /.git`"}));
assert!(matches!(result, ProtectedPathResult::Protected { .. }));
}
#[test]
fn test_nested_command_substitution() {
let checker = ProtectedPathChecker::new();
let result = checker.check("Bash", &json!({"command": "rm $(echo $(echo /.git))"}));
assert!(matches!(result, ProtectedPathResult::Protected { .. }));
}
#[test]
fn test_quoted_command_substitution() {
let checker = ProtectedPathChecker::new();
let result = checker.check("Bash", &json!({"command": "rm \"$(echo '/.git')\""}));
assert!(matches!(result, ProtectedPathResult::Protected { .. }));
}
#[test]
fn test_env_var_in_command_substitution() {
unsafe {
std::env::set_var("TEST_PATH", "/.git");
}
let checker = ProtectedPathChecker::new();
let result = checker.check("Bash", &json!({"command": "rm $(echo $TEST_PATH)"}));
assert!(matches!(result, ProtectedPathResult::Protected { .. }));
}
#[test]
fn test_home_dir_expansion() {
let checker = ProtectedPathChecker::new();
let result = checker.check("Bash", &json!({"command": "rm ~/.ssh/id_rsa"}));
assert!(matches!(result, ProtectedPathResult::Protected { .. }));
}
#[test]
fn test_env_var_expansion() {
unsafe {
std::env::set_var("HOME", "/home/user");
}
let checker = ProtectedPathChecker::new();
let result = checker.check("Bash", &json!({"command": "rm $HOME/.ssh/id_rsa"}));
assert!(matches!(result, ProtectedPathResult::Protected { .. }));
}
}