security: add the capability and policy layer as the only path to action
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.
This commit is contained in:
@@ -0,0 +1,346 @@
|
||||
// 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/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"`
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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 | 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:"-"`
|
||||
}
|
||||
|
||||
// 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 }
|
||||
|
||||
// NewRegistry creates an empty registry.
|
||||
func NewRegistry() *Registry { return &Registry{caps: map[string]Capability{}} }
|
||||
|
||||
// Add registers a capability.
|
||||
func (r *Registry) Add(c Capability) *Registry {
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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"`
|
||||
Specificity int `json:"specificity"`
|
||||
Reason string `json:"reason"`
|
||||
Locked bool `json:"locked"`
|
||||
Considered int `json:"considered"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user