@berthos/audit writes a hash-chained log of what happened and who did it: every tool call made through berth mcp, every governance verdict and, if you turn it on, every step an agent takes. Use it when you need to answer "what did this agent do, and was it allowed?" after the fact.
The trail records what an agent did through Berth: an MCP client's tool calls through berth mcp, the governance gate's verdicts, and the agent framework's steps. You read it back afterwards, and berth attest builds a checkable record of one run on top of it.
The writers are berth mcp and, in the agent framework, the step tracer and the Computer's governance gate. Each writes through an audit sink from @berthos/audit, by default to one JSONL file on the host, ~/.berth/audit/audit.jsonl. Several processes can append to that file at once. berth audit and berth attest read it.
Inside the trail: the record format, the redaction applied when payloads are captured, and the hash chain that links the records.
One JSON object per line, in a file with mode 0600:
{"ts":"2026-08-16T09:14:22.104Z","seq":41,"actor":{"kind":"operator","id":"alice","verifiedBy":"token"},"action":"governance.evaluate","target":"filesystem.write_file","decision":"denied","reason":"path outside /workspace/reports","durationMs":12,"meta":{"mode":"fail-closed"},"prevHash":"…","hash":"…"}| Field | Meaning |
|---|---|
ts, seq |
ISO-8601 time and a sequence number, so records with the same timestamp still order. |
actor |
{ kind, id, verifiedBy }. kind is operator, app, agent or anonymous. |
action |
What happened: tool.call and sandbox.boot (from berth mcp), governance.evaluate, or agent.<step kind> such as agent.tool-call. |
target |
What it happened to: app.export, container:<name>, tool:<name> or run:<runId>. |
decision |
allowed, denied or unavailable. |
reason |
Why. Always set for denied and unavailable. |
input, output |
Only with payload capture on (below), always redacted. |
meta |
Extras, redacted. berth mcp records and agent steps carry meta.runId, which is what berth attest looks up. Each berth mcp record also carries meta.bridge, an id for the session that wrote it. A sandbox.boot record carries the boot evidence in meta.evidence. A tool call the app failed carries meta.failed; one the app never answered (timed out, the write to the sandbox failed, or the session ended first) also carries meta.outcome: "unknown", since it may have run. One cancelled before it was sent carries meta.outcome: "not-sent". |
prevHash, hash |
The chain. |
verifiedBy |
Meaning |
|---|---|
peer-socket |
The kernel established it, from the socket the caller connected on. The caller can't forge it. |
token |
The actor presented a secret bound to that name. Proves possession of the secret, nothing more. |
self-asserted |
The actor named itself and nothing checked. Recorded so you know it's unknown; don't read it as an identity. |
This is not an identity system: there's no user directory, tenancy or roles.
denied: the governor refused the call.unavailable: the governor didn't answer (error or timeout). Undermode: "fail-open"the call then ran with no policy check, so this is the record to look for. See governance.- An agent step that threw is
allowedwith areason. Nothing refused it; it ran and failed. - A
berth mcptool call with no answer from the app isallowedwithmeta.outcome: "unknown"and areasonsaying the call may have run. A call still in flight when the session ended also hasmeta.interrupted. A call the client cancelled before it was sent to the app hasmeta.outcome: "not-sent"instead: it did not run. reasonholds the first line of the app's error, capped at 300 characters. It is written even with payload capture off, so the rest (often a stack, or the input the app choked on) is left out.
Off by default, controlled in two places:
createFileAuditSink({ capturePayloads: true }): whetherinputandoutputreach the file.createAgent({ tracePayloads: true }): whether tool arguments and results are put on step events at all.
When on, values pass through redact(). Keys that look secret (password, token, apiKey, authorization, cookie, …) become [redacted], long strings become a size marker, and cycles and very deep structures are described instead of stored. redact() works from a list of key names, so a secret under an unexpected key gets through. Keep capture off unless you need it.
Each record's hash is sha256 over prevHash plus the record's canonical JSON. Editing, deleting or reordering a record breaks every hash after it, and berth audit verify reports where. The chain continues across restarts and across rotation. Several processes can write the same file at once (two berth mcp sessions share the default path): each write takes a lock on <file>.lock and reads the chain's head from the file before appending, so they extend one chain rather than forking it. A lock left by a writer that died is broken once its pid is gone. A live writer's lock is never broken, however slow the writer. A lock from another host (a shared home directory), or one with no readable owner, is broken after 10 s.
Only writers that take the lock keep to one chain. berth 0.2.4 or earlier writing the same file at the same time takes no lock and appends from its own in-memory head, so the chain forks from its first record on. Run one version against a given audit file, or give the older one its own path.
berth attest builds on this chain to produce a checkable record of one run.
Every tool call through berth mcp is recorded under the session's run id, which the bridge prints on stderr when it starts:
[berth:mcp] recording tool calls in ~/.berth/audit/audit.jsonl as run mcp-filesystem-20260928T101500Z-3fa2c1 — attest it with `berth attest mcp-filesystem-20260928T101500Z-3fa2c1`
This covers any agent that reaches Berth over MCP, including Claude Code, Cursor, and a LangChain or other loop using an MCP adapter. Pick the run id yourself with --run-id, write somewhere else with --audit-file, or turn it off with --no-audit. The bridge also records the sandbox's boot evidence once per session, so the run can be attested after the sandbox has stopped.
import { createFileAuditSink, defaultAuditPath } from "@berthos/audit";
import { createAgent } from "@berthos/agents";
import { homedir } from "node:os";
const audit = createFileAuditSink({ path: defaultAuditPath(homedir()) });
const { agent } = await createAgent({
apps: ["filesystem"],
audit, // governance verdicts + agent steps
actor: { kind: "operator", id: "alice", verifiedBy: "token" },
});audit on createAgent feeds both the step tracer and the Computer's governance gate. For a Computer you boot yourself, pass it to the gate: Computer.boot({ governance: { audit, actor } }). @berthos/agents is the experimental agent framework and isn't published; use it from a clone.
The default path is ~/.berth/audit/audit.jsonl. See examples/audit-trail for a runnable demo that edits a record and catches it.
berth audit list # everything, oldest first
berth audit list --decision denied # just refusals
berth audit list --actor alice --limit 50
berth audit list --json # raw records, one per line
berth audit verify # check the hash chain| Flag | Command | Meaning |
|---|---|---|
--file <path> |
list, verify |
Audit file to read. Default ~/.berth/audit/audit.jsonl. |
--decision <d> |
list |
Only allowed, denied or unavailable. |
--actor <id> |
list |
Only records whose actor.id matches. |
--action <prefix> |
list |
Only records whose action starts with this. |
--limit <n> |
list |
The most recent n matching records. |
--json |
list |
Print raw records instead of the formatted view. |
berth audit verify checks every rotated segment, oldest first, and exits non-zero at the first BROKEN record.
createFileAuditSink(options):
| Option | Default | Meaning |
|---|---|---|
path |
required | JSONL file. Parent directories are created. |
capturePayloads |
false |
Write redacted input and output. |
redact |
Options passed to redact(). |
|
maxBytes |
16MB | Rotate when the file reaches this size. |
maxFiles |
5 | Rotated files to keep (audit.jsonl.1 … .5). Older ones are deleted. |
Other sinks: createMemoryAuditSink(), createConsoleAuditSink() (stderr) and combineAuditSinks(...).
- Writes are synchronous, so a crash doesn't lose buffered records. Volume is low: one line per verdict or step.
- A failing sink never fails the audited call. It prints a warning on stderr and drops the record.
- Rotation is size-based only. There's no other retention policy; prune old segments with whatever manages the host.
- After rotation deletes the oldest segment, the chain no longer starts at the beginning.
berth audit verifyandberth attestcheck everything still on disk and print a note that earlier segments are gone. From the files alone, routine pruning and someone deleting segments look the same. agent-initboot events go to the container's stderr, not to this sink, as JSON lines with"source":"agent-init".- HTTP access logs are Fastify's, on stdout, and aren't chained.
- Tamper-evident, not tamper-proof. Anyone who can write the file can recompute every hash from the line they edited and produce a chain that verifies.
berth audit verifysays this in its output. - Local files only. There's no remote sink, so the trail is only as safe as the host.
- Plaintext on disk. Nothing is encrypted at rest, which is why payload capture is off by default.