diff --git a/.nvmrc b/.nvmrc index 2a393af5..85e50277 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.18.0 +22.22.0 diff --git a/README.md b/README.md index 7db893b0..f37a7301 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,7 @@ PEAC produces portable, verifiable evidence that can feed AI safety reviews, inc ## Quick start -**Requirements:** Node >= 20 +**Requirements:** Node >= 22 ```bash pnpm add @peac/protocol diff --git a/docs/README_LONG.md b/docs/README_LONG.md index d959f732..aee7fa1e 100644 --- a/docs/README_LONG.md +++ b/docs/README_LONG.md @@ -738,7 +738,7 @@ Test vectors: `tests/vectors/` and `docs/specs/TEST_VECTORS.md`. **Prerequisites:** -- Node.js 20+ +- Node.js 22+ - pnpm >= 8 **Setup:** diff --git a/examples/openclaw-capture/README.md b/examples/openclaw-capture/README.md index 35945dc7..e729368e 100644 --- a/examples/openclaw-capture/README.md +++ b/examples/openclaw-capture/README.md @@ -1,93 +1,53 @@ -# OpenClaw Interaction Evidence +# OpenClaw Activity Records -Capture OpenClaw tool calls as signed PEAC interaction evidence receipts. - -## Install - -```bash -pnpm add @peac/adapter-openclaw @peac/capture-core -``` +Capture OpenClaw tool calls as offline-verifiable activity records with durable file-based storage and real signing. ## Run ```bash -pnpm demo +# From monorepo root +pnpm -C examples/openclaw-capture demo ``` ## What it does -1. Creates a capture session with in-memory storage -2. Captures three simulated OpenClaw tool call events (web_search, file_read, code_execute) -3. Verifies tamper-evident chain integrity (each entry links to the previous via digest) -4. Emits signed receipts via the background service -5. Prints receipt summaries with interaction IDs and JWS tokens +1. Generates a signing key (to a temp directory) +2. Activates the evidence export plugin with durable file-based storage +3. Captures 3 simulated tool call events (web_search, file_read, code_execute) +4. Exports an evidence bundle (manifest.json + signed receipts) +5. Verifies the bundle offline ## Requirements -- Node.js 20+ +- Node.js 22+ - pnpm 8+ - -## Caveats - -- The demo signer produces a structurally valid but **not cryptographically signed** JWS. - For real signing, use an Ed25519 key via `@peac/crypto` or a standard JOSE library. -- In-memory stores are inlined in the demo. In production, use a durable store - (filesystem, database, etc). - -## Using this outside the PEAC monorepo - -This example uses `workspace:*` dependencies for monorepo development. -To run it standalone: - -```bash -mkdir openclaw-demo && cd openclaw-demo -npm init -y -pnpm add @peac/adapter-openclaw@0.10.9 @peac/capture-core@0.10.9 -pnpm add -D @types/node tsx typescript -``` - -Then copy `demo.ts` and `tsconfig.json` into the directory and run `npx tsx demo.ts`. +- Run from the monorepo root (uses `workspace:*` dependencies) ## Expected output ``` -OpenClaw Interaction Evidence Demo - -1. Creating capture session... - Session ready. +1. Generating signing key... + kid: -2. Capturing tool calls... - Captured: web_search -> digest a1b2c3d4... - Captured: file_read -> digest f7e8d9c0... - Captured: code_execute -> digest 1234abcd... +2. Activating plugin... + Plugin active. -3. Verifying chain integrity... - Chain OK: 3 entries, all linked +3. Capturing tool calls... + web_search: captured + file_read: captured + code_execute: captured -4. Emitting signed receipts... - Emitted 3 receipts +4. Flushing receipts... + Receipts signed and written. -5. Receipt summary: - - r_ - interaction_id: openclaw/cnVuX2RlbW8/Y2FsbF8wMDE - jws: eyJhbGciOiJFZERTQSJ9... +5. Exporting evidence bundle... + Exported 3 receipts. -Done. All tool calls captured as verifiable interaction evidence. -``` - -## Two-stage pipeline +6. Verifying bundle... +verification successful -- 3 receipts in evidence bundle ``` -Tool Call Spool Receipts -(sync hook) --> (append-only) --> (signed JWS) -< 10ms events.jsonl *.peac.json -``` - -- **Capture stage**: Maps OpenClaw events to CapturedAction, hashes payloads inline -- **Emit stage**: Drains spool, converts to InteractionEvidence, signs with Ed25519 -## Next steps +## License -- See [docs/integrations/openclaw.md](../../docs/integrations/openclaw.md) for full configuration -- See [docs/specs/INTERACTION-EVIDENCE.md](../../docs/specs/INTERACTION-EVIDENCE.md) for the schema spec -- See [examples/quickstart/](../quickstart/) for basic receipt issuance and verification +Apache-2.0 diff --git a/examples/openclaw-capture/demo.ts b/examples/openclaw-capture/demo.ts index 03a6b967..adfebc0b 100644 --- a/examples/openclaw-capture/demo.ts +++ b/examples/openclaw-capture/demo.ts @@ -1,227 +1,147 @@ /** - * OpenClaw Interaction Evidence Demo + * OpenClaw Activity Records Demo * - * Capture OpenClaw tool calls and emit signed PEAC receipts. - * Run with: pnpm demo + * Generates a signing key, activates the evidence export plugin with + * durable file-based storage, captures tool call events, exports an + * evidence bundle, and verifies it offline. + * + * Run: pnpm demo (from monorepo root: pnpm -C examples/openclaw-capture demo) */ -import { Buffer } from 'node:buffer'; -import { createCaptureSession, createHasher, GENESIS_DIGEST } from '@peac/capture-core'; -import type { SpoolStore, SpoolEntry, DedupeIndex, DedupeEntry } from '@peac/capture-core'; -import { - createHookHandler, - createReceiptEmitter, - createBackgroundService, -} from '@peac/adapter-openclaw'; - -// --------------------------------------------------------------------------- -// In-memory store implementations (for demo only). -// In production, use a durable store (filesystem, database, etc). -// --------------------------------------------------------------------------- - -class MemorySpoolStore implements SpoolStore { - private entries: SpoolEntry[] = []; - private headDigest = GENESIS_DIGEST; - private seq = 0; - - async append(entry: SpoolEntry): Promise { - this.entries.push(entry); - this.headDigest = entry.entry_digest; - this.seq = entry.sequence; - return entry.sequence; - } - async commit(): Promise {} - async read(from: number, limit?: number): Promise { - const start = from > 0 ? from - 1 : 0; - return limit ? this.entries.slice(start, start + limit) : this.entries.slice(start); - } - async getHeadDigest(): Promise { - return this.headDigest; - } - async getSequence(): Promise { - return this.seq; - } - async close(): Promise {} - - /** Get all entries for inspection (not part of SpoolStore interface). */ - getAllEntries(): SpoolEntry[] { - return [...this.entries]; +import * as path from 'node:path'; +import * as os from 'node:os'; +import * as fs from 'node:fs/promises'; +import { activate, generateSigningKey } from '@peac/adapter-openclaw'; +import type { PluginTool } from '@peac/adapter-openclaw'; + +// ============================================================================= +// Helpers +// ============================================================================= + +/** Look up a tool by name. Throws with available names on miss. */ +function getTool(tools: PluginTool[], name: string): PluginTool { + const tool = tools.find((t) => t.name === name); + if (!tool) { + const available = tools.map((t) => t.name).join(', '); + throw new Error(`Tool "${name}" not found. Available: ${available}`); } + return tool; } -class MemoryDedupeIndex implements DedupeIndex { - private map = new Map(); +// ============================================================================= +// Demo +// ============================================================================= + +async function main(): Promise { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'peac-demo-')); + + try { + // Step 1: Generate a signing key + console.log('1. Generating signing key...'); + const key = await generateSigningKey({ outputDir: tmpDir }); + console.log(` kid: ${key.kid}`); + + // Step 2: Activate the evidence export plugin + console.log('\n2. Activating plugin...'); + const result = await activate({ + config: { + signing: { + key_ref: `file:${key.keyPath}`, + issuer: 'https://demo.example.com', + }, + }, + dataDir: tmpDir, + spoolOptions: { + autoCommitIntervalMs: 0, // disable timer so demo exits cleanly + }, + }); + + // Step 3: Start the background emitter + result.instance.start(); + console.log(' Plugin active.'); + + // Step 4: Capture 3 tool call events + console.log('\n3. Capturing tool calls...'); + const events = [ + { + tool_call_id: 'call_001', + run_id: 'run_demo', + tool_name: 'web_search', + started_at: new Date().toISOString(), + completed_at: new Date().toISOString(), + status: 'ok' as const, + input: { query: 'PEAC protocol' }, + output: { results: ['result1', 'result2'] }, + }, + { + tool_call_id: 'call_002', + run_id: 'run_demo', + tool_name: 'read', + started_at: new Date().toISOString(), + completed_at: new Date().toISOString(), + status: 'ok' as const, + input: { path: '/tmp/example.txt' }, + output: { content: 'file contents here' }, + }, + { + tool_call_id: 'call_003', + run_id: 'run_demo', + tool_name: 'exec', + started_at: new Date().toISOString(), + completed_at: new Date().toISOString(), + status: 'ok' as const, + input: { command: 'echo hello' }, + output: { stdout: 'hello' }, + }, + ]; - async get(id: string) { - return this.map.get(id); - } - async set(id: string, entry: DedupeEntry) { - this.map.set(id, entry); - } - async has(id: string) { - return this.map.has(id); - } - async markEmitted(id: string) { - const e = this.map.get(id); - if (!e) return false; - e.emitted = true; - return true; - } - async delete(id: string) { - return this.map.delete(id); - } - async size() { - return this.map.size; - } - async clear() { - this.map.clear(); - } -} + for (const event of events) { + const captureResult = await result.hookHandler.afterToolCall(event); + console.log(` ${event.tool_name}: ${captureResult.success ? 'captured' : 'failed'}`); + } -// --------------------------------------------------------------------------- -// Demo -// --------------------------------------------------------------------------- - -async function main() { - console.log('OpenClaw Interaction Evidence Demo\n'); - - // 1. Create a capture session - console.log('1. Creating capture session...'); - const store = new MemorySpoolStore(); - - const session = createCaptureSession({ - store, - dedupe: new MemoryDedupeIndex(), - hasher: createHasher(), - }); - - const handler = createHookHandler({ session }); - console.log(' Session ready.\n'); - - // 2. Simulate OpenClaw tool call events - console.log('2. Capturing tool calls...'); - - const events = [ - { - tool_call_id: 'call_001', - run_id: 'run_demo', - tool_name: 'web_search', - started_at: '2026-02-10T10:00:00Z', - completed_at: '2026-02-10T10:00:01Z', - status: 'ok' as const, - input: { query: 'PEAC protocol receipts' }, - output: { results: ['peacprotocol.org', 'github.com/peacprotocol/peac'] }, - }, - { - tool_call_id: 'call_002', - run_id: 'run_demo', - tool_name: 'file_read', - started_at: '2026-02-10T10:00:02Z', - completed_at: '2026-02-10T10:00:02Z', - status: 'ok' as const, - input: { path: '/docs/README.md' }, - output: { content: 'PEAC Protocol documentation...' }, - }, - { - tool_call_id: 'call_003', - run_id: 'run_demo', - tool_name: 'code_execute', - started_at: '2026-02-10T10:00:03Z', - completed_at: '2026-02-10T10:00:04Z', - status: 'ok' as const, - input: { code: 'console.log("hello")' }, - output: { stdout: 'hello' }, - }, - ]; - - for (const event of events) { - const result = await handler.afterToolCall(event); - if (result.success) { + // Step 5: Flush -- sign and write all pending entries + console.log('\n4. Flushing receipts...'); + await result.flush(); + console.log(' Receipts signed and written.'); + + // Step 6: Export an evidence bundle + console.log('\n5. Exporting evidence bundle...'); + const exportTool = getTool(result.tools, 'peac_receipts.export_bundle'); + const bundlePath = path.join(tmpDir, 'demo-bundle'); + const exportResult = (await exportTool.execute({ output_path: bundlePath })) as { + status: string; + receipt_count: number; + bundle_path?: string; + }; + console.log(` Exported ${exportResult.receipt_count} receipts.`); + + // Step 7: Verify the bundle offline + console.log('\n6. Verifying bundle...'); + const verifyTool = getTool(result.tools, 'peac_receipts.verify'); + const verifyResult = (await verifyTool.execute({ path: bundlePath })) as { + status: string; + valid: boolean; + bundle_stats?: { total: number; valid: number }; + }; + + if (verifyResult.valid && verifyResult.bundle_stats) { console.log( - ` Captured: ${event.tool_name} -> digest ${result.entry.entry_digest.slice(0, 16)}...` + `\nverification successful -- ${verifyResult.bundle_stats.total} receipts in evidence bundle` ); } else { - console.error(` Failed: ${event.tool_name} -> ${result.code}`); + console.error('\nverification failed'); + process.exitCode = 1; } - } - console.log(); - // 3. Verify tamper-evident chain - console.log('3. Verifying chain integrity...'); - const entries = store.getAllEntries(); - - if (entries.length === 0) throw new Error('No entries captured'); - let prev = GENESIS_DIGEST; - for (let i = 0; i < entries.length; i++) { - if (entries[i].prev_entry_digest !== prev) { - throw new Error(`Chain break at index ${i}`); - } - prev = entries[i].entry_digest; + // Step 8: Shutdown + await result.shutdown(); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); } - console.log(` Chain OK: ${entries.length} entries, all linked`); - console.log(); - - // 4. Emit signed receipts via background service - console.log('4. Emitting signed receipts...'); - - const emittedDigests: string[] = []; - const writtenReceipts: Array<{ rid: string; interaction_id: string; jws: string }> = []; - - // NOTE: This demo signer produces a structurally valid but cryptographically - // meaningless JWS. It is NOT a real signature. For production use: - // - Generate an Ed25519 key (see docs/integrations/openclaw.md) - // - Use @peac/crypto or a standard JOSE library for signing - // - Never sign unredacted secrets - const emitter = createReceiptEmitter({ - signer: { - async sign(payload: unknown): Promise { - const json = JSON.stringify(payload); - return `eyJhbGciOiJFZERTQSJ9.${Buffer.from(json).toString('base64url')}.demo_signature`; - }, - getKeyId: () => 'demo-key-2026', - getIssuer: () => 'https://demo.example.com', - getAudience: () => 'https://api.example.com', - }, - writer: { - async write(receipt) { - writtenReceipts.push(receipt); - return `/receipts/${receipt.rid}.peac.json`; - }, - async close() {}, - }, - }); - - const service = createBackgroundService({ - emitter, - getPendingEntries: async () => { - return entries.filter((e) => !emittedDigests.includes(e.entry_digest)); - }, - markEmitted: async (digest) => { - emittedDigests.push(digest); - }, - }); - - await service.drain(); - - console.log(` Emitted ${writtenReceipts.length} receipts\n`); - - // 5. Inspect the receipts - console.log('5. Receipt summary:'); - for (const receipt of writtenReceipts) { - console.log(` - ${receipt.rid}`); - console.log(` interaction_id: ${receipt.interaction_id}`); - console.log(` jws: ${receipt.jws.slice(0, 50)}...`); - console.log(); - } - - // 6. Cleanup - await session.close(); - await emitter.close(); - - console.log('Done. All tool calls captured as verifiable interaction evidence.'); } -main().catch((err) => { - console.error('Error:', err); - process.exit(1); +main().catch((err: unknown) => { + console.error(err); + process.exitCode = 1; }); diff --git a/examples/openclaw-capture/package.json b/examples/openclaw-capture/package.json index 1049297e..7a4ec21c 100644 --- a/examples/openclaw-capture/package.json +++ b/examples/openclaw-capture/package.json @@ -2,15 +2,14 @@ "name": "@peac/example-openclaw-capture", "version": "0.0.0", "private": true, - "description": "Capture OpenClaw tool calls as signed PEAC interaction evidence receipts", + "description": "Capture OpenClaw tool calls as offline-verifiable activity records", "scripts": { "demo": "tsx demo.ts", "build": "tsc", "typecheck": "tsc --noEmit" }, "dependencies": { - "@peac/adapter-openclaw": "workspace:*", - "@peac/capture-core": "workspace:*" + "@peac/adapter-openclaw": "workspace:*" }, "devDependencies": { "@types/node": "^20.19.33", diff --git a/packages/adapters/openclaw/README.md b/packages/adapters/openclaw/README.md index 1cb347ab..4497c90f 100644 --- a/packages/adapters/openclaw/README.md +++ b/packages/adapters/openclaw/README.md @@ -1,82 +1,122 @@ # @peac/adapter-openclaw -OpenClaw adapter for PEAC interaction evidence capture. +Offline-verifiable activity records for OpenClaw sessions. -## Overview +## Quick Start + +```typescript +import { activate, generateSigningKey } from '@peac/adapter-openclaw'; + +// Generate a signing key (one-time setup) +const key = await generateSigningKey({ outputDir: '.peac' }); + +// Activate the plugin +const plugin = await activate({ + config: { + signing: { + key_ref: `file:${key.keyPath}`, + issuer: 'https://my-org.example.com', + }, + }, +}); + +plugin.instance.start(); +// Records are now captured automatically via hooks. +``` + +> Never log or print the contents of `key_ref`. Use `env:` key references in CI, `file:` for local development. Signing key files are written with 0600 permissions (owner read/write only). + +## What It Records -`@peac/adapter-openclaw` provides a complete capture pipeline for recording OpenClaw tool calls as PEAC interaction evidence. It includes: +Every tool call your agent makes is captured as a tamper-evident record: -- **Mapper**: OpenClaw tool call events -> PEAC `CapturedAction` -- **Hooks**: Sync capture bindings (< 10ms target) -- **Emitter**: Background receipt signing and persistence -- **Tailer**: Session history fallback for resilience +- **Tool name, timing, status** -- what ran, when, whether it succeeded +- **Input/output digests** -- SHA-256 hashes by default (payloads are never stored in plaintext unless you explicitly allowlist a tool) +- **Chain linkage** -- each record links to the previous one, so gaps or reordering are detectable -## Installation +Records are signed with your key and written to disk as individual `.peac.json` files. + +## Commands + +### Key Generation ```bash -pnpm add @peac/adapter-openclaw +npx peac-keygen --output-dir .peac ``` -## Quick Start +Generates a signing keypair. The private key is written with 0600 permissions. Print the `kid` and public key for registration with your infrastructure. -```typescript -import { createCaptureSession, createHasher } from '@peac/capture-core'; -import { createInMemorySpoolStore, createInMemoryDedupeIndex } from '@peac/capture-core/testkit'; -import { createHookHandler, mapToolCallEvent } from '@peac/adapter-openclaw'; - -// Create a capture session -const session = createCaptureSession({ - store: createInMemorySpoolStore(), - dedupe: createInMemoryDedupeIndex(), - hasher: createHasher(), -}); +### Plugin Tools + +The plugin exposes 4 tools to the agent: -// Create a hook handler -const handler = createHookHandler({ - session, +| Tool | Description | +| ----------------------------- | ----------------------------------------------------------------- | +| `peac_receipts.status` | Spool size, last receipt time, configuration summary | +| `peac_receipts.export_bundle` | Export receipts as an evidence bundle (manifest.json + receipts/) | +| `peac_receipts.verify` | Verify a receipt or bundle offline | +| `peac_receipts.query` | Query receipts by workflow ID, tool name, or time range | + +## Configuration + +### `activate()` Options + +```typescript +const plugin = await activate({ config: { - platform: 'openclaw', - platform_version: '0.2.0', + signing: { + key_ref: 'env:PEAC_SIGNING_KEY', // or 'file:/path/to/key.jwk' + issuer: 'https://my-org.example.com', + audience: 'https://api.example.com', // optional + }, + output_dir: '.peac/receipts', // optional (default: {dataDir}/receipts/) + background: { + drain_interval_ms: 1000, // optional (default: 1000) + batch_size: 100, // optional (default: 100) + }, + }, + dataDir: '.peac', // optional (default: ~/.openclaw/peac/) + spoolOptions: { + maxEntries: 100_000, // optional (default: 100,000) + maxFileBytes: 104_857_600, // optional (default: 100MB) + autoCommitIntervalMs: 5000, // optional (default: 5000, 0 to disable) }, }); +``` -// Handle OpenClaw tool call events -const result = await handler.afterToolCall({ - tool_call_id: 'call_123', - run_id: 'run_abc', - tool_name: 'web_search', - started_at: '2024-02-01T10:00:00Z', - completed_at: '2024-02-01T10:00:01Z', - status: 'ok', - input: { query: 'hello world' }, - output: { results: ['result1'] }, -}); +### Key Reference Schemes -if (result.success) { - console.log('Captured:', result.entry.entry_digest); -} +| Scheme | Format | Use Case | +| ------- | ----------------------- | ---------------------------------------------- | +| `env:` | `env:PEAC_SIGNING_KEY` | CI/CD, containers (key in env var as JWK JSON) | +| `file:` | `file:/path/to/key.jwk` | Local development (key file on disk) | -await handler.close(); -``` +## Compatibility + +| Requirement | Value | +| ------------- | ---------------------------------------------------------- | +| Node.js | >= 22.0.0 | +| Module format | ESM (`.mjs`) and CJS (`.cjs`) dual-published | +| Runtime | Node.js only (uses `fs`, `path`, `crypto`) | +| Workers/Edge | Not supported (requires file system access) | +| OpenClaw | v2026.2.7+ (before_tool_call or tool_result_persist hooks) | ## Architecture ### Two-Stage Pipeline -1. **Capture Stage** (sync, < 10ms target) - - Map OpenClaw event to `CapturedAction` - - Hash payloads inline (truncate large payloads) - - Write to tamper-evident spool +```text +Tool Call Spool Receipts +(sync hook) --> (append-only) --> (signed receipt) +< 10ms spool.jsonl *.peac.json +``` -2. **Emit Stage** (async background) - - Drain spool periodically - - Convert to `InteractionEvidenceV01` - - Sign with configured key - - Write receipt to output +1. **Capture stage** (sync, < 10ms target): Map OpenClaw event to a captured action, hash payloads inline, write to tamper-evident spool +2. **Emit stage** (async background): Drain spool periodically, convert to interaction evidence, sign with configured key, write receipt to output directory -### OpenClaw to PEAC Mapping +### OpenClaw-to-Receipt Mapping -| OpenClaw Concept | PEAC Location | +| OpenClaw Concept | Receipt Field | | --------------------- | --------------------------- | | Session key | `workflow_id` (correlation) | | Run ID + tool_call_id | `interaction_id` (dedupe) | @@ -88,85 +128,47 @@ await handler.close(); ## API Reference -### Mapper - -```typescript -import { mapToolCallEvent, mapToolCallEventBatch } from '@peac/adapter-openclaw'; - -// Map single event -const result = mapToolCallEvent(event, config); -if (result.success) { - console.log(result.action); -} - -// Map batch -const results = mapToolCallEventBatch(events, config); -``` - -### Hook Handler +### High-Level (Recommended) ```typescript -import { createHookHandler, captureBatch, captureParallel } from '@peac/adapter-openclaw'; +import { activate, generateSigningKey } from '@peac/adapter-openclaw'; -const handler = createHookHandler({ - session, - config: { platform: 'openclaw' }, - onCapture: (result, event) => { - console.log('Captured:', result); - }, -}); +// activate() wires all components from config +const plugin = await activate(options); +plugin.instance.start(); -// Single event -await handler.afterToolCall(event); +// Capture events via the hook handler +await plugin.hookHandler.afterToolCall(event); -// Batch (sequential) -await captureBatch(handler, events); +// Flush pending records into signed receipts +await plugin.flush(); -// Batch (parallel - non-deterministic order) -await captureParallel(handler, events); +// Clean shutdown (flushes + closes stores) +await plugin.shutdown(); ``` -### Background Emitter +### Lower-Level (Advanced) -```typescript -import { createReceiptEmitter, createBackgroundService } from '@peac/adapter-openclaw'; - -const emitter = createReceiptEmitter({ - signer, - writer, - config: { platform: 'openclaw' }, -}); +For custom wiring or when you need direct access to individual components: -const service = createBackgroundService({ - emitter, - getPendingEntries: () => spoolStore.getPending(), - markEmitted: (digest) => dedupeIndex.markEmitted(digest), - drainIntervalMs: 1000, -}); - -service.start(); -// ... later -service.stop(); +```typescript +import { + createHookHandler, + createReceiptEmitter, + createBackgroundService, + mapToolCallEvent, + createSessionHistoryTailer, +} from '@peac/adapter-openclaw'; ``` -### Session History Tailer (Fallback) +See the source for `createHookHandler()`, `createReceiptEmitter()`, `createBackgroundService()`, and `createSessionHistoryTailer()`. -```typescript -import { createSessionHistoryTailer } from '@peac/adapter-openclaw'; +## Security -const tailer = createSessionHistoryTailer({ - handler, - sessionId: 'session_123', - fetchHistory: async (sessionId, afterEventId) => { - return openclaw.sessions.getHistory(sessionId, { after: afterEventId }); - }, - pollIntervalMs: 1000, -}); - -tailer.start(); -// ... later -tailer.stop(); -``` +- **Privacy by default**: All inputs and outputs are hashed (SHA-256). Plaintext is only captured for explicitly allowlisted tools. +- **Key protection**: Signing key files are written with 0600 permissions. Use `env:` references in CI to avoid keys on disk. +- **Tamper evidence**: Each spool entry links to the previous via digest. Gaps or reordering are detectable. +- **No secret logging**: The adapter never logs private key material. Only the key ID (`kid`) and public key component appear in logs. ## Error Codes diff --git a/packages/adapters/openclaw/src/activate.ts b/packages/adapters/openclaw/src/activate.ts index d9f74617..85de9c89 100644 --- a/packages/adapters/openclaw/src/activate.ts +++ b/packages/adapters/openclaw/src/activate.ts @@ -86,6 +86,8 @@ export interface ActivateResult { hookHandler: OpenClawHookHandler; /** Data directory used. */ dataDir: string; + /** Flush pending spool entries into signed receipts. */ + flush: () => Promise; /** Shut down the plugin cleanly. */ shutdown: () => Promise; } @@ -222,6 +224,7 @@ export async function activate(options: ActivateOptions): Promise instance.backgroundService.drain(), shutdown, }; } diff --git a/packages/adapters/openclaw/src/plugin.ts b/packages/adapters/openclaw/src/plugin.ts index c0e67fa6..3e8594c0 100644 --- a/packages/adapters/openclaw/src/plugin.ts +++ b/packages/adapters/openclaw/src/plugin.ts @@ -283,16 +283,28 @@ export async function createFileReceiptWriter(outputDir: string): Promise): { + auth: Record; + evidence: Record; +} | null { + const jws = receipt._jws; + if (typeof jws !== 'string') return null; + + const parts = jws.split('.'); + if (parts.length !== 3) return null; + + try { + const payloadJson = Buffer.from(parts[1], 'base64url').toString('utf-8'); + const payload = JSON.parse(payloadJson); + const { evidence, ...auth } = payload; + return { auth: auth ?? {}, evidence: evidence ?? {} }; + } catch { + return null; + } +} + /** Algorithm to key type compatibility map */ const ALG_KEY_TYPE_MAP: Record = { EdDSA: { kty: 'OKP', crv: ['Ed25519', 'Ed448'] }, @@ -217,8 +242,10 @@ export function createExportBundleTool(outputDir: string, logger: PluginLogger): }; } - // Filter receipts + // Filter receipts with structured skip tracking const receipts: Array<{ file: string; content: unknown; mtime: Date }> = []; + const skippedReasons = { stat_error: 0, invalid_json: 0, malformed_jws: 0, filtered: 0 }; + const skippedFiles: string[] = []; for (const file of receiptFiles) { const filePath = pathModule.join(resolvedOutputDir, file); @@ -226,15 +253,18 @@ export function createExportBundleTool(outputDir: string, logger: PluginLogger): try { stat = await fs.promises.stat(filePath); } catch { - // Skip files that can't be stat'd + skippedReasons.stat_error++; + skippedFiles.push(file); continue; } // Apply time filters if (params.since && stat.mtime < new Date(params.since)) { + skippedReasons.filtered++; continue; } if (params.until && stat.mtime > new Date(params.until)) { + skippedReasons.filtered++; continue; } @@ -244,13 +274,30 @@ export function createExportBundleTool(outputDir: string, logger: PluginLogger): content = JSON.parse(await fs.promises.readFile(filePath, 'utf-8')); } catch { logger.warn(`Skipping invalid JSON in ${file}`); + skippedReasons.invalid_json++; + skippedFiles.push(file); continue; } - // Apply workflow filter + // Apply workflow filter (derive from _jws when present) if (params.workflow_id) { - const workflowExt = content?.auth?.extensions?.['org.peacprotocol/workflow']; + const decoded = content._jws ? decodeReceiptPayload(content) : null; + // If _jws exists but is malformed, skip this receipt (untrusted) + if (content._jws && !decoded) { + logger.warn(`Skipping ${file}: _jws present but malformed`); + skippedReasons.malformed_jws++; + skippedFiles.push(file); + continue; + } + const authBlock = (decoded?.auth ?? content?.auth) as + | Record + | undefined; + const extensions = authBlock?.extensions as Record | undefined; + const workflowExt = extensions?.['org.peacprotocol/workflow'] as + | Record + | undefined; if (workflowExt?.workflow_id !== params.workflow_id) { + skippedReasons.filtered++; continue; } } @@ -263,6 +310,15 @@ export function createExportBundleTool(outputDir: string, logger: PluginLogger): status: 'ok', message: 'No receipts match the filter criteria', receipt_count: 0, + scanned_count: receiptFiles.length, + exported_count: 0, + skipped_count: + skippedReasons.stat_error + + skippedReasons.invalid_json + + skippedReasons.malformed_jws + + skippedReasons.filtered, + skipped_reasons: skippedReasons, + skipped_files: skippedFiles.length > 0 ? skippedFiles.slice(0, 100) : undefined, }; } @@ -315,6 +371,15 @@ export function createExportBundleTool(outputDir: string, logger: PluginLogger): message: `Exported ${receipts.length} receipts to directory`, receipt_count: receipts.length, bundle_path: bundleDir, + scanned_count: receiptFiles.length, + exported_count: receipts.length, + skipped_count: + skippedReasons.stat_error + + skippedReasons.invalid_json + + skippedReasons.malformed_jws + + skippedReasons.filtered, + skipped_reasons: skippedReasons, + skipped_files: skippedFiles.length > 0 ? skippedFiles.slice(0, 100) : undefined, }; } catch (error) { logger.error('Export bundle failed:', error); @@ -340,6 +405,16 @@ interface ExportBundleResult { message: string; receipt_count: number; bundle_path?: string; + scanned_count?: number; + exported_count?: number; + skipped_count?: number; + skipped_reasons?: { + stat_error: number; + invalid_json: number; + malformed_jws: number; + filtered: number; + }; + skipped_files?: string[]; } // ============================================================================= @@ -426,16 +501,61 @@ async function verifySingleReceipt( const errors: string[] = []; const warnings: string[] = []; - // Basic structure validation - if (!receipt.auth) { + // Trust boundary: _jws is the source of truth for signed data. + // If _jws is present but malformed, the receipt is invalid (possible tampering). + // Only allow unsigned top-level fields when _jws is genuinely absent. + let auth: unknown; + let evidence: unknown; + + if (!receipt._jws) { + // No JWS -- use top-level fields (unsigned, best-effort) + auth = receipt.auth; + evidence = receipt.evidence; + warnings.push('Missing _jws field -- using unsigned top-level fields'); + } else { + const decoded = decodeReceiptPayload(receipt); + if (!decoded) { + // _jws exists but is malformed -- receipt is invalid, do not fall back + errors.push('_jws field present but payload could not be decoded (malformed JWS)'); + auth = undefined; + evidence = undefined; + } else { + auth = decoded.auth; + evidence = decoded.evidence; + + // Dual-representation mismatch check: if top-level auth/evidence also exist, + // they MUST match the signed payload. A mismatch means the unsigned fields + // have been tampered with (or carelessly copied), creating ambiguity. + if (receipt.auth !== undefined || receipt.evidence !== undefined) { + const topAuth = receipt.auth as Record | undefined; + const topEvidence = receipt.evidence as Record | undefined; + const authMatch = + topAuth === undefined || JSON.stringify(topAuth) === JSON.stringify(decoded.auth); + const evidenceMatch = + topEvidence === undefined || + JSON.stringify(topEvidence) === JSON.stringify(decoded.evidence); + if (!authMatch || !evidenceMatch) { + errors.push( + 'Dual-representation mismatch: top-level auth/evidence differs from _jws payload' + ); + } + } + } + } + + if (!auth) { errors.push('Missing auth block'); } - if (!receipt.evidence) { + if (!evidence) { errors.push('Missing evidence block'); } // Check for interaction evidence - const interaction = receipt.evidence?.extensions?.['org.peacprotocol/interaction@0.1']; + const evidenceObj = evidence as Record | undefined; + const extensions = evidenceObj?.extensions as Record | undefined; + const interaction = extensions?.['org.peacprotocol/interaction@0.1'] as + | Record + | undefined; if (interaction) { // Validate interaction fields if (!interaction.interaction_id) { @@ -444,7 +564,8 @@ async function verifySingleReceipt( if (!interaction.kind) { errors.push('Missing kind'); } - if (!interaction.executor?.platform) { + const executor = interaction.executor as Record | undefined; + if (!executor?.platform) { errors.push('Missing executor.platform'); } if (!interaction.started_at) { @@ -453,13 +574,16 @@ async function verifySingleReceipt( // Check timing invariant if (interaction.completed_at && interaction.started_at) { - if (new Date(interaction.completed_at) < new Date(interaction.started_at)) { + if ( + new Date(interaction.completed_at as string) < new Date(interaction.started_at as string) + ) { errors.push('completed_at is before started_at'); } } // Check output requires result - if (interaction.output && !interaction.result?.status) { + const result = interaction.result as Record | undefined; + if (interaction.output && !result?.status) { errors.push('output present but result.status missing'); } } else { @@ -599,8 +723,8 @@ async function verifySingleReceipt( message: errors.length === 0 ? 'Receipt is valid' : 'Receipt has validation errors', errors: errors.length > 0 ? errors : undefined, warnings: warnings.length > 0 ? warnings : undefined, - receipt_id: receipt.auth?.rid, - interaction_id: interaction?.interaction_id, + receipt_id: (auth as Record)?.rid as string | undefined, + interaction_id: (interaction as Record)?.interaction_id as string | undefined, }; } @@ -775,6 +899,7 @@ export function createQueryTool(outputDir: string, logger: PluginLogger): Plugin let filesSkippedForSize = 0; let filesSkippedForInvalidJson = 0; + let filesSkippedForMalformedJws = 0; for (const { file } of fileInfos) { const filePath = pathModule.join(resolvedOutputDir, file); @@ -799,8 +924,27 @@ export function createQueryTool(outputDir: string, logger: PluginLogger): Plugin continue; } - const interaction = content?.evidence?.extensions?.['org.peacprotocol/interaction@0.1']; - const workflow = content?.auth?.extensions?.['org.peacprotocol/workflow']; + // Derive filter data from _jws (source of truth when present). + // If _jws exists but is malformed, skip this receipt (untrusted). + const decoded = content._jws ? decodeReceiptPayload(content) : null; + if (content._jws && !decoded) { + filesSkippedForMalformedJws++; + continue; + } + const authBlock = (decoded?.auth ?? content?.auth) as Record | undefined; + const evidenceBlock = (decoded?.evidence ?? content?.evidence) as + | Record + | undefined; + const evidenceExtensions = evidenceBlock?.extensions as + | Record + | undefined; + const interaction = evidenceExtensions?.['org.peacprotocol/interaction@0.1'] as + | Record + | undefined; + const authExtensions = authBlock?.extensions as Record | undefined; + const workflow = authExtensions?.['org.peacprotocol/workflow'] as + | Record + | undefined; // Apply workflow filter if (params.workflow_id && workflow?.workflow_id !== params.workflow_id) { @@ -809,26 +953,28 @@ export function createQueryTool(outputDir: string, logger: PluginLogger): Plugin } // Apply tool filter - if (params.tool_name && interaction?.tool?.name !== params.tool_name) { + const tool = interaction?.tool as Record | undefined; + if (params.tool_name && tool?.name !== params.tool_name) { skippedForFilters++; continue; } // Apply status filter - if (params.status && interaction?.result?.status !== params.status) { + const result = interaction?.result as Record | undefined; + if (params.status && result?.status !== params.status) { skippedForFilters++; continue; } matches.push({ file, - receipt_id: content.auth?.rid, - interaction_id: interaction?.interaction_id, - workflow_id: workflow?.workflow_id, - tool_name: interaction?.tool?.name, - status: interaction?.result?.status, - started_at: interaction?.started_at, - completed_at: interaction?.completed_at, + receipt_id: authBlock?.rid as string | undefined, + interaction_id: interaction?.interaction_id as string | undefined, + workflow_id: workflow?.workflow_id as string | undefined, + tool_name: tool?.name as string | undefined, + status: result?.status as string | undefined, + started_at: interaction?.started_at as string | undefined, + completed_at: interaction?.completed_at as string | undefined, }); // Early exit if we have enough matches (limit + offset) @@ -864,11 +1010,13 @@ export function createQueryTool(outputDir: string, logger: PluginLogger): Plugin if (filesSkippedForInvalidJson > 0) { warnings.push(`${filesSkippedForInvalidJson} files skipped (invalid JSON)`); } + if (filesSkippedForMalformedJws > 0) { + warnings.push(`${filesSkippedForMalformedJws} files skipped (malformed JWS)`); + } - const hasTruncation = - receiptFiles.length > MAX_QUERY_FILE_PARSE || - filesSkippedForSize > 0 || - filesSkippedForInvalidJson > 0; + const totalSkipped = + filesSkippedForSize + filesSkippedForInvalidJson + filesSkippedForMalformedJws; + const hasTruncation = receiptFiles.length > MAX_QUERY_FILE_PARSE || totalSkipped > 0; return { status: 'ok', @@ -876,17 +1024,22 @@ export function createQueryTool(outputDir: string, logger: PluginLogger): Plugin offset, limit, results: paginated, + scanned_count: fileInfos.length, + matched_count: matches.length, truncated: hasTruncation, - skipped: hasTruncation - ? { - too_large: filesSkippedForSize, - invalid_json: filesSkippedForInvalidJson, - capped: - receiptFiles.length > MAX_QUERY_FILE_PARSE - ? receiptFiles.length - MAX_QUERY_FILE_PARSE - : 0, - } - : undefined, + skipped: + hasTruncation || skippedForFilters > 0 + ? { + too_large: filesSkippedForSize, + invalid_json: filesSkippedForInvalidJson, + malformed_jws: filesSkippedForMalformedJws, + filtered: skippedForFilters, + capped: + receiptFiles.length > MAX_QUERY_FILE_PARSE + ? receiptFiles.length - MAX_QUERY_FILE_PARSE + : 0, + } + : undefined, ...(warnings.length > 0 && { warning: warnings.join('; ') }), }; } catch (error) { @@ -931,10 +1084,14 @@ interface QueryResult { offset: number; limit: number; results: QueryMatch[]; + scanned_count?: number; + matched_count?: number; truncated?: boolean; skipped?: { too_large: number; invalid_json: number; + malformed_jws: number; + filtered: number; capped: number; }; error?: string; diff --git a/packages/adapters/openclaw/tests/demo-contract.test.ts b/packages/adapters/openclaw/tests/demo-contract.test.ts new file mode 100644 index 00000000..a5c55b64 --- /dev/null +++ b/packages/adapters/openclaw/tests/demo-contract.test.ts @@ -0,0 +1,156 @@ +/** + * @peac/adapter-openclaw - Demo Contract Test + * + * Anti-rot test that mirrors the demo flow: generate key, activate, + * capture events, drain, export bundle, verify bundle, shutdown. + * + * Asserts on stable fields (counts, validity) not timestamps. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { activate } from '../src/activate.js'; +import { generateSigningKey } from '../src/keygen.js'; +import type { PluginTool } from '../src/plugin.js'; + +// ============================================================================= +// Helpers +// ============================================================================= + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'peac-demo-contract-')); +}); + +afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +function getTool(tools: PluginTool[], name: string): PluginTool { + const tool = tools.find((t) => t.name === name); + if (!tool) { + const available = tools.map((t) => t.name).join(', '); + throw new Error(`Tool "${name}" not found. Available: ${available}`); + } + return tool; +} + +function makeEvent(id: string, toolName: string) { + return { + tool_call_id: `call_${id}`, + run_id: 'run_contract_test', + tool_name: toolName, + started_at: new Date().toISOString(), + completed_at: new Date().toISOString(), + status: 'ok' as const, + input: { key: `input_${id}` }, + output: { key: `output_${id}` }, + }; +} + +// ============================================================================= +// Tests +// ============================================================================= + +describe('demo contract', () => { + it('full flow: keygen -> activate -> capture -> drain -> export -> verify', async () => { + // 1. Generate signing key + const key = await generateSigningKey({ outputDir: tmpDir }); + expect(key.kid).toBeDefined(); + expect(key.keyPath).toContain(tmpDir); + + // 2. Activate plugin + const result = await activate({ + config: { + signing: { + key_ref: `file:${key.keyPath}`, + issuer: 'https://contract-test.example.com', + }, + }, + dataDir: tmpDir, + spoolOptions: { + autoCommitIntervalMs: 0, + }, + }); + + try { + // 3. Start background service + result.instance.start(); + + // 4. Capture 3 events + const events = [ + makeEvent('001', 'web_search'), + makeEvent('002', 'file_read'), + makeEvent('003', 'code_execute'), + ]; + + for (const event of events) { + const captureResult = await result.hookHandler.afterToolCall(event); + expect(captureResult.success).toBe(true); + } + + // 5. Flush barrier (stable API surface) + await result.flush(); + + // 6. Export bundle + const exportTool = getTool(result.tools, 'peac_receipts.export_bundle'); + const bundlePath = path.join(tmpDir, 'test-bundle'); + const exportResult = (await exportTool.execute({ output_path: bundlePath })) as { + status: string; + receipt_count: number; + bundle_path?: string; + }; + + expect(exportResult.status).toBe('ok'); + expect(exportResult.receipt_count).toBe(3); + + // 7. Verify bundle + const verifyTool = getTool(result.tools, 'peac_receipts.verify'); + const verifyResult = (await verifyTool.execute({ path: bundlePath })) as { + status: string; + valid: boolean; + bundle_stats?: { total: number; valid: number }; + }; + + expect(verifyResult.valid).toBe(true); + expect(verifyResult.bundle_stats).toBeDefined(); + expect(verifyResult.bundle_stats!.total).toBe(3); + expect(verifyResult.bundle_stats!.valid).toBe(3); + + // 8. Verify bundle directory structure + const bundleFiles = await fs.readdir(bundlePath); + expect(bundleFiles).toContain('manifest.json'); + expect(bundleFiles).toContain('receipts'); + + const receiptFiles = await fs.readdir(path.join(bundlePath, 'receipts')); + expect(receiptFiles).toHaveLength(3); + expect(receiptFiles.every((f) => f.endsWith('.peac.json'))).toBe(true); + } finally { + await result.shutdown(); + } + }); + + it('getTool throws with available names on miss', async () => { + const key = await generateSigningKey({ outputDir: tmpDir }); + const result = await activate({ + config: { + signing: { + key_ref: `file:${key.keyPath}`, + issuer: 'https://contract-test.example.com', + }, + }, + dataDir: tmpDir, + spoolOptions: { autoCommitIntervalMs: 0 }, + }); + + try { + expect(() => getTool(result.tools, 'nonexistent_tool')).toThrow(/not found/); + expect(() => getTool(result.tools, 'nonexistent_tool')).toThrow(/peac_receipts\.status/); + } finally { + await result.shutdown(); + } + }); +}); diff --git a/packages/adapters/openclaw/tests/plugin.test.ts b/packages/adapters/openclaw/tests/plugin.test.ts index de60f865..6997d220 100644 --- a/packages/adapters/openclaw/tests/plugin.test.ts +++ b/packages/adapters/openclaw/tests/plugin.test.ts @@ -303,11 +303,27 @@ describe('createFileReceiptWriter', () => { const outputDir = path.join(tempDir, 'receipts'); const writer = await createFileReceiptWriter(outputDir); + // Build a mock JWS with a valid base64url-encoded payload + const mockPayload = { + rid: 'r_test123', + iss: 'https://test.example.com', + iat: 1234567890, + evidence: { + extensions: { + 'org.peacprotocol/interaction@0.1': { + interaction_id: 'test_interaction', + }, + }, + }, + }; + const payloadB64 = Buffer.from(JSON.stringify(mockPayload)).toString('base64url'); + const mockJws = `eyJhbGciOiJFZERTQSJ9.${payloadB64}.dGVzdC1zaWduYXR1cmU`; + const receipt: SignedReceipt = { rid: 'r_test123', interaction_id: 'test_interaction', entry_digest: 'abc123', - jws: 'test.jws.signature', + jws: mockJws, }; const filePath = await writer.write(receipt); @@ -316,8 +332,12 @@ describe('createFileReceiptWriter', () => { expect(fs.existsSync(filePath)).toBe(true); const content = JSON.parse(fs.readFileSync(filePath, 'utf-8')); - expect(content.rid).toBe('r_test123'); - expect(content._jws).toBe('test.jws.signature'); + expect(content.auth.rid).toBe('r_test123'); + expect(content.auth.iss).toBe('https://test.example.com'); + expect(content.evidence.extensions['org.peacprotocol/interaction@0.1'].interaction_id).toBe( + 'test_interaction' + ); + expect(content._jws).toBe(mockJws); }); it('close is a no-op', async () => { diff --git a/packages/adapters/openclaw/tests/tools.test.ts b/packages/adapters/openclaw/tests/tools.test.ts index 15c88771..2939a7c4 100644 --- a/packages/adapters/openclaw/tests/tools.test.ts +++ b/packages/adapters/openclaw/tests/tools.test.ts @@ -41,38 +41,60 @@ function createMockStats() { }; } -function createValidReceipt(id: string, overrides?: Record) { - return { - auth: { - rid: `r_${id}`, - iss: 'https://issuer.example.com', - extensions: { - 'org.peacprotocol/workflow': { - workflow_id: 'wf_test', - }, +/** + * Build a structurally valid JWS compact serialization from auth + evidence. + * The payload is the real receipt data so decodeReceiptPayload() succeeds. + * Signature is a placeholder (not cryptographically valid). + */ +function buildMockJws(auth: Record, evidence: Record): string { + const header = Buffer.from(JSON.stringify({ alg: 'EdDSA', kid: 'test-key-id' })).toString( + 'base64url' + ); + const payload = Buffer.from(JSON.stringify({ ...auth, evidence })).toString('base64url'); + return `${header}.${payload}.fake_test_signature`; +} + +interface ReceiptOptions { + workflow_id?: string; + tool_name?: string; + status?: string; + overrides?: Record; +} + +function createValidReceipt(id: string, opts?: ReceiptOptions) { + const auth = { + rid: `r_${id}`, + iss: 'https://issuer.example.com', + extensions: { + 'org.peacprotocol/workflow': { + workflow_id: opts?.workflow_id ?? 'wf_test', }, }, - evidence: { - extensions: { - 'org.peacprotocol/interaction@0.1': { - interaction_id: `int_${id}`, - kind: 'tool.call', - executor: { - platform: 'openclaw', - }, - tool: { - name: 'web_search', - }, - started_at: '2024-02-01T10:00:00Z', - completed_at: '2024-02-01T10:00:01Z', - result: { - status: 'ok', - }, - ...overrides, + }; + const evidence = { + extensions: { + 'org.peacprotocol/interaction@0.1': { + interaction_id: `int_${id}`, + kind: 'tool.call', + executor: { + platform: 'openclaw', }, + tool: { + name: opts?.tool_name ?? 'web_search', + }, + started_at: '2024-02-01T10:00:00Z', + completed_at: '2024-02-01T10:00:01Z', + result: { + status: opts?.status ?? 'ok', + }, + ...opts?.overrides, }, }, - _jws: 'test.jws.signature', + }; + return { + auth, + evidence, + _jws: buildMockJws(auth, evidence), }; } @@ -230,13 +252,11 @@ describe('createExportBundleTool', () => { }); it('filters by workflow_id', async () => { - // Create receipts with different workflow IDs - const receipt1 = createValidReceipt('001'); - receipt1.auth.extensions['org.peacprotocol/workflow'] = { workflow_id: 'wf_alpha' }; + // Create receipts with different workflow IDs (JWS-consistent) + const receipt1 = createValidReceipt('001', { workflow_id: 'wf_alpha' }); fs.writeFileSync(path.join(tempDir, 'r_001.peac.json'), JSON.stringify(receipt1)); - const receipt2 = createValidReceipt('002'); - receipt2.auth.extensions['org.peacprotocol/workflow'] = { workflow_id: 'wf_beta' }; + const receipt2 = createValidReceipt('002', { workflow_id: 'wf_beta' }); fs.writeFileSync(path.join(tempDir, 'r_002.peac.json'), JSON.stringify(receipt2)); const tool = createExportBundleTool(tempDir, mockLogger); @@ -248,6 +268,40 @@ describe('createExportBundleTool', () => { expect(result.status).toBe('ok'); expect(result.receipt_count).toBe(1); }); + + it('returns structured skip counters', async () => { + // Create a valid receipt, an invalid JSON file, and a malformed JWS receipt + fs.writeFileSync( + path.join(tempDir, 'r_001.peac.json'), + JSON.stringify(createValidReceipt('001')) + ); + fs.writeFileSync(path.join(tempDir, 'r_bad.peac.json'), 'not valid json {{{'); + fs.writeFileSync( + path.join(tempDir, 'r_malformed.peac.json'), + JSON.stringify({ auth: {}, evidence: {}, _jws: 'not.valid' }) + ); + + const tool = createExportBundleTool(tempDir, mockLogger); + const result = (await tool.execute({ workflow_id: 'wf_test' })) as { + status: string; + receipt_count: number; + scanned_count?: number; + exported_count?: number; + skipped_count?: number; + skipped_reasons?: { invalid_json: number; malformed_jws: number }; + skipped_files?: string[]; + }; + + expect(result.status).toBe('ok'); + expect(result.scanned_count).toBe(3); + expect(result.exported_count).toBe(1); + expect(result.skipped_count).toBeGreaterThanOrEqual(2); + expect(result.skipped_reasons?.invalid_json).toBe(1); + expect(result.skipped_reasons?.malformed_jws).toBe(1); + expect(result.skipped_files).toBeDefined(); + expect(result.skipped_files).toContain('r_bad.peac.json'); + expect(result.skipped_files).toContain('r_malformed.peac.json'); + }); }); // ============================================================================= @@ -294,6 +348,48 @@ describe('createVerifyTool', () => { expect(result.warnings).toContain('No JWKS provided - signature not verified'); }); + it('detects dual-representation mismatch', async () => { + // Create a receipt where top-level auth differs from _jws payload + const receipt = createValidReceipt('001'); + // Tamper with top-level auth to create a mismatch + receipt.auth = { ...receipt.auth, iss: 'https://tampered.example.com' }; + + const receiptPath = path.join(tempDir, 'mismatch.peac.json'); + fs.writeFileSync(receiptPath, JSON.stringify(receipt)); + + const tool = createVerifyTool(mockLogger); + const result = (await tool.execute({ path: receiptPath })) as { + status: string; + valid: boolean; + errors?: string[]; + }; + + expect(result.status).toBe('error'); + expect(result.valid).toBe(false); + expect(result.errors?.some((e) => e.includes('Dual-representation mismatch'))).toBe(true); + }); + + it('accepts matching dual representation', async () => { + // Create a receipt where top-level matches _jws payload (no mismatch) + const receipt = createValidReceipt('001'); + // Top-level auth/evidence are already set by createValidReceipt and match _jws + + const receiptPath = path.join(tempDir, 'matching.peac.json'); + fs.writeFileSync(receiptPath, JSON.stringify(receipt)); + + const tool = createVerifyTool(mockLogger); + const result = (await tool.execute({ path: receiptPath })) as { + status: string; + valid: boolean; + errors?: string[]; + }; + + expect(result.status).toBe('ok'); + expect(result.valid).toBe(true); + // Should not have mismatch errors + expect(result.errors?.some((e) => e.includes('Dual-representation mismatch'))).toBeFalsy(); + }); + it('detects missing auth block', async () => { const invalidReceipt = { evidence: {} }; const receiptPath = path.join(tempDir, 'invalid.peac.json'); @@ -330,8 +426,10 @@ describe('createVerifyTool', () => { it('detects invalid timing (completed_at before started_at)', async () => { const invalidReceipt = createValidReceipt('001', { - started_at: '2024-02-01T10:00:01Z', - completed_at: '2024-02-01T10:00:00Z', // Before started_at + overrides: { + started_at: '2024-02-01T10:00:01Z', + completed_at: '2024-02-01T10:00:00Z', // Before started_at + }, }); const receiptPath = path.join(tempDir, 'invalid.peac.json'); fs.writeFileSync(receiptPath, JSON.stringify(invalidReceipt)); @@ -350,8 +448,10 @@ describe('createVerifyTool', () => { it('detects output without result status', async () => { const invalidReceipt = createValidReceipt('001', { - output: { digest: { alg: 'sha-256', value: 'a'.repeat(64), bytes: 100 } }, - result: undefined, // No result + overrides: { + output: { digest: { alg: 'sha-256', value: 'a'.repeat(64), bytes: 100 } }, + result: undefined, // No result + }, }); const receiptPath = path.join(tempDir, 'invalid.peac.json'); fs.writeFileSync(receiptPath, JSON.stringify(invalidReceipt)); @@ -580,11 +680,11 @@ describe('createVerifyTool', () => { it('rejects algorithm-key type mismatch', async () => { // Create a receipt with EdDSA algorithm but RSA key - const receipt = createValidReceipt('001'); + // Use minimal receipt (no top-level fields) to avoid dual-representation mismatch const header = Buffer.from(JSON.stringify({ alg: 'EdDSA', kid: 'key1' })).toString( 'base64url' ); - receipt._jws = `${header}.eyJ0ZXN0IjoidmFsdWUifQ.fake_signature`; + const receipt = { _jws: `${header}.eyJ0ZXN0IjoidmFsdWUifQ.fake_signature` }; const receiptPath = path.join(tempDir, 'r_001.peac.json'); fs.writeFileSync(receiptPath, JSON.stringify(receipt)); @@ -613,11 +713,11 @@ describe('createVerifyTool', () => { it('rejects algorithm-curve mismatch', async () => { // Create a receipt with EdDSA algorithm but wrong curve - const receipt = createValidReceipt('001'); + // Use minimal receipt (no top-level fields) to avoid dual-representation mismatch const header = Buffer.from(JSON.stringify({ alg: 'EdDSA', kid: 'key1' })).toString( 'base64url' ); - receipt._jws = `${header}.eyJ0ZXN0IjoidmFsdWUifQ.fake_signature`; + const receipt = { _jws: `${header}.eyJ0ZXN0IjoidmFsdWUifQ.fake_signature` }; const receiptPath = path.join(tempDir, 'r_001.peac.json'); fs.writeFileSync(receiptPath, JSON.stringify(receipt)); @@ -647,12 +747,11 @@ describe('createVerifyTool', () => { }); it('rejects key with wrong use field', async () => { - // Create a receipt with a valid JWS - const receipt = createValidReceipt('001'); + // Use minimal receipt (no top-level fields) to avoid dual-representation mismatch const header = Buffer.from(JSON.stringify({ alg: 'EdDSA', kid: 'key1' })).toString( 'base64url' ); - receipt._jws = `${header}.eyJ0ZXN0IjoidmFsdWUifQ.fake_signature`; + const receipt = { _jws: `${header}.eyJ0ZXN0IjoidmFsdWUifQ.fake_signature` }; const receiptPath = path.join(tempDir, 'r_001.peac.json'); fs.writeFileSync(receiptPath, JSON.stringify(receipt)); @@ -681,12 +780,11 @@ describe('createVerifyTool', () => { }); it('rejects key with missing verify in key_ops', async () => { - // Create a receipt with a valid JWS - const receipt = createValidReceipt('001'); + // Use minimal receipt (no top-level fields) to avoid dual-representation mismatch const header = Buffer.from(JSON.stringify({ alg: 'EdDSA', kid: 'key1' })).toString( 'base64url' ); - receipt._jws = `${header}.eyJ0ZXN0IjoidmFsdWUifQ.fake_signature`; + const receipt = { _jws: `${header}.eyJ0ZXN0IjoidmFsdWUifQ.fake_signature` }; const receiptPath = path.join(tempDir, 'r_001.peac.json'); fs.writeFileSync(receiptPath, JSON.stringify(receipt)); @@ -790,23 +888,26 @@ describe('createQueryTool', () => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'peac-test-query-')); mockLogger = createMockLogger(); - // Create test receipts with varying properties - const receipt1 = createValidReceipt('001'); - receipt1.auth.extensions['org.peacprotocol/workflow'] = { workflow_id: 'wf_alpha' }; - receipt1.evidence.extensions['org.peacprotocol/interaction@0.1'].tool = { name: 'web_search' }; - receipt1.evidence.extensions['org.peacprotocol/interaction@0.1'].result = { status: 'ok' }; + // Create test receipts with varying properties (JWS-consistent) + const receipt1 = createValidReceipt('001', { + workflow_id: 'wf_alpha', + tool_name: 'web_search', + status: 'ok', + }); fs.writeFileSync(path.join(tempDir, 'r_001.peac.json'), JSON.stringify(receipt1)); - const receipt2 = createValidReceipt('002'); - receipt2.auth.extensions['org.peacprotocol/workflow'] = { workflow_id: 'wf_beta' }; - receipt2.evidence.extensions['org.peacprotocol/interaction@0.1'].tool = { name: 'file_read' }; - receipt2.evidence.extensions['org.peacprotocol/interaction@0.1'].result = { status: 'error' }; + const receipt2 = createValidReceipt('002', { + workflow_id: 'wf_beta', + tool_name: 'read', + status: 'error', + }); fs.writeFileSync(path.join(tempDir, 'r_002.peac.json'), JSON.stringify(receipt2)); - const receipt3 = createValidReceipt('003'); - receipt3.auth.extensions['org.peacprotocol/workflow'] = { workflow_id: 'wf_alpha' }; - receipt3.evidence.extensions['org.peacprotocol/interaction@0.1'].tool = { name: 'web_search' }; - receipt3.evidence.extensions['org.peacprotocol/interaction@0.1'].result = { status: 'ok' }; + const receipt3 = createValidReceipt('003', { + workflow_id: 'wf_alpha', + tool_name: 'web_search', + status: 'ok', + }); fs.writeFileSync(path.join(tempDir, 'r_003.peac.json'), JSON.stringify(receipt3)); }); diff --git a/packages/net/node/README.md b/packages/net/node/README.md index a1f90f6d..89087f32 100644 --- a/packages/net/node/README.md +++ b/packages/net/node/README.md @@ -14,7 +14,7 @@ This package addresses the gap by: ## Requirements -- **Node.js 20+** (uses modern ES modules and native fetch) +- **Node.js 22+** (uses modern ES modules and native fetch) - **ESM only** - This package is published as ES modules only ### CJS Consumers @@ -54,7 +54,7 @@ This package passes [publint](https://publint.dev/) and [@arethetypeswrong/cli]( **Support matrix:** -- Node.js >= 20 (uses native fetch, ES modules) +- Node.js >= 22 (uses native fetch, ES modules) - Modern bundlers (Webpack 5+, esbuild, Rollup, Vite) - ESM works out of the box - TypeScript >= 5.0 with `moduleResolution: "bundler"` or `"node16"`/`"nodenext"` diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da04bc55..dd945a0f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -279,9 +279,6 @@ importers: '@peac/adapter-openclaw': specifier: workspace:* version: link:../../packages/adapters/openclaw - '@peac/capture-core': - specifier: workspace:* - version: link:../../packages/capture/core devDependencies: '@types/node': specifier: ^20.19.33 diff --git a/renovate.json b/renovate.json index 1699a25c..8bc28a67 100644 --- a/renovate.json +++ b/renovate.json @@ -31,7 +31,7 @@ }, { "matchPackageNames": ["node"], - "allowedVersions": ">=20.0.0", + "allowedVersions": ">=22.0.0", "groupName": "node engine", "reviewers": ["@peacprotocol/core-team"] },