Audit Log

May 28, 2026 · View on GitHub

aistack ships with an append-only, cryptographically-chained audit log designed to satisfy SOC2 Type II, ISO 27001, and HIPAA evidence requirements without relying on a cloud trust anchor. Every audited event is hashed into a chain so that any tampering — modification, deletion, reordering — becomes detectable when the chain is verified.

This document describes the schema, API, CLI, threat model, and how to produce an evidence pack for an external auditor.


TL;DR

# Enable in aistack.config.json
{ "audit": { "enabled": true } }

# (Recommended) provide an HMAC key out-of-band via env var
export AISTACK_AUDIT_KEY="$(node -e "console.log(require('crypto').randomBytes(32).toString('hex'))")"

# Operate
aistack audit status
aistack audit export --since=2026-01-01T00:00:00Z --format=jsonl -o evidence.jsonl
aistack audit verify

The audit chain lives in its own SQLite file (default <memory.path>.audit.db), separate from the main application DB. UPDATE and DELETE are blocked at the storage layer by triggers; hash chaining catches anything that bypasses them (e.g. raw file edits).


Schema

Migration: migrations/005_audit_log_chain.sql

CREATE TABLE audit_log_chain (
  seq INTEGER PRIMARY KEY AUTOINCREMENT,
  prev_hash TEXT NOT NULL,     -- 64-hex SHA-256 of previous entry; genesis = 64 zeros
  hash TEXT NOT NULL,          -- 64-hex SHA-256 of this entry's pre-image
  event_type TEXT NOT NULL,    -- e.g. agent.spawn, task.complete, identity.retire
  payload_json TEXT NOT NULL,  -- canonical JSON (sorted keys), redacted, <= 64KB
  signature TEXT,              -- 64-hex HMAC-SHA256 of `hash`, optional
  signature_alg TEXT,          -- "HMAC-SHA256" or NULL
  created_at INTEGER NOT NULL  -- Unix ms
);

Indexes: (event_type, created_at), (created_at DESC).

Triggers: BEFORE UPDATE and BEFORE DELETE both RAISE(ABORT). Anyone with write access to the SQLite file can drop these triggers, but the hash chain will detect the resulting tampering on verify.


Hash computation

Each entry's hash is computed as:

SHA-256(
  "aistack/audit-log-chain/v1" || "\n" ||
  prev_hash || "\n" ||
  created_at_ms || "\n" ||
  event_type || "\n" ||
  canonical_json(payload)
)

Where canonical_json is the JSON serialization with recursively sorted keys — this makes the hash insensitive to JSON-object key ordering, so an exported evidence pack re-imported on a different machine (or different language runtime) re-hashes to the same value.

The leading domain-separator string (aistack/audit-log-chain/v1) prevents cross-protocol collision attacks: a payload hash from another system cannot be confused with an audit-chain hash.

The prev_hash of the first ever entry is the all-zero string (64 hex 0s) so the hashing routine is symmetric for every entry.


Events emitted

Event typeEmitted fromPayload (top-level keys, after redaction)
agent.spawnsrc/agents/spawner.tsagentId, type, name, identityId, sessionId
agent.stopsrc/agents/spawner.tsagentId, name, identityId
agent.errorsrc/agents/spawner.ts (executeAgent)agentId, type, error
task.createsrc/memory/index.ts (MemoryManager)taskId, agentType, sessionId, riskLevel, parentTaskId, depth
task.assignsrc/memory/index.ts (status -> running)taskId, status
task.completesrc/memory/index.ts (status -> done)taskId, status
task.failsrc/memory/index.ts (status -> failed)taskId, status
consensus.decisionsrc/tasks/consensus-service.tscheckpointId, taskId, approved, reviewedBy, reviewerType
identity.createsrc/agents/identity-service.tsagentId, agentType, displayName, autoActivate
identity.activatedsrc/agents/identity-service.tsagentId, previousStatus, newStatus, reason, actorId
identity.deactivatedsrc/agents/identity-service.tsagentId, previousStatus, newStatus, reason, actorId
identity.retiresrc/agents/identity-service.tsagentId, previousStatus, reason, actorId
identity.updatesrc/agents/identity-service.tsagentId, updatedFields, actorId
memory.writesrc/memory/index.ts (store/storeShared)entryId, key, namespace, agentId, shared
memory.deletesrc/memory/index.ts (delete)key, namespace

Memory reads are intentionally not audited to keep the trail bounded; the threat model targets write operations and lifecycle transitions. If your compliance regime requires read auditing, do so at a higher layer (the MCP tool boundary) — out of scope here.

Payloads are filtered through config.audit.redactFields before hashing, so sensitive content (e.g. content, apiKey) never enters the chain.


Configuration

{
  "audit": {
    "enabled": true,
    // Path override; default is `<memory.path>.audit.db`
    "path": "./data/aistack.audit.db",
    // PREFER the AISTACK_AUDIT_KEY env var over inlining the key here.
    // Inline only if you are storing the config in a secrets manager.
    "signatureKey": "${AISTACK_AUDIT_KEY}",
    // Informational. The chain itself is append-only and never auto-pruned.
    "retentionDays": 2555,
    // Top-level payload keys that will be replaced with "[REDACTED]".
    "redactFields": ["content", "apiKey", "password", "token"]
  }
}

Key management

  • Generate: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
  • Distribute via the same channel you already use for ANTHROPIC_API_KEY (env var, sealed secret, vault).
  • Never commit the key. The ${AISTACK_AUDIT_KEY} interpolation in config is resolved from process.env at load time.
  • Rotating the key invalidates HMAC verification for all entries signed under the old key. Treat the key as long-lived; if rotation is unavoidable, export
    • verify under the old key first, archive the signed export as evidence, then re-key.

Unsigned mode

If enabled: true but no key is found, the chain still runs and still detects tampering via the hash chain — you just lose the cryptographic attribution layer. A warning is logged once at startup.


CLI

# Summary
aistack audit status

# Export as JSONL (default) for archiving
aistack audit export --since=2026-01-01T00:00:00Z --until=2026-06-01T00:00:00Z -o audit-q1.jsonl

# Export as CSV for spreadsheet review
aistack audit export --format=csv -o audit-q1.csv

# Verify end-to-end (exit 0 = OK, 2 = chain broken)
aistack audit verify

# Verify a slice
aistack audit verify --from-seq=1000 --to-seq=2000

Exit codes for verify:

ExitMeaning
0Chain valid (and signed entries verified, if a key is configured)
1Audit disabled or wrong CLI invocation
2Chain broken — stderr reports the first broken seq and reason

Threat model

What the audit chain protects against

ThreatMechanism
Modification of a single past entryHash chain — verify reports the broken seq
Deletion of past entriesprev_hash mismatch on the next entry
Reordering of entriesHash chain depends on prev_hash → reordering breaks linkage
Insertion of a forged entry mid-chainSame — prev_hash chain breaks
Bypassing UPDATE/DELETE via SQLBEFORE UPDATE/DELETE triggers + chain hash fallback
Replay of an old payload as a "new" eventcreated_at + seq are both part of the hash
Cross-protocol confusion with other hashesDomain-separator string aistack/audit-log-chain/v1 in pre-image
Forgery by a process without the HMAC keyHMAC-SHA256 signature on every entry (when signed mode is on)

What the audit chain does NOT protect against

Out of scopeWhy / how to mitigate
Compromise of the HMAC keyTreat the key as a Tier-0 secret; use a vault; rotate on incident
Adversary writing to the DB before events occurAudit captures actions, not intent; pair with OS file ACLs
Adversary deleting the entire audit DB fileMirror to append-only object storage (S3 Object Lock, etc.)
Plaintext disclosure of payloadsUse redactFields; payloads are not encrypted at rest
Clock skew leading to wrong created_atUse NTP; verify only checks monotonicity via seq, not wall time
Code path that fails to call audit(...)Coverage is by code review + the table above; add tests for new ones

The chain provides tamper evidence, not tamper resistance. Combine with filesystem-level controls (chmod 0600, chattr +a on Linux, S3 Object Lock for shipped copies) to prevent destruction.


Evidence pack for SOC2 auditors

For an evidence request like "prove no agent acted on production data between 2026-01-01 and 2026-03-31 without a consensus checkpoint":

#!/usr/bin/env bash
set -euo pipefail

PERIOD_START="2026-01-01T00:00:00Z"
PERIOD_END="2026-03-31T23:59:59Z"
OUT_DIR="evidence-$(date -u +%Y%m%dT%H%M%SZ)"
mkdir -p "$OUT_DIR"

# 1. Verify the live chain before exporting.
aistack audit verify > "$OUT_DIR/verify-live.txt"

# 2. Export the slice.
aistack audit export \
  --since="$PERIOD_START" \
  --until="$PERIOD_END" \
  --format=jsonl \
  -o "$OUT_DIR/audit.jsonl"

# 3. Hash + sign the export for chain-of-custody.
sha256sum "$OUT_DIR/audit.jsonl" > "$OUT_DIR/audit.jsonl.sha256"

# 4. Capture the auditor-readable summary.
aistack audit status > "$OUT_DIR/status.txt"

# 5. Snapshot the schema migration so the auditor can audit our audit.
cp migrations/005_audit_log_chain.sql "$OUT_DIR/"
cp docs/AUDIT.md "$OUT_DIR/"

# 6. Bundle.
tar -czf "$OUT_DIR.tar.gz" "$OUT_DIR"
sha256sum "$OUT_DIR.tar.gz" > "$OUT_DIR.tar.gz.sha256"
echo "Evidence pack: $OUT_DIR.tar.gz"

The auditor can independently verify by importing audit.jsonl rows back into a fresh audit_log_chain table (same schema) and running aistack audit verify against it — given the HMAC key, the verification is end-to-end re-runnable on their machine.


API (TypeScript)

import { audit, getAuditChain, AuditChain } from '@blackms/aistack';
import { getConfig } from '@blackms/aistack/utils/config';

const config = getConfig();

// Fire-and-forget call-site usage (never throws):
audit(config, 'agent.spawn', { agentId, type: 'coder' });

// Direct chain access (CLI, tests):
const chain = getAuditChain(config);
if (chain) {
  const { seq, hash } = chain.append('task.create', { taskId });
  const result = chain.verify();
  for await (const entry of chain.export({ sinceMs: Date.now() - 86_400_000 })) {
    console.log(entry.seq, entry.eventType);
  }
}

See src/audit/chain.ts for the full reference.


Implementation notes

  • Separate DB file: the audit chain uses its own SQLite handle and its own file, so audit writes never interleave with application transactions and a corrupted main DB does not corrupt the audit. The cost is two file handles and a small amount of duplicated WAL infrastructure.
  • Atomicity: each append runs inside a SQLite IMMEDIATE transaction so two concurrent appenders cannot read the same prev_hash and produce a fork.
  • Failure mode: audit(...) swallows errors and logs a warning. Lifecycle events must never be blocked by audit failures. If you need fail-loud semantics (e.g. for compliance gates), call getAuditChain().append(...) directly and handle the throw.
  • Payload size cap: 64 KB per entry, enforced at append. Log a reference ({ entryId, path }) instead of full content for large data.