docs(audit): correct CRITICAL #4 closure — Dispatch has a live hub caller

The closure rationale for audit finding #4 claimed in five places that
nothing in the server calls EventSink.Dispatch. That is disprovable by
grep: ws/hub.go:1034 calls Dispatch on every broadcast message, and
api/router.go:134-139 wires h.pluginSink whenever plugins are enabled.
The call site is pre-existing on main, not introduced by this branch.

Restate the closure on the claim the evidence actually supports:
Dispatch has exactly one caller outside the plugin package's tests
(ws/hub.go, on the hub's broadcast goroutine under seqMu), but its loop
body invokes no guest code and no production code calls Subscribe, so
the subscriber set is always empty and no guest code executes on the
event path. Finding #4 stays closed; the reason changes.

Also warn on Subscribe that adding the first production caller turns
Dispatch's loop live on the hub's hot path, and note in the SECURITY
GATE that the call site already exists so wiring delivery is not a new
integration.

Corrected in: plugin/host_events.go (Dispatch + Subscribe comments),
plugin/audit_closure_test.go, docs/audit-2026-04-07.md (row 4 and the
structural-mitigation paragraph), docs/audit-2026-07-19.md §1 row,
docs/plans/audit-2026-07-19-decisions.md D11.

Comments and docs only — no behaviour change.
This commit is contained in:
J3vb
2026-07-20 14:36:24 +02:00
parent 4ab01c39df
commit 605f97051a
5 changed files with 42 additions and 20 deletions
+8 -6
View File
@@ -162,12 +162,14 @@ func TestStorageRejectsOversizedKeyAndValue(t *testing.T) {
}
}
// TestEventDeliveryHasNoGuestPath locks finding #4. The finding (a plugin
// slowing the server by handling events slowly) is not reachable today:
// Subscribe requires the capability and Dispatch invokes no guest code in
// either build. If this test has to change because Dispatch grew a real
// delivery path, that change must also bring the per-plugin rate limit — see
// the SECURITY GATE comment on EventSink.Dispatch.
// TestEventDeliveryHasNoGuestPath locks finding #4. Dispatch is called on the
// hub's broadcast path (ws/hub.go) whenever plugins are enabled, but the
// finding (a plugin slowing the server by handling events slowly) is not
// reachable today: Subscribe requires the capability and has no production
// callers, and Dispatch invokes no guest code in either build. If this test
// has to change because Dispatch grew a real delivery path, that change must
// also bring the per-plugin rate limit — see the SECURITY GATE comment on
// EventSink.Dispatch.
func TestEventDeliveryHasNoGuestPath(t *testing.T) {
sink := NewEventSink()
noCap := &Instance{ID: 1, Manifest: &Manifest{Name: "nocap"}}
+22 -6
View File
@@ -58,6 +58,11 @@ func (s *EventSink) Emit(channelID int64, payload []byte) {
// Subscribe binds inst to topic. Multiple plugins may subscribe to the same
// topic — events fan out to every subscriber.
//
// No production code calls Subscribe today (only this package's tests), so
// subs is always empty at runtime and Dispatch's loop never iterates. The
// first caller added here turns Dispatch's loop live on the hub's broadcast
// path — see the SECURITY GATE comment on Dispatch before adding one.
func (s *EventSink) Subscribe(topic string, inst *Instance) error {
if !inst.Manifest.HasCapability(CapEvents) {
return ErrCapabilityNotGranted
@@ -90,17 +95,28 @@ func (s *EventSink) UnsubscribeAll(inst *Instance) {
// Dispatch invokes every subscriber's on_event for topic.
//
// SECURITY GATE (audit 2026-04-07 finding #4 — "no rate limit on event
// delivery to plugins"). Guest delivery is NOT implemented in either build:
// the loop below touches no module, and nothing in the server calls Dispatch,
// so a plugin cannot slow the hub by handling events slowly. Wiring the
// guest call is what makes the finding real, so whoever does it must land, in
// the same change:
// delivery to plugins"). Read this before adding anything to the loop below.
//
// Dispatch already has a production caller: ws/hub.go calls it on every
// broadcast message when an operator has enabled plugins (api/router.go wires
// h.pluginSink whenever the registry is non-nil). That call site runs on the
// hub's broadcast goroutine while seqMu is held, so anything this function
// does is on the hub's hot path and must not block or re-enter the hub.
//
// Guest delivery is nonetheless NOT implemented in either build: the loop
// below touches no module, and no production code calls Subscribe (only this
// package's tests), so subs is empty and the loop never iterates. No guest
// code executes on the event path today — that, not an absent call site, is
// why a plugin cannot currently slow the hub by handling events slowly.
//
// Wiring guest delivery is what makes the finding real, so whoever does it
// must land, in the same change:
//
// - a per-plugin delivery rate limit (drop, never block the caller), and
// - the same per-call CPU-budget deadline invokeCommand applies
// (sandbox_wazero.go), and
// - delivery off the hub's broadcast goroutine so a slow guest cannot
// backpressure fan-out to WS clients.
// backpressure fan-out to WS clients or extend the seqMu hold.
//
// Until then this stays inert on purpose.
func (s *EventSink) Dispatch(ctx context.Context, topic string, payload []byte) {