Log every model call. Hash every input. Chain every event. Prove what your AI did.
Contents
Someone will ask what your AI did. A customer, a regulator, your security team, or the incident reviewer at 2am.
If the answer is "give us a few days to dig through logs," you have a problem. If the answer is a query that returns a structured record in seconds, you have an audit trail.
Most teams don't have one. sealog fixes that.
- Structured audit events for every AI action — who triggered it, which model, what went in, what came out, whether it was allowed
- Input hashing — never log raw prompts; hash the input, store the output, link to the source
- Hash-chained events — each event includes the hash of the previous event, making tampering evident (the useful part of a blockchain, without the rest)
- Policy engine — allow/deny rules evaluated before every action, with the decision logged
- Queryable — filter by agent, user, time range, cost, latency, decision
- Two storage backends — append-only JSONL files or SQLite (bring your own by subclassing
StorageBackend) - Zero dependencies beyond Pydantic v2
pip install -e .from sealog import AuditLogger, Actor, SQLiteStorage
storage = SQLiteStorage("audit.db")
logger = AuditLogger(storage=storage)
event = logger.log(
action="model.call",
actor=Actor(user_id="user_441", agent_id="agent_threat_enrich"),
model="claude-opus-4",
model_version="20250514",
input_data="Summarize the threat report for IOC 192.168.1.1",
output={"summary": "3 matching IOCs found", "severity": "high"},
latency_ms=342.0,
cost_usd=0.0087,
)
print(event.input_hash) # sha256:a3f8c1... (input is hashed, not stored)
print(event.decision) # "allow"
print(event.event_hash) # tamper-evident hash of this eventThree lines to start logging. The input is hashed automatically. The event is chained to the previous one. The output is stored as-is because it's what the user saw.
from sealog import AuditLogger, Actor, PolicyEngine, PolicyRule, SQLiteStorage
policy = PolicyEngine()
policy.add_rule(PolicyRule(
name="block_database_delete",
description="Agents cannot delete from the database",
action="deny",
condition=lambda r: r.get("action") == "database.delete",
))
policy.add_rule(PolicyRule(
name="block_expensive_models",
description="Deny calls to expensive models without approval",
action="deny",
condition=lambda r: r.get("model") in ["o3", "o4-mini"] and not r.get("approved"),
))
storage = SQLiteStorage("audit.db")
logger = AuditLogger(storage=storage, policy=policy)
# This gets denied — and the denial is logged
event = logger.log(
action="database.delete",
actor=Actor(user_id="user_441", agent_id="agent_cleanup"),
model="claude-opus-4",
model_version="20250514",
input_data="DELETE FROM customers WHERE inactive = true",
output="blocked by policy",
latency_ms=2.0,
)
print(event.decision) # "deny"
print(event.decision_reason) # "Agents cannot delete from the database"Denied events are still logged. The audit trail records what happened, not just what succeeded.
from sealog import FileStorage
storage = FileStorage("audit.jsonl")Append-only. One JSON object per line. Human-readable. Easy to ship to S3, stream to a SIEM, or grep in an emergency.
from sealog import SQLiteStorage
storage = SQLiteStorage("audit.db") # persistent
storage = SQLiteStorage(":memory:") # in-memory (for tests)INSERT-only — no UPDATE, no DELETE. Queryable with filters pushed down to SQL.
Subclass StorageBackend and implement append, query, and count:
from sealog.storage.base import StorageBackend
class S3Storage(StorageBackend):
def append(self, event): ...
def query(self, filters=None): ...
def count(self, filters=None): ...from sealog import QueryFilters
# What did agent X do between 2pm and 3pm?
events = logger.query(QueryFilters(
agent_id="agent_threat_enrich",
start_time=datetime(2026, 6, 16, 14, 0, tzinfo=timezone.utc),
end_time=datetime(2026, 6, 16, 15, 0, tzinfo=timezone.utc),
))
# Which actions were denied?
denied = logger.query(QueryFilters(decision="deny"))
# How much did this user's AI usage cost?
expensive = logger.query(QueryFilters(
user_id="user_441",
min_cost=0.01,
))
# How many events total?
total = logger.count()Filters: agent_id, user_id, action, model, decision, start_time, end_time, min_cost, max_cost, min_latency, max_latency.
Every event includes:
event_hash— a SHA-256 hash of the event's contentsprev_hash— theevent_hashof the previous event
Event 1: prev_hash="" , event_hash="a3f8..."
Event 2: prev_hash="a3f8...", event_hash="7b2c..."
Event 3: prev_hash="7b2c...", event_hash="e91d..."
If someone modifies event 2, its hash changes, and event 3's prev_hash no longer matches. Tampering is detectable without complex infrastructure.
This is the useful part of a blockchain — hash chaining for integrity — without the rest.
| Class | Purpose |
|---|---|
AuditLogger(storage, policy=None) |
Main entry point. .log(), .query(), .count() |
AuditEvent |
Pydantic model for one audit record |
Actor(user_id, agent_id, session_id) |
Who triggered the action |
PolicyEngine(default_action="allow") |
Evaluates rules against requests |
PolicyRule(name, action, condition) |
One allow/deny rule |
PolicyDecision |
Result of policy evaluation |
QueryFilters |
Filter criteria for querying events |
FileStorage(path) |
JSONL append-only backend |
SQLiteStorage(path) |
SQLite INSERT-only backend |
hash_input(data) |
Hash a string or dict |
If you can edit the audit log, it is not an audit log.
Append-only. Immutable. Hash-chained. If someone asks what your AI did, the answer should take seconds, not days.
Blog post: The AI audit trail: what to log and why it matters
MIT — Shubham Katta, 2026.