Skip to content

(fix) Feat/4945 policy change closes everything - #5361

Open
mr-miles wants to merge 4 commits into
NVIDIA:mainfrom
mr-miles:feat/4945-policy-change-closes-everything
Open

mr-miles wants to merge 4 commits into
NVIDIA:mainfrom
mr-miles:feat/4945-policy-change-closes-everything

Conversation

@mr-miles

@mr-miles mr-miles commented Nov 7, 2026 •

Copy link
Copy Markdown

Summary

Every network policy reload, provider attach/detach, and middleware registry change causes outbound network connections to be closed by the supervisor, and errors reported back to the sandbox.

This was deliberate (#2373, "invalidate relays on policy changes") and is documented, but much coarser than it needs to be. Also the behaviour is very confusing for workloads that ask for approvals while they are running, causing agents to focus on "solving" and diagnosing connectivity issues rather than their assigned work.

This PR adds the ability to re-validate open connections against a newly received policy, and close them only if the new policy would not allow them. The same behaviour applies for credential changes.

Related issue

Changes

Policy reloads

  • When the policy generation moves, a relay re-checks its connection instead of closing. It closes only if the answer changed.
  • A ConnectionPolicyContract is compared, to re-evalute the connection against the new policy
  • Every relay type: raw TCP, TLS passthrough, transparent TCP, CONNECT HTTP (passthrough and inspected), upgrades and forward HTTP is wired through one entry point, revalidating_generation_guard.
  • A reload between authorization and relay setup still refuses the relay.

Provider credentials

  • Each proxied connection records which credentials it injected, and at which version. Recording happens at SecretResolver and inject_or_report.
  • The credential state keeps one version per credential, and the version changes only when the credential's content does.
  • A connection closes only when a credential it sent is rotated, reconfigured, removed, or was already superseded when sent. Other providers' changes and identical reinstalls leave it open.

Credential revocation (pre-existing bug)

  • Revoking static credentials now publishes a generation, so connections that sent a revoked credential close. Two paths revoke: failed or unready refreshes and wrong-revision snapshots go through revoke_provider_environment, and invalid bindings go through reject_provider_bindings.
  • It publishes only if something was actually revoked, so a refresh that keeps failing doesn't wake connections on every poll.

Endpoint status (MCP)

  • A re-pinned tunnel reports status under the policy hash of the generation it's pinned to, so status keeps flowing after unrelated reloads.

Safety boundaries

  • Only the network crate can create revalidating guards. Every guard another crate gets from the engine is a plain, cheap comparison.

Release notes

Re-validate open connections against a newly received policy, and close them only if the new policy would not allow them

Testing

Structured as red/green refactors

  • First commit (test-only) pins current behaviour and highlights cases where the current behaviour is broken - 48 new tests
  • Second commit (code-only) introduces the changes that fix the tests
  • Third commit (test-only) introduces new tests to cover new components
  • Last commit (test-only) tidies test comments and any refactor to new apis

Manual check: open a long-lived connection (for example a WebSocket or curl to a slow endpoint) through the sandbox proxy, approve an endpoint for a different host, and confirm that the connection survives, the log shows "Policy change does not affect established connection; re-pinned", and the connection closes when its own host's rules change.

Checklist

  • Follows Conventional Commits
  • Commits are signed off (DCO)
  • Architecture docs updated (if applicable)

…lose

Every policy reload advances one sandbox-wide generation, and every pinned
relay closes when it moves, so approving an endpoint for one host drops
every open proxied connection in the sandbox.

Add tests, against the existing relay and engine entry points, for how
established connections should respond to reloads. Each test's doc comment
says whether it describes behaviour the following change alters
("Changed", failing on this commit) or behaviour it must keep
("Unchanged", passing before and after).

Changed: connections stay open when a reload does not affect them:
- raw TCP, HTTP passthrough and inspected (L7) relays, when an endpoint is
  approved for another host, an endpoint is inserted before theirs, or a
  gateway-delivered policy is restamped with a new policy hash;
- relays without middleware, when middleware is added for another host or
  the middleware registry is replaced; a kept-alive passthrough connection
  that runs middleware, on its next request after an unrelated reload;
- relays that sleep through several unrelated reloads, or are checked
  between any number of them;
- HTTP relays, across provider credential changes they did not take part
  in: credentials never injected on the connection, other providers'
  rotations and attachments (including through install_prepared), identical
  reinstalls, suppression of a credential from child processes, and a
  credential first injected after its provider was attached or served
  through an aged-out placeholder, and a credential sent through its
  canonical or stable-handle placeholder, or through the previous
  revision's placeholder while unchanged, when another credential rotates;
- a kept-alive MCP tunnel keeps reporting endpoint status after an
  unrelated reload, against the new policy once its inventory is published.

Unchanged: connections still close when a reload does affect them:
revoked endpoints, narrowed L7 rules (YAML and gateway-delivered),
middleware added for their host, changed or made fail-closed, a replaced
registry for connections that run middleware, reloads that revoked or
changed them even if a later reload restored them, a long run of reloads
they slept through, rotated or detached credentials they injected
(including rotations that coalesced, and a rotation behind an unchanged
stable-handle placeholder), a superseded value sent through an older
retained revision, untracked credential handles, a reload between
authorization and relay setup, and plain generation guards.
Endpoint-status results of a kept-alive MCP tunnel are never attributed to
an inventory of a policy it is not running under: not before the reloaded
policy's inventory is published, not to an inventory published ahead of the
runtime, and not after a rules change, which closes the tunnel.
One test records that every reload restamps policy_hash on unchanged
endpoints.
Changed, in the supervisor: revoking provider credentials (an unavailable
environment, bindings that fail validation, or a snapshot for another
revision) publishes a policy generation, so open connections re-check the
credentials they sent. Today revocation publishes nothing, and a
connection authenticated once with a revoked credential, such as an
upgraded WebSocket, stays open.
Unchanged: a revocation that finds nothing left to revoke, as when a
failed refresh repeats on every poll, publishes no generation.

Existing relay tests use Arc<OpaEngine>, which the change requires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… affect them

Every policy reload, middleware registry change and provider credential
change advanced one sandbox-wide generation, and every pinned relay closed
when it moved. Approving an endpoint for one host dropped every open
proxied connection in the sandbox.

When the generation moves, a pinned guard now re-checks the connection it
protects and closes only if the answer changed.

Policy: a ConnectionPolicyContract captures what decides how a connection
is relayed: the L4 action and matched policy, the ordered matched
endpoints (policy name and endpoint object, ignoring the endpoint index and
the per-reload policy_hash stamp), the exact-host flag, and the host's
middleware chain plus the registry epoch when that chain is non-empty.
Endpoint configs, which relays also read, are not compared separately:
Rego derives them from the same endpoints, in the same order. The engine records its last 16 published generations, and a
waking connection is re-checked against every generation since its pin,
because the generation watch coalesces reloads. If every contract equals
the one that authorized it, the guard re-pins; any difference, evaluation
error, fail-closed generation, or pin that has left the history makes the
guard permanently stale and the relay closes as before. Per-request Rego
rules read only the matched endpoints, so a tunnel's cloned evaluator stays
valid after a re-pin.

Credentials: the provider credential state owns one version per credential
(static environment key or token-grant provider:credential identity),
recomputed lazily by comparing content whenever its snapshot changes. Each
proxied connection gets a tracked handle and records the version of every
credential it injects, at the two points credential material leaves the
supervisor: SecretResolver handing out a secret, and inject_or_report
adding a grant. HTTP-family relays close only when a credential they
injected changes or is removed, or when they sent a value that was
already superseded. A use counts as current when the value it handed out
is its key's current value, whichever placeholder form named it: provider
revisions are sandbox-wide, so an older revision's placeholder usually
still names the current value.
Untracked handles and unattributable uses close on the next check. Raw TCP
and TLS passthrough relays never inject credentials and carry no pin.

Endpoint status: a tunnel's endpoint config carries the hash of the policy
it was selected from, so an MCP tunnel that re-pinned would have stopped
reporting endpoint status. The revalidator now records the stamped policy
hash of the generation its guard is pinned to, from authorization through
every re-pin, and observations (and token-grant reports) use it. The
inventory check is unchanged, so results are still dropped while the
inventory describes a different policy.

Revocation: the supervisor revokes static provider credentials when a
provider refresh fails, its bindings fail validation, or it delivers the
wrong revision, but it published no generation, so connections that had
sent a revoked credential were never checked. Both revocation paths now
call OpaEngine::publish_credential_change, which publishes a generation of
the unchanged policy (keeping any quarantine), when the revocation changed
any credential, judged by comparing
ProviderCredentialState::installed_credential_versions before and after.
A failed refresh that repeats on every poll therefore wakes connections
once, not every time. Connections that sent the revoked credential close;
the rest re-pin.

A reload between authorization and relay setup still refuses the relay.
OpaEngine::revalidating_generation_guard is the single entry point for
pinning a revalidating guard, and it and with_revalidator are crate-private,
so every guard another crate obtains from the engine is plain: checking it
is a cheap comparison that never evaluates policy or takes the engine lock. The HTTP passthrough middleware path lets the
guard re-pin before comparing raw generation numbers. The policy docs now
describe which changes close a connection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Unit tests for the code the previous commit introduces, which need its new
APIs. Behaviour visible through the relays is covered by the earlier
characterisation tests.

- contracts: unrelated endpoints are ignored; endpoint, L7 rule, binary and
  overlapping-policy changes close the connection and keep it closed after
  the policy is restored; quarantine always closes; policy_hash restamping
  is not a change; denials capture no revalidator; guard clones share one
  pin; the wait ignores unrelated reloads;
- middleware: any field of the selected chain changing closes the
  connection, an identical chain does not, and only reloads that replace
  the registry close connections that run middleware;
- capture: baselines come from the recorded authorizing generation, and a
  generation that left the history cannot be captured;
- credential versions: recording of key and version per secret sent,
  including SigV4-style lookups; unused credentials; versions shared across
  connections and stable across identical reinstalls; a removed and
  restored key gets a new version; an older revision records stale only
  if its value has since changed; child-env-only installs and revocation
  retire credentials; grant versions follow their configuration;
  untracked handles and unattributable uses;
- installed credential versions: unchanged by identical reinstalls and by
  a revocation with nothing left to revoke; changed by a new value, an
  added or removed credential, and a grant configuration change;
- credential change publication: a new generation of the unchanged policy
  makes plain guards stale and lets a connection without credentials
  re-pin; a quarantine stays in force;
- pinned policy hash: the authorizing generation's from the start, then
  that of the generation the guard re-pinned to (not a newer one it has not
  checked); none for plain guards or policies without a stamp;
- endpoint configs are exactly the matched endpoints that carry extended
  configuration, in the same order, which is why the contract does not
  compare them;
- token grants record their identity; passthrough middleware runs on the
  next request after an unrelated reload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Test-only. The first commit's tests were written against the APIs that
existed before the change so that they could run, and fail, on their own.
Now that the change has landed:

- authorize test decisions through EgressDecision::policy_input, the input
  revalidation re-evaluates;
- check that injecting a credential is recorded on the connection, and
  that a connection that never injected one records nothing;
- drop the test module's own ProviderCredentialState import, which the
  module now inherits;
- drop the "Changed"/"Unchanged" labels and the descriptions of the old
  behaviour from the doc comments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@copy-pr-bot

copy-pr-bot Bot commented Nov 7, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@github-actions

github-actions Bot commented Nov 7, 2026

Copy link
Copy Markdown

Thank you for your submission! We ask that you sign our Developer Certificate of Origin before we can accept your contribution. You can sign the DCO by adding a comment below using this text:


I have read the DCO document and I hereby sign the DCO.


You can retrigger this bot by commenting recheck in this Pull Request. Posted by the DCO Assistant Lite bot.

@mr-miles

mr-miles commented Nov 7, 2026 •

Copy link
Copy Markdown
Author

I have read the DCO document and I hereby sign the DCO.

@mr-miles mr-miles changed the title Feat/4945 policy change closes everything (fix) Feat/4945 policy change closes everything Nov 7, 2026
@mr-miles

mr-miles commented Nov 7, 2026

Copy link
Copy Markdown
Author

recheck

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants