diff --git a/examples/langchain-agent.ts b/examples/langchain-agent.ts index 1797a16..5f4b9a2 100644 --- a/examples/langchain-agent.ts +++ b/examples/langchain-agent.ts @@ -21,6 +21,6 @@ const cl = new CommandLayer({ const input = { prompt: "Summarize how receipts prove agent actions." }; -const result = await cl.wrap("chain.invoke", async () => chain.invoke(input)); +const result = await cl.wrap("attest", async () => chain.invoke(input)); -console.log(JSON.stringify(result.receipt, null, 2)); +process.stdout.write(JSON.stringify(result.receipt, null, 2) + "\n"); diff --git a/src/index.ts b/src/index.ts index adc1cd9..f76516c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -52,11 +52,28 @@ const TRUST_VERBS = [ export type TrustVerb = (typeof TRUST_VERBS)[number]; +const TRUST_VERB_SET: ReadonlySet = new Set(TRUST_VERBS); + export function normalizeTrustVerb(verb: string): string { const prefix = `clas.${TRUST_FAMILY}.`; return verb.startsWith(prefix) ? verb.slice(prefix.length) : verb; } +/** + * Normalize and validate a trust verb. Throws if the resulting short verb is + * not a member of the schema-defined TRUST_VERBS enum. + */ +function resolveAndValidateTrustVerb(verb: string): string { + const normalized = normalizeTrustVerb(verb); + if (!TRUST_VERB_SET.has(normalized)) { + throw new Error( + `Invalid trust verb "${verb}". Must be one of: ${TRUST_VERBS.join(", ")}. ` + + `Fully-qualified names like "clas.trust-verification.verify" are also accepted.`, + ); + } + return normalized; +} + export class CommandLayer { private readonly config: { signer: string; @@ -124,7 +141,9 @@ export class CommandLayer { typeof fnOrOptions === "function" ? {} : fnOrOptions.input ?? {}; const startedMs = Date.now(); - const normalizedVerb = normalizeTrustVerb(verb); + // Validate verb before running the wrapped function so callers get a + // synchronous, descriptive error rather than a non-schema-valid receipt. + const normalizedVerb = resolveAndValidateTrustVerb(verb); const startedAt = new Date().toISOString(); try { diff --git a/test/receipt.test.ts b/test/receipt.test.ts index 187ee83..76b90ec 100644 --- a/test/receipt.test.ts +++ b/test/receipt.test.ts @@ -4,6 +4,7 @@ import { webcrypto } from "node:crypto"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { CommandLayer } from "../src/index.js"; +import { validateTrustReceipt } from "../src/index.js"; import { canonicalize } from "../src/canonicalize.js"; import { canonicalPayloadFromReceiptInput } from "../src/receipt.js"; @@ -47,6 +48,35 @@ test("wrapping an action creates a receipt with required fields", async () => { assert.ok(result.receipt.execution.completed_at); }); +test("wrap produces a schema-valid receipt (round-trip)", async () => { + const cl = new CommandLayer({ + signer: "verifyagent.eth", + keyId: "vC4WbcNoq2znSCiQ", + privateKeyPem: await generatePrivateKeyPem(), + }); + + const result = await cl.wrap("verify", { + input: { content: "hello world" }, + run: async () => ({ approved: true }), + }); + + const validation = validateTrustReceipt(result.receipt); + assert.equal(validation.ok, true, `Receipt failed schema validation: ${validation.errors.join("; ")}`); +}); + +test("wrap rejects an unrecognized verb before running the wrapped function", async () => { + const cl = new CommandLayer({ + signer: "verifyagent.eth", + keyId: "vC4WbcNoq2znSCiQ", + privateKeyPem: await generatePrivateKeyPem(), + }); + + await assert.rejects( + () => cl.wrap("summarize", async () => "should not run"), + /Invalid trust verb/, + ); +}); + test("signature is verifiable over raw canonical payload bytes", async () => { const { pem, publicKey } = await generateKeyPair(); @@ -56,7 +86,7 @@ test("signature is verifiable over raw canonical payload bytes", async () => { privateKeyPem: pem, }); - const { receipt } = await cl.wrap("summarize", { + const { receipt } = await cl.wrap("authenticate", { input: { x: 1 }, run: async () => ({ y: 2 }), }); @@ -92,7 +122,7 @@ test("wrap returns signed error receipt when wrapped agent throws", async () => privateKeyPem: await generatePrivateKeyPem(), }); - const result = await cl.wrap("summarize", { + const result = await cl.wrap("authenticate", { input: { content: "hello" }, run: async () => { throw new Error("simulated failure"); @@ -105,6 +135,24 @@ test("wrap returns signed error receipt when wrapped agent throws", async () => assert.equal(result.receipt.proof.alg, "ed25519"); }); +test("error receipt is also schema-valid", async () => { + const cl = new CommandLayer({ + signer: "verifyagent.eth", + keyId: "vC4WbcNoq2znSCiQ", + privateKeyPem: await generatePrivateKeyPem(), + }); + + const result = await cl.wrap("authenticate", { + input: { content: "hello" }, + run: async () => { + throw new Error("simulated failure"); + }, + }); + + const validation = validateTrustReceipt(result.receipt); + assert.equal(validation.ok, true, `Error receipt failed schema validation: ${validation.errors.join("; ")}`); +}); + test("fully-qualified trust capability verb normalizes to short verb", async () => { const cl = new CommandLayer({ signer: "verifyagent.eth", @@ -144,7 +192,7 @@ test("verification helper posts to verifierUrl", async () => { verifierUrl, }); - const { receipt } = await cl.wrap("summarize", { + const { receipt } = await cl.wrap("verify", { input: { content: "hello" }, run: async () => "hello", });