@nifrajs/mcp-db 5.5.1 → 5.7.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.
@@ -110,13 +110,83 @@ export function isDbRefusal(value: unknown): value is DbRefusal {
110
110
  // Statement gates
111
111
  // ---------------------------------------------------------------------------------------------------
112
112
 
113
- const stripSqlNoise = (sql: string): string =>
114
- sql
115
- // Strings first so comment markers inside literals don't count.
116
- .replace(/'(?:[^']|'')*'/g, "''")
117
- .replace(/"(?:[^"]|"")*"/g, '""')
118
- .replace(/--[^\n]*/g, " ")
119
- .replace(/\/\*[\s\S]*?\*\//g, " ")
113
+ // SQLite's four quotings, by opening delimiter: a 'literal' and the "x", `x` and [x] identifiers. Only
114
+ // the bracket form has no doubled-delimiter escape.
115
+ const QUOTE_CLOSE = new Map([
116
+ ["'", "'"],
117
+ ['"', '"'],
118
+ ["`", "`"],
119
+ ["[", "]"],
120
+ ])
121
+
122
+ /** The index just past the quoted run opening at `start`, or the text's length when it never closes. */
123
+ function quotedEnd(sql: string, start: number, open: string, close: string): number {
124
+ for (let i = start + 1; i < sql.length; i++) {
125
+ if (sql[i] !== close) continue
126
+ if (open !== "[" && sql[i + 1] === close) i++
127
+ else return i + 1
128
+ }
129
+ return sql.length
130
+ }
131
+
132
+ /** The index just past a comment opening at `start`, or `undefined` when none opens there. An open
133
+ * block comment runs to the end of the text, as SQLite reads it. */
134
+ function commentEnd(sql: string, start: number): number | undefined {
135
+ if (sql[start] === "-" && sql[start + 1] === "-") {
136
+ const end = sql.indexOf("\n", start + 2)
137
+ return end === -1 ? sql.length : end + 1
138
+ }
139
+ if (sql[start] === "/" && sql[start + 1] === "*") {
140
+ const end = sql.indexOf("*/", start + 2)
141
+ return end === -1 ? sql.length : end + 2
142
+ }
143
+ return undefined
144
+ }
145
+
146
+ /** `sql` with each literal and quoted identifier emptied and each comment turned into a space. */
147
+ function stripSqlNoise(sql: string): string {
148
+ let out = ""
149
+ let from = 0
150
+ let i = 0
151
+ while (i < sql.length) {
152
+ const char = sql.charAt(i)
153
+ const comment = commentEnd(sql, i)
154
+ const close = QUOTE_CLOSE.get(char)
155
+ if (comment !== undefined) {
156
+ out += `${sql.slice(from, i)} `
157
+ i = from = comment
158
+ } else if (close !== undefined) {
159
+ out += `${sql.slice(from, i)}${char}${close}`
160
+ i = from = quotedEnd(sql, i, char, close)
161
+ } else {
162
+ i++
163
+ }
164
+ }
165
+ return out + sql.slice(from)
166
+ }
167
+
168
+ /**
169
+ * Where the statement in `sql` ends: `end` is just past its last character of code (a trailing comment
170
+ * is not code, so the bounded wrapper's closing parenthesis can never land in one), and `terminator` is
171
+ * the index of its first `;` outside a literal, quoted identifier or comment, or -1.
172
+ */
173
+ function statementEnd(sql: string): { readonly end: number; readonly terminator: number } {
174
+ let end = 0
175
+ let i = 0
176
+ while (i < sql.length) {
177
+ const char = sql.charAt(i)
178
+ if (char === ";") return { end, terminator: i }
179
+ const comment = commentEnd(sql, i)
180
+ if (comment !== undefined) {
181
+ i = comment
182
+ continue
183
+ }
184
+ const close = QUOTE_CLOSE.get(char)
185
+ i = close === undefined ? i + 1 : quotedEnd(sql, i, char, close)
186
+ if (char !== " " && char !== "\t" && char !== "\n" && char !== "\r" && char !== "\f") end = i
187
+ }
188
+ return { end, terminator: -1 }
189
+ }
120
190
 
121
191
  // SQLite's bare-identifier alphabet. Non-ASCII is included because SQLite treats any byte >= 0x80 as
122
192
  // an identifier character; leaving it out would end a word early and split one table name into two.
@@ -167,7 +237,7 @@ function tokenizeSql(sql: string): SqlToken[] {
167
237
  tokens.push({ kind: "word", value, bare: false })
168
238
  }
169
239
  while (i < sql.length) {
170
- const char = sql[i] as string
240
+ const char = sql.charAt(i)
171
241
  if (char === " " || char === "\t" || char === "\n" || char === "\r" || char === "\f") {
172
242
  i++
173
243
  } else if (char === "-" && sql[i + 1] === "-") {
@@ -303,7 +373,6 @@ function relationNames(sql: string): string[] {
303
373
  return [...new Set(names)]
304
374
  }
305
375
 
306
- /** Reject multi-statement input: allow one terminator only when it is the final character. */
307
376
  /**
308
377
  * Names a statement defines as CTEs, column lists included (`WITH RECURSIVE c(x) AS (...)`), which
309
378
  * {@link gateSqliteStatement}'s relation scan does not see past. Only for callers whose own check of
@@ -334,50 +403,10 @@ export function sqliteCteNames(sql: string): string[] {
334
403
  return [...names]
335
404
  }
336
405
 
406
+ /** Reject multi-statement input: allow one terminator only when it is the final character. */
337
407
  const isSingleStatement = (sql: string): boolean => {
338
- let quote: "'" | '"' | null = null
339
- let lineComment = false
340
- let blockComment = false
341
- for (let i = 0; i < sql.length; i++) {
342
- const char = sql[i]
343
- const next = sql[i + 1]
344
- if (lineComment) {
345
- if (char === "\n") lineComment = false
346
- continue
347
- }
348
- if (blockComment) {
349
- if (char === "*" && next === "/") {
350
- blockComment = false
351
- i++
352
- }
353
- continue
354
- }
355
- if (quote !== null) {
356
- if (char === quote) {
357
- if (next === quote) i++
358
- else quote = null
359
- }
360
- continue
361
- }
362
- if (char === "'" || char === '"') {
363
- quote = char
364
- continue
365
- }
366
- if (char === "-" && next === "-") {
367
- lineComment = true
368
- i++
369
- continue
370
- }
371
- if (char === "/" && next === "*") {
372
- blockComment = true
373
- i++
374
- continue
375
- }
376
- if (char === ";") {
377
- return sql.slice(i + 1).trim() === ""
378
- }
379
- }
380
- return true
408
+ const { terminator } = statementEnd(sql)
409
+ return terminator === -1 || sql.slice(terminator + 1).trim() === ""
381
410
  }
382
411
 
383
412
  const isReadStatement = (sql: string): boolean => {
@@ -385,58 +414,8 @@ const isReadStatement = (sql: string): boolean => {
385
414
  return head.startsWith("select") || head.startsWith("with")
386
415
  }
387
416
 
388
- /** Remove one trailing statement terminator (and anything after it) while preserving literals and
389
- * identifiers. Wrapping the query in a bounded subquery must not let a trailing comment consume the
390
- * wrapper's closing parenthesis. */
391
- function executableSql(sql: string): string {
392
- let quote: "'" | '"' | null = null
393
- let lineComment = false
394
- let lineCommentStart = -1
395
- let blockComment = false
396
- for (let i = 0; i < sql.length; i++) {
397
- const char = sql[i]
398
- const next = sql[i + 1]
399
- if (lineComment) {
400
- if (char === "\n") {
401
- lineComment = false
402
- lineCommentStart = -1
403
- }
404
- continue
405
- }
406
- if (blockComment) {
407
- if (char === "*" && next === "/") {
408
- blockComment = false
409
- i++
410
- }
411
- continue
412
- }
413
- if (quote !== null) {
414
- if (char === quote) {
415
- if (next === quote) i++
416
- else quote = null
417
- }
418
- continue
419
- }
420
- if (char === "'" || char === '"') {
421
- quote = char
422
- continue
423
- }
424
- if (char === "-" && next === "-") {
425
- lineComment = true
426
- lineCommentStart = i
427
- i++
428
- continue
429
- }
430
- if (char === "/" && next === "*") {
431
- blockComment = true
432
- i++
433
- continue
434
- }
435
- if (char === ";") return sql.slice(0, i).trim()
436
- }
437
- if (lineComment && lineCommentStart >= 0) return sql.slice(0, lineCommentStart).trim()
438
- return sql.trim()
439
- }
417
+ /** The statement without its terminator or a trailing comment, literals and identifiers intact. */
418
+ const executableSql = (sql: string): string => sql.slice(0, statementEnd(sql).end)
440
419
 
441
420
  /** The outcome of {@link gateSqliteStatement}. A refusal's `text` is the message `run_query` returns. */
442
421
  export type SqliteGate =
@@ -451,8 +430,8 @@ export type SqliteGate =
451
430
  /**
452
431
  * Gate one statement before SQLite sees it: non-empty, a single statement, SELECT/WITH only, and every
453
432
  * relation it names after `FROM`/`JOIN`/`IN` exposed. Returns the statement without its terminator, ready
454
- * to wrap. Relation names are checked here as well as in the plan because SQLite's plan names an
455
- * alias as the scan target (`users AS habits` plans as `SCAN habits`).
433
+ * to wrap. The compiled program ({@link unexposedProgramRelation}) remains the authority on the
434
+ * tables a statement reads; this check refuses a named relation before SQLite compiles anything.
456
435
  */
457
436
  export function gateSqliteStatement(
458
437
  input: string,
@@ -486,7 +465,9 @@ export function gateSqliteStatement(
486
465
 
487
466
  /**
488
467
  * The first relation an `EXPLAIN QUERY PLAN` result scans or searches that `exposed` rejects, as the
489
- * plan spells it, or `undefined` when every scanned relation is exposed.
468
+ * plan spells it, or `undefined` when every scanned relation is exposed. The plan names a scan by its
469
+ * alias (`FROM habits h` plans as `SCAN h`, `users AS habits` as `SCAN habits`), so it cannot decide
470
+ * on its own which tables a statement reads: the compiled program names them by root page.
490
471
  */
491
472
  export function unexposedPlanRelation(
492
473
  planRows: readonly { readonly detail?: unknown }[],
package/src/postgres.ts CHANGED
@@ -39,7 +39,12 @@ import {
39
39
  type PostgresClient,
40
40
  records,
41
41
  } from "./internal/pg-exec.ts"
42
- import { DENIED_FUNCTION_PREFIXES, lintPostgres, withoutTerminator } from "./internal/pg-lexer.ts"
42
+ import {
43
+ DENIED_FUNCTION_PREFIXES,
44
+ lintPostgres,
45
+ tableAliasLists,
46
+ withoutTerminator,
47
+ } from "./internal/pg-lexer.ts"
43
48
  import {
44
49
  type DbPlan,
45
50
  type DbRedaction,
@@ -342,9 +347,14 @@ function scopeMatcher(scope: PostgresScope): (relation: PlanRelation) => string
342
347
  * The plan-level table check: each relation, and every table it inherits from (partitions too). A
343
348
  * `pg_*` function in FROM reads server state the way a catalog does - a system view such as
344
349
  * `pg_stat_activity` plans as one once the planner drops its catalog joins - so it is out of scope too.
345
- * With `masked`, a plan expression that uses a masked column, or a whole row holding one, is refused.
350
+ * With `masked`, a plan expression that uses a masked column, or a whole row holding one, is refused,
351
+ * and so is any masked column of a relation `statement` reads when it gives a table a column alias list.
346
352
  */
347
- function checkScope(scope: PostgresScope, masked?: (column: string) => boolean) {
353
+ function checkScope(
354
+ scope: PostgresScope,
355
+ masked: ((column: string) => boolean) | undefined,
356
+ statement: string,
357
+ ) {
348
358
  const outOfScope = scopeMatcher(scope)
349
359
  return async (connection: PgConnection, reads: PlanReads): Promise<DbRefusal | undefined> => {
350
360
  for (const fn of reads.functions) {
@@ -393,6 +403,9 @@ function checkScope(scope: PostgresScope, masked?: (column: string) => boolean)
393
403
  if (column === "*") wholeRows.push(relation)
394
404
  else if (masked(column)) hits.add(`${relation.name}.${column}`)
395
405
  }
406
+ // The plan names an alias-listed scan's columns by the list, which can reuse a real column's name.
407
+ const renamed = tableAliasLists(statement)
408
+ if (renamed.length > 0) wholeRows.push(...reads.relations)
396
409
  const listed = [...wholeRows, ...reads.outputs.map((output) => output.relation)]
397
410
  if (listed.length > 0) {
398
411
  const live = new Map<string, string[]>()
@@ -423,6 +436,13 @@ function checkScope(scope: PostgresScope, masked?: (column: string) => boolean)
423
436
  }
424
437
  }
425
438
  if (hits.size === 0) return undefined
439
+ const [aliased] = renamed
440
+ if (aliased !== undefined) {
441
+ return dbRefusal(
442
+ "NIFRA_DB_COLUMN_REFUSED",
443
+ `the query renames the columns of ${aliased.table} with the alias list ${aliased.alias}(...), so the plan cannot show which columns it reads, and it reads a relation holding ${[...hits].join(", ")}, masked as ${hits.size === 1 ? "a credential" : "credentials"}; alias columns in the select list instead`,
444
+ )
445
+ }
426
446
  return dbRefusal(
427
447
  "NIFRA_DB_COLUMN_REFUSED",
428
448
  `the query reads ${[...hits].join(", ")}, masked as ${hits.size === 1 ? "a credential" : "credentials"}`,
@@ -465,7 +485,7 @@ export async function queryPostgres(
465
485
  const fetched = await gated(client, sql, options, (connection, statement) =>
466
486
  fetchThroughCursor(connection, statement, {
467
487
  maxRows: options.maxRows,
468
- scope: checkScope(options, options.redaction?.column),
488
+ scope: checkScope(options, options.redaction?.column, statement),
469
489
  }),
470
490
  )
471
491
  if (isDbRefusal(fetched)) return fetched
@@ -494,7 +514,7 @@ export async function explainPostgres(
494
514
  const explained = await gated(client, sql, options, (connection, statement) =>
495
515
  explainThroughServer(connection, statement, {
496
516
  analyze: options.analyze === true,
497
- scope: checkScope(options, options.redaction?.column),
517
+ scope: checkScope(options, options.redaction?.column, statement),
498
518
  }),
499
519
  )
500
520
  if (isDbRefusal(explained)) return explained