# Let an AI agent query your database without risking it Give the agent a read-only login on a copy of the data. Run its SQL in a sandbox that holds the password but has 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.** 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 `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. Root inside the sandbox cannot change them ([the sandbox environment](/docs/sandbox-environment#the-network)). ## 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: ```sql CREATE 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: ```sql SET default_transaction_read_only = off; DELETE FROM agent.orders; -- permission denied: it holds SELECT only SELECT email FROM public.customers; -- permission denied for table customers CREATE TABLE agent.x (id int); -- permission denied for schema agent COPY agent.orders TO '/tmp/orders.csv'; -- needs a privilege it does not have SELECT pg_read_file('/etc/passwd'); -- permission denied for function ``` The 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: ```ts import { 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`); ``` ```python import os from withruntime import Sandbox 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')", ] 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](/integrations/postgres)). ```ts check import { 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: 1. **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 `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`. The model never touches the query sandbox except through `run_sql`, and `run_sql` only passes SQL text to the runner. ```ts check import { Runtime } from "withruntime"; const runtime = new Runtime(); const RUNNER = `import json, os, sys import psycopg, sqlglot from sqlglot import exp sql = 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'])")); ``` ```python check import os from pathlib import Path from withruntime import Runtime runtime = 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](/docs/images)). Three details in the runner matter: - **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 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](/how-to/use-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](/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](/docs/networking#dedicated-outbound-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](/how-to/compare-your-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](/use-cases/sql-analysis-agent) for a single-sandbox version. Then [sign in at withruntime.com](/sign-in) to get 100 free hours of a 1 GB machine every month.