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:
Andy Dixon
2026-06-29 20:28:05 +01:00
parent 99a89ba847
commit b35dbe0299
29 changed files with 2318 additions and 23 deletions
+100
View File
@@ -0,0 +1,100 @@
package cognition
import (
"context"
"encoding/json"
"fmt"
"regexp"
"strings"
"github.com/andydixon/noema/internal/attention"
"github.com/andydixon/noema/internal/brain"
"github.com/andydixon/noema/internal/capabilities"
"github.com/andydixon/noema/internal/conversation"
"github.com/andydixon/noema/internal/workspace"
"github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/pkg/ids"
)
// Act proposes capability invocations from requests the mind receives. In
// this phase the only deterministic mapping is "fetch <url>" → http.fetch;
// integrations register more. Proposing is free; executing goes through the
// policy layer, so a request never bypasses ALLOW / ASK / DENY.
type Act struct{}
// Name implements brain.Process.
func (Act) Name() string { return "act" }
var urlRe = regexp.MustCompile(`https?://[^\s<>"']+`)
// Observe implements brain.Process.
func (Act) Observe(_ context.Context, s *brain.State) ([]brain.Candidate, error) {
if s.Services.Capabilities == nil {
return nil, nil
}
var out []brain.Candidate
for _, ev := range s.NewEvents {
if ev.Type != events.Observation || ev.Metadata["conversation_id"] == nil {
continue
}
var p events.ObservationPayload
if json.Unmarshal(ev.Payload, &p) != nil {
continue
}
a := conversation.Analyse(p.Text)
if a.Intent != "request" {
continue
}
lower := strings.ToLower(p.Text)
if u := urlRe.FindString(p.Text); u != "" && (strings.Contains(lower, "fetch") || strings.Contains(lower, "read") || strings.Contains(lower, "look at") || strings.Contains(lower, "check")) {
u = strings.TrimRight(u, ".,)")
out = append(out, brain.Candidate{Kind: "action", Subject: "http.fetch:" + u, Summary: "proposed action: fetch " + u,
Content: map[string]any{"capability": "http.fetch", "target": u, "params": map[string]any{"url": u}, "reason": "asked by " + p.Actor + ": " + truncate(p.Text, 120), "source_event": string(ev.ID), "conversation_id": ev.Metadata["conversation_id"]},
Features: attention.Features{Salience: 0.7, Urgency: 0.6, Consequence: 0.7, Confidence: 0.9, OperatorPriority: prioFor(p.Trust), ContextRelevance: 0.5},
Confidence: 0.9, SourceEvents: []ids.ID{ev.ID}, Evidence: []events.EvidenceRef{{Kind: "event", ID: string(ev.ID), Note: "request"}}})
}
}
return out, nil
}
func prioFor(t events.Trust) float64 {
if t == events.TrustOperator {
return 1
}
return 0.7
}
// action invokes the capability layer for an admitted action thought and
// records the outcome in working memory. The layer decides; this handler
// never executes anything itself.
func (h Handlers) action(ctx context.Context, c *brain.Cycle, t workspace.Thought) error {
svc := c.State.Services.Capabilities
if svc == nil {
return nil
}
capName, _ := t.Content["capability"].(string)
target, _ := t.Content["target"].(string)
reason, _ := t.Content["reason"].(string)
params, _ := t.Content["params"].(map[string]any)
var ev []capabilities.EvidenceRef
for _, e := range t.Evidence {
ev = append(ev, capabilities.EvidenceRef{Kind: e.Kind, ID: e.ID, Note: e.Note})
}
if c.State.Mode == brain.ModeDreaming {
ctx = capabilities.WithForbidden(ctx, "dreaming")
}
out, err := svc.Invoke(ctx, capabilities.Invocation{MindID: c.State.Mind.ID, Capability: capName, Target: target, Params: params, Reason: reason, Evidence: ev, Correlation: c.State.CycleID})
if err != nil {
c.Emit(events.New{Type: events.Metacognition, Source: "brain.act", Summary: "action refused before policy: " + err.Error(), Salience: 0.4, Confidence: 1, CausationID: t.EventID, Metadata: map[string]any{"check": "action_invalid", "capability": capName}})
return nil
}
content := fmt.Sprintf("%s on %s: %s", capName, target, out.Status)
if out.Status == "pending" {
content += " (awaiting operator approval)"
}
if out.Status == "denied" {
content += " (" + out.Decision.Reason + ")"
}
c.Runtime.Hold(workspace.Item{Key: "action:" + capName + ":" + target, Content: content, Kind: "note", SourceEvent: out.EventID, Ref: string(out.ApprovalID)})
return nil
}
+1
View File
@@ -38,6 +38,7 @@ func (h Handlers) Register(r *brain.Registry) {
r.Handle("goal", h.goal)
r.Handle("question", h.question)
r.Handle("metacognition", h.metacognition)
r.Handle("action", h.action)
}
// emitter adapts the cycle to the beliefs store's Emitter.
+15 -3
View File
@@ -200,12 +200,24 @@ func (h Handlers) buildIntent(ctx context.Context, c *brain.Cycle, a conversatio
}
case "request":
in.Kind = "decline"
caps := c.State.SelfModel.Capabilities
if len(caps) == 0 {
var usable []string
for _, cap := range c.State.SelfModel.Capabilities {
if cap.Policy == "allow" || cap.Policy == "ask" {
usable = append(usable, cap.Name+" ("+cap.Policy+")")
}
}
if len(usable) == 0 {
in.Summary = "Explain honestly that no external capabilities are granted, so the request cannot be carried out; offer what is known instead."
in.Points = append(in.Points, "I have no capabilities granted to act on external systems.")
} else {
in.Summary = "Explain which capabilities exist and that actions require policy approval."
in.Kind = "acknowledge"
in.Summary = "Say which capabilities could apply and that any action goes through policy; if one was proposed this cycle its status is in working memory."
in.Points = append(in.Points, "Capabilities I may use: "+strings.Join(usable, ", ")+".")
for _, it := range c.Runtime.Workspace().WorkingMemory {
if strings.HasPrefix(it.Key, "action:") {
in.Points = append(in.Points, "Action status: "+it.Content)
}
}
}
h.addKnowledge(ctx, c, &in, topic)
case "introspection":
+71
View File
@@ -0,0 +1,71 @@
package cognition
import (
"context"
"fmt"
"log/slog"
"github.com/andydixon/noema/internal/capabilities"
evstore "github.com/andydixon/noema/internal/events"
"github.com/andydixon/noema/internal/minds"
"github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/pkg/ids"
)
// SelfModelSync keeps each mind's self-model capability list equal to the
// effective policy, so the mind cannot claim tools it does not have.
type SelfModelSync struct {
Minds *minds.Store
Caps *capabilities.Service
Events *evstore.Store
Log *slog.Logger
}
// Sync updates one mind (or all when mind is Nil) and emits
// self_model_updated only when something changed.
func (s SelfModelSync) Sync(ctx context.Context, mind ids.ID) {
var list []minds.Mind
if mind.IsZero() {
list, _ = s.Minds.List(ctx, false)
} else if m, err := s.Minds.Get(ctx, mind); err == nil {
list = []minds.Mind{m}
}
for _, m := range list {
inv, err := s.Caps.Inventory(ctx, m.ID)
if err != nil {
continue
}
sm, err := s.Minds.GetSelfModel(ctx, m.ID)
if err != nil {
continue
}
var caps []minds.CapabilityEntry
for _, i := range inv {
eff := string(i.Effect)
if i.Capability.Locked {
eff = "unavailable"
}
caps = append(caps, minds.CapabilityEntry{Name: i.Capability.Name, Description: i.Capability.Description, Policy: eff})
}
if fmt.Sprint(caps) == fmt.Sprint(sm.Capabilities) {
continue
}
sm.Capabilities = caps
sm.Refresh(m.Personality)
if err := s.Minds.SaveSelfModel(ctx, m.ID, sm); err != nil {
s.Log.Warn("self-model save failed", "mind", m.Slug, "err", err)
continue
}
allow, ask := 0, 0
for _, c := range caps {
switch c.Policy {
case "allow":
allow++
case "ask":
ask++
}
}
s.Events.Append(ctx, events.New{MindID: m.ID, Type: events.SelfModelUpdated, Source: "capabilities", Summary: fmt.Sprintf("self-model: %d capabilities allowed, %d need approval, rest denied", allow, ask), //nolint:errcheck
Salience: 0.3, Confidence: 1, Metadata: map[string]any{"allow": allow, "ask": ask}, Payload: map[string]any{"capabilities": caps}})
}
}