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:
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/andydixon/noema/internal/auth"
|
||||
"github.com/andydixon/noema/internal/beliefs"
|
||||
"github.com/andydixon/noema/internal/brain"
|
||||
"github.com/andydixon/noema/internal/capabilities"
|
||||
"github.com/andydixon/noema/internal/config"
|
||||
"github.com/andydixon/noema/internal/consolidation"
|
||||
"github.com/andydixon/noema/internal/conversation"
|
||||
@@ -51,6 +52,11 @@ func testServer(t *testing.T) (*httptest.Server, string) {
|
||||
ctx := context.Background()
|
||||
deps.Resolver = &predictions.Resolver{Store: deps.Predictions, Events: deps.Events, Beliefs: deps.Beliefs, Log: log}
|
||||
deps.Graph = graph.NewStore(pool)
|
||||
capReg := capabilities.NewRegistry()
|
||||
for _, c := range capabilities.Builtins() {
|
||||
capReg.Add(c)
|
||||
}
|
||||
deps.Capabilities = &capabilities.Service{Registry: capReg, DB: pool, Events: deps.Events, Log: log}
|
||||
deps.Narrative = narrative.NewStore(pool)
|
||||
deps.Notifications = notifications.NewStore(pool)
|
||||
deps.Consolidation = &consolidation.Service{DB: pool, Memory: deps.Memory, Beliefs: deps.Beliefs, Events: deps.Events, Minds: deps.Minds, Log: log}
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/andydixon/noema/internal/auth"
|
||||
"github.com/andydixon/noema/internal/capabilities"
|
||||
"github.com/andydixon/noema/pkg/ids"
|
||||
)
|
||||
|
||||
func (s *Server) capabilityRoutes() {
|
||||
m := s.mux
|
||||
m.Handle("GET /api/v1/capabilities", require(auth.RoleObserver, s.capabilitiesList))
|
||||
m.Handle("GET /api/v1/policies", require(auth.RoleObserver, s.policiesList))
|
||||
m.Handle("POST /api/v1/policies", require(auth.RoleAdministrator, s.policiesCreate))
|
||||
m.Handle("DELETE /api/v1/policies/{id}", require(auth.RoleAdministrator, s.policyDelete))
|
||||
m.Handle("GET /api/v1/minds/{mind}/capabilities", require(auth.RoleObserver, s.mindCapabilities))
|
||||
m.Handle("POST /api/v1/minds/{mind}/invoke", require(auth.RoleOperator, s.invoke))
|
||||
m.Handle("GET /api/v1/approvals", require(auth.RoleObserver, s.approvalsList))
|
||||
m.Handle("GET /api/v1/approvals/{id}", require(auth.RoleObserver, s.approvalGet))
|
||||
m.Handle("POST /api/v1/approvals/{id}/approve", require(auth.RoleOperator, s.approvalApprove))
|
||||
m.Handle("POST /api/v1/approvals/{id}/deny", require(auth.RoleOperator, s.approvalDeny))
|
||||
m.Handle("GET /api/v1/grants", require(auth.RoleObserver, s.grantsList))
|
||||
m.Handle("DELETE /api/v1/grants/{id}", require(auth.RoleOperator, s.grantRevoke))
|
||||
m.Handle("GET /api/v1/executions", require(auth.RoleObserver, s.executionsList))
|
||||
}
|
||||
|
||||
func capErr(err error) error {
|
||||
if errors.Is(err, capabilities.ErrInvalid) || errors.Is(err, capabilities.ErrUnknown) || errors.Is(err, capabilities.ErrForbiddenContext) {
|
||||
return ValidationError(err.Error())
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Server) capabilitiesList(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, page[capabilities.Capability]{Items: s.deps.Capabilities.Registry.List()})
|
||||
}
|
||||
|
||||
func (s *Server) policiesList(w http.ResponseWriter, r *http.Request) {
|
||||
var mind ids.ID
|
||||
if v := r.URL.Query().Get("mind"); v != "" {
|
||||
if m, err := s.deps.Minds.Resolve(r.Context(), v); err == nil {
|
||||
mind = m.ID
|
||||
}
|
||||
}
|
||||
list, err := s.deps.Capabilities.Policies(r.Context(), mind)
|
||||
if err != nil {
|
||||
fail(w, r, err)
|
||||
return
|
||||
}
|
||||
if list == nil {
|
||||
list = []capabilities.Policy{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, page[capabilities.Policy]{Items: list})
|
||||
}
|
||||
|
||||
// CreatePolicy validates and stores a rule; shared with the UI.
|
||||
func (s *Server) CreatePolicy(r *http.Request, p capabilities.Policy) (capabilities.Policy, error) {
|
||||
p.CreatedBy = actor(r)
|
||||
out, err := s.deps.Capabilities.SetPolicy(r.Context(), p)
|
||||
if err != nil {
|
||||
return out, capErr(err)
|
||||
}
|
||||
s.deps.Audit.Log(r.Context(), "policy.create", "policy", out.ID.String(), map[string]any{"mind": out.MindID, "capability": out.Capability, "target": out.TargetPattern, "effect": out.Effect})
|
||||
if !out.MindID.IsZero() {
|
||||
s.deps.Events.OperatorAction(r.Context(), out.MindID, actor(r), "policy.create", "Operator "+actor(r)+" set "+string(out.Effect)+" for "+out.Capability+" on "+out.TargetPattern, map[string]any{"policy_id": out.ID})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Server) policiesCreate(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
MindID ids.ID `json:"mind_id"`
|
||||
Capability string `json:"capability"`
|
||||
TargetPattern string `json:"target_pattern"`
|
||||
ActionKind string `json:"action_kind"`
|
||||
Effect capabilities.Effect `json:"effect"`
|
||||
HoursFrom *int `json:"hours_from"`
|
||||
HoursTo *int `json:"hours_to"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := decode(r, &in); err != nil {
|
||||
fail(w, r, err)
|
||||
return
|
||||
}
|
||||
p, err := s.CreatePolicy(r, capabilities.Policy{MindID: in.MindID, Capability: in.Capability, TargetPattern: in.TargetPattern, ActionKind: in.ActionKind, Effect: in.Effect, HoursFrom: in.HoursFrom, HoursTo: in.HoursTo, Reason: in.Reason})
|
||||
if err != nil {
|
||||
fail(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, p)
|
||||
}
|
||||
|
||||
func (s *Server) policyDelete(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := ids.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
fail(w, r, ValidationError("bad id"))
|
||||
return
|
||||
}
|
||||
if err := s.deps.Capabilities.DeletePolicy(r.Context(), id); err != nil {
|
||||
fail(w, r, err)
|
||||
return
|
||||
}
|
||||
s.deps.Audit.Log(r.Context(), "policy.delete", "policy", id.String(), nil)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) mindCapabilities(w http.ResponseWriter, r *http.Request) {
|
||||
m, ok := s.mind(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
inv, err := s.deps.Capabilities.Inventory(r.Context(), m.ID)
|
||||
if err != nil {
|
||||
fail(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": inv})
|
||||
}
|
||||
|
||||
// invoke lets an operator trigger a capability on a mind's behalf (testing
|
||||
// integrations, demonstrations). It goes through exactly the same path.
|
||||
func (s *Server) invoke(w http.ResponseWriter, r *http.Request) {
|
||||
m, ok := s.mind(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Capability string `json:"capability"`
|
||||
Target string `json:"target"`
|
||||
Params map[string]any `json:"params"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := decode(r, &in); err != nil {
|
||||
fail(w, r, err)
|
||||
return
|
||||
}
|
||||
out, err := s.deps.Capabilities.Invoke(r.Context(), capabilities.Invocation{MindID: m.ID, Capability: in.Capability, Target: in.Target, Params: in.Params, Reason: firstNonEmpty(in.Reason, "operator "+actor(r)+" invoked it")})
|
||||
if err != nil {
|
||||
fail(w, r, capErr(err))
|
||||
return
|
||||
}
|
||||
s.deps.Audit.Log(r.Context(), "capability.invoke", "mind", m.ID.String(), map[string]any{"capability": in.Capability, "target": in.Target, "status": out.Status})
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) approvalsList(w http.ResponseWriter, r *http.Request) {
|
||||
var mind ids.ID
|
||||
if v := r.URL.Query().Get("mind"); v != "" {
|
||||
if m, err := s.deps.Minds.Resolve(r.Context(), v); err == nil {
|
||||
mind = m.ID
|
||||
}
|
||||
}
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
list, err := s.deps.Capabilities.Approvals(r.Context(), mind, r.URL.Query().Get("status"), limit)
|
||||
if err != nil {
|
||||
fail(w, r, err)
|
||||
return
|
||||
}
|
||||
if list == nil {
|
||||
list = []capabilities.Approval{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, page[capabilities.Approval]{Items: list})
|
||||
}
|
||||
|
||||
func (s *Server) approvalGet(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := ids.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
fail(w, r, ValidationError("bad id"))
|
||||
return
|
||||
}
|
||||
a, err := s.deps.Capabilities.GetApproval(r.Context(), id)
|
||||
if err != nil {
|
||||
fail(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, a)
|
||||
}
|
||||
|
||||
// Decide approves or denies; shared with the UI.
|
||||
func (s *Server) Decide(r *http.Request, id ids.ID, decision, note string, params map[string]any) (any, error) {
|
||||
switch decision {
|
||||
case "approve", "session":
|
||||
out, err := s.deps.Capabilities.Approve(r.Context(), id, actor(r), note, decision == "session", params)
|
||||
if err != nil {
|
||||
return nil, capErr(err)
|
||||
}
|
||||
s.deps.Audit.Log(r.Context(), "approval.approve", "approval", id.String(), map[string]any{"for_session": decision == "session", "modified": params != nil, "status": out.Status})
|
||||
return out, nil
|
||||
case "deny":
|
||||
a, err := s.deps.Capabilities.Deny(r.Context(), id, actor(r), note)
|
||||
if err != nil {
|
||||
return nil, capErr(err)
|
||||
}
|
||||
s.deps.Audit.Log(r.Context(), "approval.deny", "approval", id.String(), map[string]any{"note": note})
|
||||
return a, nil
|
||||
}
|
||||
return nil, ValidationError("decision must be approve, session or deny")
|
||||
}
|
||||
|
||||
func (s *Server) approvalApprove(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := ids.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
fail(w, r, ValidationError("bad id"))
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Note string `json:"note"`
|
||||
ForSession bool `json:"for_session"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
}
|
||||
if r.ContentLength != 0 {
|
||||
if err := decode(r, &in); err != nil {
|
||||
fail(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
var params map[string]any
|
||||
if len(in.Params) > 0 {
|
||||
if err := json.Unmarshal(in.Params, ¶ms); err != nil {
|
||||
fail(w, r, ValidationError("params must be a JSON object"))
|
||||
return
|
||||
}
|
||||
}
|
||||
decision := "approve"
|
||||
if in.ForSession {
|
||||
decision = "session"
|
||||
}
|
||||
out, err := s.Decide(r, id, decision, in.Note, params)
|
||||
if err != nil {
|
||||
fail(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) approvalDeny(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := ids.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
fail(w, r, ValidationError("bad id"))
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Note string `json:"note"`
|
||||
}
|
||||
if r.ContentLength != 0 {
|
||||
decode(r, &in) //nolint:errcheck
|
||||
}
|
||||
out, err := s.Decide(r, id, "deny", in.Note, nil)
|
||||
if err != nil {
|
||||
fail(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) grantsList(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := s.deps.Capabilities.Grants(r.Context(), ids.Nil)
|
||||
if err != nil {
|
||||
fail(w, r, err)
|
||||
return
|
||||
}
|
||||
if list == nil {
|
||||
list = []capabilities.Grant{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, page[capabilities.Grant]{Items: list})
|
||||
}
|
||||
|
||||
func (s *Server) grantRevoke(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := ids.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
fail(w, r, ValidationError("bad id"))
|
||||
return
|
||||
}
|
||||
if err := s.deps.Capabilities.RevokeGrant(r.Context(), id); err != nil {
|
||||
fail(w, r, err)
|
||||
return
|
||||
}
|
||||
s.deps.Audit.Log(r.Context(), "grant.revoke", "grant", id.String(), nil)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) executionsList(w http.ResponseWriter, r *http.Request) {
|
||||
var mind ids.ID
|
||||
if v := r.URL.Query().Get("mind"); v != "" {
|
||||
if m, err := s.deps.Minds.Resolve(r.Context(), v); err == nil {
|
||||
mind = m.ID
|
||||
}
|
||||
}
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
list, err := s.deps.Capabilities.Executions(r.Context(), mind, limit)
|
||||
if err != nil {
|
||||
fail(w, r, err)
|
||||
return
|
||||
}
|
||||
if list == nil {
|
||||
list = []capabilities.Execution{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, page[capabilities.Execution]{Items: list})
|
||||
}
|
||||
@@ -63,6 +63,7 @@ func (s *Server) routes() {
|
||||
s.motivationRoutes()
|
||||
s.cognitionRoutes()
|
||||
s.graphRoutes()
|
||||
s.capabilityRoutes()
|
||||
|
||||
m.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, r, http.StatusNotFound, "not_found", "no such endpoint")
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/andydixon/noema/internal/auth"
|
||||
"github.com/andydixon/noema/internal/beliefs"
|
||||
"github.com/andydixon/noema/internal/brain"
|
||||
"github.com/andydixon/noema/internal/capabilities"
|
||||
"github.com/andydixon/noema/internal/config"
|
||||
"github.com/andydixon/noema/internal/consolidation"
|
||||
"github.com/andydixon/noema/internal/conversation"
|
||||
@@ -56,6 +57,7 @@ type Deps struct {
|
||||
Consolidation *consolidation.Service
|
||||
Reflection *reflection.Service
|
||||
Graph *graph.Store
|
||||
Capabilities *capabilities.Service
|
||||
LLM *router.Router
|
||||
LLMStore *router.Store
|
||||
Prompts *prompts.Store
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/andydixon/noema/internal/attention"
|
||||
"github.com/andydixon/noema/internal/beliefs"
|
||||
"github.com/andydixon/noema/internal/capabilities"
|
||||
"github.com/andydixon/noema/internal/goals"
|
||||
"github.com/andydixon/noema/internal/llm/prompts"
|
||||
"github.com/andydixon/noema/internal/llm/router"
|
||||
@@ -94,14 +95,15 @@ func (s *State) AllowLLM() bool {
|
||||
|
||||
// Services are read handles processes may use. Extended per phase.
|
||||
type Services struct {
|
||||
Events EventReader
|
||||
Memory *memory.Store
|
||||
Beliefs *beliefs.Store
|
||||
LLM *router.Router
|
||||
Prompts *prompts.Store
|
||||
Goals *goals.Store
|
||||
Motivation *motivation.Store
|
||||
Predictions *predictions.Store
|
||||
Events EventReader
|
||||
Memory *memory.Store
|
||||
Beliefs *beliefs.Store
|
||||
LLM *router.Router
|
||||
Prompts *prompts.Store
|
||||
Goals *goals.Store
|
||||
Motivation *motivation.Store
|
||||
Predictions *predictions.Store
|
||||
Capabilities *capabilities.Service
|
||||
}
|
||||
|
||||
// EventReader is the subset of the event store processes may call.
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package capabilities
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/andydixon/noema/internal/security/httpclient"
|
||||
)
|
||||
|
||||
// Builtins returns the capabilities every deployment has. Integrations add
|
||||
// more. Locked capabilities exist so that policy can name them and the
|
||||
// self-model can list them as unavailable; they never execute.
|
||||
func Builtins() []Capability {
|
||||
return []Capability{
|
||||
{Name: "noema.note", Description: "Leave a note for the operator in the mind's own history.", Risk: RiskLow, TargetKind: "none",
|
||||
Params: map[string]Param{"text": {Type: "string", Required: true, Description: "note text", MaxLen: 2000}}, DefaultEffect: Allow,
|
||||
Executor: func(_ context.Context, inv Invocation) (Result, error) {
|
||||
t, _ := inv.Params["text"].(string)
|
||||
return Result{Summary: "note recorded", Data: map[string]any{"text": t}}, nil
|
||||
}},
|
||||
{Name: "http.fetch", Description: "Fetch a public HTTP(S) URL read-only (bounded, no private addresses) and return its text.", Risk: RiskMedium, TargetKind: "url",
|
||||
Params: map[string]Param{"url": {Type: "url", Required: true, Description: "the URL to fetch", MaxLen: 2000}, "max_bytes": {Type: "int", Description: "response cap (default 64 KiB)"}}, DefaultEffect: Ask,
|
||||
Executor: fetchURL},
|
||||
{Name: "internet_search", Description: "Search the web (requires a configured search integration).", Risk: RiskMedium, TargetKind: "none",
|
||||
Params: map[string]Param{"query": {Type: "string", Required: true, MaxLen: 500}}, DefaultEffect: Ask,
|
||||
Executor: func(context.Context, Invocation) (Result, error) {
|
||||
return Result{}, errors.New("no search integration configured")
|
||||
}},
|
||||
{Name: "send_email", Description: "Send an email (requires a mail integration).", Risk: RiskHigh, TargetKind: "address",
|
||||
Params: map[string]Param{"to": {Type: "string", Required: true, MaxLen: 300}, "subject": {Type: "string", Required: true, MaxLen: 300}, "body": {Type: "string", Required: true, MaxLen: 20000}}, DefaultEffect: Ask,
|
||||
Executor: func(context.Context, Invocation) (Result, error) {
|
||||
return Result{}, errors.New("no mail integration configured")
|
||||
}},
|
||||
{Name: "sudo_shell", Description: "Privileged shell. Locked to DENY; exists so it can be named, never run.", Risk: RiskCritical, TargetKind: "host", Locked: true,
|
||||
Params: map[string]Param{"command": {Type: "string", Required: true}}, Executor: never},
|
||||
{Name: "delete_database", Description: "Destroy data stores. Locked to DENY; exists so it can be named, never run.", Risk: RiskCritical, TargetKind: "service", Locked: true,
|
||||
Params: map[string]Param{"name": {Type: "string", Required: true}}, Executor: never},
|
||||
}
|
||||
}
|
||||
|
||||
func never(context.Context, Invocation) (Result, error) {
|
||||
return Result{}, errors.New("locked capability invoked; this is a bug")
|
||||
}
|
||||
|
||||
func fetchURL(ctx context.Context, inv Invocation) (Result, error) {
|
||||
u, _ := inv.Params["url"].(string)
|
||||
max := int64(64 << 10)
|
||||
if n, ok := inv.Params["max_bytes"].(int); ok && n > 0 && n <= 1<<20 {
|
||||
max = int64(n)
|
||||
}
|
||||
client := httpclient.New(httpclient.Options{Timeout: 20 * time.Second, MaxBody: max})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "noema/1 (+capability http.fetch)")
|
||||
req.Header.Set("Accept", "text/plain, text/html;q=0.9, application/json;q=0.8, */*;q=0.1")
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
body, err := httpclient.ReadAll(res.Body, max)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
text := string(body)
|
||||
if strings.Contains(res.Header.Get("Content-Type"), "text/html") {
|
||||
text = stripTags(text)
|
||||
}
|
||||
return Result{Summary: fmt.Sprintf("HTTP %d, %d bytes, %s", res.StatusCode, len(body), res.Header.Get("Content-Type")), Output: text, Data: map[string]any{"status": res.StatusCode, "content_type": res.Header.Get("Content-Type"), "bytes": len(body)}}, nil
|
||||
}
|
||||
|
||||
// stripTags is a crude tag remover; the output is data, never rendered as HTML.
|
||||
func stripTags(s string) string {
|
||||
var b strings.Builder
|
||||
in := false
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r == '<':
|
||||
in = true
|
||||
case r == '>':
|
||||
in = false
|
||||
b.WriteByte(' ')
|
||||
case !in:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return strings.Join(strings.Fields(b.String()), " ")
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
// Package capabilities is the only path from cognition to the outside world
|
||||
// (ADR-0005, spec §29–30). A capability is registered code with a parameter
|
||||
// schema and a risk class; a policy engine decides ALLOW / ASK / DENY per
|
||||
// mind, capability, target and time; ASK creates an approval request bound
|
||||
// to the exact request; every step is an event.
|
||||
package capabilities
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/andydixon/noema/pkg/ids"
|
||||
)
|
||||
|
||||
// Risk classifies the worst plausible consequence of a capability.
|
||||
type Risk string
|
||||
|
||||
// Risk classes.
|
||||
const (
|
||||
RiskLow Risk = "low" // read-only, bounded
|
||||
RiskMedium Risk = "medium" // writes to a sandboxed or reversible place
|
||||
RiskHigh Risk = "high" // visible external side effects
|
||||
RiskCritical Risk = "critical" // irreversible or security-relevant
|
||||
)
|
||||
|
||||
// Effect is a policy outcome.
|
||||
type Effect string
|
||||
|
||||
// Effects.
|
||||
const (
|
||||
Allow Effect = "allow"
|
||||
Ask Effect = "ask"
|
||||
Deny Effect = "deny"
|
||||
)
|
||||
|
||||
// Valid reports whether e is known.
|
||||
func (e Effect) Valid() bool { return e == Allow || e == Ask || e == Deny }
|
||||
|
||||
// Param describes one parameter.
|
||||
type Param struct {
|
||||
Type string `json:"type"` // string | int | bool | url | path
|
||||
Required bool `json:"required"`
|
||||
Description string `json:"description"`
|
||||
MaxLen int `json:"max_len,omitempty"`
|
||||
}
|
||||
|
||||
// Invocation is a validated request to run a capability.
|
||||
type Invocation struct {
|
||||
MindID ids.ID
|
||||
Capability string
|
||||
Target string
|
||||
Params map[string]any
|
||||
Reason string
|
||||
Evidence []EvidenceRef
|
||||
Correlation ids.ID
|
||||
ApprovalID ids.ID
|
||||
}
|
||||
|
||||
// EvidenceRef mirrors events.EvidenceRef without importing it (keeps this
|
||||
// package free of the event store so executors can be tested alone).
|
||||
type EvidenceRef struct {
|
||||
Kind string `json:"kind"`
|
||||
ID string `json:"id"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// Result is what an executor returns. Output is untrusted data: it becomes an
|
||||
// observation with tool trust, never an instruction.
|
||||
type Result struct {
|
||||
Summary string `json:"summary"`
|
||||
Output string `json:"output,omitempty"`
|
||||
Data map[string]any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// Executor runs a capability.
|
||||
type Executor func(ctx context.Context, inv Invocation) (Result, error)
|
||||
|
||||
// Capability is a registered tool.
|
||||
type Capability struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Risk Risk `json:"risk"`
|
||||
TargetKind string `json:"target_kind"` // url | path | host | service | repository | none
|
||||
Params map[string]Param `json:"params"`
|
||||
DefaultEffect Effect `json:"default_effect"` // when no policy matches
|
||||
Locked bool `json:"locked"` // always DENY; cannot be relaxed
|
||||
Executor Executor `json:"-"`
|
||||
}
|
||||
|
||||
// Registry holds capabilities. It is built in code; nothing at runtime can
|
||||
// add one, so a mind cannot invent tools.
|
||||
type Registry struct{ caps map[string]Capability }
|
||||
|
||||
// NewRegistry creates an empty registry.
|
||||
func NewRegistry() *Registry { return &Registry{caps: map[string]Capability{}} }
|
||||
|
||||
// Add registers a capability.
|
||||
func (r *Registry) Add(c Capability) *Registry {
|
||||
if c.Params == nil {
|
||||
c.Params = map[string]Param{}
|
||||
}
|
||||
if !c.DefaultEffect.Valid() {
|
||||
c.DefaultEffect = Ask
|
||||
}
|
||||
if c.Locked {
|
||||
c.DefaultEffect = Deny
|
||||
}
|
||||
r.caps[c.Name] = c
|
||||
return r
|
||||
}
|
||||
|
||||
// Get returns a capability.
|
||||
func (r *Registry) Get(name string) (Capability, bool) {
|
||||
c, ok := r.caps[name]
|
||||
return c, ok
|
||||
}
|
||||
|
||||
// List returns capabilities sorted by name.
|
||||
func (r *Registry) List() []Capability {
|
||||
out := make([]Capability, 0, len(r.caps))
|
||||
for _, c := range r.caps {
|
||||
out = append(out, c)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
// ErrInvalid marks bad input; ErrForbiddenContext marks dreaming/replay contexts.
|
||||
var (
|
||||
ErrInvalid = errors.New("capabilities: invalid")
|
||||
ErrForbiddenContext = errors.New("capabilities: actions are not permitted in this context")
|
||||
ErrUnknown = errors.New("capabilities: unknown capability")
|
||||
)
|
||||
|
||||
// ValidateParams checks and normalises params against the schema. Unknown
|
||||
// keys are rejected so a model cannot smuggle extra arguments.
|
||||
func (c Capability) ValidateParams(params map[string]any) (map[string]any, error) {
|
||||
out := map[string]any{}
|
||||
for k := range params {
|
||||
if _, ok := c.Params[k]; !ok {
|
||||
return nil, fmt.Errorf("%w: unknown parameter %q", ErrInvalid, k)
|
||||
}
|
||||
}
|
||||
for name, p := range c.Params {
|
||||
v, present := params[name]
|
||||
if !present {
|
||||
if p.Required {
|
||||
return nil, fmt.Errorf("%w: missing parameter %q", ErrInvalid, name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch p.Type {
|
||||
case "string", "url", "path":
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %q must be a string", ErrInvalid, name)
|
||||
}
|
||||
if p.MaxLen > 0 && len(s) > p.MaxLen {
|
||||
return nil, fmt.Errorf("%w: %q too long", ErrInvalid, name)
|
||||
}
|
||||
if p.Type == "url" && !(strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")) {
|
||||
return nil, fmt.Errorf("%w: %q must be an http(s) URL", ErrInvalid, name)
|
||||
}
|
||||
if p.Type == "path" && (strings.Contains(s, "..") || strings.ContainsRune(s, 0)) {
|
||||
return nil, fmt.Errorf("%w: %q contains an unsafe path", ErrInvalid, name)
|
||||
}
|
||||
out[name] = s
|
||||
case "int":
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
out[name] = int(n)
|
||||
case int:
|
||||
out[name] = n
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: %q must be an integer", ErrInvalid, name)
|
||||
}
|
||||
case "bool":
|
||||
b, ok := v.(bool)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %q must be a boolean", ErrInvalid, name)
|
||||
}
|
||||
out[name] = b
|
||||
default:
|
||||
out[name] = v
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Hash binds an approval to the exact request.
|
||||
func Hash(mind ids.ID, capability, target string, params map[string]any) string {
|
||||
raw, _ := json.Marshal(struct {
|
||||
M ids.ID
|
||||
C string
|
||||
T string
|
||||
P map[string]any
|
||||
}{mind, capability, target, params})
|
||||
sum := sha256.Sum256(raw)
|
||||
return hex.EncodeToString(sum[:])[:32]
|
||||
}
|
||||
|
||||
// ---- policy engine ----
|
||||
|
||||
// Policy is one rule.
|
||||
type Policy struct {
|
||||
ID ids.ID `json:"id"`
|
||||
MindID ids.ID `json:"mind_id,omitempty"` // empty = global
|
||||
Capability string `json:"capability"` // name or *
|
||||
TargetPattern string `json:"target_pattern"` // glob or *
|
||||
ActionKind string `json:"action_kind"` // * or a capability-specific action label
|
||||
Effect Effect `json:"effect"`
|
||||
HoursFrom *int `json:"hours_from,omitempty"`
|
||||
HoursTo *int `json:"hours_to,omitempty"`
|
||||
Reason string `json:"reason"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// matches reports whether the policy applies, and its specificity.
|
||||
func (p Policy) matches(mind ids.ID, capability, target, action string, now time.Time) (bool, int) {
|
||||
spec := 0
|
||||
if !p.MindID.IsZero() {
|
||||
if p.MindID != mind {
|
||||
return false, 0
|
||||
}
|
||||
spec += 8
|
||||
}
|
||||
if p.Capability != "*" {
|
||||
if p.Capability != capability {
|
||||
return false, 0
|
||||
}
|
||||
spec += 4
|
||||
}
|
||||
if p.TargetPattern != "*" {
|
||||
if ok, _ := path.Match(p.TargetPattern, target); !ok && !strings.EqualFold(p.TargetPattern, target) {
|
||||
return false, 0
|
||||
}
|
||||
spec += 2
|
||||
}
|
||||
if p.ActionKind != "*" {
|
||||
if p.ActionKind != action {
|
||||
return false, 0
|
||||
}
|
||||
spec++
|
||||
}
|
||||
if p.HoursFrom != nil && p.HoursTo != nil {
|
||||
h := now.Hour()
|
||||
from, to := *p.HoursFrom, *p.HoursTo
|
||||
inWindow := (from <= to && h >= from && h < to) || (from > to && (h >= from || h < to))
|
||||
if !inWindow {
|
||||
return false, 0
|
||||
}
|
||||
spec++
|
||||
}
|
||||
return true, spec
|
||||
}
|
||||
|
||||
// Decision explains a policy outcome.
|
||||
type Decision struct {
|
||||
Effect Effect `json:"effect"`
|
||||
PolicyID ids.ID `json:"policy_id,omitempty"`
|
||||
Specificity int `json:"specificity"`
|
||||
Reason string `json:"reason"`
|
||||
Locked bool `json:"locked"`
|
||||
Considered int `json:"considered"`
|
||||
}
|
||||
|
||||
func rank(e Effect) int {
|
||||
switch e {
|
||||
case Deny:
|
||||
return 3
|
||||
case Ask:
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// Evaluate picks the most specific matching policy; on ties the stricter
|
||||
// effect wins (DENY > ASK > ALLOW). Locked capabilities always deny.
|
||||
func Evaluate(c Capability, policies []Policy, mind ids.ID, target, action string, now time.Time) Decision {
|
||||
if c.Locked {
|
||||
return Decision{Effect: Deny, Reason: "capability is locked to DENY by design", Locked: true}
|
||||
}
|
||||
best := Decision{Effect: c.DefaultEffect, Reason: "no matching policy; capability default"}
|
||||
bestSpec := -1
|
||||
for _, p := range policies {
|
||||
ok, spec := p.matches(mind, c.Name, target, action, now)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
best.Considered++
|
||||
if spec > bestSpec || (spec == bestSpec && rank(p.Effect) > rank(best.Effect)) {
|
||||
bestSpec = spec
|
||||
best.Effect, best.PolicyID, best.Specificity = p.Effect, p.ID, spec
|
||||
best.Reason = fmt.Sprintf("policy %s (specificity %d): %s", shortID(p.ID), spec, firstNonEmpty(p.Reason, describe(p)))
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func describe(p Policy) string {
|
||||
scope := "global"
|
||||
if !p.MindID.IsZero() {
|
||||
scope = "mind"
|
||||
}
|
||||
return fmt.Sprintf("%s rule %s on %s target %s", scope, p.Effect, p.Capability, p.TargetPattern)
|
||||
}
|
||||
|
||||
func shortID(id ids.ID) string {
|
||||
if len(id) >= 8 {
|
||||
return string(id[:8])
|
||||
}
|
||||
return string(id)
|
||||
}
|
||||
|
||||
func firstNonEmpty(a, b string) string {
|
||||
if a != "" {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// ---- context guards ----
|
||||
|
||||
type ctxKey struct{}
|
||||
|
||||
// WithForbidden marks a context (dreaming, replay) in which no capability may
|
||||
// run. Invoke refuses unconditionally.
|
||||
func WithForbidden(ctx context.Context, why string) context.Context {
|
||||
return context.WithValue(ctx, ctxKey{}, why)
|
||||
}
|
||||
|
||||
// Forbidden reports whether actions are refused in ctx.
|
||||
func Forbidden(ctx context.Context) (string, bool) {
|
||||
s, ok := ctx.Value(ctxKey{}).(string)
|
||||
return s, ok
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package capabilities
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
evstore "github.com/andydixon/noema/internal/events"
|
||||
"github.com/andydixon/noema/internal/minds"
|
||||
"github.com/andydixon/noema/internal/storage"
|
||||
"github.com/andydixon/noema/migrations"
|
||||
"github.com/andydixon/noema/pkg/events"
|
||||
"github.com/andydixon/noema/pkg/ids"
|
||||
)
|
||||
|
||||
func TestEvaluateSpecificityAndLocks(t *testing.T) {
|
||||
mind, other := ids.New(), ids.New()
|
||||
c := Capability{Name: "restart_service", DefaultEffect: Ask}
|
||||
pols := []Policy{
|
||||
{ID: ids.New(), Capability: "*", TargetPattern: "*", ActionKind: "*", Effect: Deny},
|
||||
{ID: ids.New(), Capability: "restart_service", TargetPattern: "*", ActionKind: "*", Effect: Ask},
|
||||
{ID: ids.New(), MindID: mind, Capability: "restart_service", TargetPattern: "web-*", ActionKind: "*", Effect: Allow},
|
||||
{ID: ids.New(), MindID: other, Capability: "restart_service", TargetPattern: "*", ActionKind: "*", Effect: Allow},
|
||||
}
|
||||
now := time.Now()
|
||||
if d := Evaluate(c, pols, mind, "web-01", "*", now); d.Effect != Allow {
|
||||
t.Fatalf("most specific mind rule should allow: %+v", d)
|
||||
}
|
||||
if d := Evaluate(c, pols, mind, "db-01", "*", now); d.Effect != Ask {
|
||||
t.Fatalf("capability rule should ask: %+v", d)
|
||||
}
|
||||
if d := Evaluate(Capability{Name: "send_email", DefaultEffect: Ask}, pols, mind, "x", "*", now); d.Effect != Deny {
|
||||
t.Fatalf("global wildcard deny should apply: %+v", d)
|
||||
}
|
||||
// Tie: two rules with equal specificity, stricter wins.
|
||||
tie := []Policy{{ID: ids.New(), Capability: "restart_service", Effect: Allow, TargetPattern: "*", ActionKind: "*"}, {ID: ids.New(), Capability: "restart_service", Effect: Deny, TargetPattern: "*", ActionKind: "*"}}
|
||||
if d := Evaluate(c, tie, mind, "x", "*", now); d.Effect != Deny {
|
||||
t.Fatalf("tie should deny: %+v", d)
|
||||
}
|
||||
if d := Evaluate(Capability{Name: "sudo_shell", Locked: true}, []Policy{{Capability: "sudo_shell", Effect: Allow, TargetPattern: "*", ActionKind: "*"}}, mind, "x", "*", now); d.Effect != Deny || !d.Locked {
|
||||
t.Fatal("locked capability must deny regardless of policy")
|
||||
}
|
||||
// Time window.
|
||||
from, to := 9, 17
|
||||
win := []Policy{{ID: ids.New(), Capability: "restart_service", TargetPattern: "*", ActionKind: "*", Effect: Allow, HoursFrom: &from, HoursTo: &to}}
|
||||
at := time.Date(2026, 1, 1, 12, 0, 0, 0, time.Local)
|
||||
off := time.Date(2026, 1, 1, 22, 0, 0, 0, time.Local)
|
||||
if Evaluate(c, win, mind, "x", "*", at).Effect != Allow || Evaluate(c, win, mind, "x", "*", off).Effect != Ask {
|
||||
t.Fatal("time window wrong")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateParamsAndHash(t *testing.T) {
|
||||
c := Capability{Name: "f", Params: map[string]Param{"url": {Type: "url", Required: true}, "n": {Type: "int"}, "p": {Type: "path"}}}
|
||||
if _, err := c.ValidateParams(map[string]any{"url": "ftp://x"}); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatal("non-http url accepted")
|
||||
}
|
||||
if _, err := c.ValidateParams(map[string]any{"url": "https://x", "extra": 1}); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatal("unknown param accepted")
|
||||
}
|
||||
if _, err := c.ValidateParams(map[string]any{"url": "https://x", "p": "../etc/passwd"}); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatal("traversal accepted")
|
||||
}
|
||||
p, err := c.ValidateParams(map[string]any{"url": "https://x", "n": float64(3)})
|
||||
if err != nil || p["n"] != 3 {
|
||||
t.Fatalf("validate: %v %v", err, p)
|
||||
}
|
||||
m := ids.New()
|
||||
if Hash(m, "f", "t", p) != Hash(m, "f", "t", map[string]any{"url": "https://x", "n": 3}) || Hash(m, "f", "t", p) == Hash(m, "f", "t2", p) {
|
||||
t.Fatal("hash not stable/discriminating")
|
||||
}
|
||||
}
|
||||
|
||||
func setup(t *testing.T) (*Service, minds.Mind, *evstore.Store) {
|
||||
t.Helper()
|
||||
pool := storage.TestPool(t, migrations.FS)
|
||||
ctx := context.Background()
|
||||
m, _ := minds.NewStore(pool).Create(ctx, minds.CreateParams{Slug: "cap"})
|
||||
es := evstore.NewStore(pool, evstore.NewBus(8), nil)
|
||||
reg := NewRegistry()
|
||||
for _, c := range Builtins() {
|
||||
reg.Add(c)
|
||||
}
|
||||
calls := 0
|
||||
reg.Add(Capability{Name: "test.echo", Description: "echo", Risk: RiskLow, TargetKind: "none", Params: map[string]Param{"text": {Type: "string", Required: true}}, DefaultEffect: Ask,
|
||||
Executor: func(_ context.Context, inv Invocation) (Result, error) {
|
||||
calls++
|
||||
return Result{Summary: "echoed", Output: inv.Params["text"].(string)}, nil
|
||||
}})
|
||||
reg.Add(Capability{Name: "test.boom", Risk: RiskLow, DefaultEffect: Allow, Executor: func(context.Context, Invocation) (Result, error) { panic("bad tool") }})
|
||||
return &Service{Registry: reg, DB: pool, Events: es, Log: slog.New(slog.DiscardHandler)}, m, es
|
||||
}
|
||||
|
||||
func TestInvokePaths(t *testing.T) {
|
||||
s, m, es := setup(t)
|
||||
ctx := context.Background()
|
||||
// Default ALLOW executes and its output becomes a tool-trust observation.
|
||||
out, err := s.Invoke(ctx, Invocation{MindID: m.ID, Capability: "noema.note", Params: map[string]any{"text": "hello"}, Reason: "test"})
|
||||
if err != nil || out.Status != "executed" || out.Result == nil {
|
||||
t.Fatalf("allow path: %v %+v", err, out)
|
||||
}
|
||||
// Default ASK creates an approval and executes only after approval.
|
||||
out, err = s.Invoke(ctx, Invocation{MindID: m.ID, Capability: "test.echo", Target: "t1", Params: map[string]any{"text": "ping"}, Reason: "need it"})
|
||||
if err != nil || out.Status != "pending" || out.ApprovalID.IsZero() {
|
||||
t.Fatalf("ask path: %v %+v", err, out)
|
||||
}
|
||||
pend, _ := s.Approvals(ctx, m.ID, "pending", 10)
|
||||
if len(pend) != 1 || pend[0].Risk != RiskLow {
|
||||
t.Fatalf("pending: %+v", pend)
|
||||
}
|
||||
res, err := s.Approve(ctx, out.ApprovalID, "andy", "ok", true, nil)
|
||||
if err != nil || res.Status != "executed" || res.Result.Output != "ping" {
|
||||
t.Fatalf("approve: %v %+v", err, res)
|
||||
}
|
||||
obs, _ := es.Query(ctx, evstore.Filter{MindID: m.ID, Types: []events.Type{events.Observation}})
|
||||
if len(obs) == 0 || fmt.Sprint(obs[0].Metadata["trust"]) != string(events.TrustTool) {
|
||||
t.Fatalf("tool output should be a tool-trust observation: %+v", obs)
|
||||
}
|
||||
// Session grant now allows the same target without asking; a different target still asks.
|
||||
out, _ = s.Invoke(ctx, Invocation{MindID: m.ID, Capability: "test.echo", Target: "t1", Params: map[string]any{"text": "again"}})
|
||||
if out.Status != "executed" {
|
||||
t.Fatalf("session grant not applied: %+v", out)
|
||||
}
|
||||
out, _ = s.Invoke(ctx, Invocation{MindID: m.ID, Capability: "test.echo", Target: "t2", Params: map[string]any{"text": "x"}})
|
||||
if out.Status != "pending" {
|
||||
t.Fatalf("other target should still ask: %+v", out)
|
||||
}
|
||||
// Deny with note; double decision refused.
|
||||
if _, err := s.Deny(ctx, out.ApprovalID, "andy", "not now"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Approve(ctx, out.ApprovalID, "andy", "", false, nil); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatal("denied request approved")
|
||||
}
|
||||
// Policy DENY, locked capability, and forbidden context.
|
||||
if _, err := s.SetPolicy(ctx, Policy{MindID: m.ID, Capability: "test.echo", Effect: Deny, Reason: "no echo here"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, _ = s.Invoke(ctx, Invocation{MindID: m.ID, Capability: "test.echo", Target: "t1", Params: map[string]any{"text": "y"}})
|
||||
if out.Status != "denied" {
|
||||
t.Fatalf("policy deny not applied: %+v", out)
|
||||
}
|
||||
if _, err := s.SetPolicy(ctx, Policy{Capability: "sudo_shell", Effect: Allow}); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatal("locked capability relaxed")
|
||||
}
|
||||
out, _ = s.Invoke(ctx, Invocation{MindID: m.ID, Capability: "sudo_shell", Params: map[string]any{"command": "rm -rf /"}})
|
||||
if out.Status != "denied" || !out.Decision.Locked {
|
||||
t.Fatalf("locked not denied: %+v", out)
|
||||
}
|
||||
if _, err := s.Invoke(WithForbidden(ctx, "dreaming"), Invocation{MindID: m.ID, Capability: "noema.note", Params: map[string]any{"text": "x"}}); !errors.Is(err, ErrForbiddenContext) {
|
||||
t.Fatal("dreaming context allowed an action")
|
||||
}
|
||||
// A panicking executor is a failed execution, not a crash.
|
||||
out, err = s.Invoke(ctx, Invocation{MindID: m.ID, Capability: "test.boom"})
|
||||
if err != nil || out.Status != "failed" {
|
||||
t.Fatalf("panic handling: %v %+v", err, out)
|
||||
}
|
||||
// Decisions are append-only.
|
||||
if _, err := s.DB.Exec(ctx, `DELETE FROM approval_decisions`); err == nil {
|
||||
t.Fatal("decisions must be append-only")
|
||||
}
|
||||
inv, _ := s.Inventory(ctx, m.ID)
|
||||
var sawLocked bool
|
||||
for _, i := range inv {
|
||||
if i.Capability.Name == "sudo_shell" && i.Effect == Deny {
|
||||
sawLocked = true
|
||||
}
|
||||
}
|
||||
if !sawLocked {
|
||||
t.Fatal("inventory missing locked deny")
|
||||
}
|
||||
execs, _ := s.Executions(ctx, m.ID, 10)
|
||||
if len(execs) < 3 {
|
||||
t.Fatalf("executions not recorded: %d", len(execs))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,648 @@
|
||||
package capabilities
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
evstore "github.com/andydixon/noema/internal/events"
|
||||
"github.com/andydixon/noema/internal/observability"
|
||||
"github.com/andydixon/noema/internal/storage"
|
||||
"github.com/andydixon/noema/pkg/events"
|
||||
"github.com/andydixon/noema/pkg/ids"
|
||||
)
|
||||
|
||||
// Approval is a pending or decided request.
|
||||
type Approval struct {
|
||||
ID ids.ID `json:"id"`
|
||||
MindID ids.ID `json:"mind_id"`
|
||||
Capability string `json:"capability"`
|
||||
Target string `json:"target"`
|
||||
Params map[string]any `json:"params"`
|
||||
RequestHash string `json:"request_hash"`
|
||||
Reason string `json:"reason"`
|
||||
Risk Risk `json:"risk"`
|
||||
Evidence []EvidenceRef `json:"evidence"`
|
||||
Status string `json:"status"`
|
||||
DecisionBy string `json:"decision_by"`
|
||||
DecisionNote string `json:"decision_note"`
|
||||
DecidedAt *time.Time `json:"decided_at,omitempty"`
|
||||
SessionGrant bool `json:"session_grant"`
|
||||
ModifiedParams map[string]any `json:"modified_params,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error string `json:"error"`
|
||||
EventID ids.ID `json:"event_id,omitempty"`
|
||||
CorrelationID ids.ID `json:"correlation_id,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Execution is a recorded run.
|
||||
type Execution struct {
|
||||
ID ids.ID `json:"id"`
|
||||
MindID ids.ID `json:"mind_id"`
|
||||
Capability string `json:"capability"`
|
||||
Target string `json:"target"`
|
||||
Params map[string]any `json:"params"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error string `json:"error"`
|
||||
DurationMS int `json:"duration_ms"`
|
||||
ApprovalID ids.ID `json:"approval_id,omitempty"`
|
||||
EventID ids.ID `json:"event_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Grant is an approve-for-session exception.
|
||||
type Grant struct {
|
||||
ID ids.ID `json:"id"`
|
||||
MindID ids.ID `json:"mind_id"`
|
||||
Capability string `json:"capability"`
|
||||
TargetPattern string `json:"target_pattern"`
|
||||
GrantedBy string `json:"granted_by"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Outcome is what Invoke returns.
|
||||
type Outcome struct {
|
||||
Status string `json:"status"` // executed | pending | denied | failed
|
||||
Decision Decision `json:"decision"`
|
||||
Result *Result `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ApprovalID ids.ID `json:"approval_id,omitempty"`
|
||||
EventID ids.ID `json:"event_id,omitempty"`
|
||||
Execution ids.ID `json:"execution_id,omitempty"`
|
||||
}
|
||||
|
||||
// Service wires the registry, policies, approvals and executions together.
|
||||
type Service struct {
|
||||
Registry *Registry
|
||||
DB *pgxpool.Pool
|
||||
Events *evstore.Store
|
||||
Log *slog.Logger
|
||||
Metrics *observability.Metrics
|
||||
ApprovalTTL time.Duration
|
||||
SessionTTL time.Duration
|
||||
Now func() time.Time
|
||||
OnSelfModel func(ctx context.Context, mind ids.ID) // called when effective policies may have changed
|
||||
}
|
||||
|
||||
func (s *Service) now() time.Time {
|
||||
if s.Now != nil {
|
||||
return s.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
// ---- policies ----
|
||||
|
||||
const policyCols = `id, mind_id, capability, target_pattern, action_kind, effect, hours_from, hours_to, reason, created_by, created_at, updated_at`
|
||||
|
||||
func scanPolicy(row pgx.Row) (Policy, error) {
|
||||
var p Policy
|
||||
var mind *string
|
||||
var effect string
|
||||
if err := row.Scan(&p.ID, &mind, &p.Capability, &p.TargetPattern, &p.ActionKind, &effect, &p.HoursFrom, &p.HoursTo, &p.Reason, &p.CreatedBy, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
||||
return p, storage.Translate(err)
|
||||
}
|
||||
p.Effect = Effect(effect)
|
||||
if mind != nil {
|
||||
p.MindID = ids.ID(*mind)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Policies lists rules applying to a mind (global plus its own), or all when mind is Nil.
|
||||
func (s *Service) Policies(ctx context.Context, mind ids.ID) ([]Policy, error) {
|
||||
rows, err := s.DB.Query(ctx, `SELECT `+policyCols+` FROM policies WHERE mind_id IS NULL OR $1::uuid IS NULL OR mind_id=$1 ORDER BY mind_id NULLS FIRST, capability, target_pattern`, nullID(mind))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Policy
|
||||
for rows.Next() {
|
||||
p, err := scanPolicy(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SetPolicy inserts a rule. Locked capabilities cannot be relaxed.
|
||||
func (s *Service) SetPolicy(ctx context.Context, p Policy) (Policy, error) {
|
||||
if !p.Effect.Valid() {
|
||||
return Policy{}, fmt.Errorf("%w: effect must be allow, ask or deny", ErrInvalid)
|
||||
}
|
||||
if p.Capability == "" {
|
||||
p.Capability = "*"
|
||||
}
|
||||
if p.TargetPattern == "" {
|
||||
p.TargetPattern = "*"
|
||||
}
|
||||
if p.ActionKind == "" {
|
||||
p.ActionKind = "*"
|
||||
}
|
||||
if p.Capability != "*" {
|
||||
c, ok := s.Registry.Get(p.Capability)
|
||||
if !ok {
|
||||
return Policy{}, fmt.Errorf("%w: unknown capability %q", ErrUnknown, p.Capability)
|
||||
}
|
||||
if c.Locked && p.Effect != Deny {
|
||||
return Policy{}, fmt.Errorf("%w: %s is locked to DENY and cannot be relaxed", ErrInvalid, c.Name)
|
||||
}
|
||||
}
|
||||
if _, err := path.Match(p.TargetPattern, "x"); err != nil {
|
||||
return Policy{}, fmt.Errorf("%w: bad target pattern", ErrInvalid)
|
||||
}
|
||||
if (p.HoursFrom == nil) != (p.HoursTo == nil) {
|
||||
return Policy{}, fmt.Errorf("%w: both hours_from and hours_to are required", ErrInvalid)
|
||||
}
|
||||
p.ID, p.CreatedAt, p.UpdatedAt = ids.New(), s.now(), s.now()
|
||||
_, err := s.DB.Exec(ctx, `INSERT INTO policies (id, mind_id, capability, target_pattern, action_kind, effect, hours_from, hours_to, reason, created_by) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
|
||||
p.ID, nullID(p.MindID), p.Capability, p.TargetPattern, p.ActionKind, string(p.Effect), p.HoursFrom, p.HoursTo, p.Reason, p.CreatedBy)
|
||||
if err != nil {
|
||||
return Policy{}, err
|
||||
}
|
||||
s.selfModelChanged(ctx, p.MindID)
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// DeletePolicy removes a rule.
|
||||
func (s *Service) DeletePolicy(ctx context.Context, id ids.ID) error {
|
||||
var mind *string
|
||||
err := s.DB.QueryRow(ctx, `DELETE FROM policies WHERE id=$1 RETURNING mind_id`, id).Scan(&mind)
|
||||
if err != nil {
|
||||
return storage.Translate(err)
|
||||
}
|
||||
if mind != nil {
|
||||
s.selfModelChanged(ctx, ids.ID(*mind))
|
||||
} else {
|
||||
s.selfModelChanged(ctx, ids.Nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) selfModelChanged(ctx context.Context, mind ids.ID) {
|
||||
if s.OnSelfModel != nil {
|
||||
s.OnSelfModel(ctx, mind)
|
||||
}
|
||||
}
|
||||
|
||||
// Decide evaluates policy and session grants for a request without executing.
|
||||
func (s *Service) Decide(ctx context.Context, mind ids.ID, c Capability, target, action string) (Decision, error) {
|
||||
pols, err := s.Policies(ctx, mind)
|
||||
if err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
d := Evaluate(c, pols, mind, target, action, s.now())
|
||||
if d.Effect == Ask && !c.Locked {
|
||||
if g, ok := s.activeGrant(ctx, mind, c.Name, target); ok {
|
||||
d.Effect, d.Reason = Allow, fmt.Sprintf("session grant by %s until %s", g.GrantedBy, g.ExpiresAt.Format(time.Kitchen))
|
||||
}
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (s *Service) activeGrant(ctx context.Context, mind ids.ID, capability, target string) (Grant, bool) {
|
||||
rows, err := s.DB.Query(ctx, `SELECT id, mind_id, capability, target_pattern, granted_by, expires_at, created_at FROM session_grants WHERE mind_id=$1 AND capability=$2 AND revoked_at IS NULL AND expires_at > $3`, mind, capability, s.now())
|
||||
if err != nil {
|
||||
return Grant{}, false
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var g Grant
|
||||
if err := rows.Scan(&g.ID, &g.MindID, &g.Capability, &g.TargetPattern, &g.GrantedBy, &g.ExpiresAt, &g.CreatedAt); err != nil {
|
||||
return Grant{}, false
|
||||
}
|
||||
if ok, _ := path.Match(g.TargetPattern, target); ok || g.TargetPattern == "*" || strings.EqualFold(g.TargetPattern, target) {
|
||||
return g, true
|
||||
}
|
||||
}
|
||||
return Grant{}, false
|
||||
}
|
||||
|
||||
// Inventory lists every capability with the effect a mind would get for a
|
||||
// generic target now; it feeds the self-model.
|
||||
type InventoryItem struct {
|
||||
Capability Capability `json:"capability"`
|
||||
Effect Effect `json:"effect"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// Inventory computes effective effects for a mind.
|
||||
func (s *Service) Inventory(ctx context.Context, mind ids.ID) ([]InventoryItem, error) {
|
||||
pols, err := s.Policies(ctx, mind)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []InventoryItem
|
||||
for _, c := range s.Registry.List() {
|
||||
d := Evaluate(c, pols, mind, "*", "*", s.now())
|
||||
out = append(out, InventoryItem{Capability: c, Effect: d.Effect, Reason: d.Reason})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---- invocation ----
|
||||
|
||||
// Invoke is the single entry point for actions.
|
||||
func (s *Service) Invoke(ctx context.Context, inv Invocation) (Outcome, error) {
|
||||
if why, forbidden := Forbidden(ctx); forbidden {
|
||||
return Outcome{Status: "denied", Decision: Decision{Effect: Deny, Reason: "forbidden context: " + why}}, ErrForbiddenContext
|
||||
}
|
||||
c, ok := s.Registry.Get(inv.Capability)
|
||||
if !ok {
|
||||
return Outcome{}, fmt.Errorf("%w: %s", ErrUnknown, inv.Capability)
|
||||
}
|
||||
params, err := c.ValidateParams(inv.Params)
|
||||
if err != nil {
|
||||
return Outcome{}, err
|
||||
}
|
||||
inv.Params = params
|
||||
action, _ := params["action"].(string)
|
||||
if action == "" {
|
||||
action = "*"
|
||||
}
|
||||
d, err := s.Decide(ctx, inv.MindID, c, inv.Target, action)
|
||||
if err != nil {
|
||||
return Outcome{}, err
|
||||
}
|
||||
if s.Metrics != nil {
|
||||
s.Metrics.ToolRequests.WithLabelValues(c.Name).Inc()
|
||||
}
|
||||
hash := Hash(inv.MindID, c.Name, inv.Target, params)
|
||||
base := map[string]any{"capability": c.Name, "target": inv.Target, "params": params, "risk": c.Risk, "reason": inv.Reason, "hash": hash, "decision": d}
|
||||
evs, err := s.Events.Append(ctx, events.New{MindID: inv.MindID, Type: events.PolicyDecision, Source: "capabilities", Summary: fmt.Sprintf("policy %s for %s on %q: %s", strings.ToUpper(string(d.Effect)), c.Name, inv.Target, d.Reason),
|
||||
Salience: 0.3, Confidence: 1, CorrelationID: inv.Correlation, Evidence: toEvidence(inv.Evidence), Metadata: map[string]any{"capability": c.Name, "effect": d.Effect, "policy_id": string(d.PolicyID)}, Payload: base})
|
||||
if err != nil {
|
||||
return Outcome{}, err
|
||||
}
|
||||
decisionEv := evs[0].ID
|
||||
switch d.Effect {
|
||||
case Deny:
|
||||
if s.Metrics != nil {
|
||||
s.Metrics.ToolDenied.WithLabelValues(c.Name).Inc()
|
||||
}
|
||||
ev, _ := s.Events.Append(ctx, events.New{MindID: inv.MindID, Type: events.ToolDenied, Source: "capabilities", Summary: fmt.Sprintf("denied: %s on %q (%s)", c.Name, inv.Target, d.Reason),
|
||||
Salience: 0.5, Confidence: 1, CausationID: decisionEv, CorrelationID: inv.Correlation, Metadata: map[string]any{"capability": c.Name, "target": inv.Target}, Payload: base})
|
||||
return Outcome{Status: "denied", Decision: d, EventID: firstID(ev)}, nil
|
||||
case Ask:
|
||||
a, err := s.createApproval(ctx, inv, c, hash, decisionEv)
|
||||
if err != nil {
|
||||
return Outcome{}, err
|
||||
}
|
||||
return Outcome{Status: "pending", Decision: d, ApprovalID: a.ID, EventID: a.EventID}, nil
|
||||
}
|
||||
return s.execute(ctx, inv, c, d, decisionEv, ids.Nil)
|
||||
}
|
||||
|
||||
func (s *Service) createApproval(ctx context.Context, inv Invocation, c Capability, hash string, cause ids.ID) (Approval, error) {
|
||||
ttl := s.ApprovalTTL
|
||||
if ttl <= 0 {
|
||||
ttl = 24 * time.Hour
|
||||
}
|
||||
a := Approval{ID: ids.New(), MindID: inv.MindID, Capability: c.Name, Target: inv.Target, Params: inv.Params, RequestHash: hash, Reason: inv.Reason, Risk: c.Risk, Evidence: inv.Evidence, Status: "pending", CorrelationID: inv.Correlation, ExpiresAt: s.now().Add(ttl), CreatedAt: s.now()}
|
||||
if a.Evidence == nil {
|
||||
a.Evidence = []EvidenceRef{}
|
||||
}
|
||||
ev, err := s.Events.Append(ctx, events.New{MindID: inv.MindID, Type: events.ToolRequested, Source: "capabilities", Summary: fmt.Sprintf("approval requested: %s on %q — %s", c.Name, inv.Target, firstNonEmpty(inv.Reason, "no reason given")),
|
||||
Salience: 0.6, Confidence: 1, CausationID: cause, CorrelationID: inv.Correlation, Evidence: toEvidence(inv.Evidence),
|
||||
Metadata: map[string]any{"capability": c.Name, "target": inv.Target, "approval_id": string(a.ID), "risk": c.Risk}, Payload: map[string]any{"approval_id": a.ID, "capability": c.Name, "target": inv.Target, "params": inv.Params, "risk": c.Risk, "reason": inv.Reason, "expires_at": a.ExpiresAt}})
|
||||
if err != nil {
|
||||
return Approval{}, err
|
||||
}
|
||||
a.EventID = ev[0].ID
|
||||
params, _ := json.Marshal(inv.Params)
|
||||
evid, _ := json.Marshal(a.Evidence)
|
||||
_, err = s.DB.Exec(ctx, `INSERT INTO approval_requests (id, mind_id, capability, target, params, request_hash, reason, risk, evidence, status, event_id, correlation_id, expires_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'pending',$10,$11,$12)`, a.ID, a.MindID, a.Capability, a.Target, params, a.RequestHash, a.Reason, string(a.Risk), evid, string(a.EventID), nullID(a.CorrelationID), a.ExpiresAt)
|
||||
return a, err
|
||||
}
|
||||
|
||||
func (s *Service) execute(ctx context.Context, inv Invocation, c Capability, d Decision, cause ids.ID, approvalID ids.ID) (Outcome, error) {
|
||||
start := s.now()
|
||||
var res Result
|
||||
var execErr error
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
execErr = fmt.Errorf("executor panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
ectx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
defer cancel()
|
||||
res, execErr = c.Executor(ectx, inv)
|
||||
}()
|
||||
dur := time.Since(start)
|
||||
exec := Execution{ID: ids.New(), MindID: inv.MindID, Capability: c.Name, Target: inv.Target, Params: inv.Params, DurationMS: int(dur.Milliseconds()), ApprovalID: approvalID, CreatedAt: s.now()}
|
||||
payload := map[string]any{"capability": c.Name, "target": inv.Target, "params": inv.Params, "approval_id": approvalID, "duration_ms": exec.DurationMS}
|
||||
var ev []events.Event
|
||||
if execErr != nil {
|
||||
exec.Error = execErr.Error()
|
||||
payload["error"] = exec.Error
|
||||
ev, _ = s.Events.Append(ctx, events.New{MindID: inv.MindID, Type: events.ToolFailed, Source: "capabilities", Summary: fmt.Sprintf("%s on %q failed: %s", c.Name, inv.Target, truncate(exec.Error, 160)),
|
||||
Salience: 0.6, Confidence: 1, CausationID: cause, CorrelationID: inv.Correlation, Metadata: map[string]any{"capability": c.Name, "target": inv.Target, "execution_id": string(exec.ID)}, Payload: payload})
|
||||
} else {
|
||||
rj, _ := json.Marshal(res)
|
||||
exec.Result = rj
|
||||
payload["result"] = res
|
||||
ev, _ = s.Events.Append(ctx, events.New{MindID: inv.MindID, Type: events.ToolExecuted, Source: "capabilities", Summary: fmt.Sprintf("%s on %q: %s", c.Name, inv.Target, truncate(res.Summary, 160)),
|
||||
Salience: 0.5, Confidence: 1, CausationID: cause, CorrelationID: inv.Correlation, Evidence: toEvidence(inv.Evidence), Metadata: map[string]any{"capability": c.Name, "target": inv.Target, "execution_id": string(exec.ID), "trust": events.TrustTool}, Payload: payload})
|
||||
// Tool output re-enters cognition as an observation with tool trust.
|
||||
if strings.TrimSpace(res.Output) != "" {
|
||||
s.Events.Append(ctx, events.New{MindID: inv.MindID, Type: events.Observation, Source: "tool:" + c.Name, Summary: "tool output from " + c.Name + ": " + truncate(oneLine(res.Output), 140), Salience: 0.6, Confidence: 0.9, CausationID: firstID(ev), CorrelationID: inv.Correlation, //nolint:errcheck
|
||||
Metadata: map[string]any{"trust": events.TrustTool, "capability": c.Name, "execution_id": string(exec.ID)},
|
||||
Payload: events.ObservationPayload{Text: truncate(res.Output, 20000), ContentType: "text/plain", Source: "tool:" + c.Name, Trust: events.TrustTool, Method: "capability", Reference: string(exec.ID), Channel: inv.Target}})
|
||||
}
|
||||
}
|
||||
exec.EventID = firstID(ev)
|
||||
params, _ := json.Marshal(inv.Params)
|
||||
_, err := s.DB.Exec(ctx, `INSERT INTO tool_executions (id, mind_id, capability, target, params, result, error, duration_ms, approval_id, event_id) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
|
||||
exec.ID, exec.MindID, exec.Capability, exec.Target, params, nullRaw(exec.Result), exec.Error, exec.DurationMS, nullID(approvalID), nullID(exec.EventID))
|
||||
if err != nil {
|
||||
s.Log.Error("record execution failed", "err", err)
|
||||
}
|
||||
if execErr != nil {
|
||||
return Outcome{Status: "failed", Decision: d, Error: exec.Error, EventID: exec.EventID, Execution: exec.ID, ApprovalID: approvalID}, nil
|
||||
}
|
||||
return Outcome{Status: "executed", Decision: d, Result: &res, EventID: exec.EventID, Execution: exec.ID, ApprovalID: approvalID}, nil
|
||||
}
|
||||
|
||||
// ---- approvals ----
|
||||
|
||||
const approvalCols = `id, mind_id, capability, target, params, request_hash, reason, risk, evidence, status, decision_by, decision_note, decided_at, session_grant, modified_params, result, error, event_id, correlation_id, expires_at, created_at`
|
||||
|
||||
func scanApproval(row pgx.Row) (Approval, error) {
|
||||
var a Approval
|
||||
var params, evid []byte
|
||||
var modified, result *json.RawMessage
|
||||
var risk string
|
||||
var ev, corr *string
|
||||
if err := row.Scan(&a.ID, &a.MindID, &a.Capability, &a.Target, ¶ms, &a.RequestHash, &a.Reason, &risk, &evid, &a.Status, &a.DecisionBy, &a.DecisionNote, &a.DecidedAt, &a.SessionGrant, &modified, &result, &a.Error, &ev, &corr, &a.ExpiresAt, &a.CreatedAt); err != nil {
|
||||
return a, storage.Translate(err)
|
||||
}
|
||||
a.Risk = Risk(risk)
|
||||
json.Unmarshal(params, &a.Params) //nolint:errcheck
|
||||
json.Unmarshal(evid, &a.Evidence) //nolint:errcheck
|
||||
if a.Params == nil {
|
||||
a.Params = map[string]any{}
|
||||
}
|
||||
if a.Evidence == nil {
|
||||
a.Evidence = []EvidenceRef{}
|
||||
}
|
||||
if modified != nil {
|
||||
json.Unmarshal(*modified, &a.ModifiedParams) //nolint:errcheck
|
||||
}
|
||||
if result != nil {
|
||||
a.Result = *result
|
||||
}
|
||||
if ev != nil {
|
||||
a.EventID = ids.ID(*ev)
|
||||
}
|
||||
if corr != nil {
|
||||
a.CorrelationID = ids.ID(*corr)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// Approvals lists requests by status (empty = all), newest first; mind Nil = all minds.
|
||||
func (s *Service) Approvals(ctx context.Context, mind ids.ID, status string, limit int) ([]Approval, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := s.DB.Query(ctx, `SELECT `+approvalCols+` FROM approval_requests WHERE ($1::uuid IS NULL OR mind_id=$1) AND ($2='' OR status=$2) ORDER BY created_at DESC LIMIT $3`, nullID(mind), status, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Approval
|
||||
for rows.Next() {
|
||||
a, err := scanApproval(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetApproval fetches one request.
|
||||
func (s *Service) GetApproval(ctx context.Context, id ids.ID) (Approval, error) {
|
||||
return scanApproval(s.DB.QueryRow(ctx, `SELECT `+approvalCols+` FROM approval_requests WHERE id=$1`, id))
|
||||
}
|
||||
|
||||
// Approve executes the request now. Modified params are re-validated and
|
||||
// re-hashed; forSession additionally grants matching requests until SessionTTL.
|
||||
func (s *Service) Approve(ctx context.Context, id ids.ID, by, note string, forSession bool, modified map[string]any) (Outcome, error) {
|
||||
a, err := s.GetApproval(ctx, id)
|
||||
if err != nil {
|
||||
return Outcome{}, err
|
||||
}
|
||||
if a.Status != "pending" {
|
||||
return Outcome{}, fmt.Errorf("%w: request is %s", ErrInvalid, a.Status)
|
||||
}
|
||||
if s.now().After(a.ExpiresAt) {
|
||||
s.DB.Exec(ctx, `UPDATE approval_requests SET status='expired' WHERE id=$1`, id) //nolint:errcheck
|
||||
return Outcome{}, fmt.Errorf("%w: request expired", ErrInvalid)
|
||||
}
|
||||
c, ok := s.Registry.Get(a.Capability)
|
||||
if !ok || c.Locked {
|
||||
return Outcome{}, fmt.Errorf("%w: capability unavailable or locked", ErrInvalid)
|
||||
}
|
||||
params := a.Params
|
||||
if modified != nil {
|
||||
if params, err = c.ValidateParams(modified); err != nil {
|
||||
return Outcome{}, err
|
||||
}
|
||||
}
|
||||
hash := Hash(a.MindID, a.Capability, a.Target, params)
|
||||
details := map[string]any{"for_session": forSession, "modified": modified != nil, "hash": hash, "original_hash": a.RequestHash}
|
||||
if err := s.recordDecision(ctx, a.ID, "approved", by, note, details); err != nil {
|
||||
return Outcome{}, err
|
||||
}
|
||||
mod, _ := json.Marshal(modified)
|
||||
if _, err := s.DB.Exec(ctx, `UPDATE approval_requests SET status='approved', decision_by=$2, decision_note=$3, decided_at=$4, session_grant=$5, modified_params=$6 WHERE id=$1`, id, by, note, s.now(), forSession, nullRawIf(mod, modified != nil)); err != nil {
|
||||
return Outcome{}, err
|
||||
}
|
||||
if forSession {
|
||||
ttl := s.SessionTTL
|
||||
if ttl <= 0 {
|
||||
ttl = 8 * time.Hour
|
||||
}
|
||||
s.DB.Exec(ctx, `INSERT INTO session_grants (id, mind_id, capability, target_pattern, granted_by, expires_at) VALUES ($1,$2,$3,$4,$5,$6)`, ids.New(), a.MindID, a.Capability, a.Target, by, s.now().Add(ttl)) //nolint:errcheck
|
||||
}
|
||||
ev, _ := s.Events.Append(ctx, events.New{MindID: a.MindID, Type: events.ToolAuthorised, Source: "operator", Summary: fmt.Sprintf("operator %s approved %s on %q%s", by, a.Capability, a.Target, map[bool]string{true: " for this session", false: ""}[forSession]),
|
||||
Salience: 0.5, Confidence: 1, CausationID: a.EventID, CorrelationID: a.CorrelationID, Metadata: map[string]any{"approval_id": string(a.ID), "actor": by, "capability": a.Capability, "for_session": forSession}, Payload: details})
|
||||
inv := Invocation{MindID: a.MindID, Capability: a.Capability, Target: a.Target, Params: params, Reason: a.Reason, Evidence: a.Evidence, Correlation: a.CorrelationID, ApprovalID: a.ID}
|
||||
out, err := s.execute(ctx, inv, c, Decision{Effect: Allow, Reason: "approved by " + by}, firstID(ev), a.ID)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
status := "executed"
|
||||
if out.Status == "failed" {
|
||||
status = "failed"
|
||||
}
|
||||
rj, _ := json.Marshal(out.Result)
|
||||
s.DB.Exec(ctx, `UPDATE approval_requests SET status=$2, result=$3, error=$4 WHERE id=$1`, id, status, nullRawIf(rj, out.Result != nil), out.Error) //nolint:errcheck
|
||||
s.selfModelChanged(ctx, a.MindID)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Deny refuses a request with an explanation the mind will see.
|
||||
func (s *Service) Deny(ctx context.Context, id ids.ID, by, note string) (Approval, error) {
|
||||
a, err := s.GetApproval(ctx, id)
|
||||
if err != nil {
|
||||
return Approval{}, err
|
||||
}
|
||||
if a.Status != "pending" {
|
||||
return Approval{}, fmt.Errorf("%w: request is %s", ErrInvalid, a.Status)
|
||||
}
|
||||
if err := s.recordDecision(ctx, a.ID, "denied", by, note, nil); err != nil {
|
||||
return Approval{}, err
|
||||
}
|
||||
if _, err := s.DB.Exec(ctx, `UPDATE approval_requests SET status='denied', decision_by=$2, decision_note=$3, decided_at=$4 WHERE id=$1`, id, by, note, s.now()); err != nil {
|
||||
return Approval{}, err
|
||||
}
|
||||
if s.Metrics != nil {
|
||||
s.Metrics.ToolDenied.WithLabelValues(a.Capability).Inc()
|
||||
}
|
||||
s.Events.Append(ctx, events.New{MindID: a.MindID, Type: events.ToolDenied, Source: "operator", Summary: fmt.Sprintf("operator %s denied %s on %q: %s", by, a.Capability, a.Target, firstNonEmpty(note, "no reason given")), //nolint:errcheck
|
||||
Salience: 0.5, Confidence: 1, CausationID: a.EventID, CorrelationID: a.CorrelationID, Metadata: map[string]any{"approval_id": string(a.ID), "actor": by, "capability": a.Capability, "target": a.Target}, Payload: map[string]any{"note": note, "params": a.Params}})
|
||||
return s.GetApproval(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) recordDecision(ctx context.Context, request ids.ID, decision, by, note string, details map[string]any) error {
|
||||
if details == nil {
|
||||
details = map[string]any{}
|
||||
}
|
||||
raw, _ := json.Marshal(details)
|
||||
_, err := s.DB.Exec(ctx, `INSERT INTO approval_decisions (id, request_id, decision, decided_by, note, details) VALUES ($1,$2,$3,$4,$5,$6)`, ids.New(), request, decision, by, note, raw)
|
||||
return err
|
||||
}
|
||||
|
||||
// ExpirePending marks overdue requests expired.
|
||||
func (s *Service) ExpirePending(ctx context.Context) (int64, error) {
|
||||
tag, err := s.DB.Exec(ctx, `UPDATE approval_requests SET status='expired' WHERE status='pending' AND expires_at < $1`, s.now())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
|
||||
// Grants lists live session grants for a mind.
|
||||
func (s *Service) Grants(ctx context.Context, mind ids.ID) ([]Grant, error) {
|
||||
rows, err := s.DB.Query(ctx, `SELECT id, mind_id, capability, target_pattern, granted_by, expires_at, created_at FROM session_grants WHERE ($1::uuid IS NULL OR mind_id=$1) AND revoked_at IS NULL AND expires_at > $2 ORDER BY created_at DESC`, nullID(mind), s.now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Grant
|
||||
for rows.Next() {
|
||||
var g Grant
|
||||
if err := rows.Scan(&g.ID, &g.MindID, &g.Capability, &g.TargetPattern, &g.GrantedBy, &g.ExpiresAt, &g.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, g)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// RevokeGrant ends a session grant early.
|
||||
func (s *Service) RevokeGrant(ctx context.Context, id ids.ID) error {
|
||||
tag, err := s.DB.Exec(ctx, `UPDATE session_grants SET revoked_at=$2 WHERE id=$1 AND revoked_at IS NULL`, id, s.now())
|
||||
if err == nil && tag.RowsAffected() == 0 {
|
||||
return storage.ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Executions lists recent runs.
|
||||
func (s *Service) Executions(ctx context.Context, mind ids.ID, limit int) ([]Execution, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.DB.Query(ctx, `SELECT id, mind_id, capability, target, params, result, error, duration_ms, approval_id, event_id, created_at FROM tool_executions WHERE ($1::uuid IS NULL OR mind_id=$1) ORDER BY created_at DESC LIMIT $2`, nullID(mind), limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Execution
|
||||
for rows.Next() {
|
||||
var e Execution
|
||||
var params []byte
|
||||
var result *json.RawMessage
|
||||
var appr, ev *string
|
||||
if err := rows.Scan(&e.ID, &e.MindID, &e.Capability, &e.Target, ¶ms, &result, &e.Error, &e.DurationMS, &appr, &ev, &e.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
json.Unmarshal(params, &e.Params) //nolint:errcheck
|
||||
if result != nil {
|
||||
e.Result = *result
|
||||
}
|
||||
if appr != nil {
|
||||
e.ApprovalID = ids.ID(*appr)
|
||||
}
|
||||
if ev != nil {
|
||||
e.EventID = ids.ID(*ev)
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
func toEvidence(in []EvidenceRef) []events.EvidenceRef {
|
||||
out := make([]events.EvidenceRef, 0, len(in))
|
||||
for _, e := range in {
|
||||
out = append(out, events.EvidenceRef{Kind: e.Kind, ID: e.ID, Note: e.Note})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstID(evs []events.Event) ids.ID {
|
||||
if len(evs) > 0 {
|
||||
return evs[0].ID
|
||||
}
|
||||
return ids.Nil
|
||||
}
|
||||
|
||||
func nullID(id ids.ID) *string {
|
||||
if id.IsZero() {
|
||||
return nil
|
||||
}
|
||||
s := string(id)
|
||||
return &s
|
||||
}
|
||||
|
||||
func nullRaw(r json.RawMessage) *json.RawMessage {
|
||||
if len(r) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &r
|
||||
}
|
||||
|
||||
func nullRawIf(r json.RawMessage, cond bool) *json.RawMessage {
|
||||
if !cond || len(r) == 0 || string(r) == "null" {
|
||||
return nil
|
||||
}
|
||||
return &r
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) > n {
|
||||
return s[:n-1] + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func oneLine(s string) string { return strings.Join(strings.Fields(s), " ") }
|
||||
@@ -0,0 +1,100 @@
|
||||
package cognition
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/andydixon/noema/internal/attention"
|
||||
"github.com/andydixon/noema/internal/brain"
|
||||
"github.com/andydixon/noema/internal/capabilities"
|
||||
"github.com/andydixon/noema/internal/conversation"
|
||||
"github.com/andydixon/noema/internal/workspace"
|
||||
"github.com/andydixon/noema/pkg/events"
|
||||
"github.com/andydixon/noema/pkg/ids"
|
||||
)
|
||||
|
||||
// Act proposes capability invocations from requests the mind receives. In
|
||||
// this phase the only deterministic mapping is "fetch <url>" → http.fetch;
|
||||
// integrations register more. Proposing is free; executing goes through the
|
||||
// policy layer, so a request never bypasses ALLOW / ASK / DENY.
|
||||
type Act struct{}
|
||||
|
||||
// Name implements brain.Process.
|
||||
func (Act) Name() string { return "act" }
|
||||
|
||||
var urlRe = regexp.MustCompile(`https?://[^\s<>"']+`)
|
||||
|
||||
// Observe implements brain.Process.
|
||||
func (Act) Observe(_ context.Context, s *brain.State) ([]brain.Candidate, error) {
|
||||
if s.Services.Capabilities == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var out []brain.Candidate
|
||||
for _, ev := range s.NewEvents {
|
||||
if ev.Type != events.Observation || ev.Metadata["conversation_id"] == nil {
|
||||
continue
|
||||
}
|
||||
var p events.ObservationPayload
|
||||
if json.Unmarshal(ev.Payload, &p) != nil {
|
||||
continue
|
||||
}
|
||||
a := conversation.Analyse(p.Text)
|
||||
if a.Intent != "request" {
|
||||
continue
|
||||
}
|
||||
lower := strings.ToLower(p.Text)
|
||||
if u := urlRe.FindString(p.Text); u != "" && (strings.Contains(lower, "fetch") || strings.Contains(lower, "read") || strings.Contains(lower, "look at") || strings.Contains(lower, "check")) {
|
||||
u = strings.TrimRight(u, ".,)")
|
||||
out = append(out, brain.Candidate{Kind: "action", Subject: "http.fetch:" + u, Summary: "proposed action: fetch " + u,
|
||||
Content: map[string]any{"capability": "http.fetch", "target": u, "params": map[string]any{"url": u}, "reason": "asked by " + p.Actor + ": " + truncate(p.Text, 120), "source_event": string(ev.ID), "conversation_id": ev.Metadata["conversation_id"]},
|
||||
Features: attention.Features{Salience: 0.7, Urgency: 0.6, Consequence: 0.7, Confidence: 0.9, OperatorPriority: prioFor(p.Trust), ContextRelevance: 0.5},
|
||||
Confidence: 0.9, SourceEvents: []ids.ID{ev.ID}, Evidence: []events.EvidenceRef{{Kind: "event", ID: string(ev.ID), Note: "request"}}})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func prioFor(t events.Trust) float64 {
|
||||
if t == events.TrustOperator {
|
||||
return 1
|
||||
}
|
||||
return 0.7
|
||||
}
|
||||
|
||||
// action invokes the capability layer for an admitted action thought and
|
||||
// records the outcome in working memory. The layer decides; this handler
|
||||
// never executes anything itself.
|
||||
func (h Handlers) action(ctx context.Context, c *brain.Cycle, t workspace.Thought) error {
|
||||
svc := c.State.Services.Capabilities
|
||||
if svc == nil {
|
||||
return nil
|
||||
}
|
||||
capName, _ := t.Content["capability"].(string)
|
||||
target, _ := t.Content["target"].(string)
|
||||
reason, _ := t.Content["reason"].(string)
|
||||
params, _ := t.Content["params"].(map[string]any)
|
||||
var ev []capabilities.EvidenceRef
|
||||
for _, e := range t.Evidence {
|
||||
ev = append(ev, capabilities.EvidenceRef{Kind: e.Kind, ID: e.ID, Note: e.Note})
|
||||
}
|
||||
if c.State.Mode == brain.ModeDreaming {
|
||||
ctx = capabilities.WithForbidden(ctx, "dreaming")
|
||||
}
|
||||
out, err := svc.Invoke(ctx, capabilities.Invocation{MindID: c.State.Mind.ID, Capability: capName, Target: target, Params: params, Reason: reason, Evidence: ev, Correlation: c.State.CycleID})
|
||||
if err != nil {
|
||||
c.Emit(events.New{Type: events.Metacognition, Source: "brain.act", Summary: "action refused before policy: " + err.Error(), Salience: 0.4, Confidence: 1, CausationID: t.EventID, Metadata: map[string]any{"check": "action_invalid", "capability": capName}})
|
||||
return nil
|
||||
}
|
||||
content := fmt.Sprintf("%s on %s: %s", capName, target, out.Status)
|
||||
if out.Status == "pending" {
|
||||
content += " (awaiting operator approval)"
|
||||
}
|
||||
if out.Status == "denied" {
|
||||
content += " (" + out.Decision.Reason + ")"
|
||||
}
|
||||
c.Runtime.Hold(workspace.Item{Key: "action:" + capName + ":" + target, Content: content, Kind: "note", SourceEvent: out.EventID, Ref: string(out.ApprovalID)})
|
||||
return nil
|
||||
}
|
||||
@@ -38,6 +38,7 @@ func (h Handlers) Register(r *brain.Registry) {
|
||||
r.Handle("goal", h.goal)
|
||||
r.Handle("question", h.question)
|
||||
r.Handle("metacognition", h.metacognition)
|
||||
r.Handle("action", h.action)
|
||||
}
|
||||
|
||||
// emitter adapts the cycle to the beliefs store's Emitter.
|
||||
|
||||
@@ -200,12 +200,24 @@ func (h Handlers) buildIntent(ctx context.Context, c *brain.Cycle, a conversatio
|
||||
}
|
||||
case "request":
|
||||
in.Kind = "decline"
|
||||
caps := c.State.SelfModel.Capabilities
|
||||
if len(caps) == 0 {
|
||||
var usable []string
|
||||
for _, cap := range c.State.SelfModel.Capabilities {
|
||||
if cap.Policy == "allow" || cap.Policy == "ask" {
|
||||
usable = append(usable, cap.Name+" ("+cap.Policy+")")
|
||||
}
|
||||
}
|
||||
if len(usable) == 0 {
|
||||
in.Summary = "Explain honestly that no external capabilities are granted, so the request cannot be carried out; offer what is known instead."
|
||||
in.Points = append(in.Points, "I have no capabilities granted to act on external systems.")
|
||||
} else {
|
||||
in.Summary = "Explain which capabilities exist and that actions require policy approval."
|
||||
in.Kind = "acknowledge"
|
||||
in.Summary = "Say which capabilities could apply and that any action goes through policy; if one was proposed this cycle its status is in working memory."
|
||||
in.Points = append(in.Points, "Capabilities I may use: "+strings.Join(usable, ", ")+".")
|
||||
for _, it := range c.Runtime.Workspace().WorkingMemory {
|
||||
if strings.HasPrefix(it.Key, "action:") {
|
||||
in.Points = append(in.Points, "Action status: "+it.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
h.addKnowledge(ctx, c, &in, topic)
|
||||
case "introspection":
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package cognition
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/andydixon/noema/internal/capabilities"
|
||||
evstore "github.com/andydixon/noema/internal/events"
|
||||
"github.com/andydixon/noema/internal/minds"
|
||||
"github.com/andydixon/noema/pkg/events"
|
||||
"github.com/andydixon/noema/pkg/ids"
|
||||
)
|
||||
|
||||
// SelfModelSync keeps each mind's self-model capability list equal to the
|
||||
// effective policy, so the mind cannot claim tools it does not have.
|
||||
type SelfModelSync struct {
|
||||
Minds *minds.Store
|
||||
Caps *capabilities.Service
|
||||
Events *evstore.Store
|
||||
Log *slog.Logger
|
||||
}
|
||||
|
||||
// Sync updates one mind (or all when mind is Nil) and emits
|
||||
// self_model_updated only when something changed.
|
||||
func (s SelfModelSync) Sync(ctx context.Context, mind ids.ID) {
|
||||
var list []minds.Mind
|
||||
if mind.IsZero() {
|
||||
list, _ = s.Minds.List(ctx, false)
|
||||
} else if m, err := s.Minds.Get(ctx, mind); err == nil {
|
||||
list = []minds.Mind{m}
|
||||
}
|
||||
for _, m := range list {
|
||||
inv, err := s.Caps.Inventory(ctx, m.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
sm, err := s.Minds.GetSelfModel(ctx, m.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var caps []minds.CapabilityEntry
|
||||
for _, i := range inv {
|
||||
eff := string(i.Effect)
|
||||
if i.Capability.Locked {
|
||||
eff = "unavailable"
|
||||
}
|
||||
caps = append(caps, minds.CapabilityEntry{Name: i.Capability.Name, Description: i.Capability.Description, Policy: eff})
|
||||
}
|
||||
if fmt.Sprint(caps) == fmt.Sprint(sm.Capabilities) {
|
||||
continue
|
||||
}
|
||||
sm.Capabilities = caps
|
||||
sm.Refresh(m.Personality)
|
||||
if err := s.Minds.SaveSelfModel(ctx, m.ID, sm); err != nil {
|
||||
s.Log.Warn("self-model save failed", "mind", m.Slug, "err", err)
|
||||
continue
|
||||
}
|
||||
allow, ask := 0, 0
|
||||
for _, c := range caps {
|
||||
switch c.Policy {
|
||||
case "allow":
|
||||
allow++
|
||||
case "ask":
|
||||
ask++
|
||||
}
|
||||
}
|
||||
s.Events.Append(ctx, events.New{MindID: m.ID, Type: events.SelfModelUpdated, Source: "capabilities", Summary: fmt.Sprintf("self-model: %d capabilities allowed, %d need approval, rest denied", allow, ask), //nolint:errcheck
|
||||
Salience: 0.3, Confidence: 1, Metadata: map[string]any{"allow": allow, "ask": ask}, Payload: map[string]any{"capabilities": caps}})
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,8 @@ func Rules(ev events.Event, cfg minds.CognitiveConfig, p minds.Personality) []Ca
|
||||
if ev.Metadata["check"] == "overconfidence" || ev.Metadata["check"] == "loop" {
|
||||
out = append(out, Candidate{Level: "warn", Title: "Metacognitive warning", Body: ev.Summary, Rule: "metacognition", Reason: map[string]any{"check": ev.Metadata["check"]}, AggregateKey: "meta:" + str(ev.Metadata["check"]), SourceEvent: ev.ID})
|
||||
}
|
||||
case events.ToolRequested:
|
||||
out = append(out, Candidate{Level: "alert", Title: "An action needs your approval", Body: ev.Summary, Rule: "tool_requested", Reason: map[string]any{"risk": ev.Metadata["risk"]}, AggregateKey: "approval:" + str(ev.Metadata["approval_id"]), SourceEvent: ev.ID})
|
||||
case events.ToolDenied:
|
||||
out = append(out, Candidate{Level: "alert", Title: "An action was denied by policy", Body: ev.Summary, Rule: "tool_denied", Reason: map[string]any{}, AggregateKey: "denied:" + str(ev.Metadata["capability"]), SourceEvent: ev.ID})
|
||||
case events.Observation:
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/andydixon/noema/internal/auth"
|
||||
"github.com/andydixon/noema/internal/capabilities"
|
||||
"github.com/andydixon/noema/pkg/ids"
|
||||
)
|
||||
|
||||
func (u *UI) capabilityRoutes(m *http.ServeMux) {
|
||||
m.Handle("GET /policies", u.require(auth.RoleObserver, u.policiesPage))
|
||||
m.Handle("POST /policies", u.require(auth.RoleAdministrator, u.policyCreate))
|
||||
m.Handle("POST /policies/{id}/delete", u.require(auth.RoleAdministrator, u.policyDelete))
|
||||
m.Handle("POST /policies/grants/{id}/revoke", u.require(auth.RoleOperator, u.grantRevoke))
|
||||
m.Handle("GET /approvals", u.require(auth.RoleObserver, u.approvalsPage))
|
||||
m.Handle("POST /approvals/{id}/decide", u.require(auth.RoleOperator, u.approvalDecide))
|
||||
}
|
||||
|
||||
func (u *UI) mindNames(r *http.Request) (map[ids.ID]string, []any) {
|
||||
list, _ := u.deps.Minds.List(r.Context(), true)
|
||||
names := map[ids.ID]string{}
|
||||
var live []any
|
||||
for _, m := range list {
|
||||
names[m.ID] = m.DisplayName
|
||||
if m.Status == "active" {
|
||||
live = append(live, m)
|
||||
}
|
||||
}
|
||||
return names, live
|
||||
}
|
||||
|
||||
func (u *UI) policiesPage(w http.ResponseWriter, r *http.Request) {
|
||||
names, _ := u.mindNames(r)
|
||||
mindsList, _ := u.deps.Minds.List(r.Context(), false)
|
||||
caps := u.deps.Capabilities.Registry.List()
|
||||
matrix := map[ids.ID]map[string]capabilities.InventoryItem{}
|
||||
for _, m := range mindsList {
|
||||
inv, _ := u.deps.Capabilities.Inventory(r.Context(), m.ID)
|
||||
row := map[string]capabilities.InventoryItem{}
|
||||
for _, i := range inv {
|
||||
row[i.Capability.Name] = i
|
||||
}
|
||||
matrix[m.ID] = row
|
||||
}
|
||||
pols, _ := u.deps.Capabilities.Policies(r.Context(), ids.Nil)
|
||||
grants, _ := u.deps.Capabilities.Grants(r.Context(), ids.Nil)
|
||||
u.render(w, r, "policies", "Capabilities and policies", map[string]any{"Capabilities": caps, "Minds": mindsList, "MindNames": names, "Matrix": matrix, "Policies": pols, "Grants": grants})
|
||||
}
|
||||
|
||||
func (u *UI) policyCreate(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm() //nolint:errcheck
|
||||
p := capabilities.Policy{Capability: r.PostFormValue("capability"), TargetPattern: r.PostFormValue("target_pattern"), Effect: capabilities.Effect(r.PostFormValue("effect")), Reason: r.PostFormValue("reason")}
|
||||
if v := r.PostFormValue("mind_id"); v != "" {
|
||||
p.MindID, _ = ids.Parse(v)
|
||||
}
|
||||
if hf, err := strconv.Atoi(r.PostFormValue("hours_from")); err == nil {
|
||||
if ht, err := strconv.Atoi(r.PostFormValue("hours_to")); err == nil {
|
||||
p.HoursFrom, p.HoursTo = &hf, &ht
|
||||
}
|
||||
}
|
||||
if _, err := u.policyF(r, p); err != nil {
|
||||
u.flash(w, r, "error", err.Error(), "/policies")
|
||||
return
|
||||
}
|
||||
u.flash(w, r, "ok", "Rule added.", "/policies")
|
||||
}
|
||||
|
||||
func (u *UI) policyDelete(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := ids.Parse(r.PathValue("id"))
|
||||
if err != nil || u.deps.Capabilities.DeletePolicy(r.Context(), id) != nil {
|
||||
u.flash(w, r, "error", "Rule not found.", "/policies")
|
||||
return
|
||||
}
|
||||
u.deps.Audit.Log(r.Context(), "policy.delete", "policy", id.String(), nil)
|
||||
u.flash(w, r, "ok", "Rule deleted.", "/policies")
|
||||
}
|
||||
|
||||
func (u *UI) grantRevoke(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := ids.Parse(r.PathValue("id"))
|
||||
if err != nil || u.deps.Capabilities.RevokeGrant(r.Context(), id) != nil {
|
||||
u.flash(w, r, "error", "Grant not found.", "/policies")
|
||||
return
|
||||
}
|
||||
u.deps.Audit.Log(r.Context(), "grant.revoke", "grant", id.String(), nil)
|
||||
u.flash(w, r, "ok", "Session grant revoked.", "/policies")
|
||||
}
|
||||
|
||||
func (u *UI) approvalsPage(w http.ResponseWriter, r *http.Request) {
|
||||
names, _ := u.mindNames(r)
|
||||
pending, _ := u.deps.Capabilities.Approvals(r.Context(), ids.Nil, "pending", 100)
|
||||
all, _ := u.deps.Capabilities.Approvals(r.Context(), ids.Nil, "", 60)
|
||||
var decided []capabilities.Approval
|
||||
for _, a := range all {
|
||||
if a.Status != "pending" {
|
||||
decided = append(decided, a)
|
||||
}
|
||||
}
|
||||
execs, _ := u.deps.Capabilities.Executions(r.Context(), ids.Nil, 30)
|
||||
u.render(w, r, "approvals", "Approval centre", map[string]any{"Pending": pending, "Decided": decided, "Executions": execs, "MindNames": names})
|
||||
}
|
||||
|
||||
func (u *UI) approvalDecide(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := ids.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
u.flash(w, r, "error", "Bad request id.", "/approvals")
|
||||
return
|
||||
}
|
||||
r.ParseForm() //nolint:errcheck
|
||||
var params map[string]any
|
||||
if raw := strings.TrimSpace(r.PostFormValue("params")); raw != "" {
|
||||
if err := json.Unmarshal([]byte(raw), ¶ms); err != nil {
|
||||
u.flash(w, r, "error", "Parameters are not a JSON object.", "/approvals")
|
||||
return
|
||||
}
|
||||
// Only treat as a modification when it differs from the original.
|
||||
if a, err := u.deps.Capabilities.GetApproval(r.Context(), id); err == nil {
|
||||
orig, _ := json.Marshal(a.Params)
|
||||
now, _ := json.Marshal(params)
|
||||
if string(orig) == string(now) {
|
||||
params = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
decision := r.PostFormValue("decision")
|
||||
if _, err := u.decideF(r, id, decision, r.PostFormValue("note"), params); err != nil {
|
||||
u.flash(w, r, "error", err.Error(), "/approvals")
|
||||
return
|
||||
}
|
||||
u.flash(w, r, "ok", "Decision recorded: "+decision+".", "/approvals")
|
||||
}
|
||||
@@ -104,7 +104,13 @@ func (u *UI) dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
names[m.ID] = m.DisplayName
|
||||
}
|
||||
evs, _ := u.deps.Events.Query(r.Context(), evstore.Filter{MinSalience: 0.6, Limit: 12})
|
||||
u.render(w, r, "dashboard", "Dashboard", map[string]any{"Minds": mindsList, "MindNames": names, "Events": evs, "Status": u.statusF(r), "Audit": entries})
|
||||
pending := 0
|
||||
if u.deps.Capabilities != nil {
|
||||
if list, err := u.deps.Capabilities.Approvals(r.Context(), ids.Nil, "pending", 500); err == nil {
|
||||
pending = len(list)
|
||||
}
|
||||
}
|
||||
u.render(w, r, "dashboard", "Dashboard", map[string]any{"Minds": mindsList, "MindNames": names, "Events": evs, "Status": u.statusF(r), "Audit": entries, "Pending": pending})
|
||||
}
|
||||
|
||||
func (u *UI) system(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
+12
-4
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/andydixon/noema/internal/attention"
|
||||
"github.com/andydixon/noema/internal/auth"
|
||||
"github.com/andydixon/noema/internal/beliefs"
|
||||
"github.com/andydixon/noema/internal/capabilities"
|
||||
"github.com/andydixon/noema/internal/consolidation"
|
||||
"github.com/andydixon/noema/internal/conversation"
|
||||
"github.com/andydixon/noema/internal/goals"
|
||||
@@ -58,6 +59,8 @@ type UI struct {
|
||||
entF func(r *http.Request, mind, id ids.ID) (api.EntityDetail, error)
|
||||
mergeF func(r *http.Request, mind ids.ID, e graph.Entity, other string) error
|
||||
distinctF func(r *http.Request, mind ids.ID, e graph.Entity, other string) error
|
||||
policyF func(r *http.Request, p capabilities.Policy) (capabilities.Policy, error)
|
||||
decideF func(r *http.Request, id ids.ID, decision, note string, params map[string]any) (any, error)
|
||||
}
|
||||
|
||||
// Actions groups the server-side operations the UI delegates to the API layer
|
||||
@@ -80,6 +83,8 @@ type Actions struct {
|
||||
Entity func(r *http.Request, mind, id ids.ID) (api.EntityDetail, error)
|
||||
Merge func(r *http.Request, mind ids.ID, e graph.Entity, other string) error
|
||||
Distinct func(r *http.Request, mind ids.ID, e graph.Entity, other string) error
|
||||
Policy func(r *http.Request, p capabilities.Policy) (capabilities.Policy, error)
|
||||
Decide func(r *http.Request, id ids.ID, decision, note string, params map[string]any) (any, error)
|
||||
}
|
||||
|
||||
// View is the data passed to every template.
|
||||
@@ -109,7 +114,7 @@ func New(deps app.Deps, a Actions) (*UI, error) {
|
||||
deps.Log.Info("ui: serving templates and static files from ./web (development)")
|
||||
}
|
||||
}
|
||||
u := &UI{deps: deps, static: staticFS, statusF: a.Status, ctxF: a.Context, wsF: a.Workspace, memF: a.Memory, belF: a.Belief, emitF: a.Emitter, postF: a.Post, goalF: a.Goal, goalUpdF: a.GoalUpdate, predF: a.Predict, resolveF: a.Resolve, resetF: a.ResetState, consolF: a.Consolidate, reflF: a.Reflect, entF: a.Entity, mergeF: a.Merge, distinctF: a.Distinct}
|
||||
u := &UI{deps: deps, static: staticFS, statusF: a.Status, ctxF: a.Context, wsF: a.Workspace, memF: a.Memory, belF: a.Belief, emitF: a.Emitter, postF: a.Post, goalF: a.Goal, goalUpdF: a.GoalUpdate, predF: a.Predict, resolveF: a.Resolve, resetF: a.ResetState, consolF: a.Consolidate, reflF: a.Reflect, entF: a.Entity, mergeF: a.Merge, distinctF: a.Distinct, policyF: a.Policy, decideF: a.Decide}
|
||||
if err := u.parse(tmplFS); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -164,7 +169,11 @@ func (u *UI) parse(fsys fs.FS) error {
|
||||
"llmRoles": func() []llm.RoleInfo { return llm.Roles },
|
||||
"pretty": prettyRaw,
|
||||
"entityKinds": func() []graph.Kind { return graph.AllKinds },
|
||||
"join": strings.Join,
|
||||
"jsonOf": func(v any) json.RawMessage {
|
||||
b, _ := json.Marshal(v)
|
||||
return b
|
||||
},
|
||||
"join": strings.Join,
|
||||
"outputs": func(raw json.RawMessage) []consolidation.Output {
|
||||
var out []consolidation.Output
|
||||
json.Unmarshal(raw, &out) //nolint:errcheck
|
||||
@@ -369,11 +378,10 @@ func (u *UI) Register(m *http.ServeMux) {
|
||||
u.motivationRoutes(m)
|
||||
u.cognitionRoutes(m)
|
||||
u.graphRoutes(m)
|
||||
u.capabilityRoutes(m)
|
||||
|
||||
for _, p := range []struct{ path, title, note string }{
|
||||
{"/approvals", "Approvals", "The approval centre arrives with the capability system."},
|
||||
{"/integrations", "Integrations", "Integrations arrive in phase 13."},
|
||||
{"/policies", "Policies", "The policy matrix arrives in phase 12."},
|
||||
} {
|
||||
m.Handle("GET "+p.path, u.require(auth.RoleObserver, func(w http.ResponseWriter, r *http.Request) { u.render(w, r, "placeholder", p.title, p.note) }))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user