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:
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,7 @@ func (s *Server) routes() {
|
||||
s.cognitionRoutes()
|
||||
s.graphRoutes()
|
||||
s.capabilityRoutes()
|
||||
s.negotiationRoutes()
|
||||
s.integrationRoutes()
|
||||
s.dreamRoutes()
|
||||
s.studyRoutes()
|
||||
|
||||
Reference in New Issue
Block a user