mod blast_paths;
mod blast_radius;
mod entry_point_check;
pub mod lock;
mod portable;
mod self_mod;
mod token_budget;
use clap::Subcommand;
pub use portable::check_command;
#[cfg(test)]
use portable::{
has_adjacent_variable_splice, has_brace_expansion, is_git_force, is_git_push_to_main,
is_git_reset_hard, is_rm_rf,
};
use serde::Deserialize;
use std::io::Read;
#[derive(Subcommand)]
pub enum GuardAction {
Destructive,
TokenBudget {
#[arg(long)]
tool: Option<String>,
},
BlastRadius,
SelfMod,
EntryPointCheck,
LockWith {
#[arg(long)]
resource: String,
#[arg(long, default_value = "30")]
timeout: u64,
#[arg(trailing_var_arg = true, required = true)]
command: Vec<String>,
},
LockIdentity {
#[arg(long)]
resource: String,
},
}
pub fn dispatch(action: GuardAction) {
let code = match action {
GuardAction::Destructive => cmd_destructive(),
GuardAction::TokenBudget { tool } => token_budget::cmd_token_budget(tool),
GuardAction::BlastRadius => blast_radius::cmd_blast_radius(),
GuardAction::SelfMod => self_mod::cmd_self_mod(),
GuardAction::EntryPointCheck => entry_point_check::cmd_entry_point_check(),
GuardAction::LockWith {
resource,
timeout,
command,
} => lock::cmd_lock_with(&resource, timeout, &command),
GuardAction::LockIdentity { resource } => lock::cmd_lock_identity(&resource),
};
std::process::exit(code);
}
#[derive(Deserialize, Default)]
struct HookEvent {
#[serde(default)]
tool_name: String,
#[serde(default)]
tool_input: serde_json::Value,
}
const COMMAND_LIKE_KEYS: &[&str] = &[
"command",
"commands",
"cmd",
"script",
"exec",
"execute",
"sql",
"statement",
"shell",
"bash",
"sh",
];
fn tokenize_key(key: &str) -> Vec<String> {
let chars: Vec<char> = key.chars().collect();
let mut spaced = String::new();
for (i, &ch) in chars.iter().enumerate() {
if ch.is_uppercase() && i > 0 {
let prev = chars[i - 1];
let prev_lower_or_digit = prev.is_lowercase() || prev.is_ascii_digit();
let acronym_to_word_boundary =
prev.is_uppercase() && chars.get(i + 1).is_some_and(|c| c.is_lowercase());
if prev_lower_or_digit || acronym_to_word_boundary {
spaced.push('_');
}
}
spaced.push(ch.to_ascii_lowercase());
}
spaced
.split(|c: char| !c.is_alphanumeric())
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect()
}
fn is_command_like_key(key: &str) -> bool {
tokenize_key(key)
.iter()
.any(|t| COMMAND_LIKE_KEYS.contains(&t.as_str()))
}
const MAX_COLLECT_DEPTH: usize = 32;
fn collect_command_like_strings(v: &serde_json::Value, out: &mut Vec<String>) {
collect_command_like_strings_at(v, out, 0);
}
fn collect_command_like_strings_at(v: &serde_json::Value, out: &mut Vec<String>, depth: usize) {
if depth >= MAX_COLLECT_DEPTH {
return;
}
match v {
serde_json::Value::Object(map) => {
for (k, val) in map {
let key_is_command_like = is_command_like_key(k);
match val {
serde_json::Value::String(s) if key_is_command_like => out.push(s.clone()),
serde_json::Value::Array(arr) if key_is_command_like => {
for item in arr {
if let serde_json::Value::String(s) = item {
out.push(s.clone());
}
}
}
_ => {}
}
collect_command_like_strings_at(val, out, depth + 1);
}
}
serde_json::Value::Array(arr) => {
for val in arr {
collect_command_like_strings_at(val, out, depth + 1);
}
}
_ => {}
}
}
fn deny_json(reason: &str) -> i32 {
let out = serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason
}
});
println!("{out}");
2
}
fn cmd_destructive() -> i32 {
let mut buf = String::new();
if std::io::stdin().read_to_string(&mut buf).is_err() {
return deny_json(
"Blocked: the destructive-command guard could not read the tool-call payload from stdin. \
Failing closed rather than allowing an unverified command through.",
);
}
if buf.trim().is_empty() {
return 0;
}
let event: HookEvent = match serde_json::from_str(&buf) {
Ok(event) => event,
Err(_) => {
return deny_json(
"Blocked: the destructive-command guard received a tool-call payload that isn't valid JSON. \
Failing closed rather than allowing an unverified command through.",
);
}
};
let primary = event
.tool_input
.get("command")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let mut candidates = vec![primary];
if event.tool_name.starts_with("mcp__") {
collect_command_like_strings(&event.tool_input, &mut candidates);
}
for command in candidates.iter().filter(|c| !c.is_empty()) {
if let Some(reason) = check_command(command) {
return deny_json(reason);
}
}
0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rm_rf_combined_still_blocked() {
assert!(is_rm_rf("rm -rf /tmp/x"));
assert!(is_rm_rf("rm -fr /tmp/x"));
assert!(is_rm_rf("rm -Rf /tmp/x"));
}
#[test]
fn rm_rf_long_form_bypass_fixed() {
assert!(is_rm_rf("rm --recursive --force /tmp/x"));
assert!(is_rm_rf("rm --force --recursive /tmp/x"));
}
#[test]
fn rm_rf_separated_short_flags_bypass_fixed() {
assert!(is_rm_rf("rm -r -f /tmp/x"));
assert!(is_rm_rf("rm -f -r /tmp/x"));
}
#[test]
fn rm_rf_mixed_form_bypass_fixed() {
assert!(is_rm_rf("rm --recursive -f /tmp/x"));
assert!(is_rm_rf("rm -r --force /tmp/x"));
}
#[test]
fn rm_recursive_alone_not_blocked() {
assert!(!is_rm_rf("rm -r /tmp/x"));
assert!(!is_rm_rf("rm -f /tmp/x"));
assert!(!is_rm_rf("rm /tmp/x"));
}
#[test]
fn rm_rf_in_chain_still_caught_unrelated_not_flagged() {
assert!(is_rm_rf("cd /tmp && rm -rf x"));
assert!(is_rm_rf("echo hi; rm -rf /tmp/x"));
assert!(!is_rm_rf("ls -r foo && curl -f url"));
}
#[test]
fn git_push_force_combined_short_flags_bypass_fixed() {
assert!(is_git_force("git push -uf origin main", "push"));
assert!(is_git_force("git push -fu origin main", "push"));
}
#[test]
fn git_push_force_original_forms_still_blocked() {
assert!(is_git_force("git push --force origin main", "push"));
assert!(is_git_force("git push -f origin main", "push"));
assert!(is_git_force("git push --force-with-lease", "push"));
}
#[test]
fn git_push_without_force_allowed() {
assert!(!is_git_force("git push origin main", "push"));
}
#[test]
fn git_clean_force_flag_order_bypass_fixed() {
assert!(is_git_force("git clean -df", "clean"));
assert!(is_git_force("git clean -xdf", "clean"));
}
#[test]
fn git_clean_force_original_forms_still_blocked() {
assert!(is_git_force("git clean -f", "clean"));
assert!(is_git_force("git clean -fd", "clean"));
}
#[test]
fn git_clean_dry_run_allowed() {
assert!(!is_git_force("git clean -n", "clean"));
}
#[test]
fn dash_c_global_opt_no_longer_bypasses_push_force() {
assert!(is_git_force(
"git -C /tmp/x push --force origin main",
"push"
));
}
#[test]
fn dash_c_global_opt_no_longer_bypasses_clean_force() {
assert!(is_git_force("git -C /tmp/x clean -f", "clean"));
}
#[test]
fn dash_c_global_opt_no_longer_bypasses_push_to_main() {
assert!(is_git_push_to_main("git -C /tmp/x push origin main"));
}
#[test]
fn dash_c_global_opt_no_longer_bypasses_reset_hard() {
assert!(is_git_reset_hard("git -C /tmp/x reset --hard HEAD~1"));
}
#[test]
fn dash_c_legit_usage_still_allowed() {
assert!(!is_git_force("git -C /tmp/x status", "push"));
assert!(!is_git_push_to_main("git -C /tmp/x log --oneline -5"));
}
#[test]
fn unlisted_global_opt_no_longer_bypasses_push_force() {
assert!(is_git_force(
"git --super-prefix /tmp/x push --force origin main",
"push"
));
}
#[test]
fn unlisted_global_opt_no_longer_bypasses_clean_force() {
assert!(is_git_force("git --super-prefix /tmp/x clean -fd", "clean"));
}
#[test]
fn quoted_subcommand_token_no_longer_bypasses() {
assert!(is_git_force(r#"git "push" --force origin main"#, "push"));
}
#[test]
fn backslash_escaped_subcommand_token_no_longer_bypasses() {
assert!(is_git_force(r"git \push --force origin main", "push"));
}
#[test]
fn quoted_force_flag_still_blocked() {
assert!(is_git_force(
r#"git push "--force" origin feature-branch"#,
"push"
));
}
#[test]
fn quoted_rm_flag_token_no_longer_bypasses() {
assert!(is_rm_rf(r#"rm "-rf" /tmp/x"#));
}
#[test]
fn ifs_spliced_subcommand_denied_outright() {
assert!(has_adjacent_variable_splice(
"git${IFS}push --force origin main"
));
}
#[test]
fn ifs_spliced_rm_flag_denied_outright() {
assert!(has_adjacent_variable_splice("rm${IFS}-rf /tmp/x"));
}
#[test]
fn env_var_prefixed_git_command_not_flagged_as_splice() {
assert!(!has_adjacent_variable_splice(
"GIT_AUTHOR_NAME=x git commit -m test"
));
}
#[test]
fn unrelated_adjacent_splice_without_git_or_rm_allowed() {
assert!(!has_adjacent_variable_splice("echo a${b}c"));
}
#[test]
fn ansi_c_quoted_subcommand_no_longer_bypasses() {
assert!(is_git_force("git $'push' --force origin main", "push"));
}
#[test]
fn ansi_c_quoted_force_flag_still_blocked() {
assert!(is_git_force(
"git push $'--force' origin feature-branch",
"push"
));
}
#[test]
fn brace_expansion_alongside_rm_denied_outright() {
assert!(has_brace_expansion("rm -{rf,} /tmp/x"));
}
#[test]
fn unrelated_brace_expansion_without_git_or_rm_allowed() {
assert!(!has_brace_expansion("echo file.{js,ts}"));
}
#[test]
fn reset_hard_still_blocked_without_global_opt() {
assert!(is_git_reset_hard("git reset --hard"));
assert!(!is_git_reset_hard("git reset"));
assert!(!is_git_reset_hard("git reset --soft HEAD~1"));
}
#[test]
fn push_to_main_still_blocked_without_global_opt() {
assert!(is_git_push_to_main("git push origin main"));
assert!(is_git_push_to_main("git push master"));
assert!(!is_git_push_to_main("git push origin feature-branch"));
}
fn does_not_panic(f: impl FnOnce() -> bool + std::panic::UnwindSafe) -> bool {
std::panic::catch_unwind(f).unwrap_or_else(|_| panic!("guard function panicked"))
}
#[test]
fn vietnamese_text_in_benign_command_does_not_panic() {
assert!(!does_not_panic(|| is_rm_rf("echo \"xin chào thế giới\"")));
assert!(!does_not_panic(|| is_git_force(
"git commit -m \"sửa lỗi\"",
"push"
)));
}
#[test]
fn em_dash_in_git_commit_message_does_not_panic() {
assert!(does_not_panic(|| is_git_push_to_main(
"git commit -m \"note — done\" && git push origin main"
)));
}
#[test]
fn destructive_command_with_vietnamese_text_still_denied() {
assert!(does_not_panic(|| is_rm_rf(
"rm -rf /tmp/x # xóa thư mục tạm"
)));
}
#[test]
fn cjk_and_emoji_in_command_does_not_panic() {
assert!(!does_not_panic(|| is_rm_rf("echo \"你好 🎉\"")));
}
#[test]
fn malformed_json_is_rejected_not_silently_defaulted() {
let result: Result<HookEvent, _> = serde_json::from_str("not valid json{{{");
assert!(result.is_err());
}
#[test]
fn empty_json_object_parses_to_empty_command() {
let event: HookEvent = serde_json::from_str("{}").unwrap();
assert!(event.tool_input.get("command").is_none());
assert_eq!(event.tool_name, "");
}
#[test]
fn hook_event_parses_tool_name_and_arbitrary_tool_input_shape() {
let event: HookEvent =
serde_json::from_str(r#"{"tool_name":"mcp__x__y","tool_input":{"cmd":"ls"}}"#).unwrap();
assert_eq!(event.tool_name, "mcp__x__y");
assert_eq!(
event.tool_input.get("cmd").and_then(|v| v.as_str()),
Some("ls")
);
}
#[test]
fn tokenize_key_splits_snake_case() {
assert_eq!(tokenize_key("shell_command"), vec!["shell", "command"]);
}
#[test]
fn tokenize_key_splits_camel_case() {
assert_eq!(tokenize_key("executeScript"), vec!["execute", "script"]);
assert_eq!(tokenize_key("shellCommand"), vec!["shell", "command"]);
}
#[test]
fn tokenize_key_single_word_stays_one_token() {
assert_eq!(tokenize_key("description"), vec!["description"]);
assert_eq!(tokenize_key("command"), vec!["command"]);
}
#[test]
fn is_command_like_key_matches_exact_tokens_only() {
assert!(is_command_like_key("command"));
assert!(is_command_like_key("cmd"));
assert!(is_command_like_key("shell_command"));
assert!(is_command_like_key("executeScript"));
assert!(is_command_like_key("params_script"));
}
#[test]
fn is_command_like_key_rejects_substring_false_positives() {
assert!(!is_command_like_key("description"));
assert!(!is_command_like_key("content"));
assert!(!is_command_like_key("message"));
assert!(!is_command_like_key("prompt"));
assert!(!is_command_like_key("recommendation")); }
#[test]
fn collect_command_like_strings_finds_nested_value_ignores_sibling_prose() {
let v = serde_json::json!({
"description": "never run rm -rf in prod",
"params": { "command": "rm -rf /tmp/x" }
});
let mut out = Vec::new();
collect_command_like_strings(&v, &mut out);
assert_eq!(out, vec!["rm -rf /tmp/x".to_string()]);
}
#[test]
fn collect_command_like_strings_finds_camel_case_key_at_top_level() {
let v = serde_json::json!({ "shellCommand": "git push --force origin main" });
let mut out = Vec::new();
collect_command_like_strings(&v, &mut out);
assert_eq!(out, vec!["git push --force origin main".to_string()]);
}
#[test]
fn collect_command_like_strings_descends_into_arrays() {
let v = serde_json::json!({ "steps": [ { "cmd": "rm -rf /tmp/y" } ] });
let mut out = Vec::new();
collect_command_like_strings(&v, &mut out);
assert_eq!(out, vec!["rm -rf /tmp/y".to_string()]);
}
#[test]
fn check_command_denies_rm_rf_regardless_of_source() {
assert!(check_command("rm -rf /tmp/x").is_some());
}
#[test]
fn tokenize_key_splits_acronym_to_word_boundary() {
assert_eq!(tokenize_key("SQLCommand"), vec!["sql", "command"]);
assert_eq!(tokenize_key("URLExecScript"), vec!["url", "exec", "script"]);
}
#[test]
fn tokenize_key_ordinary_camel_case_unaffected_by_acronym_fix() {
assert_eq!(tokenize_key("shellCommand"), vec!["shell", "command"]);
assert_eq!(tokenize_key("executeScript"), vec!["execute", "script"]);
}
#[test]
fn is_command_like_key_matches_acronym_prefixed_key() {
assert!(is_command_like_key("SQLCommand"));
}
#[test]
fn collect_command_like_strings_finds_value_under_acronym_prefixed_key() {
let v = serde_json::json!({ "SQLCommand": "DROP TABLE users;" });
let mut out = Vec::new();
collect_command_like_strings(&v, &mut out);
assert_eq!(out, vec!["DROP TABLE users;".to_string()]);
}
#[test]
fn collect_command_like_strings_extracts_array_of_strings_under_plural_key() {
let v = serde_json::json!({ "commands": ["rm -rf /tmp/x", "echo ok"] });
let mut out = Vec::new();
collect_command_like_strings(&v, &mut out);
assert_eq!(
out,
vec!["rm -rf /tmp/x".to_string(), "echo ok".to_string()]
);
}
#[test]
fn collect_command_like_strings_array_of_objects_still_works_after_array_fix() {
let v = serde_json::json!({ "steps": [ { "cmd": "rm -rf /tmp/y" } ] });
let mut out = Vec::new();
collect_command_like_strings(&v, &mut out);
assert_eq!(out, vec!["rm -rf /tmp/y".to_string()]);
}
#[test]
fn collect_command_like_strings_respects_max_depth() {
let mut v = serde_json::json!({ "command": "rm -rf /tmp/deep" });
for _ in 0..(MAX_COLLECT_DEPTH + 10) {
v = serde_json::json!({ "wrapper": v });
}
let mut out = Vec::new();
collect_command_like_strings(&v, &mut out);
assert!(
out.is_empty(),
"value past MAX_COLLECT_DEPTH must not be collected"
);
}
#[test]
fn collect_command_like_strings_within_max_depth_still_found() {
let mut v = serde_json::json!({ "command": "rm -rf /tmp/shallow" });
for _ in 0..(MAX_COLLECT_DEPTH - 5) {
v = serde_json::json!({ "wrapper": v });
}
let mut out = Vec::new();
collect_command_like_strings(&v, &mut out);
assert_eq!(out, vec!["rm -rf /tmp/shallow".to_string()]);
}
#[test]
fn python_c_rm_rf_bypass_now_blocked() {
assert!(check_command("python3 -c \"import os; os.system('rm -rf /tmp/x')\"").is_some());
assert!(check_command("python -c \"import os; os.system('rm -rf /tmp/x')\"").is_some());
}
#[test]
fn node_e_rm_rf_bypass_now_blocked() {
assert!(
check_command("node -e \"require('child_process').execSync('rm -rf /tmp/x')\"")
.is_some()
);
}
#[test]
fn ruby_e_and_perl_e_rm_rf_bypass_now_blocked() {
assert!(check_command("ruby -e \"system('rm -rf /tmp/x')\"").is_some());
assert!(check_command("perl -e \"system('rm -rf /tmp/x')\"").is_some());
}
#[test]
fn python_c_drop_table_bypass_now_blocked() {
assert!(check_command("python3 -c \"cursor.execute('DROP TABLE users')\"").is_some());
}
#[test]
fn python_c_git_force_push_bypass_now_blocked() {
assert!(
check_command("python3 -c \"os.system('git push --force origin main')\"").is_some()
);
}
#[test]
fn python_c_git_reset_hard_bypass_now_blocked() {
assert!(check_command("python3 -c \"os.system('git reset --hard HEAD~5')\"").is_some());
}
#[test]
fn benign_inline_scripts_not_blocked() {
assert!(check_command("python3 -c \"print('hello world')\"").is_none());
assert!(check_command("python3 -c \"import json; print(json.dumps({'a': 1}))\"").is_none());
assert!(check_command("node -e \"console.log('hi')\"").is_none());
assert!(check_command("python3 -c \"print('please remove the file manually')\"").is_none());
assert!(
check_command("python3 -c \"import os.path; print(os.path.exists('/tmp'))\"").is_none()
);
}
#[test]
fn interpreter_without_inline_flag_not_affected() {
assert!(check_command("python3 script.py").is_none());
assert!(check_command("node index.js").is_none());
}
#[test]
fn capitalized_interpreter_name_no_longer_bypasses() {
assert!(check_command("Python3 -c \"import os; os.system('rm -rf /tmp/x')\"").is_some());
}
#[test]
fn capitalized_inner_payload_no_longer_bypasses() {
assert!(check_command("python3 -c \"import os; os.system('RM -RF /tmp/x')\"").is_some());
}
#[test]
fn bash_c_and_sh_c_inline_rm_rf_now_blocked() {
assert!(check_command("bash -c \"rm -rf /tmp/x\"").is_some());
assert!(check_command("sh -c \"rm -rf /tmp/x\"").is_some());
}
#[test]
fn git_clean_force_inside_interpreter_now_blocked() {
assert!(check_command("python3 -c \"import os; os.system('git clean -fdx')\"").is_some());
}
}