Volition and planning now run as part of cognition (PLAN §8.2 item 3):
- A volition hook derives wants after each cycle from what it perceived
and produced, satisfies answer wants when a reply is sent, raises due,
overdue, expired and conflicting commitments, and expires faded wants.
Events produced by earlier cycles are not re-derived.
- An intend process proposes deciding about the strongest undecided
wants; the candidate competes for the workspace, so attention gates
what the mind decides to pursue. The handler makes value tensions
explicit (caution against disruptive writes, privacy against reading
personal data), resolves them with recorded scores, runs deterministic
formation with commitments, policy preview, goals and values, records
the decision whatever it is, and plans a formed intention in the same
transaction. Operator requests carry their own weight in formation.
- Explicit requests in conversation ("restart <unit>", "logs for <unit>",
"fetch <url>") become wants with proposed means instead of direct
invocations; the reply says the request was recorded and that acting
depends on commitments and policy.
- The executor is woken after commits, runs in a scheduler-owned loop and
reacts to the event bus: a new commitment tightens or abandons affected
intentions (withdrawing their pending approvals), a weakened assumption
or a finished goal triggers reconsideration. A tightened plan may reuse
its strategy; a failed one may not.
- Metacognition gains volition checks: strong wants repeatedly set aside,
intentions with no recorded reason, intentions a commitment forbids,
commitments that cannot be kept, churn, repeated failed execution,
cost outweighing value, and intentions that outlived their goal.
- A plan model role and prompt let a model propose decompositions that
the planner validates. Ablations are a fork-only configuration field.
- Approvals required by an intention are described as coming from a
commitment or value constraint. The expectation after an approved
action is linked to the execution event, not the request.
- The wants.values column of the unreleased migration 0026 is renamed
value_keys: VALUES is reserved and the generic snapshot restore does
not quote identifiers.
- The hostile-output security test now uses a message that asks for no
action, because an operator's "status of <unit>" is legitimately a
read-only status check.
779 lines
32 KiB
Go
779 lines
32 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"`
|
|
Origin string `json:"origin,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"`
|
|
Origin string `json:"origin,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
|
|
// 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 {
|
|
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
|
|
if inv.Target, err = c.ResolveTarget(inv.Target, params); err != nil {
|
|
return Outcome{}, err
|
|
}
|
|
action, _ := params["action"].(string)
|
|
if action == "" {
|
|
action = "*"
|
|
}
|
|
d, err := s.decideInvocation(ctx, inv, c, 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, "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), "grant_id": string(d.GrantID), "origin": inv.Origin}, 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
|
|
}
|
|
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)
|
|
}
|
|
|
|
// Preview reports what Invoke would decide for a request without recording
|
|
// or executing anything: parameters validated, target derived, policy,
|
|
// session and scoped grants and approval requirements applied. Planning uses
|
|
// it to choose routes; it confers nothing.
|
|
func (s *Service) Preview(ctx context.Context, inv Invocation) (Decision, error) {
|
|
c, ok := s.Registry.Get(inv.Capability)
|
|
if !ok {
|
|
return Decision{Effect: Deny, Reason: "unknown capability"}, fmt.Errorf("%w: %s", ErrUnknown, inv.Capability)
|
|
}
|
|
if c.Locked {
|
|
return Decision{Effect: Deny, Locked: true, Reason: "capability is locked to DENY by design"}, nil
|
|
}
|
|
params, err := c.ValidateParams(inv.Params)
|
|
if err != nil {
|
|
return Decision{Effect: Deny, Reason: err.Error()}, err
|
|
}
|
|
inv.Params = params
|
|
if inv.Target, err = c.ResolveTarget(inv.Target, params); err != nil {
|
|
return Decision{Effect: Deny, Reason: err.Error()}, err
|
|
}
|
|
action, _ := params["action"].(string)
|
|
if action == "" {
|
|
action = "*"
|
|
}
|
|
return s.decideInvocation(ctx, inv, c, action)
|
|
}
|
|
|
|
// 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 the requesting intention (a commitment or value constraint); 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 {
|
|
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, Origin: inv.Origin, 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, "origin": inv.Origin}, 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, origin)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'pending',$10,$11,$12,$13)`, 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, a.Origin)
|
|
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)
|
|
}
|
|
}()
|
|
timeout := c.Timeout
|
|
if timeout <= 0 {
|
|
timeout = 60 * time.Second
|
|
}
|
|
ectx, cancel := context.WithTimeout(ctx, timeout)
|
|
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, Origin: inv.Origin, CreatedAt: s.now()}
|
|
payload := map[string]any{"capability": c.Name, "target": inv.Target, "params": inv.Params, "approval_id": approvalID, "duration_ms": exec.DurationMS, "origin": inv.Origin}
|
|
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), "origin": inv.Origin, "approval_id": string(approvalID)}, 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": c.OutputTrust, "origin": inv.Origin, "approval_id": string(approvalID)}, 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": 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)
|
|
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, origin) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`,
|
|
exec.ID, exec.MindID, exec.Capability, exec.Target, params, nullRaw(exec.Result), exec.Error, exec.DurationMS, nullID(approvalID), nullID(exec.EventID), exec.Origin)
|
|
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, origin`
|
|
|
|
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, ¶ms, &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, &a.Origin); 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) {
|
|
if !ValidDecider(by) {
|
|
return Outcome{}, ErrReservedDecider
|
|
}
|
|
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
|
|
}
|
|
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}
|
|
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, Origin: a.Origin}
|
|
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) {
|
|
if !ValidDecider(by) {
|
|
return Approval{}, ErrReservedDecider
|
|
}
|
|
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
|
|
}
|
|
return s.queryExecutions(ctx, `SELECT id, mind_id, capability, target, params, result, error, duration_ms, approval_id, event_id, created_at, origin FROM tool_executions WHERE ($1::uuid IS NULL OR mind_id=$1) ORDER BY created_at DESC LIMIT $2`, nullID(mind), limit)
|
|
}
|
|
|
|
// ExecutionsByOrigin lists executions a cognitive object asked for, oldest first.
|
|
func (s *Service) ExecutionsByOrigin(ctx context.Context, mind ids.ID, origin string) ([]Execution, error) {
|
|
return s.queryExecutions(ctx, `SELECT id, mind_id, capability, target, params, result, error, duration_ms, approval_id, event_id, created_at, origin FROM tool_executions WHERE mind_id=$1 AND origin=$2 ORDER BY created_at LIMIT 100`, mind, origin)
|
|
}
|
|
|
|
// GetExecution fetches one execution.
|
|
func (s *Service) GetExecution(ctx context.Context, id ids.ID) (Execution, error) {
|
|
out, err := s.queryExecutions(ctx, `SELECT id, mind_id, capability, target, params, result, error, duration_ms, approval_id, event_id, created_at, origin FROM tool_executions WHERE id=$1`, id)
|
|
if err != nil {
|
|
return Execution{}, err
|
|
}
|
|
if len(out) == 0 {
|
|
return Execution{}, storage.ErrNotFound
|
|
}
|
|
return out[0], nil
|
|
}
|
|
|
|
// ApprovalsByOrigin lists approval requests a cognitive object raised, oldest first.
|
|
func (s *Service) ApprovalsByOrigin(ctx context.Context, mind ids.ID, origin string) ([]Approval, error) {
|
|
rows, err := s.DB.Query(ctx, `SELECT `+approvalCols+` FROM approval_requests WHERE mind_id=$1 AND origin=$2 ORDER BY created_at LIMIT 100`, mind, origin)
|
|
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()
|
|
}
|
|
|
|
// WithdrawApproval lets the requesting mind withdraw a pending approval
|
|
// request it no longer needs (its plan changed). It never approves anything.
|
|
func (s *Service) WithdrawApproval(ctx context.Context, mind, id ids.ID, reason string) error {
|
|
tag, err := s.DB.Exec(ctx, `UPDATE approval_requests SET status='withdrawn', decision_note=$3 WHERE id=$1 AND mind_id=$2 AND status='pending'`, id, mind, reason)
|
|
if err == nil && tag.RowsAffected() == 0 {
|
|
return storage.ErrNotFound
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (s *Service) queryExecutions(ctx context.Context, sql string, args ...any) ([]Execution, error) {
|
|
rows, err := s.DB.Query(ctx, sql, args...)
|
|
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, ¶ms, &result, &e.Error, &e.DurationMS, &appr, &ev, &e.CreatedAt, &e.Origin); 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), " ") }
|