Stage 1 — Foundation:
- Target validation: iptables + systemctl/docker target-type gates
- Async run for long-running commands (sleep/wait/poll loops)
- audit_log.session_id plumbing (SQL, sqlcgen, 18 call sites)
Stage 2 — External agent observe (11 tools):
- get_dashboard_summary, get_ontology, list_checks, list_executions
- get_knowledge_revisions, get_knowledge_duplicates, get_knowledge_orphans
- list_knowledge_tags, list_entity_sessions, find_entities_by
- 3 resource templates: oikos://entity/{slug}, knowledge/{id}, execution/{id}
Stage 3 — Nomos reliability:
- complete_task(success) refused without verification (upgraded from warn)
- sessionHasPlan excludes replaced steps (forces propose_plan after reopen)
- Bash syntax validation in run() (rejects literal \n, flag-space typos)
- Scope gate in SOUL.md (ask before pivoting to unrelated subsystem)
Stage 4 — External agent act (9 mutation tools):
- ack_signal, resolve_signal, mute_signal, cancel_execution
- update_check, delete_knowledge, restore_knowledge
- merge_knowledge, rename_knowledge_tag
288 lines
10 KiB
Go
288 lines
10 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
|
"github.com/dtoro/oikos/internal/domain"
|
|
"github.com/dtoro/oikos/internal/httpapi/gen"
|
|
"github.com/dtoro/oikos/internal/observability"
|
|
"github.com/dtoro/oikos/internal/safego"
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
// ─── Approvals ─────────────────────────────────────────────────────────
|
|
|
|
func (s *Server) ListApprovals(ctx context.Context, req gen.ListApprovalsRequestObject) (gen.ListApprovalsResponseObject, error) {
|
|
limit := clampLimit(req.Params.Limit)
|
|
var status *string
|
|
if req.Params.Status != nil {
|
|
s := string(*req.Params.Status)
|
|
status = &s
|
|
}
|
|
var kind *string
|
|
if req.Params.Kind != nil {
|
|
k := string(*req.Params.Kind)
|
|
kind = &k
|
|
}
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT a.entity_id, a.action, a.risk_class, a.kind, a.payload,
|
|
a.status, a.expires_at, a.decided_at, a.decided_by::text,
|
|
a.created_at, e.slug
|
|
FROM approvals a
|
|
JOIN entities e ON e.id = COALESCE(a.subject_entity_id, a.entity_id)
|
|
WHERE ($1::text IS NULL OR a.status = $1)
|
|
AND ($2::text IS NULL OR a.kind = $2)
|
|
AND ($3::text IS NULL OR e.slug > $3)
|
|
ORDER BY e.slug
|
|
LIMIT $4`,
|
|
status, kind, req.Params.Cursor, limit+1)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := []gen.Approval{}
|
|
for rows.Next() {
|
|
var a gen.Approval
|
|
var payloadBytes []byte
|
|
var decidedBy *string
|
|
if err := rows.Scan(&a.Id, &a.Action, &a.RiskClass, &a.Kind, &payloadBytes,
|
|
&a.Status, &a.ExpiresAt, &a.DecidedAt, &decidedBy,
|
|
&a.CreatedAt, &a.Slug); err != nil {
|
|
return nil, err
|
|
}
|
|
a.DecidedBy = decidedBy
|
|
var payload map[string]any
|
|
if len(payloadBytes) > 0 && json.Unmarshal(payloadBytes, &payload) == nil {
|
|
a.Payload = &payload
|
|
}
|
|
items = append(items, a)
|
|
}
|
|
if rows.Err() != nil {
|
|
return nil, rows.Err()
|
|
}
|
|
|
|
var next *string
|
|
if len(items) > limit {
|
|
items = items[:limit]
|
|
next = &items[len(items)-1].Slug
|
|
}
|
|
if items == nil {
|
|
items = []gen.Approval{}
|
|
}
|
|
return gen.ListApprovals200JSONResponse{Items: items, NextCursor: next}, nil
|
|
}
|
|
|
|
func (s *Server) DecideApproval(ctx context.Context, req gen.DecideApprovalRequestObject) (gen.DecideApprovalResponseObject, error) {
|
|
if req.Body == nil {
|
|
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
|
|
}
|
|
|
|
id, err := s.resolveEntityID(ctx, req.Id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
actorType, actor := actorInfo(ctx)
|
|
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
q := sqlcgen.New(tx)
|
|
|
|
// Verify HMAC token if provided (single-use, S5).
|
|
if req.Body.Token != nil && *req.Body.Token != "" {
|
|
var tokenHash *string
|
|
var apprStatus string
|
|
var expiresAt time.Time
|
|
err := tx.QueryRow(ctx,
|
|
"SELECT token_hash, status, expires_at FROM approvals WHERE entity_id = $1",
|
|
id).Scan(&tokenHash, &apprStatus, &expiresAt)
|
|
if err != nil || tokenHash == nil {
|
|
return nil, fmt.Errorf("%w: approval not found", domain.ErrNotFound)
|
|
}
|
|
if apprStatus != "pending" {
|
|
return nil, fmt.Errorf("%w: approval already decided", domain.ErrInvalidTransition)
|
|
}
|
|
if expiresAt.Before(time.Now()) {
|
|
return nil, fmt.Errorf("%w: approval token expired", domain.ErrInvalidTransition)
|
|
}
|
|
if *tokenHash != hashToken(*req.Body.Token) {
|
|
return nil, fmt.Errorf("%w: invalid approval token", domain.ErrInvalidInput)
|
|
}
|
|
}
|
|
|
|
// Map decision to status.
|
|
var status string
|
|
switch req.Body.Decision {
|
|
case gen.Approve:
|
|
status = "approved"
|
|
case gen.Deny:
|
|
status = "denied"
|
|
case gen.Revoke:
|
|
status = "revoked"
|
|
default:
|
|
return nil, fmt.Errorf("%w: invalid decision %q", domain.ErrInvalidInput, req.Body.Decision)
|
|
}
|
|
|
|
if err := q.UpdateApprovalStatus(ctx, sqlcgen.UpdateApprovalStatusParams{
|
|
EntityID: id,
|
|
Status: status,
|
|
}); err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
return nil, fmt.Errorf("%w: approval %s not found or already decided", domain.ErrNotFound, req.Id)
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
// Re-read approval.
|
|
app, err := q.GetApprovalByID(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
approval := approvalToGen(app)
|
|
|
|
if auditErr := observability.Audit(ctx, q, actorType, actor, "decide",
|
|
&id, "POST", "/api/v1/approvals/"+req.Id+"/decision", "",
|
|
nil,
|
|
map[string]any{"decision": status}); auditErr != nil {
|
|
return nil, auditErr
|
|
}
|
|
|
|
// Emit for SSE fan-out (in-tx; NOTIFY fires post-commit).
|
|
if evErr := observability.Event(ctx, q, "approval.decided", &id, "info", "api", "",
|
|
map[string]any{"decision": status, "actor": actor}); evErr != nil {
|
|
return nil, evErr
|
|
}
|
|
|
|
// On approve: execute the linked gated command.
|
|
if status == "approved" {
|
|
var execID, targetID uuid.UUID
|
|
var actionStr, targetSlug, riskClass string
|
|
err := tx.QueryRow(ctx, `
|
|
SELECT e.entity_id, e.target_entity_id, e.action, e.risk_class
|
|
FROM executions e
|
|
WHERE e.approval_id = $1 AND e.status = 'pending_approval'
|
|
LIMIT 1`, id).Scan(&execID, &targetID, &actionStr, &riskClass)
|
|
if err == nil {
|
|
// Resolve target entity slug from targetID.
|
|
_ = tx.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", targetID).Scan(&targetSlug)
|
|
|
|
safego.Go("httpapi:executeApprovedAction", func() {
|
|
executeApprovedAction(context.Background(), s.pool, execID, targetSlug, actionStr)
|
|
})
|
|
// Status only — risk_class was set correctly at request time
|
|
// (e.g. by policy.ClassifyCommand for `run`); overwriting it to
|
|
// a hardcoded 'config_mutation' here corrupted the audit ledger
|
|
// for every other risk class, including destructive.
|
|
_, _ = tx.Exec(ctx, `UPDATE executions SET status = 'approved' WHERE entity_id = $1`, execID)
|
|
|
|
// Approving a plan step — by ANY route (this endpoint backs both
|
|
// the chat Approve button and chat-assent) — opens/extends the
|
|
// agent's assent window. This is the scope gate the Nomos
|
|
// auto-continuation worker checks: with the window open, the
|
|
// finished execution's result is fed back to the agent so it runs
|
|
// the plan to completion. Without opening it here, approving via
|
|
// the button (instead of typing "go ahead") would silently not
|
|
// auto-continue.
|
|
var agentID *uuid.UUID
|
|
if qerr := tx.QueryRow(ctx, "SELECT agent_id FROM executions WHERE entity_id = $1", execID).Scan(&agentID); qerr == nil && agentID != nil {
|
|
expires := time.Now().Add(30 * time.Minute).UTC().Format(time.RFC3339)
|
|
_, _ = tx.Exec(ctx, `INSERT INTO autonomy_settings (key, value) VALUES ($1, $2)
|
|
ON CONFLICT (key) DO UPDATE SET value = $2`, "assent_window.agent:"+agentID.String(), expires)
|
|
|
|
// Approving a DESTRUCTIVE step via the button is exactly as
|
|
// explicit as a typed "I confirm" — the operator affirmatively
|
|
// clicked Approve on a card that said DESTRUCTIVE. Open the
|
|
// same short, target-scoped destructive window chat-assent's
|
|
// typed-confirm path opens, for parity: a multi-step
|
|
// destructive recovery (stop, then destroy) shouldn't need a
|
|
// fresh confirmation per click any more than it needs one per
|
|
// typed phrase.
|
|
if riskClass == "destructive" && targetSlug != "" {
|
|
dExpires := time.Now().Add(15 * time.Minute).UTC().Format(time.RFC3339)
|
|
_, _ = tx.Exec(ctx, `INSERT INTO autonomy_settings (key, value) VALUES ($1, $2)
|
|
ON CONFLICT (key) DO UPDATE SET value = $2`,
|
|
"destructive_window.agent:"+agentID.String()+".target:"+targetSlug, dExpires)
|
|
}
|
|
}
|
|
|
|
slog.Info("httpapi: approved execution queued",
|
|
"execution_id", execID, "target", targetSlug, "action", actionStr)
|
|
} else {
|
|
slog.Warn("httpapi: no pending execution found for approval", "approval_id", id, "error", err)
|
|
}
|
|
} else {
|
|
// Denied/revoked: reflect it on the linked execution too. Previously
|
|
// only the approvals row changed, so the execution stayed
|
|
// 'pending_approval' forever — any UI/poller reading execution
|
|
// status (not approval status) never saw the decision.
|
|
_, _ = tx.Exec(ctx, `UPDATE executions SET status = $2, completed_at = now() WHERE approval_id = $1 AND status = 'pending_approval'`, id, status)
|
|
}
|
|
|
|
// If this execution belongs to a nomos session, flip it out of
|
|
// awaiting_input — the counterpart to classifyAndGate flipping it IN
|
|
// the moment the approval was created (internal/mcp/server.go's
|
|
// markSessionAwaitingApproval). Runs for all three decisions (approve/
|
|
// deny/revoke): each one is an operator answer to "what do I do about
|
|
// this?", same as answerQuestion's unconditional resume-to-executing
|
|
// (cmd/nomos/store.go) for a session_questions answer.
|
|
var awaitingSessionID string
|
|
_ = tx.QueryRow(ctx, `
|
|
SELECT pe.session_id FROM nomos_plan_executions pe
|
|
JOIN executions ex ON ex.entity_id = pe.execution_id
|
|
WHERE ex.approval_id = $1
|
|
LIMIT 1`, id).Scan(&awaitingSessionID)
|
|
if awaitingSessionID != "" {
|
|
if rtag, rerr := tx.Exec(ctx, `
|
|
UPDATE agent_sessions SET status = 'executing', last_active_at = now()
|
|
WHERE id = $1 AND status = 'awaiting_input'`, awaitingSessionID); rerr == nil && rtag.RowsAffected() > 0 {
|
|
var taskEntID *uuid.UUID
|
|
var e uuid.UUID
|
|
if qerr := tx.QueryRow(ctx, `SELECT entity_id FROM agent_sessions WHERE id = $1`, awaitingSessionID).Scan(&e); qerr == nil && e != uuid.Nil {
|
|
taskEntID = &e
|
|
}
|
|
_ = observability.Event(ctx, q, "task.status", taskEntID, "info", "api", awaitingSessionID,
|
|
map[string]any{"status": "executing", "reason": "approval_decided", "decision": status})
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return gen.DecideApproval200JSONResponse(approval), nil
|
|
}
|
|
|
|
func approvalToGen(a sqlcgen.Approval) gen.Approval {
|
|
app := gen.Approval{
|
|
Id: a.EntityID,
|
|
Action: a.Action,
|
|
RiskClass: a.RiskClass,
|
|
Kind: gen.ApprovalKind(a.Kind),
|
|
Status: gen.ApprovalStatus(a.Status),
|
|
ExpiresAt: a.ExpiresAt,
|
|
DecidedAt: a.DecidedAt,
|
|
CreatedAt: a.CreatedAt,
|
|
}
|
|
if a.DecidedBy != nil {
|
|
s := a.DecidedBy.String()
|
|
app.DecidedBy = &s
|
|
}
|
|
var payload map[string]any
|
|
if len(a.Payload) > 0 && json.Unmarshal(a.Payload, &payload) == nil && len(payload) > 0 {
|
|
app.Payload = &payload
|
|
}
|
|
return app
|
|
}
|