Boring Docs
Agent Cage — SQLite Is a File; Cage the File
OS-user isolation so disobeying agents physically cannot delete prod data.
In this guide
15 — The Agent Filesystem Cage (SQLite is a file; cage the file)
Let me tell you about nine seconds in April 2026. An autonomous coding agent — with explicit written rules saying do-not-delete — wiped a company's production database. Then it deleted the backups too. Nine seconds, two decades of "don't worry, the agent knows the rules" gone. The postmortems all said the same thing: obedience is not a control. A request is not a boundary.
This doc is the boundary. It assumes the standard layout from 11-deployment.md: app runs as $APP_USER (default deploy), code in /var/www/<app>. By the end, "don't delete prod" will be enforced by the Linux kernel instead of promised by a prompt — and you'll have tested the enforcement yourself, which is the only reason to believe it.
Why SQL guards aren't enough (the hole this closes)
We already have three SQL-layer defenses: the runtime firewall blocking destructive statements, read-only agent handles, migration scanners. All of them assume the attacker talks SQL. But an agent with shell access doesn't need SQL — it can rm db/app.db instead. No SQLite flag prevents that, because deleting a file is an OS operation, not a database one. Every SQL guard becomes decoration the moment rm exists in the same session.
So stop thinking about what the agent is allowed and start thinking about what it can touch. Different question, much stronger answer.
The principle (30 seconds)
Every file carries owner + mode bits. On every syscall the kernel checks who is asking. An agent running as user agent touching /var/www/myapp/db (owned deploy:deploy, mode 750) gets Permission denied before any code runs. No prompt trick argues with the kernel. Same idea as Android UIDs (Termux) and Claude Code's sandbox — stock Linux, no tooling, works on the $5 VPS today.
Internalize this and the whole doc is obvious: data safety isn't about constraining behavior, it's about constraining reach. The agent can misbehave all it wants inside its own directories. Prod isn't in its directories.
Setup (copy-paste, replace myapp)
# 1. Agent gets its own OS user — no sudo, no password, key only
sudo useradd -m -s /bin/bash agent
sudo -u agent ssh-keygen -t ed25519 -N "" -f /home/agent/.ssh/id_ed25519
# 2. Prod db directory: agent can't even list it
sudo chown -R deploy:deploy /var/www/myapp/db
sudo chmod 750 /var/www/myapp/db
# 3. Staging copy: the agent's playground, owned by the agent
sudo mkdir -p /srv/agent-staging/db
sudo chown -R agent:agent /srv/agent-staging
# 4. Hourly atomic refresh from prod (runs as deploy — the agent
# never touches the prod path). sqlite3 .backup never tears.
(crontab -l 2>/dev/null; echo "0 * * * * sqlite3 /var/www/myapp/db/app.db \".backup '/srv/agent-staging/db/app.db'\" && chown agent:agent /srv/agent-staging/db/app.db") | crontab -
# 5. Agent sessions export (session harness or /home/agent/.bashrc)
export AGENT_SQLITE_PATH=/srv/agent-staging/db/app.dbAGENT_SQLITE_PATH is the same variable getAgentDb() already respects (providers/db/sqlite/client.js) — app layer and OS layer agree on which file the agent sees. When layers agree, there are no gaps to fall through.
The staleness trade (read this, it's the price)
The staging copy refreshes hourly, which means your agent inspects data up to an hour old. For debugging, migrations prep, and feature work, that's plenty — schemas don't move hourly and recent rows are representative. But never let an agent conclude about "current" production state from it ("no orders in the last hour" might mean the refresh hasn't run). If an investigation needs live data, that's a human query with the deploy-owned handle, not an agent session. Stale reads are the price of the cage; fresh reads are the human's job. Say this to your agent in the session prompt so it reasons correctly about what it sees.
Verify the cage holds (do this, don't trust it)
sudo -u agent ls /var/www/myapp/db # → Permission denied ✅
sudo -u agent rm /var/www/myapp/db/app.db # → Permission denied ✅
sudo -u agent touch /srv/agent-staging/db/probe && sudo -u agent rm /srv/agent-staging/db/probe # → works ✅Three commands, ten seconds, and now you know instead of believe. Run them after setup and after every server change that touches users, permissions, or paths. The cage rots exactly the way everything rots: someone chmods something during a late-night fix and forgets. The verify commands are the maintenance. Put a quarterly calendar reminder next to the dangerouslySetInnerHTML grep from 10-security.md — paranoia with a schedule is just operations, and operations is what keeps the nine seconds from ever being your story.
Worst case, on purpose
Agent deletes the staging copy: next hourly refresh rebuilds it, prod never flinches. Agent disobeys every instruction: kernel says no. Agent finds a SQL injection in your app (see 10-security.md — fix those too): it runs as the app user through the app's handle, which is the app's threat model, not the agent's. That is the entire threat model for SQLite file destruction, closed.
What this does NOT cover (honesty section)
- Code is readable by design. Give
agentread-only (755/644) on the checkout — it must read the repo to work. Code is replaceable; data and secrets are not. Don't waste effort hiding code; spend it caging data. - One user per agent is enough. Per-session ephemeral users isolate better but add provisioning to every start. Start standing; go ephemeral for untrusted third-party agents.
- Supabase/Mongo need the credential twin: service keys absent from the agent's env (
app_agent/appAgentroles only — see10-security.mdA11). No OS user protects a key the agent can read. The cage is for files; roles are for APIs. You need both, and now you have both. - The cage doesn't fix your app's own bugs. An agent working through your API routes with valid credentials is exercising your app, not attacking it — if that breaks data, the bug is in your validation, and the cage correctly lets it through. Layers, friend. Every layer does its own job.