Skip to content

Where the read-only boundary lives on a PostgreSQL agent run

Containment is the database itself - a read-only transaction, exactly one statement on the extended protocol, then ROLLBACK and DISCARD ALL.

One way to give an LLM agent read-only database access is a regular expression over the statement text: look for INSERT, UPDATE, DELETE and DROP, let everything else through. It holds until the first statement written to look like something it is not. On PostgreSQL, LibreDB Studio has no such classifier on the agent path. queryReadOnly() sends the statement to the server and lets the server refuse it.

A parser is not a boundary

A SQL classifier has to be right about every statement an attacker can write, in a grammar it does not own, on a version it was not tested against. The server has to be right about one thing it already enforces for its own correctness. Those are not comparable positions.

The concrete failure is not exotic. PostgreSQL supports data-modifying CTEs, so WITH t AS (DELETE FROM audit RETURNING *) INSERT INTO audit_archive SELECT * FROM t leads with the keyword WITH and reads as a query to anything matching on the first word. LibreDB’s own statement classifier had to learn that lesson elsewhere: the editor’s automatic LIMIT injection types a WITH statement by the keyword that follows its CTE list - the statement the server actually executes - because before that the appended bound applied to the rows the statement wrote, committing at most the default page size of 500 of them while reporting the result as merely truncated. That classifier is good enough to decide where to append a LIMIT. It is not what the agent path trusts with the question of whether a statement may run.

So queryReadOnly() classifies nothing. The provider it lives on is acquired per (connection id, execution profile) from a cache that is physically separate from the editor’s, so an agent execution can never be handed the editor’s writable pool. Called on an ordinary provider, queryReadOnly() throws, because such a provider has had no role verification behind it.

What read-only database access for an LLM agent actually sends

Every statement on the agent path is wrapped in the same sequence:

BEGIN READ ONLY;
SET LOCAL statement_timeout = <budget.statementTimeoutMs>;
-- the single statement, sent on the extended query protocol
ROLLBACK;
DISCARD ALL;

BEGIN READ ONLY is what makes PostgreSQL answer SQLSTATE 25006 - cannot execute … in a read-only transaction - to a write. That error comes from the server, not from the tool.

SET LOCAL statement_timeout dies with the transaction, so no session state leaks into the next statement. The value is clamped to the run’s remaining wall clock before it is interpolated, so a statement cannot outlive the run that asked for it. It is interpolated rather than bound because SET LOCAL takes no bind parameters, which is why the budget fields are validated as positive integers before any pool client is acquired.

ROLLBACK is unconditional. The profile has no commit path at all.

DISCARD ALL runs after the rollback, because it cannot run inside a transaction block. A rollback is not a full session reset: an advisory lock taken inside the transaction survives it, verified on PostgreSQL 18, and nothing on the agent path is required to release one. Without DISCARD ALL a pooled client would carry that lock into every later execution. A client that fails either cleanup step is destroyed rather than returned to the pool.

Why exactly one statement is load-bearing

The statement is sent with queryMode: 'extended'. On the extended query protocol the server refuses a multi-command string in the Parse message with SQLSTATE 42601, before executing anything.

That is the difference between a boundary and a suggestion. On the simple query protocol, SELECT 1; COMMIT; INSERT INTO ... is one string containing three commands, and the COMMIT ends the read-only transaction that was supposed to be containing the third one. Nothing about BEGIN READ ONLY stops that. What stops it is that the string never parses.

The single-statement rule is load-bearing rather than decorative, and here is why. SET TRANSACTION READ WRITE is accepted inside BEGIN READ ONLY and does relax the transaction - verified on PostgreSQL 18, where a following INSERT committed. What contains it is the protocol-enforced single statement plus the unconditional ROLLBACK, not the read-only transaction alone. A session-level SET reverts with the rollback, since GUC changes are transactional. A bare COMMIT merely ends an empty read-only transaction.

What a read-only transaction does not stop

A read-only transaction forbids changing the database. It does not forbid a statement from reaching the server. Three statements, all verified as succeeding inside BEGIN READ ONLY on PostgreSQL 18 as a superuser:

Statement What it did
COPY (...) TO '<path>' wrote query results to an arbitrary server-side file
COPY (...) TO PROGRAM '<cmd>' ran a shell command as the server’s OS user
SELECT pg_read_file('<path>') read an arbitrary server-side file

None of the three writes to a table, so none of the three is a write as far as 25006 is concerned. The transaction is the wrong layer to be asking.

How this composes with the least-privilege role

A role holding only CONNECT, USAGE and SELECT is refused all three - by privileges, not by the transaction. So the profile does not offer that as advice. It probes the role when the connection opens and refuses with PROFILE_PRIVILEGES_TOO_BROAD unless superuser and membership of pg_read_server_files, pg_write_server_files and pg_execute_server_program all read back false. A server that answers nothing, or answers non-booleans, is refused too. Every catalog function in the probe is written pg_catalog-qualified, because a search_path that names pg_catalog explicitly behind another schema would let a shadow pg_has_role() answer false for a superuser. The probe runs once, at open: a profiled provider stays cached until the idle sweep, so a role granted new privileges afterwards keeps serving from the already-verified pool until that entry is evicted.

The recipe the probe expects:

CREATE ROLE libredb_agent LOGIN PASSWORD '<secret>';
GRANT CONNECT ON DATABASE <db> TO libredb_agent;
GRANT USAGE ON SCHEMA <schema> TO libredb_agent;
GRANT SELECT ON ALL TABLES IN SCHEMA <schema> TO libredb_agent;
-- Grant nothing else.

Two consequences worth stating plainly. A superuser connection cannot run agent AUTO mode at all, so pointing the agent at your existing admin credentials fails at open rather than at the first statement. And what the probe proves is non-membership and non-superuser, not the absence of the capability: a role directly granted EXECUTE on pg_read_file() answers false to all four flags and can still read server files. That is what “grant nothing else” is carrying.

The per-table SELECT grants are also what bound which rows a run can reach. The policy layer’s catalog and schema allowlist screens the target the agent declared; only the grants bound what a hostile statement could touch instead.

This is the AUTO path - the metered, tool-using run, which exists on PostgreSQL, SQLite and DuckDB only, because the read-only profile is database-native and exists only where a provider implements it. PLAN mode opens on every connection: it is toolless, executes nothing, and drafts a statement for a human to run. The agent mode entry in the feature list states that boundary, and the security page publishes the known limitations next to the controls, which is where the SET TRANSACTION READ WRITE result belongs as much as it belongs here.