diff --git a/src/domain/policy.test.ts b/src/domain/policy.test.ts index 224f29e..9b532eb 100644 --- a/src/domain/policy.test.ts +++ b/src/domain/policy.test.ts @@ -8,6 +8,12 @@ const policy: AgentPolicy = { requireIntentNote: true, } +const validDraft = { + amountSol: 0.025, + intentNote: 'Pay devnet test invoice', + recipient: 'Recipient111', +} + describe('evaluateAction', () => { it('allows an action that satisfies every local safeguard', () => { const result = evaluateAction( @@ -19,28 +25,149 @@ describe('evaluateAction', () => { expect(result.decision).toBe('ALLOW') }) - it('blocks a recipient that was not locally approved', () => { + // ── Boundary: invalid amounts ────────────────────────────────────── + + it('blocks a NaN transfer amount', () => { const result = evaluateAction( - { amountSol: 0.025, intentNote: 'Pay devnet test invoice', recipient: 'Unknown111' }, + { ...validDraft, amountSol: NaN }, policy, 0, ) expect(result.decision).toBe('BLOCK') - expect(result.reason).toBe('Recipient allowlist') }) - it('blocks a transfer that would exceed the remaining daily budget', () => { + it('blocks a zero transfer amount', () => { const result = evaluateAction( - { amountSol: 0.025, intentNote: 'Pay devnet test invoice', recipient: 'Recipient111' }, + { ...validDraft, amountSol: 0 }, + policy, + 0, + ) + + expect(result.decision).toBe('BLOCK') + }) + + it('blocks a negative transfer amount', () => { + const result = evaluateAction( + { ...validDraft, amountSol: -1 }, + policy, + 0, + ) + + expect(result.decision).toBe('BLOCK') + }) + + // ── Boundary: per-action cap ─────────────────────────────────────── + + it('blocks an amount above the per-action cap', () => { + const result = evaluateAction( + { ...validDraft, amountSol: 0.051 }, + policy, + 0, + ) + + expect(result.decision).toBe('BLOCK') + expect(result.reason).toBe('Per-action budget') + }) + + it('allows an amount exactly equal to the per-action cap', () => { + const result = evaluateAction( + { ...validDraft, amountSol: 0.05 }, + policy, + 0, + ) + + expect(result.decision).toBe('ALLOW') + }) + + // ── Boundary: daily cap ──────────────────────────────────────────── + + it('allows a transfer that exactly reaches the daily cap', () => { + // spentTodaySol=0.15 + amountSol=0.05 = dailyLimitSol=0.2 + const result = evaluateAction( + { ...validDraft, amountSol: 0.05 }, + policy, + 0.15, + ) + + expect(result.decision).toBe('ALLOW') + }) + + it('blocks a transfer exceeding the daily cap by the smallest practical value', () => { + // spentTodaySol=0.18 + amountSol=0.03 = 0.21 > dailyLimitSol=0.2 + // amountSol=0.03 is within per-action cap (0.05), so daily budget check is reached + const result = evaluateAction( + { ...validDraft, amountSol: 0.03 }, policy, - 0.19, + 0.18, ) expect(result.decision).toBe('BLOCK') expect(result.reason).toBe('Daily budget') }) + // ── Boundary: intent note length ─────────────────────────────────── + + it('blocks a required intent note shorter than 12 trimmed characters', () => { + const result = evaluateAction( + { ...validDraft, intentNote: 'Short' }, + policy, + 0, + ) + + expect(result.decision).toBe('BLOCK') + expect(result.reason).toBe('Intent evidence') + }) + + it('allows an intent note exactly 12 trimmed characters', () => { + const result = evaluateAction( + { ...validDraft, intentNote: 'Twelve chars' }, // exactly 12 chars + policy, + 0, + ) + + expect(result.decision).toBe('ALLOW') + }) + + // ── Boundary: whitespace handling ────────────────────────────────── + + it('does not let leading/trailing whitespace bypass the recipient allowlist', () => { + // Recipient is trimmed, so ' Recipient111 ' should match the allowlist + const result = evaluateAction( + { ...validDraft, recipient: ' Recipient111 ' }, + policy, + 0, + ) + + expect(result.decision).toBe('ALLOW') + }) + + it('does not let leading/trailing whitespace bypass the intent note length check', () => { + // ' Short ' trimmed === 'Short' (5 chars < 12) + const result = evaluateAction( + { ...validDraft, intentNote: ' Short ' }, + policy, + 0, + ) + + expect(result.decision).toBe('BLOCK') + expect(result.reason).toBe('Intent evidence') + }) + + it('blocks an unknown recipient even with whitespace around it', () => { + // ' Unknown111 ' trimmed === 'Unknown111' not in allowlist + const result = evaluateAction( + { ...validDraft, recipient: ' Unknown111 ' }, + policy, + 0, + ) + + expect(result.decision).toBe('BLOCK') + expect(result.reason).toBe('Recipient allowlist') + }) + + // ── Existing: fingerprint binding ────────────────────────────────── + it('binds an approval to the recipient, amount, and declared intent', () => { const approved = actionFingerprint({ amountSol: 0.025, @@ -64,4 +191,4 @@ describe('evaluateAction', () => { recipient: 'Recipient222', })).not.toBe(approved) }) -}) +}) \ No newline at end of file diff --git a/src/domain/receipt.test.ts b/src/domain/receipt.test.ts index 12b453e..8e46a2f 100644 --- a/src/domain/receipt.test.ts +++ b/src/domain/receipt.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { createReceipt, shortHash } from './receipt' +import { createReceipt, shortHash, verifyReceiptChain } from './receipt' const action = { amountSol: 0.025, @@ -31,4 +31,113 @@ describe('audit receipts', () => { expect(second.hash).not.toBe(first.hash) expect(shortHash(first.hash)).toMatch(/^[a-f0-9]{8}\.\.\.[a-f0-9]{6}$/) }) + + it('verifyReceiptChain validates a valid chain', async () => { + const first = await createReceipt({ + action, + agentId: 'audit-agent.local', + decision: 'BLOCK', + previousHash: null, + reason: 'Recipient allowlist', + status: 'blocked', + }) + const second = await createReceipt({ + action, + agentId: 'audit-agent.local', + decision: 'ALLOW', + previousHash: first.hash, + reason: 'All local safeguards passed.', + status: 'approved', + }) + const third = await createReceipt({ + action, + agentId: 'audit-agent.local', + decision: 'ALLOW', + previousHash: second.hash, + reason: 'Submitted to chain.', + status: 'submitted', + }) + + const result = await verifyReceiptChain([first, second, third]) + expect(result.valid).toBe(true) + expect(result.errors).toEqual([]) + }) + + it('verifyReceiptChain rejects chain with invalid first receipt previousHash', async () => { + const receipt = await createReceipt({ + action, + agentId: 'audit-agent.local', + decision: 'ALLOW', + previousHash: null, + reason: 'Test', + status: 'approved', + }) + const tampered = { ...receipt, previousHash: 'fake-hash' } + + const result = await verifyReceiptChain([tampered]) + expect(result.valid).toBe(false) + expect(result.errors.length).toBeGreaterThanOrEqual(1) + expect(result.errors[0]).toContain('previousHash') + }) + + it('verifyReceiptChain rejects chain with broken hash linkage', async () => { + const first = await createReceipt({ + action, + agentId: 'audit-agent.local', + decision: 'ALLOW', + previousHash: null, + reason: 'First', + status: 'approved', + }) + const second = await createReceipt({ + action, + agentId: 'audit-agent.local', + decision: 'ALLOW', + previousHash: first.hash, + reason: 'Second', + status: 'approved', + }) + const tampered = { ...second, previousHash: 'wrong-hash' } + + const result = await verifyReceiptChain([first, tampered]) + expect(result.valid).toBe(false) + expect(result.errors.some((e) => e.includes('previousHash'))).toBe(true) + }) + + it('verifyReceiptChain rejects chain with tampered content', async () => { + const first = await createReceipt({ + action, + agentId: 'audit-agent.local', + decision: 'ALLOW', + previousHash: null, + reason: 'Original reason', + status: 'approved', + }) + const tampered = { ...first, reason: 'Tampered reason' } + + const result = await verifyReceiptChain([tampered]) + expect(result.valid).toBe(false) + expect(result.errors.some((e) => e.includes('hash mismatch'))).toBe(true) + }) + + it('verifyReceiptChain returns valid for empty chain', async () => { + const result = await verifyReceiptChain([]) + expect(result.valid).toBe(true) + expect(result.errors).toEqual([]) + }) + + it('verifyReceiptChain returns valid for single receipt', async () => { + const receipt = await createReceipt({ + action, + agentId: 'audit-agent.local', + decision: 'ALLOW', + previousHash: null, + reason: 'Single receipt', + status: 'approved', + }) + + const result = await verifyReceiptChain([receipt]) + expect(result.valid).toBe(true) + expect(result.errors).toEqual([]) + }) }) diff --git a/src/domain/receipt.ts b/src/domain/receipt.ts index e0a8484..b93d3e8 100644 --- a/src/domain/receipt.ts +++ b/src/domain/receipt.ts @@ -36,3 +36,41 @@ export function shortHash(hash: string | null | undefined): string { if (!hash) return 'Genesis' return `${hash.slice(0, 8)}...${hash.slice(-6)}` } + +export type VerificationResult = { + valid: boolean + errors: string[] +} + +export async function verifyReceiptChain(receipts: AuditReceipt[]): Promise { + const errors: string[] = [] + + if (receipts.length === 0) { + return { valid: true, errors: [] } + } + + if (receipts[0].previousHash !== null) { + errors.push(`First receipt's previousHash is "${receipts[0].previousHash}", expected null`) + } + + for (let i = 0; i < receipts.length; i++) { + const receipt = receipts[i] + + const { hash: _storedHash, transactionSignature: _txSig, ...fields } = receipt + const payload = JSON.stringify(fields) + const expectedHash = await sha256(payload) + + if (expectedHash !== receipt.hash) { + errors.push(`Receipt ${i} hash mismatch: expected "${expectedHash}", got "${receipt.hash}"`) + } + + if (i > 0) { + const prev = receipts[i - 1] + if (receipt.previousHash !== prev.hash) { + errors.push(`Receipt ${i} previousHash "${receipt.previousHash}" does not match receipt ${i - 1} hash "${prev.hash}"`) + } + } + } + + return { valid: errors.length === 0, errors } +}