DEV Community

Yusuke Hayashi
Yusuke Hayashi

Posted on

A SELECT-only prompt is not a sandbox: bounding agent-generated SQL

Suppose an AI agent has one job: read package.json and return the package name and version.

You can put “use only SELECT” in the prompt. But if the SQL engine can still open another file, load an extension, run forever, or overwrite an output path, the prompt has not created a security boundary. It has only described one.

I ran into this distinction while building sqrail, a small DuckDB-based executor for analytical SQL over explicitly named files. The useful lesson was not about generating better SQL. It was about turning the agent's request into a process contract that remains true when the request is wrong.

Five promises a prompt cannot enforce

Prompt-level intention Executor-level boundary
“Read only this dataset” Canonical file allowlist plus disabled external access
“Only run a query” One parser-validated SELECT statement
“Do not use too much” Deadlines and row, byte, file, thread, memory, and spill limits
“Do not overwrite anything” No-replace output commit
“Tell me what failed” Stable JSON diagnostics and exit classes

The second column is where enforcement starts.

Bind data instead of giving SQL a filesystem

The agent refers to a logical table name, while the host supplies the path:

sqrail run -t pkg=package.json --timeout 5s "SELECT name, version FROM pkg"
Enter fullscreen mode Exit fullscreen mode

On sqrail 0.3.4, that produced:

{"name":"sqrail-site","version":"0.3.4"}
Enter fullscreen mode Exit fullscreen mode

The executor canonicalizes every bound path. Globs and partitioned Parquet directories are expanded, sorted, deduplicated, and required to be non-empty. After binding, only those exact files are allowlisted and DuckDB external access is disabled.

I then tried to bypass the binding and read the same file directly:

SELECT * FROM read_json_auto('package.json')
Enter fullscreen mode Exit fullscreen mode

The v0.3.4 Windows release rejected it with exit 4 and a QUERY_FAILED diagnostic whose message began Permission Error: Cannot access file.

That generic error code is worth noting: the important guarantee is the denied access, not a specially branded “sandbox” message.

Parse the statement; do not pattern-match text

Checking whether a string begins with SELECT is not sufficient. Comments, multiple statements, and other syntax make text-prefix rules brittle.

The v0.3 contract accepts exactly one parsed statement whose type is SELECT. That includes VALUES and queries beginning with WITH. It rejects DDL, DML, COPY, ATTACH, INSTALL, LOAD, PRAGMA statements, and multiple statements. Extension autoloading, automatic installation, and community extensions are disabled before configuration is locked.

This probe:

SELECT 1; SELECT 2
Enter fullscreen mode Exit fullscreen mode

returned exit 4 with MULTIPLE_STATEMENTS.

This is still not a general sandbox for a hostile SQL engine. It is a deliberately narrower contract: one read-only analytical statement over files the caller explicitly bound.

Give the agent a preflight lane

An agent often does not know the schema before writing its query. Executing a guess just to discover column names wastes budget and mixes planning with side effects.

sqrail separates the flow into three commands:

schema  -> inspect names and types
check   -> bind and plan without running the query
run     -> execute once
Enter fullscreen mode Exit fullscreen mode

For the package query, check reported two VARCHAR result columns, one resolved input file, and a READ_JSON_AUTO physical plan. An orchestrator can inspect that JSON before spending the execution budget.

Bound the whole task, not only the query

A memory flag alone does not bound an agent task. Discovery, schema inference, planning, execution, spilling, result materialization, and output finalization all consume resources.

The v0.3 executor can limit threads, deadline, result rows, output bytes, input-file count, SQL bytes, memory, and temporary spill. Its timeout begins when command handling starts rather than when DuckDB finally begins executing.

I reran two failure probes against the Windows x86-64 release:

Probe Exit Diagnostic code
Three rows with --max-rows 2 4 RESULT_LIMIT
Large aggregation with --timeout 1ms 4 QUERY_TIMEOUT

The DuckDB memory setting is not a hard operating-system RSS limit. If the threat model requires hard containment, the host still needs a process, container, Windows job object, or equivalent OS boundary.

Treat file output as a commit

Streaming JSONL to stdout is useful, but it cannot be rolled back. A failure after several rows may leave the consumer with a valid partial prefix.

File output needs different semantics. With -o, sqrail writes a private same-directory temporary file and exposes it only after the result completes. POSIX uses a no-replace hard-link commit; Windows uses a no-replace, write-through move.

When I pointed v0.3.4 at an existing .jsonl destination, it returned exit 5 with OUTPUT_EXISTS; the destination was not replaced.

That distinction belongs in the tool contract:

  • use stdout when partial streaming is acceptable;
  • use a file destination when the consumer needs all-or-nothing output.

What I would require from any agent data tool

Before giving an agent a local SQL executor, I would ask:

  1. Are all readable files explicitly bound and canonicalized?
  2. Is the statement type checked by the parser?
  3. Are extension loading and other escape hatches disabled?
  4. Do discovery, planning, execution, spill, and output share bounded resources?
  5. Can failed output become visible or overwrite an existing file?
  6. Are failures stable enough for software to handle without scraping prose?
  7. Which guarantees still require OS-level isolation?

A prompt can tell a model to be careful. An execution contract determines what happens when it is not.

If you expose a data tool to an agent, which boundary has failed first in your experience: input access, resource use, or output commit?

The complete v0.3 process contract is public in CONTRACT.md, with the measurement rules in BENCHMARKS.md.


Disclosure: I used an AI assistant to organize the public specification and edit this article. The commands and failure cases above were rerun on August 7, 2026 against the published sqrail 0.3.4 Windows x86-64 archive after its SHA-256 digest was matched to the release checksum.

Top comments (0)