From b34e4c5efc406ea12f3966b89e868a18370ff9a4 Mon Sep 17 00:00:00 2001 From: Otto Jongerius Date: Tue, 31 Mar 2026 08:23:44 +1300 Subject: [PATCH 1/3] Extract SDK into @attest-protocol/attest-ts Move receipt, store, and taxonomy modules to the standalone SDK package. Proxy and CLI now import from @attest-protocol/attest-ts instead of local source. 51 proxy/CLI tests pass against the SDK. --- package.json | 3 + pnpm-lock.yaml | 9 ++ src/cli/export.test.ts | 3 +- src/cli/export.ts | 2 +- src/cli/inspect.test.ts | 15 ++- src/cli/inspect.ts | 3 +- src/cli/list.test.ts | 3 +- src/cli/list.ts | 5 +- src/cli/main.test.ts | 9 +- src/cli/main.ts | 2 +- src/cli/verify.test.ts | 11 +- src/cli/verify.ts | 6 +- src/index.ts | 2 +- src/proxy/emitter.test.ts | 11 +- src/proxy/emitter.ts | 18 ++-- src/proxy/main.test.ts | 2 +- src/proxy/main.ts | 8 +- src/receipt/chain.test.ts | 161 ---------------------------- src/receipt/chain.ts | 112 -------------------- src/receipt/create.test.ts | 164 ----------------------------- src/receipt/create.ts | 64 ------------ src/receipt/hash.test.ts | 160 ---------------------------- src/receipt/hash.ts | 75 -------------- src/receipt/index.ts | 33 ------ src/receipt/signing.test.ts | 122 ---------------------- src/receipt/signing.ts | 78 -------------- src/receipt/types.test.ts | 161 ---------------------------- src/receipt/types.ts | 148 -------------------------- src/store/index.ts | 10 -- src/store/store.test.ts | 163 ----------------------------- src/store/store.ts | 190 ---------------------------------- src/store/verify.test.ts | 100 ------------------ src/store/verify.ts | 18 ---- src/taxonomy/actions.test.ts | 68 ------------ src/taxonomy/actions.ts | 103 ------------------ src/taxonomy/classify.test.ts | 45 -------- src/taxonomy/classify.ts | 22 ---- src/taxonomy/config.test.ts | 128 ----------------------- src/taxonomy/config.ts | 65 ------------ src/taxonomy/index.ts | 11 -- src/taxonomy/types.ts | 12 --- src/test-utils/receipts.ts | 16 +-- 42 files changed, 81 insertions(+), 2260 deletions(-) delete mode 100644 src/receipt/chain.test.ts delete mode 100644 src/receipt/chain.ts delete mode 100644 src/receipt/create.test.ts delete mode 100644 src/receipt/create.ts delete mode 100644 src/receipt/hash.test.ts delete mode 100644 src/receipt/hash.ts delete mode 100644 src/receipt/index.ts delete mode 100644 src/receipt/signing.test.ts delete mode 100644 src/receipt/signing.ts delete mode 100644 src/receipt/types.test.ts delete mode 100644 src/receipt/types.ts delete mode 100644 src/store/index.ts delete mode 100644 src/store/store.test.ts delete mode 100644 src/store/store.ts delete mode 100644 src/store/verify.test.ts delete mode 100644 src/store/verify.ts delete mode 100644 src/taxonomy/actions.test.ts delete mode 100644 src/taxonomy/actions.ts delete mode 100644 src/taxonomy/classify.test.ts delete mode 100644 src/taxonomy/classify.ts delete mode 100644 src/taxonomy/config.test.ts delete mode 100644 src/taxonomy/config.ts delete mode 100644 src/taxonomy/index.ts delete mode 100644 src/taxonomy/types.ts diff --git a/package.json b/package.json index 8b782a0..de7b990 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,9 @@ ] }, "packageManager": "pnpm@10.33.0", + "dependencies": { + "@attest-protocol/attest-ts": "file:../attest-ts" + }, "devDependencies": { "@biomejs/biome": "^2.4.9", "@types/node": "^25.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f845da6..8dfa659 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,10 @@ settings: importers: .: + dependencies: + '@attest-protocol/attest-ts': + specifier: file:../attest-ts + version: file:../attest-ts devDependencies: '@biomejs/biome': specifier: ^2.4.9 @@ -26,6 +30,9 @@ importers: packages: + '@attest-protocol/attest-ts@file:../attest-ts': + resolution: {directory: ../attest-ts, type: directory} + '@biomejs/biome@2.4.9': resolution: {integrity: sha512-wvZW92FrwitTcacvCBT8xdAbfbxWfDLwjYMmU3djjqQTh7Ni4ZdiWIT/x5VcZ+RQuxiKzIOzi5D+dcyJDFZMsA==} engines: {node: '>=14.21.3'} @@ -570,6 +577,8 @@ packages: snapshots: + '@attest-protocol/attest-ts@file:../attest-ts': {} + '@biomejs/biome@2.4.9': optionalDependencies: '@biomejs/cli-darwin-arm64': 2.4.9 diff --git a/src/cli/export.test.ts b/src/cli/export.test.ts index 8901d33..811cffd 100644 --- a/src/cli/export.test.ts +++ b/src/cli/export.test.ts @@ -1,6 +1,5 @@ +import { openStore, type ReceiptStore } from "@attest-protocol/attest-ts"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import type { ReceiptStore } from "../store/store.js"; -import { openStore } from "../store/store.js"; import { makeReceipt } from "../test-utils/receipts.js"; import { runExport } from "./export.js"; diff --git a/src/cli/export.ts b/src/cli/export.ts index 098a393..2b5ff8c 100644 --- a/src/cli/export.ts +++ b/src/cli/export.ts @@ -1,4 +1,4 @@ -import type { ReceiptStore } from "../store/store.js"; +import type { ReceiptStore } from "@attest-protocol/attest-ts"; /** * Run the `attest export` command. diff --git a/src/cli/inspect.test.ts b/src/cli/inspect.test.ts index 149fb7f..ab65368 100644 --- a/src/cli/inspect.test.ts +++ b/src/cli/inspect.test.ts @@ -1,9 +1,14 @@ +import { + CONTEXT, + CREDENTIAL_TYPE, + generateKeyPair, + openStore, + type ReceiptStore, + signReceipt, + type UnsignedActionReceipt, + RECEIPT_VERSION as VERSION, +} from "@attest-protocol/attest-ts"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { generateKeyPair, signReceipt } from "../receipt/signing.js"; -import type { UnsignedActionReceipt } from "../receipt/types.js"; -import { CONTEXT, CREDENTIAL_TYPE, VERSION } from "../receipt/types.js"; -import type { ReceiptStore } from "../store/store.js"; -import { openStore } from "../store/store.js"; import { runInspect } from "./inspect.js"; function makeUnsigned(): UnsignedActionReceipt { diff --git a/src/cli/inspect.ts b/src/cli/inspect.ts index af4bf28..2d1dc24 100644 --- a/src/cli/inspect.ts +++ b/src/cli/inspect.ts @@ -1,5 +1,4 @@ -import { verifyReceipt } from "../receipt/signing.js"; -import type { ReceiptStore } from "../store/store.js"; +import { type ReceiptStore, verifyReceipt } from "@attest-protocol/attest-ts"; /** * Options for the `attest inspect` command. diff --git a/src/cli/list.test.ts b/src/cli/list.test.ts index e238b23..c17cf28 100644 --- a/src/cli/list.test.ts +++ b/src/cli/list.test.ts @@ -1,6 +1,5 @@ +import { openStore, type ReceiptStore } from "@attest-protocol/attest-ts"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import type { ReceiptStore } from "../store/store.js"; -import { openStore } from "../store/store.js"; import { makeReceipt } from "../test-utils/receipts.js"; import { runList } from "./list.js"; diff --git a/src/cli/list.ts b/src/cli/list.ts index 1da741f..6abf532 100644 --- a/src/cli/list.ts +++ b/src/cli/list.ts @@ -1,9 +1,10 @@ import type { ActionReceipt, OutcomeStatus, + ReceiptQuery, + ReceiptStore, RiskLevel, -} from "../receipt/types.js"; -import type { ReceiptQuery, ReceiptStore } from "../store/store.js"; +} from "@attest-protocol/attest-ts"; const VALID_RISK_LEVELS = new Set([ "low", diff --git a/src/cli/main.test.ts b/src/cli/main.test.ts index e82d3c5..7afb4e9 100644 --- a/src/cli/main.test.ts +++ b/src/cli/main.test.ts @@ -2,10 +2,13 @@ import { execFileSync } from "node:child_process"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { + generateKeyPair, + hashReceipt, + openStore, + signReceipt, +} from "@attest-protocol/attest-ts"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { hashReceipt } from "../receipt/hash.js"; -import { generateKeyPair, signReceipt } from "../receipt/signing.js"; -import { openStore } from "../store/store.js"; import { makeReceipt, makeUnsigned } from "../test-utils/receipts.js"; const CLI = join( diff --git a/src/cli/main.ts b/src/cli/main.ts index dd663f1..7a84adf 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -2,7 +2,7 @@ import { readFileSync } from "node:fs"; import { parseArgs } from "node:util"; -import { openStore } from "../store/store.js"; +import { openStore } from "@attest-protocol/attest-ts"; import { runExport } from "./export.js"; import { runInspect } from "./inspect.js"; import { runList } from "./list.js"; diff --git a/src/cli/verify.test.ts b/src/cli/verify.test.ts index f970770..753a383 100644 --- a/src/cli/verify.test.ts +++ b/src/cli/verify.test.ts @@ -1,8 +1,11 @@ +import { + generateKeyPair, + hashReceipt, + openStore, + type ReceiptStore, + signReceipt, +} from "@attest-protocol/attest-ts"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { hashReceipt } from "../receipt/hash.js"; -import { generateKeyPair, signReceipt } from "../receipt/signing.js"; -import type { ReceiptStore } from "../store/store.js"; -import { openStore } from "../store/store.js"; import { makeUnsigned } from "../test-utils/receipts.js"; import { runVerify } from "./verify.js"; diff --git a/src/cli/verify.ts b/src/cli/verify.ts index 558363a..0f4ccd7 100644 --- a/src/cli/verify.ts +++ b/src/cli/verify.ts @@ -1,5 +1,7 @@ -import type { ReceiptStore } from "../store/store.js"; -import { verifyStoredChain } from "../store/verify.js"; +import { + type ReceiptStore, + verifyStoredChain, +} from "@attest-protocol/attest-ts"; /** * Options for the `attest verify` command. diff --git a/src/index.ts b/src/index.ts index 52c905c..458809d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1 @@ -export const VERSION = "0.1.0"; +export { VERSION } from "@attest-protocol/attest-ts"; diff --git a/src/proxy/emitter.test.ts b/src/proxy/emitter.test.ts index 8103000..09a1041 100644 --- a/src/proxy/emitter.test.ts +++ b/src/proxy/emitter.test.ts @@ -1,11 +1,14 @@ import { dirname, join } from "node:path"; import { PassThrough } from "node:stream"; import { fileURLToPath } from "node:url"; +import { + generateKeyPair, + hashReceipt, + openStore, + type ReceiptStore, + verifyReceipt, +} from "@attest-protocol/attest-ts"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { hashReceipt } from "../receipt/hash.js"; -import { generateKeyPair, verifyReceipt } from "../receipt/signing.js"; -import type { ReceiptStore } from "../store/store.js"; -import { openStore } from "../store/store.js"; import { readLine } from "../test-utils/streams.js"; import type { EmitterConfig } from "./emitter.js"; import { ReceiptEmitter } from "./emitter.js"; diff --git a/src/proxy/emitter.ts b/src/proxy/emitter.ts index fb037da..d593c2a 100644 --- a/src/proxy/emitter.ts +++ b/src/proxy/emitter.ts @@ -1,11 +1,15 @@ import { randomUUID } from "node:crypto"; -import { createReceipt } from "../receipt/create.js"; -import { hashReceipt, sha256 } from "../receipt/hash.js"; -import { signReceipt } from "../receipt/signing.js"; -import type { Issuer, Principal } from "../receipt/types.js"; -import type { ReceiptStore } from "../store/store.js"; -import { classifyToolCall } from "../taxonomy/classify.js"; -import type { TaxonomyMapping } from "../taxonomy/types.js"; +import { + classifyToolCall, + createReceipt, + hashReceipt, + type Issuer, + type Principal, + type ReceiptStore, + sha256, + signReceipt, + type TaxonomyMapping, +} from "@attest-protocol/attest-ts"; import type { ToolCallComplete, ToolCallInterceptor } from "./interceptor.js"; /** diff --git a/src/proxy/main.test.ts b/src/proxy/main.test.ts index 2b688dc..7093e8d 100644 --- a/src/proxy/main.test.ts +++ b/src/proxy/main.test.ts @@ -3,8 +3,8 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { openStore } from "@attest-protocol/attest-ts"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { openStore } from "../store/store.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const PROXY_CLI = join(__dirname, "..", "..", "dist", "proxy", "main.js"); diff --git a/src/proxy/main.ts b/src/proxy/main.ts index ec0f905..218b5db 100644 --- a/src/proxy/main.ts +++ b/src/proxy/main.ts @@ -2,9 +2,11 @@ import { readFileSync } from "node:fs"; import { parseArgs } from "node:util"; -import { generateKeyPair } from "../receipt/signing.js"; -import { openStore } from "../store/store.js"; -import { loadTaxonomyConfig } from "../taxonomy/config.js"; +import { + generateKeyPair, + loadTaxonomyConfig, + openStore, +} from "@attest-protocol/attest-ts"; import { ReceiptEmitter } from "./emitter.js"; import { ToolCallInterceptor } from "./interceptor.js"; import { McpProxy } from "./proxy.js"; diff --git a/src/receipt/chain.test.ts b/src/receipt/chain.test.ts deleted file mode 100644 index 033bf7c..0000000 --- a/src/receipt/chain.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { makeUnsigned } from "../test-utils/receipts.js"; -import { verifyChain } from "./chain.js"; -import { hashReceipt } from "./hash.js"; -import { generateKeyPair, signReceipt } from "./signing.js"; - -function buildChain(count: number, privateKey: string) { - const receipts = []; - let previousHash: string | null = null; - - for (let i = 1; i <= count; i++) { - const unsigned = makeUnsigned(i, previousHash); - const signed = signReceipt(unsigned, privateKey, "did:agent:test#key-1"); - receipts.push(signed); - previousHash = hashReceipt(signed); - } - - return receipts; -} - -describe("verifyChain", () => { - it("returns valid for an empty chain", () => { - const { publicKey } = generateKeyPair(); - const result = verifyChain([], publicKey); - - expect(result.valid).toBe(true); - expect(result.length).toBe(0); - expect(result.brokenAt).toBe(-1); - }); - - it("verifies a single receipt", () => { - const { publicKey, privateKey } = generateKeyPair(); - const chain = buildChain(1, privateKey); - const result = verifyChain(chain, publicKey); - - expect(result.valid).toBe(true); - expect(result.length).toBe(1); - expect(result.receipts[0]?.signatureValid).toBe(true); - expect(result.receipts[0]?.hashLinkValid).toBe(true); - expect(result.receipts[0]?.sequenceValid).toBe(true); - }); - - it("verifies a valid 3-receipt chain", () => { - const { publicKey, privateKey } = generateKeyPair(); - const chain = buildChain(3, privateKey); - const result = verifyChain(chain, publicKey); - - expect(result.valid).toBe(true); - expect(result.length).toBe(3); - expect(result.brokenAt).toBe(-1); - - for (const r of result.receipts) { - expect(r.signatureValid).toBe(true); - expect(r.hashLinkValid).toBe(true); - expect(r.sequenceValid).toBe(true); - } - }); - - it("detects a tampered receipt (broken signature)", () => { - const { publicKey, privateKey } = generateKeyPair(); - const chain = buildChain(3, privateKey); - - // Tamper with the second receipt - const tampered = chain[1]; - if (tampered) tampered.credentialSubject.action.risk_level = "critical"; - - const result = verifyChain(chain, publicKey); - - expect(result.valid).toBe(false); - expect(result.brokenAt).toBe(1); - expect(result.receipts[0]?.signatureValid).toBe(true); - expect(result.receipts[1]?.signatureValid).toBe(false); - }); - - it("detects a broken hash link", () => { - const { publicKey, privateKey } = generateKeyPair(); - const chain = buildChain(3, privateKey); - - // Replace the second receipt with one that has wrong previous_hash - const badUnsigned = makeUnsigned(2, "sha256:wrong"); - chain[1] = signReceipt(badUnsigned, privateKey, "did:agent:test#key-1"); - - const result = verifyChain(chain, publicKey); - - expect(result.valid).toBe(false); - expect(result.brokenAt).toBe(1); - expect(result.receipts[1]?.hashLinkValid).toBe(false); - }); - - it("detects a broken sequence", () => { - const { publicKey, privateKey } = generateKeyPair(); - - // Build chain with gap: sequence 1, 3 - const first = signReceipt( - makeUnsigned(1, null), - privateKey, - "did:agent:test#key-1", - ); - const firstHash = hashReceipt(first); - const second = signReceipt( - makeUnsigned(3, firstHash), - privateKey, - "did:agent:test#key-1", - ); - - const result = verifyChain([first, second], publicKey); - - expect(result.valid).toBe(false); - expect(result.receipts[1]?.sequenceValid).toBe(false); - }); - - it("detects wrong signing key", () => { - const signer = generateKeyPair(); - const other = generateKeyPair(); - const chain = buildChain(2, signer.privateKey); - - const result = verifyChain(chain, other.publicKey); - - expect(result.valid).toBe(false); - expect(result.brokenAt).toBe(0); - expect(result.receipts[0]?.signatureValid).toBe(false); - }); - - it("first receipt must have null previous_receipt_hash", () => { - const { publicKey, privateKey } = generateKeyPair(); - - const bad = signReceipt( - makeUnsigned(1, "sha256:unexpected"), - privateKey, - "did:agent:test#key-1", - ); - - const result = verifyChain([bad], publicKey); - - expect(result.valid).toBe(false); - expect(result.receipts[0]?.hashLinkValid).toBe(false); - }); - - it("continues verifying after a break", () => { - const { publicKey, privateKey } = generateKeyPair(); - const chain = buildChain(3, privateKey); - - // Tamper with second receipt - const tampered = chain[1]; - if (tampered) tampered.credentialSubject.action.risk_level = "critical"; - - const result = verifyChain(chain, publicKey); - - // Should still have results for all 3 receipts - expect(result.receipts).toHaveLength(3); - expect(result.brokenAt).toBe(1); - - // Tampered receipt: signature invalid, but hash link to first is still valid - expect(result.receipts[1]?.signatureValid).toBe(false); - expect(result.receipts[1]?.hashLinkValid).toBe(true); - - // Third receipt: own signature valid, but hash link to tampered second is broken - expect(result.receipts[2]?.signatureValid).toBe(true); - expect(result.receipts[2]?.hashLinkValid).toBe(false); - }); -}); diff --git a/src/receipt/chain.ts b/src/receipt/chain.ts deleted file mode 100644 index 7a45add..0000000 --- a/src/receipt/chain.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { hashReceipt } from "./hash.js"; -import { verifyReceipt } from "./signing.js"; -import type { ActionReceipt } from "./types.js"; - -/** - * Result of verifying a single receipt in a chain. - */ -export interface ReceiptVerification { - /** Index of the receipt in the chain. */ - index: number; - /** Receipt id. */ - receiptId: string; - /** Whether the Ed25519 signature is valid. */ - signatureValid: boolean; - /** Whether the previous_receipt_hash matches the prior receipt's hash. */ - hashLinkValid: boolean; - /** Whether the sequence number is correct. */ - sequenceValid: boolean; -} - -/** - * Result of verifying an entire chain. - */ -export interface ChainVerification { - /** Whether the entire chain is valid. */ - valid: boolean; - /** Number of receipts verified. */ - length: number; - /** Per-receipt verification results. */ - receipts: ReceiptVerification[]; - /** Index of the first broken receipt, or -1 if chain is valid. */ - brokenAt: number; -} - -/** - * Verify a chain of signed receipts. - * - * Checks for each receipt: - * 1. Ed25519 signature validity - * 2. Hash linkage: previous_receipt_hash matches SHA-256 of prior receipt - * 3. Sequence numbers are strictly incrementing - * - * Receipts must be provided in chain order (by sequence number). - */ -export function verifyChain( - receipts: ActionReceipt[], - publicKey: string, -): ChainVerification { - if (receipts.length === 0) { - return { valid: true, length: 0, receipts: [], brokenAt: -1 }; - } - - const results: ReceiptVerification[] = []; - let brokenAt = -1; - - let previous: ActionReceipt | undefined; - - for (let i = 0; i < receipts.length; i++) { - const receipt = receipts[i]; - if (!receipt) continue; - const chain = receipt.credentialSubject.chain; - - const signatureValid = verifyReceipt(receipt, publicKey); - - let hashLinkValid: boolean; - if (previous === undefined) { - hashLinkValid = chain.previous_receipt_hash === null; - } else { - const previousHash = hashReceipt(previous); - hashLinkValid = chain.previous_receipt_hash === previousHash; - } - - let sequenceValid: boolean; - const currentSequence = chain.sequence; - if (!Number.isSafeInteger(currentSequence)) { - sequenceValid = false; - } else if (previous === undefined) { - sequenceValid = currentSequence >= 1; - } else { - const prevSequence = previous.credentialSubject.chain.sequence; - sequenceValid = - Number.isSafeInteger(prevSequence) && - currentSequence === prevSequence + 1; - } - - const verification: ReceiptVerification = { - index: i, - receiptId: receipt.id, - signatureValid, - hashLinkValid, - sequenceValid, - }; - - results.push(verification); - - if ( - brokenAt === -1 && - (!signatureValid || !hashLinkValid || !sequenceValid) - ) { - brokenAt = i; - } - - previous = receipt; - } - - return { - valid: brokenAt === -1, - length: receipts.length, - receipts: results, - brokenAt, - }; -} diff --git a/src/receipt/create.test.ts b/src/receipt/create.test.ts deleted file mode 100644 index c1a7687..0000000 --- a/src/receipt/create.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { CreateReceiptInput } from "./create.js"; -import { createReceipt } from "./create.js"; -import { CONTEXT, CREDENTIAL_TYPE, VERSION } from "./types.js"; - -function makeInput( - overrides?: Partial, -): CreateReceiptInput { - return { - issuer: { id: "did:agent:test-agent" }, - principal: { id: "did:user:test-user" }, - action: { - type: "filesystem.file.read", - risk_level: "low", - }, - outcome: { status: "success" }, - chain: { - sequence: 1, - previous_receipt_hash: null, - chain_id: "chain_test", - }, - ...overrides, - }; -} - -describe("createReceipt", () => { - it("sets context, type, and version constants", () => { - const receipt = createReceipt(makeInput()); - - expect(receipt["@context"]).toEqual(CONTEXT); - expect(receipt.type).toEqual(CREDENTIAL_TYPE); - expect(receipt.version).toBe(VERSION); - }); - - it("generates a URN UUID for the receipt id", () => { - const receipt = createReceipt(makeInput()); - - expect(receipt.id).toMatch( - /^urn:receipt:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, - ); - }); - - it("generates unique ids on each call", () => { - const a = createReceipt(makeInput()); - const b = createReceipt(makeInput()); - - expect(a.id).not.toBe(b.id); - }); - - it("generates an action id", () => { - const receipt = createReceipt(makeInput()); - - expect(receipt.credentialSubject.action.id).toMatch( - /^act_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, - ); - }); - - it("sets issuanceDate to current time", () => { - const before = new Date().toISOString(); - const receipt = createReceipt(makeInput()); - const after = new Date().toISOString(); - - expect(receipt.issuanceDate >= before).toBe(true); - expect(receipt.issuanceDate <= after).toBe(true); - }); - - it("defaults action timestamp to now", () => { - const before = new Date().toISOString(); - const receipt = createReceipt(makeInput()); - - expect(receipt.credentialSubject.action.timestamp >= before).toBe(true); - }); - - it("allows overriding action timestamp", () => { - const receipt = createReceipt( - makeInput({ actionTimestamp: "2026-01-01T00:00:00Z" }), - ); - - expect(receipt.credentialSubject.action.timestamp).toBe( - "2026-01-01T00:00:00Z", - ); - }); - - it("passes through issuer, principal, outcome, and chain", () => { - const input = makeInput(); - const receipt = createReceipt(input); - - expect(receipt.issuer).toEqual(input.issuer); - expect(receipt.credentialSubject.principal).toEqual(input.principal); - expect(receipt.credentialSubject.outcome).toEqual(input.outcome); - expect(receipt.credentialSubject.chain).toEqual(input.chain); - }); - - it("passes through action fields", () => { - const input = makeInput({ - action: { - type: "filesystem.file.create", - risk_level: "medium", - target: { system: "local", resource: "/tmp/test.txt" }, - parameters_hash: "sha256:abc123", - }, - }); - const receipt = createReceipt(input); - const action = receipt.credentialSubject.action; - - expect(action.type).toBe("filesystem.file.create"); - expect(action.risk_level).toBe("medium"); - expect(action.target).toEqual({ - system: "local", - resource: "/tmp/test.txt", - }); - expect(action.parameters_hash).toBe("sha256:abc123"); - }); - - it("includes intent when provided", () => { - const receipt = createReceipt( - makeInput({ - intent: { - prompt_preview: "Read the config file", - conversation_hash: "sha256:abc", - }, - }), - ); - - expect(receipt.credentialSubject.intent).toEqual({ - prompt_preview: "Read the config file", - conversation_hash: "sha256:abc", - }); - }); - - it("omits intent when not provided", () => { - const receipt = createReceipt(makeInput()); - - expect(receipt.credentialSubject).not.toHaveProperty("intent"); - }); - - it("includes authorization when provided", () => { - const receipt = createReceipt( - makeInput({ - authorization: { - scopes: ["filesystem:read"], - granted_at: "2026-03-29T14:00:00Z", - }, - }), - ); - - expect(receipt.credentialSubject.authorization).toEqual({ - scopes: ["filesystem:read"], - granted_at: "2026-03-29T14:00:00Z", - }); - }); - - it("omits authorization when not provided", () => { - const receipt = createReceipt(makeInput()); - - expect(receipt.credentialSubject).not.toHaveProperty("authorization"); - }); - - it("does not include a proof field", () => { - const receipt = createReceipt(makeInput()); - - expect(receipt).not.toHaveProperty("proof"); - }); -}); diff --git a/src/receipt/create.ts b/src/receipt/create.ts deleted file mode 100644 index 095b81a..0000000 --- a/src/receipt/create.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { randomUUID } from "node:crypto"; -import type { - Action, - Authorization, - Chain, - Intent, - Issuer, - Outcome, - Principal, - UnsignedActionReceipt, -} from "./types.js"; -import { CONTEXT, CREDENTIAL_TYPE, VERSION } from "./types.js"; - -/** - * Inputs for creating an unsigned receipt. - * - * Required fields match the mandatory parts of CredentialSubject. - * Optional fields (intent, authorization) can be omitted. - */ -export interface CreateReceiptInput { - issuer: Issuer; - principal: Principal; - action: Omit; - outcome: Outcome; - chain: Chain; - intent?: Intent; - authorization?: Authorization; - /** Override the action timestamp (defaults to now). */ - actionTimestamp?: string; -} - -/** - * Build an unsigned Action Receipt from structured inputs. - * - * Auto-generates: receipt id (URN UUID), action id, issuanceDate, - * action timestamp, @context, type, and version. - */ -export function createReceipt( - input: CreateReceiptInput, -): UnsignedActionReceipt { - const now = new Date().toISOString(); - const actionTimestamp = input.actionTimestamp ?? now; - - return { - "@context": CONTEXT, - id: `urn:receipt:${randomUUID()}`, - type: CREDENTIAL_TYPE, - version: VERSION, - issuer: input.issuer, - issuanceDate: now, - credentialSubject: { - principal: input.principal, - action: { - ...input.action, - id: `act_${randomUUID()}`, - timestamp: actionTimestamp, - }, - outcome: input.outcome, - chain: input.chain, - ...(input.intent && { intent: input.intent }), - ...(input.authorization && { authorization: input.authorization }), - }, - }; -} diff --git a/src/receipt/hash.test.ts b/src/receipt/hash.test.ts deleted file mode 100644 index 8d81be9..0000000 --- a/src/receipt/hash.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { createHash } from "node:crypto"; -import { describe, expect, it } from "vitest"; -import { canonicalize, hashReceipt, sha256 } from "./hash.js"; -import type { ActionReceipt } from "./types.js"; -import { CONTEXT, CREDENTIAL_TYPE, VERSION } from "./types.js"; - -describe("canonicalize", () => { - it("sorts object keys lexicographically", () => { - expect(canonicalize({ z: 1, a: 2 })).toBe('{"a":2,"z":1}'); - }); - - it("recursively sorts nested object keys", () => { - expect(canonicalize({ b: { d: 1, c: 2 }, a: 3 })).toBe( - '{"a":3,"b":{"c":2,"d":1}}', - ); - }); - - it("preserves array order", () => { - expect(canonicalize([3, 1, 2])).toBe("[3,1,2]"); - }); - - it("handles null", () => { - expect(canonicalize(null)).toBe("null"); - }); - - it("handles booleans", () => { - expect(canonicalize(true)).toBe("true"); - expect(canonicalize(false)).toBe("false"); - }); - - it("handles strings with escaping", () => { - expect(canonicalize('hello "world"')).toBe('"hello \\"world\\""'); - }); - - it("produces no whitespace", () => { - const result = canonicalize({ a: [1, 2], b: { c: "d" } }); - expect(result).not.toMatch(/\s/); - }); - - it("handles negative zero as zero", () => { - expect(canonicalize(-0)).toBe("0"); - }); - - it("throws for non-finite numbers", () => { - expect(() => canonicalize(Number.POSITIVE_INFINITY)).toThrow( - "non-finite numbers", - ); - expect(() => canonicalize(Number.NaN)).toThrow("non-finite numbers"); - }); - - it("throws for undefined", () => { - expect(() => canonicalize(undefined)).toThrow( - "undefined is not a valid JSON", - ); - }); - - it("throws for non-plain objects", () => { - expect(() => canonicalize(new Date())).toThrow("non-plain objects"); - expect(() => canonicalize(new Map())).toThrow("non-plain objects"); - }); - - it("throws for unsupported types", () => { - expect(() => canonicalize(BigInt(1))).toThrow("unsupported type"); - expect(() => canonicalize(Symbol("x"))).toThrow("unsupported type"); - }); - - it("handles empty objects and arrays", () => { - expect(canonicalize({})).toBe("{}"); - expect(canonicalize([])).toBe("[]"); - }); - - it("handles mixed nested structures", () => { - const input = { - credentialSubject: { - chain: { sequence: 1, chain_id: "abc", previous_receipt_hash: null }, - action: { type: "filesystem.file.read", id: "act_001" }, - }, - }; - const result = canonicalize(input); - // Keys should be sorted at every level - expect(result).toBe( - '{"credentialSubject":{"action":{"id":"act_001","type":"filesystem.file.read"},"chain":{"chain_id":"abc","previous_receipt_hash":null,"sequence":1}}}', - ); - }); -}); - -describe("sha256", () => { - it("returns sha256: format", () => { - const result = sha256("hello"); - expect(result).toMatch(/^sha256:[0-9a-f]{64}$/); - }); - - it("matches Node.js crypto output", () => { - const expected = createHash("sha256") - .update("hello", "utf-8") - .digest("hex"); - expect(sha256("hello")).toBe(`sha256:${expected}`); - }); - - it("produces different hashes for different inputs", () => { - expect(sha256("a")).not.toBe(sha256("b")); - }); -}); - -describe("hashReceipt", () => { - function makeReceipt(): ActionReceipt { - return { - "@context": CONTEXT, - id: "urn:receipt:test-hash", - type: CREDENTIAL_TYPE, - version: VERSION, - issuer: { id: "did:agent:test" }, - issuanceDate: "2026-03-29T14:31:00Z", - credentialSubject: { - principal: { id: "did:user:test" }, - action: { - id: "act_001", - type: "filesystem.file.read", - risk_level: "low", - timestamp: "2026-03-29T14:31:00Z", - }, - outcome: { status: "success" }, - chain: { - sequence: 1, - previous_receipt_hash: null, - chain_id: "chain_test", - }, - }, - proof: { - type: "Ed25519Signature2020", - proofValue: "utest-signature", - }, - }; - } - - it("returns sha256: format", () => { - expect(hashReceipt(makeReceipt())).toMatch(/^sha256:[0-9a-f]{64}$/); - }); - - it("excludes proof from hash computation", () => { - const a = makeReceipt(); - const b = makeReceipt(); - b.proof.proofValue = "udifferent-signature"; - - expect(hashReceipt(a)).toBe(hashReceipt(b)); - }); - - it("produces different hashes for different receipt content", () => { - const a = makeReceipt(); - const b = makeReceipt(); - b.id = "urn:receipt:different"; - - expect(hashReceipt(a)).not.toBe(hashReceipt(b)); - }); - - it("is deterministic", () => { - const receipt = makeReceipt(); - expect(hashReceipt(receipt)).toBe(hashReceipt(receipt)); - }); -}); diff --git a/src/receipt/hash.ts b/src/receipt/hash.ts deleted file mode 100644 index 8a7edfe..0000000 --- a/src/receipt/hash.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { createHash } from "node:crypto"; -import type { ActionReceipt, UnsignedActionReceipt } from "./types.js"; - -/** - * Serialize a value to canonical JSON per RFC 8785 (JSON Canonicalization Scheme). - * - * Key rules: - * - Object keys are sorted lexicographically (by UTF-16 code units) - * - Numbers use ECMAScript shortest representation (no trailing zeros; positive exponents may include '+') - * - No whitespace between tokens - * - Strings use minimal escaping (only required characters) - * - null, boolean, and string values serialized per JSON spec - */ -export function canonicalize(value: unknown): string { - if (value === null) return "null"; - if (value === undefined) { - throw new Error("RFC 8785: undefined is not a valid JSON value"); - } - if (typeof value === "boolean") return value ? "true" : "false"; - if (typeof value === "number") return canonicalizeNumber(value); - if (typeof value === "string") return JSON.stringify(value); - if (Array.isArray(value)) { - return `[${value.map(canonicalize).join(",")}]`; - } - if (typeof value === "object") { - if ( - value.constructor !== Object && - value.constructor !== undefined && - value.constructor !== null - ) { - throw new Error( - `RFC 8785: non-plain objects are not valid JSON: ${value.constructor.name}`, - ); - } - const keys = Object.keys(value).sort(); - const entries = keys.map( - (k) => - `${JSON.stringify(k)}:${canonicalize((value as Record)[k])}`, - ); - return `{${entries.join(",")}}`; - } - throw new Error(`RFC 8785: unsupported type: ${typeof value}`); -} - -/** - * RFC 8785 number serialization: use ES Number.toString() which already - * produces the shortest representation for finite numbers. - */ -function canonicalizeNumber(n: number): string { - if (!Number.isFinite(n)) { - throw new Error(`RFC 8785: non-finite numbers are not valid JSON: ${n}`); - } - return Object.is(n, -0) ? "0" : String(n); -} - -/** - * Compute SHA-256 hash of a receipt, excluding the proof field. - * - * Returns the hash in "sha256:" format as used throughout the spec. - */ -export function hashReceipt(receipt: ActionReceipt): string { - const { proof: _, ...unsigned } = receipt; - const canonical = canonicalize(unsigned as UnsignedActionReceipt); - return sha256(canonical); -} - -/** - * Compute SHA-256 hash of arbitrary data. - * - * Returns "sha256:" format. - */ -export function sha256(data: string): string { - const hash = createHash("sha256").update(data, "utf-8").digest("hex"); - return `sha256:${hash}`; -} diff --git a/src/receipt/index.ts b/src/receipt/index.ts deleted file mode 100644 index b64dc53..0000000 --- a/src/receipt/index.ts +++ /dev/null @@ -1,33 +0,0 @@ -export { - type ChainVerification, - type ReceiptVerification, - verifyChain, -} from "./chain.js"; -export { type CreateReceiptInput, createReceipt } from "./create.js"; -export { canonicalize, hashReceipt, sha256 } from "./hash.js"; -export { - generateKeyPair, - type KeyPair, - signReceipt, - verifyReceipt, -} from "./signing.js"; -export { - type ActionReceipt, - type ActionTarget, - type Authorization, - type Chain, - CONTEXT, - CREDENTIAL_TYPE, - type CredentialSubject, - type Intent, - type Issuer, - type Operator, - type Outcome, - type OutcomeStatus, - type Principal, - type Proof, - type RiskLevel, - type StateChange, - type UnsignedActionReceipt, - VERSION, -} from "./types.js"; diff --git a/src/receipt/signing.test.ts b/src/receipt/signing.test.ts deleted file mode 100644 index 5bd8f71..0000000 --- a/src/receipt/signing.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { generateKeyPair, signReceipt, verifyReceipt } from "./signing.js"; -import type { UnsignedActionReceipt } from "./types.js"; -import { CONTEXT, CREDENTIAL_TYPE, VERSION } from "./types.js"; - -function makeUnsignedReceipt(): UnsignedActionReceipt { - return { - "@context": CONTEXT, - id: "urn:receipt:550e8400-e29b-41d4-a716-446655440000", - type: CREDENTIAL_TYPE, - version: VERSION, - issuer: { id: "did:agent:test-agent" }, - issuanceDate: "2026-03-29T14:31:00Z", - credentialSubject: { - principal: { id: "did:user:test-user" }, - action: { - id: "act_001", - type: "filesystem.file.read", - risk_level: "low", - timestamp: "2026-03-29T14:31:00Z", - }, - outcome: { status: "success" }, - chain: { - sequence: 1, - previous_receipt_hash: null, - chain_id: "chain_test", - }, - }, - }; -} - -describe("generateKeyPair", () => { - it("returns PEM-encoded Ed25519 keys", () => { - const { publicKey, privateKey } = generateKeyPair(); - - expect(publicKey).toContain("BEGIN PUBLIC KEY"); - expect(publicKey).toContain("END PUBLIC KEY"); - expect(privateKey).toContain("BEGIN PRIVATE KEY"); - expect(privateKey).toContain("END PRIVATE KEY"); - }); - - it("generates unique key pairs on each call", () => { - const a = generateKeyPair(); - const b = generateKeyPair(); - - expect(a.publicKey).not.toBe(b.publicKey); - expect(a.privateKey).not.toBe(b.privateKey); - }); -}); - -describe("signReceipt", () => { - it("returns a receipt with a valid proof", () => { - const { privateKey } = generateKeyPair(); - const unsigned = makeUnsignedReceipt(); - const verificationMethod = "did:agent:test-agent#key-1"; - - const signed = signReceipt(unsigned, privateKey, verificationMethod); - - expect(signed.proof.type).toBe("Ed25519Signature2020"); - expect(signed.proof.proofPurpose).toBe("assertionMethod"); - expect(signed.proof.verificationMethod).toBe(verificationMethod); - expect(signed.proof.proofValue).toMatch(/^u[A-Za-z0-9_-]+$/); - expect(signed.proof.created).toBeDefined(); - }); - - it("preserves all unsigned fields", () => { - const { privateKey } = generateKeyPair(); - const unsigned = makeUnsignedReceipt(); - - const signed = signReceipt(unsigned, privateKey, "did:agent:test#key-1"); - - expect(signed.id).toBe(unsigned.id); - expect(signed.issuer).toEqual(unsigned.issuer); - expect(signed.credentialSubject).toEqual(unsigned.credentialSubject); - }); -}); - -describe("verifyReceipt", () => { - it("returns true for a validly signed receipt", () => { - const { publicKey, privateKey } = generateKeyPair(); - const unsigned = makeUnsignedReceipt(); - - const signed = signReceipt(unsigned, privateKey, "did:agent:test#key-1"); - const valid = verifyReceipt(signed, publicKey); - - expect(valid).toBe(true); - }); - - it("returns false when the receipt has been tampered with", () => { - const { publicKey, privateKey } = generateKeyPair(); - const unsigned = makeUnsignedReceipt(); - - const signed = signReceipt(unsigned, privateKey, "did:agent:test#key-1"); - signed.credentialSubject.action.risk_level = "critical"; - - expect(verifyReceipt(signed, publicKey)).toBe(false); - }); - - it("returns false for malformed proofValue", () => { - const { publicKey, privateKey } = generateKeyPair(); - const unsigned = makeUnsignedReceipt(); - - const signed = signReceipt(unsigned, privateKey, "did:agent:test#key-1"); - signed.proof.proofValue = "invalid"; - - expect(verifyReceipt(signed, publicKey)).toBe(false); - }); - - it("returns false when verified with a different key", () => { - const signer = generateKeyPair(); - const other = generateKeyPair(); - const unsigned = makeUnsignedReceipt(); - - const signed = signReceipt( - unsigned, - signer.privateKey, - "did:agent:test#key-1", - ); - - expect(verifyReceipt(signed, other.publicKey)).toBe(false); - }); -}); diff --git a/src/receipt/signing.ts b/src/receipt/signing.ts deleted file mode 100644 index 5ff9e00..0000000 --- a/src/receipt/signing.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { generateKeyPairSync, sign, verify } from "node:crypto"; -import { canonicalize } from "./hash.js"; -import type { ActionReceipt, Proof, UnsignedActionReceipt } from "./types.js"; - -export interface KeyPair { - publicKey: string; - privateKey: string; -} - -/** Multibase prefix for base64url (no padding) encoding. */ -const MULTIBASE_BASE64URL = "u"; - -/** - * Generate an Ed25519 key pair (PEM-encoded). - * - * Note: uses synchronous generation which blocks the event loop. - * For long-running services, consider wrapping in a worker thread. - */ -export function generateKeyPair(): KeyPair { - const { publicKey, privateKey } = generateKeyPairSync("ed25519", { - publicKeyEncoding: { type: "spki", format: "pem" }, - privateKeyEncoding: { type: "pkcs8", format: "pem" }, - }); - return { publicKey, privateKey }; -} - -/** - * Serialize an unsigned receipt to bytes using RFC 8785 canonicalization. - */ -function canonicalizeReceipt(receipt: UnsignedActionReceipt): Buffer { - return Buffer.from(canonicalize(receipt), "utf-8"); -} - -/** - * Sign an unsigned receipt, returning a complete ActionReceipt with proof. - */ -export function signReceipt( - unsigned: UnsignedActionReceipt, - privateKey: string, - verificationMethod: string, -): ActionReceipt { - const data = canonicalizeReceipt(unsigned); - const signature = sign(null, data, privateKey); - - const proof: Proof = { - type: "Ed25519Signature2020", - created: new Date().toISOString(), - verificationMethod, - proofPurpose: "assertionMethod", - proofValue: `${MULTIBASE_BASE64URL}${signature.toString("base64url")}`, - }; - - return { ...unsigned, proof }; -} - -/** - * Verify the Ed25519 signature on a signed receipt. - */ -export function verifyReceipt( - receipt: ActionReceipt, - publicKey: string, -): boolean { - const { proof, ...unsigned } = receipt; - - const proofValue = proof?.proofValue; - if ( - typeof proofValue !== "string" || - proofValue.length < 2 || - !proofValue.startsWith(MULTIBASE_BASE64URL) - ) { - return false; - } - - const data = canonicalizeReceipt(unsigned as UnsignedActionReceipt); - const signature = Buffer.from(proofValue.slice(1), "base64url"); - - return verify(null, data, publicKey, signature); -} diff --git a/src/receipt/types.test.ts b/src/receipt/types.test.ts deleted file mode 100644 index a893f55..0000000 --- a/src/receipt/types.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - type ActionReceipt, - CONTEXT, - CREDENTIAL_TYPE, - type UnsignedActionReceipt, - VERSION, -} from "./types.js"; - -describe("receipt schema constants", () => { - it("has the correct context URIs", () => { - expect(CONTEXT).toEqual([ - "https://www.w3.org/ns/credentials/v2", - "https://attest.sh/v1", - ]); - }); - - it("has the correct credential type", () => { - expect(CREDENTIAL_TYPE).toEqual([ - "VerifiableCredential", - "AIActionReceipt", - ]); - }); - - it("has version 0.1.0", () => { - expect(VERSION).toBe("0.1.0"); - }); -}); - -describe("receipt types", () => { - it("accepts a minimal receipt", () => { - const receipt: ActionReceipt = { - "@context": CONTEXT, - id: "urn:receipt:550e8400-e29b-41d4-a716-446655440000", - type: CREDENTIAL_TYPE, - version: VERSION, - issuer: { id: "did:agent:test-agent" }, - issuanceDate: "2026-03-29T14:31:00Z", - credentialSubject: { - principal: { id: "did:user:test-user" }, - action: { - id: "act_001", - type: "filesystem.file.read", - risk_level: "low", - timestamp: "2026-03-29T14:31:00Z", - }, - outcome: { status: "success" }, - chain: { - sequence: 1, - previous_receipt_hash: null, - chain_id: "chain_test", - }, - }, - proof: { type: "Ed25519Signature2020", proofValue: "z..." }, - }; - - expect(receipt.id).toBe("urn:receipt:550e8400-e29b-41d4-a716-446655440000"); - expect(receipt.credentialSubject.action.risk_level).toBe("low"); - }); - - it("accepts a full receipt with all optional fields", () => { - const receipt: ActionReceipt = { - "@context": CONTEXT, - id: "urn:receipt:550e8400-e29b-41d4-a716-446655440000", - type: CREDENTIAL_TYPE, - version: VERSION, - issuer: { - id: "did:agent:claude-cowork-instance-abc123", - type: "AIAgent", - name: "Claude Cowork", - operator: { id: "did:org:anthropic", name: "Anthropic" }, - model: "claude-sonnet-4.6", - session_id: "session_xyz789", - }, - issuanceDate: "2026-03-29T14:30:00Z", - credentialSubject: { - principal: { id: "did:user:otto-abc", type: "HumanPrincipal" }, - action: { - id: "act_001", - type: "communication.email.send", - risk_level: "high", - target: { system: "mail.google.com", resource: "email:compose" }, - parameters_hash: "sha256:abc123", - timestamp: "2026-03-29T14:30:00Z", - trusted_timestamp: null, - }, - intent: { - conversation_hash: "sha256:def456", - prompt_preview: "Send the Q3 report to the team", - prompt_preview_truncated: true, - reasoning_hash: "sha256:ghi789", - }, - outcome: { - status: "success", - error: null, - reversible: true, - reversal_method: "gmail:undo_send", - reversal_window_seconds: 30, - state_change: { - before_hash: "sha256:before", - after_hash: "sha256:after", - }, - }, - authorization: { - scopes: ["email:send", "drive:read"], - granted_at: "2026-03-29T14:00:00Z", - expires_at: "2026-03-29T15:00:00Z", - grant_ref: null, - }, - chain: { - sequence: 1, - previous_receipt_hash: null, - chain_id: "chain_session_xyz789", - }, - }, - proof: { - type: "Ed25519Signature2020", - created: "2026-03-29T14:30:01Z", - verificationMethod: "did:agent:claude-cowork-instance-abc123#key-1", - proofPurpose: "assertionMethod", - proofValue: "z...", - }, - }; - - expect(receipt.credentialSubject.intent?.prompt_preview).toBe( - "Send the Q3 report to the team", - ); - expect(receipt.credentialSubject.authorization?.scopes).toEqual([ - "email:send", - "drive:read", - ]); - }); - - it("UnsignedActionReceipt omits proof", () => { - const unsigned: UnsignedActionReceipt = { - "@context": CONTEXT, - id: "urn:receipt:test", - type: CREDENTIAL_TYPE, - version: VERSION, - issuer: { id: "did:agent:test" }, - issuanceDate: "2026-03-29T14:31:00Z", - credentialSubject: { - principal: { id: "did:user:test" }, - action: { - id: "act_001", - type: "filesystem.file.read", - risk_level: "low", - timestamp: "2026-03-29T14:31:00Z", - }, - outcome: { status: "success" }, - chain: { - sequence: 1, - previous_receipt_hash: null, - chain_id: "chain_test", - }, - }, - }; - - expect(unsigned).not.toHaveProperty("proof"); - }); -}); diff --git a/src/receipt/types.ts b/src/receipt/types.ts deleted file mode 100644 index fe3cfbd..0000000 --- a/src/receipt/types.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** - * Action Receipt schema types. - * - * These types model the Attest Action Receipt as a W3C Verifiable Credential. - * Both the full and minimal receipt variants share the same type — optional - * fields are marked with `?`. - */ - -export const CONTEXT = [ - "https://www.w3.org/ns/credentials/v2", - "https://attest.sh/v1", -] as const; - -export const CREDENTIAL_TYPE = [ - "VerifiableCredential", - "AIActionReceipt", -] as const; - -export const VERSION = "0.1.0"; - -// --- Risk levels --- - -export type RiskLevel = "low" | "medium" | "high" | "critical"; - -// --- Outcome status --- - -export type OutcomeStatus = "success" | "failure" | "pending"; - -// --- Issuer --- - -export interface Operator { - id: string; - name: string; -} - -export interface Issuer { - id: string; - type?: string; - name?: string; - operator?: Operator; - model?: string; - session_id?: string; -} - -// --- Principal --- - -export interface Principal { - id: string; - type?: string; -} - -// --- Action --- - -export interface ActionTarget { - system: string; - resource?: string; -} - -export interface Action { - id: string; - type: string; - risk_level: RiskLevel; - target?: ActionTarget; - parameters_hash?: string; - timestamp: string; - trusted_timestamp?: string | null; -} - -// --- Intent --- - -export interface Intent { - conversation_hash?: string; - prompt_preview?: string; - prompt_preview_truncated?: boolean; - reasoning_hash?: string; -} - -// --- Outcome --- - -export interface StateChange { - before_hash: string; - after_hash: string; -} - -export interface Outcome { - status: OutcomeStatus; - error?: string | null; - reversible?: boolean; - reversal_method?: string; - reversal_window_seconds?: number; - state_change?: StateChange; -} - -// --- Authorization --- - -export interface Authorization { - scopes: string[]; - granted_at: string; - expires_at?: string; - grant_ref?: string | null; -} - -// --- Chain --- - -export interface Chain { - sequence: number; - previous_receipt_hash: string | null; - chain_id: string; -} - -// --- Credential Subject --- - -export interface CredentialSubject { - principal: Principal; - action: Action; - intent?: Intent; - outcome: Outcome; - authorization?: Authorization; - chain: Chain; -} - -// --- Proof --- - -export interface Proof { - type: string; - created?: string; - verificationMethod?: string; - proofPurpose?: string; - proofValue: string; -} - -// --- Action Receipt --- - -export interface ActionReceipt { - "@context": readonly string[]; - id: string; - type: readonly string[]; - version: string; - issuer: Issuer; - issuanceDate: string; - credentialSubject: CredentialSubject; - proof: Proof; -} - -/** - * An Action Receipt before signing — no proof field yet. - */ -export type UnsignedActionReceipt = Omit; diff --git a/src/store/index.ts b/src/store/index.ts deleted file mode 100644 index 45454b1..0000000 --- a/src/store/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -export type { - ChainVerification, - ReceiptVerification, -} from "../receipt/chain.js"; -export { - openStore, - type ReceiptQuery, - ReceiptStore, -} from "./store.js"; -export { verifyStoredChain } from "./verify.js"; diff --git a/src/store/store.test.ts b/src/store/store.test.ts deleted file mode 100644 index b64373e..0000000 --- a/src/store/store.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { makeReceipt } from "../test-utils/receipts.js"; -import type { ReceiptStore } from "./store.js"; -import { openStore } from "./store.js"; - -describe("ReceiptStore", () => { - let store: ReceiptStore; - - beforeEach(() => { - store = openStore(":memory:"); - }); - - afterEach(() => { - store.close(); - }); - - describe("insert and getById", () => { - it("stores and retrieves a receipt", () => { - const receipt = makeReceipt({}); - store.insert(receipt, "sha256:abc"); - - const retrieved = store.getById(receipt.id); - expect(retrieved).toEqual(receipt); - }); - - it("returns undefined for missing receipt", () => { - expect(store.getById("urn:receipt:missing")).toBeUndefined(); - }); - }); - - describe("getChain", () => { - it("returns receipts ordered by sequence", () => { - store.insert( - makeReceipt({ id: "urn:receipt:2", sequence: 2 }), - "sha256:b", - ); - store.insert( - makeReceipt({ id: "urn:receipt:1", sequence: 1 }), - "sha256:a", - ); - store.insert( - makeReceipt({ id: "urn:receipt:3", sequence: 3 }), - "sha256:c", - ); - - const chain = store.getChain("chain_test"); - - expect(chain).toHaveLength(3); - expect(chain[0]?.id).toBe("urn:receipt:1"); - expect(chain[1]?.id).toBe("urn:receipt:2"); - expect(chain[2]?.id).toBe("urn:receipt:3"); - }); - - it("returns empty array for unknown chain", () => { - expect(store.getChain("nonexistent")).toEqual([]); - }); - - it("only returns receipts from the requested chain", () => { - store.insert( - makeReceipt({ id: "urn:receipt:a1", chainId: "chain_a" }), - "sha256:a", - ); - store.insert( - makeReceipt({ id: "urn:receipt:b1", chainId: "chain_b" }), - "sha256:b", - ); - - const chain = store.getChain("chain_a"); - expect(chain).toHaveLength(1); - expect(chain[0]?.id).toBe("urn:receipt:a1"); - }); - }); - - describe("query", () => { - beforeEach(() => { - store.insert( - makeReceipt({ - id: "urn:receipt:1", - actionType: "filesystem.file.read", - riskLevel: "low", - status: "success", - timestamp: "2026-03-29T10:00:00Z", - }), - "sha256:1", - ); - store.insert( - makeReceipt({ - id: "urn:receipt:2", - sequence: 2, - actionType: "filesystem.file.delete", - riskLevel: "high", - status: "success", - timestamp: "2026-03-29T11:00:00Z", - }), - "sha256:2", - ); - store.insert( - makeReceipt({ - id: "urn:receipt:3", - sequence: 3, - actionType: "system.command.execute", - riskLevel: "critical", - status: "failure", - timestamp: "2026-03-29T12:00:00Z", - }), - "sha256:3", - ); - }); - - it("filters by action type", () => { - const results = store.query({ - actionType: "filesystem.file.read", - }); - expect(results).toHaveLength(1); - expect(results[0]?.id).toBe("urn:receipt:1"); - }); - - it("filters by risk level", () => { - const results = store.query({ riskLevel: "critical" }); - expect(results).toHaveLength(1); - expect(results[0]?.id).toBe("urn:receipt:3"); - }); - - it("filters by status", () => { - const results = store.query({ status: "failure" }); - expect(results).toHaveLength(1); - expect(results[0]?.id).toBe("urn:receipt:3"); - }); - - it("filters by time range", () => { - const results = store.query({ - after: "2026-03-29T10:30:00Z", - before: "2026-03-29T11:30:00Z", - }); - expect(results).toHaveLength(1); - expect(results[0]?.id).toBe("urn:receipt:2"); - }); - - it("combines multiple filters", () => { - const results = store.query({ - riskLevel: "high", - status: "success", - }); - expect(results).toHaveLength(1); - expect(results[0]?.id).toBe("urn:receipt:2"); - }); - - it("respects limit", () => { - const results = store.query({ limit: 2 }); - expect(results).toHaveLength(2); - }); - - it("returns empty array when no matches", () => { - const results = store.query({ riskLevel: "medium" }); - expect(results).toEqual([]); - }); - - it("returns all receipts with empty filter", () => { - const results = store.query({}); - expect(results).toHaveLength(3); - }); - }); -}); diff --git a/src/store/store.ts b/src/store/store.ts deleted file mode 100644 index 559f68e..0000000 --- a/src/store/store.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { DatabaseSync } from "node:sqlite"; -import type { - ActionReceipt, - OutcomeStatus, - RiskLevel, -} from "../receipt/types.js"; - -const SCHEMA = ` -CREATE TABLE IF NOT EXISTS receipts ( - id TEXT PRIMARY KEY, - chain_id TEXT NOT NULL, - sequence INTEGER NOT NULL, - action_type TEXT NOT NULL, - risk_level TEXT NOT NULL, - status TEXT NOT NULL, - timestamp TEXT NOT NULL, - issuer_id TEXT NOT NULL, - principal_id TEXT, - receipt_json TEXT NOT NULL, - receipt_hash TEXT NOT NULL, - previous_receipt_hash TEXT, - created_at TEXT DEFAULT CURRENT_TIMESTAMP -); - -CREATE UNIQUE INDEX IF NOT EXISTS idx_receipts_chain ON receipts(chain_id, sequence); -CREATE INDEX IF NOT EXISTS idx_receipts_action ON receipts(action_type); -CREATE INDEX IF NOT EXISTS idx_receipts_risk ON receipts(risk_level); -CREATE INDEX IF NOT EXISTS idx_receipts_timestamp ON receipts(timestamp); -`; - -/** - * Filters for querying receipts. - */ -export interface ReceiptQuery { - chainId?: string; - actionType?: string; - riskLevel?: RiskLevel; - status?: OutcomeStatus; - /** Return receipts with timestamp >= after (ISO 8601). */ - after?: string; - /** Return receipts with timestamp <= before (ISO 8601). */ - before?: string; - /** Maximum number of results. */ - limit?: number; -} - -interface ReceiptRow { - receipt_json: string; -} - -const DEFAULT_QUERY_LIMIT = 10000; - -/** - * Parse a receipt JSON string from the store, with error context. - */ -function parseReceiptJson(json: string, context: string): ActionReceipt { - try { - return JSON.parse(json) as ActionReceipt; - } catch (cause) { - throw new Error(`Corrupt receipt in store (${context}): ${cause}`); - } -} - -/** - * SQLite-backed receipt store. - */ -export class ReceiptStore { - private db: DatabaseSync; - - constructor(dbPath: string) { - this.db = new DatabaseSync(dbPath); - this.db.exec(SCHEMA); - } - - /** - * Insert a signed receipt into the store. - */ - insert(receipt: ActionReceipt, receiptHash: string): void { - const subject = receipt.credentialSubject; - this.db - .prepare( - `INSERT INTO receipts - (id, chain_id, sequence, action_type, risk_level, status, - timestamp, issuer_id, principal_id, receipt_json, receipt_hash, - previous_receipt_hash) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - receipt.id, - subject.chain.chain_id, - subject.chain.sequence, - subject.action.type, - subject.action.risk_level, - subject.outcome.status, - subject.action.timestamp, - receipt.issuer.id, - subject.principal.id, - JSON.stringify(receipt), - receiptHash, - subject.chain.previous_receipt_hash, - ); - } - - /** - * Retrieve a receipt by its id. - */ - getById(id: string): ActionReceipt | undefined { - const row = this.db - .prepare("SELECT receipt_json FROM receipts WHERE id = ?") - .get(id) as ReceiptRow | undefined; - return row ? parseReceiptJson(row.receipt_json, `id=${id}`) : undefined; - } - - /** - * Retrieve all receipts in a chain, ordered by sequence. - */ - getChain(chainId: string): ActionReceipt[] { - const rows = this.db - .prepare( - "SELECT receipt_json FROM receipts WHERE chain_id = ? ORDER BY sequence ASC", - ) - .all(chainId) as unknown as ReceiptRow[]; - return rows.map((r) => - parseReceiptJson(r.receipt_json, `chain=${chainId}`), - ); - } - - /** - * Query receipts with optional filters. - */ - query(filters: ReceiptQuery): ActionReceipt[] { - const conditions: string[] = []; - const params: string[] = []; - - if (filters.chainId !== undefined) { - conditions.push("chain_id = ?"); - params.push(filters.chainId); - } - if (filters.actionType !== undefined) { - conditions.push("action_type = ?"); - params.push(filters.actionType); - } - if (filters.riskLevel !== undefined) { - conditions.push("risk_level = ?"); - params.push(filters.riskLevel); - } - if (filters.status !== undefined) { - conditions.push("status = ?"); - params.push(filters.status); - } - if (filters.after !== undefined) { - conditions.push("timestamp >= ?"); - params.push(filters.after); - } - if (filters.before !== undefined) { - conditions.push("timestamp <= ?"); - params.push(filters.before); - } - - const where = - conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; - - const limit = filters.limit ?? DEFAULT_QUERY_LIMIT; - params.push(String(limit)); - - const rows = this.db - .prepare( - `SELECT receipt_json FROM receipts ${where} ORDER BY timestamp ASC LIMIT ?`, - ) - .all(...params) as unknown as ReceiptRow[]; - - return rows.map((r) => parseReceiptJson(r.receipt_json, "query")); - } - - /** - * Close the database connection. - */ - close(): void { - this.db.close(); - } -} - -/** - * Open (or create) a receipt store at the given path. - * - * Use ":memory:" for an in-memory database. - */ -export function openStore(dbPath: string): ReceiptStore { - return new ReceiptStore(dbPath); -} diff --git a/src/store/verify.test.ts b/src/store/verify.test.ts deleted file mode 100644 index ad6ce8b..0000000 --- a/src/store/verify.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { hashReceipt } from "../receipt/hash.js"; -import { generateKeyPair, signReceipt } from "../receipt/signing.js"; -import { makeUnsigned } from "../test-utils/receipts.js"; -import type { ReceiptStore } from "./store.js"; -import { openStore } from "./store.js"; -import { verifyStoredChain } from "./verify.js"; - -describe("verifyStoredChain", () => { - let store: ReceiptStore; - let publicKey: string; - let privateKey: string; - - beforeEach(() => { - store = openStore(":memory:"); - const keys = generateKeyPair(); - publicKey = keys.publicKey; - privateKey = keys.privateKey; - }); - - afterEach(() => { - store.close(); - }); - - it("verifies a valid stored chain", () => { - let previousHash: string | null = null; - for (let i = 1; i <= 3; i++) { - const unsigned = makeUnsigned(i, previousHash); - const signed = signReceipt(unsigned, privateKey, "did:agent:test#key-1"); - const hash = hashReceipt(signed); - store.insert(signed, hash); - previousHash = hash; - } - - const result = verifyStoredChain(store, "chain_test", publicKey); - - expect(result.valid).toBe(true); - expect(result.length).toBe(3); - }); - - it("detects tampered receipt in store", () => { - const unsigned = makeUnsigned(1, null); - const signed = signReceipt(unsigned, privateKey, "did:agent:test#key-1"); - const hash = hashReceipt(signed); - - // Tamper before storing - signed.credentialSubject.action.risk_level = "critical"; - store.insert(signed, hash); - - const result = verifyStoredChain(store, "chain_test", publicKey); - - expect(result.valid).toBe(false); - expect(result.receipts[0]?.signatureValid).toBe(false); - }); - - it("returns valid for empty chain", () => { - const result = verifyStoredChain(store, "nonexistent", publicKey); - - expect(result.valid).toBe(true); - expect(result.length).toBe(0); - }); - - it("detects broken hash link in stored chain", () => { - // Insert first receipt normally - const first = signReceipt( - makeUnsigned(1, null), - privateKey, - "did:agent:test#key-1", - ); - store.insert(first, hashReceipt(first)); - - // Insert second receipt with wrong previous hash - const second = signReceipt( - makeUnsigned(2, "sha256:wrong"), - privateKey, - "did:agent:test#key-1", - ); - store.insert(second, hashReceipt(second)); - - const result = verifyStoredChain(store, "chain_test", publicKey); - - expect(result.valid).toBe(false); - expect(result.brokenAt).toBe(1); - expect(result.receipts[1]?.hashLinkValid).toBe(false); - }); - - it("verifies only the requested chain", () => { - // Insert into two different chains - for (const chainId of ["chain_a", "chain_b"]) { - const unsigned = makeUnsigned(1, null, chainId); - const signed = signReceipt(unsigned, privateKey, "did:agent:test#key-1"); - store.insert(signed, hashReceipt(signed)); - } - - const result = verifyStoredChain(store, "chain_a", publicKey); - - expect(result.valid).toBe(true); - expect(result.length).toBe(1); - }); -}); diff --git a/src/store/verify.ts b/src/store/verify.ts deleted file mode 100644 index b8fcea0..0000000 --- a/src/store/verify.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { ChainVerification } from "../receipt/chain.js"; -import { verifyChain } from "../receipt/chain.js"; -import type { ReceiptStore } from "./store.js"; - -/** - * Load a chain from the store and verify its integrity. - * - * Checks Ed25519 signatures, hash linkage, and sequence ordering - * for all receipts in the given chain. - */ -export function verifyStoredChain( - store: ReceiptStore, - chainId: string, - publicKey: string, -): ChainVerification { - const receipts = store.getChain(chainId); - return verifyChain(receipts, publicKey); -} diff --git a/src/taxonomy/actions.test.ts b/src/taxonomy/actions.test.ts deleted file mode 100644 index a054cc0..0000000 --- a/src/taxonomy/actions.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - ALL_ACTIONS, - FILESYSTEM_ACTIONS, - getActionType, - resolveActionType, - SYSTEM_ACTIONS, - UNKNOWN_ACTION, -} from "./actions.js"; - -describe("action taxonomy", () => { - it("has 7 filesystem actions", () => { - expect(FILESYSTEM_ACTIONS).toHaveLength(7); - }); - - it("has 7 system actions", () => { - expect(SYSTEM_ACTIONS).toHaveLength(7); - }); - - it("has 15 total actions (filesystem + system + unknown)", () => { - expect(ALL_ACTIONS).toHaveLength(15); - }); - - it("all action types are unique", () => { - const types = ALL_ACTIONS.map((a) => a.type); - expect(new Set(types).size).toBe(types.length); - }); - - it("all actions have valid risk levels", () => { - for (const action of ALL_ACTIONS) { - expect(["low", "medium", "high", "critical"]).toContain( - action.risk_level, - ); - } - }); -}); - -describe("getActionType", () => { - it("returns the entry for a known action type", () => { - const entry = getActionType("filesystem.file.read"); - expect(entry).toEqual({ - type: "filesystem.file.read", - description: "Read a file", - risk_level: "low", - }); - }); - - it("returns undefined for an unknown action type", () => { - expect(getActionType("nonexistent.action")).toBeUndefined(); - }); - - it("returns the unknown action entry for 'unknown'", () => { - expect(getActionType("unknown")).toEqual(UNKNOWN_ACTION); - }); -}); - -describe("resolveActionType", () => { - it("returns the correct entry for known types", () => { - expect(resolveActionType("filesystem.file.delete").risk_level).toBe("high"); - expect(resolveActionType("system.browser.navigate").risk_level).toBe("low"); - }); - - it("falls back to unknown for unrecognized types", () => { - const result = resolveActionType("something.totally.new"); - expect(result.type).toBe("unknown"); - expect(result.risk_level).toBe("medium"); - }); -}); diff --git a/src/taxonomy/actions.ts b/src/taxonomy/actions.ts deleted file mode 100644 index c4c84e0..0000000 --- a/src/taxonomy/actions.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { ActionTypeEntry } from "./types.js"; - -const FILESYSTEM_ACTIONS: readonly ActionTypeEntry[] = [ - { - type: "filesystem.file.create", - description: "Create a file", - risk_level: "low", - }, - { - type: "filesystem.file.read", - description: "Read a file", - risk_level: "low", - }, - { - type: "filesystem.file.modify", - description: "Modify a file", - risk_level: "medium", - }, - { - type: "filesystem.file.delete", - description: "Delete a file", - risk_level: "high", - }, - { - type: "filesystem.file.move", - description: "Move or rename a file", - risk_level: "medium", - }, - { - type: "filesystem.directory.create", - description: "Create a directory", - risk_level: "low", - }, - { - type: "filesystem.directory.delete", - description: "Delete a directory", - risk_level: "high", - }, -]; - -const SYSTEM_ACTIONS: readonly ActionTypeEntry[] = [ - { - type: "system.application.launch", - description: "Launch an application", - risk_level: "low", - }, - { - type: "system.application.control", - description: "Control an application via UI automation", - risk_level: "medium", - }, - { - type: "system.settings.modify", - description: "Modify system or app settings", - risk_level: "high", - }, - { - type: "system.command.execute", - description: "Execute a shell command", - risk_level: "high", - }, - { - type: "system.browser.navigate", - description: "Navigate to a URL", - risk_level: "low", - }, - { - type: "system.browser.form_submit", - description: "Submit a web form", - risk_level: "medium", - }, - { - type: "system.browser.authenticate", - description: "Log into a service", - risk_level: "high", - }, -]; - -const UNKNOWN_ACTION: ActionTypeEntry = { - type: "unknown", - description: "Tool call that does not map to any known action type", - risk_level: "medium", -}; - -const ALL_ACTIONS: readonly ActionTypeEntry[] = [ - ...FILESYSTEM_ACTIONS, - ...SYSTEM_ACTIONS, - UNKNOWN_ACTION, -]; - -const ACTION_MAP = new Map( - ALL_ACTIONS.map((entry) => [entry.type, entry]), -); - -export function getActionType(type: string): ActionTypeEntry | undefined { - return ACTION_MAP.get(type); -} - -export function resolveActionType(type: string): ActionTypeEntry { - return ACTION_MAP.get(type) ?? UNKNOWN_ACTION; -} - -export { ALL_ACTIONS, FILESYSTEM_ACTIONS, SYSTEM_ACTIONS, UNKNOWN_ACTION }; diff --git a/src/taxonomy/classify.test.ts b/src/taxonomy/classify.test.ts deleted file mode 100644 index c9d912d..0000000 --- a/src/taxonomy/classify.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { classifyToolCall } from "./classify.js"; -import type { TaxonomyMapping } from "./types.js"; - -const TEST_MAPPINGS: TaxonomyMapping[] = [ - { tool_name: "read_file", action_type: "filesystem.file.read" }, - { tool_name: "write_file", action_type: "filesystem.file.create" }, - { tool_name: "delete_file", action_type: "filesystem.file.delete" }, - { tool_name: "run_command", action_type: "system.command.execute" }, -]; - -describe("classifyToolCall", () => { - it("classifies a mapped tool call", () => { - const result = classifyToolCall("read_file", TEST_MAPPINGS); - expect(result.action_type).toBe("filesystem.file.read"); - expect(result.risk_level).toBe("low"); - }); - - it("returns correct risk level for high-risk actions", () => { - const result = classifyToolCall("delete_file", TEST_MAPPINGS); - expect(result.action_type).toBe("filesystem.file.delete"); - expect(result.risk_level).toBe("high"); - }); - - it("falls back to unknown for unmapped tool calls", () => { - const result = classifyToolCall("some_random_tool", TEST_MAPPINGS); - expect(result.action_type).toBe("unknown"); - expect(result.risk_level).toBe("medium"); - }); - - it("falls back to unknown with empty mappings", () => { - const result = classifyToolCall("read_file", []); - expect(result.action_type).toBe("unknown"); - expect(result.risk_level).toBe("medium"); - }); - - it("handles mapping to an unknown action type gracefully", () => { - const mappings: TaxonomyMapping[] = [ - { tool_name: "weird_tool", action_type: "nonexistent.domain.action" }, - ]; - const result = classifyToolCall("weird_tool", mappings); - expect(result.action_type).toBe("unknown"); - expect(result.risk_level).toBe("medium"); - }); -}); diff --git a/src/taxonomy/classify.ts b/src/taxonomy/classify.ts deleted file mode 100644 index 60aea14..0000000 --- a/src/taxonomy/classify.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { RiskLevel } from "../receipt/types.js"; -import { resolveActionType, UNKNOWN_ACTION } from "./actions.js"; -import type { TaxonomyMapping } from "./types.js"; - -export interface ClassificationResult { - action_type: string; - risk_level: RiskLevel; -} - -export function classifyToolCall( - toolName: string, - mappings: TaxonomyMapping[] = [], -): ClassificationResult { - const mapping = mappings.find((m) => m.tool_name === toolName); - const actionType = mapping?.action_type ?? UNKNOWN_ACTION.type; - const entry = resolveActionType(actionType); - - return { - action_type: entry.type, - risk_level: entry.risk_level, - }; -} diff --git a/src/taxonomy/config.test.ts b/src/taxonomy/config.test.ts deleted file mode 100644 index 526e2ff..0000000 --- a/src/taxonomy/config.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { classifyToolCall } from "./classify.js"; -import { loadTaxonomyConfig } from "./config.js"; - -describe("loadTaxonomyConfig", () => { - let tempDir: string; - - beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), "attest-config-")); - }); - - afterEach(() => { - rmSync(tempDir, { recursive: true }); - }); - - function writeConfig(content: string): string { - const filePath = join(tempDir, "taxonomy.json"); - writeFileSync(filePath, content, "utf-8"); - return filePath; - } - - it("loads valid mappings from a config file", () => { - const path = writeConfig( - JSON.stringify({ - mappings: [ - { tool_name: "read_file", action_type: "filesystem.file.read" }, - { - tool_name: "write_file", - action_type: "filesystem.file.create", - }, - ], - }), - ); - - const mappings = loadTaxonomyConfig(path); - - expect(mappings).toHaveLength(2); - expect(mappings[0]).toEqual({ - tool_name: "read_file", - action_type: "filesystem.file.read", - }); - }); - - it("works with classifyToolCall", () => { - const path = writeConfig( - JSON.stringify({ - mappings: [ - { tool_name: "read_file", action_type: "filesystem.file.read" }, - ], - }), - ); - - const mappings = loadTaxonomyConfig(path); - const result = classifyToolCall("read_file", mappings); - - expect(result.action_type).toBe("filesystem.file.read"); - expect(result.risk_level).toBe("low"); - }); - - it("unmapped tools fall back to unknown", () => { - const path = writeConfig(JSON.stringify({ mappings: [] })); - - const mappings = loadTaxonomyConfig(path); - const result = classifyToolCall("some_unknown_tool", mappings); - - expect(result.action_type).toBe("unknown"); - expect(result.risk_level).toBe("medium"); - }); - - it("loads an empty mappings array", () => { - const path = writeConfig(JSON.stringify({ mappings: [] })); - - const mappings = loadTaxonomyConfig(path); - expect(mappings).toEqual([]); - }); - - it("throws for missing file", () => { - expect(() => loadTaxonomyConfig("/nonexistent/path.json")).toThrow(); - }); - - it("throws for invalid JSON", () => { - const path = writeConfig("not json"); - - expect(() => loadTaxonomyConfig(path)).toThrow(); - }); - - it("throws for missing mappings key", () => { - const path = writeConfig(JSON.stringify({ tools: [] })); - - expect(() => loadTaxonomyConfig(path)).toThrow("Invalid taxonomy config"); - }); - - it("throws for duplicate tool_name", () => { - const path = writeConfig( - JSON.stringify({ - mappings: [ - { tool_name: "read_file", action_type: "filesystem.file.read" }, - { tool_name: "read_file", action_type: "filesystem.file.modify" }, - ], - }), - ); - - expect(() => loadTaxonomyConfig(path)).toThrow( - "Duplicate taxonomy mapping", - ); - }); - - it("throws for empty string tool_name or action_type", () => { - const path = writeConfig( - JSON.stringify({ - mappings: [{ tool_name: "", action_type: "filesystem.file.read" }], - }), - ); - - expect(() => loadTaxonomyConfig(path)).toThrow("non-empty"); - }); - - it("throws for invalid mapping entries", () => { - const path = writeConfig( - JSON.stringify({ mappings: [{ tool_name: 123 }] }), - ); - - expect(() => loadTaxonomyConfig(path)).toThrow("Invalid taxonomy mapping"); - }); -}); diff --git a/src/taxonomy/config.ts b/src/taxonomy/config.ts deleted file mode 100644 index 69df71f..0000000 --- a/src/taxonomy/config.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { readFileSync } from "node:fs"; -import type { TaxonomyMapping } from "./types.js"; - -/** - * Shape of the taxonomy config file (JSON). - * - * Example: - * ```json - * { - * "mappings": [ - * { "tool_name": "read_file", "action_type": "filesystem.file.read" }, - * { "tool_name": "write_file", "action_type": "filesystem.file.create" } - * ] - * } - * ``` - */ -export interface TaxonomyConfig { - mappings: TaxonomyMapping[]; -} - -/** - * Load taxonomy mappings from a JSON config file. - * - * The file must contain a JSON object with a `mappings` array - * of `{ tool_name, action_type }` entries. - * - * @throws If the file cannot be read or has invalid structure. - */ -export function loadTaxonomyConfig(filePath: string): TaxonomyMapping[] { - const raw = readFileSync(filePath, "utf-8"); - const parsed: unknown = JSON.parse(raw); - - if ( - typeof parsed !== "object" || - parsed === null || - !Array.isArray((parsed as TaxonomyConfig).mappings) - ) { - throw new Error(`Invalid taxonomy config: expected { "mappings": [...] }`); - } - - const config = parsed as TaxonomyConfig; - - const seen = new Set(); - - for (const mapping of config.mappings) { - if ( - typeof mapping.tool_name !== "string" || - typeof mapping.action_type !== "string" || - mapping.tool_name === "" || - mapping.action_type === "" - ) { - throw new Error( - `Invalid taxonomy mapping: each entry must have non-empty "tool_name" and "action_type" strings`, - ); - } - if (seen.has(mapping.tool_name)) { - throw new Error( - `Duplicate taxonomy mapping for tool_name "${mapping.tool_name}"`, - ); - } - seen.add(mapping.tool_name); - } - - return config.mappings; -} diff --git a/src/taxonomy/index.ts b/src/taxonomy/index.ts deleted file mode 100644 index 76d66ce..0000000 --- a/src/taxonomy/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -export { - ALL_ACTIONS, - FILESYSTEM_ACTIONS, - getActionType, - resolveActionType, - SYSTEM_ACTIONS, - UNKNOWN_ACTION, -} from "./actions.js"; -export { type ClassificationResult, classifyToolCall } from "./classify.js"; -export { loadTaxonomyConfig, type TaxonomyConfig } from "./config.js"; -export type { ActionTypeEntry, TaxonomyMapping } from "./types.js"; diff --git a/src/taxonomy/types.ts b/src/taxonomy/types.ts deleted file mode 100644 index bbcf2fc..0000000 --- a/src/taxonomy/types.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { RiskLevel } from "../receipt/types.js"; - -export interface ActionTypeEntry { - type: string; - description: string; - risk_level: RiskLevel; -} - -export interface TaxonomyMapping { - tool_name: string; - action_type: string; -} diff --git a/src/test-utils/receipts.ts b/src/test-utils/receipts.ts index dd20f2c..ab13691 100644 --- a/src/test-utils/receipts.ts +++ b/src/test-utils/receipts.ts @@ -1,13 +1,15 @@ /** * Shared test factories for receipts. */ -import type { - ActionReceipt, - OutcomeStatus, - RiskLevel, - UnsignedActionReceipt, -} from "../receipt/types.js"; -import { CONTEXT, CREDENTIAL_TYPE, VERSION } from "../receipt/types.js"; +import { + type ActionReceipt, + CONTEXT, + CREDENTIAL_TYPE, + type OutcomeStatus, + type RiskLevel, + type UnsignedActionReceipt, + RECEIPT_VERSION as VERSION, +} from "@attest-protocol/attest-ts"; /** * Create a signed ActionReceipt with overridable fields. From 5de071b48810d954d9d9f63303179752d748c59e Mon Sep 17 00:00:00 2001 From: Otto Jongerius Date: Tue, 31 Mar 2026 08:38:34 +1300 Subject: [PATCH 2/3] Export both VERSION and RECEIPT_VERSION from SDK Address Copilot review: make the distinction between package version and receipt schema version explicit by exporting both symbols. --- src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 458809d..5fb82e7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1 @@ -export { VERSION } from "@attest-protocol/attest-ts"; +export { RECEIPT_VERSION, VERSION } from "@attest-protocol/attest-ts"; From 3bf7877e1606d9847328b4edac559471a31acdd8 Mon Sep 17 00:00:00 2001 From: Otto Jongerius Date: Tue, 31 Mar 2026 09:00:45 +1300 Subject: [PATCH 3/3] Switch SDK dependency from file: link to published npm package @attest-protocol/attest-ts@0.1.0 is now on npm. Replace the local file: reference with ^0.1.0 for reproducible installs. --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index de7b990..6bdf14f 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ }, "packageManager": "pnpm@10.33.0", "dependencies": { - "@attest-protocol/attest-ts": "file:../attest-ts" + "@attest-protocol/attest-ts": "^0.1.0" }, "devDependencies": { "@biomejs/biome": "^2.4.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8dfa659..9ef0cab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@attest-protocol/attest-ts': - specifier: file:../attest-ts - version: file:../attest-ts + specifier: ^0.1.0 + version: 0.1.0 devDependencies: '@biomejs/biome': specifier: ^2.4.9 @@ -30,8 +30,8 @@ importers: packages: - '@attest-protocol/attest-ts@file:../attest-ts': - resolution: {directory: ../attest-ts, type: directory} + '@attest-protocol/attest-ts@0.1.0': + resolution: {integrity: sha512-1hcIJ18nXQMSx2ScLaX7FhgeowZtUt4dX+Q19xUS0+TBpmqAv5wdBVCGYLttiV7jF9vvfCmyAaP21y2YbqiUjA==} '@biomejs/biome@2.4.9': resolution: {integrity: sha512-wvZW92FrwitTcacvCBT8xdAbfbxWfDLwjYMmU3djjqQTh7Ni4ZdiWIT/x5VcZ+RQuxiKzIOzi5D+dcyJDFZMsA==} @@ -577,7 +577,7 @@ packages: snapshots: - '@attest-protocol/attest-ts@file:../attest-ts': {} + '@attest-protocol/attest-ts@0.1.0': {} '@biomejs/biome@2.4.9': optionalDependencies: