← All posts
By The agentjail team

Why every decision lives in SQLite

We chose SQLite as agentjail's decision store. Not a log file. Not a remote API. Here is why local-first storage matters for security tooling.

The agentjail daemon writes decisions to a SQLite WAL store, which is read concurrently by agentjail logs, agentjail ui, and agentjail replay.
One writer, many readers, no network. The decision store stays on your machine.

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.