I2 — pre-apply drift and conflict detection - #10
Open
henleda wants to merge 1 commit into
Open
Conversation
Compare live LB vs last snapshot vs what is about to be pushed, read-only
throughout, and gate the apply on the result.
`drift.preflight()` is called by BOTH apply paths — `apply.apply_from_scan` and
`refiner.refine_apply_service_policy`. The refiner is the default for
`--from-scan` and the console's Mitigate button, so gating only the former
would have gated nothing anyone uses.
The roadmap's conflict criterion was wrong, and running it live is what proved
it. It assumed the new policy is appended after the attached ones; both attach
paths replace the oneof with exactly one policy, so this tool never leaves two
service policies attached and there is no earlier policy to be shadowed by.
The first live run produced a confident false positive on banknimbus-dev.
Replaced by the two real behaviours underneath it:
* shadowing, in its only true scope — an ALLOW inside the policy being
applied that matches the exploit before its DENY. Refuses; `--force`
overrides; reuses `lint_service_policy` so drift and the linter cannot
disagree about FIRST_MATCH. Degrades to a warning on the refine path,
because reordering rules is what refine exists to do.
* displacement — that same wholesale replacement silently DETACHES whatever
was attached, a live loss of protection nothing was reporting. Warned and
audited, never refused: replacing the previous band-aid is the normal flow.
`no_change` returns before the XC create, not merely before
`ApplyContext.load()`, so a skipped apply leaves no LB PUT, no snapshot, no
run artifact and no stray policy object. It is determined for service_policy
only — there the proposed end state is exact. For the six LB-wide toggles
presence is detectable but presence is not parameter equality, so those report
`already_attached` and are never auto-skipped.
Six new audit actions, all category `gate`, with the decision as their outcome
(`refused` / `overridden` / `no_change` / `displaced` / `drift_detected`) —
never passed/failed, because there was no apply to score. `simulate_override`
from G2 was missing from the export model and is added with them.
Surfaces: `vpcopilot drift --lb <name>` (exit 1 on conflict), `GET /api/drift`,
a drift strip on the console's Mitigate step, and an inline "apply anyway"
button on a refusal. The strip renders nothing when the check cannot run, so a
credential-free screenshot capture stays clean.
No regressions: an LB already carrying a foreign policy still mitigates; dry
runs and `create_only` are never gated; behaviour with no snapshot and no
conflict is unchanged. Each pinned by a test.
Verified live against the tenant: field-level diff off a real 2026-07-24
snapshot, displacement warning, shadowed-DENY refusal (no LB change, no stray
policy), `no_change`, and `--force`.
341 tests, ruff clean.
Co-Authored-By: Claude Opus 5 (1M context) <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.
Compares the live LB against the snapshot the last apply left and against what is about to be pushed — read-only throughout — and gates the apply on the result.
The criterion was wrong, and running it live is what proved it
The roadmap's acceptance said "an earlier ALLOW rule shadowing the new DENY blocks apply as a conflict." That assumes the new policy is appended after the attached ones. Both attach paths do the opposite:
They replace the oneof with exactly one policy. This tool never leaves two service policies attached, so there is no earlier policy to be shadowed by. The first live run against
banknimbus-devproduced a confident false positive. Two real behaviours replaced it:--forceoverrides. Degrades to a warning on the refine path, because reordering rules is what refine exists to do — refusing there would break the default flow to protect it from a problem it already fixesShadowing reuses
lint_service_policyrather than re-deriving FIRST_MATCH, so drift and the linter can never disagree.Both apply paths, not just one
drift.preflight()is called byapply.apply_from_scanandrefiner.refine_apply_service_policy. The refiner is the default for--from-scanand for the console's Mitigate button — gating onlyapply_from_scanwould have gated nothing anyone uses.no_changewrites nothingIt returns before the XC create, not merely before
ApplyContext.load(). A skipped apply leaves no LB PUT, nosnapshots/, nolb_snapshot.json, and no stray policy object in the tenant.Deliberate partial: determined for
service_policyonly, where the proposed end state is exact (that name inactive_service_policies), so "unchanged" is a fact. For the six LB-wide toggles, presence is detectable by invertingdetach_controlbut presence is not parameter equality — arate_limitalready on at 100/MINUTE would read as unchanged while you push 5/MINUTE. Those reportalready_attachedwith a note and are never auto-skipped.Audit
Six new actions, all category
gate— the only entries that record a moment when the LB did not change, which is exactly why they belong in the trail:drift_detected·policy_displaced·apply_skipped_no_change·drift_block·drift_override·simulate_overrideTheir
outcomein the normalized export is the decision itself (refused,overridden,no_change,displaced) — never passed/failed, because there was no apply to score.simulate_overridewas already being written by G2 but was missing fromexport.py's model; added here.Surfaces
vpcopilot drift --lb <name> [--control --policy --finding --artifact]— exits1on conflictGET /api/drift— read-only, safe to pollvpcopilot apply --forceno_changerenders as its own outcome rather than a pass or a fail. The strip renders nothing when the check cannot run (no creds, unknown LB), so credential-free screenshot capture stays clean — noted indocs/DEMO.md.No regressions
create_onlyis never gatedEach pinned by a test.
Verified live against the tenant
Field-level diff off a real 2026-07-24 snapshot of
banknimbus-dev(12 dotted-path changes) · displacement warning namingbanknimbus-dev-replay-deny· shadowed-DENY refusal leaving the LB untouched and no stray policy created ·no_change·--force.Tests
341 passing, ruff clean.
drift.py86%.🤖 Generated with Claude Code