Compare commits

...

2 Commits

Author SHA1 Message Date
Claw
d503e1fc1c ops+test: persist spend ledger in prod (volume) + restart-survival test
- Dockerfile: writable /app/data (owned by bun) + SPEND_LEDGER_PATH default.
- compose.yml: named volume agentview-data:/app/data so budgets+audit survive
  redeploys (without it, every redeploy reset the leash — the scope pass caught this).
- test: persistence round-trip — a budget + spend survive a simulated restart
  (reload from the same file), proving budgets are NOT reset.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-06 06:12:45 -05:00
Claw
8578dbc817 feat(safety): persistent spend ledger — budgets survive restarts
The in-memory ledger reset all spend state on restart, which silently defeated the
budget (restart → budget resets → agent can spend again). createSpendLedger now
persists to SPEND_LEDGER_PATH (whole state on each mutation; bigints as strings),
loading on startup. Budgets + audit trail survive restarts/redeploys (mount the
dir as a volume). Reference persistence; a real deployment uses a transactional DB.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-06 06:07:22 -05:00
5 changed files with 82 additions and 4 deletions

View File

@ -17,9 +17,14 @@ COPY tsconfig.json ./
# IIFE format (not ESM) so it loads as a classic <script> and self-assigns window.AgentViewPay.
RUN bun build src/web/pay.ts --target=browser --format=iife --minify --outfile dist-web/pay.js
# Writable data dir for the persistent spend ledger (mounted as a volume in compose.yml).
# Owned by the unprivileged 'bun' user so the process can write budgets/audit across restarts.
RUN mkdir -p /app/data && chown -R bun:bun /app/data
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=8402
ENV SPEND_LEDGER_PATH=/app/data/spend-ledger.json
# Drop to the image's unprivileged 'bun' user.
USER bun

View File

@ -19,6 +19,10 @@ services:
environment:
HOST: 0.0.0.0
PORT: 8402
# Persist the spend ledger (budgets + audit) across restarts/redeploys. Without this a
# redeploy would reset every agent's budget — silently defeating the leash.
volumes:
- agentview-data:/app/data
networks: [coolify]
labels:
- traefik.enable=true
@ -31,6 +35,9 @@ services:
- traefik.http.routers.agentview.tls.certresolver=letsencrypt
- traefik.http.services.agentview.loadbalancer.server.port=8402
volumes:
agentview-data:
networks:
coolify:
external: true

View File

@ -6,6 +6,8 @@
* so an operator can see exactly what an agent did and why.
*/
import type { SpendPolicy, SpendState } from './policy.ts';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
export interface AuditEntry {
at: number; // unix seconds
@ -34,13 +36,51 @@ export interface SpendLedger {
audit(agentId: string): AuditEntry[];
}
export function createSpendLedger(): SpendLedger {
// Policy <-> JSON (bigint fields serialize as strings so budgets survive a restart).
type StoredPolicy = Omit<SpendPolicy, 'budgetTotalAtomic' | 'perPaymentMaxAtomic' | 'windowMaxAtomic'> & {
budgetTotalAtomic: string;
perPaymentMaxAtomic: string;
windowMaxAtomic: string;
};
const toStored = (p: SpendPolicy): StoredPolicy => ({ ...p, budgetTotalAtomic: p.budgetTotalAtomic.toString(), perPaymentMaxAtomic: p.perPaymentMaxAtomic.toString(), windowMaxAtomic: p.windowMaxAtomic.toString() });
const fromStored = (p: StoredPolicy): SpendPolicy => ({ ...p, budgetTotalAtomic: BigInt(p.budgetTotalAtomic), perPaymentMaxAtomic: BigInt(p.perPaymentMaxAtomic), windowMaxAtomic: BigInt(p.windowMaxAtomic) });
/**
* @param persistPath Optional file to persist the ledger to. Without it the ledger is in-memory
* only a restart resets all spend state, which DEFEATS the budget. With it, budgets and audit
* survive restarts/redeploys (mount the file's dir as a volume). Reference persistence (whole
* state written on each mutation); a real deployment uses a transactional DB.
*/
export function createSpendLedger(persistPath?: string): SpendLedger {
const agents = new Map<string, AgentRecord>();
if (persistPath && existsSync(persistPath)) {
try {
const raw = JSON.parse(readFileSync(persistPath, 'utf8')) as { agents: Record<string, { policy: StoredPolicy; entries: AuditEntry[] }> };
for (const [id, rec] of Object.entries(raw.agents ?? {})) {
agents.set(id, { policy: fromStored(rec.policy), entries: rec.entries ?? [] });
}
} catch {
/* corrupt/unreadable ledger file — start empty rather than crash */
}
}
const save = (): void => {
if (!persistPath) return;
try {
mkdirSync(dirname(persistPath), { recursive: true });
const out = { agents: Object.fromEntries([...agents].map(([id, r]) => [id, { policy: toStored(r.policy), entries: r.entries }])) };
writeFileSync(persistPath, JSON.stringify(out));
} catch {
/* never let a persistence failure break enforcement */
}
};
return {
setPolicy(policy) {
const existing = agents.get(policy.agentId);
agents.set(policy.agentId, { policy, entries: existing?.entries ?? [] });
save();
},
getPolicy(agentId) {
return agents.get(agentId)?.policy;
@ -68,6 +108,7 @@ export function createSpendLedger(): SpendLedger {
const rec = agents.get(agentId);
if (!rec) return;
rec.entries.push(entry);
save();
},
audit(agentId) {
return agents.get(agentId)?.entries ?? [];

View File

@ -50,9 +50,10 @@ const x402Deps: X402Deps | null = config.x402
? { config: config.x402, facilitator: createFacilitator(config.x402), replay, ...(receiptSigner ? { receiptSigner } : {}) }
: null;
// Safety layer: per-agent spend policies + audit ledger. The gateway signs with a spending key
// (the demo buyer key here); the policy is enforced before any payment.
const spendLedger = createSpendLedger();
// Safety layer: per-agent spend policies + audit ledger. Persisted when SPEND_LEDGER_PATH is set
// (else in-memory only — a restart would reset budgets, which defeats them). The gateway signs
// with a spending key; the policy is enforced before any payment.
const spendLedger = createSpendLedger((process.env.SPEND_LEDGER_PATH ?? '').trim() || undefined);
// Cooldowns for the public scripted safety demo (each run does real testnet settlements, so it
// must be un-drainable). The per-IP one is UX only and keys on X-Forwarded-For, which is

View File

@ -47,6 +47,30 @@ test('blocks over the total budget', () => {
expect(d.reason).toBe('over_total_budget');
});
test('ledger persistence: budget + spend survive a restart (reload from the same file)', () => {
const path = `/tmp/av-ledger-test-${process.pid}.json`;
try {
// "boot 1": set a policy, record an allowed spend, then drop the ledger (simulated restart).
const l1 = createSpendLedger(path);
l1.setPolicy(policy);
const now = 2_000_000;
l1.record('a', { at: now, recipient: RCV, amountAtomic: '10000', network: 'eip155:84532', asset: '', allowed: true, reason: 'ok', transaction: '0xtx' });
// "boot 2": a fresh ledger loading the same file must reconstruct the policy AND the spend.
const l2 = createSpendLedger(path);
const p = l2.getPolicy('a');
expect(p?.budgetTotalAtomic).toBe(20000n); // bigint round-tripped through JSON
const s = l2.state('a', now + 10);
expect(s.spentTotalAtomic).toBe(10000n); // spend survived the restart — budget is NOT reset
expect(l2.audit('a').length).toBe(1);
} finally {
try {
require('node:fs').unlinkSync(path);
} catch {
/* ignore */
}
}
});
test('ledger: allowed payments consume budget; denied ones do not; window respected', () => {
const l = createSpendLedger();
l.setPolicy(policy);