Skip to content

Read-only mode is not the DuckDB boundary, external access is

Under read-only access alone, file copies, exports, extension installs and filesystem reads all succeeded while an insert was refused in the same session.

With access_mode: 'READ_ONLY' in force, INSERT was refused and read_text('/etc/hostname') returned the file. Both in the same session, on the same handle. Everything below follows from that pair of results.

The read-only flag bounds the database, not the process

DuckDB is embedded and file-based. There is no host, no port, no user and no password - the connection is a server-local path to a database file or :memory:, and the filesystem is the access control. defaultPort is null, so the connection dialog offers no port at all.

That shape is what makes the read-only flag misleading. access_mode is a property of the attached database. When it is set, the engine refuses statements that would modify that database, and it says so plainly:

Cannot execute statement of type "INSERT" on database "w" which is attached in read-only mode!

The flag was genuinely in force during every measurement below. INSERT was refused throughout. What it does not touch is the process: the DuckDB library is linked into Studio’s own process, holds Studio’s own privileges, and its file-reaching functions are ordinary reads as far as access_mode is concerned. This is the same class of escape as VACUUM INTO on SQLite and COPY TO PROGRAM on PostgreSQL, both already closed in their own providers.

What still succeeded under it, measured

Each row below was run against DuckDB v1.5.5 through @duckdb/node-api 1.5.5-r.4, on a handle carrying access_mode: 'READ_ONLY' and nothing else. The second column is the same statement on the handle the agent actually gets.

Statement READ_ONLY alone With enable_external_access: 'false'
INSERT INTO users ... refused refused
COPY (SELECT 1) TO '/path/leak.csv' (FORMAT CSV) allowed, file written Permission Error
COPY users TO '/path/leak.parquet' (FORMAT PARQUET) allowed, file written Permission Error
EXPORT DATABASE '/path/exp' allowed, directory written Permission Error
INSTALL httpfs allowed, reaches the network Permission Error
read_csv_auto('/path/x.csv') allowed, arbitrary local file read Permission Error
read_text('/etc/hostname') allowed, contents returned Permission Error
read_blob('/etc/hostname') allowed, bytes returned Permission Error
glob('/etc/*') allowed, directory listing Permission Error
sniff_csv('/etc/hostname') allowed Permission Error
ATTACH '/path/new.duckdb' AS side refused, file must exist Permission Error
CREATE TEMP TABLE then INSERT allowed allowed
SET memory_limit='1GB' allowed allowed
Ordinary reads, duckdb_*(), pragma_database_size() allowed allowed

Read the first column as a capability list for anything holding that handle: it can write a CSV anywhere the Studio process can write, read any file the process can read, and pull an extension over the network. The database file it was told to protect is the one thing it cannot touch.

Note what the second column leaves alone. Catalog reads, storage pragmas and ordinary SELECT are untouched, which is why the agent still has a usable engine after the option is set.

The option that closes the process, fixed at open

queryReadOnly opens a second handle with two engine options, both fixed at open: access_mode: 'READ_ONLY' and enable_external_access: 'false'. The second one is the boundary. Under it, every form in the table above answers with the engine’s own sentence:

Permission Error: Cannot access file "/etc/hostname" - file system operations are disabled by configuration

Two details about that handle are worth knowing before you reason about deployment. It is a second handle in the same operating-system process as the writable editor connection, and that is the only reason it can exist: a second DuckDB process on an open file is refused with a lock error even when it asks for READ_ONLY. And the writable editor connection passes neither option, by design - there COPY ... TO and read_csv_auto('...') are features, and they were measured unaffected. The file-reading capability listed for DuckDB on the engine list is a property of the editor connection, not of an agent run.

This is also the concrete meaning of a boundary published elsewhere on this site: agent AUTO mode, the run that executes model-authored statements, works on PostgreSQL, SQLite and DuckDB only, because that read-only profile is database-native and exists only where a provider implements it. On any other engine an auto run ends engine-unsupported. Agent PLAN mode opens on every connection, executes nothing, and drafts a statement for a human to run.

Why it cannot be turned back on mid-session

A configuration option is only a boundary if the thing inside the box cannot change it. That was not a given, so it was measured. Both spellings answer the same way:

SET enable_external_access = true;
SET GLOBAL enable_external_access = true;
-- Invalid Input Error: Cannot enable external access while database is running

The control measurement is the one that makes this meaningful. SET memory_limit='1GB' is accepted on the same read-only handle. So the refusal is a property of enable_external_access specifically, not a blanket “read-only sessions cannot run SET”. The engine lets a session reconfigure itself in general and refuses this one option in particular, which is the property the profile rests on rather than something to assume without measuring.

A denylist is defence in depth, not the boundary

The provider still runs a SQL denylist before the engine sees a statement. It names COPY, EXPORT, ATTACH, INSTALL, LOAD, the read_csv / read_parquet / read_json / read_text / read_blob / glob family and json_execute_serialized_sql, and it refuses outright any statement it cannot read reliably, such as one with an unterminated string or comment. It stays because a refusal that names the construct and the reason is worth more to a reader than a generic permission error, and because it costs nothing to run first.

It is not the boundary, and three measured bypasses are why. All three executed when the denylist was the only control. All three are refused by the engine option today.

Bypass Why a text guard misses it
SELECT * FROM "read_text"('/etc/hostname') The scanner skips quoted-identifier spans by design, which is correct for a keyword and wrong for a function name. DuckDB resolves "read_text"(...) and main."read_text"(...) exactly like the bare spelling; all three returned the file
SELECT * FROM '/tmp/x.csv' A replacement scan turns a bare path in FROM into a read_csv_auto. There is no forbidden word anywhere in the statement to find
json_execute_serialized_sql(json_serialize_sql('SELECT * FROM read_text(''/etc/hostname'')')) The second statement travels inside a string literal, which the scanner correctly declines to read as code. The outer function is denied by name, but the class is not closable by reading text

The same pass found one thing the option does not stop: read_duckdb() pointed at the connection’s own file still answers, because it reaches nothing the profile had not already granted.

So the limit, stated plainly: read-only access mode alone bounds the database rather than the process, the boundary is external access disabled at open, and the SQL denylist is defence in depth with three measured bypasses on record. One more boundary sits outside DuckDB entirely and is worth carrying into your threat model with the rest of what this product publishes about its own security: anyone who can create a DuckDB connection chooses a path on the server’s filesystem, so on a shared deployment that right is closer to a shell on the Studio host than to a database login.