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.
This commit is contained in:
Andy Dixon
2026-09-06 22:29:03 +01:00
parent 286d40a2d9
commit 937c5fb2ff
23 changed files with 2094 additions and 33 deletions
+12 -7
View File
@@ -9,6 +9,7 @@ import (
"time"
"github.com/andydixon/noema/internal/security/httpclient"
"github.com/andydixon/noema/pkg/events"
)
// Builtins returns the capabilities every deployment has. Integrations add
@@ -16,28 +17,32 @@ import (
// self-model can list them as unavailable; they never execute.
func Builtins() []Capability {
return []Capability{
{Name: "noema.note", Description: "Leave a note for the operator in the mind's own history.", Risk: RiskLow, TargetKind: "none",
Params: map[string]Param{"text": {Type: "string", Required: true, Description: "note text", MaxLen: 2000}}, DefaultEffect: Allow,
{Name: "noema.note", Description: "Leave a note for the operator in the mind's own history.", Risk: RiskLow, TargetKind: "none", Access: AccessRead, Idempotent: false,
SideEffects: "adds a note to the mind's own history; nothing outside Noema changes",
Params: map[string]Param{"text": {Type: "string", Required: true, Description: "note text", MaxLen: 2000}}, DefaultEffect: Allow,
Executor: func(_ context.Context, inv Invocation) (Result, error) {
t, _ := inv.Params["text"].(string)
return Result{Summary: "note recorded", Data: map[string]any{"text": t}}, nil
}},
{Name: "http.fetch", Description: "Fetch a public HTTP(S) URL read-only (bounded, no private addresses) and return its text.", Risk: RiskMedium, TargetKind: "url",
{Name: "http.fetch", Description: "Fetch a public HTTP(S) URL read-only (bounded, no private addresses) and return its text.", Risk: RiskMedium, TargetKind: "url", Access: AccessRead, Idempotent: true, Timeout: 25 * time.Second, OutputTrust: events.TrustLow,
SideEffects: "one outbound GET request; the remote server may log it", Estimate: Cost{WallSeconds: 2, Network: 1},
Params: map[string]Param{"url": {Type: "url", Required: true, Description: "the URL to fetch", MaxLen: 2000}, "max_bytes": {Type: "int", Description: "response cap (default 64 KiB)"}}, DefaultEffect: Ask,
Executor: fetchURL},
{Name: "internet_search", Description: "Search the web (requires a configured search integration).", Risk: RiskMedium, TargetKind: "none",
{Name: "internet_search", Description: "Search the web (requires a configured search integration).", Risk: RiskMedium, TargetKind: "none", Access: AccessRead, Idempotent: true,
SideEffects: "one query to a search provider", Estimate: Cost{WallSeconds: 2, Network: 1},
Params: map[string]Param{"query": {Type: "string", Required: true, MaxLen: 500}}, DefaultEffect: Ask,
Executor: func(context.Context, Invocation) (Result, error) {
return Result{}, errors.New("no search integration configured")
}},
{Name: "send_email", Description: "Send an email (requires a mail integration).", Risk: RiskHigh, TargetKind: "address",
{Name: "send_email", Description: "Send an email (requires a mail integration).", Risk: RiskHigh, TargetKind: "address", Access: AccessWrite, Idempotent: false,
SideEffects: "delivers a message to a recipient; cannot be recalled", Estimate: Cost{WallSeconds: 2, Network: 1},
Params: map[string]Param{"to": {Type: "string", Required: true, MaxLen: 300}, "subject": {Type: "string", Required: true, MaxLen: 300}, "body": {Type: "string", Required: true, MaxLen: 20000}}, DefaultEffect: Ask,
Executor: func(context.Context, Invocation) (Result, error) {
return Result{}, errors.New("no mail integration configured")
}},
{Name: "sudo_shell", Description: "Privileged shell. Locked to DENY; exists so it can be named, never run.", Risk: RiskCritical, TargetKind: "host", Locked: true,
{Name: "sudo_shell", Description: "Privileged shell. Locked to DENY; exists so it can be named, never run.", Risk: RiskCritical, TargetKind: "host", Locked: true, Access: AccessWrite, SideEffects: "arbitrary; never runs",
Params: map[string]Param{"command": {Type: "string", Required: true}}, Executor: never},
{Name: "delete_database", Description: "Destroy data stores. Locked to DENY; exists so it can be named, never run.", Risk: RiskCritical, TargetKind: "service", Locked: true,
{Name: "delete_database", Description: "Destroy data stores. Locked to DENY; exists so it can be named, never run.", Risk: RiskCritical, TargetKind: "service", Locked: true, Access: AccessWrite, SideEffects: "irreversible data loss; never runs",
Params: map[string]Param{"name": {Type: "string", Required: true}}, Executor: never},
}
}