Every runbook for giving an AI agent a database login contains the same two lines:
1 ALTER ROLE agent SET statement_timeout = '2s';
2 ALTER ROLE agent SET default_transaction_read_only = on;
Both of those work. Run SELECT pg_sleep(4) as that role and it gets cancelled at two seconds. Run CREATE TABLE and it gets refused. The role definition reads exactly as intended, and a reviewer who checks rolconfig will find precisely what the runbook promised.
Neither line contains anything. Mikhail Shytsko went and tested this properly last week, with transcripts, and his results are worth your time. I want to make the more general argument, because this is not really about agents. It is about where a boundary has to live in order to be a boundary, and the industry has spent the last eighteen months relearning that the hard way.
Any user is allowed to change their session-local value
That is not my phrasing. That is the PostgreSQL documentation, describing what context = user means in pg_settings. It is about as plain as the project gets.
1 SELECT name, context FROM pg_settings
2 WHERE name IN ('statement_timeout', 'lock_timeout',
3 'default_transaction_read_only', 'application_name');
All four come back user. So do transaction_timeout, idle_in_transaction_session_timeout, and idle_session_timeout. What ALTER ROLE ... SET writes is the value a session starts with. Whether the session stays there is entirely the session’s business:
1 SET default_transaction_read_only = off;
One statement. Done.
The interesting part is what happens in pg_settings.source. Before the SET, the value came from user, meaning the role’s own configuration. After, it reads session. That column is the only place the change is visible, and nobody monitors that column.
The quieter route does not even require a statement. libpq’s options connection parameter carries command-line switches in the startup packet, and those are applied after per-role settings have loaded:
1 postgresql://agent@localhost:5432/app?options=-c%20statement_timeout%3D0
Or, since nobody needs to touch the DSN:
1 PGOPTIONS="-c statement_timeout=0" psql "postgresql://agent@localhost:5432/app"
The session comes up with source = client, which outranks the role’s user, and the cap is gone before the first query runs. Grep your logs for SET statement_timeout all you like. There is nothing to find, because nothing was set. This is the version to worry about, and it is also the version that any competently written client library will do by default if someone puts a timeout in a config file.
The revoke that is accepted and does nothing
PostgreSQL 15 added GRANT SET ON PARAMETER, and the obvious next thought is to take the privilege away instead of granting it. I have seen this in at least three hardening guides. It does not work, and the reason is in the definition of the privilege rather than in any bug.
1 REVOKE SET ON PARAMETER statement_timeout FROM agent;
2 REVOKE
3 SELECT parname, paracl FROM pg_parameter_acl;
4 (0 rows)
No row. The SET privilege exists to hand a non-superuser a parameter it could not otherwise touch, which means it only has anything to say about parameters in the superuser and superuser-backend contexts. Point it at a user-context parameter and there is nothing to record, because the ability you are trying to revoke was never granted in the first place. Every role already has it.
The command is accepted. That is the entire extent of what it does. If you want to confirm the mechanism is working rather than broken, grant SET on something like session_replication_role, which is superuser context, and watch the catalog behave exactly as advertised. The negative result above is real, not a typo.
What actually binds
Everything that holds has one property in common: it is evaluated somewhere the session cannot reach. Either before the session exists, or in a different session entirely. There are four of these and you should be using all four.
pg_hba.conf is the earliest boundary you have. It decides which role may reach which database from which address, before a single byte of SQL is parsed. If your agent has no business connecting to the billing database from outside the VPC, that is where you say so. It is also the control people forget they have, because it is a file rather than a GRANT.
Object grants are answered by the server on every statement. This is the one the agent cannot argue with, and it is where the actual work is. Give the agent CONNECT, USAGE on the schemas it needs, and SELECT on the specific relations it needs. Not pg_read_all_data, which reaches every table any user creates in the database, forever, including ones created after you granted it.
While you are in there, check what the role has inherited. A login role can pick up a predefined role through a chain of group memberships that nobody remembers building, and \du will not show it, having dropped its member-of column in PostgreSQL 16. Ask properly:
1 SELECT r.rolname,
2 pg_has_role(r.oid, 'pg_execute_server_program', 'USAGE') AS can_run_programs,
3 pg_has_role(r.oid, 'pg_read_server_files', 'USAGE') AS can_read_files
4 FROM pg_roles r
5 WHERE r.rolcanlogin
6 ORDER BY 1;
Run that against every login role in the cluster before you believe any inventory you have been handed. Two hops of inherited membership is enough to give a role with no attributes and no direct grants a fully working COPY ... TO PROGRAM.
Role attributes are read at authentication. CONNECTION LIMIT lives in pg_authid.rolconnlimit and is checked before any SQL happens; the agent cannot SET it and cannot ALTER ROLE itself. Same for NOLOGIN. These are blunt, which is why they work.
The kill switch is a second connection. A privileged watchdog that reads pg_stat_activity and acts from its own backend is unreachable from the session it is policing. Two details matter here and both are easy to get wrong. Filter on usename, which is recorded at authentication, and not on application_name, which is user context like everything else and which a session renames in one statement, silently dropping out of your filter. And use the two-argument form:
1 SELECT pg_terminate_backend(pid, 5000)
2 FROM pg_stat_activity
3 WHERE usename = 'agent' AND state = 'active'
4 AND now() - query_start > interval '30 seconds';
pg_cancel_backend cancels the current statement and leaves the connection open, at which point the client simply runs something else; I have watched people write that into a log as a stop. The single-argument pg_terminate_backend returns as soon as SIGTERM is delivered and tells you nothing about whether the backend has gone. The two-argument form waits. And take the login away first with ALTER ROLE agent NOLOGIN, or the driver reconnects while you are still reading the log line.
If the agent only reads, point it at a replica
This retires most of the above. On a hot standby, read-only is enforced by recovery, well below the level any parameter can reach. SET default_transaction_read_only = off changes nothing. A connection string full of options changes nothing. The restriction goes all the way down to temporary tables. For a read-only agent this is the single highest-value thing on the list and it costs you a connection string.
The same mistake, one layer up
Two advisories this month make the identical point from the application side, and both are worth reading as architecture rather than as news.
CVE-2026-87911, published 9 September against awslabs.postgres-mcp-server before 1.1.7 and scored 9.6, was a read-only enforcement bypass: a crafted COPY ... TO PROGRAM reaching the server through content processed in its default read-only mode. Five days earlier, CVE-2026-85620 hit Postgres MCP Pro 0.3.0 and earlier, where function-name validation never walked the RangeFunction nodes that a FROM clause can carry, so pg_read_file went straight through the checker.
Neither is a PostgreSQL bug. In both cases the thing deciding what a statement was allowed to do was a parser sitting in front of the database, and that parser has to agree with the parser inside the server about the meaning of arbitrary SQL, forever, across every version. It will not. The CWE assigned to the first one is 184, “Incomplete List of Disallowed Inputs,” which is the most honest description of a SQL blocklist I have seen in a CVE record.
Give AWS credit: their advisory says the strongest control is connecting with a role that has only the privileges it needs, “so that the database itself enforces the boundary regardless of what SQL reaches it,” and notes that minimal-privilege users were never affected. That is exactly right, and it is the same sentence as this whole post.
Then the workaround list says to “force read-only transactions at the role level.” Do not count that as a control. It is the default_transaction_read_only default from the top of this article, it is user context, and the session takes it off in one statement or never accepts it in the first place. It belongs in the list as a default that stops accidents. It does not belong in a list of things that stop an attacker.
So set the timeouts anyway
I am not telling you to remove those two ALTER ROLE lines. A statement_timeout that stops a runaway analytical query at two seconds is doing useful work every day, and most of what an agent does wrong is an accident rather than an attack. Defaults that catch accidents earn their place.
Just file them correctly. They are ergonomics, not security. The security is pg_hba.conf, the grant list, the role attributes, the watchdog, and the replica.
And if you are logging any of this for an audit, log the value the session was actually holding when the statement ran, out of pg_settings, rather than the value in rolconfig. The role’s copy stopped being evidence the moment the session connected.
If you are running either of those MCP servers, go and check your version before you do anything else on this list.