internal/httpapi/phase3.go (2627 lines, 12+ resource domains) split into 15 per-resource files: - actuator.go: SSH execution machinery (initSSH, sshExec, resolveRunTarget, executeApprovedAction, jsonErr, gatewayPreflightPassed, resolveTemplate) - checks.go, classifications.go, executions.go, approvals.go, patterns.go, skills.go, approval_rules.go, autonomy.go, risk_classes.go, relationships.go, entity_types.go, metrics.go, agent_activity.go, helpers.go — one file per resource domain, each with its own imports. internal/mcp/server.go: newServer (708 lines, 33 inline tool registrations) refactored to a registry pattern: - internal/mcp/tools.go (new): toolReg struct + allTools() returning all 33 tool definitions. Handler logic moved verbatim — no changes to tool names, descriptions, schemas, or behavior. - server.go: newServer is now 9 lines (iterate registry, AddTool each). -699 lines. No function logic, names, or signatures changed. go vet, build, and all tests pass (httpapi, mcp, db, policy).
260 lines
8.7 KiB
Go
260 lines
8.7 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", "",
|
|
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 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
|
|
}
|