Skip to content

feat: make plugin service backing an explicit signed declaration - #8

Closed
lr00rl wants to merge 1 commit into
fix/plugin-gateway-hardeningfrom
feat/plugin-service-backing
Closed

feat: make plugin service backing an explicit signed declaration#8
lr00rl wants to merge 1 commit into
fix/plugin-gateway-hardeningfrom
feat/plugin-service-backing

Conversation

@lr00rl

@lr00rl lr00rl commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Stacked on #7 (fix/plugin-gateway-hardening). Review that one first; this PR's diff is the second commit.

Implements Option C from the 2026-07-14 plugin-boundary architecture review (lattice_plan/ARCH-REVIEW-2026-07-14-plugin-boundary.md §3–§4.2) — the resolution of the ownership contradiction, pending your sign-off on the direction.

The problem

Three design documents disagree about who owns a domain engine, and the code implemented a fourth, silent compromise:

  • ADR-001 D5: "Engine = core… keeps the trust base small."
  • Bundle V2 §16: completion requires plugins to own their domain backend; §9.1 forbids a v2 service falling back to an in-core handler.
  • The plugins' own file headers: extend D5 to mean the domain engine stays in core.

Reality: vpn-core, netguard, and wireguard ship v2 manifests declaring 8–15 interface methods and host-risk capabilities (network:apply, task:run) — but their backends stop at plan and return unsupported action for everything else. They work only because handlePluginCall routed any service to the in-core provider whenever core owned it and the publisher string was latticenet.

So a manifest could declare methods its own artifact cannot serve, core quietly answered them, and neither an operator nor an auditor could tell the difference. CI never caught it because tests cover what plugins do, never what their manifests promise.

Keeping those engines in core is defensible — arguably correct, given D5's small-trust-base rationale. Inferring it from a publisher string is not.

The fix: declare it

backing is now a per-service field inside the signed manifest, and dispatch follows it exactly:

backing Behaviour
core A core provider owned by this plugin must exist, or the call fails. System plugins only; every v2 manifest already requires a trusted-publisher signature, so the claim is signed by construction.
runtime (default) The artifact serves it, and core never answers in its place — a core provider shadowing a runtime-backed service fails closed rather than being silently picked. This closes the §9.1 hole.
undeclared Manifests signed before the field existed. The old inference (publisher + core ownership) still resolves them so nothing breaks, but each is logged by name once, so the remaining set is visible instead of silent.

This keeps ADR-001 D5's small trust base, makes the contract honest, and stays extensible: an engine can later move core → runtime as a data change, not a control-flow rewrite. It also retires the Publisher == "latticenet" heuristic from the dispatch path (review item E4).

Signature parity is the load-bearing constraint

backing is omitempty, so an already-signed manifest serializes to byte-identical bytes and keeps its signature valid. This is not cosmetic: the publisher seed is operator-held, so I cannot re-sign the plugin manifests — breaking parity would strand every deployed plugin. TestBackingOmittedKeepsSigningPayloadByteIdentical pins it, and pins that declaring backing does change the payload, so the claim cannot be swapped in after signing.

This is why the undeclared path must remain for now, and why removing it is a follow-up gated on re-signing.

Disable now stops the backend, not just the UI

Spec §10 says disable stops the runtime. It didn't. In-core providers are registered once at boot (server.go:391-392) and never unregistered (grep Unregister over internal/server → nothing), so a disabled plugin kept serving: to the gateway, and to any consumer still holding a granted RPC edge (Sub-Store holds rpc:call on vpn-core/nodes.export).

The RPC bus now consults the owning plugin's lifecycle before every dispatch, on both the operator and inter-plugin paths. A service is servable exactly while its owner is active — whichever side the engine lives on. This only ever tightens: core never calls these services outside the gateway (verified — CallOperator appears only in the gateway handler).

Two existing vpn-core tests encoded the old leaky behaviour (calling an in-core service with no active installation); they now activate the plugin first, as a real deployment does.

Test plan

  • go vet ./internal/... clean
  • go test ./internal/plugin/ ./internal/server/ -race — both green
  • TestBackingOmittedKeepsSigningPayloadByteIdentical — existing signatures survive; declared backing is signed
  • TestBackingValidation — unknown backing rejected; non-system plugin cannot claim core
  • TestPluginCallRuntimeBackedServiceIsNeverAnsweredByCore — the §9.1 hole, fails closed
  • TestPluginCallCoreBackedServiceDispatchesToCore — incl. disable stopping a core-backed backend
  • TestPluginCallCoreBackedWithoutProviderFailsClosed

Follow-ups (not in this PR)

  1. Re-sign the three façade manifests with "backing":"core" (needs the operator-held publisher seed — I can't do this).
  2. Then delete the undeclared-inference path entirely.
  3. The conformance gate from review §4.1: walk manifest.interfaces[].methods[] and assert every runtime-backed method is answerable by its own artifact. That is the test that would have made this gap a red CI instead of an invisible one.

https://claude.ai/code/session_01FzYExyy6G7Cb7QpXoUEY78

A plugin need not carry its own engine. The nftables renderer and the
WireGuard key/config engine deliberately stay in core so the trust base stays
small (ADR-001 D5), and what the plugin owns is the UI, the validation, and the
workflow intent. That arrangement is legitimate.

What was not legitimate was leaving it implicit. The gateway routed a v2 call
to core whenever core happened to own the service and the manifest's publisher
string was "latticenet" — so three official plugins could declare interface
methods their own artifacts cannot serve, core quietly answered them, and
neither an operator nor an auditor could tell the difference. A manifest that
declares a method core secretly answers is a contract that lies.

Backing is now a per-service field inside the signed manifest, and dispatch
follows it exactly:

  - core: a core provider owned by this plugin must exist, or the call fails.
    Confined to system plugins; every v2 manifest already requires a trusted
    publisher signature, so the claim is signed by construction.
  - runtime: the artifact serves it and core never answers in its place. This
    is what closes the silent-fallback hole.
  - undeclared: manifests signed before the field existed. The old inference
    (publisher + core ownership) still resolves them so nothing breaks, but each
    one is now logged by name so the remaining set is visible rather than silent.

Backing is omitempty, so an already-signed manifest serializes to identical
bytes and keeps its signature valid — the publisher seed is operator-held, and
breaking parity would strand every deployed plugin. A test pins that parity,
and pins that DECLARING backing does change the payload, so the claim cannot be
swapped in after signing.

Disable now stops the backend, not just the UI. In-core providers are wired at
boot and never unregistered, so a disabled plugin kept serving — to the gateway,
and to any consumer still holding a granted RPC edge. The bus now consults the
owning plugin's lifecycle before every dispatch, so a service is servable
exactly while its owner is active, whichever side the engine lives on.

Tests: go vet clean; internal/plugin and internal/server green under -race.
@lr00rl

lr00rl commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

[ack] with one Low finding — zeus review, Olympus TASK-0001 item 2 (2026-07-26)

Matches the co-signed 2026-07-14 backing ruling exactly: signed per-service backing; core→system-only; runtime-shadowed-by-core fails closed; undeclared→legacy inference logged once; signing-payload byte-parity pinned both directions; disable honesty via RPC-bus lifecycle predicate wired at boot.

Finding (Low, follow-up — not merge-blocking): RPCRegistry.Call runs serviceIfActive BEFORE the grant check, so an ungranted caller distinguishes absent/disabled/active by error (ErrRPCNoService / ErrRPCOwnerInactive / ErrRPCDenied) — and the serviceIfActive comment claims disabled and absent are indistinguishable, which the code contradicts. Deny before reveal: grant-check first or uniform error to ungranted callers; fix the comment either way.

Evidence: contained in integration (86422a1); full -race -cover suite green there (internal/plugin 76.7%, internal/server 69.8%). Disposition: close-with-landing-commit per Olympus rules/01 §8.5.

@lr00rl

lr00rl commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

Closed as landed — zeus, operator-approved sweep (2026-07-26, rules/01 §8.5): commit 1272a3f contained in integration (86422a1). [ack] verdict above; Low finding tracked as Olympus TASK-0008 (already implemented as PR #21). Closing, not rejecting.

@lr00rl lr00rl closed this Jul 26, 2026
lr00rl added a commit that referenced this pull request Jul 31, 2026
TASK-0016 lands the deterministic suggestion core while the report endpoint, State persistence, dashboard/plugin surfaces, and agent wiring remain separate contract-bound work. This keeps low-trust node reality useful for operator review without letting it mutate policy or enter apply paths.

Constraint: Allowed paths are limited to lattice-server/internal/netguard/**; PR #26 exact head e3238de has green GitHub ci / go.

Rejected: Add /api/agent/guard-reality or storage now | contract/api-contract.md row #8 is still draft and endpoint/storage is outside TASK-0016.

Rejected: Ignore Binding.Overrides in suggestions | Zeus review proved overrides are part of effective compiler intent.

Confidence: high

Scope-risk: narrow

Directive: Keep reality-derived suggestions review-only; accepting one must remain a later audited mutation path.

Tested: go test ./internal/netguard -run 'TestSuggest' -count=1; go test -race -cover ./internal/netguard (81.7%); sh scripts/check-docker-defaults.sh; go vet ./...; go test -race -cover ./...; redaction-scan with inspected synthetic fixture ledger; git diff --check; PR #26 ci / go SUCCESS 10m28s.

Not-tested: Endpoint/storage/dashboard/agent integration and live host discovery commands are intentionally out of scope.
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