mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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.
78 lines
3.0 KiB
Go
78 lines
3.0 KiB
Go
// Phase C Step 9 — `commands` host capability.
|
|
//
|
|
// Plugins that declare the "commands" capability register one or more slash
|
|
// commands at activation time. The WS command dispatcher (Server/ws/command.go)
|
|
// calls Registry.DispatchCommand after exhausting its built-in command table.
|
|
|
|
package plugin
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// CommandResult is what a plugin returns from a command invocation.
|
|
type CommandResult struct {
|
|
// Reply is sent back to the invoking user as an ephemeral message.
|
|
Reply string
|
|
// Broadcast, when set, is also broadcast to the channel.
|
|
Broadcast string
|
|
}
|
|
|
|
// RegisterCommand binds cmd to inst. Called from the activation path in the
|
|
// wazero-tagged build once the module exports its `register_commands` table.
|
|
// Default build can call it directly from tests.
|
|
func (r *Registry) RegisterCommand(cmd string, inst *Instance) error {
|
|
cmd = strings.ToLower(strings.TrimPrefix(cmd, "/"))
|
|
if cmd == "" {
|
|
return fmt.Errorf("plugin: cannot register empty command")
|
|
}
|
|
if !inst.Manifest.HasCapability(CapCommands) {
|
|
return ErrCapabilityNotGranted
|
|
}
|
|
// Per-command ACL. The `commands` capability alone used to bind whatever
|
|
// the guest module returned from list_commands, so an admin enabling a
|
|
// plugin could not know which commands it would claim. The manifest is now
|
|
// the authority: only declared names bind, and this is the single choke
|
|
// point both auto-registration and direct registration route through.
|
|
if !inst.Manifest.DeclaresCommand(cmd) {
|
|
return fmt.Errorf("%w: %s/%s", ErrCommandNotDeclared, inst.Manifest.Name, cmd)
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
// Ownership is compared by plugin identity (manifest name — unique per
|
|
// registry), not instance pointer: an in-place upgrade replaces the
|
|
// *Instance, and the same plugin must be able to re-bind its own
|
|
// commands. A *different* plugin claiming an owned command is still
|
|
// refused (cross-plugin command-hijack protection).
|
|
if existing, ok := r.commands[cmd]; ok && existing.Manifest.Name != inst.Manifest.Name {
|
|
return fmt.Errorf("plugin: command %q already registered by %q", cmd, existing.Manifest.Name)
|
|
}
|
|
r.commands[cmd] = inst
|
|
return nil
|
|
}
|
|
|
|
// DispatchCommand routes a slash command to the owning plugin. Returns
|
|
// (nil, false) when no plugin owns the command, letting the WS dispatcher
|
|
// fall back to the not-found response. Returns (nil, true) when the runtime
|
|
// is unavailable so the dispatcher can show a helpful error message.
|
|
func (r *Registry) DispatchCommand(ctx context.Context, userID int64, channelID int64, cmd string, args []string) (*CommandResult, bool) {
|
|
if r == nil {
|
|
return nil, false
|
|
}
|
|
cmd = strings.ToLower(strings.TrimPrefix(cmd, "/"))
|
|
r.mu.RLock()
|
|
inst, ok := r.commands[cmd]
|
|
r.mu.RUnlock()
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
if r.runtimePlatform == nil {
|
|
return &CommandResult{
|
|
Reply: fmt.Sprintf("plugin %q owns /%s but the wazero runtime is not built (run with -tags wazero)", inst.Manifest.Name, cmd),
|
|
}, true
|
|
}
|
|
return r.invokeCommand(ctx, inst, userID, channelID, cmd, args)
|
|
}
|