feat(core): grounded control-check backbone (ground gate + cache)
The deterministic spine of control-driven checking: turn a text control into a finding via an LLM used as a grounded pattern-recognizer. ground() admits a verdict only if its quoted snippet appears verbatim in the retrieved region and recomputes the finding line from that match (the model's line is discarded), so a fabricated snippet cannot survive. cache_key() makes verdicts reproducible. Pure — no LLM, no IO. 4 lib tests incl. fabricated-snippet-dropped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
5285fb67ae
commit
bfeb3f041a
@@ -0,0 +1,204 @@
|
||||
//! Grounded control-driven checking.
|
||||
//!
|
||||
//! Turns a *text* control into findings via an LLM used as a **pattern-recognizer**
|
||||
//! whose output is grounded to real code — so a hallucinated finding cannot
|
||||
//! survive. Determinism is structural, not a prompt plea:
|
||||
//!
|
||||
//! 1. the LLM only ever judges *retrieved* regions — it can't invent findings in
|
||||
//! code it never saw;
|
||||
//! 2. a verdict becomes a finding only if its quoted snippet appears **verbatim**
|
||||
//! in the region, and the line is recomputed from that match — the model's own
|
||||
//! line number is never trusted ([`ground`]);
|
||||
//! 3. verdicts are cached by content hash ([`cache_key`]) so re-scans reproduce.
|
||||
//!
|
||||
//! The LLM supplies cross-language / cross-stack pattern recognition; this module
|
||||
//! supplies the determinism.
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::models::finding::{Finding, Severity};
|
||||
use crate::models::scan::ScanType;
|
||||
|
||||
/// A control rendered as a check the LLM judges code against.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ControlCheckSpec {
|
||||
/// Stable control id, e.g. `"cra-ai-8"`.
|
||||
pub control_id: String,
|
||||
/// Short control title (used in the finding title).
|
||||
pub title: String,
|
||||
/// The requirement text the LLM judges against (control objective/statement).
|
||||
pub requirement: String,
|
||||
/// CWE to fall back to when the model doesn't supply one.
|
||||
pub default_cwe: Option<String>,
|
||||
/// Severity for findings raised from this control.
|
||||
pub severity: Severity,
|
||||
}
|
||||
|
||||
/// A retrieved code region the LLM judges — never the whole repo.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CandidateRegion {
|
||||
/// Repo-relative path.
|
||||
pub file: String,
|
||||
/// 1-based line number of the region's first line in `file`.
|
||||
pub start_line: u32,
|
||||
/// The region's source text.
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// The LLM's structured verdict for one (control, region). `snippet` is the
|
||||
/// verbatim code the model claims proves the violation — it is the anchor the
|
||||
/// grounding gate checks.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LlmVerdict {
|
||||
pub violates: bool,
|
||||
pub snippet: String,
|
||||
pub cwe: Option<String>,
|
||||
pub confidence: f64,
|
||||
}
|
||||
|
||||
/// The grounding gate. A verdict becomes a [`Finding`] only if it claims a
|
||||
/// violation AND its quoted `snippet` appears verbatim in `region.content`; the
|
||||
/// finding's line is computed from the match, so a fabricated or mis-located
|
||||
/// snippet is dropped. Pure — no LLM, no I/O.
|
||||
pub fn ground(
|
||||
spec: &ControlCheckSpec,
|
||||
region: &CandidateRegion,
|
||||
verdict: &LlmVerdict,
|
||||
repo_id: &str,
|
||||
) -> Option<Finding> {
|
||||
if !verdict.violates {
|
||||
return None;
|
||||
}
|
||||
let snippet = verdict.snippet.trim();
|
||||
if snippet.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Grounding: the quoted snippet must literally exist in the retrieved region.
|
||||
let pos = region.content.find(snippet)?;
|
||||
// Recompute the real line from the match — never trust the model's number.
|
||||
let newlines_before = region.content[..pos].matches('\n').count();
|
||||
let line = region.start_line + newlines_before as u32;
|
||||
|
||||
let mut finding = Finding::new(
|
||||
repo_id.to_string(),
|
||||
control_finding_fingerprint(&spec.control_id, ®ion.file, snippet),
|
||||
"control-check".to_string(),
|
||||
ScanType::CodeReview,
|
||||
format!("{}: {}", spec.control_id, spec.title),
|
||||
format!(
|
||||
"Control {} appears violated ({}) at {}:{line}",
|
||||
spec.control_id, spec.requirement, region.file
|
||||
),
|
||||
spec.severity.clone(),
|
||||
);
|
||||
finding.cwe = verdict.cwe.clone().or_else(|| spec.default_cwe.clone());
|
||||
finding.file_path = Some(region.file.clone());
|
||||
finding.line_number = Some(line);
|
||||
finding.code_snippet = Some(snippet.to_string());
|
||||
finding.confidence = Some(verdict.confidence);
|
||||
// Carry the control reference (a dedicated field lands with the dashboard slice).
|
||||
finding.rule_id = Some(spec.control_id.clone());
|
||||
Some(finding)
|
||||
}
|
||||
|
||||
/// Deterministic cache key for a (control, region, model, prompt-version) verdict
|
||||
/// so identical inputs reproduce the same verdict without another LLM call.
|
||||
pub fn cache_key(
|
||||
control_id: &str,
|
||||
region_content: &str,
|
||||
model: &str,
|
||||
prompt_version: &str,
|
||||
) -> String {
|
||||
hash_parts(&[control_id, region_content, model, prompt_version])
|
||||
}
|
||||
|
||||
fn control_finding_fingerprint(control_id: &str, file: &str, snippet: &str) -> String {
|
||||
hash_parts(&[control_id, file, snippet])
|
||||
}
|
||||
|
||||
fn hash_parts(parts: &[&str]) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
for part in parts {
|
||||
hasher.update(part.as_bytes());
|
||||
hasher.update([0u8]); // domain separator between parts
|
||||
}
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
fn region() -> CandidateRegion {
|
||||
CandidateRegion {
|
||||
file: "src/auth.py".into(),
|
||||
start_line: 10,
|
||||
content: "def login():\n PASSWORD = \"admin123\"\n return PASSWORD\n".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grounds_real_snippet_with_recomputed_line() {
|
||||
let v = LlmVerdict {
|
||||
violates: true,
|
||||
snippet: "PASSWORD = \"admin123\"".into(),
|
||||
cwe: None,
|
||||
confidence: 0.9,
|
||||
};
|
||||
let f = ground(&spec(), ®ion(), &v, "repo").expect("should ground");
|
||||
assert_eq!(f.line_number, Some(11)); // 2nd line of a region starting at 10
|
||||
assert_eq!(f.cwe.as_deref(), Some("CWE-798")); // fell back to the spec default
|
||||
assert_eq!(f.rule_id.as_deref(), Some("cra-ai-8")); // control ref carried
|
||||
assert_eq!(f.file_path.as_deref(), Some("src/auth.py"));
|
||||
assert_eq!(f.code_snippet.as_deref(), Some("PASSWORD = \"admin123\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_fabricated_snippet_not_in_region() {
|
||||
let v = LlmVerdict {
|
||||
violates: true,
|
||||
snippet: "SECRET = \"totally-made-up\"".into(),
|
||||
cwe: None,
|
||||
confidence: 0.99,
|
||||
};
|
||||
assert!(ground(&spec(), ®ion(), &v, "repo").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_non_violation_and_empty_snippet() {
|
||||
let no = LlmVerdict {
|
||||
violates: false,
|
||||
snippet: "PASSWORD = \"admin123\"".into(),
|
||||
cwe: None,
|
||||
confidence: 0.9,
|
||||
};
|
||||
assert!(ground(&spec(), ®ion(), &no, "repo").is_none());
|
||||
let empty = LlmVerdict {
|
||||
violates: true,
|
||||
snippet: " ".into(),
|
||||
cwe: None,
|
||||
confidence: 0.9,
|
||||
};
|
||||
assert!(ground(&spec(), ®ion(), &empty, "repo").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_key_and_fingerprint_are_deterministic() {
|
||||
assert_eq!(cache_key("c", "x", "m", "v"), cache_key("c", "x", "m", "v"));
|
||||
assert_ne!(cache_key("c", "x", "m", "v"), cache_key("c", "y", "m", "v"));
|
||||
assert_eq!(
|
||||
control_finding_fingerprint("c", "f", "s"),
|
||||
control_finding_fingerprint("c", "f", "s")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod config;
|
||||
pub mod control_check;
|
||||
pub mod db;
|
||||
pub mod error;
|
||||
pub mod models;
|
||||
|
||||
Reference in New Issue
Block a user