use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fs::File;
use std::io::{Read, Seek, SeekFrom};
use std::path::Path;
pub const BREAKER_RULE: &str = "circuit-breaker";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Intent {
FileDelete,
DbDestroy,
GitDestructive,
InfraDestroy,
FileOverwrite,
}
impl Intent {
pub fn label(&self) -> &'static str {
match self {
Intent::FileDelete => "file-delete",
Intent::DbDestroy => "db-destroy",
Intent::GitDestructive => "git-destructive",
Intent::InfraDestroy => "infra-destroy",
Intent::FileOverwrite => "file-overwrite",
}
}
fn rank(&self) -> u8 {
match self {
Intent::DbDestroy => 4,
Intent::InfraDestroy => 3,
Intent::FileDelete => 2,
Intent::FileOverwrite => 2,
Intent::GitDestructive => 1,
}
}
}
pub fn classify_command(command: &str) -> Option<Intent> {
crate::shell::split_segments_deep(command)
.iter()
.filter_map(classify_segment)
.max_by_key(|i| i.rank())
}
fn overwrite_intent(segment: &crate::shell::Segment) -> Option<Intent> {
segment
.redirects
.iter()
.any(|o| o.truncates)
.then_some(Intent::FileOverwrite)
}
fn classify_segment(segment: &crate::shell::Segment) -> Option<Intent> {
let base = classify_segment_named(segment);
let ow = overwrite_intent(segment);
match (base, ow) {
(Some(b), Some(o)) => Some(if b.rank() >= o.rank() { b } else { o }),
(b, o) => b.or(o),
}
}
fn classify_segment_named(segment: &str) -> Option<Intent> {
let toks = tokens(segment);
if toks.is_empty() {
return None;
}
let (head, at) = crate::delete::resolve_head(&toks)?;
let first = head.as_str();
let lc: Vec<String> = toks[at..].iter().map(|t| t.to_ascii_lowercase()).collect();
let delete_cmds = ["rm", "ri", "del", "erase", "rd", "rmdir", "remove-item"];
if delete_cmds.contains(&first) {
let recursive = lc.iter().skip(1).any(|t| {
t == "-recurse"
|| t == "/s"
|| (t.starts_with('-') && !t.starts_with("--") && t.contains('r'))
});
let force = lc.iter().skip(1).any(|t| {
t == "-force"
|| t == "/q"
|| t == "--force"
|| (t.starts_with('-') && !t.starts_with("--") && t.contains('f'))
});
if recursive || force {
return Some(Intent::FileDelete);
}
}
if first == "find" {
if lc.iter().any(|t| t == "-delete") {
return Some(Intent::FileDelete);
}
let exec_flags = ["-exec", "-execdir", "-ok", "-okdir"];
if lc.iter().any(|t| exec_flags.contains(&t.as_str()))
&& lc
.iter()
.any(|t| delete_cmds.contains(&t.as_str()) || t == "unlink")
{
return Some(Intent::FileDelete);
}
}
if first == "xargs"
&& lc
.iter()
.skip(1)
.any(|t| delete_cmds.contains(&t.as_str()) || t == "unlink")
{
return Some(Intent::FileDelete);
}
if first == "unlink" && toks.len() > 1 {
return Some(Intent::FileDelete);
}
if first == "shred" && lc.iter().any(|t| t == "-u" || t == "--remove") {
return Some(Intent::FileDelete);
}
if first == "git" {
let sub = lc.get(1).map(|s| s.as_str()).unwrap_or("");
let hit = match sub {
"push" => lc.iter().any(|t| {
t == "--force" || t == "-f" || t == "--force-with-lease" || t.starts_with('+')
}),
"reset" => lc.iter().any(|t| t == "--hard"),
"clean" => lc.iter().any(|t| {
t == "--force" || (t.starts_with('-') && !t.starts_with("--") && t.contains('f'))
}),
"branch" => toks.iter().any(|t| t == "-D"),
_ => false,
};
if hit {
return Some(Intent::GitDestructive);
}
}
let db_clients = ["psql", "mysql", "sqlcmd", "sqlite3", "mariadb"];
if db_clients.contains(&first) {
let upper = segment.to_ascii_uppercase();
if upper.contains("DROP TABLE")
|| upper.contains("DROP DATABASE")
|| upper.contains("DROP SCHEMA")
|| upper.contains("TRUNCATE")
{
return Some(Intent::DbDestroy);
}
if upper.contains("DELETE FROM") && !upper.contains("WHERE") {
return Some(Intent::DbDestroy);
}
}
if (first == "terraform" || first == "tofu")
&& lc.iter().any(|t| t == "destroy" || t == "-destroy")
{
return Some(Intent::InfraDestroy);
}
if first == "kubectl" && lc.get(1).map(|s| s.as_str()) == Some("delete") {
return Some(Intent::InfraDestroy);
}
None
}
pub fn recent_intent_count(
log_path: &Path,
session: &str,
intent: Intent,
max_bytes: u64,
) -> usize {
let Ok(mut f) = File::open(log_path) else {
return 0;
};
let len = match f.metadata() {
Ok(m) => m.len(),
Err(_) => return 0,
};
let start = len.saturating_sub(max_bytes);
if f.seek(SeekFrom::Start(start)).is_err() {
return 0;
}
let mut buf = String::new();
if f.read_to_string(&mut buf).is_err() {
return 0;
}
let lines = buf.lines().skip(if start > 0 { 1 } else { 0 });
let entries: Vec<serde_json::Value> = lines
.filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
.filter(|e| e["session"].as_str() == Some(session))
.collect();
let approved: HashSet<&str> = entries
.iter()
.filter(|e| e["source"].as_str() == Some("post"))
.filter_map(|e| e["command"].as_str())
.collect();
entries
.iter()
.filter(|e| e["intent"].as_str() == Some(intent.label()))
.filter(|e| intent != Intent::FileOverwrite || e["matched_rule"].as_str().is_some())
.filter(|e| match e["decision"].as_str() {
Some("deny") => true,
Some("ask") => !approved.contains(e["command"].as_str().unwrap_or("")),
_ => false,
})
.count()
}
#[derive(Debug, Clone, Copy)]
pub struct BreakerConfig {
pub enabled: bool,
pub threshold: usize,
}
impl Default for BreakerConfig {
fn default() -> Self {
BreakerConfig {
enabled: true,
threshold: 2,
}
}
}
pub fn breaker_config(policy_path: &Path) -> BreakerConfig {
let d = BreakerConfig::default();
let Ok(text) = std::fs::read_to_string(policy_path) else {
return d;
};
let Ok(v) = serde_yaml::from_str::<serde_yaml::Value>(&text) else {
return d;
};
let cb = &v["circuit_breaker"];
BreakerConfig {
enabled: cb["enabled"].as_bool().unwrap_or(d.enabled),
threshold: cb["threshold"].as_u64().unwrap_or(d.threshold as u64) as usize,
}
}
pub fn maybe_trip(
policy_path: &Path,
log_path: &Path,
session: Option<&str>,
command: &str,
) -> Option<(Intent, usize, String)> {
let intent = classify_command(command)?;
let session = session?;
let cfg = breaker_config(policy_path);
if !cfg.enabled {
return None;
}
let prior = recent_intent_count(log_path, session, intent, 64 * 1024);
if prior >= cfg.threshold {
let reason = format!(
"circuit breaker: {} prior {} attempt(s) this session — \
repeated destructive intent, denying variant #{}",
prior,
intent.label(),
prior + 1
);
return Some((intent, prior, reason));
}
None
}
pub fn tokens(segment: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut quote: Option<char> = None;
for c in segment.chars() {
match quote {
Some(q) => {
if c == q {
quote = None;
} else {
cur.push(c);
}
}
None => match c {
'"' | '\'' => quote = Some(c),
c if c.is_whitespace() => {
if !cur.is_empty() {
out.push(std::mem::take(&mut cur));
}
}
_ => cur.push(c),
},
}
}
if !cur.is_empty() {
out.push(cur);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::TempTree;
#[test]
fn a_truncating_redirect_is_a_destructive_intent() {
for cmd in [
"cat /dev/null > .env",
"echo '' > config.json",
"ls -la > /etc/hosts",
"grep -r . / > /tmp/exfil",
"cmd >| forced.txt",
] {
assert_eq!(
classify_command(cmd),
Some(Intent::FileOverwrite),
"{cmd} destroys a file by writing over it"
);
}
}
#[test]
fn git_clean_counts_both_spellings_of_force() {
for cmd in ["git clean -f", "git clean --force", "git clean -fdx"] {
assert_eq!(
classify_command(cmd),
Some(Intent::GitDestructive),
"{cmd}: force is force in either spelling"
);
}
for cmd in [
"git clean -n",
"git clean --dry-run",
"git clean -d",
"git clean --interactive",
] {
assert_eq!(classify_command(cmd), None, "{cmd} deletes nothing");
}
}
#[test]
fn a_posix_shell_does_not_hide_the_command_it_runs() {
assert_eq!(
classify_command(r#"sh -c "cat /dev/null > src/main.rs""#),
Some(Intent::FileOverwrite)
);
assert_eq!(
classify_command(r#"bash -lc "rm -rf ./dist""#),
Some(Intent::FileDelete)
);
assert_eq!(
classify_command(r#"sudo sh -c "psql -d shop -c 'DROP TABLE users'""#),
Some(Intent::DbDestroy)
);
assert_eq!(classify_command(r#"sh -c "ls -la""#), None);
assert_eq!(
classify_command("sh clean.sh"),
None,
"a script file is not read"
);
}
#[test]
fn a_wrapper_does_not_hide_the_command_it_runs() {
for cmd in [
"sudo rm -rf ./dist",
"doas rm -rf /",
"env rm -rf ./dist",
"env FOO=1 rm -rf ./dist",
"sudo -u alice rm -rf ./dist",
"nice -n 10 rm -rf x",
"nohup rm -rf x",
"command rm -rf x",
] {
assert_eq!(
classify_command(cmd),
Some(Intent::FileDelete),
"{cmd}: the wrapper must not hide the delete"
);
}
assert_eq!(
classify_command("sudo terraform destroy -auto-approve"),
Some(Intent::InfraDestroy),
);
for cmd in ["sudo", "sudo -u alice", "env FOO=1", "echo sudo rm -rf x"] {
assert_eq!(classify_command(cmd), None, "{cmd} must not classify");
}
}
#[test]
fn a_path_qualified_command_is_the_command_it_names() {
for cmd in [
"/bin/rm -rf ./dist",
"/usr/bin/rm -rf ./dist",
"C:\\Windows\\System32\\del.exe /s /q x",
] {
assert_eq!(
classify_command(cmd),
Some(Intent::FileDelete),
"{cmd}: a path-qualified delete is still a delete"
);
}
assert_eq!(classify_command("/bin/ls -la"), None);
}
#[test]
fn appends_descriptors_and_sinks_are_not_destructive() {
for cmd in [
"echo entry >> app.log",
"make 2>&1",
"cmd >&2",
"npm run build &> build.log",
"cargo test > /dev/null",
"cmd 2> /dev/null",
"make >/dev/null 2>&1",
] {
assert_eq!(classify_command(cmd), None, "{cmd} must not classify");
}
}
#[test]
fn a_redirect_never_downgrades_a_higher_intent() {
assert_eq!(
classify_command("psql -c \"DROP TABLE users\" > out.sql"),
Some(Intent::DbDestroy),
"db-destroy (4) must not demote to file-overwrite (2)"
);
assert_eq!(
classify_command("terraform destroy -auto-approve > tf.log"),
Some(Intent::InfraDestroy)
);
assert_eq!(
classify_command("rm -rf build > rm.log"),
Some(Intent::FileDelete),
"equal ranks: the command-name classification wins the tie"
);
}
#[test]
fn only_gated_overwrites_accumulate_breaker_pressure() {
let tmp = TempTree::new("intent-ow");
let ungated = write_log(
&tmp,
&[
entry_with_rule(
"s1",
"ask",
"file-overwrite",
"cargo build > build.log",
None,
),
entry_with_rule("s1", "ask", "file-overwrite", "cargo test > test.log", None),
],
);
assert_eq!(
recent_intent_count(&ungated, "s1", Intent::FileOverwrite, 64 * 1024),
0,
"default-ask overwrites are the policy having no opinion"
);
let tmp2 = TempTree::new("intent-ow2");
let gated = write_log(
&tmp2,
&[
entry_with_rule(
"s1",
"deny",
"file-overwrite",
"cat /dev/null > .env",
Some("*> .env*"),
),
entry_with_rule(
"s1",
"deny",
"file-overwrite",
"echo '' > .env",
Some("*> .env*"),
),
entry_with_rule(
"s1",
"deny",
"file-overwrite",
"true > .env",
Some("*> .env*"),
),
],
);
assert_eq!(
recent_intent_count(&gated, "s1", Intent::FileOverwrite, 64 * 1024),
3,
"gated overwrite attempts count in full"
);
}
#[test]
fn an_overwrite_is_found_in_a_compound() {
assert_eq!(
classify_command("git status && echo x > .env"),
Some(Intent::FileOverwrite)
);
}
#[test]
fn classifies_unix_rm() {
assert_eq!(classify_command("rm -rf ."), Some(Intent::FileDelete));
assert_eq!(classify_command("rm -fr /tmp/x"), Some(Intent::FileDelete));
assert_eq!(classify_command("rm notes.txt"), None);
}
#[test]
fn classifies_powershell_delete_variants() {
assert_eq!(
classify_command("Remove-Item -Recurse -Force ."),
Some(Intent::FileDelete)
);
assert_eq!(classify_command("del /s /q ."), Some(Intent::FileDelete));
assert_eq!(classify_command("rd /s /q build"), Some(Intent::FileDelete));
assert_eq!(
classify_command("Get-ChildItem -Force . | Remove-Item -Recurse -Force"),
Some(Intent::FileDelete)
);
}
#[test]
fn classifies_delete_indirection_find_and_xargs() {
assert_eq!(
classify_command("find . -mindepth 1 -maxdepth 1 -exec rm -rf {} +"),
Some(Intent::FileDelete)
);
assert_eq!(
classify_command("find /tmp/cache -type f -delete"),
Some(Intent::FileDelete)
);
assert_eq!(
classify_command("find . -name '*.log' -execdir rm {} ;"),
Some(Intent::FileDelete)
);
assert_eq!(
classify_command("find . -name '*.tmp' | xargs rm -f"),
Some(Intent::FileDelete)
);
assert_eq!(classify_command("xargs rm -rf"), Some(Intent::FileDelete));
assert_eq!(
classify_command("unlink important.db"),
Some(Intent::FileDelete)
);
assert_eq!(
classify_command("shred -u secret.key"),
Some(Intent::FileDelete)
);
assert_eq!(classify_command("find . -name '*.rs' -print"), None);
assert_eq!(classify_command("find . -type d"), None);
assert_eq!(classify_command("find . -name '*.rs' | xargs wc -l"), None);
assert_eq!(classify_command("unlink"), None);
}
#[test]
fn classifies_compound_by_most_dangerous_segment() {
assert_eq!(
classify_command(
"cd \"c:\\Users\\User\\code\\cursor-test3\" && rm -rf .cursor .git .termaxa"
),
Some(Intent::FileDelete)
);
assert_eq!(
classify_command("git status && rm -rf /"),
Some(Intent::FileDelete)
);
}
#[test]
fn classifies_git_destructive() {
assert_eq!(
classify_command("git push --force origin main"),
Some(Intent::GitDestructive)
);
assert_eq!(
classify_command("git reset --hard HEAD~3"),
Some(Intent::GitDestructive)
);
assert_eq!(
classify_command("git clean -fd"),
Some(Intent::GitDestructive)
);
assert_eq!(classify_command("git status"), None);
assert_eq!(classify_command("git push origin main"), None);
assert_eq!(classify_command("git branch -d feature"), None);
assert_eq!(
classify_command("git branch -D feature"),
Some(Intent::GitDestructive)
);
}
#[test]
fn a_plus_refspec_is_a_force_push() {
for cmd in [
"git push origin +main",
"git push origin +main:main",
"git push origin +refs/heads/main:refs/heads/main",
"git push origin +HEAD:production",
] {
assert_eq!(classify_command(cmd), Some(Intent::GitDestructive), "{cmd}");
}
assert_eq!(classify_command("git push origin main:main"), None);
assert_eq!(classify_command("git log --format=+%h"), None);
}
#[test]
fn classifies_db_destroy() {
assert_eq!(
classify_command(r#"psql -c "DROP TABLE users CASCADE""#),
Some(Intent::DbDestroy)
);
assert_eq!(
classify_command(r#"psql -c "TRUNCATE audit_log""#),
Some(Intent::DbDestroy)
);
assert_eq!(
classify_command(r#"psql -c "DELETE FROM users""#),
Some(Intent::DbDestroy)
);
assert_eq!(
classify_command(r#"psql -c "DELETE FROM users WHERE id = 5""#),
None
);
assert_eq!(classify_command("DROP TABLE users"), None);
}
#[test]
fn classifies_infra_destroy() {
assert_eq!(
classify_command("terraform destroy -auto-approve"),
Some(Intent::InfraDestroy)
);
assert_eq!(classify_command("tofu destroy"), Some(Intent::InfraDestroy));
assert_eq!(
classify_command("kubectl delete deployment api"),
Some(Intent::InfraDestroy)
);
assert_eq!(classify_command("terraform plan"), None);
}
#[test]
fn severity_ordering_on_mixed_compound() {
assert_eq!(
classify_command(r#"rm -rf ./cache && psql -c "TRUNCATE users""#),
Some(Intent::DbDestroy)
);
}
fn write_log(tmp: &TempTree, lines: &[serde_json::Value]) -> std::path::PathBuf {
let mut body = String::new();
for l in lines {
body.push_str(&format!("{}\n", l));
}
tmp.file("audit.jsonl", &body)
}
fn entry_with_rule(
session: &str,
decision: &str,
intent: &str,
command: &str,
matched_rule: Option<&str>,
) -> serde_json::Value {
let mut e = entry(session, decision, intent, command);
if let Some(r) = matched_rule {
e["matched_rule"] = serde_json::json!(r);
}
e
}
fn entry(session: &str, decision: &str, intent: &str, command: &str) -> serde_json::Value {
serde_json::json!({
"ts": "2026-07-09T00:00:00Z",
"source": "hook",
"session": session,
"decision": decision,
"intent": intent,
"command": command,
})
}
#[test]
fn counts_asks_and_denies_for_same_session_and_intent() {
let tmp = TempTree::new("intent");
let log = write_log(
&tmp,
&[
entry("s1", "ask", "file-delete", "rm -rf ."),
entry("s1", "ask", "file-delete", "Remove-Item -Recurse -Force ."),
entry("s1", "allow", "file-delete", "rm -rf /tmp/scratch"),
],
);
assert_eq!(
recent_intent_count(&log, "s1", Intent::FileDelete, 64 * 1024),
2
);
}
#[test]
fn session_isolation() {
let tmp = TempTree::new("intent");
let log = write_log(
&tmp,
&[
entry("s1", "ask", "file-delete", "rm -rf ."),
entry("s1", "deny", "file-delete", "del /s /q ."),
],
);
assert_eq!(
recent_intent_count(&log, "s2", Intent::FileDelete, 64 * 1024),
0
);
}
#[test]
fn intent_isolation() {
let tmp = TempTree::new("intent");
let log = write_log(
&tmp,
&[
entry("s1", "ask", "file-delete", "rm -rf ."),
entry("s1", "ask", "file-delete", "del /s /q ."),
],
);
assert_eq!(
recent_intent_count(&log, "s1", Intent::GitDestructive, 64 * 1024),
0
);
}
#[test]
fn approved_ask_is_excluded() {
let tmp = TempTree::new("intent");
let log = write_log(
&tmp,
&[
entry("s1", "ask", "file-delete", "rm -rf ./node_modules"),
entry("s1", "executed", "file-delete", "rm -rf ./node_modules"),
],
);
let text = std::fs::read_to_string(&log).unwrap();
let patched: Vec<String> = text
.lines()
.map(|l| {
if l.contains("executed") {
l.replace("\"source\":\"hook\"", "\"source\":\"post\"")
} else {
l.to_string()
}
})
.collect();
std::fs::write(&log, patched.join("\n") + "\n").unwrap();
assert_eq!(
recent_intent_count(&log, "s1", Intent::FileDelete, 64 * 1024),
0
);
}
#[test]
fn old_log_lines_without_intent_are_ignored_not_fatal() {
let tmp = TempTree::new("intent");
let log = write_log(
&tmp,
&[
serde_json::json!({
"ts": "2026-01-01T00:00:00Z", "source": "hook",
"session": "s1", "decision": "ask", "command": "rm -rf ."
}),
entry("s1", "ask", "file-delete", "del /s /q ."),
],
);
assert_eq!(
recent_intent_count(&log, "s1", Intent::FileDelete, 64 * 1024),
1
);
}
#[test]
fn missing_log_fails_open() {
let tmp = TempTree::new("intent-ghost");
let ghost = tmp.absent("no-such-dir/audit.jsonl");
assert_eq!(
recent_intent_count(&ghost, "s1", Intent::FileDelete, 64 * 1024),
0
);
}
#[test]
fn breaker_trips_on_third_variant() {
let tmp = TempTree::new("intent");
let log = write_log(
&tmp,
&[
entry("s1", "ask", "file-delete", "rm -rf ."),
entry("s1", "ask", "file-delete", "Remove-Item -Recurse -Force ."),
],
);
let policy = tmp.file("policy.yaml", "version: 1\ndefault: ask\nrules: []\n");
let tripped = maybe_trip(&policy, &log, Some("s1"), "del /s /q .");
assert!(
tripped.is_some(),
"third delete variant must trip the breaker"
);
let (intent, prior, reason) = tripped.unwrap();
assert_eq!(intent, Intent::FileDelete);
assert_eq!(prior, 2);
assert!(reason.contains("circuit breaker"));
assert!(maybe_trip(&policy, &log, Some("s1"), "git status").is_none());
assert!(maybe_trip(&policy, &log, None, "del /s /q .").is_none());
}
#[test]
fn breaker_respects_config() {
let tmp = TempTree::new("intent");
let log = write_log(
&tmp,
&[
entry("s1", "ask", "file-delete", "rm -rf ."),
entry("s1", "ask", "file-delete", "del /s /q ."),
],
);
let policy = tmp.file(
"policy.yaml",
"version: 1\ndefault: ask\nrules: []\ncircuit_breaker:\n enabled: false\n",
);
assert!(maybe_trip(&policy, &log, Some("s1"), "rd /s /q .").is_none());
std::fs::write(
&policy,
"version: 1\ndefault: ask\nrules: []\ncircuit_breaker:\n threshold: 5\n",
)
.unwrap();
assert!(maybe_trip(&policy, &log, Some("s1"), "rd /s /q .").is_none());
}
#[test]
fn config_defaults_when_block_missing_or_file_absent() {
let tmp = TempTree::new("intent-nopolicy");
let policy = tmp.absent("policy.yaml");
let c = breaker_config(&policy);
assert!(c.enabled);
assert_eq!(c.threshold, 2);
}
#[test]
fn the_most_severe_intent_in_a_compound_is_the_one_reported() {
assert_eq!(
classify_command(r#"psql -c "DROP TABLE users" && rm -rf ./build"#),
Some(Intent::DbDestroy)
);
}
#[test]
fn a_delete_counts_when_it_is_recursive_or_forced_in_any_dialect() {
for command in [
"rm -r ./dir",
"rm -f notes.txt",
"rm -rf ./dir",
"rm --force notes.txt",
"del /s C:\\tmp",
"del /q C:\\tmp",
"Remove-Item -Recurse ./dir",
"Remove-Item -Force notes.txt",
] {
assert_eq!(
classify_command(command),
Some(Intent::FileDelete),
"{command}"
);
}
assert_eq!(classify_command("rm notes.txt"), None);
}
#[test]
fn a_long_flag_that_merely_contains_the_letter_is_not_the_short_flag() {
assert_eq!(classify_command("rm --verbose notes.txt"), None);
assert_eq!(classify_command("rm --preserve-root notes.txt"), None);
assert_eq!(classify_command("rm --one-file-system ./dir"), None);
}
#[test]
fn find_and_xargs_count_only_when_they_invoke_a_delete() {
for command in [
"find . -delete",
"find . -mindepth 1 -exec rm -rf {} +",
"find . -execdir unlink {} ;",
"xargs rm -rf",
] {
assert_eq!(
classify_command(command),
Some(Intent::FileDelete),
"{command}"
);
}
assert_eq!(classify_command("find . -exec ls {} +"), None);
assert_eq!(classify_command("xargs ls -la"), None);
}
#[test]
fn shred_counts_only_when_it_also_removes() {
assert_eq!(
classify_command("shred -u secrets.txt"),
Some(Intent::FileDelete)
);
assert_eq!(
classify_command("shred --remove secrets.txt"),
Some(Intent::FileDelete)
);
assert_eq!(classify_command("shred secrets.txt"), None);
assert_eq!(classify_command("ls -u"), None);
}
#[test]
fn every_git_spelling_that_destroys_history_is_recognised() {
for command in [
"git push --force origin main",
"git push -f origin main",
"git push --force-with-lease origin main",
"git push origin +main",
"git reset --hard HEAD~3",
"git branch -D feature",
] {
assert_eq!(
classify_command(command),
Some(Intent::GitDestructive),
"{command}"
);
}
for benign in [
"git push origin main",
"git reset --soft HEAD~1",
"git branch -d merged-feature",
] {
assert_eq!(classify_command(benign), None, "{benign}");
}
}
#[test]
fn git_clean_was_a_known_gap_and_is_now_closed() {
assert_eq!(
classify_command("git clean -f"),
Some(Intent::GitDestructive)
);
assert_eq!(
classify_command("git clean -fd"),
Some(Intent::GitDestructive)
);
assert_eq!(
classify_command("git clean --force"),
Some(Intent::GitDestructive),
"the pinned gap: both spellings delete the same untracked files"
);
assert_eq!(classify_command("git clean -n"), None);
}
#[test]
fn destructive_sql_is_recognised_through_a_db_client() {
for sql in [
"DROP TABLE users",
"DROP DATABASE shop",
"DROP SCHEMA public CASCADE",
"TRUNCATE users",
"DELETE FROM users",
] {
assert_eq!(
classify_command(&format!(r#"psql -d shop -c "{sql}""#)),
Some(Intent::DbDestroy),
"{sql}"
);
}
assert_eq!(
classify_command(r#"psql -d shop -c "DELETE FROM users WHERE id = 1""#),
None
);
assert_eq!(classify_command(r#"psql -d shop -c "SELECT 1""#), None);
}
#[test]
fn infrastructure_teardown_is_recognised_by_its_verb() {
for command in [
"terraform destroy",
"tofu destroy",
"kubectl delete pod web",
] {
assert_eq!(
classify_command(command),
Some(Intent::InfraDestroy),
"{command}"
);
}
for benign in ["terraform plan", "kubectl get pods"] {
assert_eq!(classify_command(benign), None, "{benign}");
}
}
fn padded_log(dir: &std::path::Path, n: usize) -> std::path::PathBuf {
const LINE: usize = 1024;
let path = dir.join("audit.jsonl");
let mut out = String::new();
for i in 0..n {
let mut entry = serde_json::json!({
"session": "sess-1",
"source": "hook",
"command": format!("rm -rf ./dir{i}"),
"decision": "deny",
"intent": Intent::FileDelete.label(),
"pad": "",
});
let base = serde_json::to_string(&entry).expect("entry must serialize");
let pad = LINE - 1 - base.len();
entry["pad"] = serde_json::Value::String("x".repeat(pad));
let line = serde_json::to_string(&entry).expect("entry must serialize");
assert_eq!(
line.len(),
LINE - 1,
"every line must be exactly {LINE} bytes"
);
out.push_str(&line);
out.push('\n');
}
std::fs::write(&path, out).expect("log must be writable");
path
}
#[test]
fn the_window_reads_back_the_bytes_it_promises() {
let tmp = TempTree::new("intent-window");
let log = padded_log(tmp.path(), 128);
let counted = recent_intent_count(&log, "sess-1", Intent::FileDelete, 64 * 1024);
assert_eq!(
counted, 63,
"64 KiB holds 64 lines, and the one the window lands on top of is \
dropped as possibly partial"
);
let counted = recent_intent_count(&log, "sess-1", Intent::FileDelete, 8 * 1024);
assert_eq!(counted, 7);
}
#[test]
fn the_breaker_looks_back_further_than_a_line_or_two() {
let tmp = TempTree::new("intent-trip-window");
let log = padded_log(tmp.path(), 128);
let policy = tmp.file(
"policy.yaml",
"version: 1\ndefault: ask\nrules: []\ncircuit_breaker:\n enabled: true\n threshold: 5\n",
);
let (intent, prior, _reason) =
maybe_trip(&policy, &log, Some("sess-1"), "rm -rf ./another")
.expect("dozens of prior denied attempts is well past a threshold of 5");
assert_eq!(intent, Intent::FileDelete);
assert!(
prior >= 60,
"the window must reach back across the whole log, counted {prior}"
);
}
#[test]
fn a_log_smaller_than_the_window_is_read_whole() {
let tmp = TempTree::new("intent-whole");
let log = padded_log(tmp.path(), 4);
assert_eq!(
recent_intent_count(&log, "sess-1", Intent::FileDelete, 64 * 1024),
4
);
}
}