From 17af55f69e7f6c3abbd45ae23a98c599cb33e532 Mon Sep 17 00:00:00 2001 From: Jeff Larson Date: Tue, 28 Jul 2026 20:57:44 -0700 Subject: [PATCH 1/3] feat(engine): wire IncidentDecision + the deterministic action menu into the live ledger (JEF-570) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lands ADR-0034 (target-choice, not a mechanism-menu) on top of the merged incident/ module (JEF-609): the model now names the compromised on-path node(s); determinism resolves each to its narrowest legal cut. - prompt.rs: splices the deterministic containment-options menu (incident::Menu::render) LAST, immediately before a new output instruction asking for {assessment, reason, contain} instead of the old 4-value {verdict, reason} JSON. Evidence-framing sections are unchanged; the menu is part of the full-state prompt, so prompt_cache_key/the ADR-0023 delta gate cover it unchanged. build_delta_prompt_with_menu_asn is a new, additive entry point (build_delta_prompt_asn/build_judgment_prompt* keep their existing signatures, rendering an empty menu, for the ~60 existing prompt-content tests). - model_call.rs: ModelAdjudicator::judge now parses via incident::parse_incident_decision (tolerant, skeptic default) against the caller-built menu, chains the D5 grounding guards, and reuses guard_unsupported_exploitable (grandfathered zero-anchor backstop) via a round-trip through the legacy Verdict shape it operates on. - Adjudicator::judge now takes the entry's Menu and returns an IncidentDecision. IncidentDecision::to_verdict() bridges to the legacy Verdict so the ADR-0023 cache / re-judge gate / breaker+backoff / journal Breach line / notifier / dashboard keep consuming exactly what they already do, unchanged. - adj_pass.rs: builds each entry's menu (unioned across its objective chains), dispatches judge() with it, and records this pass's DECISIVE IncidentDecision into a new Engine-owned map (never persisted — see scope note below) that carries forward across a cache-hit/backoff pass (D7's retirement asymmetry: a fresh Uncertain retires nothing). - respond/mod.rs::reconcile now takes that per-entry decision map. Desired set = model-chosen cuts whose entry still has a proven chain, plus the containment_for FALLBACK (stamped adjudicated=false, never auto-applied) for a breach-relevant entry with no decisive Attack-with-cuts decision. The deterministic quarantine_targets desired-set insertion is DELETED for breach-relevant chains (kept, unchanged, for a non-breach-relevant chain's JEF-284 condition-2 targets — outside the north star's two lanes, ADR-0032 §6, untouched by this ticket). - incident/menu.rs, incident/mod.rs: MenuLine/ChosenCut gain a `cut: Link` field (JEF-570 needed the concrete Link to build a ledger Mitigation; the pre-merged module only carried the signature string) and a shared `normalize` helper (build_menu and the new per-entry union both use it). Rails verified (tested): shadow stays the DEFAULT posture; ADR-0021 per-class arming and enforceScope are untouched (decide() unchanged); blast-radius/alive-collateral gate untouched; reversible/additive + self-revert untouched; zero-egress (no new outbound path); untrusted text in the menu stays fenced / fixed mechanism strings only; presentation still never gates (ADR-0016, untouched). Tests: unit tests for the fold (engine::tests, journal_tests — mechanical Adjudicator-signature updates across 8 existing test doubles), the reconcile desired-set (respond/decisions_tests.rs: fallback stamped non-auto even when corroborated, model cuts clear the gate, a confident NoAttack proposes nothing, D1 attack-with-empty-cuts still falls back, a non-member reply degrades end-to-end), retirement asymmetry and the shadow-default rail (engine::tests, two new integration tests), and the pre-existing containment/quarantine test suites updated for the new model-gated desired set (respond/tests.rs, pivot_quarantine_tests.rs). 1022 lib tests green; cargo fmt clean; cargo clippy --all-targets -D warnings clean (workspace). DECISION NEEDED (scope, flagged per the ticket's own escape hatch): journal schema v2 (D8's typed IncidentDecision variant + the fingerprint/cut_signature double replay-lock) is DEFERRED to a follow-up ticket. This PR's per-entry decision map is Engine-local and NOT persisted, so a restart starts every entry with no decision — every breach-relevant entry falls back to the non-auto containment_for proposal until it is freshly re-judged (strictly MORE conservative than D8's target, never less safe: no cut can ever auto-apply on a replayed decision it never actually re-derived this run). The verdict CACHE (JEF-301) still re-seeds from the journal exactly as before, so a restart still skips a redundant model call for an unchanged entry — only the CUTS require a fresh live decision, not the assessment. Also flagged: the D6 fallback-trigger boundary condition — the ADR text literally scopes the fallback to "no decisive decision" but D1 explicitly routes an `Attack` decision with an empty `contain` to the fallback too. Resolved by triggering the fallback whenever there is no decisive model-chosen cut (absent, Uncertain, or Attack-with-empty-contain) and NEVER for a decisive NoAttack (a confident clear proposes nothing at all) — implemented and covered by respond::decisions_tests::decisive_attack_with_empty_cuts_still_gets_the_fallback_proposal and ::decisive_no_attack_produces_no_proposal_at_all. Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP Co-authored-by: Claude Opus 4.8 --- engine/src/engine/adj_gate.rs | 15 +- engine/src/engine/adj_gate_tests.rs | 2 + engine/src/engine/adj_pass.rs | 100 ++++++-- engine/src/engine/churn_diag.rs | 1 + engine/src/engine/journal_tests.rs | 16 +- engine/src/engine/mod.rs | 27 ++- .../adjudicate/incident/guards_tests.rs | 8 + .../engine/reason/adjudicate/incident/menu.rs | 25 +- .../engine/reason/adjudicate/incident/mod.rs | 26 ++ .../reason/adjudicate/incident/parse_tests.rs | 11 +- engine/src/engine/reason/adjudicate/mod.rs | 28 ++- .../engine/reason/adjudicate/model_call.rs | 115 ++++++--- engine/src/engine/reason/adjudicate/prompt.rs | 95 +++++++- .../engine/reason/adjudicate/tests/group_1.rs | 21 +- .../engine/reason/adjudicate/tests/group_2.rs | 92 +++++-- .../engine/reason/adjudicate/tests/group_3.rs | 9 +- .../reason/proof/pivot_quarantine_tests.rs | 62 ++++- engine/src/engine/respond/decisions_tests.rs | 226 ++++++++++++++++++ engine/src/engine/respond/mod.rs | 126 +++++++--- engine/src/engine/respond/tests.rs | 147 ++++++++++-- engine/src/engine/tests.rs | 162 ++++++++++++- 21 files changed, 1118 insertions(+), 196 deletions(-) create mode 100644 engine/src/engine/respond/decisions_tests.rs diff --git a/engine/src/engine/adj_gate.rs b/engine/src/engine/adj_gate.rs index 241d65de..a4f6ce4e 100644 --- a/engine/src/engine/adj_gate.rs +++ b/engine/src/engine/adj_gate.rs @@ -53,9 +53,9 @@ impl Engine { /// (re-judge) vs subtractive (the prior verdict holds), and the baseline itself (the gate /// serves its verdict on a subtractive hold). Built before the cache lookup so the cached-on /// and sent prompt bytes can never drift. - // 8 args (JEF-565 added `downstream`): each is a distinct, already-computed piece of this - // pass's per-entry state (no natural sub-grouping that wouldn't just be a wrapper struct for - // its own sake — see the same call already made in `run_loop.rs`/`supply_chain/mod.rs`). + // 9 args (JEF-570 added `menu`): each is a distinct, already-computed piece of this pass's + // per-entry state (no natural sub-grouping that wouldn't just be a wrapper struct for its + // own sake — see the same call already made in `run_loop.rs`/`supply_chain/mod.rs`). #[allow(clippy::too_many_arguments)] pub(super) fn prepare_pending( &self, @@ -66,19 +66,23 @@ impl Engine { idxs: &[usize], graph: &graph::SecurityGraph, asn: &crate::engine::observe::asn::AsnDb, + menu: reason::adjudicate::incident::Menu, ) -> (PendingEntry, bool, Option) { let baseline = self.verdicts.baseline_for(entry_key); - let delta = reason::adjudicate::build_delta_prompt_asn( + let delta = reason::adjudicate::build_delta_prompt_with_menu_asn( &entry, &objectives, graph, asn, baseline.as_ref().map(|b| &b.surface), &downstream, + &menu, ); // The verdict-cache key is the FULL-STATE hash (excludes the "Changes since…" section) so // an identical full state always keys identically regardless of the delta — see - // `build_delta_prompt_asn` for why (ADR-0023's fingerprint↔delta-gate resolution). + // `build_delta_prompt_asn` for why (ADR-0023's fingerprint↔delta-gate resolution). The + // menu is part of that full state (JEF-570): a mapping change is a prompt change is a + // re-judge (ADR-0034 D4). let fingerprint = delta.cache_key; let chain = reason::adjudicate::chain_shape_hash(&objectives); let pending = PendingEntry { @@ -92,6 +96,7 @@ impl Engine { chain, surface: delta.surface, idxs: idxs.to_vec(), + menu, }; (pending, delta.additive, baseline) } diff --git a/engine/src/engine/adj_gate_tests.rs b/engine/src/engine/adj_gate_tests.rs index 75bbcd2c..f5678576 100644 --- a/engine/src/engine/adj_gate_tests.rs +++ b/engine/src/engine/adj_gate_tests.rs @@ -38,6 +38,7 @@ fn pending(entry: &str, fingerprint: &str) -> PendingEntry { chain: "ch".into(), surface: JudgedSurface::default(), idxs: vec![0], + menu: reason::adjudicate::incident::Menu::default(), } } @@ -315,6 +316,7 @@ fn downstream_only_cve_appearing_is_additive_and_forces_a_rejudge() { chain: "ch".into(), surface: current_build.surface, idxs: vec![0], + menu: reason::adjudicate::incident::Menu::default(), }; assert!( matches!( diff --git a/engine/src/engine/adj_pass.rs b/engine/src/engine/adj_pass.rs index defe122f..429aec01 100644 --- a/engine/src/engine/adj_pass.rs +++ b/engine/src/engine/adj_pass.rs @@ -21,13 +21,25 @@ //! This is a behavior-neutral code move: it mutates exactly the state the inline block did //! (`verdicts`, `journal`, `notifier`, `findings`, `metrics`) and stamps verdicts onto the //! passed-in `chains` in place. The caller re-publishes the enriched chains afterward. +//! +//! **ADR-0034 (JEF-570):** each entry's model call now returns an +//! [`incident::IncidentDecision`] (a 3-value assessment + the engine-resolved cuts it chose +//! from the entry's deterministic menu, built in Phase 1), not the bare legacy +//! [`reason::adjudicate::Verdict`]. The pass folds it two ways: `to_verdict()` derives the +//! `Verdict` every existing rail here — the cache, the re-judge gate, the journal `Breach` +//! line, the notifier, the display — keeps consuming unchanged (ADR-0023 stays intact), while +//! the decision itself is collected and returned so the caller can hand it to +//! [`super::respond::MitigationLedger::reconcile`] (D6/D7): the ledger's desired set is now the +//! model-chosen cuts, not a deterministic insertion. use futures::StreamExt; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use super::{ - Engine, PendingEntry, adj_gate, churn_diag, graph, journal, model, notify, reason, state, + Engine, PendingEntry, adj_gate, churn_diag, graph, journal, model, notify, observe, reason, + state, }; +use reason::adjudicate::incident; impl Engine { /// Run the four-phase adjudication pass over this pass's breach-relevant chains (see the @@ -35,12 +47,15 @@ impl Engine { /// `chain.emit()` log and the `from_chain` fallback) and writes the resolved verdict to the /// shared store the instant it's decided, so the findings snapshot resolves it immediately. /// `pass_now` is the pass's single injected clock, shared with every backoff/breaker decision. + /// Returns this pass's per-entry cut-choice decision (ADR-0034), keyed by entry key, for the + /// ledger reconcile. pub(super) async fn run_adjudication_pass( &mut self, chains: &mut [reason::proof::ProvenChain], graph: &graph::SecurityGraph, + health: &observe::health::HealthReport, pass_now: std::time::Instant, - ) { + ) -> BTreeMap { // Adjudicate (ADR-0013): the model is the JUDGE of every breach-relevant PATH, // always. The deterministic proof winnows to the paths an internet-facing // workload can actually reach (internet → entry → objective); the model then @@ -118,11 +133,18 @@ impl Engine { // block for (see `downstream_workloads`). let downstream = downstream_workloads(&entry, idxs, chains); + // ADR-0034 D4 (JEF-570): the deterministic cut-choice menu for this entry, unioned + // across every one of its objective-chains (see `entry_menu`) — the SAME menu the + // prompt's containment-options section renders and the model's `contain` reply + // resolves against. + let menu = entry_menu(idxs, chains, graph, health); + // Build the entry's delta-aware pending record (prompt + fingerprint + projected // surface) and read its baseline — see [`Engine::prepare_pending`] (ADR-0023 / JEF-350 // / JEF-387). `additive` says whether the delta since the baseline is additive. - let (pending, additive, baseline) = - self.prepare_pending(entry_key, entry, objectives, downstream, idxs, graph, &asn); + let (pending, additive, baseline) = self.prepare_pending( + entry_key, entry, objectives, downstream, idxs, graph, &asn, menu, + ); // The layered re-judge gate (JEF-390 LRU / JEF-391 delta hold / JEF-234 breaker + // backoff / re-judge), decided WITHOUT a model call — see [`adj_gate`]. match adj_gate::classify_adjudication( @@ -164,40 +186,44 @@ impl Engine { // other entries' adjudication in the same pass. let judged_results: Vec<( PendingEntry, - reason::adjudicate::Verdict, + incident::IncidentDecision, std::time::Duration, )> = { let adjudicator = &self.adjudicator; let graph_ref = graph; futures::stream::iter(to_judge.into_iter().map(|pending| async move { let started = std::time::Instant::now(); - let verdict = adjudicator + let decision = adjudicator .judge( &pending.entry, &pending.objectives, graph_ref, &pending.prompt, &pending.downstream, + &pending.menu, ) .await; - (pending, verdict, started.elapsed()) + (pending, decision, started.elapsed()) })) .buffer_unordered(model::model_concurrency()) .collect() .await }; - // Phase 3 — fold each fresh verdict back into the per-entry store (sequential; each - // step mutates the engine). Cache a decisive verdict + clear its backoff/close the - // breaker; arm backoff on an Uncertain; record the model-call latency + outcome. This - // is the SAME bookkeeping the old sequential loop did per fresh call — only the - // dispatch shape (concurrent, above) changed. - for (pending, verdict, elapsed) in judged_results { + // Phase 3 — fold each fresh decision back into the per-entry store (sequential; each + // step mutates the engine). Cache a decisive verdict (derived from the decision, + // ADR-0034) + clear its backoff/close the breaker; arm backoff on an Uncertain; record + // the model-call latency + outcome. This is the SAME bookkeeping the old sequential loop + // did per fresh call — only the dispatch shape (concurrent, above) changed. + for (pending, decision, elapsed) in judged_results { // Time the (slow, CPU-bound) model call so its latency tail is observable in // shadow (JEF-100). Recorded for every fresh call; `result` labels the outcome. self.metrics .model_latency_ms .record(elapsed.as_secs_f64() * 1000.0, &[]); + // The legacy 4-value Verdict every rail below still consumes unchanged (ADR-0023's + // cache/gate/journal/notify/display — see `IncidentDecision::to_verdict`). + let verdict = decision.to_verdict(); // An Uncertain is usually a transient model outage — re-judge later rather than // pin the failure into the cache. Logged at info: on a slow CPU model nearly every // verdict lands here, and a silent inconclusive is indistinguishable from "the @@ -209,10 +235,19 @@ impl Engine { // breaker's failure run, so the next pass does NOT re-judge it immediately. self.verdicts .record_inconclusive(&pending.entry_key, pass_now); + // ADR-0034 D7: a fresh Uncertain retires nothing and cuts nothing — leave + // whatever decision (if any) is already recorded for this entry standing, + // rather than clearing it. Inert both ways. "unavailable" } decisive => { tracing::info!(entry = %pending.entry.0, objectives = pending.objectives.len(), verdict = ?decisive, "adjudicated entry"); + // ADR-0034 D6/D7: record THIS pass's decisive cut-choice decision — the + // ledger reconcile reads it back below. Overwrites any prior decision for + // this entry, so a fresh decisive decision that DROPS a cut is exactly what + // retires it (the ledger's own active-vs-desired diff does the rest). + self.decisions + .insert(pending.entry_key.clone(), decision.clone()); self.verdicts.cache_decisive( &pending.entry_key, pending.fingerprint.clone(), @@ -388,6 +423,16 @@ impl Engine { "adjudication pass (model calls = judged)" ); } + // ADR-0034 D6/D7 (JEF-570): drop decisions for entries that no longer exist this pass + // (mirrors `self.verdicts.retain_present` above) — a stale decision must never outlive + // the entry it was judged for. Every entry STILL present keeps its last DECISIVE + // decision even on a cache-hit/held/backoff pass this cycle (no fresh call ran), which + // is exactly D7's retirement asymmetry: only a fresh decisive decision (Phase 3 above) + // or the entry disappearing here changes what the ledger sees; a this-pass Uncertain + // (skipped/backoff) never clears it. + self.decisions + .retain(|k, _| current_entries.contains(k.as_str())); + self.decisions.clone() } } @@ -414,3 +459,30 @@ fn downstream_workloads( nodes.dedup(); nodes } + +/// The deterministic cut-choice menu for one entry (ADR-0034 D4, JEF-570), unioned across +/// EVERY one of its objective-chains — [`incident::build_menu`] itself takes just one +/// [`reason::proof::ProvenChain`] (an (entry, objective) pair), but the model is judged once +/// PER ENTRY over every objective it reaches, so the menu it's shown must be the union of what +/// each of those chains would offer. Pure data merge (no decision logic duplicated): re-sorts + +/// dedups exactly as `build_menu` does internally, so a node covered by more than one chain +/// (e.g. two objectives sharing a downstream pivot) still appears exactly once. +fn entry_menu( + idxs: &[usize], + chains: &[reason::proof::ProvenChain], + graph: &graph::SecurityGraph, + health: &observe::health::HealthReport, +) -> incident::Menu { + let mut selectable = Vec::new(); + let mut uncontainable = Vec::new(); + for &i in idxs { + let m = incident::build_menu(&chains[i], graph, health); + selectable.extend(m.selectable); + uncontainable.extend(m.uncontainable); + } + incident::normalize_menu(&mut selectable, &mut uncontainable); + incident::Menu { + selectable, + uncontainable, + } +} diff --git a/engine/src/engine/churn_diag.rs b/engine/src/engine/churn_diag.rs index 5ec5d906..9b205c5b 100644 --- a/engine/src/engine/churn_diag.rs +++ b/engine/src/engine/churn_diag.rs @@ -115,6 +115,7 @@ mod tests { chain: "ch1".into(), surface: crate::engine::reason::adjudicate::JudgedSurface::default(), idxs: vec![], + menu: crate::engine::reason::adjudicate::incident::Menu::default(), }; let buf = Arc::new(Mutex::new(Vec::new())); diff --git a/engine/src/engine/journal_tests.rs b/engine/src/engine/journal_tests.rs index 8e1daf6e..c5719cae 100644 --- a/engine/src/engine/journal_tests.rs +++ b/engine/src/engine/journal_tests.rs @@ -10,12 +10,14 @@ use super::*; use crate::engine::graph::attack::AttackRef; use crate::engine::graph::{NodeKey, SecurityGraph}; use crate::engine::reason::adjudicate::Verdict; +use crate::engine::reason::adjudicate::incident::{IncidentDecision, Menu}; use crate::engine::respond::actuator::DryRunActuator; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use super::tests::{ - CountingAdjudicator, FixedAdjudicator, engine_with, engine_with_adjudicator, exposed_snapshot, + CountingAdjudicator, FixedAdjudicator, decision_of, engine_with, engine_with_adjudicator, + exposed_snapshot, }; /// A unique temp journal path for a test, without a temp-file crate. @@ -239,9 +241,10 @@ impl reason::adjudicate::Adjudicator for AlwaysUncertain { _graph: &SecurityGraph, _prompt: &str, _downstream: &[NodeKey], - ) -> Verdict { + _menu: &Menu, + ) -> IncidentDecision { self.0.fetch_add(1, Ordering::SeqCst); - Verdict::Uncertain("model unavailable".into()) + IncidentDecision::uncertain("model unavailable") } } @@ -285,12 +288,13 @@ impl reason::adjudicate::Adjudicator for RecoversAfter { _graph: &SecurityGraph, _prompt: &str, _downstream: &[NodeKey], - ) -> Verdict { + _menu: &Menu, + ) -> IncidentDecision { let n = self.calls.fetch_add(1, Ordering::SeqCst); if n < self.down_for { - Verdict::Uncertain("model unavailable".into()) + IncidentDecision::uncertain("model unavailable") } else { - Verdict::Exploitable("RCE reaches the secret".into()) + decision_of(Verdict::Exploitable("RCE reaches the secret".into())) } } } diff --git a/engine/src/engine/mod.rs b/engine/src/engine/mod.rs index b44914c1..594737cb 100644 --- a/engine/src/engine/mod.rs +++ b/engine/src/engine/mod.rs @@ -118,6 +118,11 @@ struct PendingEntry { /// baseline when this pass judges it decisively, so the next pass measures additions against it. surface: reason::adjudicate::JudgedSurface, idxs: Vec, + /// The deterministic cut-choice menu (ADR-0034 D4, JEF-570) `prompt`'s containment-options + /// section rendered — carried alongside the prompt so [`reason::adjudicate::Adjudicator::judge`] + /// parses/resolves the model's `contain` reply against the EXACT menu the entry's prompt + /// showed (never rebuilt, so the two can never drift). + menu: reason::adjudicate::incident::Menu, } /// The engine's stateful processing core. It owns everything that persists across @@ -176,6 +181,15 @@ pub struct Engine { /// end-of-pass re-publish lag. Pruned to present entries each pass (ephemeral workloads, /// removed exposure). verdicts: std::sync::Arc, + /// This pass's per-entry cut-choice decision (ADR-0034, JEF-570): the model's last + /// DECISIVE [`reason::adjudicate::incident::IncidentDecision`], keyed by entry key. Engine- + /// local (no `Arc`, no reader shares it — only [`respond::MitigationLedger::reconcile`] + /// consumes it, via [`adj_pass::run_adjudication_pass`]'s return value). Retained across a + /// cache-hit/backoff pass exactly like [`Self::verdicts`]'s cache (D7's retirement + /// asymmetry: a this-pass `Uncertain` never clears it); pruned to present entries each pass; + /// NOT persisted (a restart starts empty — every entry cold-re-judges for cuts before any + /// auto-apply, JEF-570's deliberately conservative stand-in for the deferred journal v2). + decisions: std::collections::BTreeMap, /// Per-node agent-liveness (JEF-308), shared with the ingest; classified each pass into the /// runtime-corroboration coverage the readiness row reads. `None` when no ingest is wired. agent_liveness: Option>, @@ -231,6 +245,7 @@ impl Engine { ledger: MitigationLedger::new(), actions: ActionLog::new(), verdicts, + decisions: std::collections::BTreeMap::new(), agent_liveness: None, // Empty until the watch loop attaches the file-backed feed (JEF-380): internet // peers then render as raw `IP:port`, exactly today's pre-feed behavior. @@ -523,8 +538,11 @@ impl Engine { // Adjudicate (ADR-0013): the model is the JUDGE of every breach-relevant path — group // the breach-relevant chains by their internet-facing entry, judge each entry once, and // fold the verdicts back into the store / journal / notifier, stamping each chain in - // place. Extracted whole (JEF-370); see [`adj_pass`] for the four-phase detail. - self.run_adjudication_pass(&mut chains, &graph, pass_now) + // place. Extracted whole (JEF-370); see [`adj_pass`] for the four-phase detail. Returns + // this pass's per-entry cut-choice decisions (ADR-0034, JEF-570), consumed by the ledger + // reconcile below. + let decisions = self + .run_adjudication_pass(&mut chains, &graph, &health, pass_now) .await; // Re-publish the enriched chains — promotions move into remediations, vetoes flip // `adjudicated`, so the disposition is current. JEF-157: the VERDICT is no longer @@ -548,8 +566,9 @@ impl Engine { } } - // Reconcile proposed mitigations against the current chains (Q4 and Q5). - let ledger_delta = self.ledger.reconcile(&chains); + // Reconcile proposed mitigations against the current chains AND this pass's model + // decisions (Q4 and Q5, ADR-0034 D6/D7). + let ledger_delta = self.ledger.reconcile(&chains, &decisions); if !ledger_delta.is_empty() { ledger_delta.emit(); } diff --git a/engine/src/engine/reason/adjudicate/incident/guards_tests.rs b/engine/src/engine/reason/adjudicate/incident/guards_tests.rs index 1dbb6082..17cb47be 100644 --- a/engine/src/engine/reason/adjudicate/incident/guards_tests.rs +++ b/engine/src/engine/reason/adjudicate/incident/guards_tests.rs @@ -15,6 +15,14 @@ fn attack(reason: &str, cuts: Vec) -> IncidentDecision { fn cut(node: NodeKey) -> ChosenCut { ChosenCut { + cut: crate::engine::reason::proof::Link { + from: node.clone(), + to: node.clone(), + relation: "test".to_string(), + technique: None, + from_labels: Default::default(), + to_labels: Default::default(), + }, node, action: ProposedAction::QuarantineWorkload, cut_signature: "sig".to_string(), diff --git a/engine/src/engine/reason/adjudicate/incident/menu.rs b/engine/src/engine/reason/adjudicate/incident/menu.rs index cff767f4..f2464a7d 100644 --- a/engine/src/engine/reason/adjudicate/incident/menu.rs +++ b/engine/src/engine/reason/adjudicate/incident/menu.rs @@ -35,6 +35,11 @@ use super::ChosenCut; pub struct MenuLine { pub node: NodeKey, pub action: ProposedAction, + /// The concrete edge/self-reference this line's mechanism severs (JEF-570) — the SAME + /// `Link` [`containment_for`]/[`quarantine_workload_link`] resolved, carried forward so a + /// caller resolving a model-chosen node (via [`Menu::resolve`]) can build the ledger's own + /// [`crate::engine::respond::Mitigation`] without re-deriving it from `cut_signature` alone. + pub cut: Link, pub cut_signature: String, /// The advisory [`predict_blast_radius`] note for this line — fixed-shape, no /// untrusted text (a responder should weigh collateral before naming a node, but the @@ -68,6 +73,7 @@ impl Menu { .map(|l| ChosenCut { node: l.node.clone(), action: l.action, + cut: l.cut.clone(), cut_signature: l.cut_signature.clone(), }) } @@ -156,6 +162,19 @@ pub fn build_menu(chain: &ProvenChain, graph: &SecurityGraph, health: &HealthRep } } + normalize(&mut selectable, &mut uncontainable); + Menu { + selectable, + uncontainable, + } +} + +/// Sort + dedup a menu's two lists into the canonical shape [`Menu`] always carries, and drop +/// any uncontainable entry a selectable line also covers. Shared by [`build_menu`] and by the +/// caller that unions several chains' menus into one per-entry menu (an entry judged over +/// several objectives has several [`ProvenChain`]s, JEF-570) — so the SAME normalization runs +/// whether a menu comes from one chain or several, and the two can never drift. +pub(crate) fn normalize(selectable: &mut Vec, uncontainable: &mut Vec) { selectable.sort_by(|a, b| a.node.cmp(&b.node)); selectable.dedup_by(|a, b| a.node == b.node); uncontainable.sort(); @@ -163,11 +182,6 @@ pub fn build_menu(chain: &ProvenChain, graph: &SecurityGraph, health: &HealthRep // Defensive: a node can never be both selectable and uncontainable, but keep the // selectable line authoritative if a chain ever produced a redundant target entry. uncontainable.retain(|n| !selectable.iter().any(|l| &l.node == n)); - - Menu { - selectable, - uncontainable, - } } /// Resolve one menu line: the cut signature and the advisory blast-radius note, built the @@ -191,6 +205,7 @@ fn menu_line( node, action, cut_signature: mitigation.cut_signature(), + cut: mitigation.cut, blast_note: blast_note(&blast), } } diff --git a/engine/src/engine/reason/adjudicate/incident/mod.rs b/engine/src/engine/reason/adjudicate/incident/mod.rs index a44918ee..1f8ff246 100644 --- a/engine/src/engine/reason/adjudicate/incident/mod.rs +++ b/engine/src/engine/reason/adjudicate/incident/mod.rs @@ -23,6 +23,7 @@ //! all downgrade to `Uncertain`, never `Refuted`, never hide evidence. use crate::engine::graph::NodeKey; +use crate::engine::reason::proof::Link; use crate::engine::respond::ProposedAction; /// The model's 3-value call on a proven incident (ADR-0034 D1/D2). Collapses the old @@ -51,6 +52,10 @@ pub enum Assessment { pub struct ChosenCut { pub node: NodeKey, pub action: ProposedAction, + /// The concrete edge/self-reference this cut severs — the SAME [`Link`] the menu itself + /// resolved (JEF-570), so a caller (the ledger's `reconcile`) can build the mitigation + /// directly rather than re-deriving a `Link` from the signature string. + pub cut: Link, /// The stable cut identity ([`crate::engine::respond::cut_signature`]) — the ledger's /// and journal's key for this cut (ADR-0034 D6/D8, JEF-570). pub cut_signature: String, @@ -78,6 +83,26 @@ impl IncidentDecision { cuts: Vec::new(), } } + + /// Bridge to the legacy 4-value [`super::Verdict`] (JEF-570), so the ADR-0023 verdict + /// cache / re-judge gate / breaker+backoff / journal `Breach` line / dashboard / notifier — + /// every rail this ticket must leave intact — keep consuming exactly the type they already + /// do, unchanged. `Confirmed` is never produced here: it collapsed into `Attack` + /// (ADR-0034 D2 — the `Confirmed`-vs-`Exploitable` split encoded a DETERMINISTIC fact, + /// `ProvenChain::corroborated`, that never needed the model to restate it), which is why + /// `Verdict::is_confirmed()`/`promotes()` — the only two predicates the caching/promotion + /// logic reads — are preserved bit-for-bit: `is_confirmed()` is true for `Confirmed` OR + /// `Exploitable`, and `promotes()` true only for `Exploitable`, so mapping every `Attack` + /// uniformly to `Exploitable` changes neither. `Confirmed` remains a valid `Verdict` value + /// only for backward-reading an old journal line (JEF-301 replay) written before this + /// ticket landed. + pub fn to_verdict(&self) -> super::Verdict { + match self.assessment { + Assessment::Attack => super::Verdict::Exploitable(self.reason.clone()), + Assessment::NoAttack => super::Verdict::Refuted(self.reason.clone()), + Assessment::Uncertain => super::Verdict::Uncertain(self.reason.clone()), + } + } } mod guards; @@ -87,6 +112,7 @@ mod parse; pub use guards::{ guard_assessment_cuts_consistency, guard_containment_grounding, guard_fabrication, }; +pub(crate) use menu::normalize as normalize_menu; pub use menu::{Menu, MenuLine, build_menu}; pub use parse::parse_incident_decision; diff --git a/engine/src/engine/reason/adjudicate/incident/parse_tests.rs b/engine/src/engine/reason/adjudicate/incident/parse_tests.rs index c6c179ce..4ff28817 100644 --- a/engine/src/engine/reason/adjudicate/incident/parse_tests.rs +++ b/engine/src/engine/reason/adjudicate/incident/parse_tests.rs @@ -3,9 +3,18 @@ use super::*; use crate::engine::respond::ProposedAction; fn line(node: &str, action: ProposedAction) -> MenuLine { + let key = NodeKey(node.to_string()); MenuLine { - node: NodeKey(node.to_string()), + node: key.clone(), action, + cut: crate::engine::reason::proof::Link { + from: key.clone(), + to: key, + relation: "test".to_string(), + technique: None, + from_labels: Default::default(), + to_labels: Default::default(), + }, cut_signature: format!("sig:{node}"), blast_note: "blast radius: no alive collateral".to_string(), } diff --git a/engine/src/engine/reason/adjudicate/mod.rs b/engine/src/engine/reason/adjudicate/mod.rs index 49e1e2d7..d534756b 100644 --- a/engine/src/engine/reason/adjudicate/mod.rs +++ b/engine/src/engine/reason/adjudicate/mod.rs @@ -110,6 +110,10 @@ pub trait Adjudicator: Send + Sync { /// `downstream` is the same deduped, sorted set of workload [`NodeKey`]s on the entry's /// proven paths that `prompt` renders a per-node evidence block for (JEF-565) — so an /// implementation's own backstops can weigh downstream evidence exactly as the prompt does. + /// `menu` is the deterministic cut-choice menu (ADR-0034 D4) the caller built for this exact + /// entry — the SAME menu `prompt`'s containment-options section renders — so an + /// implementation parses its reply's `contain` against it and resolves each surviving node + /// key through it (never carrying a cut as model text, ADR-0034 D1). async fn judge( &self, entry: &NodeKey, @@ -117,11 +121,16 @@ pub trait Adjudicator: Send + Sync { graph: &SecurityGraph, prompt: &str, downstream: &[NodeKey], - ) -> Verdict; + menu: &incident::Menu, + ) -> incident::IncidentDecision; } -/// The default: confirm everything. Absent a model the deterministic action bar -/// alone governs — behaviour is unchanged, no veto is applied. +/// The default: no decisive decision. Absent a model there is no cut-choosing analyst to +/// consult, so every breach-relevant entry it covers falls through to the ADR-0034 D6 +/// human-proposal fallback (`containment_for`, stamped `adjudicated=false`) — never an +/// auto-applied cut. This RETIRES the pre-ADR-0034 "confirm everything, the deterministic +/// action bar alone governs" behavior: that bar (the `quarantine_targets`/`containment_for` +/// desired-set insertion) is no longer an auto-action path at all without a live model. pub struct NullAdjudicator; #[async_trait::async_trait] @@ -133,8 +142,11 @@ impl Adjudicator for NullAdjudicator { _graph: &SecurityGraph, _prompt: &str, _downstream: &[NodeKey], - ) -> Verdict { - Verdict::Confirmed + _menu: &incident::Menu, + ) -> incident::IncidentDecision { + incident::IncidentDecision::uncertain( + "no adjudicator configured — a model is required for a cut decision", + ) } } @@ -153,9 +165,9 @@ mod surface; pub use evidence::{EntryCoverage, entry_coverage}; pub use model_call::ModelAdjudicator; pub use prompt::{ - DeltaBuild, PromptSections, build_delta_prompt_asn, build_judgment_prompt, - build_judgment_prompt_with_asn, build_judgment_prompt_with_sections_asn, chain_shape_hash, - parse_verdict, prompt_cache_key, + DeltaBuild, PromptSections, build_delta_prompt_asn, build_delta_prompt_with_menu_asn, + build_judgment_prompt, build_judgment_prompt_with_asn, build_judgment_prompt_with_sections_asn, + chain_shape_hash, parse_verdict, prompt_cache_key, }; pub use surface::JudgedSurface; // The cross-module helpers the rest of the crate imports by the stable diff --git a/engine/src/engine/reason/adjudicate/model_call.rs b/engine/src/engine/reason/adjudicate/model_call.rs index d43bcc5d..9efde23e 100644 --- a/engine/src/engine/reason/adjudicate/model_call.rs +++ b/engine/src/engine/reason/adjudicate/model_call.rs @@ -8,11 +8,12 @@ use crate::engine::graph::attack::AttackRef; use crate::engine::graph::{Behavior, NodeKey, SecurityGraph}; -use super::evidence::{cve_ids_of, entry_evidence, entry_findings, retain_reachable_cves}; -use super::guards::{ - guard_fabricated_cve, guard_fabricated_reachability_tag, guard_unsupported_exploitable, +use super::evidence::{entry_evidence, entry_findings, retain_reachable_cves}; +use super::guards::guard_unsupported_exploitable; +use super::incident::{ + Assessment, IncidentDecision, Menu, guard_assessment_cuts_consistency, + guard_containment_grounding, guard_fabrication, parse_incident_decision, }; -use super::prompt::parse_verdict; use super::{Adjudicator, Verdict}; /// The downstream counterpart of the entry's own evidence fetch below (JEF-565): every @@ -69,20 +70,22 @@ impl ModelAdjudicator { self } - /// Record a judgement into the diagnostic log, if one is attached. + /// Record a judgement into the diagnostic log, if one is attached. Logs the legacy + /// [`Verdict`] shape (via [`IncidentDecision::to_verdict`]) so the diagnostic record's + /// format is unchanged (JEF-570) — the cuts themselves are the caller's `judge` return. fn record_judgement( &self, entry: &NodeKey, objectives: usize, prompt: Option, reply: Option, - verdict: &Verdict, + decision: &IncidentDecision, ) { if let Some(journal) = &self.journal { journal.record(crate::engine::state::Judgement { entry: entry.0.clone(), objectives, - verdict: format!("{verdict:?}"), + verdict: format!("{:?}", decision.to_verdict()), prompt, reply, }); @@ -90,6 +93,41 @@ impl ModelAdjudicator { } } +/// ADR-0034 D5 (grandfathered): the zero-anchor backstop +/// ([`guard_unsupported_exploitable`]), reused UNCHANGED by round-tripping an `Attack` +/// decision through the legacy [`Verdict`] shape it operates on — no reimplementation. A +/// zero-anchor `Attack` (no CVE, no exposed secret, no corroborating behavior anywhere in the +/// entry+downstream evidence this call already fetched) downgrades all the way to `NoAttack` +/// (the old `Refuted`), never merely `Uncertain`: reachability alone was never a breach, so +/// there is nothing here to re-judge later, exactly the pre-ADR-0034 behavior. Every other +/// assessment passes through untouched — this only ever narrows an `Attack`. +fn guard_zero_anchor( + decision: IncidentDecision, + cves: &[String], + behaviors: &[Behavior], + has_exposed_secret: bool, +) -> IncidentDecision { + if decision.assessment != Assessment::Attack { + return decision; + } + let verdict = guard_unsupported_exploitable( + Verdict::Exploitable(decision.reason.clone()), + cves, + behaviors, + has_exposed_secret, + ); + match verdict { + Verdict::Refuted(reason) => IncidentDecision { + assessment: Assessment::NoAttack, + reason, + cuts: Vec::new(), + }, + // `guard_unsupported_exploitable` only ever returns the verdict unchanged or + // downgrades it to `Refuted` — no other arm is reachable. + _ => decision, + } +} + #[async_trait::async_trait] impl Adjudicator for ModelAdjudicator { #[tracing::instrument( @@ -104,7 +142,8 @@ impl Adjudicator for ModelAdjudicator { graph: &SecurityGraph, prompt: &str, downstream: &[NodeKey], - ) -> Verdict { + menu: &Menu, + ) -> IncidentDecision { // Fetch the entry's evidence ONCE for the two anti-fabrication backstops. JEF-134: // the deterministic layer PROVES + ENRICHES only — there is no pre-call decision // filter and no deterministic promotion-ground gate. EVERY breach-relevant entry's @@ -133,49 +172,47 @@ impl Adjudicator for ModelAdjudicator { // JEF-350: the caller already built this exact prompt to derive the verdict-cache key // (its hash); reuse those bytes for the model call rather than rebuilding, so the input // the cache keyed on and the input the model sees can never drift. - let (reply, verdict) = + let (reply, decision) = match crate::engine::model::chat(&self.client, &self.endpoint, &self.model, prompt) .await { - // The sole deterministic backstop on a promotion is anti-fabrication (JEF-79): - // a fabricated CVE citation can never auto-promote (→ skeptic). This is NOT a - // breach-decision gate — it only ensures the model cannot cite a CVE absent - // from the real evidence. A genuine `Exploitable` (a real CVE, or a non-CVE - // step that cites no CVE) passes through untouched. Some(reply) => { - // Two deterministic backstops, chained, both only ever acting on an - // `Exploitable` verdict: anti-fabrication first (a cited CVE absent from the - // evidence → skeptic), then the symmetric zero-anchor net (an `Exploitable` - // with NO CVE, NO exposed secret, and NO corroborating runtime behavior → - // `Refuted`, since reachability is not a breach — the watcher-server false - // 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, - &behaviors, - has_exposed_secret, - ); - (Some(reply), verdict) + // The tolerant, skeptic-default parser (ADR-0034 D3): unparseable/out-of- + // range/non-member `contain` all degrade to Uncertain, no cuts — the + // membership check against `menu` is the structural grounding guard. + let decision = parse_incident_decision(&reply, menu); + // The ADR-0034 D5 grounding guards, chained (all downgrade to `Uncertain`, + // never `Refuted`/carry a hidden line of evidence): a contained downstream + // node with no exploitation evidence of its own, then the reused + // anti-fabrication backstops (a fabricated CVE id, or a fabricated + // `[reachability: loaded-at-runtime]` tag — JEF-451 G1) over the + // entry+downstream evidence union, then the assessment↔cuts consistency + // check (idempotent — the parser already enforces it). + let decision = guard_containment_grounding(decision, graph, entry); + let decision = guard_fabrication(decision, graph, entry, downstream); + let decision = guard_assessment_cuts_consistency(decision); + // Grandfathered zero-anchor backstop (ADR-0029 scope note): an `Attack` + // resting on NO anchor at all (no CVE, no exposed secret, no corroborating + // behavior anywhere in the entry+downstream evidence) downgrades all the way + // to `NoAttack`, not merely `Uncertain` — reachability alone was never a + // breach, so there is nothing to re-judge later (the watcher-server false + // breach this backstop was built for). + let decision = + guard_zero_anchor(decision, &cves, &behaviors, has_exposed_secret); + (Some(reply), decision) } // Model unavailable → skeptic: do not let an auto-action proceed. - None => (None, Verdict::Uncertain("model unavailable".to_string())), + None => (None, IncidentDecision::uncertain("model unavailable")), }; - // Capture the prompt the model saw, its raw reply, and the guarded verdict so an - // `exploitable` call can be diagnosed from the judgement record (JEF diagnostic). + // Capture the prompt the model saw, its raw reply, and the guarded decision so an + // `attack` call can be diagnosed from the judgement record (JEF diagnostic). self.record_judgement( entry, objectives.len(), Some(prompt.to_string()), reply, - &verdict, + &decision, ); - verdict + decision } } diff --git a/engine/src/engine/reason/adjudicate/prompt.rs b/engine/src/engine/reason/adjudicate/prompt.rs index ba2c10e6..cdfd6665 100644 --- a/engine/src/engine/reason/adjudicate/prompt.rs +++ b/engine/src/engine/reason/adjudicate/prompt.rs @@ -16,9 +16,20 @@ use super::evidence::{ entry_evidence, entry_findings, objective_outcome, render_behavior_lines, retain_reachable_cves, }; use super::guards::{fence, fence_list, ns_marker, objective_reach, sanitize}; +use super::incident::Menu; use super::surface::{ChangesSince, JudgedSurface}; use crate::engine::observe::asn::AsnDb; +/// The empty containment-options section ("(none)") that every non-live-path prompt builder +/// below renders (JEF-570): [`build_judgment_prompt`] and its siblings are kept for tests and +/// callers that only want the entry-scoped evidence prompt (mirrors the existing "no +/// downstream nodes" precedent those same builders already document for JEF-565) — only the +/// live engine's [`build_delta_prompt_with_menu_asn`] renders a REAL menu, built from the +/// entry's actual proven chains. +fn no_menu() -> String { + Menu::default().render() +} + /// Build the adjudication prompt — framed as the on-call security analyst whose job /// this model replaces (ADR-0011/0013): make the call a human would, don't hedge. The /// evidence is fenced as untrusted data so a malicious CVE id / rule name / node key @@ -116,7 +127,9 @@ pub(super) fn build_judgment_prompt_with( let ev = render_evidence(entry, objectives, graph, cves, behaviors, asn, &[]); // Empty `changes_block` ⇒ byte-identical to the pre-ADR-0023 full-state prompt (the // non-delta callers/tests). The delta path passes the rendered "Changes since…" section. - assemble(entry, &ev, "") + // No real menu (JEF-570, see `no_menu`): these callers don't have a proven chain to build + // one from. + assemble(entry, &ev, "", &no_menu()) } /// The delta-aware prompt build (ADR-0023, JEF-391): the FULL-state prompt PLUS the "Changes @@ -141,6 +154,58 @@ pub fn build_delta_prompt_asn( asn: &AsnDb, baseline: Option<&JudgedSurface>, downstream: &[NodeKey], +) -> DeltaBuild { + // No real menu (JEF-570, see `no_menu`): kept for tests/callers that don't have a proven + // chain (and so no containment options) to build one from. The live engine calls + // [`build_delta_prompt_with_menu_asn`] instead. + build_delta_prompt_inner( + entry, + objectives, + graph, + asn, + baseline, + downstream, + &no_menu(), + ) +} + +/// As [`build_delta_prompt_asn`], but with the deterministic cut-choice menu (ADR-0034 D4) +/// spliced into the prompt (JEF-570) — the live engine's ONLY delta-prompt entry point. +/// `menu` is built ONCE by the caller (from this entry's proven chains) and rendered via +/// [`Menu::render`] into the SAME containment-options section [`incident::parse_incident_decision`] +/// resolves `contain` against — the render is deterministic (sorted, deduped, same snapshot), +/// so it is part of the full-state prompt exactly like every other section: a mapping change is +/// a prompt change is a re-judge (the ADR-0023 `prompt_cache_key`/delta gate covers it +/// unchanged, since it hashes the assembled prompt bytes, menu included). +pub fn build_delta_prompt_with_menu_asn( + entry: &NodeKey, + objectives: &[(NodeKey, AttackRef)], + graph: &SecurityGraph, + asn: &AsnDb, + baseline: Option<&JudgedSurface>, + downstream: &[NodeKey], + menu: &Menu, +) -> DeltaBuild { + build_delta_prompt_inner( + entry, + objectives, + graph, + asn, + baseline, + downstream, + &menu.render(), + ) +} + +#[allow(clippy::too_many_arguments)] +fn build_delta_prompt_inner( + entry: &NodeKey, + objectives: &[(NodeKey, AttackRef)], + graph: &SecurityGraph, + asn: &AsnDb, + baseline: Option<&JudgedSurface>, + downstream: &[NodeKey], + menu: &str, ) -> DeltaBuild { let (cves, behaviors) = entry_evidence(graph, entry); let ev = render_evidence(entry, objectives, graph, &cves, &behaviors, asn, downstream); @@ -171,10 +236,10 @@ pub fn build_delta_prompt_asn( // true EXACT-STATE guard (an identical full state always HITS, restart-safe with JEF-301) and // leaves the surface-delta gate as the sole ADDITIVE re-judge driver. `sections` (JEF-387) are // likewise full-state only, so they never depend on `changes`. - let (state_prompt, sections) = assemble(entry, &ev, ""); + let (state_prompt, sections) = assemble(entry, &ev, "", menu); let cache_key = prompt_cache_key(&state_prompt); // The prompt SENT to the model carries the full state PLUS the delta section (attention). - let prompt = assemble(entry, &ev, &render_changes_block(&changes)).0; + let prompt = assemble(entry, &ev, &render_changes_block(&changes), menu).0; DeltaBuild { prompt, cache_key, @@ -306,11 +371,15 @@ fn render_evidence( /// Assemble the final prompt + per-section fingerprints from rendered `ev`. `changes_block` is /// spliced in after the reachable-objectives list: empty (`""`) for the full-state prompt (the /// non-delta callers — byte-identical to the pre-ADR-0023 prompt), or the rendered "Changes -/// since…" section for the delta build. +/// since…" section for the delta build. `menu` is the deterministic cut-choice containment- +/// options section (ADR-0034 D4/D9, JEF-570) — ALWAYS rendered, immediately before the output +/// instruction (recency maximizes copy fidelity); `" (none)"` (see `no_menu`) for a caller with +/// no proven chain to build one from. fn assemble( entry: &NodeKey, ev: &RenderedEvidence, changes_block: &str, + menu: &str, ) -> (String, PromptSections) { // No cap on objectives: the model judges every reachable objective. Truncating to a // summary ("+N more") hid the full reach from the judge; a broad front door (argo: ~110 @@ -395,13 +464,18 @@ Reachable objectives (each states the OUTCOME an attacker achieves by reaching i Downstream evidence on this entry's proven paths (JEF-565) — every workload the entry can reach along a PROVEN path, each with its OWN CVE/secret/behavior evidence: its secret/behavior evidence is the SAME exploitation-evidence bar as the entry's own fields above, but its CVE evidence is CONTEXT/SEVERITY ONLY (that node's vulnerability surface IF it were ever popped), never a breach driver by itself — see above. A "no evidence observed" workload carries none of any of this. {downstream} -Decide: - "exploitable" — a reached objective WITH exploitation evidence: a CVE in the ENTRY's "observed loading at runtime" list above, an alert/hands-on-keyboard runtime signal (entry OR downstream), a credential listed in the (non-empty) "Exposed secrets baked into this image" field (entry OR downstream). A downstream workload's OWN loaded-at-runtime CVE is NEVER, by itself, exploitation evidence — it is that node's severity/context only. - "refuted" — the CVE list is "(none)" (no vulnerable code observed running), no live signal, and no exposed secret in that field: NOT a breach, however broad, cross-tenant, high-impact, or cross-namespace the reach, however many reachable secret objectives, and however many misconfig/RBAC posture findings. - "confirmed" — ONLY an already-in-progress attack corroborated by a live alert / hands-on-keyboard signal that should stand. A CVE observed loading at runtime, or an exposed secret in the field, is "exploitable", NEVER "confirmed". - "uncertain" — ONLY when the evidence is self-contradictory or unintelligible. Absence of evidence is NOT uncertainty: an empty CVE list, no live signal, and no exposed secret is a confident "refuted", not "uncertain". +You are ALSO the incident responder (ADR-0032/0034): if this is a breach, decide which workloads on this proven path must be CONTAINED, at minimum scope. A workload is compromised only with EXPLOITATION EVIDENCE ON THAT WORKLOAD (the same three-anchor bar above, entry or downstream) — reaching it, however broadly, is never by itself a reason to contain it. A downstream workload's OWN loaded-at-runtime CVE is NEVER, by itself, exploitation evidence — it is that node's severity/context only, never a reason to contain it alone. +Absence of evidence anywhere on the path is a confident "no_attack", not "uncertain" — reserve "uncertain" for self-contradictory or unintelligible evidence only. + +Containment options — each line is a reversible cut you MAY choose; name its node key in "contain" to apply it. Choose the FEWEST that stop the breach; [] to leave everything running: +{menu} -Output ONLY this JSON: {{"verdict": "exploitable"|"confirmed"|"refuted"|"uncertain", "reason": "one sentence on what made it a breach or not"}}. If you say "exploitable" citing a CVE, that CVE id MUST appear VERBATIM in the CVE list above or in a downstream workload's block below — never invent, recall, or copy a CVE id from anywhere else; if both are "(none)", do not name any CVE."#, +Output ONLY a JSON object with exactly three keys: "assessment" (one of "attack", "no_attack", "uncertain"), "reason" (one sentence on what makes it a breach or not), and "contain" (a JSON array of workload node keys copied EXACTLY from the Containment options above — never invent, recall, or copy a node key from anywhere else). +Fill "contain" with EXACTLY the compromised workloads — every workload whose OWN evidence shows exploitation, and no others: + - a compromised workload IS: the entry with a loaded-at-runtime CVE, a live alert/hands-on-keyboard signal, or an exposed secret; OR a downstream workload with a live alert/hands-on-keyboard signal or an exposed secret. + - do NOT add an uncompromised workload — in particular a CLEAN entry that is merely the path to a compromised downstream stays running (name the downstream, not the entry). + - do NOT omit a compromised one. +If "assessment" is "attack", "contain" MUST name at least the workload that carries the evidence — an "attack" with an empty "contain" is contradictory and wrong. If "assessment" is "no_attack" or "uncertain", "contain" MUST be []."#, entry = fence(&entry.0), cves = fence_list(&ev.cves), secrets = fence_list(&ev.secret_lines), @@ -414,6 +488,7 @@ Output ONLY this JSON: {{"verdict": "exploitable"|"confirmed"|"refuted"|"uncerta } else { ev.downstream.blocks.join("\n") }, + menu = menu, ); (prompt, sections) } diff --git a/engine/src/engine/reason/adjudicate/tests/group_1.rs b/engine/src/engine/reason/adjudicate/tests/group_1.rs index 4cab3e48..e6be1467 100644 --- a/engine/src/engine/reason/adjudicate/tests/group_1.rs +++ b/engine/src/engine/reason/adjudicate/tests/group_1.rs @@ -633,8 +633,9 @@ fn prompt_includes_the_chain_evidence() { "names the runtime signal" ); assert!( - prompt.contains("refuted"), - "offers the skeptic refuted verdict" + prompt.contains("no_attack"), + "offers the skeptic no_attack assessment (ADR-0034 D2: the 4-value verdict collapsed \ + to a 3-value assessment)" ); // JEF-51: the CVE is tagged with its reachability (here loaded-at-runtime — the only // reachability the JEF-453 filter shows the judge). @@ -682,18 +683,18 @@ fn prompt_includes_the_chain_evidence() { && prompt.contains("cross-namespace"), "frames breach as exploitation evidence only — reachability (incl. cross-namespace) is severity, not a breach" ); - // Calibration: the verdict menu now FORBIDS the `uncertain` escape hatch for - // absence-of-evidence. The argocd-server false-uncertain reason ("No exposed secrets, - // no live runtime signals, and no critical CVEs running") was a textbook refute; the - // strengthened menu line directs that case to `refuted`, reserving `uncertain` for + // Calibration: the assessment instruction now FORBIDS the `uncertain` escape hatch for + // absence-of-evidence (ADR-0034's ancestor: the argocd-server false-uncertain reason "No + // exposed secrets, no live runtime signals, and no critical CVEs running" was a textbook + // `no_attack`); the instruction directs that case to `no_attack`, reserving `uncertain` for // self-contradictory / unintelligible evidence only. assert!( prompt.contains( - "\"uncertain\" — ONLY when the evidence is self-contradictory or unintelligible. \ - Absence of evidence is NOT uncertainty: an empty CVE list, no live signal, \ - and no exposed secret is a confident \"refuted\", not \"uncertain\"." + "Absence of evidence anywhere on the path is a confident \"no_attack\", not \ + \"uncertain\" — reserve \"uncertain\" for self-contradictory or unintelligible \ + evidence only." ), - "absence of evidence is directed to refuted, not the uncertain escape hatch" + "absence of evidence is directed to no_attack, not the uncertain escape hatch" ); } diff --git a/engine/src/engine/reason/adjudicate/tests/group_2.rs b/engine/src/engine/reason/adjudicate/tests/group_2.rs index 72968cb4..6536b4a1 100644 --- a/engine/src/engine/reason/adjudicate/tests/group_2.rs +++ b/engine/src/engine/reason/adjudicate/tests/group_2.rs @@ -18,8 +18,13 @@ use crate::engine::reason::proof::{ProvenChain, prove}; use serde_json::json; use std::time::SystemTime; +/// ADR-0034 (JEF-570): absent a model there is no cut-choosing analyst to consult, so +/// `NullAdjudicator` is now `Uncertain` (no decisive decision) rather than the old unconditional +/// `Confirmed` — every breach-relevant entry it covers falls through to the D6 human-proposal +/// fallback, never an auto-applied cut (retires the pre-ADR-0034 "deterministic action bar +/// alone governs" behavior). #[tokio::test] -async fn null_adjudicator_confirms() { +async fn null_adjudicator_is_uncertain() { let graph = build_graph(&Snapshot::default(), &default_adapters()); let chain = ProvenChain { entry: NodeKey("workload/app/Pod/x".into()), @@ -37,13 +42,19 @@ async fn null_adjudicator_confirms() { single_edge_cuts: vec![], quarantine_targets: vec![], }; - assert_eq!( - NullAdjudicator - // NullAdjudicator ignores the prompt (it confirms unconditionally). - .judge(&chain.entry, &objectives_of(&chain), &graph, "", &[]) - .await, - Verdict::Confirmed - ); + let decision = NullAdjudicator + // NullAdjudicator ignores the prompt/menu (it never has a decisive decision). + .judge( + &chain.entry, + &objectives_of(&chain), + &graph, + "", + &[], + &incident::Menu::default(), + ) + .await; + assert_eq!(decision.assessment, incident::Assessment::Uncertain); + assert!(decision.cuts.is_empty()); } /// JEF-134: the deterministic pre-decision is GONE. An entry that under the old @@ -71,9 +82,11 @@ async fn every_breach_relevant_entry_is_handed_to_the_model() { // `Uncertain("model unavailable")` instead, proving the model IS consulted. let adjudicator = ModelAdjudicator::new("http://127.0.0.1:1/v1/chat/completions", "none"); let prompt = build_judgment_prompt(&entry, &objs, &g); - let verdict = adjudicator.judge(&entry, &objs, &g, &prompt, &[]).await; + let decision = adjudicator + .judge(&entry, &objs, &g, &prompt, &[], &incident::Menu::default()) + .await; assert_eq!( - verdict, + decision.to_verdict(), Verdict::Uncertain("model unavailable".to_string()), "the engine no longer pre-decides — every breach-relevant entry reaches the model" ); @@ -95,13 +108,24 @@ async fn judgements_are_journaled_with_prompt_and_verdict() { // engine builds the prompt (JEF-350) and hands it to `judge`; mirror that here. let (g, entry, objs) = entry_reaching_db("app", "app", "postgres-0", DATA_FROM_REPOSITORY); let prompt = build_judgment_prompt(&entry, &objs, &g); - adjudicator.judge(&entry, &objs, &g, &prompt, &[]).await; + adjudicator + .judge(&entry, &objs, &g, &prompt, &[], &incident::Menu::default()) + .await; // A cross-ns entry — also judged. let (g2, entry2, objs2) = entry_reaching_db("app", "billing", "ledger-db", DATA_FROM_REPOSITORY); let prompt2 = build_judgment_prompt(&entry2, &objs2, &g2); - adjudicator.judge(&entry2, &objs2, &g2, &prompt2, &[]).await; + adjudicator + .judge( + &entry2, + &objs2, + &g2, + &prompt2, + &[], + &incident::Menu::default(), + ) + .await; let recorded = journal.snapshot(); // newest-first assert_eq!(recorded.len(), 2, "both judgements captured"); @@ -195,17 +219,43 @@ async fn real_model_judges_toxic_vs_unevidenced() { let (g_toxic, toxic) = exposed_chain(true); let toxic_objs = objectives_of(&toxic); let toxic_prompt = build_judgment_prompt(&toxic.entry, &toxic_objs, &g_toxic); - let toxic_verdict = adjudicator - .judge(&toxic.entry, &toxic_objs, &g_toxic, &toxic_prompt, &[]) + let toxic_menu = incident::build_menu( + &toxic, + &g_toxic, + &crate::engine::observe::health::HealthReport::default(), + ); + let toxic_decision = adjudicator + .judge( + &toxic.entry, + &toxic_objs, + &g_toxic, + &toxic_prompt, + &[], + &toxic_menu, + ) .await; + let toxic_verdict = toxic_decision.to_verdict(); eprintln!("[{model}] exposed + critical KEV CVE -> secret : {toxic_verdict:?}"); let (g_bare, bare) = exposed_chain(false); let bare_objs = objectives_of(&bare); let bare_prompt = build_judgment_prompt(&bare.entry, &bare_objs, &g_bare); - let bare_verdict = adjudicator - .judge(&bare.entry, &bare_objs, &g_bare, &bare_prompt, &[]) + let bare_menu = incident::build_menu( + &bare, + &g_bare, + &crate::engine::observe::health::HealthReport::default(), + ); + let bare_decision = adjudicator + .judge( + &bare.entry, + &bare_objs, + &g_bare, + &bare_prompt, + &[], + &bare_menu, + ) .await; + let bare_verdict = bare_decision.to_verdict(); eprintln!("[{model}] exposed, NO cve / NO runtime -> secret: {bare_verdict:?}"); // A competence probe for "can this model be the analyst" — the speculative @@ -324,8 +374,16 @@ async fn real_model_judges_toxic_vs_unevidenced() { } let prompt = build_judgment_prompt(&entry_key, &objectives, &g); adjudicator - .judge(&entry_key, &objectives, &g, &prompt, &[]) + .judge( + &entry_key, + &objectives, + &g, + &prompt, + &[], + &incident::Menu::default(), + ) .await + .to_verdict() }; eprintln!("[{model}] argo: broad RBAC-granted secrets, NO cve/behavior: {argo_verdict:?}"); assert!( diff --git a/engine/src/engine/reason/adjudicate/tests/group_3.rs b/engine/src/engine/reason/adjudicate/tests/group_3.rs index d22aae01..090ea3b2 100644 --- a/engine/src/engine/reason/adjudicate/tests/group_3.rs +++ b/engine/src/engine/reason/adjudicate/tests/group_3.rs @@ -51,11 +51,12 @@ fn oversized_fence_laden_title_stays_bounded_and_fence_intact() { // The whole prompt is small despite the megabyte input — the cap bounds it hard. The // bound is on the UNTRUSTED payload, not the static template (the floor here is the - // ~5.5 KB static prompt after the JEF-402 grounding-rule wording + the per-field-capped - // title); a megabyte of title would blow past this by orders of magnitude if the cap - // failed, so the assertion still proves the payload is capped, not the template. + // ~6 KB static prompt after the JEF-402 grounding-rule wording + the ADR-0034 cut-choice + // instruction + an empty containment-options menu + the per-field-capped title); a + // megabyte of title would blow past this by orders of magnitude if the cap failed, so the + // assertion still proves the payload is capped, not the template. assert!( - prompt.len() < 8_000, + prompt.len() < 9_000, "prompt must stay bounded; was {} bytes", prompt.len() ); diff --git a/engine/src/engine/reason/proof/pivot_quarantine_tests.rs b/engine/src/engine/reason/proof/pivot_quarantine_tests.rs index 4e2205c8..04dc7221 100644 --- a/engine/src/engine/reason/proof/pivot_quarantine_tests.rs +++ b/engine/src/engine/reason/proof/pivot_quarantine_tests.rs @@ -67,7 +67,9 @@ fn critical_image(image: &str) -> crate::engine::observe::ImageVulnerabilities { /// `store` mounts a secret (the objective) and runs a critical-CVE image; `runtime` /// is the caller's to vary — empty for "no live evidence", or one alarming signal for /// the actively-exploited contrast case. -fn web_reaches_pivot_store_with_runtime(runtime: Vec) -> Vec { +fn web_reaches_pivot_store_with_runtime( + runtime: Vec, +) -> (crate::engine::graph::SecurityGraph, Vec) { let web = pod(json!({ "apiVersion": "v1", "kind": "Pod", "metadata": {"name": "web", "namespace": "app", "labels": {"role": "web"}}, @@ -105,12 +107,14 @@ fn web_reaches_pivot_store_with_runtime(runtime: Vec) -> Vec runtime_events: runtime, ..Default::default() }; - prove(&build_graph(&snap, &default_adapters())) + let graph = build_graph(&snap, &default_adapters()); + let chains = prove(&graph); + (graph, chains) } /// [`web_reaches_pivot_store_with_runtime`] with **no runtime events at all** — no alert, /// no notable exec, no alarming write. -fn web_reaches_pivot_store() -> Vec { +fn web_reaches_pivot_store() -> (crate::engine::graph::SecurityGraph, Vec) { web_reaches_pivot_store_with_runtime(Vec::new()) } @@ -121,6 +125,38 @@ fn web_to_store_chain(chains: &[ProvenChain]) -> &ProvenChain { .expect("web → store → secret chain") } +/// ADR-0034 (JEF-570): a breach-relevant chain's `QuarantineWorkload` mitigation is now +/// PROPOSED only when a decisive `Attack` decision named the node in `contain` — the +/// deterministic `quarantine_targets` desired-set insertion this file's tests used to rely on +/// is gone for breach-relevant chains (it stays, unchanged, for a non-breach-relevant one). So +/// these tests supply the decision the model WOULD have made naming `store`, built through the +/// real menu resolver (never hand-rolled), to isolate what they actually test: the JEF-566 +/// `is_live_corroborated` gate on the resulting mitigation, independent of how it got proposed. +fn decisions_naming_store( + chain: &ProvenChain, + graph: &crate::engine::graph::SecurityGraph, +) -> std::collections::BTreeMap +{ + use crate::engine::observe::health::HealthReport; + use crate::engine::reason::adjudicate::incident::{Assessment, IncidentDecision, build_menu}; + + let store_node = crate::engine::graph::NodeKey("workload/app/Pod/store".into()); + let menu = build_menu(chain, graph, &HealthReport::default()); + let cut = menu + .resolve(&store_node) + .expect("store is selectable on the menu"); + let mut decisions = std::collections::BTreeMap::new(); + decisions.insert( + chain.entry.0.clone(), + IncidentDecision { + assessment: Assessment::Attack, + reason: "store shows exploitation evidence".to_string(), + cuts: vec![cut], + }, + ); + decisions +} + /// A pivot pod that is network-reachable from an exposed entry and carries a critical /// CVE — but whose justifying chain has ZERO runtime/live evidence (a clean, unpromoted /// edge) — is still IDENTIFIED as a `RemotelyExploitable` quarantine candidate at the @@ -129,7 +165,7 @@ fn web_to_store_chain(chains: &[ProvenChain]) -> &ProvenChain { /// lane's ADR-0013 bar ("CVE presence no longer auto-cuts"). #[test] fn pivot_reachable_plus_cve_alone_behind_a_clean_edge_is_propose_only() { - let chains = web_reaches_pivot_store(); + let (graph, chains) = web_reaches_pivot_store(); let chain = web_to_store_chain(&chains); // No runtime evidence anywhere in the snapshot ⇒ nothing is live-corroborated. @@ -155,9 +191,12 @@ fn pivot_reachable_plus_cve_alone_behind_a_clean_edge_is_propose_only() { // Build the mitigation the way the response layer actually does — through the // ledger, so the justification carries this chain's real corroborated/adjudicated/ - // breach_relevant state, not a hand-rolled empty vec. + // breach_relevant state, not a hand-rolled empty vec. ADR-0034 (JEF-570): a + // breach-relevant chain's workload quarantine is proposed only when a decisive + // Attack decision named it — supply the decision the model would have made. + let decisions = decisions_naming_store(chain, &graph); let mut ledger = crate::engine::respond::MitigationLedger::new(); - let delta = ledger.reconcile(&chains); + let delta = ledger.reconcile(&chains, &decisions); let mitigation = delta .proposed .iter() @@ -182,7 +221,7 @@ fn pivot_reachable_plus_cve_alone_behind_a_clean_edge_is_propose_only() { fn pivot_behind_a_corroborated_breach_relevant_edge_is_auto_actionable() { use crate::engine::observe::Attribution; - let chains = web_reaches_pivot_store_with_runtime(vec![RuntimeObservation { + let (graph, chains) = web_reaches_pivot_store_with_runtime(vec![RuntimeObservation { attribution: Attribution::by_namespaced_name("app", "web"), source: Some("alert".into()), observed_at_ms: None, @@ -199,8 +238,11 @@ fn pivot_behind_a_corroborated_breach_relevant_edge_is_auto_actionable() { assert!(chain.is_breach_relevant(), "the entry is internet-facing"); let store_node = crate::engine::graph::NodeKey("workload/app/Pod/store".into()); + // ADR-0034 (JEF-570): supply the decisive Attack decision the model would have made + // naming `store` — see `decisions_naming_store`. + let decisions = decisions_naming_store(chain, &graph); let mut ledger = crate::engine::respond::MitigationLedger::new(); - let delta = ledger.reconcile(&chains); + let delta = ledger.reconcile(&chains, &decisions); let mitigation = delta .proposed .iter() @@ -222,7 +264,7 @@ fn pivot_behind_a_corroborated_breach_relevant_edge_is_auto_actionable() { /// `RemotelyExploitable` bar is weaker than the entry's own foothold bar. #[test] fn entry_foothold_alone_does_not_meet_the_stricter_action_bar() { - let chains = web_reaches_pivot_store(); + let (_graph, chains) = web_reaches_pivot_store(); let chain = web_to_store_chain(&chains); assert!( !chain.meets_action_bar(), @@ -238,7 +280,7 @@ fn entry_foothold_alone_does_not_meet_the_stricter_action_bar() { fn pivot_with_live_signal_is_actively_exploited_not_remotely_exploitable() { use crate::engine::observe::Attribution; - let chains = web_reaches_pivot_store_with_runtime(vec![RuntimeObservation { + let (_graph, chains) = web_reaches_pivot_store_with_runtime(vec![RuntimeObservation { attribution: Attribution::by_namespaced_name("app", "store"), source: None, observed_at_ms: None, diff --git a/engine/src/engine/respond/decisions_tests.rs b/engine/src/engine/respond/decisions_tests.rs new file mode 100644 index 00000000..db2f7e34 --- /dev/null +++ b/engine/src/engine/respond/decisions_tests.rs @@ -0,0 +1,226 @@ +//! ADR-0034 D6/D7 (JEF-570): `MitigationLedger::reconcile` consuming per-entry cut-choice +//! decisions end to end — the desired-set rules (model-chosen cuts / the `containment_for` +//! fallback / a confident clear) and D5's non-member whole-decision degrade reaching the +//! ledger. Split out of `tests.rs` purely to keep every file under the 1,000-line cap +//! (repo CLAUDE.md); `super::tests` covers the pre-existing containment/quarantine shapes. + +use super::*; +use crate::engine::observe::adapter::{build_graph, default_adapters}; +use crate::engine::observe::health::HealthReport; +use crate::engine::observe::{Attribution, RuntimeObservation, Snapshot}; +use crate::engine::reason::adjudicate::incident::{ + Assessment, IncidentDecision, build_menu, parse_incident_decision, +}; +use crate::engine::reason::proof::prove; +use protector_behavior::Behavior; +use serde_json::json; + +/// An internet-facing `web` pod mounting a secret, with a live alert on it — breach-relevant +/// AND corroborated, so [`Mitigation::is_live_corroborated`] would clear on ANY justification +/// carrying `adjudicated: true`. The one chain a fallback proposal's `adjudicated = false` +/// stamp must override. +fn corroborated_breach_relevant_snapshot() -> Snapshot { + let web = json!({ + "apiVersion": "v1", "kind": "Pod", + "metadata": {"name": "web", "namespace": "app", "labels": {"app": "web"}}, + "spec": {"containers": [{ + "name": "c", "image": "web:1", + "envFrom": [{"secretRef": {"name": "session-key"}}] + }]} + }); + let lb = json!({ + "apiVersion": "v1", "kind": "Service", + "metadata": {"name": "web-lb", "namespace": "app"}, + "spec": {"type": "LoadBalancer", "selector": {"app": "web"}} + }); + Snapshot { + pods: vec![serde_json::from_value(web).unwrap()], + services: vec![serde_json::from_value(lb).unwrap()], + runtime_events: vec![RuntimeObservation { + attribution: Attribution::by_namespaced_name("app", "web"), + source: Some("alert".into()), + observed_at_ms: None, + node: None, + behavior: Behavior::Alert { + rule: "Terminal shell in container".into(), + }, + }], + ..Default::default() + } +} + +fn web_chain(chains: &[ProvenChain]) -> &ProvenChain { + chains + .iter() + .find(|c| c.entry.0 == "workload/app/Pod/web") + .expect("web entry chain") +} + +/// D6: no decision at all ⇒ the `containment_for` FALLBACK proposes, but stamped +/// `adjudicated = false` so it can NEVER clear the auto-action gate — even though the chain +/// itself is genuinely corroborated. The human-proposal fallback is never auto-applied. +#[test] +fn fallback_proposal_is_stamped_non_auto_even_when_corroborated() { + let graph = build_graph( + &corroborated_breach_relevant_snapshot(), + &default_adapters(), + ); + let chains = prove(&graph); + let chain = web_chain(&chains); + assert!(chain.corroborated, "sanity: the live alert corroborates"); + assert!(chain.is_breach_relevant()); + + let mut ledger = MitigationLedger::new(); + let delta = ledger.reconcile(&chains, &BTreeMap::new()); + + let mitigation = delta + .proposed + .iter() + .find(|m| m.cut.from == chain.entry) + .expect("the containment_for fallback proposes the entry"); + assert!( + !mitigation.is_live_corroborated(), + "D6: a fallback proposal is stamped adjudicated=false — never auto-applied, no matter \ + how corroborated the chain is" + ); +} + +/// The positive contrast: a decisive `Attack` decision naming the SAME entry on the SAME +/// corroborated chain DOES clear the auto-action gate — the model's say-so, not determinism, +/// is what makes a breach-relevant cut auto-eligible now (ADR-0034). +#[test] +fn model_chosen_cut_clears_the_auto_action_gate_when_corroborated() { + let graph = build_graph( + &corroborated_breach_relevant_snapshot(), + &default_adapters(), + ); + let chains = prove(&graph); + let chain = web_chain(&chains); + + let menu = build_menu(chain, &graph, &HealthReport::default()); + let cut = menu.resolve(&chain.entry).expect("the entry is selectable"); + let mut decisions = BTreeMap::new(); + decisions.insert( + chain.entry.0.clone(), + IncidentDecision { + assessment: Assessment::Attack, + reason: "live shell on the entry".to_string(), + cuts: vec![cut], + }, + ); + + let mut ledger = MitigationLedger::new(); + let delta = ledger.reconcile(&chains, &decisions); + let mitigation = delta + .proposed + .iter() + .find(|m| m.cut.from == chain.entry) + .expect("the model-chosen cut proposes the entry"); + assert!( + mitigation.is_live_corroborated(), + "a decisive Attack naming a corroborated, breach-relevant entry clears the gate" + ); +} + +/// D6: a decisive, confident `NoAttack` gets NEITHER the model cuts NOR the fallback — the +/// model cleared this entry, so there is nothing to propose to a human either. +#[test] +fn decisive_no_attack_produces_no_proposal_at_all() { + let graph = build_graph( + &corroborated_breach_relevant_snapshot(), + &default_adapters(), + ); + let chains = prove(&graph); + let chain = web_chain(&chains); + + let mut decisions = BTreeMap::new(); + decisions.insert( + chain.entry.0.clone(), + IncidentDecision { + assessment: Assessment::NoAttack, + reason: "the alert is a benign debug shell, not an attacker".to_string(), + cuts: Vec::new(), + }, + ); + + let mut ledger = MitigationLedger::new(); + let delta = ledger.reconcile(&chains, &decisions); + assert!( + delta.proposed.iter().all(|m| m.cut.from != chain.entry), + "a confident no_attack proposes NOTHING for the entry — no fallback either, got {:?}", + delta.proposed + ); +} + +/// D1: `Attack` with an EMPTY `contain` ("attack, but no cut warranted") is valid and routes +/// to the human-proposal fallback — it must not be treated as "no decision" in spirit (it +/// still surfaces something to review) NOR as if the model had chosen a cut (nothing is +/// auto-eligible). +#[test] +fn decisive_attack_with_empty_cuts_still_gets_the_fallback_proposal() { + let graph = build_graph( + &corroborated_breach_relevant_snapshot(), + &default_adapters(), + ); + let chains = prove(&graph); + let chain = web_chain(&chains); + + let mut decisions = BTreeMap::new(); + decisions.insert( + chain.entry.0.clone(), + IncidentDecision { + assessment: Assessment::Attack, + reason: "attack in progress, nothing warrants a cut yet".to_string(), + cuts: Vec::new(), + }, + ); + + let mut ledger = MitigationLedger::new(); + let delta = ledger.reconcile(&chains, &decisions); + let mitigation = delta + .proposed + .iter() + .find(|m| m.cut.from == chain.entry) + .expect("D1: attack-with-no-cuts still routes to the containment_for fallback"); + assert!( + !mitigation.is_live_corroborated(), + "the fallback for an empty-contain Attack is stamped non-auto too — nothing was \ + actually CHOSEN to cut" + ); +} + +/// D5 end to end: a model reply naming a workload OUTSIDE the menu degrades the WHOLE +/// decision to `Uncertain` (the parser's membership guard) — which `reconcile` then treats +/// exactly like "no decision", falling back to the entry's `containment_for` proposal, never +/// the (nonexistent) cut the model tried to name. +#[test] +fn a_non_member_reply_degrades_to_uncertain_and_reconcile_falls_back() { + let graph = build_graph( + &corroborated_breach_relevant_snapshot(), + &default_adapters(), + ); + let chains = prove(&graph); + let chain = web_chain(&chains); + let menu = build_menu(chain, &graph, &HealthReport::default()); + + let reply = r#"{"assessment": "attack", "reason": "x", "contain": ["workload/app/Pod/not-on-the-menu"]}"#; + let decision = parse_incident_decision(reply, &menu); + assert_eq!( + decision.assessment, + Assessment::Uncertain, + "a non-member contain element degrades the whole decision (ADR-0034 D3)" + ); + assert!(decision.cuts.is_empty()); + + let mut decisions = BTreeMap::new(); + decisions.insert(chain.entry.0.clone(), decision); + + let mut ledger = MitigationLedger::new(); + let delta = ledger.reconcile(&chains, &decisions); + let mitigation = delta + .proposed + .iter() + .find(|m| m.cut.from == chain.entry) + .expect("the degraded decision falls back to containment_for, like no decision at all"); + assert!(!mitigation.is_live_corroborated()); +} diff --git a/engine/src/engine/respond/mod.rs b/engine/src/engine/respond/mod.rs index 9bdb0ece..43229222 100644 --- a/engine/src/engine/respond/mod.rs +++ b/engine/src/engine/respond/mod.rs @@ -390,42 +390,103 @@ impl MitigationLedger { Self::default() } - /// Reconcile the ledger against this cycle's proven chains. The active set - /// becomes exactly the mitigations justified by a current chain; the delta - /// reports what that added and removed. - pub fn reconcile(&mut self, chains: &[ProvenChain]) -> LedgerDelta { + /// Reconcile the ledger against this cycle's proven chains AND this pass's per-entry + /// cut-choice decisions (ADR-0034 D6/D7, JEF-570). The active set becomes exactly: + /// + /// - **model-chosen cuts** whose entry still has a proven, breach-relevant justifying + /// chain and a DECISIVE `Attack` decision naming them (they clear the JEF-566 + /// `is_live_corroborated` auto-action gate on their own justifications, same as before); + /// - **`containment_for` FALLBACK proposals** — the entry's own ladder result only, never a + /// downstream workload — for every breach-relevant entry with no decisive `Attack` + /// decision that named a cut (no decision at all, `Uncertain`, or a decisive `Attack` + /// with an empty `contain` — D1's "attack, but no cut warranted"). Stamped + /// `adjudicated = false` so [`Mitigation::is_live_corroborated`] can never clear it — + /// the human-proposal fallback, never auto-applied. A decisive `NoAttack` gets NEITHER + /// (the model confidently cleared the entry — nothing to propose). + /// + /// The deterministic `quarantine_targets` desired-set insertion is **deleted** for + /// breach-relevant chains — completing the ADR-0032 auto-fire removal — but UNCHANGED for + /// a non-breach-relevant (internal-only) chain's JEF-284 condition-2 targets: those never + /// reach the model at all (`adj_pass` only judges breach-relevant entries) and stay outside + /// the north star's two lanes (ADR-0032 §6 propose-only, deferred by ADR-0034), so their + /// proposal mechanism is untouched by this ticket. + pub fn reconcile( + &mut self, + chains: &[ProvenChain], + decisions: &BTreeMap, + ) -> LedgerDelta { + use crate::engine::reason::adjudicate::incident::Assessment; + let mut desired: BTreeMap = BTreeMap::new(); let mut unsevered = Vec::new(); for chain in chains { - // Choose the containment by precedence (surgical edge-cut → entry - // quarantine → durable-fix). A chain with none can't be severed by one - // action, so it is surfaced as unsevered. - let primary = containment_for(chain); - match &primary { - Some((cut, action)) => { - desired - .entry(cut_signature(cut)) - .or_insert_with(|| Mitigation { - cut: cut.clone(), - action: *action, - justifications: Vec::new(), - }) - .justifications - .push(Justification::of(chain)); + // Structural report only (independent of any decision): a chain with no + // single-edge cut can't be severed by one action. + if containment_for(chain).is_none() { + unsevered.push(Justification::of(chain)); + } + + if chain.is_breach_relevant() { + let decision = decisions.get(&chain.entry.0); + match decision { + // A decisive Attack that named cuts: the model-chosen desired set. + Some(d) if d.assessment == Assessment::Attack && !d.cuts.is_empty() => { + for cut in &d.cuts { + desired + .entry(cut.cut_signature.clone()) + .or_insert_with(|| Mitigation { + cut: cut.cut.clone(), + action: cut.action, + justifications: Vec::new(), + }) + .justifications + .push(Justification::of(chain)); + } + } + // A decisive, confident NoAttack: the model cleared this entry — no + // fallback proposal either (nothing to hand a human to review). + Some(d) if d.assessment == Assessment::NoAttack => {} + // No decision yet / Uncertain / a decisive Attack naming no cut (D1): + // the containment_for FALLBACK, entry-only, stamped non-auto. + _ => { + if let Some((cut, action)) = containment_for(chain) { + let mut justification = Justification::of(chain); + justification.adjudicated = false; + desired + .entry(cut_signature(&cut)) + .or_insert_with(|| Mitigation { + cut, + action, + justifications: Vec::new(), + }) + .justifications + .push(justification); + } + } } - None => unsevered.push(Justification::of(chain)), + continue; } - // Sibling pass (JEF-284): additionally quarantine each *compromised workload - // on the chain* — a remotely-exploitable or actively-exploited pod. Independent - // of the primary containment, so several qualifying pods on one chain are each - // isolated (independent compromises). The chain **entry** is governed entirely - // by the primary above: when the primary already contains it with an additive- - // live control (surgical edge-cut or entry quarantine) we skip the entry here, - // preserving JEF-279's behavior and the "prefer the narrower surgical cut" - // invariant. The entry is quarantined here only when nothing else contained it — - // the internal actively-exploited pod whose primary is a durable-fix / no-cut. + // Non-breach-relevant (internal-only): UNCHANGED pre-ADR-0034 behavior — never + // reaches the model, so it is governed entirely by determinism, exactly as before. + let primary = containment_for(chain); + if let Some((cut, action)) = &primary { + desired + .entry(cut_signature(cut)) + .or_insert_with(|| Mitigation { + cut: cut.clone(), + action: *action, + justifications: Vec::new(), + }) + .justifications + .push(Justification::of(chain)); + } + // Sibling pass (JEF-284): additionally quarantine each *compromised workload on + // the chain* — an internal-only actively-exploited pod (condition 2), outside the + // north star's two lanes. The chain's entry is governed entirely by the primary + // above: skip it here when the primary already additively contains it (JEF-279, + // "prefer the narrower surgical cut"). let entry_additively_contained = primary .as_ref() .is_some_and(|(_, action)| action.is_additive_live()); @@ -476,3 +537,10 @@ impl MitigationLedger { #[cfg(test)] mod tests; + +// ADR-0034 (JEF-570): the cut-choice decision-consumption tests, split into their own file +// (rather than growing `tests.rs` toward the 1,000-line cap, CLAUDE.md) — the D6 desired-set +// rules (model cuts / fallback / confident-clear) and D5's non-member whole-decision degrade +// reaching `reconcile` end to end. +#[cfg(test)] +mod decisions_tests; diff --git a/engine/src/engine/respond/tests.rs b/engine/src/engine/respond/tests.rs index dd03d5c7..02541f19 100644 --- a/engine/src/engine/respond/tests.rs +++ b/engine/src/engine/respond/tests.rs @@ -1,9 +1,46 @@ use super::*; use crate::engine::observe::Snapshot; use crate::engine::observe::adapter::{build_graph, default_adapters}; +use crate::engine::observe::health::HealthReport; +use crate::engine::reason::adjudicate::incident::{Assessment, IncidentDecision, build_menu}; use crate::engine::reason::proof::prove; use serde_json::json; +/// No decision for any entry (ADR-0034 D6): every breach-relevant entry falls through to the +/// `containment_for` human-proposal fallback, exactly what most of this file's tests exercise — +/// they predate the cut-choice contract and never depended on a model. +fn no_decisions() -> BTreeMap { + BTreeMap::new() +} + +/// A decisive `Attack` decision for `chain.entry`, naming exactly the menu-resolved cuts for +/// `nodes` — built through the REAL menu resolver (never hand-rolled), so a test decision is +/// exactly what the model would have produced. +fn decisive_attack( + chain: &ProvenChain, + graph: &crate::engine::graph::SecurityGraph, + nodes: &[crate::engine::graph::NodeKey], +) -> BTreeMap { + let menu = build_menu(chain, graph, &HealthReport::default()); + let cuts = nodes + .iter() + .map(|n| { + menu.resolve(n) + .unwrap_or_else(|| panic!("{} is selectable on the menu", n.0)) + }) + .collect(); + let mut decisions = BTreeMap::new(); + decisions.insert( + chain.entry.0.clone(), + IncidentDecision { + assessment: Assessment::Attack, + reason: "test-supplied decision".to_string(), + cuts, + }, + ); + decisions +} + /// A lateral chain web →reaches→ db →can-read→ secret, whose first cut is the /// `reaches` edge → a DenyNetworkPath proposal. fn lateral_chain_snapshot() -> Snapshot { @@ -43,7 +80,7 @@ fn lateral_chain_snapshot() -> Snapshot { fn proposes_a_mitigation_for_a_cuttable_chain() { let chains = prove(&build_graph(&lateral_chain_snapshot(), &default_adapters())); let mut ledger = MitigationLedger::new(); - let delta = ledger.reconcile(&chains); + let delta = ledger.reconcile(&chains, &no_decisions()); assert!( !delta.proposed.is_empty(), @@ -119,18 +156,18 @@ fn reconcile_is_idempotent_then_retires_when_chains_vanish() { let chains = prove(&build_graph(&lateral_chain_snapshot(), &default_adapters())); let mut ledger = MitigationLedger::new(); - let first = ledger.reconcile(&chains); + let first = ledger.reconcile(&chains, &no_decisions()); assert!(!first.proposed.is_empty()); let active_after_first = ledger.active().count(); // Same chains again: nothing new proposed, nothing retired. - let second = ledger.reconcile(&chains); + let second = ledger.reconcile(&chains, &no_decisions()); assert!(second.proposed.is_empty()); assert!(second.retired.is_empty()); assert_eq!(ledger.active().count(), active_after_first); // Posture improves — all chains gone. Every mitigation retires (Q5). - let third = ledger.reconcile(&[]); + let third = ledger.reconcile(&[], &no_decisions()); assert!(third.proposed.is_empty()); assert_eq!(third.retired.len(), active_after_first); assert_eq!(ledger.active().count(), 0); @@ -191,7 +228,7 @@ fn direct_mount_chain_quarantines_the_entry_not_the_objective() { &default_adapters(), )); let mut ledger = MitigationLedger::new(); - let delta = ledger.reconcile(&chains); + let delta = ledger.reconcile(&chains, &no_decisions()); let q = only_quarantine(&delta); // Targets ONLY the internet-facing entry (from == to == entry), never the secret. @@ -249,7 +286,7 @@ fn direct_rbac_chain_quarantines_the_entry() { }; let chains = prove(&build_graph(&snap, &default_adapters())); let mut ledger = MitigationLedger::new(); - let delta = ledger.reconcile(&chains); + let delta = ledger.reconcile(&chains, &no_decisions()); let q = only_quarantine(&delta); // The entry pod is quarantined — never the RBAC identity or the secret. @@ -321,7 +358,7 @@ fn lateral_chain_with_reversible_reaches_stays_surgical() { let chains = prove(&build_graph(&snap, &default_adapters())); let mut ledger = MitigationLedger::new(); - let delta = ledger.reconcile(&chains); + let delta = ledger.reconcile(&chains, &no_decisions()); // The internet-facing web→db→secret chain is contained by the surgical reaches cut. assert!( @@ -355,7 +392,7 @@ fn internal_direct_mount_is_not_quarantined() { let chains = prove(&build_graph(&snap, &default_adapters())); let mut ledger = MitigationLedger::new(); - let delta = ledger.reconcile(&chains); + let delta = ledger.reconcile(&chains, &no_decisions()); assert!( delta @@ -383,7 +420,7 @@ fn quarantine_entry_self_reverts_when_its_chain_is_gone() { )); let mut ledger = MitigationLedger::new(); - let first = ledger.reconcile(&chains); + let first = ledger.reconcile(&chains, &no_decisions()); let q = only_quarantine(&first); assert!( ledger @@ -392,7 +429,7 @@ fn quarantine_entry_self_reverts_when_its_chain_is_gone() { ); // Posture improves — the chain is gone. The quarantine retires (Q5). - let retired = ledger.reconcile(&[]); + let retired = ledger.reconcile(&[], &no_decisions()); assert!( retired .retired @@ -500,21 +537,62 @@ fn multi_hop_breach_snapshot_with_runtime( } } +/// ADR-0034 (JEF-570) NEGATIVE control: without ANY model decision, the deterministic +/// `quarantine_targets` desired-set insertion is GONE for a breach-relevant chain — the popped +/// pods one and two hops in are IDENTIFIED as candidates (proof layer, unchanged, see +/// `pivot_quarantine_tests`) but are no longer PROPOSED at all absent a decisive `Attack` +/// decision naming them. Only the entry's own `containment_for` fallback line proposes. #[test] -fn remotely_exploitable_pods_two_hops_in_are_quarantined() { +fn remotely_exploitable_pods_two_hops_in_are_not_quarantined_without_a_decision() { let chains = prove(&build_graph( &multi_hop_breach_snapshot(), &default_adapters(), )); let mut ledger = MitigationLedger::new(); - let delta = ledger.reconcile(&chains); + let delta = ledger.reconcile(&chains, &no_decisions()); + + assert!( + workload_quarantines(&delta).is_empty(), + "no model decision named a downstream node ⇒ no workload quarantine is proposed \ + (the quarantine_targets desired-set insertion is deleted), got {:?}", + workload_quarantines(&delta) + ); + assert!( + delta + .proposed + .iter() + .all(|m| !m.cut.from.0.starts_with("secret/")), + "no mitigation targets the objective secret" + ); +} + +/// The positive contrast: a decisive `Attack` decision naming BOTH popped pods produces +/// exactly the mitigations the pre-ADR-0034 deterministic pass used to propose unconditionally +/// — independent compromises on the same chain (JEF-284 condition 1) are each isolated, and the +/// entry (governed by the ADR-0022 `containment_for` precedence, a surgical edge-cut here) is +/// never workload-quarantined. +#[test] +fn remotely_exploitable_pods_two_hops_in_are_quarantined_when_the_model_names_them() { + let graph = build_graph(&multi_hop_breach_snapshot(), &default_adapters()); + let chains = prove(&graph); + let chain = chains + .first() + .expect("one chain: web -> app1 -> app2 -> secret"); + let decisions = decisive_attack( + chain, + &graph, + &[ + crate::engine::graph::NodeKey("workload/app/Pod/app1".into()), + crate::engine::graph::NodeKey("workload/app/Pod/app2".into()), + ], + ); + let mut ledger = MitigationLedger::new(); + let delta = ledger.reconcile(&chains, &decisions); let quarantined: Vec = workload_quarantines(&delta) .iter() .map(|m| m.cut.from.0.clone()) .collect(); - // The popped app one hop in AND the popped app two hops in are both quarantined — - // independent compromises on the same chain (JEF-284 condition 1). assert!( quarantined.contains(&"workload/app/Pod/app2".to_string()), "the KEV pod two hops in is quarantined, got {quarantined:?}" @@ -579,7 +657,7 @@ fn internal_actively_exploited_pod_is_quarantined() { &default_adapters(), )); let mut ledger = MitigationLedger::new(); - let delta = ledger.reconcile(&chains); + let delta = ledger.reconcile(&chains, &no_decisions()); let quarantined: Vec = workload_quarantines(&delta) .iter() @@ -660,9 +738,24 @@ fn reachable_but_clean_pod_is_not_quarantined() { ..Default::default() }; - let chains = prove(&build_graph(&snap, &default_adapters())); + let graph = build_graph(&snap, &default_adapters()); + let chains = prove(&graph); + // ADR-0034 (JEF-570): a decisive Attack decision naming ONLY `popped` — never `cleandb`, + // which carries no exploitation evidence of its own and so isn't even offered as + // selectable on the menu. + let popped_chain = chains + .iter() + .find(|c| c.objective.0 == "secret/app/creds") + .expect("web -> popped -> creds chain"); + let decisions = decisive_attack( + popped_chain, + &graph, + &[crate::engine::graph::NodeKey( + "workload/app/Pod/popped".into(), + )], + ); let mut ledger = MitigationLedger::new(); - let delta = ledger.reconcile(&chains); + let delta = ledger.reconcile(&chains, &decisions); let quarantined: Vec = workload_quarantines(&delta) .iter() @@ -696,7 +789,7 @@ fn workload_quarantine_is_proposed_in_audit_actuated_only_under_enforce() { // give the entry a live alert — any "attack happening now" signal — corroborating the // justifying chain, so `is_live_corroborated()` clears independently of what's asserted // here. - let chains = prove(&build_graph( + let graph = build_graph( &multi_hop_breach_snapshot_with_runtime(vec![RuntimeObservation { attribution: Attribution::by_namespaced_name("app", "web"), source: Some("alert".into()), @@ -707,9 +800,19 @@ fn workload_quarantine_is_proposed_in_audit_actuated_only_under_enforce() { }, }]), &default_adapters(), - )); + ); + let chains = prove(&graph); + // ADR-0034 (JEF-570): this test isolates the `decide()` enable/scope gate, which needs a + // PROPOSED mitigation to run against — supply the decisive Attack decision naming app2. + let decisions = decisive_attack( + chains.first().expect("one chain"), + &graph, + &[crate::engine::graph::NodeKey( + "workload/app/Pod/app2".into(), + )], + ); let mut ledger = MitigationLedger::new(); - let delta = ledger.reconcile(&chains); + let delta = ledger.reconcile(&chains, &decisions); let mitigation = workload_quarantines(&delta) .into_iter() .find(|m| m.cut.from.0 == "workload/app/Pod/app2") @@ -755,7 +858,7 @@ fn workload_quarantine_self_reverts_when_evidence_clears() { &internal_active_snapshot(true), &default_adapters(), )); - let first = ledger.reconcile(&chains); + let first = ledger.reconcile(&chains, &no_decisions()); let q = workload_quarantines(&first) .into_iter() .find(|m| m.cut.from.0 == "workload/app/Pod/watcher") @@ -773,7 +876,7 @@ fn workload_quarantine_self_reverts_when_evidence_clears() { &internal_active_snapshot(false), &default_adapters(), )); - let delta = ledger.reconcile(&cleared); + let delta = ledger.reconcile(&cleared, &no_decisions()); assert!( delta .retired diff --git a/engine/src/engine/tests.rs b/engine/src/engine/tests.rs index 68601bd3..ad79e69b 100644 --- a/engine/src/engine/tests.rs +++ b/engine/src/engine/tests.rs @@ -9,11 +9,38 @@ use crate::engine::graph::attack::AttackRef; use crate::engine::graph::{NodeKey, SecurityGraph}; use crate::engine::observe::{SecretMeta, Snapshot}; use crate::engine::reason::adjudicate::Verdict; +use crate::engine::reason::adjudicate::incident::{Assessment, IncidentDecision, Menu}; use crate::engine::respond::actuator::DryRunActuator; use serde_json::json; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; +/// Translate a legacy [`Verdict`] literal into the [`IncidentDecision`] shape a test double +/// returns (JEF-570) — test-only, and deliberately lossy for `Confirmed` (mapped to `Attack`, +/// same as every other test double; no test double here needs the Confirmed/Exploitable +/// distinction ADR-0034 D2 retired). Never carries cuts: none of these doubles exercise +/// cut-selection, only the caching/gate/dispatch machinery `to_verdict()` bridges back exactly. +pub(super) fn decision_of(verdict: Verdict) -> IncidentDecision { + match verdict { + Verdict::Confirmed => IncidentDecision { + assessment: Assessment::Attack, + reason: "confirmed".to_string(), + cuts: Vec::new(), + }, + Verdict::Exploitable(reason) => IncidentDecision { + assessment: Assessment::Attack, + reason, + cuts: Vec::new(), + }, + Verdict::Refuted(reason) => IncidentDecision { + assessment: Assessment::NoAttack, + reason, + cuts: Vec::new(), + }, + Verdict::Uncertain(reason) => IncidentDecision::uncertain(reason), + } +} + /// An adjudicator that counts how many times it's consulted (and confirms). pub(super) struct CountingAdjudicator(pub(super) Arc); @@ -26,9 +53,10 @@ impl reason::adjudicate::Adjudicator for CountingAdjudicator { _graph: &SecurityGraph, _prompt: &str, _downstream: &[NodeKey], - ) -> Verdict { + _menu: &Menu, + ) -> IncidentDecision { self.0.fetch_add(1, Ordering::SeqCst); - Verdict::Refuted("counted".into()) + decision_of(Verdict::Refuted("counted".into())) } } @@ -216,8 +244,9 @@ impl reason::adjudicate::Adjudicator for FixedAdjudicator { _graph: &SecurityGraph, _prompt: &str, _downstream: &[NodeKey], - ) -> Verdict { - self.0.clone() + _menu: &Menu, + ) -> IncidentDecision { + decision_of(self.0.clone()) } } @@ -285,11 +314,12 @@ async fn an_uncertain_re_judge_keeps_showing_the_prior_decisive_verdict() { _graph: &SecurityGraph, _prompt: &str, _downstream: &[NodeKey], - ) -> Verdict { + _menu: &Menu, + ) -> IncidentDecision { if self.0.fetch_add(1, Ordering::SeqCst) == 0 { - Verdict::Exploitable("RCE reaches the secret".into()) + decision_of(Verdict::Exploitable("RCE reaches the secret".into())) } else { - Verdict::Uncertain("model unavailable".into()) + IncidentDecision::uncertain("model unavailable") } } } @@ -487,13 +517,14 @@ impl reason::adjudicate::Adjudicator for ConcurrencyProbe { _graph: &SecurityGraph, _prompt: &str, _downstream: &[NodeKey], - ) -> Verdict { + _menu: &Menu, + ) -> IncidentDecision { let now = self.in_flight.fetch_add(1, Ordering::SeqCst) + 1; self.max_in_flight.fetch_max(now, Ordering::SeqCst); // Linger so concurrent calls overlap and are seen by the max counter above. tokio::time::sleep(std::time::Duration::from_millis(80)).await; self.in_flight.fetch_sub(1, Ordering::SeqCst); - Verdict::Refuted("counted".into()) + decision_of(Verdict::Refuted("counted".into())) } } @@ -540,11 +571,12 @@ async fn one_entrys_model_failure_does_not_poison_the_others() { _graph: &SecurityGraph, _prompt: &str, _downstream: &[NodeKey], - ) -> Verdict { + _menu: &Menu, + ) -> IncidentDecision { if self.0.fetch_add(1, Ordering::SeqCst) == 0 { - Verdict::Uncertain("model unavailable".into()) + IncidentDecision::uncertain("model unavailable") } else { - Verdict::Exploitable("RCE reaches the secret".into()) + decision_of(Verdict::Exploitable("RCE reaches the secret".into())) } } } @@ -576,3 +608,109 @@ async fn one_entrys_model_failure_does_not_poison_the_others() { N - 1 ); } + +// --- ADR-0034 (JEF-570): the cut-choice contract wired into the live ledger --- + +/// ADR-0034 D7 (the retirement asymmetry, the safety-critical half of this ticket): a +/// PREVIOUSLY model-chosen cut must NOT retire just because the model comes back `Uncertain` +/// on a later pass — a transient outage may neither open a live attack path (this test) nor +/// sever one (D7's other half, unit-tested directly on `IncidentDecision`/guards in the +/// `incident` module). Pass 1 decisively chooses the entry's own menu line; pass 2's fresh +/// `Uncertain` must leave that exact mitigation active. +#[tokio::test] +async fn a_fresh_uncertain_does_not_retire_a_previously_chosen_cut() { + struct AttackThenUncertain(Arc); + #[async_trait::async_trait] + impl reason::adjudicate::Adjudicator for AttackThenUncertain { + async fn judge( + &self, + entry: &NodeKey, + _objectives: &[(NodeKey, AttackRef)], + _graph: &SecurityGraph, + _prompt: &str, + _downstream: &[NodeKey], + menu: &Menu, + ) -> IncidentDecision { + if self.0.fetch_add(1, Ordering::SeqCst) == 0 { + let cut = menu + .resolve(entry) + .expect("the entry is selectable on its own menu"); + IncidentDecision { + assessment: Assessment::Attack, + reason: "RCE reaches the secret".to_string(), + cuts: vec![cut], + } + } else { + IncidentDecision::uncertain("model unavailable") + } + } + } + let mut engine = + engine_with_adjudicator(Box::new(AttackThenUncertain(Arc::new(AtomicUsize::new(0))))); + + // Pass 1: decisive Attack naming the entry — the cut goes active. + engine.process(&exposed_snapshot(true)).await; + let active_after_first: Vec = + engine.ledger.active().map(|m| m.cut_signature()).collect(); + assert!( + !active_after_first.is_empty(), + "the decisive Attack's chosen cut is active after pass 1" + ); + + // Pass 2 ADDS nothing (identical facts), so the entry re-verifies (JEF-445 — a cached + // Exploitable/Attack is never served from cache) and this time comes back Uncertain. The + // ledger's active set must be UNCHANGED — inert, not retired. + engine.process(&exposed_snapshot(true)).await; + let active_after_second: Vec = + engine.ledger.active().map(|m| m.cut_signature()).collect(); + assert_eq!( + active_after_second, active_after_first, + "a fresh Uncertain must retire nothing — the standing cut persists (ADR-0034 D7)" + ); +} + +/// Rail: shadow stays the default posture (CLAUDE.md / ADR-0021) — even a decisive `Attack` +/// naming a real cut on a corroborated, breach-relevant chain only ever PROPOSES with no +/// action classes armed; nothing reaches the (dry-run) actuator. +#[tokio::test] +async fn a_model_chosen_cut_only_proposes_in_shadow_by_default() { + struct AlwaysNamesTheEntry; + #[async_trait::async_trait] + impl reason::adjudicate::Adjudicator for AlwaysNamesTheEntry { + async fn judge( + &self, + entry: &NodeKey, + _objectives: &[(NodeKey, AttackRef)], + _graph: &SecurityGraph, + _prompt: &str, + _downstream: &[NodeKey], + menu: &Menu, + ) -> IncidentDecision { + let cut = menu + .resolve(entry) + .expect("the entry is selectable on its own menu"); + IncidentDecision { + assessment: Assessment::Attack, + reason: "RCE reaches the secret".to_string(), + cuts: vec![cut], + } + } + } + // `engine_with_adjudicator` builds with `EnabledActions::from_names(std::iter::empty())` + // (shadow — nothing armed) and a `DryRunActuator` — the default posture (CLAUDE.md). + let mut engine = engine_with_adjudicator(Box::new(AlwaysNamesTheEntry)); + engine.process(&exposed_snapshot(true)).await; + + let active: Vec<_> = engine.ledger.active().cloned().collect(); + assert!( + !active.is_empty(), + "the model-chosen cut is proposed (ledger-active), even in shadow" + ); + // Shadow is the DEFAULT (CLAUDE.md / ADR-0021) — `engine_with_adjudicator` arms no action + // class, so the actuator never sees an apply: the applied-action log stays empty. + assert_eq!( + engine.actions.active_count(), + 0, + "nothing armed ⇒ nothing actually applied, even for a decisive model-chosen cut" + ); +} From 190e3a0c14b8392566de2b6a3f1ee1ab043edfd7 Mon Sep 17 00:00:00 2001 From: Jeff Larson Date: Tue, 28 Jul 2026 21:18:13 -0700 Subject: [PATCH 2/3] fix(engine): reconcile carries a standing cut forward on a fresh Uncertain (JEF-570) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HIGH severity D7 retirement-asymmetry violation caught by security review: the `_ =>` fallback arm rebuilt an entry's desired set from `containment_for` alone whenever this pass had no decisive decision (no decision at all, or a fresh Uncertain). Since a previously model-chosen cut's signature generally differs from containment_for's own default — every downstream QuarantineWorkload does — that cut silently dropped out of `desired`, landed in `retired`, and the caller's self-revert loop (engine/mod.rs) actually reverted the live isolation NetworkPolicy. A model wobble/timeout/the documented cold-start window after a restart would sever every standing downstream cut at once, reopening the attacker's path in enforce mode — exactly what D7 forbids ("a fresh Uncertain retires nothing"). Splits the old catch-all arm by decisiveness: - No decision at all, or a fresh Uncertain: INERT (new). Carry every mitigation already active for the entry forward UNCHANGED (carry_forward_or_fallback), re-justified against this pass's chain so a genuine chain-clear next pass still retires it structurally. Only offers the containment_for fallback when there is no standing cut to carry. - Decisive Attack naming no cut (D1): unchanged — a decisive omission retires the standing cut and offers only the fallback. - Decisive NoAttack / decisive Attack with cuts: unchanged. New tests (respond/decisions_tests.rs), confirmed to fail against the prior code and pass against the fix: - a_downstream_cut_persists_across_a_pass_with_no_decision — a downstream QuarantineWorkload cut (signature != the entry's containment_for default) chosen on pass N stays active after a pass N+1 with no decision at all. - a_decisive_no_attack_still_retires_a_standing_cut — the regression guard: the carry-forward must not make a cut un-retirable; a later decisive NoAttack still clears it. 1024 lib tests green (was 1022); cargo fmt clean; cargo clippy --all-targets -D warnings clean (workspace). Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP Co-authored-by: Claude Opus 4.8 --- engine/src/engine/respond/decisions_tests.rs | 191 +++++++++++++++++++ engine/src/engine/respond/mod.rs | 114 ++++++++--- 2 files changed, 280 insertions(+), 25 deletions(-) diff --git a/engine/src/engine/respond/decisions_tests.rs b/engine/src/engine/respond/decisions_tests.rs index db2f7e34..5321575b 100644 --- a/engine/src/engine/respond/decisions_tests.rs +++ b/engine/src/engine/respond/decisions_tests.rs @@ -56,6 +56,69 @@ fn web_chain(chains: &[ProvenChain]) -> &ProvenChain { .expect("web entry chain") } +/// An internet-facing `web` pod that REACHES a downstream `store` pivot (a critical CVE makes +/// it a `RemotelyExploitable` quarantine candidate) which mounts the secret. The entry's OWN +/// `containment_for` default is the surgical `reaches` edge-cut — a DIFFERENT cut signature +/// than `store`'s `QuarantineWorkload` line — so a decision naming `store` produces a standing +/// cut `containment_for(chain)` would never rebuild on its own. Exactly the shape the D7 +/// retirement-asymmetry bug (a fresh Uncertain silently dropping a differing-signature standing +/// cut) needs to be exercised against. +fn web_reaches_pivot_store_snapshot() -> Snapshot { + use crate::engine::graph::{Provenance, Severity, Vulnerability}; + use crate::engine::observe::ImageVulnerabilities; + use std::time::SystemTime; + + let web = json!({ + "apiVersion": "v1", "kind": "Pod", + "metadata": {"name": "web", "namespace": "app", "labels": {"role": "web"}}, + "spec": {"containers": [{"name": "c", "image": "web:1"}]} + }); + let lb = json!({ + "apiVersion": "v1", "kind": "Service", + "metadata": {"name": "web-lb", "namespace": "app"}, + "spec": {"type": "LoadBalancer", "selector": {"role": "web"}} + }); + let store = json!({ + "apiVersion": "v1", "kind": "Pod", + "metadata": {"name": "store", "namespace": "app", "labels": {"role": "store"}}, + "spec": { + "containers": [{ + "name": "c", "image": "store:1", + "envFrom": [{"secretRef": {"name": "store-creds"}}] + }] + } + }); + let policy = json!({ + "apiVersion": "networking.k8s.io/v1", "kind": "NetworkPolicy", + "metadata": {"name": "store-ingress", "namespace": "app"}, + "spec": { + "podSelector": {"matchLabels": {"role": "store"}}, + "policyTypes": ["Ingress"], + "ingress": [{"from": [{"podSelector": {"matchLabels": {"role": "web"}}}]}] + } + }); + Snapshot { + pods: vec![ + serde_json::from_value(web).unwrap(), + serde_json::from_value(store).unwrap(), + ], + services: vec![serde_json::from_value(lb).unwrap()], + network_policies: vec![serde_json::from_value(policy).unwrap()], + image_vulns: vec![ImageVulnerabilities { + image: "store:1".into(), + vulnerabilities: vec![Vulnerability { + id: "CVE-2026-0570".into(), + severity: Severity::Critical, + exploited_in_wild: false, + epss: None, + sources: vec![Provenance::new("trivy", SystemTime::UNIX_EPOCH)], + ..Default::default() + }], + }], + ..Default::default() + } +} + /// D6: no decision at all ⇒ the `containment_for` FALLBACK proposes, but stamped /// `adjudicated = false` so it can NEVER clear the auto-action gate — even though the chain /// itself is genuinely corroborated. The human-proposal fallback is never auto-applied. @@ -224,3 +287,131 @@ fn a_non_member_reply_degrades_to_uncertain_and_reconcile_falls_back() { .expect("the degraded decision falls back to containment_for, like no decision at all"); assert!(!mitigation.is_live_corroborated()); } + +/// ADR-0034 D7 (the retirement-asymmetry SAFETY bug this test locks down): a DOWNSTREAM +/// `QuarantineWorkload` cut chosen on pass N — whose signature is NOT `containment_for`'s own +/// default for the entry (the entry's default here is the surgical `reaches` edge-cut) — must +/// still be standing on pass N+1 when the entry comes back with NO decision at all (model +/// unavailable / not yet judged / the cold-start window). A fresh Uncertain must be INERT, not +/// silently rebuild the desired set from `containment_for` and drop it into `retired` (which +/// would tear down the live isolation NetworkPolicy in enforce mode). +#[test] +fn a_downstream_cut_persists_across_a_pass_with_no_decision() { + let graph = build_graph(&web_reaches_pivot_store_snapshot(), &default_adapters()); + let chains = prove(&graph); + let chain = web_chain(&chains); + let store = crate::engine::graph::NodeKey("workload/app/Pod/store".into()); + + let menu = build_menu(chain, &graph, &HealthReport::default()); + let store_cut = menu.resolve(&store).expect("store is selectable"); + let store_signature = store_cut.cut_signature.clone(); + // Sanity: this really is a DIFFERENT signature than the entry's own containment_for + // default — the exact shape the bug needs to be exercised against. + let (entry_default_cut, _) = containment_for(chain).expect("entry has a default containment"); + assert_ne!( + store_signature, + cut_signature(&entry_default_cut), + "sanity: the downstream cut's signature must differ from containment_for's own default" + ); + + let mut decisions = BTreeMap::new(); + decisions.insert( + chain.entry.0.clone(), + IncidentDecision { + assessment: Assessment::Attack, + reason: "store shows exploitation evidence".to_string(), + cuts: vec![store_cut], + }, + ); + let mut ledger = MitigationLedger::new(); + + // Pass N: the model names `store` — the downstream cut goes active. + ledger.reconcile(&chains, &decisions); + assert!( + ledger + .active() + .any(|m| m.cut_signature() == store_signature), + "the downstream cut is active after the decisive pass" + ); + + // Pass N+1: SAME chains, but no decision for this entry at all this cycle (model down / + // not yet re-judged). The standing downstream cut must persist — not retire. + let delta = ledger.reconcile(&chains, &BTreeMap::new()); + assert!( + ledger + .active() + .any(|m| m.cut_signature() == store_signature), + "D7: a pass with no decision must be inert — the standing downstream cut must still be \ + active, got active={:?}", + ledger + .active() + .map(Mitigation::cut_signature) + .collect::>() + ); + assert!( + delta + .retired + .iter() + .all(|m| m.cut_signature() != store_signature), + "the downstream cut must NOT appear in this pass's retired set, got {:?}", + delta.retired + ); +} + +/// The regression guard for the fix above: the carry-forward must not make a standing cut +/// UN-retirable. A decisive `NoAttack` on a LATER pass still clears it, exactly as D6/D7 intend. +#[test] +fn a_decisive_no_attack_still_retires_a_standing_cut() { + let graph = build_graph(&web_reaches_pivot_store_snapshot(), &default_adapters()); + let chains = prove(&graph); + let chain = web_chain(&chains); + let store = crate::engine::graph::NodeKey("workload/app/Pod/store".into()); + + let menu = build_menu(chain, &graph, &HealthReport::default()); + let store_cut = menu.resolve(&store).expect("store is selectable"); + let store_signature = store_cut.cut_signature.clone(); + + let mut attack_decisions = BTreeMap::new(); + attack_decisions.insert( + chain.entry.0.clone(), + IncidentDecision { + assessment: Assessment::Attack, + reason: "store shows exploitation evidence".to_string(), + cuts: vec![store_cut], + }, + ); + let mut ledger = MitigationLedger::new(); + ledger.reconcile(&chains, &attack_decisions); + assert!( + ledger + .active() + .any(|m| m.cut_signature() == store_signature) + ); + + // A LATER pass decisively clears the entry — the standing cut retires, same as before this + // fix (the carry-forward only applies to Uncertain/no-decision, never to a decisive call). + let mut clear_decisions = BTreeMap::new(); + clear_decisions.insert( + chain.entry.0.clone(), + IncidentDecision { + assessment: Assessment::NoAttack, + reason: "store's CVE was patched and reachability closed".to_string(), + cuts: Vec::new(), + }, + ); + let delta = ledger.reconcile(&chains, &clear_decisions); + assert!( + delta + .retired + .iter() + .any(|m| m.cut_signature() == store_signature), + "a decisive NoAttack still retires the standing cut, got retired={:?}", + delta.retired + ); + assert!( + !ledger + .active() + .any(|m| m.cut_signature() == store_signature), + "no longer active after the decisive clear" + ); +} diff --git a/engine/src/engine/respond/mod.rs b/engine/src/engine/respond/mod.rs index 43229222..a626260d 100644 --- a/engine/src/engine/respond/mod.rs +++ b/engine/src/engine/respond/mod.rs @@ -323,6 +323,67 @@ pub fn containment_for(chain: &ProvenChain) -> Option<(Link, ProposedAction)> { .map(|cut| (cut.clone(), ProposedAction::for_cut(cut))) } +/// The `containment_for` human-proposal fallback for one breach-relevant chain's entry (ADR-0034 +/// D6) — stamped `adjudicated = false` so [`Mitigation::is_live_corroborated`] can never clear +/// it, no matter how corroborated the chain actually is. Used both when there is no standing +/// cut to carry forward ([`carry_forward_or_fallback`]) and for a decisive `Attack` that named +/// no cut (D1 — a decisive omission). +fn fallback_proposal(chain: &ProvenChain, desired: &mut BTreeMap) { + let Some((cut, action)) = containment_for(chain) else { + return; + }; + let mut justification = Justification::of(chain); + justification.adjudicated = false; + desired + .entry(cut_signature(&cut)) + .or_insert_with(|| Mitigation { + cut, + action, + justifications: Vec::new(), + }) + .justifications + .push(justification); +} + +/// ADR-0034 D7 (the retirement asymmetry, safety-critical): when this pass has no decisive +/// decision for `chain`'s entry (no decision at all, or a fresh `Uncertain` — model +/// unavailable, not yet judged, or the cold-start window after a restart), it must be INERT — +/// neither open a live attack path nor sever one. A previous pass's model-chosen cut (its +/// signature generally differs from `containment_for`'s own default — a downstream +/// `QuarantineWorkload` line always does) must NOT quietly drop out of the desired set just +/// because this pass rebuilt it from scratch: dropping it would read to the caller as "no +/// longer justified" and trigger the self-revert loop, tearing down a live isolation control on +/// a transient model wobble. So: carry every mitigation ALREADY active for this entry forward +/// unchanged (re-justified against THIS pass's chain, so a genuine chain-clear next pass still +/// retires it structurally, exactly like any other mitigation) — never rebuild from +/// `containment_for` while a standing cut exists. Only when there is NO standing cut for this +/// entry does the `containment_for` fallback apply, so a human still has something to review. +fn carry_forward_or_fallback( + chain: &ProvenChain, + active: &BTreeMap, + desired: &mut BTreeMap, +) { + let standing: Vec<&Mitigation> = active + .values() + .filter(|m| m.justifications.iter().any(|j| j.entry == chain.entry.0)) + .collect(); + if standing.is_empty() { + fallback_proposal(chain, desired); + return; + } + for mitigation in standing { + desired + .entry(mitigation.cut_signature()) + .or_insert_with(|| Mitigation { + cut: mitigation.cut.clone(), + action: mitigation.action, + justifications: Vec::new(), + }) + .justifications + .push(Justification::of(chain)); + } +} + /// What changed in the ledger this cycle. #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct LedgerDelta { @@ -396,13 +457,23 @@ impl MitigationLedger { /// - **model-chosen cuts** whose entry still has a proven, breach-relevant justifying /// chain and a DECISIVE `Attack` decision naming them (they clear the JEF-566 /// `is_live_corroborated` auto-action gate on their own justifications, same as before); + /// - **carried-forward standing cuts** (D7's retirement asymmetry, safety-critical) — when + /// this pass has NO decisive decision for a breach-relevant entry (no decision at all, or + /// a fresh `Uncertain`: model unavailable, not yet judged, or the cold-start window after + /// a restart), any mitigation ALREADY active for that entry stays active, UNCHANGED, this + /// cycle — a transient model wobble/outage must never look like a decisive omission and + /// sever a standing, possibly downstream, cut (that would tear down a live isolation + /// NetworkPolicy in enforce mode, reopening the path). Re-justified against this pass's + /// chain (so a genuine chain-clear still retires it structurally, next pass, exactly as + /// for any other mitigation); /// - **`containment_for` FALLBACK proposals** — the entry's own ladder result only, never a - /// downstream workload — for every breach-relevant entry with no decisive `Attack` - /// decision that named a cut (no decision at all, `Uncertain`, or a decisive `Attack` - /// with an empty `contain` — D1's "attack, but no cut warranted"). Stamped - /// `adjudicated = false` so [`Mitigation::is_live_corroborated`] can never clear it — - /// the human-proposal fallback, never auto-applied. A decisive `NoAttack` gets NEITHER - /// (the model confidently cleared the entry — nothing to propose). + /// downstream workload — for a breach-relevant entry with no decisive cut AND no standing + /// cut to carry forward, OR a decisive `Attack` with an empty `contain` (D1's "attack, but + /// no cut warranted" — a decisive OMISSION, so it retires same as `NoAttack` and offers + /// only the fallback). Stamped `adjudicated = false` so + /// [`Mitigation::is_live_corroborated`] can never clear it — the human-proposal fallback, + /// never auto-applied. A decisive `NoAttack` gets NEITHER (the model confidently cleared + /// the entry — nothing to propose, and any standing cut retires). /// /// The deterministic `quarantine_targets` desired-set insertion is **deleted** for /// breach-relevant chains — completing the ADR-0032 auto-fire removal — but UNCHANGED for @@ -428,8 +499,7 @@ impl MitigationLedger { } if chain.is_breach_relevant() { - let decision = decisions.get(&chain.entry.0); - match decision { + match decisions.get(&chain.entry.0) { // A decisive Attack that named cuts: the model-chosen desired set. Some(d) if d.assessment == Assessment::Attack && !d.cuts.is_empty() => { for cut in &d.cuts { @@ -445,25 +515,19 @@ impl MitigationLedger { } } // A decisive, confident NoAttack: the model cleared this entry — no - // fallback proposal either (nothing to hand a human to review). + // fallback proposal either (nothing to hand a human to review), and any + // standing cut is deliberately NOT carried forward (it retires). Some(d) if d.assessment == Assessment::NoAttack => {} - // No decision yet / Uncertain / a decisive Attack naming no cut (D1): - // the containment_for FALLBACK, entry-only, stamped non-auto. - _ => { - if let Some((cut, action)) = containment_for(chain) { - let mut justification = Justification::of(chain); - justification.adjudicated = false; - desired - .entry(cut_signature(&cut)) - .or_insert_with(|| Mitigation { - cut, - action, - justifications: Vec::new(), - }) - .justifications - .push(justification); - } + // No decision at all, or a fresh Uncertain: D7's retirement asymmetry — + // INERT. Carry any standing cut for this entry forward unchanged; only + // when there is none do we offer the fallback proposal. + None => carry_forward_or_fallback(chain, &self.active, &mut desired), + Some(d) if d.assessment == Assessment::Uncertain => { + carry_forward_or_fallback(chain, &self.active, &mut desired) } + // A decisive Attack naming no cut (D1): a decisive OMISSION — retires any + // standing cut and offers only the containment_for fallback. + Some(_) => fallback_proposal(chain, &mut desired), } continue; } From d908ecd88cc0545176d9992be0a382057bea60b8 Mon Sep 17 00:00:00 2001 From: Jeff Larson Date: Tue, 28 Jul 2026 21:48:37 -0700 Subject: [PATCH 3/3] fix(e2e): bring scripts/e2e.sh into line with ADR-0034 D6 (JEF-570) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/e2e.sh step 7 still encoded the OLD ADR-0009 contract — asserting that live corroboration ALONE, with no model, auto-cuts the entry (and the remotely-exploitable pivot). This PR's D6 deliberately deletes that deterministic auto-cut: the desired active set is now model-chosen cuts + non-auto (adjudicated=false) containment_for fallbacks. CI has no Ollama (steps 10-11 skip), so with no model reconcile correctly applies nothing — step 7's "engine cuts the corroborated entry web" assertion timed out. - Step 7 (renamed): HARD MODE + live corroboration + NO model is now PROPOSE-only. Asserts no NetworkPolicy is applied for web OR store, and that the engine logs a "mitigation proposed" line for web's containment_for fallback (two new log-line predicates, mitigation_proposed_for_web / mitigation_retired_for_web, mirroring the existing chains_proven two-stage-grep pattern so field order in the log formatter can't break the match). - Step 8 (renamed): the fallback proposal is chain-gated — removing the durable allow retires it (a "mitigation retired" log line), exactly like an applied cut would revert. Reasserts no NetworkPolicy exists (nothing was ever applied to revert). - Step 6 + the managed_np_roles doc comment: reworded off "asymmetric action bar (ADR-0009)" / "corroboration would flip auto-eligible" — corroboration alone no longer drives an auto-cut for either the entry or the (now model-only) pivot proposal. - Steps 9-11 (log4j/model phases) are UNCHANGED — they already tested the right thing (no model ⇒ propose-only; a model's own verdict ⇒ cut) and needed no changes for D6. - docs/adr/0034-cut-choice-contract.md: one-line Consequences note — ADR-0034 supersedes ADR-0009's corroboration-alone auto-cut. The model-driven auto-cut/self-revert cycle (steps 10-11) remains this e2e's only apply/revert coverage against a REAL cluster + a real model (Ollama-gated, skipped in CI) — but the SAME Engine::process apply path is now ALSO exercised unconditionally in CI by a new Rust integration test, engine::tests::a_model_chosen_cut_auto_applies_in_enforce_mode (enforce mode + judgement armed + a stub Adjudicator's decisive Attack ⇒ the dry-run actuator's active_count() > 0), alongside the existing shadow contrast a_model_chosen_cut_only_proposes_in_shadow_by_default. Validation: docker is unreachable in this sandbox (k3d needs a live daemon), so scripts/e2e.sh could not be run locally — the assertions were updated precisely against a full read of the script's pre/post-D6 behavior and cross-checked against engine::model::config()'s documented "no PROTECTOR_ENGINE_MODEL ⇒ NullAdjudicator, deterministic-only" contract, which is exactly step 7's configuration. CI will validate; will iterate if still red. 1025 lib tests green (was 1024); cargo fmt clean; cargo clippy --all-targets -D warnings clean (workspace). Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP Co-authored-by: Claude Opus 4.8 --- docs/adr/0034-cut-choice-contract.md | 4 + engine/src/engine/tests.rs | 48 ++++++++++ scripts/e2e.sh | 131 +++++++++++++++++---------- 3 files changed, 135 insertions(+), 48 deletions(-) diff --git a/docs/adr/0034-cut-choice-contract.md b/docs/adr/0034-cut-choice-contract.md index 03f9c89e..fb18d9ca 100644 --- a/docs/adr/0034-cut-choice-contract.md +++ b/docs/adr/0034-cut-choice-contract.md @@ -133,6 +133,10 @@ ladder, and entry-exclusion all survive as the resolver + fallback.) 1.7B's over-cut mass is acceptable. - **Model unavailable** ⇒ fallback proposals only; standing cuts persist until chain-clear (§7's deliberate asymmetry). +- **ADR-0034 supersedes ADR-0009's corroboration-alone auto-cut**: under enforce mode with no + live model, determinism only *proposes* (the `containment_for` fallback, stamped + `adjudicated=false`) — nothing auto-cuts on live corroboration by itself anymore. Only a + model's decisive `attack` naming a node (§1/§6) arms an auto-eligible cut now. - Refines [ADR-0032](0032-model-is-incident-responder.md) §3 (mechanism-menu → target-choice) and its 4-value output (→ 3-value). Judge tier remains [ADR-0033] (pending T2b). - Deferred: edge-granular downstream cuts (no actuator lever — the schema versions forward via diff --git a/engine/src/engine/tests.rs b/engine/src/engine/tests.rs index ad79e69b..9807a2c8 100644 --- a/engine/src/engine/tests.rs +++ b/engine/src/engine/tests.rs @@ -714,3 +714,51 @@ async fn a_model_chosen_cut_only_proposes_in_shadow_by_default() { "nothing armed ⇒ nothing actually applied, even for a decisive model-chosen cut" ); } + +/// The positive contrast, in ENFORCE mode: the SAME decisive Attack, on an +/// uncorroborated-but-promoted chain (`judgement` opt-in, ADR-0011), with the `network` +/// class armed, actually reaches the (dry-run) actuator's apply — the full +/// `Engine::process` path this ticket wires, exercised unconditionally in CI (unlike +/// `scripts/e2e.sh`'s Ollama-gated model phase, which exercises the same path against a +/// real cluster + a real model). +#[tokio::test] +async fn a_model_chosen_cut_auto_applies_in_enforce_mode() { + struct AlwaysAttacksTheEntry; + #[async_trait::async_trait] + impl reason::adjudicate::Adjudicator for AlwaysAttacksTheEntry { + async fn judge( + &self, + entry: &NodeKey, + _objectives: &[(NodeKey, AttackRef)], + _graph: &SecurityGraph, + _prompt: &str, + _downstream: &[NodeKey], + menu: &Menu, + ) -> IncidentDecision { + let cut = menu + .resolve(entry) + .expect("the entry is selectable on its own menu"); + IncidentDecision { + assessment: Assessment::Attack, + reason: "RCE reaches the secret".to_string(), + cuts: vec![cut], + } + } + } + // `network` arms the cut classes; `judgement` lets the model's own Attack PROMOTE an + // uncorroborated chain to auto-eligible (ADR-0011) — `exposed_snapshot(true)` carries + // a CVE but no live runtime signal, so promotion is what clears the gate here. + let mut engine = Engine::new( + EnabledActions::from_names(["network", "judgement"]), + ActuationScope::unscoped(), + Box::new(DryRunActuator), + Box::new(AlwaysAttacksTheEntry), + ); + engine.process(&exposed_snapshot(true)).await; + + assert!( + engine.actions.active_count() > 0, + "network armed + the model's own Attack promoting the chain ⇒ the dry-run \ + actuator actually applies the model-chosen cut" + ); +} diff --git a/scripts/e2e.sh b/scripts/e2e.sh index 13ef5256..4c3d4624 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -10,26 +10,41 @@ # actuator exactly as it behaves in prod. (Cilium/Calico is only needed for the # ANP actuator, which this test does not cover.) # -# The scenario proves the whole asymmetric action bar (ADR-0009) and the Q5 -# self-revert invariant end-to-end: +# The scenario proves the target-choice contract (ADR-0034 — the model names the +# compromised node, determinism resolves the narrowest cut) and the Q5 self-revert +# invariant end-to-end: # # web (internet-exposed) ──reaches──▶ store ──can-read──▶ secret/session-key # -# A. shadow — the chain proves (store reachable + compromisable), but the -# entry has no foothold and no corroboration yet. -# B. corroborate — an `Alert` behavior on `web` (posted to the behavioral port) -# flips it to "auto-eligible", but nothing is applied (shadow). -# C. hard mode — enable=network: the engine applies a default-deny -# NetworkPolicy quarantining `web`. -# D. self-revert — remove the durable allow (store-ingress); the chain stops -# being provable, so the engine DELETES its NetworkPolicy. +# A. shadow — the chain proves (store reachable + compromisable), but the +# entry has no foothold and no corroboration yet. +# B. corroborate — an `Alert` behavior on `web` (posted to the behavioral port) +# flips it breach-relevant + corroborated, but nothing is +# applied OR proposed yet (shadow). +# C. hard, NO model — enable=network, live corroboration alone: ADR-0034 D6 +# DELETES ADR-0009's corroboration-only auto-fire — the engine +# only PROPOSES the entry's containment_for fallback (stamped +# non-auto), it applies NOTHING. Only a model's decisive +# `attack` naming a node drives an auto-cut now (see F below). +# D. fallback retires — remove the durable allow (store-ingress); the chain +# stops being provable, so the (never-applied) fallback +# proposal itself retires — proposals are chain-gated exactly +# like an applied cut would be. # -# Then the core thesis — proofs WINNOW, the model DECIDES: +# Then the core thesis — proofs WINNOW, the model DECIDES what to cut: # E. log4j present, NO model — a critical CVE is proven reachable, but presence # is not proof of exploitability, so the engine only PROPOSES (no auto-cut). -# F. log4j + model — the model examines the proven path, judges it EXPLOITABLE, -# and ONLY THEN does the engine cut. The determination is the model's, not a -# rule's. (Skipped if no Ollama is reachable; see PROTECTOR_E2E_MODEL.) +# F. log4j + model — the model examines the proven path, decides `attack` naming +# the entry, and ONLY THEN does the engine cut. The determination — and the +# TARGET — are the model's, not a rule's (ADR-0034). (Skipped if no Ollama is +# reachable; see PROTECTOR_E2E_MODEL. The model-driven auto-cut/self-revert +# cycle this phase exercises is this e2e's ONLY apply/revert coverage against a +# REAL cluster + a real model now that C/D no longer apply anything — it is +# Ollama-gated here, but the SAME `Engine::process` apply path is also exercised +# unconditionally in CI by a Rust integration test with a stub `Adjudicator`: +# `engine::tests::a_model_chosen_cut_auto_applies_in_enforce_mode` (the shadow-vs- +# enforce contrast lives alongside it as +# `a_model_chosen_cut_only_proposes_in_shadow_by_default`).) # G. self-revert — the model-driven cut reverts when the chain stops proving. # # Requirements: docker, k3d, kubectl, jq, curl. A reachable Ollama for E/F. @@ -172,22 +187,38 @@ managed_np_name() { managed_np_present() { [ -n "$(managed_np_name)" ]; } managed_np_absent() { [ -z "$(managed_np_name)" ]; } +# ADR-0034 D6: with no model, the corroborated entry's `containment_for` fallback is only a +# PROPOSAL — never a NetworkPolicy — so the observable proof is the engine's own log line +# (`LedgerDelta::emit`, respond/mod.rs), not the cluster's NetworkPolicy state. Two-stage grep +# (message, then the cut string) so field order in the log formatter can't break the match, the +# same defensive shape `chains_proven` already uses for its own log-line assertion. +mitigation_proposed_for_web() { + kubectl -n "$NS" logs deploy/protector 2>/dev/null \ + | grep 'mitigation proposed' | grep -q 'workload/app/Pod/web' +} +mitigation_retired_for_web() { + kubectl -n "$NS" logs deploy/protector 2>/dev/null \ + | grep 'mitigation retired' | grep -q 'workload/app/Pod/web' +} + # Hard mode can apply MORE than one managed policy at once (ADR-0022 / JEF-284), so an # assertion must name the workload ROLE it means, never assume a single policy or grab -# "the first one" (that races). Two distinct controls coexist on the web→store→secret +# "the first one" (that races). Two distinct controls exist on the web→store→secret # scenario: -# - the ENTRY cut (role=web): the internet-facing entry is isolated once its chain -# meets the asymmetric action bar — live corroboration (ADR-0009) or a model -# "exploitable" verdict (ADR-0011). It is gated: no cut on mere CVE presence. +# - the ENTRY cut (role=web): the internet-facing entry is isolated ONLY once the +# model decides `attack` and names it (ADR-0034 D6). Live corroboration ALONE no +# longer auto-cuts it — that's ADR-0009's contract, which D6 supersedes; corroborated- +# but-unjudged now PROPOSES the `containment_for` fallback (stamped non-auto), never +# applies. # - the PIVOT quarantine (role=store): a *remotely-exploitable* pod — a non-entry # workload reachable from the internet-exposed entry that runs a critical/KEV CVE — -# is IDENTIFIED as a quarantine candidate DETERMINISTICALLY (JEF-284: reachable + -# critical CVE, no model needed). But auto-ACTION on it now clears the SAME bar as -# the entry (JEF-566 / ADR-0032): its justifying chain must be corroborated/promoted, -# adjudicated, and breach-relevant. Corroborated ⇒ auto-quarantined; otherwise it -# stays propose-only. Reachability + CVE presence alone no longer auto-cuts the pivot. -# The two race, and store's policy-name can sort either side of web's, so the old -# `managed_np_name | head -n1`-selects-web check was inherently flaky. +# is still IDENTIFIED as a quarantine CANDIDATE deterministically at the proof layer +# (JEF-284: reachable + critical CVE, no model needed), but is no longer PROPOSED at +# all without the model naming it (ADR-0034 D6: the `containment_for` fallback is +# entry-only, never a downstream workload) — reachability + CVE presence, and even +# live corroboration, no longer surfaces it as a proposal, let alone auto-cuts it. +# The old "corroboration alone auto-quarantines a race between the two" scenario is GONE; +# a policy can now only exist for a workload the model itself named. managed_np_roles() { kubectl -n "$APP_NS" get networkpolicy -l app.kubernetes.io/managed-by=protector \ -o jsonpath='{range .items[*]}{.spec.podSelector.matchLabels.role}{"\n"}{end}' 2>/dev/null @@ -508,7 +539,7 @@ spec: YAML kubectl -n "$APP_NS" wait --for=condition=Ready pod/web pod/store --timeout=120s -step "6/11 SHADOW: chain proves structurally, then corroboration would flip it auto-eligible — but NOTHING is applied" +step "6/11 SHADOW: chain proves structurally, then corroboration lands — but NOTHING is applied (shadow proposes, never acts)" # This is the FIRST proof pass after a fresh rollout, and it is gated behind the engine's # per-pass signing-posture sweep (ADR-0020), which runs BEFORE process() on every image # running in the cluster. On a cold TUF cache with CI's constrained egress that first sweep @@ -527,35 +558,39 @@ post_alert sleep 10 managed_np_absent || fail "shadow mode applied a NetworkPolicy — propose-only was violated" -pass "shadow mode applied nothing (propose-only honored) — corroboration would meet the asymmetric action bar, but shadow only proposes" +pass "shadow mode applied nothing (propose-only honored) — shadow is the default posture regardless of what phase 7 goes on to (not) auto-cut" -step "7/11 HARD MODE: enable=network + actuation RBAC; engine cuts the corroborated entry (web) AND quarantines the remotely-exploitable pivot (store)" +step "7/11 HARD MODE, NO model: live corroboration ALONE no longer auto-cuts (ADR-0034 D6 supersedes ADR-0009) — the engine only PROPOSES the entry's fallback; nothing is applied" deploy_protector network true "" "" kubectl -n "$NS" rollout status deploy/protector --timeout=180s pf_reset # The pod was replaced, so its runtime-evidence store reset — re-send the alert that -# corroborates web (the internet-facing entry), which is what flips its chain to -# auto-eligible under the asymmetric action bar. +# corroborates web (the internet-facing entry). post_alert -# Wait for the ENTRY cut specifically (role=web) — the corroboration-driven control this -# step exists to prove — not "the first managed policy". The SAME live alert that -# corroborates web's chain also corroborates the pivot `store`'s justifying chain (they -# share it), so the engine ALSO auto-quarantines the remotely-exploitable pivot `store`: -# JEF-284 identifies the candidate (reachable from the entry + a critical CVE), and -# JEF-566 clears it to auto-action because that chain is now corroborated. The two race, -# so naming the role is the only stable assertion. -wait_until "engine cuts the corroborated entry web" 120 managed_np_for web -pass "engine quarantined role=web — live corroboration met the asymmetric action bar (ADR-0009)" -# The pivot is ALSO isolated — its justifying chain is corroborated by the same alert -# (JEF-566: the pivot clears the same auto-action bar as the entry, not a separate one). -managed_np_for store \ - || fail "engine did not quarantine the remotely-exploitable pivot role=store (corroborated chain)" -pass "engine also quarantined role=store — a remotely-exploitable pivot on a corroborated chain" - -step "8/11 SELF-REVERT: remove the durable allow; web is no longer provable AND store is no longer reachable-from-internet, so the engine reverts BOTH controls" +# Give reconcile a few cycles, exactly like the shadow-mode assertion in step 6. +sleep 10 +# ADR-0034 D6: with NO model configured the engine has no decisive decision for ANY +# entry — corroboration alone is no longer the auto-action trigger (that was ADR-0009; +# D6 deletes the deterministic auto-fire it drove). So NEITHER the entry NOR the pivot +# is ever actually isolated here, no matter how corroborated the chain is. +managed_np_for_absent web \ + || fail "engine auto-cut the corroborated entry web with NO model — ADR-0034 D6 (the model, not corroboration alone, must decide the cut) was violated" +managed_np_for_absent store \ + || fail "engine auto-quarantined the pivot role=store on corroboration alone with NO model — D6 makes downstream proposals model-only too" +pass "no NetworkPolicy applied for web or store — corroboration alone no longer auto-cuts (ADR-0034 D6)" +# The corroborated entry still surfaces something to a human: the containment_for +# fallback, stamped non-auto (never the pivot — D6's fallback is entry-only; a +# downstream proposal now requires the model to name it, see phase 10). +wait_until "engine proposes the corroborated entry's fallback (non-auto)" 60 mitigation_proposed_for_web +pass "engine proposed role=web's containment_for fallback — surfaced to a human, never auto-applied" + +step "8/11 the fallback proposal is chain-gated: remove the durable allow, the (never-applied) proposal retires exactly like an applied cut would" kubectl -n "$APP_NS" delete networkpolicy store-ingress -wait_until "engine deletes every managed NetworkPolicy" 120 managed_np_absent -pass "engine reverted both compensating controls once the chain stopped being proven (Q5 invariant)" +wait_until "engine retires the fallback proposal once the chain stops proving" 120 mitigation_retired_for_web +pass "engine retired the fallback proposal once the chain stopped being proven (Q5 invariant holds for a proposal too)" +# No NetworkPolicy ever existed to delete, but reassert absence for symmetry with the +# pre-ADR-0034 self-revert step this one replaces. +managed_np_absent || fail "a NetworkPolicy exists that phase 7 should never have applied" step "9/11 LOG4J PRESENT, NO MODEL: a critical CVE on the exposed image is PROVEN reachable — but presence ≠ exploitability, so with no analyst to judge it, the engine only PROPOSES (no auto-cut)" # The VulnerabilityReport CRD was created in phase 5. Now a CRITICAL log4shell finding