use super::blast_paths::protected_hit;
use serde::Deserialize;
use std::io::Read;
use std::path::Path;
use walkdir::WalkDir;
fn max_files() -> usize {
std::env::var("YANA_BLAST_MAX_FILES")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(50)
}
fn walk_cap() -> usize {
std::env::var("YANA_BLAST_WALK_CAP")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(5000)
}
fn protected_prefixes() -> Vec<String> {
let mut v = vec![
"core/rules".to_string(),
"core/hooks".to_string(),
"core/gates".to_string(),
"gates".to_string(),
".git".to_string(),
"memory/L1_atomic".to_string(),
];
if let Ok(extra) = std::env::var("YANA_BLAST_PROTECTED") {
v.extend(extra.split(':').filter(|s| !s.is_empty()).map(String::from));
}
v
}
#[derive(Deserialize, Default)]
struct ToolInput {
command: Option<String>,
}
#[derive(Deserialize, Default)]
struct HookEvent {
#[serde(default)]
tool_input: ToolInput,
}
fn deny_json(reason: &str) -> i32 {
let out = serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason
}
});
println!("{out}");
2
}
pub fn cmd_blast_radius() -> i32 {
let mut buf = String::new();
if std::io::stdin().read_to_string(&mut buf).is_err() {
return deny_json(
"Blocked: blast-radius guard could not read the tool-call payload from stdin. \
Failing closed rather than allowing an unmeasured command through.",
);
}
let event: HookEvent = serde_json::from_str(&buf).unwrap_or_default();
let command = event.tool_input.command.unwrap_or_default();
if command.trim().is_empty() {
return 0;
}
let tokens = match shell_words::split(&command) {
Ok(t) => t,
Err(_) => {
return deny_json(
"Blocked: blast-radius guard could not tokenize this command (unbalanced quotes \
or escapes). A command that can't be parsed can't be measured — rephrase it.",
)
}
};
let targets = extract_write_targets(&tokens, &command);
if targets.is_empty() {
return 0; }
let cap = walk_cap();
let mut total_files = 0usize;
let protected = protected_prefixes();
for raw in &targets {
if let Some(hit) = protected_hit(raw, &protected) {
return deny_json(&format!(
"Blocked: command targets a protected path '{hit}'. Bulk write/delete operations \
are not allowed inside Yana AI's own safety surface (rules, hooks, gates, .git, \
L1 memory). Edit one file at a time with an explicit path, or get human approval."
));
}
let n = count_files(raw, cap);
if n >= cap {
return deny_json(&format!(
"Blocked: '{raw}' expands to at least {cap} files (walk cap hit). This command's \
blast radius is too large to verify safely. Narrow the path/glob and retry."
));
}
total_files += n;
if total_files > max_files() {
return deny_json(&format!(
"Blocked: this command would write to or delete {}+ files (limit {}). High blast \
radius regardless of which tool ('rm', 'find', 'truncate'...) is used. Split it \
into smaller targeted operations, or raise YANA_BLAST_MAX_FILES deliberately.",
total_files,
max_files()
));
}
}
0
}
fn extract_write_targets(tokens: &[String], raw_command: &str) -> Vec<String> {
let mut out = Vec::new();
if tokens.is_empty() {
return out;
}
for w in tokens.windows(2) {
if w[0] == ">" || w[0] == ">>" {
out.push(w[1].clone());
}
}
let head = tokens[0].as_str();
let is_path = |s: &str| !s.starts_with('-') && s != ">" && s != ">>";
match head {
"rm" | "shred" | "truncate" | "mv" | "dd" => {
out.extend(tokens[1..].iter().filter(|t| is_path(t)).cloned());
}
"cp" | "rsync" => {
let paths: Vec<&String> = tokens[1..].iter().filter(|t| is_path(t)).collect();
if let Some(dst) = paths.last() {
out.push((*dst).clone());
}
}
"find" => {
let destructive = raw_command.contains("-delete")
|| raw_command.contains("-exec rm")
|| raw_command.contains("-exec rm")
|| raw_command.contains("-execdir rm");
if destructive {
if let Some(root) = tokens[1..].iter().find(|t| is_path(t)) {
out.push(root.clone());
}
}
}
"git" if tokens.get(1).map(|s| s == "clean").unwrap_or(false) => {
out.push(".".to_string());
}
_ => {}
}
out
}
fn count_files(raw: &str, cap: usize) -> usize {
if raw.contains('*') || raw.contains('?') || raw.contains('[') {
if let Ok(paths) = glob::glob(raw) {
let mut n = 0;
for entry in paths.flatten() {
n += count_path(&entry, cap - n.min(cap));
if n >= cap {
return cap;
}
}
return n;
}
}
count_path(Path::new(raw), cap)
}
fn count_path(p: &Path, cap: usize) -> usize {
if !p.exists() {
return 0;
}
if p.is_file() {
return 1;
}
let mut n = 0;
for entry in WalkDir::new(p).into_iter().filter_map(|e| e.ok()) {
if entry.file_type().is_file() {
n += 1;
if n >= cap {
return cap;
}
}
}
n
}
#[cfg(test)]
mod tests {
use super::*;
fn targets(cmd: &str) -> Vec<String> {
let toks = shell_words::split(cmd).unwrap();
extract_write_targets(&toks, cmd)
}
#[test]
fn detects_find_delete_that_regex_guard_misses() {
assert_eq!(targets("find . -delete"), vec!["."]);
assert_eq!(targets("find ./build -exec rm {} +"), vec!["./build"]);
}
#[test]
fn detects_redirection_truncate() {
assert_eq!(targets("echo x > important.db"), vec!["important.db"]);
assert_eq!(targets("cat a >> log.txt"), vec!["log.txt"]);
}
#[test]
fn ignores_read_only_commands() {
assert!(targets("grep -r foo .").is_empty());
assert!(targets("cat ./big.log").is_empty());
assert!(targets("ls -la /etc").is_empty());
}
#[test]
fn rm_collects_every_path_operand() {
assert_eq!(targets("rm a b c"), vec!["a", "b", "c"]);
assert_eq!(targets("rm -rf build"), vec!["build"]); }
}