Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 134 additions & 7 deletions src/domain/policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
Expand All @@ -64,4 +191,4 @@ describe('evaluateAction', () => {
recipient: 'Recipient222',
})).not.toBe(approved)
})
})
})
111 changes: 110 additions & 1 deletion src/domain/receipt.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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([])
})
})
38 changes: 38 additions & 0 deletions src/domain/receipt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<VerificationResult> {
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 }
}