Agent-First PSQL v0.13.0: Read-Only Is a Transaction Mode

by Agent-First Kit Contributors

afpsql-readonly let `;COMMIT` through, accepted ProxyJump hops that could reopen command execution, and checked a pipe session's container runtime without the environment it would connect with. All three are closed, and the docs now say plainly what a read-only transaction does not stop. Plus explicit connection fields that beat environment DSNs, secrets kept out of parse errors, and a reconciled storage inspection report.

afpsql-readonly is the entrypoint you hand an agent when it should look and not touch. It runs every statement inside a read-only transaction and keeps the connection path to a fixed set of options. This release closes three ways around that, and is honest about what a read-only transaction never promised.

Three ways around readonly, closed

PostgreSQL drops empty statements, so ;COMMIT is a single COMMIT and passes the one-statement limit. The readonly classifier stopped at the ;, found no keyword, and let it through. A pipe client could end the real transaction behind afpsql’s back, or, with prepared transactions enabled, leave a PREPARE TRANSACTION holding its locks. Leading ; and vertical tabs are now skipped like whitespace, and SET transaction_read_only counts as transaction control too.

ProxyJump is on the readonly SSH allowlist, but OpenSSH runs a jump through a shell-built ProxyCommand, and older OpenSSH releases pass the host through unchecked. An allowed ProxyJump could reopen the command execution the allowlist keeps out. Each hop must now be [user@]host[:port], with IPv6 in brackets, and may not start with -.

The container runtime variables (AFPSQL_CONTAINER_*_RUNTIME) are merged when afpsql connects. CLI mode merged them while parsing, so the readonly policy saw them, but a session added by a pipe config request was checked without them and then ran the custom runtime anyway. The policy now judges the configuration as it will connect. A pinned profile never reads them.

What a read-only transaction does not stop

A read-only transaction stops writes to tables. It does not stop a privileged role from running CLUSTER, REINDEX, ANALYZE or LOCK TABLE, from COPY ... TO a file or a program, from lo_export, from terminating other backends, or from writing through dblink_exec. The docs now say so: readonly is a transaction mode, not a privilege boundary. If the role must not do those things, give it a role that cannot.

The target you named

An explicit --host, --port, --user or --dbname was silently discarded whenever AFPSQL_DSN_SECRET or AFPSQL_CONNINFO_SECRET was set, so afpsql connected to the environment’s target instead of the one you named. Explicit fields now override the environment’s connection string, and an explicit connection string wins over either environment form.

The environment is read more carefully too:

  • an invalid port in a selected environment variable is rejected instead of falling through;
  • when empty AFPSQL_* variables leave libpq’s own PG* variables in charge, afpsql reports those fallbacks rather than hiding them;
  • percent-encoded TLS options in a DSN are decoded before the TLS policy checks them, so an escaped sslmode cannot slip past it.

Secrets stay out of parse errors

A connection string is a secret as a whole. The parser’s diagnostics could quote an unknown option or a malformed value, and with it a password. Parse errors now carry only afpsql’s own text and a hint about where to look.

Storage inspection

afpsql inspect storage returns one report of where a database’s bytes are: the database size before and after, every relation’s forks counted once, the files in the database’s directories grouped by kind, PGroonga’s database-wide files, and a reconciliation that says how much is accounted for and how much is not. Every query is read-only and reads no application rows, so it runs through afpsql-readonly and locked profiles too.

Each section reports complete, partial or unknown with its reason, and a missing basis comes out as null rather than a made-up zero. A failed initial connection names its cause: connect_failed, invalid_request or storage_inspection_failed.

Smaller things

  • The container bridge writes all socket data to stdout even when a write is short.
  • The test fixtures and CI run PostgreSQL 18.6, and the container bridge image is on Ubuntu 26.04.

Breaking changes

  • afpsql-readonly refuses ;COMMIT-style statements and SET transaction_read_only.
  • afpsql-readonly accepts only host-shaped ProxyJump hops.
  • Explicit connection fields and connection strings override environment DSNs. Unset the environment variable if you relied on it winning.
  • An invalid environment port is an error.
  • Agent-First UI 0.9.0 and Agent-First Data 0.36.1 are the pinned dependencies, and Rust 1.99 is the minimum.

Getting it

$ brew install agentfirstkit/tap/afpsql
$ cargo install agent-first-psql