MCPG
Security
Security

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:

FieldMeaning
actorWho triggered the event (subject id, auth provider, …)
actionWhat happened (mcpg.tool.call.allowed, mcpg.payment.charged, …)
resourceWhat it acted on (tool://payments.charge, resource://patient/…)
outcomeSuccess / Failure / Partial / Denied
event_idUUIDv7 — chronologically sortable
occurred_atRFC 3339 UTC, millisecond precision
request_id, node_idCorrelation back to the request and emitting node
prev_event_hashChain 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:

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

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

yaml
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_failureBehavior
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_openFailures 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.

SinkUse
dev.mcpg.builtin.audit.local-fileShips 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:

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

bash
# 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.