Skip to content

Audit log

The tamper-evident Merkle audit chain, HMAC signing, query/verify API, and the webhook audit hook.

Revka records every security-relevant action — command executions, file access, config changes, authentication outcomes, and policy violations — to a tamper-evident Merkle hash-chain audit log. Each entry is SHA-256 linked to the one before it, so altering, inserting, or deleting any record breaks the chain and is detectable. You can optionally sign every entry with HMAC-SHA256, query the trail over the REST API, and verify the chain’s integrity on demand.

Use this page to enable auditing, turn on signing, read the event format, and wire the log into a SIEM via the webhook audit hook.

Auditing is configured under [security.audit] in ~/.revka/config.toml and is on by default.

[security.audit]
enabled = true # default true
log_path = "audit.log" # relative to the revka/workspace dir
max_size_mb = 100 # rotate when the file reaches this size
sign_events = false # optional HMAC-SHA256 signing (see below)
KeyTypeDefaultMeaning
enabledbooltrueMaster switch. When false, the logger leaves no file on disk and the API returns empty results.
log_pathstring"audit.log"Log file path, resolved relative to the workspace directory.
max_size_mbu32100Size threshold (MB) at which the active log rotates to a numbered archive.
sign_eventsboolfalseWhen true, each entry is signed with HMAC-SHA256. Requires REVKA_AUDIT_SIGNING_KEY.

The logger touches the log file on a fresh install so readers never hit a “not found” error, and it recovers the chain state from the last entry on restart so new writes continue the existing chain.

Every entry carries three chaining fields in addition to its payload:

  • sequence — a monotonically increasing counter, starting at 0.
  • prev_hash — the entry_hash of the previous entry. The first (genesis) entry uses a well-known seed of 64 zeros.
  • entry_hashSHA-256(prev_hash || canonical_JSON_of_content), where content is the entry’s payload fields excluding prev_hash and entry_hash (but including sequence).

Because each hash folds in the previous one, modifying any entry changes its entry_hash, which no longer matches the prev_hash recorded in the next entry — invalidating every record downstream. Sequence numbers must also be contiguous, so deleting an entry is detectable as a gap.

Writes are serialized under a mutex and protected by an advisory exclusive file lock (so a brief two-daemon overlap can’t interleave bytes), and each append is flushed with fsync for durability. The sequence and prev_hash only advance after a successful write, so an I/O failure cannot leave a gap.

Entries are written one JSON object per line (JSONL). A signed command-execution entry looks like this:

{
"timestamp": "2026-06-18T10:00:00Z",
"event_id": "f1c2…-uuid",
"event_type": "command_execution",
"actor": { "channel": "telegram", "user_id": "123", "username": "@alice" },
"action": { "command": "ls -la", "risk_level": "low", "approved": false, "allowed": true },
"result": { "success": true, "exit_code": 0, "duration_ms": 15, "error": null },
"security": { "policy_violation": false, "rate_limit_remaining": 19, "sandbox_backend": "firejail" },
"sequence": 42,
"prev_hash": "<64 hex chars>",
"entry_hash": "<64 hex chars>",
"signature": "<64 hex chars>"
}

The signature field is present only when sign_events = true. The actor, action, and result objects may be absent depending on the event type.

event_type is one of:

ValueLogged when
command_executionA shell command runs (with risk level, approval, allowed/blocked, and timing).
file_accessA file operation is evaluated.
config_changeConfiguration is modified.
auth_successAuthentication succeeds (e.g. a client pairs).
auth_failureAuthentication fails (e.g. an invalid pairing code).
policy_violationA security policy blocks an action.
security_eventOther security-relevant events (WebSocket connection, pairing flow).

Hash-chaining alone is tamper-evident: an attacker who can rewrite the whole file could recompute every hash. Enabling sign_events adds an HMAC-SHA256 signature over each entry_hash, keyed by a secret only you hold — so a forged chain can’t be re-signed without the key.

  1. Generate a 32-byte key (64 hex characters) and export it as REVKA_AUDIT_SIGNING_KEY before the gateway starts — the key is loaded once when the logger is constructed.

    Terminal window
    export REVKA_AUDIT_SIGNING_KEY="$(openssl rand -hex 32)"
  2. Turn on signing in config:

    [security.audit]
    enabled = true
    sign_events = true
  3. Start the daemon. From now on, every new entry includes a signature.

Signed and unsigned records are forward-compatible: a chain that contains a mix of both (for example, entries written before you enabled signing) still verifies cleanly. Records that carry a signature are checked against the key when it is available; records without one are skipped.

Both endpoints are part of the Gateway API and require a bearer token from the pairing flow.

GET /api/audit?limit=50&event_type=command_execution&since=2026-01-01T00:00:00Z
Authorization: Bearer <token>
ParameterTypeDefaultNotes
limitint50Maximum events to return. Capped at 500.
event_typestringFilter to one event type (see table above).
sincestringRFC 3339 timestamp lower bound.

Events are returned newest-first:

{
"events": [ /* AuditEvent objects, newest first */ ],
"count": 50,
"audit_enabled": true
}

If auditing is disabled, the endpoint returns {"events": [], "count": 0, "audit_enabled": false} rather than an error, so dashboards degrade gracefully.

Terminal window
curl -s "http://127.0.0.1:42617/api/audit?limit=20&event_type=policy_violation" \
-H "Authorization: Bearer rk_<token>"
GET /api/audit/verify
Authorization: Bearer <token>

This re-reads the active log and checks that every entry_hash recomputes correctly, every prev_hash links to its predecessor, sequence numbers are contiguous from 0, and (when the signing key is available) every signature matches.

{ "verified": true, "entry_count": 128 }

On the first violation it returns the reason:

{ "verified": false, "error": "entry_hash mismatch at line 43 (sequence 42): expected …, got …" }
Terminal window
curl -s http://127.0.0.1:42617/api/audit/verify \
-H "Authorization: Bearer rk_<token>"

If auditing is disabled, the response is {"verified": false, "error": "Audit logging not enabled"}.

When the active log reaches max_size_mb, it rotates: audit.log becomes audit.log.1.log, older archives shift up (.1.2, … .9.10), and a fresh audit.log starts.

Rotation resets the chain to genesis for the new file, so /api/audit/verify succeeds immediately against the active log. Each rotated audit.log.N.log archive is its own self-contained chain and remains independently verifiable — keep the archives if you need a continuous historical record.

For real-time, push-based audit delivery — feeding a SIEM, a compliance pipeline, or centralized log storage — use the built-in WebhookAuditHook. It POSTs a JSON payload to an external HTTPS endpoint for every tool call whose name matches a configured glob pattern, without anyone polling /api/audit.

Configure it under [hooks.builtin.webhook_audit]:

[hooks]
enabled = true
[hooks.builtin.webhook_audit]
enabled = true
url = "https://siem.example.com/revka/audit"
tool_patterns = ["Bash", "Write", "mcp__*"]
include_args = false
max_args_bytes = 4096
KeyTypeDefaultMeaning
enabledboolfalseEnable the hook.
urlstring""Target endpoint. Must be https:// (see security note). Empty + enabled logs a warning and drops events.
tool_patternslist[]Glob patterns for tool names to audit (* wildcard, e.g. mcp__*, *_write). An empty list audits nothing.
include_argsboolfalseInclude tool arguments in the payload. May contain secrets or PII — leave off unless you need it.
max_args_bytesu644096Truncate serialized args to this many bytes (0 = unlimited).

The POST body looks like:

{
"event": "tool_call",
"timestamp": "2026-06-18T10:00:00Z",
"tool": "Bash",
"success": true,
"duration_ms": 42,
"error": null,
"args": null
}

args is null when include_args = false (or when no captured arguments exist). Delivery is fire-and-forget: failures are logged but never block or crash the agent. The hook runs last in the lifecycle pipeline.

If you only want tool calls echoed to the daemon’s structured logs (no external endpoint), enable the companion CommandLoggerHook instead — it writes each completed tool call to tracing with tool name, duration, and success:

[hooks.builtin]
command_logger = true