← back to PoppyPetitions

lib/agentGuard.ts

129 lines

// 2026-05-30 (P1-E): agent_id session-binding via validate-and-audit (DTD verdict B).
//
// Three write routes (petitions POST, vote POST, comment POST) currently accept
// `agent_id` from the request body and then `SELECT id, name, codename FROM
// poppy.agents WHERE id = $1` to verify the agent exists. Two gaps:
//
//   1. The check doesn't look at `is_active`, so a write can succeed against a
//      soft-deleted / paused / archived agent — the row exists but the system's
//      semantic state says "do not act as this agent right now".
//
//   2. There is no audit trail binding the authenticated session (the human admin
//      behind poppy-auth) to the agent_id they claimed in the body. If the cookie
//      ever leaks, or two-admin support is added later, there is no forensic
//      record of "session X claimed to be agent Y at time Z".
//
// This module fixes both:
//
//   - `resolveActingAgent(agent_id, session_user)` performs the existence +
//     is_active check in one place with a consistent error response shape.
//   - `buildAuditMetadata(session_user, extra?)` produces the JSONB blob to
//     attach to every poppy.compute_usage write, so the session ↔ agent ↔ action
//     triple is always recoverable from the audit table.
//
// Implementation choices:
//   - No schema migration. session_user goes into the existing `metadata` JSONB
//     column on compute_usage rather than a new column. Forensics queries can
//     `SELECT metadata->>'acting_session', agent_id, ts FROM poppy.compute_usage`.
//   - Error shape mirrors what the routes already return (`{ error: '...' }`).
//   - Distinct status codes: 404 for not-found, 409 for inactive (the row
//     exists, the semantic state forbids the action — Conflict is the right
//     family per RFC 9110 §15.5.10).

import { query } from '@/lib/db';

export type ActingAgent = {
  id: string;
  name: string;
  codename: string | null;
};

export type AgentGuardOk = {
  ok: true;
  agent: ActingAgent;
};

export type AgentGuardErr = {
  ok: false;
  status: 404 | 409 | 500;
  error: string;
};

export type AgentGuardResult = AgentGuardOk | AgentGuardErr;

/**
 * Validate that `agent_id` refers to an existing, active agent. The caller
 * must already have verified the request session (verifyAuth) and length-
 * capped `agent_id` (validate.firstLengthViolation) BEFORE calling this.
 *
 * Returns either the resolved agent shape (for downstream use in prompts /
 * inserts) or a Result-shaped error the route can return verbatim:
 *
 *   const guard = await resolveActingAgent(body.agent_id);
 *   if (!guard.ok) {
 *     return NextResponse.json({ error: guard.error }, { status: guard.status });
 *   }
 *   const agent = guard.agent;
 */
export async function resolveActingAgent(agent_id: string): Promise<AgentGuardResult> {
  try {
    const r = await query(
      'SELECT id, name, codename, is_active FROM poppy.agents WHERE id = $1',
      [agent_id],
    );
    if (r.rowCount === 0) {
      return { ok: false, status: 404, error: 'Agent not found' };
    }
    const row = r.rows[0];
    if (row.is_active === false) {
      return { ok: false, status: 409, error: 'Agent is not active' };
    }
    return {
      ok: true,
      agent: { id: row.id, name: row.name, codename: row.codename },
    };
  } catch (e) {
    console.error('[agentGuard] lookup failed:', (e as Error).message);
    return { ok: false, status: 500, error: 'Agent lookup failed' };
  }
}

/**
 * Build the metadata JSONB to attach to every poppy.compute_usage INSERT so
 * the session-id ↔ agent-id ↔ action triple is forensically recoverable.
 * Pass the authenticated session user (the string returned by verifyAuth) and
 * optionally an `extra` object whose keys get merged in.
 *
 * The shape is intentionally small and forward-compatible — additional fields
 * can be added without breaking existing rows because `metadata` is JSONB.
 */
export function buildAuditMetadata(
  session_user: string,
  extra?: Record<string, unknown>,
): Record<string, unknown> {
  return {
    acting_session: session_user,
    ts: new Date().toISOString(),
    ...(extra ?? {}),
  };
}

/**
 * 2026-05-30 (P1-F): coerce a possibly-NaN / possibly-undefined numeric to a
 * finite number before it crosses into a poppy.compute_usage / poppy.agents
 * INSERT. Gemini's usageMetadata fields use `?? 0` everywhere upstream, which
 * catches null + undefined but NOT NaN — a malformed Gemini response can
 * smuggle NaN through, and pg's int / bigint columns throw on NaN while
 * double precision accepts it silently. Both failure modes corrupt the
 * compute_usage audit table. This guard fixes both at the boundary.
 *
 * Usage:
 *   safeNumber(geminiResult.inputTokens)        // → 0 on NaN/undefined/null
 *   safeNumber(cost, 0)                         // → 0 fallback
 *   safeNumber(geminiResult.totalTokens)        // → 0 on NaN
 */
export function safeNumber(value: unknown, fallback = 0): number {
  if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
  return value;
}