From 88708f54ccf728644ea7b2fe4777ca06b8939259 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 20:05:30 +0000 Subject: [PATCH 1/3] feat(tools): surface HPKE disclosure on receipts; bump sdk-ts to ^0.11.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that the daemon emits HPKE parameters_disclosure envelopes (--parameter-disclosure), make the plugin's read path first-class for them: - ar_query_receipts results gain a `disclosed` boolean, set when the daemon attached an envelope. The plugin never decrypts — recovery needs the forensic private key, which lives with the responder, not the agent host. - Bump @agnt-rcpt/sdk-ts ^0.9.0 -> ^0.11.0. The caret on 0.9.x pinned the plugin to a tree that still dragged @hpke/core transitively; 0.11.0 replaced it with an in-tree node:crypto RFC 9180 implementation, dropping a third-party crypto package from the supply chain. Envelope shape and wire format are unchanged. - Add cross-engine read-path tests: a daemon-shaped envelope is accepted by the strict TS schema on query/verify, the Ed25519 signature commits to it, and the ciphertext round-trips back to plaintext only with the forensic private key. --- CHANGELOG.md | 8 +++ package.json | 2 +- pnpm-lock.yaml | 26 ++------ src/tools.test.ts | 159 +++++++++++++++++++++++++++++++++++++++++++++- src/tools.ts | 6 ++ 5 files changed, 179 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad49fe9..d0dbc3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Bump `@agnt-rcpt/sdk-ts` to `^0.11.0`. The SDK dropped its transitive `@hpke/core` dependency in favour of an in-tree RFC 9180 implementation built on `node:crypto` (per [agent-receipts/ar#473](https://github.com/agent-receipts/ar/issues/473)), removing a third-party crypto package from the plugin's supply chain. The `parameters_disclosure` envelope shape and the on-the-wire format are unchanged. + +### Added + +- `ar_query_receipts` results now include a `disclosed` boolean, set when the daemon attached an HPKE `parameters_disclosure` envelope (its `--parameter-disclosure` mode). The plugin never decrypts the envelope — recovery requires the forensic private key, which lives with the responder, not the agent host. + ### Fixed - **Socket emission silent failures and misleading startup warnings** ([#148](https://github.com/agent-receipts/openclaw/issues/148)): diff --git a/package.json b/package.json index bef5445..8872a4a 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@agnt-rcpt/sdk-ts": "^0.9.0", + "@agnt-rcpt/sdk-ts": "^0.11.0", "@sinclair/typebox": "0.34.49" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7ef36aa..055000b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@agnt-rcpt/sdk-ts': - specifier: ^0.9.0 - version: 0.9.0 + specifier: ^0.11.0 + version: 0.11.0 '@sinclair/typebox': specifier: 0.34.49 version: 0.34.49 @@ -38,8 +38,8 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 - '@agnt-rcpt/sdk-ts@0.9.0': - resolution: {integrity: sha512-ZmvJbT4/fqNhdMQvEZYBq488zHxT1n0IngCcN4Il+l8ZN7FC/C0Yp120SR2RPwnFDQcFvFtLpfELDUrRnQe4ow==} + '@agnt-rcpt/sdk-ts@0.11.0': + resolution: {integrity: sha512-WsM/dNepNLgZXiOtBCLaOAwfH+QGHE3L0fep83zO92+Ry5z4Al1psc0EepBkByjhgOObfPiIFTOPR27c1vWX6w==} engines: {node: '>=22.11.0'} '@anthropic-ai/sdk@0.91.1': @@ -404,14 +404,6 @@ packages: peerDependencies: hono: ^4 - '@hpke/common@1.10.1': - resolution: {integrity: sha512-moJwhmtLtuxiUzzNp1jpfBfx8yefKoO9D/RCR9dmwrnc7qjJqId1rEtQz+lSlU5cabX8daToMSx/7HayXOiaFw==} - engines: {node: '>=16.0.0'} - - '@hpke/core@1.9.0': - resolution: {integrity: sha512-pFxWl1nNJeQCSUFs7+GAblHvXBCjn9EPN65vdKlYQil2aURaRxfGMO6vBKGqm1YHTKwiAxJQNEI70PbSowMP9Q==} - engines: {node: '>=16.0.0'} - '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -2384,9 +2376,9 @@ snapshots: dependencies: zod: 4.4.3 - '@agnt-rcpt/sdk-ts@0.9.0': + '@agnt-rcpt/sdk-ts@0.11.0': dependencies: - '@hpke/core': 1.9.0 + undici: 8.3.0 zod: 4.4.3 '@anthropic-ai/sdk@0.91.1(zod@4.4.3)': @@ -2844,12 +2836,6 @@ snapshots: dependencies: hono: 4.12.19 - '@hpke/common@1.10.1': {} - - '@hpke/core@1.9.0': - dependencies: - '@hpke/common': 1.10.1 - '@img/colour@1.1.0': optional: true diff --git a/src/tools.test.ts b/src/tools.test.ts index 7e52a10..9427c9e 100644 --- a/src/tools.test.ts +++ b/src/tools.test.ts @@ -2,13 +2,16 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { openStore, generateKeyPair, createReceipt, signReceipt, hashReceipt, + encryptDisclosure, + decryptDisclosure, + generateForensicKeyPair, type ReceiptStore, type RiskLevel, type OutcomeStatus, @@ -18,6 +21,7 @@ import { createVerifyChainTool, createVerifyChainToolFactory, } from "./tools.js"; +import { openDaemonStore } from "./daemon-store.js"; // ---- Test fixture helpers ---- @@ -508,3 +512,156 @@ describe("ar_verify_chain", () => { expect(data.valid).toBe(true); }); }); + +// ---- HPKE parameter disclosure: cross-engine read path ---- +// +// The daemon (Go) WRITES the HPKE `parameters_disclosure` envelope into the +// SQLite DB; the plugin READS it back through the TS SDK's strict zod schema +// (which `store.query()` / `verifyStoredChain()` run on every load). These +// tests pin that an envelope of the shape the daemon emits is accepted on the +// read path, that the Ed25519 signature commits to it, and that the ciphertext +// stays recoverable — but only with the forensic private key, which lives with +// the responder, not here. The plugin itself never decrypts. + +describe("HPKE parameter disclosure — cross-engine read path", () => { + let tempDir: string; + let dbPath: string; + let pubKeyPath: string; + let keys: ReturnType; + let forensic: Awaited>; + let kid: string; + let writableStore: ReceiptStore; + + beforeEach(async () => { + tempDir = join(tmpdir(), `ar-test-${randomUUID()}`); + mkdirSync(tempDir, { recursive: true }); + dbPath = join(tempDir, "receipts.db"); + pubKeyPath = join(tempDir, "signing.key.pub"); + keys = generateKeyPair(); + writeFileSync(pubKeyPath, keys.publicKey); + forensic = await generateForensicKeyPair(); + // kid = sha256: + lowercase hex SHA-256 of the raw 32-byte public key, + // matching the fingerprint the daemon writes (see parameter-disclosure spec). + kid = `sha256:${createHash("sha256").update(forensic.publicKey).digest("hex")}`; + writableStore = openStore(dbPath); + }); + + afterEach(() => { + writableStore.close(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + // Insert a receipt whose action carries an HPKE disclosure envelope encrypted + // to the forensic public key — the shape the daemon writes when its + // --parameter-disclosure mode fires. Returns the stored receipt hash. + async function insertDisclosedReceipt(opts: { + seq: number; + chainId: string; + timestamp: string; + previousHash: string | null; + params: Record; + }): Promise { + const envelope = await encryptDisclosure(opts.params, forensic.publicKey, kid); + const unsigned = createReceipt({ + issuer: { id: "did:openclaw:test-agent" }, + principal: { id: "did:session:test-session" }, + action: { + type: "system.command.execute", + risk_level: "high", + target: { system: "openclaw", resource: "run_command" }, + parameters_hash: "abc123", + parameters_disclosure: envelope, + }, + outcome: { status: "success" }, + chain: { + sequence: opts.seq, + previous_receipt_hash: opts.previousHash, + chain_id: opts.chainId, + }, + actionTimestamp: opts.timestamp, + }); + const signed = signReceipt(unsigned, keys.privateKey, "did:openclaw:test-agent#key-1"); + const h = hashReceipt(signed); + writableStore.insert(signed, h); + return h; + } + + it("flags disclosed receipts in ar_query_receipts and accepts the daemon envelope shape", async () => { + await insertDisclosedReceipt({ + seq: 1, + chainId: "chain-d", + timestamp: "2024-01-01T10:00:00.000Z", + previousHash: null, + params: { command: "rm -rf /tmp/x", cwd: "/home/me" }, + }); + insertReceiptAt(writableStore, keys.privateKey, { + seq: 2, + chainId: "chain-d", + timestamp: "2024-01-01T11:00:00.000Z", + previousHash: "h1", + actionType: "filesystem.file.read", + }); + + const tool = createQueryReceiptsTool({ daemonDbPath: dbPath, daemonPublicKeyPath: pubKeyPath }); + const result = await tool.execute("tc-q", {}); + const data = JSON.parse(result.content[0].text); + + const disclosedByAction = Object.fromEntries( + data.results.map((r: { action: string; disclosed: boolean }) => [r.action, r.disclosed]), + ); + expect(disclosedByAction["system.command.execute"]).toBe(true); + expect(disclosedByAction["filesystem.file.read"]).toBe(false); + }); + + it("verifies a chain whose receipts carry HPKE disclosure envelopes", async () => { + const h1 = await insertDisclosedReceipt({ + seq: 1, + chainId: "chain-d", + timestamp: "2024-01-01T10:00:00.000Z", + previousHash: null, + params: { command: "echo hi" }, + }); + await insertDisclosedReceipt({ + seq: 2, + chainId: "chain-d", + timestamp: "2024-01-01T11:00:00.000Z", + previousHash: h1, + params: { command: "cat secret" }, + }); + + const tool = createVerifyChainTool({ daemonDbPath: dbPath, daemonPublicKeyPath: pubKeyPath }); + const result = await tool.execute("tc-v", { chain_id: "chain-d" }); + + const data = JSON.parse(result.content[1].text); + expect(data.valid).toBe(true); + expect(data.length).toBe(2); + for (const r of data.receipts) { + expect(r.signature_valid).toBe(true); + expect(r.hash_link_valid).toBe(true); + } + }); + + it("keeps the disclosed parameters recoverable only with the forensic private key", async () => { + const params = { command: "rm -rf /tmp/x", cwd: "/home/me" }; + await insertDisclosedReceipt({ + seq: 1, + chainId: "chain-d", + timestamp: "2024-01-01T10:00:00.000Z", + previousHash: null, + params, + }); + + const store = openDaemonStore(dbPath); + try { + const [receipt] = store.query({}); + const envelope = receipt!.credentialSubject.action.parameters_disclosure; + expect(envelope).toBeDefined(); + // The forensic key holder (off-host) recovers the plaintext; the plugin + // never performs this step. + const recovered = await decryptDisclosure(envelope!, forensic.privateKey); + expect(recovered).toEqual(params); + } finally { + store.close(); + } + }); +}); diff --git a/src/tools.ts b/src/tools.ts index cb1712b..aaab14b 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -148,6 +148,12 @@ export function createQueryReceiptsToolFactory(deps: ToolDeps) { status: r.credentialSubject.outcome.status, sequence: r.credentialSubject.chain.sequence, timestamp: r.credentialSubject.action.timestamp, + // True when the daemon attached an HPKE parameters_disclosure + // envelope (its --parameter-disclosure mode). The plugin never + // decrypts it — recovery needs the forensic private key, which + // lives with the responder, not the agent host. This flag only + // signals that the parameters are recoverable by that key holder. + disclosed: r.credentialSubject.action.parameters_disclosure !== undefined, })), }; From 0b87afcdb9fa0f2e208bb09ec303b30918f5ab10 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 21:03:29 +0000 Subject: [PATCH 2/3] test(tools): replace non-null assertions with explicit guards Address review feedback on the disclosure round-trip test: drop the `receipt!` / `envelope!` non-null assertions in favour of an explicit toBeDefined() check and a narrowing guard, so a failure reports clearly and the test stays within the repo's no-assertions posture. --- src/tools.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/tools.test.ts b/src/tools.test.ts index 9427c9e..2b2715d 100644 --- a/src/tools.test.ts +++ b/src/tools.test.ts @@ -654,11 +654,12 @@ describe("HPKE parameter disclosure — cross-engine read path", () => { const store = openDaemonStore(dbPath); try { const [receipt] = store.query({}); - const envelope = receipt!.credentialSubject.action.parameters_disclosure; - expect(envelope).toBeDefined(); + expect(receipt).toBeDefined(); + const envelope = receipt.credentialSubject.action.parameters_disclosure; + if (!envelope) throw new Error("expected a disclosure envelope on the stored receipt"); // The forensic key holder (off-host) recovers the plaintext; the plugin // never performs this step. - const recovered = await decryptDisclosure(envelope!, forensic.privateKey); + const recovered = await decryptDisclosure(envelope, forensic.privateKey); expect(recovered).toEqual(params); } finally { store.close(); From 7e5d757e1274e0e6ff751f503c610f1ffd58436e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 01:09:25 +0000 Subject: [PATCH 3/3] test(tools): tighten HPKE disclosure tests per review - Assert exclusivity: decryption with an unrelated forensic key rejects, so the "recoverable only with the forensic private key" property is actually verified, not just the happy path. - Assert sequence_valid in the disclosed-chain verification, matching the non-disclosure sibling test. - Centralise the forensic kid derivation in a forensicKid() helper and assert the stored envelope carries it, so the fingerprint convention lives in one place. - Fold insertDisclosedReceipt into the generalised insertReceiptAt (optional disclosure envelope) instead of duplicating the build/sign/hash/insert pipeline. --- src/tools.test.ts | 53 ++++++++++++++++++++++++++--------------------- 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/src/tools.test.ts b/src/tools.test.ts index 2b2715d..0ab0b86 100644 --- a/src/tools.test.ts +++ b/src/tools.test.ts @@ -15,6 +15,7 @@ import { type ReceiptStore, type RiskLevel, type OutcomeStatus, + type DisclosureEnvelope, } from "@agnt-rcpt/sdk-ts"; import { createQueryReceiptsTool, @@ -41,6 +42,7 @@ function insertReceiptAt( actionType?: string; riskLevel?: RiskLevel; status?: OutcomeStatus; + disclosure?: DisclosureEnvelope; }, ): string { const unsigned = createReceipt({ @@ -51,6 +53,7 @@ function insertReceiptAt( risk_level: opts.riskLevel ?? "low", target: { system: "openclaw", resource: "read_file" }, parameters_hash: "abc123", + ...(opts.disclosure ? { parameters_disclosure: opts.disclosure } : {}), }, outcome: { status: opts.status ?? "success", @@ -69,6 +72,15 @@ function insertReceiptAt( return h; } +/** + * Forensic-key fingerprint: "sha256:" + lowercase hex SHA-256 of the raw 32-byte + * X25519 public key — the same convention the daemon uses to derive a recipient + * `kid` (see the parameter-disclosure spec). + */ +function forensicKid(publicKey: Uint8Array): string { + return `sha256:${createHash("sha256").update(publicKey).digest("hex")}`; +} + // ---- ar_query_receipts ---- describe("ar_query_receipts", () => { @@ -540,9 +552,7 @@ describe("HPKE parameter disclosure — cross-engine read path", () => { keys = generateKeyPair(); writeFileSync(pubKeyPath, keys.publicKey); forensic = await generateForensicKeyPair(); - // kid = sha256: + lowercase hex SHA-256 of the raw 32-byte public key, - // matching the fingerprint the daemon writes (see parameter-disclosure spec). - kid = `sha256:${createHash("sha256").update(forensic.publicKey).digest("hex")}`; + kid = forensicKid(forensic.publicKey); writableStore = openStore(dbPath); }); @@ -562,28 +572,15 @@ describe("HPKE parameter disclosure — cross-engine read path", () => { params: Record; }): Promise { const envelope = await encryptDisclosure(opts.params, forensic.publicKey, kid); - const unsigned = createReceipt({ - issuer: { id: "did:openclaw:test-agent" }, - principal: { id: "did:session:test-session" }, - action: { - type: "system.command.execute", - risk_level: "high", - target: { system: "openclaw", resource: "run_command" }, - parameters_hash: "abc123", - parameters_disclosure: envelope, - }, - outcome: { status: "success" }, - chain: { - sequence: opts.seq, - previous_receipt_hash: opts.previousHash, - chain_id: opts.chainId, - }, - actionTimestamp: opts.timestamp, + return insertReceiptAt(writableStore, keys.privateKey, { + seq: opts.seq, + chainId: opts.chainId, + timestamp: opts.timestamp, + previousHash: opts.previousHash, + actionType: "system.command.execute", + riskLevel: "high", + disclosure: envelope, }); - const signed = signReceipt(unsigned, keys.privateKey, "did:openclaw:test-agent#key-1"); - const h = hashReceipt(signed); - writableStore.insert(signed, h); - return h; } it("flags disclosed receipts in ar_query_receipts and accepts the daemon envelope shape", async () => { @@ -638,6 +635,7 @@ describe("HPKE parameter disclosure — cross-engine read path", () => { for (const r of data.receipts) { expect(r.signature_valid).toBe(true); expect(r.hash_link_valid).toBe(true); + expect(r.sequence_valid).toBe(true); } }); @@ -657,10 +655,17 @@ describe("HPKE parameter disclosure — cross-engine read path", () => { expect(receipt).toBeDefined(); const envelope = receipt.credentialSubject.action.parameters_disclosure; if (!envelope) throw new Error("expected a disclosure envelope on the stored receipt"); + // The stored envelope carries the forensic key fingerprint a responder uses + // to locate matching receipts. + expect(envelope.recipients[0].kid).toBe(kid); // The forensic key holder (off-host) recovers the plaintext; the plugin // never performs this step. const recovered = await decryptDisclosure(envelope, forensic.privateKey); expect(recovered).toEqual(params); + // Exclusivity: an unrelated forensic key cannot recover the parameters — + // the AEAD tag fails to authenticate, so decryption rejects. + const otherForensic = await generateForensicKeyPair(); + await expect(decryptDisclosure(envelope, otherForensic.privateKey)).rejects.toThrow(); } finally { store.close(); }