Skip to content

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.

yaml
audit:
  enabled: true
  retentionDays: 400
  includePromptText: false     # the default
  # sink: <name>               # omit for the local floor

The record

ts
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 · other

Everything 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.

sh
moxxy security audit-log                 # list days, verify each chain
moxxy security audit-log 2026-07-28      # one day: record count + chain head

Exit 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.includePromptText is 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.
  • redactSecrets and redactSecretText mask by value shape as well as by field name, so a bearer token inside a Bash command is caught even though command is 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.

ts
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:

  1. write takes 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-sealed seq/hash would be a lie for all but one of them.
  2. write must not throw. A failing audit sink degrades loudly; it never takes down the turn it is recording. Report failure by returning false.

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.

Next

Open source · self-hosted · MIT