From 9d4a9db44ebcfa90b0ae53a79268fa965eb22acb Mon Sep 17 00:00:00 2001 From: Julio M Cruz Date: Sun, 23 Aug 2026 16:11:32 -0400 Subject: [PATCH] Accept and record which agent acted `wallet` identifies the BOARD, not the caller. For an invited agent it is the human owner's wallet, so every agent action in the audit log was indistinguishable from the owner's own, and a leaked agent credential left no trace of which agent it impersonated. - verifyToken accepts an optional `agent` claim and returns it. Typed, so a non-string is rejected rather than silently dropped. - Tokens minted before the claim existed keep verifying: the checks are by type and the claim is optional. Covered by test. - The audit record carries `agent` (null when absent), so tool calls are attributable to the credential that made them. Pairs with PerkOS-API #176, which stamps the claim. --- src/audit.ts | 4 +++ src/auth/jwt.ts | 15 ++++++++++ tests/agentClaim.test.ts | 59 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 tests/agentClaim.test.ts diff --git a/src/audit.ts b/src/audit.ts index 05c3472..9801781 100644 --- a/src/audit.ts +++ b/src/audit.ts @@ -75,6 +75,10 @@ export function audit( tsMs: ts, requestId: claims.requestId, wallet: claims.wallet, + // Which agent credential was used. Without it every agent action in the + // log is indistinguishable from the owner's own, so a leaked credential + // leaves no trace of WHICH agent it impersonated. + agent: claims.agent ?? null, convId: claims.convId, role: claims.role, tool: record.tool, diff --git a/src/auth/jwt.ts b/src/auth/jwt.ts index bd21255..50e7d7f 100644 --- a/src/auth/jwt.ts +++ b/src/auth/jwt.ts @@ -20,6 +20,16 @@ import { config } from "../config.js"; export type TokenClaims = { wallet: string; + /** + * WHO acted, when the caller is an agent rather than a person. + * + * `wallet` is the BOARD's owner: for an invited agent it is the human's + * wallet, so it cannot tell the agent's actions apart from the human's, and + * a leaked agent credential reads as the owner. Optional because tokens + * minted before this claim existed must keep verifying — the checks below + * are by type and ignore extras. + */ + agent?: string; convId: string; role: "user" | "admin"; requestId: string; @@ -109,6 +119,7 @@ export function verifyToken(token: string): VerifyResult { typeof payload.role !== "string" || !["user", "admin"].includes(payload.role) || typeof payload.requestId !== "string" || + (payload.agent !== undefined && typeof payload.agent !== "string") || typeof payload.iat !== "number" || typeof payload.exp !== "number" ) { @@ -133,6 +144,10 @@ export function verifyToken(token: string): VerifyResult { convId: payload.convId, role: payload.role as "user" | "admin", requestId: payload.requestId, + // Absent on tokens minted before the claim existed. + ...(typeof payload.agent === "string" && payload.agent + ? { agent: payload.agent } + : {}), iat: payload.iat, exp: payload.exp, }, diff --git a/tests/agentClaim.test.ts b/tests/agentClaim.test.ts new file mode 100644 index 0000000..d60d05d --- /dev/null +++ b/tests/agentClaim.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; + +// Set required env BEFORE importing the modules so config validation passes. +process.env.FIREBASE_PROJECT_ID = "test-project"; +process.env.FIREBASE_CLIENT_EMAIL = "test@example.com"; +process.env.FIREBASE_PRIVATE_KEY = "-----BEGIN PRIVATE KEY-----\\nfake\\n-----END PRIVATE KEY-----"; +process.env.JWT_SHARED_SECRET = "a".repeat(40); + +const { signToken, verifyToken } = await import("../src/auth/jwt.js"); + +const WALLET = "0xc2564e41b7f5cb66d2d99466450cfebce9e8228f"; + +/** + * `wallet` identifies the BOARD, not the caller. For an invited agent it is the + * human owner's wallet, so without a separate claim every agent action is + * indistinguishable from the owner's own — and a leaked agent credential + * leaves no trace of which agent it impersonated. + */ +describe("agent claim", () => { + it("round-trips the acting agent", () => { + const token = signToken({ + wallet: WALLET, + convId: "agent:Athena", + role: "user", + requestId: "req-1", + agent: "Athena", + }); + const result = verifyToken(token); + expect(result.ok).toBe(true); + if (result.ok) expect(result.claims.agent).toBe("Athena"); + }); + + it("still accepts tokens minted before the claim existed", () => { + const token = signToken({ + wallet: WALLET, + convId: `assistant-${WALLET}`, + role: "user", + requestId: "req-2", + }); + const result = verifyToken(token); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.claims.agent).toBeUndefined(); + expect(result.claims.wallet).toBe(WALLET); + } + }); + + it("rejects a non-string agent instead of silently dropping it", () => { + const token = signToken({ + wallet: WALLET, + convId: "agent:Athena", + role: "user", + requestId: "req-3", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + agent: 42 as any, + }); + expect(verifyToken(token).ok).toBe(false); + }); +});