Teams ship two controls in front of a query-generating agent, and neither one is the boundary. PostgreSQL's own documentation lists what a read-only transaction disallows, and COPY FROM is on that list while COPY TO is not: reproduced on PostgreSQL 18.6, COPY (SELECT 1) TO PROGRAM ran to completion with transaction_read_only reporting on, and the spawned command executed as uid=70(postgres). SELECT pg_read_file('/etc/passwd') is a SELECT, so it clears the transaction wrapper and a SELECT-only statement allowlist at the same time. That allowlist layer collected six advisories in 61 days against one framework, one of them landing a single day after the layer first shipped. CVE-2026-12045 shows why prompt hardening is the wrong answer: the delivery is a poisoned row, and the tool ran as the login a human had already registered. The one control that held against every payload was a plain LOGIN role with CONNECT, USAGE and SELECT and no predefined-role memberships, which still refused a write after the role turned its own read-only default off. Run the pg_roles and pg_has_role audit query against the connection string your agent actually uses and read the three boolean columns.
Ask what permissions a text-to-SQL agent should have and the answer is a wrapper: BEGIN TRANSACTION READ ONLY around the query, or a parser that only passes SELECT, with the connection string still pointing at an existing analytics login.
Reproduced on PostgreSQL 18.6: COPY (SELECT 1) TO PROGRAM 'id > /tmp/pwned.txt 2>&1' ran to completion inside a transaction reporting transaction_read_only as on, and the file it wrote on the database host held uid=70(postgres). No multi-statement trick was needed, and SELECT pg_read_file('/etc/passwd') is a SELECT, so it clears both controls at once.
The control that refused every payload is the one nobody configures: the role the connection authenticates as. Whether the generated SQL is correct is a separate problem.
The two controls teams ship, and what each one actually stops
PostgreSQL documents the first control exactly. From the SET TRANSACTION page: "When a transaction is read-only, the following SQL commands are disallowed: INSERT, UPDATE, DELETE, MERGE, and COPY FROM if the table they would write to is not a temporary table; all CREATE, ALTER, and DROP commands; COMMENT, GRANT, REVOKE, TRUNCATE ... This is a high-level notion of read-only that does not prevent all writes to disk."
COPY FROM is on that list. COPY TO is not. That omission is the whole post.
The second control is a statement allowlist. A parser reports shape, not what a function inside a permitted SELECT does, and it calls a data-modifying CTE by its outermost node, a Select. The third control, the one nobody ships, is the role.
Three CVEs anchor it: CVE-2026-12045 (pgAdmin 4, 9.13 before 9.16), CVE-2026-25879 (Langroid before 0.63.0) and CVE-2026-55615 (Langroid before 0.65.5). CVE-2026-12045's delivery clause is what makes the role the answer: "an attacker who can write content into any object the AI Assistant may inspect (a row, a column value, a comment) can cause the LLM to emit the multi-statement payload as a tool call." A poisoned row, not a hostile user, and prompt injection still beats defended models too often to be a boundary.
| Control | Stops | Does not stop | Basis |
|---|---|---|---|
BEGIN TRANSACTION READ ONLY | INSERT, UPDATE, DELETE, MERGE, COPY FROM to non-temporary tables; CREATE, ALTER, DROP; COMMENT, GRANT, REVOKE, TRUNCATE | COPY ... TO PROGRAM, SELECT pg_read_file(...), any payload that COMMITs first | SET TRANSACTION docs, copy.c |
| SELECT-only allowlist | Statements whose top-level type it classifies correctly | Functions inside a permitted SELECT, data-modifying CTEs, SELECT ... INTO, quoted or comment-separated names, uncovered dialects | Three bypasses, one uncovered dialect |
| The role and its grants | Every primitive needing a privilege the role lacks, even after the agent turns its read-only default off | Reading the data the role may read | 18.6: every escalation refused |
BEGIN TRANSACTION READ ONLY does not stop code execution
CVE-2026-12045 describes a specific escape. The tool ran the model's SQL inside a read-only transaction but forwarded it to the driver "without restriction to a single statement or to read-only verbs, so a multi-statement payload beginning with COMMIT, END, ROLLBACK, or ABORT terminated the read-only transaction and ran subsequent statements in autocommit mode. The trailing ROLLBACK then had no effect." Reproducing the first payload on 18.6 printed COPY 1, and the trailing ROLLBACK warned there was no transaction in progress. Four multi-statement scenarios from the 327-line test file the fix added:
COMMIT; COPY (SELECT 1) TO PROGRAM 'id > /tmp/id.txt 2>&1'; SELECT 1 END; DELETE FROM t; SELECT 1 ABORT; UPDATE t SET c = 1; SELECT 1 SELECT 1; /* hide */ DROP TABLE t
The escape is also optional:
BEGIN TRANSACTION READ ONLY;
SELECT current_setting('transaction_read_only'); -- on
COPY (SELECT 1) TO PROGRAM 'id > /tmp/pwned.txt 2>&1'; -- COPY 1
ROLLBACK;In src/backend/commands/copy.c on the 18 branch the read-only guard sits only in the is_from branch: if (XactReadOnly && !rel->rd_islocaltemp) PreventCommandIfReadOnly("COPY FROM");. The COPY TO branch has no such check, only the privilege gate on any COPY naming a program: "permission denied to COPY to or from an external program". The transaction mode was never in the path. The role was.
The tool had no credential of its own: _get_connection resolves the connection manager for the server id the human is browsing and takes no credential argument, so the model's SQL ran with "the privileges of the pgAdmin user's database role". And web/pgadmin/llm/tools/database.py first appears at tag REL-9_13, so the tool was vulnerable from the release that introduced it: 9.13 on 2026-03-05 to the fix in 9.16 on 2026-06-18, 105 days.
From a generated query to the operating system, per engine
Every engine has a statement that reaches outside the database, and on every one the gate is a privilege, not a shape.
PostgreSQL's gate is plain: COPY "naming a file or command is only allowed to database superusers or users who are granted one of the roles pg_read_server_files, pg_write_server_files, or pg_execute_server_program". Granting EXECUTE on the file-access functions is worse than it looks: they "bypass all in-database privilege checks".
MySQL is where the second gate matters. On MySQL 8.4.11 from the official image, @@secure_file_priv is /var/lib/mysql-files/ and @@local_infile is 0. With FILE granted, LOAD_FILE('/etc/hostname') still returned NULL because that path sits outside the directory, a write to /tmp returned ERROR 1290, and repeating a permitted write returned ERROR 1086, since the target "cannot be an existing file". FILE alone is not arbitrary file access; FILE plus an empty secure_file_priv is. Audit with SHOW GRANTS, close it with REVOKE FILE ON *.*.
On SQL Server, "by default, the xp_cmdshell option is disabled on new installations", it "requires CONTROL SERVER permission to execute" once enabled, and the spawned process "has the same security rights as the SQL Server service account". Read the state, then close it:
SELECT name, value, value_in_use FROM sys.configurations WHERE name = 'xp_cmdshell'; EXECUTE sp_configure 'show advanced options', 1; RECONFIGURE; EXECUTE sp_configure 'xp_cmdshell', 0; RECONFIGURE;
Neo4j's model is role-based: PUBLIC executes procedures with the user's own privileges, reader adds traverse and read, admin alone executes boosted and admin procedures. dbms.security.procedures.allowlist and dbms.security.procedures.unrestricted decide what loads at all, and per-role control is GRANT EXECUTE PROCEDURE <glob> ON DBMS TO <role>.
| Engine | Primitive | Gated by | Default state |
|---|---|---|---|
| PostgreSQL | COPY ... TO/FROM PROGRAM | Superuser or membership in pg_execute_server_program | Not granted |
| PostgreSQL | pg_read_file, pg_read_binary_file, pg_stat_file, pg_ls_dir | Superuser, or EXECUTE on the function; pg_read_server_files for paths outside the cluster and log directories | Superusers only |
| MySQL | LOAD DATA, SELECT ... INTO OUTFILE, LOAD_FILE() | The global FILE privilege, then secure_file_priv | FILE not granted; mysql:8.4 ships secure_file_priv=/var/lib/mysql-files/ |
| SQL Server | xp_cmdshell | CONTROL SERVER, or GRANT EXEC plus the ##xp_cmdshell_proxy_account## credential | Disabled on new installations |
| Neo4j | APOC import and export, dbms.*, LOAD CSV | apoc.import.file.enabled, apoc.export.file.enabled, EXECUTE PROCEDURE grants | Both APOC file settings false |
The role is the boundary: what to grant and what to revoke
The risk is rarely a badly built role: CREATE ROLE defaults to NOSUPERUSER, NOCREATEDB, NOCREATEROLE and NOBYPASSRLS. The risk is reusing an admin login, as pgAdmin did.
CREATE ROLE agent_ro LOGIN PASSWORD :'pw' NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS CONNECTION LIMIT 5; GRANT CONNECT ON DATABASE analytics TO agent_ro; GRANT USAGE ON SCHEMA reporting TO agent_ro; GRANT SELECT ON ALL TABLES IN SCHEMA reporting TO agent_ro; ALTER DEFAULT PRIVILEGES IN SCHEMA reporting GRANT SELECT ON TABLES TO agent_ro; REVOKE EXECUTE ON ALL FUNCTIONS IN SCHEMA reporting FROM PUBLIC; REVOKE CREATE ON SCHEMA public FROM PUBLIC; -- upgraded-from-14 databases only ALTER ROLE agent_ro SET statement_timeout = '15s'; ALTER ROLE agent_ro SET idle_in_transaction_session_timeout = '30s'; ALTER ROLE agent_ro SET default_transaction_read_only = on;
Three statements there are the ones teams skip. ALTER DEFAULT PRIVILEGES is the clause that reaches objects created later; GRANT ... ON ALL TABLES IN SCHEMA covers existing objects only. REVOKE EXECUTE ON ALL FUNCTIONS matters because EXECUTE on functions and procedures is one of the few privileges PostgreSQL grants to PUBLIC by default. The public schema revoke applies to databases upgraded from PostgreSQL 14 or earlier.
The three ALTER ROLE ... SET lines apply at login, which is why they survive a RESET: on 18.6, RESET statement_timeout returned the role's 15s, not the server's 0. None is a boundary. The role turned default_transaction_read_only off with a plain SET, and the grants held anyway.
analytics=> SELECT count(*) FROM reporting.orders; -- 1
analytics=> COPY (SELECT 1) TO PROGRAM 'id';
ERROR: permission denied to COPY to or from an external program
analytics=> SELECT pg_read_file('/etc/passwd');
ERROR: permission denied for function pg_read_file
analytics=> SET default_transaction_read_only = off;
SET
analytics=> WITH x AS (DELETE FROM reporting.orders RETURNING *) SELECT * FROM x;
ERROR: permission denied for table ordersThe last two statements are the point. SELECT * INTO reporting.copy1 FROM reporting.orders failed the same way, on permission denied for schema reporting. Both shapes got past a shipped SELECT-only parser, and are stopped here without parsing anything. pg_read_all_data is the tempting shortcut, and it "does not bypass row-level security (RLS) policies", so tenant scope stays bound below the call site with row level security.
Then audit what you actually have:
SELECT r.rolname,
r.rolsuper,
r.rolbypassrls,
pg_has_role(r.rolname, 'pg_execute_server_program', 'MEMBER') AS can_exec_program,
pg_has_role(r.rolname, 'pg_read_server_files', 'MEMBER') AS can_read_files,
pg_has_role(r.rolname, 'pg_write_server_files', 'MEMBER') AS can_write_files
FROM pg_roles r
WHERE r.rolcanlogin
ORDER BY 1;Any true in the last three columns, or rolsuper true, is the finding. Granting pg_execute_server_program to that same non-superuser role made COPY (SELECT 1) TO PROGRAM succeed inside a read-only transaction; revoking it closed the path.
What changes when the database is on your own hardware
Private weights change nothing here. pgAdmin lists Ollama and Docker Model Runner among its four providers and documents that local providers "do not require internet access", so this chain runs end to end inside a private perimeter and every primitive above still works. What on-premise changes is the escalation: a managed warehouse never grants superuser or pg_execute_server_program, while on your own cluster the server runs as a real operating system user and that membership is one GRANT away. The second consequence only bites on-premise: you cannot keep pace with the parser layer. One framework shipped six advisories against its query-validation layer in 61 days, one a single day after the layer first shipped. Where upgrades move through a change window you are permanently behind, and a validated GxP or DORA-scoped system does not bump a package the afternoon a bypass lands. The role needs no release. The compensation is that you can evidence the negative. Owning the instance means producing four artefacts: the role definition, the grant list, the pg_roles and pg_has_role output showing the login holds none of the three server-file memberships, and a log line carrying %u and %a. None is producible against a service you do not run.
Parse before you execute, and expect the parser to be wrong
The parser is worth building, just not as the boundary. The pgAdmin specification is specific enough to copy: the query must parse to exactly one non-empty, non-comment statement whose leading real token is one of six, SELECT, WITH, EXPLAIN, SHOW, VALUES or TABLE. The token walk skips whitespace, comment and punctuation first, so a leading parenthesis or block comment cannot mask the verb. The library is sqlparse, not sqlglot, and the regression suite is 60 scenarios, 41 rejects and 19 accepts.
Langroid's SQLChatAgent takes the other shape: a sqlglot statement-type allowlist plus a dialect-aware regex blocklist. Both security values below are shipped defaults, so the block is about pinning the version. There is no read_only parameter:
# requirements.txt: langroid>=0.65.14
from langroid.agent.special.sql.sql_chat_agent import SQLChatAgentConfig
# this validation layer was bypassed three times
# between 0.63.0 and 0.65.14
config = SQLChatAgentConfig(
database_uri="postgresql+psycopg://agent_ro@warehouse/analytics",
allowed_statement_types=["SELECT"], # default
allow_dangerous_operations=False, # default
max_result_rows=500,
)Six advisories in 61 days decompose cleanly: one for having no validator, three bypasses of the layer it introduced, and two agents the gate never covered. Enumerating dangerous functions by name misses a family, and CVE-2026-50180's payloads worked "even with the agent's strict default configuration". Requiring an open paren right after the name is defeated by the engine itself: on 18.6, pg_read_file('/etc/hostname'), "pg_read_file"(...), pg_read_file/**/(...) and pg_catalog."pg_read_file"(...) all returned identical content. And classifying on the top-level node calls a DELETE ... RETURNING CTE a SELECT.
Parameterized queries are a different control for a different threat, the one parameterized queries at the checkpoint layer fixed elsewhere; here the model writes the whole statement, so there is nothing to bind. The maintainers' own verdict, from the validator docstring: "A blocklist is inherently bypassable; the real guarantee is the default-off gate plus running the agent against a least-privilege Neo4j role."
| Published | Advisory | What got through | Fixed in |
|---|---|---|---|
| 2026-05-27 | GHSA-mxfr-6hcw-j9rq / CVE-2026-25879 (critical) | Nothing validated the model's SQL | 0.63.0 |
| 2026-05-28 | GHSA-pmch-g965-grmr / CVE-2026-50180 (high) | The blocklist missed the pg_read_file / pg_stat_file / pg_ls_* family | 0.64.0, one day later |
| 2026-06-09 | GHSA-6xc5-4r68-67fc / CVE-2026-54760 (high) | A quoted identifier, an inline comment or a schema qualifier defeated the regex | 0.65.1 |
| 2026-06-15 | GHSA-2pq5-3q89-j7cc / CVE-2026-55615 (high) | The SQL fix never extended to the graph agents | 0.65.5 |
| 2026-07-25 | GHSA-83w4-crcp-3w4p (critical) | CSVGraphAgent.pandas_to_kg, a fourth agent, never called the gate | 0.65.11 |
| 2026-07-26 | GHSA-3gpx-vwr3-xvwx (high) | A data-modifying CTE and SELECT ... INTO parse with a Select top node | 0.65.14 |
Every dialect and every agent is a separate piece of work
CVE-2026-55615 states it in one clause: the SQL fix in 0.63.0 "did not extend to the neo4j module." Nineteen days of release time separate that fix on 2026-05-27 from the graph fix in 0.65.5 on 2026-06-15, and in between Neo4jChatAgent passed Cypher straight to the driver.
The remedy was two new validator modules, 169 lines for Cypher and 161 for AQL, plus 22 lines wired into each agent and 76 static unit tests. The advisory names only Neo4j; the commit also covers ArangoDB. The Cypher validator blocks four dangerous patterns on both paths (LOAD CSV, apoc., dbms., CALL db.) and seven write patterns on the read path (CREATE, MERGE, SET, DELETE, REMOVE, DROP, FOREACH). Even then CSVGraphAgent.pandas_to_kg ran Cypher ungated until 0.65.11 on 2026-07-25.
Count your drivers, not your frameworks: every dialect the agent reaches needs its own validator, tests and role. Whether the model should write raw Cypher at all is settled on accuracy grounds, in favour of template-based Cypher.
The four-layer decision: role, grants, parser, timeout
Configure them in that order. Layer 1 held against every payload tested here without knowing any SQL syntax. Layer 2 catches the writes the parser misclassifies. Layer 3 is a rate limiter whose floor moves; treat a rejection as a security event, not a retry. Layer 4 bounds cost, not authority: pgAdmin caps rows at 1000 and sets no statement_timeout.
Egress control sits outside the stack: the outbound channel the agent already has is its own answer text. The rule: if a layer can be bypassed by rewriting the query, it is not a boundary.
| Layer | What to set | Fails open when | Verified by |
|---|---|---|---|
| 1. Role identity | A dedicated LOGIN role, NOSUPERUSER, no membership in the three server-file predefined roles | You point the agent at a login a human registered | pg_roles plus pg_has_role |
| 2. Grants | CONNECT, USAGE on one schema, SELECT on its tables, ALTER DEFAULT PRIVILEGES, REVOKE EXECUTE ON ALL FUNCTIONS ... FROM PUBLIC | You grant pg_read_all_data instead of SELECT on one schema | \dp in psql, or has_table_privilege |
| 3. Parser | Single statement, leading token in a six-verb allowlist, framework pinned at the current floor | A new dialect, a new agent class, or an unanticipated call form | The framework's regression suite |
| 4. Session limits | ALTER ROLE ... SET statement_timeout, idle_in_transaction_session_timeout, CONNECTION LIMIT, plus the tool's row cap | You set them in postgresql.conf, for everyone | SHOW after login as the role |
What to log, and why log_statement = 'mod' records none of it
Two PostgreSQL defaults guarantee an incident review finds nothing. log_statement defaults to none, and the middle value, mod, logs DDL plus INSERT, UPDATE, DELETE, TRUNCATE and COPY FROM. A text-to-SQL agent emits SELECTs, so mod records nothing it did. The second is log_line_prefix: %m [%p] is a timestamp and a process id.
# 'mod' does not log SELECT, and a text-to-SQL agent emits nothing else log_statement = 'all' log_line_prefix = '%m [%p] user=%u db=%d app=%a ' log_min_duration_statement = 0 # raise it if the volume hurts
%a works because the tools set it: pgAdmin tags every LLM connection with an application name of the form pgAdmin 4 - LLM - llm_<n>, also visible in pg_stat_activity. That plus %u ties a statement to a role and, through the session, to a named human.
Five fields belong in the application's own log: the role the statement authenticated as, the raw statement the model emitted, the validator verdict and which token tripped it, the count of statements the parser found, and whether the row cap truncated the result. The verdict earns its place: a rejected query is the earliest signal that database content is steering the model, and Langroid returns it as a message string rather than raising, so the agent retries unseen. Sampling and redaction belong to the generic invocation-logging envelope.
The thing to do this week is small. Take the connection string your query agent actually uses, log in as that role, run the pg_roles audit query above and read three boolean columns. If any is true, or rolsuper is true, you have a finding no parser work will fix, and the remedy is a REVOKE you can run today. The rest of the cluster is on the AI security pillar.
FAQ
Quick answers to the questions this post tends to raise.



