Is read-only database access safe for an AI agent?
Published August 31, 2026
Read-only database access is not safe on its own for an AI agent. It stops destruction, not disclosure: an agent that can read the whole database can summarise it and carry it away inside an answer. The useful question is not whether it can write, but what it can see, and where what it saw ends up.
What read-only actually stops
The guardrail is real and it holds. We built a throwaway database on PostgreSQL 16.15 to measure it: a clients table holding fifty thousand invented rows, a jetons_api table nobody is allowed to read, and an agent role granted three things and nothing else.
CREATE ROLE agent LOGIN PASSWORD 'demo';
GRANT CONNECT ON DATABASE boutique TO agent;
GRANT USAGE ON SCHEMA public TO agent;
GRANT SELECT ON clients TO agent;
That is the role you hand an agent once you have decided to be careful. Here is what it gets when it tries to break something.
$ psql -U agent -d boutique -c "DELETE FROM clients WHERE id = 1"
ERROR: permission denied for table clients
$ psql -U agent -d boutique -c "UPDATE clients SET courriel = 'x' WHERE id = 1"
ERROR: permission denied for table clients
$ psql -U agent -d boutique -c "DROP TABLE clients"
ERROR: must be owner of table clients
$ psql -U agent -d boutique -c "CREATE TABLE ailleurs (x int)"
ERROR: permission denied for schema public
LINE 1: CREATE TABLE ailleurs (x int)
^Four destructive statements, four refusals.
The accident an agent is most likely to cause, a DELETE with no WHERE clause or a migration replayed twice, is closed off. Read-only is not theatre. It simply answers a different question from the one people ask of it.
What it leaves wide open
SELECT decides what can be read. It says nothing about what can leave, and it does not hide the map. Our agent role has no privilege at all on jetons_api, yet it knows the table exists and what its columns are called.
$ psql -U agent -d boutique -c "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' ORDER BY 1"
table_name
------------
clients
(1 row)
$ psql -U agent -d boutique -c "SELECT relname FROM pg_class WHERE relnamespace = 'public'::regnamespace AND relkind = 'r' ORDER BY 1"
relname
------------
clients
jetons_api
(2 rows)
$ psql -U agent -d boutique -c "SELECT attname, format_type(atttypid, atttypmod) AS type FROM pg_attribute WHERE attrelid = 'jetons_api'::regclass AND attnum > 0"
attname | type
---------+------
service | text
secret | text
(2 rows)
$ psql -U agent -d boutique -c "SELECT * FROM jetons_api"
ERROR: permission denied for table jetons_apiThe first two commands disagree on purpose. The PostgreSQL 16 documentation for [information_schema.tables](https://www.postgresql.org/docs/16/infoschema-tables.html), read on 31 August 2026, states it plainly: only the tables the current user has access to are shown. The pg_catalog tables filter nothing. So the agent will not read what is inside jetons_api, but it walks away with the table's name, the name of its secret column, and everything it needs to make a very specific request to the first human who answers it.
| The action | Under a read-only role | What it means for an agent |
|---|---|---|
| Delete, update, create | refused by the server | the destructive accident is off the table |
| Read an entire table | allowed, with no ceiling | fifty thousand rows in one result set |
| Read the catalogue | allowed | a map of the forbidden tables, column names included |
| Keep the server busy | allowed | one wide join occupies the database |
| Take away what was read | outside the server's reach | data leaves through the answer, not the database |
The shortest path out of the database
Nothing about it looks like an attack. It is the ordinary use of a read tool, followed all the way to the end. What triggers the read has already been covered here: an MCP server wired to production discovers its tools at runtime, and the text it exposes steers what the model decides to go and fetch.
A single statement puts the whole table into one text value, which the model then receives like any other part of its context.
$ psql -U agent -d boutique -c "SELECT count(*) FROM clients"
count
-------
50000
(1 row)
$ psql -U agent -d boutique -c '\timing on' \
-c "SELECT length(string_agg(nom || ';' || courriel || ';' || telephone, E'\n')) AS octets FROM clients"
Timing is on.
octets
---------
2827787
(1 row)
Time: 20.318 ms2,827,787 bytes of personal contact data, returned in 20 milliseconds.
We ran the same measurement five times: the server answered between 18.1 and 21.4 milliseconds every time. Cost is therefore not a brake. On a database of any reasonable size, nothing distinguishes that query from a weekly report.
What happens next is no longer a database problem. The result enters the model's context, and from there it leaves through everything that touches that context: the answer shown to the user, the harness logs, the inference provider. OWASP files this under LLM02:2025, Sensitive Information Disclosure, read on 31 August 2026, and its page adds the caveat that matters here. Restrictions written into the system prompt about what the model may return "may not always be honored and could be bypassed via prompt injection or other methods". Asking the model to be discreet is not a boundary.
The limits that actually hold
The useful boundary does not sit between reading and writing. It sits on three questions: which columns, which rows, for how long. PostgreSQL answers all three, and the whole tightening fits in six statements.
REVOKE SELECT ON clients FROM agent;
GRANT SELECT (nom, courriel) ON clients TO agent;
ALTER TABLE clients ENABLE ROW LEVEL SECURITY;
CREATE POLICY agent_recents ON clients FOR SELECT TO agent USING (id <= 3);
ALTER ROLE agent SET statement_timeout = '2s';
ALTER ROLE agent SET default_transaction_read_only = on;
Same agent, same password, same database.
$ psql -U agent -d boutique -c "SELECT * FROM clients"
ERROR: permission denied for table clients
$ psql -U agent -d boutique -c "SELECT telephone FROM clients LIMIT 1"
ERROR: permission denied for table clients
$ psql -U agent -d boutique -c "SELECT nom, courriel FROM clients"
nom | courriel
----------+-------------------------
Client 1 | [email protected]
Client 2 | [email protected]
Client 3 | [email protected]
(3 rows)
$ psql -U agent -d boutique -c "SELECT pg_sleep(5)"
ERROR: canceling statement due to statement timeoutFifty thousand readable rows before, three after, and two columns out of four.
Each statement closes something specific, and the [GRANT reference](https://www.postgresql.org/docs/16/sql-grant.html) and the row security policy chapter, both read on 31 August 2026, describe exactly how far each one reaches.
GRANT SELECT (columns): a column outside the list does not exist for the agent, and that holds for columns added later. We added anibancolumn after the grant, and it stayed refused.ENABLE ROW LEVEL SECURITYand its policy: rows outside the perimeter never come back, not even underSELECT *.statement_timeoutset on the role: a wide query is cut at two seconds with nobody watching.default_transaction_read_only: the belt for the braces. We grantedINSERTdeliberately to test it, and the session still answeredcannot execute INSERT in a read-only transaction.
Those four limits live in the database and cost nothing. A fifth one is missing, and it cannot be written in SQL: how long the door stays open. That is the one Kestro holds, and our page on Postgres MCP servers sets out what travels through that door. Column grants and a row policy are enough on their own, if you would rather not add another tool.
What is still open
The catalogue stays readable after the tightening. We replayed the same pg_class query and jetons_api was still listed. Locking pg_catalog away from a role breaks too many tools to be honest advice, so the map of the place is part of what an agent takes with it, whatever else you do.
Our setup is a container full of invented data. A real database also has views, functions and triggers, and any of them can reopen what those six statements close: a view reads with the privileges of its owner, not those of its caller.
The row policy deserves one warning. The table owner is not subject to it until you write FORCE ROW LEVEL SECURITY. Our postgres role still sees all fifty thousand rows. That is what you want for the application, and it is exactly what people forget to check once they believe everyone is fenced in.
Finally, statement_timeout cuts one wide query, not a thousand small ones. And none of the above says anything about what becomes of the data once it has entered the model's context. That question is not settled in the database, and we know of nobody who has settled it elsewhere.
Measured on 31 August 2026: PostgreSQL 16.15 in a Docker 28.4.0 container, on Linux 6.12 x86_64. Sessions are pasted as they ran, with the docker exec prefix removed from each line for readability.