Repository navigation
Conversation
The read-only and approval gates treat anything that does not classify as a clean read as a write. The classifier only knew SQL keywords, so the MongoDB driver plugin's query language (`db.<collection>.<op>(...)`) fell through to "unknown": every MongoDB read — `find`, `aggregate`, `countDocuments` — was blocked in read-only mode and queued for approval under the default `writes_only` mode. Classify the shell grammar the plugin accepts. `find`, `findOne`, `count`, `countDocuments` and `distinct` are reads; `aggregate` is a read unless the pipeline has an `$out` or `$merge` stage; the CRUD calls are writes; `drop`, `createCollection` and `createIndex` are DDL. Anything else, including `runCommand` (which can run any command) and unknown operations, stays "unknown" so the gates keep failing closed.
| /// safe direction. | ||
| fn mongo_aggregate_writes(args: &str) -> bool { | ||
| ["$out", "$merge"].iter().any(|stage| { | ||
| args.match_indices(stage).any(|(idx, _)| { |
There was a problem hiding this comment.
WARNING: $out / $merge detection is a raw text scan and can fail open
This classifier is the only read-only / approval gate for MongoDB, and aggregate is classified select unless the literal bytes $out or $merge appear in key position. The plugin parses the pipeline as JSON/EJSON, which allows spellings that decode to the same stage name but do not contain those bytes, e.g. {"\u0024out": "archive"} (JSON unicode escape for $). db.users.aggregate([{"\u0024out": "archive"}]) would be classified select, bypassing read-only mode and the writes_only approval gate while writing to a collection.
Since the doc comment states the design goal is to never widen what a gate lets through, consider failing closed: e.g. treat an aggregate as a read only if the arguments contain no backslash escapes outside plain string values (or simply parse the args as JSON and inspect the actual stage keys), and return unknown/write when the args cannot be parsed. Add a test for the escaped-key case.
Code Review SummaryStatus: No Issues Found | Recommendation: Merge The only change since the last review (
The earlier findings ( Files Reviewed (1 file)
Previous Review Summaries (4 snapshots, latest commit 205d9c9)Current summary above is authoritative. Previous snapshots are kept for context only. Previous review (commit 205d9c9)Status: No Issues Found | Recommendation: Merge The PR changed design since the last review. The host-side Checked against current code:
The plugin RPC contract is documented, and the tests cover opt-in, failure, fail-closed and no-fallback behavior. Files Reviewed (15 files)
Previous review (commit 43e5f5c)Status: No Issues Found | Recommendation: Merge The incremental changes harden
The earlier Note: the MongoDB plugins are external and were not available, so their grammars were assumed from the PR description and code comments. Files Reviewed (2 files)
Previous review (commit c2ca213)Status: No Issues Found | Recommendation: Merge The previously reported Note: the MongoDB plugin is external and was not available, so its grammar was assumed from the PR description and code comments. Files Reviewed (2 files)
Previous review (commit 98d977b)Status: 1 Issue Found | Recommendation: Address before merge Overview
Issue Details (click to expand)WARNING
Files Reviewed (2 files)
Note: the MongoDB plugin is external and was not available, so its grammar was assumed from the PR description. Reviewed by claude-sonnet-5.5 · Input: 252 · Output: 1.4K · Cached: 167.9K Review guidance: REVIEW.md from base branch |
The `$out` / `$merge` check scanned the raw argument text, but the plugin
hands those args to `serde_json` before running them. An escaped key such as
`db.c.aggregate([{ "\u0024out": "archive" }])` therefore ran as a real `$out`
stage while the classifier saw no write stage and called it a read, letting
it past the read-only and `writes_only` approval gates.
Parse the args as JSON and check the decoded keys. Args that do not parse
(e.g. ones holding `ObjectId(…)` shell helpers) fall back to the text scan,
and any backslash there counts as a write: it may be an escape the scan
cannot decode, and failing closed is the safe direction.
|
Thanks — that was a real bypass, not a false positive. Fixed in c2ca213. Confirmed the mechanism in the plugin: The check now parses the args as JSON and inspects the decoded keys:
New test
|
HongTin2104
left a comment
There was a problem hiding this comment.
Requesting changes for a read-only bypass. It only affects the MongoDB Atlas plugin, not the one this PR mirrors.
The README lists two MongoDB plugins. classify_mongo_shell mirrors utils/query_parse.rs from danielnuld/tabularis-mongodb-plugin. TabularisDB/tabularis-mongodb-plugin (the Atlas one) parses differently in src/main.rs:
execute_queryfirst runsstrip_comment_lines, which drops every line whose trimmed start is//.parse_shell_querytakes the collection up to the first.and the operation from there to the first(. It also ignores anything after the balanced).
The classifier is host-wide and does not know which plugin it is guarding. Because of that, a comment line can show the classifier one operation while the plugin runs another:
db.users
//.find(
.drop()
- Classifier: the first
(is on the comment line, sopathisusers\n//.find.rfind('.')givesfind, the text ends with), there is no;and no quoted call, so the result isselect. - Atlas plugin: the comment line is stripped, leaving
db.users\n.drop(). The collection isusersand the operation isdrop, so the collection is dropped.
The same works with .deleteMany({}), .updateMany(...) and the other write operations. Read-only mode and writes_only approval would both let these through. I checked this by porting both parsers (yours and the Atlas plugin's) to a small script, not by running the plugin against a server, so please double-check it on your side.
Some fixes that keep this fail-closed for both plugins:
- Return
Nonewhenpath(the text before the first() contains any whitespace or any/. Neither plugin's documented grammar needs either, and that alone rules out the comment trick. - Or return
Nonewhen any line of the query starts with//after trimming, which matches what the Atlas plugin strips.
A regression test with the payload above would be good either way.
Smaller divergences, all fail-closed today, just noting them:
- The Atlas plugin uses
trim_end_matches(';'), so it strips every trailing semicolon, while this PR strips exactly one.db.users.find({});;is thereforeunknownhere and still runs there. This is only a false positive. db.a.b.find({})classifies asselecthere (last dot). The Atlas plugin reads it as collectiona, operationb.find, and rejects it as unsupported. This is harmless now, but it shows why the "first dot vs. last dot" split should be pinned down.- The Atlas plugin also accepts
estimatedDocumentCountanddropIndex, which aren't mapped here. Both stayunknown, which is safe.estimatedDocumentCountcould be added to the read list if you want.
Apart from this, the approach is good. Defaulting to unknown and decoding JSON before looking for $out/$merge are the right calls, and the escape-handling fix in c2ca213 is solid.
The classifier is host-wide, but two MongoDB plugins are registered and they
do not agree on how a shell call is split. `danielnuld/tabularis-mongodb-plugin`
takes the LAST dot as the operation separator; `TabularisDB/tabularis-mongodb-plugin`
(the Atlas one) drops every line starting with `//` and then takes the FIRST
dot. So this payload showed one parser a read and handed the other a write:
db.users
//.find(
.drop()
`rfind('.')` saw `find` and classified it `select`, while the Atlas plugin
stripped the comment line, read `.drop()` and dropped the collection — past
both read-only mode and the `writes_only` approval gate.
Classify only what both parsers read identically:
- Refuse any `//` line: the Atlas plugin drops it and the other keeps it, so
the two disagree on what the statement is.
- Split the operation on the FIRST dot, the stricter reading, so
`db.a.b.find({})` is the Atlas plugin's unsupported `b.find`, not a read.
- Reject whitespace and `/` in the path; neither grammar needs them, and
whitespace is what moved the split point in the payload above.
- Strip exactly one trailing `;`; the Atlas plugin strips all of them, so
`db.c.find({});;` stays unknown instead of running there as a read.
Also map the operations the Atlas plugin accepts and this table missed:
`estimatedDocumentCount` (read), `dropIndex` (ddl).
|
Fixed in 43e5f5c — thanks, this was a real bypass and I'd only mirrored one of the two plugins. I confirmed your reading of the Atlas parser: So the fix makes the classifier accept only what both parsers read identically:
Regression tests in On your smaller notes:
One thing worth raising, since it's the root of both this and the |
There was a problem hiding this comment.
Thanks for tackling MongoDB reads being blocked by the MCP gates, especially the regression cases for escaped aggregation write stages and disagreements between the two plugin parsers.
Since this is your first PR here, we would be happy to have you join us on Discord if you would like to get involved: https://discord.com/invite/K2hmhfHRSt
After looking at where this belongs, I would like us to keep the MongoDB grammar and operation classification in the driver plugin rather than add them to the core SQL classifier. The MCP code already resolves the driver before checking read-only mode and approval, so that is where a generic driver classification hook could be used. The current plugin contract does not provide that hook yet, so this needs a small host-side contract extension as well.
Could you rework this around an optional plugin classification RPC, with an explicit capability and a conservative fallback for unsupported methods, errors or unknown results? The core should continue to enforce read-only and approval policy, including reclassifying a query edited during approval. MongoDB-specific parsing and its tests should live alongside the parser in each plugin.
Please also account for execution-time protection: the current default execute_read_only_query implementation delegates to execute_query for plugins, so classification alone does not provide database-enforced read-only execution. We should define that guarantee in the plugin contract rather than assume it is already present. The manifest schema, frontend capability types and plugin guide should stay in sync.
I am holding off on end-to-end checks and merging this version while we settle that boundary. The problem is worth fixing; I would prefer a plugin extension that other non-SQL drivers can use too.
|
Friendly ping on this one — no rush, just flagging something that may be blocking it. The five main workflows on this PR are sitting in
That is the standard first-contributor gate for fork PRs, not a failure. Only "PR title" and "Kilo Code Review" have actually executed so far. Approving them would let the automated suite run and take the manual end-to-end check off your plate — that's the part I can't do from the fork side. What I have verified locally: One housekeeping note: the branch is now 45 commits behind Still available for the |
|
@kobewl ouch my fault, started work flows right now |
|
@debba — quick status question rather than a nudge. CI is green and the merge state is Both points from that review were addressed in 43e5f5c, and the reviewer's own suggestions are what the code now implements:
CI confirms it: So I think the review is stale rather than open work. Is dismissing it something you need to do on your side, or is it simply waiting in the merge queue? Happy to adjust anything if I've misread the state. Also — thanks for merging |
|
@debba — following up on my question from 11-06, now with something concrete rather than a guess. I noticed #1392 was merged yesterday and its review history reads That review asked for two things, and both are what the code now does (all in 43e5f5c):
CI on 6e9795b is fully green — Would you be able to dismiss that review so this can go in? If there's something in it you still want changed, tell me and I'll do it — I just want to be sure it isn't being read as open work. Thanks for clearing the backlog yesterday, incidentally — 23 merges in a day is a lot of review. |
debba
left a comment
There was a problem hiding this comment.
Thanks for following up and for addressing the parser discrepancies raised in the earlier review. My architectural request is still outstanding, though: #1324 (review)
The current PR still adds MongoDB-specific parsing and operation classification to the core SQL classifier. Please rework this around an optional plugin classification RPC with an explicit capability, keeping the MongoDB grammar and its regression tests alongside the parser in each plugin. Unsupported methods, errors and unknown results must fall back conservatively.
The core should continue to enforce read-only and approval policy, including reclassifying queries edited during approval. Please also define the execution-time read-only guarantee in the plugin contract: the current default execute_read_only_query implementation delegates to execute_query for plugins, so classification alone is not database-enforced read-only execution. Keep the manifest schema, frontend capability types and plugin guide in sync.
The green CI and the fixes to the earlier review do not resolve this boundary concern, so dismissing that review is not the only remaining step before merge. If useful, the host contract extension and the plugin implementations can be split into coordinated PRs.
Drop the MongoDB grammar from the core SQL classifier and give plugins a
contract to classify their own query language instead.
The MCP read-only mode and the approval gate both ask whether a query is a
clean read. The host can only answer that for SQL, so every query of a
non-SQL plugin counted as `unknown`. Two optional capabilities let the
plugin take the question over:
- `query_classification` enables the optional `classify_query` RPC. The
reply is `{ "kind": "select" | "write" | "ddl" | "unknown" }`.
- `read_only_execution` enables the optional `execute_read_only_query` RPC,
which refuses anything that is not a read at execution time.
The host keeps the policy and fails closed:
- Without the capability nothing changes: no RPC, SQL classification.
- With it the query is never read as SQL. An RPC error (including -32601),
a timeout or an unreadable result is `unknown`.
- A `select` verdict only counts together with `read_only_execution`, so
classification is never the only guard for a query that runs unapproved.
- A declared `read_only_execution` is never retried through `execute_query`.
- Read-only mode, approval and the re-classification of a query edited in
the approval dialog stay in `tool_run_query`.
MongoDB-specific parsing and its tests belong in the plugins. The
capabilities are documented in the plugin guide and mirrored in the
manifest schemas and the frontend types.
Co-authored-by: Cursor <cursoragent@cursor.com>
The example showed a request with `page_size` and a result with `total_count` / `execution_time_ms`, but the host sends `limit`, `page` and `schema`, and a plugin result must carry `columns`, `rows` and `affected_rows` (`truncated`, `pagination` and the timing fields are optional). A result written from the old example fails to deserialize on the host side, and the new `execute_read_only_query` section refers to these params, so fix the example here. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Reworked per your review. The MongoDB grammar is out of core —
Verified locally: |
NewtTheWolf
left a comment
There was a problem hiding this comment.
Thanks for moving the MongoDB grammar out of core and adding an opt-in classifier with fail-closed error handling. I checked de7d105, including reclassification after an approval edit. Typecheck, the four schema tests and all 2,448 Rust library tests pass locally. The original request for a generic plugin-owned classifier is addressed.
There is now an integration update needed after #1463 landed. Main already defines capabilities.read_only_queries and RpcDriver::execute_read_only_query. Please rebase and reuse that execution guarantee, preserving its intrinsically read-only and legacy-plugin behavior, rather than adding a parallel read_only_execution flag and competing override. The current merge conflicts in the driver implementation.
Please also carry the plugin verdict through the saved-connection read-only preflight. Main's application/read_only.rs currently classifies that path as SQL; otherwise a valid MongoDB read can still be rejected before the new plugin classifier helps. Add combined regressions for MCP read-only mode and saved read-only connections: a classified read with the execution guarantee is allowed, writes/unknown/errors are blocked, and a failed protected RPC never falls back to ordinary execution. I am keeping this open for that combined revision.
Rework the plugin query classification on top of main's protected read-only queries instead of a parallel mechanism, as asked in review. - Drop the read_only_execution capability and the second RpcDriver::execute_read_only_query. The execution guarantee is capabilities.read_only_queries (or readonly) and main's execute_read_only_query, unchanged: its legacy-plugin behaviour stays and a failed protected call is never retried through execute_query. - Move the plugin-aware classification from mcp/query_kind.rs to application/read_only, where every read-only policy reads it. - Make read_only::validate_query driver-aware, so a saved read-only connection hears the plugin's verdict in the editor, in batches, in exports and in MCP instead of reading the text as SQL. MCP classifies once and passes the verdict to validate_kind. - Add regressions that combine the MCP read-only mode, the approval gate and saved read-only connections: a classified read with the execution guarantee runs through the protected RPC, writes, unknown text and classifier failures never reach an executor, and a failed protected call is not retried through execute_query. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Thanks, this is the combined revision. I merged
Local runs are in the description: One trade-off to look at: a saved read-only connection classifies in the preflight and again inside the protected execution, so a plugin that declares Side note, not part of this PR: on main |
NewtTheWolf
left a comment
There was a problem hiding this comment.
Re-reviewed e97fe771beb47ddd94161ba5cb392b1b000dd423. This addresses my combined revision request: the parallel capability/override is gone, the existing read_only_queries/readonly execution guarantee is preserved, and saved-connection preflight now uses the plugin verdict. The combined MCP/editor tests check reads, writes, unknown/error verdicts, approval edits and failure of the protected RPC without an ordinary-execution fallback. I found no remaining blocker in that correction.
The complete Rust library suite passes with one test thread (2,540 passed, 19 ignored), as do server-only checking, the manifest-schema tests, frontend typecheck and lint. The default parallel library run was not clean: registry reconciliation observed readonly-export-* fixtures, and the Kubernetes tunnel tests reported an occupied port followed by a poisoned lock. Those failures remain recorded separately; I am not claiming that parallel run passed. I did not test a released MongoDB plugin against a live server.
@debba, please re-review the updated architecture against your outstanding review. I am leaving that blocking review for you to resolve before merge. The registry rollout and matching plugin implementations remain coordinated follow-ups, as documented in the guide.
Problem
Three places ask the same question of a query: is it a clean read? The MCP read-only mode, the MCP approval gate and, since #1463, a saved read-only connection. The host can only answer it for SQL. A non-SQL plugin such as MongoDB (
db.users.find({})) comes outunknown, which counts as a write: the MCP read-only mode blocks it,writes_onlyapproval queues it, and a saved read-only connection rejects it before the plugin is ever asked.What changed
This reworks the PR after @NewtTheWolf's review. It builds on #1463 instead of next to it.
read_only_executionand my secondRpcDriver::execute_read_only_queryare gone. The guarantee ismain'scapabilities.read_only_queries(orreadonly) andmain'sRpcDriver::execute_read_only_query, unchanged: legacy plugins keep their behaviour, an intrinsically read-only driver keepsexecute_query, and a failed protected call never falls back to it.plugins/driver.rsonly gainsclassify_query, its response parser and a test module.query_classification. It gates an optionalclassify_queryRPC that replies{ "kind": "select" | "write" | "ddl" | "unknown" }for the exact text about to run. Opt-in, defaultfalse, so existing plugins see no new RPC.application::read_only::classify_query(driver, params, query)(moved frommcp/query_kind.rs) is what the MCP gates and the saved read-only connection both ask. The MongoDB grammar stays out of core:ai_activity.rsandai_activity_tests.rsare identical tomain.read_only::validate_queryis nowasyncand takes the driver. The editor (single queries and batches), exports and the transfers reach it, directly or throughread_only::execute_query. Before, it read the text as SQL, so a valid MongoDB read was rejected before the plugin was consulted.tool_run_queryfeeds one verdict to the saved policy, the MCP read-only mode and the approval gate, throughread_only::validate_kind(the same policy for a verdict already in hand). A query edited in the approval dialog is classified again, and the edited text is what the gates and the final check see.How the host decides (fail closed)
query_classification: no RPC, the text is read as SQL, as onmain.-32601included), a transport error, a timeout, a result without a validkindand an unknownkindare allunknown.selectonly counts when the driver also has an execution guarantee (read_only_queriesorreadonly). Otherwise it isunknown: a classifier alone does not stop a write at execution.read_only_queriesorreadonly(main'svalidate_support), so a classifier-only driver cannot enter read-only mode at all.main'sexecute_read_only_query: the protected RPC forread_only_queries, the ordinary path for areadonlydriver. A failed protected call is never retried throughexecute_query.Contract surface kept in sync
plugins/PLUGIN_GUIDE.md(new "Query Classification" section, capability rows, a cross-reference in "Optional protected read-only queries", registry rollout note),plugins/manifest.schema.json,plugins/tabularium-extensions.schema.jsonandpackages/web-ui/src/types/plugins.ts, with a Vitest test for the two schemas next tocolumnStatsSchema.test.ts.Also corrected the pre-existing
execute_queryexample in the guide while touching it: the host sendslimit/page/schema(notpage_size) and a plugin result must carrycolumns,rowsandaffected_rows. A result written from the old example does not deserialize on the host side.Tests
24 new Rust tests and 2 Vitest cases. They combine the MCP read-only mode and saved read-only connections, as requested in review. Every scenario reads the call log of a fake plugin, because "blocked" has to mean that nothing reached its executor.
application/read_only/plugin_tests.rs(9), the saved read-only connection: a classified read with the guarantee runs through the protected RPC after its verdict; writes, DDL, unknown text, SQL-looking text and a failing classifier are refused with onlyclassify_queryreaching the plugin; a failed protected call is not retried throughexecute_query; a classifier-only driver cannot enter read-only mode; areadonlydriver keepsexecute_query; a SQL plugin is unchanged. One test drivesapplication::queries::execute_query/execute_query_batchagainst a saved connection, the real editor entry points, including a batch that is refused as a whole before its first statement is sent.mcp/read_only_tests.rs(1 test, several scenarios, run in a child process with throwaway storage likedatabase_scope_is_enforced_before_driver_access): the MCP read-only mode; a saved read-only connection; the approval gate with nobody to approve (a guaranteed read runs, a classifier-onlyselectstill waits); queries edited in the approval dialog (into another read, into a write / DDL / unknown text / failing classification on a saved read-only connection, into a write while the MCP read-only mode is on); a legacy SQL plugin unchanged.application/read_only/classification/tests.rs(7) andplugins/query_classification_tests.rs(7): the verdict table, every failure mode, and the exact text and connection on the wire.application/read_only/tests.rs:main's SQL preflight test is now async.I checked that these fail when the code is wrong. Each of these seven changes makes at least one test fail (and each was reverted): the final MCP check uses the original kind instead of the edited one; a
selectis trusted without a guarantee; the saved-connection preflight reads the text as SQL again; a failed protected call falls back toexecute_query;validate_kindlets every kind through; a batch is not validated up front; MCP classifies the text as SQL.Run locally:
cd src-tauri && cargo test --locked --no-fail-fast: lib 2531 passed, 0 failed, 18 ignored; the integration test crates compile (the database-backed ones are ignored). On macOS I setTMPDIRto a path without symlinks: with the default/var/folders/...two theme tests (application::persistence::tests::persists_and_deletes_custom_themes_in_the_shared_config_directory,application::themes::tests::announces_committed_personal_theme_changes) fail with "Theme paths must not contain symbolic links", because/varis a symlink. They are unrelated to this change.cargo check --locked --no-default-features --bin tabularisand the Tauri boundary grep from CI: pass.pnpm run typecheck,pnpm run lint(ESLint, Prettier for the whole repo, theme tokens),web:boundaries,web:packaging,web:docs,web:manual-parity,test:web-contract: pass.pnpm test: 555 files, 8829 tests passed.rustfmtis clean on the files this PR adds. A few other files onmainhave formatting differences already; I left them alone.Behaviour to know
classify_querycalls for a plugin that declares the capability. SQL drivers never make the call. Folding the two into one would mean passing the verdict intoread_only::execute_query, which every transfer and export caller uses, so I left it out of this PR.COUNT(*)wrapper of the editor and the transfer pagers are classified like any other text. A plugin that declaresquery_classificationis asked about them, and answers for what its executor would do with that exact text.Scope and follow-ups
classify_queryandexecute_read_only_queryand declare the capabilities, in their own repositories. Until then MongoDB behaves as onmain.query_classificationis published, the boolean has to be added to the driver kind'scapabilities.propertiesin the Tabularium registry (noted in the guide, like the other optional capabilities).For the maintainers to decide
trusted_kindinapplication/read_only/classification.rsdowngrades a plugin'sselecttounknownunless the driver has an execution guarantee. If you would rather trust the plugin'sselect, it is a one-line change.origin/maininto the branch instead of rebasing, to avoid force-pushing a branch with review history. The PR is squash merged, so I expect the history not to matter. I am happy to rebase if you prefer.