Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions docs/adr/0029-adjudication-verdict-is-authoritative.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,38 @@ guard, not the former; and it adds no new guards.
actually sends.
- If tail flips ever become frequent enough to matter operationally, the response is a model change
(evaluated via the bakeoff) — a bounded, reversible knob — never a deterministic gate on the verdict.

## Amendment (2026-07-19): tag-grounding is grounding, not a verdict gate (JEF-451)

The tail flip recurred on protector's own pod, and a full audit (fable architect, 2026-07-19;
`scratchpad/false-positive-audit.md`) isolated its dominant shape: the model cites a **real** CVE id
(so `guard_fabricated_cve` passes) but attributes the `[reachability: loaded-at-runtime]` **tag** to
it — a tag **no evidence line carries** (every CVE is `not-observed`). The audit's root cause R1: the
phrase `loaded-at-runtime` is the most-primed n-gram in the prompt (≈10× in the instructions, 0× in
the evidence), so a 1.7B judge copy-completes it. The reason strings are self-contradictory (*"…
[reachability: loaded-at-runtime] tags … despite not being observed running"*) — the model is
referencing a tag that isn't there, exactly the failure `guard_fabricated_cve` was built for, one
token deeper.

We therefore add **`guard_fabricated_reachability_tag`** under the scope note's **preserved
grounding/integrity class**, NOT the forbidden breach-decision class. It is admissible under this ADR
because it re-derives no breach and steers no judgement:

- It is a **string-membership test over a closed three-value vocabulary the engine itself renders**
(`graph::Reachability::label` → `loaded-at-runtime | not-observed | present-static-binary`). The
vocabulary cannot grow adversarially, so it is not the unbounded whack-a-mole this ADR rejects.
- It weighs **no severity**, inspects **no breadth**, and can **never fire toward a breach**. It only
ever fires on an `Exploitable` whose *reason* asserts a `loaded-at-runtime` tag the *evidence* does
not contain, and it downgrades to the skeptic **`Uncertain`** (re-judged next pass), **never
`Refuted`** — so it decides nothing about breach in either direction, identical to
`guard_fabricated_cve`.
- A genuine `Exploitable` that cites a truly loaded-at-runtime CVE, or rests on a live signal /
exposed secret and never claims the tag, passes through untouched.

The forbidden classes stand unchanged: no guard may downgrade an `Exploitable` to `Refuted` on a
*judgement* basis (does present evidence amount to a breach), and no evidence is capped or summarized.
The **primary** remedy for this flip class remains the model/prompt layer — an evidence-shaping prompt
restructuring is being planned (split the CVE field by tag; rename the `[reachability:]` axis so the
"reachability is a GIVEN" assertion can't transfer onto CVE tags), bakeoff-validated A/B. This guard
is the deterministic backstop for the *grounding* failure the prompt fixes shrink but cannot
guarantee, not a substitute for them.
43 changes: 41 additions & 2 deletions engine/src/engine/reason/adjudicate/guards.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
//! The fence/sanitize prompt-injection defenses and the anti-fabrication backstop. Split
//! out of the adjudicate module root purely to keep every file under the 1,000-line cap
//! (repo CLAUDE.md). These are pure helpers: `sanitize`/`fence` neutralize untrusted text,
//! and `guard_fabricated_cve` is the sole remaining deterministic backstop (it never
//! decides breach). The cross-pass verdict cache no longer keys on a predicted-input
//! and the anti-fabrication backstops (`guard_fabricated_cve` for a fabricated CVE *id*,
//! `guard_fabricated_reachability_tag` for a fabricated `loaded-at-runtime` *tag* — JEF-451)
//! are grounding/integrity checks that never decide breach. The cross-pass verdict cache no longer keys on a predicted-input
//! fingerprint; it keys on a hash of the deterministic prompt (JEF-350, see
//! `prompt::prompt_cache_key`).

Expand Down Expand Up @@ -106,6 +107,44 @@ pub(crate) fn guard_fabricated_cve(
})
}

/// Tag-grounding guard (JEF-451 / G1): the sibling of [`guard_fabricated_cve`] one token deeper.
/// A small CPU judge can cite a REAL CVE id (so the id guard passes) yet attribute the
/// `[reachability: loaded-at-runtime]` tag to it — the single tag that IS exploitation evidence —
/// when NO evidence line carries that tag (every CVE is `not-observed`). The two live protector
/// flips did exactly this: *"Critical CVEs with [reachability: loaded-at-runtime] tags
/// (CVE-2023-45853, …)"* over evidence that tags all four `not-observed`. The audit's R1 explains
/// why: `loaded-at-runtime` is the most-primed phrase in the prompt (≈10× in the instructions,
/// 0× in the evidence), so the model copy-completes it.
///
/// **This is a GROUNDING/integrity check, not a breach-decision gate (ADR-0029 scope-note).** It
/// is a string-membership test over the CLOSED three-value reachability vocabulary *our own code*
/// renders ([`graph::Reachability::label`]) — it weighs no severity, re-derives no breach, and can
/// never fire toward a breach. Like [`guard_fabricated_cve`] it acts ONLY on an `Exploitable` and
/// downgrades to the skeptic `Uncertain` (never `Refuted`), so the entry is simply re-judged next
/// pass. It fires only when the `Exploitable` reason ASSERTS a loaded-at-runtime tag the evidence
/// does not contain; an `Exploitable` that cites a genuinely loaded-at-runtime CVE, or cites no
/// such tag at all (a real exposed-secret / live-signal breach), passes through untouched.
pub(crate) fn guard_fabricated_reachability_tag(verdict: Verdict, cves: &[String]) -> Verdict {
// The exact tag the prompt renders for a genuinely-running CVE. If ANY evidence CVE line
// carries it, a loaded-at-runtime claim is grounded — leave the verdict alone.
const TAG: &str = "reachability: loaded-at-runtime";
let evidence_has_loaded = cves.iter().any(|c| c.contains(TAG));
guard_exploitable(verdict, |reason| {
// The model's free prose may write the tag with or without the surrounding brackets, and
// with either the hyphenated (`loaded-at-runtime`) or spaced (`loaded at runtime`) form.
let lower = reason.to_ascii_lowercase();
let reason_claims_loaded =
lower.contains("loaded-at-runtime") || lower.contains("loaded at runtime");
(reason_claims_loaded && !evidence_has_loaded).then(|| {
Verdict::Uncertain(
"model asserted a [reachability: loaded-at-runtime] exploitation tag that no CVE \
in the evidence carries (fabricated reachability tag)"
.to_string(),
)
})
})
}

/// Whether a runtime behavior CORROBORATES an exploit — the engine's single shared
/// "alarming-now" definition ([`crate::engine::observe::alarm_class::is_alarming_now`]), NOT a
/// new one: an `Alert` ([`Behavior::is_alert`]), a notable shell/package-manager
Expand Down
9 changes: 8 additions & 1 deletion engine/src/engine/reason/adjudicate/model_call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ use crate::engine::graph::attack::AttackRef;
use crate::engine::graph::{NodeKey, SecurityGraph};

use super::evidence::{cve_ids_of, entry_evidence, entry_findings};
use super::guards::{guard_fabricated_cve, guard_unsupported_exploitable};
use super::guards::{
guard_fabricated_cve, guard_fabricated_reachability_tag, guard_unsupported_exploitable,
};
use super::prompt::parse_verdict;
use super::{Adjudicator, Verdict};

Expand Down Expand Up @@ -115,6 +117,11 @@ impl Adjudicator for ModelAdjudicator {
// breach). Order is harmless: the fabrication guard only fires when a CVE is
// cited, the unsupported guard only when no anchor exists.
let verdict = guard_fabricated_cve(parse_verdict(&reply), &cve_ids_of(&cves));
// JEF-451 (G1): a cited-real-id Exploitable that fabricates the
// `[reachability: loaded-at-runtime]` TAG the evidence doesn't carry → skeptic.
// Grounding/integrity, not a breach gate (ADR-0029 scope-note); reads the same
// rendered `cves` strings the prompt shows.
let verdict = guard_fabricated_reachability_tag(verdict, &cves);
let verdict = guard_unsupported_exploitable(
verdict,
&cves,
Expand Down
66 changes: 65 additions & 1 deletion engine/src/engine/reason/adjudicate/tests/group_2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
//! purely to keep every file under the 1,000-line cap (repo CLAUDE.md).
#![allow(unused_imports)]

use super::super::guards::{ns_marker, objective_reach};
use super::super::guards::{guard_fabricated_reachability_tag, ns_marker, objective_reach};
use super::super::*;
use super::{critical_cve, entry_reaching_db, graph_with_vuln, objectives_of};
use crate::engine::graph::attack::{AttackRef, EXPLOIT_PUBLIC_FACING};
Expand Down Expand Up @@ -335,3 +335,67 @@ async fn real_model_judges_toxic_vs_unevidenced() {
from {model}"
);
}

/// JEF-451 (G1): the model cites a REAL CVE id but fabricates its `[reachability: loaded-at-runtime]`
/// TAG — the exact protector flip. `guard_fabricated_cve` passes (the id is real); the tag guard
/// downgrades the promotion to the skeptic `Uncertain` because no evidence line carries that tag.
#[test]
fn tag_grounding_guard_downgrades_fabricated_loaded_at_runtime() {
// Evidence with all CVEs `not-observed` — the protector shape. The guard reads the rendered
// CVE strings (which carry the tag), exactly as the model_call site passes them.
let not_observed = vec![
"CVE-2023-45853 [severity: critical] [reachability: not-observed] [cvss: 9.8]".to_string(),
"CVE-2026-13221 [severity: critical] [reachability: not-observed] [cvss: 9.1]".to_string(),
];
let has_loaded =
vec!["CVE-2021-44228 [severity: critical] [reachability: loaded-at-runtime]".to_string()];
let none: Vec<String> = vec![];

// The live flip: Exploitable claiming loaded-at-runtime over all-not-observed evidence → skeptic.
let v = guard_fabricated_reachability_tag(
Verdict::Exploitable(
"Critical CVEs with [reachability: loaded-at-runtime] tags (CVE-2023-45853) indicate \
exploitation evidence despite not being observed running."
.into(),
),
&not_observed,
);
assert!(matches!(v, Verdict::Uncertain(_)) && !v.promotes());

// The spaced prose form ("loaded at runtime") is caught too.
assert!(matches!(
guard_fabricated_reachability_tag(
Verdict::Exploitable("the vulnerable code is loaded at runtime".into()),
&not_observed,
),
Verdict::Uncertain(_)
));

// A GENUINE loaded-at-runtime CVE in the evidence → the claim is grounded → preserved.
assert!(matches!(
guard_fabricated_reachability_tag(
Verdict::Exploitable("CVE-2021-44228 [reachability: loaded-at-runtime] runs".into()),
&has_loaded,
),
Verdict::Exploitable(_)
));

// An Exploitable resting on a DIFFERENT anchor that never claims loaded-at-runtime → untouched
// (a real exposed-secret / live-signal breach), even with no CVE evidence.
assert!(matches!(
guard_fabricated_reachability_tag(
Verdict::Exploitable("AWS key baked into the image is an immediate primitive".into()),
&none,
),
Verdict::Exploitable(_)
));

// Never touches a non-Exploitable verdict (Refuted mentioning the tag in passing).
assert!(matches!(
guard_fabricated_reachability_tag(
Verdict::Refuted("no loaded-at-runtime CVE, so not a breach".into()),
&not_observed,
),
Verdict::Refuted(_)
));
}
Loading
Loading