Runtime

Let an AI agent query your database without risking it

Give the agent a read-only login on a copy of the data, and run its SQL in a sandbox that holds the password but no shell.

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, and a new one starts 102 ms after the request on Runtime's servers. 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 sets out what can go wrong, which defenses are locks and which are only seatbelts, and a complete two-sandbox design you can copy.

What can go wrong when an agent queries a database?

More than 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 at some point the model will try to do whatever the data tells it to. Everything it can do then is decided by the locks, not by the prompt.

Which defenses are locks, and which are seatbelts?

A lock holds whatever SQL the model sends; a seatbelt can be undone by the SQL itself. Postgres makes the 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 and put the locks somewhere the session cannot reach:

  • Privileges are a lock. A role granted SELECT on 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, and 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, or on a restored copy if you do not. The views double 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 following an injected instruction would. Every statement below but 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 function

The first line succeeds, and that is the point: it shows the read-only default is only a seatbelt. Everything after it must still fail. Watch one more trap: in Postgres before version 15, every role may create objects in public by default. Run REVOKE CREATE ON SCHEMA public FROM PUBLIC on older servers, or a model can create a function there and call it.

Run the same attempts from a sandbox after every migration, so a grant added by mistake 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?

On the least valuable copy that still answers the question. Three choices, 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, snapshot that sandbox once, and start every agent session from the snapshot. Each session gets a full database it may write to, alter or wreck, and the next session starts clean from the same point. A sandbox started from a snapshot is running 440 ms after the request on Runtime's servers, database process included (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 it in a sandbox the model never gets a shell in. This is the core of the design. A coding agent that can run commands in the sandbox holding DATABASE_URL can read the variable, connect directly and skip every check your tool makes. So use two sandboxes:

  1. The query sandbox gets the read-only login with each call, can reach only the database host, and runs one program: a runner that takes a single SELECT on standard input and prints rows as JSON.
  2. 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. It 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 earn their place:

  • The parse refuses anything but one SELECT, including a data-changing WITH clause and SELECT ... INTO. It is a seatbelt; the role's privileges are the lock behind it.
  • EXPLAIN before 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.
  • truncated tells 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 ask for a short-lived OpenID Connect token naming itself, trade it with AWS for a role, and use that role to generate an RDS IAM login token, which works as a password for a short while and is never stored anywhere. Scope the AWS trust policy to the image the query sandbox runs, so 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 records only a hash of them.

What does this cost to run?

Little, because both sandboxes spend most of a session waiting on the model. Take 1,000 sessions a month, each keeping two 2 vCPU, 4 GiB sandboxes running for 10 minutes, with 30 CPU-seconds of query parsing and pandas work between them:

TextCPU:     1,000 × 30 s / 3,600 × $0.025                 = $0.21Memory:  1,000 × 2 sandboxes × 10 min / 60 × 4 GiB × $0.0075 = $10.00Total:                                                  $10.21

CPU is billed on what the commands use, at $0.025 per vCPU-hour, and an idle sandbox pauses itself after 60 seconds with nothing happening (pricing). If your database admits only listed IP addresses, a dedicated outbound address lets it admit exactly your sandboxes, for $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 that keeps a running Postgres, and a fresh start 102 ms after the request, measured on Runtime's servers (speed). See the text-to-SQL agent for a single-sandbox version, then sign in at withruntime.com: the 100 hours of a 2 vCPU, 4 GB sandbox is included every month.

400 sandbox hours,every month.

Hours of a 1 GB sandbox, included free.

  • No credit card
  • Eight sandboxes at once, 2 vCPU and 4 GiB each
  • Then prepaid credit from $10, no plan fee
Start free, no card