@askdb/core 1.0.0-beta.40 → 1.0.0-beta.42

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 (73) hide show
  1. package/README.md +39 -1
  2. package/dist/ask.d.ts +48 -0
  3. package/dist/ask.d.ts.map +1 -1
  4. package/dist/ask.js +176 -10
  5. package/dist/ask.js.map +1 -1
  6. package/dist/enrichment/suggest.d.ts +7 -0
  7. package/dist/enrichment/suggest.d.ts.map +1 -1
  8. package/dist/enrichment/suggest.js +7 -1
  9. package/dist/enrichment/suggest.js.map +1 -1
  10. package/dist/errors.d.ts +49 -0
  11. package/dist/errors.d.ts.map +1 -1
  12. package/dist/errors.js +20 -0
  13. package/dist/errors.js.map +1 -1
  14. package/dist/exec/postgres.d.ts +6 -0
  15. package/dist/exec/postgres.d.ts.map +1 -0
  16. package/dist/exec/postgres.js +30 -0
  17. package/dist/exec/postgres.js.map +1 -0
  18. package/dist/index.d.ts +3 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +3 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/logging/log-events.d.ts +13 -0
  23. package/dist/logging/log-events.d.ts.map +1 -1
  24. package/dist/logging/log-events.js +13 -0
  25. package/dist/logging/log-events.js.map +1 -1
  26. package/dist/modes/post-execute-log.d.ts +9 -0
  27. package/dist/modes/post-execute-log.d.ts.map +1 -0
  28. package/dist/modes/post-execute-log.js +26 -0
  29. package/dist/modes/post-execute-log.js.map +1 -0
  30. package/dist/sql/bind.d.ts +100 -0
  31. package/dist/sql/bind.d.ts.map +1 -0
  32. package/dist/sql/bind.js +560 -0
  33. package/dist/sql/bind.js.map +1 -0
  34. package/dist/sql/dialect-spec.d.ts +4 -0
  35. package/dist/sql/dialect-spec.d.ts.map +1 -1
  36. package/dist/sql/dialect-spec.js +8 -0
  37. package/dist/sql/dialect-spec.js.map +1 -1
  38. package/dist/sql/extract-sql.d.ts +8 -1
  39. package/dist/sql/extract-sql.d.ts.map +1 -1
  40. package/dist/sql/extract-sql.js +27 -4
  41. package/dist/sql/extract-sql.js.map +1 -1
  42. package/dist/sql/generate.d.ts +19 -0
  43. package/dist/sql/generate.d.ts.map +1 -1
  44. package/dist/sql/generate.js +79 -5
  45. package/dist/sql/generate.js.map +1 -1
  46. package/dist/sql/index.d.ts +4 -2
  47. package/dist/sql/index.d.ts.map +1 -1
  48. package/dist/sql/index.js +4 -2
  49. package/dist/sql/index.js.map +1 -1
  50. package/dist/sql/parameter-manifest.d.ts +34 -0
  51. package/dist/sql/parameter-manifest.d.ts.map +1 -0
  52. package/dist/sql/parameter-manifest.js +150 -0
  53. package/dist/sql/parameter-manifest.js.map +1 -0
  54. package/dist/sql/prompt.d.ts +7 -1
  55. package/dist/sql/prompt.d.ts.map +1 -1
  56. package/dist/sql/prompt.js +24 -1
  57. package/dist/sql/prompt.js.map +1 -1
  58. package/dist/sql/sensitive-guardrail.d.ts +47 -0
  59. package/dist/sql/sensitive-guardrail.d.ts.map +1 -0
  60. package/dist/sql/sensitive-guardrail.js +589 -0
  61. package/dist/sql/sensitive-guardrail.js.map +1 -0
  62. package/dist/sql/tenant-placeholders.d.ts +3 -2
  63. package/dist/sql/tenant-placeholders.d.ts.map +1 -1
  64. package/dist/sql/tenant-placeholders.js +14 -11
  65. package/dist/sql/tenant-placeholders.js.map +1 -1
  66. package/dist/sql/validate.d.ts.map +1 -1
  67. package/dist/sql/validate.js +1 -47
  68. package/dist/sql/validate.js.map +1 -1
  69. package/package.json +6 -5
  70. package/dist/ai/provider.d.ts +0 -116
  71. package/dist/ai/provider.d.ts.map +0 -1
  72. package/dist/ai/provider.js +0 -255
  73. package/dist/ai/provider.js.map +0 -1
@@ -0,0 +1,47 @@
1
+ import { type SensitiveReference, type SensitiveScopeReport } from "../errors.js";
2
+ import type { AnyNormalizedSchema } from "../schema/types.js";
3
+ export type SensitiveGuardrailMode = "warn" | "strict";
4
+ export type ValidateSensitiveReferencesOptions = {
5
+ /**
6
+ * `"warn"` (default) returns the references without throwing — today's CLI behaviour.
7
+ * `"strict"` throws {@link SensitiveReferenceError} whenever the statement cannot be
8
+ * proven clean (references found, or scope could not be resolved).
9
+ */
10
+ mode?: SensitiveGuardrailMode;
11
+ };
12
+ export type SensitiveGuardrailResult = {
13
+ /** True only when nothing sensitive was referenced **and** table scope was fully resolved. */
14
+ passed: boolean;
15
+ references: SensitiveReference[];
16
+ /** Present when the statement's `FROM`/`JOIN` scope could not be resolved with confidence. */
17
+ unresolvedScope?: SensitiveScopeReport;
18
+ };
19
+ /**
20
+ * Check whether a SQL statement references tables/columns marked `sensitive`
21
+ * in the schema artifact.
22
+ *
23
+ * `sensitive: true` is otherwise a **prompt-level** marker: `formatSchemaForNlToSql`
24
+ * either tags identifiers `(sensitive)` or withholds them entirely. Both act on what
25
+ * the model *sees*. This function is the **enforcement** path — it inspects SQL that
26
+ * is about to execute, so hosts that cache, store, or replay statements should call it
27
+ * on every execution path, not only at generation time.
28
+ *
29
+ * Unlike a bare substring scan, an unqualified column name only counts when the owning
30
+ * table is actually in the statement's scope: `FROM`/`JOIN` targets and their aliases
31
+ * are resolved first. When scope cannot be resolved (no resolvable table source, or a
32
+ * qualifier that binds to nothing known), the check fails conservatively — it widens
33
+ * matching and reports why in {@link SensitiveGuardrailResult.unresolvedScope} — rather
34
+ * than passing silently.
35
+ *
36
+ * Heuristic, not a SQL parser. It is a review/enforcement aid, not a substitute for
37
+ * database-side column privileges.
38
+ */
39
+ export declare function validateSensitiveReferences(sql: string, schema: AnyNormalizedSchema, options?: ValidateSensitiveReferencesOptions): SensitiveGuardrailResult;
40
+ /**
41
+ * True when the schema marks at least one table or column `sensitive`. Cheap pre-check
42
+ * for hosts (and `ask()`) that skip the guardrail entirely on schemas with no markers.
43
+ */
44
+ export declare function schemaHasSensitiveIdentifiers(schema: AnyNormalizedSchema): boolean;
45
+ /** Render a reference as `schema.table.column` (or `table.column` when the schema has no namespace). */
46
+ export declare function formatSensitiveReference(ref: SensitiveReference): string;
47
+ //# sourceMappingURL=sensitive-guardrail.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sensitive-guardrail.d.ts","sourceRoot":"","sources":["../../src/sql/sensitive-guardrail.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,kBAAkB,EAGvB,KAAK,oBAAoB,EAC1B,MAAM,cAAc,CAAC;AACtB,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAE9D,MAAM,MAAM,sBAAsB,GAAG,MAAM,GAAG,QAAQ,CAAC;AAEvD,MAAM,MAAM,kCAAkC,GAAG;IAC/C;;;;OAIG;IACH,IAAI,CAAC,EAAE,sBAAsB,CAAC;CAC/B,CAAC;AAEF,MAAM,MAAM,wBAAwB,GAAG;IACrC,8FAA8F;IAC9F,MAAM,EAAE,OAAO,CAAC;IAChB,UAAU,EAAE,kBAAkB,EAAE,CAAC;IACjC,8FAA8F;IAC9F,eAAe,CAAC,EAAE,oBAAoB,CAAC;CACxC,CAAC;AAEF;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,2BAA2B,CACzC,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,mBAAmB,EAC3B,OAAO,CAAC,EAAE,kCAAkC,GAC3C,wBAAwB,CAiB1B;AAED;;;GAGG;AACH,wBAAgB,6BAA6B,CAAC,MAAM,EAAE,mBAAmB,GAAG,OAAO,CAElF;AAED,wGAAwG;AACxG,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,kBAAkB,GAAG,MAAM,CAGxE"}
@@ -0,0 +1,589 @@
1
+ import { SensitiveReferenceError, } from "../errors.js";
2
+ /**
3
+ * Check whether a SQL statement references tables/columns marked `sensitive`
4
+ * in the schema artifact.
5
+ *
6
+ * `sensitive: true` is otherwise a **prompt-level** marker: `formatSchemaForNlToSql`
7
+ * either tags identifiers `(sensitive)` or withholds them entirely. Both act on what
8
+ * the model *sees*. This function is the **enforcement** path — it inspects SQL that
9
+ * is about to execute, so hosts that cache, store, or replay statements should call it
10
+ * on every execution path, not only at generation time.
11
+ *
12
+ * Unlike a bare substring scan, an unqualified column name only counts when the owning
13
+ * table is actually in the statement's scope: `FROM`/`JOIN` targets and their aliases
14
+ * are resolved first. When scope cannot be resolved (no resolvable table source, or a
15
+ * qualifier that binds to nothing known), the check fails conservatively — it widens
16
+ * matching and reports why in {@link SensitiveGuardrailResult.unresolvedScope} — rather
17
+ * than passing silently.
18
+ *
19
+ * Heuristic, not a SQL parser. It is a review/enforcement aid, not a substitute for
20
+ * database-side column privileges.
21
+ */
22
+ export function validateSensitiveReferences(sql, schema, options) {
23
+ const mode = options?.mode ?? "warn";
24
+ const index = indexSchema(schema);
25
+ const result = index.hasSensitive
26
+ ? scan(sql, index)
27
+ : { passed: true, references: [] };
28
+ if (mode === "strict" && !result.passed) {
29
+ throw new SensitiveReferenceError(buildStrictMessage(result), ruleFor(result), result.references, result.unresolvedScope);
30
+ }
31
+ return result;
32
+ }
33
+ /**
34
+ * True when the schema marks at least one table or column `sensitive`. Cheap pre-check
35
+ * for hosts (and `ask()`) that skip the guardrail entirely on schemas with no markers.
36
+ */
37
+ export function schemaHasSensitiveIdentifiers(schema) {
38
+ return indexSchema(schema).hasSensitive;
39
+ }
40
+ /** Render a reference as `schema.table.column` (or `table.column` when the schema has no namespace). */
41
+ export function formatSensitiveReference(ref) {
42
+ const table = ref.schema ? `${ref.schema}.${ref.table}` : ref.table;
43
+ return `${table}.${ref.column}`;
44
+ }
45
+ // ---------------------------------------------------------------------------
46
+ // Strict-mode messaging
47
+ // ---------------------------------------------------------------------------
48
+ function ruleFor(result) {
49
+ if (result.references.some((r) => r.matchKind === "table"))
50
+ return "SENSITIVE_TABLE_REFERENCED";
51
+ if (result.references.length > 0)
52
+ return "SENSITIVE_COLUMN_REFERENCED";
53
+ return "UNRESOLVED_TABLE_SCOPE";
54
+ }
55
+ function buildStrictMessage(result) {
56
+ const parts = [];
57
+ if (result.references.length > 0) {
58
+ parts.push(`SQL references sensitive identifiers: ${result.references.map(formatSensitiveReference).join(", ")}`);
59
+ }
60
+ if (result.unresolvedScope)
61
+ parts.push(result.unresolvedScope.message);
62
+ return `Sensitive-identifier guardrail failed (strict mode): ${parts.join("; ")}`;
63
+ }
64
+ /** Loaded schemas are long-lived and re-used across calls; index once per object. */
65
+ const indexCache = new WeakMap();
66
+ function indexSchema(schema) {
67
+ const cached = indexCache.get(schema);
68
+ if (cached)
69
+ return cached;
70
+ const tables = [];
71
+ const byName = new Map();
72
+ let hasSensitive = false;
73
+ const rawTables = schema.tables;
74
+ rawTables.forEach((t, order) => {
75
+ const schemaName = typeof t.schema === "string" && t.schema !== "" ? t.schema : undefined;
76
+ const tableSensitive = t.sensitive === true;
77
+ if (tableSensitive)
78
+ hasSensitive = true;
79
+ const columns = (t.columns ?? []).map((c, ci) => {
80
+ const sensitive = tableSensitive || c.sensitive === true;
81
+ if (sensitive)
82
+ hasSensitive = true;
83
+ return { name: c.name, lower: c.name.toLowerCase(), sensitive, order: ci };
84
+ });
85
+ const entry = {
86
+ name: t.name,
87
+ ...(schemaName ? { schema: schemaName } : {}),
88
+ lower: t.name.toLowerCase(),
89
+ ...(schemaName ? { schemaLower: schemaName.toLowerCase() } : {}),
90
+ sensitive: tableSensitive,
91
+ order,
92
+ columns,
93
+ byColumn: new Map(columns.map((c) => [c.lower, c])),
94
+ };
95
+ tables.push(entry);
96
+ const bucket = byName.get(entry.lower);
97
+ if (bucket)
98
+ bucket.push(entry);
99
+ else
100
+ byName.set(entry.lower, [entry]);
101
+ });
102
+ const index = { tables, byName, hasSensitive };
103
+ indexCache.set(schema, index);
104
+ return index;
105
+ }
106
+ const WORD_START = /[A-Za-z_\u0080-\uFFFF]/;
107
+ const WORD_CHAR = /[A-Za-z0-9_$\u0080-\uFFFF]/;
108
+ function tokenize(sql) {
109
+ const out = [];
110
+ const push = (kind, value, quoted = false) => {
111
+ out.push({ kind, value, lower: value.toLowerCase(), quoted });
112
+ };
113
+ let i = 0;
114
+ while (i < sql.length) {
115
+ const ch = sql[i];
116
+ if (/\s/.test(ch)) {
117
+ i++;
118
+ continue;
119
+ }
120
+ if (ch === "-" && sql[i + 1] === "-") {
121
+ while (i < sql.length && sql[i] !== "\n")
122
+ i++;
123
+ continue;
124
+ }
125
+ if (ch === "/" && sql[i + 1] === "*") {
126
+ i += 2;
127
+ while (i < sql.length && !(sql[i] === "*" && sql[i + 1] === "/"))
128
+ i++;
129
+ i = Math.min(i + 2, sql.length);
130
+ continue;
131
+ }
132
+ if (ch === "'") {
133
+ i++;
134
+ while (i < sql.length) {
135
+ if (sql[i] === "'" && sql[i + 1] === "'") {
136
+ i += 2;
137
+ continue;
138
+ }
139
+ if (sql[i] === "'") {
140
+ i++;
141
+ break;
142
+ }
143
+ i++;
144
+ }
145
+ push("literal", "'");
146
+ continue;
147
+ }
148
+ if (ch === "$") {
149
+ const dollarQuote = /^\$(\w*)\$/.exec(sql.slice(i));
150
+ if (dollarQuote) {
151
+ const opener = dollarQuote[0];
152
+ const close = sql.indexOf(opener, i + opener.length);
153
+ i = close === -1 ? sql.length : close + opener.length;
154
+ push("literal", "$$");
155
+ continue;
156
+ }
157
+ if (/[0-9]/.test(sql[i + 1] ?? "")) {
158
+ let j = i + 1;
159
+ while (j < sql.length && /[0-9]/.test(sql[j]))
160
+ j++;
161
+ push("placeholder", sql.slice(i, j));
162
+ i = j;
163
+ continue;
164
+ }
165
+ }
166
+ if (ch === '"' || ch === "`") {
167
+ const close = ch;
168
+ i++;
169
+ let buf = "";
170
+ while (i < sql.length) {
171
+ if (sql[i] === close && sql[i + 1] === close) {
172
+ buf += close;
173
+ i += 2;
174
+ continue;
175
+ }
176
+ if (sql[i] === close) {
177
+ i++;
178
+ break;
179
+ }
180
+ buf += sql[i];
181
+ i++;
182
+ }
183
+ push("word", buf, true);
184
+ continue;
185
+ }
186
+ if (ch === "[") {
187
+ i++;
188
+ let buf = "";
189
+ while (i < sql.length && sql[i] !== "]") {
190
+ buf += sql[i];
191
+ i++;
192
+ }
193
+ if (i < sql.length)
194
+ i++;
195
+ push("word", buf, true);
196
+ continue;
197
+ }
198
+ if (ch === ":" && sql[i + 1] === ":") {
199
+ push("punct", "::");
200
+ i += 2;
201
+ continue;
202
+ }
203
+ if ((ch === ":" || ch === "@") && WORD_START.test(sql[i + 1] ?? "")) {
204
+ let j = i + 1;
205
+ while (j < sql.length && WORD_CHAR.test(sql[j]))
206
+ j++;
207
+ push("placeholder", sql.slice(i, j));
208
+ i = j;
209
+ continue;
210
+ }
211
+ if (/[0-9]/.test(ch)) {
212
+ let j = i;
213
+ while (j < sql.length && /[0-9]/.test(sql[j]))
214
+ j++;
215
+ if (sql[j] === "." && /[0-9]/.test(sql[j + 1] ?? "")) {
216
+ j++;
217
+ while (j < sql.length && /[0-9]/.test(sql[j]))
218
+ j++;
219
+ }
220
+ push("number", sql.slice(i, j));
221
+ i = j;
222
+ continue;
223
+ }
224
+ if (WORD_START.test(ch)) {
225
+ let j = i;
226
+ while (j < sql.length && WORD_CHAR.test(sql[j]))
227
+ j++;
228
+ push("word", sql.slice(i, j));
229
+ i = j;
230
+ continue;
231
+ }
232
+ push("punct", ch);
233
+ i++;
234
+ }
235
+ return out;
236
+ }
237
+ // ---------------------------------------------------------------------------
238
+ // Keyword sets
239
+ // ---------------------------------------------------------------------------
240
+ /**
241
+ * Words that can never be a relation alias. Deliberately broad — a wrongly-taken
242
+ * "alias" would bind a qualifier to the wrong table.
243
+ */
244
+ const ALIAS_STOP = new Set([
245
+ "all", "and", "any", "as", "asc", "at", "between", "by", "case", "collate", "cross",
246
+ "desc", "distinct", "else", "end", "escape", "except", "exists", "fetch", "filter",
247
+ "first", "for", "from", "full", "group", "having", "ilike", "in", "inner", "intersect",
248
+ "into", "is", "join", "last", "lateral", "left", "like", "limit", "natural", "next",
249
+ "not", "null", "nulls", "offset", "on", "only", "or", "order", "outer", "over",
250
+ "partition", "returning", "right", "rows", "select", "set", "similar", "some",
251
+ "straight_join", "tablesample", "then", "union", "unknown", "using", "values", "when",
252
+ "where", "window", "with", "within",
253
+ ]);
254
+ /**
255
+ * Words that are never plausible column names, so they are excluded from the
256
+ * unqualified-candidate set. Deliberately *narrow* — words like `key`, `first`,
257
+ * `time`, `filter`, or `rows` are real column names in real schemas and must stay
258
+ * eligible so a sensitive column named `key` is still caught.
259
+ */
260
+ const BARE_STOP = new Set([
261
+ "and", "as", "asc", "between", "by", "case", "cross", "desc", "distinct", "else",
262
+ "escape", "except", "exists", "false", "from", "group", "having", "ilike", "in",
263
+ "inner", "intersect", "is", "join", "lateral", "like", "limit", "natural", "not",
264
+ "null", "nulls", "offset", "on", "or", "order", "outer", "over", "partition",
265
+ "recursive", "select", "then", "true", "union", "using", "when", "where", "with",
266
+ ]);
267
+ const JOIN_MODIFIER = new Set([
268
+ "inner", "left", "right", "full", "outer", "cross", "natural", "straight_join",
269
+ ]);
270
+ function scan(sql, index) {
271
+ const tokens = tokenize(sql);
272
+ const isWord = (t) => t?.kind === "word";
273
+ const isPunct = (t, v) => t?.kind === "punct" && t.value === v;
274
+ const canAlias = (t) => t?.kind === "word" && (t.quoted || !ALIAS_STOP.has(t.lower));
275
+ /** Token indices already accounted for as relation names or aliases. */
276
+ const consumed = new Set();
277
+ /** Relation names that exist but are not schema tables (CTEs, derived tables). */
278
+ const derived = new Set();
279
+ const sourcePaths = [];
280
+ const aliasDecls = [];
281
+ let opaqueSource = false;
282
+ let sawTableSource = false;
283
+ const closeParenAfter = (open) => {
284
+ let depth = 0;
285
+ for (let k = open; k < tokens.length; k++) {
286
+ if (isPunct(tokens[k], "("))
287
+ depth++;
288
+ else if (isPunct(tokens[k], ")")) {
289
+ depth--;
290
+ if (depth === 0)
291
+ return k;
292
+ }
293
+ }
294
+ return -1;
295
+ };
296
+ const openParenBefore = (close) => {
297
+ let depth = 0;
298
+ for (let k = close; k >= 0; k--) {
299
+ if (isPunct(tokens[k], ")"))
300
+ depth++;
301
+ else if (isPunct(tokens[k], "(")) {
302
+ depth--;
303
+ if (depth === 0)
304
+ return k;
305
+ }
306
+ }
307
+ return -1;
308
+ };
309
+ /** Consume an optional `AS alias` / bare `alias`; returns the index after it. */
310
+ const takeAlias = (start, register) => {
311
+ let k = start;
312
+ if (isWord(tokens[k]) && tokens[k].lower === "as" && isWord(tokens[k + 1])) {
313
+ consumed.add(k);
314
+ k++;
315
+ }
316
+ if (canAlias(tokens[k])) {
317
+ register(tokens[k].lower);
318
+ consumed.add(k);
319
+ k++;
320
+ // Optional derived-column alias list: `… AS t(a, b)`
321
+ if (isPunct(tokens[k], "(")) {
322
+ const close = closeParenAfter(k);
323
+ if (close !== -1) {
324
+ for (let x = k + 1; x < close; x++)
325
+ if (isWord(tokens[x]))
326
+ consumed.add(x);
327
+ k = close + 1;
328
+ }
329
+ }
330
+ }
331
+ return k;
332
+ };
333
+ const parseTableRef = (start) => {
334
+ let j = start;
335
+ while (isWord(tokens[j]) && (tokens[j].lower === "lateral" || tokens[j].lower === "only")) {
336
+ consumed.add(j);
337
+ j++;
338
+ }
339
+ // Derived table / parenthesized join. The body is deliberately not skipped: the
340
+ // outer walk still visits its FROM/JOINs, so nested base tables land in scope too.
341
+ if (isPunct(tokens[j], "(")) {
342
+ const close = closeParenAfter(j);
343
+ if (close === -1)
344
+ return j + 1;
345
+ sawTableSource = true;
346
+ return takeAlias(close + 1, (alias) => derived.add(alias));
347
+ }
348
+ if (!canAlias(tokens[j])) {
349
+ // e.g. the operand in `EXTRACT(YEAR FROM 1)` — not a relation name.
350
+ return j;
351
+ }
352
+ const path = [];
353
+ const pathIdx = [];
354
+ let k = j;
355
+ for (;;) {
356
+ if (!isWord(tokens[k]))
357
+ break;
358
+ path.push(tokens[k].lower);
359
+ pathIdx.push(k);
360
+ k++;
361
+ if (isPunct(tokens[k], ".")) {
362
+ k++;
363
+ continue;
364
+ }
365
+ break;
366
+ }
367
+ for (const x of pathIdx)
368
+ consumed.add(x);
369
+ if (isPunct(tokens[k], "(")) {
370
+ // Table function / table-valued expression — its columns are not schema columns.
371
+ opaqueSource = true;
372
+ const close = closeParenAfter(k);
373
+ return takeAlias(close === -1 ? k + 1 : close + 1, (alias) => derived.add(alias));
374
+ }
375
+ sawTableSource = true;
376
+ sourcePaths.push(path);
377
+ return takeAlias(k, (alias) => aliasDecls.push({ alias, path }));
378
+ };
379
+ // Pass A — resolve relation names, aliases, and CTE names.
380
+ for (let i = 0; i < tokens.length; i++) {
381
+ const t = tokens[i];
382
+ if (t.kind !== "word" || t.quoted)
383
+ continue;
384
+ // `name AS (` / `name (cols) AS (` → CTE or named subquery.
385
+ if (t.lower === "as" && isPunct(tokens[i + 1], "(")) {
386
+ let back = i - 1;
387
+ if (isPunct(tokens[back], ")"))
388
+ back = openParenBefore(back) - 1;
389
+ if (canAlias(tokens[back])) {
390
+ derived.add(tokens[back].lower);
391
+ consumed.add(back);
392
+ }
393
+ continue;
394
+ }
395
+ if (t.lower !== "from" && t.lower !== "join")
396
+ continue;
397
+ consumed.add(i);
398
+ if (t.lower === "join") {
399
+ for (let k = i - 1; k >= 0 && isWord(tokens[k]) && JOIN_MODIFIER.has(tokens[k].lower); k--) {
400
+ consumed.add(k);
401
+ }
402
+ }
403
+ let j = i + 1;
404
+ for (;;) {
405
+ const next = parseTableRef(j);
406
+ // Old-style comma-separated FROM lists; JOIN takes exactly one reference.
407
+ if (t.lower === "from" && next > j && isPunct(tokens[next], ",")) {
408
+ j = next + 1;
409
+ continue;
410
+ }
411
+ break;
412
+ }
413
+ }
414
+ // Pass B — collect the column references the statement actually makes.
415
+ const qualifiedRefs = [];
416
+ const bare = new Set();
417
+ for (let i = 0; i < tokens.length; i++) {
418
+ const t = tokens[i];
419
+ if (t.kind !== "word")
420
+ continue;
421
+ if (isPunct(tokens[i - 1], "."))
422
+ continue; // tail of a path, handled at its head
423
+ if (isPunct(tokens[i - 1], "::"))
424
+ continue; // cast target type
425
+ if (isWord(tokens[i - 1]) && tokens[i - 1].lower === "as")
426
+ continue; // output alias
427
+ if (isPunct(tokens[i + 1], ".")) {
428
+ const path = [t.lower];
429
+ let k = i + 1;
430
+ let star = false;
431
+ while (isPunct(tokens[k], ".")) {
432
+ k++;
433
+ if (isPunct(tokens[k], "*")) {
434
+ star = true;
435
+ k++;
436
+ break;
437
+ }
438
+ if (!isWord(tokens[k]))
439
+ break;
440
+ path.push(tokens[k].lower);
441
+ k++;
442
+ }
443
+ if (!star && path.length >= 2 && !consumed.has(i)) {
444
+ qualifiedRefs.push({
445
+ column: path[path.length - 1],
446
+ qualifier: path[path.length - 2],
447
+ ...(path.length >= 3 ? { qualifierSchema: path[path.length - 3] } : {}),
448
+ });
449
+ }
450
+ i = k - 1;
451
+ continue;
452
+ }
453
+ if (consumed.has(i))
454
+ continue;
455
+ if (isPunct(tokens[i + 1], "("))
456
+ continue; // function call
457
+ if (!t.quoted && BARE_STOP.has(t.lower))
458
+ continue;
459
+ bare.add(t.lower);
460
+ }
461
+ // Bind relation names and aliases to schema tables.
462
+ const resolve = (path) => {
463
+ const name = path[path.length - 1];
464
+ const qualifier = path.length >= 2 ? path[path.length - 2] : undefined;
465
+ const candidates = index.byName.get(name) ?? [];
466
+ if (!qualifier)
467
+ return candidates;
468
+ const exact = candidates.filter((e) => e.schemaLower === qualifier);
469
+ if (exact.length > 0)
470
+ return exact;
471
+ // v1 schemas carry no namespace — a qualified reference still matches by name.
472
+ return candidates.filter((e) => e.schemaLower === undefined);
473
+ };
474
+ const inScope = new Set();
475
+ const qualifiers = new Map();
476
+ const bindQualifier = (name, entries) => {
477
+ const bucket = qualifiers.get(name);
478
+ if (bucket)
479
+ bucket.push(...entries.filter((e) => !bucket.includes(e)));
480
+ else
481
+ qualifiers.set(name, [...entries]);
482
+ };
483
+ for (const path of sourcePaths) {
484
+ const entries = resolve(path);
485
+ for (const e of entries)
486
+ inScope.add(e);
487
+ bindQualifier(path[path.length - 1], entries);
488
+ }
489
+ for (const decl of aliasDecls)
490
+ bindQualifier(decl.alias, resolve(decl.path));
491
+ const found = new Map();
492
+ const record = (table, column, columnOrder, matchKind) => {
493
+ const key = `${table.schemaLower ?? ""}.${table.lower}.${column.toLowerCase()}`;
494
+ const existing = found.get(key);
495
+ if (existing) {
496
+ // A qualified hit is the stronger explanation of the same reference.
497
+ if (matchKind === "qualified")
498
+ existing.matchKind = "qualified";
499
+ return;
500
+ }
501
+ found.set(key, {
502
+ table: table.name,
503
+ ...(table.schema ? { schema: table.schema } : {}),
504
+ column,
505
+ matchKind,
506
+ tableOrder: table.order,
507
+ columnOrder,
508
+ });
509
+ };
510
+ // Sensitive tables reached as a FROM/JOIN target — catches `SELECT * FROM secrets`.
511
+ for (const table of inScope) {
512
+ if (table.sensitive)
513
+ record(table, "*", -1, "table");
514
+ }
515
+ const issues = new Set();
516
+ if (opaqueSource)
517
+ issues.add("OPAQUE_TABLE_SOURCE");
518
+ // Qualified references.
519
+ const unknownQualifierColumns = new Set();
520
+ for (const ref of qualifiedRefs) {
521
+ const bound = qualifiers.get(ref.qualifier);
522
+ if (!bound) {
523
+ if (derived.has(ref.qualifier))
524
+ continue; // CTE / derived relation — a known namespace
525
+ issues.add("UNKNOWN_QUALIFIER");
526
+ unknownQualifierColumns.add(ref.column);
527
+ continue;
528
+ }
529
+ const scoped = ref.qualifierSchema
530
+ ? bound.filter((e) => e.schemaLower === undefined || e.schemaLower === ref.qualifierSchema)
531
+ : bound;
532
+ for (const table of scoped) {
533
+ const column = table.byColumn.get(ref.column);
534
+ if (column?.sensitive)
535
+ record(table, column.name, column.order, "qualified");
536
+ }
537
+ }
538
+ // Unqualified references — counted only when the owning table is in scope.
539
+ const noTableSource = !sawTableSource && bare.size > 0;
540
+ if (noTableSource)
541
+ issues.add("NO_TABLE_SOURCE");
542
+ const bareScope = noTableSource ? index.tables : [...inScope];
543
+ for (const table of bareScope) {
544
+ for (const column of table.columns) {
545
+ if (column.sensitive && bare.has(column.lower)) {
546
+ record(table, column.name, column.order, "unqualified");
547
+ }
548
+ }
549
+ }
550
+ // A qualifier we could not bind: its column could belong to anything, so match it
551
+ // against every sensitive column rather than drop it.
552
+ if (unknownQualifierColumns.size > 0) {
553
+ for (const table of index.tables) {
554
+ for (const column of table.columns) {
555
+ if (column.sensitive && unknownQualifierColumns.has(column.lower)) {
556
+ record(table, column.name, column.order, "unqualified");
557
+ }
558
+ }
559
+ }
560
+ }
561
+ const references = [...found.values()]
562
+ .sort((a, b) => a.tableOrder - b.tableOrder || a.columnOrder - b.columnOrder)
563
+ .map(({ tableOrder: _tableOrder, columnOrder: _columnOrder, ...ref }) => ref);
564
+ const unresolvedScope = issues.size > 0
565
+ ? buildScopeReport([...issues], noTableSource || unknownQualifierColumns.size > 0)
566
+ : undefined;
567
+ return {
568
+ passed: references.length === 0 && unresolvedScope === undefined,
569
+ references,
570
+ ...(unresolvedScope ? { unresolvedScope } : {}),
571
+ };
572
+ }
573
+ const SCOPE_ISSUE_TEXT = {
574
+ NO_TABLE_SOURCE: "no FROM/JOIN target could be resolved, so unqualified column names cannot be bound to a table",
575
+ UNKNOWN_QUALIFIER: "a qualified reference used a qualifier that is not a known table, alias, or CTE",
576
+ OPAQUE_TABLE_SOURCE: "a table source is not a relation name (table function or table-valued expression)",
577
+ };
578
+ function buildScopeReport(issues, widened) {
579
+ const detail = issues.map((i) => SCOPE_ISSUE_TEXT[i]).join("; ");
580
+ return {
581
+ issues,
582
+ widened,
583
+ message: `Could not resolve the statement's table scope: ${detail}.` +
584
+ (widened
585
+ ? " Unqualified names were matched against every sensitive column in the schema, so some references may be over-reported."
586
+ : ""),
587
+ };
588
+ }
589
+ //# sourceMappingURL=sensitive-guardrail.js.map