@dudousxd/nestjs-agent-data 0.3.3 → 0.3.4
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.
- package/dist/index.cjs +383 -0
- package/package.json +8 -3
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
var __copyProps = (to, from, except, desc) => {
|
|
12
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
13
|
+
for (let key of __getOwnPropNames(from))
|
|
14
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
15
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
16
|
+
}
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
20
|
+
|
|
21
|
+
// src/index.ts
|
|
22
|
+
var index_exports = {};
|
|
23
|
+
__export(index_exports, {
|
|
24
|
+
GroupTableAccessPolicy: () => GroupTableAccessPolicy,
|
|
25
|
+
SqlValidationError: () => SqlValidationError,
|
|
26
|
+
SqlValidator: () => SqlValidator,
|
|
27
|
+
TenantScopeRewriter: () => TenantScopeRewriter,
|
|
28
|
+
createExecuteSqlTool: () => createExecuteSqlTool,
|
|
29
|
+
injectLimit: () => injectLimit
|
|
30
|
+
});
|
|
31
|
+
module.exports = __toCommonJS(index_exports);
|
|
32
|
+
|
|
33
|
+
// src/sql-validator.ts
|
|
34
|
+
var import_node_sql_parser = require("node-sql-parser");
|
|
35
|
+
var SqlValidationError = class extends Error {
|
|
36
|
+
static {
|
|
37
|
+
__name(this, "SqlValidationError");
|
|
38
|
+
}
|
|
39
|
+
constructor(message) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = "SqlValidationError";
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
var FORBIDDEN_AST_TYPES = /* @__PURE__ */ new Set([
|
|
45
|
+
"insert",
|
|
46
|
+
"update",
|
|
47
|
+
"delete",
|
|
48
|
+
"replace",
|
|
49
|
+
"create",
|
|
50
|
+
"drop",
|
|
51
|
+
"alter",
|
|
52
|
+
"truncate",
|
|
53
|
+
"rename",
|
|
54
|
+
"load_data",
|
|
55
|
+
"lock",
|
|
56
|
+
"unlock",
|
|
57
|
+
"set",
|
|
58
|
+
"call",
|
|
59
|
+
"handler",
|
|
60
|
+
"use",
|
|
61
|
+
"grant",
|
|
62
|
+
"revoke"
|
|
63
|
+
]);
|
|
64
|
+
var SqlValidator = class {
|
|
65
|
+
static {
|
|
66
|
+
__name(this, "SqlValidator");
|
|
67
|
+
}
|
|
68
|
+
parser = new import_node_sql_parser.Parser();
|
|
69
|
+
/**
|
|
70
|
+
* Throws `SqlValidationError` unless `sql` is exactly one SELECT statement.
|
|
71
|
+
* On success returns the distinct base table names referenced.
|
|
72
|
+
*/
|
|
73
|
+
validate(sql) {
|
|
74
|
+
let parsed;
|
|
75
|
+
try {
|
|
76
|
+
parsed = this.parser.astify(sql, {
|
|
77
|
+
database: "MySQL"
|
|
78
|
+
});
|
|
79
|
+
} catch (err) {
|
|
80
|
+
throw new SqlValidationError(`Parse error: ${err.message}`);
|
|
81
|
+
}
|
|
82
|
+
const statements = Array.isArray(parsed) ? parsed : [
|
|
83
|
+
parsed
|
|
84
|
+
];
|
|
85
|
+
if (statements.length !== 1) {
|
|
86
|
+
throw new SqlValidationError("Only a single statement is allowed");
|
|
87
|
+
}
|
|
88
|
+
const statement = statements[0];
|
|
89
|
+
const type = (statement?.type ?? "").toLowerCase();
|
|
90
|
+
if (type !== "select") {
|
|
91
|
+
if ([
|
|
92
|
+
"create",
|
|
93
|
+
"drop",
|
|
94
|
+
"alter",
|
|
95
|
+
"truncate",
|
|
96
|
+
"rename"
|
|
97
|
+
].includes(type)) {
|
|
98
|
+
throw new SqlValidationError("DDL is not allowed; only SELECT statements are accepted");
|
|
99
|
+
}
|
|
100
|
+
if (FORBIDDEN_AST_TYPES.has(type)) {
|
|
101
|
+
throw new SqlValidationError(`${type.toUpperCase()} is not allowed; only SELECT statements are accepted`);
|
|
102
|
+
}
|
|
103
|
+
throw new SqlValidationError(`Statement type "${type || "unknown"}" is not allowed; only SELECT statements are accepted`);
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
tables: this.extractReferencedTables(sql)
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Distinct base table names a statement touches — walking CTEs, subqueries,
|
|
111
|
+
* and joins. Backed by `node-sql-parser`'s `tableList`, which emits
|
|
112
|
+
* `mode::db::table` strings; the table name is the last segment.
|
|
113
|
+
*/
|
|
114
|
+
extractReferencedTables(sql) {
|
|
115
|
+
const raw = this.parser.tableList(sql, {
|
|
116
|
+
database: "MySQL"
|
|
117
|
+
});
|
|
118
|
+
const tables = /* @__PURE__ */ new Set();
|
|
119
|
+
for (const entry of raw) {
|
|
120
|
+
const parts = entry.split("::");
|
|
121
|
+
const table = parts[parts.length - 1];
|
|
122
|
+
if (table && table !== "null") tables.add(table);
|
|
123
|
+
}
|
|
124
|
+
return Array.from(tables);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// src/table-access.ts
|
|
129
|
+
var GroupTableAccessPolicy = class {
|
|
130
|
+
static {
|
|
131
|
+
__name(this, "GroupTableAccessPolicy");
|
|
132
|
+
}
|
|
133
|
+
roleGroups;
|
|
134
|
+
tablesByGroup;
|
|
135
|
+
constructor(config) {
|
|
136
|
+
this.roleGroups = config.roleGroups;
|
|
137
|
+
this.tablesByGroup = config.tablesByGroup;
|
|
138
|
+
}
|
|
139
|
+
canAccess(roles, table) {
|
|
140
|
+
const group = this.resolveGroup(table);
|
|
141
|
+
if (group === void 0) return false;
|
|
142
|
+
return roles.some((role) => this.roleGroups[role]?.includes(group) ?? false);
|
|
143
|
+
}
|
|
144
|
+
/** Resolve a table to its group, or `undefined` if unclassified (fail-closed). */
|
|
145
|
+
resolveGroup(table) {
|
|
146
|
+
for (const [group, patterns] of Object.entries(this.tablesByGroup)) {
|
|
147
|
+
for (const pattern of patterns) {
|
|
148
|
+
if (matchesPattern(table, pattern)) return group;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return void 0;
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
function matchesPattern(table, pattern) {
|
|
155
|
+
if (pattern.endsWith("*")) {
|
|
156
|
+
return table.startsWith(pattern.slice(0, -1));
|
|
157
|
+
}
|
|
158
|
+
return table === pattern;
|
|
159
|
+
}
|
|
160
|
+
__name(matchesPattern, "matchesPattern");
|
|
161
|
+
|
|
162
|
+
// src/tenant-scope.ts
|
|
163
|
+
var import_node_sql_parser2 = require("node-sql-parser");
|
|
164
|
+
var STRING_LITERAL_TYPES = /* @__PURE__ */ new Set([
|
|
165
|
+
"string",
|
|
166
|
+
"single_quote_string",
|
|
167
|
+
"double_quote_string"
|
|
168
|
+
]);
|
|
169
|
+
var TenantScopeRewriter = class {
|
|
170
|
+
static {
|
|
171
|
+
__name(this, "TenantScopeRewriter");
|
|
172
|
+
}
|
|
173
|
+
parser = new import_node_sql_parser2.Parser();
|
|
174
|
+
tenantColumn;
|
|
175
|
+
scopedTables;
|
|
176
|
+
constructor(config) {
|
|
177
|
+
this.tenantColumn = config.tenantColumn;
|
|
178
|
+
this.scopedTables = new Set(config.scopedTables);
|
|
179
|
+
}
|
|
180
|
+
/** Rewrite `sql` to constrain scoped tables to `tenantRef`. Undefined → pass through. */
|
|
181
|
+
rewrite(sql, tenantRef) {
|
|
182
|
+
if (tenantRef === void 0) return sql;
|
|
183
|
+
const parsed = this.parser.astify(sql, {
|
|
184
|
+
database: "MySQL"
|
|
185
|
+
});
|
|
186
|
+
const ast = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
187
|
+
if (ast.type !== "select") {
|
|
188
|
+
throw new Error("tenant scope: only SELECT is supported");
|
|
189
|
+
}
|
|
190
|
+
if (ast.with) {
|
|
191
|
+
throw new Error("tenant scope: WITH (CTE) is not supported in scoped mode \u2014 rewrite using JOINs/subqueries in FROM");
|
|
192
|
+
}
|
|
193
|
+
if (ast._next) {
|
|
194
|
+
throw new Error("tenant scope: UNION/INTERSECT/EXCEPT is not supported in scoped mode \u2014 run each branch as a separate query");
|
|
195
|
+
}
|
|
196
|
+
const fromEntries = ast.from ?? [];
|
|
197
|
+
for (const entry of fromEntries) {
|
|
198
|
+
if (!entry.table && entry.expr?.ast) {
|
|
199
|
+
throw new Error("tenant scope: subqueries in FROM are not supported in scoped mode");
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const scopedFrom = fromEntries.filter((entry) => typeof entry.table === "string" && this.scopedTables.has(entry.table));
|
|
203
|
+
if (scopedFrom.length === 0) return sql;
|
|
204
|
+
const existing = this.collectTenantPredicates(ast.where);
|
|
205
|
+
for (const predicate of existing) {
|
|
206
|
+
if (predicate.value !== tenantRef) {
|
|
207
|
+
throw new Error("tenant scope: tenant mismatch \u2014 query targets a tenant other than the current session");
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const coveredAliases = new Set(existing.map((predicate) => predicate.tableAlias));
|
|
211
|
+
for (const entry of scopedFrom) {
|
|
212
|
+
const alias = entry.as ?? entry.table;
|
|
213
|
+
const isAmbiguous = scopedFrom.length > 1;
|
|
214
|
+
const covered = coveredAliases.has(alias) || !isAmbiguous && coveredAliases.has(null);
|
|
215
|
+
if (covered) continue;
|
|
216
|
+
ast.where = this.andCondition(ast.where, this.buildTenantEquality(isAmbiguous ? alias : null, tenantRef));
|
|
217
|
+
}
|
|
218
|
+
return this.parser.sqlify(ast, {
|
|
219
|
+
database: "MySQL"
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
collectTenantPredicates(where) {
|
|
223
|
+
if (!isBinaryExpr(where)) return [];
|
|
224
|
+
if (where.operator === "AND" || where.operator === "OR") {
|
|
225
|
+
return [
|
|
226
|
+
...this.collectTenantPredicates(where.left),
|
|
227
|
+
...this.collectTenantPredicates(where.right)
|
|
228
|
+
];
|
|
229
|
+
}
|
|
230
|
+
if (where.operator !== "=") return [];
|
|
231
|
+
const lhs = where.left;
|
|
232
|
+
const rhs = where.right;
|
|
233
|
+
if (!isColumnRef(lhs) || lhs.column !== this.tenantColumn) return [];
|
|
234
|
+
if (!isStringLiteral(rhs)) return [];
|
|
235
|
+
return [
|
|
236
|
+
{
|
|
237
|
+
tableAlias: lhs.table ?? null,
|
|
238
|
+
value: rhs.value
|
|
239
|
+
}
|
|
240
|
+
];
|
|
241
|
+
}
|
|
242
|
+
buildTenantEquality(tableAlias, tenantRef) {
|
|
243
|
+
return {
|
|
244
|
+
type: "binary_expr",
|
|
245
|
+
operator: "=",
|
|
246
|
+
left: {
|
|
247
|
+
type: "column_ref",
|
|
248
|
+
table: tableAlias,
|
|
249
|
+
column: this.tenantColumn
|
|
250
|
+
},
|
|
251
|
+
right: {
|
|
252
|
+
type: "single_quote_string",
|
|
253
|
+
value: tenantRef
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
andCondition(existing, added) {
|
|
258
|
+
if (existing == null) return added;
|
|
259
|
+
return {
|
|
260
|
+
type: "binary_expr",
|
|
261
|
+
operator: "AND",
|
|
262
|
+
left: existing,
|
|
263
|
+
right: added
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
function isBinaryExpr(value) {
|
|
268
|
+
return typeof value === "object" && value !== null && value.type === "binary_expr";
|
|
269
|
+
}
|
|
270
|
+
__name(isBinaryExpr, "isBinaryExpr");
|
|
271
|
+
function isColumnRef(value) {
|
|
272
|
+
return typeof value === "object" && value !== null && value.type === "column_ref";
|
|
273
|
+
}
|
|
274
|
+
__name(isColumnRef, "isColumnRef");
|
|
275
|
+
function isStringLiteral(value) {
|
|
276
|
+
return typeof value === "object" && value !== null && typeof value.type === "string" && STRING_LITERAL_TYPES.has(value.type) && typeof value.value === "string";
|
|
277
|
+
}
|
|
278
|
+
__name(isStringLiteral, "isStringLiteral");
|
|
279
|
+
|
|
280
|
+
// src/limit.ts
|
|
281
|
+
var import_node_sql_parser3 = require("node-sql-parser");
|
|
282
|
+
var parser = new import_node_sql_parser3.Parser();
|
|
283
|
+
function injectLimit(sql, max) {
|
|
284
|
+
const trimmed = sql.trim().replace(/;\s*$/, "");
|
|
285
|
+
if (hasLimit(trimmed)) return trimmed;
|
|
286
|
+
return `SELECT * FROM (${trimmed}) AS subq LIMIT ${max}`;
|
|
287
|
+
}
|
|
288
|
+
__name(injectLimit, "injectLimit");
|
|
289
|
+
function hasLimit(sql) {
|
|
290
|
+
try {
|
|
291
|
+
const parsed = parser.astify(sql, {
|
|
292
|
+
database: "MySQL"
|
|
293
|
+
});
|
|
294
|
+
const statement = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
295
|
+
const limit = statement?.limit;
|
|
296
|
+
return Boolean(limit && Array.isArray(limit.value) && limit.value.length > 0);
|
|
297
|
+
} catch {
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
__name(hasLimit, "hasLimit");
|
|
302
|
+
|
|
303
|
+
// src/execute-sql.tool.ts
|
|
304
|
+
var import_zod = require("zod");
|
|
305
|
+
var DEFAULT_MAX_ROWS = 100;
|
|
306
|
+
var MAX_RESULT_BYTES = 256 * 1024;
|
|
307
|
+
var inputSchema = import_zod.z.object({
|
|
308
|
+
sql: import_zod.z.string().min(1).describe("A single read-only MySQL SELECT statement")
|
|
309
|
+
});
|
|
310
|
+
var DESCRIPTION = "Execute a single read-only MySQL SELECT statement. Use this to answer questions about real data. Only SELECT is allowed (no INSERT/UPDATE/DELETE/DDL). Access is restricted to the tables your role is permitted to read, results are capped, and tenant-scoped tables are automatically constrained to your current tenant.";
|
|
311
|
+
function createExecuteSqlTool(deps) {
|
|
312
|
+
const validator = deps.validator ?? new SqlValidator();
|
|
313
|
+
const maxRows = deps.maxRows ?? DEFAULT_MAX_ROWS;
|
|
314
|
+
const spec = {
|
|
315
|
+
name: "executeSql",
|
|
316
|
+
kind: "read",
|
|
317
|
+
description: DESCRIPTION,
|
|
318
|
+
inputSchema
|
|
319
|
+
};
|
|
320
|
+
const handler = {
|
|
321
|
+
async execute(input, ctx) {
|
|
322
|
+
const { tables } = validator.validate(input.sql);
|
|
323
|
+
const roles = ctx.actor.roles ?? [];
|
|
324
|
+
const forbidden = tables.filter((table) => !deps.tableAccess.canAccess(roles, table));
|
|
325
|
+
if (forbidden.length > 0) {
|
|
326
|
+
const formatted = forbidden.map((table) => `\`${table}\``).join(", ");
|
|
327
|
+
const rolesLabel = roles.length > 0 ? roles.join(", ") : "none";
|
|
328
|
+
throw new Error(`Your roles (${rolesLabel}) are not allowed to query ${formatted}.`);
|
|
329
|
+
}
|
|
330
|
+
let sql = input.sql;
|
|
331
|
+
if (deps.tenantScope) {
|
|
332
|
+
sql = deps.tenantScope.rewrite(sql, ctx.actor.tenantRef);
|
|
333
|
+
}
|
|
334
|
+
sql = injectLimit(sql, maxRows);
|
|
335
|
+
const rows = await deps.runner.run(sql);
|
|
336
|
+
return buildResult(rows, sql);
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
return {
|
|
340
|
+
spec,
|
|
341
|
+
handler
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
__name(createExecuteSqlTool, "createExecuteSqlTool");
|
|
345
|
+
function buildResult(rows, sql) {
|
|
346
|
+
const rowCount = rows.length;
|
|
347
|
+
if (byteLength(rows) <= MAX_RESULT_BYTES) {
|
|
348
|
+
return {
|
|
349
|
+
rows,
|
|
350
|
+
rowCount,
|
|
351
|
+
sql
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
const kept = [];
|
|
355
|
+
let size = 2;
|
|
356
|
+
for (const row of rows) {
|
|
357
|
+
const rowSize = byteLength(row) + 1;
|
|
358
|
+
if (size + rowSize > MAX_RESULT_BYTES) break;
|
|
359
|
+
kept.push(row);
|
|
360
|
+
size += rowSize;
|
|
361
|
+
}
|
|
362
|
+
return {
|
|
363
|
+
rows: kept,
|
|
364
|
+
rowCount,
|
|
365
|
+
sql,
|
|
366
|
+
truncated: true
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
__name(buildResult, "buildResult");
|
|
370
|
+
function byteLength(value) {
|
|
371
|
+
return Buffer.byteLength(JSON.stringify(value) ?? "", "utf8");
|
|
372
|
+
}
|
|
373
|
+
__name(byteLength, "byteLength");
|
|
374
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
375
|
+
0 && (module.exports = {
|
|
376
|
+
GroupTableAccessPolicy,
|
|
377
|
+
SqlValidationError,
|
|
378
|
+
SqlValidator,
|
|
379
|
+
TenantScopeRewriter,
|
|
380
|
+
createExecuteSqlTool,
|
|
381
|
+
injectLimit
|
|
382
|
+
});
|
|
383
|
+
//# sourceMappingURL=index.cjs.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dudousxd/nestjs-agent-data",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "git+https://github.com/DavideCarvalho/nestjs-agent.git",
|
|
7
|
+
"directory": "packages/data"
|
|
8
|
+
},
|
|
4
9
|
"description": "nestjs-agent data — governed read-only SQL satellite (single-SELECT validation, fail-closed table access, tenant scoping)",
|
|
5
10
|
"license": "MIT",
|
|
6
11
|
"author": "Davide Carvalho",
|
|
@@ -26,12 +31,12 @@
|
|
|
26
31
|
"dependencies": {
|
|
27
32
|
"node-sql-parser": "5.4.0",
|
|
28
33
|
"zod": "3.25.76",
|
|
29
|
-
"@dudousxd/nestjs-agent-core": "0.
|
|
34
|
+
"@dudousxd/nestjs-agent-core": "0.4.0"
|
|
30
35
|
},
|
|
31
36
|
"devDependencies": {
|
|
32
37
|
"tsup": "8.3.5",
|
|
33
38
|
"typescript": "5.9.3",
|
|
34
|
-
"@dudousxd/nestjs-agent-core": "0.
|
|
39
|
+
"@dudousxd/nestjs-agent-core": "0.4.0"
|
|
35
40
|
},
|
|
36
41
|
"scripts": {
|
|
37
42
|
"build": "tsup",
|