← 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;
}