security: add the capability and policy layer as the only path to action
Phase 12 (ADR-0005). Capabilities are registered in code with parameter schemas, risk classes and default effects; sudo_shell and delete_database are locked to DENY and cannot be relaxed. Policies are scoped globally or per mind with capability, target glob, action kind and optional hour window; evaluation is deterministic, most specific wins, and ties go to the stricter effect. Invoke is the single entry point: it refuses dreaming and replay contexts, validates parameters (unknown keys, non http URLs and path traversal rejected), evaluates policy and session grants, hashes the exact request and records a policy_decision event. ALLOW executes with timeout and panic isolation and turns textual output into a tool-trust observation; ASK creates an approval request bound to the hash with an alert notification; DENY is counted and recorded. Operators approve once, approve for a time-boxed session grant, deny with a note, or modify parameters (re-validated, re-hashed, original kept in the append-only decision log). The self-model's capability list is recomputed from effective policy on start and on every policy change. The act process maps fetch requests to http.fetch and the action handler invokes through the layer; replies report capabilities and outcomes. Policy matrix and approval centre pages, dashboard count, API, CLI (approvals, policy, invoke) and docs/capabilities.md.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
)
|
||||
|
||||
func init() {
|
||||
extraCommands = append(extraCommands,
|
||||
command{"approvals", "list|approve <id> [note]|session <id>|deny <id> [note] – approval centre", cmdApprovals},
|
||||
command{"policy", "list|add -mind m -capability c -target t -effect e [-reason r]|delete <id> – capability policies", cmdPolicy},
|
||||
command{"invoke", "<mind> <capability> [target] [json-params] – invoke a capability through policy", cmdInvoke},
|
||||
)
|
||||
}
|
||||
|
||||
func cmdApprovals(args []string) int {
|
||||
if len(args) < 1 {
|
||||
fmt.Fprintln(os.Stderr, "usage: noemactl approvals list | approve <id> [note] | session <id> [note] | deny <id> [note]")
|
||||
return exitUsage
|
||||
}
|
||||
switch args[0] {
|
||||
case "list":
|
||||
var out struct{ Items []map[string]any }
|
||||
if code, err := apiRequest("GET", "/api/v1/approvals?status=pending", nil, &out); err != nil {
|
||||
return exitFor(code, err)
|
||||
}
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "RISK\tCAPABILITY\tTARGET\tREASON\tID")
|
||||
for _, a := range out.Items {
|
||||
fmt.Fprintf(tw, "%v\t%v\t%v\t%v\t%v\n", a["risk"], a["capability"], a["target"], a["reason"], a["id"])
|
||||
}
|
||||
tw.Flush()
|
||||
return exitOK
|
||||
case "approve", "session", "deny":
|
||||
if len(args) < 2 {
|
||||
return exitUsage
|
||||
}
|
||||
note := strings.Join(args[2:], " ")
|
||||
path := "/api/v1/approvals/" + args[1] + "/approve"
|
||||
body := map[string]any{"note": note, "for_session": args[0] == "session"}
|
||||
if args[0] == "deny" {
|
||||
path = "/api/v1/approvals/" + args[1] + "/deny"
|
||||
body = map[string]any{"note": note}
|
||||
}
|
||||
var res map[string]any
|
||||
if code, err := apiRequest("POST", path, body, &res); err != nil {
|
||||
return exitFor(code, err)
|
||||
}
|
||||
if st, ok := res["status"]; ok {
|
||||
fmt.Println("result:", st)
|
||||
if r, ok := res["result"].(map[string]any); ok {
|
||||
fmt.Println(r["summary"])
|
||||
}
|
||||
} else {
|
||||
fmt.Println("recorded")
|
||||
}
|
||||
return exitOK
|
||||
}
|
||||
return exitUsage
|
||||
}
|
||||
|
||||
func cmdPolicy(args []string) int {
|
||||
if len(args) < 1 {
|
||||
fmt.Fprintln(os.Stderr, "usage: noemactl policy list | add -mind m -capability c -target t -effect allow|ask|deny [-reason r] | delete <id>")
|
||||
return exitUsage
|
||||
}
|
||||
switch args[0] {
|
||||
case "list":
|
||||
var out struct{ Items []map[string]any }
|
||||
if code, err := apiRequest("GET", "/api/v1/policies", nil, &out); err != nil {
|
||||
return exitFor(code, err)
|
||||
}
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "SCOPE\tCAPABILITY\tTARGET\tEFFECT\tREASON\tID")
|
||||
for _, p := range out.Items {
|
||||
scope := "global"
|
||||
if m, _ := p["mind_id"].(string); m != "" {
|
||||
scope = m[:8]
|
||||
}
|
||||
fmt.Fprintf(tw, "%v\t%v\t%v\t%v\t%v\t%v\n", scope, p["capability"], p["target_pattern"], p["effect"], p["reason"], p["id"])
|
||||
}
|
||||
tw.Flush()
|
||||
return exitOK
|
||||
case "add":
|
||||
body := map[string]any{"target_pattern": "*"}
|
||||
for i := 1; i+1 < len(args); i += 2 {
|
||||
switch args[i] {
|
||||
case "-mind":
|
||||
var m map[string]any
|
||||
if code, err := apiRequest("GET", "/api/v1/minds/"+args[i+1], nil, &m); err != nil {
|
||||
return exitFor(code, err)
|
||||
}
|
||||
body["mind_id"] = m["id"]
|
||||
case "-capability":
|
||||
body["capability"] = args[i+1]
|
||||
case "-target":
|
||||
body["target_pattern"] = args[i+1]
|
||||
case "-effect":
|
||||
body["effect"] = args[i+1]
|
||||
case "-reason":
|
||||
body["reason"] = args[i+1]
|
||||
}
|
||||
}
|
||||
var p map[string]any
|
||||
if code, err := apiRequest("POST", "/api/v1/policies", body, &p); err != nil {
|
||||
return exitFor(code, err)
|
||||
}
|
||||
fmt.Println("policy", p["id"], "added")
|
||||
return exitOK
|
||||
case "delete":
|
||||
if len(args) < 2 {
|
||||
return exitUsage
|
||||
}
|
||||
if code, err := apiRequest("DELETE", "/api/v1/policies/"+args[1], nil, nil); err != nil {
|
||||
return exitFor(code, err)
|
||||
}
|
||||
fmt.Println("deleted")
|
||||
return exitOK
|
||||
}
|
||||
return exitUsage
|
||||
}
|
||||
|
||||
func cmdInvoke(args []string) int {
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: noemactl invoke <mind> <capability> [target] [json-params]")
|
||||
return exitUsage
|
||||
}
|
||||
body := map[string]any{"capability": args[1]}
|
||||
if len(args) > 2 {
|
||||
body["target"] = args[2]
|
||||
}
|
||||
if len(args) > 3 {
|
||||
var params map[string]any
|
||||
if err := json.Unmarshal([]byte(args[3]), ¶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
|
||||
}
|
||||
+12
-2
@@ -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) })
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Capabilities and policy
|
||||
|
||||
Nothing a mind reasons about can touch the world except through `capabilities.Service.Invoke` (ADR-0005). There is no other execution path, and there is no capability that edits policies, so a mind cannot escalate.
|
||||
|
||||
## Capabilities
|
||||
|
||||
A capability is registered in code with a name, description, risk class (low, medium, high, critical), target kind, a parameter schema (unknown parameters are rejected; URLs must be http(s); paths may not traverse), a default effect and an executor. Built-ins: `noema.note` (allow), `http.fetch` (ask; read-only through the SSRF-safe client), `internet_search` and `send_email` (ask; need integrations), and the locked `sudo_shell` and `delete_database`, which exist only so they can be named in policy and listed as unavailable in the self-model. Locked capabilities always deny and cannot be relaxed by any rule.
|
||||
|
||||
## Policy
|
||||
|
||||
Rules live in `policies` with scope (global or one mind), capability (or `*`), a target glob, an action kind and an optional daily hour window. Evaluation is deterministic: the most specific matching rule wins (mind 8, capability 4, target 2, action 1, window 1), and on ties the stricter effect wins (DENY > ASK > ALLOW). With no matching rule the capability's default applies. Every invocation writes a `policy_decision` event naming the rule and its specificity.
|
||||
|
||||
The policy page shows the effective matrix (mind × capability) for a generic target right now, the rules, and active session grants.
|
||||
|
||||
## Invocation
|
||||
|
||||
`Invoke` refuses in forbidden contexts (dreaming, replay), validates parameters, evaluates policy and session grants, hashes the exact request, and then:
|
||||
|
||||
- **ALLOW** executes with a timeout and panic isolation, records a `tool_executions` row, emits `tool_executed` or `tool_failed`, and turns any textual output into an **observation with tool trust** so it re-enters cognition as data, never as instructions.
|
||||
- **ASK** creates an approval request (capability, target, parameters, reason, risk, evidence, expiry), emits `tool_requested` and an alert notification.
|
||||
- **DENY** emits `tool_denied` and counts the denial.
|
||||
|
||||
## Approvals
|
||||
|
||||
Operators approve once, approve for the session (a time-boxed grant matching the capability and target), deny with a note, or modify parameters before approving (re-validated and re-hashed; the original hash is kept in the append-only decision record). Approvals execute immediately through the same path and the outcome is stored on the request. Requests expire; a job marks overdue ones.
|
||||
|
||||
## Self-model
|
||||
|
||||
After start-up and after any policy change, each mind's self-model capability list is recomputed from the effective policy and a `self_model_updated` event is emitted when it changed. The reply generator uses it, so a mind says which capabilities it may use and reports action status (pending, denied, executed) instead of claiming to have acted.
|
||||
|
||||
## Cognition
|
||||
|
||||
The `act` process proposes actions from requests it can map deterministically (currently "fetch <url>" → `http.fetch`); integrations add more. Proposals compete for attention like any thought; the admitted `action` handler calls `Invoke` and holds the outcome in working memory.
|
||||
@@ -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}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()), " ")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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), " ") }
|
||||
@@ -0,0 +1,100 @@
|
||||
package cognition
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/andydixon/noema/internal/attention"
|
||||
"github.com/andydixon/noema/internal/brain"
|
||||
"github.com/andydixon/noema/internal/capabilities"
|
||||
"github.com/andydixon/noema/internal/conversation"
|
||||
"github.com/andydixon/noema/internal/workspace"
|
||||
"github.com/andydixon/noema/pkg/events"
|
||||
"github.com/andydixon/noema/pkg/ids"
|
||||
)
|
||||
|
||||
// Act proposes capability invocations from requests the mind receives. In
|
||||
// this phase the only deterministic mapping is "fetch <url>" → http.fetch;
|
||||
// integrations register more. Proposing is free; executing goes through the
|
||||
// policy layer, so a request never bypasses ALLOW / ASK / DENY.
|
||||
type Act struct{}
|
||||
|
||||
// Name implements brain.Process.
|
||||
func (Act) Name() string { return "act" }
|
||||
|
||||
var urlRe = regexp.MustCompile(`https?://[^\s<>"']+`)
|
||||
|
||||
// Observe implements brain.Process.
|
||||
func (Act) Observe(_ context.Context, s *brain.State) ([]brain.Candidate, error) {
|
||||
if s.Services.Capabilities == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var out []brain.Candidate
|
||||
for _, ev := range s.NewEvents {
|
||||
if ev.Type != events.Observation || ev.Metadata["conversation_id"] == nil {
|
||||
continue
|
||||
}
|
||||
var p events.ObservationPayload
|
||||
if json.Unmarshal(ev.Payload, &p) != nil {
|
||||
continue
|
||||
}
|
||||
a := conversation.Analyse(p.Text)
|
||||
if a.Intent != "request" {
|
||||
continue
|
||||
}
|
||||
lower := strings.ToLower(p.Text)
|
||||
if u := urlRe.FindString(p.Text); u != "" && (strings.Contains(lower, "fetch") || strings.Contains(lower, "read") || strings.Contains(lower, "look at") || strings.Contains(lower, "check")) {
|
||||
u = strings.TrimRight(u, ".,)")
|
||||
out = append(out, brain.Candidate{Kind: "action", Subject: "http.fetch:" + u, Summary: "proposed action: fetch " + u,
|
||||
Content: map[string]any{"capability": "http.fetch", "target": u, "params": map[string]any{"url": u}, "reason": "asked by " + p.Actor + ": " + truncate(p.Text, 120), "source_event": string(ev.ID), "conversation_id": ev.Metadata["conversation_id"]},
|
||||
Features: attention.Features{Salience: 0.7, Urgency: 0.6, Consequence: 0.7, Confidence: 0.9, OperatorPriority: prioFor(p.Trust), ContextRelevance: 0.5},
|
||||
Confidence: 0.9, SourceEvents: []ids.ID{ev.ID}, Evidence: []events.EvidenceRef{{Kind: "event", ID: string(ev.ID), Note: "request"}}})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func prioFor(t events.Trust) float64 {
|
||||
if t == events.TrustOperator {
|
||||
return 1
|
||||
}
|
||||
return 0.7
|
||||
}
|
||||
|
||||
// action invokes the capability layer for an admitted action thought and
|
||||
// records the outcome in working memory. The layer decides; this handler
|
||||
// never executes anything itself.
|
||||
func (h Handlers) action(ctx context.Context, c *brain.Cycle, t workspace.Thought) error {
|
||||
svc := c.State.Services.Capabilities
|
||||
if svc == nil {
|
||||
return nil
|
||||
}
|
||||
capName, _ := t.Content["capability"].(string)
|
||||
target, _ := t.Content["target"].(string)
|
||||
reason, _ := t.Content["reason"].(string)
|
||||
params, _ := t.Content["params"].(map[string]any)
|
||||
var ev []capabilities.EvidenceRef
|
||||
for _, e := range t.Evidence {
|
||||
ev = append(ev, capabilities.EvidenceRef{Kind: e.Kind, ID: e.ID, Note: e.Note})
|
||||
}
|
||||
if c.State.Mode == brain.ModeDreaming {
|
||||
ctx = capabilities.WithForbidden(ctx, "dreaming")
|
||||
}
|
||||
out, err := svc.Invoke(ctx, capabilities.Invocation{MindID: c.State.Mind.ID, Capability: capName, Target: target, Params: params, Reason: reason, Evidence: ev, Correlation: c.State.CycleID})
|
||||
if err != nil {
|
||||
c.Emit(events.New{Type: events.Metacognition, Source: "brain.act", Summary: "action refused before policy: " + err.Error(), Salience: 0.4, Confidence: 1, CausationID: t.EventID, Metadata: map[string]any{"check": "action_invalid", "capability": capName}})
|
||||
return nil
|
||||
}
|
||||
content := fmt.Sprintf("%s on %s: %s", capName, target, out.Status)
|
||||
if out.Status == "pending" {
|
||||
content += " (awaiting operator approval)"
|
||||
}
|
||||
if out.Status == "denied" {
|
||||
content += " (" + out.Decision.Reason + ")"
|
||||
}
|
||||
c.Runtime.Hold(workspace.Item{Key: "action:" + capName + ":" + target, Content: content, Kind: "note", SourceEvent: out.EventID, Ref: string(out.ApprovalID)})
|
||||
return nil
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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}})
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -104,7 +104,13 @@ func (u *UI) dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
names[m.ID] = m.DisplayName
|
||||
}
|
||||
evs, _ := u.deps.Events.Query(r.Context(), evstore.Filter{MinSalience: 0.6, Limit: 12})
|
||||
u.render(w, r, "dashboard", "Dashboard", map[string]any{"Minds": mindsList, "MindNames": names, "Events": evs, "Status": u.statusF(r), "Audit": entries})
|
||||
pending := 0
|
||||
if u.deps.Capabilities != nil {
|
||||
if list, err := u.deps.Capabilities.Approvals(r.Context(), ids.Nil, "pending", 500); err == nil {
|
||||
pending = len(list)
|
||||
}
|
||||
}
|
||||
u.render(w, r, "dashboard", "Dashboard", map[string]any{"Minds": mindsList, "MindNames": names, "Events": evs, "Status": u.statusF(r), "Audit": entries, "Pending": pending})
|
||||
}
|
||||
|
||||
func (u *UI) system(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
+12
-4
@@ -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) }))
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,83 @@
|
||||
-- Capability policies, approval requests, session grants and tool executions (ADR-0005).
|
||||
|
||||
CREATE TABLE policies (
|
||||
id uuid PRIMARY KEY,
|
||||
mind_id uuid REFERENCES minds(id), -- NULL = global
|
||||
capability text NOT NULL DEFAULT '*',
|
||||
target_pattern text NOT NULL DEFAULT '*',
|
||||
action_kind text NOT NULL DEFAULT '*',
|
||||
effect text NOT NULL CHECK (effect IN ('allow','ask','deny')),
|
||||
hours_from smallint, -- optional daily window (local hours), NULL = always
|
||||
hours_to smallint,
|
||||
reason text NOT NULL DEFAULT '',
|
||||
created_by text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX policies_mind_idx ON policies (mind_id, capability);
|
||||
|
||||
CREATE TABLE approval_requests (
|
||||
id uuid PRIMARY KEY,
|
||||
mind_id uuid NOT NULL REFERENCES minds(id),
|
||||
capability text NOT NULL,
|
||||
target text NOT NULL DEFAULT '',
|
||||
params jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
request_hash text NOT NULL,
|
||||
reason text NOT NULL DEFAULT '',
|
||||
risk text NOT NULL,
|
||||
evidence jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','approved','denied','expired','executed','failed')),
|
||||
decision_by text NOT NULL DEFAULT '',
|
||||
decision_note text NOT NULL DEFAULT '',
|
||||
decided_at timestamptz,
|
||||
session_grant boolean NOT NULL DEFAULT false,
|
||||
modified_params jsonb,
|
||||
result jsonb,
|
||||
error text NOT NULL DEFAULT '',
|
||||
event_id uuid,
|
||||
correlation_id uuid,
|
||||
expires_at timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX approval_requests_status_idx ON approval_requests (status, created_at DESC);
|
||||
CREATE INDEX approval_requests_mind_idx ON approval_requests (mind_id, created_at DESC);
|
||||
|
||||
-- Append-only record of every decision.
|
||||
CREATE TABLE approval_decisions (
|
||||
id uuid PRIMARY KEY,
|
||||
request_id uuid NOT NULL REFERENCES approval_requests(id),
|
||||
decision text NOT NULL,
|
||||
decided_by text NOT NULL,
|
||||
note text NOT NULL DEFAULT '',
|
||||
details jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
decided_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TRIGGER approval_decisions_append_only BEFORE UPDATE OR DELETE ON approval_decisions
|
||||
FOR EACH ROW EXECUTE FUNCTION noema_forbid_change();
|
||||
|
||||
CREATE TABLE session_grants (
|
||||
id uuid PRIMARY KEY,
|
||||
mind_id uuid NOT NULL REFERENCES minds(id),
|
||||
capability text NOT NULL,
|
||||
target_pattern text NOT NULL DEFAULT '*',
|
||||
granted_by text NOT NULL,
|
||||
expires_at timestamptz NOT NULL,
|
||||
revoked_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX session_grants_mind_idx ON session_grants (mind_id, capability);
|
||||
|
||||
CREATE TABLE tool_executions (
|
||||
id uuid PRIMARY KEY,
|
||||
mind_id uuid NOT NULL REFERENCES minds(id),
|
||||
capability text NOT NULL,
|
||||
target text NOT NULL DEFAULT '',
|
||||
params jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
result jsonb,
|
||||
error text NOT NULL DEFAULT '',
|
||||
duration_ms integer NOT NULL DEFAULT 0,
|
||||
approval_id uuid REFERENCES approval_requests(id),
|
||||
event_id uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX tool_executions_mind_idx ON tool_executions (mind_id, created_at DESC);
|
||||
+8
-2
@@ -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}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
{{define "content"}}
|
||||
<div class="pagehead"><h1>Approval centre</h1><p class="muted">Actions a mind wants to take that policy marks ASK. Approvals are bound to the exact request; modified parameters are re-validated and recorded.</p></div>
|
||||
{{$op := .Principal.User.Role.AtLeast "operator"}}
|
||||
<section class="card">
|
||||
<h2>Pending <span class="muted small">{{len .Data.Pending}}</span></h2>
|
||||
{{if .Data.Pending}}{{range .Data.Pending}}
|
||||
<div class="approval">
|
||||
<div class="approval-head"><span class="badge rk-{{.Risk}}">{{.Risk}}</span> <strong>{{index $.Data.MindNames .MindID}}</strong> wants <code>{{.Capability}}</code>{{if .Target}} on <span class="mono">{{.Target}}</span>{{end}} <span class="muted small">· requested {{ago .CreatedAt}} · expires {{.ExpiresAt.Local.Format "02 Jan 15:04"}}{{if .EventID}} · <a href="/events/{{.EventID}}">event</a>{{end}}</span></div>
|
||||
<div class="grid cols-2 tight">
|
||||
<div><h3>Reason</h3><p>{{if .Reason}}{{.Reason}}{{else}}<em class="muted">none given</em>{{end}}</p>
|
||||
{{if .Evidence}}<h3>Evidence</h3><ul class="plain small">{{range .Evidence}}<li><span class="badge">{{.Kind}}</span> {{if eq .Kind "event"}}<a href="/events/{{.ID}}">{{.ID}}</a>{{else}}{{.ID}}{{end}} {{.Note}}</li>{{end}}</ul>{{end}}</div>
|
||||
<div><h3>Parameters</h3><pre class="json">{{pretty (jsonOf .Params)}}</pre></div>
|
||||
</div>
|
||||
{{if $op}}
|
||||
<form method="post" action="/approvals/{{.ID}}/decide" class="stack">
|
||||
<input type="hidden" name="_csrf" value="{{$.CSRF}}">
|
||||
<details><summary class="small">Modify parameters before approving</summary><textarea name="params" rows="3" class="mono" placeholder="JSON object; leave empty to keep">{{pretty (jsonOf .Params)}}</textarea></details>
|
||||
<div class="row"><input name="note" placeholder="note (shown to the mind)" class="grow" maxlength="500">
|
||||
<button class="btn btn-primary" name="decision" value="approve">Approve once</button>
|
||||
<button class="btn" name="decision" value="session" title="Also allow matching requests for this session">Approve for session</button>
|
||||
<button class="btn btn-danger" name="decision" value="deny">Deny</button></div>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>{{end}}
|
||||
{{else}}<p class="muted">Nothing is waiting.</p>{{end}}
|
||||
</section>
|
||||
<section class="grid cols-2">
|
||||
<div class="card"><h2>Decided recently</h2>
|
||||
{{if .Data.Decided}}<table class="table compact"><thead><tr><th>Status</th><th>Mind</th><th>Action</th><th>By</th><th>Note</th><th>When</th></tr></thead><tbody>{{range .Data.Decided}}<tr><td><span class="badge ap-{{.Status}}">{{.Status}}</span></td><td class="small">{{index $.Data.MindNames .MindID}}</td><td><code>{{.Capability}}</code> <span class="mono small">{{.Target}}</span>{{if .Error}}<div class="small danger">{{.Error}}</div>{{end}}</td><td class="small">{{.DecisionBy}}</td><td class="small">{{.DecisionNote}}</td><td class="small muted nowrap">{{ago .CreatedAt}}</td></tr>{{end}}</tbody></table>{{else}}<p class="muted">None.</p>{{end}}</div>
|
||||
<div class="card"><h2>Executions</h2>
|
||||
{{if .Data.Executions}}<table class="table compact"><thead><tr><th>Mind</th><th>Capability</th><th>Target</th><th>Result</th><th class="num">ms</th><th></th></tr></thead><tbody>{{range .Data.Executions}}<tr><td class="small">{{index $.Data.MindNames .MindID}}</td><td><code>{{.Capability}}</code></td><td class="mono small">{{.Target}}</td><td class="small">{{if .Error}}<span class="danger">{{trunc .Error 80}}</span>{{else}}{{payloadField .Result "summary"}}{{end}}</td><td class="num">{{.DurationMS}}</td><td>{{if .EventID}}<a class="small" href="/events/{{.EventID}}">event</a>{{end}}</td></tr>{{end}}</tbody></table>{{else}}<p class="muted">None yet.</p>{{end}}</div>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -3,7 +3,7 @@
|
||||
<section class="grid cols-3">
|
||||
<div class="card stat"><div class="stat-label">Active minds</div><div class="stat-value">{{len .Data.Minds}}</div><div class="muted small">{{range .Data.Minds}}<a href="/minds/{{.Slug}}">{{.DisplayName}}</a> {{else}}No minds yet. Create one under Minds.{{end}}</div></div>
|
||||
<div class="card stat"><div class="stat-label">System health</div><div class="stat-value">{{if .Data.Status.Database.OK}}<span class="ok">Healthy</span>{{else}}<span class="danger">Degraded</span>{{end}}</div><div class="muted small">Database {{if .Data.Status.Database.OK}}connected{{else}}unavailable{{end}} · up {{duration .Data.Status.UptimeSeconds}}</div></div>
|
||||
<div class="card stat"><div class="stat-label">Outstanding approvals</div><div class="stat-value">0</div><div class="muted small">Capability layer arrives in a later phase.</div></div>
|
||||
<div class="card stat"><div class="stat-label">Outstanding approvals</div><div class="stat-value">{{.Data.Pending}}</div><div class="muted small"><a href="/approvals">Approval centre</a> · <a href="/policies">policies</a></div></div>
|
||||
</section>
|
||||
<section class="grid cols-2">
|
||||
<div class="card">
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
{{define "content"}}
|
||||
<div class="pagehead"><h1>Capabilities and policies</h1><p class="muted">No action reaches the world except through a capability, and every capability is governed by ALLOW / ASK / DENY rules. The most specific rule wins; on ties the stricter effect wins. Locked capabilities cannot be relaxed.</p></div>
|
||||
{{$admin := .Principal.User.Role.AtLeast "administrator"}}
|
||||
<section class="card">
|
||||
<h2>Effective policy matrix <span class="muted small">mind × capability, for a generic target right now</span></h2>
|
||||
<div class="tablewrap"><table class="table compact matrix">
|
||||
<thead><tr><th>Capability</th><th>Risk</th><th>Default</th>{{range .Data.Minds}}<th>{{.DisplayName}}</th>{{end}}</tr></thead>
|
||||
<tbody>{{range $c := .Data.Capabilities}}<tr>
|
||||
<td><code>{{$c.Name}}</code>{{if $c.Locked}} <span class="badge danger">locked</span>{{end}}<div class="small muted">{{$c.Description}}</div></td>
|
||||
<td><span class="badge rk-{{$c.Risk}}">{{$c.Risk}}</span></td><td><span class="badge ef-{{$c.DefaultEffect}}">{{$c.DefaultEffect}}</span></td>
|
||||
{{range $m := $.Data.Minds}}{{$e := index $.Data.Matrix $m.ID $c.Name}}<td><span class="badge ef-{{$e.Effect}}" title="{{$e.Reason}}">{{$e.Effect}}</span></td>{{end}}
|
||||
</tr>{{end}}</tbody>
|
||||
</table></div>
|
||||
</section>
|
||||
<section class="grid cols-3-1">
|
||||
<div class="card">
|
||||
<h2>Rules</h2>
|
||||
{{if .Data.Policies}}<table class="table compact"><thead><tr><th>Scope</th><th>Capability</th><th>Target</th><th>Action</th><th>Hours</th><th>Effect</th><th>Reason</th><th>By</th>{{if $admin}}<th></th>{{end}}</tr></thead>
|
||||
<tbody>{{range .Data.Policies}}<tr><td class="small">{{if .MindID}}{{index $.Data.MindNames .MindID}}{{else}}<em>global</em>{{end}}</td><td><code>{{.Capability}}</code></td><td class="mono small">{{.TargetPattern}}</td><td class="small">{{.ActionKind}}</td><td class="small">{{if .HoursFrom}}{{deref_int .HoursFrom}}–{{deref_int .HoursTo}}{{else}}always{{end}}</td><td><span class="badge ef-{{.Effect}}">{{.Effect}}</span></td><td class="small">{{.Reason}}</td><td class="small muted">{{.CreatedBy}}</td>
|
||||
{{if $admin}}<td><form method="post" action="/policies/{{.ID}}/delete" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="btn small btn-danger" data-confirm="Delete this rule?">Delete</button></form></td>{{end}}</tr>{{end}}</tbody></table>
|
||||
{{else}}<p class="muted">No rules yet: every capability uses its default (ASK for reads and writes, DENY for locked).</p>{{end}}
|
||||
{{if .Data.Grants}}<h3>Active session grants</h3><table class="table compact"><tbody>{{range .Data.Grants}}<tr><td>{{index $.Data.MindNames .MindID}}</td><td><code>{{.Capability}}</code></td><td class="mono small">{{.TargetPattern}}</td><td class="small muted">by {{.GrantedBy}} until {{.ExpiresAt.Local.Format "15:04"}}</td><td>{{if $.Principal.User.Role.AtLeast "operator"}}<form method="post" action="/policies/grants/{{.ID}}/revoke" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="btn small">Revoke</button></form>{{end}}</td></tr>{{end}}</tbody></table>{{end}}
|
||||
</div>
|
||||
{{if $admin}}
|
||||
<div class="card">
|
||||
<h2>Add rule</h2>
|
||||
<form method="post" action="/policies" class="stack">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<label>Scope <select name="mind_id"><option value="">global</option>{{range .Data.Minds}}<option value="{{.ID}}">{{.DisplayName}}</option>{{end}}</select></label>
|
||||
<label>Capability <select name="capability"><option value="*">* (all)</option>{{range .Data.Capabilities}}<option value="{{.Name}}">{{.Name}}</option>{{end}}</select></label>
|
||||
<label>Target pattern <input name="target_pattern" value="*" placeholder="web-* or https://example.com/*"></label>
|
||||
<label>Effect <select name="effect"><option value="ask">ask</option><option value="allow">allow</option><option value="deny">deny</option></select></label>
|
||||
<div class="grid cols-2 tight"><label>Hours from <input type="number" name="hours_from" min="0" max="23" placeholder="any"></label><label>to <input type="number" name="hours_to" min="0" max="23"></label></div>
|
||||
<label>Reason <input name="reason" maxlength="200"></label>
|
||||
<div><button class="btn btn-primary" type="submit">Add rule</button></div>
|
||||
</form>
|
||||
<p class="muted small">ALLOW rules on high-risk capabilities are shown with a warning in the matrix. sudo_shell and delete_database are locked to DENY.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user