diff --git a/packages/memorypack/CHANGELOG.md b/packages/memorypack/CHANGELOG.md index 1ee6d9e5d..01a067103 100644 --- a/packages/memorypack/CHANGELOG.md +++ b/packages/memorypack/CHANGELOG.md @@ -2,6 +2,53 @@ All notable changes to `@clude/memorypack` are documented here. The package follows [Semantic Versioning](https://semver.org/). +## [0.5.0] — 2026-04-28 + +Standalone CLI verifier. Auditors can install `@clude/memorypack` alone (~30 KB) and run `npx @clude/memorypack verify ` without touching the rest of Clude. + +### Added + +- New CLI binary `memorypack` declared via the package's `bin` field. Available as `npx @clude/memorypack verify ` or as a globally installed `memorypack verify` after `npm install -g`. +- `dist/cli.js` (preserved shebang `#!/usr/bin/env node`). +- New subpath export `@clude/memorypack/cli` for programmatic callers that want to invoke the CLI surface (`runVerify`, `parseArgs`, `printUsage`, `printVersion`). +- Verifier surfaces the v0.4 revocations panel when `revocations.jsonl` is present. + +### CLI flags + +``` +verify Validate a pack (directory or .tar.zst) + --public-key Override the manifest public key + --strict-signatures Fail if any record is unsigned + --verify-chain Also verify Solana on-chain anchors + --strict-chain Fail on any chain verification mismatch + --rpc-url Solana RPC URL + --cluster Cross-check RPC genesis hash + --decrypt-key Pack-level decryption key (32 bytes) +--version Print the package version +--help Print usage +``` + +Exit codes: `0` if every check passed, `1` otherwise. `NO_COLOR=1` honored for CI logs. + +### Tests + +11 new CLI tests (subprocess-invoked against the built `dist/cli.js`): + +- `--version`, `--help`, bare invocation, unknown command exit codes +- `parseArgs` unit tests (every flag + error cases) +- Round-trip verify on a synthesized signed pack (exit 0) +- Tampered pack → exit 1 with REJECTED +- Encrypted pack with correct decrypt key → exit 0 + decryption diagnostic +- `--strict-signatures` rejects unsigned packs + +Total: 70 tests passing. + +### Not in this release + +- Tarball-aware `appendRevocations` — still requires extract / append / re-tarball. +- Chain-anchored revocations. +- Production IPFS / Arweave content anchoring. + ## [0.4.0] — 2026-04-28 Signed revocations — soft-delete protocol for GDPR right-to-erasure, PII leaks, and corrections. diff --git a/packages/memorypack/README.md b/packages/memorypack/README.md index 1e520d8bc..9aa06996b 100644 --- a/packages/memorypack/README.md +++ b/packages/memorypack/README.md @@ -24,6 +24,28 @@ For chain anchor verification (Solana memo proofs), also install the optional pe npm install @solana/web3.js ``` +## CLI verifier + +The package ships a tiny standalone verifier — the auditor experience. Install one ~30 KB package, point it at a pack, get a clean OK / REJECTED with exit code 0 / 1. No Clude SDK, no Supabase, no API keys. + +```bash +# Verify offline (signatures + blobs + revocations) +npx @clude/memorypack verify ./my-pack + +# Also verify on-chain anchors against a Solana RPC +npx @clude/memorypack verify ./my-pack \ + --verify-chain --strict-chain \ + --rpc-url https://api.mainnet-beta.solana.com + +# Decrypt a pack-level-encrypted pack +npx @clude/memorypack verify ./my-pack \ + --decrypt-key $(cat ~/.keys/pack-key.b64) +``` + +Exit codes: `0` if every check passed, `1` otherwise. Suitable for cron, CI, and regulator workflows. + +Run `npx @clude/memorypack --help` for the full flag list. + ## Usage ### Write a pack @@ -131,6 +153,7 @@ Records remain in `result.records` after revocation; apps decide whether to surf | Schema-evolution fallback (minimal-shape readers) | `result.minimalRecords` | | **Streaming reader for large packs** | `streamMemoryPack` (async iterator) | | **Signed revocations (soft-delete)** | `appendRevocations`, `result.revokedRecordHashes` | +| **Standalone CLI verifier** | `npx @clude/memorypack verify ` | | Reference test vectors (deterministic fixture) | `src/__tests__/fixtures.ts` | Full spec: [docs/memorypack.md](https://github.com/sebbsssss/clude/blob/main/docs/memorypack.md). @@ -164,4 +187,4 @@ Post-v0.2 (tracked in the [main repo](https://github.com/sebbsssss/clude)): - Multi-chain anchors (Ethereum L2, Bitcoin OP_RETURN) - True streaming through tar (today the reader extracts to a temp dir first) - Tarball-aware `appendRevocations` (today only directory packs) -- `clude verify` CLI distributed alongside this package +- Chain-anchored revocations (so revocation timestamps can't be backdated) diff --git a/packages/memorypack/package.json b/packages/memorypack/package.json index b6cfb5825..41780fcea 100644 --- a/packages/memorypack/package.json +++ b/packages/memorypack/package.json @@ -1,6 +1,6 @@ { "name": "@clude/memorypack", - "version": "0.4.0", + "version": "0.5.0", "description": "Reference reader/writer for the MemoryPack spec \u2014 open, signed, chain-anchorable file format for portable AI agent memory.", "license": "MIT", "homepage": "https://github.com/sebbsssss/clude/blob/main/docs/memorypack.md", @@ -31,8 +31,15 @@ ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" + }, + "./cli": { + "types": "./dist/cli.d.ts", + "default": "./dist/cli.js" } }, + "bin": { + "memorypack": "dist/cli.js" + }, "files": [ "dist", "README.md", diff --git a/packages/memorypack/src/__tests__/cli.test.ts b/packages/memorypack/src/__tests__/cli.test.ts new file mode 100644 index 000000000..ad468a966 --- /dev/null +++ b/packages/memorypack/src/__tests__/cli.test.ts @@ -0,0 +1,222 @@ +// CLI tests — invoke the built dist/cli.js as a subprocess to exercise +// the actual binary that `npx @clude/memorypack verify` will run. +// +// Tests cover: +// - --version prints the package version +// - --help prints usage and exits 0 +// - bare invocation (no args) prints help and exits 1 +// - `verify ` exits 0 with "Pack is valid" +// - `verify ` exits 1 with "REJECTED" +// - `verify --decrypt-key ...` decrypts inline +// - unknown command exits 1 +// - parseArgs is unit-testable via the named export + +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { spawnSync } from 'child_process'; +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'fs'; +import { tmpdir } from 'os'; +import { join, resolve } from 'path'; +import nacl from 'tweetnacl'; +// @ts-ignore — bs58 is ESM-only, works at runtime via Node CJS/ESM interop +import * as bs58Module from 'bs58'; +const bs58: { encode: (b: Uint8Array) => string; decode: (s: string) => Uint8Array } = + (bs58Module as any).default || bs58Module; + +import { parseArgs } from '../cli.js'; +import { writeMemoryPack } from '../writer.js'; +import type { MemoryPackRecord } from '../types.js'; + +// dist/cli.js path — vitest runs from the package root, so this is stable. +const CLI = resolve(__dirname, '..', '..', 'dist', 'cli.js'); + +// Force NO_COLOR so subprocess output is grep-able without ANSI escapes. +const ENV = { ...process.env, NO_COLOR: '1' }; + +function runCli(args: string[]): { code: number; stdout: string; stderr: string } { + const r = spawnSync('node', [CLI, ...args], { + env: ENV, + encoding: 'utf-8', + }); + return { + code: r.status ?? -1, + stdout: r.stdout ?? '', + stderr: r.stderr ?? '', + }; +} + +let dir: string; +beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mp-cli-')); }); +afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + +beforeAll(() => { + if (!existsSync(CLI)) { + throw new Error( + `CLI test prerequisites missing: ${CLI} does not exist. Run \`pnpm build\` in packages/memorypack/ first.`, + ); + } +}); + +// ──────────────────────────────────────────────────────────────────── +// Pure unit tests on parseArgs +// ──────────────────────────────────────────────────────────────────── + +describe('parseArgs', () => { + it('defaults to help when no args', () => { + expect(parseArgs([])).toMatchObject({ command: 'help' }); + }); + it('--version sets command=version', () => { + expect(parseArgs(['--version'])).toMatchObject({ command: 'version' }); + expect(parseArgs(['-v'])).toMatchObject({ command: 'version' }); + }); + it('verify sets command + path', () => { + const args = parseArgs(['verify', '/tmp/x']); + expect(args.command).toBe('verify'); + expect(args.path).toBe('/tmp/x'); + }); + it('flags parse correctly', () => { + const args = parseArgs([ + 'verify', '/tmp/x', + '--strict-signatures', + '--verify-chain', + '--strict-chain', + '--rpc-url', 'https://rpc.example', + '--cluster', 'devnet', + '--public-key', 'PK', + '--decrypt-key', 'YWJj', + ]); + expect(args).toMatchObject({ + command: 'verify', + path: '/tmp/x', + strictSignatures: true, + verifyChain: true, + strictChain: true, + rpcUrl: 'https://rpc.example', + cluster: 'devnet', + publicKey: 'PK', + decryptKey: 'YWJj', + }); + }); + it('unknown command throws', () => { + expect(() => parseArgs(['nope'])).toThrow(/unknown command/i); + }); + it('unknown flag throws', () => { + expect(() => parseArgs(['verify', '/tmp/x', '--bogus'])).toThrow(/unknown argument/i); + }); +}); + +// ──────────────────────────────────────────────────────────────────── +// Subprocess tests against dist/cli.js +// ──────────────────────────────────────────────────────────────────── + +describe('CLI subprocess — meta commands', () => { + it('--version prints the package version, exit 0', () => { + const r = runCli(['--version']); + expect(r.code).toBe(0); + // The version string should look like X.Y.Z + expect(r.stdout.trim()).toMatch(/^\d+\.\d+\.\d+/); + }); + + it('--help prints usage, exit 0', () => { + const r = runCli(['--help']); + expect(r.code).toBe(0); + expect(r.stdout).toMatch(/Usage:/); + expect(r.stdout).toMatch(/verify/); + }); + + it('bare invocation prints help, exit 0', () => { + const r = runCli([]); + expect(r.code).toBe(0); + expect(r.stdout).toMatch(/Usage:/); + }); + + it('unknown command exits 1', () => { + const r = runCli(['frobnicate']); + expect(r.code).toBe(1); + expect(r.stderr).toMatch(/unknown command/i); + }); + + it('verify with no path prints help, exit 1', () => { + const r = runCli(['verify']); + expect(r.code).toBe(1); + expect(r.stdout).toMatch(/Usage:/); + }); +}); + +// Test pack synthesis helper +function synthSignedPack(target: string): { publicKey: string; secretKey: Uint8Array } { + const kp = nacl.sign.keyPair(); + const publicKey = bs58.encode(kp.publicKey); + const records: MemoryPackRecord[] = [ + { id: 'r1', created_at: '2026-04-28T00:00:00Z', kind: 'episodic', content: 'one', tags: ['t'], importance: 0.5, source: 'test' }, + { id: 'r2', created_at: '2026-04-28T00:01:00Z', kind: 'semantic', content: 'two', tags: ['t'], importance: 0.6, source: 'test' }, + ]; + writeMemoryPack(target, records, { + producer: { name: 'cli-test', version: '0.0.1', public_key: publicKey }, + record_schema: 'cli-test-v1', + secretKey: kp.secretKey, + clock: () => '2026-04-28T00:00:00.000Z', + }); + return { publicKey, secretKey: kp.secretKey }; +} + +describe('CLI subprocess — verify', () => { + it('verify on a good pack exits 0 with "Pack is valid"', () => { + synthSignedPack(dir); + const r = runCli(['verify', dir]); + expect(r.code).toBe(0); + expect(r.stdout).toMatch(/Pack is valid/); + expect(r.stdout).toMatch(/Signatures/); + expect(r.stdout).toMatch(/verified: 2/); + }); + + it('verify on a tampered pack exits 1 with REJECTED', () => { + synthSignedPack(dir); + // Tamper records.jsonl + const recordsPath = join(dir, 'records.jsonl'); + writeFileSync( + recordsPath, + readFileSync(recordsPath, 'utf-8').replace('"one"', '"oneX"'), + ); + const r = runCli(['verify', dir]); + expect(r.code).toBe(1); + expect(r.stderr + r.stdout).toMatch(/REJECTED/); + expect(r.stderr + r.stdout).toMatch(/signature verification failed/i); + }); + + it('verify on encrypted pack with correct --decrypt-key succeeds', () => { + const records: MemoryPackRecord[] = [ + { id: 'enc1', created_at: '2026-04-28T00:00:00Z', kind: 'episodic', content: 'secret', tags: [], importance: 0.5, source: 'test' }, + ]; + const key = new Uint8Array(32).fill(11); + writeMemoryPack(dir, records, { + producer: { name: 'enc-test', version: '0.0.1' }, + record_schema: 'cli-test-v1', + encryption: { key, scope: 'records' }, + clock: () => '2026-04-28T00:00:00.000Z', + }); + const r = runCli(['verify', dir, '--decrypt-key', Buffer.from(key).toString('base64')]); + expect(r.code).toBe(0); + expect(r.stdout).toMatch(/Encryption/); + expect(r.stdout).toMatch(/decrypted: 1 of 1/); + }); + + it('verify with --strict-signatures on unsigned pack rejects', () => { + const records: MemoryPackRecord[] = [ + { id: 'u1', created_at: '2026-04-28T00:00:00Z', kind: 'episodic', content: 'u', tags: [], importance: 0.5, source: 'test' }, + ]; + writeMemoryPack(dir, records, { + producer: { name: 'unsigned', version: '0.0.1' }, + record_schema: 'cli-test-v1', + clock: () => '2026-04-28T00:00:00.000Z', + }); + const r = runCli(['verify', dir, '--strict-signatures']); + expect(r.code).toBe(1); + expect(r.stderr + r.stdout).toMatch(/REJECTED|unsigned/i); + }); +}); diff --git a/packages/memorypack/src/cli.ts b/packages/memorypack/src/cli.ts new file mode 100644 index 000000000..141a0a3da --- /dev/null +++ b/packages/memorypack/src/cli.ts @@ -0,0 +1,290 @@ +#!/usr/bin/env node +// @clude/memorypack — standalone CLI verifier. +// +// Designed for auditors and long-term preservation. Install one tiny +// package, point it at a pack, get a clean OK/REJECTED with exit code +// 0/1. No Clude SDK, no Supabase, no API keys. +// +// Usage: +// npx @clude/memorypack verify [options] +// +// Run with no args to see the help text. + +import { readMemoryPack } from './reader.js'; +import { verifyChainAnchors } from './chain-verify.js'; + +// ── Tiny ANSI helpers (no deps) ──────────────────────────────────── +const isTTY = process.stdout.isTTY; +const noColor = !isTTY || process.env.NO_COLOR != null || process.env.TERM === 'dumb'; +const ansi = (code: string, s: string) => (noColor ? s : `\x1b[${code}m${s}\x1b[0m`); +const c = { + dim: (s: string) => ansi('2', s), + bold: (s: string) => ansi('1', s), + green: (s: string) => ansi('32', s), + yellow: (s: string) => ansi('33', s), + red: (s: string) => ansi('31', s), +}; + +// ── Args ─────────────────────────────────────────────────────────── + +interface Args { + command: 'verify' | 'help' | 'version'; + path: string; + publicKey?: string; + strictSignatures: boolean; + verifyChain: boolean; + strictChain: boolean; + rpcUrl: string; + cluster?: 'mainnet' | 'mainnet-beta' | 'devnet' | 'testnet'; + decryptKey?: string; +} + +function parseArgs(argv: string[]): Args { + const args: Args = { + command: 'help', + path: '', + strictSignatures: false, + verifyChain: false, + strictChain: false, + rpcUrl: process.env.SOLANA_RPC_URL || 'https://api.mainnet-beta.solana.com', + }; + if (argv.length === 0) return args; + + // First positional must be the subcommand + const first = argv[0]; + if (first === '--version' || first === '-v') { + args.command = 'version'; + return args; + } + if (first === '--help' || first === '-h' || first === 'help') { + args.command = 'help'; + return args; + } + if (first !== 'verify') { + throw new Error(`unknown command "${first}". Run with --help for usage.`); + } + args.command = 'verify'; + + for (let i = 1; i < argv.length; i++) { + const a = argv[i]; + if (a === '--help' || a === '-h') { + args.command = 'help'; + return args; + } + if (a === '--public-key') args.publicKey = argv[++i]; + else if (a === '--strict-signatures') args.strictSignatures = true; + else if (a === '--strict-chain') args.strictChain = true; + else if (a === '--verify-chain') args.verifyChain = true; + else if (a === '--rpc-url') args.rpcUrl = argv[++i]; + else if (a === '--cluster') args.cluster = argv[++i] as Args['cluster']; + else if (a === '--decrypt-key') args.decryptKey = argv[++i]; + else if (!a.startsWith('--') && !args.path) args.path = a; + else throw new Error(`unknown argument "${a}". Run with --help for usage.`); + } + return args; +} + +function printUsage(): void { + console.log(`@clude/memorypack — standalone MemoryPack verifier + +Usage: + npx @clude/memorypack verify [options] + npx @clude/memorypack --version + npx @clude/memorypack --help + +Options: + --public-key Override the public key (default: from manifest) + --strict-signatures Fail if any record is unsigned + --verify-chain Also verify Solana on-chain anchors + --strict-chain Fail on any chain verification mismatch + --rpc-url Solana RPC URL for chain verification + (default: SOLANA_RPC_URL env or mainnet-beta) + --cluster Cross-check RPC genesis hash (mainnet | devnet | testnet) + --decrypt-key Pack-level decryption key (32 bytes, base64) + +Exit code: 0 if valid, 1 if any check failed. + +For the format spec: https://github.com/sebbsssss/clude/blob/main/docs/memorypack.md`); +} + +function printVersion(): void { + // Read package.json next to dist/. When running from src (tsx / vitest), + // walk up two dirs; from dist, walk up one. Try both. + const fs = require('fs'); + const path = require('path'); + const candidates = [ + path.join(__dirname, '..', 'package.json'), + path.join(__dirname, '..', '..', 'package.json'), + ]; + for (const p of candidates) { + try { + const pkg = JSON.parse(fs.readFileSync(p, 'utf-8')); + if (pkg.name === '@clude/memorypack') { + console.log(pkg.version); + return; + } + } catch { + /* fall through */ + } + } + console.log('(version unknown — package.json not located)'); +} + +// ── Verify command ───────────────────────────────────────────────── + +async function runVerify(args: Args): Promise { + if (!args.path) { + printUsage(); + return 1; + } + + console.log(`${c.bold('MemoryPack verify')} ${c.dim(args.path)}\n`); + + let decryptionKey: Uint8Array | undefined; + if (args.decryptKey) { + decryptionKey = new Uint8Array(Buffer.from(args.decryptKey, 'base64')); + } + + let result; + try { + result = readMemoryPack(args.path, { + publicKey: args.publicKey, + strictSignatures: args.strictSignatures, + decryptionKey, + }); + } catch (e: any) { + console.error(`${c.red('REJECTED')}: ${e.message}`); + return 1; + } + + const { + manifest, + records, + verifiedRecords, + unsignedRecords, + anchors, + verifiedBlobs, + revocations, + revokedRecordHashes, + minimalRecords, + warnings, + } = result; + + // Manifest summary + console.log(` ${c.dim('producer:')} ${manifest.producer.name} ${manifest.producer.version}`); + if (manifest.producer.did) console.log(` ${c.dim('did:')} ${manifest.producer.did}`); + if (manifest.producer.public_key) console.log(` ${c.dim('public_key:')} ${manifest.producer.public_key}`); + console.log(` ${c.dim('created_at:')} ${manifest.created_at}`); + console.log(` ${c.dim('records:')} ${records.length}`); + console.log(` ${c.dim('schema:')} ${manifest.record_schema}`); + console.log(` ${c.dim('encryption:')} ${manifest.encryption ? `${manifest.encryption.algorithm} (scope=${manifest.encryption.scope})` : 'none'}`); + console.log(` ${c.dim('pack_format:')} ${manifest.pack_format ?? 'directory'}`); + console.log(); + + // Signatures + console.log(` ${c.bold('Signatures')}`); + console.log(` verified: ${c.green(String(verifiedRecords.size))}`); + console.log(` unsigned: ${unsignedRecords.size}`); + console.log(); + + // Blobs (only when declared) + if (verifiedBlobs.size > 0 || manifest.blobs_count) { + console.log(` ${c.bold('Blobs')}`); + console.log(` verified: ${c.green(String(verifiedBlobs.size))} of ${manifest.blobs_count ?? '?'}`); + console.log(); + } + + // Revocations (only when present) + if (revocations.length > 0) { + console.log(` ${c.bold('Revocations')}`); + console.log(` verified: ${c.green(String(revocations.length))}`); + console.log(` distinct records affected: ${revokedRecordHashes.size}`); + console.log(); + } + + // Anchors + console.log(` ${c.bold('Anchors')}`); + console.log(` declared: ${anchors.length}`); + + let chainFailed = false; + if (args.verifyChain && anchors.length > 0) { + process.stdout.write(' fetching: '); + try { + const expectedSigner = args.publicKey ?? manifest.producer.public_key; + const { verified, warnings: chainWarnings } = await verifyChainAnchors(anchors, { + rpcUrl: args.rpcUrl, + cluster: args.cluster, + expectedSigner, + strict: args.strictChain, + }); + console.log(`${c.green(String(verified.size))}/${anchors.length} verified on-chain`); + for (const w of chainWarnings) console.log(` ${c.yellow('warn:')} ${w}`); + if (verified.size < anchors.length) chainFailed = true; + } catch (e: any) { + console.log(c.red('FAILED')); + console.error(` ${c.red(e.message)}`); + chainFailed = true; + } + } + console.log(); + + // Encryption / minimal diagnostic + if (manifest.encryption) { + console.log(` ${c.bold('Encryption')}`); + if (decryptionKey) { + const decrypted = records.filter((r) => !r.encrypted).length; + console.log(` decrypted: ${c.green(String(decrypted))} of ${records.length}`); + } else { + console.log(` ${c.yellow(`no --decrypt-key supplied; ${records.length - minimalRecords.length} record(s) excluded from minimalRecords`)}`); + } + console.log(); + } + + // Warnings + if (warnings.length > 0) { + console.log(` ${c.bold('Warnings')}`); + for (const w of warnings) console.log(` ${c.yellow('!')} ${w}`); + console.log(); + } + + if (chainFailed) { + console.log(`${c.red('REJECTED')} Chain verification incomplete.`); + return 1; + } + console.log(`${c.green('OK')} Pack is valid.`); + return 0; +} + +// ── Entry point ──────────────────────────────────────────────────── + +async function main(): Promise { + let args: Args; + try { + args = parseArgs(process.argv.slice(2)); + } catch (e: any) { + console.error(`${c.red('error:')} ${e.message}`); + return 1; + } + + switch (args.command) { + case 'help': + printUsage(); + return 0; + case 'version': + printVersion(); + return 0; + case 'verify': + return await runVerify(args); + } +} + +// Only auto-run when invoked directly as a script. Importing this +// module in tests / programmatic callers won't trigger main(). +if (require.main === module) { + main().then((code) => process.exit(code)).catch((err) => { + console.error(`${c.red('fatal:')} ${err.message ?? err}`); + process.exit(1); + }); +} + +export { runVerify, parseArgs, printUsage, printVersion };