ktx/packages/cli/src/sql.ts
Luca Martial a651b82e2f
feat: query_policy semantic-layer-only restricts agents to predefined semantic-layer measures (#334)
* feat(sl): add predefined_measures_only guard to semantic query planning

SemanticQuery gains a predefined_measures_only flag; the planner rejects
any measure resolved with Provenance.COMPOSED (runtime aggregate
expressions and query-time derivations) while predefined measures,
predefined derived chains, dimensions, filters, and segments pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(config): add per-connection query_policy to warehouse connections

query_policy: semantic-layer-only | read-only-sql (default) on the
warehouse connection schema, plus a policy module with the raw-SQL
guard, federated member restriction lookup, and the project-level
predicate used to gate sql_execution registration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(cli): enforce query_policy on raw SQL through one shared executor

ktx sql and the MCP sql_execution tool now share executeProjectRawSql
(resolve, policy check, read-only validation, execute), collapsing
their duplicated validate-then-execute paths. Restricted connections
are rejected before validation; federated raw SQL is rejected when any
member is restricted. sql_execution is not registered when every SQL
connection is restricted, and connection_list marks restricted
connections so agents route to sl_query. executeProjectReadOnlySql
stays generic for ktx-internal SQL (scan, ingest, SL-generated).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(sl): compile queries with predefined_measures_only from query_policy

compileLocalSlQuery injects the flag from the connection's query_policy,
never from caller input, covering both ktx sl query and the MCP
sl_query tool through the daemon compile path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: document query_policy semantic-layer-only

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sl): close semantic-layer-only bypasses via filters and federated hint

The predefined_measures_only guard only inspected query.measures, so a
composed aggregate written into `filters` slipped through _classify_filters
into a HAVING clause untouched — letting a restricted agent evaluate
arbitrary aggregates (e.g. threshold-probing `sum(x) BETWEEN a AND b`).
Reject filter clauses that compose an aggregate function; a HAVING that
compares a predefined measure by name (`orders.revenue > 100`) still works.

Also make the federated sl_query error policy-aware: when a member is
restricted, raw federated SQL is disabled too, so stop directing the agent
to `ktx sql -c _ktx_federated` / sql_execution (a guaranteed failure) and
point to per-connection semantic-layer queries instead.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Andrey Avtomonov <andreybavt@gmail.com>
2026-07-03 08:54:17 +00:00

219 lines
7.8 KiB
TypeScript

import { executeFederatedQuery } from './connectors/duckdb/federated-executor.js';
import { FEDERATED_CONNECTION_ID } from './context/connections/federation.js';
import { executeProjectRawSql } from './context/connections/project-sql-executor.js';
import type { KtxSqlQueryExecutionResult } from './context/connections/query-executor.js';
import { assertSqlQueryableConnection } from './context/connections/dialects.js';
import { resolveConfiguredConnection } from './context/connections/resolve-connection.js';
import { loadKtxProject, type KtxLocalProject } from './context/project/project.js';
import { sqlAnalysisDialectForDriver } from './context/sql-analysis/dialect.js';
import type { SqlAnalysisDialect, SqlAnalysisPort } from './context/sql-analysis/ports.js';
import type { KtxCliIo } from './cli-runtime.js';
import { type KtxOutputMode, resolveOutputMode } from './io/mode.js';
import { createKtxCliScanConnector } from './local-scan-connectors.js';
import { createManagedDaemonSqlAnalysisPort } from './managed-python-http.js';
import { profileMark } from './startup-profile.js';
import { isDemoConnection } from './telemetry/demo-detect.js';
import { emitTelemetryEvent, reportException } from './telemetry/index.js';
import { collectTelemetryRedactionSecrets } from './telemetry/redaction-secrets.js';
import { scrubErrorClass } from './telemetry/scrubber.js';
profileMark('module:sql');
type KtxSqlOutputMode = KtxOutputMode;
export type KtxSqlArgs = {
command: 'execute';
projectDir: string;
connectionId: string;
sql: string;
maxRows: number;
output?: KtxSqlOutputMode;
json?: boolean;
cliVersion: string;
};
export interface KtxSqlDeps {
loadProject?: typeof loadKtxProject;
createSqlAnalysis?: () => SqlAnalysisPort;
createScanConnector?: typeof createKtxCliScanConnector;
executeFederated?: typeof executeFederatedQuery;
}
interface SqlExecutionOutput {
connectionId: string;
headers: string[];
headerTypes?: string[];
rows: unknown[][];
rowCount: number;
}
function queryVerb(sql: string): 'select' | 'explain' | 'show' | 'with' | 'other' {
const first = sql.trim().split(/\s+/, 1)[0]?.toLowerCase();
if (first === 'select' || first === 'explain' || first === 'show' || first === 'with') {
return first;
}
return 'other';
}
async function safeReferencedTableCount(
port: SqlAnalysisPort,
sql: string,
dialect: SqlAnalysisDialect,
): Promise<number> {
try {
const results = await port.analyzeBatch([{ id: 'cli-sql', sql }], dialect);
return results.get('cli-sql')?.tablesTouched.length ?? 0;
} catch {
return 0;
}
}
function formatValue(value: unknown): string {
if (value === null || value === undefined) return '';
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') return String(value);
return JSON.stringify(value);
}
function printJson(output: SqlExecutionOutput, io: KtxCliIo): void {
io.stdout.write(`${JSON.stringify(output, null, 2)}\n`);
}
function printPlain(output: SqlExecutionOutput, io: KtxCliIo): void {
io.stdout.write(`${output.headers.join('\t')}\n`);
for (const row of output.rows) {
io.stdout.write(`${row.map(formatValue).join('\t')}\n`);
}
}
function printPretty(output: SqlExecutionOutput, io: KtxCliIo): void {
const rows = output.rows.map((row) => row.map(formatValue));
const widths = output.headers.map((header, index) =>
Math.max(header.length, ...rows.map((row) => row[index]?.length ?? 0)),
);
const renderRow = (cells: string[]): string =>
cells.map((cell, index) => cell.padEnd(widths[index] ?? cell.length)).join(' ').trimEnd();
if (output.headers.length > 0) {
io.stdout.write(`${renderRow(output.headers)}\n`);
io.stdout.write(`${renderRow(widths.map((width) => '-'.repeat(width)))}\n`);
}
for (const row of rows) {
io.stdout.write(`${renderRow(row)}\n`);
}
io.stdout.write(`\n${output.rowCount} ${output.rowCount === 1 ? 'row' : 'rows'}\n`);
}
function printSqlResult(output: SqlExecutionOutput, mode: KtxSqlOutputMode, io: KtxCliIo): void {
if (mode === 'json') {
printJson(output, io);
return;
}
if (mode === 'plain') {
printPlain(output, io);
return;
}
printPretty(output, io);
}
function resultOutput(connectionId: string, result: KtxSqlQueryExecutionResult): SqlExecutionOutput {
return {
connectionId,
headers: result.headers,
...(result.headerTypes ? { headerTypes: result.headerTypes } : {}),
rows: result.rows,
rowCount: result.rowCount ?? result.rows.length,
};
}
export async function runKtxSql(args: KtxSqlArgs, io: KtxCliIo = process, deps: KtxSqlDeps = {}): Promise<number> {
const startedAt = performance.now();
let driver = 'unknown';
let demoConnection = false;
let project: KtxLocalProject | undefined;
try {
project = await (deps.loadProject ?? loadKtxProject)({ projectDir: args.projectDir });
const isFederated = args.connectionId === FEDERATED_CONNECTION_ID;
const connection = isFederated ? undefined : resolveConfiguredConnection(project.config, args.connectionId);
driver = isFederated ? 'duckdb' : String(connection?.driver ?? 'unknown').toLowerCase();
demoConnection = isFederated ? false : isDemoConnection(args.connectionId, connection);
// Fail fast before creating the SQL-analysis daemon port; executeProjectRawSql
// re-asserts this for every caller.
if (!isFederated) {
assertSqlQueryableConnection(args.connectionId, connection?.driver);
}
const createSqlAnalysis =
deps.createSqlAnalysis ??
(() =>
createManagedDaemonSqlAnalysisPort({
cliVersion: args.cliVersion,
projectDir: args.projectDir,
installPolicy: 'auto',
io,
}));
const analysisPort = createSqlAnalysis();
const dialect: SqlAnalysisDialect = isFederated ? 'duckdb' : sqlAnalysisDialectForDriver(connection?.driver);
const createScanConnector = deps.createScanConnector ?? createKtxCliScanConnector;
const result = await executeProjectRawSql({
project,
connectionId: args.connectionId,
sql: args.sql,
maxRows: args.maxRows,
sqlAnalysis: analysisPort,
createConnector: (connectionId) => createScanConnector(project!, connectionId),
executeFederated: deps.executeFederated,
runId: 'cli-sql',
});
const referencedTableCount = await safeReferencedTableCount(analysisPort, args.sql, dialect);
const mode = resolveOutputMode({ explicit: args.output, json: args.json, io });
printSqlResult(resultOutput(args.connectionId, result), mode, io);
await emitTelemetryEvent({
name: 'sql_completed',
projectDir: args.projectDir,
io,
fields: {
driver,
isDemoConnection: demoConnection,
queryVerb: queryVerb(args.sql),
referencedTableCount,
durationMs: Math.max(0, performance.now() - startedAt),
outcome: 'ok',
},
});
return 0;
} catch (error) {
const errorClass = scrubErrorClass(error);
await emitTelemetryEvent({
name: 'sql_completed',
projectDir: args.projectDir,
io,
fields: {
driver,
isDemoConnection: demoConnection,
queryVerb: queryVerb(args.sql),
referencedTableCount: 0,
durationMs: Math.max(0, performance.now() - startedAt),
outcome: 'error',
...(errorClass ? { errorClass } : {}),
},
});
await reportException({
error,
context: { source: 'sql run', handled: true, fatal: false },
projectDir: args.projectDir,
io,
redactionSecrets: await collectTelemetryRedactionSecrets({
project,
projectDir: args.projectDir,
connectionId: args.connectionId,
includeLlm: false,
includeEmbeddings: false,
env: process.env,
}),
});
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
return 1;
}
}