Skip to main content

talon/
eval_cmd.rs

1//! `talon eval score` and `talon eval run`: the golden-set regression
2//! harness for investigation quality.
3//!
4//! The scoring itself is pure and lives in [`talon_intel::eval`]; this
5//! module does the I/O around it:
6//!
7//! - loading golden cases (`evals/golden/<case id>.json`),
8//! - `score`: reading one already-produced report per case from a
9//!   directory and printing the summary,
10//! - `run`: submitting every case to a running Talon through the CoPilot
11//!   HTTP API (`POST /investigate`, then polling
12//!   `GET /investigations/{id}`), i.e. the same path CoPilot uses, and
13//!   scoring the verdict Talon recorded.
14//!
15//! Reports are agent output: they are read without following symlinks and
16//! with a size cap ([`crate::safe_fs`]), parsed as data only, and whatever
17//! of them reaches the terminal has been stripped of control characters by
18//! the scorer.
19//!
20//! Exit codes: `0` pass rate reached, `1` pass rate below
21//! `--min-pass-rate`, `2` unusable input or configuration.
22
23use std::collections::{BTreeMap, HashSet};
24use std::os::fd::{AsFd, BorrowedFd};
25use std::path::{Path, PathBuf};
26use std::time::Duration;
27
28use anyhow::{Context, anyhow, bail};
29use async_trait::async_trait;
30use serde_json::{Value, json};
31use talon_core::config::TalonConfig;
32use talon_http::HttpConfig;
33use talon_http::validate::{
34    is_valid_customer_code, is_valid_investigation_id, is_valid_template_name,
35};
36use talon_intel::eval::{
37    CaseRequest, GoldenCase, Outcome, Summary, is_valid_case_id, score_case, summarize,
38};
39use tokio::time::Instant;
40
41use crate::cli::{EvalCommonArgs, EvalRunArgs, EvalScoreArgs};
42use crate::safe_fs::{OpenRefusal, open_regular_at, open_root, read_capped};
43
44/// Golden-case directory below the project root.
45pub const DEFAULT_CASES_DIR: &str = "evals/golden";
46/// Largest golden-case file read.
47pub const MAX_CASE_BYTES: u64 = 1024 * 1024;
48/// Largest report / investigation record read (file or HTTP body).
49pub const MAX_REPORT_BYTES: u64 = 2 * 1024 * 1024;
50/// `triggered_by` of harness-submitted investigations (the HTTP API
51/// accepts `manual`, `scheduled` and `webhook`).
52pub const TRIGGERED_BY: &str = "manual";
53/// Consecutive failed status polls after which a case is given up.
54pub const MAX_POLL_ERRORS: u32 = 3;
55/// Timeout of one HTTP request to Talon.
56const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
57
58/// Exit code: the pass rate is below the threshold.
59pub const EXIT_BELOW_THRESHOLD: i32 = 1;
60/// Exit code: unusable input or configuration.
61pub const EXIT_USAGE: i32 = 2;
62
63fn read_regular(dir: BorrowedFd<'_>, name: &str, max: u64) -> Result<Option<Vec<u8>>, String> {
64    match open_regular_at(dir, name, max) {
65        Ok((file, _)) => read_capped(file, max)
66            .map(Some)
67            .map_err(|e| format!("cannot read: {e}")),
68        Err(OpenRefusal::Missing) => Ok(None),
69        Err(OpenRefusal::Rejected(reason)) => Err(reason),
70    }
71}
72
73/// Load and validate every `*.json` golden case in `dir`, sorted by file
74/// name. The file stem must equal the case id.
75///
76/// # Errors
77/// The directory cannot be read, holds no cases, or a case file is
78/// unreadable, malformed or invalid (every problem is listed).
79pub fn load_cases(dir: &Path) -> anyhow::Result<Vec<GoldenCase>> {
80    let mut names = Vec::new();
81    for entry in std::fs::read_dir(dir)
82        .with_context(|| format!("cannot read the golden-case directory {}", dir.display()))?
83    {
84        let name = entry?.file_name();
85        if let Some(name) = name.to_str().filter(|n| n.ends_with(".json")) {
86            names.push(name.to_string());
87        }
88    }
89    names.sort();
90    if names.is_empty() {
91        bail!("no golden cases (*.json) in {}", dir.display());
92    }
93    let root = open_root(dir).with_context(|| format!("cannot open {}", dir.display()))?;
94    let mut cases = Vec::new();
95    let mut problems = Vec::new();
96    for name in &names {
97        let stem = name.trim_end_matches(".json");
98        let raw = match read_regular(root.as_fd(), name, MAX_CASE_BYTES) {
99            Ok(Some(raw)) => raw,
100            Ok(None) => continue,
101            Err(reason) => {
102                problems.push(format!("{name}: {reason}"));
103                continue;
104            }
105        };
106        let case = match std::str::from_utf8(&raw)
107            .map_err(|e| format!("not UTF-8: {e}"))
108            .and_then(GoldenCase::from_json_str)
109        {
110            Ok(case) => case,
111            Err(e) => {
112                problems.push(format!("{name}: {e}"));
113                continue;
114            }
115        };
116        let mut issues = case.validate();
117        if case.id != stem {
118            issues.push(format!("id {:?} does not match the file name", case.id));
119        }
120        problems.extend(issues.into_iter().map(|i| format!("{name}: {i}")));
121        cases.push(case);
122    }
123    if !problems.is_empty() {
124        bail!("invalid golden cases:\n  {}", problems.join("\n  "));
125    }
126    Ok(cases)
127}
128
129/// What `<case_id>.json` in the open reports directory says about a case.
130pub fn load_outcome(reports: BorrowedFd<'_>, case_id: &str) -> Outcome {
131    if !is_valid_case_id(case_id) {
132        return Outcome::Missing("invalid case id".to_string());
133    }
134    let name = format!("{case_id}.json");
135    match read_regular(reports, &name, MAX_REPORT_BYTES) {
136        Ok(Some(raw)) => match serde_json::from_slice::<Value>(&raw) {
137            Ok(doc) => Outcome::from_document(doc),
138            Err(e) => Outcome::Failed(format!("{name} is not valid JSON: {e}")),
139        },
140        Ok(None) => Outcome::Missing(format!("no {name} in the reports directory")),
141        Err(reason) => Outcome::Failed(format!("{name} was not read: {reason}")),
142    }
143}
144
145/// Score the reports in `reports_dir` against `cases`.
146///
147/// # Errors
148/// The reports directory cannot be opened.
149pub fn score_reports(cases: &[GoldenCase], reports_dir: &Path) -> anyhow::Result<Summary> {
150    let reports = open_root(reports_dir).with_context(|| {
151        format!(
152            "cannot open the reports directory {}",
153            reports_dir.display()
154        )
155    })?;
156    Ok(summarize(
157        cases
158            .iter()
159            .map(|case| score_case(case, &load_outcome(reports.as_fd(), &case.id)))
160            .collect(),
161    ))
162}
163
164fn cases_dir(root: &Path, common: &EvalCommonArgs) -> PathBuf {
165    match &common.cases {
166        Some(dir) => dir.clone(),
167        None => root.join(DEFAULT_CASES_DIR),
168    }
169}
170
171/// Print the summary and turn it into the exit code.
172fn report(summary: &Summary, common: &EvalCommonArgs) -> i32 {
173    if common.json {
174        match serde_json::to_string_pretty(summary) {
175            Ok(text) => println!("{text}"),
176            Err(e) => {
177                eprintln!("talon eval: cannot serialize the summary: {e}");
178                return EXIT_USAGE;
179            }
180        }
181    } else {
182        print!("{}", summary.render_table());
183    }
184    if summary.meets(common.min_pass_rate) {
185        0
186    } else {
187        eprintln!(
188            "talon eval: pass rate {:.1}% is below the required {:.1}%",
189            summary.pass_rate * 100.0,
190            common.min_pass_rate * 100.0
191        );
192        EXIT_BELOW_THRESHOLD
193    }
194}
195
196fn usage_error(e: &anyhow::Error) -> i32 {
197    eprintln!("talon eval: {e:#}");
198    EXIT_USAGE
199}
200
201/// `talon eval score`: returns the process exit code.
202pub fn score(root: &Path, args: &EvalScoreArgs) -> i32 {
203    let result = load_cases(&cases_dir(root, &args.common))
204        .and_then(|cases| score_reports(&cases, &args.reports));
205    match result {
206        Ok(summary) => report(&summary, &args.common),
207        Err(e) => usage_error(&e),
208    }
209}
210
211// ---------------------------------------------------------------------------
212// Online mode
213// ---------------------------------------------------------------------------
214
215/// Answer of `POST /investigate`.
216#[derive(Debug, Clone, PartialEq, Eq)]
217pub struct Submission {
218    /// Investigation job id.
219    pub job_id: String,
220    /// Talon returned an existing job instead of starting a new one.
221    pub deduplicated: bool,
222}
223
224/// The slice of the CoPilot HTTP API the harness uses; a trait so the run
225/// loop is tested without a server.
226#[async_trait]
227pub trait InvestigationApi: Send + Sync {
228    /// `POST /investigate`.
229    async fn submit(&self, request: &CaseRequest) -> anyhow::Result<Submission>;
230    /// `GET /investigations/{id}`: the investigation record.
231    async fn fetch(&self, job_id: &str) -> anyhow::Result<Value>;
232}
233
234/// [`InvestigationApi`] over HTTP against a running Talon.
235pub struct HttpInvestigationApi {
236    client: reqwest::Client,
237    base: String,
238    api_key: String,
239}
240
241impl HttpInvestigationApi {
242    /// Client for the Talon HTTP API at `base_url`, authenticating with
243    /// `api_key` (`X-Api-Key`). Redirects are not followed, so the key is
244    /// never forwarded to another origin.
245    ///
246    /// # Errors
247    /// `base_url` is not an `http(s)` URL with a host, or the HTTP client
248    /// cannot be built.
249    pub fn new(base_url: &str, api_key: impl Into<String>) -> anyhow::Result<Self> {
250        let url = reqwest::Url::parse(base_url.trim())
251            .map_err(|e| anyhow!("invalid Talon URL {base_url:?}: {e}"))?;
252        if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
253            bail!("the Talon URL must be http(s)://host[:port], got {base_url:?}");
254        }
255        let client = reqwest::Client::builder()
256            .timeout(REQUEST_TIMEOUT)
257            .connect_timeout(REQUEST_TIMEOUT)
258            .redirect(reqwest::redirect::Policy::none())
259            .build()
260            .context("cannot build the HTTP client")?;
261        Ok(Self {
262            client,
263            base: url.as_str().trim_end_matches('/').to_string(),
264            api_key: api_key.into(),
265        })
266    }
267
268    /// Read a JSON body of at most [`MAX_REPORT_BYTES`]; a non-2xx status
269    /// becomes an error naming the status and the API's `error` text.
270    async fn json_body(mut response: reqwest::Response, what: &str) -> anyhow::Result<Value> {
271        let status = response.status();
272        let mut body = Vec::new();
273        while let Some(chunk) = response
274            .chunk()
275            .await
276            .with_context(|| format!("{what}: reading the response failed"))?
277        {
278            body.extend_from_slice(&chunk);
279            if u64::try_from(body.len()).unwrap_or(u64::MAX) > MAX_REPORT_BYTES {
280                bail!("{what}: response exceeds {MAX_REPORT_BYTES} bytes");
281            }
282        }
283        let parsed = serde_json::from_slice::<Value>(&body);
284        if !status.is_success() {
285            let message = parsed
286                .ok()
287                .and_then(|v| v.get("error").and_then(Value::as_str).map(str::to_string))
288                .unwrap_or_default();
289            let message: String = message
290                .chars()
291                .filter(|c| !c.is_control())
292                .take(200)
293                .collect();
294            bail!("{what}: HTTP {status} {message}");
295        }
296        parsed.with_context(|| format!("{what}: the response is not JSON"))
297    }
298}
299
300/// The `POST /investigate` body for a case request.
301pub fn investigate_body(request: &CaseRequest) -> Value {
302    let mut body = json!({
303        "alert_id": request.alert_id,
304        "customer_code": request.customer_code,
305        "triggered_by": TRIGGERED_BY,
306    });
307    if let Some(template) = &request.template_override {
308        body["template_override"] = json!(template);
309    }
310    body
311}
312
313#[async_trait]
314impl InvestigationApi for HttpInvestigationApi {
315    async fn submit(&self, request: &CaseRequest) -> anyhow::Result<Submission> {
316        let response = self
317            .client
318            .post(format!("{}/investigate", self.base))
319            .header("x-api-key", &self.api_key)
320            .json(&investigate_body(request))
321            .send()
322            .await
323            .map_err(|e| anyhow!("POST /investigate: {}", e.without_url()))?;
324        let body = Self::json_body(response, "POST /investigate").await?;
325        let job_id = body
326            .get("job_id")
327            .and_then(Value::as_str)
328            .filter(|id| is_valid_investigation_id(id))
329            .ok_or_else(|| anyhow!("POST /investigate: the response has no valid job_id"))?;
330        Ok(Submission {
331            job_id: job_id.to_string(),
332            deduplicated: body.get("deduplicated").and_then(Value::as_bool) == Some(true),
333        })
334    }
335
336    async fn fetch(&self, job_id: &str) -> anyhow::Result<Value> {
337        if !is_valid_investigation_id(job_id) {
338            bail!("invalid investigation id");
339        }
340        let response = self
341            .client
342            .get(format!("{}/investigations/{job_id}", self.base))
343            .header("x-api-key", &self.api_key)
344            .send()
345            .await
346            .map_err(|e| anyhow!("GET /investigations/{job_id}: {}", e.without_url()))?;
347        Self::json_body(response, "GET /investigations/{id}").await
348    }
349}
350
351/// How the run loop waits.
352#[derive(Debug, Clone, Copy, PartialEq, Eq)]
353pub struct RunOptions {
354    /// Longest wait for one investigation's verdict.
355    pub timeout: Duration,
356    /// Pause between status polls.
357    pub poll: Duration,
358    /// Score a de-duplicated (pre-existing) job instead of failing the
359    /// case.
360    pub allow_deduplicated: bool,
361}
362
363/// What investigating one case through the API produced.
364#[derive(Debug, Clone, PartialEq)]
365pub struct CaseRun {
366    /// Job id, once the submission was accepted.
367    pub job_id: Option<String>,
368    /// The final investigation record, when the job reached a terminal
369    /// status.
370    pub record: Option<Value>,
371    /// The outcome to score.
372    pub outcome: Outcome,
373}
374
375impl CaseRun {
376    fn failed(job_id: Option<String>, reason: String) -> Self {
377        Self {
378            job_id,
379            record: None,
380            outcome: Outcome::Failed(reason),
381        }
382    }
383}
384
385/// Submit `request` and wait for its investigation to finish.
386///
387/// Never returns an error: a rejected submission, a de-duplicated trigger
388/// (unless allowed), repeated poll failures and a timeout all become
389/// [`Outcome::Failed`]. A job that times out here keeps running in Talon —
390/// the HTTP API has no cancel operation.
391pub async fn investigate_case(
392    api: &dyn InvestigationApi,
393    request: &CaseRequest,
394    options: RunOptions,
395) -> CaseRun {
396    let submission = match api.submit(request).await {
397        Ok(s) => s,
398        Err(e) => return CaseRun::failed(None, format!("submission failed: {e:#}")),
399    };
400    let job_id = submission.job_id;
401    if submission.deduplicated && !options.allow_deduplicated {
402        return CaseRun::failed(
403            Some(job_id.clone()),
404            format!(
405                "Talon de-duplicated the trigger to the existing job {job_id}; its verdict may \
406                 predate the change under test (set COPILOT_INVESTIGATION_DEDUP_MINUTES=0 on \
407                 the eval instance, or pass --allow-deduplicated)"
408            ),
409        );
410    }
411    let deadline = Instant::now() + options.timeout;
412    let mut last_status = "unknown".to_string();
413    let mut errors = 0;
414    loop {
415        match api.fetch(&job_id).await {
416            Ok(record) => {
417                errors = 0;
418                let status = record
419                    .get("status")
420                    .and_then(Value::as_str)
421                    .unwrap_or("unknown");
422                if matches!(status, "completed" | "failed") {
423                    return CaseRun {
424                        job_id: Some(job_id),
425                        outcome: Outcome::from_document(record.clone()),
426                        record: Some(record),
427                    };
428                }
429                last_status = status
430                    .chars()
431                    .filter(|c| !c.is_control())
432                    .take(32)
433                    .collect();
434            }
435            Err(e) => {
436                errors += 1;
437                if errors >= MAX_POLL_ERRORS {
438                    return CaseRun::failed(
439                        Some(job_id),
440                        format!("status polling failed {errors} times in a row: {e:#}"),
441                    );
442                }
443            }
444        }
445        if Instant::now() + options.poll > deadline {
446            return CaseRun::failed(
447                Some(job_id.clone()),
448                format!(
449                    "no verdict within {}s (last status: {last_status}); job {job_id} may \
450                     still be running in Talon",
451                    options.timeout.as_secs()
452                ),
453            );
454        }
455        tokio::time::sleep(options.poll).await;
456    }
457}
458
459/// Parse an `--alert-map` document: `{"<case id>": <alert id>, ...}`.
460///
461/// # Errors
462/// Not a JSON object of positive integers.
463pub fn parse_alert_map(text: &str) -> anyhow::Result<BTreeMap<String, u64>> {
464    let map: BTreeMap<String, u64> = serde_json::from_str(text)
465        .context("the alert map must be a JSON object of case id -> alert id")?;
466    if let Some((id, _)) = map.iter().find(|(_, alert)| **alert == 0) {
467        bail!("alert map: the alert id of {id:?} must be positive");
468    }
469    Ok(map)
470}
471
472/// The request to submit for each case: the case's own, with the alert id
473/// from `alert_map` and the customer code from `customer_code` when given.
474/// Validated against the HTTP API's input rules before anything is sent.
475///
476/// # Errors
477/// The map names an unknown case, or a resulting request would be rejected
478/// by `POST /investigate`.
479pub fn prepare_requests(
480    cases: &[GoldenCase],
481    alert_map: &BTreeMap<String, u64>,
482    customer_code: Option<&str>,
483) -> anyhow::Result<Vec<CaseRequest>> {
484    let known: HashSet<&str> = cases.iter().map(|c| c.id.as_str()).collect();
485    if let Some(unknown) = alert_map.keys().find(|id| !known.contains(id.as_str())) {
486        bail!("alert map: no golden case named {unknown:?}");
487    }
488    cases
489        .iter()
490        .map(|case| {
491            let mut request = case.request.clone();
492            if let Some(alert_id) = alert_map.get(&case.id) {
493                request.alert_id = *alert_id;
494            }
495            if let Some(code) = customer_code {
496                request.customer_code = code.to_string();
497            }
498            if request.alert_id == 0 || request.alert_id > talon_http::validate::MAX_ALERT_ID {
499                bail!("{}: alert_id is out of range", case.id);
500            }
501            if !is_valid_customer_code(&request.customer_code) {
502                bail!(
503                    "{}: customer_code must match ^[A-Za-z0-9_-]{{1,64}}$",
504                    case.id
505                );
506            }
507            if let Some(template) = &request.template_override
508                && !is_valid_template_name(template)
509            {
510                bail!(
511                    "{}: template_override must be a plain .txt filename",
512                    case.id
513                );
514            }
515            Ok(request)
516        })
517        .collect()
518}
519
520/// Investigate every case in turn (one at a time: the investigation lane
521/// is serialized anyway, and a per-case timeout stays meaningful) and
522/// score the outcomes. `progress` receives one line per step.
523pub async fn run_cases(
524    api: &dyn InvestigationApi,
525    cases: &[GoldenCase],
526    requests: &[CaseRequest],
527    options: RunOptions,
528    mut progress: impl FnMut(&str) + Send,
529) -> (Summary, Vec<CaseRun>) {
530    let mut results = Vec::new();
531    let mut runs = Vec::new();
532    for (i, (case, request)) in cases.iter().zip(requests).enumerate() {
533        progress(&format!(
534            "[{}/{}] {}: investigating alert {} ({})",
535            i + 1,
536            cases.len(),
537            case.id,
538            request.alert_id,
539            request.customer_code
540        ));
541        let run = investigate_case(api, request, options).await;
542        let result = score_case(case, &run.outcome);
543        progress(&format!(
544            "[{}/{}] {}: {} (job {}, verdict {})",
545            i + 1,
546            cases.len(),
547            case.id,
548            if result.passed { "PASS" } else { "FAIL" },
549            run.job_id.as_deref().unwrap_or("-"),
550            result.actual_verdict
551        ));
552        results.push(result);
553        runs.push(run);
554    }
555    (summarize(results), runs)
556}
557
558/// Write each finished run's investigation record to
559/// `<dir>/<case id>.json` (the layout `talon eval score --reports` reads).
560/// Cases without a record (submission failed, timed out) get no file and
561/// score as missing.
562fn save_records(dir: &Path, cases: &[GoldenCase], runs: &[CaseRun]) -> anyhow::Result<()> {
563    std::fs::create_dir_all(dir).with_context(|| format!("cannot create {}", dir.display()))?;
564    for (case, run) in cases.iter().zip(runs) {
565        let Some(record) = &run.record else { continue };
566        let path = dir.join(format!("{}.json", case.id));
567        let text = serde_json::to_vec_pretty(record)?;
568        std::fs::write(&path, text).with_context(|| format!("cannot write {}", path.display()))?;
569    }
570    Ok(())
571}
572
573fn default_base_url(http: &HttpConfig) -> String {
574    let host = match http.bind {
575        ip if ip.is_unspecified() => "127.0.0.1".to_string(),
576        std::net::IpAddr::V6(ip) => format!("[{ip}]"),
577        std::net::IpAddr::V4(ip) => ip.to_string(),
578    };
579    format!("http://{host}:{}", http.port)
580}
581
582/// Whether sending the API key to `url` would expose it on the network:
583/// plain HTTP to a host that is not loopback.
584fn is_cleartext_remote(url: &str) -> bool {
585    let Ok(url) = reqwest::Url::parse(url) else {
586        return false;
587    };
588    let host = url.host_str().unwrap_or_default();
589    let loopback = match host.trim_matches(['[', ']']).parse::<std::net::IpAddr>() {
590        Ok(ip) => ip.is_loopback(),
591        Err(_) => host.eq_ignore_ascii_case("localhost"),
592    };
593    url.scheme() == "http" && !loopback
594}
595
596async fn run_inner(root: &Path, config: &TalonConfig, args: &EvalRunArgs) -> anyhow::Result<i32> {
597    let cases = load_cases(&cases_dir(root, &args.common))?;
598    let alert_map = match &args.alert_map {
599        Some(path) => parse_alert_map(
600            &std::fs::read_to_string(path)
601                .with_context(|| format!("cannot read {}", path.display()))?,
602        )?,
603        None => BTreeMap::new(),
604    };
605    let requests = prepare_requests(&cases, &alert_map, args.customer_code.as_deref())?;
606
607    let http = HttpConfig::from_talon_config(config).map_err(|e| anyhow!("{e}"))?;
608    if !http.api_key.is_configured() {
609        bail!(
610            "HTTP_API_KEY is not set in the project's .env; the Talon HTTP API rejects every request without it"
611        );
612    }
613    let base = match &args.url {
614        Some(url) => url.clone(),
615        None => default_base_url(&http),
616    };
617    if is_cleartext_remote(&base) {
618        eprintln!(
619            "talon eval: warning: sending the API key over plain HTTP to a non-loopback host"
620        );
621    }
622    let api = HttpInvestigationApi::new(&base, http.api_key.expose())?;
623    let options = RunOptions {
624        timeout: Duration::from_secs(args.timeout_secs),
625        poll: Duration::from_secs(args.poll_secs),
626        allow_deduplicated: args.allow_deduplicated,
627    };
628    let (summary, runs) =
629        run_cases(&api, &cases, &requests, options, |line| eprintln!("{line}")).await;
630    if let Some(dir) = &args.save_dir {
631        save_records(dir, &cases, &runs)?;
632    }
633    Ok(report(&summary, &args.common))
634}
635
636/// `talon eval run`: returns the process exit code.
637pub async fn run(root: &Path, config: &TalonConfig, args: &EvalRunArgs) -> i32 {
638    match run_inner(root, config, args).await {
639        Ok(code) => code,
640        Err(e) => usage_error(&e),
641    }
642}
643
644#[cfg(test)]
645mod tests {
646    use std::collections::VecDeque;
647    use std::sync::Mutex;
648
649    use talon_intel::eval::{ACTUAL_FAILED, ACTUAL_MISSING};
650    use wiremock::matchers::{body_json, header, method, path};
651    use wiremock::{Mock, MockServer, ResponseTemplate};
652
653    use super::*;
654
655    fn case_json(id: &str, alert_id: u64) -> Value {
656        json!({
657            "id": id,
658            "description": "test",
659            "request": {"alert_id": alert_id, "customer_code": "golden"},
660            "alert": {},
661            "expect": {
662                "verdict": "true_positive",
663                "severity": {"min": "medium", "max": "critical"},
664                "required_iocs": [{"type": "ip", "value": "203.0.113.10"}],
665                "forbidden_strings": ["USER_1"]
666            }
667        })
668    }
669
670    fn case(id: &str, alert_id: u64) -> GoldenCase {
671        serde_json::from_value(case_json(id, alert_id)).unwrap()
672    }
673
674    fn good_report() -> Value {
675        json!({
676            "alert_id": "1", "verdict": "true_positive", "confidence": 90, "severity": "high",
677            "summary": "Beaconing to 203.0.113.10.",
678            "iocs": [{"value": "203.0.113.10", "type": "ip", "verdict": "malicious", "details": "c2"}],
679            "mitre": [], "timeline": [], "evidence": [],
680            "recommended_actions": [{"action": "Isolate the host", "priority": "immediate", "rationale": "c2"}]
681        })
682    }
683
684    fn record(status: &str, verdict: Value) -> Value {
685        json!({"id": "copilot-inv-1-2", "alert_id": "1", "customer_code": "golden",
686               "triggered_by": "manual", "status": status, "template": null,
687               "created_at": "t", "updated_at": "t", "verdict": verdict, "error": null})
688    }
689
690    fn write(dir: &Path, name: &str, v: &Value) {
691        std::fs::write(dir.join(name), serde_json::to_vec(v).unwrap()).unwrap();
692    }
693
694    // -- loading ----------------------------------------------------------
695
696    #[test]
697    fn load_cases_sorted_and_validated() {
698        let dir = tempfile::tempdir().unwrap();
699        write(dir.path(), "b-case.json", &case_json("b-case", 2));
700        write(dir.path(), "a-case.json", &case_json("a-case", 1));
701        std::fs::write(dir.path().join("README.md"), "docs").unwrap();
702        let cases = load_cases(dir.path()).unwrap();
703        assert_eq!(
704            cases.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(),
705            ["a-case", "b-case"]
706        );
707    }
708
709    #[test]
710    fn load_cases_lists_every_problem() {
711        let dir = tempfile::tempdir().unwrap();
712        assert!(
713            load_cases(dir.path())
714                .unwrap_err()
715                .to_string()
716                .contains("no golden cases")
717        );
718        assert!(load_cases(&dir.path().join("absent")).is_err());
719
720        write(dir.path(), "renamed.json", &case_json("other", 1));
721        std::fs::write(dir.path().join("broken.json"), "{").unwrap();
722        let mut bad = case_json("bad", 1);
723        bad["expect"]["severity"] = json!({"min": "high", "max": "low"});
724        write(dir.path(), "bad.json", &bad);
725        std::os::unix::fs::symlink(dir.path().join("bad.json"), dir.path().join("link.json"))
726            .unwrap();
727        let err = load_cases(dir.path()).unwrap_err().to_string();
728        for needle in [
729            "renamed.json: id \"other\" does not match",
730            "broken.json: not a golden case",
731            "bad.json: expect.severity.min",
732            "link.json: symbolic link",
733        ] {
734            assert!(err.contains(needle), "{needle} missing in {err}");
735        }
736    }
737
738    // -- offline scoring --------------------------------------------------
739
740    #[test]
741    fn score_reports_handles_reports_records_and_bad_files() {
742        let reports = tempfile::tempdir().unwrap();
743        let cases: Vec<GoldenCase> = [
744            "bare",
745            "record",
746            "failed-run",
747            "no-verdict",
748            "absent",
749            "garbage",
750            "leaky",
751            "linked",
752        ]
753        .iter()
754        .map(|id| case(id, 1))
755        .collect();
756        write(reports.path(), "bare.json", &good_report());
757        write(
758            reports.path(),
759            "record.json",
760            &record("completed", good_report()),
761        );
762        let mut failed = record("failed", json!(null));
763        failed["error"] = json!("container exited 137");
764        write(reports.path(), "failed-run.json", &failed);
765        write(
766            reports.path(),
767            "no-verdict.json",
768            &record("completed", json!(null)),
769        );
770        std::fs::write(reports.path().join("garbage.json"), "<html>").unwrap();
771        let mut leaky = good_report();
772        leaky["summary"] = json!("USER_1 beaconed to 203.0.113.10.");
773        write(reports.path(), "leaky.json", &leaky);
774        std::os::unix::fs::symlink(
775            reports.path().join("bare.json"),
776            reports.path().join("linked.json"),
777        )
778        .unwrap();
779
780        let s = score_reports(&cases, reports.path()).unwrap();
781        let by_id = |id: &str| s.cases.iter().find(|c| c.case_id == id).unwrap();
782        assert!(by_id("bare").passed && by_id("record").passed);
783        assert_eq!(by_id("failed-run").actual_verdict, ACTUAL_FAILED);
784        assert!(
785            by_id("failed-run").checks[0]
786                .detail
787                .contains("container exited 137")
788        );
789        assert_eq!(by_id("no-verdict").actual_verdict, ACTUAL_MISSING);
790        assert_eq!(by_id("absent").actual_verdict, ACTUAL_MISSING);
791        assert_eq!(by_id("garbage").actual_verdict, ACTUAL_FAILED);
792        assert_eq!(by_id("leaky").actual_verdict, "true_positive");
793        assert!(!by_id("leaky").passed);
794        // A symlinked report is refused, not followed.
795        assert_eq!(by_id("linked").actual_verdict, ACTUAL_FAILED);
796        assert!(by_id("linked").checks[0].detail.contains("symbolic link"));
797        assert_eq!((s.total, s.passed), (8, 2));
798
799        assert!(score_reports(&cases, &reports.path().join("absent")).is_err());
800    }
801
802    #[test]
803    fn score_exit_codes() {
804        let root = tempfile::tempdir().unwrap();
805        let golden = root.path().join(DEFAULT_CASES_DIR);
806        std::fs::create_dir_all(&golden).unwrap();
807        write(&golden, "one.json", &case_json("one", 1));
808        write(&golden, "two.json", &case_json("two", 2));
809        let reports = tempfile::tempdir().unwrap();
810        write(reports.path(), "one.json", &good_report());
811        let args = |min: f64, json: bool| EvalScoreArgs {
812            reports: reports.path().to_path_buf(),
813            common: EvalCommonArgs {
814                cases: None,
815                json,
816                min_pass_rate: min,
817            },
818        };
819        assert_eq!(score(root.path(), &args(1.0, false)), EXIT_BELOW_THRESHOLD);
820        assert_eq!(score(root.path(), &args(0.5, true)), 0);
821        write(reports.path(), "two.json", &good_report());
822        assert_eq!(score(root.path(), &args(1.0, false)), 0);
823
824        // Unusable input: no cases / no reports directory.
825        let empty = tempfile::tempdir().unwrap();
826        assert_eq!(score(empty.path(), &args(1.0, false)), EXIT_USAGE);
827        let mut missing = args(1.0, false);
828        missing.reports = reports.path().join("absent");
829        assert_eq!(score(root.path(), &missing), EXIT_USAGE);
830    }
831
832    // -- request preparation ----------------------------------------------
833
834    #[test]
835    fn alert_map_and_customer_override() {
836        let cases = vec![case("a", 1), case("b", 2)];
837        let map = parse_alert_map(r#"{"b": 4711}"#).unwrap();
838        let reqs = prepare_requests(&cases, &map, Some("lab_01")).unwrap();
839        assert_eq!((reqs[0].alert_id, reqs[1].alert_id), (1, 4711));
840        assert!(reqs.iter().all(|r| r.customer_code == "lab_01"));
841
842        assert!(prepare_requests(&cases, &parse_alert_map(r#"{"c": 1}"#).unwrap(), None).is_err());
843        assert!(prepare_requests(&cases, &BTreeMap::new(), Some("bad code")).is_err());
844        let too_big = BTreeMap::from([("a".to_string(), u64::MAX)]);
845        assert!(prepare_requests(&cases, &too_big, None).is_err());
846        let mut templated = case("t", 1);
847        templated.request.template_override = Some("../x.txt".into());
848        assert!(prepare_requests(&[templated], &BTreeMap::new(), None).is_err());
849
850        for bad in [r#"{"a": 0}"#, r#"{"a": "1"}"#, r#"{"a": -1}"#, "[]", "{"] {
851            assert!(parse_alert_map(bad).is_err(), "{bad}");
852        }
853    }
854
855    #[test]
856    fn investigate_body_matches_the_http_contract() {
857        let mut request = case("a", 7).request;
858        assert_eq!(
859            investigate_body(&request),
860            json!({"alert_id": 7, "customer_code": "golden", "triggered_by": "manual"})
861        );
862        request.template_override = Some("sysmon_event_1.txt".into());
863        assert_eq!(
864            investigate_body(&request)["template_override"],
865            "sysmon_event_1.txt"
866        );
867        // The label must be one the API accepts.
868        assert!(talon_http::validate::is_valid_triggered_by(TRIGGERED_BY));
869    }
870
871    // -- run loop (fake API) ----------------------------------------------
872
873    struct FakeApi {
874        submit: Mutex<VecDeque<anyhow::Result<Submission>>>,
875        fetch: Mutex<VecDeque<anyhow::Result<Value>>>,
876        fetched: Mutex<Vec<String>>,
877    }
878
879    impl FakeApi {
880        fn new(submit: Vec<anyhow::Result<Submission>>, fetch: Vec<anyhow::Result<Value>>) -> Self {
881            Self {
882                submit: Mutex::new(submit.into()),
883                fetch: Mutex::new(fetch.into()),
884                fetched: Mutex::new(Vec::new()),
885            }
886        }
887    }
888
889    #[async_trait]
890    impl InvestigationApi for FakeApi {
891        async fn submit(&self, _request: &CaseRequest) -> anyhow::Result<Submission> {
892            self.submit.lock().unwrap().pop_front().unwrap()
893        }
894        async fn fetch(&self, job_id: &str) -> anyhow::Result<Value> {
895            self.fetched.lock().unwrap().push(job_id.to_string());
896            // Once the script is exhausted the job stays `running`.
897            self.fetch
898                .lock()
899                .unwrap()
900                .pop_front()
901                .unwrap_or_else(|| Ok(record("running", json!(null))))
902        }
903    }
904
905    fn submitted(job: &str, deduplicated: bool) -> anyhow::Result<Submission> {
906        Ok(Submission {
907            job_id: job.into(),
908            deduplicated,
909        })
910    }
911
912    const FAST: RunOptions = RunOptions {
913        timeout: Duration::from_millis(200),
914        poll: Duration::from_millis(1),
915        allow_deduplicated: false,
916    };
917
918    #[tokio::test]
919    async fn waits_through_queued_and_running() {
920        let api = FakeApi::new(
921            vec![submitted("job-1", false)],
922            vec![
923                Ok(record("queued", json!(null))),
924                Err(anyhow!("connection reset")),
925                Ok(record("running", json!(null))),
926                Ok(record("completed", good_report())),
927            ],
928        );
929        let run = investigate_case(&api, &case("a", 1).request, FAST).await;
930        assert_eq!(run.job_id.as_deref(), Some("job-1"));
931        assert_eq!(run.outcome, Outcome::Report(good_report()));
932        assert_eq!(run.record.unwrap()["status"], "completed");
933        assert_eq!(api.fetched.lock().unwrap().len(), 4);
934    }
935
936    #[tokio::test]
937    async fn failure_modes_become_failed_outcomes() {
938        let request = case("a", 1).request;
939        let reason = |run: CaseRun| match run.outcome {
940            Outcome::Failed(r) => r,
941            other => panic!("expected a failure, got {other:?}"),
942        };
943
944        // Submission rejected.
945        let api = FakeApi::new(vec![Err(anyhow!("HTTP 401 Unauthorized"))], vec![]);
946        let run = investigate_case(&api, &request, FAST).await;
947        assert_eq!(run.job_id, None);
948        assert!(reason(run).contains("submission failed: HTTP 401"));
949
950        // De-duplicated trigger: never polled, never scored.
951        let api = FakeApi::new(vec![submitted("old-job", true)], vec![]);
952        let run = investigate_case(&api, &request, FAST).await;
953        assert!(reason(run).contains("de-duplicated the trigger to the existing job old-job"));
954        assert!(api.fetched.lock().unwrap().is_empty());
955
956        // ... unless explicitly allowed.
957        let api = FakeApi::new(
958            vec![submitted("old-job", true)],
959            vec![Ok(record("completed", good_report()))],
960        );
961        let allow = RunOptions {
962            allow_deduplicated: true,
963            ..FAST
964        };
965        let run = investigate_case(&api, &request, allow).await;
966        assert!(matches!(run.outcome, Outcome::Report(_)));
967
968        // Investigation failed.
969        let mut failed = record("failed", json!(null));
970        failed["error"] = json!("agent crashed");
971        let api = FakeApi::new(vec![submitted("job-2", false)], vec![Ok(failed)]);
972        let run = investigate_case(&api, &request, FAST).await;
973        assert!(run.record.is_some());
974        assert!(reason(run).contains("agent crashed"));
975
976        // Polling keeps failing.
977        let api = FakeApi::new(
978            vec![submitted("job-3", false)],
979            (0..5).map(|_| Err(anyhow!("HTTP 500"))).collect(),
980        );
981        let run = investigate_case(&api, &request, FAST).await;
982        assert!(reason(run).contains("status polling failed 3 times in a row"));
983        assert_eq!(api.fetched.lock().unwrap().len(), 3);
984
985        // Timeout while the job is still running.
986        let api = FakeApi::new(vec![submitted("job-4", false)], vec![]);
987        let quick = RunOptions {
988            timeout: Duration::from_millis(20),
989            ..FAST
990        };
991        let started = std::time::Instant::now();
992        let run = investigate_case(&api, &request, quick).await;
993        assert!(started.elapsed() < Duration::from_secs(5));
994        assert_eq!(run.record, None);
995        let reason = reason(run);
996        assert!(
997            reason.contains("no verdict within 0s (last status: running)"),
998            "{reason}"
999        );
1000        assert!(reason.contains("job-4"));
1001    }
1002
1003    #[tokio::test]
1004    async fn completed_without_verdict_is_missing() {
1005        let api = FakeApi::new(
1006            vec![submitted("job-1", false)],
1007            vec![Ok(record("completed", json!(null)))],
1008        );
1009        let run = investigate_case(&api, &case("a", 1).request, FAST).await;
1010        assert!(matches!(run.outcome, Outcome::Missing(_)));
1011    }
1012
1013    #[tokio::test]
1014    async fn run_cases_scores_and_round_trips_through_score() {
1015        let cases = vec![case("good", 1), case("wrong", 2), case("dead", 3)];
1016        let requests = prepare_requests(&cases, &BTreeMap::new(), None).unwrap();
1017        let mut wrong = good_report();
1018        wrong["verdict"] = json!("false_positive");
1019        let api = FakeApi::new(
1020            vec![
1021                submitted("job-1", false),
1022                submitted("job-2", false),
1023                Err(anyhow!("HTTP 503")),
1024            ],
1025            vec![
1026                Ok(record("completed", good_report())),
1027                Ok(record("completed", wrong)),
1028            ],
1029        );
1030        let mut lines = Vec::new();
1031        let (summary, runs) =
1032            run_cases(&api, &cases, &requests, FAST, |l| lines.push(l.to_string())).await;
1033        assert_eq!((summary.total, summary.passed), (3, 1));
1034        assert_eq!(summary.confusion["true_positive"]["false_positive"], 1);
1035        assert_eq!(summary.confusion["true_positive"][ACTUAL_FAILED], 1);
1036        assert_eq!(lines.len(), 6);
1037        assert!(lines[1].contains("good: PASS (job job-1, verdict true_positive)"));
1038        assert!(lines[5].contains("dead: FAIL (job -, verdict failed)"));
1039
1040        // Saved records re-score offline; the case that never ran has no
1041        // file and counts as missing.
1042        let dir = tempfile::tempdir().unwrap();
1043        let save = dir.path().join("out");
1044        save_records(&save, &cases, &runs).unwrap();
1045        let rescored = score_reports(&cases, &save).unwrap();
1046        assert_eq!((rescored.total, rescored.passed), (3, 1));
1047        assert_eq!(rescored.cases[0], summary.cases[0]);
1048        assert_eq!(rescored.cases[1], summary.cases[1]);
1049        assert_eq!(rescored.cases[2].actual_verdict, ACTUAL_MISSING);
1050    }
1051
1052    // -- HTTP client (wire level) -------------------------------------------
1053
1054    #[tokio::test]
1055    async fn http_api_speaks_the_copilot_contract() {
1056        let server = MockServer::start().await;
1057        Mock::given(method("POST"))
1058            .and(path("/investigate"))
1059            .and(header("x-api-key", "s3cret"))
1060            .and(body_json(json!({
1061                "alert_id": 7, "customer_code": "golden", "triggered_by": "manual"
1062            })))
1063            .respond_with(ResponseTemplate::new(202).set_body_json(json!({
1064                "job_id": "copilot-inv-7-1700000000000", "status": "queued",
1065                "template_override": null, "deduplicated": false
1066            })))
1067            .expect(1)
1068            .mount(&server)
1069            .await;
1070        Mock::given(method("GET"))
1071            .and(path("/investigations/copilot-inv-7-1700000000000"))
1072            .and(header("x-api-key", "s3cret"))
1073            .respond_with(
1074                ResponseTemplate::new(200).set_body_json(record("completed", good_report())),
1075            )
1076            .expect(1)
1077            .mount(&server)
1078            .await;
1079
1080        // A trailing slash on the base URL is tolerated.
1081        let api = HttpInvestigationApi::new(&format!("{}/", server.uri()), "s3cret").unwrap();
1082        let run = investigate_case(&api, &case("a", 7).request, FAST).await;
1083        assert_eq!(run.job_id.as_deref(), Some("copilot-inv-7-1700000000000"));
1084        assert_eq!(run.outcome, Outcome::Report(good_report()));
1085    }
1086
1087    #[tokio::test]
1088    async fn http_api_reports_errors_without_leaking_the_key() {
1089        let server = MockServer::start().await;
1090        Mock::given(method("POST"))
1091            .and(path("/investigate"))
1092            .respond_with(
1093                ResponseTemplate::new(401).set_body_json(json!({"error": "Unauthorized\u{1b}[2J"})),
1094            )
1095            .mount(&server)
1096            .await;
1097        Mock::given(method("GET"))
1098            .and(path("/investigations/job-1"))
1099            .respond_with(ResponseTemplate::new(200).set_body_string("<html>"))
1100            .mount(&server)
1101            .await;
1102        let api = HttpInvestigationApi::new(&server.uri(), "s3cret-key").unwrap();
1103        let err = api
1104            .submit(&case("a", 7).request)
1105            .await
1106            .unwrap_err()
1107            .to_string();
1108        assert!(
1109            err.contains("HTTP 401") && err.contains("Unauthorized"),
1110            "{err}"
1111        );
1112        assert!(!err.contains('\u{1b}') && !err.contains("s3cret-key"));
1113        let err = format!("{:#}", api.fetch("job-1").await.unwrap_err());
1114        assert!(err.contains("not JSON"), "{err}");
1115        // Ids that are not investigation ids never reach the URL.
1116        assert!(api.fetch("../status").await.is_err());
1117
1118        // Deduplicated answer (HTTP 200) and a response without a job id.
1119        let server = MockServer::start().await;
1120        Mock::given(method("POST"))
1121            .and(path("/investigate"))
1122            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1123                "job_id": "copilot-inv-7-1", "status": "completed", "deduplicated": true
1124            })))
1125            .up_to_n_times(1)
1126            .mount(&server)
1127            .await;
1128        Mock::given(method("POST"))
1129            .and(path("/investigate"))
1130            .respond_with(ResponseTemplate::new(202).set_body_json(json!({"job_id": "a/b"})))
1131            .mount(&server)
1132            .await;
1133        let api = HttpInvestigationApi::new(&server.uri(), "k").unwrap();
1134        let first = api.submit(&case("a", 7).request).await.unwrap();
1135        assert!(first.deduplicated);
1136        assert!(api.submit(&case("a", 7).request).await.is_err());
1137
1138        // Nothing listening.
1139        let api = HttpInvestigationApi::new("http://127.0.0.1:1", "s3cret-key").unwrap();
1140        let err = format!("{:#}", api.submit(&case("a", 7).request).await.unwrap_err());
1141        assert!(err.starts_with("POST /investigate:"), "{err}");
1142    }
1143
1144    #[test]
1145    fn url_handling() {
1146        for bad in [
1147            "",
1148            "ftp://x",
1149            "localhost:3100",
1150            "http://",
1151            "file:///etc/passwd",
1152        ] {
1153            assert!(HttpInvestigationApi::new(bad, "k").is_err(), "{bad}");
1154        }
1155        assert!(HttpInvestigationApi::new("https://talon.example.com", "k").is_ok());
1156        assert!(is_cleartext_remote("http://talon.example.com:3100"));
1157        assert!(is_cleartext_remote("http://192.0.2.10:3100"));
1158        for fine in [
1159            "http://127.0.0.1:3100",
1160            "http://localhost:3100",
1161            "http://[::1]:3100",
1162            "https://talon.example.com",
1163        ] {
1164            assert!(!is_cleartext_remote(fine), "{fine}");
1165        }
1166        let mut http = HttpConfig::new("/srv/talon");
1167        assert_eq!(default_base_url(&http), "http://127.0.0.1:3100");
1168        http.bind = "0.0.0.0".parse().unwrap();
1169        http.port = 3200;
1170        assert_eq!(default_base_url(&http), "http://127.0.0.1:3200");
1171        http.bind = "::1".parse().unwrap();
1172        assert_eq!(default_base_url(&http), "http://[::1]:3200");
1173    }
1174
1175    // -- the shipped golden set -------------------------------------------
1176
1177    fn repo_root() -> PathBuf {
1178        Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")
1179    }
1180
1181    /// A report that satisfies every expectation of `case`.
1182    fn ideal_report(case: &GoldenCase) -> Value {
1183        let e = &case.expect;
1184        let iocs: Vec<Value> = e
1185            .required_iocs
1186            .iter()
1187            .map(|i| {
1188                // An untyped expectation is reported the way an agent most
1189                // plausibly would.
1190                let ioc_type = match i.ioc_type {
1191                    Some(t) => t.as_str(),
1192                    None if i.value.parse::<std::net::IpAddr>().is_ok() => "ip",
1193                    None => "domain",
1194                };
1195                json!({"value": i.value, "type": ioc_type,
1196                       "verdict": "unknown", "details": "from the alert"})
1197            })
1198            .collect();
1199        let mitre: Vec<Value> = e
1200            .required_mitre
1201            .iter()
1202            .map(|id| {
1203                let t = talon_intel::mitre::technique(id).unwrap_or_else(|| {
1204                    panic!("{}: {id} is not in the embedded ATT&CK table", case.id)
1205                });
1206                json!({"technique_id": t.id, "name": t.full_name(),
1207                       "tactic": t.tactics[0], "rationale": "observed"})
1208            })
1209            .collect();
1210        json!({
1211            "alert_id": case.request.alert_id.to_string(),
1212            "verdict": talon_intel::eval::verdict_str(e.verdict),
1213            "confidence": 80,
1214            "severity": e.severity.min.as_str(),
1215            "summary": "Synthetic ideal answer.",
1216            "iocs": iocs, "mitre": mitre, "timeline": [], "evidence": [],
1217            "recommended_actions": [
1218                {"action": "Review", "priority": "medium", "rationale": "golden"}
1219            ]
1220        })
1221    }
1222
1223    #[test]
1224    fn shipped_golden_cases_are_valid_and_self_consistent() {
1225        let root = repo_root();
1226        if !root.join("Cargo.lock").exists() {
1227            // Built from a published crate tarball: the repository's
1228            // golden set is not part of the package.
1229            return;
1230        }
1231        let cases = load_cases(&root.join(DEFAULT_CASES_DIR)).unwrap();
1232        assert!((5..=8).contains(&cases.len()), "{} cases", cases.len());
1233        prepare_requests(&cases, &BTreeMap::new(), None).unwrap();
1234
1235        let mut alert_ids = HashSet::new();
1236        let mut verdicts = HashSet::new();
1237        let mut types = HashSet::new();
1238        for case in &cases {
1239            let id = &case.id;
1240            assert!(
1241                alert_ids.insert(case.request.alert_id),
1242                "{id}: duplicate alert_id"
1243            );
1244            verdicts.insert(case.expect.verdict);
1245            assert!(case.alert.is_object(), "{id}: no alert document");
1246            assert!(!case.description.is_empty(), "{id}: no description");
1247
1248            // The alert type names a shipped prompt template.
1249            let alert_type = case
1250                .alert_type
1251                .as_deref()
1252                .unwrap_or_else(|| panic!("{id}: no alert_type"));
1253            types.insert(alert_type.to_string());
1254            let template = root
1255                .join("groups/copilot/prompts")
1256                .join(format!("{alert_type}.txt"));
1257            assert!(template.is_file(), "{id}: no template {alert_type}.txt");
1258
1259            // Expectations derive from the alert: required indicators occur
1260            // in it, forbidden content does not.
1261            let alert_text = case.alert.to_string().replace("\\\\", "\\").to_lowercase();
1262            for ioc in &case.expect.required_iocs {
1263                assert!(
1264                    alert_text.contains(&ioc.value.to_lowercase()),
1265                    "{id}: required IOC {} is not in the alert",
1266                    ioc.value
1267                );
1268            }
1269            for forbidden in case
1270                .expect
1271                .forbidden_iocs
1272                .iter()
1273                .chain(&case.expect.forbidden_strings)
1274            {
1275                assert!(
1276                    !alert_text.contains(&forbidden.to_lowercase()),
1277                    "{id}: forbidden {forbidden} occurs in the alert"
1278                );
1279            }
1280            // Every case guards against leaked anonymization tokens.
1281            assert!(
1282                !case.expect.forbidden_patterns.is_empty(),
1283                "{id}: no forbidden_patterns"
1284            );
1285
1286            // An ideal answer passes every check; an empty one does not.
1287            let ideal = score_case(case, &Outcome::Report(ideal_report(case)));
1288            assert!(
1289                ideal.passed,
1290                "{id}: {:?}",
1291                ideal.failures().collect::<Vec<_>>()
1292            );
1293            assert!(!score_case(case, &Outcome::Report(json!({}))).passed);
1294            let mut leaky = ideal_report(case);
1295            leaky["summary"] = json!("USER_1 on HOST_2 did it.");
1296            assert!(!score_case(case, &Outcome::Report(leaky)).passed, "{id}");
1297        }
1298        assert!(verdicts.len() >= 3, "verdict classes covered: {verdicts:?}");
1299        assert!(types.len() >= 4, "alert types covered: {types:?}");
1300    }
1301}