Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 9 additions & 4 deletions deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ exposed ports.
## Deploy (the secure VM bundle — the default)
```bash
git clone <this repo> && cd <repo>/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
```
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion docs/self-hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | 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__<TOKEN>` 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). |
Expand Down
2 changes: 2 additions & 0 deletions plugins/agami/shared/credentials-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 28 additions & 3 deletions plugins/agami/shared/readonly-grants.md
Original file line number Diff line number Diff line change
@@ -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 `<artifacts_dir>/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**. 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

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 `<artifacts_dir>/local/credentials` for the single-player flow, or `DATASOURCE_URL` in `agami.env` for a deploy.

Replace the `<…>` placeholders — `<password>`, `<db>`, `<schema>`, `<warehouse>`, `<catalog>`, `<project>`, `<dataset>`, 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.

Expand All @@ -19,6 +32,16 @@ ALTER DEFAULT PRIVILEGES IN SCHEMA <schema> GRANT SELECT ON TABLES TO agami_ro;

(Redshift uses the same statements. `<schema>` 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 <db> 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
Expand Down Expand Up @@ -109,6 +132,8 @@ bq add-iam-policy-binding --member="serviceAccount:agami-ro@<project>.iam.gservi
--role="roles/bigquery.dataViewer" <project>:<dataset>
```

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.)
Loading