Files
Andy Dixon 937c5fb2ff feat(capabilities): metadata, frozen registry, scoped grants and requests
- Capabilities declare access (read/write, unknown fails closed as
  write), side effects, idempotency, timeout, cost estimate and the trust
  of their output (web content is low trust). A capability may derive its
  policy target from its parameters; a disagreeing target is refused, and
  approvals whose modified parameters change the target are refused.
- The registry is frozen at start-up; registration afterwards panics.
- Invocation.RequireApproval turns ALLOW into ASK for commitments; it can
  only tighten, and scoped grants never satisfy it.
- Scoped grants (migration 0025): capability, target and parameter globs,
  access, expiry of at most seven days and an optional operation count
  consumed atomically; applied only where policy says ASK. A sweep marks
  lapsed grants and refreshes the self-model, which now lists live grants.
- Capability requests carry scope, duration, operation cap, reason,
  evidence, risk, mitigation, fallback, alternatives and explicit decision
  history; they batch per intention, are capped per mind (operator
  attention budget) and can be decided only by a named person. Component
  names are refused as deciders, for approvals too, and as usernames.
- API routes, OpenAPI entries, scheduler sweeps and approval-centre
  sections for requests and temporary grants.
2026-09-06 22:29:03 +01:00

208 lines
8.1 KiB
Go

package capabilities
import (
"context"
"errors"
"sync"
"testing"
"time"
"github.com/andydixon/noema/pkg/ids"
)
func intp(n int) *int { return &n }
func TestRegistryFreezes(t *testing.T) {
r := NewRegistry()
r.Add(Capability{Name: "a"})
r.Freeze()
defer func() {
if recover() == nil {
t.Fatal("adding to a frozen registry must panic")
}
}()
r.Add(Capability{Name: "shell"})
}
func TestUnknownAccessIsWrite(t *testing.T) {
r := NewRegistry().Add(Capability{Name: "x"})
if c, _ := r.Get("x"); c.Access != AccessWrite || c.Timeout != 60*time.Second {
t.Fatalf("defaults must fail closed: %+v", c)
}
}
func TestRequireApprovalOnlyTightens(t *testing.T) {
s, m, _ := setup(t)
ctx := context.Background()
// noema.note is ALLOW by default; a commitment-driven invocation must still ask.
out, err := s.Invoke(ctx, Invocation{MindID: m.ID, Capability: "noema.note", Params: map[string]any{"text": "x"}, RequireApproval: true, Origin: "plan_step:t"})
if err != nil || out.Status != "pending" || !out.Decision.Tightened {
t.Fatalf("require approval did not tighten: %v %+v", err, out)
}
// It never loosens: DENY stays DENY and locked stays locked.
if _, err := s.SetPolicy(ctx, Policy{MindID: m.ID, Capability: "test.echo", Effect: Deny}); err != nil {
t.Fatal(err)
}
out, _ = s.Invoke(ctx, Invocation{MindID: m.ID, Capability: "test.echo", Params: map[string]any{"text": "x"}, RequireApproval: true})
if out.Status != "denied" {
t.Fatalf("deny loosened: %+v", out)
}
// A scoped grant does not satisfy a per-request approval requirement.
if _, err := s.Grant(ctx, GrantInput{MindID: m.ID, Capability: "http.fetch", Duration: time.Hour, By: "andy"}); err != nil {
t.Fatal(err)
}
out, _ = s.Invoke(ctx, Invocation{MindID: m.ID, Capability: "http.fetch", Target: "https://example.org", Params: map[string]any{"url": "https://example.org"}, RequireApproval: true})
if out.Status != "pending" {
t.Fatalf("grant bypassed a required approval: %+v", out)
}
}
func TestScopedGrantScopeCountAndExpiry(t *testing.T) {
s, m, _ := setup(t)
ctx := context.Background()
now := time.Now()
s.Now = func() time.Time { return now }
// Read-only grants cannot cover a writing capability.
if _, err := s.Grant(ctx, GrantInput{MindID: m.ID, Capability: "send_email", Access: AccessRead, Duration: time.Hour, By: "andy"}); !errors.Is(err, ErrInvalid) {
t.Fatalf("read grant on a write capability accepted: %v", err)
}
if _, err := s.Grant(ctx, GrantInput{MindID: m.ID, Capability: "sudo_shell", Duration: time.Hour, By: "andy"}); !errors.Is(err, ErrInvalid) {
t.Fatal("locked capability granted")
}
if _, err := s.Grant(ctx, GrantInput{MindID: m.ID, Capability: "test.echo", Duration: time.Hour, By: "planning"}); !errors.Is(err, ErrReservedDecider) {
t.Fatal("a component granted authority")
}
g, err := s.Grant(ctx, GrantInput{MindID: m.ID, Capability: "test.echo", TargetPattern: "api-*", ParamConstraints: map[string]string{"text": "ping*"}, Duration: 30 * time.Minute, MaxOperations: intp(2), By: "andy"})
if err != nil {
t.Fatal(err)
}
try := func(target, text string) string {
out, err := s.Invoke(ctx, Invocation{MindID: m.ID, Capability: "test.echo", Target: target, Params: map[string]any{"text": text}})
if err != nil {
t.Fatal(err)
}
return out.Status
}
if st := try("db-1", "ping"); st != "pending" {
t.Fatalf("target restriction ignored: %s", st)
}
if st := try("api-1", "rm"); st != "pending" {
t.Fatalf("parameter restriction ignored: %s", st)
}
if st := try("api-1", "ping 1"); st != "executed" {
t.Fatalf("grant not applied: %s", st)
}
if st := try("api-2", "ping 2"); st != "executed" {
t.Fatalf("second operation refused: %s", st)
}
if st := try("api-3", "ping 3"); st != "pending" {
t.Fatalf("operation cap ignored: %s", st)
}
// Used up: the sweep refreshes the self-model once.
var refreshed []ids.ID
s.OnSelfModel = func(_ context.Context, mind ids.ID) { refreshed = append(refreshed, mind) }
if got, _ := s.SweepGrants(ctx); len(got) != 1 || got[0] != m.ID {
t.Fatalf("sweep: %v", got)
}
if got, _ := s.SweepGrants(ctx); len(got) != 0 {
t.Fatal("sweep notified twice")
}
// Expiry.
g2, _ := s.Grant(ctx, GrantInput{MindID: m.ID, Capability: "test.echo", Duration: time.Minute, By: "andy"})
if st := try("any", "x"); st != "executed" {
t.Fatalf("second grant: %s", st)
}
now = now.Add(2 * time.Minute)
if st := try("any", "x"); st != "pending" {
t.Fatalf("expired grant used: %s", st)
}
if got, _ := s.SweepGrants(ctx); len(got) != 1 {
t.Fatalf("expiry not swept: %v", got)
}
live, _ := s.ScopedGrants(ctx, m.ID, true)
if len(live) != 0 || g.ID == g2.ID {
t.Fatalf("live grants: %+v", live)
}
}
func TestGrantOperationsAreNotOverspent(t *testing.T) {
s, m, _ := setup(t)
ctx := context.Background()
if _, err := s.Grant(ctx, GrantInput{MindID: m.ID, Capability: "test.echo", Duration: time.Hour, MaxOperations: intp(1), By: "andy"}); err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
var mu sync.Mutex
executed := 0
for i := 0; i < 6; i++ {
wg.Add(1)
go func() {
defer wg.Done()
out, _ := s.Invoke(ctx, Invocation{MindID: m.ID, Capability: "test.echo", Params: map[string]any{"text": "x"}})
if out.Status == "executed" {
mu.Lock()
executed++
mu.Unlock()
}
}()
}
wg.Wait()
if executed != 1 {
t.Fatalf("one-operation grant executed %d times", executed)
}
}
func TestCapabilityRequestLifecycle(t *testing.T) {
s, m, _ := setup(t)
ctx := context.Background()
s.MaxPendingRequests = 2
base := CapabilityRequest{MindID: m.ID, Capability: "test.echo", TargetPattern: "api-*", DurationSeconds: 1800, MaxOperations: intp(20),
Reason: "metrics cannot distinguish deployment failure from downstream failure", Risk: "logs may contain personal identifiers", Mitigation: "PII redaction", Fallback: "ask the operator to query manually",
Evidence: []EvidenceRef{{Kind: "event", ID: string(ids.New()), Note: "deployment at 07:12"}}, IntentionID: ids.New()}
if _, err := s.RequestCapability(ctx, CapabilityRequest{MindID: m.ID, Capability: "sudo_shell", DurationSeconds: 60, Reason: "x"}); !errors.Is(err, ErrNotRequestable) {
t.Fatalf("locked capability requestable: %v", err)
}
if _, err := s.RequestCapability(ctx, CapabilityRequest{MindID: m.ID, Capability: "invented.tool", DurationSeconds: 60, Reason: "x"}); !errors.Is(err, ErrUnknown) {
t.Fatal("unknown capability requestable")
}
if _, err := s.RequestCapability(WithForbidden(ctx, "dreaming"), base); !errors.Is(err, ErrForbiddenContext) {
t.Fatal("dream requested authority")
}
r1, err := s.RequestCapability(ctx, base)
if err != nil || r1.Status != "pending" || r1.BatchKey != "intention:"+string(base.IntentionID) {
t.Fatalf("request: %v %+v", err, r1)
}
r2, _ := s.RequestCapability(ctx, base)
if r2.BatchKey != r1.BatchKey {
t.Fatal("requests from one intention should batch")
}
if _, err := s.RequestCapability(ctx, base); !errors.Is(err, ErrAttentionBudget) {
t.Fatalf("attention budget not enforced: %v", err)
}
// A component cannot decide; an operator can narrow.
if _, err := s.DecideCapabilityRequest(ctx, r1.ID, "mind:"+string(m.ID), true, "", Narrowing{}); !errors.Is(err, ErrReservedDecider) {
t.Fatal("mind approved its own request")
}
got, err := s.DecideCapabilityRequest(ctx, r1.ID, "andy", true, "ok for the incident", Narrowing{DurationSeconds: 600, MaxOperations: intp(5)})
if err != nil || got.Status != "granted" || got.GrantID.IsZero() {
t.Fatalf("grant: %v %+v", err, got)
}
gs, _ := s.ScopedGrants(ctx, m.ID, true)
if len(gs) != 1 || *gs[0].MaxOperations != 5 || time.Until(gs[0].ExpiresAt) > 11*time.Minute || gs[0].RequestID != r1.ID {
t.Fatalf("grant not narrowed: %+v", gs)
}
if _, err := s.DecideCapabilityRequest(ctx, r1.ID, "andy", true, "", Narrowing{}); !errors.Is(err, ErrInvalid) {
t.Fatal("request granted twice")
}
if _, err := s.DecideCapabilityRequest(ctx, r2.ID, "andy", false, "no", Narrowing{}); err != nil {
t.Fatal(err)
}
r3, _ := s.RequestCapability(ctx, base)
if r3.History["granted"] != 1 || r3.History["denied"] != 1 {
t.Fatalf("explicit history: %+v", r3.History)
}
if err := s.WithdrawCapabilityRequest(ctx, m.ID, r3.ID, "no longer needed"); err != nil {
t.Fatal(err)
}
}