feat(agent): control judge — LLM recognize stage (temp 0, verbatim, fail-closed)

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 <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-07-20 22:50:20 +02:00
co-authored by Claude Opus 4.8
parent bfeb3f041a
commit b0117078f3
2 changed files with 169 additions and 0 deletions
+167
View File
@@ -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\": \"<verbatim code or empty>\", \"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<LlmClient>,
}
impl LlmControlJudge {
pub fn new(llm: Arc<LlmClient>) -> 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<String>,
#[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::<RawVerdict>(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(), &region);
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"));
}
}
+2
View File
@@ -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;