From b35dbe02998162174450b199a8b0d28cbc2c3bba Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Mon, 29 Jun 2026 20:28:05 +0100 Subject: [PATCH] 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. --- CHANGELOG.md | 1 + PLAN.md | 8 +- cmd/noemactl/capabilities.go | 156 +++++ cmd/noemad/main.go | 14 +- docs/capabilities.md | 33 ++ internal/api/api_test.go | 6 + internal/api/capabilities.go | 303 ++++++++++ internal/api/server.go | 1 + internal/app/app.go | 2 + internal/brain/process.go | 18 +- internal/capabilities/builtin.go | 94 +++ internal/capabilities/capabilities.go | 346 +++++++++++ internal/capabilities/capabilities_test.go | 179 ++++++ internal/capabilities/service.go | 648 +++++++++++++++++++++ internal/cognition/action.go | 100 ++++ internal/cognition/handlers.go | 1 + internal/cognition/respond.go | 18 +- internal/cognition/selfmodel.go | 71 +++ internal/notifications/notifications.go | 2 + internal/ui/capabilities.go | 134 +++++ internal/ui/pages.go | 8 +- internal/ui/ui.go | 16 +- migrations/0012_capabilities.down.sql | 5 + migrations/0012_capabilities.up.sql | 83 +++ tests/m1/m1_test.go | 10 +- web/static/app.css | 8 + web/templates/pages/approvals.html | 33 ++ web/templates/pages/dashboard.html | 2 +- web/templates/pages/policies.html | 41 ++ 29 files changed, 2318 insertions(+), 23 deletions(-) create mode 100644 cmd/noemactl/capabilities.go create mode 100644 docs/capabilities.md create mode 100644 internal/api/capabilities.go create mode 100644 internal/capabilities/builtin.go create mode 100644 internal/capabilities/capabilities.go create mode 100644 internal/capabilities/capabilities_test.go create mode 100644 internal/capabilities/service.go create mode 100644 internal/cognition/action.go create mode 100644 internal/cognition/selfmodel.go create mode 100644 internal/ui/capabilities.go create mode 100644 migrations/0012_capabilities.down.sql create mode 100644 migrations/0012_capabilities.up.sql create mode 100644 web/templates/pages/approvals.html create mode 100644 web/templates/pages/policies.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 4abd42c..e35d271 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/PLAN.md b/PLAN.md index ae834db..202e126 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 diff --git a/cmd/noemactl/capabilities.go b/cmd/noemactl/capabilities.go new file mode 100644 index 0000000..f7cb82a --- /dev/null +++ b/cmd/noemactl/capabilities.go @@ -0,0 +1,156 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "text/tabwriter" +) + +func init() { + extraCommands = append(extraCommands, + command{"approvals", "list|approve [note]|session |deny [note] – approval centre", cmdApprovals}, + command{"policy", "list|add -mind m -capability c -target t -effect e [-reason r]|delete – capability policies", cmdPolicy}, + command{"invoke", " [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 [note] | session [note] | deny [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 ") + 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 [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]), ¶ms); 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 +} diff --git a/cmd/noemad/main.go b/cmd/noemad/main.go index b78176e..02b3d30 100644 --- a/cmd/noemad/main.go +++ b/cmd/noemad/main.go @@ -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) }) diff --git a/docs/capabilities.md b/docs/capabilities.md new file mode 100644 index 0000000..98197f5 --- /dev/null +++ b/docs/capabilities.md @@ -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 " → `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. diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 8b10d27..f105696 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -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} diff --git a/internal/api/capabilities.go b/internal/api/capabilities.go new file mode 100644 index 0000000..54d1e06 --- /dev/null +++ b/internal/api/capabilities.go @@ -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, ¶ms); 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}) +} diff --git a/internal/api/server.go b/internal/api/server.go index e8a42aa..2ae6e15 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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") diff --git a/internal/app/app.go b/internal/app/app.go index 2645dec..1efd89f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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 diff --git a/internal/brain/process.go b/internal/brain/process.go index dd80d55..bb82ba3 100644 --- a/internal/brain/process.go +++ b/internal/brain/process.go @@ -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. diff --git a/internal/capabilities/builtin.go b/internal/capabilities/builtin.go new file mode 100644 index 0000000..a68d930 --- /dev/null +++ b/internal/capabilities/builtin.go @@ -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()), " ") +} diff --git a/internal/capabilities/capabilities.go b/internal/capabilities/capabilities.go new file mode 100644 index 0000000..5ab2f39 --- /dev/null +++ b/internal/capabilities/capabilities.go @@ -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 +} diff --git a/internal/capabilities/capabilities_test.go b/internal/capabilities/capabilities_test.go new file mode 100644 index 0000000..41819d5 --- /dev/null +++ b/internal/capabilities/capabilities_test.go @@ -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)) + } +} diff --git a/internal/capabilities/service.go b/internal/capabilities/service.go new file mode 100644 index 0000000..d4466fa --- /dev/null +++ b/internal/capabilities/service.go @@ -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, ¶ms, &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, ¶ms, &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), " ") } diff --git a/internal/cognition/action.go b/internal/cognition/action.go new file mode 100644 index 0000000..7dcc015 --- /dev/null +++ b/internal/cognition/action.go @@ -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 " → 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 +} diff --git a/internal/cognition/handlers.go b/internal/cognition/handlers.go index 467c51e..c0b4b44 100644 --- a/internal/cognition/handlers.go +++ b/internal/cognition/handlers.go @@ -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. diff --git a/internal/cognition/respond.go b/internal/cognition/respond.go index 85708e4..c5308e5 100644 --- a/internal/cognition/respond.go +++ b/internal/cognition/respond.go @@ -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": diff --git a/internal/cognition/selfmodel.go b/internal/cognition/selfmodel.go new file mode 100644 index 0000000..62296c7 --- /dev/null +++ b/internal/cognition/selfmodel.go @@ -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}}) + } +} diff --git a/internal/notifications/notifications.go b/internal/notifications/notifications.go index 13436e8..8c9cb97 100644 --- a/internal/notifications/notifications.go +++ b/internal/notifications/notifications.go @@ -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: diff --git a/internal/ui/capabilities.go b/internal/ui/capabilities.go new file mode 100644 index 0000000..336164d --- /dev/null +++ b/internal/ui/capabilities.go @@ -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), ¶ms); 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") +} diff --git a/internal/ui/pages.go b/internal/ui/pages.go index 76f050c..e4cc22b 100644 --- a/internal/ui/pages.go +++ b/internal/ui/pages.go @@ -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) { diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 2663394..793e132 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -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) })) } diff --git a/migrations/0012_capabilities.down.sql b/migrations/0012_capabilities.down.sql new file mode 100644 index 0000000..113d26a --- /dev/null +++ b/migrations/0012_capabilities.down.sql @@ -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; diff --git a/migrations/0012_capabilities.up.sql b/migrations/0012_capabilities.up.sql new file mode 100644 index 0000000..90458a8 --- /dev/null +++ b/migrations/0012_capabilities.up.sql @@ -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); diff --git a/tests/m1/m1_test.go b/tests/m1/m1_test.go index 3c2873f..0a674b0 100644 --- a/tests/m1/m1_test.go +++ b/tests/m1/m1_test.go @@ -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} diff --git a/web/static/app.css b/web/static/app.css index 97285de..1e779e6 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -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; } diff --git a/web/templates/pages/approvals.html b/web/templates/pages/approvals.html new file mode 100644 index 0000000..15b3ed4 --- /dev/null +++ b/web/templates/pages/approvals.html @@ -0,0 +1,33 @@ +{{define "content"}} +

Approval centre

Actions a mind wants to take that policy marks ASK. Approvals are bound to the exact request; modified parameters are re-validated and recorded.

+{{$op := .Principal.User.Role.AtLeast "operator"}} +
+

Pending {{len .Data.Pending}}

+ {{if .Data.Pending}}{{range .Data.Pending}} +
+
{{.Risk}} {{index $.Data.MindNames .MindID}} wants {{.Capability}}{{if .Target}} on {{.Target}}{{end}} · requested {{ago .CreatedAt}} · expires {{.ExpiresAt.Local.Format "02 Jan 15:04"}}{{if .EventID}} · event{{end}}
+
+

Reason

{{if .Reason}}{{.Reason}}{{else}}none given{{end}}

+ {{if .Evidence}}

Evidence

    {{range .Evidence}}
  • {{.Kind}} {{if eq .Kind "event"}}{{.ID}}{{else}}{{.ID}}{{end}} {{.Note}}
  • {{end}}
{{end}}
+

Parameters

{{pretty (jsonOf .Params)}}
+
+ {{if $op}} +
+ +
Modify parameters before approving
+
+ + +
+
+ {{end}} +
{{end}} + {{else}}

Nothing is waiting.

{{end}} +
+
+

Decided recently

+ {{if .Data.Decided}}{{range .Data.Decided}}{{end}}
StatusMindActionByNoteWhen
{{.Status}}{{index $.Data.MindNames .MindID}}{{.Capability}} {{.Target}}{{if .Error}}
{{.Error}}
{{end}}
{{.DecisionBy}}{{.DecisionNote}}{{ago .CreatedAt}}
{{else}}

None.

{{end}}
+

Executions

+ {{if .Data.Executions}}{{range .Data.Executions}}{{end}}
MindCapabilityTargetResultms
{{index $.Data.MindNames .MindID}}{{.Capability}}{{.Target}}{{if .Error}}{{trunc .Error 80}}{{else}}{{payloadField .Result "summary"}}{{end}}{{.DurationMS}}{{if .EventID}}event{{end}}
{{else}}

None yet.

{{end}}
+
+{{end}} diff --git a/web/templates/pages/dashboard.html b/web/templates/pages/dashboard.html index 95df98e..94179b7 100644 --- a/web/templates/pages/dashboard.html +++ b/web/templates/pages/dashboard.html @@ -3,7 +3,7 @@
Active minds
{{len .Data.Minds}}
{{range .Data.Minds}}{{.DisplayName}} {{else}}No minds yet. Create one under Minds.{{end}}
System health
{{if .Data.Status.Database.OK}}Healthy{{else}}Degraded{{end}}
Database {{if .Data.Status.Database.OK}}connected{{else}}unavailable{{end}} · up {{duration .Data.Status.UptimeSeconds}}
-
Outstanding approvals
0
Capability layer arrives in a later phase.
+
Outstanding approvals
{{.Data.Pending}}
diff --git a/web/templates/pages/policies.html b/web/templates/pages/policies.html new file mode 100644 index 0000000..ea39fe7 --- /dev/null +++ b/web/templates/pages/policies.html @@ -0,0 +1,41 @@ +{{define "content"}} +

Capabilities and policies

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.

+{{$admin := .Principal.User.Role.AtLeast "administrator"}} +
+

Effective policy matrix mind × capability, for a generic target right now

+
+ {{range .Data.Minds}}{{end}} + {{range $c := .Data.Capabilities}} + + + {{range $m := $.Data.Minds}}{{$e := index $.Data.Matrix $m.ID $c.Name}}{{end}} + {{end}} +
CapabilityRiskDefault{{.DisplayName}}
{{$c.Name}}{{if $c.Locked}} locked{{end}}
{{$c.Description}}
{{$c.Risk}}{{$c.DefaultEffect}}{{$e.Effect}}
+
+
+
+

Rules

+ {{if .Data.Policies}}{{if $admin}}{{end}} + {{range .Data.Policies}} + {{if $admin}}{{end}}{{end}}
ScopeCapabilityTargetActionHoursEffectReasonBy
{{if .MindID}}{{index $.Data.MindNames .MindID}}{{else}}global{{end}}{{.Capability}}{{.TargetPattern}}{{.ActionKind}}{{if .HoursFrom}}{{deref_int .HoursFrom}}–{{deref_int .HoursTo}}{{else}}always{{end}}{{.Effect}}{{.Reason}}{{.CreatedBy}}
+ {{else}}

No rules yet: every capability uses its default (ASK for reads and writes, DENY for locked).

{{end}} + {{if .Data.Grants}}

Active session grants

{{range .Data.Grants}}{{end}}
{{index $.Data.MindNames .MindID}}{{.Capability}}{{.TargetPattern}}by {{.GrantedBy}} until {{.ExpiresAt.Local.Format "15:04"}}{{if $.Principal.User.Role.AtLeast "operator"}}
{{end}}
{{end}} +
+ {{if $admin}} +
+

Add rule

+
+ + + + + +
+ +
+
+

ALLOW rules on high-risk capabilities are shown with a warning in the matrix. sudo_shell and delete_database are locked to DENY.

+
+ {{end}} +
+{{end}}