import { ToolSpec, ToolHandler } from '@dudousxd/nestjs-agent-core'; import { z } from 'zod'; /** * Thrown when a statement is not a single, read-only SELECT — i.e. it is an * INSERT/UPDATE/DELETE, DDL, a CALL, a multi-statement string, or it fails to * parse. The handler surfaces `.message` to the model so it can re-plan. */ declare class SqlValidationError extends Error { constructor(message: string); } interface SqlValidationResult { /** Every base table the statement references (CTEs, joins, subqueries walked). */ tables: string[]; } /** * Parses SQL (MySQL dialect) and asserts it is a single SELECT, returning the * set of tables it touches. Domain-agnostic: it knows nothing about which * tables a caller may read — that is the `TableAccessPolicy`'s job. */ declare class SqlValidator { private readonly parser; /** * Throws `SqlValidationError` unless `sql` is exactly one SELECT statement. * On success returns the distinct base table names referenced. */ validate(sql: string): SqlValidationResult; /** * Distinct base table names a statement touches — walking CTEs, subqueries, * and joins. Backed by `node-sql-parser`'s `tableList`, which emits * `mode::db::table` strings; the table name is the last segment. */ private extractReferencedTables; } /** * Decides whether a role may read a given table at all. This is the coarse, * table-level gate; per-row scoping (see `TenantScopeRewriter`) is a separate * layer applied at query time. * * Domain-agnostic: the host app supplies the role→group and group→table maps. */ interface TableAccessPolicy { /** True iff any of the caller's `roles` is permitted to read `table`. Fail-closed by contract. */ canAccess(roles: readonly string[], table: string): boolean; } /** Inputs for {@link GroupTableAccessPolicy}: roles map to groups, groups to tables. */ interface GroupTableAccessConfig { /** Role name → the table groups that role may read. */ roleGroups: Record; /** * Group name → the tables in that group. Entries are exact table names or * `prefix_*` patterns (e.g. `pribuy_*`). */ tablesByGroup: Record; } /** * Two-layer, data-driven table allowlist: * * 1. Every table is classified into a group (`tablesByGroup`). * 2. Every role lists the groups it can read (`roleGroups`). * * `canAccess(roles, table)` is then "is the table's group in ANY of the roles' * group lists?". **Fail-closed:** an unclassified table, unknown roles, or an * empty role set is denied — a forgotten table never accidentally leaks. */ declare class GroupTableAccessPolicy implements TableAccessPolicy { private readonly roleGroups; private readonly tablesByGroup; constructor(config: GroupTableAccessConfig); canAccess(roles: readonly string[], table: string): boolean; /** Resolve a table to its group, or `undefined` if unclassified (fail-closed). */ private resolveGroup; } /** Configuration for {@link TenantScopeRewriter}. */ interface TenantScopeConfig { /** The column that carries the tenant key on every scoped table (e.g. `base_id`, `org_id`). */ tenantColumn: string; /** Tables that must be constrained to the caller's tenant when referenced. */ scopedTables: string[]; /** * What a query on a scoped table does when the caller has no `tenantRef`: `'reject'` (default) * refuses it; `'passthrough'` runs it unscoped — the privileged path, for an app where a missing * tenant means "sees every tenant" (an internal admin). A query that touches no scoped table runs * either way. */ onMissingTenant?: 'reject' | 'passthrough'; } /** * Rewrites a SELECT so every reference to a scoped table is constrained to a * single tenant: the SELECT's WHERE is parenthesised whole and * `. = ''` is AND-ed to it, for every scoped * table in its FROM — always, even when the query already names the tenant * (an `OR` can make such a predicate constrain nothing). This applies to each * SELECT that reads a scoped table — the top-level one and every * subquery (in the select list, WHERE, HAVING, a JOIN's ON …). An existing * predicate for a different tenant is rejected (no cross-tenant reads). * * `tenantRef === undefined` on a query that reads a scoped table is refused, * unless `onMissingTenant: 'passthrough'` makes it the privileged path that runs * the SQL unchanged. * * Scoped mode rejects CTEs, UNION/INTERSECT/EXCEPT, and subqueries in FROM, at * any depth: those make it impossible to statically guarantee every * tenant-bearing source is constrained, so we fail closed and ask the caller to * rephrase. */ declare class TenantScopeRewriter { private readonly parser; private readonly tenantColumn; private readonly scopedTables; private readonly onMissingTenant; constructor(config: TenantScopeConfig); /** * Rewrite `sql` to constrain scoped tables to `tenantRef`. Undefined → refused when the query * reads a scoped table, unless `onMissingTenant: 'passthrough'`. */ rewrite(sql: string, tenantRef: string | undefined): string; /** Constrain one SELECT's own FROM — its subqueries are scoped on their own. */ private scopeSelect; private collectTenantPredicates; private buildTenantEquality; private andCondition; } /** * Ensures a SELECT returns at most `max` rows. A statement whose own LIMIT is * already within `max` is returned unchanged; otherwise — no LIMIT, or one above * `max` — it is wrapped in a bounding subquery * (`SELECT * FROM () AS subq LIMIT `) so any ORDER BY / GROUP BY / * UNION / OFFSET inside `sql` is preserved. */ declare function injectLimit(sql: string, max: number): string; /** App-supplied runner over a read-only connection pool. The package never opens a DB. */ interface QueryRunner { run(sql: string): Promise[]>; } /** Dependencies for {@link createExecuteSqlTool}. */ interface ExecuteSqlDeps { /** Runs the (already validated, scoped, and limited) SQL against the read-only pool. */ runner: QueryRunner; /** Single-SELECT validator. Defaults to a fresh {@link SqlValidator}. */ validator?: SqlValidator; /** Coarse table-level allowlist, checked for every referenced table. */ tableAccess: TableAccessPolicy; /** Optional per-row tenant constraint applied before the query runs. */ tenantScope?: TenantScopeRewriter; /** Row cap: injected when the query has no LIMIT, enforced over a larger one. Defaults to 100. */ maxRows?: number; } interface ExecuteSqlResult { rows: Record[]; rowCount: number; sql: string; truncated?: true; } declare const inputSchema: z.ZodObject<{ sql: z.ZodString; }, "strip", z.ZodTypeAny, { sql: string; }, { sql: string; }>; type ExecuteSqlInput = z.infer; /** * Builds the governed `executeSql` read tool. The pipeline per call is: * validate (single SELECT) → assert table access for every referenced table → * tenant-scope rewrite (if configured) → inject LIMIT → run → return rows, * truncating the payload if it would exceed ~256KB. */ declare function createExecuteSqlTool(deps: ExecuteSqlDeps): { spec: ToolSpec; handler: ToolHandler; }; export { type ExecuteSqlDeps, type ExecuteSqlResult, type GroupTableAccessConfig, GroupTableAccessPolicy, type QueryRunner, SqlValidationError, type SqlValidationResult, SqlValidator, type TableAccessPolicy, type TenantScopeConfig, TenantScopeRewriter, createExecuteSqlTool, injectLimit };