feat(signer): local append-only audit log

Records intent at signing time; the signer has no network so it cannot
observe submission. Refusals are recorded but never consume daily budget.

Hardened beyond the brief after an edge-case pass found two genuine
under-report vectors in committedTodayLovelace() (the function the daily
limit's alreadySpentTodayLovelace comes from): a negative netOutflowLovelace
string parses as a valid negative bigint and would silently subtract from the
total, and a null element in the on-disk array crashes the summation outright
rather than being skipped. Both are neutralized. Also: load() now reports a
non-array log file on stderr instead of resetting silently, recent(0) no
longer returns the whole log (Array.prototype.slice(-0) is slice(0), not "no
entries" - a plain JS gotcha), and append() now throws on a write failure
instead of swallowing it, since a swallowed failure is a normal-looking
return while the entry silently exists only in memory until the next
restart's reload finds it missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
FilipBl4gojevic
2026-07-28 13:38:29 +02:00
parent ea3a23e5f9
commit 74ddf873d6
2 changed files with 523 additions and 0 deletions

View File

@@ -0,0 +1,161 @@
// Local, per-user audit log. Records intent at signing time, not confirmed
// submission — the signer has no network, so it cannot know whether a submit
// later succeeded. That over-counts on a failed submit, which is conservative.
//
// The log file is untrusted input every time it is read: it can be hand-edited,
// truncated by a process that died mid-write, or produced by a future version
// of this module with a different entry shape. Every method below treats it
// accordingly. Two different failure shapes get two different responses, on
// purpose:
// - The whole file is unreadable or not a JSON array: nothing in it can be
// trusted, so start clean. Loud on stderr, never silent (see load()).
// - The file is a JSON array but one element in it is garbage (not an
// object — most dangerously `null`, see below): drop only that element
// and keep the rest. A single bad entry must not erase an otherwise-good
// day's history, since committedTodayLovelace() is exactly what the
// daily spend limit depends on.
// Nothing here ever throws out of committedTodayLovelace() or recent(): a
// thrown exception from a "how much have I already spent" query is the kind
// of bug most likely to be handled upstream by assuming zero — which would
// silently defeat the daily limit. append() is the one exception: see its
// own comment for why a write failure there throws instead of swallowing.
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
export interface AuditEntry {
timestamp: string;
txHash: string;
decision: 'signed' | 'refused';
netOutflowLovelace: string;
reason?: string;
assetMovements: number;
}
export class AuditLog {
private entries: AuditEntry[] = [];
constructor(private readonly filePath: string) {
this.load();
}
private load(): void {
if (!existsSync(this.filePath)) return;
try {
const parsed: unknown = JSON.parse(readFileSync(this.filePath, 'utf8'));
if (!Array.isArray(parsed)) {
throw new Error('log file did not contain a JSON array');
}
// JSON.parse can hand back array elements of any shape. Most of those
// are harmless here even though they are not real AuditEntry objects:
// a stray string, number, or boolean silently returns `undefined` for
// `.decision`, which just fails the `!== 'signed'` check like any other
// unrecognised value. `null` is the one exception — property access on
// null throws — so committedTodayLovelace() would crash on the entry
// AFTER it, and every entry after that, not just skip the bad one.
// Filtering to object-shaped, non-array elements up front means every
// other method can assume its entries are at least objects, even if
// individual fields on them still turn out to be garbage (handled
// separately, per field, in committedTodayLovelace()).
const usable = parsed.filter(
(e): e is AuditEntry => e !== null && typeof e === 'object' && !Array.isArray(e)
);
if (usable.length !== parsed.length) {
console.error(
`[audit] ${this.filePath}: dropped ${parsed.length - usable.length} of ${parsed.length} log entries that were not objects (e.g. null); keeping the rest`
);
}
this.entries = usable;
} catch (err) {
// A corrupt log must not stop the signer from working, and must not be
// silently trusted either. Start clean; the file is overwritten on the
// next append. This branch also catches a valid-JSON-but-wrong-shape
// file (an object, `null`, a string, ...) via the throw above — that is
// just as untrustworthy as a syntax error and gets the same fresh-start
// treatment and the same stderr line.
console.error(
`[audit] ${this.filePath} was unreadable (${err instanceof Error ? err.message : String(err)}); starting a fresh log`
);
this.entries = [];
}
}
append(entry: AuditEntry): void {
// Pushed before the write is attempted: if the write below fails, this
// process's own in-memory state (committedTodayLovelace(), recent())
// must still reflect the decision that was actually made. Losing it here
// would under-count even within this same process, which would be worse
// than the cross-restart gap discussed below.
this.entries.push(entry);
try {
writeFileSync(this.filePath, JSON.stringify(this.entries, null, 2) + '\n');
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`[audit] could not write ${this.filePath}: ${message}`);
// Deliberately rethrown rather than swallowed. Swallowing would make
// append() return normally — a plausible-looking success — while the
// entry silently exists only in this process's memory. That is fine
// until the next restart: a fresh AuditLog reloads from disk, does not
// find this entry, and committedTodayLovelace() under-reports by
// exactly its amount. For a 'signed' entry that is precisely the
// failure this module exists to prevent — it raises the effective
// daily cap with no visible symptom until someone goes looking. Making
// the write failure loud and synchronous, at the moment it happens,
// is the fail-closed choice: the caller finds out now, instead of a
// future process finding out by silently allowing more spend than
// intended. This does mean a write failure can surface as a thrown
// error at the call site (e.g. the tool call that just signed a
// transaction); the caller is expected to treat that as "the signature
// was produced but its audit record was not durably saved" and act
// accordingly, not to retry blindly.
throw new Error(`Audit log write failed (entry kept in memory only, not persisted): ${message}`);
}
}
/** Lovelace committed today (UTC) by signed transactions. Refusals do not count. */
committedTodayLovelace(): bigint {
const startOfDayUtc = new Date();
startOfDayUtc.setUTCHours(0, 0, 0, 0);
const cutoff = startOfDayUtc.getTime();
let total = 0n;
for (const e of this.entries) {
if (e.decision !== 'signed') continue;
const t = new Date(e.timestamp).getTime();
if (Number.isNaN(t) || t < cutoff) continue;
let amount: bigint;
try {
amount = BigInt(e.netOutflowLovelace);
} catch {
// Not parseable at all (non-numeric text, a decimal, ...). Ignore
// rather than crash the signer's accounting.
continue;
}
// BigInt string parsing accepts more than plain positive integers.
// BigInt('') is 0n — a harmless no-op, not a crash and not skipped,
// just zero. BigInt('-5000000') is a valid negative bigint, and that
// one is NOT harmless: adding a negative amount can only ever reduce
// the running total, i.e. under-report — the one direction this
// function must never move in. Nothing in the real pipeline can
// produce a negative netOutflowLovelace (policy.ts sums non-negative
// output lovelace plus a non-negative fee), so a negative value here
// can only come from a corrupted or hand-edited log entry. Treated the
// same as any other unparseable amount: skipped, not subtracted.
if (amount < 0n) continue;
total += amount;
}
return total;
}
recent(limit: number): AuditEntry[] {
// Array.prototype.slice(-0) behaves as slice(0): -0 is not < 0, so the
// "count back from the end" branch never triggers, and the call returns
// the ENTIRE array instead of none. limit <= 0 is handled explicitly so
// recent(0) means "no entries" rather than "all of them" — and the same
// guard makes a negative limit equally harmless instead of doing
// whatever slice() would otherwise do with it (drop entries off the
// front, which is not a sensible reading of "give me N recent entries").
if (limit <= 0) return [];
return this.entries.slice(-limit).reverse();
}
}

View File

@@ -0,0 +1,362 @@
import { describe, test, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { AuditLog, type AuditEntry } from '../src/audit.ts';
let dir: string;
let logPath: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'signer-audit-'));
logPath = join(dir, 'audit.json');
});
afterEach(() => rmSync(dir, { recursive: true, force: true }));
function entry(over: Partial<AuditEntry> = {}): AuditEntry {
return {
timestamp: new Date().toISOString(),
txHash: 'b'.repeat(64),
decision: 'signed',
netOutflowLovelace: '3000000',
assetMovements: 0,
...over,
};
}
describe('AuditLog', () => {
test('starts empty when the file does not exist', () => {
const log = new AuditLog(logPath);
assert.deepStrictEqual(log.recent(10), []);
assert.equal(log.committedTodayLovelace(), 0n);
});
test('appends and persists to disk', () => {
new AuditLog(logPath).append(entry());
assert.ok(existsSync(logPath));
assert.equal(JSON.parse(readFileSync(logPath, 'utf8')).length, 1);
});
test('reloads entries written by a previous instance', () => {
new AuditLog(logPath).append(entry());
assert.equal(new AuditLog(logPath).recent(10).length, 1);
});
test('sums only signed entries into today total', () => {
const log = new AuditLog(logPath);
log.append(entry({ netOutflowLovelace: '3000000' }));
log.append(entry({ netOutflowLovelace: '5000000' }));
assert.equal(log.committedTodayLovelace(), 8_000_000n);
});
test('excludes refused entries from today total', () => {
const log = new AuditLog(logPath);
log.append(entry({ netOutflowLovelace: '3000000' }));
log.append(entry({ decision: 'refused', netOutflowLovelace: '900000000', reason: 'over limit' }));
assert.equal(log.committedTodayLovelace(), 3_000_000n, 'a refusal must not consume budget');
});
test('excludes entries from previous days', () => {
const log = new AuditLog(logPath);
log.append(entry({ timestamp: '2020-01-01T00:00:00.000Z', netOutflowLovelace: '400000000' }));
log.append(entry({ netOutflowLovelace: '3000000' }));
assert.equal(log.committedTodayLovelace(), 3_000_000n);
});
test('recent() returns newest first and respects the limit', () => {
const log = new AuditLog(logPath);
log.append(entry({ txHash: 'c'.repeat(64) }));
log.append(entry({ txHash: 'd'.repeat(64) }));
const r = log.recent(1);
assert.equal(r.length, 1);
assert.equal(r[0].txHash, 'd'.repeat(64));
});
test('survives a corrupt log file rather than crashing', () => {
// The brief's own version of this test called `require('node:fs')` here,
// which throws `ReferenceError: require is not defined` in this package:
// packages/signer/package.json sets "type": "module", so this file is
// real ESM (not CJS-via-tsx-in-a-directory-with-no-package.json, which is
// what made an ad-hoc isolated check of the same snippet misleadingly
// pass). Using the top-level import instead is the only change from the
// brief's given test code needed to make it run at all in this package.
writeFileSync(logPath, 'not json at all');
const log = new AuditLog(logPath);
assert.deepStrictEqual(log.recent(10), []);
log.append(entry());
assert.equal(log.recent(10).length, 1);
});
});
// --- Edge-case pass (beyond the brief) --------------------------------------
//
// The brief's 8 tests are a floor. Everything below was added after they
// passed, hunting specifically for the failure shape this module is exposed
// to: committedTodayLovelace() quietly returning a number that is too LOW
// (which loosens the daily limit) rather than throwing or visibly failing.
// Some of these were verified against plain Node semantics before writing an
// assertion, precisely because a couple of them are not what intuition
// suggests (see the BigInt('') and slice(-0) cases below).
/** Captures console.error output for the duration of `fn`, then restores it. */
function captureStderr(fn: () => void): string[] {
const messages: string[] = [];
const original = console.error;
console.error = (...args: unknown[]) => {
messages.push(args.map(String).join(' '));
};
try {
fn();
} finally {
console.error = original;
}
return messages;
}
describe('committedTodayLovelace — netOutflowLovelace parsing', () => {
test('an unparseable amount (non-numeric text) does not crash the total and contributes nothing', () => {
const log = new AuditLog(logPath);
log.append(entry({ netOutflowLovelace: '3000000' }));
log.append(entry({ netOutflowLovelace: 'not-a-number' }));
assert.equal(log.committedTodayLovelace(), 3_000_000n);
});
test('a decimal amount is rejected by BigInt (no fractional lovelace) and contributes nothing', () => {
const log = new AuditLog(logPath);
log.append(entry({ netOutflowLovelace: '3000000' }));
log.append(entry({ netOutflowLovelace: '3000000.5' }));
assert.equal(log.committedTodayLovelace(), 3_000_000n);
});
test('an empty-string amount parses as BigInt("") === 0n — verified directly, it does NOT throw — so it contributes zero rather than crashing or getting "skipped"', () => {
// BigInt("") is a genuine JS gotcha (mirrors Number("") === 0): the catch
// block never fires for this input, so this is a different code path
// from the non-numeric-text case above even though the externally
// observable result (no change to the total) looks the same.
const log = new AuditLog(logPath);
log.append(entry({ netOutflowLovelace: '3000000' }));
log.append(entry({ netOutflowLovelace: '' }));
assert.equal(log.committedTodayLovelace(), 3_000_000n);
});
test('a negative amount parses successfully as a valid negative bigint but must not reduce the total', () => {
// The dangerous case: BigInt('-5000000') does not throw, so without an
// explicit sign check this entry would SUBTRACT from an already-correct
// total — an actual under-report, not just a missed contribution. Not
// reachable through the real decode.ts -> policy.ts pipeline (outputs
// and fees are always non-negative), so this can only arise from a
// corrupted or hand-edited log file, which is exactly the input this
// module must not trust.
const log = new AuditLog(logPath);
log.append(entry({ netOutflowLovelace: '5000000' }));
log.append(entry({ netOutflowLovelace: '-5000000' }));
assert.equal(
log.committedTodayLovelace(),
5_000_000n,
'a negative entry must never subtract from the committed total'
);
});
});
describe('committedTodayLovelace — timestamp handling', () => {
test('a missing timestamp field does not crash the total and is excluded', () => {
const raw = [
{ txHash: 'e'.repeat(64), decision: 'signed', netOutflowLovelace: '3000000', assetMovements: 0 },
// no `timestamp` key at all — simulates an older or hand-edited entry.
];
writeFileSync(logPath, JSON.stringify(raw));
const log = new AuditLog(logPath);
assert.equal(log.committedTodayLovelace(), 0n);
assert.equal(log.recent(10).length, 1, 'the entry itself is still visible for forensic purposes');
});
test('an unparseable timestamp string does not crash the total and is excluded', () => {
const log = new AuditLog(logPath);
log.append(entry({ netOutflowLovelace: '3000000' }));
log.append({ ...entry({ netOutflowLovelace: '9000000' }), timestamp: 'not-a-date' });
assert.equal(log.committedTodayLovelace(), 3_000_000n);
});
test('a JSON null timestamp resolves to the 1970 epoch (verified: does not throw) and is excluded as not-today', () => {
// new Date(null) is new Date(+null) = new Date(0) = the epoch — a VALID
// date, not NaN. It still gets excluded, but via the "not today" branch
// rather than the NaN branch; worth pinning so that distinction stays
// intentional rather than incidental.
const raw = [
{
txHash: 'e'.repeat(64),
decision: 'signed',
netOutflowLovelace: '3000000',
assetMovements: 0,
timestamp: null,
},
];
writeFileSync(logPath, JSON.stringify(raw));
const log = new AuditLog(logPath);
assert.equal(log.committedTodayLovelace(), 0n);
});
});
describe('committedTodayLovelace — the UTC day boundary', () => {
test('23:59 UTC yesterday is excluded and 00:01 UTC today is included', () => {
const now = new Date();
const startOfTodayUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
const yesterday2359 = new Date(startOfTodayUtc - 60_000).toISOString(); // 23:59:00 UTC yesterday
const today0001 = new Date(startOfTodayUtc + 60_000).toISOString(); // 00:01:00 UTC today
const log = new AuditLog(logPath);
log.append(entry({ timestamp: yesterday2359, netOutflowLovelace: '400000000' }));
log.append(entry({ timestamp: today0001, netOutflowLovelace: '3000000' }));
assert.equal(log.committedTodayLovelace(), 3_000_000n, 'only the entry from today (UTC) should count');
});
test('an entry timestamped at exactly UTC midnight counts as today (inclusive boundary)', () => {
const now = new Date();
const startOfTodayUtc = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
).toISOString();
const log = new AuditLog(logPath);
log.append(entry({ timestamp: startOfTodayUtc, netOutflowLovelace: '3000000' }));
assert.equal(log.committedTodayLovelace(), 3_000_000n);
});
});
describe('load() — malformed on-disk shapes', () => {
test('a JSON object at the top level (not an array) starts clean rather than crashing', () => {
writeFileSync(logPath, JSON.stringify({ oops: 'not an array' }));
const log = new AuditLog(logPath);
assert.deepStrictEqual(log.recent(10), []);
assert.equal(log.committedTodayLovelace(), 0n);
});
test('a bare JSON null at the top level starts clean rather than crashing', () => {
writeFileSync(logPath, 'null');
const log = new AuditLog(logPath);
assert.deepStrictEqual(log.recent(10), []);
assert.equal(log.committedTodayLovelace(), 0n);
});
test('a non-array top-level shape is reported on stderr, not silently accepted', () => {
const messages = captureStderr(() => {
writeFileSync(logPath, JSON.stringify({ oops: 'not an array' }));
new AuditLog(logPath);
});
assert.equal(messages.length, 1);
assert.match(messages[0], /\[audit\]/);
});
test('a null element inside an otherwise-valid array crashed committedTodayLovelace() before the fix; now it is dropped and the rest of the log still counts', () => {
// Verified directly against plain JS before writing this test: accessing
// `.decision` on `null` throws (unlike a stray string or number element,
// which just yields `undefined` and is safely skipped). One bad element
// anywhere in the array — from a hand edit, a future producer bug, or a
// torn write that happens to still leave valid-but-odd JSON — must not
// take down accounting for every entry in the file.
const good = entry({ netOutflowLovelace: '5000000' });
writeFileSync(logPath, JSON.stringify([null, good]));
const log = new AuditLog(logPath);
assert.equal(log.committedTodayLovelace(), 5_000_000n);
assert.equal(log.recent(10).length, 1, 'only the one genuine entry survives; null is dropped, not kept as a phantom entry');
});
test('dropping a null element is reported on stderr, not silently accepted', () => {
const good = entry({ netOutflowLovelace: '5000000' });
const messages = captureStderr(() => {
writeFileSync(logPath, JSON.stringify([null, good]));
new AuditLog(logPath);
});
assert.equal(messages.length, 1);
assert.match(messages[0], /\[audit\]/);
assert.match(messages[0], /dropped 1 of 2/);
});
});
describe('recent() — limit handling', () => {
test('recent(0) returns no entries, not the entire log (slice(-0) === slice(0) is a JS gotcha — verified directly)', () => {
// [1,2,3].slice(-0) returns [1,2,3], the whole array, because -0 < 0 is
// false in JS — the "count back from the end" branch never triggers.
// Without an explicit limit <= 0 guard, recent(0) would silently return
// every entry ever recorded instead of none.
const log = new AuditLog(logPath);
log.append(entry());
log.append(entry());
assert.deepStrictEqual(log.recent(0), []);
});
test('a negative limit is treated the same as zero rather than slicing from the front', () => {
const log = new AuditLog(logPath);
log.append(entry());
assert.deepStrictEqual(log.recent(-1), []);
});
test('recent(n) where n exceeds the entry count returns everything available, newest first', () => {
const log = new AuditLog(logPath);
log.append(entry({ txHash: 'c'.repeat(64) }));
log.append(entry({ txHash: 'd'.repeat(64) }));
const r = log.recent(1000);
assert.equal(r.length, 2);
assert.equal(r[0].txHash, 'd'.repeat(64));
assert.equal(r[1].txHash, 'c'.repeat(64));
});
});
describe('append() — write failure', () => {
test('a write to an unwritable path throws, but the entry is kept in this process\'s memory rather than lost', () => {
const unwritablePath = join(dir, 'no-such-subdirectory', 'audit.json');
const log = new AuditLog(unwritablePath);
assert.throws(() => log.append(entry({ netOutflowLovelace: '3000000' })), /audit log write failed/i);
// The write failed, but the decision it recorded must still be visible
// to this same process — see the comment on append() in audit.ts for why
// silently losing it here (or silently swallowing the write failure)
// would be worse than throwing.
assert.equal(log.recent(10).length, 1);
assert.equal(log.committedTodayLovelace(), 3_000_000n);
});
test('a write failure is reported on stderr in addition to being thrown', () => {
const unwritablePath = join(dir, 'no-such-subdirectory', 'audit.json');
const log = new AuditLog(unwritablePath);
const messages = captureStderr(() => {
assert.throws(() => log.append(entry()));
});
assert.equal(messages.length, 1);
assert.match(messages[0], /\[audit\]/);
});
test('a refused entry\'s write failure throws exactly like a signed entry\'s — append() does not special-case by decision', () => {
const unwritablePath = join(dir, 'no-such-subdirectory', 'audit.json');
const log = new AuditLog(unwritablePath);
assert.throws(
() => log.append(entry({ decision: 'refused', reason: 'over limit', netOutflowLovelace: '900000000' })),
/audit log write failed/i
);
assert.equal(log.recent(10).length, 1);
});
});
describe('committedTodayLovelace — decision field integrity', () => {
test('recent() returns entries newest-first even when some are refused, and refused entries carry their reason', () => {
const log = new AuditLog(logPath);
log.append(entry({ netOutflowLovelace: '3000000' }));
log.append(entry({ decision: 'refused', netOutflowLovelace: '900000000', reason: 'over limit' }));
const r = log.recent(10);
assert.equal(r.length, 2);
assert.equal(r[0].decision, 'refused');
assert.equal(r[0].reason, 'over limit');
assert.equal(r[1].decision, 'signed');
});
test('many interleaved signed and refused entries: only the signed amounts sum, regardless of order', () => {
const log = new AuditLog(logPath);
log.append(entry({ decision: 'refused', netOutflowLovelace: '999000000', reason: 'too big' }));
log.append(entry({ netOutflowLovelace: '1000000' }));
log.append(entry({ decision: 'refused', netOutflowLovelace: '888000000', reason: 'too big' }));
log.append(entry({ netOutflowLovelace: '2000000' }));
log.append(entry({ decision: 'refused', netOutflowLovelace: '777000000', reason: 'too big' }));
log.append(entry({ netOutflowLovelace: '4000000' }));
assert.equal(log.committedTodayLovelace(), 7_000_000n);
assert.equal(log.recent(100).length, 6);
});
});