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
+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)
}