Creating the PostgreSQL role agent mode will accept
Agent AUTO mode refuses a superuser connection outright, so the deployment task is a login holding CONNECT, USAGE and SELECT and nothing else.
A read-only transaction is not a sandbox. That is the finding the whole
provisioning step rests on, and it is why agent mode makes you create a
dedicated login rather than offering a setting inside the product. Verified on
PostgreSQL 18, inside BEGIN READ ONLY, as a superuser:
COPY (...) TO '<path>' wrote query results to a server-side file,
COPY (...) TO PROGRAM '<cmd>' ran a shell command as the server’s OS user, and
SELECT pg_read_file('<path>') read a server-side file. None of the three
changed the database, so none of the three was refused.
Why the agent asks for a second role at all
Agent AUTO mode’s read-only boundary on this engine is the database, not a SQL
parser. Every statement runs through queryReadOnly(), which issues
BEGIN READ ONLY, sets SET LOCAL statement_timeout to the run’s remaining
budget, sends exactly one statement on the extended query protocol, then
ROLLBACK and DISCARD ALL. A write is refused by PostgreSQL with SQLSTATE
25006; a multi-command string is refused in the Parse message with SQLSTATE
42601, before anything executes. No statement is classified in that path,
which is the point - there is no parser to fool.
What that transaction covers is changes to the database. What it does not cover
is a statement reaching the server. The three statements above are the proof.
The transaction also relaxes on request: SET TRANSACTION READ WRITE is
accepted inside BEGIN READ ONLY and does work, verified on 18 by a following
INSERT that committed. What contains it is that it can only ever be the
transaction’s only statement before the unconditional rollback. The
single-statement rule is load-bearing, not decorative.
So the file and program statements have to be refused by something else, and on
PostgreSQL the something else is privileges. A role holding only CONNECT,
USAGE and SELECT is denied all three, because each one requires superuser or
membership of a predefined role.
The privileges the profile probes before it opens
The product does not print this as advice and hope. Opening the agent execution profile probes the connecting role and refuses to connect at all unless four answers all read back false:
| Probed | Why it matters |
|---|---|
rolsuper |
a superuser bypasses every check below |
pg_read_server_files |
membership grants pg_read_file() and friends |
pg_write_server_files |
membership grants COPY (...) TO '<path>' |
pg_execute_server_program |
membership grants COPY (...) TO PROGRAM '<cmd>' |
The refusal is PROFILE_PRIVILEGES_TOO_BROAD, an ExecutionProfileError
carrying a deny code, so callers branch on the code and never on a message. The
probe uses to_regrole, so a server that does not define one of the predefined
roles answers false rather than erroring. A server that answers nothing, or
answers something that is not a boolean, is refused as well: an unproven
boundary is not a boundary.
Every catalog function the probe calls is written pg_catalog-qualified.
pg_catalog is searched implicitly first only while it is not named in
search_path, so a path that names it explicitly behind another schema could
otherwise let a shadow pg_has_role() answer false for a superuser and defeat
the check.
Two limits on what the probe proves. It proves non-membership and
non-superuser, not the absence of the capability:
a role directly granted EXECUTE on pg_read_file() answers false to all four
flags and can still read server files. And it runs once, at open. A profiled
provider stays cached until the 30-minute idle sweep, so a role granted new
privileges after that keeps serving from the already-verified pool until it is
evicted.
CREATE ROLE, CONNECT, USAGE, SELECT, and nothing else
This is the whole deployment task.
CREATE ROLE libredb_agent LOGIN PASSWORD '<secret>';
GRANT CONNECT ON DATABASE <db> TO libredb_agent;
GRANT USAGE ON SCHEMA <schema> TO libredb_agent;
GRANT SELECT ON ALL TABLES IN SCHEMA <schema> TO libredb_agent;
-- Grant nothing else. In particular do NOT grant pg_read_server_files,
-- pg_write_server_files, pg_execute_server_program, or superuser.
The grants are also the read boundary, not only the write one. The policy layer
screens the catalog and schema an agent declares it is reading; the per-table
SELECT grants are what bound what a hostile statement could actually reach
instead. A table you do not grant is a table no run can read, whatever it asks
for.
One consequence of running this narrow: the agent’s
relations inventory reads pg_constraint rather than the information_schema
constraint views, because PostgreSQL restricts those views to constraints on
tables the role owns or holds a privilege on other than SELECT. Against a
seeded dvdrental, libredb_agent read an empty foreign-key graph from those
views, where pg_constraint WHERE contype = 'f' holds 18 rows. The column
inventory is still the privilege-filtered one, and that is correct rather than a
defect: the agent should see the tables you granted it and no others.
Why a superuser connection is refused rather than downgraded
The obvious alternative is to accept the superuser connection you already have
and drop privileges for the duration of the run. The product does not, and the
reason is the three statements this post opens with. Every mechanism available
for that downgrade - the read-only transaction, a session SET, a wrapper
around the statement text - is either something a single statement can undo or
something that never covered COPY ... TO PROGRAM in the first place. A
boundary you can talk your way out of inside one statement is not one worth
shipping under the word read-only.
Agent AUTO mode runs on PostgreSQL, SQLite and DuckDB only, because the
read-only profile is database-native and exists only where a provider implements
it. Even on PostgreSQL, a superuser connection is refused with
PROFILE_PRIVILEGES_TOO_BROAD. On every other engine an auto run ends
engine-unsupported. Agent PLAN mode opens on every connection: it is toolless,
it executes nothing, and it drafts a statement for a human to run.
Those two modes are not degraded versions of each other.
There is a second, quieter reason. queryReadOnly() exists only on a provider
opened under the profile - called on an ordinary provider it throws, because
such a provider has had no role verification. The profiled cache is physically
separate from the editor’s, so an agent acquisition can never be handed the
editor’s pool.
Checking the role from the connection dialog
The connection carries two optional fields, agentUser and agentPassword, so
the agent can authenticate as libredb_agent while the editor keeps the
credentials it already has. agentPassword is secret-classified and sealed at
rest. Resolution fails closed rather than quietly using the better-privileged
default:
| Configuration | Outcome |
|---|---|
| Neither field set | the connection’s own credentials, which must pass the probe themselves |
| Both set, password resolves | the profile pool authenticates as agentUser |
| Only one field set | AGENT_CREDENTIAL_UNRESOLVABLE |
| Sealed password that does not open | AGENT_CREDENTIAL_UNRESOLVABLE |
Either field beside a connectionString |
AGENT_CREDENTIAL_WITH_CONNECTION_STRING |
The last row is worth knowing before you paste a URL: when a connection string is present it wins over the discrete fields, so the pool config would silently drop the agent credential. That is refused instead.
You can predict the probe’s answer before you open anything. Connect as the role you intend to hand the agent and ask the same four questions the profile asks:
SELECT rolsuper,
pg_catalog.pg_has_role(current_user, 'pg_read_server_files', 'member')
AS read_files,
pg_catalog.pg_has_role(current_user, 'pg_write_server_files', 'member')
AS write_files,
pg_catalog.pg_has_role(current_user, 'pg_execute_server_program', 'member')
AS exec_program
FROM pg_catalog.pg_roles
WHERE rolname = current_user;
Four falses and the role clears the privilege probe. Anything else and the open
is refused with PROFILE_PRIVILEGES_TOO_BROAD, which names the check that
failed but not which of the four answers tripped it - the query above is how you
find that out. The rest of what agent mode does - the metering,
the citation rule, the verdict - is on the features page; what the
run is allowed to touch and what it records is on the
security page; and which engines carry the profile at all is on the
engine grid.