Audit Trail

What you'll accomplish: Enable the audit trail, understand what gets logged, query and filter audit events, and verify chain integrity for compliance audits.

Overview

Hoziron maintains a tamper-evident audit log of all security-relevant operations. Every action — agent lifecycle changes, model interactions, tool invocations, policy enforcement decisions — is recorded in an append-only store with cryptographic chaining.

Enable the audit trail

The audit trail is not opt-in — there is no [audit] enabled toggle. Boot always opens the segmented audit store (platform/boot.rs: "Audit is not opt-in — there is no config toggle to skip this block"); a store-open failure is a non-fatal degradation (the platform still boots, logged loudly), not a way to turn auditing off. To customize retention and segment behavior:

[audit]
segment_max_size_mb = 64  # segment rotation threshold (default: 64 MB)
retention_days = 0        # sealed-segment retention period; 0 = unlimited (default: 0)

# Optional — see "SIEM integration" below. Export is genuinely optional;
# the local audit store itself is not.
[audit.export]
enabled = false
target = "webhook"          # webhook | syslog | file
endpoint = "https://siem.corp.internal/api/events"
batch_size = 100
flush_interval_secs = 5
max_retry_attempts = 5

retention_days defaults to 0 (unlimited) — deletion is opt-in, not opt-out (Issue #1240). Set it above 0 only once you've decided on a retention obligation; a non-zero value below 7 is rejected at boot as almost certainly a mistake (a unit confusion, or a misunderstanding that 0 means "no retention" rather than "unlimited"). If you do set retention_days > 0 while audit.export.enabled = false, boot logs a warning naming the deletion horizon — that combination means audit data older than N days is permanently deleted with nowhere else it was ever sent. There is no max_entries field; the segmented store has no count-based pruning, only this time-based reaper.

What gets logged

CategoryEvents
Agent LifecycleAgent create, start, stop, suspend, resume, delete, competency equip
Model InteractionsAgent invocations (success/error, duration, token usage), model routing decisions
Tool InvocationsEvery MCP tool call (tool name, agent, success/error, execution time)
Trust EnforcementPermission denials, rate limiting, trigger mismatches, cost quota enforcement
System EventsCircuit breaker trips, health check failures, cron executions

Each entry records: timestamp, actor identity, action, target resource, outcome, and a SHA-256 chain hash.

Query audit events

CLI

# Recent events
hoziron-cli security audit --limit 50

# JSON output
hoziron-cli security audit --limit 50 --json

API

# Recent entries
curl http://localhost:4200/security/audit?limit=50 \
  -H "Authorization: Bearer hzk_..."

# Filtered: only trust enforcement events from a specific actor
curl "http://localhost:4200/security/audit?limit=100&category=trust_enforcement&actor=admin-key" \
  -H "Authorization: Bearer hzk_..."

# Filtered: events since a specific time
curl "http://localhost:4200/security/audit?limit=1000&since=2026-06-01T00:00:00Z" \
  -H "Authorization: Bearer hzk_..."

GET /security/audit is gated on the security:audit action, which only admin and auditor hold — operator cannot read the audit trail (see RBAC).

Filter parameters

ParameterDescriptionExample
limitMaximum entries to return (default: 20)100
categoryEvent category prefixagent_lifecycle, trust_enforcement, tool_invocation
actorIdentity of the calleradmin-key, system
sinceOnly entries after this ISO 8601 timestamp2026-06-01T00:00:00Z

Verify integrity

The audit trail uses a Merkle hash chain — each entry's hash incorporates the previous entry's hash, forming a tamper-evident sequence. Any modification breaks the chain.

Self-verification

hoziron-cli security verify

API verification (for third-party auditors)

curl http://localhost:4200/security/verify \
  -H "Authorization: Bearer hzk_..."

Response:

{
  "valid": true,
  "entry_count": 4521,
  "chain_anchor": "0000000000000000000000000000000000000000000000000000000000000000",
  "tip_hash": "a3f8e1c2....",
  "algorithm": "sha256(\"hoziron-audit-chain-v2\" || len(prev_hash)+prev_hash || len(timestamp)+timestamp || len(identity)+identity || len(role)+role || len(action)+action || len(target)+target || len(result)+result || len(metadata_hash)+metadata_hash)",
  "integrity_covers": "timestamp, identity, role, action, target, result, metadata (via metadata_hash)",
  "integrity_excludes": "none — metadata is covered via metadata_hash, not hashed inline",
  "verification_note": "Each entry's hash = SHA-256 of a version-tagged, length-prefixed concatenation..."
}

When audit is disabled, the response clearly indicates no verification is possible:

{
  "valid": null,
  "status": "disabled",
  "reason": "audit disabled"
}

Independent verification by auditors

An auditor can verify without trusting the platform:

  1. Export all entries via GET /security/audit?limit=999999
  2. Get chain metadata via GET /security/verify
  3. Sort entries by id ascending
  4. For each entry, compute metadata_hash = SHA-256(canonical_json(metadata)) — canonical form recursively sorts object keys and serializes compactly (empty/absent metadata hashes as SHA-256(""))
  5. For each entry, compute the entry hash over a version-tagged, length-prefixed encoding — not a delimiter join — of the literal tag hoziron-audit-chain-v2 followed by each field as u64_le(byte_length) || field_bytes, in this order: prev_hash, timestamp, identity, role, action, target, result, metadata_hash
  6. Verify the computed hash equals the entry's hash field
  7. Verify first entry's prev_hash equals chain_anchor
  8. Verify last entry's hash equals tip_hash

If any check fails, tampering has occurred — except for a mismatch on every entry starting from chain_anchor, which is the signature of comparing against an older preimage encoding version rather than tamper evidence (see Preimage encoding versions below); confirm deployment history before concluding tampering in that case.

Length-prefixing (instead of joining fields with a delimiter byte) makes the encoding unambiguous: no byte sequence a field could contain can be mistaken for a field boundary, so distinct field tuples can never collide onto the same hash (this closed Issue #529, a delimiter-collision bug in the prior encoding).

Preimage encoding versions

The version tag hashed first (hoziron-audit-chain-v2 today) is a domain separator: if the preimage encoding ever changes again, the tag bumps to v3 so old and new hashes can never collide with each other. There is no migration path across a version bump — pre-existing entries were hashed under the prior tag and will not re-verify against a newer encoding. This is expected, not corruption: if verify_chain fails identically starting from the very first entry checked, check whether that range predates a preimage version bump before treating it as tampering.

Immutability guarantees

The audit store enforces append-only at the database level:

  • UPDATE operations are blocked — SQLite triggers reject any modification to existing entries
  • DELETE operations are blocked — only the internal retention pruning path can remove old entries
  • Merkle chain detects tampering — even if database-level protections are bypassed (e.g., file-level editing), the hash chain breaks and verification fails

Storage

Audit data is stored as sealed, rotating segments under $HOZIRON_HOME/data/audit_segments/. Every audit source writes into this same segmented store — including kernel mechanical events (dispatch, tokens, tool calls), which prior to Issue #1240 wrote into a separate, unread, unbacked-up audit_kernel_sink.db file; that file is no longer created, and a leftover one is removed at boot. A pre-migration $HOZIRON_HOME/data/audit.db (the legacy single-file store) is read once to migrate its rows into segments if present. The database files use WAL mode for concurrent read performance.

Retention and pruning

Retention is time-based, governed by retention_days — there is no count-based pruning. An hourly retention reaper deletes sealed segments whose last entry is older than retention_days (skipped entirely when retention_days = 0, the default). Deletion is self-documenting:

  1. Before deleting a segment, the reaper records a tombstone in the manifest — the segment's sequence number, first/last entry timestamps, entry count, and tail hash — so the manifest still describes what used to be there
  2. The reap itself emits an audit.retention_reap system event through the normal audit chain, so the deletion appears in the trail rather than only in a log line
  3. Chain verification anchors from the oldest surviving segment (not genesis) and remains verifiable across a tombstoned boundary

Export-before-reap interlock: when audit.export.enabled = true, a segment that time-expired but hasn't been confirmed drained by the SIEM export consumer is retained (and warned about) rather than deleted — export confirmation gates deletion, so enabling export can never lose data to a race between the reaper and the exporter.

SIEM integration

Native SIEM export is shipped and wired into production boot, not a roadmap item — hoziron-server starts an ExportConsumer automatically whenever [audit.export].enabled = true. Enable it in config.toml:

[audit.export]
enabled = true
target = "webhook"                                       # webhook | syslog | file
endpoint = "https://siem.corp.internal/api/events"
batch_size = 100
flush_interval_secs = 5
max_retry_attempts = 5
  • target = "webhook" — batches of audit entries POSTed to endpoint as JSON
  • target = "syslog" — forwarded to the local syslog daemon
  • target = "file" — appended to a file at endpoint (a path, not a URL, in this mode)
  • batch_size / flush_interval_secs — whichever limit is hit first triggers a flush
  • max_retry_attempts — retries before a batch is dropped and logged as a failure

For log-based SIEM ingestion instead of the native exporter, JSON log format is also available: set HOZIRON_LOG_FORMAT=json (this is the only thing that actually controls server-side log format — [logging].format in config.toml is parsed but currently has no effect; see environment-variables.md).

Best practices

  1. Keep audit enabled in production — it's enabled by default, don't disable it
  2. Verify chain regularly — schedule hoziron-cli security verify in cron or monitoring
  3. Export for compliance — enable audit.export before setting retention_days > 0, so data due for reaping has already left the platform
  4. Monitor trust enforcement — filter by category=trust_enforcement to detect attack patterns
  5. Alert on verification failure — a broken chain indicates tampering or corruption

Next steps


Related: