Files
J3vb 3d2dd19001 feat(plugin): enforce manifest-declared per-command ACL
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.
2026-07-20 14:10:02 +02:00

69 lines
2.5 KiB
Go

// Phase C Step 9 — `storage` host capability.
//
// Plugins get a per-plugin namespaced KV store backed by the PluginStore
// rows in the events/plugin schema. Capacity caps and value-size caps are
// enforced here so a misbehaving plugin can't fill the database.
package plugin
import (
"context"
"fmt"
)
// Key isolation is structural rather than checked: every call below passes
// inst.ID as the namespace and there is no parameter by which a caller (let
// alone a guest module) can name a different plugin's namespace. The
// plugin_kv table's PRIMARY KEY (plugin_id, key) — migrations/015_plugins.sql
// — makes the same split the storage layout, so two plugins using the same
// key never collide. Audit 2026-04-07 finding #2.
const (
maxPluginKeyBytes = 256 // 256 B per key
maxPluginValueBytes = 64 * 1024 // 64 KB per value
maxPluginScanLimit = 1000 // hard cap on PluginKVScan results
)
// StoragePut writes a single key/value pair on behalf of inst.
func (r *Registry) StoragePut(ctx context.Context, inst *Instance, key string, value []byte) error {
if !inst.Manifest.HasCapability(CapStorage) {
return ErrCapabilityNotGranted
}
if key == "" {
return fmt.Errorf("plugin storage: key must not be empty")
}
if len(key) > maxPluginKeyBytes {
return fmt.Errorf("plugin storage: key exceeds %d bytes", maxPluginKeyBytes)
}
if len(value) > maxPluginValueBytes {
return fmt.Errorf("plugin storage: value exceeds %d bytes", maxPluginValueBytes)
}
return r.cfg.Store.PluginKVSet(ctx, inst.ID, key, value)
}
// StorageGet returns the value for key, or (nil, error) when missing.
func (r *Registry) StorageGet(ctx context.Context, inst *Instance, key string) ([]byte, error) {
if !inst.Manifest.HasCapability(CapStorage) {
return nil, ErrCapabilityNotGranted
}
return r.cfg.Store.PluginKVGet(ctx, inst.ID, key)
}
// StorageDelete removes a key.
func (r *Registry) StorageDelete(ctx context.Context, inst *Instance, key string) error {
if !inst.Manifest.HasCapability(CapStorage) {
return ErrCapabilityNotGranted
}
return r.cfg.Store.PluginKVDelete(ctx, inst.ID, key)
}
// StorageScan returns all keys with the given prefix, capped at maxPluginScanLimit.
func (r *Registry) StorageScan(ctx context.Context, inst *Instance, prefix string, limit int) (map[string][]byte, error) {
if !inst.Manifest.HasCapability(CapStorage) {
return nil, ErrCapabilityNotGranted
}
if limit <= 0 || limit > maxPluginScanLimit {
limit = maxPluginScanLimit
}
return r.cfg.Store.PluginKVScan(ctx, inst.ID, prefix, limit)
}