@shibbirweb/mcp-db-read-only 0.1.0

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 (96) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/LICENSE +21 -0
  3. package/README.dockerhub.md +354 -0
  4. package/README.md +387 -0
  5. package/dist/ApplicationFactory.js +144 -0
  6. package/dist/config/EnvironmentConfigLoader.js +179 -0
  7. package/dist/config/PackageVersionLoader.js +43 -0
  8. package/dist/connections/ConnectionManager.js +101 -0
  9. package/dist/connections/ConnectionRegistry.js +109 -0
  10. package/dist/connections/ConnectionTargetFactory.js +104 -0
  11. package/dist/connections/ConnectionUrlParser.js +195 -0
  12. package/dist/domain/ConnectionProfile.js +39 -0
  13. package/dist/domain/ConnectionTarget.js +139 -0
  14. package/dist/domain/Engine.js +159 -0
  15. package/dist/drivers/BaseDriver.js +35 -0
  16. package/dist/drivers/DatabaseDriver.js +1 -0
  17. package/dist/drivers/DriverCache.js +107 -0
  18. package/dist/drivers/DriverProvider.js +71 -0
  19. package/dist/drivers/DriverRegistry.js +24 -0
  20. package/dist/drivers/GlobPattern.js +41 -0
  21. package/dist/drivers/LazyResource.js +56 -0
  22. package/dist/drivers/document/MongoDriver.js +187 -0
  23. package/dist/drivers/document/MongoSchemaSampler.js +74 -0
  24. package/dist/drivers/document/MongoStageAllowlist.js +87 -0
  25. package/dist/drivers/keyvalue/RedisCommandFlagsGuard.js +69 -0
  26. package/dist/drivers/keyvalue/RedisDriver.js +224 -0
  27. package/dist/drivers/search/ElasticsearchDriver.js +159 -0
  28. package/dist/drivers/sql/ClickHouseDriver.js +156 -0
  29. package/dist/drivers/sql/MsSqlDriver.js +147 -0
  30. package/dist/drivers/sql/MySqlDriver.js +144 -0
  31. package/dist/drivers/sql/MySqlSessionInitializer.js +100 -0
  32. package/dist/drivers/sql/PostgresDriver.js +176 -0
  33. package/dist/drivers/sql/SqlIdentifier.js +36 -0
  34. package/dist/drivers/sql/SqliteDriver.js +202 -0
  35. package/dist/drivers/sql/SqliteProtocol.js +7 -0
  36. package/dist/drivers/sql/SqliteWorker.js +71 -0
  37. package/dist/errors/ApplicationError.js +15 -0
  38. package/dist/errors/EngineMismatchError.js +15 -0
  39. package/dist/errors/InvalidConnectionUrlError.js +14 -0
  40. package/dist/errors/InvalidProfileDefinitionError.js +15 -0
  41. package/dist/errors/NoActiveConnectionError.js +13 -0
  42. package/dist/errors/NoDatabaseSelectedError.js +13 -0
  43. package/dist/errors/ObjectNotFoundError.js +14 -0
  44. package/dist/errors/UnknownProfileError.js +16 -0
  45. package/dist/errors/UnsupportedOperationError.js +14 -0
  46. package/dist/errors/index.js +9 -0
  47. package/dist/formatting/JsonSerializer.js +49 -0
  48. package/dist/formatting/RowFormatter.js +43 -0
  49. package/dist/formatting/ToolResponse.js +25 -0
  50. package/dist/index.js +15 -0
  51. package/dist/server/McpDbServer.js +69 -0
  52. package/dist/tools/BaseTool.js +42 -0
  53. package/dist/tools/DatabaseScopedTool.js +61 -0
  54. package/dist/tools/QueryTools.js +13 -0
  55. package/dist/tools/browse/DescribeTableTool.js +37 -0
  56. package/dist/tools/browse/GetForeignKeysTool.js +36 -0
  57. package/dist/tools/browse/GetTableIndexesTool.js +30 -0
  58. package/dist/tools/browse/GetTableSampleTool.js +50 -0
  59. package/dist/tools/browse/ListTablesTool.js +51 -0
  60. package/dist/tools/connection/ConnectTool.js +67 -0
  61. package/dist/tools/connection/CurrentConnectionTool.js +36 -0
  62. package/dist/tools/connection/ListConnectionsTool.js +38 -0
  63. package/dist/tools/connection/ListDatabasesTool.js +41 -0
  64. package/dist/tools/connection/UseConnectionTool.js +57 -0
  65. package/dist/tools/connection/UseDatabaseTool.js +45 -0
  66. package/dist/tools/document/AggregateTool.js +44 -0
  67. package/dist/tools/document/CountDocumentsTool.js +32 -0
  68. package/dist/tools/document/DistinctValuesTool.js +36 -0
  69. package/dist/tools/document/DocumentTool.js +38 -0
  70. package/dist/tools/document/FindDocumentsTool.js +56 -0
  71. package/dist/tools/keyvalue/RedisCommandTool.js +40 -0
  72. package/dist/tools/search/SearchTool.js +54 -0
  73. package/dist/tools/sql/RunQueryTool.js +46 -0
  74. package/dist/types/config.types.js +1 -0
  75. package/dist/types/connection.types.js +1 -0
  76. package/dist/types/driver.types.js +1 -0
  77. package/dist/types/index.js +1 -0
  78. package/dist/types/tool.types.js +1 -0
  79. package/dist/types/validation.types.js +1 -0
  80. package/dist/validation/document/MongoOperatorGuard.js +72 -0
  81. package/dist/validation/keyvalue/RedisCommandValidator.js +176 -0
  82. package/dist/validation/names/NamePolicy.js +122 -0
  83. package/dist/validation/names/NamePolicyRegistry.js +33 -0
  84. package/dist/validation/search/SearchBodyValidator.js +56 -0
  85. package/dist/validation/sql/ReadOnlyQueryValidator.js +82 -0
  86. package/dist/validation/sql/SqlDialect.js +196 -0
  87. package/dist/validation/sql/SqlSkeletonizer.js +197 -0
  88. package/dist/validation/sql/SqlValidatorRegistry.js +24 -0
  89. package/dist/validation/sql/rules/AmbiguousSyntaxRule.js +23 -0
  90. package/dist/validation/sql/rules/EmptyQueryRule.js +16 -0
  91. package/dist/validation/sql/rules/ForbiddenPatternRule.js +30 -0
  92. package/dist/validation/sql/rules/LeadingKeywordRule.js +29 -0
  93. package/dist/validation/sql/rules/SingleStatementRule.js +26 -0
  94. package/dist/validation/sql/rules/SmuggledWriteRule.js +50 -0
  95. package/dist/validation/sql/rules/index.js +6 -0
  96. package/package.json +76 -0
@@ -0,0 +1,196 @@
1
+ /** The five SQL dialects this server speaks. */
2
+ export class SqlDialects {
3
+ static MYSQL = {
4
+ name: "mysql",
5
+ label: "MySQL",
6
+ lexical: {
7
+ singleQuote: "backslash",
8
+ doubleQuote: "backslash",
9
+ backtick: "plain",
10
+ escapeStringPrefix: false,
11
+ brackets: "none",
12
+ dollarQuotes: false,
13
+ hashComments: true,
14
+ dashCommentNeedsSpace: true,
15
+ executableComments: true,
16
+ },
17
+ allowedLeadingKeywords: ["SELECT", "WITH", "SHOW", "DESCRIBE", "DESC", "EXPLAIN"],
18
+ bodyScanKeywords: ["WITH"],
19
+ scanExplainAnalyze: true,
20
+ scanEveryStatement: false,
21
+ // MySQL 8 allows a CTE to prefix UPDATE and DELETE, and EXPLAIN ANALYZE
22
+ // runs what it explains. Nothing else can follow those two openings.
23
+ writeKeywords: /\b(INSERT|UPDATE|DELETE|REPLACE|DROP|CREATE|ALTER|TRUNCATE|GRANT|REVOKE|RENAME|CALL|LOAD|HANDLER|LOCK|UNLOCK|INSTALL|UNINSTALL|FLUSH|SHUTDOWN|KILL)\b/i,
24
+ forbiddenPatterns: [
25
+ { pattern: /INTO\s+OUTFILE/i, reason: "INTO OUTFILE writes a file on the database server" },
26
+ { pattern: /INTO\s+DUMPFILE/i, reason: "INTO DUMPFILE writes a file on the database server" },
27
+ { pattern: /LOAD\s+DATA/i, reason: "LOAD DATA reads files into a table" },
28
+ { pattern: /\bBENCHMARK\s*\(/i, reason: "BENCHMARK() can hang the conversation" },
29
+ { pattern: /\bSLEEP\s*\(/i, reason: "SLEEP() can hang the conversation" },
30
+ ],
31
+ quoteExample: "`name`",
32
+ };
33
+ static POSTGRES = {
34
+ name: "postgres",
35
+ label: "PostgreSQL",
36
+ lexical: {
37
+ // Plain strings take no backslash escapes, which is only true with
38
+ // standard_conforming_strings on. The driver sets it on every
39
+ // transaction so this is a fact rather than an assumption.
40
+ singleQuote: "plain",
41
+ doubleQuote: "plain",
42
+ backtick: "none",
43
+ escapeStringPrefix: true,
44
+ brackets: "none",
45
+ dollarQuotes: true,
46
+ hashComments: false,
47
+ dashCommentNeedsSpace: false,
48
+ executableComments: false,
49
+ },
50
+ allowedLeadingKeywords: ["SELECT", "WITH", "SHOW", "EXPLAIN", "VALUES", "TABLE"],
51
+ bodyScanKeywords: ["WITH"],
52
+ scanExplainAnalyze: true,
53
+ scanEveryStatement: false,
54
+ // What may follow WITH (data-modifying CTEs) or EXPLAIN ANALYZE, and no
55
+ // more: a longer list would reject ordinary queries over columns with
56
+ // names like "comment" for no gain in safety.
57
+ writeKeywords: /\b(INSERT|UPDATE|DELETE|MERGE|CREATE|DROP|ALTER|TRUNCATE|EXECUTE|DECLARE|REFRESH)\b/i,
58
+ forbiddenPatterns: [
59
+ { pattern: /\bINTO\b/i, reason: "SELECT ... INTO creates a table" },
60
+ {
61
+ pattern: /\b(set_config|pg_reload_conf|pg_rotate_logfile|pg_promote|pg_switch_wal|pg_create_restore_point|pg_logical_emit_message|pg_notify)\s*\(/i,
62
+ reason: "that function changes server or session state",
63
+ },
64
+ {
65
+ pattern: /\b(pg_terminate_backend|pg_cancel_backend)\s*\(/i,
66
+ reason: "that function stops other sessions",
67
+ },
68
+ {
69
+ // lo_export writes a file on the server, which a read-only
70
+ // transaction does not prevent.
71
+ pattern: /\b(lo_import|lo_export|pg_file_write|pg_read_file|pg_read_binary_file|pg_ls_dir)\s*\(/i,
72
+ reason: "that function reaches the server's filesystem",
73
+ },
74
+ {
75
+ // Each of these runs SQL passed as a string. The string is a literal,
76
+ // so it is invisible to every rule here, and dblink runs it on a
77
+ // separate connection that the read-only transaction does not cover.
78
+ pattern: /\b(dblink\w*|query_to_xml\w*|cursor_to_xml\w*|ts_stat|crosstab\w*)\s*\(/i,
79
+ reason: "that function runs SQL supplied as a string",
80
+ },
81
+ { pattern: /\bpg_sleep\w*\s*\(/i, reason: "pg_sleep() can hang the conversation" },
82
+ { pattern: /\bpg_advisory\w*\s*\(/i, reason: "advisory locks outlive the query" },
83
+ ],
84
+ quoteExample: '"name"',
85
+ };
86
+ static SQLITE = {
87
+ name: "sqlite",
88
+ label: "SQLite",
89
+ lexical: {
90
+ singleQuote: "plain",
91
+ doubleQuote: "plain",
92
+ backtick: "plain",
93
+ escapeStringPrefix: false,
94
+ brackets: "simple",
95
+ dollarQuotes: false,
96
+ hashComments: false,
97
+ dashCommentNeedsSpace: false,
98
+ executableComments: false,
99
+ },
100
+ allowedLeadingKeywords: ["SELECT", "WITH", "EXPLAIN", "VALUES"],
101
+ bodyScanKeywords: ["WITH"],
102
+ scanExplainAnalyze: false,
103
+ scanEveryStatement: false,
104
+ writeKeywords: /\b(INSERT|UPDATE|DELETE|REPLACE|CREATE|DROP|ALTER)\b/i,
105
+ forbiddenPatterns: [
106
+ {
107
+ pattern: /\b(load_extension|writefile|readfile|edit|fts3_tokenizer)\s*\(/i,
108
+ reason: "that function reaches outside the database file",
109
+ },
110
+ ],
111
+ quoteExample: '"name"',
112
+ };
113
+ static MSSQL = {
114
+ name: "mssql",
115
+ label: "SQL Server",
116
+ lexical: {
117
+ singleQuote: "plain",
118
+ doubleQuote: "plain",
119
+ backtick: "none",
120
+ escapeStringPrefix: false,
121
+ brackets: "doubled",
122
+ dollarQuotes: false,
123
+ hashComments: false,
124
+ dashCommentNeedsSpace: false,
125
+ executableComments: false,
126
+ },
127
+ allowedLeadingKeywords: ["SELECT", "WITH"],
128
+ bodyScanKeywords: [],
129
+ scanExplainAnalyze: false,
130
+ scanEveryStatement: true,
131
+ // Every keyword that can start a statement with a side effect. OPEN,
132
+ // CLOSE, FETCH, DECLARE and similar are left out deliberately: they are
133
+ // harmless alone and common as column names in real schemas.
134
+ writeKeywords: /\b(INSERT|UPDATE|DELETE|MERGE|DROP|CREATE|ALTER|TRUNCATE|GRANT|REVOKE|DENY|EXEC|EXECUTE|DBCC|BACKUP|RESTORE|SHUTDOWN|KILL|BULK|WAITFOR|RECONFIGURE|CHECKPOINT|SET|BEGIN|COMMIT|ROLLBACK|SAVE|USE|UPDATETEXT|WRITETEXT|SETUSER|ENABLE|DISABLE|SEND|RECEIVE)\b/i,
135
+ forbiddenPatterns: [
136
+ { pattern: /\bINTO\b/i, reason: "SELECT ... INTO creates a table" },
137
+ {
138
+ pattern: /\b(OPENROWSET|OPENDATASOURCE|OPENQUERY)\b/i,
139
+ reason: "that function reaches another server and can run statements there",
140
+ },
141
+ ],
142
+ quoteExample: "[name]",
143
+ };
144
+ static CLICKHOUSE = {
145
+ name: "clickhouse",
146
+ label: "ClickHouse",
147
+ lexical: {
148
+ singleQuote: "backslash",
149
+ // Identifier escaping is the one ClickHouse rule not pinned down well
150
+ // enough to rely on, so a backslash inside an identifier is refused.
151
+ doubleQuote: "reject-backslash",
152
+ backtick: "reject-backslash",
153
+ escapeStringPrefix: false,
154
+ brackets: "none",
155
+ dollarQuotes: true,
156
+ hashComments: true,
157
+ dashCommentNeedsSpace: false,
158
+ executableComments: false,
159
+ },
160
+ allowedLeadingKeywords: ["SELECT", "WITH", "SHOW", "DESCRIBE", "DESC", "EXPLAIN", "EXISTS"],
161
+ bodyScanKeywords: ["WITH"],
162
+ scanExplainAnalyze: false,
163
+ scanEveryStatement: false,
164
+ writeKeywords: /\b(INSERT|ALTER|DROP|CREATE|TRUNCATE|RENAME|OPTIMIZE|ATTACH|DETACH|SYSTEM|KILL|DELETE|UPDATE|EXCHANGE|GRANT|REVOKE|SET|BACKUP|RESTORE)\b/i,
165
+ forbiddenPatterns: [
166
+ { pattern: /INTO\s+OUTFILE/i, reason: "INTO OUTFILE writes a file" },
167
+ {
168
+ // Table functions that fetch from outside the server: files on its
169
+ // disk, URLs, object stores, other databases, arbitrary executables.
170
+ // Reading through them turns a read-only analytics account into a
171
+ // way to reach anything the server can reach.
172
+ pattern: /\b(file|url|urlCluster|s3|s3Cluster|gcs|hdfs|hdfsCluster|azureBlobStorage|azureBlobStorageCluster|remote|remoteSecure|mysql|postgresql|mongodb|redis|sqlite|jdbc|odbc|executable|iceberg\w*|deltaLake\w*|hudi\w*)\s*\(/i,
173
+ reason: "that table function reaches outside the ClickHouse server",
174
+ },
175
+ { pattern: /\b(sleep|sleepEachRow)\s*\(/i, reason: "sleep() can hang the conversation" },
176
+ ],
177
+ quoteExample: "`name`",
178
+ };
179
+ static BY_NAME = new Map([
180
+ ["mysql", SqlDialects.MYSQL],
181
+ ["postgres", SqlDialects.POSTGRES],
182
+ ["sqlite", SqlDialects.SQLITE],
183
+ ["mssql", SqlDialects.MSSQL],
184
+ ["clickhouse", SqlDialects.CLICKHOUSE],
185
+ ]);
186
+ static get(name) {
187
+ const dialect = SqlDialects.BY_NAME.get(name);
188
+ if (!dialect) {
189
+ throw new Error(`Unknown SQL dialect "${name}".`);
190
+ }
191
+ return dialect;
192
+ }
193
+ static all() {
194
+ return Array.from(SqlDialects.BY_NAME.values());
195
+ }
196
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Reduces a statement to its syntactic skeleton by blanking every string
3
+ * literal, quoted identifier and comment, following one dialect's rules.
4
+ *
5
+ * Its own class because every rule depends on it and none should re-implement
6
+ * it. It is the foundation of the whole read-only guard: rules inspecting the
7
+ * skeleton can never mistake user data for SQL.
8
+ *
9
+ * Written as a character scanner rather than regular expressions because these
10
+ * constructs nest and escape in ways regular expressions cannot express
11
+ * correctly. A regex version was tried in the MySQL-only predecessor and
12
+ * rejected; its failure mode was false rejections of ordinary queries.
13
+ *
14
+ * Where a construct could be read two ways, the scanner reports it instead of
15
+ * picking one. A nested block comment is the example: PostgreSQL and SQL
16
+ * Server nest them, MySQL and SQLite do not, and a validator that guesses
17
+ * wrong hides whatever follows the inner `*\/` from itself while the server
18
+ * runs it. Refusing the construct removes the question entirely.
19
+ */
20
+ export class SqlSkeletonizer {
21
+ static NESTED_COMMENT = "a nested block comment";
22
+ static EXECUTABLE_COMMENT = "a MySQL executable comment (/*! ... */)";
23
+ static IDENTIFIER_BACKSLASH = "a backslash inside a quoted identifier";
24
+ /** Characters that can continue an identifier, which decides where `$tag$` may begin. */
25
+ static IDENTIFIER_CHAR = /[A-Za-z0-9_$\u0080-￿]/;
26
+ /** A dollar-quote delimiter: `$$` or `$tag$`, where a tag cannot start with a digit. */
27
+ static DOLLAR_TAG = /\$(?:[A-Za-z_\u0080-￿][A-Za-z0-9_\u0080-￿]*)?\$/y;
28
+ skeletonize(sql, rules) {
29
+ const ambiguities = new Set();
30
+ let out = "";
31
+ let index = 0;
32
+ while (index < sql.length) {
33
+ const char = sql[index];
34
+ const quoteMode = this.quoteModeAt(sql, index, rules);
35
+ if (quoteMode !== "none") {
36
+ index = this.skipQuoted(sql, index, quoteMode, ambiguities);
37
+ // Blanked to a single space, so a quoted column named after a keyword
38
+ // is invisible to the keyword rules, and `'a'OR` still separates.
39
+ out += " ";
40
+ continue;
41
+ }
42
+ if (char === "[" && rules.brackets !== "none") {
43
+ index = this.skipBracketed(sql, index, rules.brackets);
44
+ out += " ";
45
+ continue;
46
+ }
47
+ if (char === "$" && rules.dollarQuotes) {
48
+ const delimiter = this.dollarDelimiterAt(sql, index);
49
+ if (delimiter) {
50
+ index = this.skipDollarQuoted(sql, index, delimiter);
51
+ out += " ";
52
+ continue;
53
+ }
54
+ }
55
+ if (this.startsLineComment(sql, index, rules)) {
56
+ index = this.skipToLineEnd(sql, index);
57
+ continue;
58
+ }
59
+ if (char === "/" && sql[index + 1] === "*") {
60
+ if (rules.executableComments && this.isExecutableComment(sql, index)) {
61
+ ambiguities.add(SqlSkeletonizer.EXECUTABLE_COMMENT);
62
+ }
63
+ index = this.skipBlockComment(sql, index, ambiguities);
64
+ out += " ";
65
+ continue;
66
+ }
67
+ out += char;
68
+ index += 1;
69
+ }
70
+ return { text: out, ambiguities: Array.from(ambiguities) };
71
+ }
72
+ /** Which quoting applies to the character at `index`, if it opens one. */
73
+ quoteModeAt(sql, index, rules) {
74
+ const char = sql[index];
75
+ if (char === "'") {
76
+ return rules.escapeStringPrefix && this.isEscapeStringPrefix(sql, index)
77
+ ? "backslash"
78
+ : rules.singleQuote;
79
+ }
80
+ if (char === '"') {
81
+ return rules.doubleQuote;
82
+ }
83
+ if (char === "`") {
84
+ return rules.backtick;
85
+ }
86
+ return "none";
87
+ }
88
+ /**
89
+ * `E'...'` in PostgreSQL, where the `E` stands alone rather than ending an
90
+ * identifier: `E'\''` is an escape string, `name'...'` is not.
91
+ */
92
+ isEscapeStringPrefix(sql, quoteIndex) {
93
+ const prefix = sql[quoteIndex - 1];
94
+ if (prefix !== "E" && prefix !== "e") {
95
+ return false;
96
+ }
97
+ const before = sql[quoteIndex - 2];
98
+ return before === undefined || !SqlSkeletonizer.IDENTIFIER_CHAR.test(before);
99
+ }
100
+ skipQuoted(sql, start, mode, ambiguities) {
101
+ const quote = sql[start];
102
+ let index = start + 1;
103
+ while (index < sql.length) {
104
+ const char = sql[index];
105
+ if (char === "\\") {
106
+ if (mode === "backslash") {
107
+ index += 2;
108
+ continue;
109
+ }
110
+ if (mode === "reject-backslash") {
111
+ ambiguities.add(SqlSkeletonizer.IDENTIFIER_BACKSLASH);
112
+ }
113
+ }
114
+ if (char === quote) {
115
+ if (sql[index + 1] === quote) {
116
+ index += 2;
117
+ continue;
118
+ }
119
+ return index + 1;
120
+ }
121
+ index += 1;
122
+ }
123
+ // Unterminated: blank to the end. The server rejects it as a syntax
124
+ // error, so treating the rest as literal cannot let anything through.
125
+ return index;
126
+ }
127
+ skipBracketed(sql, start, style) {
128
+ let index = start + 1;
129
+ while (index < sql.length) {
130
+ if (sql[index] === "]") {
131
+ if (style === "doubled" && sql[index + 1] === "]") {
132
+ index += 2;
133
+ continue;
134
+ }
135
+ return index + 1;
136
+ }
137
+ index += 1;
138
+ }
139
+ return index;
140
+ }
141
+ /**
142
+ * A `$` preceded by an identifier character is part of that identifier
143
+ * (`price$usd`), and `$1` is a parameter, so neither opens a quote.
144
+ */
145
+ dollarDelimiterAt(sql, index) {
146
+ const before = sql[index - 1];
147
+ if (before !== undefined && SqlSkeletonizer.IDENTIFIER_CHAR.test(before)) {
148
+ return null;
149
+ }
150
+ SqlSkeletonizer.DOLLAR_TAG.lastIndex = index;
151
+ const match = SqlSkeletonizer.DOLLAR_TAG.exec(sql);
152
+ return match ? match[0] : null;
153
+ }
154
+ skipDollarQuoted(sql, start, delimiter) {
155
+ const end = sql.indexOf(delimiter, start + delimiter.length);
156
+ return end === -1 ? sql.length : end + delimiter.length;
157
+ }
158
+ startsLineComment(sql, index, rules) {
159
+ if (sql[index] === "#" && rules.hashComments) {
160
+ return true;
161
+ }
162
+ if (sql[index] !== "-" || sql[index + 1] !== "-") {
163
+ return false;
164
+ }
165
+ if (!rules.dashCommentNeedsSpace) {
166
+ return true;
167
+ }
168
+ // MySQL requires whitespace after `--`, so `SELECT 1--2` stays an
169
+ // arithmetic expression rather than becoming a comment.
170
+ const after = sql[index + 2];
171
+ return after === undefined || /\s/.test(after);
172
+ }
173
+ skipToLineEnd(sql, start) {
174
+ let index = start;
175
+ while (index < sql.length && sql[index] !== "\n") {
176
+ index += 1;
177
+ }
178
+ return index;
179
+ }
180
+ /** `/*!` and MariaDB's `/*M!`, both of which MySQL executes. */
181
+ isExecutableComment(sql, start) {
182
+ return sql[start + 2] === "!" || (sql[start + 2] === "M" && sql[start + 3] === "!");
183
+ }
184
+ skipBlockComment(sql, start, ambiguities) {
185
+ let index = start + 2;
186
+ while (index < sql.length) {
187
+ if (sql[index] === "*" && sql[index + 1] === "/") {
188
+ return index + 2;
189
+ }
190
+ if (sql[index] === "/" && sql[index + 1] === "*") {
191
+ ambiguities.add(SqlSkeletonizer.NESTED_COMMENT);
192
+ }
193
+ index += 1;
194
+ }
195
+ return index;
196
+ }
197
+ }
@@ -0,0 +1,24 @@
1
+ import { ReadOnlyQueryValidator } from "./ReadOnlyQueryValidator.js";
2
+ import { SqlDialects } from "./SqlDialect.js";
3
+ /**
4
+ * One validator per SQL dialect, built once.
5
+ *
6
+ * run_query learns the dialect from the driver it is about to use, so it
7
+ * needs a way to get the matching validator without constructing one on
8
+ * every call or knowing how validators are assembled.
9
+ */
10
+ export class SqlValidatorRegistry {
11
+ validators = new Map();
12
+ constructor() {
13
+ for (const dialect of SqlDialects.all()) {
14
+ this.validators.set(dialect.name, new ReadOnlyQueryValidator(dialect));
15
+ }
16
+ }
17
+ for(dialect) {
18
+ const validator = this.validators.get(dialect);
19
+ if (!validator) {
20
+ throw new Error(`No validator for SQL dialect "${dialect}".`);
21
+ }
22
+ return validator;
23
+ }
24
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Rejects statements containing a construct the skeletonizer could not read
3
+ * with certainty.
4
+ *
5
+ * Runs before every rule that inspects the skeleton, because those rules are
6
+ * only as trustworthy as the skeleton, and an ambiguous construct means the
7
+ * skeleton might not be what the server sees. The constructs involved (nested
8
+ * comments, MySQL executable comments, backslashes in quoted identifiers) are
9
+ * rare in hand-written reads, so the cost of refusing them is small and the
10
+ * message says exactly what to remove.
11
+ */
12
+ export class AmbiguousSyntaxRule {
13
+ name = "ambiguous-syntax";
14
+ evaluate(inspection) {
15
+ if (inspection.ambiguities.length === 0) {
16
+ return null;
17
+ }
18
+ return {
19
+ valid: false,
20
+ error: `Query contains ${inspection.ambiguities.join(" and ")}, which ${inspection.dialect.label} could read differently from this validator. Rewrite the query without it.`,
21
+ };
22
+ }
23
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Rejects a statement with no content.
3
+ *
4
+ * Runs first so later rules can assume a non-empty skeleton. Catches the empty
5
+ * string, whitespace, a bare semicolon, and a statement that was nothing but a
6
+ * comment, since all of them arrive here as an empty skeleton.
7
+ */
8
+ export class EmptyQueryRule {
9
+ name = "empty-query";
10
+ evaluate(inspection) {
11
+ if (inspection.skeleton.length === 0) {
12
+ return { valid: false, error: "Empty query." };
13
+ }
14
+ return null;
15
+ }
16
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Blocks constructs that are harmful even inside an otherwise read-only
3
+ * statement.
4
+ *
5
+ * These begin with SELECT and pass every rule above. They write files
6
+ * (`INTO OUTFILE`, PostgreSQL's `lo_export`), create tables (`SELECT ... INTO`
7
+ * in PostgreSQL and SQL Server), reach other servers (ClickHouse table
8
+ * functions, `OPENROWSET`), run SQL hidden inside a string literal (dblink,
9
+ * `query_to_xml`), or simply hang the conversation (`SLEEP`).
10
+ *
11
+ * The lists live in each dialect, since each engine has its own. This rule
12
+ * applies to every statement, so a sloppy pattern causes false rejections
13
+ * everywhere; each one matches a function call or a fixed phrase, not a bare
14
+ * word that could be a column name.
15
+ */
16
+ export class ForbiddenPatternRule {
17
+ name = "forbidden-pattern";
18
+ evaluate(inspection) {
19
+ for (const forbidden of inspection.dialect.forbiddenPatterns) {
20
+ const match = forbidden.pattern.exec(inspection.skeleton);
21
+ if (match) {
22
+ return {
23
+ valid: false,
24
+ error: `Query contains "${match[0].trim()}", which is not allowed: ${forbidden.reason}.`,
25
+ };
26
+ }
27
+ }
28
+ return null;
29
+ }
30
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Requires the statement to begin with a keyword that reads rather than writes.
3
+ *
4
+ * The allowed set is per dialect, because the read statements differ: SHOW
5
+ * exists in MySQL and ClickHouse but not SQLite, and SQL Server has neither
6
+ * SHOW nor EXPLAIN. WITH is allowed everywhere, since rejecting CTEs is a real
7
+ * loss on an analysis tool, and that is exactly why SmuggledWriteRule exists.
8
+ */
9
+ export class LeadingKeywordRule {
10
+ name = "leading-keyword";
11
+ evaluate(inspection) {
12
+ const allowed = inspection.dialect.allowedLeadingKeywords;
13
+ if (allowed.includes(inspection.leadingKeyword)) {
14
+ return null;
15
+ }
16
+ // Naming the offending keyword matters: a model told precisely what was
17
+ // wrong usually rewrites the query correctly without further prompting.
18
+ return {
19
+ valid: false,
20
+ error: `Only ${this.list(allowed)} are allowed on ${inspection.dialect.label}. Got: ${inspection.leadingKeyword}`,
21
+ };
22
+ }
23
+ list(keywords) {
24
+ if (keywords.length === 1) {
25
+ return keywords[0];
26
+ }
27
+ return `${keywords.slice(0, -1).join(", ")} and ${keywords[keywords.length - 1]}`;
28
+ }
29
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Rejects stacked statements.
3
+ *
4
+ * Because the skeleton has literals and comments blanked, any remaining `;` is
5
+ * a real separator. A trailing one is already stripped before rules run, since
6
+ * people routinely paste queries that way.
7
+ *
8
+ * On most engines this is a better error message rather than the actual
9
+ * protection, because the driver cannot send a second statement at all
10
+ * (mysql2 with multipleStatements off, pg in extended query mode, one query
11
+ * per ClickHouse HTTP request). SQLite is the exception worth knowing: its
12
+ * prepare silently ignores everything after the first statement, so without
13
+ * this rule `SELECT 1; DELETE FROM t` would run the SELECT and report success.
14
+ */
15
+ export class SingleStatementRule {
16
+ name = "single-statement";
17
+ evaluate(inspection) {
18
+ if (inspection.skeleton.includes(";")) {
19
+ return {
20
+ valid: false,
21
+ error: "Multiple statements are not allowed. Send one query at a time.",
22
+ };
23
+ }
24
+ return null;
25
+ }
26
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Catches writes hidden behind an allowed first keyword.
3
+ *
4
+ * Three forms can do this:
5
+ *
6
+ * - `WITH c AS (...) DELETE FROM t` is a write whose first word is allowed,
7
+ * in MySQL 8, PostgreSQL, SQLite and ClickHouse.
8
+ * - `EXPLAIN ANALYZE` genuinely executes the statement in MySQL and
9
+ * PostgreSQL, unlike plain EXPLAIN, which only plans it and is left alone.
10
+ * - In T-SQL any statement can follow another with no separator at all, so on
11
+ * SQL Server every statement is scanned.
12
+ *
13
+ * **Elsewhere the scan is deliberately not applied to plain SELECT.** A
14
+ * SELECT cannot become a write in those dialects, so scanning adds no safety,
15
+ * and it actively breaks ordinary queries: `SELECT start FROM sessions`
16
+ * contains START and `SELECT begin, end FROM ranges` contains BEGIN. The
17
+ * MySQL-only predecessor tried a global scan first and failed on exactly these.
18
+ *
19
+ * The residual cost is that a column named exactly like a write keyword must
20
+ * be quoted where the scan does run, which the message explains.
21
+ */
22
+ export class SmuggledWriteRule {
23
+ name = "smuggled-write";
24
+ static ANALYZE = /\bANALYZE\b/i;
25
+ evaluate(inspection) {
26
+ if (!this.needsBodyScan(inspection)) {
27
+ return null;
28
+ }
29
+ const match = inspection.dialect.writeKeywords.exec(inspection.skeleton);
30
+ if (!match) {
31
+ return null;
32
+ }
33
+ return {
34
+ valid: false,
35
+ error: `This statement contains ${match[1].toUpperCase()}, which can modify data and is not allowed on a read-only connection. If ${match[1]} is a column or table name here, quote it as ${inspection.dialect.quoteExample}.`,
36
+ };
37
+ }
38
+ needsBodyScan(inspection) {
39
+ const dialect = inspection.dialect;
40
+ if (dialect.scanEveryStatement) {
41
+ return true;
42
+ }
43
+ if (dialect.bodyScanKeywords.includes(inspection.leadingKeyword)) {
44
+ return true;
45
+ }
46
+ return (dialect.scanExplainAnalyze &&
47
+ inspection.leadingKeyword === "EXPLAIN" &&
48
+ SmuggledWriteRule.ANALYZE.test(inspection.skeleton));
49
+ }
50
+ }
@@ -0,0 +1,6 @@
1
+ export { EmptyQueryRule } from "./EmptyQueryRule.js";
2
+ export { AmbiguousSyntaxRule } from "./AmbiguousSyntaxRule.js";
3
+ export { SingleStatementRule } from "./SingleStatementRule.js";
4
+ export { LeadingKeywordRule } from "./LeadingKeywordRule.js";
5
+ export { SmuggledWriteRule } from "./SmuggledWriteRule.js";
6
+ export { ForbiddenPatternRule } from "./ForbiddenPatternRule.js";