The short answer
Create a ddl_command_start event trigger that raises an error when current_user is the agent’s role and the command tag is one you want to stop, such as DROP TABLE or ALTER TABLE. Event triggers fire before DDL such as CREATE, ALTER, DROP and GRANT.1 On PostgreSQL 18, ours refused ALTER TABLE and DROP TABLE from the agent’s role, even on a table that role owned, and the role couldn’t disable the trigger.
Two limits: only superusers can create event triggers2, so on some managed services you can’t use this; and TRUNCATE isn’t DDL, so an event trigger doesn’t stop it. In our run, the same role emptied the table with TRUNCATE.
Deedglass
A database change, checked and put back
-
1The agent’s change
Change order 42
UPDATE orders … WHERE id = 42
The agent: “1 row updated” -
2What the database really did
301 rows
A hidden trigger changed 300 more
Doesn’t match -
3Put back
301 rows restored
Signed off by a person
Matches
When you need this
The cleanest setup gives an agent a role that owns nothing and has only the grants it needs. Then DROP TABLE fails for lack of ownership, and no trigger is needed. Our post on what rds_superuser and neon_superuser carry shows how to build that role.
Real projects aren’t always clean:
- Migration tools often run as the role that owns the schema, and the agent runs the migration tool.
- A development database where the agent creates its own tables, but shouldn’t drop the shared ones.
- A legacy app whose single role owns everything, and the agent has to use it for now.
In those cases ownership already lets the role drop and alter tables, and grants can’t take that away. An event trigger can.
The setup
As a superuser, on a test database:
Code A database command in SQL. The text around it says what it does.
create role agent_rw login password '...';
grant create, usage on schema public to agent_rw;
create function block_agent_ddl() returns event_trigger language plpgsql as $$
begin
if current_user = 'agent_rw' then
raise exception 'DDL (%) is blocked for %; ask a person', tg_tag, current_user;
end if;
end $$;
create event trigger no_agent_ddl on ddl_command_start
when tag in ('DROP TABLE', 'ALTER TABLE', 'DROP SCHEMA') -- add 'DROP TRIGGER' too; see below
execute function block_agent_ddl();
create table orders(id int primary key, total int);
alter table orders owner to agent_rw;
insert into orders select g, g from generate_series(1,5) g;
orders is owned by the agent’s role on purpose: that’s the case grants can’t help with.
What happened when the agent tried
With set role agent_rw, we ran six statements. What psql printed:
| Statement | Result |
|---|---|
alter table orders drop column total; |
ERROR: DDL (ALTER TABLE) is blocked for agent_rw; ask a person |
drop table orders; |
ERROR: DDL (DROP TABLE) is blocked for agent_rw; ask a person |
truncate orders; |
TRUNCATE TABLE |
select count(*) from orders; |
0 |
create table scratch(x int); |
CREATE TABLE |
drop table scratch; |
ERROR: DDL (DROP TABLE) is blocked for agent_rw; ask a person |
The first two are what we wanted: the owner role can’t drop or alter the table. The last two show the trigger is about the command, not the table: the agent can create its own scratch table but not drop it, which may or may not be what you want. Widen or narrow the tag list and add a schema check for that.
Rows 3 and 4 are the gap. TRUNCATE went straight through and the table was empty.
Can the agent turn it off?
We tried both obvious routes as agent_rw:
Code What the screen or file shows, copied as is.
ERROR: must be owner of event trigger no_agent_ddl
ERROR: permission denied to set parameter "event_triggers"
ALTER EVENT TRIGGER ... DISABLE needs the trigger’s owner, a superuser here, and the event_triggers setting couldn’t be changed by the role. The docs note that event triggers are disabled in single-user mode and when event_triggers is set to false, which is the escape hatch for a broken trigger.2 Neither is available to a normal role.
What an event trigger doesn’t see
From the docs and our run:
TRUNCATE. Not in the DDL list event triggers fire for.1 Revoke theTRUNCATEprivilege instead, which is a table-level privilege3, though an owner keeps it. If the role must own the table, aBEFORE TRUNCATEtrigger on the table can raise for that role. We tried it: asagent_rw,truncate ordersthen failed withERROR: TRUNCATE is blocked for agent_rw. But the very next statement,drop trigger no_agent_truncate on orders, succeeded, because the role owns the table andDROP TRIGGERwasn’t in our event trigger’s tag list. AddDROP TRIGGERto the list (disabling a trigger is anALTER TABLE, already covered) or the per-table trigger is a door the owner can open.DELETEandUPDATE. Data changes aren’t DDL. ADELETE FROM orderswith noWHEREis as destructive asTRUNCATEand isn’t stopped here. Grants, row-level security and a blast radius small enough to recover from cover that.- Shared objects.
ddl_command_startdoesn’t fire for DDL on databases, roles, tablespaces, parameter privileges orALTER SYSTEM.1DROP DATABASEisn’t covered. - Commands on event triggers themselves.1 Protection there comes from ownership, as above.
- Superusers. A superuser can disable the trigger. Don’t give agents superuser credentials.
Operating it safely
- Test the error path. A bug in an event trigger function can block all DDL, including your migrations. The documented recovery is restarting with
event_triggersoff or using single-user mode.2 Try your trigger on a copy first. - Make the message useful. “Ask a person” in the error tells an agent what to do next; a bare permission error invites it to try another route.
- Keep migrations working. If your migration tool runs as the same role, give migrations their own role, or the trigger will block them too. That split is good practice anyway.
- Log refusals. A
RAISEreaches the client and the server log. Repeated refusals for the agent’s role are worth a look; the ALTER TABLE over SSH report shows why an agent might try.
A checklist
- The agent’s role owns as little as possible; event triggers are for what’s left.
- An event trigger refuses
DROP TABLE,ALTER TABLE,DROP SCHEMAandDROP TRIGGERfor that role. -
TRUNCATEcovered separately (privilege or a per-table trigger). -
DELETEandUPDATElimited by grants and row-level security. - The trigger tested on a copy, including a deliberate failure.
- Migrations run as a different role.
- No superuser credentials anywhere the agent can read.
For the role and limits side, see safe Postgres access for agents. To grade the role your agent connects with, run npx deedglass scan --postgres-dsn-env DATABASE_URL, which lists the tables that role can write (how the grade works), or paste an MCP config into the check page; to try tool-call rules in your browser, open the playground.
Written with AI assistance and checked against our claims register.