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
+85 -6
View File
@@ -17,6 +17,7 @@ import (
"strings"
"time"
"github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/pkg/ids"
)
@@ -52,6 +53,24 @@ type Param struct {
MaxLen int `json:"max_len,omitempty"`
}
// Access says whether a capability changes anything outside Noema.
type Access string
// Access classes. Unknown access is treated as write (fail closed).
const (
AccessRead Access = "read" // observes; no external state changes
AccessWrite Access = "write" // changes external state
)
// Cost is a capability's expected resource use per invocation, used for plan
// estimates. Zero values mean "negligible", not "unknown".
type Cost struct {
WallSeconds float64 `json:"wall_seconds,omitempty"`
Network int `json:"network_operations,omitempty"`
Money float64 `json:"money,omitempty"`
Tokens int `json:"llm_tokens,omitempty"`
}
// Invocation is a validated request to run a capability.
type Invocation struct {
MindID ids.ID
@@ -62,6 +81,12 @@ type Invocation struct {
Evidence []EvidenceRef
Correlation ids.ID
ApprovalID ids.ID
// RequireApproval turns an ALLOW decision into ASK. Cognition sets it
// when a commitment demands approval; it can only tighten a decision.
RequireApproval bool
// Origin names the cognitive object that asked (for example
// "plan_step:<id>"), recorded with the decision for provenance.
Origin string
}
// EvidenceRef mirrors events.EvidenceRef without importing it (keeps this
@@ -88,22 +113,52 @@ type Capability struct {
Name string `json:"name"`
Description string `json:"description"`
Risk Risk `json:"risk"`
TargetKind string `json:"target_kind"` // url | path | host | service | repository | none
TargetKind string `json:"target_kind"` // url | path | host | service | repository | domain | none
Params map[string]Param `json:"params"`
DefaultEffect Effect `json:"default_effect"` // when no policy matches
Locked bool `json:"locked"` // always DENY; cannot be relaxed
Executor Executor `json:"-"`
Access Access `json:"access"` // read | write
SideEffects string `json:"side_effects"` // what changes in the world when it runs
Idempotent bool `json:"idempotent"` // running it twice has the same effect as once
Timeout time.Duration `json:"timeout"` // executor deadline (default 60s)
Estimate Cost `json:"estimate"`
// TargetFrom derives the policy target from validated parameters (the
// unit a restart acts on, the host of a URL), so a policy or grant on a
// target always matches what the executor will touch. A caller-supplied
// target that disagrees is refused.
TargetFrom func(params map[string]any) (string, error) `json:"-"`
// OutputTrust is the trust given to the observation made from the
// output (default tool). Content fetched from the web is low trust.
OutputTrust events.Trust `json:"output_trust"`
Executor Executor `json:"-"`
}
// Registry holds capabilities. It is built in code; nothing at runtime can
// add one, so a mind cannot invent tools.
type Registry struct{ caps map[string]Capability }
// Registry holds capabilities. It is built in code at start-up and frozen
// before any mind runs; nothing at runtime can add one, so a mind cannot
// invent tools.
type Registry struct {
caps map[string]Capability
frozen bool
}
// NewRegistry creates an empty registry.
func NewRegistry() *Registry { return &Registry{caps: map[string]Capability{}} }
// Add registers a capability.
// Add registers a capability. Adding to a frozen registry is a programming
// error and panics: capabilities are code, never runtime data.
func (r *Registry) Add(c Capability) *Registry {
if r.frozen {
panic("capabilities: registry is frozen; " + c.Name + " cannot be added at runtime")
}
if c.Access != AccessRead {
c.Access = AccessWrite
}
if c.Timeout <= 0 {
c.Timeout = 60 * time.Second
}
if !c.OutputTrust.Valid() {
c.OutputTrust = events.TrustTool
}
if c.Params == nil {
c.Params = map[string]Param{}
}
@@ -117,6 +172,12 @@ func (r *Registry) Add(c Capability) *Registry {
return r
}
// Freeze stops further registration.
func (r *Registry) Freeze() { r.frozen = true }
// Frozen reports whether the registry accepts no more capabilities.
func (r *Registry) Frozen() bool { return r.frozen }
// Get returns a capability.
func (r *Registry) Get(name string) (Capability, bool) {
c, ok := r.caps[name]
@@ -195,6 +256,22 @@ func (c Capability) ValidateParams(params map[string]any) (map[string]any, error
return out, nil
}
// ResolveTarget applies TargetFrom: it returns the target the request acts
// on, or an error when the caller named a different one.
func (c Capability) ResolveTarget(target string, params map[string]any) (string, error) {
if c.TargetFrom == nil {
return target, nil
}
derived, err := c.TargetFrom(params)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrInvalid, err)
}
if target != "" && !strings.EqualFold(target, derived) {
return "", fmt.Errorf("%w: target %q does not match the parameters (%q)", ErrInvalid, target, derived)
}
return derived, nil
}
// Hash binds an approval to the exact request.
func Hash(mind ids.ID, capability, target string, params map[string]any) string {
raw, _ := json.Marshal(struct {
@@ -268,10 +345,12 @@ func (p Policy) matches(mind ids.ID, capability, target, action string, now time
type Decision struct {
Effect Effect `json:"effect"`
PolicyID ids.ID `json:"policy_id,omitempty"`
GrantID ids.ID `json:"grant_id,omitempty"` // scoped grant that turned ASK into ALLOW
Specificity int `json:"specificity"`
Reason string `json:"reason"`
Locked bool `json:"locked"`
Considered int `json:"considered"`
Tightened bool `json:"tightened,omitempty"` // ALLOW became ASK because the invocation required approval
}
func rank(e Effect) int {