Audit trail
A tamper-evident, hash-chained, fail-closed compliance ledger. Every authorization decision, payment, and access attempt is recorded with actor, action, resource, and outcome, fanned out to durable sinks.
MCPG keeps a dedicated audit lane — separate from logs, metrics, and traces — for every security-relevant event the gateway produces. Where observability is best-effort and high-cardinality, the audit lane is curated, durable, hash-chained, and (by default) fail-closed. It is the channel that answers an auditor's question: "show me every access to this resource, by whom, with the outcome."
The lane is enforced at the type level: an audit event is not a log event, and an audit sink is a distinct plugin kind. The gateway never blurs the two.
What gets recorded
Every event carries the four fields every compliance framework expects, plus correlation and chain metadata:
| Field | Meaning |
|---|---|
actor | Who triggered the event (subject id, auth provider, …) |
action | What happened (mcpg.tool.call.allowed, mcpg.payment.charged, …) |
resource | What it acted on (tool://payments.charge, resource://patient/…) |
outcome | Success / Failure / Partial / Denied |
event_id | UUIDv7 — chronologically sortable |
occurred_at | RFC 3339 UTC, millisecond precision |
request_id, node_id | Correlation back to the request and emitting node |
prev_event_hash | Chain pointer for tamper detection (see below) |
The runtime emits these at canonical points across the request lifecycle — tool calls (allow / deny / challenge), resource and prompt access, LLM sampling (with prompt hash and cost-attribution fields), payments (with PCI-shaped receipt fields), pipeline transactions, session and cluster lifecycle, and approval workflows. Plugins do not have to emit audit events themselves; the gateway records on their behalf.
Tamper evidence: the hash chain
The built-in local-file sink writes each event as a single canonical-JSON line (JSONL) and chains them with SHA-256:
event[0]: { …, prev_event_hash: null } → durable_hash h0 = sha256(bytes of line 0)
event[1]: { …, prev_event_hash: h0 } → durable_hash h1 = sha256(bytes of line 1)
event[2]: { …, prev_event_hash: h1 } → durable_hash h2 = sha256(bytes of line 2)
Each line stores the previous line's hash in prev_event_hash; a line's own
hash is the SHA-256 of its exact on-disk bytes (as written — no re-sorting or
re-compacting). A consumer walks the file, re-hashes each raw line, and checks the
next line's prev_event_hash against it. Any insertion, deletion, or mutation
breaks the chain and is detectable:
import hashlib, json
prev = None
with open("/var/log/mcpg/audit.log", "rb") as f:
for i, raw in enumerate(f):
line = raw.rstrip(b"\n") # the exact bytes that were hashed
ev = json.loads(line)
assert ev.get("prev_event_hash") == prev, f"chain break at event {i}"
prev = hashlib.sha256(line).hexdigest() # bare hex over the raw bytes
print("chain intact")
Hash the raw line bytes, not a re-serialized object: parsing to a dict and re-dumping would reorder keys and break the comparison. The stored hashes are bare lowercase hex (no
sha256:prefix).
The hash chain proves integrity of what's on disk; it does not by itself prevent a host that controls the file from truncating it to a shorter valid prefix. For that, pair the local sink with an append-only off-node sink the gateway node cannot delete from (S3 with object-lock, CloudTrail, a SIEM). Chain integrity under concurrency and the fail-closed policy are covered by the gateway's integration test suite.
Fail-closed by default
Audit behavior is controlled under governance.audit:
governance:
audit:
enabled: true # default; false disables audit entirely (dev only)
required: true # refuse to start unless ≥1 sink is serving
on_failure: fail_closed # default
sinks:
- kind: dev.mcpg.builtin.audit.local-file
config: { path: "/var/log/mcpg/audit.log" }
on_failure | Behavior |
|---|---|
fail_closed (default) | If a sink fails to durably persist an event, the calling site treats it as a Deny — a sink outage stops traffic. Highest-strength compliance posture. |
fail_open | Failures are metered and logged; traffic continues. Dev / CI only. |
With required: true, the gateway refuses to boot unless at least one audit sink
is serving. Two allow-listed high-volume events (tool.call.allowed,
tool.call.completed) can be toggled off for cost; deny and challenge events
always emit regardless of configuration.
Sinks
Audit is fan-out: every event reaches every registered sink, each sink
acknowledges durable persistence, and the gateway meters
mcpg_audit_emits_total{sink_id, outcome} to detect partial failure.
| Sink | Use |
|---|---|
dev.mcpg.builtin.audit.local-file | Ships built in. Writes hash-chained JSONL to a file (O_APPEND, crash-safe) — point a SIEM forwarder (filebeat, vector, fluentbit) at it. Also supports stdout for container log scraping. |
Operators add off-node sinks as their own plugins for production — CloudTrail,
Datadog Audit Logs, Splunk HEC, Elasticsearch, Kafka, an S3 object-lock archive,
or a PCI-segregated SIEM. List each one as another sinks[] entry; every event
fans out to all of them:
governance:
audit:
sinks:
- kind: dev.mcpg.builtin.audit.local-file
config: { path: "/var/log/mcpg/audit.log" }
- kind: dev.acme.s3-archive # append-only, object-locked
config: { bucket: "acme-mcpg-audit", region: "us-east-1" }
Production guidance. The local file is the audit-of-last-resort: a compromised node can tamper with its own log. Always register at least one append-only off-node sink so the integrity record lives somewhere the gateway node cannot rewrite.
Compliance mapping
The audit lane is built to satisfy the "every access attempt logged" requirement
common to enterprise frameworks — SOC 2 (CC6.1 / CC6.6: every authorization
decision), HIPAA 164.312(b) (every access to identifiable resources), PCI-DSS
10.2 (every charge / capture / refund with receipt fields), GDPR 30.1.b (records
of processing activities), and ISO 27001 A.12.4 (event logging and log
protection). Because events are structured (actor, action, resource, outcome)
with a stable wire schema, the same jq/SIEM query answers the auditor's
question directly:
# Every tool call by a user in the last 24h (SOC 2 CC6.1)
jq -c 'select(.actor.subject_id == "alice@corp"
and (.action | startswith("mcpg.tool.call.")))' \
/var/log/mcpg/audit.log
See the Compliance statement for MCPG's protocol
conformance posture, and the configuration reference
for the full governance.audit schema.