feat: agent CLI — database access for AI agents, with approval-gated writes - #19
Open
matej21 wants to merge 21 commits into
Open
feat: agent CLI — database access for AI agents, with approval-gated writes#19matej21 wants to merge 21 commits into
matej21 wants to merge 21 commits into
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, and
connections.listresponses are stripped of passwords on the way out.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
1832 tests pass. 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.🤖 Generated with Claude Code
https://claude.ai/code/session_01GaiURkVJQu7uSu1A7YUxP8