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); + }); +});