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
+1
View File
@@ -5,6 +5,7 @@ All notable changes to Noema are recorded here. Format follows Keep a Changelog;
## [Unreleased]
### Added
- Phase 12 capabilities: single policy-governed action path with ALLOW/ASK/DENY rules, locked capabilities, request-bound approvals (once, session, modified), tool output as tool-trust observations, self-model synchronisation, approval centre and policy matrix.
- Phase 11 relationship graph: mind-scoped entities with aliases, evidenced typed edges, links to memories/beliefs/events, deterministic extraction from conversation and observations, merge/distinct corrections, bounded canvas explorer, API and CLI.
- Phase 10 background cognition: metacognitive self-checks, scheduled reflection with lessons, episode consolidation into evidenced generalisations with operator review, internal narrative, thresholded notifications with aggregation.
- Phase 9 motivation: goal tree with attention relevance, closed drive set and rule-driven affect state with decay and weight modulation, explicit predictions with deterministic resolution, prediction errors feeding beliefs, calibration dashboard, curiosity questions becoming investigation goals.
+6 -2
View File
@@ -153,8 +153,12 @@ An administrator can: start Noema → log in → create a mind → configure per
- ☑ API: graph, entities search/detail, merge (entities_same correction), distinct (entities_different correction), update, relate; UI: canvas force explorer with filters, focus and side panel, entity page with corrections; `noemactl entity`; docs/graph.md
- ☑ Tests: kind guessing, canonicalisation, upsert dedupe, edge reinforcement, cross-mind refusal, merge with alias/link migration, bounded subgraph, stats
### Phase 12 – Capability and action policy system
- ☐ registry, policy engine, approvals, approval centre UI, policy matrix UI, dream/replay refusal, locked DENYs, tool events
### Phase 12 – Capability and action policy system ☑
- ☑ `migrations/0012_capabilities`: policies, approval_requests, append-only approval_decisions, session_grants, tool_executions
- ☑ `internal/capabilities`: code-defined registry with param schemas and risk classes, locked capabilities, deterministic policy evaluation (specificity, strict tie-break, time windows), request hashing, single `Invoke` path with forbidden-context refusal, execution isolation, tool output as tool-trust observations, approvals (once, session, deny, modified params), grants, expiry, inventory; built-ins
- ☑ Cognition: `act` process (fetch requests → http.fetch), `action` handler through the policy layer, replies report capabilities and action status, self-model synced from effective policy with self_model_updated events
- ☑ API: capabilities, policies, per-mind inventory, invoke, approvals, grants, executions; UI: policy matrix and rules, approval centre with modify/approve/session/deny, dashboard count; notification rule for requests; `noemactl approvals|policy|invoke`; docs/capabilities.md
- ☑ Tests: specificity and ties, locks, windows, parameter validation, hashing, allow/ask/deny/session/forbidden/panic paths, append-only decisions, inventory; live flow verified
### Phase 13 – Integrations
- ☐ research folder watcher (fingerprints, safe extraction, provenance), git repositories, Prometheus/VictoriaMetrics, generic webhooks (HMAC); integration health UI
+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) })
+33
View File
@@ -0,0 +1,33 @@
# Capabilities and policy
Nothing a mind reasons about can touch the world except through `capabilities.Service.Invoke` (ADR-0005). There is no other execution path, and there is no capability that edits policies, so a mind cannot escalate.
## Capabilities
A capability is registered in code with a name, description, risk class (low, medium, high, critical), target kind, a parameter schema (unknown parameters are rejected; URLs must be http(s); paths may not traverse), a default effect and an executor. Built-ins: `noema.note` (allow), `http.fetch` (ask; read-only through the SSRF-safe client), `internet_search` and `send_email` (ask; need integrations), and the locked `sudo_shell` and `delete_database`, which exist only so they can be named in policy and listed as unavailable in the self-model. Locked capabilities always deny and cannot be relaxed by any rule.
## Policy
Rules live in `policies` with scope (global or one mind), capability (or `*`), a target glob, an action kind and an optional daily hour window. Evaluation is deterministic: the most specific matching rule wins (mind 8, capability 4, target 2, action 1, window 1), and on ties the stricter effect wins (DENY > ASK > ALLOW). With no matching rule the capability's default applies. Every invocation writes a `policy_decision` event naming the rule and its specificity.
The policy page shows the effective matrix (mind × capability) for a generic target right now, the rules, and active session grants.
## Invocation
`Invoke` refuses in forbidden contexts (dreaming, replay), validates parameters, evaluates policy and session grants, hashes the exact request, and then:
- **ALLOW** executes with a timeout and panic isolation, records a `tool_executions` row, emits `tool_executed` or `tool_failed`, and turns any textual output into an **observation with tool trust** so it re-enters cognition as data, never as instructions.
- **ASK** creates an approval request (capability, target, parameters, reason, risk, evidence, expiry), emits `tool_requested` and an alert notification.
- **DENY** emits `tool_denied` and counts the denial.
## Approvals
Operators approve once, approve for the session (a time-boxed grant matching the capability and target), deny with a note, or modify parameters before approving (re-validated and re-hashed; the original hash is kept in the append-only decision record). Approvals execute immediately through the same path and the outcome is stored on the request. Requests expire; a job marks overdue ones.
## Self-model
After start-up and after any policy change, each mind's self-model capability list is recomputed from the effective policy and a `self_model_updated` event is emitted when it changed. The reply generator uses it, so a mind says which capabilities it may use and reports action status (pending, denied, executed) instead of claiming to have acted.
## Cognition
The `act` process proposes actions from requests it can map deterministically (currently "fetch <url>" → `http.fetch`); integrations add more. Proposals compete for attention like any thought; the admitted `action` handler calls `Invoke` and holds the outcome in working memory.
+6
View File
@@ -15,6 +15,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/config"
"github.com/andydixon/noema/internal/consolidation"
"github.com/andydixon/noema/internal/conversation"
@@ -51,6 +52,11 @@ func testServer(t *testing.T) (*httptest.Server, string) {
ctx := context.Background()
deps.Resolver = &predictions.Resolver{Store: deps.Predictions, Events: deps.Events, Beliefs: deps.Beliefs, Log: log}
deps.Graph = graph.NewStore(pool)
capReg := capabilities.NewRegistry()
for _, c := range capabilities.Builtins() {
capReg.Add(c)
}
deps.Capabilities = &capabilities.Service{Registry: capReg, DB: pool, Events: deps.Events, Log: log}
deps.Narrative = narrative.NewStore(pool)
deps.Notifications = notifications.NewStore(pool)
deps.Consolidation = &consolidation.Service{DB: pool, Memory: deps.Memory, Beliefs: deps.Beliefs, Events: deps.Events, Minds: deps.Minds, Log: log}
+303
View File
@@ -0,0 +1,303 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/capabilities"
"github.com/andydixon/noema/pkg/ids"
)
func (s *Server) capabilityRoutes() {
m := s.mux
m.Handle("GET /api/v1/capabilities", require(auth.RoleObserver, s.capabilitiesList))
m.Handle("GET /api/v1/policies", require(auth.RoleObserver, s.policiesList))
m.Handle("POST /api/v1/policies", require(auth.RoleAdministrator, s.policiesCreate))
m.Handle("DELETE /api/v1/policies/{id}", require(auth.RoleAdministrator, s.policyDelete))
m.Handle("GET /api/v1/minds/{mind}/capabilities", require(auth.RoleObserver, s.mindCapabilities))
m.Handle("POST /api/v1/minds/{mind}/invoke", require(auth.RoleOperator, s.invoke))
m.Handle("GET /api/v1/approvals", require(auth.RoleObserver, s.approvalsList))
m.Handle("GET /api/v1/approvals/{id}", require(auth.RoleObserver, s.approvalGet))
m.Handle("POST /api/v1/approvals/{id}/approve", require(auth.RoleOperator, s.approvalApprove))
m.Handle("POST /api/v1/approvals/{id}/deny", require(auth.RoleOperator, s.approvalDeny))
m.Handle("GET /api/v1/grants", require(auth.RoleObserver, s.grantsList))
m.Handle("DELETE /api/v1/grants/{id}", require(auth.RoleOperator, s.grantRevoke))
m.Handle("GET /api/v1/executions", require(auth.RoleObserver, s.executionsList))
}
func capErr(err error) error {
if errors.Is(err, capabilities.ErrInvalid) || errors.Is(err, capabilities.ErrUnknown) || errors.Is(err, capabilities.ErrForbiddenContext) {
return ValidationError(err.Error())
}
return err
}
func (s *Server) capabilitiesList(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, page[capabilities.Capability]{Items: s.deps.Capabilities.Registry.List()})
}
func (s *Server) policiesList(w http.ResponseWriter, r *http.Request) {
var mind ids.ID
if v := r.URL.Query().Get("mind"); v != "" {
if m, err := s.deps.Minds.Resolve(r.Context(), v); err == nil {
mind = m.ID
}
}
list, err := s.deps.Capabilities.Policies(r.Context(), mind)
if err != nil {
fail(w, r, err)
return
}
if list == nil {
list = []capabilities.Policy{}
}
writeJSON(w, http.StatusOK, page[capabilities.Policy]{Items: list})
}
// CreatePolicy validates and stores a rule; shared with the UI.
func (s *Server) CreatePolicy(r *http.Request, p capabilities.Policy) (capabilities.Policy, error) {
p.CreatedBy = actor(r)
out, err := s.deps.Capabilities.SetPolicy(r.Context(), p)
if err != nil {
return out, capErr(err)
}
s.deps.Audit.Log(r.Context(), "policy.create", "policy", out.ID.String(), map[string]any{"mind": out.MindID, "capability": out.Capability, "target": out.TargetPattern, "effect": out.Effect})
if !out.MindID.IsZero() {
s.deps.Events.OperatorAction(r.Context(), out.MindID, actor(r), "policy.create", "Operator "+actor(r)+" set "+string(out.Effect)+" for "+out.Capability+" on "+out.TargetPattern, map[string]any{"policy_id": out.ID})
}
return out, nil
}
func (s *Server) policiesCreate(w http.ResponseWriter, r *http.Request) {
var in struct {
MindID ids.ID `json:"mind_id"`
Capability string `json:"capability"`
TargetPattern string `json:"target_pattern"`
ActionKind string `json:"action_kind"`
Effect capabilities.Effect `json:"effect"`
HoursFrom *int `json:"hours_from"`
HoursTo *int `json:"hours_to"`
Reason string `json:"reason"`
}
if err := decode(r, &in); err != nil {
fail(w, r, err)
return
}
p, err := s.CreatePolicy(r, capabilities.Policy{MindID: in.MindID, Capability: in.Capability, TargetPattern: in.TargetPattern, ActionKind: in.ActionKind, Effect: in.Effect, HoursFrom: in.HoursFrom, HoursTo: in.HoursTo, Reason: in.Reason})
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusCreated, p)
}
func (s *Server) policyDelete(w http.ResponseWriter, r *http.Request) {
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
fail(w, r, ValidationError("bad id"))
return
}
if err := s.deps.Capabilities.DeletePolicy(r.Context(), id); err != nil {
fail(w, r, err)
return
}
s.deps.Audit.Log(r.Context(), "policy.delete", "policy", id.String(), nil)
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) mindCapabilities(w http.ResponseWriter, r *http.Request) {
m, ok := s.mind(w, r)
if !ok {
return
}
inv, err := s.deps.Capabilities.Inventory(r.Context(), m.ID)
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": inv})
}
// invoke lets an operator trigger a capability on a mind's behalf (testing
// integrations, demonstrations). It goes through exactly the same path.
func (s *Server) invoke(w http.ResponseWriter, r *http.Request) {
m, ok := s.mind(w, r)
if !ok {
return
}
var in struct {
Capability string `json:"capability"`
Target string `json:"target"`
Params map[string]any `json:"params"`
Reason string `json:"reason"`
}
if err := decode(r, &in); err != nil {
fail(w, r, err)
return
}
out, err := s.deps.Capabilities.Invoke(r.Context(), capabilities.Invocation{MindID: m.ID, Capability: in.Capability, Target: in.Target, Params: in.Params, Reason: firstNonEmpty(in.Reason, "operator "+actor(r)+" invoked it")})
if err != nil {
fail(w, r, capErr(err))
return
}
s.deps.Audit.Log(r.Context(), "capability.invoke", "mind", m.ID.String(), map[string]any{"capability": in.Capability, "target": in.Target, "status": out.Status})
writeJSON(w, http.StatusOK, out)
}
func (s *Server) approvalsList(w http.ResponseWriter, r *http.Request) {
var mind ids.ID
if v := r.URL.Query().Get("mind"); v != "" {
if m, err := s.deps.Minds.Resolve(r.Context(), v); err == nil {
mind = m.ID
}
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
list, err := s.deps.Capabilities.Approvals(r.Context(), mind, r.URL.Query().Get("status"), limit)
if err != nil {
fail(w, r, err)
return
}
if list == nil {
list = []capabilities.Approval{}
}
writeJSON(w, http.StatusOK, page[capabilities.Approval]{Items: list})
}
func (s *Server) approvalGet(w http.ResponseWriter, r *http.Request) {
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
fail(w, r, ValidationError("bad id"))
return
}
a, err := s.deps.Capabilities.GetApproval(r.Context(), id)
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusOK, a)
}
// Decide approves or denies; shared with the UI.
func (s *Server) Decide(r *http.Request, id ids.ID, decision, note string, params map[string]any) (any, error) {
switch decision {
case "approve", "session":
out, err := s.deps.Capabilities.Approve(r.Context(), id, actor(r), note, decision == "session", params)
if err != nil {
return nil, capErr(err)
}
s.deps.Audit.Log(r.Context(), "approval.approve", "approval", id.String(), map[string]any{"for_session": decision == "session", "modified": params != nil, "status": out.Status})
return out, nil
case "deny":
a, err := s.deps.Capabilities.Deny(r.Context(), id, actor(r), note)
if err != nil {
return nil, capErr(err)
}
s.deps.Audit.Log(r.Context(), "approval.deny", "approval", id.String(), map[string]any{"note": note})
return a, nil
}
return nil, ValidationError("decision must be approve, session or deny")
}
func (s *Server) approvalApprove(w http.ResponseWriter, r *http.Request) {
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
fail(w, r, ValidationError("bad id"))
return
}
var in struct {
Note string `json:"note"`
ForSession bool `json:"for_session"`
Params json.RawMessage `json:"params"`
}
if r.ContentLength != 0 {
if err := decode(r, &in); err != nil {
fail(w, r, err)
return
}
}
var params map[string]any
if len(in.Params) > 0 {
if err := json.Unmarshal(in.Params, &params); err != nil {
fail(w, r, ValidationError("params must be a JSON object"))
return
}
}
decision := "approve"
if in.ForSession {
decision = "session"
}
out, err := s.Decide(r, id, decision, in.Note, params)
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) approvalDeny(w http.ResponseWriter, r *http.Request) {
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
fail(w, r, ValidationError("bad id"))
return
}
var in struct {
Note string `json:"note"`
}
if r.ContentLength != 0 {
decode(r, &in) //nolint:errcheck
}
out, err := s.Decide(r, id, "deny", in.Note, nil)
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) grantsList(w http.ResponseWriter, r *http.Request) {
list, err := s.deps.Capabilities.Grants(r.Context(), ids.Nil)
if err != nil {
fail(w, r, err)
return
}
if list == nil {
list = []capabilities.Grant{}
}
writeJSON(w, http.StatusOK, page[capabilities.Grant]{Items: list})
}
func (s *Server) grantRevoke(w http.ResponseWriter, r *http.Request) {
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
fail(w, r, ValidationError("bad id"))
return
}
if err := s.deps.Capabilities.RevokeGrant(r.Context(), id); err != nil {
fail(w, r, err)
return
}
s.deps.Audit.Log(r.Context(), "grant.revoke", "grant", id.String(), nil)
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) executionsList(w http.ResponseWriter, r *http.Request) {
var mind ids.ID
if v := r.URL.Query().Get("mind"); v != "" {
if m, err := s.deps.Minds.Resolve(r.Context(), v); err == nil {
mind = m.ID
}
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
list, err := s.deps.Capabilities.Executions(r.Context(), mind, limit)
if err != nil {
fail(w, r, err)
return
}
if list == nil {
list = []capabilities.Execution{}
}
writeJSON(w, http.StatusOK, page[capabilities.Execution]{Items: list})
}
+1
View File
@@ -63,6 +63,7 @@ func (s *Server) routes() {
s.motivationRoutes()
s.cognitionRoutes()
s.graphRoutes()
s.capabilityRoutes()
m.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
writeError(w, r, http.StatusNotFound, "not_found", "no such endpoint")
+2
View File
@@ -14,6 +14,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/config"
"github.com/andydixon/noema/internal/consolidation"
"github.com/andydixon/noema/internal/conversation"
@@ -56,6 +57,7 @@ type Deps struct {
Consolidation *consolidation.Service
Reflection *reflection.Service
Graph *graph.Store
Capabilities *capabilities.Service
LLM *router.Router
LLMStore *router.Store
Prompts *prompts.Store
+10 -8
View File
@@ -11,6 +11,7 @@ import (
"github.com/andydixon/noema/internal/attention"
"github.com/andydixon/noema/internal/beliefs"
"github.com/andydixon/noema/internal/capabilities"
"github.com/andydixon/noema/internal/goals"
"github.com/andydixon/noema/internal/llm/prompts"
"github.com/andydixon/noema/internal/llm/router"
@@ -94,14 +95,15 @@ func (s *State) AllowLLM() bool {
// Services are read handles processes may use. Extended per phase.
type Services struct {
Events EventReader
Memory *memory.Store
Beliefs *beliefs.Store
LLM *router.Router
Prompts *prompts.Store
Goals *goals.Store
Motivation *motivation.Store
Predictions *predictions.Store
Events EventReader
Memory *memory.Store
Beliefs *beliefs.Store
LLM *router.Router
Prompts *prompts.Store
Goals *goals.Store
Motivation *motivation.Store
Predictions *predictions.Store
Capabilities *capabilities.Service
}
// EventReader is the subset of the event store processes may call.
+94
View File
@@ -0,0 +1,94 @@
package capabilities
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/andydixon/noema/internal/security/httpclient"
)
// Builtins returns the capabilities every deployment has. Integrations add
// more. Locked capabilities exist so that policy can name them and the
// self-model can list them as unavailable; they never execute.
func Builtins() []Capability {
return []Capability{
{Name: "noema.note", Description: "Leave a note for the operator in the mind's own history.", Risk: RiskLow, TargetKind: "none",
Params: map[string]Param{"text": {Type: "string", Required: true, Description: "note text", MaxLen: 2000}}, DefaultEffect: Allow,
Executor: func(_ context.Context, inv Invocation) (Result, error) {
t, _ := inv.Params["text"].(string)
return Result{Summary: "note recorded", Data: map[string]any{"text": t}}, nil
}},
{Name: "http.fetch", Description: "Fetch a public HTTP(S) URL read-only (bounded, no private addresses) and return its text.", Risk: RiskMedium, TargetKind: "url",
Params: map[string]Param{"url": {Type: "url", Required: true, Description: "the URL to fetch", MaxLen: 2000}, "max_bytes": {Type: "int", Description: "response cap (default 64 KiB)"}}, DefaultEffect: Ask,
Executor: fetchURL},
{Name: "internet_search", Description: "Search the web (requires a configured search integration).", Risk: RiskMedium, TargetKind: "none",
Params: map[string]Param{"query": {Type: "string", Required: true, MaxLen: 500}}, DefaultEffect: Ask,
Executor: func(context.Context, Invocation) (Result, error) {
return Result{}, errors.New("no search integration configured")
}},
{Name: "send_email", Description: "Send an email (requires a mail integration).", Risk: RiskHigh, TargetKind: "address",
Params: map[string]Param{"to": {Type: "string", Required: true, MaxLen: 300}, "subject": {Type: "string", Required: true, MaxLen: 300}, "body": {Type: "string", Required: true, MaxLen: 20000}}, DefaultEffect: Ask,
Executor: func(context.Context, Invocation) (Result, error) {
return Result{}, errors.New("no mail integration configured")
}},
{Name: "sudo_shell", Description: "Privileged shell. Locked to DENY; exists so it can be named, never run.", Risk: RiskCritical, TargetKind: "host", Locked: true,
Params: map[string]Param{"command": {Type: "string", Required: true}}, Executor: never},
{Name: "delete_database", Description: "Destroy data stores. Locked to DENY; exists so it can be named, never run.", Risk: RiskCritical, TargetKind: "service", Locked: true,
Params: map[string]Param{"name": {Type: "string", Required: true}}, Executor: never},
}
}
func never(context.Context, Invocation) (Result, error) {
return Result{}, errors.New("locked capability invoked; this is a bug")
}
func fetchURL(ctx context.Context, inv Invocation) (Result, error) {
u, _ := inv.Params["url"].(string)
max := int64(64 << 10)
if n, ok := inv.Params["max_bytes"].(int); ok && n > 0 && n <= 1<<20 {
max = int64(n)
}
client := httpclient.New(httpclient.Options{Timeout: 20 * time.Second, MaxBody: max})
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return Result{}, err
}
req.Header.Set("User-Agent", "noema/1 (+capability http.fetch)")
req.Header.Set("Accept", "text/plain, text/html;q=0.9, application/json;q=0.8, */*;q=0.1")
res, err := client.Do(req)
if err != nil {
return Result{}, err
}
defer res.Body.Close()
body, err := httpclient.ReadAll(res.Body, max)
if err != nil {
return Result{}, err
}
text := string(body)
if strings.Contains(res.Header.Get("Content-Type"), "text/html") {
text = stripTags(text)
}
return Result{Summary: fmt.Sprintf("HTTP %d, %d bytes, %s", res.StatusCode, len(body), res.Header.Get("Content-Type")), Output: text, Data: map[string]any{"status": res.StatusCode, "content_type": res.Header.Get("Content-Type"), "bytes": len(body)}}, nil
}
// stripTags is a crude tag remover; the output is data, never rendered as HTML.
func stripTags(s string) string {
var b strings.Builder
in := false
for _, r := range s {
switch {
case r == '<':
in = true
case r == '>':
in = false
b.WriteByte(' ')
case !in:
b.WriteRune(r)
}
}
return strings.Join(strings.Fields(b.String()), " ")
}
+346
View File
@@ -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
}
+179
View File
@@ -0,0 +1,179 @@
package capabilities
import (
"context"
"errors"
"fmt"
"log/slog"
"testing"
"time"
evstore "github.com/andydixon/noema/internal/events"
"github.com/andydixon/noema/internal/minds"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/migrations"
"github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/pkg/ids"
)
func TestEvaluateSpecificityAndLocks(t *testing.T) {
mind, other := ids.New(), ids.New()
c := Capability{Name: "restart_service", DefaultEffect: Ask}
pols := []Policy{
{ID: ids.New(), Capability: "*", TargetPattern: "*", ActionKind: "*", Effect: Deny},
{ID: ids.New(), Capability: "restart_service", TargetPattern: "*", ActionKind: "*", Effect: Ask},
{ID: ids.New(), MindID: mind, Capability: "restart_service", TargetPattern: "web-*", ActionKind: "*", Effect: Allow},
{ID: ids.New(), MindID: other, Capability: "restart_service", TargetPattern: "*", ActionKind: "*", Effect: Allow},
}
now := time.Now()
if d := Evaluate(c, pols, mind, "web-01", "*", now); d.Effect != Allow {
t.Fatalf("most specific mind rule should allow: %+v", d)
}
if d := Evaluate(c, pols, mind, "db-01", "*", now); d.Effect != Ask {
t.Fatalf("capability rule should ask: %+v", d)
}
if d := Evaluate(Capability{Name: "send_email", DefaultEffect: Ask}, pols, mind, "x", "*", now); d.Effect != Deny {
t.Fatalf("global wildcard deny should apply: %+v", d)
}
// Tie: two rules with equal specificity, stricter wins.
tie := []Policy{{ID: ids.New(), Capability: "restart_service", Effect: Allow, TargetPattern: "*", ActionKind: "*"}, {ID: ids.New(), Capability: "restart_service", Effect: Deny, TargetPattern: "*", ActionKind: "*"}}
if d := Evaluate(c, tie, mind, "x", "*", now); d.Effect != Deny {
t.Fatalf("tie should deny: %+v", d)
}
if d := Evaluate(Capability{Name: "sudo_shell", Locked: true}, []Policy{{Capability: "sudo_shell", Effect: Allow, TargetPattern: "*", ActionKind: "*"}}, mind, "x", "*", now); d.Effect != Deny || !d.Locked {
t.Fatal("locked capability must deny regardless of policy")
}
// Time window.
from, to := 9, 17
win := []Policy{{ID: ids.New(), Capability: "restart_service", TargetPattern: "*", ActionKind: "*", Effect: Allow, HoursFrom: &from, HoursTo: &to}}
at := time.Date(2026, 1, 1, 12, 0, 0, 0, time.Local)
off := time.Date(2026, 1, 1, 22, 0, 0, 0, time.Local)
if Evaluate(c, win, mind, "x", "*", at).Effect != Allow || Evaluate(c, win, mind, "x", "*", off).Effect != Ask {
t.Fatal("time window wrong")
}
}
func TestValidateParamsAndHash(t *testing.T) {
c := Capability{Name: "f", Params: map[string]Param{"url": {Type: "url", Required: true}, "n": {Type: "int"}, "p": {Type: "path"}}}
if _, err := c.ValidateParams(map[string]any{"url": "ftp://x"}); !errors.Is(err, ErrInvalid) {
t.Fatal("non-http url accepted")
}
if _, err := c.ValidateParams(map[string]any{"url": "https://x", "extra": 1}); !errors.Is(err, ErrInvalid) {
t.Fatal("unknown param accepted")
}
if _, err := c.ValidateParams(map[string]any{"url": "https://x", "p": "../etc/passwd"}); !errors.Is(err, ErrInvalid) {
t.Fatal("traversal accepted")
}
p, err := c.ValidateParams(map[string]any{"url": "https://x", "n": float64(3)})
if err != nil || p["n"] != 3 {
t.Fatalf("validate: %v %v", err, p)
}
m := ids.New()
if Hash(m, "f", "t", p) != Hash(m, "f", "t", map[string]any{"url": "https://x", "n": 3}) || Hash(m, "f", "t", p) == Hash(m, "f", "t2", p) {
t.Fatal("hash not stable/discriminating")
}
}
func setup(t *testing.T) (*Service, minds.Mind, *evstore.Store) {
t.Helper()
pool := storage.TestPool(t, migrations.FS)
ctx := context.Background()
m, _ := minds.NewStore(pool).Create(ctx, minds.CreateParams{Slug: "cap"})
es := evstore.NewStore(pool, evstore.NewBus(8), nil)
reg := NewRegistry()
for _, c := range Builtins() {
reg.Add(c)
}
calls := 0
reg.Add(Capability{Name: "test.echo", Description: "echo", Risk: RiskLow, TargetKind: "none", Params: map[string]Param{"text": {Type: "string", Required: true}}, DefaultEffect: Ask,
Executor: func(_ context.Context, inv Invocation) (Result, error) {
calls++
return Result{Summary: "echoed", Output: inv.Params["text"].(string)}, nil
}})
reg.Add(Capability{Name: "test.boom", Risk: RiskLow, DefaultEffect: Allow, Executor: func(context.Context, Invocation) (Result, error) { panic("bad tool") }})
return &Service{Registry: reg, DB: pool, Events: es, Log: slog.New(slog.DiscardHandler)}, m, es
}
func TestInvokePaths(t *testing.T) {
s, m, es := setup(t)
ctx := context.Background()
// Default ALLOW executes and its output becomes a tool-trust observation.
out, err := s.Invoke(ctx, Invocation{MindID: m.ID, Capability: "noema.note", Params: map[string]any{"text": "hello"}, Reason: "test"})
if err != nil || out.Status != "executed" || out.Result == nil {
t.Fatalf("allow path: %v %+v", err, out)
}
// Default ASK creates an approval and executes only after approval.
out, err = s.Invoke(ctx, Invocation{MindID: m.ID, Capability: "test.echo", Target: "t1", Params: map[string]any{"text": "ping"}, Reason: "need it"})
if err != nil || out.Status != "pending" || out.ApprovalID.IsZero() {
t.Fatalf("ask path: %v %+v", err, out)
}
pend, _ := s.Approvals(ctx, m.ID, "pending", 10)
if len(pend) != 1 || pend[0].Risk != RiskLow {
t.Fatalf("pending: %+v", pend)
}
res, err := s.Approve(ctx, out.ApprovalID, "andy", "ok", true, nil)
if err != nil || res.Status != "executed" || res.Result.Output != "ping" {
t.Fatalf("approve: %v %+v", err, res)
}
obs, _ := es.Query(ctx, evstore.Filter{MindID: m.ID, Types: []events.Type{events.Observation}})
if len(obs) == 0 || fmt.Sprint(obs[0].Metadata["trust"]) != string(events.TrustTool) {
t.Fatalf("tool output should be a tool-trust observation: %+v", obs)
}
// Session grant now allows the same target without asking; a different target still asks.
out, _ = s.Invoke(ctx, Invocation{MindID: m.ID, Capability: "test.echo", Target: "t1", Params: map[string]any{"text": "again"}})
if out.Status != "executed" {
t.Fatalf("session grant not applied: %+v", out)
}
out, _ = s.Invoke(ctx, Invocation{MindID: m.ID, Capability: "test.echo", Target: "t2", Params: map[string]any{"text": "x"}})
if out.Status != "pending" {
t.Fatalf("other target should still ask: %+v", out)
}
// Deny with note; double decision refused.
if _, err := s.Deny(ctx, out.ApprovalID, "andy", "not now"); err != nil {
t.Fatal(err)
}
if _, err := s.Approve(ctx, out.ApprovalID, "andy", "", false, nil); !errors.Is(err, ErrInvalid) {
t.Fatal("denied request approved")
}
// Policy DENY, locked capability, and forbidden context.
if _, err := s.SetPolicy(ctx, Policy{MindID: m.ID, Capability: "test.echo", Effect: Deny, Reason: "no echo here"}); err != nil {
t.Fatal(err)
}
out, _ = s.Invoke(ctx, Invocation{MindID: m.ID, Capability: "test.echo", Target: "t1", Params: map[string]any{"text": "y"}})
if out.Status != "denied" {
t.Fatalf("policy deny not applied: %+v", out)
}
if _, err := s.SetPolicy(ctx, Policy{Capability: "sudo_shell", Effect: Allow}); !errors.Is(err, ErrInvalid) {
t.Fatal("locked capability relaxed")
}
out, _ = s.Invoke(ctx, Invocation{MindID: m.ID, Capability: "sudo_shell", Params: map[string]any{"command": "rm -rf /"}})
if out.Status != "denied" || !out.Decision.Locked {
t.Fatalf("locked not denied: %+v", out)
}
if _, err := s.Invoke(WithForbidden(ctx, "dreaming"), Invocation{MindID: m.ID, Capability: "noema.note", Params: map[string]any{"text": "x"}}); !errors.Is(err, ErrForbiddenContext) {
t.Fatal("dreaming context allowed an action")
}
// A panicking executor is a failed execution, not a crash.
out, err = s.Invoke(ctx, Invocation{MindID: m.ID, Capability: "test.boom"})
if err != nil || out.Status != "failed" {
t.Fatalf("panic handling: %v %+v", err, out)
}
// Decisions are append-only.
if _, err := s.DB.Exec(ctx, `DELETE FROM approval_decisions`); err == nil {
t.Fatal("decisions must be append-only")
}
inv, _ := s.Inventory(ctx, m.ID)
var sawLocked bool
for _, i := range inv {
if i.Capability.Name == "sudo_shell" && i.Effect == Deny {
sawLocked = true
}
}
if !sawLocked {
t.Fatal("inventory missing locked deny")
}
execs, _ := s.Executions(ctx, m.ID, 10)
if len(execs) < 3 {
t.Fatalf("executions not recorded: %d", len(execs))
}
}
+648
View File
@@ -0,0 +1,648 @@
package capabilities
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"path"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
evstore "github.com/andydixon/noema/internal/events"
"github.com/andydixon/noema/internal/observability"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/pkg/ids"
)
// Approval is a pending or decided request.
type Approval struct {
ID ids.ID `json:"id"`
MindID ids.ID `json:"mind_id"`
Capability string `json:"capability"`
Target string `json:"target"`
Params map[string]any `json:"params"`
RequestHash string `json:"request_hash"`
Reason string `json:"reason"`
Risk Risk `json:"risk"`
Evidence []EvidenceRef `json:"evidence"`
Status string `json:"status"`
DecisionBy string `json:"decision_by"`
DecisionNote string `json:"decision_note"`
DecidedAt *time.Time `json:"decided_at,omitempty"`
SessionGrant bool `json:"session_grant"`
ModifiedParams map[string]any `json:"modified_params,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error string `json:"error"`
EventID ids.ID `json:"event_id,omitempty"`
CorrelationID ids.ID `json:"correlation_id,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
}
// Execution is a recorded run.
type Execution struct {
ID ids.ID `json:"id"`
MindID ids.ID `json:"mind_id"`
Capability string `json:"capability"`
Target string `json:"target"`
Params map[string]any `json:"params"`
Result json.RawMessage `json:"result,omitempty"`
Error string `json:"error"`
DurationMS int `json:"duration_ms"`
ApprovalID ids.ID `json:"approval_id,omitempty"`
EventID ids.ID `json:"event_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// Grant is an approve-for-session exception.
type Grant struct {
ID ids.ID `json:"id"`
MindID ids.ID `json:"mind_id"`
Capability string `json:"capability"`
TargetPattern string `json:"target_pattern"`
GrantedBy string `json:"granted_by"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
}
// Outcome is what Invoke returns.
type Outcome struct {
Status string `json:"status"` // executed | pending | denied | failed
Decision Decision `json:"decision"`
Result *Result `json:"result,omitempty"`
Error string `json:"error,omitempty"`
ApprovalID ids.ID `json:"approval_id,omitempty"`
EventID ids.ID `json:"event_id,omitempty"`
Execution ids.ID `json:"execution_id,omitempty"`
}
// Service wires the registry, policies, approvals and executions together.
type Service struct {
Registry *Registry
DB *pgxpool.Pool
Events *evstore.Store
Log *slog.Logger
Metrics *observability.Metrics
ApprovalTTL time.Duration
SessionTTL time.Duration
Now func() time.Time
OnSelfModel func(ctx context.Context, mind ids.ID) // called when effective policies may have changed
}
func (s *Service) now() time.Time {
if s.Now != nil {
return s.Now()
}
return time.Now()
}
// ---- policies ----
const policyCols = `id, mind_id, capability, target_pattern, action_kind, effect, hours_from, hours_to, reason, created_by, created_at, updated_at`
func scanPolicy(row pgx.Row) (Policy, error) {
var p Policy
var mind *string
var effect string
if err := row.Scan(&p.ID, &mind, &p.Capability, &p.TargetPattern, &p.ActionKind, &effect, &p.HoursFrom, &p.HoursTo, &p.Reason, &p.CreatedBy, &p.CreatedAt, &p.UpdatedAt); err != nil {
return p, storage.Translate(err)
}
p.Effect = Effect(effect)
if mind != nil {
p.MindID = ids.ID(*mind)
}
return p, nil
}
// Policies lists rules applying to a mind (global plus its own), or all when mind is Nil.
func (s *Service) Policies(ctx context.Context, mind ids.ID) ([]Policy, error) {
rows, err := s.DB.Query(ctx, `SELECT `+policyCols+` FROM policies WHERE mind_id IS NULL OR $1::uuid IS NULL OR mind_id=$1 ORDER BY mind_id NULLS FIRST, capability, target_pattern`, nullID(mind))
if err != nil {
return nil, err
}
defer rows.Close()
var out []Policy
for rows.Next() {
p, err := scanPolicy(rows)
if err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// SetPolicy inserts a rule. Locked capabilities cannot be relaxed.
func (s *Service) SetPolicy(ctx context.Context, p Policy) (Policy, error) {
if !p.Effect.Valid() {
return Policy{}, fmt.Errorf("%w: effect must be allow, ask or deny", ErrInvalid)
}
if p.Capability == "" {
p.Capability = "*"
}
if p.TargetPattern == "" {
p.TargetPattern = "*"
}
if p.ActionKind == "" {
p.ActionKind = "*"
}
if p.Capability != "*" {
c, ok := s.Registry.Get(p.Capability)
if !ok {
return Policy{}, fmt.Errorf("%w: unknown capability %q", ErrUnknown, p.Capability)
}
if c.Locked && p.Effect != Deny {
return Policy{}, fmt.Errorf("%w: %s is locked to DENY and cannot be relaxed", ErrInvalid, c.Name)
}
}
if _, err := path.Match(p.TargetPattern, "x"); err != nil {
return Policy{}, fmt.Errorf("%w: bad target pattern", ErrInvalid)
}
if (p.HoursFrom == nil) != (p.HoursTo == nil) {
return Policy{}, fmt.Errorf("%w: both hours_from and hours_to are required", ErrInvalid)
}
p.ID, p.CreatedAt, p.UpdatedAt = ids.New(), s.now(), s.now()
_, err := s.DB.Exec(ctx, `INSERT INTO policies (id, mind_id, capability, target_pattern, action_kind, effect, hours_from, hours_to, reason, created_by) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
p.ID, nullID(p.MindID), p.Capability, p.TargetPattern, p.ActionKind, string(p.Effect), p.HoursFrom, p.HoursTo, p.Reason, p.CreatedBy)
if err != nil {
return Policy{}, err
}
s.selfModelChanged(ctx, p.MindID)
return p, nil
}
// DeletePolicy removes a rule.
func (s *Service) DeletePolicy(ctx context.Context, id ids.ID) error {
var mind *string
err := s.DB.QueryRow(ctx, `DELETE FROM policies WHERE id=$1 RETURNING mind_id`, id).Scan(&mind)
if err != nil {
return storage.Translate(err)
}
if mind != nil {
s.selfModelChanged(ctx, ids.ID(*mind))
} else {
s.selfModelChanged(ctx, ids.Nil)
}
return nil
}
func (s *Service) selfModelChanged(ctx context.Context, mind ids.ID) {
if s.OnSelfModel != nil {
s.OnSelfModel(ctx, mind)
}
}
// Decide evaluates policy and session grants for a request without executing.
func (s *Service) Decide(ctx context.Context, mind ids.ID, c Capability, target, action string) (Decision, error) {
pols, err := s.Policies(ctx, mind)
if err != nil {
return Decision{}, err
}
d := Evaluate(c, pols, mind, target, action, s.now())
if d.Effect == Ask && !c.Locked {
if g, ok := s.activeGrant(ctx, mind, c.Name, target); ok {
d.Effect, d.Reason = Allow, fmt.Sprintf("session grant by %s until %s", g.GrantedBy, g.ExpiresAt.Format(time.Kitchen))
}
}
return d, nil
}
func (s *Service) activeGrant(ctx context.Context, mind ids.ID, capability, target string) (Grant, bool) {
rows, err := s.DB.Query(ctx, `SELECT id, mind_id, capability, target_pattern, granted_by, expires_at, created_at FROM session_grants WHERE mind_id=$1 AND capability=$2 AND revoked_at IS NULL AND expires_at > $3`, mind, capability, s.now())
if err != nil {
return Grant{}, false
}
defer rows.Close()
for rows.Next() {
var g Grant
if err := rows.Scan(&g.ID, &g.MindID, &g.Capability, &g.TargetPattern, &g.GrantedBy, &g.ExpiresAt, &g.CreatedAt); err != nil {
return Grant{}, false
}
if ok, _ := path.Match(g.TargetPattern, target); ok || g.TargetPattern == "*" || strings.EqualFold(g.TargetPattern, target) {
return g, true
}
}
return Grant{}, false
}
// Inventory lists every capability with the effect a mind would get for a
// generic target now; it feeds the self-model.
type InventoryItem struct {
Capability Capability `json:"capability"`
Effect Effect `json:"effect"`
Reason string `json:"reason"`
}
// Inventory computes effective effects for a mind.
func (s *Service) Inventory(ctx context.Context, mind ids.ID) ([]InventoryItem, error) {
pols, err := s.Policies(ctx, mind)
if err != nil {
return nil, err
}
var out []InventoryItem
for _, c := range s.Registry.List() {
d := Evaluate(c, pols, mind, "*", "*", s.now())
out = append(out, InventoryItem{Capability: c, Effect: d.Effect, Reason: d.Reason})
}
return out, nil
}
// ---- invocation ----
// Invoke is the single entry point for actions.
func (s *Service) Invoke(ctx context.Context, inv Invocation) (Outcome, error) {
if why, forbidden := Forbidden(ctx); forbidden {
return Outcome{Status: "denied", Decision: Decision{Effect: Deny, Reason: "forbidden context: " + why}}, ErrForbiddenContext
}
c, ok := s.Registry.Get(inv.Capability)
if !ok {
return Outcome{}, fmt.Errorf("%w: %s", ErrUnknown, inv.Capability)
}
params, err := c.ValidateParams(inv.Params)
if err != nil {
return Outcome{}, err
}
inv.Params = params
action, _ := params["action"].(string)
if action == "" {
action = "*"
}
d, err := s.Decide(ctx, inv.MindID, c, inv.Target, action)
if err != nil {
return Outcome{}, err
}
if s.Metrics != nil {
s.Metrics.ToolRequests.WithLabelValues(c.Name).Inc()
}
hash := Hash(inv.MindID, c.Name, inv.Target, params)
base := map[string]any{"capability": c.Name, "target": inv.Target, "params": params, "risk": c.Risk, "reason": inv.Reason, "hash": hash, "decision": d}
evs, err := s.Events.Append(ctx, events.New{MindID: inv.MindID, Type: events.PolicyDecision, Source: "capabilities", Summary: fmt.Sprintf("policy %s for %s on %q: %s", strings.ToUpper(string(d.Effect)), c.Name, inv.Target, d.Reason),
Salience: 0.3, Confidence: 1, CorrelationID: inv.Correlation, Evidence: toEvidence(inv.Evidence), Metadata: map[string]any{"capability": c.Name, "effect": d.Effect, "policy_id": string(d.PolicyID)}, Payload: base})
if err != nil {
return Outcome{}, err
}
decisionEv := evs[0].ID
switch d.Effect {
case Deny:
if s.Metrics != nil {
s.Metrics.ToolDenied.WithLabelValues(c.Name).Inc()
}
ev, _ := s.Events.Append(ctx, events.New{MindID: inv.MindID, Type: events.ToolDenied, Source: "capabilities", Summary: fmt.Sprintf("denied: %s on %q (%s)", c.Name, inv.Target, d.Reason),
Salience: 0.5, Confidence: 1, CausationID: decisionEv, CorrelationID: inv.Correlation, Metadata: map[string]any{"capability": c.Name, "target": inv.Target}, Payload: base})
return Outcome{Status: "denied", Decision: d, EventID: firstID(ev)}, nil
case Ask:
a, err := s.createApproval(ctx, inv, c, hash, decisionEv)
if err != nil {
return Outcome{}, err
}
return Outcome{Status: "pending", Decision: d, ApprovalID: a.ID, EventID: a.EventID}, nil
}
return s.execute(ctx, inv, c, d, decisionEv, ids.Nil)
}
func (s *Service) createApproval(ctx context.Context, inv Invocation, c Capability, hash string, cause ids.ID) (Approval, error) {
ttl := s.ApprovalTTL
if ttl <= 0 {
ttl = 24 * time.Hour
}
a := Approval{ID: ids.New(), MindID: inv.MindID, Capability: c.Name, Target: inv.Target, Params: inv.Params, RequestHash: hash, Reason: inv.Reason, Risk: c.Risk, Evidence: inv.Evidence, Status: "pending", CorrelationID: inv.Correlation, ExpiresAt: s.now().Add(ttl), CreatedAt: s.now()}
if a.Evidence == nil {
a.Evidence = []EvidenceRef{}
}
ev, err := s.Events.Append(ctx, events.New{MindID: inv.MindID, Type: events.ToolRequested, Source: "capabilities", Summary: fmt.Sprintf("approval requested: %s on %q — %s", c.Name, inv.Target, firstNonEmpty(inv.Reason, "no reason given")),
Salience: 0.6, Confidence: 1, CausationID: cause, CorrelationID: inv.Correlation, Evidence: toEvidence(inv.Evidence),
Metadata: map[string]any{"capability": c.Name, "target": inv.Target, "approval_id": string(a.ID), "risk": c.Risk}, Payload: map[string]any{"approval_id": a.ID, "capability": c.Name, "target": inv.Target, "params": inv.Params, "risk": c.Risk, "reason": inv.Reason, "expires_at": a.ExpiresAt}})
if err != nil {
return Approval{}, err
}
a.EventID = ev[0].ID
params, _ := json.Marshal(inv.Params)
evid, _ := json.Marshal(a.Evidence)
_, err = s.DB.Exec(ctx, `INSERT INTO approval_requests (id, mind_id, capability, target, params, request_hash, reason, risk, evidence, status, event_id, correlation_id, expires_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'pending',$10,$11,$12)`, a.ID, a.MindID, a.Capability, a.Target, params, a.RequestHash, a.Reason, string(a.Risk), evid, string(a.EventID), nullID(a.CorrelationID), a.ExpiresAt)
return a, err
}
func (s *Service) execute(ctx context.Context, inv Invocation, c Capability, d Decision, cause ids.ID, approvalID ids.ID) (Outcome, error) {
start := s.now()
var res Result
var execErr error
func() {
defer func() {
if r := recover(); r != nil {
execErr = fmt.Errorf("executor panicked: %v", r)
}
}()
ectx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
res, execErr = c.Executor(ectx, inv)
}()
dur := time.Since(start)
exec := Execution{ID: ids.New(), MindID: inv.MindID, Capability: c.Name, Target: inv.Target, Params: inv.Params, DurationMS: int(dur.Milliseconds()), ApprovalID: approvalID, CreatedAt: s.now()}
payload := map[string]any{"capability": c.Name, "target": inv.Target, "params": inv.Params, "approval_id": approvalID, "duration_ms": exec.DurationMS}
var ev []events.Event
if execErr != nil {
exec.Error = execErr.Error()
payload["error"] = exec.Error
ev, _ = s.Events.Append(ctx, events.New{MindID: inv.MindID, Type: events.ToolFailed, Source: "capabilities", Summary: fmt.Sprintf("%s on %q failed: %s", c.Name, inv.Target, truncate(exec.Error, 160)),
Salience: 0.6, Confidence: 1, CausationID: cause, CorrelationID: inv.Correlation, Metadata: map[string]any{"capability": c.Name, "target": inv.Target, "execution_id": string(exec.ID)}, Payload: payload})
} else {
rj, _ := json.Marshal(res)
exec.Result = rj
payload["result"] = res
ev, _ = s.Events.Append(ctx, events.New{MindID: inv.MindID, Type: events.ToolExecuted, Source: "capabilities", Summary: fmt.Sprintf("%s on %q: %s", c.Name, inv.Target, truncate(res.Summary, 160)),
Salience: 0.5, Confidence: 1, CausationID: cause, CorrelationID: inv.Correlation, Evidence: toEvidence(inv.Evidence), Metadata: map[string]any{"capability": c.Name, "target": inv.Target, "execution_id": string(exec.ID), "trust": events.TrustTool}, Payload: payload})
// Tool output re-enters cognition as an observation with tool trust.
if strings.TrimSpace(res.Output) != "" {
s.Events.Append(ctx, events.New{MindID: inv.MindID, Type: events.Observation, Source: "tool:" + c.Name, Summary: "tool output from " + c.Name + ": " + truncate(oneLine(res.Output), 140), Salience: 0.6, Confidence: 0.9, CausationID: firstID(ev), CorrelationID: inv.Correlation, //nolint:errcheck
Metadata: map[string]any{"trust": events.TrustTool, "capability": c.Name, "execution_id": string(exec.ID)},
Payload: events.ObservationPayload{Text: truncate(res.Output, 20000), ContentType: "text/plain", Source: "tool:" + c.Name, Trust: events.TrustTool, Method: "capability", Reference: string(exec.ID), Channel: inv.Target}})
}
}
exec.EventID = firstID(ev)
params, _ := json.Marshal(inv.Params)
_, err := s.DB.Exec(ctx, `INSERT INTO tool_executions (id, mind_id, capability, target, params, result, error, duration_ms, approval_id, event_id) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
exec.ID, exec.MindID, exec.Capability, exec.Target, params, nullRaw(exec.Result), exec.Error, exec.DurationMS, nullID(approvalID), nullID(exec.EventID))
if err != nil {
s.Log.Error("record execution failed", "err", err)
}
if execErr != nil {
return Outcome{Status: "failed", Decision: d, Error: exec.Error, EventID: exec.EventID, Execution: exec.ID, ApprovalID: approvalID}, nil
}
return Outcome{Status: "executed", Decision: d, Result: &res, EventID: exec.EventID, Execution: exec.ID, ApprovalID: approvalID}, nil
}
// ---- approvals ----
const approvalCols = `id, mind_id, capability, target, params, request_hash, reason, risk, evidence, status, decision_by, decision_note, decided_at, session_grant, modified_params, result, error, event_id, correlation_id, expires_at, created_at`
func scanApproval(row pgx.Row) (Approval, error) {
var a Approval
var params, evid []byte
var modified, result *json.RawMessage
var risk string
var ev, corr *string
if err := row.Scan(&a.ID, &a.MindID, &a.Capability, &a.Target, &params, &a.RequestHash, &a.Reason, &risk, &evid, &a.Status, &a.DecisionBy, &a.DecisionNote, &a.DecidedAt, &a.SessionGrant, &modified, &result, &a.Error, &ev, &corr, &a.ExpiresAt, &a.CreatedAt); err != nil {
return a, storage.Translate(err)
}
a.Risk = Risk(risk)
json.Unmarshal(params, &a.Params) //nolint:errcheck
json.Unmarshal(evid, &a.Evidence) //nolint:errcheck
if a.Params == nil {
a.Params = map[string]any{}
}
if a.Evidence == nil {
a.Evidence = []EvidenceRef{}
}
if modified != nil {
json.Unmarshal(*modified, &a.ModifiedParams) //nolint:errcheck
}
if result != nil {
a.Result = *result
}
if ev != nil {
a.EventID = ids.ID(*ev)
}
if corr != nil {
a.CorrelationID = ids.ID(*corr)
}
return a, nil
}
// Approvals lists requests by status (empty = all), newest first; mind Nil = all minds.
func (s *Service) Approvals(ctx context.Context, mind ids.ID, status string, limit int) ([]Approval, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
rows, err := s.DB.Query(ctx, `SELECT `+approvalCols+` FROM approval_requests WHERE ($1::uuid IS NULL OR mind_id=$1) AND ($2='' OR status=$2) ORDER BY created_at DESC LIMIT $3`, nullID(mind), status, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Approval
for rows.Next() {
a, err := scanApproval(rows)
if err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
// GetApproval fetches one request.
func (s *Service) GetApproval(ctx context.Context, id ids.ID) (Approval, error) {
return scanApproval(s.DB.QueryRow(ctx, `SELECT `+approvalCols+` FROM approval_requests WHERE id=$1`, id))
}
// Approve executes the request now. Modified params are re-validated and
// re-hashed; forSession additionally grants matching requests until SessionTTL.
func (s *Service) Approve(ctx context.Context, id ids.ID, by, note string, forSession bool, modified map[string]any) (Outcome, error) {
a, err := s.GetApproval(ctx, id)
if err != nil {
return Outcome{}, err
}
if a.Status != "pending" {
return Outcome{}, fmt.Errorf("%w: request is %s", ErrInvalid, a.Status)
}
if s.now().After(a.ExpiresAt) {
s.DB.Exec(ctx, `UPDATE approval_requests SET status='expired' WHERE id=$1`, id) //nolint:errcheck
return Outcome{}, fmt.Errorf("%w: request expired", ErrInvalid)
}
c, ok := s.Registry.Get(a.Capability)
if !ok || c.Locked {
return Outcome{}, fmt.Errorf("%w: capability unavailable or locked", ErrInvalid)
}
params := a.Params
if modified != nil {
if params, err = c.ValidateParams(modified); err != nil {
return Outcome{}, err
}
}
hash := Hash(a.MindID, a.Capability, a.Target, params)
details := map[string]any{"for_session": forSession, "modified": modified != nil, "hash": hash, "original_hash": a.RequestHash}
if err := s.recordDecision(ctx, a.ID, "approved", by, note, details); err != nil {
return Outcome{}, err
}
mod, _ := json.Marshal(modified)
if _, err := s.DB.Exec(ctx, `UPDATE approval_requests SET status='approved', decision_by=$2, decision_note=$3, decided_at=$4, session_grant=$5, modified_params=$6 WHERE id=$1`, id, by, note, s.now(), forSession, nullRawIf(mod, modified != nil)); err != nil {
return Outcome{}, err
}
if forSession {
ttl := s.SessionTTL
if ttl <= 0 {
ttl = 8 * time.Hour
}
s.DB.Exec(ctx, `INSERT INTO session_grants (id, mind_id, capability, target_pattern, granted_by, expires_at) VALUES ($1,$2,$3,$4,$5,$6)`, ids.New(), a.MindID, a.Capability, a.Target, by, s.now().Add(ttl)) //nolint:errcheck
}
ev, _ := s.Events.Append(ctx, events.New{MindID: a.MindID, Type: events.ToolAuthorised, Source: "operator", Summary: fmt.Sprintf("operator %s approved %s on %q%s", by, a.Capability, a.Target, map[bool]string{true: " for this session", false: ""}[forSession]),
Salience: 0.5, Confidence: 1, CausationID: a.EventID, CorrelationID: a.CorrelationID, Metadata: map[string]any{"approval_id": string(a.ID), "actor": by, "capability": a.Capability, "for_session": forSession}, Payload: details})
inv := Invocation{MindID: a.MindID, Capability: a.Capability, Target: a.Target, Params: params, Reason: a.Reason, Evidence: a.Evidence, Correlation: a.CorrelationID, ApprovalID: a.ID}
out, err := s.execute(ctx, inv, c, Decision{Effect: Allow, Reason: "approved by " + by}, firstID(ev), a.ID)
if err != nil {
return out, err
}
status := "executed"
if out.Status == "failed" {
status = "failed"
}
rj, _ := json.Marshal(out.Result)
s.DB.Exec(ctx, `UPDATE approval_requests SET status=$2, result=$3, error=$4 WHERE id=$1`, id, status, nullRawIf(rj, out.Result != nil), out.Error) //nolint:errcheck
s.selfModelChanged(ctx, a.MindID)
return out, nil
}
// Deny refuses a request with an explanation the mind will see.
func (s *Service) Deny(ctx context.Context, id ids.ID, by, note string) (Approval, error) {
a, err := s.GetApproval(ctx, id)
if err != nil {
return Approval{}, err
}
if a.Status != "pending" {
return Approval{}, fmt.Errorf("%w: request is %s", ErrInvalid, a.Status)
}
if err := s.recordDecision(ctx, a.ID, "denied", by, note, nil); err != nil {
return Approval{}, err
}
if _, err := s.DB.Exec(ctx, `UPDATE approval_requests SET status='denied', decision_by=$2, decision_note=$3, decided_at=$4 WHERE id=$1`, id, by, note, s.now()); err != nil {
return Approval{}, err
}
if s.Metrics != nil {
s.Metrics.ToolDenied.WithLabelValues(a.Capability).Inc()
}
s.Events.Append(ctx, events.New{MindID: a.MindID, Type: events.ToolDenied, Source: "operator", Summary: fmt.Sprintf("operator %s denied %s on %q: %s", by, a.Capability, a.Target, firstNonEmpty(note, "no reason given")), //nolint:errcheck
Salience: 0.5, Confidence: 1, CausationID: a.EventID, CorrelationID: a.CorrelationID, Metadata: map[string]any{"approval_id": string(a.ID), "actor": by, "capability": a.Capability, "target": a.Target}, Payload: map[string]any{"note": note, "params": a.Params}})
return s.GetApproval(ctx, id)
}
func (s *Service) recordDecision(ctx context.Context, request ids.ID, decision, by, note string, details map[string]any) error {
if details == nil {
details = map[string]any{}
}
raw, _ := json.Marshal(details)
_, err := s.DB.Exec(ctx, `INSERT INTO approval_decisions (id, request_id, decision, decided_by, note, details) VALUES ($1,$2,$3,$4,$5,$6)`, ids.New(), request, decision, by, note, raw)
return err
}
// ExpirePending marks overdue requests expired.
func (s *Service) ExpirePending(ctx context.Context) (int64, error) {
tag, err := s.DB.Exec(ctx, `UPDATE approval_requests SET status='expired' WHERE status='pending' AND expires_at < $1`, s.now())
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}
// Grants lists live session grants for a mind.
func (s *Service) Grants(ctx context.Context, mind ids.ID) ([]Grant, error) {
rows, err := s.DB.Query(ctx, `SELECT id, mind_id, capability, target_pattern, granted_by, expires_at, created_at FROM session_grants WHERE ($1::uuid IS NULL OR mind_id=$1) AND revoked_at IS NULL AND expires_at > $2 ORDER BY created_at DESC`, nullID(mind), s.now())
if err != nil {
return nil, err
}
defer rows.Close()
var out []Grant
for rows.Next() {
var g Grant
if err := rows.Scan(&g.ID, &g.MindID, &g.Capability, &g.TargetPattern, &g.GrantedBy, &g.ExpiresAt, &g.CreatedAt); err != nil {
return nil, err
}
out = append(out, g)
}
return out, rows.Err()
}
// RevokeGrant ends a session grant early.
func (s *Service) RevokeGrant(ctx context.Context, id ids.ID) error {
tag, err := s.DB.Exec(ctx, `UPDATE session_grants SET revoked_at=$2 WHERE id=$1 AND revoked_at IS NULL`, id, s.now())
if err == nil && tag.RowsAffected() == 0 {
return storage.ErrNotFound
}
return err
}
// Executions lists recent runs.
func (s *Service) Executions(ctx context.Context, mind ids.ID, limit int) ([]Execution, error) {
if limit <= 0 || limit > 500 {
limit = 50
}
rows, err := s.DB.Query(ctx, `SELECT id, mind_id, capability, target, params, result, error, duration_ms, approval_id, event_id, created_at FROM tool_executions WHERE ($1::uuid IS NULL OR mind_id=$1) ORDER BY created_at DESC LIMIT $2`, nullID(mind), limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Execution
for rows.Next() {
var e Execution
var params []byte
var result *json.RawMessage
var appr, ev *string
if err := rows.Scan(&e.ID, &e.MindID, &e.Capability, &e.Target, &params, &result, &e.Error, &e.DurationMS, &appr, &ev, &e.CreatedAt); err != nil {
return nil, err
}
json.Unmarshal(params, &e.Params) //nolint:errcheck
if result != nil {
e.Result = *result
}
if appr != nil {
e.ApprovalID = ids.ID(*appr)
}
if ev != nil {
e.EventID = ids.ID(*ev)
}
out = append(out, e)
}
return out, rows.Err()
}
// ---- helpers ----
func toEvidence(in []EvidenceRef) []events.EvidenceRef {
out := make([]events.EvidenceRef, 0, len(in))
for _, e := range in {
out = append(out, events.EvidenceRef{Kind: e.Kind, ID: e.ID, Note: e.Note})
}
return out
}
func firstID(evs []events.Event) ids.ID {
if len(evs) > 0 {
return evs[0].ID
}
return ids.Nil
}
func nullID(id ids.ID) *string {
if id.IsZero() {
return nil
}
s := string(id)
return &s
}
func nullRaw(r json.RawMessage) *json.RawMessage {
if len(r) == 0 {
return nil
}
return &r
}
func nullRawIf(r json.RawMessage, cond bool) *json.RawMessage {
if !cond || len(r) == 0 || string(r) == "null" {
return nil
}
return &r
}
func truncate(s string, n int) string {
if len(s) > n {
return s[:n-1] + "…"
}
return s
}
func oneLine(s string) string { return strings.Join(strings.Fields(s), " ") }
+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}})
}
}
+2
View File
@@ -78,6 +78,8 @@ func Rules(ev events.Event, cfg minds.CognitiveConfig, p minds.Personality) []Ca
if ev.Metadata["check"] == "overconfidence" || ev.Metadata["check"] == "loop" {
out = append(out, Candidate{Level: "warn", Title: "Metacognitive warning", Body: ev.Summary, Rule: "metacognition", Reason: map[string]any{"check": ev.Metadata["check"]}, AggregateKey: "meta:" + str(ev.Metadata["check"]), SourceEvent: ev.ID})
}
case events.ToolRequested:
out = append(out, Candidate{Level: "alert", Title: "An action needs your approval", Body: ev.Summary, Rule: "tool_requested", Reason: map[string]any{"risk": ev.Metadata["risk"]}, AggregateKey: "approval:" + str(ev.Metadata["approval_id"]), SourceEvent: ev.ID})
case events.ToolDenied:
out = append(out, Candidate{Level: "alert", Title: "An action was denied by policy", Body: ev.Summary, Rule: "tool_denied", Reason: map[string]any{}, AggregateKey: "denied:" + str(ev.Metadata["capability"]), SourceEvent: ev.ID})
case events.Observation:
+134
View File
@@ -0,0 +1,134 @@
package ui
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/capabilities"
"github.com/andydixon/noema/pkg/ids"
)
func (u *UI) capabilityRoutes(m *http.ServeMux) {
m.Handle("GET /policies", u.require(auth.RoleObserver, u.policiesPage))
m.Handle("POST /policies", u.require(auth.RoleAdministrator, u.policyCreate))
m.Handle("POST /policies/{id}/delete", u.require(auth.RoleAdministrator, u.policyDelete))
m.Handle("POST /policies/grants/{id}/revoke", u.require(auth.RoleOperator, u.grantRevoke))
m.Handle("GET /approvals", u.require(auth.RoleObserver, u.approvalsPage))
m.Handle("POST /approvals/{id}/decide", u.require(auth.RoleOperator, u.approvalDecide))
}
func (u *UI) mindNames(r *http.Request) (map[ids.ID]string, []any) {
list, _ := u.deps.Minds.List(r.Context(), true)
names := map[ids.ID]string{}
var live []any
for _, m := range list {
names[m.ID] = m.DisplayName
if m.Status == "active" {
live = append(live, m)
}
}
return names, live
}
func (u *UI) policiesPage(w http.ResponseWriter, r *http.Request) {
names, _ := u.mindNames(r)
mindsList, _ := u.deps.Minds.List(r.Context(), false)
caps := u.deps.Capabilities.Registry.List()
matrix := map[ids.ID]map[string]capabilities.InventoryItem{}
for _, m := range mindsList {
inv, _ := u.deps.Capabilities.Inventory(r.Context(), m.ID)
row := map[string]capabilities.InventoryItem{}
for _, i := range inv {
row[i.Capability.Name] = i
}
matrix[m.ID] = row
}
pols, _ := u.deps.Capabilities.Policies(r.Context(), ids.Nil)
grants, _ := u.deps.Capabilities.Grants(r.Context(), ids.Nil)
u.render(w, r, "policies", "Capabilities and policies", map[string]any{"Capabilities": caps, "Minds": mindsList, "MindNames": names, "Matrix": matrix, "Policies": pols, "Grants": grants})
}
func (u *UI) policyCreate(w http.ResponseWriter, r *http.Request) {
r.ParseForm() //nolint:errcheck
p := capabilities.Policy{Capability: r.PostFormValue("capability"), TargetPattern: r.PostFormValue("target_pattern"), Effect: capabilities.Effect(r.PostFormValue("effect")), Reason: r.PostFormValue("reason")}
if v := r.PostFormValue("mind_id"); v != "" {
p.MindID, _ = ids.Parse(v)
}
if hf, err := strconv.Atoi(r.PostFormValue("hours_from")); err == nil {
if ht, err := strconv.Atoi(r.PostFormValue("hours_to")); err == nil {
p.HoursFrom, p.HoursTo = &hf, &ht
}
}
if _, err := u.policyF(r, p); err != nil {
u.flash(w, r, "error", err.Error(), "/policies")
return
}
u.flash(w, r, "ok", "Rule added.", "/policies")
}
func (u *UI) policyDelete(w http.ResponseWriter, r *http.Request) {
id, err := ids.Parse(r.PathValue("id"))
if err != nil || u.deps.Capabilities.DeletePolicy(r.Context(), id) != nil {
u.flash(w, r, "error", "Rule not found.", "/policies")
return
}
u.deps.Audit.Log(r.Context(), "policy.delete", "policy", id.String(), nil)
u.flash(w, r, "ok", "Rule deleted.", "/policies")
}
func (u *UI) grantRevoke(w http.ResponseWriter, r *http.Request) {
id, err := ids.Parse(r.PathValue("id"))
if err != nil || u.deps.Capabilities.RevokeGrant(r.Context(), id) != nil {
u.flash(w, r, "error", "Grant not found.", "/policies")
return
}
u.deps.Audit.Log(r.Context(), "grant.revoke", "grant", id.String(), nil)
u.flash(w, r, "ok", "Session grant revoked.", "/policies")
}
func (u *UI) approvalsPage(w http.ResponseWriter, r *http.Request) {
names, _ := u.mindNames(r)
pending, _ := u.deps.Capabilities.Approvals(r.Context(), ids.Nil, "pending", 100)
all, _ := u.deps.Capabilities.Approvals(r.Context(), ids.Nil, "", 60)
var decided []capabilities.Approval
for _, a := range all {
if a.Status != "pending" {
decided = append(decided, a)
}
}
execs, _ := u.deps.Capabilities.Executions(r.Context(), ids.Nil, 30)
u.render(w, r, "approvals", "Approval centre", map[string]any{"Pending": pending, "Decided": decided, "Executions": execs, "MindNames": names})
}
func (u *UI) approvalDecide(w http.ResponseWriter, r *http.Request) {
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
u.flash(w, r, "error", "Bad request id.", "/approvals")
return
}
r.ParseForm() //nolint:errcheck
var params map[string]any
if raw := strings.TrimSpace(r.PostFormValue("params")); raw != "" {
if err := json.Unmarshal([]byte(raw), &params); err != nil {
u.flash(w, r, "error", "Parameters are not a JSON object.", "/approvals")
return
}
// Only treat as a modification when it differs from the original.
if a, err := u.deps.Capabilities.GetApproval(r.Context(), id); err == nil {
orig, _ := json.Marshal(a.Params)
now, _ := json.Marshal(params)
if string(orig) == string(now) {
params = nil
}
}
}
decision := r.PostFormValue("decision")
if _, err := u.decideF(r, id, decision, r.PostFormValue("note"), params); err != nil {
u.flash(w, r, "error", err.Error(), "/approvals")
return
}
u.flash(w, r, "ok", "Decision recorded: "+decision+".", "/approvals")
}
+7 -1
View File
@@ -104,7 +104,13 @@ func (u *UI) dashboard(w http.ResponseWriter, r *http.Request) {
names[m.ID] = m.DisplayName
}
evs, _ := u.deps.Events.Query(r.Context(), evstore.Filter{MinSalience: 0.6, Limit: 12})
u.render(w, r, "dashboard", "Dashboard", map[string]any{"Minds": mindsList, "MindNames": names, "Events": evs, "Status": u.statusF(r), "Audit": entries})
pending := 0
if u.deps.Capabilities != nil {
if list, err := u.deps.Capabilities.Approvals(r.Context(), ids.Nil, "pending", 500); err == nil {
pending = len(list)
}
}
u.render(w, r, "dashboard", "Dashboard", map[string]any{"Minds": mindsList, "MindNames": names, "Events": evs, "Status": u.statusF(r), "Audit": entries, "Pending": pending})
}
func (u *UI) system(w http.ResponseWriter, r *http.Request) {
+12 -4
View File
@@ -21,6 +21,7 @@ import (
"github.com/andydixon/noema/internal/attention"
"github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/beliefs"
"github.com/andydixon/noema/internal/capabilities"
"github.com/andydixon/noema/internal/consolidation"
"github.com/andydixon/noema/internal/conversation"
"github.com/andydixon/noema/internal/goals"
@@ -58,6 +59,8 @@ type UI struct {
entF func(r *http.Request, mind, id ids.ID) (api.EntityDetail, error)
mergeF func(r *http.Request, mind ids.ID, e graph.Entity, other string) error
distinctF func(r *http.Request, mind ids.ID, e graph.Entity, other string) error
policyF func(r *http.Request, p capabilities.Policy) (capabilities.Policy, error)
decideF func(r *http.Request, id ids.ID, decision, note string, params map[string]any) (any, error)
}
// Actions groups the server-side operations the UI delegates to the API layer
@@ -80,6 +83,8 @@ type Actions struct {
Entity func(r *http.Request, mind, id ids.ID) (api.EntityDetail, error)
Merge func(r *http.Request, mind ids.ID, e graph.Entity, other string) error
Distinct func(r *http.Request, mind ids.ID, e graph.Entity, other string) error
Policy func(r *http.Request, p capabilities.Policy) (capabilities.Policy, error)
Decide func(r *http.Request, id ids.ID, decision, note string, params map[string]any) (any, error)
}
// View is the data passed to every template.
@@ -109,7 +114,7 @@ func New(deps app.Deps, a Actions) (*UI, error) {
deps.Log.Info("ui: serving templates and static files from ./web (development)")
}
}
u := &UI{deps: deps, static: staticFS, statusF: a.Status, ctxF: a.Context, wsF: a.Workspace, memF: a.Memory, belF: a.Belief, emitF: a.Emitter, postF: a.Post, goalF: a.Goal, goalUpdF: a.GoalUpdate, predF: a.Predict, resolveF: a.Resolve, resetF: a.ResetState, consolF: a.Consolidate, reflF: a.Reflect, entF: a.Entity, mergeF: a.Merge, distinctF: a.Distinct}
u := &UI{deps: deps, static: staticFS, statusF: a.Status, ctxF: a.Context, wsF: a.Workspace, memF: a.Memory, belF: a.Belief, emitF: a.Emitter, postF: a.Post, goalF: a.Goal, goalUpdF: a.GoalUpdate, predF: a.Predict, resolveF: a.Resolve, resetF: a.ResetState, consolF: a.Consolidate, reflF: a.Reflect, entF: a.Entity, mergeF: a.Merge, distinctF: a.Distinct, policyF: a.Policy, decideF: a.Decide}
if err := u.parse(tmplFS); err != nil {
return nil, err
}
@@ -164,7 +169,11 @@ func (u *UI) parse(fsys fs.FS) error {
"llmRoles": func() []llm.RoleInfo { return llm.Roles },
"pretty": prettyRaw,
"entityKinds": func() []graph.Kind { return graph.AllKinds },
"join": strings.Join,
"jsonOf": func(v any) json.RawMessage {
b, _ := json.Marshal(v)
return b
},
"join": strings.Join,
"outputs": func(raw json.RawMessage) []consolidation.Output {
var out []consolidation.Output
json.Unmarshal(raw, &out) //nolint:errcheck
@@ -369,11 +378,10 @@ func (u *UI) Register(m *http.ServeMux) {
u.motivationRoutes(m)
u.cognitionRoutes(m)
u.graphRoutes(m)
u.capabilityRoutes(m)
for _, p := range []struct{ path, title, note string }{
{"/approvals", "Approvals", "The approval centre arrives with the capability system."},
{"/integrations", "Integrations", "Integrations arrive in phase 13."},
{"/policies", "Policies", "The policy matrix arrives in phase 12."},
} {
m.Handle("GET "+p.path, u.require(auth.RoleObserver, func(w http.ResponseWriter, r *http.Request) { u.render(w, r, "placeholder", p.title, p.note) }))
}
+5
View File
@@ -0,0 +1,5 @@
DROP TABLE IF EXISTS tool_executions;
DROP TABLE IF EXISTS session_grants;
DROP TABLE IF EXISTS approval_decisions;
DROP TABLE IF EXISTS approval_requests;
DROP TABLE IF EXISTS policies;
+83
View File
@@ -0,0 +1,83 @@
-- Capability policies, approval requests, session grants and tool executions (ADR-0005).
CREATE TABLE policies (
id uuid PRIMARY KEY,
mind_id uuid REFERENCES minds(id), -- NULL = global
capability text NOT NULL DEFAULT '*',
target_pattern text NOT NULL DEFAULT '*',
action_kind text NOT NULL DEFAULT '*',
effect text NOT NULL CHECK (effect IN ('allow','ask','deny')),
hours_from smallint, -- optional daily window (local hours), NULL = always
hours_to smallint,
reason text NOT NULL DEFAULT '',
created_by text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX policies_mind_idx ON policies (mind_id, capability);
CREATE TABLE approval_requests (
id uuid PRIMARY KEY,
mind_id uuid NOT NULL REFERENCES minds(id),
capability text NOT NULL,
target text NOT NULL DEFAULT '',
params jsonb NOT NULL DEFAULT '{}'::jsonb,
request_hash text NOT NULL,
reason text NOT NULL DEFAULT '',
risk text NOT NULL,
evidence jsonb NOT NULL DEFAULT '[]'::jsonb,
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','approved','denied','expired','executed','failed')),
decision_by text NOT NULL DEFAULT '',
decision_note text NOT NULL DEFAULT '',
decided_at timestamptz,
session_grant boolean NOT NULL DEFAULT false,
modified_params jsonb,
result jsonb,
error text NOT NULL DEFAULT '',
event_id uuid,
correlation_id uuid,
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX approval_requests_status_idx ON approval_requests (status, created_at DESC);
CREATE INDEX approval_requests_mind_idx ON approval_requests (mind_id, created_at DESC);
-- Append-only record of every decision.
CREATE TABLE approval_decisions (
id uuid PRIMARY KEY,
request_id uuid NOT NULL REFERENCES approval_requests(id),
decision text NOT NULL,
decided_by text NOT NULL,
note text NOT NULL DEFAULT '',
details jsonb NOT NULL DEFAULT '{}'::jsonb,
decided_at timestamptz NOT NULL DEFAULT now()
);
CREATE TRIGGER approval_decisions_append_only BEFORE UPDATE OR DELETE ON approval_decisions
FOR EACH ROW EXECUTE FUNCTION noema_forbid_change();
CREATE TABLE session_grants (
id uuid PRIMARY KEY,
mind_id uuid NOT NULL REFERENCES minds(id),
capability text NOT NULL,
target_pattern text NOT NULL DEFAULT '*',
granted_by text NOT NULL,
expires_at timestamptz NOT NULL,
revoked_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX session_grants_mind_idx ON session_grants (mind_id, capability);
CREATE TABLE tool_executions (
id uuid PRIMARY KEY,
mind_id uuid NOT NULL REFERENCES minds(id),
capability text NOT NULL,
target text NOT NULL DEFAULT '',
params jsonb NOT NULL DEFAULT '{}'::jsonb,
result jsonb,
error text NOT NULL DEFAULT '',
duration_ms integer NOT NULL DEFAULT 0,
approval_id uuid REFERENCES approval_requests(id),
event_id uuid,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX tool_executions_mind_idx ON tool_executions (mind_id, created_at DESC);
+8 -2
View File
@@ -21,6 +21,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"
@@ -60,12 +61,12 @@ func start(t *testing.T, deps app.Deps, perceive, respond llm.LanguageModel) *ha
ctx := context.Background()
group := scheduler.NewGroup(ctx, deps.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.LLM.Override = map[llm.Role]llm.LanguageModel{llm.RolePerceive: perceive, llm.RoleRespond: respond}
deps.Brain = brain.NewManager(brain.Config{DB: deps.DB, Events: deps.Events, Minds: deps.Minds, Registry: registry, Pool: scheduler.NewPool(4), Log: deps.Log, Metrics: deps.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)
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, Capabilities: deps.Capabilities}}, group)
if err := deps.Brain.Start(ctx); err != nil {
t.Fatal(err)
}
@@ -115,6 +116,11 @@ func TestFirstMilestone(t *testing.T) {
Conversations: conversation.NewStore(pool), Goals: goals.NewStore(pool), Motivation: motivation.NewStore(pool), Predictions: predictions.NewStore(pool), Secrets: sec, LLMStore: llmStore, LLM: router.NewRouter(llmStore, sec, log, nil, 2), Prompts: prompts.NewStore(pool), Migrations: migrations.FS, Started: time.Now()}
deps.Resolver = &predictions.Resolver{Store: deps.Predictions, Events: deps.Events, Beliefs: deps.Beliefs, Log: log}
deps.Graph = graph.NewStore(pool)
capReg := capabilities.NewRegistry()
for _, c := range capabilities.Builtins() {
capReg.Add(c)
}
deps.Capabilities = &capabilities.Service{Registry: capReg, DB: pool, Events: deps.Events, Log: log}
deps.Narrative = narrative.NewStore(pool)
deps.Notifications = notifications.NewStore(pool)
deps.Consolidation = &consolidation.Service{DB: pool, Memory: deps.Memory, Beliefs: deps.Beliefs, Events: deps.Events, Minds: deps.Minds, Log: log}
+8
View File
@@ -216,3 +216,11 @@ canvas.graph { width: 100%; height: 560px; display: block; border-radius: 8px; b
.legend { display: flex; flex-wrap: wrap; gap: .6rem; padding: .4rem .3rem 0; font-size: .78rem; color: var(--muted); }
.legend-item i { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: .3rem; vertical-align: middle; }
.legend-item i.k-person { background: #2f6fed; } .legend-item i.k-system { background: #0ea5a4; } .legend-item i.k-project { background: #16a34a; } .legend-item i.k-document { background: #d97706; } .legend-item i.k-concept { background: #8b5cf6; } .legend-item i.k-repository { background: #64748b; } .legend-item i.k-organisation { background: #db2777; } .legend-item i.k-place { background: #0891b2; } .legend-item i.k-other { background: #9ca3af; }
/* Capabilities */
.badge.rk-low { background: var(--ok-soft); color: var(--ok); } .badge.rk-medium { background: var(--warn-soft); color: var(--warn); } .badge.rk-high { background: var(--danger-soft); color: var(--danger); } .badge.rk-critical { background: var(--danger); color: #fff; }
.badge.ef-allow { background: var(--ok-soft); color: var(--ok); } .badge.ef-ask { background: var(--warn-soft); color: var(--warn); } .badge.ef-deny { background: var(--danger-soft); color: var(--danger); }
.badge.ap-executed, .badge.ap-approved { background: var(--ok-soft); color: var(--ok); } .badge.ap-denied, .badge.ap-failed { background: var(--danger-soft); color: var(--danger); } .badge.ap-expired { background: var(--surface-2); color: var(--muted); }
.tablewrap { overflow-x: auto; }
.approval { border: 1px solid var(--border); border-radius: 8px; padding: .8rem 1rem; margin-bottom: .8rem; }
.approval-head { margin-bottom: .5rem; }
+33
View File
@@ -0,0 +1,33 @@
{{define "content"}}
<div class="pagehead"><h1>Approval centre</h1><p class="muted">Actions a mind wants to take that policy marks ASK. Approvals are bound to the exact request; modified parameters are re-validated and recorded.</p></div>
{{$op := .Principal.User.Role.AtLeast "operator"}}
<section class="card">
<h2>Pending <span class="muted small">{{len .Data.Pending}}</span></h2>
{{if .Data.Pending}}{{range .Data.Pending}}
<div class="approval">
<div class="approval-head"><span class="badge rk-{{.Risk}}">{{.Risk}}</span> <strong>{{index $.Data.MindNames .MindID}}</strong> wants <code>{{.Capability}}</code>{{if .Target}} on <span class="mono">{{.Target}}</span>{{end}} <span class="muted small">· requested {{ago .CreatedAt}} · expires {{.ExpiresAt.Local.Format "02 Jan 15:04"}}{{if .EventID}} · <a href="/events/{{.EventID}}">event</a>{{end}}</span></div>
<div class="grid cols-2 tight">
<div><h3>Reason</h3><p>{{if .Reason}}{{.Reason}}{{else}}<em class="muted">none given</em>{{end}}</p>
{{if .Evidence}}<h3>Evidence</h3><ul class="plain small">{{range .Evidence}}<li><span class="badge">{{.Kind}}</span> {{if eq .Kind "event"}}<a href="/events/{{.ID}}">{{.ID}}</a>{{else}}{{.ID}}{{end}} {{.Note}}</li>{{end}}</ul>{{end}}</div>
<div><h3>Parameters</h3><pre class="json">{{pretty (jsonOf .Params)}}</pre></div>
</div>
{{if $op}}
<form method="post" action="/approvals/{{.ID}}/decide" class="stack">
<input type="hidden" name="_csrf" value="{{$.CSRF}}">
<details><summary class="small">Modify parameters before approving</summary><textarea name="params" rows="3" class="mono" placeholder="JSON object; leave empty to keep">{{pretty (jsonOf .Params)}}</textarea></details>
<div class="row"><input name="note" placeholder="note (shown to the mind)" class="grow" maxlength="500">
<button class="btn btn-primary" name="decision" value="approve">Approve once</button>
<button class="btn" name="decision" value="session" title="Also allow matching requests for this session">Approve for session</button>
<button class="btn btn-danger" name="decision" value="deny">Deny</button></div>
</form>
{{end}}
</div>{{end}}
{{else}}<p class="muted">Nothing is waiting.</p>{{end}}
</section>
<section class="grid cols-2">
<div class="card"><h2>Decided recently</h2>
{{if .Data.Decided}}<table class="table compact"><thead><tr><th>Status</th><th>Mind</th><th>Action</th><th>By</th><th>Note</th><th>When</th></tr></thead><tbody>{{range .Data.Decided}}<tr><td><span class="badge ap-{{.Status}}">{{.Status}}</span></td><td class="small">{{index $.Data.MindNames .MindID}}</td><td><code>{{.Capability}}</code> <span class="mono small">{{.Target}}</span>{{if .Error}}<div class="small danger">{{.Error}}</div>{{end}}</td><td class="small">{{.DecisionBy}}</td><td class="small">{{.DecisionNote}}</td><td class="small muted nowrap">{{ago .CreatedAt}}</td></tr>{{end}}</tbody></table>{{else}}<p class="muted">None.</p>{{end}}</div>
<div class="card"><h2>Executions</h2>
{{if .Data.Executions}}<table class="table compact"><thead><tr><th>Mind</th><th>Capability</th><th>Target</th><th>Result</th><th class="num">ms</th><th></th></tr></thead><tbody>{{range .Data.Executions}}<tr><td class="small">{{index $.Data.MindNames .MindID}}</td><td><code>{{.Capability}}</code></td><td class="mono small">{{.Target}}</td><td class="small">{{if .Error}}<span class="danger">{{trunc .Error 80}}</span>{{else}}{{payloadField .Result "summary"}}{{end}}</td><td class="num">{{.DurationMS}}</td><td>{{if .EventID}}<a class="small" href="/events/{{.EventID}}">event</a>{{end}}</td></tr>{{end}}</tbody></table>{{else}}<p class="muted">None yet.</p>{{end}}</div>
</section>
{{end}}
+1 -1
View File
@@ -3,7 +3,7 @@
<section class="grid cols-3">
<div class="card stat"><div class="stat-label">Active minds</div><div class="stat-value">{{len .Data.Minds}}</div><div class="muted small">{{range .Data.Minds}}<a href="/minds/{{.Slug}}">{{.DisplayName}}</a> {{else}}No minds yet. Create one under Minds.{{end}}</div></div>
<div class="card stat"><div class="stat-label">System health</div><div class="stat-value">{{if .Data.Status.Database.OK}}<span class="ok">Healthy</span>{{else}}<span class="danger">Degraded</span>{{end}}</div><div class="muted small">Database {{if .Data.Status.Database.OK}}connected{{else}}unavailable{{end}} · up {{duration .Data.Status.UptimeSeconds}}</div></div>
<div class="card stat"><div class="stat-label">Outstanding approvals</div><div class="stat-value">0</div><div class="muted small">Capability layer arrives in a later phase.</div></div>
<div class="card stat"><div class="stat-label">Outstanding approvals</div><div class="stat-value">{{.Data.Pending}}</div><div class="muted small"><a href="/approvals">Approval centre</a> · <a href="/policies">policies</a></div></div>
</section>
<section class="grid cols-2">
<div class="card">
+41
View File
@@ -0,0 +1,41 @@
{{define "content"}}
<div class="pagehead"><h1>Capabilities and policies</h1><p class="muted">No action reaches the world except through a capability, and every capability is governed by ALLOW / ASK / DENY rules. The most specific rule wins; on ties the stricter effect wins. Locked capabilities cannot be relaxed.</p></div>
{{$admin := .Principal.User.Role.AtLeast "administrator"}}
<section class="card">
<h2>Effective policy matrix <span class="muted small">mind × capability, for a generic target right now</span></h2>
<div class="tablewrap"><table class="table compact matrix">
<thead><tr><th>Capability</th><th>Risk</th><th>Default</th>{{range .Data.Minds}}<th>{{.DisplayName}}</th>{{end}}</tr></thead>
<tbody>{{range $c := .Data.Capabilities}}<tr>
<td><code>{{$c.Name}}</code>{{if $c.Locked}} <span class="badge danger">locked</span>{{end}}<div class="small muted">{{$c.Description}}</div></td>
<td><span class="badge rk-{{$c.Risk}}">{{$c.Risk}}</span></td><td><span class="badge ef-{{$c.DefaultEffect}}">{{$c.DefaultEffect}}</span></td>
{{range $m := $.Data.Minds}}{{$e := index $.Data.Matrix $m.ID $c.Name}}<td><span class="badge ef-{{$e.Effect}}" title="{{$e.Reason}}">{{$e.Effect}}</span></td>{{end}}
</tr>{{end}}</tbody>
</table></div>
</section>
<section class="grid cols-3-1">
<div class="card">
<h2>Rules</h2>
{{if .Data.Policies}}<table class="table compact"><thead><tr><th>Scope</th><th>Capability</th><th>Target</th><th>Action</th><th>Hours</th><th>Effect</th><th>Reason</th><th>By</th>{{if $admin}}<th></th>{{end}}</tr></thead>
<tbody>{{range .Data.Policies}}<tr><td class="small">{{if .MindID}}{{index $.Data.MindNames .MindID}}{{else}}<em>global</em>{{end}}</td><td><code>{{.Capability}}</code></td><td class="mono small">{{.TargetPattern}}</td><td class="small">{{.ActionKind}}</td><td class="small">{{if .HoursFrom}}{{deref_int .HoursFrom}}–{{deref_int .HoursTo}}{{else}}always{{end}}</td><td><span class="badge ef-{{.Effect}}">{{.Effect}}</span></td><td class="small">{{.Reason}}</td><td class="small muted">{{.CreatedBy}}</td>
{{if $admin}}<td><form method="post" action="/policies/{{.ID}}/delete" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="btn small btn-danger" data-confirm="Delete this rule?">Delete</button></form></td>{{end}}</tr>{{end}}</tbody></table>
{{else}}<p class="muted">No rules yet: every capability uses its default (ASK for reads and writes, DENY for locked).</p>{{end}}
{{if .Data.Grants}}<h3>Active session grants</h3><table class="table compact"><tbody>{{range .Data.Grants}}<tr><td>{{index $.Data.MindNames .MindID}}</td><td><code>{{.Capability}}</code></td><td class="mono small">{{.TargetPattern}}</td><td class="small muted">by {{.GrantedBy}} until {{.ExpiresAt.Local.Format "15:04"}}</td><td>{{if $.Principal.User.Role.AtLeast "operator"}}<form method="post" action="/policies/grants/{{.ID}}/revoke" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="btn small">Revoke</button></form>{{end}}</td></tr>{{end}}</tbody></table>{{end}}
</div>
{{if $admin}}
<div class="card">
<h2>Add rule</h2>
<form method="post" action="/policies" class="stack">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<label>Scope <select name="mind_id"><option value="">global</option>{{range .Data.Minds}}<option value="{{.ID}}">{{.DisplayName}}</option>{{end}}</select></label>
<label>Capability <select name="capability"><option value="*">* (all)</option>{{range .Data.Capabilities}}<option value="{{.Name}}">{{.Name}}</option>{{end}}</select></label>
<label>Target pattern <input name="target_pattern" value="*" placeholder="web-* or https://example.com/*"></label>
<label>Effect <select name="effect"><option value="ask">ask</option><option value="allow">allow</option><option value="deny">deny</option></select></label>
<div class="grid cols-2 tight"><label>Hours from <input type="number" name="hours_from" min="0" max="23" placeholder="any"></label><label>to <input type="number" name="hours_to" min="0" max="23"></label></div>
<label>Reason <input name="reason" maxlength="200"></label>
<div><button class="btn btn-primary" type="submit">Add rule</button></div>
</form>
<p class="muted small">ALLOW rules on high-risk capabilities are shown with a warning in the matrix. sudo_shell and delete_database are locked to DENY.</p>
</div>
{{end}}
</section>
{{end}}