@askdb/core 1.0.0-beta.41 → 1.0.0-beta.43

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 (105) hide show
  1. package/README.md +44 -4
  2. package/dist/ask.d.ts +129 -2
  3. package/dist/ask.d.ts.map +1 -1
  4. package/dist/ask.js +391 -15
  5. package/dist/ask.js.map +1 -1
  6. package/dist/errors.d.ts +87 -2
  7. package/dist/errors.d.ts.map +1 -1
  8. package/dist/errors.js +29 -0
  9. package/dist/errors.js.map +1 -1
  10. package/dist/index.d.ts +8 -5
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +5 -2
  13. package/dist/index.js.map +1 -1
  14. package/dist/logging/log-events.d.ts +13 -0
  15. package/dist/logging/log-events.d.ts.map +1 -1
  16. package/dist/logging/log-events.js +13 -0
  17. package/dist/logging/log-events.js.map +1 -1
  18. package/dist/schema/v2/index.d.ts +1 -1
  19. package/dist/schema/v2/index.d.ts.map +1 -1
  20. package/dist/schema/v2/loader.d.ts +9 -1
  21. package/dist/schema/v2/loader.d.ts.map +1 -1
  22. package/dist/schema/v2/loader.js +171 -49
  23. package/dist/schema/v2/loader.js.map +1 -1
  24. package/dist/schema/v2/normalized.d.ts +33 -0
  25. package/dist/schema/v2/normalized.d.ts.map +1 -1
  26. package/dist/schema/v2/parser.d.ts +9 -0
  27. package/dist/schema/v2/parser.d.ts.map +1 -1
  28. package/dist/schema/v2/parser.js +18 -2
  29. package/dist/schema/v2/parser.js.map +1 -1
  30. package/dist/schema/v2/tenant-policy-loader.d.ts.map +1 -1
  31. package/dist/schema/v2/tenant-policy-loader.js +7 -3
  32. package/dist/schema/v2/tenant-policy-loader.js.map +1 -1
  33. package/dist/schema/v2/tenant-policy.d.ts +28 -21
  34. package/dist/schema/v2/tenant-policy.d.ts.map +1 -1
  35. package/dist/schema/v2/tenant-policy.js +67 -11
  36. package/dist/schema/v2/tenant-policy.js.map +1 -1
  37. package/dist/sql/bind.d.ts +102 -0
  38. package/dist/sql/bind.d.ts.map +1 -0
  39. package/dist/sql/bind.js +422 -0
  40. package/dist/sql/bind.js.map +1 -0
  41. package/dist/sql/dialect-spec.d.ts +24 -1
  42. package/dist/sql/dialect-spec.d.ts.map +1 -1
  43. package/dist/sql/dialect-spec.js +82 -3
  44. package/dist/sql/dialect-spec.js.map +1 -1
  45. package/dist/sql/extract-sql.d.ts +8 -1
  46. package/dist/sql/extract-sql.d.ts.map +1 -1
  47. package/dist/sql/extract-sql.js +27 -4
  48. package/dist/sql/extract-sql.js.map +1 -1
  49. package/dist/sql/generate.d.ts +22 -0
  50. package/dist/sql/generate.d.ts.map +1 -1
  51. package/dist/sql/generate.js +92 -16
  52. package/dist/sql/generate.js.map +1 -1
  53. package/dist/sql/index.d.ts +4 -2
  54. package/dist/sql/index.d.ts.map +1 -1
  55. package/dist/sql/index.js +4 -2
  56. package/dist/sql/index.js.map +1 -1
  57. package/dist/sql/lexer.d.ts +114 -0
  58. package/dist/sql/lexer.d.ts.map +1 -0
  59. package/dist/sql/lexer.js +397 -0
  60. package/dist/sql/lexer.js.map +1 -0
  61. package/dist/sql/parameter-manifest.d.ts +34 -0
  62. package/dist/sql/parameter-manifest.d.ts.map +1 -0
  63. package/dist/sql/parameter-manifest.js +150 -0
  64. package/dist/sql/parameter-manifest.js.map +1 -0
  65. package/dist/sql/prompt.d.ts +7 -1
  66. package/dist/sql/prompt.d.ts.map +1 -1
  67. package/dist/sql/prompt.js +24 -1
  68. package/dist/sql/prompt.js.map +1 -1
  69. package/dist/sql/sensitive-guardrail.d.ts +62 -0
  70. package/dist/sql/sensitive-guardrail.d.ts.map +1 -0
  71. package/dist/sql/sensitive-guardrail.js +876 -0
  72. package/dist/sql/sensitive-guardrail.js.map +1 -0
  73. package/dist/sql/tenant-guardrail.d.ts +70 -8
  74. package/dist/sql/tenant-guardrail.d.ts.map +1 -1
  75. package/dist/sql/tenant-guardrail.js +799 -52
  76. package/dist/sql/tenant-guardrail.js.map +1 -1
  77. package/dist/sql/tenant-hierarchy.d.ts +19 -0
  78. package/dist/sql/tenant-hierarchy.d.ts.map +1 -0
  79. package/dist/sql/tenant-hierarchy.js +53 -0
  80. package/dist/sql/tenant-hierarchy.js.map +1 -0
  81. package/dist/sql/tenant-placeholders.d.ts +60 -9
  82. package/dist/sql/tenant-placeholders.d.ts.map +1 -1
  83. package/dist/sql/tenant-placeholders.js +237 -63
  84. package/dist/sql/tenant-placeholders.js.map +1 -1
  85. package/dist/sql/tenant-prompt.d.ts +4 -0
  86. package/dist/sql/tenant-prompt.d.ts.map +1 -1
  87. package/dist/sql/tenant-prompt.js +79 -11
  88. package/dist/sql/tenant-prompt.js.map +1 -1
  89. package/dist/sql/tenant-scope-validate.d.ts +3 -1
  90. package/dist/sql/tenant-scope-validate.d.ts.map +1 -1
  91. package/dist/sql/tenant-scope-validate.js +5 -2
  92. package/dist/sql/tenant-scope-validate.js.map +1 -1
  93. package/dist/sql/validate.d.ts +24 -4
  94. package/dist/sql/validate.d.ts.map +1 -1
  95. package/dist/sql/validate.js +123 -83
  96. package/dist/sql/validate.js.map +1 -1
  97. package/package.json +9 -7
  98. package/dist/exec/postgres.d.ts +0 -6
  99. package/dist/exec/postgres.d.ts.map +0 -1
  100. package/dist/exec/postgres.js +0 -30
  101. package/dist/exec/postgres.js.map +0 -1
  102. package/dist/modes/post-execute-log.d.ts +0 -9
  103. package/dist/modes/post-execute-log.d.ts.map +0 -1
  104. package/dist/modes/post-execute-log.js +0 -26
  105. package/dist/modes/post-execute-log.js.map +0 -1
@@ -0,0 +1,876 @@
1
+ import { SensitiveReferenceError, } from "../errors.js";
2
+ import { ENGINE_LEXER_PROFILES, GENERIC_LEXER, hasUnterminatedToken, lexSql, lexerProfileFor, } from "./lexer.js";
3
+ /**
4
+ * Check whether a SQL statement references tables/columns marked `sensitive`
5
+ * in the schema artifact.
6
+ *
7
+ * `sensitive: true` is otherwise a **prompt-level** marker: `formatSchemaForNlToSql`
8
+ * either tags identifiers `(sensitive)` or withholds them entirely. Both act on what
9
+ * the model *sees*. This function is the **enforcement** path — it inspects SQL that
10
+ * is about to execute, so hosts that cache, store, or replay statements should call it
11
+ * on every execution path, not only at generation time.
12
+ *
13
+ * Unlike a bare substring scan, an unqualified column name only counts when the owning
14
+ * table is actually in the statement's scope: `FROM`/`JOIN` targets and their aliases
15
+ * are resolved first. When scope cannot be resolved (no resolvable table source, or a
16
+ * qualifier that binds to nothing known), the check fails conservatively — it widens
17
+ * matching and reports why in {@link SensitiveGuardrailResult.unresolvedScope} — rather
18
+ * than passing silently. A string, quoted identifier, or comment that never closes is
19
+ * reported the same way (`UNTERMINATED_TOKEN`).
20
+ *
21
+ * `SELECT *`, `alias.*`, and whole-row references such as `row_to_json(alias)` count as
22
+ * referencing every sensitive column of the table they reach. They expand per query
23
+ * block: a bare `*` covers its own `SELECT`'s FROM/JOIN tables, and an alias resolves in
24
+ * its own block first, then in enclosing blocks. When the block structure cannot be
25
+ * read with confidence, they expand against every table in the statement.
26
+ *
27
+ * Heuristic, not a SQL parser, and not a security boundary. It is defense in depth for
28
+ * review and enforcement; database-side column privileges are the real control.
29
+ */
30
+ export function validateSensitiveReferences(sql, schema, options) {
31
+ const mode = options?.mode ?? "warn";
32
+ const index = indexSchema(schema);
33
+ const result = index.hasSensitive
34
+ ? scanSql(sql, index, options?.dialect)
35
+ : { passed: true, references: [] };
36
+ if (mode === "strict" && !result.passed) {
37
+ throw new SensitiveReferenceError(buildStrictMessage(result), ruleFor(result), result.references, result.unresolvedScope);
38
+ }
39
+ return result;
40
+ }
41
+ /**
42
+ * True when the schema marks at least one table or column `sensitive`. Cheap pre-check
43
+ * for hosts (and `ask()`) that skip the guardrail entirely on schemas with no markers.
44
+ */
45
+ export function schemaHasSensitiveIdentifiers(schema) {
46
+ return indexSchema(schema).hasSensitive;
47
+ }
48
+ /** Render a reference as `schema.table.column` (or `table.column` when the schema has no namespace). */
49
+ export function formatSensitiveReference(ref) {
50
+ const table = ref.schema ? `${ref.schema}.${ref.table}` : ref.table;
51
+ return `${table}.${ref.column}`;
52
+ }
53
+ // ---------------------------------------------------------------------------
54
+ // Strict-mode messaging
55
+ // ---------------------------------------------------------------------------
56
+ function ruleFor(result) {
57
+ if (result.references.some((r) => r.matchKind === "table"))
58
+ return "SENSITIVE_TABLE_REFERENCED";
59
+ if (result.references.length > 0)
60
+ return "SENSITIVE_COLUMN_REFERENCED";
61
+ return "UNRESOLVED_TABLE_SCOPE";
62
+ }
63
+ function buildStrictMessage(result) {
64
+ const parts = [];
65
+ if (result.references.length > 0) {
66
+ parts.push(`SQL references sensitive identifiers: ${result.references.map(formatSensitiveReference).join(", ")}`);
67
+ }
68
+ if (result.unresolvedScope)
69
+ parts.push(result.unresolvedScope.message);
70
+ return `Sensitive-identifier guardrail failed (strict mode): ${parts.join("; ")}`;
71
+ }
72
+ /** Loaded schemas are long-lived and re-used across calls; index once per object. */
73
+ const indexCache = new WeakMap();
74
+ function indexSchema(schema) {
75
+ const cached = indexCache.get(schema);
76
+ if (cached)
77
+ return cached;
78
+ const tables = [];
79
+ const byName = new Map();
80
+ let hasSensitive = false;
81
+ const rawTables = schema.tables;
82
+ rawTables.forEach((t, order) => {
83
+ const schemaName = typeof t.schema === "string" && t.schema !== "" ? t.schema : undefined;
84
+ const tableSensitive = t.sensitive === true;
85
+ if (tableSensitive)
86
+ hasSensitive = true;
87
+ const columns = (t.columns ?? []).map((c, ci) => {
88
+ const sensitive = tableSensitive || c.sensitive === true;
89
+ if (sensitive)
90
+ hasSensitive = true;
91
+ return { name: c.name, lower: c.name.toLowerCase(), sensitive, order: ci };
92
+ });
93
+ const entry = {
94
+ name: t.name,
95
+ ...(schemaName ? { schema: schemaName } : {}),
96
+ lower: t.name.toLowerCase(),
97
+ ...(schemaName ? { schemaLower: schemaName.toLowerCase() } : {}),
98
+ sensitive: tableSensitive,
99
+ order,
100
+ columns,
101
+ byColumn: new Map(columns.map((c) => [c.lower, c])),
102
+ };
103
+ tables.push(entry);
104
+ const bucket = byName.get(entry.lower);
105
+ if (bucket)
106
+ bucket.push(entry);
107
+ else
108
+ byName.set(entry.lower, [entry]);
109
+ });
110
+ const index = { tables, byName, hasSensitive };
111
+ indexCache.set(schema, index);
112
+ return index;
113
+ }
114
+ /**
115
+ * Map lexer tokens onto the scanner's simpler vocabulary. Comments are dropped (MySQL
116
+ * `/*! … *\/` bodies are already lexed as code).
117
+ */
118
+ function toToks(tokens) {
119
+ const out = [];
120
+ for (const t of tokens) {
121
+ switch (t.kind) {
122
+ case "comment":
123
+ break;
124
+ case "word":
125
+ out.push({ kind: "word", value: t.value, lower: t.lower, quoted: false });
126
+ break;
127
+ case "quoted_identifier":
128
+ out.push({ kind: "word", value: t.value, lower: t.lower, quoted: true });
129
+ break;
130
+ case "string":
131
+ // MySQL/MariaDB `"…"` is a string under the default sql_mode but an identifier
132
+ // under ANSI_QUOTES, and the lexer cannot know which the server runs. Reading it
133
+ // as an identifier means `SELECT "ssn"` is flagged: over-reporting is the safe
134
+ // failure for this check, while reading it as a string would be a bypass on
135
+ // ANSI_QUOTES servers. The tenant guardrail makes the mirror-image choice (a
136
+ // `"tenant_id"` predicate reads as a string, so it never counts as scoping),
137
+ // because there treating it as a string is what fails closed.
138
+ if (t.quote === '"') {
139
+ const inner = t.text.slice(1, t.unterminated ? undefined : -1);
140
+ out.push({ kind: "word", value: inner, lower: inner.toLowerCase(), quoted: true });
141
+ }
142
+ else {
143
+ out.push({ kind: "literal", value: "'", lower: "'", quoted: false });
144
+ }
145
+ break;
146
+ case "number":
147
+ out.push({ kind: "number", value: t.text, lower: t.lower, quoted: false });
148
+ break;
149
+ case "parameter":
150
+ out.push({ kind: "placeholder", value: t.text, lower: t.lower, quoted: false });
151
+ break;
152
+ case "punct":
153
+ out.push({ kind: "punct", value: t.text, lower: t.text, quoted: false });
154
+ break;
155
+ }
156
+ }
157
+ return out;
158
+ }
159
+ // ---------------------------------------------------------------------------
160
+ // Keyword sets
161
+ // ---------------------------------------------------------------------------
162
+ /**
163
+ * Words that can never be a relation alias. Deliberately broad — a wrongly-taken
164
+ * "alias" would bind a qualifier to the wrong table.
165
+ */
166
+ const ALIAS_STOP = new Set([
167
+ "all", "and", "any", "as", "asc", "at", "between", "by", "case", "collate", "cross",
168
+ "desc", "distinct", "else", "end", "escape", "except", "exists", "fetch", "filter",
169
+ "first", "for", "from", "full", "group", "having", "ilike", "in", "inner", "intersect",
170
+ "into", "is", "join", "last", "lateral", "left", "like", "limit", "natural", "next",
171
+ "not", "null", "nulls", "offset", "on", "only", "or", "order", "outer", "over",
172
+ "partition", "returning", "right", "rows", "select", "set", "similar", "some",
173
+ "straight_join", "tablesample", "then", "union", "unknown", "using", "values", "when",
174
+ "where", "window", "with", "within",
175
+ ]);
176
+ /**
177
+ * Words that are never plausible column names, so they are excluded from the
178
+ * unqualified-candidate set. Deliberately *narrow* — words like `key`, `first`,
179
+ * `time`, `filter`, or `rows` are real column names in real schemas and must stay
180
+ * eligible so a sensitive column named `key` is still caught.
181
+ */
182
+ const BARE_STOP = new Set([
183
+ "and", "as", "asc", "between", "by", "case", "cross", "desc", "distinct", "else",
184
+ "escape", "except", "exists", "false", "from", "group", "having", "ilike", "in",
185
+ "inner", "intersect", "is", "join", "lateral", "like", "limit", "natural", "not",
186
+ "null", "nulls", "offset", "on", "or", "order", "outer", "over", "partition",
187
+ "recursive", "select", "then", "true", "union", "using", "when", "where", "with",
188
+ ]);
189
+ const JOIN_MODIFIER = new Set([
190
+ "inner", "left", "right", "full", "outer", "cross", "natural", "straight_join",
191
+ ]);
192
+ /**
193
+ * Words that continue an expression rather than end one, on top of {@link ALIAS_STOP} and
194
+ * {@link BARE_STOP}. A word after one of these is an operand, never an implicit alias —
195
+ * `a DIV u`, `ts AT TIME ZONE u`, `json_object('k' VALUE u)`. Broad on purpose: a word
196
+ * wrongly listed here only costs an over-report.
197
+ */
198
+ const EXPR_KEYWORDS = new Set([
199
+ "array", "asymmetric", "binary", "content", "default", "div", "document", "false",
200
+ "glob", "interval", "isnull", "match", "mod", "notnull", "operator", "overlaps",
201
+ "placing", "regexp", "rlike", "row", "sounds", "symmetric", "to", "true", "unique",
202
+ "value", "variadic", "xor", "zone",
203
+ ]);
204
+ const isKeyword = (lower) => ALIAS_STOP.has(lower) || BARE_STOP.has(lower) || EXPR_KEYWORDS.has(lower);
205
+ /** Unquoted words that end a select list at its own nesting depth. */
206
+ const SELECT_LIST_END = new Set([
207
+ "except", "fetch", "for", "from", "group", "having", "intersect", "into", "limit",
208
+ "offset", "order", "union", "where", "window",
209
+ ]);
210
+ const STANDARD_SELECT_WORDS = ["all", "distinct"];
211
+ const MYSQL_SELECT_WORDS = [
212
+ ...STANDARD_SELECT_WORDS,
213
+ "distinctrow", "high_priority", "straight_join", "sql_small_result", "sql_big_result",
214
+ "sql_buffer_result", "sql_no_cache", "sql_cache", "sql_calc_found_rows",
215
+ ];
216
+ const SELECT_MODIFIERS = {
217
+ postgres: { words: new Set(STANDARD_SELECT_WORDS), distinctOn: true, top: false },
218
+ mysql: { words: new Set(MYSQL_SELECT_WORDS), distinctOn: false, top: false },
219
+ sqlserver: { words: new Set(STANDARD_SELECT_WORDS), distinctOn: false, top: true },
220
+ sqlite: { words: new Set(STANDARD_SELECT_WORDS), distinctOn: false, top: false },
221
+ /** Unknown or no dialect: accept every engine's modifiers, so no wildcard slips past. */
222
+ any: { words: new Set(MYSQL_SELECT_WORDS), distinctOn: true, top: true },
223
+ };
224
+ function selectModifiersFor(dialect) {
225
+ switch (dialect?.id) {
226
+ case "postgres":
227
+ case "cockroachdb":
228
+ return SELECT_MODIFIERS.postgres;
229
+ case "mysql":
230
+ case "mariadb":
231
+ return SELECT_MODIFIERS.mysql;
232
+ case "sqlserver":
233
+ return SELECT_MODIFIERS.sqlserver;
234
+ case "sqlite":
235
+ return SELECT_MODIFIERS.sqlite;
236
+ default:
237
+ return SELECT_MODIFIERS.any;
238
+ }
239
+ }
240
+ /**
241
+ * Lex and scan. With a known dialect the statement is lexed once, the way that engine
242
+ * reads it. Without one (or for an unrecognized id) the generic reading decides scope
243
+ * issues, and references are unioned across every engine's reading that lexes cleanly —
244
+ * so a column hidden from one reading by an engine-specific quote or comment rule (for
245
+ * example MySQL `'\''` or Postgres `E'\''`) is still reported. Readings with an
246
+ * unterminated token are skipped: the engine would reject that SQL outright.
247
+ */
248
+ function scanSql(sql, index, dialect) {
249
+ const profile = dialect ? lexerProfileFor(dialect) : undefined;
250
+ if (profile) {
251
+ return finalize(scanTokens(lexSql(sql, profile), index, selectModifiersFor(dialect)));
252
+ }
253
+ const modifiers = SELECT_MODIFIERS.any;
254
+ const primary = scanTokens(lexSql(sql, GENERIC_LEXER), index, modifiers);
255
+ for (const p of ENGINE_LEXER_PROFILES) {
256
+ const tokens = lexSql(sql, p);
257
+ if (hasUnterminatedToken(tokens))
258
+ continue;
259
+ for (const [key, ref] of scanTokens(tokens, index, modifiers).found) {
260
+ const existing = primary.found.get(key);
261
+ if (!existing)
262
+ primary.found.set(key, ref);
263
+ else if (ref.matchKind === "qualified")
264
+ existing.matchKind = "qualified";
265
+ }
266
+ }
267
+ return finalize(primary);
268
+ }
269
+ function finalize(outcome) {
270
+ const references = [...outcome.found.values()]
271
+ .sort((a, b) => a.tableOrder - b.tableOrder || a.columnOrder - b.columnOrder)
272
+ .map(({ tableOrder: _tableOrder, columnOrder: _columnOrder, ...ref }) => ref);
273
+ const unresolvedScope = outcome.issues.size > 0 ? buildScopeReport([...outcome.issues], outcome.widened) : undefined;
274
+ return {
275
+ passed: references.length === 0 && unresolvedScope === undefined,
276
+ references,
277
+ ...(unresolvedScope ? { unresolvedScope } : {}),
278
+ };
279
+ }
280
+ function scanTokens(lexed, index, modifiers) {
281
+ const tokens = toToks(lexed);
282
+ const isWord = (t) => t?.kind === "word";
283
+ const isPunct = (t, v) => t?.kind === "punct" && t.value === v;
284
+ const isKeywordTok = (t, v) => t?.kind === "word" && !t.quoted && t.lower === v;
285
+ const canAlias = (t) => t?.kind === "word" && (t.quoted || !ALIAS_STOP.has(t.lower));
286
+ /** Token indices already accounted for as relation names or aliases. */
287
+ const consumed = new Set();
288
+ /** Relation names that exist but are not schema tables (CTEs, derived tables). */
289
+ const derived = new Set();
290
+ // FROM/JOIN bindings. `at` is the index of the FROM/JOIN keyword that introduced them,
291
+ // which places the binding in that clause's query block.
292
+ const sourcePaths = [];
293
+ const aliasDecls = [];
294
+ /** Aliases of derived tables and table functions — bound, but to no schema table. */
295
+ const derivedDecls = [];
296
+ /** The FROM/JOIN keyword whose items `parseTableRef` is reading. */
297
+ let bindingAt = -1;
298
+ const addDerived = (alias) => {
299
+ derived.add(alias);
300
+ derivedDecls.push({ alias, at: bindingAt });
301
+ };
302
+ let opaqueSource = false;
303
+ let sawTableSource = false;
304
+ const closeParenAfter = (open) => {
305
+ let depth = 0;
306
+ for (let k = open; k < tokens.length; k++) {
307
+ if (isPunct(tokens[k], "("))
308
+ depth++;
309
+ else if (isPunct(tokens[k], ")")) {
310
+ depth--;
311
+ if (depth === 0)
312
+ return k;
313
+ }
314
+ }
315
+ return -1;
316
+ };
317
+ const openParenBefore = (close) => {
318
+ let depth = 0;
319
+ for (let k = close; k >= 0; k--) {
320
+ if (isPunct(tokens[k], ")"))
321
+ depth++;
322
+ else if (isPunct(tokens[k], "(")) {
323
+ depth--;
324
+ if (depth === 0)
325
+ return k;
326
+ }
327
+ }
328
+ return -1;
329
+ };
330
+ /**
331
+ * Index of the first select-list item after the `SELECT` at `select`, skipping the
332
+ * engine's modifiers: `DISTINCT`, `DISTINCT ON (x)`, MySQL `SQL_NO_CACHE`, SQL Server
333
+ * `TOP (5) PERCENT WITH TIES`, …. `PERCENT` / `TIES` only count inside a `TOP` clause,
334
+ * so `SELECT percent * rate` stays a multiplication.
335
+ */
336
+ const selectListStart = (select) => {
337
+ let k = select + 1;
338
+ for (;;) {
339
+ const t = tokens[k];
340
+ if (t?.kind !== "word" || t.quoted)
341
+ return k;
342
+ if (modifiers.distinctOn &&
343
+ t.lower === "distinct" &&
344
+ isKeywordTok(tokens[k + 1], "on") &&
345
+ isPunct(tokens[k + 2], "(")) {
346
+ const close = closeParenAfter(k + 2);
347
+ if (close === -1)
348
+ return k;
349
+ k = close + 1;
350
+ continue;
351
+ }
352
+ if (modifiers.words.has(t.lower)) {
353
+ k++;
354
+ continue;
355
+ }
356
+ if (modifiers.top && t.lower === "top") {
357
+ let j = k + 1;
358
+ if (isPunct(tokens[j], "(")) {
359
+ const close = closeParenAfter(j);
360
+ if (close === -1)
361
+ return k;
362
+ j = close + 1;
363
+ }
364
+ else if (tokens[j]?.kind === "number" || tokens[j]?.kind === "placeholder") {
365
+ j++;
366
+ }
367
+ else {
368
+ return k; // `top` is an ordinary column name here
369
+ }
370
+ if (isKeywordTok(tokens[j], "percent"))
371
+ j++;
372
+ if (isKeywordTok(tokens[j], "with") && isKeywordTok(tokens[j + 1], "ties"))
373
+ j += 2;
374
+ k = j;
375
+ continue;
376
+ }
377
+ return k;
378
+ }
379
+ };
380
+ /** A token that can end a select-list expression, so a word right after it is an alias. */
381
+ const endsExpression = (k) => {
382
+ const t = tokens[k];
383
+ if (!t)
384
+ return false;
385
+ if (t.kind === "word")
386
+ return t.quoted || !isKeyword(t.lower);
387
+ if (t.kind === "number" || t.kind === "literal")
388
+ return true;
389
+ if (!isPunct(t, ")"))
390
+ return false;
391
+ // `a OPERATOR(pg_catalog.||) u` — the parenthesized operator takes a right operand.
392
+ return !isKeywordTok(tokens[openParenBefore(k) - 1], "operator");
393
+ };
394
+ const endsSelectList = (t) => t === undefined ||
395
+ isPunct(t, ")") ||
396
+ isPunct(t, ";") ||
397
+ (t.kind === "word" && !t.quoted && SELECT_LIST_END.has(t.lower));
398
+ /**
399
+ * Bare `*` projections, and select-list words that are implicit output aliases. `SELECT
400
+ * id u` names an output column `u`, while `SELECT u` and `SELECT id, u` read the row, so
401
+ * only a word between an expression-ending token and the end of its item (`,` or the end
402
+ * of the list) is an alias. Anything less certain stays a reference.
403
+ */
404
+ const wildcards = new Set();
405
+ const implicitAliases = new Set();
406
+ for (let s = 0; s < tokens.length; s++) {
407
+ if (!isKeywordTok(tokens[s], "select"))
408
+ continue;
409
+ const start = selectListStart(s);
410
+ // `EXISTS (SELECT * …)` returns no columns.
411
+ const inExists = isPunct(tokens[s - 1], "(") && isKeywordTok(tokens[s - 2], "exists");
412
+ if (isPunct(tokens[start], "*") && !inExists)
413
+ wildcards.add(start);
414
+ let depth = 0;
415
+ for (let k = start; k < tokens.length; k++) {
416
+ const t = tokens[k];
417
+ if (isPunct(t, "("))
418
+ depth++;
419
+ else if (isPunct(t, ")")) {
420
+ if (depth === 0)
421
+ break;
422
+ depth--;
423
+ }
424
+ else if (depth > 0)
425
+ continue;
426
+ else if (endsSelectList(t))
427
+ break;
428
+ else if (k > start &&
429
+ t.kind === "word" &&
430
+ (t.quoted || !isKeyword(t.lower)) &&
431
+ endsExpression(k - 1) &&
432
+ (isPunct(tokens[k + 1], ",") || endsSelectList(tokens[k + 1]))) {
433
+ implicitAliases.add(k);
434
+ }
435
+ }
436
+ }
437
+ /** Consume an optional `AS alias` / bare `alias`; returns the index after it. */
438
+ const takeAlias = (start, register) => {
439
+ let k = start;
440
+ if (isWord(tokens[k]) && tokens[k].lower === "as" && isWord(tokens[k + 1])) {
441
+ consumed.add(k);
442
+ k++;
443
+ }
444
+ if (canAlias(tokens[k])) {
445
+ register(tokens[k].lower);
446
+ consumed.add(k);
447
+ k++;
448
+ // Optional derived-column alias list: `… AS t(a, b)`
449
+ if (isPunct(tokens[k], "(")) {
450
+ const close = closeParenAfter(k);
451
+ if (close !== -1) {
452
+ for (let x = k + 1; x < close; x++)
453
+ if (isWord(tokens[x]))
454
+ consumed.add(x);
455
+ k = close + 1;
456
+ }
457
+ }
458
+ }
459
+ return k;
460
+ };
461
+ const parseTableRef = (start) => {
462
+ let j = start;
463
+ while (isWord(tokens[j]) && (tokens[j].lower === "lateral" || tokens[j].lower === "only")) {
464
+ consumed.add(j);
465
+ j++;
466
+ }
467
+ // Derived table / parenthesized join. The body is deliberately not skipped: the
468
+ // outer walk still visits its FROM/JOINs, so nested base tables land in scope too.
469
+ if (isPunct(tokens[j], "(")) {
470
+ const close = closeParenAfter(j);
471
+ if (close === -1)
472
+ return j + 1;
473
+ sawTableSource = true;
474
+ return takeAlias(close + 1, addDerived);
475
+ }
476
+ if (!canAlias(tokens[j])) {
477
+ // e.g. the operand in `EXTRACT(YEAR FROM 1)` — not a relation name.
478
+ return j;
479
+ }
480
+ const path = [];
481
+ const pathIdx = [];
482
+ let k = j;
483
+ for (;;) {
484
+ if (!isWord(tokens[k]))
485
+ break;
486
+ path.push(tokens[k].lower);
487
+ pathIdx.push(k);
488
+ k++;
489
+ if (isPunct(tokens[k], ".")) {
490
+ k++;
491
+ continue;
492
+ }
493
+ break;
494
+ }
495
+ for (const x of pathIdx)
496
+ consumed.add(x);
497
+ if (isPunct(tokens[k], "(")) {
498
+ // Table function / table-valued expression — its columns are not schema columns.
499
+ opaqueSource = true;
500
+ const close = closeParenAfter(k);
501
+ return takeAlias(close === -1 ? k + 1 : close + 1, addDerived);
502
+ }
503
+ sawTableSource = true;
504
+ sourcePaths.push({ path, at: bindingAt });
505
+ return takeAlias(k, (alias) => aliasDecls.push({ alias, path, at: bindingAt }));
506
+ };
507
+ // Pass A — resolve relation names, aliases, and CTE names.
508
+ for (let i = 0; i < tokens.length; i++) {
509
+ const t = tokens[i];
510
+ if (t.kind !== "word" || t.quoted)
511
+ continue;
512
+ // `name AS (` / `name (cols) AS (` → CTE or named subquery.
513
+ if (t.lower === "as" && isPunct(tokens[i + 1], "(")) {
514
+ let back = i - 1;
515
+ if (isPunct(tokens[back], ")"))
516
+ back = openParenBefore(back) - 1;
517
+ if (canAlias(tokens[back])) {
518
+ derived.add(tokens[back].lower);
519
+ consumed.add(back);
520
+ }
521
+ continue;
522
+ }
523
+ if (t.lower !== "from" && t.lower !== "join")
524
+ continue;
525
+ consumed.add(i);
526
+ if (t.lower === "join") {
527
+ for (let k = i - 1; k >= 0 && isWord(tokens[k]) && JOIN_MODIFIER.has(tokens[k].lower); k--) {
528
+ consumed.add(k);
529
+ }
530
+ }
531
+ bindingAt = i;
532
+ let j = i + 1;
533
+ for (;;) {
534
+ const next = parseTableRef(j);
535
+ // Old-style comma-separated FROM lists; JOIN takes exactly one reference.
536
+ if (t.lower === "from" && next > j && isPunct(tokens[next], ",")) {
537
+ j = next + 1;
538
+ continue;
539
+ }
540
+ break;
541
+ }
542
+ }
543
+ // Pass B — collect the column references the statement actually makes.
544
+ const qualifiedRefs = [];
545
+ const starRefs = [];
546
+ const bare = new Set();
547
+ /** Each bare word where it occurs: a table name or alias used as a value is the whole row. */
548
+ const wholeRowRefs = [];
549
+ const bareStars = [];
550
+ for (let i = 0; i < tokens.length; i++) {
551
+ const t = tokens[i];
552
+ // `SELECT *`, `SELECT DISTINCT *`, `SELECT TOP 5 *`, `SELECT a, *` — not `count(*)`
553
+ // or multiplication.
554
+ if (isPunct(t, "*") && (wildcards.has(i) || isPunct(tokens[i - 1], ","))) {
555
+ bareStars.push(i);
556
+ continue;
557
+ }
558
+ if (t.kind !== "word")
559
+ continue;
560
+ if (isPunct(tokens[i - 1], "."))
561
+ continue; // tail of a path, handled at its head
562
+ if (isPunct(tokens[i - 1], "::"))
563
+ continue; // cast target type
564
+ if (isWord(tokens[i - 1]) && tokens[i - 1].lower === "as")
565
+ continue; // output alias
566
+ if (implicitAliases.has(i))
567
+ continue; // output alias without AS
568
+ if (isPunct(tokens[i + 1], ".")) {
569
+ const path = [t.lower];
570
+ let k = i + 1;
571
+ let star = false;
572
+ while (isPunct(tokens[k], ".")) {
573
+ k++;
574
+ if (isPunct(tokens[k], "*")) {
575
+ star = true;
576
+ k++;
577
+ break;
578
+ }
579
+ if (!isWord(tokens[k]))
580
+ break;
581
+ path.push(tokens[k].lower);
582
+ k++;
583
+ }
584
+ if (star && !consumed.has(i)) {
585
+ // `alias.*` / `schema.table.*` — every column of the qualifier's table.
586
+ starRefs.push({
587
+ qualifier: path[path.length - 1],
588
+ ...(path.length >= 2 ? { qualifierSchema: path[path.length - 2] } : {}),
589
+ at: i,
590
+ });
591
+ }
592
+ else if (!star && path.length >= 2 && !consumed.has(i)) {
593
+ qualifiedRefs.push({
594
+ column: path[path.length - 1],
595
+ qualifier: path[path.length - 2],
596
+ ...(path.length >= 3 ? { qualifierSchema: path[path.length - 3] } : {}),
597
+ });
598
+ }
599
+ i = k - 1;
600
+ continue;
601
+ }
602
+ if (consumed.has(i))
603
+ continue;
604
+ if (isPunct(tokens[i + 1], "("))
605
+ continue; // function call
606
+ if (!t.quoted && BARE_STOP.has(t.lower))
607
+ continue;
608
+ bare.add(t.lower);
609
+ wholeRowRefs.push({ word: t.lower, at: i });
610
+ }
611
+ // Bind relation names and aliases to schema tables.
612
+ const resolve = (path) => {
613
+ const name = path[path.length - 1];
614
+ const qualifier = path.length >= 2 ? path[path.length - 2] : undefined;
615
+ const candidates = index.byName.get(name) ?? [];
616
+ if (!qualifier)
617
+ return candidates;
618
+ const exact = candidates.filter((e) => e.schemaLower === qualifier);
619
+ if (exact.length > 0)
620
+ return exact;
621
+ // v1 schemas carry no namespace — a qualified reference still matches by name.
622
+ return candidates.filter((e) => e.schemaLower === undefined);
623
+ };
624
+ // Query blocks, for wildcard and whole-row expansion. When the block tree cannot be
625
+ // trusted — an unterminated token, unbalanced parentheses, a SELECT that neither nests
626
+ // nor follows a set operator, or a FROM/JOIN outside every block — every binding and
627
+ // reference goes into one block, which is the statement-global reading.
628
+ let tree = hasUnterminatedToken(lexed) ? undefined : buildQueryBlocks(tokens);
629
+ if (tree && [...sourcePaths, ...derivedDecls].some((b) => tree.blockOf[b.at] === -1)) {
630
+ tree = undefined;
631
+ }
632
+ const blockAt = (k) => (tree ? tree.blockOf[k] : 0);
633
+ const parentOf = (block) => (tree ? tree.parent[block] : -1);
634
+ /** Every table the statement reaches, and every qualifier it binds, in any block. */
635
+ const inScope = new Set();
636
+ const qualifiers = new Map();
637
+ const blockScopes = new Map();
638
+ const blockScope = (block) => {
639
+ let scope = blockScopes.get(block);
640
+ if (!scope) {
641
+ scope = { tables: new Set(), qualifiers: new Map() };
642
+ blockScopes.set(block, scope);
643
+ }
644
+ return scope;
645
+ };
646
+ const bindQualifier = (into, name, entries) => {
647
+ const bucket = into.get(name);
648
+ if (bucket)
649
+ bucket.push(...entries.filter((e) => !bucket.includes(e)));
650
+ else
651
+ into.set(name, [...entries]);
652
+ };
653
+ for (const { path, at } of sourcePaths) {
654
+ const entries = resolve(path);
655
+ const scope = blockScope(blockAt(at));
656
+ for (const e of entries) {
657
+ inScope.add(e);
658
+ scope.tables.add(e);
659
+ }
660
+ bindQualifier(qualifiers, path[path.length - 1], entries);
661
+ bindQualifier(scope.qualifiers, path[path.length - 1], entries);
662
+ }
663
+ for (const { alias, path, at } of aliasDecls) {
664
+ const entries = resolve(path);
665
+ bindQualifier(qualifiers, alias, entries);
666
+ bindQualifier(blockScope(blockAt(at)).qualifiers, alias, entries);
667
+ }
668
+ // A derived-table alias binds no schema table, but it still shadows outer bindings.
669
+ for (const { alias, at } of derivedDecls) {
670
+ bindQualifier(blockScope(blockAt(at)).qualifiers, alias, []);
671
+ }
672
+ /**
673
+ * The tables `name` denotes where it is written: its own block's bindings first, then
674
+ * each enclosing block's (correlated references). A name no enclosing block binds falls
675
+ * back to every binding in the statement, so a reference that cannot be placed is
676
+ * over-reported rather than dropped.
677
+ */
678
+ const lookupQualifier = (name, block) => {
679
+ for (let b = block; b !== -1; b = parentOf(b)) {
680
+ const bound = blockScopes.get(b)?.qualifiers.get(name);
681
+ if (bound)
682
+ return bound;
683
+ }
684
+ return qualifiers.get(name);
685
+ };
686
+ // Collect findings.
687
+ const found = new Map();
688
+ const record = (table, column, columnOrder, matchKind) => {
689
+ const key = `${table.schemaLower ?? ""}.${table.lower}.${column.toLowerCase()}`;
690
+ const existing = found.get(key);
691
+ if (existing) {
692
+ // A qualified hit is the stronger explanation of the same reference.
693
+ if (matchKind === "qualified")
694
+ existing.matchKind = "qualified";
695
+ return;
696
+ }
697
+ found.set(key, {
698
+ table: table.name,
699
+ ...(table.schema ? { schema: table.schema } : {}),
700
+ column,
701
+ matchKind,
702
+ tableOrder: table.order,
703
+ columnOrder,
704
+ });
705
+ };
706
+ // Sensitive tables reached as a FROM/JOIN target — catches `SELECT * FROM secrets`.
707
+ for (const table of inScope) {
708
+ if (table.sensitive)
709
+ record(table, "*", -1, "table");
710
+ }
711
+ const issues = new Set();
712
+ if (opaqueSource)
713
+ issues.add("OPAQUE_TABLE_SOURCE");
714
+ // Qualified references.
715
+ const unknownQualifierColumns = new Set();
716
+ for (const ref of qualifiedRefs) {
717
+ const bound = qualifiers.get(ref.qualifier);
718
+ if (!bound) {
719
+ if (derived.has(ref.qualifier))
720
+ continue; // CTE / derived relation — a known namespace
721
+ issues.add("UNKNOWN_QUALIFIER");
722
+ unknownQualifierColumns.add(ref.column);
723
+ continue;
724
+ }
725
+ const scoped = ref.qualifierSchema
726
+ ? bound.filter((e) => e.schemaLower === undefined || e.schemaLower === ref.qualifierSchema)
727
+ : bound;
728
+ for (const table of scoped) {
729
+ const column = table.byColumn.get(ref.column);
730
+ if (column?.sensitive)
731
+ record(table, column.name, column.order, "qualified");
732
+ }
733
+ }
734
+ // Unqualified references — counted only when the owning table is in scope.
735
+ const noTableSource = !sawTableSource && bare.size > 0;
736
+ if (noTableSource)
737
+ issues.add("NO_TABLE_SOURCE");
738
+ const bareScope = noTableSource ? index.tables : [...inScope];
739
+ for (const table of bareScope) {
740
+ for (const column of table.columns) {
741
+ if (column.sensitive && bare.has(column.lower)) {
742
+ record(table, column.name, column.order, "unqualified");
743
+ }
744
+ }
745
+ }
746
+ // A qualifier we could not bind: its column could belong to anything, so match it
747
+ // against every sensitive column rather than drop it.
748
+ if (unknownQualifierColumns.size > 0) {
749
+ for (const table of index.tables) {
750
+ for (const column of table.columns) {
751
+ if (column.sensitive && unknownQualifierColumns.has(column.lower)) {
752
+ record(table, column.name, column.order, "unqualified");
753
+ }
754
+ }
755
+ }
756
+ }
757
+ // Wildcards and whole-row references reach every column of the table, so they reach
758
+ // its sensitive columns. (Table-level-sensitive tables are already reported as "*".)
759
+ const recordAllSensitive = (table, matchKind) => {
760
+ if (table.sensitive)
761
+ return;
762
+ for (const column of table.columns) {
763
+ if (column.sensitive)
764
+ record(table, column.name, column.order, matchKind);
765
+ }
766
+ };
767
+ // Bare `SELECT *` — every table its own block's FROM/JOIN reaches.
768
+ for (const at of bareStars) {
769
+ const block = blockAt(at);
770
+ const tables = block === -1 ? inScope : (blockScopes.get(block)?.tables ?? []);
771
+ for (const table of tables)
772
+ recordAllSensitive(table, "unqualified");
773
+ }
774
+ // `alias.*`
775
+ for (const ref of starRefs) {
776
+ const bound = lookupQualifier(ref.qualifier, blockAt(ref.at));
777
+ if (!bound) {
778
+ if (!derived.has(ref.qualifier))
779
+ issues.add("UNKNOWN_QUALIFIER");
780
+ continue;
781
+ }
782
+ const scoped = ref.qualifierSchema
783
+ ? bound.filter((e) => e.schemaLower === undefined || e.schemaLower === ref.qualifierSchema)
784
+ : bound;
785
+ for (const table of scoped)
786
+ recordAllSensitive(table, "qualified");
787
+ }
788
+ // A table name or alias used as a value — `row_to_json(u)`, `to_jsonb(u)`,
789
+ // `json_agg(u)`, Postgres `SELECT u FROM users u` — is the whole row.
790
+ for (const { word, at } of wholeRowRefs) {
791
+ for (const table of lookupQualifier(word, blockAt(at)) ?? []) {
792
+ recordAllSensitive(table, "qualified");
793
+ }
794
+ }
795
+ if (hasUnterminatedToken(lexed))
796
+ issues.add("UNTERMINATED_TOKEN");
797
+ return { found, issues, widened: noTableSource || unknownQualifierColumns.size > 0 };
798
+ }
799
+ /**
800
+ * The statement's query blocks, read from parenthesis depth. Every `SELECT` opens a block
801
+ * that runs to the `)` closing its depth, a set operator (`UNION` / `INTERSECT` /
802
+ * `EXCEPT`) at its depth, or a `;`. A block's parent is the innermost block still open
803
+ * where it starts, so subqueries, derived tables, CTE bodies, and `EXISTS` / `IN`
804
+ * operands nest, and set-operation branches are siblings. Parentheses that do not start
805
+ * a `SELECT` (function calls, parenthesized joins) stay in the enclosing block.
806
+ *
807
+ * `blockOf[k]` is the block token `k` belongs to, or -1 outside every block (e.g. the
808
+ * tokens of a top-level `WITH` clause). Returns `undefined` when the tree cannot be
809
+ * trusted: unbalanced parentheses, a `;` inside parentheses, or a `SELECT` at a depth
810
+ * whose block is still open (two statements with no separator, or a construct this
811
+ * reader does not know).
812
+ */
813
+ function buildQueryBlocks(tokens) {
814
+ const blockOf = [];
815
+ const parent = [];
816
+ /** `open[d]` is the block open at parenthesis depth `d`, or -1. */
817
+ const open = [-1];
818
+ const innermost = () => {
819
+ for (let d = open.length - 1; d >= 0; d--)
820
+ if (open[d] !== -1)
821
+ return open[d];
822
+ return -1;
823
+ };
824
+ for (const t of tokens) {
825
+ const depth = open.length - 1;
826
+ const punct = t.kind === "punct" ? t.value : undefined;
827
+ if (punct === "(") {
828
+ blockOf.push(innermost());
829
+ open.push(-1);
830
+ continue;
831
+ }
832
+ if (punct === ")") {
833
+ if (depth === 0)
834
+ return undefined;
835
+ open.pop();
836
+ blockOf.push(innermost());
837
+ continue;
838
+ }
839
+ if (punct === ";") {
840
+ if (depth !== 0)
841
+ return undefined;
842
+ open[0] = -1;
843
+ blockOf.push(-1);
844
+ continue;
845
+ }
846
+ const keyword = t.kind === "word" && !t.quoted ? t.lower : undefined;
847
+ if (keyword === "select") {
848
+ if (open[depth] !== -1)
849
+ return undefined;
850
+ parent.push(innermost());
851
+ open[depth] = parent.length - 1;
852
+ }
853
+ blockOf.push(innermost());
854
+ if (keyword === "union" || keyword === "intersect" || keyword === "except")
855
+ open[depth] = -1;
856
+ }
857
+ return open.length === 1 ? { blockOf, parent } : undefined;
858
+ }
859
+ const SCOPE_ISSUE_TEXT = {
860
+ NO_TABLE_SOURCE: "no FROM/JOIN target could be resolved, so unqualified column names cannot be bound to a table",
861
+ UNKNOWN_QUALIFIER: "a qualified reference used a qualifier that is not a known table, alias, or CTE",
862
+ OPAQUE_TABLE_SOURCE: "a table source is not a relation name (table function or table-valued expression)",
863
+ UNTERMINATED_TOKEN: "a string, quoted identifier, or comment never closes, so the rest of the statement could not be scanned",
864
+ };
865
+ function buildScopeReport(issues, widened) {
866
+ const detail = issues.map((i) => SCOPE_ISSUE_TEXT[i]).join("; ");
867
+ return {
868
+ issues,
869
+ widened,
870
+ message: `Could not resolve the statement's table scope: ${detail}.` +
871
+ (widened
872
+ ? " Unqualified names were matched against every sensitive column in the schema, so some references may be over-reported."
873
+ : ""),
874
+ };
875
+ }
876
+ //# sourceMappingURL=sensitive-guardrail.js.map