Volition and planning now run as part of cognition (PLAN §8.2 item 3):
- A volition hook derives wants after each cycle from what it perceived
and produced, satisfies answer wants when a reply is sent, raises due,
overdue, expired and conflicting commitments, and expires faded wants.
Events produced by earlier cycles are not re-derived.
- An intend process proposes deciding about the strongest undecided
wants; the candidate competes for the workspace, so attention gates
what the mind decides to pursue. The handler makes value tensions
explicit (caution against disruptive writes, privacy against reading
personal data), resolves them with recorded scores, runs deterministic
formation with commitments, policy preview, goals and values, records
the decision whatever it is, and plans a formed intention in the same
transaction. Operator requests carry their own weight in formation.
- Explicit requests in conversation ("restart <unit>", "logs for <unit>",
"fetch <url>") become wants with proposed means instead of direct
invocations; the reply says the request was recorded and that acting
depends on commitments and policy.
- The executor is woken after commits, runs in a scheduler-owned loop and
reacts to the event bus: a new commitment tightens or abandons affected
intentions (withdrawing their pending approvals), a weakened assumption
or a finished goal triggers reconsideration. A tightened plan may reuse
its strategy; a failed one may not.
- Metacognition gains volition checks: strong wants repeatedly set aside,
intentions with no recorded reason, intentions a commitment forbids,
commitments that cannot be kept, churn, repeated failed execution,
cost outweighing value, and intentions that outlived their goal.
- A plan model role and prompt let a model propose decompositions that
the planner validates. Ablations are a fork-only configuration field.
- Approvals required by an intention are described as coming from a
commitment or value constraint. The expectation after an approved
action is linked to the execution event, not the request.
- The wants.values column of the unreleased migration 0026 is renamed
value_keys: VALUES is reserved and the generic snapshot restore does
not quote identifiers.
- The hostile-output security test now uses a message that asks for no
action, because an operator's "status of <unit>" is legitimately a
read-only status check.
427 lines
13 KiB
Go
427 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 or a value constraint 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
|
||
}
|