feat: make plugin service backing an explicit signed declaration - #8
feat: make plugin service backing an explicit signed declaration#8lr00rl wants to merge 1 commit into
Conversation
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.
|
[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 Finding (Low, follow-up — not merge-blocking): Evidence: contained in |
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.
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:
Reality:
vpn-core,netguard, andwireguardship v2 manifests declaring 8–15 interface methods and host-risk capabilities (network:apply,task:run) — but their backends stop atplanand returnunsupported actionfor everything else. They work only becausehandlePluginCallrouted any service to the in-core provider whenever core owned it and the publisher string waslatticenet.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
backingis now a per-service field inside the signed manifest, and dispatch follows it exactly:backingcoreruntime(default)This keeps ADR-001 D5's small trust base, makes the contract honest, and stays extensible: an engine can later move
core → runtimeas a data change, not a control-flow rewrite. It also retires thePublisher == "latticenet"heuristic from the dispatch path (review item E4).Signature parity is the load-bearing constraint
backingisomitempty, 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.TestBackingOmittedKeepsSigningPayloadByteIdenticalpins 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 Unregisteroverinternal/server→ nothing), so a disabled plugin kept serving: to the gateway, and to any consumer still holding a granted RPC edge (Sub-Store holdsrpc:callonvpn-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 —
CallOperatorappears 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/...cleango test ./internal/plugin/ ./internal/server/ -race— both greenTestBackingOmittedKeepsSigningPayloadByteIdentical— existing signatures survive; declared backing is signedTestBackingValidation— unknown backing rejected; non-system plugin cannot claimcoreTestPluginCallRuntimeBackedServiceIsNeverAnsweredByCore— the §9.1 hole, fails closedTestPluginCallCoreBackedServiceDispatchesToCore— incl. disable stopping a core-backed backendTestPluginCallCoreBackedWithoutProviderFailsClosedFollow-ups (not in this PR)
"backing":"core"(needs the operator-held publisher seed — I can't do this).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