Phase 12 (ADR-0005). Capabilities are registered in code with parameter schemas, risk classes and default effects; sudo_shell and delete_database are locked to DENY and cannot be relaxed. Policies are scoped globally or per mind with capability, target glob, action kind and optional hour window; evaluation is deterministic, most specific wins, and ties go to the stricter effect. Invoke is the single entry point: it refuses dreaming and replay contexts, validates parameters (unknown keys, non http URLs and path traversal rejected), evaluates policy and session grants, hashes the exact request and records a policy_decision event. ALLOW executes with timeout and panic isolation and turns textual output into a tool-trust observation; ASK creates an approval request bound to the hash with an alert notification; DENY is counted and recorded. Operators approve once, approve for a time-boxed session grant, deny with a note, or modify parameters (re-validated, re-hashed, original kept in the append-only decision log). The self-model's capability list is recomputed from effective policy on start and on every policy change. The act process maps fetch requests to http.fetch and the action handler invokes through the layer; replies report capabilities and outcomes. Policy matrix and approval centre pages, dashboard count, API, CLI (approvals, policy, invoke) and docs/capabilities.md.
180 lines
8.1 KiB
Go
180 lines
8.1 KiB
Go
package capabilities
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"testing"
|
|
"time"
|
|
|
|
evstore "github.com/andydixon/noema/internal/events"
|
|
"github.com/andydixon/noema/internal/minds"
|
|
"github.com/andydixon/noema/internal/storage"
|
|
"github.com/andydixon/noema/migrations"
|
|
"github.com/andydixon/noema/pkg/events"
|
|
"github.com/andydixon/noema/pkg/ids"
|
|
)
|
|
|
|
func TestEvaluateSpecificityAndLocks(t *testing.T) {
|
|
mind, other := ids.New(), ids.New()
|
|
c := Capability{Name: "restart_service", DefaultEffect: Ask}
|
|
pols := []Policy{
|
|
{ID: ids.New(), Capability: "*", TargetPattern: "*", ActionKind: "*", Effect: Deny},
|
|
{ID: ids.New(), Capability: "restart_service", TargetPattern: "*", ActionKind: "*", Effect: Ask},
|
|
{ID: ids.New(), MindID: mind, Capability: "restart_service", TargetPattern: "web-*", ActionKind: "*", Effect: Allow},
|
|
{ID: ids.New(), MindID: other, Capability: "restart_service", TargetPattern: "*", ActionKind: "*", Effect: Allow},
|
|
}
|
|
now := time.Now()
|
|
if d := Evaluate(c, pols, mind, "web-01", "*", now); d.Effect != Allow {
|
|
t.Fatalf("most specific mind rule should allow: %+v", d)
|
|
}
|
|
if d := Evaluate(c, pols, mind, "db-01", "*", now); d.Effect != Ask {
|
|
t.Fatalf("capability rule should ask: %+v", d)
|
|
}
|
|
if d := Evaluate(Capability{Name: "send_email", DefaultEffect: Ask}, pols, mind, "x", "*", now); d.Effect != Deny {
|
|
t.Fatalf("global wildcard deny should apply: %+v", d)
|
|
}
|
|
// Tie: two rules with equal specificity, stricter wins.
|
|
tie := []Policy{{ID: ids.New(), Capability: "restart_service", Effect: Allow, TargetPattern: "*", ActionKind: "*"}, {ID: ids.New(), Capability: "restart_service", Effect: Deny, TargetPattern: "*", ActionKind: "*"}}
|
|
if d := Evaluate(c, tie, mind, "x", "*", now); d.Effect != Deny {
|
|
t.Fatalf("tie should deny: %+v", d)
|
|
}
|
|
if d := Evaluate(Capability{Name: "sudo_shell", Locked: true}, []Policy{{Capability: "sudo_shell", Effect: Allow, TargetPattern: "*", ActionKind: "*"}}, mind, "x", "*", now); d.Effect != Deny || !d.Locked {
|
|
t.Fatal("locked capability must deny regardless of policy")
|
|
}
|
|
// Time window.
|
|
from, to := 9, 17
|
|
win := []Policy{{ID: ids.New(), Capability: "restart_service", TargetPattern: "*", ActionKind: "*", Effect: Allow, HoursFrom: &from, HoursTo: &to}}
|
|
at := time.Date(2026, 1, 1, 12, 0, 0, 0, time.Local)
|
|
off := time.Date(2026, 1, 1, 22, 0, 0, 0, time.Local)
|
|
if Evaluate(c, win, mind, "x", "*", at).Effect != Allow || Evaluate(c, win, mind, "x", "*", off).Effect != Ask {
|
|
t.Fatal("time window wrong")
|
|
}
|
|
}
|
|
|
|
func TestValidateParamsAndHash(t *testing.T) {
|
|
c := Capability{Name: "f", Params: map[string]Param{"url": {Type: "url", Required: true}, "n": {Type: "int"}, "p": {Type: "path"}}}
|
|
if _, err := c.ValidateParams(map[string]any{"url": "ftp://x"}); !errors.Is(err, ErrInvalid) {
|
|
t.Fatal("non-http url accepted")
|
|
}
|
|
if _, err := c.ValidateParams(map[string]any{"url": "https://x", "extra": 1}); !errors.Is(err, ErrInvalid) {
|
|
t.Fatal("unknown param accepted")
|
|
}
|
|
if _, err := c.ValidateParams(map[string]any{"url": "https://x", "p": "../etc/passwd"}); !errors.Is(err, ErrInvalid) {
|
|
t.Fatal("traversal accepted")
|
|
}
|
|
p, err := c.ValidateParams(map[string]any{"url": "https://x", "n": float64(3)})
|
|
if err != nil || p["n"] != 3 {
|
|
t.Fatalf("validate: %v %v", err, p)
|
|
}
|
|
m := ids.New()
|
|
if Hash(m, "f", "t", p) != Hash(m, "f", "t", map[string]any{"url": "https://x", "n": 3}) || Hash(m, "f", "t", p) == Hash(m, "f", "t2", p) {
|
|
t.Fatal("hash not stable/discriminating")
|
|
}
|
|
}
|
|
|
|
func setup(t *testing.T) (*Service, minds.Mind, *evstore.Store) {
|
|
t.Helper()
|
|
pool := storage.TestPool(t, migrations.FS)
|
|
ctx := context.Background()
|
|
m, _ := minds.NewStore(pool).Create(ctx, minds.CreateParams{Slug: "cap"})
|
|
es := evstore.NewStore(pool, evstore.NewBus(8), nil)
|
|
reg := NewRegistry()
|
|
for _, c := range Builtins() {
|
|
reg.Add(c)
|
|
}
|
|
calls := 0
|
|
reg.Add(Capability{Name: "test.echo", Description: "echo", Risk: RiskLow, TargetKind: "none", Params: map[string]Param{"text": {Type: "string", Required: true}}, DefaultEffect: Ask,
|
|
Executor: func(_ context.Context, inv Invocation) (Result, error) {
|
|
calls++
|
|
return Result{Summary: "echoed", Output: inv.Params["text"].(string)}, nil
|
|
}})
|
|
reg.Add(Capability{Name: "test.boom", Risk: RiskLow, DefaultEffect: Allow, Executor: func(context.Context, Invocation) (Result, error) { panic("bad tool") }})
|
|
return &Service{Registry: reg, DB: pool, Events: es, Log: slog.New(slog.DiscardHandler)}, m, es
|
|
}
|
|
|
|
func TestInvokePaths(t *testing.T) {
|
|
s, m, es := setup(t)
|
|
ctx := context.Background()
|
|
// Default ALLOW executes and its output becomes a tool-trust observation.
|
|
out, err := s.Invoke(ctx, Invocation{MindID: m.ID, Capability: "noema.note", Params: map[string]any{"text": "hello"}, Reason: "test"})
|
|
if err != nil || out.Status != "executed" || out.Result == nil {
|
|
t.Fatalf("allow path: %v %+v", err, out)
|
|
}
|
|
// Default ASK creates an approval and executes only after approval.
|
|
out, err = s.Invoke(ctx, Invocation{MindID: m.ID, Capability: "test.echo", Target: "t1", Params: map[string]any{"text": "ping"}, Reason: "need it"})
|
|
if err != nil || out.Status != "pending" || out.ApprovalID.IsZero() {
|
|
t.Fatalf("ask path: %v %+v", err, out)
|
|
}
|
|
pend, _ := s.Approvals(ctx, m.ID, "pending", 10)
|
|
if len(pend) != 1 || pend[0].Risk != RiskLow {
|
|
t.Fatalf("pending: %+v", pend)
|
|
}
|
|
res, err := s.Approve(ctx, out.ApprovalID, "andy", "ok", true, nil)
|
|
if err != nil || res.Status != "executed" || res.Result.Output != "ping" {
|
|
t.Fatalf("approve: %v %+v", err, res)
|
|
}
|
|
obs, _ := es.Query(ctx, evstore.Filter{MindID: m.ID, Types: []events.Type{events.Observation}})
|
|
if len(obs) == 0 || fmt.Sprint(obs[0].Metadata["trust"]) != string(events.TrustTool) {
|
|
t.Fatalf("tool output should be a tool-trust observation: %+v", obs)
|
|
}
|
|
// Session grant now allows the same target without asking; a different target still asks.
|
|
out, _ = s.Invoke(ctx, Invocation{MindID: m.ID, Capability: "test.echo", Target: "t1", Params: map[string]any{"text": "again"}})
|
|
if out.Status != "executed" {
|
|
t.Fatalf("session grant not applied: %+v", out)
|
|
}
|
|
out, _ = s.Invoke(ctx, Invocation{MindID: m.ID, Capability: "test.echo", Target: "t2", Params: map[string]any{"text": "x"}})
|
|
if out.Status != "pending" {
|
|
t.Fatalf("other target should still ask: %+v", out)
|
|
}
|
|
// Deny with note; double decision refused.
|
|
if _, err := s.Deny(ctx, out.ApprovalID, "andy", "not now"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := s.Approve(ctx, out.ApprovalID, "andy", "", false, nil); !errors.Is(err, ErrInvalid) {
|
|
t.Fatal("denied request approved")
|
|
}
|
|
// Policy DENY, locked capability, and forbidden context.
|
|
if _, err := s.SetPolicy(ctx, Policy{MindID: m.ID, Capability: "test.echo", Effect: Deny, Reason: "no echo here"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out, _ = s.Invoke(ctx, Invocation{MindID: m.ID, Capability: "test.echo", Target: "t1", Params: map[string]any{"text": "y"}})
|
|
if out.Status != "denied" {
|
|
t.Fatalf("policy deny not applied: %+v", out)
|
|
}
|
|
if _, err := s.SetPolicy(ctx, Policy{Capability: "sudo_shell", Effect: Allow}); !errors.Is(err, ErrInvalid) {
|
|
t.Fatal("locked capability relaxed")
|
|
}
|
|
out, _ = s.Invoke(ctx, Invocation{MindID: m.ID, Capability: "sudo_shell", Params: map[string]any{"command": "rm -rf /"}})
|
|
if out.Status != "denied" || !out.Decision.Locked {
|
|
t.Fatalf("locked not denied: %+v", out)
|
|
}
|
|
if _, err := s.Invoke(WithForbidden(ctx, "dreaming"), Invocation{MindID: m.ID, Capability: "noema.note", Params: map[string]any{"text": "x"}}); !errors.Is(err, ErrForbiddenContext) {
|
|
t.Fatal("dreaming context allowed an action")
|
|
}
|
|
// A panicking executor is a failed execution, not a crash.
|
|
out, err = s.Invoke(ctx, Invocation{MindID: m.ID, Capability: "test.boom"})
|
|
if err != nil || out.Status != "failed" {
|
|
t.Fatalf("panic handling: %v %+v", err, out)
|
|
}
|
|
// Decisions are append-only.
|
|
if _, err := s.DB.Exec(ctx, `DELETE FROM approval_decisions`); err == nil {
|
|
t.Fatal("decisions must be append-only")
|
|
}
|
|
inv, _ := s.Inventory(ctx, m.ID)
|
|
var sawLocked bool
|
|
for _, i := range inv {
|
|
if i.Capability.Name == "sudo_shell" && i.Effect == Deny {
|
|
sawLocked = true
|
|
}
|
|
}
|
|
if !sawLocked {
|
|
t.Fatal("inventory missing locked deny")
|
|
}
|
|
execs, _ := s.Executions(ctx, m.ID, 10)
|
|
if len(execs) < 3 {
|
|
t.Fatalf("executions not recorded: %d", len(execs))
|
|
}
|
|
}
|