Skip to main content

kintsugi_model/
heuristic.rs

1//! The deterministic, dependency-free scorer.
2//!
3//! Always available, sub-microsecond, and the graceful-degradation path when no
4//! GGUF model is present. It produces an honest one-line summary from the Tier-1
5//! rule id and a severity score from the class plus signal words in the command.
6//! It never sees a catastrophic command in the decision path (the daemon only
7//! scores the ambiguous band), but it can summarize one for the hold card.
8
9use kintsugi_core::{Class, ProposedCommand};
10
11use crate::{ModelOutput, Scorer};
12
13/// A fixed scoring backend with no external dependencies.
14#[derive(Debug, Default, Clone)]
15pub struct HeuristicScorer;
16
17impl HeuristicScorer {
18    pub fn new() -> Self {
19        Self
20    }
21}
22
23impl Scorer for HeuristicScorer {
24    fn name(&self) -> &str {
25        "heuristic"
26    }
27
28    fn score(&self, cmd: &ProposedCommand, class: Class, rule: &str) -> ModelOutput {
29        ModelOutput {
30            summary: summarize(&cmd.raw, class, rule),
31            risk: risk_for(&cmd.raw, class),
32        }
33    }
34}
35
36/// One plain-English sentence about the command.
37fn summarize(raw: &str, class: Class, rule: &str) -> String {
38    let prog = raw.split_whitespace().next().unwrap_or("the command");
39    let detail = friendly(rule);
40    match class {
41        Class::Safe => format!("Runs `{prog}` — a read-only or build/test command."),
42        Class::Ambiguous => {
43            if detail.is_empty() {
44                format!("Runs `{prog}`; effects are unclear, so it needs your call.")
45            } else {
46                format!("Runs `{prog}` — {detail}")
47            }
48        }
49        Class::Catastrophic => {
50            if detail.is_empty() {
51                format!("`{prog}` is destructive and may be irreversible.")
52            } else {
53                format!("{detail} This is hard or impossible to undo.")
54            }
55        }
56    }
57}
58
59/// A severity score 0..=100, anchored by class and nudged by signal words.
60fn risk_for(raw: &str, class: Class) -> u8 {
61    let base: i32 = match class {
62        Class::Safe => 5,
63        Class::Ambiguous => 45,
64        Class::Catastrophic => 95,
65    };
66    let lower = raw.to_lowercase();
67    let mut score = base;
68    // Words that signal blast radius / irreversibility.
69    for (needle, bump) in [
70        ("--force", 15),
71        ("-f", 8),
72        ("--hard", 15),
73        ("-rf", 20),
74        ("-r ", 8),
75        ("prod", 20),
76        ("production", 20),
77        ("--all", 12),
78        ("-a ", 6),
79        ("/", 4),
80        ("sudo", 10),
81        ("--no-preserve-root", 25),
82        ("drop ", 20),
83        ("delete", 12),
84        ("destroy", 20),
85    ] {
86        if lower.contains(needle) {
87            score += bump;
88        }
89    }
90    score.clamp(0, 100) as u8
91}
92
93/// Short human phrase for a rule id (kept terse for a one-line summary).
94fn friendly(rule: &str) -> &'static str {
95    match rule.split_whitespace().next().unwrap_or(rule) {
96        "rm:recursive" => "recursively deletes files and directories.",
97        "rm:force-root" => "force-deletes a top-level path.",
98        "git:force-push" => "force-pushes, overwriting remote history.",
99        "git:reset-hard" => "discards local commits and changes.",
100        "git:clean" => "deletes untracked files.",
101        "git:history-rewrite" => "rewrites git history.",
102        "git:branch-delete" => "force-deletes a branch.",
103        "terraform:destroy" => "tears down infrastructure.",
104        "kubectl:delete" => "deletes Kubernetes resources.",
105        "helm:uninstall" => "uninstalls a release.",
106        "sql:destructive" | "sql:truncate" => "runs destructive SQL.",
107        "dd:write" | "disk:destructive" | "disk:mkfs" | "disk:block-device-write" => {
108            "writes directly to a disk or device."
109        }
110        "secret:read" => "reads a secret or credential file.",
111        "net:pipe-to-shell" => "pipes a download straight into a shell.",
112        "docker:system-prune" | "docker:volume-destroy" => "destroys Docker data.",
113        "forkbomb" => "is a fork bomb that will exhaust the system.",
114        _ => "",
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    fn cmd(raw: &str) -> ProposedCommand {
123        ProposedCommand::new("t", "/tmp", vec![raw.into()], raw)
124    }
125
126    #[test]
127    fn safe_is_low_risk() {
128        let out = HeuristicScorer::new().score(&cmd("ls -la"), Class::Safe, "safe:ls");
129        assert!(out.risk < 20);
130        assert!(out.summary.contains("read-only") || out.summary.contains("build"));
131    }
132
133    #[test]
134    fn catastrophic_is_high_risk_and_explained() {
135        let out =
136            HeuristicScorer::new().score(&cmd("rm -rf /"), Class::Catastrophic, "rm:recursive");
137        assert!(out.risk >= 95);
138        assert!(out.summary.to_lowercase().contains("undo") || out.summary.contains("delete"));
139    }
140
141    #[test]
142    fn ambiguous_signal_words_raise_score() {
143        let plain =
144            HeuristicScorer::new().score(&cmd("make build"), Class::Ambiguous, "ambiguous:make");
145        let prod = HeuristicScorer::new().score(
146            &cmd("./deploy.sh --force production"),
147            Class::Ambiguous,
148            "ambiguous:deploy.sh",
149        );
150        assert!(prod.risk > plain.risk, "prod/force should score higher");
151        assert!(prod.risk <= 100);
152    }
153
154    #[test]
155    fn risk_is_always_in_range() {
156        let out = HeuristicScorer::new().score(
157            &cmd("sudo rm -rf / --no-preserve-root --force production drop destroy"),
158            Class::Catastrophic,
159            "rm:recursive",
160        );
161        assert!(out.risk <= 100);
162    }
163}