Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Audit

Definition

The audit log is the hash-chained record of every decision the gateway makes — both allows and denies — together with the action that prompted it. It is the accountability layer: for any agent it answers what did it do, when, and was it permitted? Audit records use one wire format regardless of which interception layer observed the action, so the gateway presents a single unified history.

Read the guarantee precisely. The chain is tamper-evident, not tamper-proof and not immutable. The digest is unkeyed, so it is not a signature; it covers the JSONL sink, while the database mirror stores no chain metadata; the log is append-only by convention rather than by constraint, since retention pruning deletes rows; and emission is best-effort. aasm audit verify-chain distinguishes INCOMPLETE (entries never reached the sink, chain otherwise intact) from FAIL (an entry was altered or removed), but a loss at the head or tail is indistinguishable from a deletion there, because there is no neighbouring entry to anchor against. Everything below describes what the chain does detect — within those bounds.

The unit of the log is an AuditEntry. Each entry carries a monotonic seq, a nanosecond timestamp_ns, an AuditEventType (for example ToolCallIntercepted, PolicyViolation, CredentialLeakBlocked, ApprovalGranted, BudgetLimitExceeded), the agent_id and session_id, a serialized payload, optional lineage (team, org, delegation chain), and the hash-chain fields below.

How it works

Every entry commits to its tamper-meaningful fields via a SHA-256 hash that includes the hash of the preceding entry, forming a tamper-evident chain. The genesis entry uses [0u8; 32] as its previous_hash. The hash input opens with a fixed 84-byte prefix plus the payload:

SHA-256(
    seq.to_be_bytes()                    //  8 bytes
    || timestamp_ns.to_be_bytes()        //  8 bytes
    || (event_type as u32).to_be_bytes() //  4 bytes
    || agent_id.as_bytes()               // 16 bytes
    || session_id.as_bytes()             // 16 bytes
    || previous_hash                     // 32 bytes
    || payload.as_bytes()                // variable
    || <optional sections, append-only>
)

The sequence is not closed. Lineage (root_agent_id, parent_agent_id, team_id, delegation_reason, spawned_by_tool, depth, org_id), redaction findings, policy_doc_id, and decision_id are each appended after the payload — but only when present, and each new field is appended last when it is introduced. A field that is None contributes zero bytes, so an entry written before a given field existed hashes to exactly the same value afterwards and chains already on disk keep verifying across upgrades. Variable-length fields are length-prefixed so the concatenation stays unambiguous as the tail grows. That append-only discipline, not a frozen field list, is what makes the chain both extensible and tamper-evident.

An AuditLog is a session-scoped sequence that enforces two invariants on every append: each entry’s seq is exactly one greater than the last, and each entry’s previous_hash equals the prior entry’s entry_hash. verify_chain re-validates both across the whole log in O(n) and re-computes every hash, so any altered field — even via unsafe — is detected. It returns true for an empty log, which is vacuously valid: a passing verify_chain proves no entry was tampered with, not that any entry exists.

flowchart LR
    G["entry 0<br/>prev = 0x00…"] --> E1["entry 1<br/>prev = hash(0)"]
    E1 --> E2["entry 2<br/>prev = hash(1)"]
    E2 --> E3["entry 3<br/>prev = hash(2)"]

Records are kept free of secrets. Before any event is persisted it passes a write-boundary sanitizer that strips banned keys (prompts, completions, full tool payloads, packet bodies) and a credential scanner that redacts detected secrets — only the [REDACTED:<kind>] label and byte offset are stored, never the raw secret. Heartbeats are collapsed into a “last seen” update rather than written per beat.

Example

The hash chain makes tampering detectable. Mutating any persisted field causes re-verification to fail:

#![allow(unused)]
fn main() {
let mut log = AuditLog::new(agent_id, session_id);
log.next_entry(AuditEventType::ToolCallIntercepted, ts, payload);
log.next_entry(AuditEventType::PolicyViolation, ts, payload);
assert!(log.verify_chain()); // true for an untouched log
// Altering any entry's payload, seq, or hash would flip this to false.
}

Retention and export are operational concerns: the unified trail is what compliance export reads to produce a verifiable record for downstream systems.

  • Policy — the decisions that produce audit entries.
  • Approval — the Approval* events recorded per transition.
  • Trace — the per-session view over governed actions.
  • Audit and assurance — sanitizer, redaction, and the publish path.
  • Compliance export — retention and export.
  • API reference — aa-core (AuditEntry, AuditLog) and aa-security (scanner, redaction) rustdoc entry points.

Last updated: 2026-09-28 by Chisanan232