Closes audit-2026-04-07 CRITICAL #3. Holding the `commands` capability used
to bind whatever names the guest module returned from `list_commands`, so an
admin enabling a plugin could not know which commands it would claim and a
plugin could widen its own command surface after review.
The manifest is now the authority. `plugin.json` gains a `commands` block
(`[{"name": "hello"}]`) and `RegisterCommand` refuses any undeclared name —
the single choke point both auto-registration and direct registration route
through, so no caller can bypass it. Declared names are validated to the
dispatcher's canonical lowercase form, deduplicated, and capped at 64.
The object shape matches docs/plans/slash-commands.md so the richer
per-command schema can land later without a manifest migration.
Also pins the two neighbouring CRITICALs that verification found already
closed, and adds the storage key cap host_storage.go's doc comment already
promised:
- #2 (storage key isolation): TestStorageKeysIsolatedPerPlugin — the KV
namespace is the caller's Instance.ID with no parameter to override it,
and plugin_kv PRIMARY KEY (plugin_id, key) makes the split structural.
- #4 (event rate limit): TestEventDeliveryHasNoGuestPath — EventSink.Dispatch
invokes no guest code and has no callers, so there is nothing to limit yet;
a SECURITY GATE comment requires the limiter in whatever change wires
delivery.
- #5 mitigation: TestEmptyAllowlistDeniesEveryHost — the shipped empty
http_allowlist must fail closed.
BREAKING CHANGE: a plugin declaring the `commands` capability must now list
its commands in the manifest's `commands` block; undeclared names no longer
bind. Only the in-repo `hello` example is affected and is updated here.
installFromDisk replaced r.plugins/r.byName with a fresh *Instance but
left r.commands keyed to the old pointer and the old module running:
re-installing an enabled plugin blocked its own command re-registration
(RegisterCommand compared ownership by pointer) and kept dispatch routing
into the orphaned module until restart. Reinstall now deactivates the old
instance and clears its bindings, and RegisterCommand compares ownership
by plugin identity (manifest name) — the same plugin re-binds freely, a
different plugin still cannot hijack an owned command.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>