Repository navigation
feat: agent CLI — database access for AI agents, with approval-gated writes - #19
Conversation
e8df039 to
666140a
Compare
e17a0bc to
31d6ac4
Compare
Review fixes: read-only bypass + control-server idle timeoutPushed two fixes to this branch ( 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 The reported
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:
Re-run against live DBs: 13 blocked, 0 written; legitimate reads (including 2. Control server timed out slow requests (high)Neither 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
|
Review round 1 —
|
Response to review round 1
Verified locally: biome lint, dprint check, |
Review round 2:
|
Response to review round 2
Verified locally: lint, format, |
Review round 3 —
|
| 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
- 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/*!99999and 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.
- The scanner reads the inside of
- 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 stayedpending.
- 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.
- With
- 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.
- SQLite lexes
- low (upstream):
SELECT ''on MySQL/MariaDB crashes the backend.- On Bun 1.3.14, an empty column name in a result makes
Bun.SQLsegfault. - It is reachable through
agent.query. This is a Bun bug, so there is no scanner fix.
- On Bun 1.3.14, an empty column name in a result makes
- 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
queryIdwithout checking it is its own, so the run never reports back and the proposal staysapproved.
- 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.
Response to review round 3
Verified locally: lint, format, |
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
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>
8f4b1b3 to
b21bda7
Compare
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>
Review round 4:
|
| 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.
Response to review round 4
Verified locally:
|
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>
Review round 5:
|
Response to review round 5
Verified locally:
|
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
0600endpoint file and authenticated with a token.dotazis a one-shot client against it, so it reuses the connections the user already configured in the app.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 proposeopens 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.listresponses are stripped of passwords andinitSqlon the way out. Theui.*methods only prefill:dotaz ui console --sqlfills the console, anddotaz ui open --wherefills 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, orDOTAZ_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:
executed, 1 row affected, CLI exits 0, and the row really changed (checked from outside the app).rejected, CLI exits 8, and theDELETEnever ran.pendingafter 30 s, CLI exits 7. There is no auto-run path.dotaz propose;ui.statereports 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
integrationjob. 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:sqliteexposes neither an interrupt nor a progress handler, andbusy_timeoutbounds 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
dc18075, independent Claude subagent):blocked, 11 findings, addressed ined022c0…2149b90.2149b90):blocked, 7 findings (3 more scanner/engine lexer disagreements, proposal-claim gaps), addressed incce18ad,ae5933b.ae5933b, differential fuzzing of the scanner against live engines):blocked, 7 findings, addressed in63a52d4,9609af7,8f4b1b3. Bun'sSELECT ''segfault is upstream and won't be fixed here.mainafter fix: count returned rows in history for statements with a result set #22 (history row count under Bun 1.4) was merged.8f4b1b3, the same fuzzing re-run at HEAD):changes, 3 low findings, addressed indd32e5f,9f187ef,3863280. No read-only bypass was found on PostgreSQL 17, MariaDB 11.8 or MySQL 8.4.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 MySQLiteratereconnect between pages. Both addressed in2cecc87.2cecc87(checkandintegration). The branch is up to date withmain.SELECT '', an upstream bug.LIKEpatterns with a backslash.feb5512).🤖 Generated with Claude Code
https://claude.ai/code/session_01GaiURkVJQu7uSu1A7YUxP8