diff --git a/cmd/noemad/main.go b/cmd/noemad/main.go index 9c88527..c7e9f1d 100644 --- a/cmd/noemad/main.go +++ b/cmd/noemad/main.go @@ -166,6 +166,7 @@ func run() error { for _, c := range integrations.Capabilities(deps.Integrations, integrations.GitPoller{DataDir: "./data/git"}, integrations.PrometheusPoller{Secrets: sec}) { 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} selfSync := cognition.SelfModelSync{Minds: deps.Minds, Caps: deps.Capabilities, Events: deps.Events, Log: log} deps.Capabilities.OnSelfModel = selfSync.Sync @@ -202,7 +203,14 @@ func run() error { }} selfSync.Sync(ctx, ids.Nil) 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("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) }) diff --git a/docs/openapi.json b/docs/openapi.json index 9e0aad4..75ee0c9 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -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" + } + ] + } } } } \ No newline at end of file diff --git a/internal/api/negotiation.go b/internal/api/negotiation.go new file mode 100644 index 0000000..b660d00 --- /dev/null +++ b/internal/api/negotiation.go @@ -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) +} diff --git a/internal/api/openapi.json b/internal/api/openapi.json index 9e0aad4..75ee0c9 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -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" + } + ] + } } } } \ No newline at end of file diff --git a/internal/api/server.go b/internal/api/server.go index 853acc3..aa42f1b 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -76,6 +76,7 @@ func (s *Server) routes() { s.cognitionRoutes() s.graphRoutes() s.capabilityRoutes() + s.negotiationRoutes() s.integrationRoutes() s.dreamRoutes() s.studyRoutes() diff --git a/internal/auth/store.go b/internal/auth/store.go index b19629a..d422d3a 100644 --- a/internal/auth/store.go +++ b/internal/auth/store.go @@ -10,6 +10,7 @@ import ( "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/storage" "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 { 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() { return User{}, fmt.Errorf("invalid role %q", role) } diff --git a/internal/capabilities/builtin.go b/internal/capabilities/builtin.go index a68d930..d7013ac 100644 --- a/internal/capabilities/builtin.go +++ b/internal/capabilities/builtin.go @@ -9,6 +9,7 @@ import ( "time" "github.com/andydixon/noema/internal/security/httpclient" + "github.com/andydixon/noema/pkg/events" ) // Builtins returns the capabilities every deployment has. Integrations add @@ -16,28 +17,32 @@ import ( // self-model can list them as unavailable; they never execute. func Builtins() []Capability { return []Capability{ - {Name: "noema.note", Description: "Leave a note for the operator in the mind's own history.", Risk: RiskLow, TargetKind: "none", - Params: map[string]Param{"text": {Type: "string", Required: true, Description: "note text", MaxLen: 2000}}, DefaultEffect: Allow, + {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, Executor: func(_ context.Context, inv Invocation) (Result, error) { t, _ := inv.Params["text"].(string) return Result{Summary: "note recorded", Data: map[string]any{"text": t}}, nil }}, - {Name: "http.fetch", Description: "Fetch a public HTTP(S) URL read-only (bounded, no private addresses) and return its text.", Risk: RiskMedium, TargetKind: "url", + {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, 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, Executor: func(context.Context, Invocation) (Result, error) { return Result{}, errors.New("no search integration configured") }}, - {Name: "send_email", Description: "Send an email (requires a mail integration).", Risk: RiskHigh, TargetKind: "address", + {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, Executor: func(context.Context, Invocation) (Result, error) { return Result{}, errors.New("no mail integration configured") }}, - {Name: "sudo_shell", Description: "Privileged shell. Locked to DENY; exists so it can be named, never run.", Risk: RiskCritical, TargetKind: "host", Locked: true, + {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}, - {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}, } } diff --git a/internal/capabilities/capabilities.go b/internal/capabilities/capabilities.go index 5ab2f39..d02e8b3 100644 --- a/internal/capabilities/capabilities.go +++ b/internal/capabilities/capabilities.go @@ -17,6 +17,7 @@ import ( "strings" "time" + "github.com/andydixon/noema/pkg/events" "github.com/andydixon/noema/pkg/ids" ) @@ -52,6 +53,24 @@ type Param struct { 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. type Invocation struct { MindID ids.ID @@ -62,6 +81,12 @@ type Invocation struct { Evidence []EvidenceRef Correlation 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:"), recorded with the decision for provenance. + Origin string } // EvidenceRef mirrors events.EvidenceRef without importing it (keeps this @@ -88,22 +113,52 @@ type Capability struct { Name string `json:"name"` Description string `json:"description"` Risk Risk `json:"risk"` - TargetKind string `json:"target_kind"` // url | path | host | service | repository | none + TargetKind string `json:"target_kind"` // url | path | host | service | repository | domain | none Params map[string]Param `json:"params"` DefaultEffect Effect `json:"default_effect"` // when no policy matches Locked bool `json:"locked"` // always DENY; cannot be relaxed - Executor Executor `json:"-"` + 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:"-"` } -// Registry holds capabilities. It is built in code; nothing at runtime can -// add one, so a mind cannot invent tools. -type Registry struct{ caps map[string]Capability } +// Registry holds capabilities. It is built in code at start-up and frozen +// before any mind runs; nothing at runtime can add one, so a mind cannot +// invent tools. +type Registry struct { + caps map[string]Capability + frozen bool +} // NewRegistry creates an empty registry. 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 { + 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 { c.Params = map[string]Param{} } @@ -117,6 +172,12 @@ func (r *Registry) Add(c Capability) *Registry { 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. func (r *Registry) Get(name string) (Capability, bool) { c, ok := r.caps[name] @@ -195,6 +256,22 @@ func (c Capability) ValidateParams(params map[string]any) (map[string]any, error 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. func Hash(mind ids.ID, capability, target string, params map[string]any) string { raw, _ := json.Marshal(struct { @@ -268,10 +345,12 @@ func (p Policy) matches(mind ids.ID, capability, target, action string, now time type Decision struct { Effect Effect `json:"effect"` 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"` Reason string `json:"reason"` Locked bool `json:"locked"` Considered int `json:"considered"` + Tightened bool `json:"tightened,omitempty"` // ALLOW became ASK because the invocation required approval } func rank(e Effect) int { diff --git a/internal/capabilities/negotiation.go b/internal/capabilities/negotiation.go new file mode 100644 index 0000000..2bc3ecc --- /dev/null +++ b/internal/capabilities/negotiation.go @@ -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 +} diff --git a/internal/capabilities/negotiation_test.go b/internal/capabilities/negotiation_test.go new file mode 100644 index 0000000..0aa72dd --- /dev/null +++ b/internal/capabilities/negotiation_test.go @@ -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) + } +} diff --git a/internal/capabilities/service.go b/internal/capabilities/service.go index d4466fa..2f8c3ae 100644 --- a/internal/capabilities/service.go +++ b/internal/capabilities/service.go @@ -92,6 +92,11 @@ type Service struct { SessionTTL time.Duration Now func() time.Time 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 { @@ -268,11 +273,14 @@ func (s *Service) Invoke(ctx context.Context, inv Invocation) (Outcome, error) { return Outcome{}, err } inv.Params = params + if inv.Target, err = c.ResolveTarget(inv.Target, params); err != nil { + return Outcome{}, err + } action, _ := params["action"].(string) if action == "" { action = "*" } - d, err := s.Decide(ctx, inv.MindID, c, inv.Target, action) + d, err := s.decideInvocation(ctx, inv, c, action) if err != nil { 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() } 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), - 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 { 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 } + 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) } +// 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) { ttl := s.ApprovalTTL 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) } }() - 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() 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 payload["result"] = res ev, _ = s.Events.Append(ctx, events.New{MindID: inv.MindID, Type: events.ToolExecuted, Source: "capabilities", Summary: fmt.Sprintf("%s on %q: %s", c.Name, inv.Target, truncate(res.Summary, 160)), - Salience: 0.5, Confidence: 1, CausationID: cause, CorrelationID: inv.Correlation, Evidence: toEvidence(inv.Evidence), Metadata: map[string]any{"capability": c.Name, "target": inv.Target, "execution_id": string(exec.ID), "trust": events.TrustTool}, Payload: payload}) + 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. if strings.TrimSpace(res.Output) != "" { s.Events.Append(ctx, events.New{MindID: inv.MindID, Type: events.Observation, Source: "tool:" + c.Name, Summary: "tool output from " + c.Name + ": " + truncate(oneLine(res.Output), 140), Salience: 0.6, Confidence: 0.9, CausationID: firstID(ev), CorrelationID: inv.Correlation, //nolint:errcheck - Metadata: map[string]any{"trust": events.TrustTool, "capability": c.Name, "execution_id": string(exec.ID)}, - Payload: events.ObservationPayload{Text: truncate(res.Output, 20000), ContentType: "text/plain", Source: "tool:" + c.Name, Trust: events.TrustTool, Method: "capability", Reference: string(exec.ID), Channel: inv.Target}}) + 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: c.OutputTrust, Method: "capability", Reference: string(exec.ID), Channel: inv.Target}}) } } 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 // 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) { + if !ValidDecider(by) { + return Outcome{}, ErrReservedDecider + } a, err := s.GetApproval(ctx, id) if err != nil { 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 { 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) 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. 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) if err != nil { return Approval{}, err diff --git a/internal/cognition/selfmodel.go b/internal/cognition/selfmodel.go index 62296c7..2f24d84 100644 --- a/internal/cognition/selfmodel.go +++ b/internal/cognition/selfmodel.go @@ -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}) } - 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 } - sm.Capabilities = caps + sm.Capabilities, sm.Grants = caps, grants sm.Refresh(m.Personality) if err := s.Minds.SaveSelfModel(ctx, m.ID, sm); err != nil { s.Log.Warn("self-model save failed", "mind", m.Slug, "err", err) @@ -65,7 +76,7 @@ func (s SelfModelSync) Sync(ctx context.Context, mind ids.ID) { ask++ } } - s.Events.Append(ctx, events.New{MindID: m.ID, Type: events.SelfModelUpdated, Source: "capabilities", Summary: fmt.Sprintf("self-model: %d capabilities allowed, %d need approval, rest denied", allow, ask), //nolint:errcheck - Salience: 0.3, Confidence: 1, Metadata: map[string]any{"allow": allow, "ask": ask}, Payload: map[string]any{"capabilities": caps}}) + 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, "grants": len(grants)}, Payload: map[string]any{"capabilities": caps, "grants": grants}}) } } diff --git a/internal/integrations/capabilities.go b/internal/integrations/capabilities.go index caa2887..20a8a5a 100644 --- a/internal/integrations/capabilities.go +++ b/internal/integrations/capabilities.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "strings" + "time" "github.com/andydixon/noema/internal/capabilities" "github.com/andydixon/noema/pkg/ids" @@ -15,7 +16,8 @@ import ( // can only reach endpoints an operator connected to it, never arbitrary ones. func Capabilities(m *Manager, git GitPoller, prom PrometheusPoller) []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, Executor: func(ctx context.Context, inv capabilities.Invocation) (capabilities.Result, error) { 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 }}, - {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, Executor: func(ctx context.Context, inv capabilities.Invocation) (capabilities.Result, error) { in, err := m.named(ctx, inv.MindID, Git, inv.Target) diff --git a/internal/minds/selfmodel.go b/internal/minds/selfmodel.go index 491c44f..6dbfb29 100644 --- a/internal/minds/selfmodel.go +++ b/internal/minds/selfmodel.go @@ -14,7 +14,19 @@ type SelfModel struct { CurrentGoals []string `json:"current_goals"` ConfidenceByDomain map[string]float64 `json:"confidence_by_domain"` KnowledgeGaps []string `json:"knowledge_gaps"` - UpdatedAt time.Time `json:"updated_at"` + // 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"` +} + +// 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. diff --git a/internal/security/actors/actors.go b/internal/security/actors/actors.go new file mode 100644 index 0000000..d43a423 --- /dev/null +++ b/internal/security/actors/actors.go @@ -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 +} diff --git a/internal/security/actors/actors_test.go b/internal/security/actors/actors_test.go new file mode 100644 index 0000000..85661b8 --- /dev/null +++ b/internal/security/actors/actors_test.go @@ -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) + } + } +} diff --git a/internal/ui/capabilities.go b/internal/ui/capabilities.go index 7f6feea..a2e657d 100644 --- a/internal/ui/capabilities.go +++ b/internal/ui/capabilities.go @@ -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("GET /approvals", u.require(auth.RoleObserver, u.approvalsPage)) 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) { @@ -100,7 +102,63 @@ func (u *UI) approvalsPage(w http.ResponseWriter, r *http.Request) { } } 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) { diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 8481ad6..794e211 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -125,10 +125,21 @@ func (u *UI) parse(fsys fs.FS) error { "roles": auth.AllRoles, "date": func(t time.Time) string { return t.Local().Format("2006-01-02 15:04") }, "ago": ago, - "duration": func(s int64) string { return (time.Duration(s) * time.Second).Truncate(time.Second).String() }, - "bytes": humanBytes, - "pct": func(f float64) string { return fmt.Sprintf("%.0f%%", f*100) }, - "list": func(xs ...string) []string { return xs }, + "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, + "pct": func(f float64) string { return fmt.Sprintf("%.0f%%", f*100) }, + "list": func(xs ...string) []string { return xs }, "conf": func(v any) string { switch x := v.(type) { case float64: diff --git a/migrations/0025_capability_negotiation.down.sql b/migrations/0025_capability_negotiation.down.sql new file mode 100644 index 0000000..f9d5d1f --- /dev/null +++ b/migrations/0025_capability_negotiation.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS capability_requests; +DROP TABLE IF EXISTS capability_grants; diff --git a/migrations/0025_capability_negotiation.up.sql b/migrations/0025_capability_negotiation.up.sql new file mode 100644 index 0000000..e7a7f10 --- /dev/null +++ b/migrations/0025_capability_negotiation.up.sql @@ -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); diff --git a/tests/harness/harness.go b/tests/harness/harness.go index cc1f079..3ac1a20 100644 --- a/tests/harness/harness.go +++ b/tests/harness/harness.go @@ -64,8 +64,21 @@ type Harness struct { 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. 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() pool := storage.TestPool(t, migrations.FS) ctx := context.Background() @@ -85,6 +98,10 @@ func Start(t *testing.T) *Harness { for _, c := range capabilities.Builtins() { 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.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} diff --git a/tests/security/ui_actions_test.go b/tests/security/ui_actions_test.go index 7abc6fb..4da7fbc 100644 --- a/tests/security/ui_actions_test.go +++ b/tests/security/ui_actions_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/url" "testing" + "time" "github.com/andydixon/noema/internal/capabilities" "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) } } + // 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") + } } diff --git a/web/templates/pages/approvals.html b/web/templates/pages/approvals.html index 15b3ed4..e68de6d 100644 --- a/web/templates/pages/approvals.html +++ b/web/templates/pages/approvals.html @@ -6,6 +6,7 @@ {{if .Data.Pending}}{{range .Data.Pending}}
{{.Risk}} {{index $.Data.MindNames .MindID}} wants {{.Capability}}{{if .Target}} on {{.Target}}{{end}} · requested {{ago .CreatedAt}} · expires {{.ExpiresAt.Local.Format "02 Jan 15:04"}}{{if .EventID}} · event{{end}}
+ {{with index $.Data.Caps .Capability}}

{{.Access}} · {{if .Idempotent}}idempotent{{else}}not idempotent{{end}}{{if .SideEffects}} · side effects: {{.SideEffects}}{{end}}

{{end}}

Reason

{{if .Reason}}{{.Reason}}{{else}}none given{{end}}

{{if .Evidence}}

Evidence

    {{range .Evidence}}
  • {{.Kind}} {{if eq .Kind "event"}}{{.ID}}{{else}}{{.ID}}{{end}} {{.Note}}
  • {{end}}
{{end}}
@@ -24,6 +25,51 @@
{{end}} {{else}}

Nothing is waiting.

{{end}} +
+

Capability requests {{len .Data.Batches}} batch(es)

+

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.

+ {{if .Data.Batches}}{{range .Data.Batches}} +
+
{{index $.Data.MindNames .MindID}} · {{len .Requests}} related request(s){{if .IntentionID}} for intention {{trunc (printf "%s" .IntentionID) 8}}{{end}}
+ {{range .Requests}} +
+
{{.Access}} {{.Capability}} on {{.TargetPattern}} for {{duration .DurationSeconds}}{{if .MaxOperations}}, at most {{deref_int .MaxOperations}} operations{{end}} · expires {{.ExpiresAt.Local.Format "02 Jan 15:04"}}{{if .EventID}} · event{{end}}
+
+

Why

{{.Reason}}

+ {{if .Evidence}}

Evidence

    {{range .Evidence}}
  • {{.Kind}} {{if eq .Kind "event"}}{{.ID}}{{else}}{{.ID}}{{end}} {{.Note}}
  • {{end}}
{{end}} + {{if .ParamConstraints}}

Parameter limits

{{pretty (jsonOf .ParamConstraints)}}
{{end}}
+

Risk

{{if .Risk}}{{.Risk}}{{else}}none stated{{end}}

+ {{if .Mitigation}}

Mitigation

{{.Mitigation}}

{{end}} + {{if .Fallback}}

If refused

{{.Fallback}}

{{end}} + {{if .Alternatives}}

Alternatives

    {{range .Alternatives}}
  • {{.}}
  • {{end}}
{{end}} + {{with index $.Data.Caps .Capability}}{{if .SideEffects}}

Side effects

{{.SideEffects}}

{{end}}{{end}} +

Earlier decisions on this capability for this mind: {{index .History "granted"}} granted, {{index .History "denied"}} denied. History informs; it never grants.

+
+ {{if $op}} +
+ + + + + + + +
+ {{end}} +
+ {{end}} +
{{end}} + {{else}}

No open capability requests.

{{end}} +
+
+

Temporary grants in force {{len .Data.Grants}}

+ {{if .Data.Grants}}{{range .Data.Grants}} + + + + {{end}}
MindCapabilityTargetAccessLimitsExpiresBy
{{index $.Data.MindNames .MindID}}{{.Capability}}{{.TargetPattern}}{{.Access}}{{if .MaxOperations}}{{.UsedOperations}} of {{deref_int .MaxOperations}} used{{else}}{{.UsedOperations}} used{{end}}{{if .ParamConstraints}} · {{range $k, $v := .ParamConstraints}}{{$k}}={{$v}} {{end}}{{end}}{{.ExpiresAt.Local.Format "02 Jan 15:04"}}{{.GrantedBy}}{{if $op}}
{{end}}
+ {{else}}

None. Session approvals appear on the policies page.

{{end}} +

Decided recently

{{if .Data.Decided}}{{range .Data.Decided}}{{end}}
StatusMindActionByNoteWhen
{{.Status}}{{index $.Data.MindNames .MindID}}{{.Capability}} {{.Target}}{{if .Error}}
{{.Error}}
{{end}}
{{.DecisionBy}}{{.DecisionNote}}{{ago .CreatedAt}}
{{else}}

None.

{{end}}