From b0117078f3b7c3bd4b58e8d7b9f2eb81994b0297 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:50:20 +0200 Subject: [PATCH] =?UTF-8?q?feat(agent):=20control=20judge=20=E2=80=94=20LL?= =?UTF-8?q?M=20recognize=20stage=20(temp=200,=20verbatim,=20fail-closed)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ControlJudge trait + LlmControlJudge: judges one (control, region) with a closed temperature-0 prompt that must quote the offending code VERBATIM; parsing fails closed to non-violation (never a fabricated finding). Behind a trait so the checker stays stub-testable. Its output is re-checked by the core grounding gate, never trusted directly. 3 lib tests. Co-Authored-By: Claude Opus 4.8 --- compliance-agent/src/controls/judge.rs | 167 +++++++++++++++++++++++++ compliance-agent/src/controls/mod.rs | 2 + 2 files changed, 169 insertions(+) create mode 100644 compliance-agent/src/controls/judge.rs diff --git a/compliance-agent/src/controls/judge.rs b/compliance-agent/src/controls/judge.rs new file mode 100644 index 0000000..928ef41 --- /dev/null +++ b/compliance-agent/src/controls/judge.rs @@ -0,0 +1,167 @@ +//! 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")); + } +} diff --git a/compliance-agent/src/controls/mod.rs b/compliance-agent/src/controls/mod.rs index ce3d5da..71f6b5a 100644 --- a/compliance-agent/src/controls/mod.rs +++ b/compliance-agent/src/controls/mod.rs @@ -5,6 +5,8 @@ //! [`OscalControlsProvider`], which pulls breakpilot-compliance's OSCAL catalog //! and snapshots it locally. +mod judge; mod oscal_provider; +pub use judge::{ControlJudge, LlmControlJudge, PROMPT_VERSION}; pub use oscal_provider::OscalControlsProvider;