ContainNode action + resolver escalation + honest proposal surface (ADR-0040, shadow-complete) - #321
Merged
thejefflarson merged 1 commit intoAug 2, 2026
Conversation
…sal surface (ADR-0040) 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP
thejefflarson
deleted the
thejefflarson/jef-730-containnode-action-resolver-escalation-honest-proposal
branch
August 2, 2026 22:19
thejefflarson
added a commit
that referenced
this pull request
Aug 2, 2026
…t-deny (ADR-0040) (#322) * feat(engine): node containment actuator — cordon + co-resident default-deny (ADR-0040) Implements ADR-0040 step 1c's actuator half (the trigger/proposal surface already landed in #321): the ContainNode render path, its deterministic rails, and their actuation metrics — all shadow-only, unit-tested. - Cordon/uncordon renderers (`node_containment::render_cordon`/ `render_uncordon`): a Node.spec.unschedulable patch carrying a fixed ownership annotation (protector.jeffl.es/cordoned-by), applied via server-side apply so a revert only ever lifts a cordon protector itself placed. - Co-resident default-deny sweep (`co_resident_denies`): one QuarantineWorkload mitigation per labelled co-resident pod, reusing quarantine_workload_link's exact self-reference shape (refactored to take (node, labels) instead of a chain-derived QuarantineTarget) and therefore render_isolation's existing renderer — an unlabelled pod declines exactly like every other quarantine candidate. - Deterministic rails (cordon_decision/revert_decision), pure over a NodeFact fleet: never cordon a control-plane node, at most one node cordoned concurrently, refuse a cordon leaving fewer than two schedulable workers, and ownership-gated revert. - Actuation metrics: a `contain_node` counter by event (proposed/applied/reverted/rail_refused) with a reason label for refusals, mirrored to the existing in-cluster OTLP sink. Wired to fire "proposed" from the real, already-live boundary_break trigger; applied/reverted wait on the rung-3 wiring ticket. - NodeContainmentActuator: the cluster-facing apply/revert glue a future break-glass/self-revert ticket can call, exercising the cordon + co- resident sweep together. ContainNode remains impossible to auto-apply: no `node` arming rung is wired, and is_additive_live() == false routes every mitigation to Decision::Forbidden regardless of the rails — asserted by test. Node role/schedulability observation (a `nodes` get/list/watch RBAC grant) is a deliberate follow-up, not this ticket — see the node_containment module doc for why evaluating the rails against fabricated "no data" would be less safe than not wiring them into the live per-pass loop yet. Closes JEF-731 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP * fix(engine): self-gate NodeContainmentActuator::revert on the ownership rail (ADR-0040) revert() now takes the target's NodeFact and short-circuits to Actuation::DryRun unless revert_decision(target).is_ok(), so the ownership gate (never lift a cordon protector did not place) cannot be bypassed by a forgetful caller. Previously the gate lived only in a doc-comment contract; the break-glass/self-revert path (ADR-0040 §6) will call this exact method, so the highest-blast action is now safe by construction rather than by caller discipline. No live caller today; defense-in-depth hardening of the path the next ticket builds on. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes JEF-730
Step 1b of ADR-0040 (node-scoped containment): shadow-complete, no RBAC, no chart,
no actuation path. The incident detail stops proposing a pod cut the engine's own
evidence proves can't contain a host-namespace process, and proposes the honest node
containment instead.
ProposedAction::ContainNode(engine/src/engine/respond/mod.rs): reversible,deliberately not additive-live — a cordon mutates a shared field on a live
Nodeobject rather than adding a new engine-owned one, so it's propose-first by
construction via the existing blast/alive-collateral gate, even once armed.
(
engine/src/engine/reason/adjudicate/incident/menu.rs's newescalatehelper): amodel-named workload X resolves to
ContainNodeiffreason::proof::boundary_break(X)holds; otherwise X keeps its existing pod-scopedcut. No new menu line, no model-selectable mechanism — the model still only ever
names the workload; determinism swaps the mechanism.
MitigationLedger::reconcileneeded no changes — it already consumes exactly the
ChosenCutthe menu resolved.stops scheduler-driven spread; co-resident denies stop lateral use; drain/reimage/
rotate is a human act) replaces the network-cut blast-radius note for
ContainNodelines, with a fixed-string self-severance clause appended when protector's own
agent/control-plane component shares the node (
respond::self_severance) — nountrusted substrings anywhere.
adj_pass::model_attack_set):the boundary_break co-resident-dual-compromise trigger now reads the model-attack
set from
Engine::decisions(this pass's live per-entry cut-choice decisions)instead of a caller-supplied stand-in.
part of the full-state prompt — a
boundary_breakflip changes the rendered menuline, which changes the fingerprint (ADR-0034 D4) and fails the D8 replay-lock's
cut-signature check byte-identity (cold re-judge, never a silent repoint).
JournaledCutwas already action-agnostic (node +cut_signature only, never the
ProposedAction).(
scripts/judge_bakeoff_cutchoice.py): a boundary-broken downstream node → modelnames it, resolver yields
ContainNode, no over-cut of neighbors. Per ADR-0033 thisneeds deployed-pod validation; not tuned to any local backend.
charts/is untouched; no RBAC touched;respond/actuator/*is untouched (theactuator that renders the cordon + co-resident denies lands separately, ADR-0040 §7).
Decisions made (no blocking questions)
incident-menu path —
build_menu's entry + downstream lines. The internal-only,non-model-judged
MitigationLedger::reconcilebranch (condition-2 quarantinetargets) is untouched, matching ADR-0040's "off-path auto-containment: won't-build"
and the pre-existing "outside the north star's two lanes, untouched" precedent for
that branch.
app.kubernetes.io/component: agentforthe eBPF DaemonSet,
app.kubernetes.io/name: protectorfor the engine Deploymentunder the chart's default naming) — presentation-only, never gates anything, so a
miss under a customized Helm
nameOverrideonly means a milder note, not afunctional gap. Documented inline (
respond::is_protector_component).ContainNodeLink identity: keyed on theHostnode (self-reference), not thenamed workload — two co-resident boundary-broken workloads collapse onto ONE
containment proposal, matching ADR-0040 §5's "at most one node cordoned
concurrently" rail. Unit-tested (
contain_node_tests.rs).Test plan
cargo fmt --check,cargo clippy --tests --all-targets -- -D warnings: clean.cargo test(full suite, incl.file_size_guardandself_containment_guard):1158 passed, 0 failed, 2 pre-existing network-gated ignores.
respond::contain_node_tests—contain_node_link/self_severancepure-functionunit tests (host-keyed signature collapse, unscheduled-workload
None, agent /engine label detection).
menu::tests— escalation both directions (boundary-broken →ContainNode;clean → pod-scoped cut unchanged), no new selectable node/line, fixed-string
blast note with/without self-severance, and a menu-mapping-change →
prompt-fingerprint-change test isolating the ADR-0034 D4 claim.
adj_pass::tests—model_attack_setpure unit test, plus two D8 replay-locktests: a
ContainNodedecision re-arming through the unmodified lock, and aboundary_breakflip failing the lock closed (cold re-judge).journal::tests—ContainNodecut_signature + fingerprint round-trip.soundcheck:pr-review: no Critical/High findings (pure internal Rust resolver logicor auth surface).
/simplify: single-pass (Agent tool unavailable in this context) — deduped arepeated ~150-char fixed string across two
ContainNodenote consts into abase+suffix concatenation; considered and skipped hoisting the small
ScheduledOn-edge-walk duplication withreason::proof::boundary_break's privateinternals (matches the codebase's existing per-module local-traversal convention).
🤖 Generated with Claude Code
https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP