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
| Category | Events |
|---|---|
| Agent Lifecycle | Agent create, start, stop, suspend, resume, delete, competency equip |
| Model Interactions | Agent invocations (success/error, duration, token usage), model routing decisions |
| Tool Invocations | Every MCP tool call (tool name, agent, success/error, execution time) |
| Trust Enforcement | Permission denials, rate limiting, trigger mismatches, cost quota enforcement |
| System Events | Circuit 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
| Parameter | Description | Example |
|---|---|---|
limit | Maximum entries to return (default: 20) | 100 |
category | Event category prefix | agent_lifecycle, trust_enforcement, tool_invocation |
actor | Identity of the caller | admin-key, system |
since | Only entries after this ISO 8601 timestamp | 2026-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:
- Export all entries via
GET /security/audit?limit=999999 - Get chain metadata via
GET /security/verify - Sort entries by
idascending - For each entry, compute
metadata_hash = SHA-256(canonical_json(metadata))— canonical form recursively sorts object keys and serializes compactly (empty/absent metadata hashes asSHA-256("")) - 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-v2followed by each field asu64_le(byte_length) || field_bytes, in this order:prev_hash,timestamp,identity,role,action,target,result,metadata_hash - Verify the computed hash equals the entry's
hashfield - Verify first entry's prev_hash equals
chain_anchor - 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:
- 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
- The reap itself emits an
audit.retention_reapsystem event through the normal audit chain, so the deletion appears in the trail rather than only in a log line - 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 toendpointas JSONtarget = "syslog"— forwarded to the local syslog daemontarget = "file"— appended to a file atendpoint(a path, not a URL, in this mode)batch_size/flush_interval_secs— whichever limit is hit first triggers a flushmax_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
- Keep audit enabled in production — it's enabled by default, don't disable it
- Verify chain regularly — schedule
hoziron-cli security verifyin cron or monitoring - Export for compliance — enable
audit.exportbefore settingretention_days > 0, so data due for reaping has already left the platform - Monitor trust enforcement — filter by
category=trust_enforcementto detect attack patterns - Alert on verification failure — a broken chain indicates tampering or corruption
Next steps
Related: