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:
co-authored by
Claude Opus 4.8
parent
bfeb3f041a
commit
b0117078f3
@@ -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(), ®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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@
|
|||||||
//! [`OscalControlsProvider`], which pulls breakpilot-compliance's OSCAL catalog
|
//! [`OscalControlsProvider`], which pulls breakpilot-compliance's OSCAL catalog
|
||||||
//! and snapshots it locally.
|
//! and snapshots it locally.
|
||||||
|
|
||||||
|
mod judge;
|
||||||
mod oscal_provider;
|
mod oscal_provider;
|
||||||
|
|
||||||
|
pub use judge::{ControlJudge, LlmControlJudge, PROMPT_VERSION};
|
||||||
pub use oscal_provider::OscalControlsProvider;
|
pub use oscal_provider::OscalControlsProvider;
|
||||||
|
|||||||
Reference in New Issue
Block a user