From 31f3248c5ae07de229bbdaf6e2f1ae62deea3a4d Mon Sep 17 00:00:00 2001 From: Sandeep Date: Sat, 11 Jul 2026 17:13:26 -0700 Subject: [PATCH 1/5] docs(agami): elevate read-only role to a stated deploy obligation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reframe readonly-grants.md from "optional / recommended" to the two-posture model: single-player = strongly recommended; hosted deploy = a requirement. Make the app-role vs operator/owner-role split explicit (writes stay on a separate privileged identity; never point DATASOURCE_URL at owner creds), and call out that agami's own store (APP_DATABASE_URL) is a separate read-write DB. Add the integrity/confinement-vs-availability residual note: the role is the primary non-bypassable control for what a query can touch, but does NOT bound runaways or block recon — timeout + row cap + recon denial are app-side. Make "SELECT-only is the whole guarantee here" explicit for BigQuery-IAM and SQLite/DuckDB. Spec: ACE-036 Co-Authored-By: Claude Opus 4.8 (1M context) --- plugins/agami/shared/readonly-grants.md | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/plugins/agami/shared/readonly-grants.md b/plugins/agami/shared/readonly-grants.md index 1455bc7c..f2db0cd4 100644 --- a/plugins/agami/shared/readonly-grants.md +++ b/plugins/agami/shared/readonly-grants.md @@ -1,8 +1,21 @@ # Read-only database user — copy-paste grants -agami only ever runs **read-only SELECT** queries (query generation refuses `INSERT` / `UPDATE` / `DELETE` / DDL, and the local MCP server double-checks every statement). So the safest thing to connect it to is a **read-only user** that can read your data and nothing else. It's optional — read-write credentials work too — but recommended, especially against a production database. +agami only ever runs **read-only SELECT** queries against your datasource: query generation refuses `INSERT` / `UPDATE` / `DELETE` / DDL, and the server's app-layer guard double-checks every statement. But that guard is defense-in-depth on top of the real boundary — **the database login agami connects as.** A role that can only `SELECT` is *physically* unable to write, run DDL, `COPY` to/from files, call server-side network functions, or reach another database, so a bug in the app guard cannot grant what the role does not have. That makes the read-only role the **primary, non-bypassable** safety control. -Create the user with one of the blocks below, then put **its** credentials in your profile: the `user` / `password` (or `url = …`) in `/local/credentials` for the single-player flow, or the `DATASOURCE_URL` in `agami.env` for a self-host deploy. +## Which posture applies to you + +- **Single-player (local `credentials`).** A read-only user is **strongly recommended** — especially against a production database — but read-write credentials also work; agami never uses the write. +- **Hosted / self-host deploy (`DATASOURCE_URL`).** A read-only role is a **requirement**, not a suggestion. The deployed server points `DATASOURCE_URL` at a **SELECT-only role**, and **any writes stay on a separate, privileged identity.** agami never migrates or loads your datasource — your own owner / ETL / migration role does that, and it must not be the login you hand agami. **Never point `DATASOURCE_URL` at owner or admin credentials.** + +> **agami's own store is a *different* database.** The read-only obligation here is about your **datasource** (`DATASOURCE_URL` — the data agami queries). agami's metadata store (`APP_DATABASE_URL` / `AGAMI_DB_URL`), which the server migrates on boot, is a **separate** database and needs its normal **read-write** user — do **not** make that one read-only. + +## What the role does and does not guarantee + +The read-only role is the **primary, non-bypassable** guarantee of **integrity and confinement**: SELECT-only means **no write / DDL / `COPY` / file access / server-side network call / cross-database reach** — and that holds even if the app-layer guard were bypassed. It does **not** bound a **runaway** query (a recursive CTE or cartesian join), and it does **not** stop schema/metadata **recon**: a per-statement **timeout + result-row cap**, and **recon-function denial + error-text sanitizing**, are enforced **app-side at the executor**, not by the role. So don't read "read-only" as "time-bounded" — the role confines *what* a query can touch, the app layer bounds *how much* it can consume. + +## Creating the role + +Create the user/role with one of the blocks below, then put **its** credentials where your setup reads them: the `user` / `password` (or `url = …`) in `/local/credentials` for the single-player flow, or `DATASOURCE_URL` in `agami.env` for a deploy. Replace the `<…>` placeholders — ``, ``, ``, ``, ``, ``, ``, and the `agami_ro` user/role name — with your values (each block uses only some of them). For **multiple schemas**, repeat the `USAGE` + `SELECT` grants once per schema. @@ -109,6 +122,8 @@ bq add-iam-policy-binding --member="serviceAccount:agami-ro@.iam.gservi --role="roles/bigquery.dataViewer" : ``` +Here the two IAM roles above **are** the whole guarantee — BigQuery has no SQL-level role to scope further and no role-level statement timeout. Confinement comes from `dataViewer` (read-only); runaway bounding is app-side (and you can add a BigQuery custom quota / maximum-bytes-billed as extra defense). + ## SQLite / DuckDB -File-based — there's no user or role. Safety comes from agami's **read-only SQL guard** (it refuses anything that isn't a `SELECT`) plus **filesystem permissions**. DuckDB files are additionally opened in read-only mode; SQLite is not, so for a hard guarantee point agami at a **read-only copy** of the file, or mark the file read-only for the account agami runs as. +File-based — there's no user or role, so the **read-only file / read-only open is the whole guarantee** at this layer. Safety comes from agami's **read-only SQL guard** (it refuses anything that isn't a `SELECT`) plus **filesystem permissions**. DuckDB files are additionally opened in read-only mode; SQLite is not, so for a hard guarantee point agami at a **read-only copy** of the file, or mark the file read-only for the account agami runs as. (Runaway-query bounding is still app-side, as above — the file mode only stops writes.) From 870cb6c7c5f615b72247fbdaa0b977176eea7075 Mon Sep 17 00:00:00 2001 From: Sandeep Date: Sat, 11 Jul 2026 17:15:11 -0700 Subject: [PATCH 2/5] docs(deploy): link the read-only recipe as the required DATASOURCE_URL posture The self-host guide (F4) now references readonly-grants.md as the required datasource posture, not a recommendation: - deploy/README.md: elevate the security-note from "safest thing to connect" to "required posture for a deploy"; spell out the datasource role vs the APP_DATABASE_URL store (which stays read-write). - docs/self-hosting.md: add DATASOURCE_URL to the config table (must be a read-only SELECT-only role; separate DB from the store) + link the recipe. - credentials-format.md: note single-player = recommended, hosted deploy = required, same recipe, and the app-role vs operator-role split. Spec: ACE-036 Co-Authored-By: Claude Opus 4.8 (1M context) --- deploy/README.md | 13 +++++++++---- docs/self-hosting.md | 3 ++- plugins/agami/shared/credentials-format.md | 2 ++ 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/deploy/README.md b/deploy/README.md index a7600c82..7eb09596 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -15,7 +15,7 @@ exposed ports. ## Deploy (the secure VM bundle — the default) ```bash git clone && cd /deploy -cp agami.env.example agami.env # then edit: PUBLIC_BASE_URL (your hostname), admin email + password, DATASOURCE_URL (use a read-only DB user — see Security notes) +cp agami.env.example agami.env # then edit: PUBLIC_BASE_URL (your hostname), admin email + password, DATASOURCE_URL (point at a read-only DB role — required; see Security notes) ln -s /path/to/your/agami-artifacts ./artifacts # or set AGAMI_ARTIFACTS_DIR in agami.env ./deploy.sh # validates agami.env, generates the signing secret, builds + starts ``` @@ -45,9 +45,14 @@ re-ingests the model on boot. **No rebuild, no new VM, no database access** — gated by per-user OAuth. Both need the HTTPS that Caddy provides. - `agami.env` holds the signing secret and DB creds — it stays on your host and is never committed. **No data ever leaves your environment.** -- **Use a read-only warehouse user** in `DATASOURCE_URL` — agami only runs read-only SELECTs and never needs - write access, so a read-only user is the safest thing to connect. Copy-paste `GRANT SELECT` SQL per database - is in [../plugins/agami/shared/readonly-grants.md](../plugins/agami/shared/readonly-grants.md). +- **Point `DATASOURCE_URL` at a read-only database role — this is the required posture for a deploy, not + just a nicety.** agami only ever runs read-only SELECTs against your datasource, and the read-only role is + the primary, non-bypassable control: a login that can only `SELECT` can't write, run DDL, `COPY`, reach + files/network, or touch another database even if the app-layer guard were bypassed. Copy-paste + `CREATE ROLE` / `GRANT SELECT` SQL per engine — and the app-role vs operator-role split — is in + [../plugins/agami/shared/readonly-grants.md](../plugins/agami/shared/readonly-grants.md). This is your + **datasource** role; agami's own store (`APP_DATABASE_URL`) is a *separate* database and keeps its normal + **read-write** user. - **Always deploy via `./deploy.sh`** (it runs the preflight). If you `docker compose up` directly without having run the preflight, `AGAMI_PUBLIC_HOST` is unset and Caddy fails to start (loudly, never insecurely) — run `python -m deploy_preflight agami.env` first. diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 6d50353e..d094ac26 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -42,7 +42,8 @@ and the other tools just read the model. Nothing leaves your environment. | Variable | Required | Purpose | |----------|----------|---------| -| `AGAMI_DB_URL` | yes (server) | The store: `postgresql://…` in prod, `sqlite://…` for a small/local run. `APP_DATABASE_URL` is accepted as an alias for the cloud-platform convention (`AGAMI_DB_URL` wins if both are set). Unset ⇒ the local file path. | +| `AGAMI_DB_URL` | yes (server) | The **store** (agami's own metadata DB — the server migrates + writes it on boot): `postgresql://…` in prod, `sqlite://…` for a small/local run. `APP_DATABASE_URL` is accepted as an alias for the cloud-platform convention (`AGAMI_DB_URL` wins if both are set). Unset ⇒ the local file path. This is **not** your datasource — keep its user **read-write**. | +| `DATASOURCE_URL` | yes (to query) | The **datasource** agami runs generated SQL against — a `postgresql://…` / `mysql://…` / etc. DSN. **Must point at a read-only (SELECT-only) role** — that role is the primary, non-bypassable safety boundary; see [read-only grants](../plugins/agami/shared/readonly-grants.md) for the per-engine recipe and the app-role vs operator-role split. A **different** database from the store above; never point it at owner/admin creds. Several datasources ⇒ one `DATASOURCE_URL__` per datasource (token = the datasource id upper-cased, non-alphanumerics → `_`). | | `PUBLIC_BASE_URL` | yes (server) | Backs OAuth/MCP discovery + the `WWW-Authenticate` resource URL. Set it explicitly — it can't be auto-detected behind a proxy/LB; the server fails fast at startup if it's missing. | | `AGAMI_ORG_ID` | no | The single configured org id (default `local`). The server is single-tenant by default. | | `AGAMI_SIGNING_SECRET` | yes (auth) | ≥32-byte secret the server signs its session JWTs with — this is what turns on **real per-user login** (admin password + per-user `/mcp` OAuth). **Required for any internet-facing deploy;** the `/agami-deploy` bundle generates and persists it for you. Unset ⇒ a local bearer-presence default only (single-user, not per-user). | diff --git a/plugins/agami/shared/credentials-format.md b/plugins/agami/shared/credentials-format.md index c457cec9..983e0426 100644 --- a/plugins/agami/shared/credentials-format.md +++ b/plugins/agami/shared/credentials-format.md @@ -24,6 +24,8 @@ Resolution order when a skill needs to know which profile to use: agami only ever runs read-only SELECT queries, so the `user` you put here only needs read access. Connecting a **read-only database user** is the safest posture (especially against production) and never limits what agami can do. Copy-paste `CREATE USER` / `GRANT SELECT` SQL for every dialect is in [`readonly-grants.md`](readonly-grants.md). +For the **single-player** flow (this file) a read-only user is *recommended*. For a **hosted / self-host deploy** — where the datasource DSN travels in `DATASOURCE_URL`, not this file — a read-only role is the **required** posture, not just a nicety; the same [`readonly-grants.md`](readonly-grants.md) recipe covers both, and explains the app-role vs operator-role split. + ## Format ```ini From 7924ad793fab6e2f112fdeac797127b7d69e1d53 Mon Sep 17 00:00:00 2001 From: Sandeep Date: Sat, 11 Jul 2026 17:15:39 -0700 Subject: [PATCH 3/5] docs(changelog): note the read-only deploy obligation (ACE-036) Unreleased entry only; no version bump (docs-only, accumulates until release). Spec: ACE-036 Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40fd82b0..1596e48b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,12 @@ below corresponds to one such version. cleared at authorize, and the query/activity logs (`query_executions` / `tool_calls`) are explicitly **retained** — never deleted by any default path — with a new `idx_query_executions_ts` index to keep newest-first reads fast as history grows. +- **Read-only datasource role is now a stated deploy obligation, not a suggestion (ACE-036).** The + self-host guide and `readonly-grants.md` now frame the SELECT-only role as the **required** + `DATASOURCE_URL` posture for a deploy (single-player stays *recommended*), spell out the app-role vs + operator/owner-role split, and clarify that the role guarantees integrity/confinement but **not** + availability/recon (per-statement timeout + row cap + recon denial are app-side). Docs only — no + behaviour or config change. ## [0.3.9] — 2026-07-10 From 7a2e3e50e904ecb17ae760ea85bd8bf5ce3c3e85 Mon Sep 17 00:00:00 2001 From: Sandeep Date: Sat, 11 Jul 2026 17:23:47 -0700 Subject: [PATCH 4/5] docs(agami): address ACE-036 security-review nits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - readonly-grants.md: add an OPTIONAL hardening note for PostgreSQL ≤14 / Redshift — a fresh role inherits CREATE-on-public + TEMP via the built-in PUBLIC role, so the flagship grant isn't strictly SELECT-only there until you REVOKE those from PUBLIC (noted as database-wide; PG15+ already drops the public CREATE default). Optional per the SELECT-only baseline (Q1), so it makes the "physically unable to run DDL" claim precise without mandating it. - docs/self-hosting.md: the DATASOURCE_URL env-DSN channel only parses postgres/redshift/mysql/snowflake/bigquery/sqlite — replace the vague "etc." with the real scheme list and point other engines at the file credentials channel, so an operator on SQL Server/Oracle/Databricks/Trino doesn't hit an "Unsupported scheme" wall. Spec: ACE-036 Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/self-hosting.md | 2 +- plugins/agami/shared/readonly-grants.md | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/self-hosting.md b/docs/self-hosting.md index d094ac26..be7c057a 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -43,7 +43,7 @@ and the other tools just read the model. Nothing leaves your environment. | Variable | Required | Purpose | |----------|----------|---------| | `AGAMI_DB_URL` | yes (server) | The **store** (agami's own metadata DB — the server migrates + writes it on boot): `postgresql://…` in prod, `sqlite://…` for a small/local run. `APP_DATABASE_URL` is accepted as an alias for the cloud-platform convention (`AGAMI_DB_URL` wins if both are set). Unset ⇒ the local file path. This is **not** your datasource — keep its user **read-write**. | -| `DATASOURCE_URL` | yes (to query) | The **datasource** agami runs generated SQL against — a `postgresql://…` / `mysql://…` / etc. DSN. **Must point at a read-only (SELECT-only) role** — that role is the primary, non-bypassable safety boundary; see [read-only grants](../plugins/agami/shared/readonly-grants.md) for the per-engine recipe and the app-role vs operator-role split. A **different** database from the store above; never point it at owner/admin creds. Several datasources ⇒ one `DATASOURCE_URL__` per datasource (token = the datasource id upper-cased, non-alphanumerics → `_`). | +| `DATASOURCE_URL` | yes (to query) | The **datasource** agami runs generated SQL against, as a DSN. The env-DSN channel accepts `postgres` / `redshift` / `mysql` / `snowflake` / `bigquery` / `sqlite` schemes; other engines (SQL Server, Oracle, Databricks, Trino) connect via the file `credentials` channel instead. **Must point at a read-only (SELECT-only) role** — that role is the primary, non-bypassable safety boundary; see [read-only grants](../plugins/agami/shared/readonly-grants.md) for the per-engine recipe and the app-role vs operator-role split. A **different** database from the store above; never point it at owner/admin creds. Several datasources ⇒ one `DATASOURCE_URL__` per datasource (token = the datasource id upper-cased, non-alphanumerics → `_`). | | `PUBLIC_BASE_URL` | yes (server) | Backs OAuth/MCP discovery + the `WWW-Authenticate` resource URL. Set it explicitly — it can't be auto-detected behind a proxy/LB; the server fails fast at startup if it's missing. | | `AGAMI_ORG_ID` | no | The single configured org id (default `local`). The server is single-tenant by default. | | `AGAMI_SIGNING_SECRET` | yes (auth) | ≥32-byte secret the server signs its session JWTs with — this is what turns on **real per-user login** (admin password + per-user `/mcp` OAuth). **Required for any internet-facing deploy;** the `/agami-deploy` bundle generates and persists it for you. Unset ⇒ a local bearer-presence default only (single-user, not per-user). | diff --git a/plugins/agami/shared/readonly-grants.md b/plugins/agami/shared/readonly-grants.md index f2db0cd4..f6532be7 100644 --- a/plugins/agami/shared/readonly-grants.md +++ b/plugins/agami/shared/readonly-grants.md @@ -32,6 +32,16 @@ ALTER DEFAULT PRIVILEGES IN SCHEMA GRANT SELECT ON TABLES TO agami_ro; (Redshift uses the same statements. `` defaults to `public` if you didn't set one.) +> **Optional hardening (PostgreSQL ≤ 14 / Redshift).** A fresh role inherits `CREATE` on schema `public` +> and `TEMP` on the database from the built-in `PUBLIC` role — so, strictly, it could create *new* objects +> (it still can't read or modify your existing data). To make it pure SELECT-only, once per database: +> ```sql +> REVOKE CREATE ON SCHEMA public FROM PUBLIC; +> REVOKE TEMP ON DATABASE FROM PUBLIC; +> ``` +> Note these affect **every** non-owner role on that database, not just `agami_ro`. PostgreSQL 15+ already +> drops the public-schema `CREATE` default. Optional, not required (per the SELECT-only baseline). + ## MySQL / MariaDB ```sql From 6a0f3e87f3a87e1859aebc4c5bb45919abadbe9b Mon Sep 17 00:00:00 2001 From: Sandeep Date: Sat, 11 Jul 2026 18:45:24 -0700 Subject: [PATCH 5/5] =?UTF-8?q?docs(agami):=20address=20Copilot=20review?= =?UTF-8?q?=20=E2=80=94=20don't=20overstate=20app-side=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - readonly-grants.md: the executor surfaces raw DB error text today (execute_sql.py `_err(f"... {e}")`); recon-function denial + error-text sanitizing are ACE-039 (not yet shipped). Reframe the residual note to state only what's enforced now (result-row cap) and describe timeout + recon/error hardening as the app layer's responsibility, not a shipped guarantee — still satisfies SC4 (availability/recon are app-side, not the role's). - self-hosting.md: correct the env-DSN scheme list to include the accepted aliases (postgresql, mariadb, bq) and soften the "required" cell — schemeless engines (SQL Server, Oracle, Databricks, Trino, DuckDB) use the file credentials channel, so DATASOURCE_URL isn't unconditionally required. Spec: ACE-036 Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/self-hosting.md | 2 +- plugins/agami/shared/readonly-grants.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/self-hosting.md b/docs/self-hosting.md index be7c057a..88ccbe4b 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -43,7 +43,7 @@ and the other tools just read the model. Nothing leaves your environment. | Variable | Required | Purpose | |----------|----------|---------| | `AGAMI_DB_URL` | yes (server) | The **store** (agami's own metadata DB — the server migrates + writes it on boot): `postgresql://…` in prod, `sqlite://…` for a small/local run. `APP_DATABASE_URL` is accepted as an alias for the cloud-platform convention (`AGAMI_DB_URL` wins if both are set). Unset ⇒ the local file path. This is **not** your datasource — keep its user **read-write**. | -| `DATASOURCE_URL` | yes (to query) | The **datasource** agami runs generated SQL against, as a DSN. The env-DSN channel accepts `postgres` / `redshift` / `mysql` / `snowflake` / `bigquery` / `sqlite` schemes; other engines (SQL Server, Oracle, Databricks, Trino) connect via the file `credentials` channel instead. **Must point at a read-only (SELECT-only) role** — that role is the primary, non-bypassable safety boundary; see [read-only grants](../plugins/agami/shared/readonly-grants.md) for the per-engine recipe and the app-role vs operator-role split. A **different** database from the store above; never point it at owner/admin creds. Several datasources ⇒ one `DATASOURCE_URL__` per datasource (token = the datasource id upper-cased, non-alphanumerics → `_`). | +| `DATASOURCE_URL` | to query (or file creds) | The **datasource** agami runs generated SQL against, as a DSN. The env-DSN channel accepts the `postgres` / `postgresql`, `redshift`, `mysql` / `mariadb`, `snowflake`, `bigquery` / `bq`, and `sqlite` schemes; engines without a DSN scheme (SQL Server, Oracle, Databricks, Trino, DuckDB) connect via the file `credentials` channel instead. Whichever channel you use, it **must point at a read-only (SELECT-only) role** — that role is the primary, non-bypassable safety boundary; see [read-only grants](../plugins/agami/shared/readonly-grants.md) for the per-engine recipe and the app-role vs operator-role split. A **different** database from the store above; never point it at owner/admin creds. Several datasources ⇒ one `DATASOURCE_URL__` per datasource (token = the datasource id upper-cased, non-alphanumerics → `_`). | | `PUBLIC_BASE_URL` | yes (server) | Backs OAuth/MCP discovery + the `WWW-Authenticate` resource URL. Set it explicitly — it can't be auto-detected behind a proxy/LB; the server fails fast at startup if it's missing. | | `AGAMI_ORG_ID` | no | The single configured org id (default `local`). The server is single-tenant by default. | | `AGAMI_SIGNING_SECRET` | yes (auth) | ≥32-byte secret the server signs its session JWTs with — this is what turns on **real per-user login** (admin password + per-user `/mcp` OAuth). **Required for any internet-facing deploy;** the `/agami-deploy` bundle generates and persists it for you. Unset ⇒ a local bearer-presence default only (single-user, not per-user). | diff --git a/plugins/agami/shared/readonly-grants.md b/plugins/agami/shared/readonly-grants.md index f6532be7..e6c517b3 100644 --- a/plugins/agami/shared/readonly-grants.md +++ b/plugins/agami/shared/readonly-grants.md @@ -11,7 +11,7 @@ agami only ever runs **read-only SELECT** queries against your datasource: query ## What the role does and does not guarantee -The read-only role is the **primary, non-bypassable** guarantee of **integrity and confinement**: SELECT-only means **no write / DDL / `COPY` / file access / server-side network call / cross-database reach** — and that holds even if the app-layer guard were bypassed. It does **not** bound a **runaway** query (a recursive CTE or cartesian join), and it does **not** stop schema/metadata **recon**: a per-statement **timeout + result-row cap**, and **recon-function denial + error-text sanitizing**, are enforced **app-side at the executor**, not by the role. So don't read "read-only" as "time-bounded" — the role confines *what* a query can touch, the app layer bounds *how much* it can consume. +The read-only role is the **primary, non-bypassable** guarantee of **integrity and confinement**: SELECT-only means **no write / DDL / `COPY` / file access / server-side network call / cross-database reach** — and that holds even if the app-layer guard were bypassed. It does **not** bound a **runaway** query (a recursive CTE or cartesian join), and it does **not** stop schema/metadata **recon**. Those are the **app layer's** job, not the role's: the executor caps the **result-row count**, and query-resource limits (statement timeout) plus recon/error-text hardening are enforced above the grant, not by it. So don't read "read-only" as "time-bounded" — the role confines *what* a query can touch; bounding *how much* it consumes is handled app-side. ## Creating the role