feat(capabilities): metadata, frozen registry, scoped grants and requests

- Capabilities declare access (read/write, unknown fails closed as
  write), side effects, idempotency, timeout, cost estimate and the trust
  of their output (web content is low trust). A capability may derive its
  policy target from its parameters; a disagreeing target is refused, and
  approvals whose modified parameters change the target are refused.
- The registry is frozen at start-up; registration afterwards panics.
- Invocation.RequireApproval turns ALLOW into ASK for commitments; it can
  only tighten, and scoped grants never satisfy it.
- Scoped grants (migration 0025): capability, target and parameter globs,
  access, expiry of at most seven days and an optional operation count
  consumed atomically; applied only where policy says ASK. A sweep marks
  lapsed grants and refreshes the self-model, which now lists live grants.
- Capability requests carry scope, duration, operation cap, reason,
  evidence, risk, mitigation, fallback, alternatives and explicit decision
  history; they batch per intention, are capped per mind (operator
  attention budget) and can be decided only by a named person. Component
  names are refused as deciders, for approvals too, and as usernames.
- API routes, OpenAPI entries, scheduler sweeps and approval-centre
  sections for requests and temporary grants.
This commit is contained in:
Andy Dixon
2026-09-06 22:29:03 +01:00
parent 286d40a2d9
commit 937c5fb2ff
23 changed files with 2094 additions and 33 deletions
+9 -1
View File
@@ -166,6 +166,7 @@ func run() error {
for _, c := range integrations.Capabilities(deps.Integrations, integrations.GitPoller{DataDir: "./data/git"}, integrations.PrometheusPoller{Secrets: sec}) { for _, c := range integrations.Capabilities(deps.Integrations, integrations.GitPoller{DataDir: "./data/git"}, integrations.PrometheusPoller{Secrets: sec}) {
capReg.Add(c) capReg.Add(c)
} }
capReg.Freeze()
deps.Capabilities = &capabilities.Service{Registry: capReg, DB: db, Events: deps.Events, Log: log, Metrics: metrics, ApprovalTTL: 24 * time.Hour, SessionTTL: 8 * time.Hour} deps.Capabilities = &capabilities.Service{Registry: capReg, DB: db, Events: deps.Events, Log: log, Metrics: metrics, ApprovalTTL: 24 * time.Hour, SessionTTL: 8 * time.Hour}
selfSync := cognition.SelfModelSync{Minds: deps.Minds, Caps: deps.Capabilities, Events: deps.Events, Log: log} selfSync := cognition.SelfModelSync{Minds: deps.Minds, Caps: deps.Capabilities, Events: deps.Events, Log: log}
deps.Capabilities.OnSelfModel = selfSync.Sync deps.Capabilities.OnSelfModel = selfSync.Sync
@@ -202,7 +203,14 @@ func run() error {
}} }}
selfSync.Sync(ctx, ids.Nil) selfSync.Sync(ctx, ids.Nil)
group.Every("integrations.tick", time.Minute, deps.Integrations.Tick) group.Every("integrations.tick", time.Minute, deps.Integrations.Tick)
group.Every("approvals.expire", 5*time.Minute, func(ctx context.Context) error { _, err := deps.Capabilities.ExpirePending(ctx); return err }) group.Every("approvals.expire", 5*time.Minute, func(ctx context.Context) error {
if _, err := deps.Capabilities.ExpirePending(ctx); err != nil {
return err
}
_, err := deps.Capabilities.ExpireCapabilityRequests(ctx)
return err
})
group.Every("grants.sweep", time.Minute, func(ctx context.Context) error { _, err := deps.Capabilities.SweepGrants(ctx); return err })
group.Every("predictions.resolve", time.Minute, deps.Resolver.Run) group.Every("predictions.resolve", time.Minute, deps.Resolver.Run)
group.Every("cognition.background", 5*time.Minute, func(ctx context.Context) error { return backgroundCognition(ctx, deps) }) group.Every("cognition.background", 5*time.Minute, func(ctx context.Context) error { return backgroundCognition(ctx, deps) })
group.Every("memory.decay", 6*time.Hour, func(ctx context.Context) error { return decayMemories(ctx, deps) }) group.Every("memory.decay", 6*time.Hour, func(ctx context.Context) error { return decayMemories(ctx, deps) })
+332
View File
@@ -8498,6 +8498,338 @@
} }
} }
} }
},
"/api/v1/capability-requests": {
"get": {
"operationId": "capabilityRequestsList",
"summary": "capability requests list",
"x-required-role": "observer",
"tags": [
"capability-requests"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"401": {
"$ref": "#/components/responses/Error"
},
"403": {
"$ref": "#/components/responses/Error"
},
"404": {
"$ref": "#/components/responses/Error"
},
"422": {
"$ref": "#/components/responses/Error"
}
}
}
},
"/api/v1/capability-requests/{id}": {
"get": {
"operationId": "capabilityRequestGet",
"summary": "capability request get",
"x-required-role": "observer",
"tags": [
"capability-requests"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"401": {
"$ref": "#/components/responses/Error"
},
"403": {
"$ref": "#/components/responses/Error"
},
"404": {
"$ref": "#/components/responses/Error"
},
"422": {
"$ref": "#/components/responses/Error"
}
},
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "identifier"
}
]
}
},
"/api/v1/capability-requests/{id}/grant": {
"post": {
"operationId": "capabilityRequestGrant",
"summary": "capability request grant",
"x-required-role": "operator",
"tags": [
"capability-requests"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"401": {
"$ref": "#/components/responses/Error"
},
"403": {
"$ref": "#/components/responses/Error"
},
"404": {
"$ref": "#/components/responses/Error"
},
"422": {
"$ref": "#/components/responses/Error"
},
"201": {
"description": "Created"
}
},
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "identifier"
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
},
"/api/v1/capability-requests/{id}/deny": {
"post": {
"operationId": "capabilityRequestDeny",
"summary": "capability request deny",
"x-required-role": "operator",
"tags": [
"capability-requests"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"401": {
"$ref": "#/components/responses/Error"
},
"403": {
"$ref": "#/components/responses/Error"
},
"404": {
"$ref": "#/components/responses/Error"
},
"422": {
"$ref": "#/components/responses/Error"
},
"201": {
"description": "Created"
}
},
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "identifier"
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
},
"/api/v1/scoped-grants": {
"get": {
"operationId": "scopedGrantsList",
"summary": "scoped grants list",
"x-required-role": "observer",
"tags": [
"scoped-grants"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"401": {
"$ref": "#/components/responses/Error"
},
"403": {
"$ref": "#/components/responses/Error"
},
"404": {
"$ref": "#/components/responses/Error"
},
"422": {
"$ref": "#/components/responses/Error"
}
}
}
},
"/api/v1/minds/{mind}/scoped-grants": {
"post": {
"operationId": "scopedGrantCreate",
"summary": "scoped grant create",
"x-required-role": "operator",
"tags": [
"scoped-grants"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"401": {
"$ref": "#/components/responses/Error"
},
"403": {
"$ref": "#/components/responses/Error"
},
"404": {
"$ref": "#/components/responses/Error"
},
"422": {
"$ref": "#/components/responses/Error"
},
"201": {
"description": "Created"
}
},
"parameters": [
{
"name": "mind",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "mind"
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
},
"/api/v1/scoped-grants/{id}": {
"delete": {
"operationId": "scopedGrantRevoke",
"summary": "scoped grant revoke",
"x-required-role": "operator",
"tags": [
"scoped-grants"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"401": {
"$ref": "#/components/responses/Error"
},
"403": {
"$ref": "#/components/responses/Error"
},
"404": {
"$ref": "#/components/responses/Error"
},
"422": {
"$ref": "#/components/responses/Error"
},
"204": {
"description": "No Content"
}
},
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "identifier"
}
]
}
} }
} }
} }
+196
View File
@@ -0,0 +1,196 @@
package api
import (
"errors"
"net/http"
"strconv"
"time"
"github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/capabilities"
"github.com/andydixon/noema/pkg/ids"
)
// negotiationRoutes: structured capability requests from minds and the
// scoped, expiring grants operators make in answer (or directly).
func (s *Server) negotiationRoutes() {
m := s.mux
m.Handle("GET /api/v1/capability-requests", require(auth.RoleObserver, s.capabilityRequestsList))
m.Handle("GET /api/v1/capability-requests/{id}", require(auth.RoleObserver, s.capabilityRequestGet))
m.Handle("POST /api/v1/capability-requests/{id}/grant", require(auth.RoleOperator, s.capabilityRequestGrant))
m.Handle("POST /api/v1/capability-requests/{id}/deny", require(auth.RoleOperator, s.capabilityRequestDeny))
m.Handle("GET /api/v1/scoped-grants", require(auth.RoleObserver, s.scopedGrantsList))
m.Handle("POST /api/v1/minds/{mind}/scoped-grants", require(auth.RoleOperator, s.scopedGrantCreate))
m.Handle("DELETE /api/v1/scoped-grants/{id}", require(auth.RoleOperator, s.scopedGrantRevoke))
}
func negotiationErr(err error) error {
if errors.Is(err, capabilities.ErrAttentionBudget) || errors.Is(err, capabilities.ErrNotRequestable) || errors.Is(err, capabilities.ErrReservedDecider) {
return ValidationError(err.Error())
}
return capErr(err)
}
func (s *Server) mindFilter(r *http.Request) ids.ID {
if v := r.URL.Query().Get("mind"); v != "" {
if m, err := s.deps.Minds.Resolve(r.Context(), v); err == nil {
return m.ID
}
}
return ids.Nil
}
func (s *Server) capabilityRequestsList(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
list, err := s.deps.Capabilities.CapabilityRequests(r.Context(), s.mindFilter(r), r.URL.Query().Get("status"), limit)
if err != nil {
fail(w, r, err)
return
}
if list == nil {
list = []capabilities.CapabilityRequest{}
}
writeJSON(w, http.StatusOK, page[capabilities.CapabilityRequest]{Items: list})
}
func (s *Server) capabilityRequestGet(w http.ResponseWriter, r *http.Request) {
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
fail(w, r, ValidationError("bad id"))
return
}
req, err := s.deps.Capabilities.GetCapabilityRequest(r.Context(), id)
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusOK, req)
}
// DecideCapabilityRequest grants or denies a request as the signed-in
// operator; shared with the UI so both surfaces audit the same way.
func (s *Server) DecideCapabilityRequest(r *http.Request, id ids.ID, grant bool, note string, n capabilities.Narrowing) (capabilities.CapabilityRequest, error) {
out, err := s.deps.Capabilities.DecideCapabilityRequest(r.Context(), id, actor(r), grant, note, n)
if err != nil {
return out, negotiationErr(err)
}
action := "capability_request.deny"
if grant {
action = "capability_request.grant"
}
s.deps.Audit.Log(r.Context(), action, "capability_request", id.String(), map[string]any{"mind": out.MindID, "capability": out.Capability, "grant_id": out.GrantID, "note": note, "duration_seconds": n.DurationSeconds, "max_operations": n.MaxOperations, "target": n.TargetPattern})
return out, nil
}
func (s *Server) capabilityRequestGrant(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"`
DurationSeconds int `json:"duration_seconds"`
MaxOperations *int `json:"max_operations"`
TargetPattern string `json:"target_pattern"`
}
if r.ContentLength != 0 {
if err := decode(r, &in); err != nil {
fail(w, r, err)
return
}
}
out, err := s.DecideCapabilityRequest(r, id, true, in.Note, capabilities.Narrowing{DurationSeconds: in.DurationSeconds, MaxOperations: in.MaxOperations, TargetPattern: in.TargetPattern})
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) capabilityRequestDeny(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 {
if err := decode(r, &in); err != nil {
fail(w, r, err)
return
}
}
out, err := s.DecideCapabilityRequest(r, id, false, in.Note, capabilities.Narrowing{})
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) scopedGrantsList(w http.ResponseWriter, r *http.Request) {
list, err := s.deps.Capabilities.ScopedGrants(r.Context(), s.mindFilter(r), r.URL.Query().Get("live") != "false")
if err != nil {
fail(w, r, err)
return
}
if list == nil {
list = []capabilities.ScopedGrant{}
}
writeJSON(w, http.StatusOK, page[capabilities.ScopedGrant]{Items: list})
}
// CreateScopedGrant grants temporary authority directly; shared with the UI.
func (s *Server) CreateScopedGrant(r *http.Request, in capabilities.GrantInput) (capabilities.ScopedGrant, error) {
in.By = actor(r)
g, err := s.deps.Capabilities.Grant(r.Context(), in)
if err != nil {
return g, negotiationErr(err)
}
s.deps.Audit.Log(r.Context(), "scoped_grant.create", "scoped_grant", g.ID.String(), map[string]any{"mind": g.MindID, "capability": g.Capability, "target": g.TargetPattern, "access": g.Access, "expires_at": g.ExpiresAt, "max_operations": g.MaxOperations, "param_constraints": g.ParamConstraints})
return g, nil
}
func (s *Server) scopedGrantCreate(w http.ResponseWriter, r *http.Request) {
m, ok := s.mind(w, r)
if !ok {
return
}
var in struct {
Capability string `json:"capability"`
TargetPattern string `json:"target_pattern"`
ParamConstraints map[string]string `json:"param_constraints"`
Access string `json:"access"`
DurationSeconds int `json:"duration_seconds"`
MaxOperations *int `json:"max_operations"`
Note string `json:"note"`
}
if err := decode(r, &in); err != nil {
fail(w, r, err)
return
}
g, err := s.CreateScopedGrant(r, capabilities.GrantInput{MindID: m.ID, Capability: in.Capability, TargetPattern: in.TargetPattern, ParamConstraints: in.ParamConstraints, Access: capabilities.Access(in.Access),
Duration: time.Duration(in.DurationSeconds) * time.Second, MaxOperations: in.MaxOperations, Note: in.Note})
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusCreated, g)
}
func (s *Server) scopedGrantRevoke(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.RevokeScopedGrant(r.Context(), id, actor(r)); err != nil {
fail(w, r, err)
return
}
s.deps.Audit.Log(r.Context(), "scoped_grant.revoke", "scoped_grant", id.String(), nil)
w.WriteHeader(http.StatusNoContent)
}
+332
View File
@@ -8498,6 +8498,338 @@
} }
} }
} }
},
"/api/v1/capability-requests": {
"get": {
"operationId": "capabilityRequestsList",
"summary": "capability requests list",
"x-required-role": "observer",
"tags": [
"capability-requests"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"401": {
"$ref": "#/components/responses/Error"
},
"403": {
"$ref": "#/components/responses/Error"
},
"404": {
"$ref": "#/components/responses/Error"
},
"422": {
"$ref": "#/components/responses/Error"
}
}
}
},
"/api/v1/capability-requests/{id}": {
"get": {
"operationId": "capabilityRequestGet",
"summary": "capability request get",
"x-required-role": "observer",
"tags": [
"capability-requests"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"401": {
"$ref": "#/components/responses/Error"
},
"403": {
"$ref": "#/components/responses/Error"
},
"404": {
"$ref": "#/components/responses/Error"
},
"422": {
"$ref": "#/components/responses/Error"
}
},
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "identifier"
}
]
}
},
"/api/v1/capability-requests/{id}/grant": {
"post": {
"operationId": "capabilityRequestGrant",
"summary": "capability request grant",
"x-required-role": "operator",
"tags": [
"capability-requests"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"401": {
"$ref": "#/components/responses/Error"
},
"403": {
"$ref": "#/components/responses/Error"
},
"404": {
"$ref": "#/components/responses/Error"
},
"422": {
"$ref": "#/components/responses/Error"
},
"201": {
"description": "Created"
}
},
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "identifier"
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
},
"/api/v1/capability-requests/{id}/deny": {
"post": {
"operationId": "capabilityRequestDeny",
"summary": "capability request deny",
"x-required-role": "operator",
"tags": [
"capability-requests"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"401": {
"$ref": "#/components/responses/Error"
},
"403": {
"$ref": "#/components/responses/Error"
},
"404": {
"$ref": "#/components/responses/Error"
},
"422": {
"$ref": "#/components/responses/Error"
},
"201": {
"description": "Created"
}
},
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "identifier"
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
},
"/api/v1/scoped-grants": {
"get": {
"operationId": "scopedGrantsList",
"summary": "scoped grants list",
"x-required-role": "observer",
"tags": [
"scoped-grants"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"401": {
"$ref": "#/components/responses/Error"
},
"403": {
"$ref": "#/components/responses/Error"
},
"404": {
"$ref": "#/components/responses/Error"
},
"422": {
"$ref": "#/components/responses/Error"
}
}
}
},
"/api/v1/minds/{mind}/scoped-grants": {
"post": {
"operationId": "scopedGrantCreate",
"summary": "scoped grant create",
"x-required-role": "operator",
"tags": [
"scoped-grants"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"401": {
"$ref": "#/components/responses/Error"
},
"403": {
"$ref": "#/components/responses/Error"
},
"404": {
"$ref": "#/components/responses/Error"
},
"422": {
"$ref": "#/components/responses/Error"
},
"201": {
"description": "Created"
}
},
"parameters": [
{
"name": "mind",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "mind"
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
},
"/api/v1/scoped-grants/{id}": {
"delete": {
"operationId": "scopedGrantRevoke",
"summary": "scoped grant revoke",
"x-required-role": "operator",
"tags": [
"scoped-grants"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"401": {
"$ref": "#/components/responses/Error"
},
"403": {
"$ref": "#/components/responses/Error"
},
"404": {
"$ref": "#/components/responses/Error"
},
"422": {
"$ref": "#/components/responses/Error"
},
"204": {
"description": "No Content"
}
},
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "identifier"
}
]
}
} }
} }
} }
+1
View File
@@ -76,6 +76,7 @@ func (s *Server) routes() {
s.cognitionRoutes() s.cognitionRoutes()
s.graphRoutes() s.graphRoutes()
s.capabilityRoutes() s.capabilityRoutes()
s.negotiationRoutes()
s.integrationRoutes() s.integrationRoutes()
s.dreamRoutes() s.dreamRoutes()
s.studyRoutes() s.studyRoutes()
+4
View File
@@ -10,6 +10,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/andydixon/noema/internal/security/actors"
"github.com/andydixon/noema/internal/security/secrets" "github.com/andydixon/noema/internal/security/secrets"
"github.com/andydixon/noema/internal/storage" "github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/pkg/ids" "github.com/andydixon/noema/pkg/ids"
@@ -79,6 +80,9 @@ func (s *Service) CreateUser(ctx context.Context, username, password string, rol
if len(username) < 2 || len(username) > 64 { if len(username) < 2 || len(username) > 64 {
return User{}, errors.New("username must be 2-64 characters") return User{}, errors.New("username must be 2-64 characters")
} }
if !actors.IsPerson(username) {
return User{}, errors.New("username is reserved for internal components or contains ':'")
}
if !role.Valid() { if !role.Valid() {
return User{}, fmt.Errorf("invalid role %q", role) return User{}, fmt.Errorf("invalid role %q", role)
} }
+11 -6
View File
@@ -9,6 +9,7 @@ import (
"time" "time"
"github.com/andydixon/noema/internal/security/httpclient" "github.com/andydixon/noema/internal/security/httpclient"
"github.com/andydixon/noema/pkg/events"
) )
// Builtins returns the capabilities every deployment has. Integrations add // Builtins returns the capabilities every deployment has. Integrations add
@@ -16,28 +17,32 @@ import (
// self-model can list them as unavailable; they never execute. // self-model can list them as unavailable; they never execute.
func Builtins() []Capability { func Builtins() []Capability {
return []Capability{ return []Capability{
{Name: "noema.note", Description: "Leave a note for the operator in the mind's own history.", Risk: RiskLow, TargetKind: "none", {Name: "noema.note", Description: "Leave a note for the operator in the mind's own history.", Risk: RiskLow, TargetKind: "none", Access: AccessRead, Idempotent: false,
SideEffects: "adds a note to the mind's own history; nothing outside Noema changes",
Params: map[string]Param{"text": {Type: "string", Required: true, Description: "note text", MaxLen: 2000}}, DefaultEffect: Allow, Params: map[string]Param{"text": {Type: "string", Required: true, Description: "note text", MaxLen: 2000}}, DefaultEffect: Allow,
Executor: func(_ context.Context, inv Invocation) (Result, error) { Executor: func(_ context.Context, inv Invocation) (Result, error) {
t, _ := inv.Params["text"].(string) t, _ := inv.Params["text"].(string)
return Result{Summary: "note recorded", Data: map[string]any{"text": t}}, nil 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", {Name: "http.fetch", Description: "Fetch a public HTTP(S) URL read-only (bounded, no private addresses) and return its text.", Risk: RiskMedium, TargetKind: "url", Access: AccessRead, Idempotent: true, Timeout: 25 * time.Second, OutputTrust: events.TrustLow,
SideEffects: "one outbound GET request; the remote server may log it", Estimate: Cost{WallSeconds: 2, Network: 1},
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, 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}, Executor: fetchURL},
{Name: "internet_search", Description: "Search the web (requires a configured search integration).", Risk: RiskMedium, TargetKind: "none", {Name: "internet_search", Description: "Search the web (requires a configured search integration).", Risk: RiskMedium, TargetKind: "none", Access: AccessRead, Idempotent: true,
SideEffects: "one query to a search provider", Estimate: Cost{WallSeconds: 2, Network: 1},
Params: map[string]Param{"query": {Type: "string", Required: true, MaxLen: 500}}, DefaultEffect: Ask, Params: map[string]Param{"query": {Type: "string", Required: true, MaxLen: 500}}, DefaultEffect: Ask,
Executor: func(context.Context, Invocation) (Result, error) { Executor: func(context.Context, Invocation) (Result, error) {
return Result{}, errors.New("no search integration configured") return Result{}, errors.New("no search integration configured")
}}, }},
{Name: "send_email", Description: "Send an email (requires a mail integration).", Risk: RiskHigh, TargetKind: "address", {Name: "send_email", Description: "Send an email (requires a mail integration).", Risk: RiskHigh, TargetKind: "address", Access: AccessWrite, Idempotent: false,
SideEffects: "delivers a message to a recipient; cannot be recalled", Estimate: Cost{WallSeconds: 2, Network: 1},
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, 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) { Executor: func(context.Context, Invocation) (Result, error) {
return Result{}, errors.New("no mail integration configured") 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, {Name: "sudo_shell", Description: "Privileged shell. Locked to DENY; exists so it can be named, never run.", Risk: RiskCritical, TargetKind: "host", Locked: true, Access: AccessWrite, SideEffects: "arbitrary; never runs",
Params: map[string]Param{"command": {Type: "string", Required: true}}, Executor: never}, 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, {Name: "delete_database", Description: "Destroy data stores. Locked to DENY; exists so it can be named, never run.", Risk: RiskCritical, TargetKind: "service", Locked: true, Access: AccessWrite, SideEffects: "irreversible data loss; never runs",
Params: map[string]Param{"name": {Type: "string", Required: true}}, Executor: never}, Params: map[string]Param{"name": {Type: "string", Required: true}}, Executor: never},
} }
} }
+84 -5
View File
@@ -17,6 +17,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/pkg/ids" "github.com/andydixon/noema/pkg/ids"
) )
@@ -52,6 +53,24 @@ type Param struct {
MaxLen int `json:"max_len,omitempty"` MaxLen int `json:"max_len,omitempty"`
} }
// Access says whether a capability changes anything outside Noema.
type Access string
// Access classes. Unknown access is treated as write (fail closed).
const (
AccessRead Access = "read" // observes; no external state changes
AccessWrite Access = "write" // changes external state
)
// Cost is a capability's expected resource use per invocation, used for plan
// estimates. Zero values mean "negligible", not "unknown".
type Cost struct {
WallSeconds float64 `json:"wall_seconds,omitempty"`
Network int `json:"network_operations,omitempty"`
Money float64 `json:"money,omitempty"`
Tokens int `json:"llm_tokens,omitempty"`
}
// Invocation is a validated request to run a capability. // Invocation is a validated request to run a capability.
type Invocation struct { type Invocation struct {
MindID ids.ID MindID ids.ID
@@ -62,6 +81,12 @@ type Invocation struct {
Evidence []EvidenceRef Evidence []EvidenceRef
Correlation ids.ID Correlation ids.ID
ApprovalID ids.ID ApprovalID ids.ID
// RequireApproval turns an ALLOW decision into ASK. Cognition sets it
// when a commitment demands approval; it can only tighten a decision.
RequireApproval bool
// Origin names the cognitive object that asked (for example
// "plan_step:<id>"), recorded with the decision for provenance.
Origin string
} }
// EvidenceRef mirrors events.EvidenceRef without importing it (keeps this // EvidenceRef mirrors events.EvidenceRef without importing it (keeps this
@@ -88,22 +113,52 @@ type Capability struct {
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
Risk Risk `json:"risk"` Risk Risk `json:"risk"`
TargetKind string `json:"target_kind"` // url | path | host | service | repository | none TargetKind string `json:"target_kind"` // url | path | host | service | repository | domain | none
Params map[string]Param `json:"params"` Params map[string]Param `json:"params"`
DefaultEffect Effect `json:"default_effect"` // when no policy matches DefaultEffect Effect `json:"default_effect"` // when no policy matches
Locked bool `json:"locked"` // always DENY; cannot be relaxed Locked bool `json:"locked"` // always DENY; cannot be relaxed
Access Access `json:"access"` // read | write
SideEffects string `json:"side_effects"` // what changes in the world when it runs
Idempotent bool `json:"idempotent"` // running it twice has the same effect as once
Timeout time.Duration `json:"timeout"` // executor deadline (default 60s)
Estimate Cost `json:"estimate"`
// TargetFrom derives the policy target from validated parameters (the
// unit a restart acts on, the host of a URL), so a policy or grant on a
// target always matches what the executor will touch. A caller-supplied
// target that disagrees is refused.
TargetFrom func(params map[string]any) (string, error) `json:"-"`
// OutputTrust is the trust given to the observation made from the
// output (default tool). Content fetched from the web is low trust.
OutputTrust events.Trust `json:"output_trust"`
Executor Executor `json:"-"` Executor Executor `json:"-"`
} }
// Registry holds capabilities. It is built in code; nothing at runtime can // Registry holds capabilities. It is built in code at start-up and frozen
// add one, so a mind cannot invent tools. // before any mind runs; nothing at runtime can add one, so a mind cannot
type Registry struct{ caps map[string]Capability } // invent tools.
type Registry struct {
caps map[string]Capability
frozen bool
}
// NewRegistry creates an empty registry. // NewRegistry creates an empty registry.
func NewRegistry() *Registry { return &Registry{caps: map[string]Capability{}} } func NewRegistry() *Registry { return &Registry{caps: map[string]Capability{}} }
// Add registers a capability. // Add registers a capability. Adding to a frozen registry is a programming
// error and panics: capabilities are code, never runtime data.
func (r *Registry) Add(c Capability) *Registry { func (r *Registry) Add(c Capability) *Registry {
if r.frozen {
panic("capabilities: registry is frozen; " + c.Name + " cannot be added at runtime")
}
if c.Access != AccessRead {
c.Access = AccessWrite
}
if c.Timeout <= 0 {
c.Timeout = 60 * time.Second
}
if !c.OutputTrust.Valid() {
c.OutputTrust = events.TrustTool
}
if c.Params == nil { if c.Params == nil {
c.Params = map[string]Param{} c.Params = map[string]Param{}
} }
@@ -117,6 +172,12 @@ func (r *Registry) Add(c Capability) *Registry {
return r return r
} }
// Freeze stops further registration.
func (r *Registry) Freeze() { r.frozen = true }
// Frozen reports whether the registry accepts no more capabilities.
func (r *Registry) Frozen() bool { return r.frozen }
// Get returns a capability. // Get returns a capability.
func (r *Registry) Get(name string) (Capability, bool) { func (r *Registry) Get(name string) (Capability, bool) {
c, ok := r.caps[name] c, ok := r.caps[name]
@@ -195,6 +256,22 @@ func (c Capability) ValidateParams(params map[string]any) (map[string]any, error
return out, nil return out, nil
} }
// ResolveTarget applies TargetFrom: it returns the target the request acts
// on, or an error when the caller named a different one.
func (c Capability) ResolveTarget(target string, params map[string]any) (string, error) {
if c.TargetFrom == nil {
return target, nil
}
derived, err := c.TargetFrom(params)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrInvalid, err)
}
if target != "" && !strings.EqualFold(target, derived) {
return "", fmt.Errorf("%w: target %q does not match the parameters (%q)", ErrInvalid, target, derived)
}
return derived, nil
}
// Hash binds an approval to the exact request. // Hash binds an approval to the exact request.
func Hash(mind ids.ID, capability, target string, params map[string]any) string { func Hash(mind ids.ID, capability, target string, params map[string]any) string {
raw, _ := json.Marshal(struct { raw, _ := json.Marshal(struct {
@@ -268,10 +345,12 @@ func (p Policy) matches(mind ids.ID, capability, target, action string, now time
type Decision struct { type Decision struct {
Effect Effect `json:"effect"` Effect Effect `json:"effect"`
PolicyID ids.ID `json:"policy_id,omitempty"` PolicyID ids.ID `json:"policy_id,omitempty"`
GrantID ids.ID `json:"grant_id,omitempty"` // scoped grant that turned ASK into ALLOW
Specificity int `json:"specificity"` Specificity int `json:"specificity"`
Reason string `json:"reason"` Reason string `json:"reason"`
Locked bool `json:"locked"` Locked bool `json:"locked"`
Considered int `json:"considered"` Considered int `json:"considered"`
Tightened bool `json:"tightened,omitempty"` // ALLOW became ASK because the invocation required approval
} }
func rank(e Effect) int { func rank(e Effect) int {
+560
View File
@@ -0,0 +1,560 @@
package capabilities
import (
"context"
"encoding/json"
"errors"
"fmt"
"path"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/andydixon/noema/internal/security/actors"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/pkg/ids"
)
// ScopedGrant is temporary authority an operator gave a mind: one
// capability, restricted by target, parameters and access, until an expiry
// and optionally for a number of operations. It is consulted only when policy
// says ASK; it never overrides DENY or a locked capability.
type ScopedGrant struct {
ID ids.ID `json:"id"`
MindID ids.ID `json:"mind_id"`
Capability string `json:"capability"`
TargetPattern string `json:"target_pattern"`
ParamConstraints map[string]string `json:"param_constraints"`
Access Access `json:"access"`
MaxOperations *int `json:"max_operations,omitempty"`
UsedOperations int `json:"used_operations"`
ExpiresAt time.Time `json:"expires_at"`
GrantedBy string `json:"granted_by"`
RequestID ids.ID `json:"request_id,omitempty"`
Note string `json:"note"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
RevokedBy string `json:"revoked_by,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// Live reports whether the grant can still authorise an operation at now.
func (g ScopedGrant) Live(now time.Time) bool {
return g.RevokedAt == nil && now.Before(g.ExpiresAt) && (g.MaxOperations == nil || g.UsedOperations < *g.MaxOperations)
}
// Covers reports whether the grant's scope includes a concrete request.
func (g ScopedGrant) Covers(c Capability, target string, params map[string]any) bool {
if g.Capability != c.Name {
return false
}
if g.Access == AccessRead && c.Access != AccessRead {
return false
}
if g.TargetPattern != "*" {
if ok, _ := path.Match(g.TargetPattern, target); !ok && !strings.EqualFold(g.TargetPattern, target) {
return false
}
}
for name, pattern := range g.ParamConstraints {
v, ok := params[name]
if !ok {
return false
}
if ok, _ := path.Match(pattern, fmt.Sprint(v)); !ok {
return false
}
}
return true
}
func remaining(g ScopedGrant) string {
if g.MaxOperations == nil {
return ""
}
return fmt.Sprintf(", %d of %d operations left", *g.MaxOperations-g.UsedOperations, *g.MaxOperations)
}
const grantCols = `id, mind_id, capability, target_pattern, param_constraints, access, max_operations, used_operations, expires_at, granted_by, request_id, note, revoked_at, revoked_by, created_at`
func scanGrant(row pgx.Row) (ScopedGrant, error) {
var g ScopedGrant
var pc []byte
var access string
var req *string
if err := row.Scan(&g.ID, &g.MindID, &g.Capability, &g.TargetPattern, &pc, &access, &g.MaxOperations, &g.UsedOperations, &g.ExpiresAt, &g.GrantedBy, &req, &g.Note, &g.RevokedAt, &g.RevokedBy, &g.CreatedAt); err != nil {
return g, storage.Translate(err)
}
g.Access = Access(access)
json.Unmarshal(pc, &g.ParamConstraints) //nolint:errcheck
if g.ParamConstraints == nil {
g.ParamConstraints = map[string]string{}
}
if req != nil {
g.RequestID = ids.ID(*req)
}
return g, nil
}
// ScopedGrants lists grants for a mind (Nil = all); live only when liveOnly.
func (s *Service) ScopedGrants(ctx context.Context, mind ids.ID, liveOnly bool) ([]ScopedGrant, error) {
rows, err := s.DB.Query(ctx, `SELECT `+grantCols+` FROM capability_grants WHERE ($1::uuid IS NULL OR mind_id=$1)
AND (NOT $2 OR (revoked_at IS NULL AND expires_at > $3 AND (max_operations IS NULL OR used_operations < max_operations))) ORDER BY created_at DESC LIMIT 500`, nullID(mind), liveOnly, s.now())
if err != nil {
return nil, err
}
defer rows.Close()
var out []ScopedGrant
for rows.Next() {
g, err := scanGrant(rows)
if err != nil {
return nil, err
}
out = append(out, g)
}
return out, rows.Err()
}
func (s *Service) matchingGrant(ctx context.Context, mind ids.ID, c Capability, target string, params map[string]any) (ScopedGrant, bool) {
gs, err := s.ScopedGrants(ctx, mind, true)
if err != nil {
return ScopedGrant{}, false
}
for _, g := range gs {
if g.Capability == c.Name && g.Live(s.now()) && g.Covers(c, target, params) {
return g, true
}
}
return ScopedGrant{}, false
}
// consumeGrant uses one operation atomically; false means the grant could
// no longer authorise (exhausted, expired or revoked in the meantime).
func (s *Service) consumeGrant(ctx context.Context, id ids.ID) bool {
tag, err := s.DB.Exec(ctx, `UPDATE capability_grants SET used_operations = used_operations + 1
WHERE id=$1 AND revoked_at IS NULL AND expires_at > $2 AND (max_operations IS NULL OR used_operations < max_operations)`, id, s.now())
return err == nil && tag.RowsAffected() == 1
}
// GrantInput is an operator's scoped grant, directly or in answer to a request.
type GrantInput struct {
MindID ids.ID
Capability string
TargetPattern string
ParamConstraints map[string]string
Access Access
Duration time.Duration
MaxOperations *int
By string
Note string
RequestID ids.ID
}
// MaxGrantDuration bounds temporary authority.
const MaxGrantDuration = 7 * 24 * time.Hour
// ErrReservedDecider marks a decision attempted in the name of cognition.
var ErrReservedDecider = errors.New("capabilities: decisions must be made by a named operator, not by a mind or an internal process")
// ValidDecider reports whether by names a person rather than a component.
func ValidDecider(by string) bool { return actors.IsPerson(by) }
// Grant stores a scoped grant. Only operator routes call it.
func (s *Service) Grant(ctx context.Context, in GrantInput) (ScopedGrant, error) {
if !ValidDecider(in.By) {
return ScopedGrant{}, ErrReservedDecider
}
c, ok := s.Registry.Get(in.Capability)
if !ok {
return ScopedGrant{}, fmt.Errorf("%w: %s", ErrUnknown, in.Capability)
}
if c.Locked {
return ScopedGrant{}, fmt.Errorf("%w: %s is locked and cannot be granted", ErrInvalid, c.Name)
}
if in.Access != AccessRead && in.Access != AccessWrite {
in.Access = c.Access
}
if in.Access == AccessRead && c.Access != AccessRead {
return ScopedGrant{}, fmt.Errorf("%w: %s changes external state; a read-only grant cannot cover it", ErrInvalid, c.Name)
}
if in.Duration <= 0 || in.Duration > MaxGrantDuration {
return ScopedGrant{}, fmt.Errorf("%w: duration must be between 1 second and 7 days", ErrInvalid)
}
if in.MaxOperations != nil && (*in.MaxOperations <= 0 || *in.MaxOperations > 10000) {
return ScopedGrant{}, fmt.Errorf("%w: max_operations must be 1–10000", ErrInvalid)
}
if in.TargetPattern == "" {
in.TargetPattern = "*"
}
if _, err := path.Match(in.TargetPattern, "x"); err != nil {
return ScopedGrant{}, fmt.Errorf("%w: bad target pattern", ErrInvalid)
}
if err := checkConstraints(c, in.ParamConstraints); err != nil {
return ScopedGrant{}, err
}
if in.ParamConstraints == nil {
in.ParamConstraints = map[string]string{}
}
g := ScopedGrant{ID: ids.New(), MindID: in.MindID, Capability: c.Name, TargetPattern: in.TargetPattern, ParamConstraints: in.ParamConstraints, Access: in.Access, MaxOperations: in.MaxOperations,
ExpiresAt: s.now().Add(in.Duration), GrantedBy: in.By, RequestID: in.RequestID, Note: in.Note, CreatedAt: s.now()}
pc, _ := json.Marshal(g.ParamConstraints)
if _, err := s.DB.Exec(ctx, `INSERT INTO capability_grants (id, mind_id, capability, target_pattern, param_constraints, access, max_operations, expires_at, granted_by, request_id, note)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, g.ID, g.MindID, g.Capability, g.TargetPattern, pc, string(g.Access), g.MaxOperations, g.ExpiresAt, g.GrantedBy, nullID(g.RequestID), g.Note); err != nil {
return ScopedGrant{}, err
}
s.Events.Append(ctx, events.New{MindID: g.MindID, Type: events.ToolAuthorised, Source: "operator", Summary: fmt.Sprintf("operator %s granted %s on %q (%s) until %s%s", g.GrantedBy, g.Capability, g.TargetPattern, g.Access, g.ExpiresAt.Format(time.RFC3339), remaining(g)), //nolint:errcheck
Salience: 0.5, Confidence: 1, Metadata: map[string]any{"grant_id": string(g.ID), "request_id": string(g.RequestID), "capability": g.Capability, "actor": g.GrantedBy, "kind": "scoped_grant"}, Payload: g})
s.selfModelChanged(ctx, g.MindID)
return g, nil
}
func checkConstraints(c Capability, pc map[string]string) error {
for name, pattern := range pc {
if _, ok := c.Params[name]; !ok {
return fmt.Errorf("%w: %s has no parameter %q", ErrInvalid, c.Name, name)
}
if _, err := path.Match(pattern, "x"); err != nil {
return fmt.Errorf("%w: bad pattern for %q", ErrInvalid, name)
}
}
return nil
}
// RevokeScopedGrant ends a grant early.
func (s *Service) RevokeScopedGrant(ctx context.Context, id ids.ID, by string) error {
var mind ids.ID
err := s.DB.QueryRow(ctx, `UPDATE capability_grants SET revoked_at=$2, revoked_by=$3 WHERE id=$1 AND revoked_at IS NULL RETURNING mind_id`, id, s.now(), by).Scan(&mind)
if err != nil {
return storage.Translate(err)
}
s.selfModelChanged(ctx, mind)
return nil
}
// SweepGrants finds grants that stopped being live since the last sweep
// (expired, used up or revoked), marks them and refreshes each affected
// mind's self-model, so a mind never believes it still holds authority that
// lapsed. It returns the minds refreshed.
func (s *Service) SweepGrants(ctx context.Context) ([]ids.ID, error) {
rows, err := s.DB.Query(ctx, `UPDATE capability_grants SET expiry_notified_at=$1
WHERE expiry_notified_at IS NULL AND (expires_at <= $1 OR revoked_at IS NOT NULL OR (max_operations IS NOT NULL AND used_operations >= max_operations))
RETURNING id, mind_id, capability, target_pattern`, s.now())
if err != nil {
return nil, err
}
type lapsed struct {
id, mind ids.ID
capability, pattern string
}
var list []lapsed
for rows.Next() {
var l lapsed
if err := rows.Scan(&l.id, &l.mind, &l.capability, &l.pattern); err != nil {
rows.Close()
return nil, err
}
list = append(list, l)
}
rows.Close()
seen := map[ids.ID]bool{}
var out []ids.ID
for _, l := range list {
s.Events.Append(ctx, events.New{MindID: l.mind, Type: events.SelfModelUpdated, Source: "capabilities", Summary: fmt.Sprintf("temporary grant for %s on %q is no longer in force", l.capability, l.pattern), //nolint:errcheck
Salience: 0.4, Confidence: 1, Metadata: map[string]any{"grant_id": string(l.id), "capability": l.capability, "kind": "grant_lapsed"}})
if !seen[l.mind] {
seen[l.mind] = true
out = append(out, l.mind)
s.selfModelChanged(ctx, l.mind)
}
}
return out, nil
}
// ---- capability requests ----
// CapabilityRequest is a mind's structured request for temporary authority.
// It carries its own justification so the operator can decide without
// reconstructing context: scope, duration, operation cap, reason, evidence,
// risk, mitigation, fallback and alternatives.
type CapabilityRequest struct {
ID ids.ID `json:"id"`
MindID ids.ID `json:"mind_id"`
Capability string `json:"capability"`
TargetPattern string `json:"target_pattern"`
ParamConstraints map[string]string `json:"param_constraints"`
Access Access `json:"access"`
DurationSeconds int `json:"duration_seconds"`
MaxOperations *int `json:"max_operations,omitempty"`
Reason string `json:"reason"`
Evidence []EvidenceRef `json:"evidence"`
Risk string `json:"risk"`
Mitigation string `json:"mitigation"`
Fallback string `json:"fallback"`
Alternatives []string `json:"alternatives"`
History map[string]int `json:"history"` // this mind's earlier explicit decisions on the capability
IntentionID ids.ID `json:"intention_id,omitempty"`
PlanID ids.ID `json:"plan_id,omitempty"`
StepID ids.ID `json:"step_id,omitempty"`
BatchKey string `json:"batch_key"`
Status string `json:"status"` // pending | granted | denied | expired | withdrawn
DecidedBy string `json:"decided_by"`
DecisionNote string `json:"decision_note"`
DecidedAt *time.Time `json:"decided_at,omitempty"`
GrantID ids.ID `json:"grant_id,omitempty"`
EventID ids.ID `json:"event_id,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
}
// ErrAttentionBudget is returned when a mind already has as many open
// requests as the operator's attention budget allows.
var ErrAttentionBudget = errors.New("capabilities: too many open capability requests for this mind; wait for decisions")
// ErrNotRequestable marks a capability that cannot be requested at all.
var ErrNotRequestable = errors.New("capabilities: this capability cannot be requested")
// RequestCapability records a request and raises it to operators. It grants
// nothing. Requests from one intention share a batch key so they can be
// decided together.
func (s *Service) RequestCapability(ctx context.Context, r CapabilityRequest) (CapabilityRequest, error) {
if why, forbidden := Forbidden(ctx); forbidden {
return CapabilityRequest{}, fmt.Errorf("%w: %s", ErrForbiddenContext, why)
}
c, ok := s.Registry.Get(r.Capability)
if !ok {
return CapabilityRequest{}, fmt.Errorf("%w: %s", ErrUnknown, r.Capability)
}
if c.Locked {
return CapabilityRequest{}, fmt.Errorf("%w: %s is locked", ErrNotRequestable, c.Name)
}
r.Reason = strings.TrimSpace(r.Reason)
if r.MindID.IsZero() || r.Reason == "" || len(r.Reason) > 2000 {
return CapabilityRequest{}, fmt.Errorf("%w: mind and a reason (max 2000 chars) are required", ErrInvalid)
}
if r.DurationSeconds <= 0 || time.Duration(r.DurationSeconds)*time.Second > MaxGrantDuration {
return CapabilityRequest{}, fmt.Errorf("%w: duration must be between 1 second and 7 days", ErrInvalid)
}
if r.MaxOperations != nil && (*r.MaxOperations <= 0 || *r.MaxOperations > 10000) {
return CapabilityRequest{}, fmt.Errorf("%w: max_operations must be 1–10000", ErrInvalid)
}
if r.TargetPattern == "" {
r.TargetPattern = "*"
}
if _, err := path.Match(r.TargetPattern, "x"); err != nil {
return CapabilityRequest{}, fmt.Errorf("%w: bad target pattern", ErrInvalid)
}
if err := checkConstraints(c, r.ParamConstraints); err != nil {
return CapabilityRequest{}, err
}
if r.Access != AccessRead {
r.Access = c.Access
}
if r.Access == AccessRead && c.Access != AccessRead {
r.Access = AccessWrite
}
for _, f := range []*string{&r.Risk, &r.Mitigation, &r.Fallback} {
if len(*f) > 2000 {
*f = (*f)[:2000]
}
}
limit := s.MaxPendingRequests
if limit <= 0 {
limit = 5
}
var open int
if err := s.DB.QueryRow(ctx, `SELECT count(*) FROM capability_requests WHERE mind_id=$1 AND status='pending'`, r.MindID).Scan(&open); err != nil {
return CapabilityRequest{}, err
}
if open >= limit {
return CapabilityRequest{}, ErrAttentionBudget
}
// Explicit history only: counts of earlier operator decisions. It informs
// the operator; it never decides anything.
r.History = map[string]int{}
if rows, err := s.DB.Query(ctx, `SELECT status, count(*) FROM capability_requests WHERE mind_id=$1 AND capability=$2 AND status IN ('granted','denied') GROUP BY status`, r.MindID, r.Capability); err == nil {
for rows.Next() {
var st string
var n int
if rows.Scan(&st, &n) == nil {
r.History[st] = n
}
}
rows.Close()
}
switch {
case !r.IntentionID.IsZero():
r.BatchKey = "intention:" + string(r.IntentionID)
case !r.PlanID.IsZero():
r.BatchKey = "plan:" + string(r.PlanID)
default:
r.BatchKey = "mind:" + string(r.MindID) + ":" + s.now().UTC().Format("2006-01-02T15")
}
ttl := s.RequestTTL
if ttl <= 0 {
ttl = 24 * time.Hour
}
r.ID, r.Status, r.CreatedAt, r.ExpiresAt = ids.New(), "pending", s.now(), s.now().Add(ttl)
if r.Evidence == nil {
r.Evidence = []EvidenceRef{}
}
if r.Alternatives == nil {
r.Alternatives = []string{}
}
if r.ParamConstraints == nil {
r.ParamConstraints = map[string]string{}
}
ev, err := s.Events.Append(ctx, events.New{MindID: r.MindID, Type: events.ToolRequested, Source: "capabilities", Summary: fmt.Sprintf("capability requested: %s on %q for %s — %s", c.Name, r.TargetPattern, time.Duration(r.DurationSeconds)*time.Second, truncate(r.Reason, 140)),
Salience: 0.6, Confidence: 1, CorrelationID: r.IntentionID, Evidence: toEvidence(r.Evidence),
Metadata: map[string]any{"kind": "capability_request", "request_id": string(r.ID), "capability": c.Name, "target": r.TargetPattern, "intention_id": string(r.IntentionID), "plan_id": string(r.PlanID), "step_id": string(r.StepID)}, Payload: r})
if err != nil {
return CapabilityRequest{}, err
}
r.EventID = ev[0].ID
pc, _ := json.Marshal(r.ParamConstraints)
evid, _ := json.Marshal(r.Evidence)
alts, _ := json.Marshal(r.Alternatives)
hist, _ := json.Marshal(r.History)
_, err = s.DB.Exec(ctx, `INSERT INTO capability_requests (id, mind_id, capability, target_pattern, param_constraints, access, duration_seconds, max_operations, reason, evidence, risk, mitigation, fallback, alternatives, history, intention_id, plan_id, step_id, batch_key, status, event_id, expires_at, created_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,'pending',$20,$21,$22)`,
r.ID, r.MindID, r.Capability, r.TargetPattern, pc, string(r.Access), r.DurationSeconds, r.MaxOperations, r.Reason, evid, r.Risk, r.Mitigation, r.Fallback, alts, hist, nullID(r.IntentionID), nullID(r.PlanID), nullID(r.StepID), r.BatchKey, r.EventID, r.ExpiresAt, r.CreatedAt)
return r, err
}
const requestCols = `id, mind_id, capability, target_pattern, param_constraints, access, duration_seconds, max_operations, reason, evidence, risk, mitigation, fallback, alternatives, history, intention_id, plan_id, step_id, batch_key, status, decided_by, decision_note, decided_at, grant_id, event_id, expires_at, created_at`
func scanRequest(row pgx.Row) (CapabilityRequest, error) {
var r CapabilityRequest
var pc, evid, alts, hist []byte
var access string
var intention, plan, step, grant, ev *string
if err := row.Scan(&r.ID, &r.MindID, &r.Capability, &r.TargetPattern, &pc, &access, &r.DurationSeconds, &r.MaxOperations, &r.Reason, &evid, &r.Risk, &r.Mitigation, &r.Fallback, &alts, &hist,
&intention, &plan, &step, &r.BatchKey, &r.Status, &r.DecidedBy, &r.DecisionNote, &r.DecidedAt, &grant, &ev, &r.ExpiresAt, &r.CreatedAt); err != nil {
return r, storage.Translate(err)
}
r.Access = Access(access)
json.Unmarshal(pc, &r.ParamConstraints) //nolint:errcheck
json.Unmarshal(evid, &r.Evidence) //nolint:errcheck
json.Unmarshal(alts, &r.Alternatives) //nolint:errcheck
json.Unmarshal(hist, &r.History) //nolint:errcheck
for _, x := range []struct {
src *string
dst *ids.ID
}{{intention, &r.IntentionID}, {plan, &r.PlanID}, {step, &r.StepID}, {grant, &r.GrantID}, {ev, &r.EventID}} {
if x.src != nil {
*x.dst = ids.ID(*x.src)
}
}
return r, nil
}
// CapabilityRequests lists requests (mind Nil = all, status empty = all), newest first.
func (s *Service) CapabilityRequests(ctx context.Context, mind ids.ID, status string, limit int) ([]CapabilityRequest, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
rows, err := s.DB.Query(ctx, `SELECT `+requestCols+` FROM capability_requests WHERE ($1::uuid IS NULL OR mind_id=$1) AND ($2='' OR status=$2) ORDER BY batch_key, created_at DESC LIMIT $3`, nullID(mind), status, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []CapabilityRequest
for rows.Next() {
r, err := scanRequest(rows)
if err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// GetCapabilityRequest fetches one request.
func (s *Service) GetCapabilityRequest(ctx context.Context, id ids.ID) (CapabilityRequest, error) {
return scanRequest(s.DB.QueryRow(ctx, `SELECT `+requestCols+` FROM capability_requests WHERE id=$1`, id))
}
// Narrowing lets an operator grant less than was asked.
type Narrowing struct {
DurationSeconds int
MaxOperations *int
TargetPattern string
}
// DecideCapabilityRequest grants (as a scoped grant, possibly narrowed) or
// denies a pending request. The decider must be a named person: internal
// component names are refused, so cognition cannot approve its own request.
func (s *Service) DecideCapabilityRequest(ctx context.Context, id ids.ID, by string, grant bool, note string, n Narrowing) (CapabilityRequest, error) {
if !ValidDecider(by) {
return CapabilityRequest{}, ErrReservedDecider
}
r, err := s.GetCapabilityRequest(ctx, id)
if err != nil {
return r, err
}
if r.Status != "pending" {
return r, fmt.Errorf("%w: request is %s", ErrInvalid, r.Status)
}
if s.now().After(r.ExpiresAt) {
s.DB.Exec(ctx, `UPDATE capability_requests SET status='expired' WHERE id=$1 AND status='pending'`, id) //nolint:errcheck
return r, fmt.Errorf("%w: request expired", ErrInvalid)
}
if !grant {
tag, err := s.DB.Exec(ctx, `UPDATE capability_requests SET status='denied', decided_by=$2, decision_note=$3, decided_at=$4 WHERE id=$1 AND status='pending'`, id, by, note, s.now())
if err != nil {
return r, err
}
if tag.RowsAffected() == 0 {
return r, fmt.Errorf("%w: request already decided", ErrInvalid)
}
s.Events.Append(ctx, events.New{MindID: r.MindID, Type: events.ToolDenied, Source: "operator", Summary: fmt.Sprintf("operator %s denied the request for %s on %q: %s", by, r.Capability, r.TargetPattern, firstNonEmpty(note, "no reason given")), //nolint:errcheck
Salience: 0.5, Confidence: 1, CausationID: r.EventID, CorrelationID: r.IntentionID, Metadata: map[string]any{"kind": "capability_request", "request_id": string(r.ID), "actor": by, "capability": r.Capability, "intention_id": string(r.IntentionID), "plan_id": string(r.PlanID), "step_id": string(r.StepID)}, Payload: map[string]any{"note": note}})
return s.GetCapabilityRequest(ctx, id)
}
dur, ops, target := r.DurationSeconds, r.MaxOperations, r.TargetPattern
if n.DurationSeconds > 0 && n.DurationSeconds < dur {
dur = n.DurationSeconds
}
if n.MaxOperations != nil && (ops == nil || *n.MaxOperations < *ops) {
ops = n.MaxOperations
}
if n.TargetPattern != "" {
target = n.TargetPattern
}
// Claim the request first so two operators cannot both grant it.
tag, err := s.DB.Exec(ctx, `UPDATE capability_requests SET status='granted', decided_by=$2, decision_note=$3, decided_at=$4 WHERE id=$1 AND status='pending'`, id, by, note, s.now())
if err != nil {
return r, err
}
if tag.RowsAffected() == 0 {
return r, fmt.Errorf("%w: request already decided", ErrInvalid)
}
g, err := s.Grant(ctx, GrantInput{MindID: r.MindID, Capability: r.Capability, TargetPattern: target, ParamConstraints: r.ParamConstraints, Access: r.Access, Duration: time.Duration(dur) * time.Second, MaxOperations: ops, By: by, Note: note, RequestID: r.ID})
if err != nil {
s.DB.Exec(ctx, `UPDATE capability_requests SET status='pending', decided_by='', decision_note='', decided_at=NULL WHERE id=$1`, id) //nolint:errcheck
return r, err
}
if _, err := s.DB.Exec(ctx, `UPDATE capability_requests SET grant_id=$2 WHERE id=$1`, id, g.ID); err != nil {
return r, err
}
return s.GetCapabilityRequest(ctx, id)
}
// WithdrawCapabilityRequest lets the requesting mind drop a request it no
// longer needs. Withdrawing never grants anything.
func (s *Service) WithdrawCapabilityRequest(ctx context.Context, mind, id ids.ID, reason string) error {
tag, err := s.DB.Exec(ctx, `UPDATE capability_requests SET status='withdrawn', decision_note=$3, decided_at=$4 WHERE id=$1 AND mind_id=$2 AND status='pending'`, id, mind, reason, s.now())
if err == nil && tag.RowsAffected() == 0 {
return storage.ErrNotFound
}
return err
}
// ExpireCapabilityRequests marks overdue requests expired.
func (s *Service) ExpireCapabilityRequests(ctx context.Context) (int64, error) {
tag, err := s.DB.Exec(ctx, `UPDATE capability_requests SET status='expired' WHERE status='pending' AND expires_at < $1`, s.now())
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}
+207
View File
@@ -0,0 +1,207 @@
package capabilities
import (
"context"
"errors"
"sync"
"testing"
"time"
"github.com/andydixon/noema/pkg/ids"
)
func intp(n int) *int { return &n }
func TestRegistryFreezes(t *testing.T) {
r := NewRegistry()
r.Add(Capability{Name: "a"})
r.Freeze()
defer func() {
if recover() == nil {
t.Fatal("adding to a frozen registry must panic")
}
}()
r.Add(Capability{Name: "shell"})
}
func TestUnknownAccessIsWrite(t *testing.T) {
r := NewRegistry().Add(Capability{Name: "x"})
if c, _ := r.Get("x"); c.Access != AccessWrite || c.Timeout != 60*time.Second {
t.Fatalf("defaults must fail closed: %+v", c)
}
}
func TestRequireApprovalOnlyTightens(t *testing.T) {
s, m, _ := setup(t)
ctx := context.Background()
// noema.note is ALLOW by default; a commitment-driven invocation must still ask.
out, err := s.Invoke(ctx, Invocation{MindID: m.ID, Capability: "noema.note", Params: map[string]any{"text": "x"}, RequireApproval: true, Origin: "plan_step:t"})
if err != nil || out.Status != "pending" || !out.Decision.Tightened {
t.Fatalf("require approval did not tighten: %v %+v", err, out)
}
// It never loosens: DENY stays DENY and locked stays locked.
if _, err := s.SetPolicy(ctx, Policy{MindID: m.ID, Capability: "test.echo", Effect: Deny}); err != nil {
t.Fatal(err)
}
out, _ = s.Invoke(ctx, Invocation{MindID: m.ID, Capability: "test.echo", Params: map[string]any{"text": "x"}, RequireApproval: true})
if out.Status != "denied" {
t.Fatalf("deny loosened: %+v", out)
}
// A scoped grant does not satisfy a per-request approval requirement.
if _, err := s.Grant(ctx, GrantInput{MindID: m.ID, Capability: "http.fetch", Duration: time.Hour, By: "andy"}); err != nil {
t.Fatal(err)
}
out, _ = s.Invoke(ctx, Invocation{MindID: m.ID, Capability: "http.fetch", Target: "https://example.org", Params: map[string]any{"url": "https://example.org"}, RequireApproval: true})
if out.Status != "pending" {
t.Fatalf("grant bypassed a required approval: %+v", out)
}
}
func TestScopedGrantScopeCountAndExpiry(t *testing.T) {
s, m, _ := setup(t)
ctx := context.Background()
now := time.Now()
s.Now = func() time.Time { return now }
// Read-only grants cannot cover a writing capability.
if _, err := s.Grant(ctx, GrantInput{MindID: m.ID, Capability: "send_email", Access: AccessRead, Duration: time.Hour, By: "andy"}); !errors.Is(err, ErrInvalid) {
t.Fatalf("read grant on a write capability accepted: %v", err)
}
if _, err := s.Grant(ctx, GrantInput{MindID: m.ID, Capability: "sudo_shell", Duration: time.Hour, By: "andy"}); !errors.Is(err, ErrInvalid) {
t.Fatal("locked capability granted")
}
if _, err := s.Grant(ctx, GrantInput{MindID: m.ID, Capability: "test.echo", Duration: time.Hour, By: "planning"}); !errors.Is(err, ErrReservedDecider) {
t.Fatal("a component granted authority")
}
g, err := s.Grant(ctx, GrantInput{MindID: m.ID, Capability: "test.echo", TargetPattern: "api-*", ParamConstraints: map[string]string{"text": "ping*"}, Duration: 30 * time.Minute, MaxOperations: intp(2), By: "andy"})
if err != nil {
t.Fatal(err)
}
try := func(target, text string) string {
out, err := s.Invoke(ctx, Invocation{MindID: m.ID, Capability: "test.echo", Target: target, Params: map[string]any{"text": text}})
if err != nil {
t.Fatal(err)
}
return out.Status
}
if st := try("db-1", "ping"); st != "pending" {
t.Fatalf("target restriction ignored: %s", st)
}
if st := try("api-1", "rm"); st != "pending" {
t.Fatalf("parameter restriction ignored: %s", st)
}
if st := try("api-1", "ping 1"); st != "executed" {
t.Fatalf("grant not applied: %s", st)
}
if st := try("api-2", "ping 2"); st != "executed" {
t.Fatalf("second operation refused: %s", st)
}
if st := try("api-3", "ping 3"); st != "pending" {
t.Fatalf("operation cap ignored: %s", st)
}
// Used up: the sweep refreshes the self-model once.
var refreshed []ids.ID
s.OnSelfModel = func(_ context.Context, mind ids.ID) { refreshed = append(refreshed, mind) }
if got, _ := s.SweepGrants(ctx); len(got) != 1 || got[0] != m.ID {
t.Fatalf("sweep: %v", got)
}
if got, _ := s.SweepGrants(ctx); len(got) != 0 {
t.Fatal("sweep notified twice")
}
// Expiry.
g2, _ := s.Grant(ctx, GrantInput{MindID: m.ID, Capability: "test.echo", Duration: time.Minute, By: "andy"})
if st := try("any", "x"); st != "executed" {
t.Fatalf("second grant: %s", st)
}
now = now.Add(2 * time.Minute)
if st := try("any", "x"); st != "pending" {
t.Fatalf("expired grant used: %s", st)
}
if got, _ := s.SweepGrants(ctx); len(got) != 1 {
t.Fatalf("expiry not swept: %v", got)
}
live, _ := s.ScopedGrants(ctx, m.ID, true)
if len(live) != 0 || g.ID == g2.ID {
t.Fatalf("live grants: %+v", live)
}
}
func TestGrantOperationsAreNotOverspent(t *testing.T) {
s, m, _ := setup(t)
ctx := context.Background()
if _, err := s.Grant(ctx, GrantInput{MindID: m.ID, Capability: "test.echo", Duration: time.Hour, MaxOperations: intp(1), By: "andy"}); err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
var mu sync.Mutex
executed := 0
for i := 0; i < 6; i++ {
wg.Add(1)
go func() {
defer wg.Done()
out, _ := s.Invoke(ctx, Invocation{MindID: m.ID, Capability: "test.echo", Params: map[string]any{"text": "x"}})
if out.Status == "executed" {
mu.Lock()
executed++
mu.Unlock()
}
}()
}
wg.Wait()
if executed != 1 {
t.Fatalf("one-operation grant executed %d times", executed)
}
}
func TestCapabilityRequestLifecycle(t *testing.T) {
s, m, _ := setup(t)
ctx := context.Background()
s.MaxPendingRequests = 2
base := CapabilityRequest{MindID: m.ID, Capability: "test.echo", TargetPattern: "api-*", DurationSeconds: 1800, MaxOperations: intp(20),
Reason: "metrics cannot distinguish deployment failure from downstream failure", Risk: "logs may contain personal identifiers", Mitigation: "PII redaction", Fallback: "ask the operator to query manually",
Evidence: []EvidenceRef{{Kind: "event", ID: string(ids.New()), Note: "deployment at 07:12"}}, IntentionID: ids.New()}
if _, err := s.RequestCapability(ctx, CapabilityRequest{MindID: m.ID, Capability: "sudo_shell", DurationSeconds: 60, Reason: "x"}); !errors.Is(err, ErrNotRequestable) {
t.Fatalf("locked capability requestable: %v", err)
}
if _, err := s.RequestCapability(ctx, CapabilityRequest{MindID: m.ID, Capability: "invented.tool", DurationSeconds: 60, Reason: "x"}); !errors.Is(err, ErrUnknown) {
t.Fatal("unknown capability requestable")
}
if _, err := s.RequestCapability(WithForbidden(ctx, "dreaming"), base); !errors.Is(err, ErrForbiddenContext) {
t.Fatal("dream requested authority")
}
r1, err := s.RequestCapability(ctx, base)
if err != nil || r1.Status != "pending" || r1.BatchKey != "intention:"+string(base.IntentionID) {
t.Fatalf("request: %v %+v", err, r1)
}
r2, _ := s.RequestCapability(ctx, base)
if r2.BatchKey != r1.BatchKey {
t.Fatal("requests from one intention should batch")
}
if _, err := s.RequestCapability(ctx, base); !errors.Is(err, ErrAttentionBudget) {
t.Fatalf("attention budget not enforced: %v", err)
}
// A component cannot decide; an operator can narrow.
if _, err := s.DecideCapabilityRequest(ctx, r1.ID, "mind:"+string(m.ID), true, "", Narrowing{}); !errors.Is(err, ErrReservedDecider) {
t.Fatal("mind approved its own request")
}
got, err := s.DecideCapabilityRequest(ctx, r1.ID, "andy", true, "ok for the incident", Narrowing{DurationSeconds: 600, MaxOperations: intp(5)})
if err != nil || got.Status != "granted" || got.GrantID.IsZero() {
t.Fatalf("grant: %v %+v", err, got)
}
gs, _ := s.ScopedGrants(ctx, m.ID, true)
if len(gs) != 1 || *gs[0].MaxOperations != 5 || time.Until(gs[0].ExpiresAt) > 11*time.Minute || gs[0].RequestID != r1.ID {
t.Fatalf("grant not narrowed: %+v", gs)
}
if _, err := s.DecideCapabilityRequest(ctx, r1.ID, "andy", true, "", Narrowing{}); !errors.Is(err, ErrInvalid) {
t.Fatal("request granted twice")
}
if _, err := s.DecideCapabilityRequest(ctx, r2.ID, "andy", false, "no", Narrowing{}); err != nil {
t.Fatal(err)
}
r3, _ := s.RequestCapability(ctx, base)
if r3.History["granted"] != 1 || r3.History["denied"] != 1 {
t.Fatalf("explicit history: %+v", r3.History)
}
if err := s.WithdrawCapabilityRequest(ctx, m.ID, r3.ID, "no longer needed"); err != nil {
t.Fatal(err)
}
}
+59 -7
View File
@@ -92,6 +92,11 @@ type Service struct {
SessionTTL time.Duration SessionTTL time.Duration
Now func() time.Time Now func() time.Time
OnSelfModel func(ctx context.Context, mind ids.ID) // called when effective policies may have changed OnSelfModel func(ctx context.Context, mind ids.ID) // called when effective policies may have changed
// MaxPendingRequests caps open capability requests per mind so operator
// attention is not spent on a stream of prompts (default 5).
MaxPendingRequests int
// RequestTTL is how long an undecided capability request stays open (default 24h).
RequestTTL time.Duration
} }
func (s *Service) now() time.Time { func (s *Service) now() time.Time {
@@ -268,11 +273,14 @@ func (s *Service) Invoke(ctx context.Context, inv Invocation) (Outcome, error) {
return Outcome{}, err return Outcome{}, err
} }
inv.Params = params inv.Params = params
if inv.Target, err = c.ResolveTarget(inv.Target, params); err != nil {
return Outcome{}, err
}
action, _ := params["action"].(string) action, _ := params["action"].(string)
if action == "" { if action == "" {
action = "*" action = "*"
} }
d, err := s.Decide(ctx, inv.MindID, c, inv.Target, action) d, err := s.decideInvocation(ctx, inv, c, action)
if err != nil { if err != nil {
return Outcome{}, err return Outcome{}, err
} }
@@ -280,9 +288,9 @@ func (s *Service) Invoke(ctx context.Context, inv Invocation) (Outcome, error) {
s.Metrics.ToolRequests.WithLabelValues(c.Name).Inc() s.Metrics.ToolRequests.WithLabelValues(c.Name).Inc()
} }
hash := Hash(inv.MindID, c.Name, inv.Target, params) 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} base := map[string]any{"capability": c.Name, "target": inv.Target, "params": params, "risk": c.Risk, "access": c.Access, "reason": inv.Reason, "hash": hash, "decision": d, "origin": inv.Origin}
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), 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}) 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), "grant_id": string(d.GrantID), "origin": inv.Origin}, Payload: base})
if err != nil { if err != nil {
return Outcome{}, err return Outcome{}, err
} }
@@ -302,9 +310,40 @@ func (s *Service) Invoke(ctx context.Context, inv Invocation) (Outcome, error) {
} }
return Outcome{Status: "pending", Decision: d, ApprovalID: a.ID, EventID: a.EventID}, nil return Outcome{Status: "pending", Decision: d, ApprovalID: a.ID, EventID: a.EventID}, nil
} }
if !d.GrantID.IsZero() && !s.consumeGrant(ctx, d.GrantID) {
// Another invocation used the last operation, or the grant lapsed
// between decision and execution: fall back to asking.
d.Effect, d.Reason, d.GrantID = Ask, "scoped grant exhausted or expired before execution", ids.Nil
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) return s.execute(ctx, inv, c, d, decisionEv, ids.Nil)
} }
// decideInvocation is Decide for a concrete request: scoped grants may match
// on parameters and are consulted only when policy says ASK, and a request
// that requires approval can never be allowed without one.
func (s *Service) decideInvocation(ctx context.Context, inv Invocation, c Capability, action string) (Decision, error) {
d, err := s.Decide(ctx, inv.MindID, c, inv.Target, action)
if err != nil {
return d, err
}
if d.Effect == Ask && !c.Locked && !inv.RequireApproval {
if g, ok := s.matchingGrant(ctx, inv.MindID, c, inv.Target, inv.Params); ok {
d.Effect, d.GrantID = Allow, g.ID
d.Reason = fmt.Sprintf("scoped grant by %s until %s%s", g.GrantedBy, g.ExpiresAt.Format(time.RFC3339), remaining(g))
}
}
if d.Effect == Allow && inv.RequireApproval {
d.Effect, d.Tightened, d.GrantID = Ask, true, ids.Nil
d.Reason = "approval required by a commitment (policy alone would allow: " + d.Reason + ")"
}
return d, nil
}
func (s *Service) createApproval(ctx context.Context, inv Invocation, c Capability, hash string, cause ids.ID) (Approval, error) { func (s *Service) createApproval(ctx context.Context, inv Invocation, c Capability, hash string, cause ids.ID) (Approval, error) {
ttl := s.ApprovalTTL ttl := s.ApprovalTTL
if ttl <= 0 { if ttl <= 0 {
@@ -338,7 +377,11 @@ func (s *Service) execute(ctx context.Context, inv Invocation, c Capability, d D
execErr = fmt.Errorf("executor panicked: %v", r) execErr = fmt.Errorf("executor panicked: %v", r)
} }
}() }()
ectx, cancel := context.WithTimeout(ctx, 60*time.Second) timeout := c.Timeout
if timeout <= 0 {
timeout = 60 * time.Second
}
ectx, cancel := context.WithTimeout(ctx, timeout)
defer cancel() defer cancel()
res, execErr = c.Executor(ectx, inv) res, execErr = c.Executor(ectx, inv)
}() }()
@@ -356,12 +399,12 @@ func (s *Service) execute(ctx context.Context, inv Invocation, c Capability, d D
exec.Result = rj exec.Result = rj
payload["result"] = res 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)), 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}) 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": c.OutputTrust}, Payload: payload})
// Tool output re-enters cognition as an observation with tool trust. // Tool output re-enters cognition as an observation with tool trust.
if strings.TrimSpace(res.Output) != "" { 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 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)}, Metadata: map[string]any{"trust": c.OutputTrust, "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}}) Payload: events.ObservationPayload{Text: truncate(res.Output, 20000), ContentType: "text/plain", Source: "tool:" + c.Name, Trust: c.OutputTrust, Method: "capability", Reference: string(exec.ID), Channel: inv.Target}})
} }
} }
exec.EventID = firstID(ev) exec.EventID = firstID(ev)
@@ -443,6 +486,9 @@ func (s *Service) GetApproval(ctx context.Context, id ids.ID) (Approval, error)
// Approve executes the request now. Modified params are re-validated and // Approve executes the request now. Modified params are re-validated and
// re-hashed; forSession additionally grants matching requests until SessionTTL. // 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) { func (s *Service) Approve(ctx context.Context, id ids.ID, by, note string, forSession bool, modified map[string]any) (Outcome, error) {
if !ValidDecider(by) {
return Outcome{}, ErrReservedDecider
}
a, err := s.GetApproval(ctx, id) a, err := s.GetApproval(ctx, id)
if err != nil { if err != nil {
return Outcome{}, err return Outcome{}, err
@@ -463,6 +509,9 @@ func (s *Service) Approve(ctx context.Context, id ids.ID, by, note string, forSe
if params, err = c.ValidateParams(modified); err != nil { if params, err = c.ValidateParams(modified); err != nil {
return Outcome{}, err return Outcome{}, err
} }
if _, err := c.ResolveTarget(a.Target, params); err != nil {
return Outcome{}, fmt.Errorf("%w: modified parameters change what the request acts on; deny it and let the mind ask again", ErrInvalid)
}
} }
hash := Hash(a.MindID, a.Capability, a.Target, params) 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} details := map[string]any{"for_session": forSession, "modified": modified != nil, "hash": hash, "original_hash": a.RequestHash}
@@ -499,6 +548,9 @@ func (s *Service) Approve(ctx context.Context, id ids.ID, by, note string, forSe
// Deny refuses a request with an explanation the mind will see. // 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) { func (s *Service) Deny(ctx context.Context, id ids.ID, by, note string) (Approval, error) {
if !ValidDecider(by) {
return Approval{}, ErrReservedDecider
}
a, err := s.GetApproval(ctx, id) a, err := s.GetApproval(ctx, id)
if err != nil { if err != nil {
return Approval{}, err return Approval{}, err
+15 -4
View File
@@ -47,10 +47,21 @@ func (s SelfModelSync) Sync(ctx context.Context, mind ids.ID) {
} }
caps = append(caps, minds.CapabilityEntry{Name: i.Capability.Name, Description: i.Capability.Description, Policy: eff}) caps = append(caps, minds.CapabilityEntry{Name: i.Capability.Name, Description: i.Capability.Description, Policy: eff})
} }
if fmt.Sprint(caps) == fmt.Sprint(sm.Capabilities) { var grants []minds.GrantEntry
if live, err := s.Caps.ScopedGrants(ctx, m.ID, true); err == nil {
for _, g := range live {
e := minds.GrantEntry{Capability: g.Capability, Target: g.TargetPattern, Access: string(g.Access), ExpiresAt: g.ExpiresAt}
if g.MaxOperations != nil {
left := *g.MaxOperations - g.UsedOperations
e.Remaining = &left
}
grants = append(grants, e)
}
}
if fmt.Sprint(caps) == fmt.Sprint(sm.Capabilities) && fmt.Sprint(grants) == fmt.Sprint(sm.Grants) {
continue continue
} }
sm.Capabilities = caps sm.Capabilities, sm.Grants = caps, grants
sm.Refresh(m.Personality) sm.Refresh(m.Personality)
if err := s.Minds.SaveSelfModel(ctx, m.ID, sm); err != nil { if err := s.Minds.SaveSelfModel(ctx, m.ID, sm); err != nil {
s.Log.Warn("self-model save failed", "mind", m.Slug, "err", err) s.Log.Warn("self-model save failed", "mind", m.Slug, "err", err)
@@ -65,7 +76,7 @@ func (s SelfModelSync) Sync(ctx context.Context, mind ids.ID) {
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 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, %d temporary grants, rest denied", allow, ask, len(grants)), //nolint:errcheck
Salience: 0.3, Confidence: 1, Metadata: map[string]any{"allow": allow, "ask": ask}, Payload: map[string]any{"capabilities": caps}}) Salience: 0.3, Confidence: 1, Metadata: map[string]any{"allow": allow, "ask": ask, "grants": len(grants)}, Payload: map[string]any{"capabilities": caps, "grants": grants}})
} }
} }
+5 -2
View File
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
"time"
"github.com/andydixon/noema/internal/capabilities" "github.com/andydixon/noema/internal/capabilities"
"github.com/andydixon/noema/pkg/ids" "github.com/andydixon/noema/pkg/ids"
@@ -15,7 +16,8 @@ import (
// can only reach endpoints an operator connected to it, never arbitrary ones. // can only reach endpoints an operator connected to it, never arbitrary ones.
func Capabilities(m *Manager, git GitPoller, prom PrometheusPoller) []capabilities.Capability { func Capabilities(m *Manager, git GitPoller, prom PrometheusPoller) []capabilities.Capability {
return []capabilities.Capability{ return []capabilities.Capability{
{Name: "read_metrics", Description: "Evaluate a PromQL expression against a connected Prometheus integration (target = integration name).", Risk: capabilities.RiskLow, TargetKind: "service", {Name: "read_metrics", Description: "Evaluate a PromQL expression against a connected Prometheus integration (target = integration name).", Risk: capabilities.RiskLow, TargetKind: "service", Access: capabilities.AccessRead, Idempotent: true, Timeout: 20 * time.Second,
SideEffects: "one query against the connected Prometheus", Estimate: capabilities.Cost{WallSeconds: 1, Network: 1},
Params: map[string]capabilities.Param{"expr": {Type: "string", Required: true, Description: "PromQL expression", MaxLen: 1000}}, DefaultEffect: capabilities.Allow, Params: map[string]capabilities.Param{"expr": {Type: "string", Required: true, Description: "PromQL expression", MaxLen: 1000}}, DefaultEffect: capabilities.Allow,
Executor: func(ctx context.Context, inv capabilities.Invocation) (capabilities.Result, error) { Executor: func(ctx context.Context, inv capabilities.Invocation) (capabilities.Result, error) {
in, err := m.named(ctx, inv.MindID, Prometheus, inv.Target) in, err := m.named(ctx, inv.MindID, Prometheus, inv.Target)
@@ -37,7 +39,8 @@ func Capabilities(m *Manager, git GitPoller, prom PrometheusPoller) []capabiliti
} }
return capabilities.Result{Summary: fmt.Sprintf("%s = %g", expr, v), Data: map[string]any{"value": v, "labels": labels, "integration_id": in.ID}}, nil return capabilities.Result{Summary: fmt.Sprintf("%s = %g", expr, v), Data: map[string]any{"value": v, "labels": labels, "integration_id": in.ID}}, nil
}}, }},
{Name: "repository.read", Description: "Read one file from a mirrored git integration (target = integration name; params ref, path).", Risk: capabilities.RiskMedium, TargetKind: "repository", {Name: "repository.read", Description: "Read one file from a mirrored git integration (target = integration name; params ref, path).", Risk: capabilities.RiskMedium, TargetKind: "repository", Access: capabilities.AccessRead, Idempotent: true, Timeout: 30 * time.Second,
SideEffects: "reads a file from the local mirror; nothing is written", Estimate: capabilities.Cost{WallSeconds: 1},
Params: map[string]capabilities.Param{"path": {Type: "string", Required: true, Description: "path inside the repository", MaxLen: 500}, "ref": {Type: "string", Description: "branch, tag or commit (default HEAD)", MaxLen: 200}}, DefaultEffect: capabilities.Ask, Params: map[string]capabilities.Param{"path": {Type: "string", Required: true, Description: "path inside the repository", MaxLen: 500}, "ref": {Type: "string", Description: "branch, tag or commit (default HEAD)", MaxLen: 200}}, DefaultEffect: capabilities.Ask,
Executor: func(ctx context.Context, inv capabilities.Invocation) (capabilities.Result, error) { Executor: func(ctx context.Context, inv capabilities.Invocation) (capabilities.Result, error) {
in, err := m.named(ctx, inv.MindID, Git, inv.Target) in, err := m.named(ctx, inv.MindID, Git, inv.Target)
+12
View File
@@ -14,9 +14,21 @@ type SelfModel struct {
CurrentGoals []string `json:"current_goals"` CurrentGoals []string `json:"current_goals"`
ConfidenceByDomain map[string]float64 `json:"confidence_by_domain"` ConfidenceByDomain map[string]float64 `json:"confidence_by_domain"`
KnowledgeGaps []string `json:"knowledge_gaps"` KnowledgeGaps []string `json:"knowledge_gaps"`
// Grants lists temporary authority currently in force. The capability
// layer rewrites it whenever a grant is made, used up, revoked or expires.
Grants []GrantEntry `json:"grants,omitempty"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
// GrantEntry is one live scoped grant as the mind sees it.
type GrantEntry struct {
Capability string `json:"capability"`
Target string `json:"target"`
Access string `json:"access"`
ExpiresAt time.Time `json:"expires_at"`
Remaining *int `json:"remaining_operations,omitempty"`
}
// CapabilityEntry records one capability and the policy that governs it. // CapabilityEntry records one capability and the policy that governs it.
type CapabilityEntry struct { type CapabilityEntry struct {
Name string `json:"name"` Name string `json:"name"`
+25
View File
@@ -0,0 +1,25 @@
// Package actors distinguishes people from Noema's own components in the
// "by" and "actor" fields of decisions. Authority-bearing decisions
// (approvals, grants, capability requests) must name a person; a name that
// an internal component writes is refused, so no cognitive process can
// approve its own request by writing a plausible decider.
package actors
import "strings"
// Components are the names internal processes use as sources or actors.
var Components = []string{"mind", "brain", "planning", "volition", "cognition", "capabilities", "noema", "system", "dream", "replay", "experiment", "scheduler", "agency", "research"}
// IsPerson reports whether name can stand for a person making a decision.
func IsPerson(name string) bool {
n := strings.ToLower(strings.TrimSpace(name))
if n == "" || strings.Contains(n, ":") {
return false
}
for _, c := range Components {
if n == c || strings.HasPrefix(n, c+".") {
return false
}
}
return true
}
+16
View File
@@ -0,0 +1,16 @@
package actors
import "testing"
func TestIsPerson(t *testing.T) {
for _, n := range []string{"", " ", "system", "System", "mind", "mind:abc", "brain.act", "planning", "volition.form", "user:andy", "noema"} {
if IsPerson(n) {
t.Errorf("%q accepted as a person", n)
}
}
for _, n := range []string{"andy", "operator-1", "a.dixon", "brainstorm"} {
if !IsPerson(n) {
t.Errorf("%q refused", n)
}
}
}
+59 -1
View File
@@ -18,6 +18,8 @@ func (u *UI) capabilityRoutes(m *http.ServeMux) {
m.Handle("POST /policies/grants/{id}/revoke", u.require(auth.RoleOperator, u.grantRevoke)) 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("GET /approvals", u.require(auth.RoleObserver, u.approvalsPage))
m.Handle("POST /approvals/{id}/decide", u.require(auth.RoleOperator, u.approvalDecide)) m.Handle("POST /approvals/{id}/decide", u.require(auth.RoleOperator, u.approvalDecide))
m.Handle("POST /capability-requests/{id}/decide", u.require(auth.RoleOperator, u.capabilityRequestDecide))
m.Handle("POST /scoped-grants/{id}/revoke", u.require(auth.RoleOperator, u.scopedGrantRevoke))
} }
func (u *UI) mindNames(r *http.Request) (map[ids.ID]string, []any) { func (u *UI) mindNames(r *http.Request) (map[ids.ID]string, []any) {
@@ -100,7 +102,63 @@ func (u *UI) approvalsPage(w http.ResponseWriter, r *http.Request) {
} }
} }
execs, _ := u.deps.Capabilities.Executions(r.Context(), ids.Nil, 30) 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}) requests, _ := u.deps.Capabilities.CapabilityRequests(r.Context(), ids.Nil, "pending", 100)
var batches []requestBatch
for _, req := range requests {
if n := len(batches); n > 0 && batches[n-1].Key == req.BatchKey {
batches[n-1].Requests = append(batches[n-1].Requests, req)
continue
}
batches = append(batches, requestBatch{Key: req.BatchKey, MindID: req.MindID, IntentionID: req.IntentionID, Requests: []capabilities.CapabilityRequest{req}})
}
grants, _ := u.deps.Capabilities.ScopedGrants(r.Context(), ids.Nil, true)
caps := map[string]capabilities.Capability{}
for _, c := range u.deps.Capabilities.Registry.List() {
caps[c.Name] = c
}
u.render(w, r, "approvals", "Approval centre", map[string]any{"Pending": pending, "Decided": decided, "Executions": execs, "MindNames": names, "Batches": batches, "Grants": grants, "Caps": caps})
}
// requestBatch groups requests raised for one intention (or one mind in one
// hour) so an operator decides related requests together.
type requestBatch struct {
Key string
MindID ids.ID
IntentionID ids.ID
Requests []capabilities.CapabilityRequest
}
func (u *UI) capabilityRequestDecide(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 n capabilities.Narrowing
if v, err := strconv.Atoi(r.PostFormValue("duration_minutes")); err == nil && v > 0 {
n.DurationSeconds = v * 60
}
if v, err := strconv.Atoi(r.PostFormValue("max_operations")); err == nil && v > 0 {
n.MaxOperations = &v
}
n.TargetPattern = strings.TrimSpace(r.PostFormValue("target_pattern"))
grant := r.PostFormValue("decision") == "grant"
if _, err := u.studyF().DecideCapabilityRequest(r, id, grant, r.PostFormValue("note"), n); err != nil {
u.flash(w, r, "error", err.Error(), "/approvals")
return
}
u.flash(w, r, "ok", map[bool]string{true: "Temporary grant made.", false: "Request denied."}[grant], "/approvals")
}
func (u *UI) scopedGrantRevoke(w http.ResponseWriter, r *http.Request) {
id, err := ids.Parse(r.PathValue("id"))
if err != nil || u.deps.Capabilities.RevokeScopedGrant(r.Context(), id, username(r)) != nil {
u.flash(w, r, "error", "Grant not found or already revoked.", "/approvals")
return
}
u.deps.Audit.Log(r.Context(), "scoped_grant.revoke", "scoped_grant", id.String(), nil)
u.flash(w, r, "ok", "Temporary grant revoked.", "/approvals")
} }
func (u *UI) approvalDecide(w http.ResponseWriter, r *http.Request) { func (u *UI) approvalDecide(w http.ResponseWriter, r *http.Request) {
+12 -1
View File
@@ -125,7 +125,18 @@ func (u *UI) parse(fsys fs.FS) error {
"roles": auth.AllRoles, "roles": auth.AllRoles,
"date": func(t time.Time) string { return t.Local().Format("2006-01-02 15:04") }, "date": func(t time.Time) string { return t.Local().Format("2006-01-02 15:04") },
"ago": ago, "ago": ago,
"duration": func(s int64) string { return (time.Duration(s) * time.Second).Truncate(time.Second).String() }, "duration": func(v any) string {
var secs int64
switch x := v.(type) {
case int:
secs = int64(x)
case int64:
secs = x
case float64:
secs = int64(x)
}
return (time.Duration(secs) * time.Second).Truncate(time.Second).String()
},
"bytes": humanBytes, "bytes": humanBytes,
"pct": func(f float64) string { return fmt.Sprintf("%.0f%%", f*100) }, "pct": func(f float64) string { return fmt.Sprintf("%.0f%%", f*100) },
"list": func(xs ...string) []string { return xs }, "list": func(xs ...string) []string { return xs },
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS capability_requests;
DROP TABLE IF EXISTS capability_grants;
@@ -0,0 +1,57 @@
-- Scoped temporary grants and structured capability requests (PLAN §8.2 item 9).
-- Grants are operator decisions: expiring, optionally counted, restricted by
-- target, parameters and access. Requests are what a mind asks for; only an
-- operator route can decide them.
CREATE TABLE capability_grants (
id uuid PRIMARY KEY,
mind_id uuid NOT NULL REFERENCES minds(id),
capability text NOT NULL,
target_pattern text NOT NULL DEFAULT '*',
param_constraints jsonb NOT NULL DEFAULT '{}'::jsonb, -- parameter name → glob the value must match
access text NOT NULL DEFAULT 'read' CHECK (access IN ('read','write')),
max_operations integer CHECK (max_operations IS NULL OR max_operations > 0),
used_operations integer NOT NULL DEFAULT 0 CHECK (used_operations >= 0),
expires_at timestamptz NOT NULL,
granted_by text NOT NULL,
request_id uuid,
note text NOT NULL DEFAULT '',
revoked_at timestamptz,
revoked_by text NOT NULL DEFAULT '',
expiry_notified_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
CHECK (max_operations IS NULL OR used_operations <= max_operations)
);
CREATE INDEX capability_grants_mind_idx ON capability_grants (mind_id, capability) WHERE revoked_at IS NULL;
CREATE TABLE capability_requests (
id uuid PRIMARY KEY,
mind_id uuid NOT NULL REFERENCES minds(id),
capability text NOT NULL,
target_pattern text NOT NULL DEFAULT '*',
param_constraints jsonb NOT NULL DEFAULT '{}'::jsonb,
access text NOT NULL DEFAULT 'read' CHECK (access IN ('read','write')),
duration_seconds integer NOT NULL CHECK (duration_seconds > 0 AND duration_seconds <= 604800),
max_operations integer CHECK (max_operations IS NULL OR max_operations > 0),
reason text NOT NULL,
evidence jsonb NOT NULL DEFAULT '[]'::jsonb,
risk text NOT NULL DEFAULT '',
mitigation text NOT NULL DEFAULT '',
fallback text NOT NULL DEFAULT '',
alternatives jsonb NOT NULL DEFAULT '[]'::jsonb,
history jsonb NOT NULL DEFAULT '{}'::jsonb, -- explicit past decisions on this capability, for the operator
intention_id uuid,
plan_id uuid,
step_id uuid,
batch_key text NOT NULL DEFAULT '',
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','granted','denied','expired','withdrawn')),
decided_by text NOT NULL DEFAULT '',
decision_note text NOT NULL DEFAULT '',
decided_at timestamptz,
grant_id uuid REFERENCES capability_grants(id),
event_id uuid,
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX capability_requests_status_idx ON capability_requests (status, created_at DESC);
CREATE INDEX capability_requests_mind_idx ON capability_requests (mind_id, created_at DESC);
+17
View File
@@ -64,8 +64,21 @@ type Harness struct {
const AdminPass, ObserverPass = "administrator-pass-1", "observer-pass-12345" const AdminPass, ObserverPass = "administrator-pass-1", "observer-pass-12345"
// Options adjust the assembled daemon for a test.
type Options struct {
// Capabilities are extra test capabilities registered before the
// registry is frozen.
Capabilities []capabilities.Capability
}
// Start builds and serves a daemon for one test. // Start builds and serves a daemon for one test.
func Start(t *testing.T) *Harness { func Start(t *testing.T) *Harness {
t.Helper()
return StartWith(t, Options{})
}
// StartWith builds a daemon with options.
func StartWith(t *testing.T, o Options) *Harness {
t.Helper() t.Helper()
pool := storage.TestPool(t, migrations.FS) pool := storage.TestPool(t, migrations.FS)
ctx := context.Background() ctx := context.Background()
@@ -85,6 +98,10 @@ func Start(t *testing.T) *Harness {
for _, c := range capabilities.Builtins() { for _, c := range capabilities.Builtins() {
capReg.Add(c) capReg.Add(c)
} }
for _, c := range o.Capabilities {
capReg.Add(c)
}
capReg.Freeze()
deps.Capabilities = &capabilities.Service{Registry: capReg, DB: pool, Events: deps.Events, Log: log} deps.Capabilities = &capabilities.Service{Registry: capReg, DB: pool, Events: deps.Events, Log: log}
deps.Integrations = &integrations.Manager{Store: integrations.NewStore(pool), Events: deps.Events, Graph: deps.Graph, Log: log, Pollers: map[integrations.Kind]integrations.Poller{}} deps.Integrations = &integrations.Manager{Store: integrations.NewStore(pool), Events: deps.Events, Graph: deps.Graph, Log: log, Pollers: map[integrations.Kind]integrations.Poller{}}
deps.Dreaming = &dreaming.Service{DB: pool, Memory: deps.Memory, Beliefs: deps.Beliefs, Events: deps.Events, Log: log} deps.Dreaming = &dreaming.Service{DB: pool, Memory: deps.Memory, Beliefs: deps.Beliefs, Events: deps.Events, Log: log}
+27
View File
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"net/url" "net/url"
"testing" "testing"
"time"
"github.com/andydixon/noema/internal/capabilities" "github.com/andydixon/noema/internal/capabilities"
"github.com/andydixon/noema/tests/harness" "github.com/andydixon/noema/tests/harness"
@@ -43,4 +44,30 @@ func TestOperatorDecidesInTheBrowser(t *testing.T) {
t.Fatalf("%s: status %s by %q", decision, a.Status, a.DecisionBy) t.Fatalf("%s: status %s by %q", decision, a.Status, a.DecisionBy)
} }
} }
// A mind's capability request is granted (narrowed) from the browser, and
// the resulting temporary grant can be revoked there.
ops := 20
req, err := h.Deps.Capabilities.RequestCapability(ctx, capabilities.CapabilityRequest{MindID: m.ID, Capability: "http.fetch", TargetPattern: "https://status.example.org/*", DurationSeconds: 1800, MaxOperations: &ops, Reason: "status page needed for the incident", Risk: "none beyond one request", Fallback: "ask the operator"})
if err != nil {
t.Fatal(err)
}
res, err = c.Get(h.Srv.URL + "/approvals")
if err != nil || res.StatusCode != 200 {
t.Fatalf("approvals page: %v %d", err, res.StatusCode)
}
res, err = c.PostForm(h.Srv.URL+"/capability-requests/"+req.ID.String()+"/decide", url.Values{"_csrf": {csrf}, "decision": {"grant"}, "duration_minutes": {"10"}, "max_operations": {"3"}})
if err != nil || res.StatusCode != 303 {
t.Fatalf("grant form: %v %d", err, res.StatusCode)
}
grants, _ := h.Deps.Capabilities.ScopedGrants(ctx, m.ID, true)
if len(grants) != 1 || *grants[0].MaxOperations != 3 || time.Until(grants[0].ExpiresAt) > 11*time.Minute || grants[0].GrantedBy != "admin" {
t.Fatalf("grant from browser: %+v", grants)
}
res, err = c.PostForm(h.Srv.URL+"/scoped-grants/"+grants[0].ID.String()+"/revoke", url.Values{"_csrf": {csrf}})
if err != nil || res.StatusCode != 303 {
t.Fatalf("revoke form: %v %d", err, res.StatusCode)
}
if live, _ := h.Deps.Capabilities.ScopedGrants(ctx, m.ID, true); len(live) != 0 {
t.Fatal("grant still live after revoke")
}
} }
+46
View File
@@ -6,6 +6,7 @@
{{if .Data.Pending}}{{range .Data.Pending}} {{if .Data.Pending}}{{range .Data.Pending}}
<div class="approval"> <div class="approval">
<div class="approval-head"><span class="badge rk-{{.Risk}}">{{.Risk}}</span> <strong>{{index $.Data.MindNames .MindID}}</strong> wants <code>{{.Capability}}</code>{{if .Target}} on <span class="mono">{{.Target}}</span>{{end}} <span class="muted small">· requested {{ago .CreatedAt}} · expires {{.ExpiresAt.Local.Format "02 Jan 15:04"}}{{if .EventID}} · <a href="/events/{{.EventID}}">event</a>{{end}}</span></div> <div class="approval-head"><span class="badge rk-{{.Risk}}">{{.Risk}}</span> <strong>{{index $.Data.MindNames .MindID}}</strong> wants <code>{{.Capability}}</code>{{if .Target}} on <span class="mono">{{.Target}}</span>{{end}} <span class="muted small">· requested {{ago .CreatedAt}} · expires {{.ExpiresAt.Local.Format "02 Jan 15:04"}}{{if .EventID}} · <a href="/events/{{.EventID}}">event</a>{{end}}</span></div>
{{with index $.Data.Caps .Capability}}<p class="small muted">{{.Access}} · {{if .Idempotent}}idempotent{{else}}not idempotent{{end}}{{if .SideEffects}} · side effects: {{.SideEffects}}{{end}}</p>{{end}}
<div class="grid cols-2 tight"> <div class="grid cols-2 tight">
<div><h3>Reason</h3><p>{{if .Reason}}{{.Reason}}{{else}}<em class="muted">none given</em>{{end}}</p> <div><h3>Reason</h3><p>{{if .Reason}}{{.Reason}}{{else}}<em class="muted">none given</em>{{end}}</p>
{{if .Evidence}}<h3>Evidence</h3><ul class="plain small">{{range .Evidence}}<li><span class="badge">{{.Kind}}</span> {{if eq .Kind "event"}}<a href="/events/{{.ID}}">{{.ID}}</a>{{else}}{{.ID}}{{end}} {{.Note}}</li>{{end}}</ul>{{end}}</div> {{if .Evidence}}<h3>Evidence</h3><ul class="plain small">{{range .Evidence}}<li><span class="badge">{{.Kind}}</span> {{if eq .Kind "event"}}<a href="/events/{{.ID}}">{{.ID}}</a>{{else}}{{.ID}}{{end}} {{.Note}}</li>{{end}}</ul>{{end}}</div>
@@ -24,6 +25,51 @@
</div>{{end}} </div>{{end}}
{{else}}<p class="muted">Nothing is waiting.</p>{{end}} {{else}}<p class="muted">Nothing is waiting.</p>{{end}}
</section> </section>
<section class="card">
<h2>Capability requests <span class="muted small">{{len .Data.Batches}} batch(es)</span></h2>
<p class="muted small">Temporary, scoped authority a mind has asked for. Granting creates an expiring grant that applies only where policy says ASK; it never overrides DENY. You may grant less than was asked.</p>
{{if .Data.Batches}}{{range .Data.Batches}}
<div class="approval">
<div class="approval-head"><strong>{{index $.Data.MindNames .MindID}}</strong> · {{len .Requests}} related request(s){{if .IntentionID}} for intention <a class="mono small" href="/intentions/{{.IntentionID}}">{{trunc (printf "%s" .IntentionID) 8}}</a>{{end}}</div>
{{range .Requests}}
<div class="stack">
<div><span class="badge">{{.Access}}</span> <code>{{.Capability}}</code> on <span class="mono">{{.TargetPattern}}</span> for {{duration .DurationSeconds}}{{if .MaxOperations}}, at most {{deref_int .MaxOperations}} operations{{end}} <span class="muted small">· expires {{.ExpiresAt.Local.Format "02 Jan 15:04"}}{{if .EventID}} · <a href="/events/{{.EventID}}">event</a>{{end}}</span></div>
<div class="grid cols-2 tight">
<div><h3>Why</h3><p>{{.Reason}}</p>
{{if .Evidence}}<h3>Evidence</h3><ul class="plain small">{{range .Evidence}}<li><span class="badge">{{.Kind}}</span> {{if eq .Kind "event"}}<a href="/events/{{.ID}}">{{.ID}}</a>{{else}}{{.ID}}{{end}} {{.Note}}</li>{{end}}</ul>{{end}}
{{if .ParamConstraints}}<h3>Parameter limits</h3><pre class="json">{{pretty (jsonOf .ParamConstraints)}}</pre>{{end}}</div>
<div><h3>Risk</h3><p>{{if .Risk}}{{.Risk}}{{else}}<em class="muted">none stated</em>{{end}}</p>
{{if .Mitigation}}<h3>Mitigation</h3><p>{{.Mitigation}}</p>{{end}}
{{if .Fallback}}<h3>If refused</h3><p>{{.Fallback}}</p>{{end}}
{{if .Alternatives}}<h3>Alternatives</h3><ul class="plain small">{{range .Alternatives}}<li>{{.}}</li>{{end}}</ul>{{end}}
{{with index $.Data.Caps .Capability}}{{if .SideEffects}}<h3>Side effects</h3><p class="small">{{.SideEffects}}</p>{{end}}{{end}}
<p class="small muted">Earlier decisions on this capability for this mind: {{index .History "granted"}} granted, {{index .History "denied"}} denied. History informs; it never grants.</p></div>
</div>
{{if $op}}
<form method="post" action="/capability-requests/{{.ID}}/decide" class="row">
<input type="hidden" name="_csrf" value="{{$.CSRF}}">
<input name="duration_minutes" type="number" min="1" placeholder="minutes (≤ asked)" aria-label="duration in minutes" size="8">
<input name="max_operations" type="number" min="1" placeholder="operations" aria-label="maximum operations" size="6">
<input name="target_pattern" placeholder="{{.TargetPattern}}" aria-label="target pattern" size="12">
<input name="note" placeholder="note (shown to the mind)" class="grow" maxlength="500">
<button class="btn btn-primary" name="decision" value="grant">Grant</button>
<button class="btn btn-danger" name="decision" value="deny">Deny</button>
</form>
{{end}}
</div>
{{end}}
</div>{{end}}
{{else}}<p class="muted">No open capability requests.</p>{{end}}
</section>
<section class="card">
<h2>Temporary grants in force <span class="muted small">{{len .Data.Grants}}</span></h2>
{{if .Data.Grants}}<table class="table compact"><thead><tr><th>Mind</th><th>Capability</th><th>Target</th><th>Access</th><th>Limits</th><th>Expires</th><th>By</th><th></th></tr></thead><tbody>{{range .Data.Grants}}<tr>
<td class="small">{{index $.Data.MindNames .MindID}}</td><td><code>{{.Capability}}</code></td><td class="mono small">{{.TargetPattern}}</td><td class="small">{{.Access}}</td>
<td class="small">{{if .MaxOperations}}{{.UsedOperations}} of {{deref_int .MaxOperations}} used{{else}}{{.UsedOperations}} used{{end}}{{if .ParamConstraints}} · {{range $k, $v := .ParamConstraints}}{{$k}}={{$v}} {{end}}{{end}}</td>
<td class="small nowrap">{{.ExpiresAt.Local.Format "02 Jan 15:04"}}</td><td class="small">{{.GrantedBy}}</td>
<td>{{if $op}}<form method="post" action="/scoped-grants/{{.ID}}/revoke" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="btn small btn-danger" data-confirm="Revoke this grant now?">Revoke</button></form>{{end}}</td></tr>{{end}}</tbody></table>
{{else}}<p class="muted">None. Session approvals appear on the policies page.</p>{{end}}
</section>
<section class="grid cols-2"> <section class="grid cols-2">
<div class="card"><h2>Decided recently</h2> <div class="card"><h2>Decided recently</h2>
{{if .Data.Decided}}<table class="table compact"><thead><tr><th>Status</th><th>Mind</th><th>Action</th><th>By</th><th>Note</th><th>When</th></tr></thead><tbody>{{range .Data.Decided}}<tr><td><span class="badge ap-{{.Status}}">{{.Status}}</span></td><td class="small">{{index $.Data.MindNames .MindID}}</td><td><code>{{.Capability}}</code> <span class="mono small">{{.Target}}</span>{{if .Error}}<div class="small danger">{{.Error}}</div>{{end}}</td><td class="small">{{.DecisionBy}}</td><td class="small">{{.DecisionNote}}</td><td class="small muted nowrap">{{ago .CreatedAt}}</td></tr>{{end}}</tbody></table>{{else}}<p class="muted">None.</p>{{end}}</div> {{if .Data.Decided}}<table class="table compact"><thead><tr><th>Status</th><th>Mind</th><th>Action</th><th>By</th><th>Note</th><th>When</th></tr></thead><tbody>{{range .Data.Decided}}<tr><td><span class="badge ap-{{.Status}}">{{.Status}}</span></td><td class="small">{{index $.Data.MindNames .MindID}}</td><td><code>{{.Capability}}</code> <span class="mono small">{{.Target}}</span>{{if .Error}}<div class="small danger">{{.Error}}</div>{{end}}</td><td class="small">{{.DecisionBy}}</td><td class="small">{{.DecisionNote}}</td><td class="small muted nowrap">{{ago .CreatedAt}}</td></tr>{{end}}</tbody></table>{{else}}<p class="muted">None.</p>{{end}}</div>