Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions src/auth/jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"
) {
Expand All @@ -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,
},
Expand Down
59 changes: 59 additions & 0 deletions tests/agentClaim.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading