Mechanism: tamper-evident audit chain
One-line claim: Every tool call lands in an append-only SQLite table; each row’s
chain_hash = sha256(prev_chain_hash || canonical_row). schemabrain audit verify re-walks the chain and exits non-zero if any past row was rewritten.chain_hash, and any external snapshot of a prior chain_hash (a backup, a CI artifact, an off-host copy) will diverge from the new local value. The chain turns silent tampering into noisy tampering.
This page documents the three mechanisms that make detection load-bearing.
Mechanism 1 — SQL triggers forbid UPDATE and DELETE
Themcp_audit table is append-only at the SQLite layer. Two triggers raise on any non-INSERT mutation:
sqlite_master sees it.
Mechanism 2 — per-row SHA256 chain hash
Each audit row stores a 32-bytechain_hash:
audit/chain.py). The canonical(row[N]) function (audit/canonical.py) emits a deterministic byte string for the 13 content fields — ordering, encoding, and null-handling are pinned so two writers producing the same logical row produce byte-identical output.
To rewrite row N coherently, an attacker must:
- Compute a new
canonical(row[N]')for the falsified row. - Compute a new
chain_hash[N]'fromprev_chain_hash || canonical(row[N]'). - Re-derive
chain_hash[N+1], N+2, … ENDbecause each depends on the previous. - Rewrite all those
chain_hashcolumns and drop the append-only triggers first.
chain_hash[N] — a nightly backup, a CI artifact, a cron job that emails the head hash — now disagrees with the local file. The cover-up fails at the external comparison.
Mechanism 3 — schemabrain audit verify re-walks locally
schemabrain/audit/verify.py walks mcp_audit rows in id order, recomputing each row’s chain_hash from the previous STORED chain hash plus the row’s canonical bytes. A ChainMismatch is yielded for every row where recomputed ≠ stored.
--full to collect every mismatch:
--since accepts a hash prefix (≥8 hex chars), an ISO 8601 timestamp, or a compact duration. Use case: an operator who archived a known-good chain head externally wants to confirm only the rows since that head, without re-walking the (potentially huge) earlier history.
--since.
What gets recorded
Every MCP tool call writes exactly one row, regardless of outcome. The 14 fields (audit/ddl.py):
| Field | Meaning |
|---|---|
id | Monotonically-increasing rowid |
occurred_at | ISO 8601 timestamp |
source_connection_id | Which Postgres source the call resolved against |
caller_id | Optional agent / session identifier (operator-configured) |
tool_name | The MCP tool that was called |
status | One of success / empty / partial / degraded / error / refused |
refusal_reason | When status='refused': pii_blocked / allowlist_violation / fragment_unsafe / cost_cap_exceeded / ambiguous_resolution / schema_drift |
cost_class | small / medium / large / refused — bucketed dollar cost |
pii_categories | The category set the envelope returned. On pii_blocked refusals this is the blocked set (the policy intersection that triggered refusal). |
ast_shape_hash | Hash of the SQL AST shape (when applicable) — for clustering similar calls |
rule_id | Allowlist or policy rule that fired, if any |
fingerprint | Content-addressable fingerprint of the row body |
fingerprint_version | Lets the schema evolve without breaking old rows |
chain_hash | The 32-byte SHA256 chain hash described above |
pii_categories on the refusal path stores only the blocked set — the same value the agent envelope surfaces. The full attempted (propagated) set that would let an operator see what categories the agent was trying to touch is not currently persisted; it lives only in the PiiBlockedError exception object. See /mechanism/pii-taxonomy §“Probe oracle” for the rationale on why the envelope returns only blocked, and the §“What this is not” caveat below.
What this is not
- It is not write-prevention against root. An attacker with root access to the host can drop the triggers and rewrite the chain coherently if and only if no external snapshot exists. Defense against root is the operator’s filesystem-encryption + backup discipline, not ours.
- It is not append-only against the OS.
chmod 0600keeps the file out of other users’ reach but not out of root’s. Full-disk encryption is the right layer for that defense. - It is not a network-tamper detector. SchemaBrain runs locally; there is no remote audit sink. The chain is local-file integrity, not network-transport integrity.
- It does not (yet) carry the full
attemptedPII set. The audit row’spii_categoriesmatches the envelope (blocked only). Forensic analysis of “what categories did the agent’sgroup_byactually touch on this refusal?” requires readingPiiBlockedError.attempted_categoriesfrom the event bus rather than the audit row. A separate operator-visible field carrying the attempted set is on the backlog. - It is not yet anchored externally. Operators wanting external anchoring can cron
schemabrain audit verify --full && sqlite3 ./schemabrain.db "SELECT chain_hash FROM mcp_audit ORDER BY id DESC LIMIT 1;"and store the result. A first-classaudit checkpointsubcommand is not currently scheduled.
Verify it yourself
schemabrain audit list is the cursor-aware reader; pair it with --since for windowed forensics.
Related
Read-only
Why writes are impossible at the type level.
Structured recovery
What the agent gets back when a read is refused.
PII taxonomy
What content the agent can pull through the read surface.
Observability
Event-bus substrate that emits audit rows.