mod blast_paths;
mod blast_radius;
mod self_mod;
mod token_budget;
use clap::Subcommand;
use serde::Deserialize;
use std::io::Read;
#[derive(Subcommand)]
pub enum GuardAction {
Destructive,
TokenBudget {
#[arg(long)]
tool: Option<String>,
},
BlastRadius,
SelfMod,
}
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(),
};
std::process::exit(code);
}
#[derive(Deserialize, Default)]
struct ToolInput {
command: Option<String>,
}
#[derive(Deserialize, Default)]
struct HookEvent {
#[serde(default)]
tool_input: ToolInput,
}
fn destructive_patterns() -> [(&'static str, &'static str); 7] {
[
(
r"(^|[;&|])\s*rm\s+-[a-zA-Z]*r[a-zA-Z]*f|rm\s+-[a-zA-Z]*f[a-zA-Z]*r",
"Blocked: 'rm -rf' is irreversible. Use targeted 'rm' with explicit paths, or ask the human to confirm first.",
),
(
r"git\s+push\s+.*--force|git\s+push\s+.*-f\b",
"Blocked: 'git push --force' is not allowed. The orchestrator pushes branches; force-pushing risks overwriting shared history.",
),
(
r"git\s+reset\s+--hard",
"Blocked: 'git reset --hard' discards uncommitted work irreversibly. Use 'git stash' or commit before resetting.",
),
(
r"git\s+clean\s+.*-f",
"Blocked: 'git clean -f' permanently deletes untracked files. Ask the human to confirm before running this.",
),
(
r"git\s+push\s+(origin\s+)?(main|master)\b",
"Blocked: direct push to main/master. Create a feature branch and open a PR instead.",
),
(
r"(?i)\b(DROP\s+(TABLE|DATABASE|SCHEMA)|TRUNCATE\s+TABLE)\b",
"Blocked: destructive SQL (DROP TABLE / TRUNCATE) detected. Database migrations must be reversible. Use ALTER/soft-delete patterns and ask the human to confirm schema drops.",
),
(
r"npm\s+publish|yarn\s+publish|pnpm\s+publish",
"Blocked: publishing to npm requires explicit human approval. Ask the human to run this command manually.",
),
]
}
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.",
);
}
let event: HookEvent = serde_json::from_str(&buf).unwrap_or_default();
let command = event.tool_input.command.unwrap_or_default();
if command.is_empty() {
return 0;
}
for (pattern, reason) in destructive_patterns() {
let re = match regex::Regex::new(pattern) {
Ok(re) => re,
Err(_) => continue, };
if re.is_match(&command) {
return deny_json(reason);
}
}
0
}