1use std::collections::{BTreeSet, HashMap};
21
22use serde::{Deserialize, Serialize};
23
24use crate::hooks::decide::{
25 classify_command, evaluate, extract_file_paths, CommandClass, Decision, EnforcementInput,
26};
27
28const DETECTION_KNOWN_GOOD: &str = include_str!("../tests/fixtures/eval/detection/known_good.json");
30const DETECTION_BENIGN: &str = include_str!("../tests/fixtures/eval/detection/benign.json");
31const DETECTION_ADVERSARIAL: &str =
32 include_str!("../tests/fixtures/eval/detection/adversarial.json");
33const DECISION_CASES: &str = include_str!("../tests/fixtures/eval/decision/cases.json");
34const BASELINE: &str = include_str!("../tests/fixtures/eval/baseline.json");
35
36#[derive(Debug, Deserialize)]
39struct DetectionCase {
40 id: String,
41 cmd: String,
42 label: String,
45 expect_class: String,
47 #[serde(default)]
51 expect_paths: Vec<String>,
52 #[serde(default)]
53 #[allow(dead_code)]
54 note: Option<String>,
55}
56
57#[derive(Debug, Deserialize)]
58struct DecisionCase {
59 id: String,
60 label: String,
62 rel_path: String,
63 #[serde(default)]
64 file_record: Option<serde_json::Value>,
65 #[serde(default)]
66 gotcha_records: HashMap<String, serde_json::Value>,
67 #[serde(default)]
68 already_consulted: bool,
69 #[serde(default)]
72 file_exists: Option<bool>,
73 expect: String,
76 #[serde(default)]
77 #[allow(dead_code)]
78 note: Option<String>,
79}
80
81#[derive(Debug, Deserialize)]
82struct Baseline {
83 #[serde(default)]
84 detection: Vec<String>,
85 #[serde(default)]
86 decision: Vec<String>,
87}
88
89#[derive(Debug, Serialize)]
93pub struct LayerReport {
94 pub layer: &'static str,
95 pub cases: u32,
96 pub tp: u32,
97 #[serde(rename = "fn")]
98 pub fn_: u32,
99 pub tn: u32,
100 pub fp: u32,
101 pub recall: f64,
102 pub fp_rate: f64,
103 pub precision: f64,
104 pub failing: Vec<String>,
106 pub known_gaps: Vec<String>,
108 pub regressions: Vec<String>,
110 pub newly_fixed: Vec<String>,
112}
113
114#[derive(Debug, Serialize)]
115pub struct EvalReport {
116 pub detection: LayerReport,
117 pub decision: LayerReport,
118}
119
120impl EvalReport {
121 pub fn gate(&self) -> Result<(), String> {
125 let mut errs = Vec::new();
126 for l in [&self.detection, &self.decision] {
127 if !l.regressions.is_empty() {
128 errs.push(format!(
129 "[{}] REGRESSION — output now wrong for cases not in baseline: {}\n \
130 Fix the regression, or (if intended) add these ids to \
131 tests/fixtures/eval/baseline.json.",
132 l.layer,
133 l.regressions.join(", ")
134 ));
135 }
136 if !l.newly_fixed.is_empty() {
137 errs.push(format!(
138 "[{}] IMPROVEMENT — baseline gaps now PASS: {}\n \
139 Remove them from tests/fixtures/eval/baseline.json so the \
140 baseline stays honest and recall ratchets up.",
141 l.layer,
142 l.newly_fixed.join(", ")
143 ));
144 }
145 }
146 if errs.is_empty() {
147 Ok(())
148 } else {
149 Err(errs.join("\n"))
150 }
151 }
152}
153
154fn parse_class(s: &str) -> Option<CommandClass> {
157 match s {
158 "cat_like" => Some(CommandClass::CatLike),
159 "grep_like" => Some(CommandClass::GrepLike),
160 "db_client_like" => Some(CommandClass::DbClientLike),
161 "path_mutating" => Some(CommandClass::PathMutating),
162 "none" => None,
163 other => panic!("corpus: bad expect_class {other:?}"),
164 }
165}
166
167fn detection_pass(c: &DetectionCase) -> bool {
168 let got_class = classify_command(&c.cmd);
169 if got_class != parse_class(&c.expect_class) {
170 return false;
171 }
172 let mut got_paths = match got_class {
175 Some(cl) => extract_file_paths(&c.cmd, cl),
176 None => Vec::new(),
177 };
178 let mut want = c.expect_paths.clone();
179 got_paths.sort();
180 want.sort();
181 got_paths == want
182}
183
184fn decision_variant(d: &Decision) -> &'static str {
187 match d {
188 Decision::Allow => "allow",
189 Decision::Deny { .. } => "deny",
190 Decision::AlreadyConsulted { .. } => "already_consulted",
191 Decision::Advisory { .. } => "advisory",
192 Decision::Liability { .. } => "liability",
193 Decision::Tombstone => "tombstone",
194 Decision::NoRecord => "no_record",
195 Decision::NotFileRead => "not_file_read",
196 }
197}
198
199const DECISION_VARIANTS: &[&str] = &[
200 "allow",
201 "advisory",
202 "deny",
203 "already_consulted",
204 "liability",
205 "tombstone",
206 "no_record",
207 "not_file_read",
208];
209
210fn decision_pass(c: &DecisionCase) -> bool {
211 let input = EnforcementInput {
212 rel_path: c.rel_path.clone(),
213 file_record: c.file_record.clone(),
214 gotcha_records: c.gotcha_records.clone(),
215 already_consulted: c.already_consulted,
216 file_exists: c.file_exists,
217 };
218 decision_variant(&evaluate(&input).decision) == c.expect
219}
220
221fn score(
223 layer: &'static str,
224 rows: &[(String, bool, bool)],
225 known_gaps: Vec<String>,
226) -> LayerReport {
227 let (mut tp, mut fn_, mut tn, mut fp) = (0u32, 0u32, 0u32, 0u32);
228 let mut failing: BTreeSet<String> = BTreeSet::new();
229 for (id, is_violation, pass) in rows {
230 match (is_violation, pass) {
231 (true, true) => tp += 1,
232 (true, false) => fn_ += 1,
233 (false, true) => tn += 1,
234 (false, false) => fp += 1,
235 }
236 if !pass {
237 failing.insert(id.clone());
238 }
239 }
240 let recall = if tp + fn_ == 0 {
241 1.0
242 } else {
243 tp as f64 / (tp + fn_) as f64
244 };
245 let fp_rate = if fp + tn == 0 {
246 0.0
247 } else {
248 fp as f64 / (fp + tn) as f64
249 };
250 let precision = if tp + fp == 0 {
251 1.0
252 } else {
253 tp as f64 / (tp + fp) as f64
254 };
255 let known: BTreeSet<String> = known_gaps.iter().cloned().collect();
256 let regressions = failing.difference(&known).cloned().collect();
257 let newly_fixed = known.difference(&failing).cloned().collect();
258 LayerReport {
259 layer,
260 cases: rows.len() as u32,
261 tp,
262 fn_,
263 tn,
264 fp,
265 recall,
266 fp_rate,
267 precision,
268 failing: failing.into_iter().collect(),
269 known_gaps,
270 regressions,
271 newly_fixed,
272 }
273}
274
275fn assert_unique_ids<'a>(layer: &str, ids: impl Iterator<Item = &'a str>) {
276 let mut seen = BTreeSet::new();
277 for id in ids {
278 assert!(seen.insert(id), "{layer} corpus: duplicate case id {id:?}");
279 }
280}
281
282pub fn run() -> EvalReport {
288 let baseline: Baseline = serde_json::from_str(BASELINE).expect("parse baseline.json");
289
290 let mut detection: Vec<DetectionCase> = Vec::new();
292 for raw in [
293 DETECTION_KNOWN_GOOD,
294 DETECTION_BENIGN,
295 DETECTION_ADVERSARIAL,
296 ] {
297 detection.extend(serde_json::from_str::<Vec<DetectionCase>>(raw).expect("parse detection"));
298 }
299 assert_unique_ids("detection", detection.iter().map(|c| c.id.as_str()));
300 let det_rows: Vec<(String, bool, bool)> = detection
301 .iter()
302 .map(|c| {
303 assert!(
304 c.label == "violation" || c.label == "benign",
305 "detection {}: bad label {:?}",
306 c.id,
307 c.label
308 );
309 (c.id.clone(), c.label == "violation", detection_pass(c))
310 })
311 .collect();
312 let detection = score("detection", &det_rows, baseline.detection);
313
314 let decision: Vec<DecisionCase> =
316 serde_json::from_str(DECISION_CASES).expect("parse decision corpus");
317 assert_unique_ids("decision", decision.iter().map(|c| c.id.as_str()));
318 let dec_rows: Vec<(String, bool, bool)> = decision
319 .iter()
320 .map(|c| {
321 assert!(
322 c.label == "violation" || c.label == "benign",
323 "decision {}: bad label {:?}",
324 c.id,
325 c.label
326 );
327 assert!(
328 DECISION_VARIANTS.contains(&c.expect.as_str()),
329 "decision {}: bad expect {:?}",
330 c.id,
331 c.expect
332 );
333 assert_eq!(
336 c.label == "violation",
337 c.expect == "deny",
338 "decision {}: label/expect mismatch (violation iff expect==deny)",
339 c.id
340 );
341 (c.id.clone(), c.label == "violation", decision_pass(c))
342 })
343 .collect();
344 let decision = score("decision", &dec_rows, baseline.decision);
345
346 EvalReport {
347 detection,
348 decision,
349 }
350}
351
352#[cfg(test)]
355mod tests {
356 use super::*;
357
358 #[test]
359 fn detection_pass_can_fail() {
360 let mk = |expect_class: &str, expect_paths: &[&str]| DetectionCase {
362 id: "x".into(),
363 cmd: "cat src/main.rs".into(),
364 label: "violation".into(),
365 expect_class: expect_class.into(),
366 expect_paths: expect_paths.iter().map(|s| s.to_string()).collect(),
367 note: None,
368 };
369 assert!(detection_pass(&mk("cat_like", &["src/main.rs"])));
370 assert!(!detection_pass(&mk("cat_like", &["WRONG.rs"])));
371 assert!(!detection_pass(&mk("none", &[])));
372 }
373
374 #[test]
375 fn decision_pass_can_fail() {
376 let deny_input = DecisionCase {
377 id: "x".into(),
378 label: "violation".into(),
379 rel_path: "src/a.rs".into(),
380 file_record: Some(serde_json::json!({
381 "confidence": {"value": 0.9}, "quality": {"value": 0.8},
382 "staleness": {"value": 0.1, "tier": "fresh"},
383 "payload": {"gotcha_keys": ["g"]}
384 })),
385 gotcha_records: HashMap::from([(
386 "g".to_string(),
387 serde_json::json!({
388 "value": "r", "confidence": {"value": 0.9}, "quality": {"value": 0.8},
389 "payload": {"confirmed": true}
390 }),
391 )]),
392 already_consulted: false,
393 file_exists: None,
394 expect: "deny".into(),
395 note: None,
396 };
397 assert!(decision_pass(&deny_input), "real deny case must pass");
398
399 let mut wrong = deny_input;
400 wrong.expect = "allow".into();
401 assert!(
402 !decision_pass(&wrong),
403 "a deny scored against expect=allow must fail"
404 );
405 }
406
407 #[test]
408 fn corpus_is_well_formed_and_gates() {
409 let report = run();
412 assert!(report.detection.cases > 0);
413 assert!(report.decision.cases > 0);
414 report
415 .gate()
416 .expect("embedded corpus must match its baseline");
417 }
418}