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
+156
View File
@@ -0,0 +1,156 @@
package main
import (
"encoding/json"
"fmt"
"os"
"strings"
"text/tabwriter"
)
func init() {
extraCommands = append(extraCommands,
command{"approvals", "list|approve <id> [note]|session <id>|deny <id> [note] – approval centre", cmdApprovals},
command{"policy", "list|add -mind m -capability c -target t -effect e [-reason r]|delete <id> – capability policies", cmdPolicy},
command{"invoke", "<mind> <capability> [target] [json-params] – invoke a capability through policy", cmdInvoke},
)
}
func cmdApprovals(args []string) int {
if len(args) < 1 {
fmt.Fprintln(os.Stderr, "usage: noemactl approvals list | approve <id> [note] | session <id> [note] | deny <id> [note]")
return exitUsage
}
switch args[0] {
case "list":
var out struct{ Items []map[string]any }
if code, err := apiRequest("GET", "/api/v1/approvals?status=pending", nil, &out); err != nil {
return exitFor(code, err)
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "RISK\tCAPABILITY\tTARGET\tREASON\tID")
for _, a := range out.Items {
fmt.Fprintf(tw, "%v\t%v\t%v\t%v\t%v\n", a["risk"], a["capability"], a["target"], a["reason"], a["id"])
}
tw.Flush()
return exitOK
case "approve", "session", "deny":
if len(args) < 2 {
return exitUsage
}
note := strings.Join(args[2:], " ")
path := "/api/v1/approvals/" + args[1] + "/approve"
body := map[string]any{"note": note, "for_session": args[0] == "session"}
if args[0] == "deny" {
path = "/api/v1/approvals/" + args[1] + "/deny"
body = map[string]any{"note": note}
}
var res map[string]any
if code, err := apiRequest("POST", path, body, &res); err != nil {
return exitFor(code, err)
}
if st, ok := res["status"]; ok {
fmt.Println("result:", st)
if r, ok := res["result"].(map[string]any); ok {
fmt.Println(r["summary"])
}
} else {
fmt.Println("recorded")
}
return exitOK
}
return exitUsage
}
func cmdPolicy(args []string) int {
if len(args) < 1 {
fmt.Fprintln(os.Stderr, "usage: noemactl policy list | add -mind m -capability c -target t -effect allow|ask|deny [-reason r] | delete <id>")
return exitUsage
}
switch args[0] {
case "list":
var out struct{ Items []map[string]any }
if code, err := apiRequest("GET", "/api/v1/policies", nil, &out); err != nil {
return exitFor(code, err)
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "SCOPE\tCAPABILITY\tTARGET\tEFFECT\tREASON\tID")
for _, p := range out.Items {
scope := "global"
if m, _ := p["mind_id"].(string); m != "" {
scope = m[:8]
}
fmt.Fprintf(tw, "%v\t%v\t%v\t%v\t%v\t%v\n", scope, p["capability"], p["target_pattern"], p["effect"], p["reason"], p["id"])
}
tw.Flush()
return exitOK
case "add":
body := map[string]any{"target_pattern": "*"}
for i := 1; i+1 < len(args); i += 2 {
switch args[i] {
case "-mind":
var m map[string]any
if code, err := apiRequest("GET", "/api/v1/minds/"+args[i+1], nil, &m); err != nil {
return exitFor(code, err)
}
body["mind_id"] = m["id"]
case "-capability":
body["capability"] = args[i+1]
case "-target":
body["target_pattern"] = args[i+1]
case "-effect":
body["effect"] = args[i+1]
case "-reason":
body["reason"] = args[i+1]
}
}
var p map[string]any
if code, err := apiRequest("POST", "/api/v1/policies", body, &p); err != nil {
return exitFor(code, err)
}
fmt.Println("policy", p["id"], "added")
return exitOK
case "delete":
if len(args) < 2 {
return exitUsage
}
if code, err := apiRequest("DELETE", "/api/v1/policies/"+args[1], nil, nil); err != nil {
return exitFor(code, err)
}
fmt.Println("deleted")
return exitOK
}
return exitUsage
}
func cmdInvoke(args []string) int {
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "usage: noemactl invoke <mind> <capability> [target] [json-params]")
return exitUsage
}
body := map[string]any{"capability": args[1]}
if len(args) > 2 {
body["target"] = args[2]
}
if len(args) > 3 {
var params map[string]any
if err := json.Unmarshal([]byte(args[3]), &params); err != nil {
fmt.Fprintln(os.Stderr, "params must be a JSON object")
return exitUsage
}
body["params"] = params
}
var out map[string]any
if code, err := apiRequest("POST", "/api/v1/minds/"+args[0]+"/invoke", body, &out); err != nil {
return exitFor(code, err)
}
d, _ := out["decision"].(map[string]any)
fmt.Printf("%v (%v: %v)\n", out["status"], d["effect"], d["reason"])
if out["approval_id"] != nil && out["approval_id"] != "" {
fmt.Println("approval:", out["approval_id"])
}
if r, ok := out["result"].(map[string]any); ok {
fmt.Println(r["summary"])
}
return exitOK
}
+12 -2
View File
@@ -19,6 +19,7 @@ import (
"github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/beliefs"
"github.com/andydixon/noema/internal/brain"
"github.com/andydixon/noema/internal/capabilities"
"github.com/andydixon/noema/internal/cognition"
"github.com/andydixon/noema/internal/config"
"github.com/andydixon/noema/internal/consolidation"
@@ -132,17 +133,26 @@ func run() error {
deps.Predictions = predictions.NewStore(db)
deps.Resolver = &predictions.Resolver{Store: deps.Predictions, Events: deps.Events, Beliefs: deps.Beliefs, Log: log, Metrics: metrics}
deps.Graph = graph.NewStore(db)
capReg := capabilities.NewRegistry()
for _, c := range capabilities.Builtins() {
capReg.Add(c)
}
deps.Capabilities = &capabilities.Service{Registry: capReg, DB: db, Events: deps.Events, Log: log, Metrics: metrics, ApprovalTTL: 24 * time.Hour, SessionTTL: 8 * time.Hour}
selfSync := cognition.SelfModelSync{Minds: deps.Minds, Caps: deps.Capabilities, Events: deps.Events, Log: log}
deps.Capabilities.OnSelfModel = selfSync.Sync
deps.Narrative = narrative.NewStore(db)
deps.Notifications = notifications.NewStore(db)
deps.Consolidation = &consolidation.Service{DB: db, Memory: deps.Memory, Beliefs: deps.Beliefs, Events: deps.Events, LLM: deps.LLM, Prompts: deps.Prompts, Minds: deps.Minds, Log: log}
deps.Reflection = &reflection.Service{DB: db, Events: deps.Events, Beliefs: deps.Beliefs, Goals: deps.Goals, Memory: deps.Memory, Predictions: deps.Predictions, LLM: deps.LLM, Prompts: deps.Prompts, Log: log}
registry := brain.NewRegistry().AddProcess(cognition.Attend{}).AddProcess(cognition.Retrieve{}).AddProcess(cognition.Associate{}).AddProcess(cognition.Perceive{}).
AddProcess(cognition.GoalMonitor{}).AddProcess(cognition.Curiosity{}).AddProcess(cognition.Metacognition{}).
AddProcess(cognition.GoalMonitor{}).AddProcess(cognition.Curiosity{}).AddProcess(cognition.Metacognition{}).AddProcess(cognition.Act{}).
AddHook(cognition.MotivationHook{Store: deps.Motivation}).AddHook(cognition.NarrativeHook{Store: deps.Narrative}).AddHook(cognition.NotifyHook{Store: deps.Notifications})
cognition.Handlers{Memory: deps.Memory, Beliefs: deps.Beliefs, Conversations: deps.Conversations, Graph: deps.Graph}.Register(registry)
deps.Brain = brain.NewManager(brain.Config{DB: db, Events: deps.Events, Minds: deps.Minds, Registry: registry, Pool: scheduler.NewPool(cfg.MaxWorkers),
Log: log, Metrics: metrics, Services: brain.Services{Events: deps.Events, Memory: deps.Memory, Beliefs: deps.Beliefs, LLM: deps.LLM, Prompts: deps.Prompts,
Goals: deps.Goals, Motivation: deps.Motivation, Predictions: deps.Predictions}}, group)
Goals: deps.Goals, Motivation: deps.Motivation, Predictions: deps.Predictions, Capabilities: deps.Capabilities}}, group)
selfSync.Sync(ctx, ids.Nil)
group.Every("approvals.expire", 5*time.Minute, func(ctx context.Context) error { _, err := deps.Capabilities.ExpirePending(ctx); return err })
group.Every("predictions.resolve", time.Minute, deps.Resolver.Run)
group.Every("cognition.background", 5*time.Minute, func(ctx context.Context) error { return backgroundCognition(ctx, deps) })
group.Every("memory.decay", 6*time.Hour, func(ctx context.Context) error { return decayMemories(ctx, deps) })