When an AI agent calls a tool — reads a file, applies a manifest, installs a package — you want an audit log you can trust weeks or months later. Tamper-evident doesn't require a blockchain or a signing service. A SHA-256 hash chain over append-only entries is enough to detect a rewrite.
Here's a minimal TypeScript example you can drop into an MCP server, a tool proxy, or an agent harness.
The entry shape
Each event captures the who, what, and when:
interface AuditEvent {
id: string; // unique id, e.g. ulid or uuid
timestamp: number; // epoch ms, set by the log
caller: string; // agent / client identity
action: string; // tool name or operation
input: string; // canonicalized, redacted JSON string
outcome: "ok" | "err" | "denied";
priorHash: string; // hash of the previous event
}
priorHash is the chain. Event 0's priorHash is a fixed genesis constant; every subsequent event's priorHash is the SHA-256 of the previous event's serialized text.
The logger
import { createHash } from "crypto";
const GENESIS = "0000000000000000000000000000000000000000000000000000000000000000";
function eventHash(event: AuditEvent): string {
return createHash("sha256")
.update(JSON.stringify(event, null, 0))
.digest("hex");
}
class ChainedLog {
private events: AuditEvent[] = [];
append(event: Omit<AuditEvent, "timestamp" | "priorHash">): AuditEvent {
const priorHash =
this.events.length === 0
? GENESIS
: eventHash(this.events[this.events.length - 1]);
const entry: AuditEvent = {
...event,
timestamp: Date.now(),
priorHash,
};
this.events.push(entry);
return entry;
}
/** Serialize one event to the canonical line you'd write to a file. */
line(event: AuditEvent): string {
return JSON.stringify(event, null, 0);
}
}
To make it append-only on disk, each line(entry) is appended to a file with O_APPEND (Node's fs/promises fd.appendFile), and no write removes or overwrites prior bytes.
How to verify
Verification walks the chain forward and recomputes each hash. If any priorHash in the log doesn't match the recomputed hash of its predecessor, the log was rewritten between those two entries.
function verify(events: AuditEvent[]): { valid: boolean; badAt: number | null } {
let expectedPrior = GENESIS;
for (let i = 0; i < events.length; i++) {
const e = events[i];
if (e.priorHash !== expectedPrior) {
return { valid: false, badAt: i };
}
expectedPrior = eventHash(e);
}
return { valid: true, badAt: null };
}
Usage is straightforward:
const log = new ChainedLog();
log.append({ id: "evt-1", caller: "agent-42", action: "read_file", input: '{"path":"README.md"}', outcome: "ok" });
log.append({ id: "evt-2", caller: "agent-42", action: "kubectl_apply", input: '{"manifest":"deploy.yaml","namespace":"prod"}', outcome: "denied" });
const snap = log.events;
const result = verify(snap);
console.log(result); // { valid: true, badAt: null }
If someone replaces evt-1's action with write_file or deletes evt-2, verify returns badAt: 1 on the first walk. The chain catches insertions, deletions, and in-place edits without any cryptography heavier than SHA-256.
Two things the chain doesn't do
A hash chain makes rewriting detectable. It does not, by itself:
- Anchor the chain in time or to a principal. Bind the head or a periodic checkpoint to something outside the log — a signed attestation, a write to a remote append store, or a human-reviewed summary — and the chain becomes evidence rather than just a local checksum.
- Prevent the writer from appending whatever they like. If the agent controls the logger, it can append favorable entries. That's a policy problem, not a hashing problem. The chain still proves that whatever is there wasn't altered afterward, which is the useful half.
Where to put it
- In an MCP server: log every tool invocation and its outcome.
- In a tool proxy: log every forwarded call and the policy decision (allow / deny / hold).
- In an agent harness: log every tool grant and every approval the harness surfaced.
The chain is the same everywhere. The identity in caller is what makes the entries attributable.
I'm building Cirvix AgentControl, an open-source default-deny policy layer for agent tool calls: https://github.com/CIRVIX/agent-control (try npx @cirvix_ai/agent-control scan).
Top comments (0)