Security: read-only guard

Connections are writable by default. read_only: true on a connection, --read-only on any command, or :readonly on in the terminal UI turns on a hard guard that the query text cannot lift. A refused write exits with code 4. This page explains how the guard works on each driver and what it cannot stop.

Quick start

connections:
  - name: prod
    dsn: postgres://reader@db.example.com/app?sslmode=require
    password: env:PROD_PASSWORD
    read_only: true
tobira query prod "delete from orders" ; echo $?   # refused, exit code 4

How the guard works

  • The database session enforces read-only wherever the database allows it: a read-only transaction, a read-only file handle, a read-only setting.
  • In front of that, a statement check refuses anything it does not recognise as a read. These checks read the statement’s text, and what a check cannot recognise as a read is refused before it is sent.
  • The AI assistant and the MCP server run their query tools only on connections whose driver has the guard, and always with the read-only flag.

The guard stops accidental writes. It is not a sandbox. A PostgreSQL superuser, for example, can still reach server-side programs or files through functions that build SQL at run time. For untrusted use, connect with a least-privilege role (on PostgreSQL, one without pg_execute_server_program or pg_write_server_files) as the real boundary.

Per-driver read-only layers

The last column names what the guard cannot stop, as each driver page documents it. A dash means the page lists no such gap, not that there is none.

DriverSession layerStatement layerDoes not stop
SQLitea read-only handle with query_onlyone statement: SELECT, WITH, VALUES or a read-only PRAGMA, optionally behind EXPLAIN [QUERY PLAN]—
PostgreSQLa read-only transaction per call and a read-only session defaultone statement per query; COPY, DO and server-file functions such as lo_export refused, also in every exporta superuser reaching server programs or files through functions that build SQL at run time; dblink
MySQLa read-only transaction per call, a post-check that the statement stayed inside it, a pinned utf8mb4 charsetan allow-list of statement kinds; MySQL 8.0 to 8.4 only, each query on its own connection—
MariaDBMySQL’s, plus a sql_mode check on every read-only callMySQL’s allow-list, plus /*M! */ comments, sequences, RETURNING, compound statements and EXECUTE IMMEDIATE refusedCONNECT, SPIDER and FEDERATED tables, which run statements on a remote server
SingleStorenone: SingleStore has no read-only transactionnone: read-only connections and queries are refusedeverything; use a SELECT-only user
CockroachDBthe PostgreSQL layers; default_transaction_read_only=onan allow-list (SELECT, WITH, VALUES, TABLE, SHOW, EXPLAIN of those); EXPORT, BACKUP, CREATE CHANGEFEED, SHOW BACKUP and crdb_internal functions refused—
Amazon Redshifta read-only transaction per callthe same allow-list; UNLOAD, COPY, CALL and SELECT … INTO refused—
Greenplumthe PostgreSQL layersthe PostgreSQL guardexternal and web tables, which read (or run) what their definition names
SQL Servera transaction that is always rolled back, under SNAPSHOT when allowedone SELECT, WITH … SELECT or sp_help…; EXEC, SET, GO, OPENROWSET, OPENQUERY, xp_…, BULK and locking hints refuseduser-defined functions reaching out through extended procedures or CLR; linked servers (four-part names)
OracleSET TRANSACTION READ ONLY, checked not to have ended, then rolled backone SELECT, WITH or parenthesised query; PL/SQL, CALL, DDL, DML, FOR UPDATE, NEXTVAL, database links and DBMS_*, UTL_* and similar packages refuseduser functions with autonomous transactions; synonyms hiding a package or a remote table; XML external entities on unpatched servers
VerticaSTART TRANSACTION READ ONLY, always rolled backone SELECT, WITH, SHOW or EXPLAIN; only allow-listed built-in functions (meta-functions refused); no COPY … FROM LOCALuser-defined functions (UDx) inside a view or overloading a built-in name
SnowflakeMULTI_STATEMENT_COUNT=1 (no read-only session exists)one SELECT, WITH, SHOW, DESCRIBE or EXPLAIN; no ->>, stage references, CALL, COPY, SYSTEM$ functions or unknown table functionsuser-defined and external functions called from a SELECT
BigQuerya dry run whose statement type must be SELECT; maximumBytesBilled on every jobone SELECT or WITH; scripts, DML, DDL, EXPORT and EXTERNAL_QUERY refusedremote and ML functions called from a SELECT (they can send data to an outside model)
libSQL / TursoBEGIN … ROLLBACK on a fresh stream; a read-only token if you use onethe SQLite allow-list, plus no write words—
Cloudflare D1none (no read-only mode or transactions over REST)the SQLite allow-list, no write words, one statement—
MongoDBnone (MongoDB has no read-only session)the command document is checked as sent against an allow-list (find, aggregate, count, distinct, the list and stats commands, explain of the first four); $out, $merge, session and write-concern fields refusedserver CPU used by $where and $function
Cloud Firestorenone (no read-only protocol mode)the request’s keys are an allow-list of reads; no write verb can be expressed—
Azure Cosmos DBCosmos SQL cannot writeone SELECT, no user-defined function (udf.…); stored procedures and triggers are never run—
Amazon DynamoDBnone (no read-only session)one PartiQL SELECT, no write verb, no Ion literal—
Cassandranone (no read-only session)one SELECT, DESCRIBE or DESC; built-in functions onlycost: ALLOW FILTERING can scan a whole table
Redisthe server’s own COMMAND INFO flags, so a renamed command is still refusedan allow-list of read commands; scripts, MULTI, CONFIG, SELECT, KEYS and every write refused—
ClickHousereadonly=1 on every read-only query; for a user already read-only, the server’s own parse of each query is checkedan allow-list (SELECT, WITH, SHOW, DESCRIBE, EXPLAIN, EXISTS), one statement, no INTO OUTFILE; outside-reaching table functions refusedserver tables and dictionaries that read from outside (URL, S3, external dictGet)
Elasticsearch / OpenSearchnone (no read-only session)a read allow-list of endpoints and parameters; no script key; system and hidden indexes refused; inference keys refused (they send data to an outside service)—
InfluxDBInfluxDB 3’s query API cannot writeone SELECT, WITH … SELECT, SHOW or EXPLAIN without INTO—
Qdrantqueries never write, on any connectiononly the five read operations run; document, image and object vector inputs refused (the server would send them to an outside model); editing refused on read-only connections—
Pineconequeries never write, on any connectiononly the read operations run; a write member hidden in a read refused—
Weaviatequeries never write, on any connectionplain reads only; nearText and hybrid refused unless every vectorizer is local; generate, ask and rerank refused (they send data to an outside service)—
Chromaqueries never write, on any connectionget, query and count only; query texts only through tobira’s embedder, so the server sends nothing out—
Milvusqueries never write; a read-only connection never loads a collectionquery, get, count and search only; filter functions off the read-only list refused; TEXTEMBEDDING search and the model reranker refused (they send data to an outside service)—
pgvectora similarity search runs in a read-only transaction that is rolled back, on read-write connections toothe PostgreSQL guardas PostgreSQL
DuckDB (plugin)a READ ONLY transaction, rolled back; every instance with external access off and its configuration lockedone statement; no file, URL or SQL-running functions, COPY, ATTACH or INSTALL; only parsed SELECT and EXPLAIN run—

The guards read text and names, so none can see inside code that already lives on the server: user-defined functions, procedures they call, and remote or external tables. Pair every read-only connection with a database user, role or key that can only read; that is the real boundary.

A plugin’s guard

A plugin driver that declares the read-only guard is trusted to enforce it. tobira cannot check that from outside. See Plugins.

When a write is refused and you did not expect it, see read-only refusals.