The audit trail
Distinct from the event log, and the distinction is the whole point.
The event log is the conversation: complete, replayable, local, and full of payloads nobody wants shipped to a SIEM. It has no retention, no export and no tamper evidence, because that is not its job.
An audit record is the receipt: bounded, redacted, hash-chained and self-attributing, so one line is meaningful on its own without replaying a session to interpret it.
Turning it on
Off unless you ask for it.
audit:
enabled: true
retentionDays: 400
includePromptText: false # the default
# sink: <name> # omit for the local floorThe record
interface AuditRecord {
readonly seq: number; // contiguous from 0; a gap is evidence
readonly ts: number;
readonly sessionId: SessionId;
readonly turnId: TurnId;
readonly action: AuditAction;
readonly eventType: MoxxyEventType; // traceable back to the log
readonly actor?: Principal; // absent means the surface had no identity
readonly detail?: Record<string, unknown>; // bounded, redacted
readonly prevHash: string | null;
readonly hash: string;
}AuditAction is deliberately coarser than the event type, because an auditor reasons about categories of action rather than the loop's internals:
prompt · tool.request · tool.approved · tool.denied · tool.result
skill.invoked · skill.created · plugin.registered · plugin.unregistered
abort · error · otherEverything absent from that map is conversation, not audit: assistant text, reasoning, provider request and response, compaction, elision, mode iterations. They belong in the event log and would only bloat a trail somebody has to read.
actor being absent is itself worth auditing. It says the surface could not establish an identity, which is a different fact from "a service did it". See identity.
The local floor
One hash-chained JSONL per day under ~/.moxxy/audit/, owner-only.
Daily rather than per-session, because an auditor asks "what happened on the 14th", not "what happened in session 01J…". It also means the chain spans every session that day, so deleting one session's records still breaks the chain. The day key is UTC, so records do not reshuffle across a DST boundary or when a fleet spans time zones.
Each record commits to its predecessor's hash. Removing or editing one breaks every hash after it.
moxxy security audit-log # list days, verify each chain
moxxy security audit-log 2026-07-28 # one day: record count + chain headExit code 1 on a break, so a scheduled compliance check can gate on it.
A truncated final line — the process died mid-write — is skipped rather than thrown on, so the rest of the day still verifies, and the resulting seq gap is reported by the verifier in its own right.
Tamper-evident, not tamper-proof
Whoever can write the file can recompute the chain. This catches silent selective deletion, which is the realistic threat on a workstation. It does not survive an attacker with write access and intent. Point the sink at a remote service to get a chain head the workstation cannot rewrite.
What is redacted, and where
Redaction happens at the sink boundary, not at the display boundary, so it holds no matter which surface was in use.
- Tool inputs are redacted, and a hash of the original is kept alongside, so two identical invocations are still provably identical.
- Prompt text is recorded only when
audit.includePromptTextis set. Its length and SHA-256 always are, so a given prompt stays provable without the trail disclosing business content. Turn the text on only if your retention policy accounts for it. redactSecretsandredactSecretTextmask by value shape as well as by field name, so a bearer token inside a Bashcommandis caught even thoughcommandis not a secret-sounding key.
Sending it somewhere else
AuditSink is a swappable block like any other. Core seeds the protected local floor; a plugin registers an alternative and an operator activates it by name.
import { defineAuditSink } from '@moxxy/sdk';
export const siemSink = defineAuditSink({
name: 'siem',
open(scope) {
return {
// Records arrive bounded and redacted, so a line is safe to forward as-is.
async write(record) {
return await post(INGEST, { ...record, session: scope.sessionId });
},
async close() {},
};
},
});Two rules the contract enforces:
writetakes an unchained record. Sequencing and tamper evidence are the sink's own concern, because each sink orders differently: the local file chains by day, syslog has no chain at all, a remote service assigns its own sequence. Handing every sink a pre-sealedseq/hashwould be a lie for all but one of them.writemust not throw. A failing audit sink degrades loudly; it never takes down the turn it is recording. Report failure by returningfalse.
A discovered sink is never auto-activated
Registering is not activating. A sink's entire purpose is to send recorded actions somewhere else, so adopting one silently would be an exfiltration path.
Building your own audit or DLP plugin
LifecycleHooks already exposes onEvent, onToolCall, onToolResult and onBeforeProviderCall, so a DLP or policy plugin is buildable without touching the audit sink at all. Use the sink when you want the chained, redacted receipt; use the hooks when you want to intervene.