返回资讯中心
外部精选
软件工程
#开发者实时流

What a read-only Postgres role does and doesn't stop when an AI agent connects

AI agents now hold database credentials. Coding agents read DATABASE_URL from the project, MCP servers expose a query tool, and text-to-SQL features run whatever a model writes. In 2026 there were public reports of agen…

Hacker News 最新donkan1682026/11/10 · 今天30 分钟阅读

以下正文同步自 Hacker News 最新,版权归原站所有,已转换为易读排版。

AI agents now hold database credentials. Coding agents read DATABASE_URL from the project, MCP servers expose a query tool, and text-to-SQL features run whatever a model writes. In 2026 there were public reports of agents dropping tables and columns in production; we collected 13 documented cases, with sources, in an earlier post.

The usual first answer is a read-only role, and it is the right place to start. This post checks what such a role does on PostgreSQL 17, statement by statement: which errors it produces, which of its settings a session can turn off, and which reads it allows that you may not want an agent to make.

Disclosure. We build Vericto, so we have a stake in this topic. Everything below is plain PostgreSQL, with no Vericto component in the path. Every output was copied from a real run; the commands are in the appendix.

A throwaway postgres:17 container (17.11) on its own Docker network, with no published ports, and a second container on the same network as the client. Every session in this post is psql 17.11 over TCP with password authentication, run with -e so each statement is echoed above its result.

docker network create hnro-net docker run -d --name hnro-db --network hnro-net -e POSTGRES_PASSWORD=admin-demo-pw postgres:17 docker run -d --name hnro-client --network hnro-net --entrypoint sleep postgres:17 infinity docker port hnro-db # prints nothing: no published ports

every agent session in this post:

docker exec -i -e PGPASSWORD=agent-demo-pw hnro-client
psql -X -e "host=hnro-db dbname=shop user=agent_ro"

The database shop belongs to app_owner and has three tables: customers (with name, email and phone), orders, split between tenants 42 and 7, and api_keys. There is also one helper function, refund_order(int), written the way many applications write them: SECURITY DEFINER with a pinned search_path.

The agent's task is to answer questions about orders and customers. This is the role, created the way a careful checklist would create it, as a superuser:

CREATE ROLE agent_ro LOGIN PASSWORD 'agent-demo-pw' NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS CONNECTION LIMIT 5; CREATE ROLE GRANT CONNECT ON DATABASE shop TO agent_ro; GRANT REVOKE CREATE ON SCHEMA public FROM PUBLIC; REVOKE GRANT USAGE ON SCHEMA public TO agent_ro; GRANT GRANT SELECT ON orders, customers TO agent_ro; GRANT ALTER ROLE agent_ro SET statement_timeout = '5s'; ALTER ROLE ALTER ROLE agent_ro SET default_transaction_read_only = on; ALTER ROLE

REVOKE CREATE ON SCHEMA public FROM PUBLIC changes nothing on a database created by PostgreSQL 15 or later, where the public schema is owned by pg_database_owner and only the database owner can create objects in it. The schemas chapter puts it this way: “In databases upgraded from PostgreSQL 14 or earlier, everyone has that privilege on the schema public.” If your database came through pg_upgrade from 14 or earlier, you need it. The schema ACL after the grant:

SELECT nspname, nspacl FROM pg_namespace WHERE nspname = 'public'; nspname | nspacl ---------+-------------------------------------------------------------------------------------------- public | {pg_database_owner=UC/pg_database_owner,=U/pg_database_owner,agent_ro=U/pg_database_owner} (1 row)

The role attributes NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS are already the defaults of CREATE ROLE. Spelling them out makes the intent visible to whoever reviews the script.

With the defaults in place, these are the errors the agent gets:

SELECT current_user, current_setting('default_transaction_read_only') AS read_only_default, current_setting('statement_timeout') AS statement_timeout; current_user | read_only_default | statement_timeout --------------+-------------------+------------------- agent_ro | on | 5s (1 row)

DELETE FROM orders WHERE id = 1; ERROR: cannot execute DELETE in a read-only transaction UPDATE orders SET total = 0; ERROR: cannot execute UPDATE in a read-only transaction INSERT INTO orders (id, tenant_id, customer_id, total) VALUES (99, 42, 1, 1); ERROR: cannot execute INSERT in a read-only transaction CREATE TABLE notes (body text); ERROR: cannot execute CREATE TABLE in a read-only transaction COPY (SELECT 1) TO PROGRAM 'id'; 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. SELECT * FROM api_keys; ERROR: permission denied for table api_keys Timing is on. SELECT pg_sleep(30); ERROR: canceling statement due to statement timeout Time: 5000.515 ms (00:05.001)

Writes and DDL fail, COPY ... TO PROGRAM fails, a table that was not granted cannot be read, and a 30-second sleep is cancelled at 5 seconds. COPY TO PROGRAM needs superuser or membership in pg_execute_server_program, which the predefined roles page describes as: “Allow executing programs on the database server as the user the database runs as with COPY and other functions which allow executing a server-side program.” That is why an agent's role should never be a superuser: with superuser, a read-only tool becomes a shell on the database host.

Look at which error each write got, though. The first four failed with cannot execute ... in a read-only transaction, not with a permission error. PostgreSQL checks whether the transaction is read-only before it checks table privileges, so the setting answered first. This is what the GRANTs say on their own, after the session turns the setting off itself:

SET default_transaction_read_only = off; SET SHOW default_transaction_read_only; default_transaction_read_only

off (1 row)

DELETE FROM orders WHERE id = 1; ERROR: permission denied for table orders UPDATE orders SET total = 0; ERROR: permission denied for table orders INSERT INTO orders (id, tenant_id, customer_id, total) VALUES (99, 42, 1, 1); ERROR: permission denied for table orders CREATE TABLE notes (body text); ERROR: permission denied for schema public LINE 1: CREATE TABLE notes (body text); ^

Same statements, different errors: permission denied for table orders and permission denied for schema public. These come from the GRANTs, and they are the ones that hold.

The client defaults page describes it precisely: “This parameter controls the default read-only status of each new transaction.” A default is what a session starts with. In the previous block the agent ran SET default_transaction_read_only = off and got SET back. A single transaction can do the same with BEGIN READ WRITE. And because PUBLIC holds TEMPORARY on every new database, the agent can then write to a temporary table:

CREATE TEMP TABLE scratch AS SELECT * FROM orders; ERROR: cannot execute CREATE TABLE AS in a read-only transaction BEGIN READ WRITE; BEGIN SHOW transaction_read_only; transaction_read_only

off (1 row)

CREATE TEMP TABLE scratch AS SELECT * FROM orders; SELECT 6 COMMIT; COMMIT

A temp table lives in the session and does no harm by itself, but it shows that the session decides, not the role. There is a less obvious step. ALTER ROLE ... SET stores a default for future sessions, and the ALTER ROLE page says: “Ordinary roles can only set defaults for themselves.” That cuts both ways:

ALTER ROLE agent_ro SET statement_timeout = 0; ERROR: cannot execute ALTER ROLE in a read-only transaction BEGIN READ WRITE; BEGIN ALTER ROLE agent_ro SET statement_timeout = 0; ALTER ROLE COMMIT; COMMIT SHOW statement_timeout; statement_timeout

0 (1 row)

SELECT rolname, setconfig FROM pg_db_role_setting s JOIN pg_roles r ON r.oid = s.setrole; rolname | setconfig ----------+-------------------------------------------------------- agent_ro | {statement_timeout=0,default_transaction_read_only=on} (1 row)

The agent changed its own stored default, and every new session of the role started with no timeout until we reset it as an admin. It works the same way for default_transaction_read_only. The role attributes page says it plainly for any per-role setting: “You can still alter this setting during the session; it will only be the default.” Treat these settings as seat belts against accidents, and make the GRANTs the boundary. If you use them, check pg_db_role_setting now and then; the checklist below has the query.

The same goes for the timeout. A cross join over generate_series needs no table grant at all, and a session can lift the limit before it sleeps:

Timing is on. SELECT count(*) FROM generate_series(1, 100000) a CROSS JOIN generate_series(1, 100000) b; ERROR: canceling statement due to statement timeout Time: 5000.608 ms (00:05.001) SET statement_timeout = 0; SET Time: 0.084 ms SELECT pg_sleep(8); pg_sleep

(1 row)

Time: 8004.167 ms (00:08.004)

generate_series and pg_sleep are executable by PUBLIC, like most built-in functions. PostgreSQL 17 added transaction_timeout, which lives in the same chapter of client settings and has the same limitation. If you need a hard cap, enforce it from outside the session: in a pooler or proxy in front of the database, or with a watchdog role that holds pg_signal_backend and pg_read_all_stats and terminates long agent queries. We tried that against a session that had set its timeout to 0:

SELECT pid, usename, now() - query_start > interval '3 seconds' AS over_limit, left(query, 30) AS query, pg_terminate_backend(pid) AS terminated FROM pg_stat_activity WHERE usename = 'agent_ro' AND state = 'active' AND now() - query_start > interval '3 seconds'; pid | usename | over_limit | query | terminated -----+----------+------------+----------------------+------------ 86 | agent_ro | t | SELECT pg_sleep(60); | t (1 row)

-- the agent's session, which had run SET statement_timeout = 0: SET statement_timeout = 0; SET SELECT pg_sleep(60); FATAL: terminating connection due to administrator command server closed the connection unexpectedly This probably means the server terminated abnormally before or while processing the request. connection to server was lost

CONNECTION LIMIT does hold, because it is a role attribute, not a session setting. The docs: “Connection limit can specify how many concurrent connections a role can make.” With five sessions already open, the sixth gets:

psql: error: connection to server at "hnro-db" (172.19.0.2), port 5432 failed: FATAL: too many connections for role "agent_ro"

Read-only transaction wrappers in tools

Many agent tools do not rely on the role. They wrap each query in a read-only transaction. In August 2025, Datadog Security Labs published “MCP vulnerability case study: SQL injection in the Postgres MCP server”, about the reference Postgres MCP server. It sent BEGIN TRANSACTION READ ONLY, then the agent's SQL, then ROLLBACK. The driver accepted several statements in one string, so SQL that starts with COMMIT; ends the read-only transaction and runs the rest outside it. Their example was COMMIT; DROP SCHEMA public CASCADE;. The server had already been deprecated and archived, and a fork shipped the fix.

We reproduced the mechanism with psql: each -c is sent as one simple-protocol Query message, which is what client.query(sql) does in node-postgres when there are no parameters. The connection uses app_rw, a role with write grants on orders, because these tools are often pointed at the application's own role:

$ psql -c "BEGIN TRANSACTION READ ONLY" -c "<agent SQL>" -c "ROLLBACK" # one -c = one Query message BEGIN TRANSACTION READ ONLY BEGIN DELETE FROM orders ERROR: cannot execute DELETE in a read-only transaction ROLLBACK ROLLBACK BEGIN TRANSACTION READ ONLY BEGIN COMMIT; DELETE FROM orders COMMIT DELETE 6 ROLLBACK WARNING: there is no transaction in progress ROLLBACK count

0 (1 row)

The plain DELETE is refused. The one behind COMMIT; deletes all six rows, and the wrapper's ROLLBACK only produces a warning. The

正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。

打开原文
去社区讨论 →