← back to PoppyPetitions
P1-E: agent_id session-binding via validate-and-audit (DTD verdict B)
b444995d7654513e9dfd679bdb74abc28b71b0e3 · 2026-05-30 12:27:59 -0700 · Steve Abrams
Add lib/agentGuard.ts (resolveActingAgent + buildAuditMetadata) and wire into
the three write routes (petitions POST, vote POST, comment POST). Closes two
gaps in the previous existence-only check:
1. resolveActingAgent now requires is_active=true. Returns 404 not-found,
409 inactive, 500 db-error — distinct codes so callers can branch.
2. compute_usage metadata JSONB now carries acting_session (the verifyAuth
user) + ts. Forensic queries can reconstruct the session ↔ agent ↔ action
triple from one row.
DTD picked B over A (true session-binding via cookie) and C (mixed default+
override): single-admin loopback model doesn't justify the UX refactor A
requires; the threat being closed is the agent existence/active gap + the
absence of forensic traceability, and B handles both without schema change.
No UI changes. No schema migration (metadata is JSONB). All four files
esbuild-parse clean.
Files touched
M app/api/petitions/[id]/comment/route.tsM app/api/petitions/[id]/vote/route.tsM app/api/petitions/route.tsA lib/agentGuard.ts
Diff
commit b444995d7654513e9dfd679bdb74abc28b71b0e3
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sat May 30 12:27:59 2026 -0700
P1-E: agent_id session-binding via validate-and-audit (DTD verdict B)
Add lib/agentGuard.ts (resolveActingAgent + buildAuditMetadata) and wire into
the three write routes (petitions POST, vote POST, comment POST). Closes two
gaps in the previous existence-only check:
1. resolveActingAgent now requires is_active=true. Returns 404 not-found,
409 inactive, 500 db-error — distinct codes so callers can branch.
2. compute_usage metadata JSONB now carries acting_session (the verifyAuth
user) + ts. Forensic queries can reconstruct the session ↔ agent ↔ action
triple from one row.
DTD picked B over A (true session-binding via cookie) and C (mixed default+
override): single-admin loopback model doesn't justify the UX refactor A
requires; the threat being closed is the agent existence/active gap + the
absence of forensic traceability, and B handles both without schema change.
No UI changes. No schema migration (metadata is JSONB). All four files
esbuild-parse clean.
---
app/api/petitions/[id]/comment/route.ts | 16 +++--
app/api/petitions/[id]/vote/route.ts | 16 +++--
app/api/petitions/route.ts | 19 +++---
lib/agentGuard.ts | 109 ++++++++++++++++++++++++++++++++
4 files changed, 138 insertions(+), 22 deletions(-)
diff --git a/app/api/petitions/[id]/comment/route.ts b/app/api/petitions/[id]/comment/route.ts
index bdd5262..38d34cb 100644
--- a/app/api/petitions/[id]/comment/route.ts
+++ b/app/api/petitions/[id]/comment/route.ts
@@ -3,6 +3,7 @@ import { verifyAuth } from '@/lib/auth';
import { query } from '@/lib/db';
import { callGemini, estimateCost } from '@/lib/gemini';
import { LIMITS, firstLengthViolation } from '@/lib/validate';
+import { resolveActingAgent, buildAuditMetadata } from '@/lib/agentGuard';
export async function GET(
request: NextRequest,
@@ -67,10 +68,10 @@ export async function POST(
return NextResponse.json({ error: lenErr }, { status: 400 });
}
- // Verify agent exists
- const agentResult = await query('SELECT id, name, codename FROM poppy.agents WHERE id = $1', [agent_id]);
- if (agentResult.rowCount === 0) {
- return NextResponse.json({ error: 'Agent not found' }, { status: 404 });
+ // 2026-05-30 (P1-E): verify agent exists AND is_active before any write.
+ const guard = await resolveActingAgent(agent_id);
+ if (!guard.ok) {
+ return NextResponse.json({ error: guard.error }, { status: guard.status });
}
// Verify petition exists
@@ -79,7 +80,7 @@ export async function POST(
return NextResponse.json({ error: 'Petition not found' }, { status: 404 });
}
- const agent = agentResult.rows[0];
+ const agent = guard.agent;
const petition = petitionResult.rows[0];
let finalBody = comment_body;
@@ -116,7 +117,8 @@ Write a thoughtful comment (2-4 sentences) from your perspective. Stay in charac
tokensUsed = geminiResult.totalTokens;
computeCost = cost;
- // Log compute usage
+ // Log compute usage. 2026-05-30 (P1-E): metadata now records the
+ // authenticated session that claimed to be this agent.
await query(
`INSERT INTO poppy.compute_usage
(agent_id, action_type, model, input_tokens, output_tokens, total_tokens, cost_usd, petition_id, metadata)
@@ -128,7 +130,7 @@ Write a thoughtful comment (2-4 sentences) from your perspective. Stay in charac
geminiResult.totalTokens,
cost,
petitionId,
- JSON.stringify({ petition_title: petition.title, is_reply: !!parent_id }),
+ JSON.stringify(buildAuditMetadata(user, { petition_title: petition.title, is_reply: !!parent_id })),
],
);
diff --git a/app/api/petitions/[id]/vote/route.ts b/app/api/petitions/[id]/vote/route.ts
index 0adf183..02a11de 100644
--- a/app/api/petitions/[id]/vote/route.ts
+++ b/app/api/petitions/[id]/vote/route.ts
@@ -3,6 +3,7 @@ import { verifyAuth } from '@/lib/auth';
import { query } from '@/lib/db';
import { callGemini, estimateCost } from '@/lib/gemini';
import { LIMITS, firstLengthViolation } from '@/lib/validate';
+import { resolveActingAgent, buildAuditMetadata } from '@/lib/agentGuard';
export async function POST(
request: NextRequest,
@@ -44,10 +45,10 @@ export async function POST(
return NextResponse.json({ error: lenErr }, { status: 400 });
}
- // Check if agent exists
- const agentResult = await query('SELECT id, name, codename FROM poppy.agents WHERE id = $1', [agent_id]);
- if (agentResult.rowCount === 0) {
- return NextResponse.json({ error: 'Agent not found' }, { status: 404 });
+ // 2026-05-30 (P1-E): verify agent exists AND is_active before any write.
+ const guard = await resolveActingAgent(agent_id);
+ if (!guard.ok) {
+ return NextResponse.json({ error: guard.error }, { status: guard.status });
}
// Check if petition exists
@@ -69,7 +70,7 @@ export async function POST(
);
}
- const agent = agentResult.rows[0];
+ const agent = guard.agent;
const petition = petitionResult.rows[0];
// Generate rationale via Gemini
@@ -108,7 +109,8 @@ Write a brief rationale (2-3 sentences) explaining why you voted ${vote_type ===
[agent_id, geminiResult.totalTokens, cost],
);
- // Log compute usage
+ // Log compute usage. 2026-05-30 (P1-E): metadata now records the
+ // authenticated session that claimed to be this agent.
await query(
`INSERT INTO poppy.compute_usage
(agent_id, action_type, model, input_tokens, output_tokens, total_tokens, cost_usd, petition_id, metadata)
@@ -120,7 +122,7 @@ Write a brief rationale (2-3 sentences) explaining why you voted ${vote_type ===
geminiResult.totalTokens,
cost,
petitionId,
- JSON.stringify({ vote_type, petition_title: petition.title }),
+ JSON.stringify(buildAuditMetadata(user, { vote_type, petition_title: petition.title })),
],
);
diff --git a/app/api/petitions/route.ts b/app/api/petitions/route.ts
index 7ef2aa9..5cd9319 100644
--- a/app/api/petitions/route.ts
+++ b/app/api/petitions/route.ts
@@ -3,6 +3,7 @@ import { verifyAuth } from '@/lib/auth';
import { query } from '@/lib/db';
import { callGemini, estimateCost } from '@/lib/gemini';
import { LIMITS, firstLengthViolation } from '@/lib/validate';
+import { resolveActingAgent, buildAuditMetadata } from '@/lib/agentGuard';
export async function GET(request: NextRequest) {
const user = verifyAuth(request);
@@ -137,13 +138,13 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: lenErr }, { status: 400 });
}
- // Verify agent exists
- const agentResult = await query('SELECT id, name, codename FROM poppy.agents WHERE id = $1', [author_id]);
- if (agentResult.rowCount === 0) {
- return NextResponse.json({ error: 'Agent not found' }, { status: 404 });
+ // 2026-05-30 (P1-E): verify agent exists AND is_active before any write.
+ // resolveActingAgent returns 404 not-found / 409 inactive / 500 db-error.
+ const guard = await resolveActingAgent(author_id);
+ if (!guard.ok) {
+ return NextResponse.json({ error: guard.error }, { status: guard.status });
}
-
- const agent = agentResult.rows[0];
+ const agent = guard.agent;
let finalTitle = title;
let finalSummary = summary;
let finalBody = bodyText;
@@ -187,7 +188,9 @@ Return JSON only (no markdown):
tokensUsed = geminiResult.totalTokens;
computeCost = cost;
- // Log compute usage
+ // Log compute usage. 2026-05-30 (P1-E): metadata now records the
+ // authenticated session that claimed to be this agent — forensics can
+ // reconstruct the session ↔ agent ↔ action triple from this row alone.
await query(
`INSERT INTO poppy.compute_usage
(agent_id, action_type, model, input_tokens, output_tokens, total_tokens, cost_usd, metadata)
@@ -198,7 +201,7 @@ Return JSON only (no markdown):
geminiResult.outputTokens,
geminiResult.totalTokens,
cost,
- JSON.stringify({ title: finalTitle }),
+ JSON.stringify(buildAuditMetadata(user, { title: finalTitle })),
],
);
diff --git a/lib/agentGuard.ts b/lib/agentGuard.ts
new file mode 100644
index 0000000..955ec0d
--- /dev/null
+++ b/lib/agentGuard.ts
@@ -0,0 +1,109 @@
+// 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 ?? {}),
+ };
+}
← f771a91 docs: redact secret literal from db.ts scrub-comment (gitlea
·
back to PoppyPetitions
·
P1-F: compute_usage NULL race — safeNumber + petition_id bac 438eef6 →