Spec: Tamper-evident audit trail
Status: DRAFT — for approval. Spec-first per CLAUDE.md; no code until sign-off.
1. Problem & outcome
Shield has three append-only audit stores — storage/audit_log.py (Redis ZSET
audit:{tenant}), storage/decision_audit.py (Redis LIST decisions:{tenant}),
storage/admin_audit.py (Redis LIST admin_audit:{tenant}). They are append-only
by convention only: anyone with Redis access (a rogue admin, a compromised
node, a backup-restore) can delete or rewrite entries undetectably. There is
no hashing, chaining, or signing today.
Enterprise buyers and SOC2 / EU AI Act Art. 12 / ISO 42001 §9.2 require an audit trail whose integrity is provable — you can show an auditor that no record was altered or dropped since it was written.
Outcome: every audit event is linked into a per-scope hash chain, and the
chain head is signed into periodic checkpoints. Given the log + checkpoints,
anyone can verify offline that the log is intact, and pinpoint the exact sequence
number where any tampering occurred. A verify endpoint and a signed export
bundle make this turnkey for auditors.
Non-goals. Not confidentiality (that’s redaction, already present). Not preventing deletion (Redis can’t) — only making it detectable. Not an external transparency log / WORM anchor in v1 (designed for, deferred). Not backfilling integrity onto pre-existing records (chain starts at enablement with a signed genesis).
2. Plane & latency contract
- Guard path impact — the hard constraint. Audit writes happen on the data
plane inside guard endpoints (
/guardrails/*,tool/check,tool/output). Chaining MUST NOT add latency to guarded traffic.- Hashing is one
sha256over a canonical record +prev_hash— microseconds. - The chain append is done by a background writer (bounded async queue); the guard response never awaits it. Guard latency budget: +0 ms (enqueue only).
- Hashing is one
- Admin plane mounts the read-side:
verifyandexportendpoints (read-only over Redis; off hot path). - Signing checkpoints is periodic (every N records or T seconds) on a background task — not per request.
3. Data model
Per scope (a scope = audit:{tenant}, decisions:{tenant},
admin_audit:{tenant}, and the *:global variants; token/cap events added — §7):
- Each record gains:
seq(monotonic int per scope, from a Redis counter),prev_hash(hex),record_hash = sha256(canonical_json(record_without_hash) || prev_hash). audit:chain:{scope}:head→{seq, record_hash}(the current chain head).audit:chain:{scope}:seq→ Redis INCR counter (monotonic, gap = evidence).- Atomicity: head-read → hash → head-write + record-append run in one Redis
Lua script so concurrent writers can’t fork the chain or reuse a
seq. - Checkpoints:
audit:checkpoint:{scope}→ LIST of signed objects{scope, seq, head_hash, ts, sig, kid},sig= Ed25519 overcanonical_json({scope, seq, head_hash, ts}). Reuses the existing Ed25519 signer used by agent tokens / capabilities (core/agent_tokens.py,core/capabilities.py) — no new key material, no new dep. - Genesis: on enable, a signed genesis checkpoint at
seq=0,prev_hash="GENESIS". - Tenant scoping: unchanged — chains and checkpoints are per-scope, so tenant
isolation is inherited from the existing key layout; cross-tenant verify is
rejected by the existing
_resolve_audit_scope(api/routes_audit.py). - TTL: checkpoints are not TTL’d (tiny, and needed to verify retained
records); records keep their existing
AUDIT_TTL. Expiry is legitimate and bounded by checkpoint seq ranges, so expired-out records don’t read as tamper (verify only checks records still present against their covering checkpoints).
4. API / interface
Additive endpoints on the admin plane (auth: existing tenant/admin key via
_resolve_audit_scope):
GET /v1/shield/audit/verify?scope=&since=&until=→{"valid": bool, "checked": int, "break_at_seq": int|null, "reason": str|null, "last_checkpoint": {seq, ts}}. Recomputes the chain over the range and checks it against the signed checkpoints + counter continuity.GET /v1/shield/audit/export?scope=&since=&until=→ a signed verifiable bundle: NDJSON records + covering checkpoints +kid/public-key reference, so an auditor verifies offline with a ~30-line standalone script (shipped).- Existing
GET /v1/shield/audit//statsare unchanged (back-compat); the newseq/prev_hash/record_hashfields simply appear in entries when enabled.
Write path: a shared helper append_hashchained(scope, record) in a new module
storage/audit_chain.py, called by the three existing writers (and token/cap
writers, §7) behind the enable flag.
5. Security & backward compatibility
- Opt-in, non-breaking. Gated by
SHIELD_AUDIT_TAMPER_EVIDENT=1(default off). Off ⇒ writers behave exactly as today (no new fields, no queue, no endpoints active). On ⇒ additive fields + endpoints. This satisfies the “secure-by-default but non-breaking” invariant via an explicit opt-in flag + migration note (“enable in staging, confirmverifygreen, then prod”). - Threat covered: silent deletion/alteration of audit records by anyone with
Redis/backup access. After tampering,
verifyreturnsvalid:falsewith the exactbreak_at_seq. A truncation (delete newest N) is caught by counter/head mismatch against the latest checkpoint. - What it can’t do: stop deletion, or prove integrity for the window between the last checkpoint and a crash (bounded by checkpoint interval — tunable).
- Key handling: signing key is the existing on-prem Ed25519 signer; public key
already published at
GET /oauth/jwksfor offline verify. Private key never leaves the signer.
6. Packaging & deploy
- New module
storage/audit_chain.py. Ifadmin_app.pyimports it (it will — verify/export live on the admin plane) → add it toDockerfile.adminCOPY allowlist (enforced bytests/test_admin_dockerfile_imports.py). Flagged explicitly. - No new pip dependency — reuses stdlib
hashlib+ the existing Ed25519 signer already inrequirements.txt. Redis Lua needs no dep. - Env flags:
SHIELD_AUDIT_TAMPER_EVIDENT(enable),SHIELD_AUDIT_CHECKPOINT_N(records per checkpoint, default 100),SHIELD_AUDIT_CHECKPOINT_SECS(max interval, default 60). Rebuild: data-plane image (writer) + admin image (verify/export).
7. Failure modes & edge cases
- Redis down / audit best-effort: if a write is lost, the counter leaves a gap
⇒ would falsely read as tamper. Mitigation: the writer emits a signed gap
marker record when it detects an enqueue drop or Redis error on resume, so
verifydistinguishes “known outage gap” from “tamper.” Gap markers are themselves chained. - Queue full (hot-path protection): bounded queue; on overflow, guard path still returns (never blocks), and a gap marker is recorded — availability > completeness, made explicit and logged.
- Concurrent writers: Lua-script atomic head update ⇒ no forked chains, no
duplicate
seq. - Enable on a non-empty store: genesis checkpoint at current tip; older records
remain unchained and are reported by
verifyasunchained_before_seq(honest, not a failure). - Huge export range: streamed NDJSON, capped page size;
verifyworks on ranges. - Clock skew: irrelevant — integrity is
seq+hash based, not time based (tsis informational).
8. Test plan (Definition of Done)
- Unit: chain continuity over N records; modify one record →
verifyfails at itsseq; delete a middle record → fail; truncate newest → fail vs latest checkpoint; checkpoint signature verifies with the published key; tamper a checkpoint → fail; concurrent appends keepseqmonotonic and unique (Lua); outage gap marker ⇒verifystaysvalid; opt-out (flag off) ⇒ byte-identical behavior to today; export bundle verifies with the standalone offline script. - Regression guard:
test_admin_dockerfile_imports.pycovers the new admin import; a test pins the canonical-JSON serialization (hash stability across py versions). - Clean venv: full
pytest tests -qgreen in a fresh venv; CIpytestgate passes.
Invariant risk flags
- ⚠️ Hot path — mitigated: enqueue-only on the guard path, all hashing/signing off-response; budget +0 ms. Must be load-verified.
- ⚠️ Admin import —
storage/audit_chain.py→ add toDockerfile.admin. - ✅ No new pip dep (reuse Ed25519 signer + hashlib).
- ✅ Opt-in flag, default off, migration note.
Task breakdown (one branch, ordered)
storage/audit_chain.py—append_hashchained(Lua atomic), checkpointer, verify/export core + unit tests (Redis mocked/fakeredis). Flag-gated.- Wire the three existing writers + token/cap events to it behind the flag.
- Admin endpoints
verify/export+Dockerfile.adminCOPY + offline verifier script + docs.
Open questions (for approval)
- Checkpoint cadence default — 100 records / 60 s ok, or tighter for higher-assurance tenants?
- External anchor — v1 keeps checkpoints in Redis. Want a v2 hook to also push checkpoints to S3-WORM / a public transparency log for off-box durability?
- Coverage — include the currently-unaudited token/cap mint/verify events in this same chained log now (recommended), or keep v1 to the three existing stores?