Skip to content

feat: agent CLI — database access for AI agents, with approval-gated writes - #19

Merged
matej21 merged 40 commits into
mainfrom
feat/agent-cli
Nov 3, 2026
Merged

matej21 merged 40 commits into
mainfrom
feat/agent-cli

Conversation

@matej21

@matej21 matej21 commented Aug 4, 2026 •

Copy link
Copy Markdown
Member

Lets an AI coding agent work with your databases through the running Dotaz app — without ever holding your credentials. Reads run against a session the database itself enforces as read-only. Writes are supported too, but they route through you: the agent submits SQL, it opens in the app with Run/Reject, and only your click executes it.

Full design: docs/agent-cli.md.

How it works

The Electrobun backend serves a local control endpoint (unix socket, loopback TCP on Windows), discovered through a 0600 endpoint file and authenticated with a token. dotaz is a one-shot client against it, so it reuses the connections the user already configured in the app.

dotaz ls prod/app/public              # tables
dotaz describe prod/app/public/orders # columns, PK, indexes, FKs both ways
dotaz rows prod/app/public/orders --where "status='new'" --limit 20
dotaz ui open prod/app/public/orders  # open it in the user's window

dotaz propose prod "UPDATE orders SET status='paid' WHERE id=42" --reason "..."
# → opens in the app for Run/Reject; `dotaz approvals wait <id>` blocks on the decision

Three things this design turns on

Read-only comes from the engine, not from parsing SQL. PostgreSQL and MySQL get read-only session characteristics on the session's dedicated connection; SQLite gets a per-session handle with PRAGMA query_only. SQLite needed the most care — it shares one connection across sessions, so a naive pragma would have leaked into the UI's own queries. A statement classifier sits in front purely to fail fast with a useful message, and treats anything it cannot classify as a write.

Writes are proposed, not executed. dotaz propose opens the SQL in the user's app with Run/Reject. Approved SQL runs in the frontend's session, so the CLI session is never switched to read-write. Closing the tab reports back rather than leaving the CLI hanging.

The endpoint serves an allowlist, not the app's handler map. That map is built for the trusted webview: it includes connection deletion, imports, settings writes, and methods that return decrypted credentials. The CLI reaches ~20 named methods, each with a parser that builds exactly the params its handler takes, and connections.list responses are stripped of passwords and initSql on the way out. The ui.* methods only prefill: dotaz ui console --sql fills the console, and dotaz ui open --where fills the filter box. Agent SQL reaches the app's own session only when the user runs or applies it.

Off by default — Settings → Allow CLI access, or DOTAZ_CLI=1. No setting, no socket, no endpoint file. Toggling it starts and stops the endpoint without a restart.

Scope

Attach-to-running-app only. No headless mode and no remote mode in this PR — both were considered and deferred (headless means two writers on the app database; remote means web mode, where connections live in the browser's IndexedDB and the CLI cannot see them).

Verified against a running app

Not just unit tests — the desktop app was launched with the endpoint enabled and driven from a real CLI against a local SQLite database:

  • Run → executed, 1 row affected, CLI exits 0, and the row really changed (checked from outside the app).
  • Reject → rejected, CLI exits 8, and the DELETE never ran.
  • No interaction → still pending after 30 s, CLI exits 7. There is no auto-run path.
  • A write from the CLI exits 4 pointing at dotaz propose; ui.state reports the prefilled tab back to the CLI.

That run found two bugs, both fixed here. A proposal resolved behind the app's back (agent cancels it, or the TTL expires) left the banner fully live — and since Run executes before reporting, clicking it would have run a write for a proposal that no longer exists. And the spec claimed the endpoint file is removed on SIGTERM; Electrobun owns the signal handlers and exits through a native quit, so it never was. The docs now describe what actually happens, and nothing depends on that cleanup.

Testing

The unit suite passes (1769 pass, 32 skip), and the PostgreSQL/MySQL blocks pass against Docker in the integration job. New coverage: statement classification, engine-level read-only enforcement per driver (PostgreSQL and MySQL blocks skip cleanly without Docker), proposal lifecycle, the method allowlist and password redaction, CLI path resolution and output capping, plus integration tests that drive the real binary against a real control server over a real unix socket.

Known limitation: SQLite agent sessions have no statement timeout. bun:sqlite exposes neither an interrupt nor a progress handler, and busy_timeout bounds lock waits rather than query runtime, so a read-only SQLite session can still run an unbounded scan. PostgreSQL and MySQL/MariaDB sessions are capped.

Status

  • Review round 1 (dc18075, independent Claude subagent): blocked, 11 findings, addressed in ed022c0…2149b90.
  • Review round 2 (2149b90): blocked, 7 findings (3 more scanner/engine lexer disagreements, proposal-claim gaps), addressed in cce18ad, ae5933b.
  • Review round 3 (ae5933b, differential fuzzing of the scanner against live engines): blocked, 7 findings, addressed in 63a52d4, 9609af7, 8f4b1b3. Bun's SELECT '' segfault is upstream and won't be fixed here.
  • Rebased onto main after fix: count returned rows in history for statements with a result set #22 (history row count under Bun 1.4) was merged.
  • Review round 4 (8f4b1b3, the same fuzzing re-run at HEAD): changes, 3 low findings, addressed in dd32e5f, 9f187ef, 3863280. No read-only bypass was found on PostgreSQL 17, MariaDB 11.8 or MySQL 8.4.
  • Review round 5 (3863280, verification of the round-4 fixes): approve. One low and one info finding, both on paths the agent cannot reach today: a PostgreSQL reconnect inside a transaction, and a MySQL iterate reconnect between pages. Both addressed in 2cecc87.
  • CI: green on 2cecc87 (check and integration). The branch is up to date with main.
  • Known limitations:
    • SQLite has no engine statement timeout.
    • Bun.SQL segfaults on SELECT '', an upstream bug.
    • EXPLAIN refuses SQL that the single-statement guard fails closed on, for example MySQL LIKE patterns with a backslash.
  • Merged with a merge commit (feb5512).

🤖 Generated with Claude Code

https://claude.ai/code/session_01GaiURkVJQu7uSu1A7YUxP8

@matej21 matej21 changed the title feat: agent CLI — read-only database access for AI agents feat: agent CLI — database access for AI agents, with approval-gated writes Aug 4, 2026
@jonasnobile

Copy link
Copy Markdown
Member

Review fixes: read-only bypass + control-server idle timeout

Pushed two fixes to this branch (0381889, dc18075) addressing the two bugs from the review.

1. Read-only agent sessions could be tricked into writing (critical)

The read-only guarantee depends on the engine seeing one statement per round-trip — the read-only characteristics are re-asserted once per execute(), so a multi-statement string could flip the session to read-write between statements (… ; SET SESSION … READ WRITE ; COMMIT ; INSERT …) and write. The classifier decided read-vs-write by splitting on ; with a hand-rolled tokenizer, and that tokenizer under-split hostile input, so the whole string looked like one dollar-quoted SELECT and slipped a write past the engine.

The reported x$$ case turned out to be one of many. Probing the classifier against live Postgres and MariaDB, 13 crafted payloads wrote to the database through a real read-only session, exploiting:

  • $-quote touching an identifier (x$$), Postgres E'…' escape strings, nested block comments, non-ASCII dollar-tags
  • MySQL backslash escapes (in '…' and "…"), backtick identifiers, # comments, --x (not a comment in MySQL)

A hand-rolled cross-dialect tokenizer can't match every engine's lexer, so patching individual rules would be whack-a-mole. A single statement provably can't escape the engine's read-only mode, so the fix guarantees one statement per round-trip:

  • New isSingleStatement(sql, dialect) — a dialect-aware, fail-closed scanner that rejects anything it can't prove is one statement (with no dialect, all three engines must agree, so a separator the real engine honors is never missed).
  • assertSessionWritable rejects non-single-statement input on read-only sessions before classifying.
  • ui.openConsole auto-run now requires a single read-only statement (dialect resolved from the connection), closing the same gap on the writable-session path.
  • Also fixed the $-adjacency bug in the shared splitStatements/stripLiteralsAndComments.

Re-run against live DBs: 13 blocked, 0 written; legitimate reads (including ; inside literals and dollar-quoted bodies) still pass. New regression tests: tests/single-statement.test.ts, plus "bypass writes nothing" blocks in tests/readonly-session.test.ts against live PG/MySQL.

2. Control server timed out slow requests (high)

Neither Bun.serve call set idleTimeout, so both took Bun's default and closed the connection out from under any handler idle past it. Two CLI paths routinely exceed that: agent.proposals.wait long-polls in 30s slices, and a read-only agent.query runs up to its statement cap (30s, or uncapped when queryTimeout is 0). On a drop, waitForProposal reported the proposal gone and a slow-but-valid query failed as unreachable instead of returning rows.

Fix: disable the idle timeout on this local, token-gated endpoint (the CLI drives its own per-request deadline and closes its side). Regression test drives an 11s handler through the real server and expects a reply.

Checks

tsc, biome lint, and dprint all clean. The only failing tests are 11 pre-existing SQLite "unable to open database file" cases — they fail identically on the clean branch tip without these changes (environmental read-only-VFS issue), so they're unrelated, though worth confirming green in CI.

@matej21

matej21 commented Nov 2, 2026

Copy link
Copy Markdown
Member Author

Review round 1 — dc18075

Reviewer: independent Claude Code subagent (Claude Opus), full diff origin/main...dc18075, with reproductions against live PostgreSQL and MariaDB (docker compose).
Verdict: blocked — the "no write without approval" guarantee can still be bypassed.

Verified as holding: control-server idleTimeout: 0 fix (11 s handler test passes); token compare is constant-time, endpoint dir/file are 0700/0600; forbidden and unknown methods are indistinguishable; passwords stripped from connections.list; connections.connect ignores client config in desktop mode; cli-agent imports only @dotaz/shared.

Findings

  1. critical — read-only agent.query on PostgreSQL still writes via E'…' strings. src/shared/sql/single-statement.ts, src/shared/sql/statements.ts. The scanner ignores backslashes in PostgreSQL, so E'\'' desyncs it and a phantom literal hides the following ;. SELECT E'\'' ; SET SESSION CHARACTERISTICS AS TRANSACTION READ WRITE ; COMMIT ; INSERT INTO t VALUES (1) ; SELECT E'\'' passes isSingleStatement, isReadOnlySql and splitStatements, and runs all five statements through the simple query protocol. The existing regression payload ends in SELECT '''', which happens to resync the scanner. ui.openConsole auto-run accepts the same string.
  2. critical — ui.openTable where fragment writes in the app's session. src/backend-shared/rpc/handlers.ts (assertBooleanExpression). The guard knows only '…' and "…"; PostgreSQL $tag$…$tag$ and MySQL # comments smuggle a ; past it, the grid query is split downstream and an INSERT commits.
  3. high — side-effecting single "read" statements run in the writable session. ui.openConsole auto-run and ui.openTable where are gated only by the keyword classifier (e.g. SELECT setval(...), SELECT pg_terminate_backend(...), user functions, pg_advisory_lock(...) in a filter), contradicting the spec's invariant that classification is never the enforcement.
  4. high — cancel during Run makes the reported status lie. src/frontend-shared/stores/proposals.ts, src/backend-shared/services/proposal-store.ts. The status check and execution are not atomic: agent.proposals.cancel succeeds while the approved SQL is running, the write commits, the later resolve is rejected as stale and swallowed, and the agent sees cancelled. Toolbar Run / Ctrl+Enter in a proposal tab skips the check entirely.
  5. medium — edited or partial runs resolve as "not executed". proposals.ts. Running edited SQL or one statement of a multi-statement proposal reports failed / "this proposal was not executed" although something ran; a frontend response timeout does the same while the backend may still commit.
  6. medium — CLI reports a cancel that did not happen. handlers.ts (query.cancel), src/cli-agent/client.ts. The executor's boolean is discarded, so a timeout that fires before the query id is registered prints "the query was cancelled" while the query runs to completion.
  7. medium (plausible) — MySQL scanner assumes default sql_mode. single-statement.ts. Under NO_BACKSLASH_ESCAPES / ANSI_QUOTES it under-splits; a probe was only stopped by splitStatements disagreeing with it.
  8. medium — untrusted endpoint input is not parsed. src/backend-shared/rpc/dispatch.ts, cli-surface.ts. Raw JSON reaches handlers through (params: any); fields are never validated at the boundary.
  9. low — an agent can flood the UI with proposals. proposal-store.ts, proposals.ts. Pending proposals are never evicted and each opens a tab and toast.
  10. low — hygiene. Type escape as unknown as Bun.Serve.Options<undefined> in control-server.ts; a literal NUL byte in src/cli-agent/context.ts makes git treat the file as binary (hidden from the diff); initSql is not redacted from connections.list.
  11. low (plausible) — first @dotaz/cli publish may fail. .github/workflows/release.yml. npm trusted publishing (OIDC) generally needs the package to exist first; release depends on npm-cli, so a failure there blocks the GitHub Release.

@matej21

matej21 commented Nov 2, 2026

Copy link
Copy Markdown
Member Author

Response to review round 1

  1. critical — E'…' bypass: fixed in ed022c0. The scanner now fails closed on any backslash inside quoted text on PostgreSQL and MySQL. Whether a backslash escapes the next quote depends on E'…', standard_conforming_strings or sql_mode, and the scanner cannot see any of them. The reviewer's symmetric payload is now a live regression test against PostgreSQL. It fails on the previous scanner and passes now.
  2. critical — ui.openTable where: fixed in ed022c0. The hand-rolled guard is replaced by isWhereFragment, which uses the same dialect-aware, fail-closed lexing. It rejects separators, all comment forms (including MySQL /*! */ and #), unbalanced parentheses and backslashes in strings. The reviewer's $tag$ and # payloads are unit tests.
  3. high — side-effecting reads in the writable session: fixed in ed022c0 (maintainer decision: prefill only). ui console --sql only prefills the console, and --run is removed. ui open --where opens the filter box with the fragment, and the user applies it. Agent SQL now reaches the app's session only through a click of the user's.
  4. high — cancel during Run: fixed in 1dd5517. Every run in a proposal's tab goes through an editor run guard: banner Run, toolbar Run and Ctrl+Enter alike. The guard first claims the proposal (pending → approved). After that, cancel and expiry are refused, and only executed or failed may follow. agent.proposals.wait keeps waiting through approved.
  5. medium — "not executed" wording: fixed in 1dd5517. An edited or partial run now reports that different SQL ran and may have changed data. A run the app stopped waiting for (timeout, cancel, lost connection) reports an unknown outcome.
  6. medium — false cancel report: fixed in 5aeb8ed. query.cancel now returns { cancelled }, and the CLI says when the app had no running query to stop. Remembering cancels for query ids that haven't started yet: won't fix. The report is now accurate, which is what the finding was about.
  7. medium — MySQL sql_mode: fixed in ed022c0 by the same backslash rule as Fixes #1. Disabling multi-statements on read-only MySQL connections: won't fix in this PR. It is a driver-level change, and the guard is now fail-closed for every mode-dependent construct.
  8. medium — no parsing at the boundary: fixed in f88bcb4. Each allowlisted method has a parser that builds exactly the handler's params, checked against the handler signature at compile time. Unknown fields are dropped, so connections.connect never sees a client config. Query params are limited to JSON scalars. The shared RpcHandler = (params: any) type stays: it predates this PR (the web WebSocket server dispatches the same way on main), and the CLI path no longer relies on it.
  9. low — proposal flood: fixed in 1dd5517. At most 20 proposals may be pending at once.
  10. low — hygiene: fixed. The cast is replaced by a per-request server.timeout(req, 0) (2149b90), and the NUL byte is replaced by \0 (2149b90). initSql is dropped from connections.list (f88bcb4). Note: on Bun 1.3.14, reading the request body already clears the idle timer, so the existing slow-handler test cannot fail there. The fix was verified separately with a body-less request, which is cut off at 10 s without it.
  11. low — first @dotaz/cli publish: won't fix. @dotaz/cli already exists on npm (0.0.0-bootstrap.0), so a trusted publisher can be configured before the first release.

Verified locally: biome lint, dprint check, tsc --noEmit, the unit suite (1769 pass, 0 fail), and readonly-session against live PostgreSQL and MariaDB (56 pass). The grid filter bar was checked by hand in demo mode.

@matej21

matej21 commented Nov 2, 2026

Copy link
Copy Markdown
Member Author

Review round 2: 2149b90

Reviewer: an independent Claude Code subagent (Claude Opus) reviewed dc18075..2149b90 and reproduced findings against live PostgreSQL 17 and MariaDB 11. Finding 7 comes from the author's own follow-up probing.

Verdict: blocked. Read-only agent.query can still commit writes.

Findings

  1. critical: PostgreSQL bypass via $ inside an identifier. src/shared/sql/single-statement.ts. The scanner opens a dollar-quote whenever the previous character is not an identifier character. isIdentChar does not count $, but PostgreSQL allows $ in identifiers.
    • In x$$$ or x$$a$ the scanner opens a phantom quote at the second $. That quote hides every ; after it.
    • SELECT 1 AS x$$$ ; SET SESSION CHARACTERISTICS AS TRANSACTION READ WRITE ; COMMIT ; INSERT … ; SELECT 1 AS y$$ committed the INSERT through a read-only session.
  2. critical: MySQL/MariaDB bypass via -- followed by a Unicode space. single-statement.ts. The check uses JavaScript's \s, which also matches U+00A0. MySQL needs ASCII whitespace or a control character after --, so the two disagree and an INSERT committed from a read-only session.
  3. high: a where fragment can still carry a hidden write. This has the same root cause as Fixes #1, through isWhereFragment. The grid's row-count query has no bound params and runs over the simple protocol, so applying a prefilled filter committed an INSERT.
  4. medium: EXPLAIN ANALYZE skips the proposal claim. src/frontend-shared/stores/editor.ts (explainQuery).
    • EXPLAIN ANALYZE in a pending proposal tab executes the write, and the proposal stays pending.
    • A plain EXPLAIN pressed while a claimed proposal is running replaces the tab's query id. The run's outcome is then dropped, and the proposal stays approved.
  5. medium: confirming the destructive-statement dialog runs a proposal that was invalidated in the meantime. src/frontend-shared/stores/proposals.ts. The run guard passes runs in an invalidated tab without claiming, so a cancelled or expired DELETE proposal still runs.
  6. low (plausible): a stale approved proposal is deleted silently. src/backend-shared/services/proposal-store.ts sweep. It disappears one hour after the claim instead of settling as failed with an unknown outcome, so the agent ends with "Proposal not found".
  7. critical (author, by reasoning from PostgreSQL's lexer): PostgreSQL bypass via a bare \r ending a line comment. PostgreSQL ends -- comments at \r as well as \n, but the scanner only ends them at \n. In SELECT 1 -- x\r; SET SESSION … READ WRITE; COMMIT; INSERT …; -- \n the scanner sees a single statement.

Closed from round 1: #3, #5, #6, #8, #9, #10. Partially closed: #4 and #7 (see 4–5 and 2 above). Not closed: #1 and #2 (see 1 and 3 above).

@matej21

matej21 commented Nov 2, 2026

Copy link
Copy Markdown
Member Author

Response to review round 2

  1. critical — $ inside an identifier: fixed in cce18ad. A tag-shaped $ that touches an identifier character, another $, or a number now fails closed. A tag can no longer start with a digit, so $1 stays a parameter. Both payloads are live PostgreSQL regression tests; they fail on 2149b90 and pass now.
  2. critical — MySQL -- + Unicode space: fixed in cce18ad. After --, only ASCII whitespace or a control character starts a comment, and any non-ASCII character fails closed. Live MariaDB regression test.
  3. high — where variant: fixed in cce18ad by the same $ rule. Unit test with the reviewer's payload.
  4. medium — EXPLAIN ANALYZE: fixed in ae5933b. EXPLAIN ANALYZE goes through the run guard and is refused while a proposal banner is live. Neither EXPLAIN form starts while a query is running in the tab, so a claimed run's outcome can no longer be dropped.
  5. medium — destructive dialog after invalidation: fixed in ae5933b. In an invalidated tab, the guard refuses the unchanged proposal SQL. Edited SQL is the user's own query and still runs.
  6. low — stale approved: fixed in ae5933b. A claim the app never settles becomes failed with an unknown-outcome error one hour after the claim, and waiters wake up. Unit test.
  7. critical — bare \r ends a PostgreSQL line comment: fixed in cce18ad. A bare \r inside a line comment now fails closed; \r\n is still accepted. Confirmed live: PostgreSQL lexes SELECT 1 -- x\rSELECT 2 as one statement and fails with a syntax error at the second SELECT. Live regression test.

Verified locally: lint, format, tsc, unit suite (1780 pass, 0 fail), and readonly-session against live PostgreSQL and MariaDB (56 pass).

@matej21

matej21 commented Nov 2, 2026

Copy link
Copy Markdown
Member Author

Review round 3 — ae5933b

Reviewer: independent Claude Code subagent (Claude Opus). It differentially fuzzed the scanner against live engines and read the round-2 proposal-guard commit.

Engine Statement cases Where cases
PostgreSQL 17 200k 100k
MariaDB 11.8 100k 100k
SQLite 200k 100k

Findings were confirmed end to end against MariaDB 11.8 and MySQL 8.4.

Verdict: blocked.

PostgreSQL had 0 hits across 300k cases, and a rule-by-rule read of scan.l found no unsafe disagreement.

Findings

  1. critical: MySQL/MariaDB executable comments. src/shared/sql/single-statement.ts.
    • The scanner reads the inside of /*! and /*+ as code. The engine skips a version-gated /*!99999 and treats a misplaced /*+ as a plain comment.
    • The scanner treats /*M! as a comment, but MariaDB executes its contents.
    • SELECT 1 AS `'`, 2 /*+ " */ ; SET SESSION TRANSACTION READ WRITE ; INSERT … ; SELECT " */ committed the INSERT through a read-only session on MariaDB 11.8 and MySQL 8.4.
    • The /*M! variant also passes the no-dialect check.
  2. high: a plain EXPLAIN runs the statements after the first in the writable session. src/backend-shared/services/query-executor.ts (explainQuery).
    • EXPLAIN … ${sql} is sent in one simple-protocol / multi-statement call, so every statement after the first executes. Only EXPLAIN ANALYZE was guarded.
    • Pressing Explain in a proposal tab with SELECT E'\'' ; DELETE FROM t ; SELECT E'\'' deleted rows while the proposal stayed pending.
  3. medium: a multibyte connection charset swallows a backtick.
    • With initSql: 'SET NAMES gbk', byte 0x60 can be the second byte of a two-byte character.
    • The scanner then opens a backtick identifier the engine never sees, and an INSERT committed.
  4. low: SQLite Tcl-style variables.
    • SQLite lexes $a(…), @a(, :a( and #a( as one token that can contain ', /* or --.
    • It is not exploitable through dotaz today: the sqlite adapter runs one statement, and the read-only handle blocks writes. The lexers still disagree.
  5. low (upstream): SELECT '' on MySQL/MariaDB crashes the backend.
    • On Bun 1.3.14, an empty column name in a result makes Bun.SQL segfault.
    • It is reachable through agent.query. This is a Bun bug, so there is no scanner fix.
  6. low–medium: a claimed run's outcome can be dropped.
    • Run can start while an EXPLAIN is running in the tab.
    • When the EXPLAIN finishes, it clears the tab's queryId without checking it is its own, so the run never reports back and the proposal stays approved.
  7. low: after cancellation, a single statement of the proposal still runs.
    • In an invalidated tab, any SQL that is not an exact match is allowed.
    • So running one statement of a multi-statement proposal (Ctrl+Enter or a selection) still executes after the agent cancelled.

@matej21

matej21 commented Nov 2, 2026

Copy link
Copy Markdown
Member Author

Response to review round 3

  1. critical — executable comments: fixed in 63a52d4. On MySQL, /*!, /*+, /*M! and /*m! fail closed. All three payloads are live MariaDB regression tests; they fail on ae5933b and pass now.
  2. high — multi-statement EXPLAIN: fixed in 9609af7. QueryExecutor.explainQuery refuses anything isSingleStatement does not accept, in every session, before anything reaches the driver. Trade-off: a single statement the guard fails closed on can no longer be explained, for example MySQL LIKE 'a\_b'. The user sees "EXPLAIN takes a single statement".
  3. medium — multibyte charset: fixed in 9609af7. MySQL read-only sessions run SET NAMES utf8mb4 after initSql. A live test with initSql: 'SET NAMES gbk' fails without the change and passes with it.
  4. low — SQLite Tcl variables: fixed in 63a52d4. A $/@/:/# variable with a ( suffix fails closed; plain named parameters still pass.
  5. low — SELECT '' crashes the backend: won't fix here. It is a Bun.SQL bug, with nothing to fix on the dotaz side. Worth reporting upstream separately.
  6. low–medium — dropped outcome: fixed in 8f4b1b3. A finishing EXPLAIN only clears its own query id, and the guard refuses to claim while a query is running in the tab.
  7. low — a single statement of a cancelled proposal: fixed in 8f4b1b3. In an invalidated tab, any piece of the proposal SQL is refused. SQL the user wrote still runs.

Verified locally: lint, format, tsc, the unit suite (1788 pass, 0 fail), and readonly-session plus mysql-driver against live PostgreSQL and MariaDB (92 pass).

matej21 and others added 19 commits November 2, 2026 18:06
Implementation contract for the agent-facing CLI: attach-only transport over
a unix socket, engine-enforced read-only sessions, and a write-proposal flow
where the user approves in the app instead of the CLI executing directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GaiURkVJQu7uSu1A7YUxP8
Defines the RPC contract implemented in the following commits: proposal
types, UI snapshot, read-only session flags, the READ_ONLY_SESSION error
code, and the cli.enabled setting (off by default).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GaiURkVJQu7uSu1A7YUxP8
The web server's WebSocket message loop held the dispatch logic inline. The
CLI control endpoint needs identical semantics over plain HTTP, so pull
parsing and dispatch into backend-shared and route the web-only stream token
methods through the same handler lookup.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GaiURkVJQu7uSu1A7YUxP8
Agent sessions must be incapable of writing, and the guarantee has to come
from the engine rather than from parsing SQL: PostgreSQL and MySQL set
read-only session characteristics on the session's dedicated connection,
SQLite gets a per-session handle with PRAGMA query_only.

SQLite needed the most care — it shares one connection across sessions, so a
naive pragma would have leaked into the UI's queries. Read-only sessions now
own a handle, including for transactions, so a CLI BEGIN cannot block the app.

classifyStatement() backs this with a fail-fast READ_ONLY_SESSION error;
'unknown' counts as a write so the classifier can never widen access.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GaiURkVJQu7uSu1A7YUxP8
The CLI never executes writes. It submits a proposal, the app shows it to the
user, and the app executes it on approval — so an approved write runs in the
frontend's session and the CLI session stays read-only.

Proposals live in memory with lazy expiry and a retention cap, so a caller
submitting in a loop cannot grow the map without bound. ui.openConsole refuses
to auto-run non-read-only SQL, which would otherwise be a way around the whole
approval flow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GaiURkVJQu7uSu1A7YUxP8
Plain HTTP over a unix socket (loopback TCP on Windows), discovered through a
0600 endpoint file and authenticated with a token. The endpoint only exists
while cli.enabled is set, and starts and stops live so the Settings toggle
needs no restart.

It serves an allowlist rather than the app's full handler map: that map is
built for the trusted webview and includes connection deletion, imports,
settings writes and methods returning decrypted credentials. connections.list
responses are stripped of passwords on the way out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GaiURkVJQu7uSu1A7YUxP8
A proposal opens its own SQL console tab, prefilled and never auto-run, with
a banner showing what the agent wants to run and why. Run executes through the
normal path so history and the transaction log behave as usual; Run, Reject,
and closing the tab all report back, so the waiting CLI never hangs.

Also publishes a debounced snapshot of open tabs for ui.state, and adds the
Settings toggle that turns the control endpoint on. Both desktop-only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GaiURkVJQu7uSu1A7YUxP8
Importing the publisher pulled the tab store into any test that only needed
buildUiSnapshot, which broke tabs-store.test.ts — it mocks solid-js/store and
depends on being the first to import the store module.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GaiURkVJQu7uSu1A7YUxP8
One-shot commands over the control socket: ls, describe, rows, query,
explain, search, history, propose, approvals and ui. Paths address objects as
connection/database/schema/table, with the schema segment optional wherever
the driver reports one schema or the table name is unambiguous.

Every data command runs inside a read-only session it destroys in a finally,
and refuses to query at all if the app hands back a session that is not
read-only. Row output is byte-capped with an explicit truncation line so an
agent can never mistake a cut result for a complete one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GaiURkVJQu7uSu1A7YUxP8
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GaiURkVJQu7uSu1A7YUxP8
The framing undersold the feature: writes are supported, they just route
through the user's approval in the app instead of executing from the CLI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GaiURkVJQu7uSu1A7YUxP8
Read-only is not the same as cheap — an agent could pin the user's app on a
full scan. PostgreSQL gets statement_timeout, MySQL MAX_EXECUTION_TIME with a
MariaDB max_statement_time fallback, both driven by the existing queryTimeout
setting. Normal UI sessions are untouched.

SQLite gets nothing: bun:sqlite exposes no interrupt or progress handler, and
busy_timeout bounds lock waits rather than runtime. Documented rather than
faked with a client-side race that would leave the query running.

A cancelled statement now maps to QUERY_CANCELED instead of matching the
generic timeout regex and reporting a healthy connection as broken.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GaiURkVJQu7uSu1A7YUxP8
Two open windows overwrote each other's endpoint file, and the second to quit
deleted it while the first was still serving. Each instance now owns
cli/endpoint-<pid>.json, prunes dead instances' files at startup, and removes
only its own on shutdown. The CLI picks the newest live instance, or a named
one with --instance <pid>.

The transport is also overridable now, so the Windows TCP path gets exercised
on Linux instead of shipping untested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GaiURkVJQu7uSu1A7YUxP8
Four CLI gaps:

- A timed-out or Ctrl-C'd query kept running in the app. The CLI now sends
  query.cancel best-effort and reports whether it took.
- --limit only trimmed the printed rows, so the database still did all the
  work. It is now pushed into the SQL for a single unlimited read-only SELECT,
  falling back to display trimming everywhere else — and saying which happened.
- A CLI blocked on approvals wait could not tell 'the app quit' (exit 5) from
  'the user hasn't decided' (exit 6/7).
- bookmarks.list was allowlisted but had no command.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GaiURkVJQu7uSu1A7YUxP8
Verified against a running desktop app: the CLI reaches it, reads schema,
rejects a write with exit 4, and a proposal opens the prefilled console tab
that ui.state then reports back.

That run also disproved a claim in the spec. Electrobun installs its own
SIGINT/SIGTERM handlers and exits through a native quit, so a killed instance
runs neither our handlers nor process.on('exit') and leaves its socket and
endpoint file behind. Nothing depends on that cleanup — the next instance
prunes dead pids and the CLI skips them — so the docs now say so instead of
promising cleanup that never happens.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GaiURkVJQu7uSu1A7YUxP8
Reported from a real run: rejecting a proposal the agent had cancelled
produced 'Proposal <id> is already cancelled' in a red toast. The toast was
the mild symptom — Run was equally live, and it executes the SQL before
reporting the outcome, so a click on a stale banner would run a write for a
proposal that no longer exists.

The backend never told the app about transitions it did not cause. It now
emits cli.proposal on every state change, including expiry, which nothing
else would have announced. The banner goes terminal and drops Run/Reject, and
Run re-checks the proposal is still pending before executing, because the
message may not have arrived yet.

The pure decision logic lives in lib/proposal-state.ts: importing the store
into a test breaks tabs-store.test.ts, which mocks solid-js/store and needs to
import it first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GaiURkVJQu7uSu1A7YUxP8
A read-only session enforced its mode once, at reserve time, with a mechanism
the session itself could turn off — and the statement that turns it off
classified as a read, so nothing stopped it reaching the driver.

PostgreSQL: default_transaction_read_only is an ordinary GUC, and
SELECT set_config('default_transaction_read_only','off',false) clears it.
Verified against the project's own PG 17.9 container: after that call the
session could INSERT. The driver now re-asserts the session characteristics
and the statement timeout before every statement. Re-asserting is enough
because a single statement cannot both clear the GUC and write under it —
also verified, including the CTE form.

SQLite: PRAGMA query_only can be revoked with PRAGMA query_only(0). The
function form carries no '=', which is what the classifier keyed on. The
dedicated handle is now opened readonly at the VFS level, which no statement
can revoke; the pragma stays as a second layer. Confirmed both directions
against bun 1.3.14.

SQLite also fell through to the shared writable handle for any sessionId it
did not recognise, so a lost read-only handle (terminated from the Connection
Inspector, or replaced by a reconnect) silently became read-write. It now
throws like PostgreSQL and MySQL already did.

The classifier fails closed on the escapes above, plus data-modifying CTEs
(WITH x AS (INSERT …) SELECT — PostgreSQL runs the body whatever the tail
does), SELECT … INTO, and INTO OUTFILE. Introspection pragmas that take an
argument are named explicitly so table_info(users) keeps reading.

SessionInfo.readOnly is now read back from driver.isSessionReadOnly() instead
of echoed from the request: a driver that accepts the option and ignores it
used to report readOnly: true anyway. A session requested read-only that the
driver does not confirm is released and refused, and the CLI's own guard
treats an absent flag like false — the CLI ships separately from the app, so
a version mismatch is the normal case, not an edge one.

readonly-session.test.ts proved none of this in CI: the check job has no
docker so its PostgreSQL and MySQL blocks skipped, and the integration job's
include list never matched the filename. It now runs in both, with
DOTAZ_REQUIRE_DB=1 turning a skip into a failure where docker is guaranteed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GaiURkVJQu7uSu1A7YUxP8
…e client

The allowlist filtered method names and passed params through untouched, so
three allowlisted paths reached the user's databases writable, with no
proposal and no approval click. All of them are reachable with nothing but
the token and curl --unix-socket; the CLI client applied the rules, but the
client is not what enforces them, and shelling out to `dotaz` is how an agent
uses this in the first place.

query.execute short-circuits its read-only assertion when sessionId is
undefined, so a sessionless call ran on the writable pool. session.create took
readOnly straight from the caller. session.list handed over the ids of the
user's own writable UI sessions, including one inside an open transaction.
The gate now pins session.create to readOnly: true, refuses query.execute
unless its sessionId names a session the backend confirms is read-only, and
session.list is off the allowlist. The desktop entry supplies that
confirmation from SessionManager.

ui.runCommand validated only that commandId was a non-empty string and then
ran any of the 45 registered commands. `ui console --sql "DELETE …"` (no run,
so the read-only check never fired) followed by `ui command run-query`
executed it in the frontend's writable session, skipping the destructive
confirm dialog; the same reach covered delete-rows, commit-transaction and
undo, and let an agent approve its own proposal. Nothing could discover a
valid command id, and SKILL.md never mentioned the subcommand, so it is
removed rather than narrowed.

ui.openTable forwarded `where` verbatim into WHERE (…) and the grid loads
without any user interaction, so `1=1); DELETE FROM orders; --` split into
three statements and ran. It now has to be a single boolean expression:
no statement terminator, no comment introducer, balanced parens, all judged
outside string literals so status='new' and 'it''s fine' still work. Note
ui.openConsole, fourteen lines away, already checked — this site was missed.

The integration harness could not have caught any of this: it had no session
guard, and its query.execute mock used /^\s*SELECT/i where production uses
isReadOnlySql, so the mock was laxer than the thing it stood in for. It now
calls the real classifier and tracks real session ids.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GaiURkVJQu7uSu1A7YUxP8
handleRunFinished matched a run to a proposal by tabId alone and never
compared what ran against what was proposed, even though RunFinishedEvent
already carries the SQL. Two consequences, both reported to the agent as
success: editing the console before pressing Run told the agent its write
landed, and running one statement of a three-statement proposal resolved the
whole proposal executed with statements: 1 — a partial write the agent will
not retry.

Tab-wide matching is deliberate, so a toolbar run is not double-counted; only
the SQL identity was missing. A mismatch now resolves failed with a message
saying the console was changed, instead of claiming an execution that did not
happen. Whitespace and a trailing semicolon are ignored, since the editor may
reformat those.

The comparison lives in lib/proposal-state.ts for the same reason the rest of
the decision logic does: importing the store into a test breaks
tabs-store.test.ts, which mocks solid-js/store and needs to import it first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GaiURkVJQu7uSu1A7YUxP8
matej21 and others added 17 commits November 2, 2026 18:06
Two CLI bugs that mislead the caller rather than failing.

--quiet dropped stderr wholesale, but for csv and jsonl the truncation notice
is deliberately routed there — stdout stays a clean stream — so it was the one
truncation the consumer could not detect. Verified: 50 rows with --max-bytes
40 --format jsonl --quiet emitted 5 rows, empty stderr, exit 0. That is also
the exact shape an agent pipes. --quiet now silences notes only; truncation is
a correctness signal, not a diagnostic. (table and md were never affected —
their footer is on stdout.)

approvals redefined --timeout as seconds while main.ts kept reading the same
key as the millisecond RPC deadline, so the documented `approvals wait <id>
--timeout 300` built a 300ms client and exited 6 with "Dotaz did not respond
within 300ms" instead of waiting five minutes. The flag is now --wait, matching
propose --wait, and mergeSpecs refuses any command flag that shadows a global
one so the next one cannot compile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GaiURkVJQu7uSu1A7YUxP8
The spec claimed the guarantee in the right place but described the wrong
mechanism, and in two spots described one that had never held.

It now records that the endpoint constrains params rather than method names,
and why: the CLI client applies the same rules, but anything holding the token
can reach the socket without it. session.list and ui.runCommand are named as
deliberately absent, with what each one reached, so neither comes back by
accident.

The per-driver table said read-only was established once. Neither mechanism
protects itself — a PostgreSQL GUC is rewritable by a statement that
classifies as a read, and PRAGMA query_only(0) revokes the SQLite one — so the
section now describes re-assertion and the VFS-level readonly handle instead.

The proposal lifecycle said Run resolves the proposal; it now says executed
means the proposed SQL ran, not that something ran in that tab.

SKILL.md told agents truncation "says so on the last line", which was false for
--json (inside the object) and for csv/jsonl (stderr). An agent that believed
it would read a truncated result as a whole table. Spelled out per format.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GaiURkVJQu7uSu1A7YUxP8
Keep session IDs behind the control endpoint and bind each agent query, schema load, and search to a backend-created read-only session that is released in finally.
`assertBooleanExpression` treated every `'`/`"` as opening a literal. With no
closing quote it consumed the rest of the fragment, never reaching the `;`, `--`
or `)` inside it. Backslash-escaped quotes desynchronised it from the real
parser (`E'\''` on Postgres, `'\''` on MySQL): the scan saw a doubled quote and
stayed inside the string while the server had already closed it.

Either one let an agent-supplied `where` through `ui.openTable` into
`gridStore.setCustomFilter`, which splices it into `WHERE (…)` and runs it in
the tab's writable session with no user interaction — the trust boundary this
guard exists to be.

Reject an unterminated quote, and reject a backslash inside quoted text: the
dialect decides whether it escapes, so no single scan agrees with every parser.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ZvH7kc1aLBsM5FWRW8CcM
A read-only session's guarantee rests on the engine seeing one statement per
round-trip: the read-only characteristics are re-asserted once per execute(),
so a multi-statement string could flip the session to read-write between
statements (`… ; SET SESSION … READ WRITE ; COMMIT ; INSERT …`) and write.
The classifier decided read vs write by splitting on `;` with a hand-rolled
tokenizer, and that tokenizer under-split hostile input — a $-quote touching
an identifier (x$$), MySQL backslash escapes, `#` comments, `--x`, nested
block comments — so the whole string looked like one dollar-quoted SELECT and
slipped a write past the engine. Confirmed writing to live Postgres and MariaDB.

Gate read-only sessions (and ui.openConsole auto-run) on a dialect-aware,
fail-closed single-statement scanner: it rejects anything it cannot prove is
one statement, so the engine only ever sees a lone statement it can enforce
read-only on. Also fix the $-adjacency bug in the shared splitter/stripper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RUeaScrcwyxTeFMDVCKRR
Neither Bun.serve call set idleTimeout, so both took Bun's default and closed
the connection out from under any handler idle past it. Two CLI paths routinely
exceed that: agent.proposals.wait long-polls in 30s slices, and a read-only
agent.query runs up to its statement cap (30s, or uncapped when queryTimeout is
0). When the socket dropped, waitForProposal reported the proposal gone and a
slow-but-valid query failed as unreachable instead of returning rows.

Disable the idle timeout on this local, token-gated endpoint; the CLI drives its
own per-request deadline and closes its side.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RUeaScrcwyxTeFMDVCKRR
The single-statement scanner ignored backslashes in PostgreSQL strings, so
a symmetric `E'\''` pair desynchronised it and hid a `; SET SESSION ... READ
WRITE; COMMIT; INSERT ...` from the guard; the same gap existed for MySQL
under NO_BACKSLASH_ESCAPES / ANSI_QUOTES. Backslash semantics depend on
server state the scanner cannot see, so any backslash in quoted text is now
rejected on PostgreSQL and MySQL.

`ui.openTable --where` had its own hand-rolled scanner that knew nothing of
dollar quotes or `#` comments. It now uses `isWhereFragment`, built on the
same dialect-aware, fail-closed lexing.

`ui console --run` and `ui open --where` executed agent SQL in the app's
writable session, gated only by statement classification, so a single
"read" such as `SELECT setval(...)` still wrote. Both now only prefill:
the console waits for the user to press Run, the filter box opens with the
fragment for the user to apply. `--run` is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Run checked that a proposal was still pending and then executed, which
was not atomic: the agent could cancel it, or it could expire, while the
approved write was running. The write committed, the later resolve was
refused as stale, and the agent was told `cancelled` for a write that
happened. Toolbar Run and Ctrl+Enter skipped the check entirely.

Every run in a proposal's tab now goes through an editor run guard that
claims the proposal (`pending -> approved`) first. A claimed proposal
cannot be cancelled or expire and may only move to `executed` or
`failed`; `agent.proposals.wait` keeps polling through `approved`.

Outcomes that were reported as "not executed" now say what is known: a
run of edited or partial SQL may have changed data, and a run the app
stopped waiting for (timeout, cancel, lost connection) has an unknown
outcome. At most 20 proposals may be pending at once, so an agent cannot
flood the window with tabs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`query.cancel` dropped the executor's result, so the CLI printed "the
query was cancelled in Dotaz" whenever the RPC call itself succeeded —
including a timeout that fired before the query was registered, after
which the query ran to completion. The handler now returns `{ cancelled }`
and the CLI says when the app had no running query to stop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Raw JSON from the control endpoint reached handlers untouched, typed only
by their declared params. Each allowlisted method now has a parser that
builds exactly the shape its handler takes — checked against the handler
signature at compile time — and rejects anything malformed. Unknown fields
are dropped, so `connections.connect` never sees a client-supplied config
or password, and bound query params are restricted to JSON scalars.

`connections.list` responses also drop `initSql`, which is free-form
session setup that may embed credentials and which the CLI never needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The unix-socket serve options lack `idleTimeout` in bun-types, which the
server worked around with an `as unknown as` cast. It now disables the
idle timeout per request with `server.timeout(req, 0)`, typed for both
transports. Verified on Bun 1.3.14 with a body-less request, which is cut
off at 10 s without it; reading a request body already clears the timer,
so the existing slow-handler test cannot fail there — its comment says so.

`context.ts` carried a literal NUL in a template string, which made git
treat the file as binary and hid it from diffs; it is now `\0`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each one let a read-only agent query commit a write. In every case the
single-statement scanner saw one statement where the engine saw several:

- PostgreSQL allows `$` inside identifiers. `x$$$` opened a phantom
  dollar-quote at the second `$`. A tag-shaped `$` touching an identifier
  character, another `$`, or a number now fails closed, and a tag can no
  longer start with a digit.
- PostgreSQL ends a line comment at a bare `\r`, MySQL and SQLite do not.
  A bare `\r` inside a line comment now fails closed; `\r\n` is fine.
- MySQL needs ASCII whitespace or a control character after `--`; `\s`
  also matched U+00A0. A non-ASCII character after `--` now fails closed.

The same `$` fix closes the `where` fragment variant, which committed a
write through the grid's row-count query once the user applied it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- EXPLAIN ANALYZE executes the statement but never went through the run
  guard. It is now refused while a proposal banner is live. Neither form
  of EXPLAIN starts while a query is running in the tab, because it
  replaced the running query's id and dropped a claimed proposal's outcome.
- The guard let runs in an invalidated tab through unclaimed. Confirming
  a destructive-statement warning opened before the agent cancelled still
  ran the DELETE. The unchanged proposal SQL is now refused there.
- A claimed proposal the app never settles was deleted an hour after the
  claim, and the agent got "not found". It now becomes `failed` with an
  unknown-outcome error and wakes waiters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Differential fuzzing against live engines found the remaining scanner
disagreements:

- MySQL/MariaDB executable comments. The scanner read the inside of
  `/*!` and `/*+` as code and `/*M!` as a comment. The engine does the
  opposite depending on vendor, version and placement: it skips
  `/*!99999` and a misplaced `/*+`, and MariaDB runs `/*M!`. Each
  committed a write from a read-only session. All three forms now fail
  closed.
- SQLite lexes `$a(…)`, `@a(`, `:a(` and `#a(` as one token running to
  `)` or whitespace, swallowing quotes and comment openers. A variable
  with a parenthesised suffix now fails closed. This was not exploitable
  through the sqlite adapter, but the lexers disagreed.

PostgreSQL: 0 hits in 300k cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ly sessions

`EXPLAIN ${sql}` goes out in one simple-protocol / multi-statement round
trip, so every statement after the first really executed. That happened
in the app's writable session too: a plain Explain in a proposal tab ran
the agent's DELETE with no claim. EXPLAIN now requires a single statement.

Under a multibyte MySQL charset such as gbk, set via `initSql`, a byte
the guard reads as a backtick can be half of a character, so the guard
saw a quoted identifier the engine never did. Read-only sessions now
switch back to utf8mb4 after `initSql`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…roposal

- A finishing EXPLAIN cleared the tab's query id without checking it
  was its own. A proposal run started meanwhile then never reported back
  and stayed `approved`. EXPLAIN now leaves a newer run alone, and the
  guard refuses to claim while a query is running in the tab.
- In an invalidated tab, only the exact proposal SQL was refused, so a
  single statement of a cancelled multi-statement proposal still ran.
  Any piece of the proposal is now refused; SQL the user wrote still runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
matej21 and others added 3 commits November 2, 2026 18:12
Bun.SQL silently reconnects a dropped connection, and the new one starts
read-write with the server's default charset. Re-run the session setup
(read-only, utf8mb4, statement timeout) before every statement, as the
PostgreSQL driver already does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SQLite counts `$` as a name character and accepts `::` inside a variable
name, so `@$(`, `$a$(`, `:$(` and `#$(` still opened the token that runs
to `)`. Follow the tokenizer: any `$`, `@`, `:` or `#` followed by name
characters (including `$` and `::`) and `(` fails closed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Format SQL uppercases keywords and moves whitespace, which made a
cancelled or expired proposal look like the user's own SQL and let it
run. Ignore case and all whitespace when checking whether SQL is a piece
of the proposal; a false match only refuses a run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@matej21

matej21 commented Nov 2, 2026

Copy link
Copy Markdown
Member Author

Review round 4: 8f4b1b3

Reviewer: an independent Claude subagent (Opus 5.5), the same adversarial differential-fuzzing setup as round 3, re-run against the HEAD scanner. After the rebase onto main, the code is unchanged at b21bda7.

Verdict: changes. PostgreSQL 17, MariaDB 11.8 and MySQL 8.4 have no read-only bypass left. Three low findings remain.

Findings

  1. low/medium: a dropped MySQL read-only session reconnects read-write.
    • Bun.SQL silently reconnects a killed connection.
    • The new connection reports transaction_read_only = 0, has lost SET NAMES utf8mb4, and has no statement timeout.
    • Triggering this needs an external connection drop.
    • PostgreSQL already reasserts its setup before every statement.
    • Location: src/backend-shared/drivers/mysql-driver.ts, reserveSession and execute.
  2. low: the SQLite Tcl-variable rule misses $ inside the name.
    • @$(, $a$(, :$( and #$( pass the scanner (24 variants), and so does the isWhereFragment equivalent.
    • bun:sqlite Database.run executed the second statement.
    • This cannot be exploited in the app: the Bun.SQL sqlite adapter runs only the first statement, and the handle is read-only.
    • Location: src/shared/sql/single-statement.ts.
  3. low: Format SQL turns an invalidated proposal into "the user's own" SQL.
    • sqlIsPartOfProposal normalizes only whitespace, and the formatter also uppercases keywords.

Rules checked that hold

  • MySQL executable comments (/*!, /*+, /*M!, /*m!) fail closed.
  • Multi-statement EXPLAIN is refused, and no other EXPLAIN path reaches a writable session.
  • SET NAMES utf8mb4 runs after initSql and survives every SET NAMES and character_set_client variant tried.
  • Fixed in round 3 and confirmed:
    • the EXPLAIN completion paths no longer clear a newer run's queryId;
    • the claim is refused while the tab is running.

Fuzz runs at HEAD

Engine Statement cases Hits WHERE-fragment cases Hits
PostgreSQL 17 200k 0 100k 0
MariaDB 11.8 200k 0 100k 0
MySQL 8.4 200k 0 100k 0
SQLite (bun:sqlite) 200k 0 100k 0

The fuzzer did not find finding 2; a hand probe did.

@matej21

matej21 commented Nov 2, 2026

Copy link
Copy Markdown
Member Author

Response to review round 4

  1. low/medium, MySQL reconnect: fixed in dd32e5f.
    • A read-only session records its setup statements: read-only, utf8mb4, and the statement timeout with whichever variable the server accepted.
    • It re-runs them before every execute and iterate, as the PostgreSQL driver does.
    • The new live test KILLs the session's connection. It checks that the reconnected connection is read-only and uses utf8mb4, and that an INSERT is rejected. It fails on the previous driver.
  2. low, SQLite $ in a Tcl variable name: fixed in 9f187ef.
    • The rule now follows SQLite's tokenizer: $, @, : or #, then name characters including $ and ::, then (, fails closed.
    • The unit test covers the 36 prefix and opener combinations, and the isWhereFragment payload. It fails on the previous scanner.
  3. low, a formatted invalidated proposal: fixed in 3863280.
    • sqlIsPartOfProposal now ignores case and all whitespace. A false match only refuses a run.
    • The test runs the real formatSql over a lower-case proposal. The outcome report (sqlMatchesProposal) stays strict, so a formatted run is still reported as different SQL.

Verified locally:

  • biome lint, dprint check and tsc --noEmit pass.
  • The unit suite passes: 1817 pass, 0 fail. The pre-fix tree, after the rebase, also passes under Bun 1.4.4.
  • readonly-session passes against live PostgreSQL and MariaDB: 58 pass.

PostgreSQL skipped the per-statement reassert inside an explicit
transaction, so a connection Bun reconnected mid-transaction ran
read-write. Reassert there too; in an aborted transaction tolerate only
the 25P02 refusal, so ROLLBACK still works. MySQL iterate reasserted
only before its snapshot transaction; reassert before every page.

No agent path opens a transaction or iterates on a read-only session
today; this closes the gap before one does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@matej21

matej21 commented Nov 2, 2026

Copy link
Copy Markdown
Member Author

Review round 5: 3863280

Reviewer: the same independent Claude subagent (Opus 5.5). It verified the round-4 fixes against live MariaDB 11.8, MySQL 8.4, PostgreSQL 17 and SQLite.

Verdict: approve. All three round-4 fixes hold, and there is no regression.

Findings

  1. low, confirmed, not reachable by the agent: a PostgreSQL read-only session reconnected inside an explicit transaction becomes read-write.
    • Cause: reassertReadOnly skipped the reassert while txActive.
    • Probe: after pg_terminate_backend, Bun reconnected and an INSERT committed.
    • Not reachable: no agent path opens a transaction on a read-only session, because BEGIN is classified as unknown and rejected.
  2. info: MySQL iterate() reasserts once, before its snapshot transaction.
    • A connection drop between pages would run the next page without the setup.
    • No read-only session calls iterate() today.

Verified

  • MySQL reconnect:
    • KILL on MariaDB and MySQL, with initSql: 'SET NAMES gbk' and with and without a statement timeout, stays read-only with utf8mb4.
    • The statement timeout is restored.
    • A KILL inside an explicit transaction stays read-only, and the SET statements do not fail inside a transaction.
    • iterate works.
    • Writable sessions are unaffected.
  • SQLite Tcl variable names:
    • The round-4 probes give 0 bypasses.
    • An exhaustive sweep of 108,480 prefix, name and payload combinations gives 0 bypasses.
    • A fuzz run of 100k statement cases and 50k WHERE-fragment cases gives 0 hits.
  • Format SQL: ten proposal shapes run through the real formatSql are all still recognised as part of their proposal.

@matej21

matej21 commented Nov 2, 2026

Copy link
Copy Markdown
Member Author

Response to review round 5

  1. low, PostgreSQL reconnect inside a transaction: fixed in 2cecc87.
    • The reassert now also runs inside a transaction.
    • In an aborted transaction it tolerates only the 25P02 refusal, so ROLLBACK still works.
    • New live tests:
      • a reconnect inside a transaction;
      • a reconnect inside an aborted transaction;
      • rollback of an aborted transaction on the read-only session.
    • The two reconnect tests fail on the previous driver.
  2. info, MySQL iterate(): fixed in 2cecc87.
    • The setup is reasserted before every page.
    • A live test KILLs the connection between pages and checks that the next page is still read-only. It fails on the previous driver.

Verified locally:

  • biome lint, dprint check and tsc --noEmit pass.
  • The unit suite passes: 1821 pass, 0 fail.
  • readonly-session passes against live PostgreSQL and MariaDB: 61 pass.
  • The live PostgreSQL and MySQL driver, session, initSql, iterate and multi-database suites pass: 141 pass.

@matej21
matej21 merged commit feb5512 into main Nov 3, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants