Skip to content

fix(engine): scope-confine the co-resident deny set at the ContainNode actuation boundary - #334

Merged
thejefflarson merged 2 commits into
mainfrom
thejefflarson/jef-767-scope-filter-the-co_resident_denies-set-at-the-future
Aug 8, 2026
Merged

fix(engine): scope-confine the co-resident deny set at the ContainNode actuation boundary#334
thejefflarson merged 2 commits into
mainfrom
thejefflarson/jef-767-scope-filter-the-co_resident_denies-set-at-the-future

Conversation

@thejefflarson

Copy link
Copy Markdown
Owner

Summary

Closes the ContainNode enforceScope escape at the actuation boundary. co_resident_denies returns one default-deny per labelled co-resident pod on a contained node, unfiltered by enforceScope — applying it unfiltered would write NetworkPolicys into namespaces the operator never authorized (the ADR-0021 enforce-everywhere escape).

Design is settled in the ADR-0040 addendum (2026-08-08, docs/adr/0040-node-scoped-containment-mechanism-escalation.md on backlog-drain-ideas/PR #331) and the accompanying brief (docs/ideas/containnode-scope-confinement.md): protector never grows an in-product approve→apply path for node containment (the durable act stays out-of-band — cordon + runbook), so the filter lives at the actuator boundary rather than a hypothetical apply call-site.

What changed

  • Added co_resident_denies_in_scope(graph, host, scope) beside the unfiltered co_resident_denies: unscoped → full set (historical meaning); scoped → retain only denies ActuationScope::in_scope itself accepts (the same namespace-OR-label match the webhook's EnforceScope uses).
  • Wrapped the result in a ScopedDenies newtype constructible only by that function — the private inner Vec means no code outside the module can build one directly (proved by a compile_fail doctest plus a runtime witness test).
  • Changed NodeContainmentActuator::apply (node_containment/live.rs) to accept &ScopedDenies by type, so the only door into apply demands a scope-confined set. apply still has no call site in Engine (ADR-0040 §5 / addendum — propose-first by construction, no in-product approve→apply flow at all).
  • Revert keeps &[Mitigation] / the full set, unchanged — the shared co_resident_denies helper is untouched, so the revert seam still lifts every deny protector could ever have placed under any historical scope.
  • Rebased contain_node_in_scope on co_resident_denies_in_scope (scope.is_unscoped() || !subset.is_empty()) — behavior-identical to the prior direct any-match, so eligibility and the apply subset now share one scope-match source instead of two implementations that could drift apart.

Invariants preserved

  • ContainNode stays propose-only — no approve→apply flow added.
  • Revert-set semantics unchanged (full set).
  • Zero-egress, ADR-0021 (no enforce-everywhere — this is the fix), ADR-0016 (view never gates).
  • No new toggle, no chart/RBAC/config change.
  • node_containment.rs is 440 lines, tests.rs 596, live.rs 166 — all under the 1,000-line cap.

Testing

  • New unit tests in node_containment/tests.rs: unscoped → full set; scoped → correct subset; scoped with no in-scope co-resident → empty; label-axis match; ScopedDenies non-constructibility (a compile_fail doctest on the type itself, since #[cfg(test)] modules are never compiled by rustdoc, plus a runtime witness test).
  • Existing contain_node_in_scope fixtures pass unchanged (equivalence with the prior direct implementation).
  • cargo fmt --check, cargo clippy --all-targets -- -D warnings, cargo nextest run (1254 passed, 2 skipped — includes the repo's file_size_guard and self_containment_guard tests) all green.
  • Ran soundcheck:pr-review manually against the diff (Agent tool unavailable in this context) — no Critical/High findings; the change is a pure in-process scope-filter with no new attack surface. Ran /simplify manually as well — code was already clean on all four angles, no changes needed.

Closes JEF-767

🤖 Generated with Claude Code

thejefflarson and others added 2 commits August 8, 2026 12:30
…e actuation boundary

Add co_resident_denies_in_scope(graph, host, scope) beside the unfiltered
co_resident_denies: unscoped -> full set (historical meaning), scoped ->
retain only denies ActuationScope::in_scope accepts (the same
namespace-OR-label match the webhook's EnforceScope uses). Wrap the result
in a ScopedDenies newtype constructible only by that function, and change
NodeContainmentActuator::apply to accept &ScopedDenies by type -- the only
door into apply now demands a scope-confined set. Revert keeps &[Mitigation]
(the full set) unchanged, since lifting a deny protector never placed under
the current scope is a harmless no-op, but filtering revert would orphan an
out-of-scope deny placed under an earlier, wider scope.

Rebase contain_node_in_scope on the same function (scope.is_unscoped() ||
!subset.is_empty()) so proposal-side eligibility and the apply-side subset
share one scope-match source instead of two implementations that could
drift apart. Behavior-identical to the prior direct any-match -- existing
tests pass unchanged.

Closes the enforceScope escape: applying the co-resident sweep unfiltered
would write default-deny NetworkPolicies into namespaces the operator never
authorized (the ADR-0021 enforce-everywhere escape). Design settled in the
ADR-0040 addendum (2026-08-08): protector never grows an in-product
approve->apply path for node containment, so the filter lives at the
actuator boundary rather than a hypothetical apply call-site.

Tests: unscoped returns the full set; scoped retains the correct subset;
scoped with no in-scope co-resident is empty; label-axis match; a
compile_fail doctest plus a runtime witness for ScopedDenies'
non-constructibility outside co_resident_denies_in_scope;
contain_node_in_scope's existing fixtures pass unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP
@thejefflarson
thejefflarson merged commit a7f7a28 into main Aug 8, 2026
5 checks passed
@thejefflarson
thejefflarson deleted the thejefflarson/jef-767-scope-filter-the-co_resident_denies-set-at-the-future branch August 8, 2026 20:19
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