Sandboxing an AI Agent's Generated SQL in MicroVMs
Picture the analytics agent everyone is shipping in 2026. A user types "which enterprise accounts grew fastest last quarter, and chart their MRR trend," and the agent does three things: it writes SQL, runs it against your database, and then writes a little pandas program to pivot the result and render a chart. Two of those three steps are code your team never wrote and never reviewed — the SQL and the post-processing — and both execute against production data. The demo is magic. The architecture underneath it is usually a connection pool, an `exec()`, and a prayer.
I'm Ajay — I build PandaStack, a Firecracker microVM sandbox platform. There's a companion post on the two-wall model for LLM-generated SQL in general (/blog/sandbox-llm-sql-execution). This one is narrower and more operational: the specific mechanics of wrapping an AI agent's query executor in a per-request microVM that holds a short-lived, least-privilege connection to exactly one tenant's database — and nothing else — for exactly as long as the request takes. It's the pattern I'd reach for if I were building the analytics agent above and wanted to sleep at night.
What the agent actually hands you
The failure modes of an agent that both generates and executes SQL are not hypothetical — they're the default behavior of a probabilistic system pointed at a database. It helps to enumerate them, because a mitigation that fixes one often does nothing for the next.
- Destructive DDL/DML — the model, mid-explanation, decides the cleanest way to answer is to reset a table first, and confidently emits `DELETE FROM users` (no WHERE clause), then apologizes profusely in the next token once it has finished executing it. Contrition does not restore rows.
- Expensive queries — a five-way join with no filter, a `SELECT *` over a billion-row event table, a cartesian product it genuinely believed was a small lookup. It runs, pins I/O, and everyone's dashboard times out.
- Injection through the prompt — a value the user (or a support ticket, or a scraped web page the agent read) controls turns into SQL structure. "Summarize account 42" quietly becomes a UNION that reads the credentials table.
- Cross-tenant leakage — the agent forgets, mis-joins, or is talked out of its `tenant_id` filter, and returns another customer's rows in a chart you then render back to the wrong customer.
- Arbitrary post-processing code — the pandas/matplotlib step is a full Python program that can read env vars, open sockets, shell out, or allocate until the box swaps. It runs right next to the query, with whatever your process can reach.
The per-request microVM executor
The pattern is to treat every agent request as disposable. When the agent has SQL (and the code around it) ready to run, you spin up a fresh Firecracker microVM, hand it a short-lived, read-only connection string to the one database this request is allowed to touch, execute the query and any post-processing inside that VM, read the result set back out, and destroy the VM. The generated code never runs in your API process, never sees your host's environment, and never holds a credential longer than the request.
This only works economically because the VM is cheap. On PandaStack every create restores a baked snapshot instead of cold-booting, so the restore step is ~49ms (end-to-end p50 179ms, p99 ~203ms); the first-ever boot of a template is ~3s, but after that you're on the fast path. A fresh microVM per agent request is a few hundred milliseconds of overhead, not a multi-second tax — which is the difference between "disposable executor per request" being a nice idea and being the thing you actually ship.
from pandastack import Sandbox
# The agent produced BOTH the SQL and the pandas post-processing. We treat all
# of it as untrusted and run it in a throwaway microVM. `analysis_dsn` is a
# SHORT-LIVED, READ-ONLY connection to exactly ONE tenant's database.
def run_agent_query(agent_sql: str, analysis_dsn: str) -> str:
executor = f'''
import os, json
import psycopg
import pandas as pd
dsn = os.environ["ANALYSIS_DSN"] # per-request, read-only, single-tenant
sql = {agent_sql!r}
with psycopg.connect(dsn) as conn:
df = pd.read_sql_query(sql, conn) # statement_timeout is set on the role
# The agent's post-processing runs HERE, in the same disposable VM.
summary = df.describe(include="all").to_json()
df.to_json("/workspace/rows.json", orient="records")
with open("/workspace/summary.json", "w") as f:
f.write(summary)
print(f"rows={{len(df)}}")
'''
with Sandbox.create(template="code-interpreter", ttl_seconds=120) as sbx:
sbx.filesystem.write("/workspace/executor.py", executor)
result = sbx.exec(
"python3 /workspace/executor.py",
env={"ANALYSIS_DSN": analysis_dsn}, # injected into the guest, never a prompt
timeout_seconds=45, # circuit breaker for runaway code
)
if result.exit_code != 0:
# Hand stderr back to the agent so it can rewrite the query itself.
raise RuntimeError(result.stderr)
rows = sbx.filesystem.read("/workspace/rows.json")
# VM is destroyed here: the DSN, temp files, and any open socket vanish with it.
return rowsThree things are load-bearing in that snippet. The `timeout_seconds` on `exec` is the circuit breaker against a generated infinite loop or a query the database is chewing on forever. The DSN arrives as a guest environment variable, not as text in a prompt the model could echo back into its own output. And the whole thing lives inside a context manager, so the VM — and the credential with it — is destroyed the instant the request finishes, success or failure.
The connection it holds: short-lived and least-privilege
The VM decides what the code can reach on the host; the connection string decides what it can do to your data. For an agent-generated query you want that connection to be three things at once: read-only (so no generated DDL/DML lands), time-bounded (so no query runs forever), and scoped to a single tenant's data (so a forgotten filter can't leak). The first two are Postgres role settings; the third is architecture.
PandaStack's managed databases make the single-tenant part a real boundary rather than a WHERE clause: each managed Postgres-16 is its own Firecracker microVM with its own durable volume, so a per-tenant analysis database physically contains one tenant's rows and nobody else's. You provision it once per tenant — a managed database create takes 30–90s because it blocks until Postgres is actually accepting connections — connect once as admin to mint a read-only role, then hand the agent's sandbox only that scoped role's DSN. The connection string pattern is `postgres://<role>:<pw>@<id>.db.pandastack.ai:5432/pandastack` over TLS.
from pandastack import Sandbox
# Bootstrap step, run ONCE per tenant (not per request). Uses a short-lived
# admin sandbox to create the read-only role that the executor will use. The
# admin DSN never leaves your control plane; the agent only ever sees ro_dsn.
def mint_readonly_role(admin_dsn: str, ro_password: str) -> None:
setup = f'''
import os, psycopg
admin = os.environ["ADMIN_DSN"]
stmts = [
"CREATE ROLE llm_agent LOGIN PASSWORD %(pw)s",
"ALTER ROLE llm_agent SET default_transaction_read_only = on", # no writes, ever
"ALTER ROLE llm_agent SET statement_timeout = '10s'", # kill runaway scans
"ALTER ROLE llm_agent SET idle_in_transaction_session_timeout = '15s'",
"GRANT USAGE ON SCHEMA analytics TO llm_agent",
"GRANT SELECT ON analytics.accounts, analytics.usage_daily TO llm_agent",
]
with psycopg.connect(admin, autocommit=True) as conn:
with conn.cursor() as cur:
for s in stmts:
cur.execute(s, {{"pw": os.environ["RO_PASSWORD"]}})
print("llm_agent role ready")
'''
with Sandbox.create(template="code-interpreter", ttl_seconds=60) as sbx:
sbx.filesystem.write("/workspace/setup.py", setup)
r = sbx.exec(
"python3 /workspace/setup.py",
env={"ADMIN_DSN": admin_dsn, "RO_PASSWORD": ro_password},
timeout_seconds=30,
)
if r.exit_code != 0:
raise RuntimeError(r.stderr)
sbx.kill() # explicit teardown even though the context manager also disposes itNow the agent's per-request executor connects as `llm_agent`, and the whole class of destructive accidents fails the same boring way: a hallucinated `DELETE FROM users` returns a permission error the agent reads and retries, the runaway scan gets cancelled at ten seconds, and the query only ever sees the two tables you granted. If you want per-run isolation on top of per-tenant — a private copy of the data per request with zero state bleed — fork the seeded database VM instead of provisioning fresh: a same-host fork lands in 400–750ms (cross-host 1.2–3.5s) and shares data copy-on-write, so each run gets a disposable database without paying the 30–90s create.
Why not just run it in-process?
The tempting shortcut is to skip the VM: run the generated SQL through your app's existing pool, run the pandas step in a thread, add a keyword blocklist, and call it a day. Here's why each convenient shortcut leaks, next to what the sandbox-plus-scoped-connection version does instead.
- Where the post-processing runs — In-process: the agent's pandas/matplotlib code executes in your API process, one prompt-injection away from reading your env or opening a socket. Sandbox: it runs in a disposable microVM with its own kernel; the blast radius is one VM you throw away.
- The database credential — In-process: usually the app's own pooled connection, often read-write, often the whole schema — a hallucinated DELETE just runs. Sandbox: a short-lived read-only role scoped to named tables, so writes fail and runaway scans get cancelled.
- Cross-tenant data — In-process: a shared warehouse where the model's WHERE clause is the only wall. Sandbox: the connection points at one tenant's own managed-Postgres microVM, so a forgotten filter leaks nothing.
- Runaway query — In-process: a full scan pins the shared primary and pages on-call. Sandbox: statement_timeout cancels it on an isolated instance whose ceiling is the VM boundary.
- Cleanup — In-process: leaked temp files, lingering connections, half-written state on your box. Sandbox: destroy the VM and every trace, including the credential, is gone.
- Cost of a fresh run — In-process: ~0, paid for in blast radius. Sandbox: ~49ms restore for the executor, and per-tenant DBs provisioned once — cheap enough to do per request.
General-purpose sandbox APIs and shared-kernel container runners get you the first column; a keyword blocklist gets you a false sense of the second. The point of the microVM is that it's a real guest kernel isolated by hardware virtualization — the same VMM class that isolates untrusted tenants at the big clouds — not a namespace trick one CVE away from your host. See /blog/firecracker-vs-docker for the boundary comparison.
Putting it together
The full loop for a production analytics agent: (1) the agent generates SQL plus whatever post-processing it wants; (2) you create a fresh microVM per request and write that code in; (3) the code connects with a short-lived, read-only role to a database that physically holds only this tenant's data — a per-tenant managed Postgres, or a per-run fork of a seeded one; (4) a statement timeout and read-only role cap what any single query can do, and an exec timeout caps the code; (5) you read the result set back through the filesystem, hand any error straight to the agent so it self-corrects, and destroy the VM — and the credential — when the request ends.
The honest version of an agent that writes and runs its own SQL is one where the worst thing it can do is return an error it then retries — because the VM is disposable, the connection is read-only, and the database it reaches holds one tenant's rows and no one else's.
None of the walls is optional and none is exotic: a disposable executor, a least-privilege connection, and a single-tenant database boundary, composed. Build them and the confident `DELETE FROM users`, the ten-way cartesian product, the injected UNION, and the forgotten tenant filter all fail the same forgettable way. Start from the code-interpreter template for the executor and /docs/concepts/databases for the per-tenant Postgres.
Frequently asked questions
How do I safely run SQL that an AI agent generated and wants to execute itself?
Treat every request as disposable. Run the agent's generated SQL — and any post-processing code it writes around it — inside a fresh Firecracker microVM per request, and give that VM only a short-lived, read-only connection to the single tenant's database this request is allowed to touch. The VM isolates the code from your host, secrets, and other tenants; the scoped read-only role stops the query from mutating data, running forever, or reading rows it shouldn't. When the request finishes, destroy the VM and the credential vanishes with it. On PandaStack a create restores a baked snapshot (~49ms restore step, p50 179ms end to end), so a per-request VM is practical rather than a multi-second tax.
Why run the pandas post-processing in the same sandbox as the query?
Because the post-processing is also code the agent generated and you didn't review — a full Python program that can read environment variables, open outbound sockets, shell out, or allocate memory until the host swaps. If you run the query safely but then hand its result to a pandas transform in your own process, you've reopened exactly the hole the sandbox closed. Running both the query and the transform inside the same disposable microVM keeps the entire generated program behind one hardware boundary, and destroying the VM cleans up both at once.
How does a short-lived read-only connection stop a hallucinated DROP or DELETE?
You mint a dedicated Postgres role with default_transaction_read_only = on and GRANT it SELECT only on the specific tables the agent should see — then a DROP, DELETE, or UPDATE fails with a permission error no matter what the model generated, and the agent reads that error and retries. Pair it with statement_timeout (e.g. 10s) so a runaway scan is cancelled instead of pinning a core, and inject that role's DSN into the sandbox as an environment variable rather than putting it in a prompt. The agent's executor never holds a credential that can write, and it never holds it longer than the request.
How does per-tenant isolation prevent the agent leaking another customer's data?
A tenant_id filter written by a probabilistic model isn't a security control — it can be forgotten, mis-joined, or defeated by prompt injection. The stronger design is to connect the agent's query to a database that physically contains only one tenant's rows, so a query that forgets its filter returns that tenant's data and nobody else's because there's no one else in the database. On PandaStack each managed Postgres-16 is its own Firecracker microVM with its own durable volume, so a per-tenant analysis database is a hardware-enforced boundary. For per-request isolation on top, you can fork a seeded database VM (400–750ms same-host, 1.2–3.5s cross-host) so each run gets a private, disposable copy.
Isn't a microVM plus a managed database per request too slow for an interactive agent?
The microVM isn't — every create restores a baked snapshot rather than cold-booting, so the restore step is ~49ms and end-to-end p50 is 179ms (p99 ~203ms), which makes a fresh executor per agent request practical. The managed database is the slower part: a full create takes 30–90s because it blocks until Postgres is accepting connections, so you provision a per-tenant database once, not per request. When you want a disposable per-run copy of the data, you fork a seeded database snapshot instead — a same-host fork is 400–750ms and shares data copy-on-write — rather than paying the full create each time.
49ms p50 cold start. Fork, snapshot, and scale to zero.