All posts

Your Agent Can Turn Off Its Own Kill Switch

Mikhail ShytskoBy Mikhail Shytsko, Founder at Seedfast ·

Share
Open in ChatGPT

A new agent needs a database login, so the role it logs in as gets the treatment every runbook recommends. Two ALTER ROLE lines go in, one capping how long a single statement may run and one making transactions read-only by default. From the operator's side, the Postgres role for AI agent sessions now looks contained.

alter role agent set statement_timeout = '2s';
ALTER ROLE
alter role agent set default_transaction_read_only = on;
ALTER ROLE
select rolname, rolconfig from pg_roles where rolname='agent';
 rolname |                        rolconfig                        
---------+---------------------------------------------------------
 agent   | {default_transaction_read_only=on,statement_timeout=2s}
(1 row)

Both settings bite at the baseline, with SELECT pg_sleep(4) cancelled at 2.060 seconds on the role above and a bare CREATE TABLE refused on a second role carrying only the read-only default. Then the agent's own session answers, in one statement.

SET default_transaction_read_only = off;
SET
SHOW default_transaction_read_only;
 default_transaction_read_only 
-------------------------------
 off
(1 row)

SELECT source FROM pg_settings WHERE name = 'default_transaction_read_only';
 source  
---------
 session
(1 row)

CREATE TABLE app.via_set (i int);
CREATE TABLE
INSERT INTO app.via_set VALUES (1);
INSERT 0 1

The pg_settings source column for a Postgres role for an AI agent, showing the value ALTER ROLE SET writes as source user and the two ways a session replaces it, with SET arriving as source session and a connection string arriving as source client

One column moved, from user to session, and that column is the whole story. What ALTER ROLE ... SET writes is the value a session starts with; whether the session stays there is up to the session, because every parameter in that opening pair carries a context of user. The quieter route skips SET entirely and needs no SQL at all.

Key Takeaways

  • statement_timeout, transaction_timeout, lock_timeout, the two idle timeouts, default_transaction_read_only and application_name all have context = user, so the session the value was meant to restrain is the session allowed to change it.
  • A connection string carrying options=-c statement_timeout=0 arrives with source = client, which outranks the role's user, so the cap is gone before the first query runs and nothing resembling a SET ever reaches the log.
  • The SET privilege on a parameter is only meaningful where superuser privilege would normally be required. Revoking SET on statement_timeout writes no row to pg_parameter_acl and stops nothing.
  • What held, in the same containers, was CONNECTION LIMIT, the object grants, and a second privileged session calling pg_terminate_backend. None of the three is reachable from inside the session it binds.

The transcripts below came out of throwaway Alpine containers on 2026-09-11. Unless a block says otherwise it was captured on PostgreSQL 18.6; the transaction-timeout comparison ran on 17.11 and 16.15. Server output is quoted verbatim; a few blocks are condensed, with every message and value left as it came back.

The quieter route, through the connection string

A SET at least leaves something to find. Search the logs for SET statement_timeout and you will catch the blunt version of this. The libpq options connection parameter carries command-line switches in the startup packet, those switches are applied after the per-role settings have been loaded, and the result is a session that never issues a single statement about its own limits.

postgresql://agent@localhost:5432/t2?options=-c%20statement_timeout%3D0%20-c%20default_transaction_read_only%3Doff

Nothing changed on the server, and rolconfig still reads exactly as it did above. What the session reports about itself is different in both rows that matter.

SELECT name, setting, source, context FROM pg_settings WHERE name = 'statement_timeout';
       name        | setting | source | context 
-------------------+---------+--------+---------
 statement_timeout | 0       | client | user
(1 row)

SHOW default_transaction_read_only;
 default_transaction_read_only 
-------------------------------
 off
(1 row)

\timing on
Timing is on.
SELECT pg_sleep(4);
 pg_sleep 
----------
 
(1 row)

Time: 4094.391 ms (00:04.094)

The four-second sleep ran for 4.094 seconds where the plain connection had it cancelled at 2.060. Nothing needs to be URL-encoded into a DSN either, since the same startup packet is built from an environment variable that libpq-based clients read without being asked, psql and psycopg among them.

$ PGOPTIONS="-c statement_timeout=0" psql "postgresql://agent@localhost:5432/t2"
0|client

A review that reads the role definition and finds statement_timeout=2s is reading a true fact. Of the two bypasses, this is the one worth worrying about, because nobody is obliged to keep that value past the startup packet.

USERSET against SUSET, and why no grant locks a timeout

PostgreSQL 15 introduced GRANT SET ON PARAMETER, and the natural next thought is to revoke the right instead of granting it. More than one hardening thread gives exactly that advice. It fails for a reason the catalog states plainly, since parameter ACLs exist to hand a non-superuser a parameter it could not otherwise touch, which leaves the mechanism with nothing to say about a parameter every role may already set.

SELECT name, context FROM pg_settings WHERE name IN
  ('statement_timeout','lock_timeout','shared_buffers','session_replication_role');
           name           |  context
--------------------------+------------
 shared_buffers           | postmaster
 session_replication_role | superuser     <-- SUSET
 lock_timeout             | user          <-- USERSET
 statement_timeout        | user          <-- USERSET

A revoke aimed at a USERSET parameter is accepted, and being accepted is all it does.

REVOKE SET ON PARAMETER statement_timeout FROM agent11;         -> REVOKE
REVOKE SET, ALTER SYSTEM ON PARAMETER statement_timeout FROM PUBLIC; -> REVOKE
SELECT parname, paracl FROM pg_parameter_acl;                   -> (0 rows)   -- nothing recorded

-- agent11 still sets it:
SET statement_timeout = 0;  SHOW statement_timeout;  -> 0

Grant and then use the same privilege on a superuser-context parameter and the catalog behaves, which is the control that makes the negative result above trustworthy rather than a typo.

-- default: agent11 cannot set a superuser-context GUC
SET session_replication_role = replica;
 ERROR:  permission denied to set parameter "session_replication_role"

-- grant SET on that parameter, and now it can
GRANT SET ON PARAMETER session_replication_role TO agent11;
SET session_replication_role = replica;  SHOW session_replication_role;  -> replica

session_replication_role is a lever with consequences of its own, and what replica mode does not switch off goes through them with the same kind of transcript. For the question at hand it is only the control case. By definition USERSET already grants what the revoke is trying to take away, which is why the catalog has nowhere to record it. The documentation puts the same limit on the privilege, which can be granted on any parameter and bites only where superuser privilege would otherwise be needed.

Five timeouts, and which of them end a session

Teams reach for timeouts because they sound like a stop. Two of the five only cancel the running statement, and a client that keeps its connection open carries on with the next one.

The five PostgreSQL session timeouts behind the usual postgres kill switch, split into the two that cancel a statement and leave the connection alive and the three that terminate the session, each shown with its SQLSTATE

Each timeout below was set to one second and deliberately tripped, with \set VERBOSITY verbose on so the SQLSTATE is visible, and the line after the trigger tests whether the connection is still there.

TimeoutSQLSTATEMessage begins withEffectConnection
statement_timeout57014canceling statementcancel STATEMENTsurvives
lock_timeout55P03canceling statementcancel STATEMENTsurvives
idle_in_transaction_session_timeout25P03terminating connectionkill SESSIONlost
idle_session_timeout57P05terminating connectionkill SESSIONlost
transaction_timeout (PG17+)25P04terminating connectionkill SESSIONlost

What the split decides is whether the agent gets a new turn. statement_timeout is a throttle that returns control to whoever is driving the connection, so the agent gets its error and tries something shorter.

ERROR:  57014: canceling statement due to statement timeout
      after
------------------
 session_survived            <-- statement cancelled, SESSION ALIVE

transaction_timeout is the one people expect to behave like a bigger statement_timeout, and it does not. It arrived in PostgreSQL 17 to bound the total duration of a transaction, which is a different quantity from the duration of a statement, and when it fires the level is FATAL.

BEGIN
FATAL:  25P04: terminating connection due to transaction timeout
server closed the connection unexpectedly
error: connection to server was lost      <-- SESSION TERMINATED

The gap that leaves on 16 is not covered by any combination of the other four. A transaction made of short statements never trips statement_timeout, and if the gaps between them are server-side work rather than client idle time, idle_in_transaction_session_timeout never sees an idle transaction either. The measured workload used pg_sleep for its gaps, so it ran as long as it liked. Six seconds of one-second statements under statement_timeout = 2s finished and committed on both 16 and 17.

 made_it: reached_end_of_transaction
 COMMIT
 proof: after_commit_still_connected

On 16 the newer knob is not there to be set, at connection time or afterwards.

SELECT name FROM pg_settings WHERE name='transaction_timeout';   -> (0 rows)
SET transaction_timeout = '3s';   -> ERROR: unrecognized configuration parameter "transaction_timeout"
# at connect via PGOPTIONS:
FATAL:  unrecognized configuration parameter "transaction_timeout"

All five are USERSET on every version where they exist. Ending a session is not much of a stop when the next connection can leave the parameter out of its own startup packet.

What the published read-only recipes do and do not stop

Two recipes went through the same probe set, applied verbatim with only the object names adapted. The first is Crunchy Data's read-only user from 2021, which grants SELECT on all tables, USAGE on all sequences, EXECUTE on all functions and default privileges for future tables and functions, then closes by recommending pg_read_all_data on PostgreSQL 14 and later as the easier replacement for that last part. The second, datamcp's Postgres AI access security kit, is tighter by design. Its kit file grants SELECT on an explicit relation list, three tables here, with owner-scoped default privileges, sets statement_timeout, idle_in_transaction_session_timeout and default_transaction_read_only per database, and gives the role itself CONNECTION LIMIT 5.

The criticism you will meet first is that a read-only role can still shell out. That criticism is wrong here, and the transcript says so in the server's own words.

-- [COPY TO PROGRAM] needs pg_execute_server_program
psql:/tmp/t3-probe-scraper.sql:6: ERROR:  permission denied to COPY to or from an external program
DETAIL:  Only roles with privileges of the "pg_execute_server_program" role may COPY to or from an external program.
HINT:  Anyone can COPY to stdout or from stdin. psql's \copy command also works for anyone.
-- [COPY FROM STDIN] needs INSERT
psql:/tmp/t3-probe-scraper.sql:8: ERROR:  permission denied for table stock_data
-- [client-side \copy TO] only needs SELECT
COPY 2

COPY ... TO PROGRAM, COPY ... FROM STDIN and the large-object functions probed, lo_export, lo_import and lo_get, were correctly refused to both roles. What got through is the humble one the HINT advertises. Client-side \copy ... TO is a SELECT whose rows land in a file on the machine running the client, it needs no privilege beyond the read you granted on purpose, and no read-only grant closes it. Exfiltration through a privilege granted on purpose is a different problem from a server compromise, and it has a different fix. The role decides what can be read, so narrow the read grant to what the agent needs, and where the copied rows then land is a question for the host the client runs on.

The same run turned up two results that argue with the names on the things they test. pg_read_all_data does not reach large objects, whatever the name of the predefined role suggests.

-- [lo_get] read large object contents
psql:/tmp/t3-probe-scraper.sql:17: ERROR:  permission denied for large object 44444
-- [nextval] advance a sequence (a write)
 nextval 
---------
       2
(1 row)

The nextval in that same block is the other one. USAGE ON ALL SEQUENCES carries nextval, which advances sequence state, so a role built this way changes no row and can still move a counter. Reading the recipe, that reach covers only sequences that existed when it ran, since its default privileges name tables and functions. Crunchy names the opposite property themselves, that pg_read_all_data gives read on any object created by any user in the database. The runs confirm it, including on a table created afterwards by a role the grant never mentioned.

Neither recipe can bind what PostgreSQL does not let a role bind. The second sets statement_timeout, idle_in_transaction_session_timeout and default_transaction_read_only as defaults, all three USERSET, so the session lifts them, and the kit labels that block as operational limits that reduce impact without replacing privileges.

Dave Page's AI Features in pgAdmin: The AI Chat Agent from 10 March 2026 puts the boundary somewhere the session cannot reach, describing an agent whose query tool "runs exclusively within a READ ONLY transaction". That boundary sits in the client, which is a different construction from the one measured here, since the process that opened the transaction fixed its access mode before the model supplied any statement text. The piece is equally straightforward that the assistant writes modification SQL for a human to run instead of running it.

Contrast that with the boundary built inside a SQL validator. The recipes above do not work that way, since a grant is checked by the server on every statement. CVE-2026-87911, published on 9 September 2026 and scored 9.6 by its assigner, was a read-only enforcement bypass in the SQL validation component of awslabs.postgres-mcp-server before 1.1.7, reached by a crafted COPY ... TO PROGRAM placed in content the server processes. Its advisory notes that a role holding neither superuser nor pg_execute_server_program is unaffected, because the database denies the operation whatever the application layer decided, which is the refusal the transcript above produced. A different tool lost its restricted mode five days earlier, on 4 September 2026. CVE-2026-85620 turns on function-name validation that never reached the RangeFunction nodes a FROM clause can carry, so pg_read_file went into Postgres MCP Pro (postgres-mcp) 0.3.0 and earlier through a syntax the checker was not walking. Neither is a PostgreSQL bug. In both, the thing deciding what a statement may do was a parser that had to keep agreeing, forever, with the parser inside the server, and one crafted statement was enough to show where the two had drifted apart.

Building a Postgres role for AI agent sessions that holds

Everything that survived contact in these containers has one property in common. It is evaluated somewhere the session cannot reach, either before the session exists or in a different session entirely.

CONNECTION LIMIT is a role attribute, stored in pg_authid.rolconnlimit, and checked at authentication time. With the limit at 2 and two sessions open, the third login is refused before any SQL happens at all.

psql: error: connection to server at "localhost" (::1), port 5432 failed: FATAL:  too many connections for role "agent"

There is no SET that touches it, and the agent cannot rewrite the attribute on itself either, which is the shape every real control in this article has.

ALTER ROLE agent CONNECTION LIMIT 100;
ERROR:  permission denied to alter role
DETAIL:  Only roles with the CREATEROLE attribute and the ADMIN option on role "agent" may alter this role.

Object grants hold the same way, with one trap that is worth ten minutes of anybody's time. A least privilege Postgres role can inherit a predefined role through a chain of group memberships nobody remembers building, and the tooling most operators reach for will not show it, since \du dropped its Member-of column in PostgreSQL 16 and \drg prints one hop per row and leaves the walking to you, which is why the answer has to come out of a query.

 rolname  | can_run_programs | can_read_files | can_write_files 
----------+------------------+----------------+-----------------
 agent    | f                | f              | f
 agent4   | t                | f              | f
 postgres | t                | t              | t
(3 rows)

  grantee  |                             path                              
-----------+---------------------------------------------------------------
 etl_tools | etl_tools -> pg_execute_server_program
 analytics | analytics -> etl_tools -> pg_execute_server_program
 agent4    | agent4 -> analytics -> etl_tools -> pg_execute_server_program
(3 rows)

agent4 has no attributes, no direct grants worth the name, and full COPY ... TO PROGRAM through two hops of inherited membership. The first result came from pg_has_role(r.oid, 'pg_execute_server_program', 'USAGE') over pg_roles, which follows the whole graph, and the second from a recursive CTE over pg_auth_members that prints the path so you can see which group to detach. Run both against every login role before you believe a role inventory.

Not every grant in that inventory was written by a person. PostgreSQL 15 removed PUBLIC's CREATE on the public schema, which is the step that decides whether a role with no schema grants of its own can still create objects, and a cluster migrated onto 15 or later keeps whatever ACL it arrived with. Read nspacl to find out which one you inherited, since a =UC/ entry is PUBLIC holding USAGE and CREATE while =U/ is USAGE alone. Which of the two you end up with turns on how the database was carried across, and the data-only restore report covers the neighbouring case of a pg_dump preamble deciding something for the session that restores it.

The watchdog lives in another session

The actual kill switch is a privileged connection that is not the agent's. Because it makes its decision out of pg_stat_activity and acts from its own backend, nothing the agent does to its own parameters reaches it. Between the two functions it can call sits the same cancel-or-terminate split the timeouts have.

=== [1] find agent running first pg_sleep ===
 pid | usename | state  |           q
-----+---------+--------+-----------------------
 774 | agent   | active | SELECT pg_sleep(120);

=== [2] pg_cancel_backend (cancel the STATEMENT) ===
 pid | cancelled
-----+-----------
 774 | t

=== [3] session still alive? now running statement (3), the 2nd sleep ===
 pid | usename | state  |           q
-----+---------+--------+-----------------------
 774 | agent   | active | SELECT pg_sleep(120);      <-- SAME pid, still connected

=== [4] pg_terminate_backend(pid) WITHOUT timeout ===
 pid | terminated
-----+------------
 774 | t

=== [5] sessions after terminate ===
 agent_sessions
----------------
              0

Step 3 shows the same PID as step 1, already on its second long statement, after a cancel that an operator could easily have written into a log as a stop. pg_terminate_backend ended it. The two-argument form is the one to write into a watchdog, because the single-argument call returns as soon as SIGTERM is delivered and tells you nothing about whether the backend has actually gone.

-- 1-arg: fire-and-return
 pid | pg_terminate_backend
-----+----------------------
 826 | t
Time: 4.080 ms

-- 2-arg pg_terminate_backend(pid, 5000): waited for the process to actually exit
 pid | terminated_confirmed
-----+----------------------
 840 | t
Time: 104.235 ms

The first call came back in four milliseconds and the second in a hundred and four, with the target dead either way. Those hundred milliseconds are the watchdog waiting for the backend to actually leave pg_stat_activity, which is what you want its log line to be asserting. Terminating is not the end of it either, because the agent still holds working credentials and its driver reconnects. Take the login away first, with ALTER ROLE agent NOLOGIN or CONNECTION LIMIT 0, and the terminate that follows stops the agent instead of interrupting it.

Then there is the matter of how the watchdog finds its target, and the obvious choice is the wrong one. application_name is USERSET like everything else in this article, so a session that connected as seed-agent renames itself with one SET and drops out of the filter.

-- filter on the name it connected with:
SELECT pid, usename, application_name FROM pg_stat_activity WHERE application_name = 'seed-agent';
 pid | usename | application_name
-----+---------+------------------
(0 rows)                          <-- the watchdog's filter now finds NOTHING

-- ground truth, filter on the role instead:
SELECT pid, usename, application_name FROM pg_stat_activity WHERE usename = 'agent';
 pid | usename | application_name
-----+---------+------------------
 958 | agent   | psql             <-- the session is alive and well, under a name of its own choosing

Filter on usename, which is recorded at authentication and is not a parameter, or on datname and client_addr. A watchdog cannot tell two agents apart when they share a login, which is the argument for giving every identity that connects its own login role, whether that is a coding agent or a data generator such as Seedfast.

Where to put the boundary

Nothing that lives in the session binds the session. Put statement_timeout on the role and you have given well-behaved clients a sensible default and accidents somewhere to stop; default_transaction_read_only buys the same thing, and application_name was only ever a label. An agent that wants any of them gone spends one statement on it, and the agent that edits its connection string before connecting never writes that statement down anywhere.

The controls that did bind were all evaluated somewhere else. Role attributes are read before the session exists, which is why CONNECTION LIMIT and NOLOGIN mean what they say, and pg_hba.conf is earlier still, since it decides which database and which source address the role may reach before any SQL is parsed. Object grants get their answer from the server on every statement, while a validator standing in front of the server only ever gets to agree with it, and that is the difference the two September advisories turn on. The watchdog needs a connection of its own and a filter on usename, and once it has taken the login away it can finish with pg_terminate_backend(pid, timeout_ms). Where a \copy may write its file stays a question for the host the client runs on.

One boundary of this kind is not a role attribute at all. On a hot standby, recovery enforces read-only below the level a parameter can reach, so SET default_transaction_read_only = off and a connection string full of options change nothing, and the documentation says such connections are strictly read-only down to temporary tables, which may not be written either. Crunchy's post opens by naming that same option before it reaches any GRANT, and for an agent that only has to read, pointing it at a replica retires most of the argument above.

Write the timeouts anyway; a default that stops an accident earns its place. What the log line should carry is the value the session is holding when the statement runs, since the role's copy of it stopped being evidence the moment the session connected. The postgres kill switch stays where the agent has no handle on it, in a session it did not open and cannot see the parameters of.

Frequently asked questions

Can I stop an AI agent's role from changing statement_timeout?

Not with a grant. statement_timeout has context = user, which means every role may set it in its own session, and REVOKE SET ON PARAMETER statement_timeout is accepted while writing no row to pg_parameter_acl and changing no behaviour. The same applies to transaction_timeout, lock_timeout, both idle timeouts, default_transaction_read_only, search_path and application_name. What you can do is enforce the limit from outside, with a watchdog session that reads pg_stat_activity and terminates backends that outstay the policy.

Does a read only Postgres user stop data leaving the database?

It stops table writes and it does nothing about reads leaving, which is the half people assume it covers. It does not always stop every write either, since the Crunchy role is granted USAGE ON ALL SEQUENCES and that carries nextval. Client-side \copy ... TO is an ordinary SELECT whose rows are written to a file by the client, so any role that can read a table can put that table in a file, and the server says as much in its own HINT on the COPY error. Server-side COPY ... TO PROGRAM and the large-object functions probed were correctly refused to both published read-only roles tested here. Once the read is granted the rows are readable, so the control that is left lives on the machine the client runs on and in what that machine can reach.

What is the difference between pg_cancel_backend and pg_terminate_backend?

pg_cancel_backend(pid) cancels the statement currently running and leaves the connection open, so the session receives an error and carries on with whatever it wants to run next. pg_terminate_backend(pid) ends the backend, and the client sees FATAL: terminating connection due to administrator command. Prefer the two-argument pg_terminate_backend(pid, timeout_ms) in automation, since it waits for the process to actually exit and its true return means the session is gone rather than signalled.

Is transaction_timeout available on PostgreSQL 16?

No, it was added in 17. On 16 the parameter is not in pg_settings, setting it raises unrecognized configuration parameter, and passing it at connection time makes the connection fail outright. That leaves a real gap, because a transaction built from short statements never trips statement_timeout, and when its gaps are server-side work rather than client idle time idle_in_transaction_session_timeout never fires either, so its total duration is unbounded on 16 by any per-role parameter.