Files
noema/internal/capabilities/service.go
T
Andy Dixon b35dbe0299 security: add the capability and policy layer as the only path to action
Phase 12 (ADR-0005). Capabilities are registered in code with parameter
schemas, risk classes and default effects; sudo_shell and delete_database
are locked to DENY and cannot be relaxed. Policies are scoped globally or
per mind with capability, target glob, action kind and optional hour
window; evaluation is deterministic, most specific wins, and ties go to
the stricter effect. Invoke is the single entry point: it refuses
dreaming and replay contexts, validates parameters (unknown keys, non
http URLs and path traversal rejected), evaluates policy and session
grants, hashes the exact request and records a policy_decision event.
ALLOW executes with timeout and panic isolation and turns textual output
into a tool-trust observation; ASK creates an approval request bound to
the hash with an alert notification; DENY is counted and recorded.

Operators approve once, approve for a time-boxed session grant, deny
with a note, or modify parameters (re-validated, re-hashed, original
kept in the append-only decision log). The self-model's capability list
is recomputed from effective policy on start and on every policy change.
The act process maps fetch requests to http.fetch and the action handler
invokes through the layer; replies report capabilities and outcomes.

Policy matrix and approval centre pages, dashboard count, API, CLI
(approvals, policy, invoke) and docs/capabilities.md.
2026-06-29 20:28:05 +01:00

649 lines
26 KiB
Go

package capabilities
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"path"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
evstore "github.com/andydixon/noema/internal/events"
"github.com/andydixon/noema/internal/observability"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/pkg/ids"
)
// Approval is a pending or decided request.
type Approval struct {
ID ids.ID `json:"id"`
MindID ids.ID `json:"mind_id"`
Capability string `json:"capability"`
Target string `json:"target"`
Params map[string]any `json:"params"`
RequestHash string `json:"request_hash"`
Reason string `json:"reason"`
Risk Risk `json:"risk"`
Evidence []EvidenceRef `json:"evidence"`
Status string `json:"status"`
DecisionBy string `json:"decision_by"`
DecisionNote string `json:"decision_note"`
DecidedAt *time.Time `json:"decided_at,omitempty"`
SessionGrant bool `json:"session_grant"`
ModifiedParams map[string]any `json:"modified_params,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error string `json:"error"`
EventID ids.ID `json:"event_id,omitempty"`
CorrelationID ids.ID `json:"correlation_id,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
}
// Execution is a recorded run.
type Execution struct {
ID ids.ID `json:"id"`
MindID ids.ID `json:"mind_id"`
Capability string `json:"capability"`
Target string `json:"target"`
Params map[string]any `json:"params"`
Result json.RawMessage `json:"result,omitempty"`
Error string `json:"error"`
DurationMS int `json:"duration_ms"`
ApprovalID ids.ID `json:"approval_id,omitempty"`
EventID ids.ID `json:"event_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// Grant is an approve-for-session exception.
type Grant struct {
ID ids.ID `json:"id"`
MindID ids.ID `json:"mind_id"`
Capability string `json:"capability"`
TargetPattern string `json:"target_pattern"`
GrantedBy string `json:"granted_by"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
}
// Outcome is what Invoke returns.
type Outcome struct {
Status string `json:"status"` // executed | pending | denied | failed
Decision Decision `json:"decision"`
Result *Result `json:"result,omitempty"`
Error string `json:"error,omitempty"`
ApprovalID ids.ID `json:"approval_id,omitempty"`
EventID ids.ID `json:"event_id,omitempty"`
Execution ids.ID `json:"execution_id,omitempty"`
}
// Service wires the registry, policies, approvals and executions together.
type Service struct {
Registry *Registry
DB *pgxpool.Pool
Events *evstore.Store
Log *slog.Logger
Metrics *observability.Metrics
ApprovalTTL time.Duration
SessionTTL time.Duration
Now func() time.Time
OnSelfModel func(ctx context.Context, mind ids.ID) // called when effective policies may have changed
}
func (s *Service) now() time.Time {
if s.Now != nil {
return s.Now()
}
return time.Now()
}
// ---- policies ----
const policyCols = `id, mind_id, capability, target_pattern, action_kind, effect, hours_from, hours_to, reason, created_by, created_at, updated_at`
func scanPolicy(row pgx.Row) (Policy, error) {
var p Policy
var mind *string
var effect string
if err := row.Scan(&p.ID, &mind, &p.Capability, &p.TargetPattern, &p.ActionKind, &effect, &p.HoursFrom, &p.HoursTo, &p.Reason, &p.CreatedBy, &p.CreatedAt, &p.UpdatedAt); err != nil {
return p, storage.Translate(err)
}
p.Effect = Effect(effect)
if mind != nil {
p.MindID = ids.ID(*mind)
}
return p, nil
}
// Policies lists rules applying to a mind (global plus its own), or all when mind is Nil.
func (s *Service) Policies(ctx context.Context, mind ids.ID) ([]Policy, error) {
rows, err := s.DB.Query(ctx, `SELECT `+policyCols+` FROM policies WHERE mind_id IS NULL OR $1::uuid IS NULL OR mind_id=$1 ORDER BY mind_id NULLS FIRST, capability, target_pattern`, nullID(mind))
if err != nil {
return nil, err
}
defer rows.Close()
var out []Policy
for rows.Next() {
p, err := scanPolicy(rows)
if err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// SetPolicy inserts a rule. Locked capabilities cannot be relaxed.
func (s *Service) SetPolicy(ctx context.Context, p Policy) (Policy, error) {
if !p.Effect.Valid() {
return Policy{}, fmt.Errorf("%w: effect must be allow, ask or deny", ErrInvalid)
}
if p.Capability == "" {
p.Capability = "*"
}
if p.TargetPattern == "" {
p.TargetPattern = "*"
}
if p.ActionKind == "" {
p.ActionKind = "*"
}
if p.Capability != "*" {
c, ok := s.Registry.Get(p.Capability)
if !ok {
return Policy{}, fmt.Errorf("%w: unknown capability %q", ErrUnknown, p.Capability)
}
if c.Locked && p.Effect != Deny {
return Policy{}, fmt.Errorf("%w: %s is locked to DENY and cannot be relaxed", ErrInvalid, c.Name)
}
}
if _, err := path.Match(p.TargetPattern, "x"); err != nil {
return Policy{}, fmt.Errorf("%w: bad target pattern", ErrInvalid)
}
if (p.HoursFrom == nil) != (p.HoursTo == nil) {
return Policy{}, fmt.Errorf("%w: both hours_from and hours_to are required", ErrInvalid)
}
p.ID, p.CreatedAt, p.UpdatedAt = ids.New(), s.now(), s.now()
_, err := s.DB.Exec(ctx, `INSERT INTO policies (id, mind_id, capability, target_pattern, action_kind, effect, hours_from, hours_to, reason, created_by) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
p.ID, nullID(p.MindID), p.Capability, p.TargetPattern, p.ActionKind, string(p.Effect), p.HoursFrom, p.HoursTo, p.Reason, p.CreatedBy)
if err != nil {
return Policy{}, err
}
s.selfModelChanged(ctx, p.MindID)
return p, nil
}
// DeletePolicy removes a rule.
func (s *Service) DeletePolicy(ctx context.Context, id ids.ID) error {
var mind *string
err := s.DB.QueryRow(ctx, `DELETE FROM policies WHERE id=$1 RETURNING mind_id`, id).Scan(&mind)
if err != nil {
return storage.Translate(err)
}
if mind != nil {
s.selfModelChanged(ctx, ids.ID(*mind))
} else {
s.selfModelChanged(ctx, ids.Nil)
}
return nil
}
func (s *Service) selfModelChanged(ctx context.Context, mind ids.ID) {
if s.OnSelfModel != nil {
s.OnSelfModel(ctx, mind)
}
}
// Decide evaluates policy and session grants for a request without executing.
func (s *Service) Decide(ctx context.Context, mind ids.ID, c Capability, target, action string) (Decision, error) {
pols, err := s.Policies(ctx, mind)
if err != nil {
return Decision{}, err
}
d := Evaluate(c, pols, mind, target, action, s.now())
if d.Effect == Ask && !c.Locked {
if g, ok := s.activeGrant(ctx, mind, c.Name, target); ok {
d.Effect, d.Reason = Allow, fmt.Sprintf("session grant by %s until %s", g.GrantedBy, g.ExpiresAt.Format(time.Kitchen))
}
}
return d, nil
}
func (s *Service) activeGrant(ctx context.Context, mind ids.ID, capability, target string) (Grant, bool) {
rows, err := s.DB.Query(ctx, `SELECT id, mind_id, capability, target_pattern, granted_by, expires_at, created_at FROM session_grants WHERE mind_id=$1 AND capability=$2 AND revoked_at IS NULL AND expires_at > $3`, mind, capability, s.now())
if err != nil {
return Grant{}, false
}
defer rows.Close()
for rows.Next() {
var g Grant
if err := rows.Scan(&g.ID, &g.MindID, &g.Capability, &g.TargetPattern, &g.GrantedBy, &g.ExpiresAt, &g.CreatedAt); err != nil {
return Grant{}, false
}
if ok, _ := path.Match(g.TargetPattern, target); ok || g.TargetPattern == "*" || strings.EqualFold(g.TargetPattern, target) {
return g, true
}
}
return Grant{}, false
}
// Inventory lists every capability with the effect a mind would get for a
// generic target now; it feeds the self-model.
type InventoryItem struct {
Capability Capability `json:"capability"`
Effect Effect `json:"effect"`
Reason string `json:"reason"`
}
// Inventory computes effective effects for a mind.
func (s *Service) Inventory(ctx context.Context, mind ids.ID) ([]InventoryItem, error) {
pols, err := s.Policies(ctx, mind)
if err != nil {
return nil, err
}
var out []InventoryItem
for _, c := range s.Registry.List() {
d := Evaluate(c, pols, mind, "*", "*", s.now())
out = append(out, InventoryItem{Capability: c, Effect: d.Effect, Reason: d.Reason})
}
return out, nil
}
// ---- invocation ----
// Invoke is the single entry point for actions.
func (s *Service) Invoke(ctx context.Context, inv Invocation) (Outcome, error) {
if why, forbidden := Forbidden(ctx); forbidden {
return Outcome{Status: "denied", Decision: Decision{Effect: Deny, Reason: "forbidden context: " + why}}, ErrForbiddenContext
}
c, ok := s.Registry.Get(inv.Capability)
if !ok {
return Outcome{}, fmt.Errorf("%w: %s", ErrUnknown, inv.Capability)
}
params, err := c.ValidateParams(inv.Params)
if err != nil {
return Outcome{}, err
}
inv.Params = params
action, _ := params["action"].(string)
if action == "" {
action = "*"
}
d, err := s.Decide(ctx, inv.MindID, c, inv.Target, action)
if err != nil {
return Outcome{}, err
}
if s.Metrics != nil {
s.Metrics.ToolRequests.WithLabelValues(c.Name).Inc()
}
hash := Hash(inv.MindID, c.Name, inv.Target, params)
base := map[string]any{"capability": c.Name, "target": inv.Target, "params": params, "risk": c.Risk, "reason": inv.Reason, "hash": hash, "decision": d}
evs, err := s.Events.Append(ctx, events.New{MindID: inv.MindID, Type: events.PolicyDecision, Source: "capabilities", Summary: fmt.Sprintf("policy %s for %s on %q: %s", strings.ToUpper(string(d.Effect)), c.Name, inv.Target, d.Reason),
Salience: 0.3, Confidence: 1, CorrelationID: inv.Correlation, Evidence: toEvidence(inv.Evidence), Metadata: map[string]any{"capability": c.Name, "effect": d.Effect, "policy_id": string(d.PolicyID)}, Payload: base})
if err != nil {
return Outcome{}, err
}
decisionEv := evs[0].ID
switch d.Effect {
case Deny:
if s.Metrics != nil {
s.Metrics.ToolDenied.WithLabelValues(c.Name).Inc()
}
ev, _ := s.Events.Append(ctx, events.New{MindID: inv.MindID, Type: events.ToolDenied, Source: "capabilities", Summary: fmt.Sprintf("denied: %s on %q (%s)", c.Name, inv.Target, d.Reason),
Salience: 0.5, Confidence: 1, CausationID: decisionEv, CorrelationID: inv.Correlation, Metadata: map[string]any{"capability": c.Name, "target": inv.Target}, Payload: base})
return Outcome{Status: "denied", Decision: d, EventID: firstID(ev)}, nil
case Ask:
a, err := s.createApproval(ctx, inv, c, hash, decisionEv)
if err != nil {
return Outcome{}, err
}
return Outcome{Status: "pending", Decision: d, ApprovalID: a.ID, EventID: a.EventID}, nil
}
return s.execute(ctx, inv, c, d, decisionEv, ids.Nil)
}
func (s *Service) createApproval(ctx context.Context, inv Invocation, c Capability, hash string, cause ids.ID) (Approval, error) {
ttl := s.ApprovalTTL
if ttl <= 0 {
ttl = 24 * time.Hour
}
a := Approval{ID: ids.New(), MindID: inv.MindID, Capability: c.Name, Target: inv.Target, Params: inv.Params, RequestHash: hash, Reason: inv.Reason, Risk: c.Risk, Evidence: inv.Evidence, Status: "pending", CorrelationID: inv.Correlation, ExpiresAt: s.now().Add(ttl), CreatedAt: s.now()}
if a.Evidence == nil {
a.Evidence = []EvidenceRef{}
}
ev, err := s.Events.Append(ctx, events.New{MindID: inv.MindID, Type: events.ToolRequested, Source: "capabilities", Summary: fmt.Sprintf("approval requested: %s on %q — %s", c.Name, inv.Target, firstNonEmpty(inv.Reason, "no reason given")),
Salience: 0.6, Confidence: 1, CausationID: cause, CorrelationID: inv.Correlation, Evidence: toEvidence(inv.Evidence),
Metadata: map[string]any{"capability": c.Name, "target": inv.Target, "approval_id": string(a.ID), "risk": c.Risk}, Payload: map[string]any{"approval_id": a.ID, "capability": c.Name, "target": inv.Target, "params": inv.Params, "risk": c.Risk, "reason": inv.Reason, "expires_at": a.ExpiresAt}})
if err != nil {
return Approval{}, err
}
a.EventID = ev[0].ID
params, _ := json.Marshal(inv.Params)
evid, _ := json.Marshal(a.Evidence)
_, err = s.DB.Exec(ctx, `INSERT INTO approval_requests (id, mind_id, capability, target, params, request_hash, reason, risk, evidence, status, event_id, correlation_id, expires_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'pending',$10,$11,$12)`, a.ID, a.MindID, a.Capability, a.Target, params, a.RequestHash, a.Reason, string(a.Risk), evid, string(a.EventID), nullID(a.CorrelationID), a.ExpiresAt)
return a, err
}
func (s *Service) execute(ctx context.Context, inv Invocation, c Capability, d Decision, cause ids.ID, approvalID ids.ID) (Outcome, error) {
start := s.now()
var res Result
var execErr error
func() {
defer func() {
if r := recover(); r != nil {
execErr = fmt.Errorf("executor panicked: %v", r)
}
}()
ectx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
res, execErr = c.Executor(ectx, inv)
}()
dur := time.Since(start)
exec := Execution{ID: ids.New(), MindID: inv.MindID, Capability: c.Name, Target: inv.Target, Params: inv.Params, DurationMS: int(dur.Milliseconds()), ApprovalID: approvalID, CreatedAt: s.now()}
payload := map[string]any{"capability": c.Name, "target": inv.Target, "params": inv.Params, "approval_id": approvalID, "duration_ms": exec.DurationMS}
var ev []events.Event
if execErr != nil {
exec.Error = execErr.Error()
payload["error"] = exec.Error
ev, _ = s.Events.Append(ctx, events.New{MindID: inv.MindID, Type: events.ToolFailed, Source: "capabilities", Summary: fmt.Sprintf("%s on %q failed: %s", c.Name, inv.Target, truncate(exec.Error, 160)),
Salience: 0.6, Confidence: 1, CausationID: cause, CorrelationID: inv.Correlation, Metadata: map[string]any{"capability": c.Name, "target": inv.Target, "execution_id": string(exec.ID)}, Payload: payload})
} else {
rj, _ := json.Marshal(res)
exec.Result = rj
payload["result"] = res
ev, _ = s.Events.Append(ctx, events.New{MindID: inv.MindID, Type: events.ToolExecuted, Source: "capabilities", Summary: fmt.Sprintf("%s on %q: %s", c.Name, inv.Target, truncate(res.Summary, 160)),
Salience: 0.5, Confidence: 1, CausationID: cause, CorrelationID: inv.Correlation, Evidence: toEvidence(inv.Evidence), Metadata: map[string]any{"capability": c.Name, "target": inv.Target, "execution_id": string(exec.ID), "trust": events.TrustTool}, Payload: payload})
// Tool output re-enters cognition as an observation with tool trust.
if strings.TrimSpace(res.Output) != "" {
s.Events.Append(ctx, events.New{MindID: inv.MindID, Type: events.Observation, Source: "tool:" + c.Name, Summary: "tool output from " + c.Name + ": " + truncate(oneLine(res.Output), 140), Salience: 0.6, Confidence: 0.9, CausationID: firstID(ev), CorrelationID: inv.Correlation, //nolint:errcheck
Metadata: map[string]any{"trust": events.TrustTool, "capability": c.Name, "execution_id": string(exec.ID)},
Payload: events.ObservationPayload{Text: truncate(res.Output, 20000), ContentType: "text/plain", Source: "tool:" + c.Name, Trust: events.TrustTool, Method: "capability", Reference: string(exec.ID), Channel: inv.Target}})
}
}
exec.EventID = firstID(ev)
params, _ := json.Marshal(inv.Params)
_, err := s.DB.Exec(ctx, `INSERT INTO tool_executions (id, mind_id, capability, target, params, result, error, duration_ms, approval_id, event_id) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
exec.ID, exec.MindID, exec.Capability, exec.Target, params, nullRaw(exec.Result), exec.Error, exec.DurationMS, nullID(approvalID), nullID(exec.EventID))
if err != nil {
s.Log.Error("record execution failed", "err", err)
}
if execErr != nil {
return Outcome{Status: "failed", Decision: d, Error: exec.Error, EventID: exec.EventID, Execution: exec.ID, ApprovalID: approvalID}, nil
}
return Outcome{Status: "executed", Decision: d, Result: &res, EventID: exec.EventID, Execution: exec.ID, ApprovalID: approvalID}, nil
}
// ---- approvals ----
const approvalCols = `id, mind_id, capability, target, params, request_hash, reason, risk, evidence, status, decision_by, decision_note, decided_at, session_grant, modified_params, result, error, event_id, correlation_id, expires_at, created_at`
func scanApproval(row pgx.Row) (Approval, error) {
var a Approval
var params, evid []byte
var modified, result *json.RawMessage
var risk string
var ev, corr *string
if err := row.Scan(&a.ID, &a.MindID, &a.Capability, &a.Target, &params, &a.RequestHash, &a.Reason, &risk, &evid, &a.Status, &a.DecisionBy, &a.DecisionNote, &a.DecidedAt, &a.SessionGrant, &modified, &result, &a.Error, &ev, &corr, &a.ExpiresAt, &a.CreatedAt); err != nil {
return a, storage.Translate(err)
}
a.Risk = Risk(risk)
json.Unmarshal(params, &a.Params) //nolint:errcheck
json.Unmarshal(evid, &a.Evidence) //nolint:errcheck
if a.Params == nil {
a.Params = map[string]any{}
}
if a.Evidence == nil {
a.Evidence = []EvidenceRef{}
}
if modified != nil {
json.Unmarshal(*modified, &a.ModifiedParams) //nolint:errcheck
}
if result != nil {
a.Result = *result
}
if ev != nil {
a.EventID = ids.ID(*ev)
}
if corr != nil {
a.CorrelationID = ids.ID(*corr)
}
return a, nil
}
// Approvals lists requests by status (empty = all), newest first; mind Nil = all minds.
func (s *Service) Approvals(ctx context.Context, mind ids.ID, status string, limit int) ([]Approval, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
rows, err := s.DB.Query(ctx, `SELECT `+approvalCols+` FROM approval_requests WHERE ($1::uuid IS NULL OR mind_id=$1) AND ($2='' OR status=$2) ORDER BY created_at DESC LIMIT $3`, nullID(mind), status, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Approval
for rows.Next() {
a, err := scanApproval(rows)
if err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
// GetApproval fetches one request.
func (s *Service) GetApproval(ctx context.Context, id ids.ID) (Approval, error) {
return scanApproval(s.DB.QueryRow(ctx, `SELECT `+approvalCols+` FROM approval_requests WHERE id=$1`, id))
}
// Approve executes the request now. Modified params are re-validated and
// re-hashed; forSession additionally grants matching requests until SessionTTL.
func (s *Service) Approve(ctx context.Context, id ids.ID, by, note string, forSession bool, modified map[string]any) (Outcome, error) {
a, err := s.GetApproval(ctx, id)
if err != nil {
return Outcome{}, err
}
if a.Status != "pending" {
return Outcome{}, fmt.Errorf("%w: request is %s", ErrInvalid, a.Status)
}
if s.now().After(a.ExpiresAt) {
s.DB.Exec(ctx, `UPDATE approval_requests SET status='expired' WHERE id=$1`, id) //nolint:errcheck
return Outcome{}, fmt.Errorf("%w: request expired", ErrInvalid)
}
c, ok := s.Registry.Get(a.Capability)
if !ok || c.Locked {
return Outcome{}, fmt.Errorf("%w: capability unavailable or locked", ErrInvalid)
}
params := a.Params
if modified != nil {
if params, err = c.ValidateParams(modified); err != nil {
return Outcome{}, err
}
}
hash := Hash(a.MindID, a.Capability, a.Target, params)
details := map[string]any{"for_session": forSession, "modified": modified != nil, "hash": hash, "original_hash": a.RequestHash}
if err := s.recordDecision(ctx, a.ID, "approved", by, note, details); err != nil {
return Outcome{}, err
}
mod, _ := json.Marshal(modified)
if _, err := s.DB.Exec(ctx, `UPDATE approval_requests SET status='approved', decision_by=$2, decision_note=$3, decided_at=$4, session_grant=$5, modified_params=$6 WHERE id=$1`, id, by, note, s.now(), forSession, nullRawIf(mod, modified != nil)); err != nil {
return Outcome{}, err
}
if forSession {
ttl := s.SessionTTL
if ttl <= 0 {
ttl = 8 * time.Hour
}
s.DB.Exec(ctx, `INSERT INTO session_grants (id, mind_id, capability, target_pattern, granted_by, expires_at) VALUES ($1,$2,$3,$4,$5,$6)`, ids.New(), a.MindID, a.Capability, a.Target, by, s.now().Add(ttl)) //nolint:errcheck
}
ev, _ := s.Events.Append(ctx, events.New{MindID: a.MindID, Type: events.ToolAuthorised, Source: "operator", Summary: fmt.Sprintf("operator %s approved %s on %q%s", by, a.Capability, a.Target, map[bool]string{true: " for this session", false: ""}[forSession]),
Salience: 0.5, Confidence: 1, CausationID: a.EventID, CorrelationID: a.CorrelationID, Metadata: map[string]any{"approval_id": string(a.ID), "actor": by, "capability": a.Capability, "for_session": forSession}, Payload: details})
inv := Invocation{MindID: a.MindID, Capability: a.Capability, Target: a.Target, Params: params, Reason: a.Reason, Evidence: a.Evidence, Correlation: a.CorrelationID, ApprovalID: a.ID}
out, err := s.execute(ctx, inv, c, Decision{Effect: Allow, Reason: "approved by " + by}, firstID(ev), a.ID)
if err != nil {
return out, err
}
status := "executed"
if out.Status == "failed" {
status = "failed"
}
rj, _ := json.Marshal(out.Result)
s.DB.Exec(ctx, `UPDATE approval_requests SET status=$2, result=$3, error=$4 WHERE id=$1`, id, status, nullRawIf(rj, out.Result != nil), out.Error) //nolint:errcheck
s.selfModelChanged(ctx, a.MindID)
return out, nil
}
// Deny refuses a request with an explanation the mind will see.
func (s *Service) Deny(ctx context.Context, id ids.ID, by, note string) (Approval, error) {
a, err := s.GetApproval(ctx, id)
if err != nil {
return Approval{}, err
}
if a.Status != "pending" {
return Approval{}, fmt.Errorf("%w: request is %s", ErrInvalid, a.Status)
}
if err := s.recordDecision(ctx, a.ID, "denied", by, note, nil); err != nil {
return Approval{}, err
}
if _, err := s.DB.Exec(ctx, `UPDATE approval_requests SET status='denied', decision_by=$2, decision_note=$3, decided_at=$4 WHERE id=$1`, id, by, note, s.now()); err != nil {
return Approval{}, err
}
if s.Metrics != nil {
s.Metrics.ToolDenied.WithLabelValues(a.Capability).Inc()
}
s.Events.Append(ctx, events.New{MindID: a.MindID, Type: events.ToolDenied, Source: "operator", Summary: fmt.Sprintf("operator %s denied %s on %q: %s", by, a.Capability, a.Target, firstNonEmpty(note, "no reason given")), //nolint:errcheck
Salience: 0.5, Confidence: 1, CausationID: a.EventID, CorrelationID: a.CorrelationID, Metadata: map[string]any{"approval_id": string(a.ID), "actor": by, "capability": a.Capability, "target": a.Target}, Payload: map[string]any{"note": note, "params": a.Params}})
return s.GetApproval(ctx, id)
}
func (s *Service) recordDecision(ctx context.Context, request ids.ID, decision, by, note string, details map[string]any) error {
if details == nil {
details = map[string]any{}
}
raw, _ := json.Marshal(details)
_, err := s.DB.Exec(ctx, `INSERT INTO approval_decisions (id, request_id, decision, decided_by, note, details) VALUES ($1,$2,$3,$4,$5,$6)`, ids.New(), request, decision, by, note, raw)
return err
}
// ExpirePending marks overdue requests expired.
func (s *Service) ExpirePending(ctx context.Context) (int64, error) {
tag, err := s.DB.Exec(ctx, `UPDATE approval_requests SET status='expired' WHERE status='pending' AND expires_at < $1`, s.now())
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}
// Grants lists live session grants for a mind.
func (s *Service) Grants(ctx context.Context, mind ids.ID) ([]Grant, error) {
rows, err := s.DB.Query(ctx, `SELECT id, mind_id, capability, target_pattern, granted_by, expires_at, created_at FROM session_grants WHERE ($1::uuid IS NULL OR mind_id=$1) AND revoked_at IS NULL AND expires_at > $2 ORDER BY created_at DESC`, nullID(mind), s.now())
if err != nil {
return nil, err
}
defer rows.Close()
var out []Grant
for rows.Next() {
var g Grant
if err := rows.Scan(&g.ID, &g.MindID, &g.Capability, &g.TargetPattern, &g.GrantedBy, &g.ExpiresAt, &g.CreatedAt); err != nil {
return nil, err
}
out = append(out, g)
}
return out, rows.Err()
}
// RevokeGrant ends a session grant early.
func (s *Service) RevokeGrant(ctx context.Context, id ids.ID) error {
tag, err := s.DB.Exec(ctx, `UPDATE session_grants SET revoked_at=$2 WHERE id=$1 AND revoked_at IS NULL`, id, s.now())
if err == nil && tag.RowsAffected() == 0 {
return storage.ErrNotFound
}
return err
}
// Executions lists recent runs.
func (s *Service) Executions(ctx context.Context, mind ids.ID, limit int) ([]Execution, error) {
if limit <= 0 || limit > 500 {
limit = 50
}
rows, err := s.DB.Query(ctx, `SELECT id, mind_id, capability, target, params, result, error, duration_ms, approval_id, event_id, created_at FROM tool_executions WHERE ($1::uuid IS NULL OR mind_id=$1) ORDER BY created_at DESC LIMIT $2`, nullID(mind), limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Execution
for rows.Next() {
var e Execution
var params []byte
var result *json.RawMessage
var appr, ev *string
if err := rows.Scan(&e.ID, &e.MindID, &e.Capability, &e.Target, &params, &result, &e.Error, &e.DurationMS, &appr, &ev, &e.CreatedAt); err != nil {
return nil, err
}
json.Unmarshal(params, &e.Params) //nolint:errcheck
if result != nil {
e.Result = *result
}
if appr != nil {
e.ApprovalID = ids.ID(*appr)
}
if ev != nil {
e.EventID = ids.ID(*ev)
}
out = append(out, e)
}
return out, rows.Err()
}
// ---- helpers ----
func toEvidence(in []EvidenceRef) []events.EvidenceRef {
out := make([]events.EvidenceRef, 0, len(in))
for _, e := range in {
out = append(out, events.EvidenceRef{Kind: e.Kind, ID: e.ID, Note: e.Note})
}
return out
}
func firstID(evs []events.Event) ids.ID {
if len(evs) > 0 {
return evs[0].ID
}
return ids.Nil
}
func nullID(id ids.ID) *string {
if id.IsZero() {
return nil
}
s := string(id)
return &s
}
func nullRaw(r json.RawMessage) *json.RawMessage {
if len(r) == 0 {
return nil
}
return &r
}
func nullRawIf(r json.RawMessage, cond bool) *json.RawMessage {
if !cond || len(r) == 0 || string(r) == "null" {
return nil
}
return &r
}
func truncate(s string, n int) string {
if len(s) > n {
return s[:n-1] + "…"
}
return s
}
func oneLine(s string) string { return strings.Join(strings.Fields(s), " ") }