mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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.
This commit is contained in:
@@ -0,0 +1,207 @@
|
|||||||
|
// Audit 2026-04-07 plugin CRITICAL closure invariants.
|
||||||
|
//
|
||||||
|
// Each test here pins a property the closure table in
|
||||||
|
// docs/audit-2026-04-07.md cites as the reason a CRITICAL is closed. They live
|
||||||
|
// in the default-build file set on purpose so they run on every
|
||||||
|
// `go test ./...`, not only under -tags wazero.
|
||||||
|
|
||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRegisterCommandRequiresManifestDeclaration locks finding #3 (per-command
|
||||||
|
// ACL). Holding the `commands` capability is not enough: only names the
|
||||||
|
// manifest's `commands` block declares may bind, so a guest module cannot
|
||||||
|
// widen its own command surface via list_commands.
|
||||||
|
func TestRegisterCommandRequiresManifestDeclaration(t *testing.T) {
|
||||||
|
mem := openPluginTestDB(t)
|
||||||
|
reg, err := NewRegistry(Config{Directory: t.TempDir(), Store: mem})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRegistry: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = reg.Close(context.Background()) })
|
||||||
|
|
||||||
|
manifest, err := ParseManifest([]byte(
|
||||||
|
`{"name":"claimer","version":"0.1.0","entrypoint":"p.wasm","permissions":["commands"],"commands":[{"name":"declared"}]}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseManifest: %v", err)
|
||||||
|
}
|
||||||
|
inst := &Instance{ID: 1, Manifest: manifest}
|
||||||
|
|
||||||
|
if err := reg.RegisterCommand("declared", inst); err != nil {
|
||||||
|
t.Fatalf("declared command must bind: %v", err)
|
||||||
|
}
|
||||||
|
// "/DECLARED" normalizes to the declared name and must still bind.
|
||||||
|
if err := reg.RegisterCommand("/DECLARED", inst); err != nil {
|
||||||
|
t.Fatalf("normalized declared command must bind: %v", err)
|
||||||
|
}
|
||||||
|
for _, undeclared := range []string{"undeclared", "ban", "declared2"} {
|
||||||
|
if err := reg.RegisterCommand(undeclared, inst); !errors.Is(err, ErrCommandNotDeclared) {
|
||||||
|
t.Fatalf("RegisterCommand(%q) = %v, want ErrCommandNotDeclared", undeclared, err)
|
||||||
|
}
|
||||||
|
reg.mu.RLock()
|
||||||
|
_, bound := reg.commands[undeclared]
|
||||||
|
reg.mu.RUnlock()
|
||||||
|
if bound {
|
||||||
|
t.Fatalf("undeclared command %q must not be bound", undeclared)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManifestCommandsValidation(t *testing.T) {
|
||||||
|
bad := map[string]string{
|
||||||
|
"no commands capability": `{"name":"x","version":"1","entrypoint":"x.wasm","commands":[{"name":"a"}]}`,
|
||||||
|
"uppercase name": `{"name":"x","version":"1","entrypoint":"x.wasm","permissions":["commands"],"commands":[{"name":"Ban"}]}`,
|
||||||
|
"leading slash": `{"name":"x","version":"1","entrypoint":"x.wasm","permissions":["commands"],"commands":[{"name":"/ban"}]}`,
|
||||||
|
"empty name": `{"name":"x","version":"1","entrypoint":"x.wasm","permissions":["commands"],"commands":[{"name":""}]}`,
|
||||||
|
"whitespace in name": `{"name":"x","version":"1","entrypoint":"x.wasm","permissions":["commands"],"commands":[{"name":"a b"}]}`,
|
||||||
|
"duplicate name": `{"name":"x","version":"1","entrypoint":"x.wasm","permissions":["commands"],"commands":[{"name":"a"},{"name":"a"}]}`,
|
||||||
|
}
|
||||||
|
for label, body := range bad {
|
||||||
|
t.Run(label, func(t *testing.T) {
|
||||||
|
if _, err := ParseManifest([]byte(body)); err == nil {
|
||||||
|
t.Fatalf("expected rejection for %s", label)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extra per-command fields from the richer schema in
|
||||||
|
// docs/plans/slash-commands.md must still parse (forward compatibility).
|
||||||
|
m, err := ParseManifest([]byte(
|
||||||
|
`{"name":"x","version":"1","entrypoint":"x.wasm","permissions":["commands"],"commands":[{"name":"kick","description":"d","options":[]}]}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rich command spec must parse: %v", err)
|
||||||
|
}
|
||||||
|
if !m.DeclaresCommand("kick") || m.DeclaresCommand("ban") {
|
||||||
|
t.Fatalf("DeclaresCommand wrong: %+v", m.Commands)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStorageKeysIsolatedPerPlugin locks finding #2 (per-plugin key
|
||||||
|
// isolation). The namespace is the caller's Instance.ID, which no plugin-
|
||||||
|
// supplied input can influence, and plugin_kv's PRIMARY KEY (plugin_id, key)
|
||||||
|
// keeps identical keys from two plugins in separate rows.
|
||||||
|
func TestStorageKeysIsolatedPerPlugin(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
mem := openPluginTestDB(t)
|
||||||
|
reg, err := NewRegistry(Config{Store: mem})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
newInst := func(name string) *Instance {
|
||||||
|
id, instErr := mem.InstallPlugin(ctx, name, "0.1", "{}")
|
||||||
|
if instErr != nil {
|
||||||
|
t.Fatalf("InstallPlugin(%s): %v", name, instErr)
|
||||||
|
}
|
||||||
|
return &Instance{
|
||||||
|
ID: id,
|
||||||
|
Manifest: &Manifest{Name: name, Permissions: []string{string(CapStorage)}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
alice, mallory := newInst("alice"), newInst("mallory")
|
||||||
|
|
||||||
|
if err := reg.StoragePut(ctx, alice, "secret", []byte("alice-value")); err != nil {
|
||||||
|
t.Fatalf("StoragePut alice: %v", err)
|
||||||
|
}
|
||||||
|
// Same key, different plugin: the writes must not collide.
|
||||||
|
if err := reg.StoragePut(ctx, mallory, "secret", []byte("mallory-value")); err != nil {
|
||||||
|
t.Fatalf("StoragePut mallory: %v", err)
|
||||||
|
}
|
||||||
|
got, err := reg.StorageGet(ctx, alice, "secret")
|
||||||
|
if err != nil || string(got) != "alice-value" {
|
||||||
|
t.Fatalf("alice's value was clobbered: got %q err %v", got, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan is namespaced too: an empty prefix returns only the caller's keys.
|
||||||
|
all, err := reg.StorageScan(ctx, mallory, "", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StorageScan: %v", err)
|
||||||
|
}
|
||||||
|
if len(all) != 1 || string(all["secret"]) != "mallory-value" {
|
||||||
|
t.Fatalf("scan leaked across plugin namespaces: %v", all)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deleting from one namespace leaves the other intact.
|
||||||
|
if err := reg.StorageDelete(ctx, mallory, "secret"); err != nil {
|
||||||
|
t.Fatalf("StorageDelete: %v", err)
|
||||||
|
}
|
||||||
|
if got, err := reg.StorageGet(ctx, alice, "secret"); err != nil || string(got) != "alice-value" {
|
||||||
|
t.Fatalf("alice's key was deleted by mallory: got %q err %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStorageRejectsOversizedKeyAndValue(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
mem := openPluginTestDB(t)
|
||||||
|
reg, err := NewRegistry(Config{Store: mem})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
id, err := mem.InstallPlugin(ctx, "bloat", "0.1", "{}")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
inst := &Instance{ID: id, Manifest: &Manifest{Name: "bloat", Permissions: []string{string(CapStorage)}}}
|
||||||
|
|
||||||
|
if err := reg.StoragePut(ctx, inst, "", []byte("v")); err == nil {
|
||||||
|
t.Fatal("empty key must be rejected")
|
||||||
|
}
|
||||||
|
if err := reg.StoragePut(ctx, inst, strings.Repeat("k", maxPluginKeyBytes+1), []byte("v")); err == nil {
|
||||||
|
t.Fatal("oversized key must be rejected")
|
||||||
|
}
|
||||||
|
if err := reg.StoragePut(ctx, inst, "k", make([]byte, maxPluginValueBytes+1)); err == nil {
|
||||||
|
t.Fatal("oversized value must be rejected")
|
||||||
|
}
|
||||||
|
if err := reg.StoragePut(ctx, inst, strings.Repeat("k", maxPluginKeyBytes), make([]byte, maxPluginValueBytes)); err != nil {
|
||||||
|
t.Fatalf("at-limit key/value must be accepted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func TestEventDeliveryHasNoGuestPath(t *testing.T) {
|
||||||
|
sink := NewEventSink()
|
||||||
|
noCap := &Instance{ID: 1, Manifest: &Manifest{Name: "nocap"}}
|
||||||
|
if err := sink.Subscribe("message_send", noCap); !errors.Is(err, ErrCapabilityNotGranted) {
|
||||||
|
t.Fatalf("Subscribe without the events capability = %v, want ErrCapabilityNotGranted", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sub := &Instance{ID: 2, Manifest: &Manifest{Name: "sub", Permissions: []string{string(CapEvents)}}}
|
||||||
|
if err := sink.Subscribe("message_send", sub); err != nil {
|
||||||
|
t.Fatalf("Subscribe: %v", err)
|
||||||
|
}
|
||||||
|
// inst.module is nil here, so a real guest call would panic or error.
|
||||||
|
sink.Dispatch(context.Background(), "message_send", []byte(`{}`))
|
||||||
|
|
||||||
|
sink.UnsubscribeAll(sub)
|
||||||
|
sink.mu.Lock()
|
||||||
|
remaining := len(sink.subs)
|
||||||
|
sink.mu.Unlock()
|
||||||
|
if remaining != 0 {
|
||||||
|
t.Fatalf("UnsubscribeAll left %d topics behind", remaining)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEmptyAllowlistDeniesEveryHost locks the standing mitigation for finding
|
||||||
|
// #5 (HTTP exfiltration to allowlisted hosts): the shipped default
|
||||||
|
// `plugins.http_allowlist` is empty (config.DefaultConfig), and an empty
|
||||||
|
// allowlist must deny every destination rather than fall open.
|
||||||
|
func TestEmptyAllowlistDeniesEveryHost(t *testing.T) {
|
||||||
|
for _, allowlist := range [][]string{nil, {}, {"", " "}} {
|
||||||
|
reg := newTestRegistry(allowlist)
|
||||||
|
for _, host := range []string{"example.com", "api.github.com", "attacker.test", "localhost"} {
|
||||||
|
if reg.hostAllowed(host) {
|
||||||
|
t.Fatalf("allowlist %v must not permit %q", allowlist, host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,3 +14,9 @@ var ErrPluginNotFound = errors.New("plugin not found")
|
|||||||
// ErrCapabilityNotGranted is returned when a host API call would require a
|
// ErrCapabilityNotGranted is returned when a host API call would require a
|
||||||
// capability the plugin's manifest did not declare.
|
// capability the plugin's manifest did not declare.
|
||||||
var ErrCapabilityNotGranted = errors.New("plugin capability not granted")
|
var ErrCapabilityNotGranted = errors.New("plugin capability not granted")
|
||||||
|
|
||||||
|
// ErrCommandNotDeclared is returned when a plugin tries to bind a slash
|
||||||
|
// command its manifest did not list in `commands`. The manifest — not the
|
||||||
|
// guest module — is the authority on which commands a plugin may own, so an
|
||||||
|
// admin can see the full command surface before enabling the plugin.
|
||||||
|
var ErrCommandNotDeclared = errors.New("plugin command not declared in manifest")
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ Phase C Step 9 — proof-of-life plugin used by `Server/plugin/plugin_test.go`.
|
|||||||
The manifest is the only file the default (no-`-tags wazero`) build needs —
|
The manifest is the only file the default (no-`-tags wazero`) build needs —
|
||||||
the registry persists it into the plugins table without executing the .wasm.
|
the registry persists it into the plugins table without executing the .wasm.
|
||||||
|
|
||||||
|
The `commands` block is the per-command ACL: activation binds only the names
|
||||||
|
listed there, so `list_commands` returning anything else is ignored. Keep
|
||||||
|
`plugin.json`'s list and `listCommandsJSON` in `main.go` in sync — a name in
|
||||||
|
the WASM but not the manifest simply never binds.
|
||||||
|
|
||||||
## Building the WASM
|
## Building the WASM
|
||||||
|
|
||||||
`main.go` in this directory implements the full plugin ABI
|
`main.go` in this directory implements the full plugin ABI
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
"description": "Trivial proof-of-life plugin: registers /hello and echoes message_send events.",
|
"description": "Trivial proof-of-life plugin: registers /hello and echoes message_send events.",
|
||||||
"entrypoint": "hello.wasm",
|
"entrypoint": "hello.wasm",
|
||||||
"permissions": ["commands", "events", "storage"],
|
"permissions": ["commands", "events", "storage"],
|
||||||
|
"commands": [
|
||||||
|
{ "name": "hello" }
|
||||||
|
],
|
||||||
"resources": {
|
"resources": {
|
||||||
"max_memory_mb": 16,
|
"max_memory_mb": 16,
|
||||||
"cpu_budget_ms": 50
|
"cpu_budget_ms": 50
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ func (r *Registry) RegisterCommand(cmd string, inst *Instance) error {
|
|||||||
if !inst.Manifest.HasCapability(CapCommands) {
|
if !inst.Manifest.HasCapability(CapCommands) {
|
||||||
return ErrCapabilityNotGranted
|
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()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
// Ownership is compared by plugin identity (manifest name — unique per
|
// Ownership is compared by plugin identity (manifest name — unique per
|
||||||
|
|||||||
@@ -87,8 +87,22 @@ func (s *EventSink) UnsubscribeAll(inst *Instance) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dispatch invokes every subscriber's on_event for topic. The default build
|
// Dispatch invokes every subscriber's on_event for topic.
|
||||||
// is a no-op; the wazero-tagged build calls into the WASM module.
|
//
|
||||||
|
// 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:
|
||||||
|
//
|
||||||
|
// - 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.
|
||||||
|
//
|
||||||
|
// Until then this stays inert on purpose.
|
||||||
func (s *EventSink) Dispatch(ctx context.Context, topic string, payload []byte) {
|
func (s *EventSink) Dispatch(ctx context.Context, topic string, payload []byte) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
subs := append([]*Instance(nil), s.subs[topic]...)
|
subs := append([]*Instance(nil), s.subs[topic]...)
|
||||||
|
|||||||
@@ -11,7 +11,14 @@ import (
|
|||||||
"fmt"
|
"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 (
|
const (
|
||||||
|
maxPluginKeyBytes = 256 // 256 B per key
|
||||||
maxPluginValueBytes = 64 * 1024 // 64 KB per value
|
maxPluginValueBytes = 64 * 1024 // 64 KB per value
|
||||||
maxPluginScanLimit = 1000 // hard cap on PluginKVScan results
|
maxPluginScanLimit = 1000 // hard cap on PluginKVScan results
|
||||||
)
|
)
|
||||||
@@ -21,6 +28,12 @@ func (r *Registry) StoragePut(ctx context.Context, inst *Instance, key string, v
|
|||||||
if !inst.Manifest.HasCapability(CapStorage) {
|
if !inst.Manifest.HasCapability(CapStorage) {
|
||||||
return ErrCapabilityNotGranted
|
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 {
|
if len(value) > maxPluginValueBytes {
|
||||||
return fmt.Errorf("plugin storage: value exceeds %d bytes", maxPluginValueBytes)
|
return fmt.Errorf("plugin storage: value exceeds %d bytes", maxPluginValueBytes)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,18 +36,39 @@ import (
|
|||||||
// with a letter or digit.
|
// with a letter or digit.
|
||||||
var pluginNameRegexp = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`)
|
var pluginNameRegexp = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`)
|
||||||
|
|
||||||
|
// pluginCommandRegexp restricts declared slash-command names to the same
|
||||||
|
// lowercase charset the dispatcher normalizes to (RegisterCommand lowercases
|
||||||
|
// and strips a leading "/"), so a declaration always compares equal to the
|
||||||
|
// name a client can actually invoke.
|
||||||
|
var pluginCommandRegexp = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,31}$`)
|
||||||
|
|
||||||
|
// maxManifestCommands caps how many commands one plugin may claim.
|
||||||
|
const maxManifestCommands = 64
|
||||||
|
|
||||||
// Manifest is the parsed plugin metadata declared in plugin.json (or
|
// Manifest is the parsed plugin metadata declared in plugin.json (or
|
||||||
// plugin.toml in the wazero-tagged build). The on-disk schema is intentionally
|
// plugin.toml in the wazero-tagged build). The on-disk schema is intentionally
|
||||||
// flat so the default JSON parser handles it without a TOML dependency.
|
// flat so the default JSON parser handles it without a TOML dependency.
|
||||||
type Manifest struct {
|
type Manifest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Author string `json:"author"`
|
Author string `json:"author"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Entrypoint string `json:"entrypoint"` // relative .wasm path
|
Entrypoint string `json:"entrypoint"` // relative .wasm path
|
||||||
Permissions []string `json:"permissions"`
|
Permissions []string `json:"permissions"`
|
||||||
Resources Resources `json:"resources"`
|
Commands []CommandSpec `json:"commands"`
|
||||||
UI UISpec `json:"ui"`
|
Resources Resources `json:"resources"`
|
||||||
|
UI UISpec `json:"ui"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommandSpec is one slash command the plugin declares. The declaration is
|
||||||
|
// the per-command ACL: activation only binds names listed here, so a guest
|
||||||
|
// module cannot claim commands its manifest never advertised.
|
||||||
|
//
|
||||||
|
// Only Name is enforced today. docs/plans/slash-commands.md extends this
|
||||||
|
// object with description/options/permission fields later; unknown JSON keys
|
||||||
|
// are ignored, so manifests written against that richer schema still parse.
|
||||||
|
type CommandSpec struct {
|
||||||
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resources caps the plugin's runtime budget. Zero means "use the runtime
|
// Resources caps the plugin's runtime budget. Zero means "use the runtime
|
||||||
@@ -130,6 +151,9 @@ func (m *Manifest) Validate() error {
|
|||||||
return fmt.Errorf("plugin manifest: unknown permission %q", p)
|
return fmt.Errorf("plugin manifest: unknown permission %q", p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := m.validateCommands(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if m.Resources.MaxMemoryMB < 0 || m.Resources.CPUBudgetMs < 0 {
|
if m.Resources.MaxMemoryMB < 0 || m.Resources.CPUBudgetMs < 0 {
|
||||||
return fmt.Errorf("plugin manifest: resources must be non-negative")
|
return fmt.Errorf("plugin manifest: resources must be non-negative")
|
||||||
}
|
}
|
||||||
@@ -180,6 +204,43 @@ func validateRelativePath(p string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validateCommands enforces the per-command ACL schema: declaring commands
|
||||||
|
// requires the `commands` capability, names must be canonical (the form the
|
||||||
|
// dispatcher normalizes to), and the list is bounded and duplicate-free.
|
||||||
|
func (m *Manifest) validateCommands() error {
|
||||||
|
if len(m.Commands) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !m.HasCapability(CapCommands) {
|
||||||
|
return fmt.Errorf("plugin manifest: commands declared without the %q permission", CapCommands)
|
||||||
|
}
|
||||||
|
if len(m.Commands) > maxManifestCommands {
|
||||||
|
return fmt.Errorf("plugin manifest: too many commands (%d, max %d)", len(m.Commands), maxManifestCommands)
|
||||||
|
}
|
||||||
|
seen := make(map[string]bool, len(m.Commands))
|
||||||
|
for i, c := range m.Commands {
|
||||||
|
if !pluginCommandRegexp.MatchString(c.Name) {
|
||||||
|
return fmt.Errorf("plugin manifest: commands[%d].name %q must match %s", i, c.Name, pluginCommandRegexp.String())
|
||||||
|
}
|
||||||
|
if seen[c.Name] {
|
||||||
|
return fmt.Errorf("plugin manifest: duplicate command %q", c.Name)
|
||||||
|
}
|
||||||
|
seen[c.Name] = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeclaresCommand reports whether the manifest listed cmd in its `commands`
|
||||||
|
// block. cmd is expected in normalized form (lowercase, no leading "/").
|
||||||
|
func (m *Manifest) DeclaresCommand(cmd string) bool {
|
||||||
|
for _, c := range m.Commands {
|
||||||
|
if c.Name == cmd {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// HasCapability reports whether the manifest declared cap.
|
// HasCapability reports whether the manifest declared cap.
|
||||||
func (m *Manifest) HasCapability(cap Capability) bool {
|
func (m *Manifest) HasCapability(cap Capability) bool {
|
||||||
for _, p := range m.Permissions {
|
for _, p := range m.Permissions {
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ func TestReinstallRebindsCommands(t *testing.T) {
|
|||||||
t.Cleanup(func() { _ = reg.Close(context.Background()) })
|
t.Cleanup(func() { _ = reg.Close(context.Background()) })
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
manifest, err := ParseManifest([]byte(`{"name":"upgrader","version":"0.1.0","entrypoint":"p.wasm","permissions":["commands"]}`))
|
manifest, err := ParseManifest([]byte(`{"name":"upgrader","version":"0.1.0","entrypoint":"p.wasm","permissions":["commands"],"commands":[{"name":"greet"}]}`))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ParseManifest: %v", err)
|
t.Fatalf("ParseManifest: %v", err)
|
||||||
}
|
}
|
||||||
@@ -173,7 +173,7 @@ func TestReinstallRebindsCommands(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// In-place upgrade: same plugin name, fresh instance.
|
// In-place upgrade: same plugin name, fresh instance.
|
||||||
manifest2, err := ParseManifest([]byte(`{"name":"upgrader","version":"0.2.0","entrypoint":"p.wasm","permissions":["commands"]}`))
|
manifest2, err := ParseManifest([]byte(`{"name":"upgrader","version":"0.2.0","entrypoint":"p.wasm","permissions":["commands"],"commands":[{"name":"greet"}]}`))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ParseManifest v2: %v", err)
|
t.Fatalf("ParseManifest v2: %v", err)
|
||||||
}
|
}
|
||||||
@@ -197,7 +197,7 @@ func TestReinstallRebindsCommands(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A different plugin still cannot hijack an owned command.
|
// A different plugin still cannot hijack an owned command.
|
||||||
other, err := ParseManifest([]byte(`{"name":"other","version":"0.1.0","entrypoint":"o.wasm","permissions":["commands"]}`))
|
other, err := ParseManifest([]byte(`{"name":"other","version":"0.1.0","entrypoint":"o.wasm","permissions":["commands"],"commands":[{"name":"greet"}]}`))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ParseManifest other: %v", err)
|
t.Fatalf("ParseManifest other: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,7 +131,10 @@ func (r *Registry) activateWithRuntime(ctx context.Context, platform any, inst *
|
|||||||
// the cross-plugin command-hijack hole. RegisterCommand acquires r.mu
|
// the cross-plugin command-hijack hole. RegisterCommand acquires r.mu
|
||||||
// itself, so it is called outside the lock below to avoid re-entrant
|
// itself, so it is called outside the lock below to avoid re-entrant
|
||||||
// locking. The plugin must also have declared the `commands` capability in
|
// locking. The plugin must also have declared the `commands` capability in
|
||||||
// its manifest, otherwise no binding happens.
|
// its manifest, otherwise no binding happens — and RegisterCommand refuses
|
||||||
|
// any name the manifest's `commands` block did not declare, so a guest
|
||||||
|
// module cannot widen its own command surface by returning extra names
|
||||||
|
// from list_commands.
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
if inst.module != nil {
|
if inst.module != nil {
|
||||||
// Lost a concurrent activation race (e.g. two dispatches both saw a
|
// Lost a concurrent activation race (e.g. two dispatches both saw a
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ func TestWazeroRegistryCreatesRuntime(t *testing.T) {
|
|||||||
|
|
||||||
func TestWazeroActivateCompilesModule(t *testing.T) {
|
func TestWazeroActivateCompilesModule(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
manifest := `{"name":"hello","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"]}`
|
manifest := `{"name":"hello","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"],"commands":[{"name":"hello"}]}`
|
||||||
writeTestPlugin(t, dir, "hello", manifest, addWASM)
|
writeTestPlugin(t, dir, "hello", manifest, addWASM)
|
||||||
|
|
||||||
reg, mem := newWazeroTestRegistry(t, dir)
|
reg, mem := newWazeroTestRegistry(t, dir)
|
||||||
@@ -116,7 +116,7 @@ func TestWazeroActivateCompilesModule(t *testing.T) {
|
|||||||
|
|
||||||
func TestWazeroDispatchCommandMissingExport(t *testing.T) {
|
func TestWazeroDispatchCommandMissingExport(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
manifest := `{"name":"hello","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"]}`
|
manifest := `{"name":"hello","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"],"commands":[{"name":"hello"}]}`
|
||||||
writeTestPlugin(t, dir, "hello", manifest, addWASM)
|
writeTestPlugin(t, dir, "hello", manifest, addWASM)
|
||||||
|
|
||||||
reg, mem := newWazeroTestRegistry(t, dir)
|
reg, mem := newWazeroTestRegistry(t, dir)
|
||||||
@@ -147,7 +147,7 @@ func TestWazeroDispatchCommandMissingExport(t *testing.T) {
|
|||||||
|
|
||||||
func TestWazeroCloseTearsDownRuntime(t *testing.T) {
|
func TestWazeroCloseTearsDownRuntime(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
manifest := `{"name":"hello","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"]}`
|
manifest := `{"name":"hello","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"],"commands":[{"name":"hello"}]}`
|
||||||
writeTestPlugin(t, dir, "hello", manifest, addWASM)
|
writeTestPlugin(t, dir, "hello", manifest, addWASM)
|
||||||
|
|
||||||
reg, mem := newWazeroTestRegistry(t, dir)
|
reg, mem := newWazeroTestRegistry(t, dir)
|
||||||
@@ -174,7 +174,7 @@ func TestWazeroCloseTearsDownRuntime(t *testing.T) {
|
|||||||
|
|
||||||
func TestWazeroDisablePluginFreesModule(t *testing.T) {
|
func TestWazeroDisablePluginFreesModule(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
manifest := `{"name":"hello","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"]}`
|
manifest := `{"name":"hello","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"],"commands":[{"name":"hello"}]}`
|
||||||
writeTestPlugin(t, dir, "hello", manifest, addWASM)
|
writeTestPlugin(t, dir, "hello", manifest, addWASM)
|
||||||
|
|
||||||
reg, mem := newWazeroTestRegistry(t, dir)
|
reg, mem := newWazeroTestRegistry(t, dir)
|
||||||
@@ -270,7 +270,7 @@ var spinWASM = []byte{
|
|||||||
// admin disable/enable cycle or server restart.
|
// admin disable/enable cycle or server restart.
|
||||||
func TestWazeroCPUBudgetOverrunDoesNotBrickPlugin(t *testing.T) {
|
func TestWazeroCPUBudgetOverrunDoesNotBrickPlugin(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
manifest := `{"name":"spinner","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"]}`
|
manifest := `{"name":"spinner","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"],"commands":[{"name":"spin"}]}`
|
||||||
writeTestPlugin(t, dir, "spinner", manifest, spinWASM)
|
writeTestPlugin(t, dir, "spinner", manifest, spinWASM)
|
||||||
|
|
||||||
reg, mem := newWazeroTestRegistry(t, dir)
|
reg, mem := newWazeroTestRegistry(t, dir)
|
||||||
|
|||||||
Reference in New Issue
Block a user