- 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.
426 lines
13 KiB
Go
426 lines
13 KiB
Go
// Package capabilities is the only path from cognition to the outside world
|
||
// (ADR-0005, spec §29–30). A capability is registered code with a parameter
|
||
// schema and a risk class; a policy engine decides ALLOW / ASK / DENY per
|
||
// mind, capability, target and time; ASK creates an approval request bound
|
||
// to the exact request; every step is an event.
|
||
package capabilities
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"path"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/andydixon/noema/pkg/events"
|
||
"github.com/andydixon/noema/pkg/ids"
|
||
)
|
||
|
||
// Risk classifies the worst plausible consequence of a capability.
|
||
type Risk string
|
||
|
||
// Risk classes.
|
||
const (
|
||
RiskLow Risk = "low" // read-only, bounded
|
||
RiskMedium Risk = "medium" // writes to a sandboxed or reversible place
|
||
RiskHigh Risk = "high" // visible external side effects
|
||
RiskCritical Risk = "critical" // irreversible or security-relevant
|
||
)
|
||
|
||
// Effect is a policy outcome.
|
||
type Effect string
|
||
|
||
// Effects.
|
||
const (
|
||
Allow Effect = "allow"
|
||
Ask Effect = "ask"
|
||
Deny Effect = "deny"
|
||
)
|
||
|
||
// Valid reports whether e is known.
|
||
func (e Effect) Valid() bool { return e == Allow || e == Ask || e == Deny }
|
||
|
||
// Param describes one parameter.
|
||
type Param struct {
|
||
Type string `json:"type"` // string | int | bool | url | path
|
||
Required bool `json:"required"`
|
||
Description string `json:"description"`
|
||
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
|
||
Capability string
|
||
Target string
|
||
Params map[string]any
|
||
Reason string
|
||
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
|
||
// package free of the event store so executors can be tested alone).
|
||
type EvidenceRef struct {
|
||
Kind string `json:"kind"`
|
||
ID string `json:"id"`
|
||
Note string `json:"note,omitempty"`
|
||
}
|
||
|
||
// Result is what an executor returns. Output is untrusted data: it becomes an
|
||
// observation with tool trust, never an instruction.
|
||
type Result struct {
|
||
Summary string `json:"summary"`
|
||
Output string `json:"output,omitempty"`
|
||
Data map[string]any `json:"data,omitempty"`
|
||
}
|
||
|
||
// Executor runs a capability.
|
||
type Executor func(ctx context.Context, inv Invocation) (Result, error)
|
||
|
||
// Capability is a registered tool.
|
||
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 | 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
|
||
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 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. 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{}
|
||
}
|
||
if !c.DefaultEffect.Valid() {
|
||
c.DefaultEffect = Ask
|
||
}
|
||
if c.Locked {
|
||
c.DefaultEffect = Deny
|
||
}
|
||
r.caps[c.Name] = c
|
||
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]
|
||
return c, ok
|
||
}
|
||
|
||
// List returns capabilities sorted by name.
|
||
func (r *Registry) List() []Capability {
|
||
out := make([]Capability, 0, len(r.caps))
|
||
for _, c := range r.caps {
|
||
out = append(out, c)
|
||
}
|
||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||
return out
|
||
}
|
||
|
||
// ErrInvalid marks bad input; ErrForbiddenContext marks dreaming/replay contexts.
|
||
var (
|
||
ErrInvalid = errors.New("capabilities: invalid")
|
||
ErrForbiddenContext = errors.New("capabilities: actions are not permitted in this context")
|
||
ErrUnknown = errors.New("capabilities: unknown capability")
|
||
)
|
||
|
||
// ValidateParams checks and normalises params against the schema. Unknown
|
||
// keys are rejected so a model cannot smuggle extra arguments.
|
||
func (c Capability) ValidateParams(params map[string]any) (map[string]any, error) {
|
||
out := map[string]any{}
|
||
for k := range params {
|
||
if _, ok := c.Params[k]; !ok {
|
||
return nil, fmt.Errorf("%w: unknown parameter %q", ErrInvalid, k)
|
||
}
|
||
}
|
||
for name, p := range c.Params {
|
||
v, present := params[name]
|
||
if !present {
|
||
if p.Required {
|
||
return nil, fmt.Errorf("%w: missing parameter %q", ErrInvalid, name)
|
||
}
|
||
continue
|
||
}
|
||
switch p.Type {
|
||
case "string", "url", "path":
|
||
s, ok := v.(string)
|
||
if !ok {
|
||
return nil, fmt.Errorf("%w: %q must be a string", ErrInvalid, name)
|
||
}
|
||
if p.MaxLen > 0 && len(s) > p.MaxLen {
|
||
return nil, fmt.Errorf("%w: %q too long", ErrInvalid, name)
|
||
}
|
||
if p.Type == "url" && !(strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")) {
|
||
return nil, fmt.Errorf("%w: %q must be an http(s) URL", ErrInvalid, name)
|
||
}
|
||
if p.Type == "path" && (strings.Contains(s, "..") || strings.ContainsRune(s, 0)) {
|
||
return nil, fmt.Errorf("%w: %q contains an unsafe path", ErrInvalid, name)
|
||
}
|
||
out[name] = s
|
||
case "int":
|
||
switch n := v.(type) {
|
||
case float64:
|
||
out[name] = int(n)
|
||
case int:
|
||
out[name] = n
|
||
default:
|
||
return nil, fmt.Errorf("%w: %q must be an integer", ErrInvalid, name)
|
||
}
|
||
case "bool":
|
||
b, ok := v.(bool)
|
||
if !ok {
|
||
return nil, fmt.Errorf("%w: %q must be a boolean", ErrInvalid, name)
|
||
}
|
||
out[name] = b
|
||
default:
|
||
out[name] = v
|
||
}
|
||
}
|
||
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 {
|
||
M ids.ID
|
||
C string
|
||
T string
|
||
P map[string]any
|
||
}{mind, capability, target, params})
|
||
sum := sha256.Sum256(raw)
|
||
return hex.EncodeToString(sum[:])[:32]
|
||
}
|
||
|
||
// ---- policy engine ----
|
||
|
||
// Policy is one rule.
|
||
type Policy struct {
|
||
ID ids.ID `json:"id"`
|
||
MindID ids.ID `json:"mind_id,omitempty"` // empty = global
|
||
Capability string `json:"capability"` // name or *
|
||
TargetPattern string `json:"target_pattern"` // glob or *
|
||
ActionKind string `json:"action_kind"` // * or a capability-specific action label
|
||
Effect Effect `json:"effect"`
|
||
HoursFrom *int `json:"hours_from,omitempty"`
|
||
HoursTo *int `json:"hours_to,omitempty"`
|
||
Reason string `json:"reason"`
|
||
CreatedBy string `json:"created_by"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
UpdatedAt time.Time `json:"updated_at"`
|
||
}
|
||
|
||
// matches reports whether the policy applies, and its specificity.
|
||
func (p Policy) matches(mind ids.ID, capability, target, action string, now time.Time) (bool, int) {
|
||
spec := 0
|
||
if !p.MindID.IsZero() {
|
||
if p.MindID != mind {
|
||
return false, 0
|
||
}
|
||
spec += 8
|
||
}
|
||
if p.Capability != "*" {
|
||
if p.Capability != capability {
|
||
return false, 0
|
||
}
|
||
spec += 4
|
||
}
|
||
if p.TargetPattern != "*" {
|
||
if ok, _ := path.Match(p.TargetPattern, target); !ok && !strings.EqualFold(p.TargetPattern, target) {
|
||
return false, 0
|
||
}
|
||
spec += 2
|
||
}
|
||
if p.ActionKind != "*" {
|
||
if p.ActionKind != action {
|
||
return false, 0
|
||
}
|
||
spec++
|
||
}
|
||
if p.HoursFrom != nil && p.HoursTo != nil {
|
||
h := now.Hour()
|
||
from, to := *p.HoursFrom, *p.HoursTo
|
||
inWindow := (from <= to && h >= from && h < to) || (from > to && (h >= from || h < to))
|
||
if !inWindow {
|
||
return false, 0
|
||
}
|
||
spec++
|
||
}
|
||
return true, spec
|
||
}
|
||
|
||
// Decision explains a policy outcome.
|
||
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 {
|
||
switch e {
|
||
case Deny:
|
||
return 3
|
||
case Ask:
|
||
return 2
|
||
}
|
||
return 1
|
||
}
|
||
|
||
// Evaluate picks the most specific matching policy; on ties the stricter
|
||
// effect wins (DENY > ASK > ALLOW). Locked capabilities always deny.
|
||
func Evaluate(c Capability, policies []Policy, mind ids.ID, target, action string, now time.Time) Decision {
|
||
if c.Locked {
|
||
return Decision{Effect: Deny, Reason: "capability is locked to DENY by design", Locked: true}
|
||
}
|
||
best := Decision{Effect: c.DefaultEffect, Reason: "no matching policy; capability default"}
|
||
bestSpec := -1
|
||
for _, p := range policies {
|
||
ok, spec := p.matches(mind, c.Name, target, action, now)
|
||
if !ok {
|
||
continue
|
||
}
|
||
best.Considered++
|
||
if spec > bestSpec || (spec == bestSpec && rank(p.Effect) > rank(best.Effect)) {
|
||
bestSpec = spec
|
||
best.Effect, best.PolicyID, best.Specificity = p.Effect, p.ID, spec
|
||
best.Reason = fmt.Sprintf("policy %s (specificity %d): %s", shortID(p.ID), spec, firstNonEmpty(p.Reason, describe(p)))
|
||
}
|
||
}
|
||
return best
|
||
}
|
||
|
||
func describe(p Policy) string {
|
||
scope := "global"
|
||
if !p.MindID.IsZero() {
|
||
scope = "mind"
|
||
}
|
||
return fmt.Sprintf("%s rule %s on %s target %s", scope, p.Effect, p.Capability, p.TargetPattern)
|
||
}
|
||
|
||
func shortID(id ids.ID) string {
|
||
if len(id) >= 8 {
|
||
return string(id[:8])
|
||
}
|
||
return string(id)
|
||
}
|
||
|
||
func firstNonEmpty(a, b string) string {
|
||
if a != "" {
|
||
return a
|
||
}
|
||
return b
|
||
}
|
||
|
||
// ---- context guards ----
|
||
|
||
type ctxKey struct{}
|
||
|
||
// WithForbidden marks a context (dreaming, replay) in which no capability may
|
||
// run. Invoke refuses unconditionally.
|
||
func WithForbidden(ctx context.Context, why string) context.Context {
|
||
return context.WithValue(ctx, ctxKey{}, why)
|
||
}
|
||
|
||
// Forbidden reports whether actions are refused in ctx.
|
||
func Forbidden(ctx context.Context) (string, bool) {
|
||
s, ok := ctx.Value(ctxKey{}).(string)
|
||
return s, ok
|
||
}
|