use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fs::File;
use std::io::{Read, Seek, SeekFrom};
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Intent {
FileDelete,
DbDestroy,
GitDestructive,
InfraDestroy,
}
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",
}
}
fn rank(&self) -> u8 {
match self {
Intent::DbDestroy => 4,
Intent::InfraDestroy => 3,
Intent::FileDelete => 2,
Intent::GitDestructive => 1,
}
}
}
pub fn classify_command(command: &str) -> Option<Intent> {
split_segments(command)
.iter()
.filter_map(|seg| classify_segment(seg))
.max_by_key(|i| i.rank())
}
pub fn classify_segment(segment: &str) -> Option<Intent> {
let toks = tokens(segment);
if toks.is_empty() {
return None;
}
let lc: Vec<String> = toks.iter().map(|t| t.to_ascii_lowercase()).collect();
let first = lc[0].as_str();
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"),
"reset" => lc.iter().any(|t| t == "--hard"),
"clean" => lc
.iter()
.any(|t| 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| 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
}
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
}
fn split_segments(command: &str) -> Vec<String> {
let mut segs = Vec::new();
let mut cur = String::new();
let mut quote: Option<char> = None;
let mut chars = command.chars().peekable();
while let Some(c) = chars.next() {
match quote {
Some(q) => {
cur.push(c);
if c == q {
quote = None;
}
}
None => match c {
'"' | '\'' => {
quote = Some(c);
cur.push(c);
}
'&' | '|' => {
if chars.peek() == Some(&c) {
chars.next();
}
if !cur.trim().is_empty() {
segs.push(cur.trim().to_string());
}
cur.clear();
}
';' => {
if !cur.trim().is_empty() {
segs.push(cur.trim().to_string());
}
cur.clear();
}
_ => cur.push(c),
},
}
}
if !cur.trim().is_empty() {
segs.push(cur.trim().to_string());
}
segs
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[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 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(lines: &[serde_json::Value]) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"tmx-intent-test-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("audit.jsonl");
let mut f = std::fs::File::create(&path).unwrap();
for l in lines {
writeln!(f, "{}", l).unwrap();
}
path
}
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 log = write_log(&[
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 log = write_log(&[
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 log = write_log(&[
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 log = write_log(&[
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 log = write_log(&[
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 ghost = std::env::temp_dir().join("tmx-intent-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 log = write_log(&[
entry("s1", "ask", "file-delete", "rm -rf ."),
entry("s1", "ask", "file-delete", "Remove-Item -Recurse -Force ."),
]);
let policy = std::env::temp_dir().join(format!(
"tmx-intent-pol-{}-{:?}.yaml",
std::process::id(),
std::thread::current().id()
));
std::fs::write(&policy, "version: 1\ndefault: ask\nrules: []\n").unwrap();
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 log = write_log(&[
entry("s1", "ask", "file-delete", "rm -rf ."),
entry("s1", "ask", "file-delete", "del /s /q ."),
]);
let policy = std::env::temp_dir().join(format!(
"tmx-intent-pol-off-{}-{:?}.yaml",
std::process::id(),
std::thread::current().id()
));
std::fs::write(
&policy,
"version: 1\ndefault: ask\nrules: []\ncircuit_breaker:\n enabled: false\n",
)
.unwrap();
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 policy = std::env::temp_dir().join("tmx-intent-nopolicy.yaml");
let _ = std::fs::remove_file(&policy);
let c = breaker_config(&policy);
assert!(c.enabled);
assert_eq!(c.threshold, 2);
}
}