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]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### 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 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 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.
|
- 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
|
- ☑ 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
|
- ☑ 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
|
### Phase 12 – Capability and action policy system ☑
|
||||||
- ☐ registry, policy engine, approvals, approval centre UI, policy matrix UI, dream/replay refusal, locked DENYs, tool events
|
- ☑ `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
|
### Phase 13 – Integrations
|
||||||
- ☐ research folder watcher (fingerprints, safe extraction, provenance), git repositories, Prometheus/VictoriaMetrics, generic webhooks (HMAC); integration health UI
|
- ☐ 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/auth"
|
||||||
"github.com/andydixon/noema/internal/beliefs"
|
"github.com/andydixon/noema/internal/beliefs"
|
||||||
"github.com/andydixon/noema/internal/brain"
|
"github.com/andydixon/noema/internal/brain"
|
||||||
|
"github.com/andydixon/noema/internal/capabilities"
|
||||||
"github.com/andydixon/noema/internal/cognition"
|
"github.com/andydixon/noema/internal/cognition"
|
||||||
"github.com/andydixon/noema/internal/config"
|
"github.com/andydixon/noema/internal/config"
|
||||||
"github.com/andydixon/noema/internal/consolidation"
|
"github.com/andydixon/noema/internal/consolidation"
|
||||||
@@ -132,17 +133,26 @@ func run() error {
|
|||||||
deps.Predictions = predictions.NewStore(db)
|
deps.Predictions = predictions.NewStore(db)
|
||||||
deps.Resolver = &predictions.Resolver{Store: deps.Predictions, Events: deps.Events, Beliefs: deps.Beliefs, Log: log, Metrics: metrics}
|
deps.Resolver = &predictions.Resolver{Store: deps.Predictions, Events: deps.Events, Beliefs: deps.Beliefs, Log: log, Metrics: metrics}
|
||||||
deps.Graph = graph.NewStore(db)
|
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.Narrative = narrative.NewStore(db)
|
||||||
deps.Notifications = notifications.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.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}
|
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{}).
|
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})
|
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)
|
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),
|
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,
|
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("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("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) })
|
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/auth"
|
||||||
"github.com/andydixon/noema/internal/beliefs"
|
"github.com/andydixon/noema/internal/beliefs"
|
||||||
"github.com/andydixon/noema/internal/brain"
|
"github.com/andydixon/noema/internal/brain"
|
||||||
|
"github.com/andydixon/noema/internal/capabilities"
|
||||||
"github.com/andydixon/noema/internal/config"
|
"github.com/andydixon/noema/internal/config"
|
||||||
"github.com/andydixon/noema/internal/consolidation"
|
"github.com/andydixon/noema/internal/consolidation"
|
||||||
"github.com/andydixon/noema/internal/conversation"
|
"github.com/andydixon/noema/internal/conversation"
|
||||||
@@ -51,6 +52,11 @@ func testServer(t *testing.T) (*httptest.Server, string) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
deps.Resolver = &predictions.Resolver{Store: deps.Predictions, Events: deps.Events, Beliefs: deps.Beliefs, Log: log}
|
deps.Resolver = &predictions.Resolver{Store: deps.Predictions, Events: deps.Events, Beliefs: deps.Beliefs, Log: log}
|
||||||
deps.Graph = graph.NewStore(pool)
|
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.Narrative = narrative.NewStore(pool)
|
||||||
deps.Notifications = notifications.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}
|
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.motivationRoutes()
|
||||||
s.cognitionRoutes()
|
s.cognitionRoutes()
|
||||||
s.graphRoutes()
|
s.graphRoutes()
|
||||||
|
s.capabilityRoutes()
|
||||||
|
|
||||||
m.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
|
m.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
writeError(w, r, http.StatusNotFound, "not_found", "no such endpoint")
|
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/auth"
|
||||||
"github.com/andydixon/noema/internal/beliefs"
|
"github.com/andydixon/noema/internal/beliefs"
|
||||||
"github.com/andydixon/noema/internal/brain"
|
"github.com/andydixon/noema/internal/brain"
|
||||||
|
"github.com/andydixon/noema/internal/capabilities"
|
||||||
"github.com/andydixon/noema/internal/config"
|
"github.com/andydixon/noema/internal/config"
|
||||||
"github.com/andydixon/noema/internal/consolidation"
|
"github.com/andydixon/noema/internal/consolidation"
|
||||||
"github.com/andydixon/noema/internal/conversation"
|
"github.com/andydixon/noema/internal/conversation"
|
||||||
@@ -56,6 +57,7 @@ type Deps struct {
|
|||||||
Consolidation *consolidation.Service
|
Consolidation *consolidation.Service
|
||||||
Reflection *reflection.Service
|
Reflection *reflection.Service
|
||||||
Graph *graph.Store
|
Graph *graph.Store
|
||||||
|
Capabilities *capabilities.Service
|
||||||
LLM *router.Router
|
LLM *router.Router
|
||||||
LLMStore *router.Store
|
LLMStore *router.Store
|
||||||
Prompts *prompts.Store
|
Prompts *prompts.Store
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"github.com/andydixon/noema/internal/attention"
|
"github.com/andydixon/noema/internal/attention"
|
||||||
"github.com/andydixon/noema/internal/beliefs"
|
"github.com/andydixon/noema/internal/beliefs"
|
||||||
|
"github.com/andydixon/noema/internal/capabilities"
|
||||||
"github.com/andydixon/noema/internal/goals"
|
"github.com/andydixon/noema/internal/goals"
|
||||||
"github.com/andydixon/noema/internal/llm/prompts"
|
"github.com/andydixon/noema/internal/llm/prompts"
|
||||||
"github.com/andydixon/noema/internal/llm/router"
|
"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.
|
// Services are read handles processes may use. Extended per phase.
|
||||||
type Services struct {
|
type Services struct {
|
||||||
Events EventReader
|
Events EventReader
|
||||||
Memory *memory.Store
|
Memory *memory.Store
|
||||||
Beliefs *beliefs.Store
|
Beliefs *beliefs.Store
|
||||||
LLM *router.Router
|
LLM *router.Router
|
||||||
Prompts *prompts.Store
|
Prompts *prompts.Store
|
||||||
Goals *goals.Store
|
Goals *goals.Store
|
||||||
Motivation *motivation.Store
|
Motivation *motivation.Store
|
||||||
Predictions *predictions.Store
|
Predictions *predictions.Store
|
||||||
|
Capabilities *capabilities.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
// EventReader is the subset of the event store processes may call.
|
// 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("goal", h.goal)
|
||||||
r.Handle("question", h.question)
|
r.Handle("question", h.question)
|
||||||
r.Handle("metacognition", h.metacognition)
|
r.Handle("metacognition", h.metacognition)
|
||||||
|
r.Handle("action", h.action)
|
||||||
}
|
}
|
||||||
|
|
||||||
// emitter adapts the cycle to the beliefs store's Emitter.
|
// 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":
|
case "request":
|
||||||
in.Kind = "decline"
|
in.Kind = "decline"
|
||||||
caps := c.State.SelfModel.Capabilities
|
var usable []string
|
||||||
if len(caps) == 0 {
|
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.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.")
|
in.Points = append(in.Points, "I have no capabilities granted to act on external systems.")
|
||||||
} else {
|
} 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)
|
h.addKnowledge(ctx, c, &in, topic)
|
||||||
case "introspection":
|
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" {
|
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})
|
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:
|
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})
|
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:
|
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
|
names[m.ID] = m.DisplayName
|
||||||
}
|
}
|
||||||
evs, _ := u.deps.Events.Query(r.Context(), evstore.Filter{MinSalience: 0.6, Limit: 12})
|
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) {
|
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/attention"
|
||||||
"github.com/andydixon/noema/internal/auth"
|
"github.com/andydixon/noema/internal/auth"
|
||||||
"github.com/andydixon/noema/internal/beliefs"
|
"github.com/andydixon/noema/internal/beliefs"
|
||||||
|
"github.com/andydixon/noema/internal/capabilities"
|
||||||
"github.com/andydixon/noema/internal/consolidation"
|
"github.com/andydixon/noema/internal/consolidation"
|
||||||
"github.com/andydixon/noema/internal/conversation"
|
"github.com/andydixon/noema/internal/conversation"
|
||||||
"github.com/andydixon/noema/internal/goals"
|
"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)
|
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
|
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
|
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
|
// 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)
|
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
|
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
|
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.
|
// 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)")
|
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 {
|
if err := u.parse(tmplFS); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -164,7 +169,11 @@ func (u *UI) parse(fsys fs.FS) error {
|
|||||||
"llmRoles": func() []llm.RoleInfo { return llm.Roles },
|
"llmRoles": func() []llm.RoleInfo { return llm.Roles },
|
||||||
"pretty": prettyRaw,
|
"pretty": prettyRaw,
|
||||||
"entityKinds": func() []graph.Kind { return graph.AllKinds },
|
"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 {
|
"outputs": func(raw json.RawMessage) []consolidation.Output {
|
||||||
var out []consolidation.Output
|
var out []consolidation.Output
|
||||||
json.Unmarshal(raw, &out) //nolint:errcheck
|
json.Unmarshal(raw, &out) //nolint:errcheck
|
||||||
@@ -369,11 +378,10 @@ func (u *UI) Register(m *http.ServeMux) {
|
|||||||
u.motivationRoutes(m)
|
u.motivationRoutes(m)
|
||||||
u.cognitionRoutes(m)
|
u.cognitionRoutes(m)
|
||||||
u.graphRoutes(m)
|
u.graphRoutes(m)
|
||||||
|
u.capabilityRoutes(m)
|
||||||
|
|
||||||
for _, p := range []struct{ path, title, note string }{
|
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."},
|
{"/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) }))
|
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/auth"
|
||||||
"github.com/andydixon/noema/internal/beliefs"
|
"github.com/andydixon/noema/internal/beliefs"
|
||||||
"github.com/andydixon/noema/internal/brain"
|
"github.com/andydixon/noema/internal/brain"
|
||||||
|
"github.com/andydixon/noema/internal/capabilities"
|
||||||
"github.com/andydixon/noema/internal/cognition"
|
"github.com/andydixon/noema/internal/cognition"
|
||||||
"github.com/andydixon/noema/internal/config"
|
"github.com/andydixon/noema/internal/config"
|
||||||
"github.com/andydixon/noema/internal/consolidation"
|
"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()
|
ctx := context.Background()
|
||||||
group := scheduler.NewGroup(ctx, deps.Log)
|
group := scheduler.NewGroup(ctx, deps.Log)
|
||||||
registry := brain.NewRegistry().AddProcess(cognition.Attend{}).AddProcess(cognition.Retrieve{}).AddProcess(cognition.Associate{}).AddProcess(cognition.Perceive{}).
|
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})
|
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)
|
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.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,
|
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 {
|
if err := deps.Brain.Start(ctx); err != nil {
|
||||||
t.Fatal(err)
|
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()}
|
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.Resolver = &predictions.Resolver{Store: deps.Predictions, Events: deps.Events, Beliefs: deps.Beliefs, Log: log}
|
||||||
deps.Graph = graph.NewStore(pool)
|
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.Narrative = narrative.NewStore(pool)
|
||||||
deps.Notifications = notifications.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}
|
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 { 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 { 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; }
|
.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">
|
<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">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">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>
|
||||||
<section class="grid cols-2">
|
<section class="grid cols-2">
|
||||||
<div class="card">
|
<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