@mcpolyglot/connector-sql 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +68 -8
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/__tests__/dialects.integration.test.d.ts +2 -0
  4. package/dist/__tests__/dialects.integration.test.d.ts.map +1 -0
  5. package/dist/__tests__/dialects.integration.test.js +183 -0
  6. package/dist/__tests__/dialects.integration.test.js.map +1 -0
  7. package/dist/__tests__/policy.test.d.ts +2 -0
  8. package/dist/__tests__/policy.test.d.ts.map +1 -0
  9. package/dist/__tests__/policy.test.js +190 -0
  10. package/dist/__tests__/policy.test.js.map +1 -0
  11. package/dist/__tests__/sqlite.integration.test.js +182 -4
  12. package/dist/__tests__/sqlite.integration.test.js.map +1 -1
  13. package/dist/connector.d.ts +30 -2
  14. package/dist/connector.d.ts.map +1 -1
  15. package/dist/connector.js +173 -14
  16. package/dist/connector.js.map +1 -1
  17. package/dist/dialect.d.ts +26 -1
  18. package/dist/dialect.d.ts.map +1 -1
  19. package/dist/dialect.js +4 -1
  20. package/dist/dialect.js.map +1 -1
  21. package/dist/dialects/mysql.d.ts +14 -3
  22. package/dist/dialects/mysql.d.ts.map +1 -1
  23. package/dist/dialects/mysql.js +67 -6
  24. package/dist/dialects/mysql.js.map +1 -1
  25. package/dist/dialects/postgres.d.ts +14 -3
  26. package/dist/dialects/postgres.d.ts.map +1 -1
  27. package/dist/dialects/postgres.js +70 -9
  28. package/dist/dialects/postgres.js.map +1 -1
  29. package/dist/dialects/sqlite.d.ts +12 -3
  30. package/dist/dialects/sqlite.d.ts.map +1 -1
  31. package/dist/dialects/sqlite.js +31 -4
  32. package/dist/dialects/sqlite.js.map +1 -1
  33. package/dist/index.d.ts +2 -1
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +2 -1
  36. package/dist/index.js.map +1 -1
  37. package/dist/policy.d.ts +56 -0
  38. package/dist/policy.d.ts.map +1 -0
  39. package/dist/policy.js +343 -0
  40. package/dist/policy.js.map +1 -0
  41. package/package.json +2 -2
package/README.md CHANGED
@@ -10,17 +10,77 @@ For every SQL source mcpolyglot generates:
10
10
  - `<id>.describe_table` — one table's columns, types, and primary key.
11
11
  - `<id>.query` — read-only SQL with parameterized args, row cap, and timeout.
12
12
 
13
- (Opt-in per-table tools like `users.find_by_email` are scaffolded by `mcpolyglot init` and land fully in Wave 3.)
14
-
15
13
  ## How read-only is enforced
16
14
 
17
- | Dialect | Enforcement |
18
- | -------- | ------------------------------------------------------------------------------------------------------------- |
19
- | Postgres | `BEGIN READ ONLY` transaction; rolled back at the end of every call. |
20
- | SQLite | `PRAGMA query_only = 1`; database opened read-only when possible. |
21
- | MySQL | AST gate (`node-sql-parser` mysql grammar) + `SET SESSION TRANSACTION READ ONLY` + `MAX_EXECUTION_TIME` hint. |
15
+ Three layers, each enough on its own for the common case:
16
+
17
+ | Layer | Postgres | MySQL | SQLite |
18
+ | --------------------------------- | -------------------------------------------- | -------------------------------------------------------------------- | ------------------------------------- |
19
+ | Policy classifier (every dialect) | parses the SQL, denies before the DB sees it | same | same |
20
+ | Per call (`query`) | `BEGIN READ ONLY`, rolled back | AST gate + `START TRANSACTION READ ONLY` + `MAX_EXECUTION_TIME` hint | statement must be a reader |
21
+ | Per connection (no `write` table) | `default_transaction_read_only=on` | `SESSION TRANSACTION READ ONLY` on every pooled connection | file opened `readonly` + `query_only` |
22
+
23
+ The MySQL AST gate matters because MySQL's transaction read-only mode doesn't cover everything. The connection layer is a session default, so a `SET` could undo it; the classifier blocks `SET`. A database role without write grants is still the real backstop.
24
+
25
+ ## Policy layer
26
+
27
+ Every `query` call is parsed (`node-sql-parser`, per-dialect grammar) and checked against the source's `policy` **before** it reaches the database. Denials come back as `forbidden.policy` with a specific reason.
28
+
29
+ ```ts
30
+ policy: {
31
+ tables: { users: 'read', orders: 'write', 'public.secrets': 'none' },
32
+ defaultAccess: 'read', // 'read' | 'none' — never 'write'
33
+ denyColumns: ['users.password_hash', '*.ssn'],
34
+ maxRows: 100, // min'd with limits.rowCap
35
+ statementTimeoutMs: 5000, // min'd with limits.timeoutMs
36
+ maxWritesPerCall: 1, // execute rolls back above this many changed rows
37
+ idempotencyWindowMinutes: 10, // execute idempotencyKey memory
38
+ }
39
+ ```
40
+
41
+ Rules (each has a case in `src/__tests__/policy.test.ts`):
42
+
43
+ - Unparseable SQL is denied. So is more than one statement per call.
44
+ - Anything other than `SELECT`/`INSERT`/`UPDATE`/`DELETE` is always denied: DDL, `GRANT`, `TRUNCATE`, `SET`, and so on.
45
+ - `UPDATE`/`DELETE` without `WHERE` is denied, as is a `WHERE` that names no column (`WHERE 1=1`). This catches a forgotten clause; a determined full-table write is stopped by `maxWritesPerCall` rolling it back, not by this check.
46
+ - `SELECT ... INTO OUTFILE/DUMPFILE` (MySQL) and `SELECT ... INTO table` (Postgres) are denied.
47
+ - Functions that reach the server's filesystem, other hosts, or the session itself are denied by name in every dialect: `pg_read_file`, `pg_ls_dir`, `lo_import`/`lo_export`, `dblink*`, `pg_sleep`, `LOAD_FILE`, `SLEEP`, `load_extension`, and so on (`DENIED_FUNCTIONS` in `policy.ts`). The list is a backstop; revoke the privileges at the database.
48
+ - System catalogs (`information_schema`, `pg_catalog`, `pg_*`, `mysql`, `performance_schema`, `sys`, `sqlite_*`) are denied under any `defaultAccess` unless a `tables` key names the table.
49
+ - Every table referenced anywhere (joins, subqueries, `INSERT … SELECT`) is checked. `none` tables are denied and hidden from `list_tables`. Writes need an explicit `write` entry.
50
+ - When several policy keys could match a table (`users` and `public.users`), the most restrictive wins.
51
+ - A denied column is blocked wherever it appears: select list, `WHERE`, subqueries, aliased tables. `SELECT *` is denied if it could expose one. With a `*.col` rule, that means every `SELECT *`. Denied columns are also stripped from `list_tables`/`describe_table`.
52
+ - A whole-row reference is treated like `SELECT *` on that table: `SELECT u FROM users u`, `to_jsonb(u)`, `json_agg(u)`, `row_to_json(users)`, `u::text`. Any bare identifier that matches a table name or alias in the query counts, so a column that shares its table's name is denied too when that table has denied columns.
53
+ - Second layer, after the query runs: any object or array value in the result has keys named like a denied column removed, at any depth. This is what stops a denied column that still arrives inside a JSON value the classifier could not attribute.
54
+ - `dryRun: true` returns `{ decision }` without executing.
55
+
56
+ Known limits:
57
+
58
+ - The function denylist is by name. A user-defined wrapper, an extension function not on the list, or `COPY ... TO PROGRAM` (blocked as non-SELECT) are examples of why the classifier is defense-in-depth. The database user's privileges are the control: `mcpolyglot doctor` fails when that user is a superuser, is in `pg_read_server_files`/`pg_write_server_files`/`pg_execute_server_program`, or holds MySQL `FILE`/`SUPER`/`ALL PRIVILEGES`.
59
+ - A whole-row value cast to text (`u::text`) is not JSON, so the output filter cannot strip fields from it; the classifier denies the reference instead. If a parser gap lets one through, the redactor still masks the built-in PII patterns.
60
+ - `query` never writes, even on a writable connection: Postgres/MySQL run it in a read-only transaction and SQLite refuses non-reader statements.
61
+
62
+ ## Writes and connection safety
63
+
64
+ The connection's mode follows the policy:
65
+
66
+ - **No `write` table** (the default): the database itself refuses writes. Postgres pools set `default_transaction_read_only=on`, MySQL sets `SESSION TRANSACTION READ ONLY` on every new pool connection (a failed SET drops the connection), SQLite opens the file `readonly` with `query_only`. The `execute` tool is not registered.
67
+ - **At least one `write` table**: the connection is writable and `<id>.execute` appears (scope `tables:write`).
68
+
69
+ `execute` runs one `INSERT`/`UPDATE`/`DELETE` that `classify()` allows, inside a transaction, with `statementTimeoutMs`. If it changes more than `maxWritesPerCall` rows it rolls back and fails with `forbidden.policy` (the reason carries both counts). Success returns `{ rowsAffected }`, also in `metadata.rows`. `dryRun` works as in `query`.
70
+
71
+ `idempotencyKey` (optional): a retry with the same key within `idempotencyWindowMinutes` returns the first result without writing again. The same key with different `sql`/`params` fails with `invalid_argument`. Failed calls are not remembered. Keys live in process memory, per connector: a restart or a second replica forgets them.
72
+
73
+ Source-level limits:
74
+
75
+ ```ts
76
+ { id: 'pg.main', kind: 'postgres', url: '…',
77
+ pool: { max: 4, idleTimeoutMs: 30000 }, // pg / mysql2 pool; SQLite ignores it
78
+ maxConcurrentQueries: 8 } // query + execute in flight on this source
79
+ ```
80
+
81
+ Calls over `maxConcurrentQueries` are **rejected** immediately with `rate_limited`, not queued, so the agent sees back-pressure instead of a hidden wait.
22
82
 
23
- The AST gate matters on MySQL because `SET TRANSACTION READ ONLY` is partially honored — the parser refuses anything whose top-level statement isn't a read.
83
+ Known gaps: MySQL has no server-side timeout for DML, so `execute` uses the driver's client-side timeout (the server may run on briefly). SQLite checks the timeout after the statement finishes, then rolls back. A replayed idempotent call returns the old result even if the row has since changed.
24
84
 
25
85
  ## Drivers are optional deps
26
86