- ApprovalService (core/app/approval.go) + ApprovalRepo (postgres adapter) with
full decide transaction: HMAC token verify, approval flip, execution un-gate,
session-scoped window keys (+session suffix matching GovernanceStore gate),
nomos session flip, audit+event on failure abort. httpapi DecideApproval now
a thin presenter delegating to the service. ListPending payload format fixed
(json.Unmarshal not raw-wrap).
- execlog folded into postgres adapter: internal/execlog deleted, NewExecutionLog
/ ReadExecutionLog live in the db package, callers updated (mcp, httpapi).
- execworker poller over ExecutionService.DispatchQueued: advisory lock leak
fixed (defer/recover per execution), correlation_id preserved via Finalize
event emission (ExecRunRepo.Finalize now emits execution.{status} with
correlation_id from the row).
- Phase 8 session export-rename completed: Store, New, and all 53 methods
exported; cmd/nomos/ agent.go fixed to use session.PendingContinuation etc.
- Coverage gates: ExecutionService.Submit 93.1%, PolicyService.Decide 100%.
- Plans index updated, VERSION bumped to 0.36.0.
307 lines
10 KiB
Go
307 lines
10 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
|
|
"github.com/dtoro/oikos/internal/core/domain"
|
|
"github.com/dtoro/oikos/internal/core/ports"
|
|
"github.com/dtoro/oikos/internal/observability"
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
const (
|
|
assentWindowDuration = 30 * time.Minute
|
|
destructiveWindowDuration = 15 * time.Minute
|
|
)
|
|
|
|
// ApprovalRepo implements ports.ApprovalRepository over the postgres pool.
|
|
// Decide verifies the HMAC token (check-then-act), flips the approval,
|
|
// un-gates the linked execution, opens assent/destructive windows, flips
|
|
// the nomos session out of awaiting_input, and appends audit + event — one
|
|
// transaction (ADR 0016 §3.6).
|
|
type ApprovalRepo struct {
|
|
pool *Pool
|
|
}
|
|
|
|
var _ ports.ApprovalRepository = (*ApprovalRepo)(nil)
|
|
|
|
// NewApprovalRepo builds the approval repository.
|
|
func NewApprovalRepo(pool *Pool) *ApprovalRepo { return &ApprovalRepo{pool: pool} }
|
|
|
|
// ListPending returns pending approvals for the given entity (or all pending
|
|
// when entityID is empty), newest first.
|
|
func (r *ApprovalRepo) ListPending(ctx context.Context, entityID domain.UUID, limit int) ([]domain.Approval, error) {
|
|
if limit <= 0 {
|
|
limit = 50
|
|
}
|
|
if limit > 200 {
|
|
limit = 200
|
|
}
|
|
rows, err := r.pool.Query(ctx, `
|
|
SELECT a.entity_id, COALESCE(a.subject_entity_id::text, ''), a.action, a.risk_class, a.kind,
|
|
a.payload, a.status, COALESCE(a.token_hash, ''), a.expires_at, a.decided_at,
|
|
COALESCE(a.decided_by::text, ''), a.created_at
|
|
FROM approvals a
|
|
WHERE ($1::uuid IS NULL OR a.entity_id = $1)
|
|
AND a.status = 'pending'
|
|
ORDER BY a.created_at DESC
|
|
LIMIT $2`, uuidOrNil(entityID), limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []domain.Approval
|
|
for rows.Next() {
|
|
var a domain.Approval
|
|
var subjectStr, tokenHash, decidedBy string
|
|
var payload []byte
|
|
if err := rows.Scan(&a.EntityID, &subjectStr, &a.Action, &a.RiskClass, &a.Kind,
|
|
&payload, &a.Status, &tokenHash, &a.ExpiresAt, &a.DecidedAt,
|
|
&decidedBy, &a.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
a.TokenHash = tokenHash
|
|
if subjectStr != "" {
|
|
a.SubjectEntityID = domain.UUID(subjectStr)
|
|
}
|
|
if decidedBy != "" {
|
|
a.DecidedBy = domain.UUID(decidedBy)
|
|
}
|
|
if len(payload) > 0 {
|
|
var m map[string]any
|
|
if json.Unmarshal(payload, &m) == nil {
|
|
a.Payload = m
|
|
}
|
|
}
|
|
out = append(out, a)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// Decide runs the full decide transaction. Returns the updated approval and,
|
|
// on approve with a linked execution, the Resume info for the driving adapter
|
|
// to dispatch the SSH work after commit.
|
|
func (r *ApprovalRepo) Decide(ctx context.Context, in ports.ApprovalDecideInput) (ports.ApprovalDecideResult, error) {
|
|
id := mustUUID(in.ApprovalID)
|
|
|
|
tx, err := r.pool.Begin(ctx)
|
|
if err != nil {
|
|
return ports.ApprovalDecideResult{}, err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
q := sqlcgen.New(tx)
|
|
|
|
// Verify HMAC token if provided (single-use, S5).
|
|
if in.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 ports.ApprovalDecideResult{}, domain.ErrNotFound
|
|
}
|
|
if apprStatus != "pending" {
|
|
return ports.ApprovalDecideResult{}, domain.ErrInvalidTransition
|
|
}
|
|
if expiresAt.Before(time.Now()) {
|
|
return ports.ApprovalDecideResult{}, domain.ErrInvalidTransition
|
|
}
|
|
if *tokenHash != hashApprovalToken(in.Token) {
|
|
return ports.ApprovalDecideResult{}, domain.ErrInvalidInput
|
|
}
|
|
}
|
|
|
|
if err := q.UpdateApprovalStatus(ctx, sqlcgen.UpdateApprovalStatusParams{
|
|
EntityID: id,
|
|
Status: in.Status,
|
|
}); err != nil {
|
|
return ports.ApprovalDecideResult{}, domain.ErrNotFound
|
|
}
|
|
|
|
// Re-read approval.
|
|
app, err := q.GetApprovalByID(ctx, id)
|
|
if err != nil {
|
|
return ports.ApprovalDecideResult{}, err
|
|
}
|
|
approval := domain.Approval{
|
|
EntityID: domain.UUID(app.EntityID.String()),
|
|
Action: app.Action,
|
|
RiskClass: app.RiskClass,
|
|
Kind: app.Kind,
|
|
Status: app.Status,
|
|
TokenHash: stringPtrOr(app.TokenHash),
|
|
ExpiresAt: app.ExpiresAt,
|
|
DecidedAt: app.DecidedAt,
|
|
DecidedBy: uuidPtrOr(app.DecidedBy),
|
|
CreatedAt: app.CreatedAt,
|
|
}
|
|
if app.SubjectEntityID != nil {
|
|
approval.SubjectEntityID = domain.UUID(app.SubjectEntityID.String())
|
|
}
|
|
|
|
// Audit + event (in-tx; NOTIFY fires post-commit). An audit failure must
|
|
// abort the decision — leaving no trace of who decided would violate the
|
|
// append-only audit contract (ADR 0016 §3.6).
|
|
actorLabel := in.Actor
|
|
if actorLabel == "" {
|
|
actorLabel = "unknown"
|
|
}
|
|
if auditErr := observability.Audit(ctx, q, "api", actorLabel, "decide",
|
|
&id, "POST", "/api/v1/approvals/"+id.String()+"/decision", "",
|
|
nil,
|
|
map[string]any{"decision": in.Status}); auditErr != nil {
|
|
return ports.ApprovalDecideResult{}, auditErr
|
|
}
|
|
if evErr := observability.Event(ctx, q, "approval.decided", &id, "info", "api", "",
|
|
map[string]any{"decision": in.Status, "actor": actorLabel}); evErr != nil {
|
|
return ports.ApprovalDecideResult{}, evErr
|
|
}
|
|
|
|
result := ports.ApprovalDecideResult{Approval: approval}
|
|
|
|
// Approve: un-gate the linked execution and open windows.
|
|
if in.Status == "approved" {
|
|
resume, aerr := r.approveSideEffects(ctx, tx, id)
|
|
if aerr != nil {
|
|
return result, aerr
|
|
}
|
|
result.Resume = resume
|
|
} else {
|
|
// Denied/revoked: reflect on the linked execution.
|
|
_, _ = tx.Exec(ctx, `UPDATE executions SET status = $2, completed_at = now() WHERE approval_id = $1 AND status = 'pending_approval'`, id, in.Status)
|
|
}
|
|
|
|
// Flip any nomos session out of awaiting_input — the operator answered.
|
|
flipAwaitingSession(ctx, tx, id, in.Status)
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return result, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// approveSideEffects finds the linked pending execution, marks it approved,
|
|
// opens the assent window (+ destructive window for destructive risk), and
|
|
// returns the Resume info for the adapter to dispatch the SSH work.
|
|
func (r *ApprovalRepo) approveSideEffects(ctx context.Context, tx pgx.Tx, approvalID uuid.UUID) (*ports.ApprovalResume, error) {
|
|
var execID, targetID uuid.UUID
|
|
var actionStr, targetSlug, riskClass, sessionID string
|
|
err := tx.QueryRow(ctx, `
|
|
SELECT e.entity_id, e.target_entity_id, e.action, e.risk_class,
|
|
COALESCE((SELECT pe.session_id FROM nomos_plan_executions pe
|
|
WHERE pe.execution_id = e.entity_id LIMIT 1)::text, '')
|
|
FROM executions e
|
|
WHERE e.approval_id = $1 AND e.status = 'pending_approval'
|
|
LIMIT 1`, approvalID).Scan(&execID, &targetID, &actionStr, &riskClass, &sessionID)
|
|
if err != nil {
|
|
slog.Warn("postgres: no pending execution found for approval", "approval_id", approvalID, "error", err)
|
|
return nil, nil
|
|
}
|
|
|
|
_ = tx.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", targetID).Scan(&targetSlug)
|
|
|
|
// 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)
|
|
|
|
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 {
|
|
// Approving a plan step — by ANY route (the UI Approve button and
|
|
// chat-assent) — opens/extends the agent's assent window. The key is
|
|
// session-scoped so the window only auto-runs THIS task's plan steps
|
|
// (governance.go AssentWindowActive + session store's AssentWindowKey
|
|
// use the same "assent_window.agent:<id>.session:<sid>" format).
|
|
expires := time.Now().Add(assentWindowDuration).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()+".session:"+sessionID, expires)
|
|
|
|
// Approving a DESTRUCTIVE step via the button is exactly as explicit
|
|
// as a typed "I confirm" — open the same short, target+session-scoped
|
|
// destructive window chat-assent's typed-confirm path opens.
|
|
if riskClass == "destructive" && targetSlug != "" {
|
|
dExpires := time.Now().Add(destructiveWindowDuration).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+".session:"+sessionID, dExpires)
|
|
}
|
|
}
|
|
|
|
slog.Info("postgres: approved execution queued",
|
|
"execution_id", execID, "target", targetSlug, "action", actionStr)
|
|
return &ports.ApprovalResume{
|
|
ExecutionID: domain.UUID(execID.String()),
|
|
TargetSlug: targetSlug,
|
|
Action: actionStr,
|
|
}, nil
|
|
}
|
|
|
|
// flipAwaitingSession marks any nomos session linked to this approval as
|
|
// executing again (awaiting_input → executing), mirroring
|
|
// classifyAndGate's awaiting_input flip in reverse.
|
|
func flipAwaitingSession(ctx context.Context, tx pgx.Tx, approvalID uuid.UUID, status string) {
|
|
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`, approvalID).Scan(&awaitingSessionID)
|
|
if awaitingSessionID == "" {
|
|
return
|
|
}
|
|
rtag, rerr := tx.Exec(ctx, `
|
|
UPDATE agent_sessions SET status = 'executing', last_active_at = now()
|
|
WHERE id = $1 AND status = 'awaiting_input'`, awaitingSessionID)
|
|
if rerr != nil || rtag.RowsAffected() == 0 {
|
|
return
|
|
}
|
|
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, sqlcgen.New(tx), "task.status", taskEntID, "info", "api", awaitingSessionID,
|
|
map[string]any{"status": "executing", "reason": "approval_decided", "decision": status})
|
|
}
|
|
|
|
// hashApprovalToken computes a SHA-256 hex-encoded hash of the token, used
|
|
// to verify single-use approval tokens (S5) without storing the plaintext.
|
|
func hashApprovalToken(token string) string {
|
|
h := sha256.Sum256([]byte(token))
|
|
return hex.EncodeToString(h[:])
|
|
}
|
|
|
|
func uuidOrNil(id domain.UUID) any {
|
|
if id == "" {
|
|
return nil
|
|
}
|
|
return mustUUID(id)
|
|
}
|
|
|
|
func stringPtrOr(s *string) string {
|
|
if s == nil {
|
|
return ""
|
|
}
|
|
return *s
|
|
}
|
|
|
|
func uuidPtrOr(u *uuid.UUID) domain.UUID {
|
|
if u == nil {
|
|
return ""
|
|
}
|
|
return domain.UUID(u.String())
|
|
}
|