Skip to content

I2 — pre-apply drift and conflict detection - #10

Open
henleda wants to merge 1 commit into
mainfrom
feat-i2-drift
Open

I2 — pre-apply drift and conflict detection#10
henleda wants to merge 1 commit into
mainfrom
feat-i2-drift

Conversation

@henleda

@henleda henleda commented Jul 27, 2026

Copy link
Copy Markdown
Owner

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:

new_spec["active_service_policies"] = {"policies": [{"namespace": xc.ns, "name": policy_name}]}

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-dev produced a confident false positive. Two real behaviours replaced it:

behaviour on refuse
shadowing, in its only true scope an ALLOW inside the policy being applied matching the exploit before its DENY — under FIRST_MATCH the band-aid attaches cleanly and blocks nothing refuses; --force overrides. 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 fixes
displacement that same wholesale replacement silently detaches whatever was attached — a live loss of protection nothing was reporting warns + audits, never refuses. Replacing the previous band-aid is the normal flow; refusing would break every second apply. Follows the G2 precedent: warn with an audited override, not a machine veto

Shadowing reuses lint_service_policy rather than re-deriving FIRST_MATCH, so drift and the linter can never disagree.

Both apply paths, not just one

drift.preflight() is called by apply.apply_from_scan and refiner.refine_apply_service_policy. The refiner is the default for --from-scan and for the console's Mitigate button — gating only apply_from_scan would have gated nothing anyone uses.

no_change writes nothing

It returns before the XC create, not merely before ApplyContext.load(). A skipped apply leaves no LB PUT, no snapshots/, no lb_snapshot.json, and no stray policy object in the tenant.

Deliberate partial: determined for service_policy only, where the proposed end state is exact (that name in active_service_policies), so "unchanged" is a fact. For the six LB-wide toggles, presence is detectable by inverting detach_control but presence is not parameter equality — a rate_limit already on at 100/MINUTE would read as unchanged while you push 5/MINUTE. Those report already_attached with 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_override

Their outcome in the normalized export is the decision itself (refused, overridden, no_change, displaced) — never passed/failed, because there was no apply to score. simulate_override was already being written by G2 but was missing from export.py's model; added here.

Surfaces

  • vpcopilot drift --lb <name> [--control --policy --finding --artifact] — exits 1 on conflict
  • GET /api/drift — read-only, safe to poll
  • vpcopilot apply --force
  • Console: a drift strip on the Mitigate step, and an inline apply anyway button on a refusal. no_change renders 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 in docs/DEMO.md.

No regressions

  • an LB already carrying a foreign policy still mitigates (warn + proceed)
  • dry runs are never gated
  • create_only is never gated
  • with no snapshot and no conflict, behaviour is unchanged

Each 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 naming banknimbus-dev-replay-deny · shadowed-DENY refusal leaving the LB untouched and no stray policy created · no_change · --force.

Tests

341 passing, ruff clean. drift.py 86%.

🤖 Generated with Claude Code

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>
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