[object Object]

← 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

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 →