//! The "recognize" stage: judge whether a code region violates a control. //! //! Behind the [`ControlJudge`] trait so the grounded checker can be driven by a //! deterministic stub in tests. The real [`LlmControlJudge`] runs the model at //! temperature 0 with a closed prompt — it must quote the offending code VERBATIM, //! and everything it returns is then re-checked by the grounding gate //! ([`compliance_core::control_check::ground`]). The judge is allowed to be //! smart; it is never trusted. use std::sync::Arc; use serde::Deserialize; use compliance_core::control_check::{CandidateRegion, ControlCheckSpec, LlmVerdict}; use crate::llm::LlmClient; /// Prompt/logic version — part of the verdict cache key, bump on any change here. pub const PROMPT_VERSION: &str = "control-judge-v1"; const SYSTEM_PROMPT: &str = "You are a precise security & compliance code auditor. \ You are given ONE compliance control (a requirement) and ONE code region. Decide \ ONLY whether the code region VIOLATES the control. Rules: (1) Judge only the code \ shown — never assume code that is not present. (2) If and only if it violates, copy \ the EXACT offending code VERBATIM into `snippet`, character-for-character from the \ region — do not paraphrase, reformat, or reconstruct it. (3) If it does not clearly \ violate, set violates=false and leave snippet empty. (4) Prefer false over guessing. \ Respond with STRICT JSON only, no prose: \ {\"violates\": bool, \"snippet\": \"\", \"cwe\": \"CWE-NNN or null\", \"confidence\": 0.0-1.0}"; /// Judges one (control, region). Async-in-trait so a stub can drive tests. #[allow(async_fn_in_trait)] pub trait ControlJudge: Send + Sync { async fn judge(&self, spec: &ControlCheckSpec, region: &CandidateRegion) -> LlmVerdict; } /// The real judge: the LLM at temperature 0 with the closed, verbatim-snippet prompt. pub struct LlmControlJudge { llm: Arc, } impl LlmControlJudge { pub fn new(llm: Arc) -> Self { Self { llm } } } impl ControlJudge for LlmControlJudge { async fn judge(&self, spec: &ControlCheckSpec, region: &CandidateRegion) -> LlmVerdict { let user = build_user_prompt(spec, region); match self.llm.chat(SYSTEM_PROMPT, &user, Some(0.0)).await { Ok(response) => parse_verdict(&response), Err(e) => { // Fail closed: a transient model error yields no finding, never a // fabricated one. tracing::warn!(control = %spec.control_id, error = %e, "control judge call failed"); no_violation() } } } } fn build_user_prompt(spec: &ControlCheckSpec, region: &CandidateRegion) -> String { format!( "CONTROL {id} — {title}\nRequirement: {req}\n\nCODE ({file}, first line = {line}):\n```\n{code}\n```\n\nReturn the JSON verdict.", id = spec.control_id, title = spec.title, req = spec.requirement, file = region.file, line = region.start_line, code = region.content, ) } #[derive(Debug, Default, Deserialize)] struct RawVerdict { #[serde(default)] violates: bool, #[serde(default)] snippet: String, #[serde(default)] cwe: Option, #[serde(default)] confidence: f64, } /// Parse the model's JSON verdict, tolerant of ```json fencing. Any parse failure /// degrades to a non-violation (never a fabricated finding). fn parse_verdict(response: &str) -> LlmVerdict { let cleaned = response .trim() .trim_start_matches("```json") .trim_start_matches("```") .trim_end_matches("```") .trim(); match serde_json::from_str::(cleaned) { Ok(raw) => LlmVerdict { violates: raw.violates, snippet: raw.snippet, cwe: raw.cwe.filter(|c| !c.trim().is_empty()), confidence: raw.confidence, }, Err(e) => { tracing::debug!(error = %e, "failed to parse control verdict; treating as non-violation"); no_violation() } } } fn no_violation() -> LlmVerdict { LlmVerdict { violates: false, snippet: String::new(), cwe: None, confidence: 0.0, } } #[cfg(test)] mod tests { use super::*; use compliance_core::models::finding::Severity; fn spec() -> ControlCheckSpec { ControlCheckSpec { control_id: "cra-ai-8".into(), title: "No default passwords".into(), requirement: "Products must not ship default credentials".into(), default_cwe: Some("CWE-798".into()), severity: Severity::High, } } #[test] fn parses_plain_and_fenced_json() { let plain = r#"{"violates": true, "snippet": "PASSWORD = \"x\"", "cwe": "CWE-798", "confidence": 0.9}"#; let v = parse_verdict(plain); assert!(v.violates); assert_eq!(v.snippet, "PASSWORD = \"x\""); assert_eq!(v.cwe.as_deref(), Some("CWE-798")); let fenced = "```json\n{\"violates\": false, \"snippet\": \"\", \"cwe\": null, \"confidence\": 0.1}\n```"; assert!(!parse_verdict(fenced).violates); } #[test] fn garbage_and_empty_cwe_are_safe() { assert!(!parse_verdict("not json at all").violates); // fail closed let no_cwe = parse_verdict(r#"{"violates": true, "snippet": "x", "cwe": " ", "confidence": 0.5}"#); assert!(no_cwe.cwe.is_none()); // blank CWE normalised away } #[test] fn user_prompt_carries_control_and_code() { let region = CandidateRegion { file: "src/auth.py".into(), start_line: 10, content: "PASSWORD = \"admin\"".into(), }; let p = build_user_prompt(&spec(), ®ion); assert!(p.contains("cra-ai-8")); assert!(p.contains("Products must not ship default credentials")); assert!(p.contains("PASSWORD = \"admin\"")); assert!(p.contains("src/auth.py")); } }