From 8fd5a2c1be5b20ad8c4e8443ef368f594e771b48 Mon Sep 17 00:00:00 2001 From: Jeff Larson Date: Sun, 2 Aug 2026 15:10:13 -0700 Subject: [PATCH] feat(engine): ContainNode action + resolver escalation + honest proposal surface (ADR-0040) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shadow-complete step 1b of node-scoped containment: the deterministic menu/ledger resolver now escalates a model-named workload X to ProposedAction::ContainNode the moment boundary_break(X) holds (typed evidence a proven pod-boundary break), instead of proposing a pod cut the engine's own evidence proves can't contain a host-namespace process. The model still only ever names the workload — no new menu line, no model-selectable mechanism; determinism resolves the escalation in the same build_menu/reconcile code path so the proposal surface and the ledger can never disagree. - ProposedAction::ContainNode: reversible, not additive-live (propose-first by construction via the existing blast/alive-collateral gate). - respond::contain_node_link/self_severance: pure Link + collateral-naming helpers, no untrusted substrings. - menu::escalate wires boundary_break into both the entry ladder and downstream quarantine lines; the honest damage-limitation blast note (with a self-severance clause when protector's own components share the node) replaces the network-cut blast-radius note for ContainNode lines. - adj_pass::model_attack_set wires trigger (d) to the LIVE per-entry IncidentDecision state instead of a caller-supplied stand-in. - Journal/replay-lock needed no schema change (JournaledCut is already action-agnostic); added round-trip + flip-fails-closed coverage. - One deployed-pod cut-choice bench fixture (judge_bakeoff_cutchoice.py) — needs deployed-pod validation per ADR-0033, not tuned to any local backend. No RBAC, no chart, no actuation path touched — the actuator that renders the cordon + co-resident denies lands separately (ADR-0040 §7). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP --- engine/src/engine/adj_pass.rs | 32 ++- engine/src/engine/adj_pass_tests.rs | 106 +++++++++ engine/src/engine/cut_divergence_tests.rs | 4 +- engine/src/engine/journal/tests.rs | 42 ++++ .../reason/adjudicate/incident/fixtures.rs | 87 ++++++++ .../engine/reason/adjudicate/incident/menu.rs | 120 +++++++++-- .../reason/adjudicate/incident/menu_tests.rs | 201 ++++++++++++++++-- .../reason/adjudicate/incident/mod_tests.rs | 21 +- .../engine/reason/adjudicate/tests/group_2.rs | 3 + .../reason/proof/pivot_quarantine_tests.rs | 7 +- .../src/engine/respond/contain_node_tests.rs | 142 +++++++++++++ engine/src/engine/respond/decisions_tests.rs | 10 +- engine/src/engine/respond/mod.rs | 106 +++++++++ engine/src/engine/respond/tests.rs | 4 +- engine/src/engine/state/findings.rs | 4 + scripts/judge_bakeoff_cutchoice.py | 37 ++++ 16 files changed, 882 insertions(+), 44 deletions(-) create mode 100644 engine/src/engine/respond/contain_node_tests.rs diff --git a/engine/src/engine/adj_pass.rs b/engine/src/engine/adj_pass.rs index c6b73d6a..aeae468e 100644 --- a/engine/src/engine/adj_pass.rs +++ b/engine/src/engine/adj_pass.rs @@ -33,7 +33,7 @@ //! model-chosen cuts, not a deterministic insertion. use futures::StreamExt; -use std::collections::{BTreeMap, HashMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use super::{ Engine, PendingEntry, RestoredDecision, adj_gate, churn_diag, graph, journal, model, notify, @@ -118,6 +118,15 @@ impl Engine { // mid-pass swaps the next pass's snapshot, never this one — so every entry judged this // pass sees a consistent provider table (mirrors the KEV/EPSS per-pass snapshot). let asn = self.asn.snapshot(); + // ADR-0040 §3(d): the LIVE cross-entry model-attack set — every workload named in a + // decisive `assessment=attack`'s `contain`, across EVERY entry's current standing + // decision (not just the one whose menu is being built; a co-resident pair can be + // decided across two different entries). Computed ONCE before the per-entry loop from + // `self.decisions` as it stands at the START of this pass (last pass's decisive calls, + // or a carried-forward decision — ADR-0034 D7), so `boundary_break`'s trigger (d) sees + // exactly the decisions the live `IncidentDecision`/judge pipeline has already made, + // never a caller-supplied stand-in. + let model_attack = model_attack_set(&self.decisions); for (entry_key, idxs) in &by_entry { let entry = chains[idxs[0]].entry.clone(); // The (objective, technique) set this entry reaches — what the model judges. @@ -137,7 +146,7 @@ impl Engine { // 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); + let menu = entry_menu(idxs, chains, graph, health, &model_attack); // Build the entry's delta-aware pending record (prompt + fingerprint + projected // surface) and read its baseline — see [`Engine::prepare_pending`] (ADR-0023 @@ -583,11 +592,12 @@ fn entry_menu( chains: &[reason::proof::ProvenChain], graph: &graph::SecurityGraph, health: &observe::health::HealthReport, + model_attack: &BTreeSet, ) -> 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); + let m = incident::build_menu(&chains[i], graph, health, model_attack); selectable.extend(m.selectable); uncontainable.extend(m.uncontainable); } @@ -598,6 +608,22 @@ fn entry_menu( } } +/// The LIVE cross-entry model-attack set (ADR-0040 §3(d)): every workload named in +/// `contain` by a decisive `assessment=attack` decision, across every entry `decisions` +/// currently holds — the set [`crate::engine::reason::proof::boundary_break`]'s trigger (d) +/// composes two SEPARATE decisive calls over (co-resident workloads decided by different +/// entries still compose). Pure over `Engine::decisions` so it is directly testable without +/// spinning up a whole pass. +fn model_attack_set( + decisions: &BTreeMap, +) -> BTreeSet { + decisions + .values() + .filter(|d| d.assessment == incident::Assessment::Attack) + .flat_map(|d| d.cuts.iter().map(|c| c.node.clone())) + .collect() +} + #[cfg(test)] #[path = "adj_pass_tests.rs"] mod tests; diff --git a/engine/src/engine/adj_pass_tests.rs b/engine/src/engine/adj_pass_tests.rs index d6d0e757..d7385964 100644 --- a/engine/src/engine/adj_pass_tests.rs +++ b/engine/src/engine/adj_pass_tests.rs @@ -168,3 +168,109 @@ fn one_failing_cut_drops_the_whole_multi_cut_decision() { "one cut failing its lock must drop the entire decision, not just that cut" ); } + +// --- ADR-0040: the escalated `ContainNode` cut needs NO special case in the replay lock --- + +/// A hand-built menu line resolving to `ContainNode` (a self-reference on a `Host`, not a +/// workload) — mirrors [`quarantine_line`] but for the node-scoped mechanism. +fn contain_node_line(node: &str, host: &str) -> incident::MenuLine { + let host_key = NodeKey(host.to_string()); + let cut = Link { + from: host_key.clone(), + to: host_key, + relation: "contain-node".to_string(), + technique: None, + from_labels: BTreeMap::new(), + to_labels: BTreeMap::new(), + }; + incident::MenuLine { + node: NodeKey(node.to_string()), + action: ProposedAction::ContainNode, + cut_signature: crate::engine::respond::cut_signature(&cut), + cut, + blast_note: "damage-limitation, not a clean sever: ...".to_string(), + } +} + +/// A `ContainNode` decision re-arms and re-resolves through the SAME lock as any other +/// mechanism — the lock only ever compares node/cut_signature strings, never the +/// `ProposedAction` itself, so the escalated action needs no special case. +#[test] +fn a_contain_node_decision_rearms_through_the_same_lock() { + let line = contain_node_line("workload/app/Pod/store", "host/node-1"); + let sig = line.cut_signature.clone(); + let menu = incident::Menu { + selectable: vec![line], + uncontainable: Vec::new(), + }; + let r = restored( + "fp-1", + vec![journal::JournaledCut { + node: "workload/app/Pod/store".into(), + cut_signature: sig.clone(), + }], + ); + let decision = rearm_restored_decision(&r, "fp-1", &menu).expect("both locks hold"); + assert_eq!(decision.cuts[0].action, ProposedAction::ContainNode); + assert_eq!(decision.cuts[0].cut_signature, sig); +} + +/// A `boundary_break` flip between passes changes a node's mechanism resolution +/// (`ContainNode` ⇄ its ordinary pod-scoped cut) — a DIFFERENT `cut_signature` for the SAME +/// node key — so lock 2 fails closed: cold re-judge, never a silent repoint from a node cut +/// to a pod cut or back. +#[test] +fn a_boundary_break_flip_fails_the_replay_lock_rather_than_repointing_the_cut() { + let r = restored( + "fp-1", + vec![journal::JournaledCut { + node: "workload/app/Pod/store".into(), + cut_signature: "host/node-1 -[contain-node]-> host/node-1".into(), + }], + ); + // `boundary_break` flipped OFF since the decision was journaled: the current menu now + // resolves `store` back to its ordinary pod-scoped quarantine cut. + let menu = menu_with(&["workload/app/Pod/store"]); + assert!( + rearm_restored_decision(&r, "fp-1", &menu).is_none(), + "a boundary_break flip must fail the replay-lock, never silently repoint the cut" + ); +} + +// --- ADR-0040 §3(d): the LIVE cross-entry model-attack set --- + +/// [`model_attack_set`] collects every `contain`-named node from a decisive `Attack` +/// decision, across every entry, and nothing from a `NoAttack`/`Uncertain` one. +#[test] +fn model_attack_set_collects_named_nodes_from_every_decisive_attack_entry() { + use crate::engine::graph::NodeKey as GraphNodeKey; + + let mut decisions = BTreeMap::new(); + decisions.insert( + "entry-a".to_string(), + incident::IncidentDecision { + assessment: incident::Assessment::Attack, + reason: "x".into(), + cuts: vec![incident::ChosenCut { + node: GraphNodeKey("workload/app/Pod/a".into()), + action: ProposedAction::QuarantineWorkload, + cut: Link { + from: GraphNodeKey("workload/app/Pod/a".into()), + to: GraphNodeKey("workload/app/Pod/a".into()), + relation: "quarantine-workload".into(), + technique: None, + from_labels: BTreeMap::new(), + to_labels: BTreeMap::new(), + }, + cut_signature: "sig-a".into(), + }], + }, + ); + decisions.insert( + "entry-b".to_string(), + incident::IncidentDecision::uncertain("model unavailable"), + ); + let set = model_attack_set(&decisions); + assert_eq!(set.len(), 1); + assert!(set.contains(&GraphNodeKey("workload/app/Pod/a".into()))); +} diff --git a/engine/src/engine/cut_divergence_tests.rs b/engine/src/engine/cut_divergence_tests.rs index 00af2c93..fc87cf47 100644 --- a/engine/src/engine/cut_divergence_tests.rs +++ b/engine/src/engine/cut_divergence_tests.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use serde_json::json; @@ -102,7 +102,7 @@ fn decisive_attack( graph: &crate::engine::graph::SecurityGraph, nodes: &[&str], ) -> BTreeMap { - let menu = build_menu(chain, graph, &HealthReport::default()); + let menu = build_menu(chain, graph, &HealthReport::default(), &BTreeSet::new()); let cuts = nodes .iter() .map(|n| { diff --git a/engine/src/engine/journal/tests.rs b/engine/src/engine/journal/tests.rs index ad8c0681..7f8d2810 100644 --- a/engine/src/engine/journal/tests.rs +++ b/engine/src/engine/journal/tests.rs @@ -158,6 +158,48 @@ fn incident_decision_round_trips_across_a_reopen() { cleanup(&path); } +/// ADR-0040: the escalated `ContainNode` cut needs no schema change — `JournaledCut` is +/// already action-agnostic (only `node` + `cut_signature`, never the `ProposedAction` itself, +/// per the type's own docs), so a node-scoped cut signature (`host/... -[contain-node]-> +/// host/...`, distinct in SHAPE from a workload self-reference) round-trips byte-for-byte +/// exactly like any other mechanism's. +#[test] +fn a_contain_node_decision_round_trips_across_a_reopen() { + let path = temp_path("contain-node-roundtrip"); + { + let journal = DecisionJournal::open(&path); + journal.record(Decision::Incident { + entry: "workload/app/Pod/web".into(), + objectives: 1, + assessment: crate::engine::reason::adjudicate::incident::Assessment::Attack, + reason: "kernel tamper on a proven pod-boundary break".into(), + cuts: vec![JournaledCut { + node: "workload/app/Pod/store".into(), + cut_signature: "host/node-1 -[contain-node]-> host/node-1".into(), + }], + fingerprint: "cves=|rt=ptrace-attach|objs=secret|findings=".into(), + }); + } + let reopened = DecisionJournal::open(&path); + let entries = reopened.replay(); + assert_eq!(entries.len(), 1); + match &entries[0].decision { + Decision::Incident { + cuts, fingerprint, .. + } => { + assert_eq!(cuts.len(), 1); + assert_eq!(cuts[0].node, "workload/app/Pod/store"); + assert_eq!( + cuts[0].cut_signature, "host/node-1 -[contain-node]-> host/node-1", + "the node-keyed cut signature survives the reopen byte-for-byte" + ); + assert_eq!(fingerprint, "cves=|rt=ptrace-attach|objs=secret|findings="); + } + other => panic!("expected an Incident, got {other:?}"), + } + cleanup(&path); +} + /// ADR-0035's shadow-bake step: a `CutDivergence` line — the model-vs-deterministic cut /// comparator's classification for one entry — round-trips a "restart" byte-for-byte, so the /// bake history a human reads for the arm-readiness review survives across a restart instead of diff --git a/engine/src/engine/reason/adjudicate/incident/fixtures.rs b/engine/src/engine/reason/adjudicate/incident/fixtures.rs index f3fe2fb5..062236d0 100644 --- a/engine/src/engine/reason/adjudicate/incident/fixtures.rs +++ b/engine/src/engine/reason/adjudicate/incident/fixtures.rs @@ -145,6 +145,93 @@ pub(super) fn web_reaches_pivot_store_with_image( (graph, chains) } +/// As [`web_reaches_pivot_store`], but `store` is SCHEDULED on a `Host` (`nodeName: +/// node-1`) and carries a `PtraceAttach` signal — `boundary_break(store)` trigger (c) +/// (kernel tamper, ADR-0040 §3), the pod-boundary-break shape the `ContainNode` escalation +/// exists for. `web` stays unscheduled and untampered — the negative contrast within the +/// SAME chain (its own line must NOT escalate). +pub(super) fn web_reaches_boundary_broken_store() -> (SecurityGraph, Vec) { + let web = pod(json!({ + "apiVersion": "v1", "kind": "Pod", + "metadata": {"name": "web", "namespace": "app", "labels": {"role": "web"}}, + "spec": {"containers": [{"name": "web", "image": "web:1"}]} + })); + let lb = service(json!({ + "apiVersion": "v1", "kind": "Service", + "metadata": {"name": "web-lb", "namespace": "app"}, + "spec": {"type": "LoadBalancer", "selector": {"role": "web"}} + })); + let store = pod(json!({ + "apiVersion": "v1", "kind": "Pod", + "metadata": {"name": "store", "namespace": "app", "labels": {"role": "store"}}, + "spec": { + "nodeName": "node-1", + "containers": [{ + "name": "store", "image": "store:1", + "envFrom": [{"secretRef": {"name": "store-creds"}}] + }] + } + })); + let policy = netpol(json!({ + "apiVersion": "networking.k8s.io/v1", "kind": "NetworkPolicy", + "metadata": {"name": "store-ingress", "namespace": "app"}, + "spec": { + "podSelector": {}, + "policyTypes": ["Ingress"], + "ingress": [{"from": [{"podSelector": {"matchLabels": {"role": "web"}}}]}] + } + })); + let ptrace = RuntimeObservation { + attribution: Attribution::by_namespaced_name("app", "store"), + source: None, + observed_at_ms: None, + node: None, + behavior: Behavior::PtraceAttach, + }; + let snap = Snapshot { + pods: vec![web, store], + services: vec![lb], + network_policies: vec![policy], + image_vulns: vec![critical_image("store:1")], + runtime_events: vec![ptrace], + ..Default::default() + }; + let graph = build_graph(&snap, &default_adapters()); + let chains = prove(&graph); + (graph, chains) +} + +/// As [`web_reaches_boundary_broken_store`], but a THIRD pod — labeled like the eBPF +/// agent DaemonSet's own pods (`app.kubernetes.io/component: agent`) — is ALSO scheduled +/// on `node-1`, so cordoning it would collaterally sever protector's own sensor there +/// (the self-severance warning, ADR-0040 "New failure/interaction surfaces"). +pub(super) fn boundary_broken_store_node_hosts_protector_agent() -> (SecurityGraph, Vec) +{ + let (mut graph, chains) = web_reaches_boundary_broken_store(); + let agent = pod(json!({ + "apiVersion": "v1", "kind": "Pod", + "metadata": { + "name": "protector-agent-xyz", "namespace": "protector-system", + "labels": {"app.kubernetes.io/component": "agent"} + }, + "spec": {"nodeName": "node-1", "containers": [{"name": "agent", "image": "agent:1"}]} + })); + // Fold the agent pod into the SAME graph the chain was proven over (rather than + // rebuilding from a snapshot, which would also re-run `prove` and could renumber node + // indices out from under `chains`): `WorkloadAdapter` sets its labels (the fact + // `self_severance` reads), `PlacementAdapter` adds its `ScheduledOn` edge — neither + // needs any cross-object context, so running just these two over a snapshot holding + // only the agent pod is a faithful incremental fold. + use crate::engine::observe::adapter::{Adapter, PlacementAdapter, WorkloadAdapter}; + let snap = Snapshot { + pods: vec![agent], + ..Default::default() + }; + WorkloadAdapter.contribute(&snap, &mut graph); + PlacementAdapter.contribute(&snap, &mut graph); + (graph, chains) +} + pub(super) fn web_to_store_chain(chains: &[ProvenChain]) -> &ProvenChain { chains .iter() diff --git a/engine/src/engine/reason/adjudicate/incident/menu.rs b/engine/src/engine/reason/adjudicate/incident/menu.rs index c5817ab1..5d04db43 100644 --- a/engine/src/engine/reason/adjudicate/incident/menu.rs +++ b/engine/src/engine/reason/adjudicate/incident/menu.rs @@ -14,13 +14,24 @@ //! Evidence-bearing-but-uncontainable nodes collapse into one aggregate non-selectable //! set, so the model isn't baited into naming them. The whole render is content-derived, //! sorted, and deduped — byte-identical across passes on the same snapshot (cache-safe). +//! +//! **ADR-0040 mechanism escalation**: whichever node a line would otherwise resolve to +//! (the entry's ladder result, or a downstream's `QuarantineWorkload`) is overridden to +//! [`ProposedAction::ContainNode`] the moment [`boundary_break`] holds for that node — the +//! SAME `build_menu` pass, so the menu and the ledger's own resolution +//! (`respond::MitigationLedger::reconcile` reads back exactly the `ChosenCut` this menu +//! resolved, ADR-0034 D6) can never disagree. This is a mechanism swap only: the model +//! still names the SAME node key, never a new node/line ([`escalate`]). + +use std::collections::BTreeSet; use crate::engine::graph::{NodeKey, SecurityGraph}; use crate::engine::observe::health::HealthReport; -use crate::engine::reason::proof::{Link, ProvenChain}; +use crate::engine::reason::proof::{Link, ProvenChain, boundary_break}; use crate::engine::respond::actuator::{BlastRadius, predict_blast_radius}; use crate::engine::respond::{ - Mitigation, ProposedAction, containment_for, quarantine_workload_link, + Mitigation, ProposedAction, contain_node_link, containment_for, quarantine_workload_link, + self_severance, }; use super::super::guards::fence; @@ -130,18 +141,29 @@ impl Menu { /// of the existing resolvers — [`containment_for`]'s precedence ladder for the entry, /// [`quarantine_workload_link`] for each downstream evidence-bearing workload — so the /// menu and the ledger's own containment (`respond::MitigationLedger::reconcile`) can -/// never disagree. -pub fn build_menu(chain: &ProvenChain, graph: &SecurityGraph, health: &HealthReport) -> Menu { +/// never disagree. `model_attack` is this pass's LIVE set of workloads the model has +/// decisively named `assessment=attack` (across every entry, not just this chain's) — +/// [`boundary_break`]'s trigger (d) composes two SEPARATE decisive model calls, so it is +/// threaded through from the caller's already-live decision state rather than re-derived +/// here (see `engine::adj_pass::model_attack_set`). +pub fn build_menu( + chain: &ProvenChain, + graph: &SecurityGraph, + health: &HealthReport, + model_attack: &BTreeSet, +) -> Menu { let mut selectable = Vec::new(); let mut uncontainable = Vec::new(); - // Entry line: the ladder result, selectable only when it is BOTH additive-live and - // reversible (a durable-fix/RBAC/mount cut, or no cut at all, is uncontainable). - match containment_for(chain) { - Some((cut, action)) if action.is_additive_live() && action.is_reversible() => { + // Entry line: `escalate` resolves the ladder result, ESCALATED to `ContainNode` when + // `boundary_break` holds for the entry — selectable only when what it resolved to is + // BOTH additive-live and reversible, UNLESS it escalated (a cordon is deliberately not + // additive-live, ADR-0040 §5, but is always selectable once escalated). + match escalate(&chain.entry, containment_for(chain), graph, model_attack) { + Some((cut, action)) => { selectable.push(menu_line(chain.entry.clone(), cut, action, graph, health)); } - _ => uncontainable.push(chain.entry.clone()), + None => uncontainable.push(chain.entry.clone()), } // Downstream lines: every evidence-bearing workload on the chain, MINUS the entry @@ -150,14 +172,12 @@ pub fn build_menu(chain: &ProvenChain, graph: &SecurityGraph, health: &HealthRep if target.node == chain.entry { continue; } - match quarantine_workload_link(target) { - Some(cut) => selectable.push(menu_line( - target.node.clone(), - cut, - ProposedAction::QuarantineWorkload, - graph, - health, - )), + let fallback = + quarantine_workload_link(target).map(|cut| (cut, ProposedAction::QuarantineWorkload)); + match escalate(&target.node, fallback, graph, model_attack) { + Some((cut, action)) => { + selectable.push(menu_line(target.node.clone(), cut, action, graph, health)); + } None => uncontainable.push(target.node.clone()), // unlabeled — decline, never widen } } @@ -169,6 +189,41 @@ pub fn build_menu(chain: &ProvenChain, graph: &SecurityGraph, health: &HealthRep } } +/// Resolve one menu candidate's mechanism (ADR-0040 §1): `boundary_break(node)` escalates to +/// [`ProposedAction::ContainNode`] regardless of what `fallback` (the ordinary ladder/ +/// quarantine resolution) would have picked — the node's OWN evidence already proves a +/// pod-scoped policy can't contain it, so the pod-scoped mechanism is never offered +/// alongside it. `fallback` can be `None` (nothing severs the chain by an edge) and +/// escalation still applies: `boundary_break` needs only a `ScheduledOn` placement edge, no +/// edge-cut. When `boundary_break` does NOT hold, `fallback` stands, filtered to the SAME +/// additive-live + reversible bar the menu has always required (unchanged behavior). +fn escalate( + node: &NodeKey, + fallback: Option<(Link, ProposedAction)>, + graph: &SecurityGraph, + model_attack: &BTreeSet, +) -> Option<(Link, ProposedAction)> { + if boundary_break_holds(node, graph, model_attack) + && let Some(cut) = contain_node_link(graph, node) + { + return Some((cut, ProposedAction::ContainNode)); + } + fallback.filter(|(_, action)| action.is_additive_live() && action.is_reversible()) +} + +/// [`boundary_break`] over a [`NodeKey`] rather than a graph [`petgraph::stable_graph::NodeIndex`] +/// — `false` (never a break) for a key absent from the graph, so a stale/removed node can +/// never spuriously escalate. +fn boundary_break_holds( + node: &NodeKey, + graph: &SecurityGraph, + model_attack: &BTreeSet, +) -> bool { + graph + .index_of(node) + .is_some_and(|idx| boundary_break(graph, idx, model_attack)) +} + /// 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 @@ -207,12 +262,21 @@ fn menu_line( /// exact cut — empty `justifications` is fine here, `predict_blast_radius` never reads them. /// `pub(crate)` (not private): the finding detail's cut-set panel reuses this to /// render a model-chosen cut's note identically to how its own menu line resolved it. +/// +/// [`ProposedAction::ContainNode`] is a special case (ADR-0040 §4): `predict_blast_radius` +/// walks `Relation::Reaches` edges OUT of the cut's source, which is meaningless for a +/// node-scoped cut (a `Host` has no `Reaches` edges — its damage is "every co-resident pod", +/// not a `reaches` peer set) — the honest, fixed-string damage-limitation note stands in +/// instead, never the network-cut phrasing. pub(crate) fn cut_blast_note( cut: &Link, action: ProposedAction, graph: &SecurityGraph, health: &HealthReport, ) -> String { + if action == ProposedAction::ContainNode { + return contain_node_note(cut, graph); + } let mitigation = Mitigation { cut: cut.clone(), action, @@ -221,6 +285,28 @@ pub(crate) fn cut_blast_note( blast_note(&predict_blast_radius(&mitigation, graph, health)) } +/// The fixed-string honest damage-limitation note for a [`ProposedAction::ContainNode`] +/// mitigation (ADR-0040 §4/consequences): never a clean sever — names the cordon, the +/// co-resident denies, and the human-act durable fix explicitly, with a self-severance +/// clause appended (also fixed-string) when protector's own agent/control-plane component +/// is among the node's co-resident pods ([`self_severance`]). Built by concatenating two +/// `&'static str` literals — no untrusted substrings, either way. +fn contain_node_note(cut: &Link, graph: &SecurityGraph) -> String { + let mut note = CONTAIN_NODE_NOTE.to_string(); + if self_severance(graph, &cut.from) { + note.push_str(CONTAIN_NODE_SELF_SEVERANCE_SUFFIX); + } + note +} + +const CONTAIN_NODE_NOTE: &str = "damage-limitation, not a clean sever: the cordon stops \ + scheduler-driven spread, the co-resident denies stop lateral use of the node's other \ + pods, and drain/reimage/rotate is a human act"; + +const CONTAIN_NODE_SELF_SEVERANCE_SUFFIX: &str = "; this node also hosts one of protector's \ + OWN components — containing it will sever protector's own visibility/control of this \ + node until a human intervenes"; + /// A fixed-shape, no-untrusted-text advisory note on a menu line's predicted blast /// radius. Only the workload COUNT varies (a number, never a name) — the full collateral /// list stays a `BlastRadius` detail for the actuator's own gate, not model-prompt text. diff --git a/engine/src/engine/reason/adjudicate/incident/menu_tests.rs b/engine/src/engine/reason/adjudicate/incident/menu_tests.rs index 68ad0158..5b599020 100644 --- a/engine/src/engine/reason/adjudicate/incident/menu_tests.rs +++ b/engine/src/engine/reason/adjudicate/incident/menu_tests.rs @@ -1,6 +1,9 @@ +use std::collections::BTreeSet; + use super::super::fixtures::{ direct_mount_chain, direct_mount_entry_chain, empty_health, entry_key, store_key, - store_live_signal, web_reaches_pivot_store, web_to_store_chain, + store_live_signal, web_reaches_boundary_broken_store, web_reaches_pivot_store, + web_to_store_chain, }; use super::*; @@ -12,7 +15,7 @@ use super::*; fn resolver_picks_the_surgical_edge_cut_over_quarantine_entry_when_one_exists() { let (graph, chains) = web_reaches_pivot_store(Vec::new(), true); let chain = web_to_store_chain(&chains); - let menu = build_menu(chain, &graph, &empty_health()); + let menu = build_menu(chain, &graph, &empty_health(), &BTreeSet::new()); let entry_line = menu .selectable @@ -37,7 +40,7 @@ fn resolver_picks_the_surgical_edge_cut_over_quarantine_entry_when_one_exists() fn resolver_falls_back_to_quarantine_entry_when_no_surgical_cut_exists() { let (graph, chains) = direct_mount_entry_chain(); let chain = direct_mount_chain(&chains); - let menu = build_menu(chain, &graph, &empty_health()); + let menu = build_menu(chain, &graph, &empty_health(), &BTreeSet::new()); let entry_line = menu .selectable @@ -55,7 +58,7 @@ fn resolver_falls_back_to_quarantine_entry_when_no_surgical_cut_exists() { fn downstream_evidence_bearing_workload_is_selectable_via_quarantine_workload() { let (graph, chains) = web_reaches_pivot_store(Vec::new(), true); let chain = web_to_store_chain(&chains); - let menu = build_menu(chain, &graph, &empty_health()); + let menu = build_menu(chain, &graph, &empty_health(), &BTreeSet::new()); let store_line = menu .selectable @@ -73,7 +76,7 @@ fn downstream_evidence_bearing_workload_is_selectable_via_quarantine_workload() fn unlabeled_downstream_pivot_is_uncontainable_not_selectable() { let (graph, chains) = web_reaches_pivot_store(Vec::new(), false); let chain = web_to_store_chain(&chains); - let menu = build_menu(chain, &graph, &empty_health()); + let menu = build_menu(chain, &graph, &empty_health(), &BTreeSet::new()); assert!( !menu.selectable.iter().any(|l| l.node == store_key()), @@ -94,7 +97,7 @@ fn entry_with_no_additive_live_mechanism_is_uncontainable() { let (graph, chains) = internal_only_rbac_chain(); let chain = internal_rbac_chain(&chains); - let menu = build_menu(chain, &graph, &empty_health()); + let menu = build_menu(chain, &graph, &empty_health(), &BTreeSet::new()); let entry = crate::engine::graph::NodeKey("workload/edge/Pod/internal-app".into()); assert!( @@ -131,7 +134,7 @@ fn entry_exclusion_the_entry_never_gets_a_second_downstream_line() { "the entry itself is ALSO an ActivelyExploited quarantine target on this fixture" ); - let menu = build_menu(chain, &graph, &empty_health()); + let menu = build_menu(chain, &graph, &empty_health(), &BTreeSet::new()); let entry_lines: Vec<_> = menu .selectable .iter() @@ -151,8 +154,8 @@ fn menu_is_byte_identical_across_two_builds_of_the_same_snapshot() { let (graph, chains) = web_reaches_pivot_store(vec![store_live_signal()], true); let chain = web_to_store_chain(&chains); - let first = build_menu(chain, &graph, &empty_health()); - let second = build_menu(chain, &graph, &empty_health()); + let first = build_menu(chain, &graph, &empty_health(), &BTreeSet::new()); + let second = build_menu(chain, &graph, &empty_health(), &BTreeSet::new()); assert_eq!(first, second); assert_eq!(first.render(), second.render()); @@ -163,7 +166,7 @@ fn menu_is_byte_identical_across_two_builds_of_the_same_snapshot() { fn selectable_lines_are_sorted_by_node_key() { let (graph, chains) = web_reaches_pivot_store(vec![store_live_signal()], true); let chain = web_to_store_chain(&chains); - let menu = build_menu(chain, &graph, &empty_health()); + let menu = build_menu(chain, &graph, &empty_health(), &BTreeSet::new()); let keys: Vec<_> = menu.selectable.iter().map(|l| l.node.0.clone()).collect(); let mut sorted = keys.clone(); @@ -177,7 +180,7 @@ fn selectable_lines_are_sorted_by_node_key() { fn render_uses_only_fixed_mechanism_strings_and_fences_the_node_key() { let (graph, chains) = web_reaches_pivot_store(vec![store_live_signal()], true); let chain = web_to_store_chain(&chains); - let menu = build_menu(chain, &graph, &empty_health()); + let menu = build_menu(chain, &graph, &empty_health(), &BTreeSet::new()); let rendered = menu.render(); for line in &menu.selectable { @@ -192,7 +195,7 @@ fn render_uses_only_fixed_mechanism_strings_and_fences_the_node_key() { fn every_selectable_line_carries_a_blast_radius_note() { let (graph, chains) = web_reaches_pivot_store(vec![store_live_signal()], true); let chain = web_to_store_chain(&chains); - let menu = build_menu(chain, &graph, &empty_health()); + let menu = build_menu(chain, &graph, &empty_health(), &BTreeSet::new()); assert!(!menu.selectable.is_empty()); for line in &menu.selectable { @@ -205,7 +208,7 @@ fn every_selectable_line_carries_a_blast_radius_note() { fn resolve_returns_none_for_a_node_not_on_the_menu() { let (graph, chains) = web_reaches_pivot_store(Vec::new(), true); let chain = web_to_store_chain(&chains); - let menu = build_menu(chain, &graph, &empty_health()); + let menu = build_menu(chain, &graph, &empty_health(), &BTreeSet::new()); let stranger = crate::engine::graph::NodeKey("workload/app/Pod/nonexistent".into()); assert!(menu.resolve(&stranger).is_none()); @@ -217,3 +220,175 @@ fn empty_menu_renders_a_placeholder() { let menu = Menu::default(); assert_eq!(menu.render(), " (none)"); } + +// --- ADR-0040: boundary-break mechanism escalation --- + +/// Deterministic escalation, positive direction: `boundary_break(store)` holds (a +/// `PtraceAttach` kernel-tamper signal, trigger (c)) — the store's line resolves to +/// `ContainNode`, on the node it's scheduled on, never `QuarantineWorkload`. +#[test] +fn boundary_broken_downstream_workload_escalates_to_contain_node() { + let (graph, chains) = web_reaches_boundary_broken_store(); + let chain = web_to_store_chain(&chains); + let menu = build_menu(chain, &graph, &empty_health(), &BTreeSet::new()); + + let store_line = menu + .selectable + .iter() + .find(|l| l.node == store_key()) + .expect("boundary-broken store is selectable"); + assert_eq!(store_line.action, ProposedAction::ContainNode); + assert_eq!( + store_line.cut.from, + crate::engine::graph::NodeKey("host/node-1".into()) + ); + + // The menu resolver — the SAME path `contain=[store]` goes through — agrees. + let resolved = menu.resolve(&store_key()).expect("store resolves"); + assert_eq!(resolved.action, ProposedAction::ContainNode); + + // `web` (untampered, unscheduled) is untouched by the escalation — the entry line's + // own resolution is independent per node. + let entry_line = menu + .selectable + .iter() + .find(|l| l.node == entry_key()) + .expect("web is still selectable"); + assert_ne!(entry_line.action, ProposedAction::ContainNode); +} + +/// Deterministic escalation, negative direction: the SAME chain shape with no +/// `boundary_break` evidence keeps the ordinary pod-scoped `QuarantineWorkload` cut. +#[test] +fn a_workload_without_boundary_break_evidence_keeps_its_pod_scoped_cut() { + let (graph, chains) = web_reaches_pivot_store(Vec::new(), true); + let chain = web_to_store_chain(&chains); + let menu = build_menu(chain, &graph, &empty_health(), &BTreeSet::new()); + + let store_line = menu + .selectable + .iter() + .find(|l| l.node == store_key()) + .expect("store is selectable"); + assert_eq!(store_line.action, ProposedAction::QuarantineWorkload); +} + +/// No new model-selectable line: escalation only ever changes the MECHANISM of the node +/// the model could already name, never adds a node/line to the menu. +#[test] +fn escalation_never_changes_the_selectable_node_count() { + let (graph, broken_chains) = web_reaches_boundary_broken_store(); + let broken_chain = web_to_store_chain(&broken_chains); + let broken_menu = build_menu(broken_chain, &graph, &empty_health(), &BTreeSet::new()); + + let (clean_graph, clean_chains) = web_reaches_pivot_store(Vec::new(), true); + let clean_chain = web_to_store_chain(&clean_chains); + let clean_menu = build_menu(clean_chain, &clean_graph, &empty_health(), &BTreeSet::new()); + + assert_eq!(broken_menu.selectable.len(), clean_menu.selectable.len()); + let broken_nodes: Vec<_> = broken_menu.selectable.iter().map(|l| &l.node).collect(); + let clean_nodes: Vec<_> = clean_menu.selectable.iter().map(|l| &l.node).collect(); + assert_eq!(broken_nodes, clean_nodes); +} + +/// The fixed-string honest damage-limitation note, no self-severance collateral. +#[test] +fn contain_node_line_carries_the_fixed_damage_limitation_note() { + let (graph, chains) = web_reaches_boundary_broken_store(); + let chain = web_to_store_chain(&chains); + let menu = build_menu(chain, &graph, &empty_health(), &BTreeSet::new()); + + let store_line = menu + .selectable + .iter() + .find(|l| l.node == store_key()) + .unwrap(); + assert!( + store_line + .blast_note + .contains("damage-limitation, not a clean sever") + ); + assert!( + store_line + .blast_note + .contains("cordon stops scheduler-driven spread") + ); + assert!( + store_line + .blast_note + .contains("drain/reimage/rotate is a human act") + ); + assert!( + !store_line.blast_note.contains("protector's OWN components"), + "no self-severance line when protector shares no co-resident pod on this host" + ); +} + +/// The self-severance warning appears, verbatim and fixed-string, when protector's own +/// agent shares the cordoned node. +#[test] +fn contain_node_note_names_self_severance_when_protector_shares_the_host() { + use super::super::fixtures::boundary_broken_store_node_hosts_protector_agent; + + let (graph, chains) = boundary_broken_store_node_hosts_protector_agent(); + let chain = web_to_store_chain(&chains); + let menu = build_menu(chain, &graph, &empty_health(), &BTreeSet::new()); + + let store_line = menu + .selectable + .iter() + .find(|l| l.node == store_key()) + .unwrap(); + assert!( + store_line.blast_note.contains("protector's OWN components"), + "the self-severance warning must name protector's own collateral explicitly" + ); +} + +/// ADR-0034 D4 ("a mapping change is a prompt change is a re-judge") applied to the +/// ADR-0040 escalation specifically: swapping ONE node's mechanism from +/// `QuarantineWorkload` to `ContainNode` — nothing else about the graph/evidence changes — +/// still busts the full-state prompt fingerprint, so a `boundary_break` flip forces a +/// re-judge rather than silently replaying a stale verdict against a since-escalated +/// mechanism. +#[test] +fn a_mechanism_escalation_on_the_same_node_changes_the_prompt_fingerprint() { + use crate::engine::observe::asn::AsnDb; + use crate::engine::reason::adjudicate::build_delta_prompt_with_menu_asn; + + let (graph, chains) = web_reaches_pivot_store(Vec::new(), true); + let chain = web_to_store_chain(&chains); + let pod_menu = build_menu(chain, &graph, &empty_health(), &BTreeSet::new()); + + let mut node_menu = pod_menu.clone(); + let store_line = node_menu + .selectable + .iter_mut() + .find(|l| l.node == store_key()) + .expect("store is on the menu"); + store_line.action = ProposedAction::ContainNode; + store_line.blast_note = "damage-limitation, not a clean sever: (test stand-in)".to_string(); + + let asn = AsnDb::empty(); + let objectives = [(chain.objective.clone(), chain.attack)]; + let pod_delta = build_delta_prompt_with_menu_asn( + &chain.entry, + &objectives, + &graph, + &asn, + None, + &[store_key()], + &pod_menu, + ); + let node_delta = build_delta_prompt_with_menu_asn( + &chain.entry, + &objectives, + &graph, + &asn, + None, + &[store_key()], + &node_menu, + ); + + assert_ne!(pod_delta.cache_key, node_delta.cache_key); +} diff --git a/engine/src/engine/reason/adjudicate/incident/mod_tests.rs b/engine/src/engine/reason/adjudicate/incident/mod_tests.rs index c8aea5c4..ab767c8c 100644 --- a/engine/src/engine/reason/adjudicate/incident/mod_tests.rs +++ b/engine/src/engine/reason/adjudicate/incident/mod_tests.rs @@ -29,7 +29,12 @@ fn assessment_values_are_distinct() { fn a_grounded_attack_decision_survives_the_full_guard_pipeline() { let (graph, chains) = web_reaches_pivot_store(vec![store_live_signal()], true); let chain = web_to_store_chain(&chains); - let menu = build_menu(chain, &graph, &empty_health()); + let menu = build_menu( + chain, + &graph, + &empty_health(), + &std::collections::BTreeSet::new(), + ); let reply = format!( r#"{{"assessment": "attack", "reason": "store shows a live drop-and-execute", "contain": ["{}"]}}"#, @@ -58,7 +63,12 @@ fn a_grounded_attack_decision_survives_the_full_guard_pipeline() { fn an_ungrounded_attack_decision_is_downgraded_by_the_pipeline() { let (graph, chains) = web_reaches_pivot_store(Vec::new(), true); let chain = web_to_store_chain(&chains); - let menu = build_menu(chain, &graph, &empty_health()); + let menu = build_menu( + chain, + &graph, + &empty_health(), + &std::collections::BTreeSet::new(), + ); let reply = format!( r#"{{"assessment": "attack", "reason": "store looks compromised", "contain": ["{}"]}}"#, @@ -78,7 +88,12 @@ fn an_ungrounded_attack_decision_is_downgraded_by_the_pipeline() { fn attack_with_no_cuts_survives_the_pipeline_unchanged() { let (graph, chains) = web_reaches_pivot_store(Vec::new(), true); let chain = web_to_store_chain(&chains); - let menu = build_menu(chain, &graph, &empty_health()); + let menu = build_menu( + chain, + &graph, + &empty_health(), + &std::collections::BTreeSet::new(), + ); let reply = r#"{"assessment": "attack", "reason": "attack in progress, nothing warrants a cut yet"}"#; diff --git a/engine/src/engine/reason/adjudicate/tests/group_2.rs b/engine/src/engine/reason/adjudicate/tests/group_2.rs index 8fb6d884..06539558 100644 --- a/engine/src/engine/reason/adjudicate/tests/group_2.rs +++ b/engine/src/engine/reason/adjudicate/tests/group_2.rs @@ -16,6 +16,7 @@ use crate::engine::observe::adapter::{build_graph, default_adapters}; use crate::engine::observe::{Attribution, ImageVulnerabilities, RuntimeObservation, Snapshot}; use crate::engine::reason::proof::{ProvenChain, prove}; use serde_json::json; +use std::collections::BTreeSet; use std::time::SystemTime; /// ADR-0034: absent a model there is no cut-choosing analyst to consult, so @@ -223,6 +224,7 @@ async fn real_model_judges_toxic_vs_unevidenced() { &toxic, &g_toxic, &crate::engine::observe::health::HealthReport::default(), + &BTreeSet::new(), ); let toxic_decision = adjudicator .judge( @@ -244,6 +246,7 @@ async fn real_model_judges_toxic_vs_unevidenced() { &bare, &g_bare, &crate::engine::observe::health::HealthReport::default(), + &BTreeSet::new(), ); let bare_decision = adjudicator .judge( diff --git a/engine/src/engine/reason/proof/pivot_quarantine_tests.rs b/engine/src/engine/reason/proof/pivot_quarantine_tests.rs index a4aa2f21..af3df5ab 100644 --- a/engine/src/engine/reason/proof/pivot_quarantine_tests.rs +++ b/engine/src/engine/reason/proof/pivot_quarantine_tests.rs @@ -141,7 +141,12 @@ fn decisions_naming_store( 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 menu = build_menu( + chain, + graph, + &HealthReport::default(), + &std::collections::BTreeSet::new(), + ); let cut = menu .resolve(&store_node) .expect("store is selectable on the menu"); diff --git a/engine/src/engine/respond/contain_node_tests.rs b/engine/src/engine/respond/contain_node_tests.rs new file mode 100644 index 00000000..3d7937bf --- /dev/null +++ b/engine/src/engine/respond/contain_node_tests.rs @@ -0,0 +1,142 @@ +//! Unit coverage for [`contain_node_link`] and [`self_severance`] (ADR-0040 §4/§5's +//! proposal-surface primitives) — split into its own file since `respond::tests` is already +//! near the 1,000-line cap (CLAUDE.md). + +use super::*; +use crate::engine::observe::Snapshot; +use crate::engine::observe::adapter::{build_graph, default_adapters}; +use serde_json::json; + +fn scheduled_pod( + name: &str, + node_name: &str, + labels: serde_json::Value, +) -> k8s_openapi::api::core::v1::Pod { + serde_json::from_value(json!({ + "apiVersion": "v1", "kind": "Pod", + "metadata": {"name": name, "namespace": "app", "labels": labels}, + "spec": { + "nodeName": node_name, + "containers": [{"name": name, "image": format!("{name}:1")}] + } + })) + .expect("valid Pod fixture") +} + +fn unscheduled_pod(name: &str) -> k8s_openapi::api::core::v1::Pod { + serde_json::from_value(json!({ + "apiVersion": "v1", "kind": "Pod", + "metadata": {"name": name, "namespace": "app"}, + "spec": {"containers": [{"name": name, "image": format!("{name}:1")}]} + })) + .expect("valid Pod fixture") +} + +#[test] +fn contain_node_link_targets_the_scheduled_host_self_referentially() { + let snap = Snapshot { + pods: vec![scheduled_pod("victim", "node-1", json!({}))], + ..Default::default() + }; + let graph = build_graph(&snap, &default_adapters()); + let x = NodeKey("workload/app/Pod/victim".into()); + + let link = contain_node_link(&graph, &x).expect("victim is scheduled on node-1"); + let host = NodeKey("host/node-1".into()); + assert_eq!(link.from, host); + assert_eq!(link.to, host); + assert_eq!(link.relation, CONTAIN_NODE_RELATION); + assert!( + link.from_labels.is_empty() && link.to_labels.is_empty(), + "a cordon acts on the node object, not a pod selector — nothing to widen" + ); +} + +#[test] +fn contain_node_link_is_none_for_an_unscheduled_workload() { + let snap = Snapshot { + pods: vec![unscheduled_pod("pending")], + ..Default::default() + }; + let graph = build_graph(&snap, &default_adapters()); + let x = NodeKey("workload/app/Pod/pending".into()); + assert!(contain_node_link(&graph, &x).is_none()); +} + +#[test] +fn contain_node_link_is_none_for_a_node_key_absent_from_the_graph() { + let graph = crate::engine::graph::SecurityGraph::new(); + let x = NodeKey("workload/app/Pod/nonexistent".into()); + assert!(contain_node_link(&graph, &x).is_none()); +} + +/// ADR-0040 §5's "at most one node cordoned concurrently" rail: two co-resident +/// boundary-broken workloads must resolve to the SAME cut signature — one containment +/// proposal, never a duplicate cut per named workload. +#[test] +fn two_co_resident_workloads_collapse_onto_one_contain_node_signature() { + let snap = Snapshot { + pods: vec![ + scheduled_pod("a", "node-1", json!({})), + scheduled_pod("b", "node-1", json!({})), + ], + ..Default::default() + }; + let graph = build_graph(&snap, &default_adapters()); + let a = contain_node_link(&graph, &NodeKey("workload/app/Pod/a".into())).unwrap(); + let b = contain_node_link(&graph, &NodeKey("workload/app/Pod/b".into())).unwrap(); + assert_eq!(cut_signature(&a), cut_signature(&b)); +} + +#[test] +fn self_severance_is_true_when_the_agent_daemonset_shares_the_host() { + let snap = Snapshot { + pods: vec![ + scheduled_pod("victim", "node-1", json!({})), + scheduled_pod( + "protector-agent-xyz", + "node-1", + json!({"app.kubernetes.io/component": "agent"}), + ), + ], + ..Default::default() + }; + let graph = build_graph(&snap, &default_adapters()); + assert!(self_severance(&graph, &NodeKey("host/node-1".into()))); +} + +#[test] +fn self_severance_is_true_when_the_engine_deployment_shares_the_host() { + let snap = Snapshot { + pods: vec![ + scheduled_pod("victim", "node-1", json!({})), + scheduled_pod( + "protector-0", + "node-1", + json!({"app.kubernetes.io/name": "protector"}), + ), + ], + ..Default::default() + }; + let graph = build_graph(&snap, &default_adapters()); + assert!(self_severance(&graph, &NodeKey("host/node-1".into()))); +} + +#[test] +fn self_severance_is_false_when_only_ordinary_pods_share_the_host() { + let snap = Snapshot { + pods: vec![ + scheduled_pod("victim", "node-1", json!({})), + scheduled_pod("neighbor", "node-1", json!({"role": "cache"})), + ], + ..Default::default() + }; + let graph = build_graph(&snap, &default_adapters()); + assert!(!self_severance(&graph, &NodeKey("host/node-1".into()))); +} + +#[test] +fn self_severance_is_false_for_a_host_absent_from_the_graph() { + let graph = crate::engine::graph::SecurityGraph::new(); + assert!(!self_severance(&graph, &NodeKey("host/nonexistent".into()))); +} diff --git a/engine/src/engine/respond/decisions_tests.rs b/engine/src/engine/respond/decisions_tests.rs index 333c234d..bc7fa5c7 100644 --- a/engine/src/engine/respond/decisions_tests.rs +++ b/engine/src/engine/respond/decisions_tests.rs @@ -4,6 +4,8 @@ //! 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 std::collections::BTreeSet; + use super::*; use crate::engine::observe::adapter::{build_graph, default_adapters}; use crate::engine::observe::health::HealthReport; @@ -160,7 +162,7 @@ fn model_chosen_cut_clears_the_auto_action_gate_when_corroborated() { let chains = prove(&graph); let chain = web_chain(&chains); - let menu = build_menu(chain, &graph, &HealthReport::default()); + let menu = build_menu(chain, &graph, &HealthReport::default(), &BTreeSet::new()); let cut = menu.resolve(&chain.entry).expect("the entry is selectable"); let mut decisions = BTreeMap::new(); decisions.insert( @@ -264,7 +266,7 @@ fn a_non_member_reply_degrades_to_uncertain_and_reconcile_falls_back() { ); let chains = prove(&graph); let chain = web_chain(&chains); - let menu = build_menu(chain, &graph, &HealthReport::default()); + let menu = build_menu(chain, &graph, &HealthReport::default(), &BTreeSet::new()); let reply = r#"{"assessment": "attack", "reason": "x", "contain": ["workload/app/Pod/not-on-the-menu"]}"#; let decision = parse_incident_decision(reply, &menu); @@ -302,7 +304,7 @@ fn a_downstream_cut_persists_across_a_pass_with_no_decision() { 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 menu = build_menu(chain, &graph, &HealthReport::default(), &BTreeSet::new()); 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 @@ -367,7 +369,7 @@ fn a_decisive_no_attack_still_retires_a_standing_cut() { 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 menu = build_menu(chain, &graph, &HealthReport::default(), &BTreeSet::new()); let store_cut = menu.resolve(&store).expect("store is selectable"); let store_signature = store_cut.cut_signature.clone(); diff --git a/engine/src/engine/respond/mod.rs b/engine/src/engine/respond/mod.rs index 32d8450c..72b06013 100644 --- a/engine/src/engine/respond/mod.rs +++ b/engine/src/engine/respond/mod.rs @@ -19,7 +19,10 @@ pub mod actuator; use std::collections::BTreeMap; +use petgraph::visit::EdgeRef; + use crate::engine::graph::attack::AttackRef; +use crate::engine::graph::{Node, NodeKey, Relation, SecurityGraph}; use crate::engine::reason::proof::{Link, ProvenChain, QuarantineTarget}; /// How a cut edge would be severed by an additive, engine-owned object (ADR-0002). @@ -55,6 +58,20 @@ pub enum ProposedAction { /// self-reverting, gated identically to [`QuarantineEntry`](Self::QuarantineEntry). /// Never targets a merely-reached objective (reached ≠ exploited). QuarantineWorkload, + /// Contain a **proven pod-boundary break** ([`crate::engine::reason::proof::boundary_break`], + /// ADR-0040) at the NODE, not the pod: cordon the `Host` the model-named workload is + /// scheduled on, plus a default-deny `NetworkPolicy` per co-resident labelled pod. The + /// deterministic escalation of a model-named workload whose own evidence proves a + /// `podSelector` policy no longer constrains it — never a model-selectable mechanism + /// (the model still only names the workload; [`crate::engine::reason::adjudicate::incident::menu`] + /// resolves the escalation the moment `boundary_break` holds). Reversible (an uncordon + /// lifts it) but deliberately **not** [`is_additive_live`](Self::is_additive_live): a + /// cordon mutates a shared field on a live `Node` object rather than adding a new + /// engine-owned one, so this class can never auto-apply — every node cut is + /// propose-first by construction, routed to a human via the existing blast/alive- + /// collateral gate. Shadow-complete as of this ticket: the actuator that would render + /// the cordon + co-resident denies lands separately (ADR-0040 §7). + ContainNode, /// A cut whose remediation isn't yet mapped to an action. Unclassified, } @@ -119,6 +136,10 @@ impl ProposedAction { ProposedAction::QuarantineWorkload => { "quarantine the compromised workload with a default-deny NetworkPolicy" } + ProposedAction::ContainNode => { + "cordon the node and default-deny its co-resident pods (proven pod-boundary \ + break — a pod-scoped policy can no longer contain this workload)" + } ProposedAction::Unclassified => "manual remediation (no automatic action mapped)", } } @@ -294,6 +315,86 @@ pub(crate) fn quarantine_workload_link(target: &QuarantineTarget) -> Option Option { + let x_idx = graph.index_of(x)?; + let host_idx = graph + .inner() + .edges(x_idx) + .find(|e| matches!(e.weight().relation, Relation::ScheduledOn)) + .map(|e| e.target())?; + let host = graph.key_of(host_idx)?; + Some(Link { + from: host.clone(), + to: host, + relation: CONTAIN_NODE_RELATION.to_string(), + technique: None, + from_labels: BTreeMap::new(), + to_labels: BTreeMap::new(), + }) +} + +/// Whether cordoning the `Host` node keyed `host` would collaterally sever one of +/// protector's OWN components — the eBPF agent DaemonSet's pod on this node, or the +/// engine's own control-plane pod under the chart's default naming (ADR-0040 "New +/// failure/interaction surfaces": "the approval UI names protector components in the +/// collateral list explicitly"). Checked over every workload with a `ScheduledOn` edge into +/// `host`. Presentation-only — feeds the honest proposal note +/// ([`crate::engine::reason::adjudicate::incident::cut_blast_note`]), never gates anything; +/// the alive-collateral/freshness/break-glass rails the ADR cites are the actual safety +/// backstop, so a label-matching miss here (e.g. under a customized Helm `nameOverride`) +/// only means a milder note, never a functional gap. +pub(crate) fn self_severance(graph: &SecurityGraph, host: &NodeKey) -> bool { + let Some(host_idx) = graph.index_of(host) else { + return false; + }; + graph + .inner() + .edges_directed(host_idx, petgraph::Direction::Incoming) + .filter(|e| matches!(e.weight().relation, Relation::ScheduledOn)) + .filter_map(|e| graph.node(e.source())) + .any(is_protector_component) +} + +/// Label-based identification of protector's own chart-rendered workloads: the agent +/// DaemonSet's `app.kubernetes.io/component: agent` label (`charts/protector/templates/ +/// _helpers.tpl`'s `protector.agentLabels`), or the engine Deployment's default +/// `app.kubernetes.io/name: protector` (`protector.selectorLabels` under an un-overridden +/// chart name). See [`self_severance`] for why a miss here is safe. +fn is_protector_component(node: &Node) -> bool { + let Node::Workload(w) = node else { + return false; + }; + w.labels + .get("app.kubernetes.io/component") + .is_some_and(|v| v == "agent") + || w.labels + .get("app.kubernetes.io/name") + .is_some_and(|v| v == "protector") +} + /// Choose the single containment for a chain, by the ADR-0009/0010 precedence — the /// narrowest control first, the entry quarantine as the default, durable-fix last: /// @@ -620,3 +721,8 @@ mod tests; // reaching `reconcile` end to end. #[cfg(test)] mod decisions_tests; + +// ADR-0040: `contain_node_link`/`self_severance` unit coverage, split into their own file — +// `tests.rs` is already near the 1,000-line cap (CLAUDE.md). +#[cfg(test)] +mod contain_node_tests; diff --git a/engine/src/engine/respond/tests.rs b/engine/src/engine/respond/tests.rs index ea88ab98..111592f6 100644 --- a/engine/src/engine/respond/tests.rs +++ b/engine/src/engine/respond/tests.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeSet; + use super::*; use crate::engine::observe::Snapshot; use crate::engine::observe::adapter::{build_graph, default_adapters}; @@ -21,7 +23,7 @@ fn decisive_attack( graph: &crate::engine::graph::SecurityGraph, nodes: &[crate::engine::graph::NodeKey], ) -> BTreeMap { - let menu = build_menu(chain, graph, &HealthReport::default()); + let menu = build_menu(chain, graph, &HealthReport::default(), &BTreeSet::new()); let cuts = nodes .iter() .map(|n| { diff --git a/engine/src/engine/state/findings.rs b/engine/src/engine/state/findings.rs index 4c1be7c1..feabd1bd 100644 --- a/engine/src/engine/state/findings.rs +++ b/engine/src/engine/state/findings.rs @@ -327,6 +327,10 @@ pub(crate) fn classify( // pass, not a chain's primary containment; the per-pod WHY is named above via // `entry_quarantine_reason`). Handled for exhaustiveness. Some(A::QuarantineWorkload) => "quarantine workload (default-deny)", + // `containment_for` never returns a node containment either — `ContainNode` is an + // INCIDENT-menu-only resolution (`menu::build_menu`/`Menu::resolve`, ADR-0040), never + // this chain-primary ladder. Handled for exhaustiveness. + Some(A::ContainNode) => "quarantine node (cordon + default-deny)", Some(A::Unclassified) => "unclassified", Some(A::DenyNetworkPath) => { if !chain.meets_action_bar() { diff --git a/scripts/judge_bakeoff_cutchoice.py b/scripts/judge_bakeoff_cutchoice.py index 9c5fd3e2..60256379 100644 --- a/scripts/judge_bakeoff_cutchoice.py +++ b/scripts/judge_bakeoff_cutchoice.py @@ -25,6 +25,17 @@ live-compromised downstream) is contain={downstream} ONLY — independently adjudicated against ADR-0034: cutting the clean entry is a GROUNDED (guard-admissible via the D5 entry-exemption) but non-minimal OVER-cut, exactly the residual risk T2b must penalize, not absolve. + +`downstream_boundary_broken_node` (ADR-0040) adds ONE fixture for the node-scoped mechanism +escalation: a downstream workload with kernel-tamper evidence (a `PtraceAttach`, ADR-0040 §3(c)) +renders its containment-options line as a NODE cordon, not a pod quarantine — the resolver, not +the model, made that swap (the model still only ever copies a node KEY into `contain`, never a +mechanism). Ground truth stays contain={the named workload} ONLY: no over-cut of the entry (clean) +or of any node-mate (not even on the menu — the fixture has none, so a fabricated third key would +already show as an OVER-CUT). Per ADR-0033 this bench is authoritative ONLY on the DEPLOYED pod +(local arm64 over-cuts, amd64 under-cuts on the pre-existing fixtures) — this fixture is added + +documented here but has NOT been run/tuned against any local backend; do not tune the prompt to a +local result for it. """ import json, re, sys, time, urllib.request from collections import Counter @@ -96,6 +107,22 @@ def menu(*lines): ROUTE_ENTRY = "workload/public/Pod/checkout-api-7f9d4c8b6d-x2p9k" ROUTE_ENTRY_LINE = f" - <<<{ROUTE_ENTRY}>>>: isolate the internet-facing entry — deny all ingress + egress except proven-benign peers (reversible NetworkPolicy) [blast radius: drops the route-forwarded backend; 1 alive peer isolated]" +# ADR-0040: a downstream workload the deterministic resolver has already escalated to the +# NODE-scoped mechanism (a proven pod-boundary break, ADR-0040 §3) — a DISTINCT identity from +# CACHE so this fixture never collides with the pod-quarantine cases above. The mechanism/blast +# text is the exact fixed strings `ProposedAction::ContainNode::describe` / +# `incident::menu::cut_blast_note` render (`engine/src/engine/respond/mod.rs`, +# `engine/src/engine/reason/adjudicate/incident/menu.rs`) — copied verbatim, never paraphrased, +# so a prompt-wording drift there would be caught by re-syncing this fixture, not silently missed. +BOUNDARY_BROKEN_NODE = "workload/public/Pod/web-worker-2" +BOUNDARY_BROKEN_NODE_LINE = ( + f" - <<<{BOUNDARY_BROKEN_NODE}>>>: cordon the node and default-deny its co-resident pods " + "(proven pod-boundary break — a pod-scoped policy can no longer contain this workload) " + "(damage-limitation, not a clean sever: the cordon stops scheduler-driven spread, the " + "co-resident denies stop lateral use of the node's other pods, and drain/reimage/rotate is " + "a human act)" +) + CASES = [ # entry-only breach: log4j loaded on the ENTRY -> attack, contain ONLY the entry. ("entry_only_log4j", "attack", {ENTRY}, @@ -175,6 +202,16 @@ def menu(*lines): " - <<>>: no evidence observed.", menu(" - <<>>: isolate the internet-facing entry — deny all ingress + egress except proven-benign peers (reversible NetworkPolicy) [blast radius: drops the OPRF service; 2 alive peers isolated]", " - <<>>: quarantine this workload — default-deny NetworkPolicy (reversible, additive) [blast radius: 1 alive peer isolated]")), + # ADR-0040 NODE-CONTAINMENT: a downstream workload with kernel-tamper evidence (PtraceAttach, + # trigger (c)) — the resolver already escalated ITS containment-options line to the node + # cordon; the model's job is UNCHANGED (name the compromised workload's key, never a + # mechanism) -> attack, contain ONLY that workload. The clean entry stays running (no + # over-cut), and there is no third menu line to over-cut onto either — the fixture's own + # minimal shape is the "no over-cut of neighbors" check. + ("downstream_boundary_broken_node", "attack", {BOUNDARY_BROKEN_NODE}, + ENTRY, "(none)", "(none)", "<<>>", OBJS, + f" - <<<{BOUNDARY_BROKEN_NODE}>>>: CVEs observed loading at runtime: <<<(none)>>> | Exposed secrets: <<<(none)>>> | Observed runtime behavior: <<>>", + menu(ENTRY_LINE, BOUNDARY_BROKEN_NODE_LINE)), ]