diff --git a/.env.example b/.env.example index c7e8779..3640339 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,4 @@ +# Required: base URL of the CommandLayer runtime service. +# Used by api/verify-receipt.js (proxy) and api/health.js. +# Example: https://runtime.commandlayer.org RUNTIME_BASE_URL=https://runtime.commandlayer.org diff --git a/api/verify-receipt.js b/api/verify-receipt.js index 216d0a6..3c43346 100644 --- a/api/verify-receipt.js +++ b/api/verify-receipt.js @@ -147,10 +147,6 @@ module.exports = async function handler(req, res) { const schema = qflag(req.query?.schema, '0'); const verifyUrl = `${RUNTIME_BASE}/verify?ens=${ens}&refresh=${refresh}&schema=${schema}`; - console.log('[verify-receipt] runtime verify target', JSON.stringify({ runtime_url: verifyUrl })); - console.log('[verify-receipt] outgoing verify body', JSON.stringify({ body: bareReceipt })); - console.log('[verify-receipt] normalized receipt', JSON.stringify({ normalized_receipt: bareReceipt })); - try { const upstream = await fetchTextWithTimeout(verifyUrl, { method: 'POST', @@ -165,8 +161,6 @@ module.exports = async function handler(req, res) { : { ok: false, error: 'Non-JSON response from runtime /verify', raw: String(upstream.text || '').slice(0, 2000) }; const normalizedChecks = normalizeChecks(data, schema === '1'); - console.log('[verify-receipt] raw verify response', JSON.stringify({ runtime_url: verifyUrl, body: data })); - return res.status(upstream.status).end( JSON.stringify( { @@ -180,13 +174,6 @@ module.exports = async function handler(req, res) { runtime_content_type: upstream.contentType, normalized_receipt_used: bareReceipt, }, - logs: { - runtime_url: verifyUrl, - outgoing_request_body: bareReceipt, - raw_runtime_response_body: data, - normalized_receipt_chosen: bareReceipt, - canonical_validation_failure_reason: data?.error || null, - }, }, null, 2 @@ -205,13 +192,6 @@ module.exports = async function handler(req, res) { verify: { ens, refresh, schema }, normalized_receipt_used: bareReceipt, }, - logs: { - runtime_url: verifyUrl, - outgoing_request_body: bareReceipt, - raw_runtime_response_body: null, - normalized_receipt_chosen: bareReceipt, - canonical_validation_failure_reason: e?.message || String(e), - }, }, null, 2 diff --git a/docs/wrap-your-agent.md b/docs/wrap-your-agent.md index 4b79014..d0a5430 100644 --- a/docs/wrap-your-agent.md +++ b/docs/wrap-your-agent.md @@ -4,21 +4,19 @@ Turn any agent action into a signed, verifiable receipt. This is the core primitive of CommandLayer: -**Agents don’t make claims — they produce proof.** +**Agents don't make claims — they produce proof.** --- ## Install -``` -bash +```bash npm install @commandlayer/agent-sdk ``` - ## What this does -When you wrap an agent action: +When you wrap an agent action: - Your action executes normally - CommandLayer captures the output @@ -29,69 +27,80 @@ When you wrap an agent action: ## Quick start -``` +```ts import { CommandLayer } from "@commandlayer/agent-sdk"; const cl = new CommandLayer({ -agent: "runtime.commandlayer.eth", -privateKey: process.env.CL_PRIVATE_KEY_PEM, -keyId: "vC4WbcNoq2znSCiQ", -verifierUrl: "https://www.commandlayer.org/api/verify"}); + agent: "runtime.commandlayer.eth", + privateKey: process.env.CL_PRIVATE_KEY_PEM, + keyId: "vC4WbcNoq2znSCiQ", + verifierUrl: "https://www.commandlayer.org/api/verify" +}); + +const result = await cl.wrap("summarize", async () => { + return { summary: "hello world" }; +}); -const result = await cl.wrap("summarize", async () => { return { summary: "hello world" };}); -console.log(result.output);console.log(result.receipt); +console.log(result.output); +console.log(result.receipt); const verified = await cl.verify(result.receipt); console.log(verified.status); ``` ## What `wrap()` returns -`wrap()` returns both: +`wrap()` returns both: - `output` — the value returned by your agent function - `receipt` — the signed CommandLayer receipt for that action - ### Example -``` + +```ts const result = await cl.wrap("summarize", async () => { - return { summary: "AI agents need verification" };}); + return { summary: "AI agents need verification" }; +}); console.log(result.output); console.log(result.receipt); ``` ### Example receipt + +```json +{ + "signer": "runtime.commandlayer.eth", + "verb": "summarize", + "ts": "2026-05-02T02:53:33.056Z", + "input": {}, + "output": { + "summary": "hello world" + }, + "execution": { + "status": "ok", + "duration_ms": 1, + "started_at": "2026-05-02T02:53:33.056Z", + "completed_at": "2026-05-02T02:53:33.057Z" + }, + "metadata": { + "proof": { + "canonicalization": "json.sorted_keys.v1", + "hash_sha256": "14e559e9454eaba437934220623b95947fdbaf38d45a1d358c327622c8352617" + } + }, + "signature": { + "alg": "ed25519", + "kid": "vC4WbcNoq2znSCiQ", + "sig": "..." + } +} ``` -{ "signer": "runtime.commandlayer.eth", -"verb": "summarize", -"ts": "2026-05-02T02:53:33.056Z", -"input": {}, -"output": { -"summary": "hello world" - }, -"execution": { - "status": "ok", - "duration_ms": 1, -"started_at": "2026-05-02T02:53:33.056Z", -"completed_at": "2026-05-02T02:53:33.057Z" - }, -"metadata": { -"proof": { -"canonicalization": "json.sorted_keys.v1", -"hash_sha256": "14e559e9454eaba437934220623b95947fdbaf38d45a1d358c327622c8352617" - } -}, - "signature": { -"alg": "ed25519", -"kid": "vC4WbcNoq2znSCiQ", - "sig": "..." }} -``` - -### What verification checks + +## What verification checks The verifier: + - Rebuilds the canonical receipt payload - Recomputes the SHA-256 hash - Compares it to `metadata.proof.hash_sha256` @@ -99,77 +108,87 @@ The verifier: - Validates the Ed25519 signature - Returns `VERIFIED` or `INVALID` +If the input or output changes after signing, the recomputed hash no longer matches and verification returns `INVALID`. -If the input or output changes after signing, the recomputed hash no longer matches and verification returns `INVALID.` - -### ENS signer records +## ENS signer records The signer should publish key metadata through ENS TXT records. -For `runtime.commandlayer.eth,` the important records are: +For `runtime.commandlayer.eth`, the important records are: + ``` cl.sig.kid=vC4WbcNoq2znSCiQ cl.sig.pub=ed25519: cl.sig.canonical=json.sorted_keys.v1 cl.receipt.signer=runtime.commandlayer.eth ``` + The private key stays local. Never commit it, paste it into frontend code, or publish it. -### Verify through the public API -``` +## Verify through the public API + +```ts const verified = await cl.verify(result.receipt); if (verified.status === "VERIFIED") { -console.log("VERIFIED"); + console.log("VERIFIED"); } else { -console.log("INVALID"); + console.log("INVALID"); } ``` -#### Default verifier: +Default verifier: + ``` POST https://www.commandlayer.org/api/verify ``` -### VerifyAgent.eth callable endpoint +## VerifyAgent.eth callable endpoint VerifyAgent.eth can also be called directly: + ``` POST https://www.commandlayer.org/api/agents/verifyagent ``` -#### Request -``` +### Request + +```json { -"receipt": { - "...": "CommandLayer receipt" + "receipt": { + "...": "CommandLayer receipt" } } +``` VerifyAgent.eth does not execute the original task. It verifies whether a submitted receipt is valid or tampered. -#### Verify in the browser +## Verify in the browser Paste any receipt into: + ``` https://www.commandlayer.org/verify.html - ``` + Expected behavior: - valid receipt → VERIFIED - tampered receipt → INVALID -#### Full proof demo - +## Full proof demo The SDK includes a full proof-loop demo: -``` + +```bash npm run example:demo ``` + It runs: + ``` wrap action → emit receipt → verify → tamper → verify invalid ``` + Expected output: ``` @@ -177,33 +196,30 @@ Original receipt verification: VERIFIED Tampered receipt verification: INVALID ``` - -### Why this matters +## Why this matters Without CommandLayer: + - agents only claim what happened - platforms are trusted by default - outputs can be edited without proof - verification is not portable - With CommandLayer: - - every action can produce a signed receipt - signer identity can be resolved through ENS - receipts can be verified independently - tampering breaks the proof -### Design principles - +## Design principles - deterministic receipts - independent verification - no platform trust required - composable across agents and apps -### Next steps +## Next steps - Install the SDK - Wrap one important agent action @@ -211,8 +227,6 @@ With CommandLayer: - Verify it through VerifyAgent.eth - Expose receipts anywhere users need proof - - -### One-line summary +## One-line summary **Wrap your agent → produce a receipt → prove what actually happened.** diff --git a/examples/webhook-auto-verify/server.js b/examples/webhook-auto-verify/server.js index bb0371c..6963623 100644 --- a/examples/webhook-auto-verify/server.js +++ b/examples/webhook-auto-verify/server.js @@ -25,21 +25,15 @@ app.post("/webhook", async (req, res) => { body: JSON.stringify({ receipt }), }); - const verifyJson = await verifyResponse.json(); - const checks = verifyJson?.checks || {}; - const verified = Boolean(verifyJson?.verified); + if (!verifyResponse.ok && verifyResponse.status !== 200) { + return res.status(502).json({ + status: "rejected", + reason: "Verification service returned unexpected status", + }); + } - console.log("[webhook] verification checks", { - event, - schema_valid: checks.schema_valid, - hash_matched: checks.hash_matched, - hash_matches: checks.hash_matches, - signature_valid: checks.signature_valid, - signer_resolved: checks.signer_resolved, - ens_resolved: checks.ens_resolved, - signer_matched: checks.signer_matched, - trust_verb: checks.trust_verb, - }); + const verifyJson = await verifyResponse.json(); + const verified = Boolean(verifyJson?.ok); if (verified) { return res.status(200).json({ @@ -52,17 +46,17 @@ app.post("/webhook", async (req, res) => { status: "rejected", event, reason: "Receipt verification failed", - checks, + checks: verifyJson?.checks || {}, }); } catch (error) { - console.error("[webhook] verification request failed", error); return res.status(502).json({ status: "rejected", reason: "Verification service unavailable", + detail: error && error.message ? error.message : "Unknown error", }); } }); app.listen(PORT, () => { - console.log(`Webhook auto-verify demo listening on http://localhost:${PORT}`); + process.stdout.write(`Webhook auto-verify demo listening on port ${PORT}\n`); }); diff --git a/public/js/verify-url.js b/public/js/verify-url.js index 55098fa..59c0a11 100644 --- a/public/js/verify-url.js +++ b/public/js/verify-url.js @@ -49,12 +49,12 @@ async function run() { return; } if (!receiptRes.ok) { - setStatus('error'); + setStatus(`error: Receipt fetch failed (${receiptRes.status})`); return; } receipt = await receiptRes.json(); - } catch { - setStatus('error'); + } catch (fetchErr) { + setStatus(`error: ${fetchErr && fetchErr.message ? fetchErr.message : 'Could not load receipt'}`); return; } @@ -67,11 +67,11 @@ async function run() { const payload = await verifyRes.json(); const result = payload?.result ?? payload?.verification ?? payload; if (!verifyRes.ok || !result || typeof result !== 'object') { - setStatus('error'); + setStatus(`error: Verification failed (${verifyRes.status})`); return; } - const isValid = Boolean(result.valid ?? result.ok ?? result.verified); + const isValid = Boolean(result.ok ?? result.valid ?? result.verified); verdict.innerHTML = `${isValid ? 'VERIFIED' : 'INVALID'}`; checksList.innerHTML = CHECK_KEYS.map((key) => { @@ -83,17 +83,24 @@ async function run() { rawReceipt.textContent = JSON.stringify(receipt, null, 2); resultCard.hidden = false; setStatus(isValid ? 'VERIFIED' : 'INVALID'); - } catch { - setStatus('error'); + } catch (verifyErr) { + setStatus(`error: ${verifyErr && verifyErr.message ? verifyErr.message : 'Verification request failed'}`); } } copyUrlBtn?.addEventListener('click', async () => { - await navigator.clipboard.writeText(window.location.href); - copyUrlBtn.textContent = 'Copied'; - setTimeout(() => { - copyUrlBtn.textContent = 'Copy verification URL'; - }, 1500); + try { + await navigator.clipboard.writeText(window.location.href); + copyUrlBtn.textContent = 'Copied'; + setTimeout(() => { + copyUrlBtn.textContent = 'Copy verification URL'; + }, 1500); + } catch { + copyUrlBtn.textContent = 'Copy failed'; + setTimeout(() => { + copyUrlBtn.textContent = 'Copy verification URL'; + }, 1500); + } }); toggleRawBtn?.addEventListener('click', () => { diff --git a/public/js/verify.js b/public/js/verify.js index 6194ef4..dab42bc 100644 --- a/public/js/verify.js +++ b/public/js/verify.js @@ -124,7 +124,6 @@ async function verifyReceiptAction() { throw new Error(result?.reason || 'Verification request failed.'); } } catch (e) { - console.error('Verification failed:', e); setVerdict(false, e?.message || 'Verification failed.'); els.verifyBtn.disabled = false; els.verifyBtn.textContent = 'Verify'; @@ -195,7 +194,7 @@ function resolveElements() { if (!resolved[id]) missing.push(id); } if (missing.length) { - console.warn(`[verify.js] Missing required element(s): ${missing.join(', ')}. Verify page handlers were not attached.`); + // Missing DOM elements — verify page handlers not attached (expected in non-verify page contexts) return null; } return resolved; diff --git a/tests/commons-flow.test.js b/tests/commons-flow.test.js index a073bd3..9b27681 100644 --- a/tests/commons-flow.test.js +++ b/tests/commons-flow.test.js @@ -1,54 +1,24 @@ 'use strict'; -const EXPECTED_ENS_SIGNER = 'runtime.commandlayer.eth'; +// commons-flow.test.js +// Tests for the canonical verification helpers that are shared between the +// browser verify UI and the server-side verifyReceipt lib. +// These tests cover the pure functions (canonicalize, canonicalReceiptPayload, +// sha256Hex) without requiring a DOM. -const ENS_RECORDS = { - 'cl.receipt.signer': 'runtime.commandlayer.eth', - 'cl.sig.kid': 'vC4WbcNoq2znSCiQ', - 'cl.sig.pub': 'ed25519:hhyCuPNoMk4JtEvGEV8F6nMZ4uDO1EcyizPufmnJTOY=', - 'cl.sig.canonical': 'json.sorted_keys.v1', -}; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { createHash } = require('node:crypto'); -const CHECK_LABELS = [ - ['schema_valid', '1) Parse receipt schema'], - ['canonical_hash_matched', '2) Recompute canonical hash'], - ['ed25519_signature_valid', '3) Verify Ed25519 signature'], - ['ens_key_resolved', '4) Resolve signer public key from ENS'], - ['signer_matched', '5) Match signer identity'], -]; - -const REQUIRED_ELEMENT_IDS = [ - 'receiptInput', - 'verifyBtn', - 'loadSampleBtn', - 'loadTamperedBtn', - 'clearBtn', - 'resultCard', - 'resultState', - 'resultNote', - 'checksList', - 'metaRows', -]; - -let els = null; - -function esc(v) { - return String(v ?? '') - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} +// ── Pure helpers under test (duplicated from browser context to avoid DOM) ── function canonicalize(value) { if (value === null || typeof value !== 'object') return JSON.stringify(value); if (Array.isArray(value)) return `[${value.map(canonicalize).join(',')}]`; - return `{${Object.keys(value) .sort() .map((key) => `${JSON.stringify(key)}:${canonicalize(value[key])}`) - .join(',')}}`; + .join(',')}}`; } function canonicalReceiptPayload(receipt) { @@ -62,292 +32,104 @@ function canonicalReceiptPayload(receipt) { }; } -function b64ToBytes(b64) { - const bin = atob(b64); - return Uint8Array.from(bin, (c) => c.charCodeAt(0)); -} - -async function sha256Hex(text) { - const bytes = new TextEncoder().encode(text); - const digest = await crypto.subtle.digest('SHA-256', bytes); - return [...new Uint8Array(digest)] - .map((b) => b.toString(16).padStart(2, '0')) - .join(''); -} - -async function verifyEd25519HashSignature(hashHex, sigB64, pubkeyB64) { - const publicKey = await crypto.subtle.importKey( - 'raw', - b64ToBytes(pubkeyB64), - { name: 'Ed25519' }, - false, - ['verify'] - ); - - return crypto.subtle.verify( - { name: 'Ed25519' }, - publicKey, - b64ToBytes(sigB64), - new TextEncoder().encode(hashHex) - ); -} - -function renderChecks(checks) { - els.checksList.innerHTML = CHECK_LABELS.map(([key, label]) => { - const status = checks[key]; - const ok = status === true; - const bad = status === false; - - return `
  • - ${label} - - ${ok ? 'PASS' : bad ? 'FAIL' : '—'} - -
  • `; - }).join(''); -} - -function renderMeta(meta) { - const rows = [ - ['Signer ENS', meta.signerEns || '—', true], - ['Public Key Source', meta.publicKeySource || '—', true], - ['Receipt ID', meta.receiptId || '—'], - ['Verb/Action', meta.verb || '—'], - ['Timestamp', meta.timestamp || '—'], - ['Hash', meta.hash || '—'], - ]; - - els.metaRows.innerHTML = rows.map(([k, v, highlight]) => ( - `
    -
    ${esc(k)}
    -
    ${esc(v)}
    -
    ` - )).join(''); -} - -function setVerdict(ok, note, isIdle = false) { - if (isIdle) { - els.resultState.className = 'result-state idle'; - els.resultState.textContent = '—'; - els.resultCard.style.background = '#f9fbff'; - els.resultCard.style.borderColor = 'var(--line)'; - els.resultNote.textContent = note; - return; - } - - els.resultState.className = `result-state ${ok ? 'verified' : 'invalid'}`; - els.resultState.textContent = ok ? 'VERIFIED' : 'INVALID'; - els.resultNote.textContent = note; - els.resultCard.style.background = ok ? 'var(--green-soft)' : 'var(--red-soft)'; - els.resultCard.style.borderColor = ok ? 'rgba(13,159,98,.35)' : 'rgba(217,45,32,.35)'; -} - -function resetToNeutralState(note = 'Run verification to see verdict.') { - renderChecks({ - schema_valid: null, - canonical_hash_matched: null, - ed25519_signature_valid: null, - ens_key_resolved: null, - signer_matched: null, - }); - - renderMeta({ - signerEns: null, - publicKeySource: null, - receiptId: null, - verb: null, - timestamp: null, - hash: null, - }); - - setVerdict(false, note, true); -} - -async function verifyReceipt() { - const raw = els.receiptInput.value.trim(); - - if (!raw) { - setVerdict(false, 'Paste a receipt JSON to verify.'); - return; - } - - let receipt; - - try { - receipt = JSON.parse(raw); - } catch (e) { - setVerdict(false, `Invalid JSON: ${e.message}`); - return; - } - - els.verifyBtn.disabled = true; - els.verifyBtn.textContent = 'Verifying...'; - - const schemaValid = - receipt && - typeof receipt === 'object' && - typeof receipt.signer === 'string' && - typeof receipt.verb === 'string' && - typeof receipt.ts === 'string' && - receipt.metadata?.proof?.canonicalization && - receipt.metadata?.proof?.hash_sha256 && - receipt.signature?.kid && - receipt.signature?.sig; - - const signerMatched = receipt?.signer === EXPECTED_ENS_SIGNER; - const ensKeyResolved = signerMatched && !!ENS_RECORDS['cl.sig.pub']; - - let hashMatched = false; - let signatureValid = false; - let recomputedHash = '—'; - - try { - if (schemaValid) { - const canonicalizationOk = - receipt.metadata.proof.canonicalization === ENS_RECORDS['cl.sig.canonical']; - - const keyIdOk = - receipt.signature.kid === ENS_RECORDS['cl.sig.kid']; - - const canonicalPayload = canonicalize(canonicalReceiptPayload(receipt)); - recomputedHash = await sha256Hex(canonicalPayload); - - hashMatched = - canonicalizationOk && - receipt.metadata.proof.hash_sha256 === recomputedHash; - - if (hashMatched && keyIdOk && ensKeyResolved) { - const pubkeyB64 = ENS_RECORDS['cl.sig.pub'].replace(/^ed25519:/, ''); - signatureValid = await verifyEd25519HashSignature( - recomputedHash, - receipt.signature.sig, - pubkeyB64 - ); - } - } - } catch (e) { - console.error('Verification error:', e); - signatureValid = false; - } - - const checks = { - schema_valid: !!schemaValid, - canonical_hash_matched: !!hashMatched, - ed25519_signature_valid: !!signatureValid, - ens_key_resolved: !!ensKeyResolved, - signer_matched: !!signerMatched, +function sha256HexSync(text) { + return createHash('sha256').update(text, 'utf8').digest('hex'); +} + +// ── Tests ── + +test('canonicalize: null and primitives round-trip as JSON', () => { + assert.equal(canonicalize(null), 'null'); + assert.equal(canonicalize(42), '42'); + assert.equal(canonicalize('hello'), '"hello"'); + assert.equal(canonicalize(true), 'true'); +}); + +test('canonicalize: object keys are sorted', () => { + const result = canonicalize({ z: 1, a: 2 }); + assert.equal(result, '{"a":2,"z":1}'); +}); + +test('canonicalize: nested objects have sorted keys', () => { + const result = canonicalize({ outer: { z: 9, a: 1 } }); + assert.equal(result, '{"outer":{"a":1,"z":9}}'); +}); + +test('canonicalize: arrays preserve order', () => { + const result = canonicalize([3, 1, 2]); + assert.equal(result, '[3,1,2]'); +}); + +test('canonicalize: produces same string for same data regardless of insertion order', () => { + const a = canonicalize({ b: 1, a: 2 }); + const b = canonicalize({ a: 2, b: 1 }); + assert.equal(a, b); +}); + +test('canonicalReceiptPayload: extracts only the six canonical fields', () => { + const receipt = { + signer: 'runtime.commandlayer.eth', + verb: 'summarize', + input: { task: 'x' }, + output: { summary: 'y' }, + execution: { runtime: 'test' }, + ts: '2026-01-01T00:00:00.000Z', + metadata: { proof: { hash_sha256: 'should-not-appear' } }, + signature: { alg: 'ed25519' }, }; - - const allPass = Object.values(checks).every(Boolean); - - renderChecks(checks); - renderMeta({ - signerEns: receipt?.signer || '—', - publicKeySource: ensKeyResolved ? 'ENS text record' : 'not resolved', - receiptId: receipt?.receipt_id || receipt?.id || '—', - verb: receipt?.verb || '—', - timestamp: receipt?.ts || receipt?.timestamp || '—', - hash: recomputedHash, - }); - - setVerdict( - allPass, - allPass - ? 'Receipt verification passed.' - : 'Receipt is invalid, tampered, or does not match the ENS signer key.' - ); - - els.verifyBtn.disabled = false; - els.verifyBtn.textContent = 'Verify'; -} - -async function fetchSampleReceipt() { - const resp = await fetch('/examples/sample-receipt.json', { cache: 'no-store' }); - if (!resp.ok) throw new Error('Sample receipt could not be loaded.'); - return resp.json(); -} - -async function loadSampleReceipt() { - els.loadSampleBtn.disabled = true; - els.loadSampleBtn.textContent = 'Loading...'; - - try { - const data = await fetchSampleReceipt(); - els.receiptInput.value = JSON.stringify(data, null, 2); - resetToNeutralState('Sample loaded. Click Verify to validate.'); - } catch (e) { - setVerdict(false, e.message); - } - - els.loadSampleBtn.disabled = false; - els.loadSampleBtn.textContent = 'Load Sample'; -} - -async function loadTamperedReceipt() { - els.loadTamperedBtn.disabled = true; - els.loadTamperedBtn.textContent = 'Loading...'; - - try { - const data = await fetchSampleReceipt(); - const tampered = JSON.parse(JSON.stringify(data)); - - if (tampered.output && typeof tampered.output.summary === 'string') { - tampered.output.summary = `${tampered.output.summary}!!!`; - } else { - tampered.tampered_demo_marker = 'TAMPERED'; - } - - els.receiptInput.value = JSON.stringify(tampered, null, 2); - resetToNeutralState('Tampered sample loaded. Click Verify to detect mismatch.'); - } catch (e) { - console.error('Failed to load tampered receipt:', e); - setVerdict(false, e.message || String(e)); - } - - els.loadTamperedBtn.disabled = false; - els.loadTamperedBtn.textContent = 'Load Tampered'; -} - -function clearVerifierState() { - els.receiptInput.value = ''; - resetToNeutralState('Run verification to see verdict.'); -} - -function resolveElements() { - const missing = []; - const resolved = {}; - - for (const id of REQUIRED_ELEMENT_IDS) { - resolved[id] = document.getElementById(id); - if (!resolved[id]) missing.push(id); - } - - if (missing.length) { - console.warn(`[verify.js] Missing required element(s): ${missing.join(', ')}`); - return null; - } - - return resolved; -} - -function initVerifyPage() { - els = resolveElements(); - if (!els) return; - - els.verifyBtn.addEventListener('click', verifyReceipt); - els.loadSampleBtn.addEventListener('click', loadSampleReceipt); - els.loadTamperedBtn.addEventListener('click', loadTamperedReceipt); - els.clearBtn.addEventListener('click', clearVerifierState); - - resetToNeutralState('Load a sample receipt, then tamper it to see invalid proof detection.'); -} - -const hasDom = typeof document !== 'undefined' && typeof window !== 'undefined'; - -if (hasDom) { - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initVerifyPage, { once: true }); - } else { - initVerifyPage(); + const payload = canonicalReceiptPayload(receipt); + assert.deepEqual(Object.keys(payload).sort(), ['execution', 'input', 'output', 'signer', 'ts', 'verb']); + assert.equal(payload.signer, 'runtime.commandlayer.eth'); + assert.equal(payload.verb, 'summarize'); + assert.ok(!('metadata' in payload)); + assert.ok(!('signature' in payload)); +}); + +test('sha256 of canonicalized payload matches known sample-receipt hash', () => { + // These values are taken from examples/sample-receipt.json + const receipt = { + signer: 'runtime.commandlayer.eth', + verb: 'agent.execute', + ts: '2026-04-29T01:32:57.167Z', + input: { task: 'summarize', content: 'hello world' }, + output: { summary: 'hello world', tokens_used: 12 }, + execution: { runtime: 'wrapped-agent-demo', run_id: 'run_1777426377167' }, + }; + const expectedHash = '4ff674e92434833a00a8f9aac6941a7962b19bf7472f6d4a184ae54168dfc379'; + const canonical = canonicalize(canonicalReceiptPayload(receipt)); + const hash = sha256HexSync(canonical); + assert.equal(hash, expectedHash); +}); + +test('canonicalize: tampering output changes the canonical hash', () => { + const base = { + signer: 'runtime.commandlayer.eth', + verb: 'agent.execute', + ts: '2026-04-29T01:32:57.167Z', + input: { task: 'summarize', content: 'hello world' }, + output: { summary: 'hello world', tokens_used: 12 }, + execution: { runtime: 'wrapped-agent-demo', run_id: 'run_1777426377167' }, + }; + const tampered = JSON.parse(JSON.stringify(base)); + tampered.output.summary = 'hello world!!!'; + + const hashBase = sha256HexSync(canonicalize(canonicalReceiptPayload(base))); + const hashTampered = sha256HexSync(canonicalize(canonicalReceiptPayload(tampered))); + assert.notEqual(hashBase, hashTampered); +}); + +test('esc: HTML special characters are escaped', () => { + function esc(v) { + return String(v ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); } -} + assert.equal(esc('