Skip to content

ContainNode action + resolver escalation + honest proposal surface (ADR-0040, shadow-complete) - #321

Merged
thejefflarson merged 1 commit into
mainfrom
thejefflarson/jef-730-containnode-action-resolver-escalation-honest-proposal
Aug 2, 2026
Merged

ContainNode action + resolver escalation + honest proposal surface (ADR-0040, shadow-complete)#321
thejefflarson merged 1 commit into
mainfrom
thejefflarson/jef-730-containnode-action-resolver-escalation-honest-proposal

Conversation

@thejefflarson

Copy link
Copy Markdown
Owner

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 Node
    object 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.
  • Resolver escalation, same menu/ledger code path
    (engine/src/engine/reason/adjudicate/incident/menu.rs's new escalate helper): a
    model-named workload X resolves to ContainNode iff
    reason::proof::boundary_break(X) holds; otherwise X keeps its existing pod-scoped
    cut. No new menu line, no model-selectable mechanism — the model still only ever
    names the workload; determinism swaps the mechanism. MitigationLedger::reconcile
    needed no changes — it already consumes exactly the ChosenCut the menu resolved.
  • Honest proposal surface: a fixed-string damage-limitation blast note (cordon
    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 ContainNode
    lines, with a fixed-string self-severance clause appended when protector's own
    agent/control-plane component shares the node (respond::self_severance) — no
    untrusted substrings anywhere.
  • Trigger (d) wired to the LIVE judge pipeline (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.
  • Fingerprint + replay-lock: no wiring needed beyond the menu render already being
    part of the full-state prompt — a boundary_break flip changes the rendered menu
    line, 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).
  • Journal: no schema change — JournaledCut was already action-agnostic (node +
    cut_signature only, never the ProposedAction).
  • One deployed-pod cut-choice bench fixture
    (scripts/judge_bakeoff_cutchoice.py): a boundary-broken downstream node → model
    names it, resolver yields ContainNode, no over-cut of neighbors. Per ADR-0033 this
    needs deployed-pod validation; not tuned to any local backend.

charts/ is untouched; no RBAC touched; respond/actuator/* is untouched (the
actuator that renders the cordon + co-resident denies lands separately, ADR-0040 §7).

Decisions made (no blocking questions)

  • Escalation scope: applied only within the model-judged (breach-relevant)
    incident-menu path — build_menu's entry + downstream lines. The internal-only,
    non-model-judged MitigationLedger::reconcile branch (condition-2 quarantine
    targets) 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.
  • Self-severance detection: label-based (app.kubernetes.io/component: agent for
    the eBPF DaemonSet, app.kubernetes.io/name: protector for the engine Deployment
    under the chart's default naming) — presentation-only, never gates anything, so a
    miss under a customized Helm nameOverride only means a milder note, not a
    functional gap. Documented inline (respond::is_protector_component).
  • ContainNode Link identity: keyed on the Host node (self-reference), not the
    named 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_guard and self_containment_guard):
    1158 passed, 0 failed, 2 pre-existing network-gated ignores.
  • New/updated coverage:
    • respond::contain_node_testscontain_node_link/self_severance pure-function
      unit 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::testsmodel_attack_set pure unit test, plus two D8 replay-lock
      tests: a ContainNode decision re-arming through the unmodified lock, and a
      boundary_break flip failing the lock closed (cold re-judge).
    • journal::testsContainNode cut_signature + fingerprint round-trip.
  • soundcheck:pr-review: no Critical/High findings (pure internal Rust resolver logic
    • a dev-only Python bench script; all new proposal text is fixed-string, no new I/O
      or auth surface).
  • /simplify: single-pass (Agent tool unavailable in this context) — deduped a
    repeated ~150-char fixed string across two ContainNode note consts into a
    base+suffix concatenation; considered and skipped hoisting the small
    ScheduledOn-edge-walk duplication with reason::proof::boundary_break's private
    internals (matches the codebase's existing per-module local-traversal convention).

🤖 Generated with Claude Code

https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP

…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
thejefflarson merged commit 2f09000 into main Aug 2, 2026
5 checks passed
@thejefflarson
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant