When we started building agentjail, decisions were logged to stdout — a line for every allow and deny, scrolling past while nobody watched.
Nobody watches the terminal.
We needed a queryable, durable, private audit trail. We chose SQLite. Not because it is trendy. Because the constraints of security tooling make almost every other option wrong.
The latency constraint
agentjail sits in the hot path of every tool call. The daemon evaluates policy and returns a verdict before the agent’s action runs. Our median is ~8 ms on warm evaluations. That budget is for the policy evaluation, not for writing audit records.
A remote API is immediately disqualified. Even a fast POST to localhost adds milliseconds of variance and a failure mode (what happens when the API is down?). A file append is fast but gives you no query capability without parsing the whole file.
$ agentjail logs --verbose | head -5
2026-06-18T14:32:01Z allow Bash git status 1.2ms
2026-06-18T14:32:04Z deny Read ~/.ssh/id_rsa 0.9ms
2026-06-18T14:32:09Z allow Write src/main.go 1.1ms
2026-06-18T14:32:15Z deny Bash curl api.evil.io 2.3ms
SQLite in WAL mode gives us sub-millisecond writes with full query
capability. The writer (the daemon) and the readers (agentjail logs,
agentjail ui, agentjail replay) never block each other. A decision
is recorded, queryable, and durable in the same operation.
The offline constraint
agentjail runs offline. That is a core design decision: your policy evaluation should never depend on a network call. The same principle extends to the audit trail.
If the decision store is a remote service, then airplane mode means no audit trail. A VPN that drops means gaps in your security log. A rate limit on the ingestion API means you lose events during the busiest (and most interesting) sessions.
SQLite has no network dependency. It is a file. It works on a plane, in a Docker container with no egress, on a CI runner with locked-down networking. The audit trail is as reliable as the filesystem.
The query constraint
“Show me every denied tool call from last Tuesday’s session” is a
reasonable question. So is “which Rego rule fires most often?” and “how
many times did the agent try to access ~/.ssh this week?”
With a log file, answering these questions means grep, awk, and jq pipelines that get longer with every new question:
# The log-file approach: one question = one pipeline
grep '"verdict":"deny"' ~/.agentjail/events.jsonl \
| jq 'select(.timestamp > "2026-06-11")' \
| jq -r '.tool' \
| sort | uniq -c | sort -rn
With SQLite, it is a query:
SELECT tool, rule_id, COUNT(*) as hits
FROM decisions
WHERE verdict = 'deny'
AND timestamp > datetime('now', '-7 days')
GROUP BY tool, rule_id
ORDER BY hits DESC;
The web UI (agentjail ui) runs these queries server-side with filters
for verdict, tool, session, and time range. Building a filterable,
paginated UI on top of SQLite is straightforward. Building one on top of
a log file is a project.
The size constraint
Developer machines are not servers. A 2019 MacBook Air with 8 GB of RAM and a half-full SSD is a real deployment target for us.
This bit us in v0.2.2. SQLite’s default configuration uses enough memory
for read operations that macOS jetsam (the OOM killer) was terminating
agentjail logs on machines with less than 200 MB free RAM.
The fix was three settings: cache capped at 1 MB, memory-mapped I/O disabled, single reader connection.
db.SetMaxOpenConns(1)
db.Exec("PRAGMA cache_size = -1000") // 1 MB page cache cap
db.Exec("PRAGMA mmap_size = 0") // no memory-mapped I/O
And a fallback: if SQLite cannot open
the database at all, agentjail logs degrades to streaming the daemon
log file.
This is the kind of problem you only hit with an embedded database on constrained hardware. But it is also the kind of problem that has a clean solution. You cannot “cap the memory” of a remote service your tool depends on.
The privacy constraint
agentjail records tool calls. Tool calls contain commands, file paths, and sometimes file contents. This is sensitive data.
With a remote store, you are asking users to trust that their commands and file paths are encrypted in transit, stored securely, retained appropriately, and not accessible to anyone they did not authorize. That is a lot of trust for a security tool.
With SQLite, the data never leaves the machine. The file lives at
~/.agentjail/store.db.
$ sqlite3 ~/.agentjail/store.db ".schema decisions"
CREATE TABLE decisions (
id INTEGER PRIMARY KEY,
session TEXT NOT NULL,
ts TEXT NOT NULL,
tool TEXT NOT NULL,
verdict TEXT NOT NULL,
rule_id TEXT,
input TEXT, -- redacted at write time
created_at TEXT DEFAULT (datetime('now'))
);
Users can inspect it, back it up, delete it, or encrypt the volume it sits on. The privacy model is “your data, your disk, your rules.”
There is nothing to trust because there is no third party.
We do run automatic retention cleanup so the database does not grow without bound. But the retention policy is local and configurable, not a setting on someone else’s dashboard.
What we gave up
SQLite is not free of tradeoffs.
Machine A Machine B
+---------------------+ +---------------------+
| agentjail daemon | | agentjail daemon |
| | | | | |
| ~/.agentjail/ | | ~/.agentjail/ |
| store.db <------+--x---+--- store.db |
+---------------------+ +---------------------+
No sync. No aggregation. By design.
No cross-machine visibility. If you run agentjail on three machines, you have three separate databases. There is no unified view. For individual developers this is fine. For teams, it means building an export/aggregation layer if you want centralized audit.
No real-time remote alerts. A deny event is recorded locally. If you want a Slack notification when an agent tries something dangerous, you need to poll the database or build a webhook layer. We are looking at this.
Single-machine durability. If the disk dies, the audit trail dies with it. For compliance-grade audit, you would want replication. SQLite does not do replication (Litestream and LiteFS exist but add operational complexity).
These are real limitations. We chose to accept them because the alternative — shipping sensitive data to a remote service by default — is worse for the majority of users who just want to know what their agent did.
The broader point
If you are building developer tooling that records sensitive operations, consider whether the data needs to leave the machine at all. Remote telemetry is the default because it is what cloud services do. But developer tools are not cloud services. They run on someone’s laptop, against someone’s code, in someone’s home directory.
SQLite lets you give users a queryable, durable, private audit trail without asking them to trust you with their data. For security tooling, that is not a nice-to-have. It is the right default.