@dudousxd/nestjs-agent-data 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +40 -0
- package/dist/index.cjs +383 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +156 -0
- package/dist/index.d.ts +156 -0
- package/dist/index.js +353 -0
- package/dist/index.js.map +1 -0
- package/package.json +40 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Davide Carvalho
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# `@dudousxd/nestjs-agent-data`
|
|
2
|
+
|
|
3
|
+
> πͺΊ Part of the [Aviary](https://davidecarvalho.github.io/aviary) Β· a governed SQL tool for [`@dudousxd/nestjs-agent`](https://www.npmjs.com/package/@dudousxd/nestjs-agent).
|
|
4
|
+
|
|
5
|
+
Give the model **read-only SQL access without handing it the database**. Every query is:
|
|
6
|
+
|
|
7
|
+
1. **AST-validated** β single `SELECT` only (rejects writes/DDL/multi-statement), via `node-sql-parser`.
|
|
8
|
+
2. **Authorized** β checked against a **fail-closed** table-access policy (a table in no allowed group is denied).
|
|
9
|
+
3. **Tenant-scoped** (optional) β rewritten to add `tenantColumn = tenantRef` for scoped tables; rejects CTE/UNION/subquery-in-FROM.
|
|
10
|
+
4. **Capped** β a `LIMIT` is injected before your runner ever touches the DB.
|
|
11
|
+
|
|
12
|
+
The package never opens a connection β you inject a `QueryRunner` over your read-only pool.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pnpm add @dudousxd/nestjs-agent-data
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Use
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import {
|
|
24
|
+
createExecuteSqlTool,
|
|
25
|
+
GroupTableAccessPolicy,
|
|
26
|
+
TenantScopeRewriter,
|
|
27
|
+
} from '@dudousxd/nestjs-agent-data';
|
|
28
|
+
|
|
29
|
+
const { spec, handler } = createExecuteSqlTool({
|
|
30
|
+
runner: { run: (sql) => readOnlyPool.query(sql) },
|
|
31
|
+
tableAccess: new GroupTableAccessPolicy({ roleGroups, tablesByGroup }),
|
|
32
|
+
tenantScope: new TenantScopeRewriter({ tenantColumn: 'tenant_id', scopedTables: ['orders'] }),
|
|
33
|
+
maxRows: 100,
|
|
34
|
+
});
|
|
35
|
+
// register `spec` + `handler` with the agent's ToolRegistry (or wrap in an @AiTool provider)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## License
|
|
39
|
+
|
|
40
|
+
MIT Β© Davide Carvalho
|
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
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/sql-validator.ts","../src/table-access.ts","../src/tenant-scope.ts","../src/limit.ts","../src/execute-sql.tool.ts"],"sourcesContent":["export { SqlValidator, SqlValidationError, type SqlValidationResult } from './sql-validator.js';\nexport {\n type TableAccessPolicy,\n type GroupTableAccessConfig,\n GroupTableAccessPolicy,\n} from './table-access.js';\nexport {\n TenantScopeRewriter,\n type TenantScopeConfig,\n} from './tenant-scope.js';\nexport { injectLimit } from './limit.js';\nexport {\n createExecuteSqlTool,\n type QueryRunner,\n type ExecuteSqlDeps,\n type ExecuteSqlResult,\n} from './execute-sql.tool.js';\n","import { Parser } from 'node-sql-parser';\n\n/**\n * Thrown when a statement is not a single, read-only SELECT β i.e. it is an\n * INSERT/UPDATE/DELETE, DDL, a CALL, a multi-statement string, or it fails to\n * parse. The handler surfaces `.message` to the model so it can re-plan.\n */\nexport class SqlValidationError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'SqlValidationError';\n }\n}\n\n/** Statement types that are categorically rejected (anything that writes or runs code). */\nconst FORBIDDEN_AST_TYPES = new Set([\n 'insert',\n 'update',\n 'delete',\n 'replace',\n 'create',\n 'drop',\n 'alter',\n 'truncate',\n 'rename',\n 'load_data',\n 'lock',\n 'unlock',\n 'set',\n 'call',\n 'handler',\n 'use',\n 'grant',\n 'revoke',\n]);\n\nexport interface SqlValidationResult {\n /** Every base table the statement references (CTEs, joins, subqueries walked). */\n tables: string[];\n}\n\n/**\n * Parses SQL (MySQL dialect) and asserts it is a single SELECT, returning the\n * set of tables it touches. Domain-agnostic: it knows nothing about which\n * tables a caller may read β that is the `TableAccessPolicy`'s job.\n */\nexport class SqlValidator {\n private readonly parser = new Parser();\n\n /**\n * Throws `SqlValidationError` unless `sql` is exactly one SELECT statement.\n * On success returns the distinct base table names referenced.\n */\n validate(sql: string): SqlValidationResult {\n let parsed: unknown;\n try {\n parsed = this.parser.astify(sql, { database: 'MySQL' });\n } catch (err) {\n throw new SqlValidationError(`Parse error: ${(err as Error).message}`);\n }\n\n const statements = Array.isArray(parsed) ? parsed : [parsed];\n if (statements.length !== 1) {\n throw new SqlValidationError('Only a single statement is allowed');\n }\n\n const statement = statements[0] as { type?: string } | undefined;\n const type = (statement?.type ?? '').toLowerCase();\n\n if (type !== 'select') {\n if (['create', 'drop', 'alter', 'truncate', 'rename'].includes(type)) {\n throw new SqlValidationError('DDL is not allowed; only SELECT statements are accepted');\n }\n if (FORBIDDEN_AST_TYPES.has(type)) {\n throw new SqlValidationError(\n `${type.toUpperCase()} is not allowed; only SELECT statements are accepted`,\n );\n }\n throw new SqlValidationError(\n `Statement type \"${type || 'unknown'}\" is not allowed; only SELECT statements are accepted`,\n );\n }\n\n return { tables: this.extractReferencedTables(sql) };\n }\n\n /**\n * Distinct base table names a statement touches β walking CTEs, subqueries,\n * and joins. Backed by `node-sql-parser`'s `tableList`, which emits\n * `mode::db::table` strings; the table name is the last segment.\n */\n private extractReferencedTables(sql: string): string[] {\n const raw = this.parser.tableList(sql, { database: 'MySQL' });\n const tables = new Set<string>();\n for (const entry of raw) {\n const parts = entry.split('::');\n const table = parts[parts.length - 1];\n if (table && table !== 'null') tables.add(table);\n }\n return Array.from(tables);\n }\n}\n","/**\n * Decides whether a role may read a given table at all. This is the coarse,\n * table-level gate; per-row scoping (see `TenantScopeRewriter`) is a separate\n * layer applied at query time.\n *\n * Domain-agnostic: the host app supplies the roleβgroup and groupβtable maps.\n */\nexport interface TableAccessPolicy {\n /** True iff any of the caller's `roles` is permitted to read `table`. Fail-closed by contract. */\n canAccess(roles: readonly string[], table: string): boolean;\n}\n\n/** Inputs for {@link GroupTableAccessPolicy}: roles map to groups, groups to tables. */\nexport interface GroupTableAccessConfig {\n /** Role name β the table groups that role may read. */\n roleGroups: Record<string, string[]>;\n /**\n * Group name β the tables in that group. Entries are exact table names or\n * `prefix_*` patterns (e.g. `pribuy_*`).\n */\n tablesByGroup: Record<string, string[]>;\n}\n\n/**\n * Two-layer, data-driven table allowlist:\n *\n * 1. Every table is classified into a group (`tablesByGroup`).\n * 2. Every role lists the groups it can read (`roleGroups`).\n *\n * `canAccess(roles, table)` is then \"is the table's group in ANY of the roles'\n * group lists?\". **Fail-closed:** an unclassified table, unknown roles, or an\n * empty role set is denied β a forgotten table never accidentally leaks.\n */\nexport class GroupTableAccessPolicy implements TableAccessPolicy {\n private readonly roleGroups: Record<string, string[]>;\n private readonly tablesByGroup: Record<string, string[]>;\n\n constructor(config: GroupTableAccessConfig) {\n this.roleGroups = config.roleGroups;\n this.tablesByGroup = config.tablesByGroup;\n }\n\n canAccess(roles: readonly string[], table: string): boolean {\n const group = this.resolveGroup(table);\n if (group === undefined) return false;\n\n return roles.some((role) => this.roleGroups[role]?.includes(group) ?? false);\n }\n\n /** Resolve a table to its group, or `undefined` if unclassified (fail-closed). */\n private resolveGroup(table: string): string | undefined {\n for (const [group, patterns] of Object.entries(this.tablesByGroup)) {\n for (const pattern of patterns) {\n if (matchesPattern(table, pattern)) return group;\n }\n }\n return undefined;\n }\n}\n\nfunction matchesPattern(table: string, pattern: string): boolean {\n if (pattern.endsWith('*')) {\n return table.startsWith(pattern.slice(0, -1));\n }\n return table === pattern;\n}\n","import type { AST } from 'node-sql-parser';\nimport { Parser } from 'node-sql-parser';\n\n/** Configuration for {@link TenantScopeRewriter}. */\nexport interface TenantScopeConfig {\n /** The column that carries the tenant key on every scoped table (e.g. `base_id`, `org_id`). */\n tenantColumn: string;\n /** Tables that must be constrained to the caller's tenant when referenced. */\n scopedTables: string[];\n}\n\ninterface FromEntry {\n table?: string;\n as?: string | null;\n join?: string;\n expr?: { ast?: unknown };\n}\n\ninterface SelectAst {\n type: string;\n with?: unknown;\n from?: FromEntry[];\n where?: unknown;\n _next?: unknown;\n}\n\ninterface BinaryExpr {\n type: 'binary_expr';\n operator: string;\n left: unknown;\n right: unknown;\n}\n\ninterface ColumnRef {\n type: 'column_ref';\n table: string | null;\n column: string;\n}\n\ninterface ExtractedPredicate {\n tableAlias: string | null;\n value: string;\n}\n\nconst STRING_LITERAL_TYPES = new Set(['string', 'single_quote_string', 'double_quote_string']);\n\n/**\n * Rewrites a SELECT so every reference to a scoped table is constrained to a\n * single tenant: `<tenantColumn> = '<tenantRef>'` is AND-ed into the WHERE for\n * each scoped table in the FROM. An existing predicate for a different tenant\n * is rejected (no cross-tenant reads). `tenantRef === undefined` is the\n * privileged path and passes the SQL through unchanged.\n *\n * Scoped mode rejects CTEs, UNION/INTERSECT/EXCEPT, and subqueries in FROM:\n * those make it impossible to statically guarantee every tenant-bearing source\n * is constrained, so we fail closed and ask the caller to rephrase.\n */\nexport class TenantScopeRewriter {\n private readonly parser = new Parser();\n private readonly tenantColumn: string;\n private readonly scopedTables: Set<string>;\n\n constructor(config: TenantScopeConfig) {\n this.tenantColumn = config.tenantColumn;\n this.scopedTables = new Set(config.scopedTables);\n }\n\n /** Rewrite `sql` to constrain scoped tables to `tenantRef`. Undefined β pass through. */\n rewrite(sql: string, tenantRef: string | undefined): string {\n if (tenantRef === undefined) return sql;\n\n const parsed = this.parser.astify(sql, { database: 'MySQL' });\n const ast = (Array.isArray(parsed) ? parsed[0] : parsed) as SelectAst;\n\n if (ast.type !== 'select') {\n throw new Error('tenant scope: only SELECT is supported');\n }\n if (ast.with) {\n throw new Error(\n 'tenant scope: WITH (CTE) is not supported in scoped mode β rewrite using JOINs/subqueries in FROM',\n );\n }\n if (ast._next) {\n throw new Error(\n 'tenant scope: UNION/INTERSECT/EXCEPT is not supported in scoped mode β run each branch as a separate query',\n );\n }\n\n const fromEntries = ast.from ?? [];\n for (const entry of fromEntries) {\n if (!entry.table && entry.expr?.ast) {\n throw new Error('tenant scope: subqueries in FROM are not supported in scoped mode');\n }\n }\n\n const scopedFrom = fromEntries.filter(\n (entry): entry is FromEntry & { table: string } =>\n typeof entry.table === 'string' && this.scopedTables.has(entry.table),\n );\n if (scopedFrom.length === 0) return sql;\n\n const existing = this.collectTenantPredicates(ast.where);\n for (const predicate of existing) {\n if (predicate.value !== tenantRef) {\n throw new Error(\n 'tenant scope: tenant mismatch β query targets a tenant other than the current session',\n );\n }\n }\n\n const coveredAliases = new Set(existing.map((predicate) => predicate.tableAlias));\n for (const entry of scopedFrom) {\n const alias = entry.as ?? entry.table;\n const isAmbiguous = scopedFrom.length > 1;\n const covered = coveredAliases.has(alias) || (!isAmbiguous && coveredAliases.has(null));\n if (covered) continue;\n ast.where = this.andCondition(\n ast.where,\n this.buildTenantEquality(isAmbiguous ? alias : null, tenantRef),\n );\n }\n\n return this.parser.sqlify(ast as unknown as AST, { database: 'MySQL' });\n }\n\n private collectTenantPredicates(where: unknown): ExtractedPredicate[] {\n if (!isBinaryExpr(where)) return [];\n if (where.operator === 'AND' || where.operator === 'OR') {\n return [\n ...this.collectTenantPredicates(where.left),\n ...this.collectTenantPredicates(where.right),\n ];\n }\n if (where.operator !== '=') return [];\n const lhs = where.left;\n const rhs = where.right;\n if (!isColumnRef(lhs) || lhs.column !== this.tenantColumn) return [];\n if (!isStringLiteral(rhs)) return [];\n return [{ tableAlias: lhs.table ?? null, value: rhs.value }];\n }\n\n private buildTenantEquality(tableAlias: string | null, tenantRef: string): BinaryExpr {\n return {\n type: 'binary_expr',\n operator: '=',\n left: { type: 'column_ref', table: tableAlias, column: this.tenantColumn },\n right: { type: 'single_quote_string', value: tenantRef },\n };\n }\n\n private andCondition(existing: unknown, added: BinaryExpr): BinaryExpr {\n if (existing == null) return added;\n return {\n type: 'binary_expr',\n operator: 'AND',\n left: existing,\n right: added,\n };\n }\n}\n\nfunction isBinaryExpr(value: unknown): value is BinaryExpr {\n return (\n typeof value === 'object' &&\n value !== null &&\n (value as { type?: unknown }).type === 'binary_expr'\n );\n}\n\nfunction isColumnRef(value: unknown): value is ColumnRef {\n return (\n typeof value === 'object' &&\n value !== null &&\n (value as { type?: unknown }).type === 'column_ref'\n );\n}\n\nfunction isStringLiteral(value: unknown): value is { type: string; value: string } {\n return (\n typeof value === 'object' &&\n value !== null &&\n typeof (value as { type?: unknown }).type === 'string' &&\n STRING_LITERAL_TYPES.has((value as { type: string }).type) &&\n typeof (value as { value?: unknown }).value === 'string'\n );\n}\n","import { Parser } from 'node-sql-parser';\n\nconst parser = new Parser();\n\n/**\n * Ensures a SELECT returns at most `max` rows. If the statement already carries\n * a LIMIT it is returned unchanged; otherwise it is wrapped in a bounding\n * subquery (`SELECT * FROM (<sql>) AS subq LIMIT <max>`) so any ORDER BY /\n * GROUP BY / UNION inside `sql` is preserved.\n */\nexport function injectLimit(sql: string, max: number): string {\n const trimmed = sql.trim().replace(/;\\s*$/, '');\n if (hasLimit(trimmed)) return trimmed;\n return `SELECT * FROM (${trimmed}) AS subq LIMIT ${max}`;\n}\n\nfunction hasLimit(sql: string): boolean {\n try {\n const parsed = parser.astify(sql, { database: 'MySQL' });\n const statement = (Array.isArray(parsed) ? parsed[0] : parsed) as\n | { limit?: { value?: unknown[] } | null }\n | undefined;\n const limit = statement?.limit;\n return Boolean(limit && Array.isArray(limit.value) && limit.value.length > 0);\n } catch {\n // If we can't parse it here, fall back to wrapping β the validator already\n // ran and accepted it, so wrapping is the safe choice.\n return false;\n }\n}\n","import type { AiToolCtx, ToolHandler, ToolSpec } from '@dudousxd/nestjs-agent-core';\nimport { z } from 'zod';\nimport { injectLimit } from './limit.js';\nimport { SqlValidator } from './sql-validator.js';\nimport type { TableAccessPolicy } from './table-access.js';\nimport type { TenantScopeRewriter } from './tenant-scope.js';\n\n/** App-supplied runner over a read-only connection pool. The package never opens a DB. */\nexport interface QueryRunner {\n run(sql: string): Promise<Record<string, unknown>[]>;\n}\n\n/** Dependencies for {@link createExecuteSqlTool}. */\nexport interface ExecuteSqlDeps {\n /** Runs the (already validated, scoped, and limited) SQL against the read-only pool. */\n runner: QueryRunner;\n /** Single-SELECT validator. Defaults to a fresh {@link SqlValidator}. */\n validator?: SqlValidator;\n /** Coarse table-level allowlist, checked for every referenced table. */\n tableAccess: TableAccessPolicy;\n /** Optional per-row tenant constraint applied before the query runs. */\n tenantScope?: TenantScopeRewriter;\n /** Row cap injected when the query has no LIMIT. Defaults to 100. */\n maxRows?: number;\n}\n\nexport interface ExecuteSqlResult {\n rows: Record<string, unknown>[];\n rowCount: number;\n sql: string;\n truncated?: true;\n}\n\nconst DEFAULT_MAX_ROWS = 100;\n\n/** Serialized rows above this size are truncated so a single tool result can't blow the context. */\nconst MAX_RESULT_BYTES = 256 * 1024;\n\nconst inputSchema = z.object({\n sql: z.string().min(1).describe('A single read-only MySQL SELECT statement'),\n});\n\ntype ExecuteSqlInput = z.infer<typeof inputSchema>;\n\nconst DESCRIPTION =\n 'Execute a single read-only MySQL SELECT statement. Use this to answer questions about real ' +\n 'data. Only SELECT is allowed (no INSERT/UPDATE/DELETE/DDL). Access is restricted to the ' +\n 'tables your role is permitted to read, results are capped, and tenant-scoped tables are ' +\n 'automatically constrained to your current tenant.';\n\n/**\n * Builds the governed `executeSql` read tool. The pipeline per call is:\n * validate (single SELECT) β assert table access for every referenced table β\n * tenant-scope rewrite (if configured) β inject LIMIT β run β return rows,\n * truncating the payload if it would exceed ~256KB.\n */\nexport function createExecuteSqlTool(deps: ExecuteSqlDeps): {\n spec: ToolSpec;\n handler: ToolHandler<ExecuteSqlInput>;\n} {\n const validator = deps.validator ?? new SqlValidator();\n const maxRows = deps.maxRows ?? DEFAULT_MAX_ROWS;\n\n const spec: ToolSpec = {\n name: 'executeSql',\n kind: 'read',\n description: DESCRIPTION,\n inputSchema,\n };\n\n const handler: ToolHandler<ExecuteSqlInput> = {\n async execute(input: ExecuteSqlInput, ctx: AiToolCtx): Promise<ExecuteSqlResult> {\n const { tables } = validator.validate(input.sql);\n\n const roles = ctx.actor.roles ?? [];\n const forbidden = tables.filter((table) => !deps.tableAccess.canAccess(roles, table));\n if (forbidden.length > 0) {\n const formatted = forbidden.map((table) => `\\`${table}\\``).join(', ');\n const rolesLabel = roles.length > 0 ? roles.join(', ') : 'none';\n throw new Error(`Your roles (${rolesLabel}) are not allowed to query ${formatted}.`);\n }\n\n let sql = input.sql;\n if (deps.tenantScope) {\n sql = deps.tenantScope.rewrite(sql, ctx.actor.tenantRef);\n }\n sql = injectLimit(sql, maxRows);\n\n const rows = await deps.runner.run(sql);\n return buildResult(rows, sql);\n },\n };\n\n return { spec, handler };\n}\n\nfunction buildResult(rows: Record<string, unknown>[], sql: string): ExecuteSqlResult {\n const rowCount = rows.length;\n if (byteLength(rows) <= MAX_RESULT_BYTES) {\n return { rows, rowCount, sql };\n }\n\n // Keep prefix rows until the serialized payload would exceed the cap.\n const kept: Record<string, unknown>[] = [];\n let size = 2; // opening + closing bracket of the JSON array\n for (const row of rows) {\n const rowSize = byteLength(row) + 1; // +1 for the joining comma\n if (size + rowSize > MAX_RESULT_BYTES) break;\n kept.push(row);\n size += rowSize;\n }\n\n return { rows: kept, rowCount, sql, truncated: true };\n}\n\nfunction byteLength(value: unknown): number {\n return Buffer.byteLength(JSON.stringify(value) ?? '', 'utf8');\n}\n"],"mappings":"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","names":["SqlValidationError","Error","message","name","FORBIDDEN_AST_TYPES","Set","SqlValidator","parser","Parser","validate","sql","parsed","astify","database","err","statements","Array","isArray","length","statement","type","toLowerCase","includes","has","toUpperCase","tables","extractReferencedTables","raw","tableList","entry","parts","split","table","add","from","GroupTableAccessPolicy","roleGroups","tablesByGroup","config","canAccess","roles","table","group","resolveGroup","undefined","some","role","includes","patterns","Object","entries","pattern","matchesPattern","endsWith","startsWith","slice","import_node_sql_parser","STRING_LITERAL_TYPES","Set","TenantScopeRewriter","parser","Parser","tenantColumn","scopedTables","config","rewrite","sql","tenantRef","undefined","parsed","astify","database","ast","Array","isArray","type","Error","with","_next","fromEntries","from","entry","table","expr","scopedFrom","filter","has","length","existing","collectTenantPredicates","where","predicate","value","coveredAliases","map","tableAlias","alias","as","isAmbiguous","covered","andCondition","buildTenantEquality","sqlify","isBinaryExpr","operator","left","right","lhs","rhs","isColumnRef","column","isStringLiteral","added","import_node_sql_parser","parser","Parser","injectLimit","sql","max","trimmed","trim","replace","hasLimit","parsed","astify","database","statement","Array","isArray","limit","Boolean","value","length","DEFAULT_MAX_ROWS","MAX_RESULT_BYTES","inputSchema","z","object","sql","string","min","describe","DESCRIPTION","createExecuteSqlTool","deps","validator","SqlValidator","maxRows","spec","name","kind","description","handler","execute","input","ctx","tables","validate","roles","actor","forbidden","filter","table","tableAccess","canAccess","length","formatted","map","join","rolesLabel","Error","tenantScope","rewrite","tenantRef","injectLimit","rows","runner","run","buildResult","rowCount","byteLength","kept","size","row","rowSize","push","truncated","value","Buffer","JSON","stringify"]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { ToolSpec, ToolHandler } from '@dudousxd/nestjs-agent-core';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Thrown when a statement is not a single, read-only SELECT β i.e. it is an
|
|
6
|
+
* INSERT/UPDATE/DELETE, DDL, a CALL, a multi-statement string, or it fails to
|
|
7
|
+
* parse. The handler surfaces `.message` to the model so it can re-plan.
|
|
8
|
+
*/
|
|
9
|
+
declare class SqlValidationError extends Error {
|
|
10
|
+
constructor(message: string);
|
|
11
|
+
}
|
|
12
|
+
interface SqlValidationResult {
|
|
13
|
+
/** Every base table the statement references (CTEs, joins, subqueries walked). */
|
|
14
|
+
tables: string[];
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Parses SQL (MySQL dialect) and asserts it is a single SELECT, returning the
|
|
18
|
+
* set of tables it touches. Domain-agnostic: it knows nothing about which
|
|
19
|
+
* tables a caller may read β that is the `TableAccessPolicy`'s job.
|
|
20
|
+
*/
|
|
21
|
+
declare class SqlValidator {
|
|
22
|
+
private readonly parser;
|
|
23
|
+
/**
|
|
24
|
+
* Throws `SqlValidationError` unless `sql` is exactly one SELECT statement.
|
|
25
|
+
* On success returns the distinct base table names referenced.
|
|
26
|
+
*/
|
|
27
|
+
validate(sql: string): SqlValidationResult;
|
|
28
|
+
/**
|
|
29
|
+
* Distinct base table names a statement touches β walking CTEs, subqueries,
|
|
30
|
+
* and joins. Backed by `node-sql-parser`'s `tableList`, which emits
|
|
31
|
+
* `mode::db::table` strings; the table name is the last segment.
|
|
32
|
+
*/
|
|
33
|
+
private extractReferencedTables;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Decides whether a role may read a given table at all. This is the coarse,
|
|
38
|
+
* table-level gate; per-row scoping (see `TenantScopeRewriter`) is a separate
|
|
39
|
+
* layer applied at query time.
|
|
40
|
+
*
|
|
41
|
+
* Domain-agnostic: the host app supplies the roleβgroup and groupβtable maps.
|
|
42
|
+
*/
|
|
43
|
+
interface TableAccessPolicy {
|
|
44
|
+
/** True iff any of the caller's `roles` is permitted to read `table`. Fail-closed by contract. */
|
|
45
|
+
canAccess(roles: readonly string[], table: string): boolean;
|
|
46
|
+
}
|
|
47
|
+
/** Inputs for {@link GroupTableAccessPolicy}: roles map to groups, groups to tables. */
|
|
48
|
+
interface GroupTableAccessConfig {
|
|
49
|
+
/** Role name β the table groups that role may read. */
|
|
50
|
+
roleGroups: Record<string, string[]>;
|
|
51
|
+
/**
|
|
52
|
+
* Group name β the tables in that group. Entries are exact table names or
|
|
53
|
+
* `prefix_*` patterns (e.g. `pribuy_*`).
|
|
54
|
+
*/
|
|
55
|
+
tablesByGroup: Record<string, string[]>;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Two-layer, data-driven table allowlist:
|
|
59
|
+
*
|
|
60
|
+
* 1. Every table is classified into a group (`tablesByGroup`).
|
|
61
|
+
* 2. Every role lists the groups it can read (`roleGroups`).
|
|
62
|
+
*
|
|
63
|
+
* `canAccess(roles, table)` is then "is the table's group in ANY of the roles'
|
|
64
|
+
* group lists?". **Fail-closed:** an unclassified table, unknown roles, or an
|
|
65
|
+
* empty role set is denied β a forgotten table never accidentally leaks.
|
|
66
|
+
*/
|
|
67
|
+
declare class GroupTableAccessPolicy implements TableAccessPolicy {
|
|
68
|
+
private readonly roleGroups;
|
|
69
|
+
private readonly tablesByGroup;
|
|
70
|
+
constructor(config: GroupTableAccessConfig);
|
|
71
|
+
canAccess(roles: readonly string[], table: string): boolean;
|
|
72
|
+
/** Resolve a table to its group, or `undefined` if unclassified (fail-closed). */
|
|
73
|
+
private resolveGroup;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Configuration for {@link TenantScopeRewriter}. */
|
|
77
|
+
interface TenantScopeConfig {
|
|
78
|
+
/** The column that carries the tenant key on every scoped table (e.g. `base_id`, `org_id`). */
|
|
79
|
+
tenantColumn: string;
|
|
80
|
+
/** Tables that must be constrained to the caller's tenant when referenced. */
|
|
81
|
+
scopedTables: string[];
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Rewrites a SELECT so every reference to a scoped table is constrained to a
|
|
85
|
+
* single tenant: `<tenantColumn> = '<tenantRef>'` is AND-ed into the WHERE for
|
|
86
|
+
* each scoped table in the FROM. An existing predicate for a different tenant
|
|
87
|
+
* is rejected (no cross-tenant reads). `tenantRef === undefined` is the
|
|
88
|
+
* privileged path and passes the SQL through unchanged.
|
|
89
|
+
*
|
|
90
|
+
* Scoped mode rejects CTEs, UNION/INTERSECT/EXCEPT, and subqueries in FROM:
|
|
91
|
+
* those make it impossible to statically guarantee every tenant-bearing source
|
|
92
|
+
* is constrained, so we fail closed and ask the caller to rephrase.
|
|
93
|
+
*/
|
|
94
|
+
declare class TenantScopeRewriter {
|
|
95
|
+
private readonly parser;
|
|
96
|
+
private readonly tenantColumn;
|
|
97
|
+
private readonly scopedTables;
|
|
98
|
+
constructor(config: TenantScopeConfig);
|
|
99
|
+
/** Rewrite `sql` to constrain scoped tables to `tenantRef`. Undefined β pass through. */
|
|
100
|
+
rewrite(sql: string, tenantRef: string | undefined): string;
|
|
101
|
+
private collectTenantPredicates;
|
|
102
|
+
private buildTenantEquality;
|
|
103
|
+
private andCondition;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Ensures a SELECT returns at most `max` rows. If the statement already carries
|
|
108
|
+
* a LIMIT it is returned unchanged; otherwise it is wrapped in a bounding
|
|
109
|
+
* subquery (`SELECT * FROM (<sql>) AS subq LIMIT <max>`) so any ORDER BY /
|
|
110
|
+
* GROUP BY / UNION inside `sql` is preserved.
|
|
111
|
+
*/
|
|
112
|
+
declare function injectLimit(sql: string, max: number): string;
|
|
113
|
+
|
|
114
|
+
/** App-supplied runner over a read-only connection pool. The package never opens a DB. */
|
|
115
|
+
interface QueryRunner {
|
|
116
|
+
run(sql: string): Promise<Record<string, unknown>[]>;
|
|
117
|
+
}
|
|
118
|
+
/** Dependencies for {@link createExecuteSqlTool}. */
|
|
119
|
+
interface ExecuteSqlDeps {
|
|
120
|
+
/** Runs the (already validated, scoped, and limited) SQL against the read-only pool. */
|
|
121
|
+
runner: QueryRunner;
|
|
122
|
+
/** Single-SELECT validator. Defaults to a fresh {@link SqlValidator}. */
|
|
123
|
+
validator?: SqlValidator;
|
|
124
|
+
/** Coarse table-level allowlist, checked for every referenced table. */
|
|
125
|
+
tableAccess: TableAccessPolicy;
|
|
126
|
+
/** Optional per-row tenant constraint applied before the query runs. */
|
|
127
|
+
tenantScope?: TenantScopeRewriter;
|
|
128
|
+
/** Row cap injected when the query has no LIMIT. Defaults to 100. */
|
|
129
|
+
maxRows?: number;
|
|
130
|
+
}
|
|
131
|
+
interface ExecuteSqlResult {
|
|
132
|
+
rows: Record<string, unknown>[];
|
|
133
|
+
rowCount: number;
|
|
134
|
+
sql: string;
|
|
135
|
+
truncated?: true;
|
|
136
|
+
}
|
|
137
|
+
declare const inputSchema: z.ZodObject<{
|
|
138
|
+
sql: z.ZodString;
|
|
139
|
+
}, "strip", z.ZodTypeAny, {
|
|
140
|
+
sql: string;
|
|
141
|
+
}, {
|
|
142
|
+
sql: string;
|
|
143
|
+
}>;
|
|
144
|
+
type ExecuteSqlInput = z.infer<typeof inputSchema>;
|
|
145
|
+
/**
|
|
146
|
+
* Builds the governed `executeSql` read tool. The pipeline per call is:
|
|
147
|
+
* validate (single SELECT) β assert table access for every referenced table β
|
|
148
|
+
* tenant-scope rewrite (if configured) β inject LIMIT β run β return rows,
|
|
149
|
+
* truncating the payload if it would exceed ~256KB.
|
|
150
|
+
*/
|
|
151
|
+
declare function createExecuteSqlTool(deps: ExecuteSqlDeps): {
|
|
152
|
+
spec: ToolSpec;
|
|
153
|
+
handler: ToolHandler<ExecuteSqlInput>;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
export { type ExecuteSqlDeps, type ExecuteSqlResult, type GroupTableAccessConfig, GroupTableAccessPolicy, type QueryRunner, SqlValidationError, type SqlValidationResult, SqlValidator, type TableAccessPolicy, type TenantScopeConfig, TenantScopeRewriter, createExecuteSqlTool, injectLimit };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { ToolSpec, ToolHandler } from '@dudousxd/nestjs-agent-core';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Thrown when a statement is not a single, read-only SELECT β i.e. it is an
|
|
6
|
+
* INSERT/UPDATE/DELETE, DDL, a CALL, a multi-statement string, or it fails to
|
|
7
|
+
* parse. The handler surfaces `.message` to the model so it can re-plan.
|
|
8
|
+
*/
|
|
9
|
+
declare class SqlValidationError extends Error {
|
|
10
|
+
constructor(message: string);
|
|
11
|
+
}
|
|
12
|
+
interface SqlValidationResult {
|
|
13
|
+
/** Every base table the statement references (CTEs, joins, subqueries walked). */
|
|
14
|
+
tables: string[];
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Parses SQL (MySQL dialect) and asserts it is a single SELECT, returning the
|
|
18
|
+
* set of tables it touches. Domain-agnostic: it knows nothing about which
|
|
19
|
+
* tables a caller may read β that is the `TableAccessPolicy`'s job.
|
|
20
|
+
*/
|
|
21
|
+
declare class SqlValidator {
|
|
22
|
+
private readonly parser;
|
|
23
|
+
/**
|
|
24
|
+
* Throws `SqlValidationError` unless `sql` is exactly one SELECT statement.
|
|
25
|
+
* On success returns the distinct base table names referenced.
|
|
26
|
+
*/
|
|
27
|
+
validate(sql: string): SqlValidationResult;
|
|
28
|
+
/**
|
|
29
|
+
* Distinct base table names a statement touches β walking CTEs, subqueries,
|
|
30
|
+
* and joins. Backed by `node-sql-parser`'s `tableList`, which emits
|
|
31
|
+
* `mode::db::table` strings; the table name is the last segment.
|
|
32
|
+
*/
|
|
33
|
+
private extractReferencedTables;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Decides whether a role may read a given table at all. This is the coarse,
|
|
38
|
+
* table-level gate; per-row scoping (see `TenantScopeRewriter`) is a separate
|
|
39
|
+
* layer applied at query time.
|
|
40
|
+
*
|
|
41
|
+
* Domain-agnostic: the host app supplies the roleβgroup and groupβtable maps.
|
|
42
|
+
*/
|
|
43
|
+
interface TableAccessPolicy {
|
|
44
|
+
/** True iff any of the caller's `roles` is permitted to read `table`. Fail-closed by contract. */
|
|
45
|
+
canAccess(roles: readonly string[], table: string): boolean;
|
|
46
|
+
}
|
|
47
|
+
/** Inputs for {@link GroupTableAccessPolicy}: roles map to groups, groups to tables. */
|
|
48
|
+
interface GroupTableAccessConfig {
|
|
49
|
+
/** Role name β the table groups that role may read. */
|
|
50
|
+
roleGroups: Record<string, string[]>;
|
|
51
|
+
/**
|
|
52
|
+
* Group name β the tables in that group. Entries are exact table names or
|
|
53
|
+
* `prefix_*` patterns (e.g. `pribuy_*`).
|
|
54
|
+
*/
|
|
55
|
+
tablesByGroup: Record<string, string[]>;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Two-layer, data-driven table allowlist:
|
|
59
|
+
*
|
|
60
|
+
* 1. Every table is classified into a group (`tablesByGroup`).
|
|
61
|
+
* 2. Every role lists the groups it can read (`roleGroups`).
|
|
62
|
+
*
|
|
63
|
+
* `canAccess(roles, table)` is then "is the table's group in ANY of the roles'
|
|
64
|
+
* group lists?". **Fail-closed:** an unclassified table, unknown roles, or an
|
|
65
|
+
* empty role set is denied β a forgotten table never accidentally leaks.
|
|
66
|
+
*/
|
|
67
|
+
declare class GroupTableAccessPolicy implements TableAccessPolicy {
|
|
68
|
+
private readonly roleGroups;
|
|
69
|
+
private readonly tablesByGroup;
|
|
70
|
+
constructor(config: GroupTableAccessConfig);
|
|
71
|
+
canAccess(roles: readonly string[], table: string): boolean;
|
|
72
|
+
/** Resolve a table to its group, or `undefined` if unclassified (fail-closed). */
|
|
73
|
+
private resolveGroup;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Configuration for {@link TenantScopeRewriter}. */
|
|
77
|
+
interface TenantScopeConfig {
|
|
78
|
+
/** The column that carries the tenant key on every scoped table (e.g. `base_id`, `org_id`). */
|
|
79
|
+
tenantColumn: string;
|
|
80
|
+
/** Tables that must be constrained to the caller's tenant when referenced. */
|
|
81
|
+
scopedTables: string[];
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Rewrites a SELECT so every reference to a scoped table is constrained to a
|
|
85
|
+
* single tenant: `<tenantColumn> = '<tenantRef>'` is AND-ed into the WHERE for
|
|
86
|
+
* each scoped table in the FROM. An existing predicate for a different tenant
|
|
87
|
+
* is rejected (no cross-tenant reads). `tenantRef === undefined` is the
|
|
88
|
+
* privileged path and passes the SQL through unchanged.
|
|
89
|
+
*
|
|
90
|
+
* Scoped mode rejects CTEs, UNION/INTERSECT/EXCEPT, and subqueries in FROM:
|
|
91
|
+
* those make it impossible to statically guarantee every tenant-bearing source
|
|
92
|
+
* is constrained, so we fail closed and ask the caller to rephrase.
|
|
93
|
+
*/
|
|
94
|
+
declare class TenantScopeRewriter {
|
|
95
|
+
private readonly parser;
|
|
96
|
+
private readonly tenantColumn;
|
|
97
|
+
private readonly scopedTables;
|
|
98
|
+
constructor(config: TenantScopeConfig);
|
|
99
|
+
/** Rewrite `sql` to constrain scoped tables to `tenantRef`. Undefined β pass through. */
|
|
100
|
+
rewrite(sql: string, tenantRef: string | undefined): string;
|
|
101
|
+
private collectTenantPredicates;
|
|
102
|
+
private buildTenantEquality;
|
|
103
|
+
private andCondition;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Ensures a SELECT returns at most `max` rows. If the statement already carries
|
|
108
|
+
* a LIMIT it is returned unchanged; otherwise it is wrapped in a bounding
|
|
109
|
+
* subquery (`SELECT * FROM (<sql>) AS subq LIMIT <max>`) so any ORDER BY /
|
|
110
|
+
* GROUP BY / UNION inside `sql` is preserved.
|
|
111
|
+
*/
|
|
112
|
+
declare function injectLimit(sql: string, max: number): string;
|
|
113
|
+
|
|
114
|
+
/** App-supplied runner over a read-only connection pool. The package never opens a DB. */
|
|
115
|
+
interface QueryRunner {
|
|
116
|
+
run(sql: string): Promise<Record<string, unknown>[]>;
|
|
117
|
+
}
|
|
118
|
+
/** Dependencies for {@link createExecuteSqlTool}. */
|
|
119
|
+
interface ExecuteSqlDeps {
|
|
120
|
+
/** Runs the (already validated, scoped, and limited) SQL against the read-only pool. */
|
|
121
|
+
runner: QueryRunner;
|
|
122
|
+
/** Single-SELECT validator. Defaults to a fresh {@link SqlValidator}. */
|
|
123
|
+
validator?: SqlValidator;
|
|
124
|
+
/** Coarse table-level allowlist, checked for every referenced table. */
|
|
125
|
+
tableAccess: TableAccessPolicy;
|
|
126
|
+
/** Optional per-row tenant constraint applied before the query runs. */
|
|
127
|
+
tenantScope?: TenantScopeRewriter;
|
|
128
|
+
/** Row cap injected when the query has no LIMIT. Defaults to 100. */
|
|
129
|
+
maxRows?: number;
|
|
130
|
+
}
|
|
131
|
+
interface ExecuteSqlResult {
|
|
132
|
+
rows: Record<string, unknown>[];
|
|
133
|
+
rowCount: number;
|
|
134
|
+
sql: string;
|
|
135
|
+
truncated?: true;
|
|
136
|
+
}
|
|
137
|
+
declare const inputSchema: z.ZodObject<{
|
|
138
|
+
sql: z.ZodString;
|
|
139
|
+
}, "strip", z.ZodTypeAny, {
|
|
140
|
+
sql: string;
|
|
141
|
+
}, {
|
|
142
|
+
sql: string;
|
|
143
|
+
}>;
|
|
144
|
+
type ExecuteSqlInput = z.infer<typeof inputSchema>;
|
|
145
|
+
/**
|
|
146
|
+
* Builds the governed `executeSql` read tool. The pipeline per call is:
|
|
147
|
+
* validate (single SELECT) β assert table access for every referenced table β
|
|
148
|
+
* tenant-scope rewrite (if configured) β inject LIMIT β run β return rows,
|
|
149
|
+
* truncating the payload if it would exceed ~256KB.
|
|
150
|
+
*/
|
|
151
|
+
declare function createExecuteSqlTool(deps: ExecuteSqlDeps): {
|
|
152
|
+
spec: ToolSpec;
|
|
153
|
+
handler: ToolHandler<ExecuteSqlInput>;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
export { type ExecuteSqlDeps, type ExecuteSqlResult, type GroupTableAccessConfig, GroupTableAccessPolicy, type QueryRunner, SqlValidationError, type SqlValidationResult, SqlValidator, type TableAccessPolicy, type TenantScopeConfig, TenantScopeRewriter, createExecuteSqlTool, injectLimit };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
|
|
3
|
+
|
|
4
|
+
// src/sql-validator.ts
|
|
5
|
+
import { Parser } from "node-sql-parser";
|
|
6
|
+
var SqlValidationError = class extends Error {
|
|
7
|
+
static {
|
|
8
|
+
__name(this, "SqlValidationError");
|
|
9
|
+
}
|
|
10
|
+
constructor(message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "SqlValidationError";
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
var FORBIDDEN_AST_TYPES = /* @__PURE__ */ new Set([
|
|
16
|
+
"insert",
|
|
17
|
+
"update",
|
|
18
|
+
"delete",
|
|
19
|
+
"replace",
|
|
20
|
+
"create",
|
|
21
|
+
"drop",
|
|
22
|
+
"alter",
|
|
23
|
+
"truncate",
|
|
24
|
+
"rename",
|
|
25
|
+
"load_data",
|
|
26
|
+
"lock",
|
|
27
|
+
"unlock",
|
|
28
|
+
"set",
|
|
29
|
+
"call",
|
|
30
|
+
"handler",
|
|
31
|
+
"use",
|
|
32
|
+
"grant",
|
|
33
|
+
"revoke"
|
|
34
|
+
]);
|
|
35
|
+
var SqlValidator = class {
|
|
36
|
+
static {
|
|
37
|
+
__name(this, "SqlValidator");
|
|
38
|
+
}
|
|
39
|
+
parser = new Parser();
|
|
40
|
+
/**
|
|
41
|
+
* Throws `SqlValidationError` unless `sql` is exactly one SELECT statement.
|
|
42
|
+
* On success returns the distinct base table names referenced.
|
|
43
|
+
*/
|
|
44
|
+
validate(sql) {
|
|
45
|
+
let parsed;
|
|
46
|
+
try {
|
|
47
|
+
parsed = this.parser.astify(sql, {
|
|
48
|
+
database: "MySQL"
|
|
49
|
+
});
|
|
50
|
+
} catch (err) {
|
|
51
|
+
throw new SqlValidationError(`Parse error: ${err.message}`);
|
|
52
|
+
}
|
|
53
|
+
const statements = Array.isArray(parsed) ? parsed : [
|
|
54
|
+
parsed
|
|
55
|
+
];
|
|
56
|
+
if (statements.length !== 1) {
|
|
57
|
+
throw new SqlValidationError("Only a single statement is allowed");
|
|
58
|
+
}
|
|
59
|
+
const statement = statements[0];
|
|
60
|
+
const type = (statement?.type ?? "").toLowerCase();
|
|
61
|
+
if (type !== "select") {
|
|
62
|
+
if ([
|
|
63
|
+
"create",
|
|
64
|
+
"drop",
|
|
65
|
+
"alter",
|
|
66
|
+
"truncate",
|
|
67
|
+
"rename"
|
|
68
|
+
].includes(type)) {
|
|
69
|
+
throw new SqlValidationError("DDL is not allowed; only SELECT statements are accepted");
|
|
70
|
+
}
|
|
71
|
+
if (FORBIDDEN_AST_TYPES.has(type)) {
|
|
72
|
+
throw new SqlValidationError(`${type.toUpperCase()} is not allowed; only SELECT statements are accepted`);
|
|
73
|
+
}
|
|
74
|
+
throw new SqlValidationError(`Statement type "${type || "unknown"}" is not allowed; only SELECT statements are accepted`);
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
tables: this.extractReferencedTables(sql)
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Distinct base table names a statement touches β walking CTEs, subqueries,
|
|
82
|
+
* and joins. Backed by `node-sql-parser`'s `tableList`, which emits
|
|
83
|
+
* `mode::db::table` strings; the table name is the last segment.
|
|
84
|
+
*/
|
|
85
|
+
extractReferencedTables(sql) {
|
|
86
|
+
const raw = this.parser.tableList(sql, {
|
|
87
|
+
database: "MySQL"
|
|
88
|
+
});
|
|
89
|
+
const tables = /* @__PURE__ */ new Set();
|
|
90
|
+
for (const entry of raw) {
|
|
91
|
+
const parts = entry.split("::");
|
|
92
|
+
const table = parts[parts.length - 1];
|
|
93
|
+
if (table && table !== "null") tables.add(table);
|
|
94
|
+
}
|
|
95
|
+
return Array.from(tables);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// src/table-access.ts
|
|
100
|
+
var GroupTableAccessPolicy = class {
|
|
101
|
+
static {
|
|
102
|
+
__name(this, "GroupTableAccessPolicy");
|
|
103
|
+
}
|
|
104
|
+
roleGroups;
|
|
105
|
+
tablesByGroup;
|
|
106
|
+
constructor(config) {
|
|
107
|
+
this.roleGroups = config.roleGroups;
|
|
108
|
+
this.tablesByGroup = config.tablesByGroup;
|
|
109
|
+
}
|
|
110
|
+
canAccess(roles, table) {
|
|
111
|
+
const group = this.resolveGroup(table);
|
|
112
|
+
if (group === void 0) return false;
|
|
113
|
+
return roles.some((role) => this.roleGroups[role]?.includes(group) ?? false);
|
|
114
|
+
}
|
|
115
|
+
/** Resolve a table to its group, or `undefined` if unclassified (fail-closed). */
|
|
116
|
+
resolveGroup(table) {
|
|
117
|
+
for (const [group, patterns] of Object.entries(this.tablesByGroup)) {
|
|
118
|
+
for (const pattern of patterns) {
|
|
119
|
+
if (matchesPattern(table, pattern)) return group;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return void 0;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
function matchesPattern(table, pattern) {
|
|
126
|
+
if (pattern.endsWith("*")) {
|
|
127
|
+
return table.startsWith(pattern.slice(0, -1));
|
|
128
|
+
}
|
|
129
|
+
return table === pattern;
|
|
130
|
+
}
|
|
131
|
+
__name(matchesPattern, "matchesPattern");
|
|
132
|
+
|
|
133
|
+
// src/tenant-scope.ts
|
|
134
|
+
import { Parser as Parser2 } from "node-sql-parser";
|
|
135
|
+
var STRING_LITERAL_TYPES = /* @__PURE__ */ new Set([
|
|
136
|
+
"string",
|
|
137
|
+
"single_quote_string",
|
|
138
|
+
"double_quote_string"
|
|
139
|
+
]);
|
|
140
|
+
var TenantScopeRewriter = class {
|
|
141
|
+
static {
|
|
142
|
+
__name(this, "TenantScopeRewriter");
|
|
143
|
+
}
|
|
144
|
+
parser = new Parser2();
|
|
145
|
+
tenantColumn;
|
|
146
|
+
scopedTables;
|
|
147
|
+
constructor(config) {
|
|
148
|
+
this.tenantColumn = config.tenantColumn;
|
|
149
|
+
this.scopedTables = new Set(config.scopedTables);
|
|
150
|
+
}
|
|
151
|
+
/** Rewrite `sql` to constrain scoped tables to `tenantRef`. Undefined β pass through. */
|
|
152
|
+
rewrite(sql, tenantRef) {
|
|
153
|
+
if (tenantRef === void 0) return sql;
|
|
154
|
+
const parsed = this.parser.astify(sql, {
|
|
155
|
+
database: "MySQL"
|
|
156
|
+
});
|
|
157
|
+
const ast = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
158
|
+
if (ast.type !== "select") {
|
|
159
|
+
throw new Error("tenant scope: only SELECT is supported");
|
|
160
|
+
}
|
|
161
|
+
if (ast.with) {
|
|
162
|
+
throw new Error("tenant scope: WITH (CTE) is not supported in scoped mode \u2014 rewrite using JOINs/subqueries in FROM");
|
|
163
|
+
}
|
|
164
|
+
if (ast._next) {
|
|
165
|
+
throw new Error("tenant scope: UNION/INTERSECT/EXCEPT is not supported in scoped mode \u2014 run each branch as a separate query");
|
|
166
|
+
}
|
|
167
|
+
const fromEntries = ast.from ?? [];
|
|
168
|
+
for (const entry of fromEntries) {
|
|
169
|
+
if (!entry.table && entry.expr?.ast) {
|
|
170
|
+
throw new Error("tenant scope: subqueries in FROM are not supported in scoped mode");
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const scopedFrom = fromEntries.filter((entry) => typeof entry.table === "string" && this.scopedTables.has(entry.table));
|
|
174
|
+
if (scopedFrom.length === 0) return sql;
|
|
175
|
+
const existing = this.collectTenantPredicates(ast.where);
|
|
176
|
+
for (const predicate of existing) {
|
|
177
|
+
if (predicate.value !== tenantRef) {
|
|
178
|
+
throw new Error("tenant scope: tenant mismatch \u2014 query targets a tenant other than the current session");
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const coveredAliases = new Set(existing.map((predicate) => predicate.tableAlias));
|
|
182
|
+
for (const entry of scopedFrom) {
|
|
183
|
+
const alias = entry.as ?? entry.table;
|
|
184
|
+
const isAmbiguous = scopedFrom.length > 1;
|
|
185
|
+
const covered = coveredAliases.has(alias) || !isAmbiguous && coveredAliases.has(null);
|
|
186
|
+
if (covered) continue;
|
|
187
|
+
ast.where = this.andCondition(ast.where, this.buildTenantEquality(isAmbiguous ? alias : null, tenantRef));
|
|
188
|
+
}
|
|
189
|
+
return this.parser.sqlify(ast, {
|
|
190
|
+
database: "MySQL"
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
collectTenantPredicates(where) {
|
|
194
|
+
if (!isBinaryExpr(where)) return [];
|
|
195
|
+
if (where.operator === "AND" || where.operator === "OR") {
|
|
196
|
+
return [
|
|
197
|
+
...this.collectTenantPredicates(where.left),
|
|
198
|
+
...this.collectTenantPredicates(where.right)
|
|
199
|
+
];
|
|
200
|
+
}
|
|
201
|
+
if (where.operator !== "=") return [];
|
|
202
|
+
const lhs = where.left;
|
|
203
|
+
const rhs = where.right;
|
|
204
|
+
if (!isColumnRef(lhs) || lhs.column !== this.tenantColumn) return [];
|
|
205
|
+
if (!isStringLiteral(rhs)) return [];
|
|
206
|
+
return [
|
|
207
|
+
{
|
|
208
|
+
tableAlias: lhs.table ?? null,
|
|
209
|
+
value: rhs.value
|
|
210
|
+
}
|
|
211
|
+
];
|
|
212
|
+
}
|
|
213
|
+
buildTenantEquality(tableAlias, tenantRef) {
|
|
214
|
+
return {
|
|
215
|
+
type: "binary_expr",
|
|
216
|
+
operator: "=",
|
|
217
|
+
left: {
|
|
218
|
+
type: "column_ref",
|
|
219
|
+
table: tableAlias,
|
|
220
|
+
column: this.tenantColumn
|
|
221
|
+
},
|
|
222
|
+
right: {
|
|
223
|
+
type: "single_quote_string",
|
|
224
|
+
value: tenantRef
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
andCondition(existing, added) {
|
|
229
|
+
if (existing == null) return added;
|
|
230
|
+
return {
|
|
231
|
+
type: "binary_expr",
|
|
232
|
+
operator: "AND",
|
|
233
|
+
left: existing,
|
|
234
|
+
right: added
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
function isBinaryExpr(value) {
|
|
239
|
+
return typeof value === "object" && value !== null && value.type === "binary_expr";
|
|
240
|
+
}
|
|
241
|
+
__name(isBinaryExpr, "isBinaryExpr");
|
|
242
|
+
function isColumnRef(value) {
|
|
243
|
+
return typeof value === "object" && value !== null && value.type === "column_ref";
|
|
244
|
+
}
|
|
245
|
+
__name(isColumnRef, "isColumnRef");
|
|
246
|
+
function isStringLiteral(value) {
|
|
247
|
+
return typeof value === "object" && value !== null && typeof value.type === "string" && STRING_LITERAL_TYPES.has(value.type) && typeof value.value === "string";
|
|
248
|
+
}
|
|
249
|
+
__name(isStringLiteral, "isStringLiteral");
|
|
250
|
+
|
|
251
|
+
// src/limit.ts
|
|
252
|
+
import { Parser as Parser3 } from "node-sql-parser";
|
|
253
|
+
var parser = new Parser3();
|
|
254
|
+
function injectLimit(sql, max) {
|
|
255
|
+
const trimmed = sql.trim().replace(/;\s*$/, "");
|
|
256
|
+
if (hasLimit(trimmed)) return trimmed;
|
|
257
|
+
return `SELECT * FROM (${trimmed}) AS subq LIMIT ${max}`;
|
|
258
|
+
}
|
|
259
|
+
__name(injectLimit, "injectLimit");
|
|
260
|
+
function hasLimit(sql) {
|
|
261
|
+
try {
|
|
262
|
+
const parsed = parser.astify(sql, {
|
|
263
|
+
database: "MySQL"
|
|
264
|
+
});
|
|
265
|
+
const statement = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
266
|
+
const limit = statement?.limit;
|
|
267
|
+
return Boolean(limit && Array.isArray(limit.value) && limit.value.length > 0);
|
|
268
|
+
} catch {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
__name(hasLimit, "hasLimit");
|
|
273
|
+
|
|
274
|
+
// src/execute-sql.tool.ts
|
|
275
|
+
import { z } from "zod";
|
|
276
|
+
var DEFAULT_MAX_ROWS = 100;
|
|
277
|
+
var MAX_RESULT_BYTES = 256 * 1024;
|
|
278
|
+
var inputSchema = z.object({
|
|
279
|
+
sql: z.string().min(1).describe("A single read-only MySQL SELECT statement")
|
|
280
|
+
});
|
|
281
|
+
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.";
|
|
282
|
+
function createExecuteSqlTool(deps) {
|
|
283
|
+
const validator = deps.validator ?? new SqlValidator();
|
|
284
|
+
const maxRows = deps.maxRows ?? DEFAULT_MAX_ROWS;
|
|
285
|
+
const spec = {
|
|
286
|
+
name: "executeSql",
|
|
287
|
+
kind: "read",
|
|
288
|
+
description: DESCRIPTION,
|
|
289
|
+
inputSchema
|
|
290
|
+
};
|
|
291
|
+
const handler = {
|
|
292
|
+
async execute(input, ctx) {
|
|
293
|
+
const { tables } = validator.validate(input.sql);
|
|
294
|
+
const roles = ctx.actor.roles ?? [];
|
|
295
|
+
const forbidden = tables.filter((table) => !deps.tableAccess.canAccess(roles, table));
|
|
296
|
+
if (forbidden.length > 0) {
|
|
297
|
+
const formatted = forbidden.map((table) => `\`${table}\``).join(", ");
|
|
298
|
+
const rolesLabel = roles.length > 0 ? roles.join(", ") : "none";
|
|
299
|
+
throw new Error(`Your roles (${rolesLabel}) are not allowed to query ${formatted}.`);
|
|
300
|
+
}
|
|
301
|
+
let sql = input.sql;
|
|
302
|
+
if (deps.tenantScope) {
|
|
303
|
+
sql = deps.tenantScope.rewrite(sql, ctx.actor.tenantRef);
|
|
304
|
+
}
|
|
305
|
+
sql = injectLimit(sql, maxRows);
|
|
306
|
+
const rows = await deps.runner.run(sql);
|
|
307
|
+
return buildResult(rows, sql);
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
return {
|
|
311
|
+
spec,
|
|
312
|
+
handler
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
__name(createExecuteSqlTool, "createExecuteSqlTool");
|
|
316
|
+
function buildResult(rows, sql) {
|
|
317
|
+
const rowCount = rows.length;
|
|
318
|
+
if (byteLength(rows) <= MAX_RESULT_BYTES) {
|
|
319
|
+
return {
|
|
320
|
+
rows,
|
|
321
|
+
rowCount,
|
|
322
|
+
sql
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
const kept = [];
|
|
326
|
+
let size = 2;
|
|
327
|
+
for (const row of rows) {
|
|
328
|
+
const rowSize = byteLength(row) + 1;
|
|
329
|
+
if (size + rowSize > MAX_RESULT_BYTES) break;
|
|
330
|
+
kept.push(row);
|
|
331
|
+
size += rowSize;
|
|
332
|
+
}
|
|
333
|
+
return {
|
|
334
|
+
rows: kept,
|
|
335
|
+
rowCount,
|
|
336
|
+
sql,
|
|
337
|
+
truncated: true
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
__name(buildResult, "buildResult");
|
|
341
|
+
function byteLength(value) {
|
|
342
|
+
return Buffer.byteLength(JSON.stringify(value) ?? "", "utf8");
|
|
343
|
+
}
|
|
344
|
+
__name(byteLength, "byteLength");
|
|
345
|
+
export {
|
|
346
|
+
GroupTableAccessPolicy,
|
|
347
|
+
SqlValidationError,
|
|
348
|
+
SqlValidator,
|
|
349
|
+
TenantScopeRewriter,
|
|
350
|
+
createExecuteSqlTool,
|
|
351
|
+
injectLimit
|
|
352
|
+
};
|
|
353
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/sql-validator.ts","../src/table-access.ts","../src/tenant-scope.ts","../src/limit.ts","../src/execute-sql.tool.ts"],"sourcesContent":["import { Parser } from 'node-sql-parser';\n\n/**\n * Thrown when a statement is not a single, read-only SELECT β i.e. it is an\n * INSERT/UPDATE/DELETE, DDL, a CALL, a multi-statement string, or it fails to\n * parse. The handler surfaces `.message` to the model so it can re-plan.\n */\nexport class SqlValidationError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'SqlValidationError';\n }\n}\n\n/** Statement types that are categorically rejected (anything that writes or runs code). */\nconst FORBIDDEN_AST_TYPES = new Set([\n 'insert',\n 'update',\n 'delete',\n 'replace',\n 'create',\n 'drop',\n 'alter',\n 'truncate',\n 'rename',\n 'load_data',\n 'lock',\n 'unlock',\n 'set',\n 'call',\n 'handler',\n 'use',\n 'grant',\n 'revoke',\n]);\n\nexport interface SqlValidationResult {\n /** Every base table the statement references (CTEs, joins, subqueries walked). */\n tables: string[];\n}\n\n/**\n * Parses SQL (MySQL dialect) and asserts it is a single SELECT, returning the\n * set of tables it touches. Domain-agnostic: it knows nothing about which\n * tables a caller may read β that is the `TableAccessPolicy`'s job.\n */\nexport class SqlValidator {\n private readonly parser = new Parser();\n\n /**\n * Throws `SqlValidationError` unless `sql` is exactly one SELECT statement.\n * On success returns the distinct base table names referenced.\n */\n validate(sql: string): SqlValidationResult {\n let parsed: unknown;\n try {\n parsed = this.parser.astify(sql, { database: 'MySQL' });\n } catch (err) {\n throw new SqlValidationError(`Parse error: ${(err as Error).message}`);\n }\n\n const statements = Array.isArray(parsed) ? parsed : [parsed];\n if (statements.length !== 1) {\n throw new SqlValidationError('Only a single statement is allowed');\n }\n\n const statement = statements[0] as { type?: string } | undefined;\n const type = (statement?.type ?? '').toLowerCase();\n\n if (type !== 'select') {\n if (['create', 'drop', 'alter', 'truncate', 'rename'].includes(type)) {\n throw new SqlValidationError('DDL is not allowed; only SELECT statements are accepted');\n }\n if (FORBIDDEN_AST_TYPES.has(type)) {\n throw new SqlValidationError(\n `${type.toUpperCase()} is not allowed; only SELECT statements are accepted`,\n );\n }\n throw new SqlValidationError(\n `Statement type \"${type || 'unknown'}\" is not allowed; only SELECT statements are accepted`,\n );\n }\n\n return { tables: this.extractReferencedTables(sql) };\n }\n\n /**\n * Distinct base table names a statement touches β walking CTEs, subqueries,\n * and joins. Backed by `node-sql-parser`'s `tableList`, which emits\n * `mode::db::table` strings; the table name is the last segment.\n */\n private extractReferencedTables(sql: string): string[] {\n const raw = this.parser.tableList(sql, { database: 'MySQL' });\n const tables = new Set<string>();\n for (const entry of raw) {\n const parts = entry.split('::');\n const table = parts[parts.length - 1];\n if (table && table !== 'null') tables.add(table);\n }\n return Array.from(tables);\n }\n}\n","/**\n * Decides whether a role may read a given table at all. This is the coarse,\n * table-level gate; per-row scoping (see `TenantScopeRewriter`) is a separate\n * layer applied at query time.\n *\n * Domain-agnostic: the host app supplies the roleβgroup and groupβtable maps.\n */\nexport interface TableAccessPolicy {\n /** True iff any of the caller's `roles` is permitted to read `table`. Fail-closed by contract. */\n canAccess(roles: readonly string[], table: string): boolean;\n}\n\n/** Inputs for {@link GroupTableAccessPolicy}: roles map to groups, groups to tables. */\nexport interface GroupTableAccessConfig {\n /** Role name β the table groups that role may read. */\n roleGroups: Record<string, string[]>;\n /**\n * Group name β the tables in that group. Entries are exact table names or\n * `prefix_*` patterns (e.g. `pribuy_*`).\n */\n tablesByGroup: Record<string, string[]>;\n}\n\n/**\n * Two-layer, data-driven table allowlist:\n *\n * 1. Every table is classified into a group (`tablesByGroup`).\n * 2. Every role lists the groups it can read (`roleGroups`).\n *\n * `canAccess(roles, table)` is then \"is the table's group in ANY of the roles'\n * group lists?\". **Fail-closed:** an unclassified table, unknown roles, or an\n * empty role set is denied β a forgotten table never accidentally leaks.\n */\nexport class GroupTableAccessPolicy implements TableAccessPolicy {\n private readonly roleGroups: Record<string, string[]>;\n private readonly tablesByGroup: Record<string, string[]>;\n\n constructor(config: GroupTableAccessConfig) {\n this.roleGroups = config.roleGroups;\n this.tablesByGroup = config.tablesByGroup;\n }\n\n canAccess(roles: readonly string[], table: string): boolean {\n const group = this.resolveGroup(table);\n if (group === undefined) return false;\n\n return roles.some((role) => this.roleGroups[role]?.includes(group) ?? false);\n }\n\n /** Resolve a table to its group, or `undefined` if unclassified (fail-closed). */\n private resolveGroup(table: string): string | undefined {\n for (const [group, patterns] of Object.entries(this.tablesByGroup)) {\n for (const pattern of patterns) {\n if (matchesPattern(table, pattern)) return group;\n }\n }\n return undefined;\n }\n}\n\nfunction matchesPattern(table: string, pattern: string): boolean {\n if (pattern.endsWith('*')) {\n return table.startsWith(pattern.slice(0, -1));\n }\n return table === pattern;\n}\n","import type { AST } from 'node-sql-parser';\nimport { Parser } from 'node-sql-parser';\n\n/** Configuration for {@link TenantScopeRewriter}. */\nexport interface TenantScopeConfig {\n /** The column that carries the tenant key on every scoped table (e.g. `base_id`, `org_id`). */\n tenantColumn: string;\n /** Tables that must be constrained to the caller's tenant when referenced. */\n scopedTables: string[];\n}\n\ninterface FromEntry {\n table?: string;\n as?: string | null;\n join?: string;\n expr?: { ast?: unknown };\n}\n\ninterface SelectAst {\n type: string;\n with?: unknown;\n from?: FromEntry[];\n where?: unknown;\n _next?: unknown;\n}\n\ninterface BinaryExpr {\n type: 'binary_expr';\n operator: string;\n left: unknown;\n right: unknown;\n}\n\ninterface ColumnRef {\n type: 'column_ref';\n table: string | null;\n column: string;\n}\n\ninterface ExtractedPredicate {\n tableAlias: string | null;\n value: string;\n}\n\nconst STRING_LITERAL_TYPES = new Set(['string', 'single_quote_string', 'double_quote_string']);\n\n/**\n * Rewrites a SELECT so every reference to a scoped table is constrained to a\n * single tenant: `<tenantColumn> = '<tenantRef>'` is AND-ed into the WHERE for\n * each scoped table in the FROM. An existing predicate for a different tenant\n * is rejected (no cross-tenant reads). `tenantRef === undefined` is the\n * privileged path and passes the SQL through unchanged.\n *\n * Scoped mode rejects CTEs, UNION/INTERSECT/EXCEPT, and subqueries in FROM:\n * those make it impossible to statically guarantee every tenant-bearing source\n * is constrained, so we fail closed and ask the caller to rephrase.\n */\nexport class TenantScopeRewriter {\n private readonly parser = new Parser();\n private readonly tenantColumn: string;\n private readonly scopedTables: Set<string>;\n\n constructor(config: TenantScopeConfig) {\n this.tenantColumn = config.tenantColumn;\n this.scopedTables = new Set(config.scopedTables);\n }\n\n /** Rewrite `sql` to constrain scoped tables to `tenantRef`. Undefined β pass through. */\n rewrite(sql: string, tenantRef: string | undefined): string {\n if (tenantRef === undefined) return sql;\n\n const parsed = this.parser.astify(sql, { database: 'MySQL' });\n const ast = (Array.isArray(parsed) ? parsed[0] : parsed) as SelectAst;\n\n if (ast.type !== 'select') {\n throw new Error('tenant scope: only SELECT is supported');\n }\n if (ast.with) {\n throw new Error(\n 'tenant scope: WITH (CTE) is not supported in scoped mode β rewrite using JOINs/subqueries in FROM',\n );\n }\n if (ast._next) {\n throw new Error(\n 'tenant scope: UNION/INTERSECT/EXCEPT is not supported in scoped mode β run each branch as a separate query',\n );\n }\n\n const fromEntries = ast.from ?? [];\n for (const entry of fromEntries) {\n if (!entry.table && entry.expr?.ast) {\n throw new Error('tenant scope: subqueries in FROM are not supported in scoped mode');\n }\n }\n\n const scopedFrom = fromEntries.filter(\n (entry): entry is FromEntry & { table: string } =>\n typeof entry.table === 'string' && this.scopedTables.has(entry.table),\n );\n if (scopedFrom.length === 0) return sql;\n\n const existing = this.collectTenantPredicates(ast.where);\n for (const predicate of existing) {\n if (predicate.value !== tenantRef) {\n throw new Error(\n 'tenant scope: tenant mismatch β query targets a tenant other than the current session',\n );\n }\n }\n\n const coveredAliases = new Set(existing.map((predicate) => predicate.tableAlias));\n for (const entry of scopedFrom) {\n const alias = entry.as ?? entry.table;\n const isAmbiguous = scopedFrom.length > 1;\n const covered = coveredAliases.has(alias) || (!isAmbiguous && coveredAliases.has(null));\n if (covered) continue;\n ast.where = this.andCondition(\n ast.where,\n this.buildTenantEquality(isAmbiguous ? alias : null, tenantRef),\n );\n }\n\n return this.parser.sqlify(ast as unknown as AST, { database: 'MySQL' });\n }\n\n private collectTenantPredicates(where: unknown): ExtractedPredicate[] {\n if (!isBinaryExpr(where)) return [];\n if (where.operator === 'AND' || where.operator === 'OR') {\n return [\n ...this.collectTenantPredicates(where.left),\n ...this.collectTenantPredicates(where.right),\n ];\n }\n if (where.operator !== '=') return [];\n const lhs = where.left;\n const rhs = where.right;\n if (!isColumnRef(lhs) || lhs.column !== this.tenantColumn) return [];\n if (!isStringLiteral(rhs)) return [];\n return [{ tableAlias: lhs.table ?? null, value: rhs.value }];\n }\n\n private buildTenantEquality(tableAlias: string | null, tenantRef: string): BinaryExpr {\n return {\n type: 'binary_expr',\n operator: '=',\n left: { type: 'column_ref', table: tableAlias, column: this.tenantColumn },\n right: { type: 'single_quote_string', value: tenantRef },\n };\n }\n\n private andCondition(existing: unknown, added: BinaryExpr): BinaryExpr {\n if (existing == null) return added;\n return {\n type: 'binary_expr',\n operator: 'AND',\n left: existing,\n right: added,\n };\n }\n}\n\nfunction isBinaryExpr(value: unknown): value is BinaryExpr {\n return (\n typeof value === 'object' &&\n value !== null &&\n (value as { type?: unknown }).type === 'binary_expr'\n );\n}\n\nfunction isColumnRef(value: unknown): value is ColumnRef {\n return (\n typeof value === 'object' &&\n value !== null &&\n (value as { type?: unknown }).type === 'column_ref'\n );\n}\n\nfunction isStringLiteral(value: unknown): value is { type: string; value: string } {\n return (\n typeof value === 'object' &&\n value !== null &&\n typeof (value as { type?: unknown }).type === 'string' &&\n STRING_LITERAL_TYPES.has((value as { type: string }).type) &&\n typeof (value as { value?: unknown }).value === 'string'\n );\n}\n","import { Parser } from 'node-sql-parser';\n\nconst parser = new Parser();\n\n/**\n * Ensures a SELECT returns at most `max` rows. If the statement already carries\n * a LIMIT it is returned unchanged; otherwise it is wrapped in a bounding\n * subquery (`SELECT * FROM (<sql>) AS subq LIMIT <max>`) so any ORDER BY /\n * GROUP BY / UNION inside `sql` is preserved.\n */\nexport function injectLimit(sql: string, max: number): string {\n const trimmed = sql.trim().replace(/;\\s*$/, '');\n if (hasLimit(trimmed)) return trimmed;\n return `SELECT * FROM (${trimmed}) AS subq LIMIT ${max}`;\n}\n\nfunction hasLimit(sql: string): boolean {\n try {\n const parsed = parser.astify(sql, { database: 'MySQL' });\n const statement = (Array.isArray(parsed) ? parsed[0] : parsed) as\n | { limit?: { value?: unknown[] } | null }\n | undefined;\n const limit = statement?.limit;\n return Boolean(limit && Array.isArray(limit.value) && limit.value.length > 0);\n } catch {\n // If we can't parse it here, fall back to wrapping β the validator already\n // ran and accepted it, so wrapping is the safe choice.\n return false;\n }\n}\n","import type { AiToolCtx, ToolHandler, ToolSpec } from '@dudousxd/nestjs-agent-core';\nimport { z } from 'zod';\nimport { injectLimit } from './limit.js';\nimport { SqlValidator } from './sql-validator.js';\nimport type { TableAccessPolicy } from './table-access.js';\nimport type { TenantScopeRewriter } from './tenant-scope.js';\n\n/** App-supplied runner over a read-only connection pool. The package never opens a DB. */\nexport interface QueryRunner {\n run(sql: string): Promise<Record<string, unknown>[]>;\n}\n\n/** Dependencies for {@link createExecuteSqlTool}. */\nexport interface ExecuteSqlDeps {\n /** Runs the (already validated, scoped, and limited) SQL against the read-only pool. */\n runner: QueryRunner;\n /** Single-SELECT validator. Defaults to a fresh {@link SqlValidator}. */\n validator?: SqlValidator;\n /** Coarse table-level allowlist, checked for every referenced table. */\n tableAccess: TableAccessPolicy;\n /** Optional per-row tenant constraint applied before the query runs. */\n tenantScope?: TenantScopeRewriter;\n /** Row cap injected when the query has no LIMIT. Defaults to 100. */\n maxRows?: number;\n}\n\nexport interface ExecuteSqlResult {\n rows: Record<string, unknown>[];\n rowCount: number;\n sql: string;\n truncated?: true;\n}\n\nconst DEFAULT_MAX_ROWS = 100;\n\n/** Serialized rows above this size are truncated so a single tool result can't blow the context. */\nconst MAX_RESULT_BYTES = 256 * 1024;\n\nconst inputSchema = z.object({\n sql: z.string().min(1).describe('A single read-only MySQL SELECT statement'),\n});\n\ntype ExecuteSqlInput = z.infer<typeof inputSchema>;\n\nconst DESCRIPTION =\n 'Execute a single read-only MySQL SELECT statement. Use this to answer questions about real ' +\n 'data. Only SELECT is allowed (no INSERT/UPDATE/DELETE/DDL). Access is restricted to the ' +\n 'tables your role is permitted to read, results are capped, and tenant-scoped tables are ' +\n 'automatically constrained to your current tenant.';\n\n/**\n * Builds the governed `executeSql` read tool. The pipeline per call is:\n * validate (single SELECT) β assert table access for every referenced table β\n * tenant-scope rewrite (if configured) β inject LIMIT β run β return rows,\n * truncating the payload if it would exceed ~256KB.\n */\nexport function createExecuteSqlTool(deps: ExecuteSqlDeps): {\n spec: ToolSpec;\n handler: ToolHandler<ExecuteSqlInput>;\n} {\n const validator = deps.validator ?? new SqlValidator();\n const maxRows = deps.maxRows ?? DEFAULT_MAX_ROWS;\n\n const spec: ToolSpec = {\n name: 'executeSql',\n kind: 'read',\n description: DESCRIPTION,\n inputSchema,\n };\n\n const handler: ToolHandler<ExecuteSqlInput> = {\n async execute(input: ExecuteSqlInput, ctx: AiToolCtx): Promise<ExecuteSqlResult> {\n const { tables } = validator.validate(input.sql);\n\n const roles = ctx.actor.roles ?? [];\n const forbidden = tables.filter((table) => !deps.tableAccess.canAccess(roles, table));\n if (forbidden.length > 0) {\n const formatted = forbidden.map((table) => `\\`${table}\\``).join(', ');\n const rolesLabel = roles.length > 0 ? roles.join(', ') : 'none';\n throw new Error(`Your roles (${rolesLabel}) are not allowed to query ${formatted}.`);\n }\n\n let sql = input.sql;\n if (deps.tenantScope) {\n sql = deps.tenantScope.rewrite(sql, ctx.actor.tenantRef);\n }\n sql = injectLimit(sql, maxRows);\n\n const rows = await deps.runner.run(sql);\n return buildResult(rows, sql);\n },\n };\n\n return { spec, handler };\n}\n\nfunction buildResult(rows: Record<string, unknown>[], sql: string): ExecuteSqlResult {\n const rowCount = rows.length;\n if (byteLength(rows) <= MAX_RESULT_BYTES) {\n return { rows, rowCount, sql };\n }\n\n // Keep prefix rows until the serialized payload would exceed the cap.\n const kept: Record<string, unknown>[] = [];\n let size = 2; // opening + closing bracket of the JSON array\n for (const row of rows) {\n const rowSize = byteLength(row) + 1; // +1 for the joining comma\n if (size + rowSize > MAX_RESULT_BYTES) break;\n kept.push(row);\n size += rowSize;\n }\n\n return { rows: kept, rowCount, sql, truncated: true };\n}\n\nfunction byteLength(value: unknown): number {\n return Buffer.byteLength(JSON.stringify(value) ?? '', 'utf8');\n}\n"],"mappings":"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","names":["Parser","SqlValidationError","Error","message","name","FORBIDDEN_AST_TYPES","Set","SqlValidator","parser","Parser","validate","sql","parsed","astify","database","err","statements","Array","isArray","length","statement","type","toLowerCase","includes","has","toUpperCase","tables","extractReferencedTables","raw","tableList","entry","parts","split","table","add","from","GroupTableAccessPolicy","roleGroups","tablesByGroup","config","canAccess","roles","table","group","resolveGroup","undefined","some","role","includes","patterns","Object","entries","pattern","matchesPattern","endsWith","startsWith","slice","Parser","STRING_LITERAL_TYPES","Set","TenantScopeRewriter","parser","Parser","tenantColumn","scopedTables","config","rewrite","sql","tenantRef","undefined","parsed","astify","database","ast","Array","isArray","type","Error","with","_next","fromEntries","from","entry","table","expr","scopedFrom","filter","has","length","existing","collectTenantPredicates","where","predicate","value","coveredAliases","map","tableAlias","alias","as","isAmbiguous","covered","andCondition","buildTenantEquality","sqlify","isBinaryExpr","operator","left","right","lhs","rhs","isColumnRef","column","isStringLiteral","added","Parser","parser","Parser","injectLimit","sql","max","trimmed","trim","replace","hasLimit","parsed","astify","database","statement","Array","isArray","limit","Boolean","value","length","z","DEFAULT_MAX_ROWS","MAX_RESULT_BYTES","inputSchema","z","object","sql","string","min","describe","DESCRIPTION","createExecuteSqlTool","deps","validator","SqlValidator","maxRows","spec","name","kind","description","handler","execute","input","ctx","tables","validate","roles","actor","forbidden","filter","table","tableAccess","canAccess","length","formatted","map","join","rolesLabel","Error","tenantScope","rewrite","tenantRef","injectLimit","rows","runner","run","buildResult","rowCount","byteLength","kept","size","row","rowSize","push","truncated","value","Buffer","JSON","stringify"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dudousxd/nestjs-agent-data",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "nestjs-agent data β governed read-only SQL satellite (single-SELECT validation, fail-closed table access, tenant scoping)",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Davide Carvalho",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./dist/index.cjs",
|
|
9
|
+
"module": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"import": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"default": "./dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"require": {
|
|
18
|
+
"types": "./dist/index.d.cts",
|
|
19
|
+
"default": "./dist/index.cjs"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist"
|
|
25
|
+
],
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"node-sql-parser": "5.4.0",
|
|
28
|
+
"zod": "3.25.76",
|
|
29
|
+
"@dudousxd/nestjs-agent-core": "0.1.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"tsup": "8.3.5",
|
|
33
|
+
"typescript": "5.9.3",
|
|
34
|
+
"@dudousxd/nestjs-agent-core": "0.1.0"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsup",
|
|
38
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
39
|
+
}
|
|
40
|
+
}
|