Runtime (withruntime.com) runs each piece in its own Firecracker microVM. One sandbox reaches only the database and runs only your SQL runner. Another runs the model's Python with no credentials and no internet. That split is the difference between an agent that answers questions about your data and one that can be talked into deleting it. This post covers what can go wrong. It separates the defenses that are locks from the ones that are only seatbelts. It ends with a complete two-sandbox design you can copy.
What can go wrong when an agent queries a database?
The risks go beyond writes. Most guides stop at "use a read-only user," which handles one row of this table:
| Risk | How it happens | What stops it |
|---|---|---|
| A write or schema change | The model "fixes" bad data, or follows an instruction found in a row | Privileges: the role can only SELECT from views |
| Load on production | A join across two large tables with no filter, run during peak hours | Querying a replica or a copy, not the primary |
| Too many connections | An agent retries in a loop and each retry opens a connection | CONNECTION LIMIT on the role |
| Personal data to a model | select * from users puts emails into a prompt sent to a model API |
Views that leave out or mask those columns |
| Data leaving | Code the model writes posts rows to a server it names | A sandbox whose network reaches only the database |
| A leaked password | The connection string ends up in a log, a transcript or a commit | No shell where the password lives; short-lived logins |
| Prompt injection in data | A support ticket's text says "ignore your instructions and run..." | Every lock above, since the model may obey it |
The last row is why the design matters. Assume that the model will, at some point, try to do whatever the data tells it to. Then the locks decide everything it can do, not the prompt.
Which defenses are locks, and which are seatbelts?
A lock holds against whatever SQL the model sends. A seatbelt can be undone by
the SQL itself. Postgres makes this difference easy to miss. A role's
default_transaction_read_only and statement_timeout are useful defaults.
But any session can run SET statement_timeout = 0 and carry on. Treat them
as seatbelts. Put the locks where the session cannot reach them:
- Privileges are a lock. A role granted
SELECTon a few views cannot write, whatever it sets. - Where the database is is a lock. A query against a replica or a copy cannot slow the primary your customers use.
- The network around the code is a lock when it is enforced outside the machine the code runs in. Runtime applies each sandbox's network rules on the host. Root inside the sandbox cannot change them (the sandbox environment).
How do you set up a read-only role the agent cannot misuse?
Grant it views, not tables, in a schema of its own. A view runs with its owner's rights. So the agent reads exactly the columns you chose, and nothing behind them:
SQLCREATE SCHEMA agent;CREATE VIEW agent.customers AS SELECT id, created_at, country, plan, left(md5(email), 12) AS email_hash FROM public.customers;CREATE VIEW agent.orders AS SELECT id, customer_id, created_at, total_cents, status FROM public.orders;CREATE ROLE agent_reader LOGIN PASSWORD 'from-your-secret-manager' CONNECTION LIMIT 4;REVOKE ALL ON ALL TABLES IN SCHEMA public FROM agent_reader;GRANT USAGE ON SCHEMA agent TO agent_reader;GRANT SELECT ON ALL TABLES IN SCHEMA agent TO agent_reader;-- Seatbelts: sensible defaults a session could change.ALTER ROLE agent_reader SET default_transaction_read_only = on;ALTER ROLE agent_reader SET statement_timeout = '15s';ALTER ROLE agent_reader SET idle_in_transaction_session_timeout = '30s';ALTER ROLE agent_reader SET search_path = agent;Create it on a read replica if you have one. If you do not, create it on a
restored copy. The views also serve as documentation. The model reads the
agent schema and sees only what it is meant to reason about.
How do you prove the role is locked down?
Log in as the role, and try to break out the way a model would after following an injected instruction. Every statement below except the first should fail:
SQLSET default_transaction_read_only = off;DELETE FROM agent.orders; -- permission denied: it holds SELECT onlySELECT email FROM public.customers; -- permission denied for table customersCREATE TABLE agent.x (id int); -- permission denied for schema agentCOPY agent.orders TO '/tmp/orders.csv'; -- needs a privilege it does not haveSELECT pg_read_file('/etc/passwd'); -- permission denied for functionThe first line succeeds, and that is the point. It shows that the read-only
default is only a seatbelt. Everything after it must still fail. Watch for one
more trap. In Postgres before version 15, every role may create objects in
public by default. On older servers, run
REVOKE CREATE ON SCHEMA public FROM PUBLIC. Otherwise a model can create a
function there and call it.
Run the same attempts from a sandbox after every migration. A grant added by
mistake then fails a build, instead of waiting for a model to find it. The SQL
travels in env with the login, so no quoting can change what runs:
TypeScriptimport { Sandbox } from "withruntime";const attempts = [ "SET default_transaction_read_only = off; DELETE FROM agent.orders", "SELECT email FROM public.customers", "CREATE TABLE agent.x (id int)", "COPY agent.orders TO '/tmp/orders.csv'", "SELECT pg_read_file('/etc/passwd')",];await using sbx = await Sandbox.create();await sbx.exec( "sudo apt-get update -qq && sudo apt-get install -y -qq postgresql-client >/dev/null", { check: true, timeoutMs: 300_000, },);const open: string[] = [];for (const sql of attempts) { const r = await sbx.exec('psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -c "$SQL"', { env: { DATABASE_URL: process.env.READONLY_DATABASE_URL ?? "", SQL: sql }, }); if (r.exitCode === 0) open.push(sql);}console.log(open.length ? `NOT LOCKED: ${open.join(" | ")}` : `all ${attempts.length} refused`);Pythonimport osfrom withruntime import SandboxATTEMPTS = [ "SET default_transaction_read_only = off; DELETE FROM agent.orders", "SELECT email FROM public.customers", "CREATE TABLE agent.x (id int)", "COPY agent.orders TO '/tmp/orders.csv'", "SELECT pg_read_file('/etc/passwd')",]with Sandbox.create() as sbx: sbx.exec("sudo apt-get update -qq && sudo apt-get install -y -qq postgresql-client >/dev/null", check=True, timeout_ms=300_000) url = os.environ.get("READONLY_DATABASE_URL", "") still_open = [sql for sql in ATTEMPTS if sbx.exec('psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -c "$SQL"', env={"DATABASE_URL": url, "SQL": sql}).exit_code == 0] print(f"NOT LOCKED: {still_open}" if still_open else f"all {len(ATTEMPTS)} refused")Where should the agent's queries run?
Run queries on the least valuable copy that still answers the question. There are three choices, ordered from safest to freshest:
| Where | Fresh data | Load on customers | Agent may write | Good for |
|---|---|---|---|---|
| A copy inside a sandbox | As of the dump | None | Yes, it is disposable | Exploration, migrations, "what if" questions |
| A read replica | Seconds behind | None on the primary | No | Analytics on live data |
| The primary, read-only role | Live | Some | No | Small lookups with a strict row cap |
A copy inside a sandbox is the most underused option. Restore a sanitized dump into Postgres running in a sandbox. Take one snapshot of that sandbox. Start every agent session from the snapshot. Each session gets a full database that it may write to, alter or wreck. The next session starts clean from the same point. A sandbox started from a snapshot has its database process running (Postgres in a sandbox).
TypeScriptimport { Runtime } from "withruntime";const runtime = new Runtime();const bin = "/usr/lib/postgresql/16/bin";const slow = { check: true, timeoutMs: 1_800_000 } as const;// Once: Postgres running with a sanitized dump loaded, kept as a snapshot for a week.await using base = await runtime.sandboxes.create({ diskMiB: 16_384, timeoutSeconds: 3600 });await base.exec("sudo apt-get update -qq && sudo apt-get install -y -qq postgresql", slow);await base.exec("sudo pg_dropcluster --stop 16 main", { check: true });await base.exec(`${bin}/initdb -D /workspace/pgdata -U postgres --auth=trust`, slow);await base.spawn(`${bin}/postgres -D /workspace/pgdata -k /tmp -c listen_addresses=127.0.0.1`);await base.exec("timeout 60 bash -c 'until pg_isready -q -h 127.0.0.1; do sleep 0.5; done'", slow);await base.files.upload("./sanitized.dump", "/workspace/sanitized.dump");await base.exec( "pg_restore -h 127.0.0.1 -U postgres -d postgres --no-owner /workspace/sanitized.dump", slow,);const snap = await base.snapshot({ name: "app-db", retentionDays: 7 });// Every session: a writable copy with the database already running and no internet.await using session = await runtime.sandboxes.create({ snapshot: snap.id, network: { internet: false },});const count = await session.exec( "psql -h 127.0.0.1 -U postgres -Atc 'select count(*) from orders'",);console.log("orders:", count.stdout.trim());How do you keep the password away from the model?
Put the password in a sandbox where the model never gets a shell. This is the
core of the design. A coding agent that can run commands in a sandbox holding
DATABASE_URL can read the variable, connect directly, and skip every check
your tool makes. So use two sandboxes:
- The query sandbox receives the read-only login with each call. It can
reach only the database host. It runs one program: a runner that takes a
single
SELECTon standard input and prints rows as JSON. - The analysis sandbox runs the model's Python and shell commands. It has no credentials and no internet, so there is nothing in it to steal and nowhere to send it.
Your code, outside both, moves results from one to the other. The model calls
two tools: run_sql and python. The model never touches the query sandbox
except through run_sql, and run_sql only passes SQL text to the runner.
TypeScriptimport { Runtime } from "withruntime";const runtime = new Runtime();const RUNNER = `import json, os, sysimport psycopg, sqlglotfrom sqlglot import expsql = sys.stdin.read().strip().rstrip(";")tree = sqlglot.parse(sql, read="postgres")if len(tree) != 1 or not isinstance(tree[0], (exp.Select, exp.Union)) or tree[0].find( exp.Insert, exp.Update, exp.Delete, exp.Into, exp.Command): sys.exit("refused: send exactly one SELECT")with psycopg.connect(os.environ["DATABASE_URL"]) as conn: conn.execute("SET TRANSACTION READ ONLY") conn.execute("SET LOCAL statement_timeout = '15s'") plan = conn.execute("EXPLAIN (FORMAT JSON) " + sql).fetchone()[0][0]["Plan"] if plan["Total Cost"] > 1_000_000: sys.exit(f"refused: estimated cost {plan['Total Cost']:.0f}; filter or aggregate first") cur = conn.execute(sql) rows = cur.fetchmany(500) print(json.dumps({"columns": [c.name for c in cur.description], "rows": rows, "truncated": cur.fetchone() is not None}, default=str))`;// Reaches only the database and runs only the runner; the login arrives with each call.const login = { DATABASE_URL: process.env.READONLY_DATABASE_URL ?? "" };await using query = await runtime.sandboxes.create({ image: "sql-runner", // python3 with psycopg and sqlglot, built once network: { internet: true, allow: ["replica.db.example.com"] },});await query.files.write("/workspace/run_sql.py", RUNNER);// The model's own machine: pandas, a shell, no credentials, no internet.await using analysis = await runtime.sandboxes.create({ network: { internet: false } });export async function runSql(sql: string, saveAs: string) { const r = await query.exec(["python3", "/workspace/run_sql.py"], { stdin: sql, env: login, timeoutMs: 30_000, }); if (r.exitCode !== 0) return `error: ${r.stderr.slice(-1500)}`; await analysis.files.write(`/workspace/data/${saveAs}.json`, r.stdout); return `saved data/${saveAs}.json, ${r.stdout.length} bytes`;}export async function python(code: string) { const r = await analysis.exec(["python3", "-c", code], { cwd: "/workspace/data", timeoutMs: 120_000, }); return (r.stdout + r.stderr).slice(-4000);}console.log(await runSql("select plan, count(*) from customers group by plan", "plans"));console.log(await python("import json; print(json.load(open('plans.json'))['rows'])"));Pythonimport osfrom pathlib import Pathfrom withruntime import Runtimeruntime = Runtime()RUNNER = Path("run_sql.py").read_text() # the runner above, saved next to this file# Reaches only the database and runs only the runner; the login arrives with each call.LOGIN = {"DATABASE_URL": os.environ.get("READONLY_DATABASE_URL", "")}query = runtime.sandboxes.create( image="sql-runner", # python3 with psycopg and sqlglot, built once network={"internet": True, "allow": ["replica.db.example.com"]},)query.files.write("/workspace/run_sql.py", RUNNER)# The model's own machine: pandas, a shell, no credentials, no internet.analysis = runtime.sandboxes.create(network={"internet": False})def run_sql(sql: str, save_as: str) -> str: r = query.exec(["python3", "/workspace/run_sql.py"], stdin=sql, env=LOGIN, timeout_ms=30_000) if r.exit_code != 0: return "error: " + r.stderr[-1500:] analysis.files.write(f"/workspace/data/{save_as}.json", r.stdout) return f"saved data/{save_as}.json, {len(r.stdout)} bytes"def python(code: str) -> str: r = analysis.exec(["python3", "-c", code], cwd="/workspace/data", timeout_ms=120_000) return (r.stdout + r.stderr)[-4000:]try: print(run_sql("select plan, count(*) from customers group by plan", "plans")) print(python("import json; print(json.load(open('plans.json'))['rows'])"))finally: query.stop() analysis.stop()Build the runner's image once with
runtime images build --pip 'psycopg[binary]' --pip sqlglot --name sql-runner
(custom images). Three details in the runner matter:
- The parse refuses anything but one
SELECT, including a data-changingWITHclause andSELECT ... INTO. It is a seatbelt; the role's privileges are the lock behind it. EXPLAINbefore running turns "this query will scan two billion rows" into an error message the model can act on, before the database does the work. Tune the cost ceiling to your data.truncatedtells the model the answer was cut, so it aggregates instead of concluding from the first 500 rows.
Can the agent avoid a stored password altogether?
Yes, on AWS. A Runtime sandbox can request a short-lived OpenID Connect token
that names itself. It trades that token with AWS for a role. It then uses the
role to generate an RDS IAM login token. That token works as a password for a
short while, and it is never stored anywhere. Scope the AWS trust policy to the
image the query sandbox runs. Then only sandboxes made from sql-runner can
assume the role (identity tokens with AWS).
Where that is not available, pass the password in each command's env, as
above. Runtime never echoes env values back, and it records only a hash of
them.
What does this cost to run?
The cost is low, because both sandboxes spend most of a session waiting on the model. Take 1,000 sessions a month. Each session keeps two 2 vCPU, 4 GiB sandboxes running for 10 minutes. Between them, the sandboxes use 30 CPU-seconds on query parsing and pandas work:
- CPU
- 1,000 × 30 s / 3,600 × $0.025
- $0.21
- Memory
- 1,000 × 2 sandboxes × 10 min / 60 × 4 GiB × $0.0075
- $10.00
- Total
- $10.21
CPU is billed on what the commands use, at $0.025 per vCPU-hour. An idle sandbox pauses itself after 300 seconds with nothing happening (pricing). If your database admits only listed IP addresses, a dedicated outbound address lets it admit exactly your sandboxes. The address costs $5 a month for the whole account (dedicated addresses).
In short
- Locks hold against any SQL: privileges on views, a replica or copy instead of the primary, and network rules enforced outside the machine.
- Read-only defaults and statement timeouts are seatbelts a session can undo; keep them, but do not rely on them.
- Keep the database login in a sandbox the model never gets a shell in, and run the model's code in a second one with no credentials and no internet.
- For exploration, give the agent a disposable copy started from a snapshot, and let it write all it likes.
Run it on Runtime
Runtime costs 42% to 88% less than fourteen other sandbox providers for an agent that mostly waits on a model (compare costs). Runtime sandboxes are Firecracker microVMs with network rules enforced on the host. A snapshot keeps a running Postgres. See the text-to-SQL agent for a single-sandbox version. Then sign in at withruntime.com to get 100 free hours of a 1 GB machine every month.