Audit tamper evidence
Hash-chained NDJSON audit stream — how it works and how to verify a captured log.
Every event Forge emits carries a prev_hash that pins it to the
previous event's canonical JSON bytes. Together with the per-emit
re-computation of the tail hash, this forms a sha256 chain over the
audit stream. Any post-hoc alteration to a prior event — a changed
field, an added byte, a deleted line — breaks the chain at the point
of tampering.
This is Forge's answer to governance requirement R5 (tamper-evident receipts) and closes issue #212.
The chain
Every AuditEvent gains one field:
{
"ts": "2026-05-28T22:07:31Z",
"event": "tool_exec",
"seq": 42,
"prev_hash": "9c4a5e7f8b1d2c3e...",
...
}- Genesis: the first event of a process lifetime carries
prev_hash: "00…00"(32 zero bytes hex-encoded, exposed asruntime.AuditChainGenesis). Verifiers treat that value as "no predecessor" and start the walk there. - Progression: every subsequent event carries
prev_hash = sha256(previous_event_canonical_json). - Canonical form: bytes produced by
json.Marshal(event)— the same bytes written to the sink, minus the trailing newline. The verifier re-marshals through the same code path, so producer and consumer agree on field order and encoding.
The prev_hash field is emitted on every event (no omitempty);
absence is itself a tampering signal.
Concurrency
Two concurrent Emit calls do NOT race. The chain-mint + marshal +
tail-hash update + sink write execute under a single mutex, so
events land on the sink in the same order they were chained.
TestHashChain_ConcurrentEmitsProduceValidChain exercises 200
concurrent emitters and asserts the resulting stream verifies.
Verifying a captured stream
forge audit verify walks an NDJSON stream, recomputes each event's
canonical hash, and asserts the next event's prev_hash matches.
Exits 0 on clean; non-zero with a report on tampering.
forge audit verify /var/log/forge/audit.ndjson
# → OK: 12471 events, hash chain intact
forge audit verify tampered.ndjson
# → TAMPERING DETECTED at line 8341 (event "tool_exec")
# expected prev_hash: 9c4a5e7f…8b1d2c3e
# actual prev_hash: ffffffff…00000000
# events verified before break: 8340The tool also reads from stdin — useful in pipelines:
kubectl logs my-agent-pod | jq -c 'select(.event)' | forge audit verify -What tamper-evidence buys you
- Detection. Any content change to a past event, any deletion, any insertion is caught. The verifier reports the exact line and the mismatch.
- Trust boundary shifts. You no longer need to trust the file system between event emission and audit review. A malicious root user (or a compromised log-forwarder) that alters events gets caught by the next verify pass.
- Compliance narrative. Framework requirements for tamper- evident receipts (governance R5) are satisfied by hash chaining; R6 layers per-event Ed25519 signatures on top for identity binding (see #213).
What it does NOT buy you
- Confidentiality. The audit stream contains what it contained before. Hashing doesn't hide field values.
- Availability. A tamperer who wholesale-deletes the audit file destroys the evidence too — hash chaining detects post-hoc alteration, not deletion. Forward the stream to an append-only sink (SIEM / immutable-storage backend) for that.
- Non-repudiation without the R6 signature layer. Without cryptographic signing, an operator who controls the emitter could forge a clean chain from scratch. R6 (issue #213) adds Ed25519 event signatures so a verifier can prove that only the agent identity's private key could have produced the stream.
Testing
- Unit:
forge-core/runtime/audit_hash_chain_test.go— genesis, progression, tampering, deletion, concurrency, malformed lines. - CLI:
forge-cli/cmd/audit_test.go— clean and tampered end-to-end throughforge audit verify.
Interop with SIEM
The stream shape is unchanged apart from the new prev_hash field
— SIEM parsers that ignore unknown fields will continue to work.
Consumers that want to leverage the chain can call
runtime.VerifyAuditLog directly (Go) or reimplement the walk in
any language (sha256 + json.Marshal-equivalent canonical form; the
Go implementation is <100 LOC in audit_verify.go).
Workflow Correlation IDs
Orchestrator workflow / stage / step / invocation-caller identifiers threaded from inbound A2A headers through context.Context onto every audit event.
Platform Policy
Three-layer policy that bounds what a forge.yaml is allowed to declare — system, user, and workspace files compose by union and most-restrictive.