Skip to content

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

Open
matej21 wants to merge 21 commits into
mainfrom
feat/agent-cli
Open

feat: agent CLI — database access for AI agents, with approval-gated writes#19
matej21 wants to merge 21 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, and connections.list responses are stripped of passwords on the way out.

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:

  • Runexecuted, 1 row affected, CLI exits 0, and the row really changed (checked from outside the app).
  • Rejectrejected, 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

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: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.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GaiURkVJQu7uSu1A7YUxP8

matej21 and others added 11 commits August 4, 2026 14:18
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
@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
matej21 and others added 10 commits August 4, 2026 15:23
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
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.

1 participant