taurusdb-core 0.4.0 → 0.5.0-rc.1
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 +3 -1
- package/dist/audit/jsonl-writer.d.ts +47 -0
- package/dist/audit/jsonl-writer.js +124 -0
- package/dist/auth/sql-profile-loader/env-source.js +10 -0
- package/dist/auth/sql-profile-loader/parsing.js +10 -1
- package/dist/auth/sql-profile-loader/runtime-override.d.ts +1 -0
- package/dist/auth/sql-profile-loader/runtime-override.js +19 -1
- package/dist/auth/sql-profile-loader/types.d.ts +7 -0
- package/dist/capability/probe.js +3 -1
- package/dist/cloud/auth.d.ts +1 -0
- package/dist/cloud/auth.js +37 -0
- package/dist/config/env.js +14 -0
- package/dist/config/schema.d.ts +68 -0
- package/dist/config/schema.js +17 -0
- package/dist/context/datasource-resolver.js +7 -0
- package/dist/context/session-context.d.ts +7 -0
- package/dist/diagnostics/replication-lag.d.ts +4 -0
- package/dist/diagnostics/replication-lag.js +138 -0
- package/dist/diagnostics/types.d.ts +5 -1
- package/dist/diagnostics/types.js +7 -7
- package/dist/engine/runtime.d.ts +2 -2
- package/dist/engine/runtime.js +8 -14
- package/dist/engine/types.d.ts +3 -2
- package/dist/engine.d.ts +4 -3
- package/dist/engine.js +34 -1
- package/dist/executor/bounded-sql.d.ts +1 -0
- package/dist/executor/bounded-sql.js +10 -0
- package/dist/executor/concurrency-limiter.d.ts +15 -0
- package/dist/executor/concurrency-limiter.js +67 -0
- package/dist/executor/connection-pool.d.ts +3 -2
- package/dist/executor/connection-pool.js +36 -24
- package/dist/executor/sql-executor.d.ts +3 -0
- package/dist/executor/sql-executor.js +52 -9
- package/dist/executor/types.d.ts +5 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.js +4 -1
- package/dist/safety/confirmation-store.d.ts +30 -7
- package/dist/safety/confirmation-store.js +154 -30
- package/dist/safety/guardrail.d.ts +3 -0
- package/dist/safety/guardrail.js +16 -6
- package/dist/safety/redaction.d.ts +6 -0
- package/dist/safety/redaction.js +47 -6
- package/dist/safety/sql-classifier.d.ts +1 -0
- package/dist/safety/sql-classifier.js +1 -0
- package/dist/safety/sql-validator.d.ts +1 -0
- package/dist/safety/sql-validator.js +24 -0
- package/dist/schema/adapters/mysql.js +3 -1
- package/dist/taurus/flashback.js +4 -10
- package/dist/taurus/recycle-bin.js +2 -8
- package/dist/utils/formatter.d.ts +6 -2
- package/dist/utils/formatter.js +7 -3
- package/dist/utils/logger.js +8 -0
- package/dist/utils/mysql-identifier.d.ts +1 -0
- package/dist/utils/mysql-identifier.js +9 -0
- package/package.json +13 -2
|
@@ -11,6 +11,9 @@ export type SqlExecutorOptions = {
|
|
|
11
11
|
historyLimit?: number;
|
|
12
12
|
queryTracker?: QueryTracker;
|
|
13
13
|
resultRedactor?: ResultRedactor;
|
|
14
|
+
maxConcurrentQueries?: number;
|
|
15
|
+
maxQueuedQueries?: number;
|
|
16
|
+
queueTimeoutMs?: number;
|
|
14
17
|
};
|
|
15
18
|
export declare class SqlExecutorImpl implements SqlExecutor {
|
|
16
19
|
private readonly connectionPool;
|
|
@@ -3,6 +3,22 @@ import { buildExplainRecommendations, normalizeExplainRows, summarizeExplainRows
|
|
|
3
3
|
import { createQueryTracker, } from "./query-tracker.js";
|
|
4
4
|
import { createResultRedactor, } from "../safety/redaction.js";
|
|
5
5
|
import { asFiniteNumber, inferColumns, normalizeRows } from "./result-normalizer.js";
|
|
6
|
+
import { QueryConcurrencyLimiter } from "./concurrency-limiter.js";
|
|
7
|
+
import { buildServerBoundedReadonlySql } from "./bounded-sql.js";
|
|
8
|
+
function isTimeoutError(error) {
|
|
9
|
+
return /timeout|timed out/i.test(error instanceof Error ? error.message : String(error));
|
|
10
|
+
}
|
|
11
|
+
async function cancelTimedOutSession(session, error) {
|
|
12
|
+
if (!isTimeoutError(error)) {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
await session.cancel();
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
// Keep the original timeout error.
|
|
20
|
+
}
|
|
21
|
+
}
|
|
6
22
|
export class SqlExecutorImpl {
|
|
7
23
|
connectionPool;
|
|
8
24
|
now;
|
|
@@ -25,7 +41,9 @@ export class SqlExecutorImpl {
|
|
|
25
41
|
async explain(sql, ctx) {
|
|
26
42
|
const queryId = this.queryIdGenerator();
|
|
27
43
|
const startedAt = this.now();
|
|
28
|
-
const session = await this.connectionPool.acquire(ctx.datasource, "ro"
|
|
44
|
+
const session = await this.connectionPool.acquire(ctx.datasource, "ro", {
|
|
45
|
+
database: ctx.database,
|
|
46
|
+
});
|
|
29
47
|
const active = this.beginQuery(queryId, session, ctx, "ro", startedAt);
|
|
30
48
|
try {
|
|
31
49
|
const result = await session.execute(`EXPLAIN ${sql}`, {
|
|
@@ -45,6 +63,7 @@ export class SqlExecutorImpl {
|
|
|
45
63
|
};
|
|
46
64
|
}
|
|
47
65
|
catch (error) {
|
|
66
|
+
await cancelTimedOutSession(session, error);
|
|
48
67
|
const durationMs = this.now() - startedAt;
|
|
49
68
|
this.completeQuery(active.queryId, active.cancelRequested ? "cancelled" : "failed", durationMs, error);
|
|
50
69
|
throw error;
|
|
@@ -59,11 +78,16 @@ export class SqlExecutorImpl {
|
|
|
59
78
|
const maxRows = opts.maxRows ?? ctx.limits.maxRows;
|
|
60
79
|
const maxColumns = opts.maxColumns ?? ctx.limits.maxColumns;
|
|
61
80
|
const maxFieldChars = opts.maxFieldChars ?? ctx.limits.maxFieldChars ?? 2048;
|
|
81
|
+
const maxResultBytes = opts.maxResultBytes ?? ctx.limits.maxResultBytes ?? 1048576;
|
|
82
|
+
const maxBlobBytes = opts.maxBlobBytes ?? ctx.limits.maxBlobBytes ?? 65536;
|
|
62
83
|
const timeoutMs = opts.timeoutMs ?? ctx.limits.timeoutMs;
|
|
63
|
-
const session = await this.connectionPool.acquire(ctx.datasource, "ro"
|
|
84
|
+
const session = await this.connectionPool.acquire(ctx.datasource, "ro", {
|
|
85
|
+
database: ctx.database,
|
|
86
|
+
});
|
|
64
87
|
const active = this.beginQuery(queryId, session, ctx, "ro", startedAt);
|
|
65
88
|
try {
|
|
66
|
-
const
|
|
89
|
+
const executionSql = buildServerBoundedReadonlySql(sql, maxRows);
|
|
90
|
+
const result = await session.execute(executionSql, { timeoutMs });
|
|
67
91
|
const sourceRows = Array.isArray(result.rows) ? result.rows : [];
|
|
68
92
|
const columns = inferColumns(result, sourceRows);
|
|
69
93
|
const normalizedRows = normalizeRows(sourceRows, columns);
|
|
@@ -76,6 +100,9 @@ export class SqlExecutorImpl {
|
|
|
76
100
|
maxRows,
|
|
77
101
|
maxColumns,
|
|
78
102
|
maxFieldChars,
|
|
103
|
+
maxResultBytes,
|
|
104
|
+
maxBlobBytes,
|
|
105
|
+
maskAllColumns: opts.maskAllColumns,
|
|
79
106
|
sensitiveColumns: opts.sensitiveColumns,
|
|
80
107
|
sensitiveStrategy: opts.sensitiveStrategy,
|
|
81
108
|
});
|
|
@@ -91,6 +118,8 @@ export class SqlExecutorImpl {
|
|
|
91
118
|
rowTruncated: redacted.rowTruncated,
|
|
92
119
|
columnTruncated: redacted.columnTruncated,
|
|
93
120
|
fieldTruncated: redacted.fieldTruncated,
|
|
121
|
+
byteTruncated: redacted.byteTruncated,
|
|
122
|
+
returnedBytes: redacted.returnedBytes,
|
|
94
123
|
redactedColumns: redacted.redactedColumns,
|
|
95
124
|
droppedColumns: redacted.droppedColumns,
|
|
96
125
|
truncatedColumns: redacted.truncatedColumns,
|
|
@@ -98,6 +127,7 @@ export class SqlExecutorImpl {
|
|
|
98
127
|
};
|
|
99
128
|
}
|
|
100
129
|
catch (error) {
|
|
130
|
+
await cancelTimedOutSession(session, error);
|
|
101
131
|
const durationMs = this.now() - startedAt;
|
|
102
132
|
this.completeQuery(active.queryId, active.cancelRequested ? "cancelled" : "failed", durationMs, error);
|
|
103
133
|
throw error;
|
|
@@ -114,7 +144,7 @@ export class SqlExecutorImpl {
|
|
|
114
144
|
const startedAt = this.now();
|
|
115
145
|
const timeoutMs = opts.timeoutMs ?? ctx.limits.timeoutMs;
|
|
116
146
|
const session = await this.connectionPool.acquire(ctx.datasource, "rw", {
|
|
117
|
-
|
|
147
|
+
database: ctx.database,
|
|
118
148
|
});
|
|
119
149
|
const active = this.beginQuery(queryId, session, ctx, "rw", startedAt);
|
|
120
150
|
try {
|
|
@@ -133,11 +163,16 @@ export class SqlExecutorImpl {
|
|
|
133
163
|
};
|
|
134
164
|
}
|
|
135
165
|
catch (error) {
|
|
136
|
-
|
|
137
|
-
await session
|
|
166
|
+
if (isTimeoutError(error)) {
|
|
167
|
+
await cancelTimedOutSession(session, error);
|
|
138
168
|
}
|
|
139
|
-
|
|
140
|
-
|
|
169
|
+
else {
|
|
170
|
+
try {
|
|
171
|
+
await session.execute("ROLLBACK", { timeoutMs });
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
// Ignore rollback failure and keep original error.
|
|
175
|
+
}
|
|
141
176
|
}
|
|
142
177
|
const durationMs = this.now() - startedAt;
|
|
143
178
|
this.completeQuery(active.queryId, active.cancelRequested ? "cancelled" : "failed", durationMs, error);
|
|
@@ -245,5 +280,13 @@ export class SqlExecutorImpl {
|
|
|
245
280
|
}
|
|
246
281
|
}
|
|
247
282
|
export function createSqlExecutor(options) {
|
|
248
|
-
|
|
283
|
+
const executor = new SqlExecutorImpl(options);
|
|
284
|
+
const limiter = new QueryConcurrencyLimiter(options.maxConcurrentQueries ?? 8, options.maxQueuedQueries ?? 32, options.queueTimeoutMs ?? 5000);
|
|
285
|
+
return {
|
|
286
|
+
explain: (sql, ctx) => limiter.run(() => executor.explain(sql, ctx)),
|
|
287
|
+
executeReadonly: (sql, ctx, opts) => limiter.run(() => executor.executeReadonly(sql, ctx, opts)),
|
|
288
|
+
executeMutation: (sql, ctx, opts) => limiter.run(() => executor.executeMutation(sql, ctx, opts)),
|
|
289
|
+
getQueryStatus: (queryId) => executor.getQueryStatus(queryId),
|
|
290
|
+
cancelQuery: (queryId) => executor.cancelQuery(queryId),
|
|
291
|
+
};
|
|
249
292
|
}
|
package/dist/executor/types.d.ts
CHANGED
|
@@ -9,13 +9,15 @@ export interface ReadonlyOptions {
|
|
|
9
9
|
maxRows?: number;
|
|
10
10
|
maxColumns?: number;
|
|
11
11
|
maxFieldChars?: number;
|
|
12
|
+
maxResultBytes?: number;
|
|
13
|
+
maxBlobBytes?: number;
|
|
14
|
+
maskAllColumns?: boolean;
|
|
12
15
|
timeoutMs?: number;
|
|
13
16
|
sensitiveColumns?: Iterable<string>;
|
|
14
17
|
sensitiveStrategy?: SensitiveStrategy;
|
|
15
18
|
}
|
|
16
19
|
export interface MutationOptions {
|
|
17
20
|
timeoutMs?: number;
|
|
18
|
-
allowReadonlyFallbackForMutations?: boolean;
|
|
19
21
|
}
|
|
20
22
|
export interface QueryResult {
|
|
21
23
|
queryId: string;
|
|
@@ -27,6 +29,8 @@ export interface QueryResult {
|
|
|
27
29
|
rowTruncated: boolean;
|
|
28
30
|
columnTruncated: boolean;
|
|
29
31
|
fieldTruncated: boolean;
|
|
32
|
+
byteTruncated: boolean;
|
|
33
|
+
returnedBytes: number;
|
|
30
34
|
redactedColumns: string[];
|
|
31
35
|
droppedColumns: string[];
|
|
32
36
|
truncatedColumns: string[];
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { TaurusDBEngine } from "./engine.js";
|
|
2
2
|
export type { EnhancedExplainResult, ConfirmationOutcome, DataSourceInfo, IssueConfirmationInput, TaurusDBEngineCreateOptions, TaurusDBEngineDeps, } from "./engine.js";
|
|
3
|
+
export { createJsonlAuditWriter, JsonlAuditWriter, type AuditDecision, type AuditEvent, type AuditWriter, type JsonlAuditWriterOptions, } from "./audit/jsonl-writer.js";
|
|
3
4
|
export { createConfigFromEnv, getConfig, redactConfigForLog, resetConfigForTests, } from "./config/index.js";
|
|
4
5
|
export type { Config } from "./config/index.js";
|
|
5
6
|
export { createSqlProfileLoader, RuntimeOverrideProfileLoader, } from "./auth/sql-profile-loader.js";
|
|
@@ -7,13 +8,15 @@ export type { DataSourceProfile, DatabaseEngine, ProfileLoader, RuntimeDataSourc
|
|
|
7
8
|
export { DatasourceResolutionError } from "./context/datasource-resolver.js";
|
|
8
9
|
export type { DatasourceResolveInput, DatasourceResolver, RuntimeLimits, SessionContext, } from "./context/session-context.js";
|
|
9
10
|
export { ConnectionPoolError } from "./executor/connection-pool.js";
|
|
11
|
+
export { QueryConcurrencyError } from "./executor/concurrency-limiter.js";
|
|
12
|
+
export { buildServerBoundedReadonlySql } from "./executor/bounded-sql.js";
|
|
10
13
|
export type { CancelResult, ExplainResult, MutationOptions, MutationResult, QueryResult, QueryStatus, ReadonlyOptions, SqlExecutor, } from "./executor/sql-executor.js";
|
|
11
14
|
export { createCapabilityProbe } from "./capability/probe.js";
|
|
12
15
|
export type { CapabilityProbe } from "./capability/probe.js";
|
|
13
16
|
export { UnsupportedFeatureError } from "./capability/types.js";
|
|
14
17
|
export type { CapabilitySnapshot, FeatureMatrix, FeatureStatus, KernelInfo, TaurusFeatureName, } from "./capability/types.js";
|
|
15
|
-
export { createConfirmationStore, InMemoryConfirmationStore, } from "./safety/confirmation-store.js";
|
|
16
|
-
export type { ConfirmationStore,
|
|
18
|
+
export { createConfirmationStore, InMemoryConfirmationStore, parseApprovalRequest, signApprovalRequest, } from "./safety/confirmation-store.js";
|
|
19
|
+
export type { ConfirmationStore, ConfirmationRequest, ConfirmationValidationResult, IssueInput, } from "./safety/confirmation-store.js";
|
|
17
20
|
export { createGuardrail } from "./safety/guardrail.js";
|
|
18
21
|
export type { Guardrail, GuardrailDecision, GuardrailRuntimeLimits, InspectInput, } from "./safety/guardrail.js";
|
|
19
22
|
export type { ExplainRiskSummary, RiskLevel, ValidationResult } from "./safety/sql-validator.js";
|
|
@@ -41,6 +44,6 @@ export type { RecycleBinListResult, RecycleBinRestoreResult, RestoreRecycleBinTa
|
|
|
41
44
|
export { createPlaceholderDiagnosticResult } from "./diagnostics/types.js";
|
|
42
45
|
export { buildResolveSlowSqlInput, createSlowSqlSource, TaurusApiSlowSqlSource, } from "./diagnostics/slow-sql-source.js";
|
|
43
46
|
export { CesMetricsSource, createMetricsSource, } from "./diagnostics/metrics-source.js";
|
|
44
|
-
export type { DbHotspotItem, DbHotspotResult, DiagnosticBaseInput, DiagnosticConfidence, DiagnosticEvidenceItem, DiagnosticEvidenceLevel, DiagnosticNextToolInput, DiagnoseDbHotspotInput, DiagnoseServiceLatencyInput, FindTopSlowSqlInput, FindTopSlowSqlResult, DiagnosticResult, DiagnosticRootCauseCandidate, DiagnosticSeverity, DiagnosticStatus, ServiceLatencyCandidate, ServiceLatencyResult, ServiceLatencySuspectedCategory, DiagnosticSuspiciousEntities, DiagnosticToolName, DiagnosisWindow, DiagnoseConnectionSpikeInput, DiagnoseLockContentionInput, DiagnoseSlowQueryInput, DiagnoseStoragePressureInput, PlaceholderDiagnosticOptions, TopSlowSqlItem, } from "./diagnostics/types.js";
|
|
47
|
+
export type { DbHotspotItem, DbHotspotResult, DiagnosticBaseInput, DiagnosticConfidence, DiagnosticEvidenceItem, DiagnosticEvidenceLevel, DiagnosticNextToolInput, DiagnoseDbHotspotInput, DiagnoseServiceLatencyInput, FindTopSlowSqlInput, FindTopSlowSqlResult, DiagnosticResult, DiagnosticRootCauseCandidate, DiagnosticSeverity, DiagnosticStatus, ServiceLatencyCandidate, ServiceLatencyResult, ServiceLatencySuspectedCategory, DiagnosticSuspiciousEntities, DiagnosticToolName, DiagnosisWindow, DiagnoseConnectionSpikeInput, DiagnoseLockContentionInput, DiagnoseReplicationLagInput, DiagnoseSlowQueryInput, DiagnoseStoragePressureInput, PlaceholderDiagnosticOptions, TopSlowSqlItem, } from "./diagnostics/types.js";
|
|
45
48
|
export type { ExternalSlowSqlSample, ResolveSlowSqlInput, SlowSqlSource, } from "./diagnostics/slow-sql-source.js";
|
|
46
49
|
export type { MetricAlias, MetricPoint, MetricSummary, MetricsSource, QueryMetricsInput, } from "./diagnostics/metrics-source.js";
|
package/dist/index.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
export { TaurusDBEngine } from "./engine.js";
|
|
2
|
+
export { createJsonlAuditWriter, JsonlAuditWriter, } from "./audit/jsonl-writer.js";
|
|
2
3
|
export { createConfigFromEnv, getConfig, redactConfigForLog, resetConfigForTests, } from "./config/index.js";
|
|
3
4
|
export { createSqlProfileLoader, RuntimeOverrideProfileLoader, } from "./auth/sql-profile-loader.js";
|
|
4
5
|
export { DatasourceResolutionError } from "./context/datasource-resolver.js";
|
|
5
6
|
export { ConnectionPoolError } from "./executor/connection-pool.js";
|
|
7
|
+
export { QueryConcurrencyError } from "./executor/concurrency-limiter.js";
|
|
8
|
+
export { buildServerBoundedReadonlySql } from "./executor/bounded-sql.js";
|
|
6
9
|
export { createCapabilityProbe } from "./capability/probe.js";
|
|
7
10
|
export { UnsupportedFeatureError } from "./capability/types.js";
|
|
8
|
-
export { createConfirmationStore, InMemoryConfirmationStore, } from "./safety/confirmation-store.js";
|
|
11
|
+
export { createConfirmationStore, InMemoryConfirmationStore, parseApprovalRequest, signApprovalRequest, } from "./safety/confirmation-store.js";
|
|
9
12
|
export { createGuardrail } from "./safety/guardrail.js";
|
|
10
13
|
export { SchemaIntrospectionError } from "./schema/introspector.js";
|
|
11
14
|
export { ErrorCode, formatBlocked, formatConfirmationRequired, formatError, formatSuccess, } from "./utils/formatter.js";
|
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import type { SessionContext } from "../context/session-context.js";
|
|
2
2
|
import type { RiskLevel, ValidationResult } from "./sql-validator.js";
|
|
3
|
+
export type ApprovalRequestPayload = {
|
|
4
|
+
version: 1;
|
|
5
|
+
requestId: string;
|
|
6
|
+
sqlHash: string;
|
|
7
|
+
datasource: string;
|
|
8
|
+
database?: string;
|
|
9
|
+
host?: string;
|
|
10
|
+
port?: number;
|
|
11
|
+
projectId?: string;
|
|
12
|
+
instanceId?: string;
|
|
13
|
+
nodeId?: string;
|
|
14
|
+
riskLevel: RiskLevel;
|
|
15
|
+
issuedAt: number;
|
|
16
|
+
expiresAt: number;
|
|
17
|
+
};
|
|
3
18
|
export type IssueInput = {
|
|
4
19
|
sqlHash: string;
|
|
5
20
|
normalizedSql: string;
|
|
@@ -7,38 +22,46 @@ export type IssueInput = {
|
|
|
7
22
|
riskLevel: RiskLevel;
|
|
8
23
|
ttlSeconds?: number;
|
|
9
24
|
};
|
|
10
|
-
export type
|
|
11
|
-
|
|
25
|
+
export type ConfirmationRequest = {
|
|
26
|
+
request: string;
|
|
27
|
+
requestId: string;
|
|
12
28
|
issuedAt: number;
|
|
13
29
|
expiresAt: number;
|
|
14
30
|
};
|
|
15
31
|
export type ConfirmationValidationResult = ValidationResult & {
|
|
16
32
|
valid: boolean;
|
|
33
|
+
actor?: string;
|
|
17
34
|
reason?: string;
|
|
18
35
|
};
|
|
19
36
|
export interface ConfirmationStore {
|
|
20
|
-
issue(input: IssueInput): Promise<
|
|
37
|
+
issue(input: IssueInput): Promise<ConfirmationRequest>;
|
|
21
38
|
validate(token: string, currentSql: string, ctx: SessionContext): Promise<ConfirmationValidationResult>;
|
|
22
|
-
revoke(
|
|
39
|
+
revoke(requestId: string): Promise<void>;
|
|
23
40
|
}
|
|
24
41
|
export type ConfirmationStoreOptions = {
|
|
42
|
+
approvalSecret?: string | Buffer;
|
|
25
43
|
ttlSeconds?: number;
|
|
26
44
|
cleanupIntervalMs?: number;
|
|
45
|
+
maxEntries?: number;
|
|
27
46
|
now?: () => number;
|
|
28
47
|
randomBytesFn?: (size: number) => Buffer;
|
|
29
48
|
};
|
|
49
|
+
export declare function parseApprovalRequest(request: string): ApprovalRequestPayload;
|
|
50
|
+
export declare function signApprovalRequest(request: string, actor: string, secret: string | Buffer): string;
|
|
30
51
|
export declare class InMemoryConfirmationStore implements ConfirmationStore {
|
|
31
52
|
private readonly entries;
|
|
32
53
|
private readonly now;
|
|
33
54
|
private readonly ttlSeconds;
|
|
55
|
+
private readonly maxEntries;
|
|
34
56
|
private readonly randomBytesFn;
|
|
57
|
+
private readonly approvalSecret?;
|
|
35
58
|
private cleanupTimer?;
|
|
36
59
|
constructor(options?: ConfirmationStoreOptions);
|
|
37
|
-
issue(input: IssueInput): Promise<
|
|
60
|
+
issue(input: IssueInput): Promise<ConfirmationRequest>;
|
|
38
61
|
validate(token: string, currentSql: string, ctx: SessionContext): Promise<ConfirmationValidationResult>;
|
|
39
|
-
revoke(
|
|
62
|
+
revoke(requestId: string): Promise<void>;
|
|
40
63
|
stop(): void;
|
|
41
64
|
cleanupExpired(now?: number): void;
|
|
42
|
-
private
|
|
65
|
+
private generateUniqueRequestId;
|
|
43
66
|
}
|
|
44
67
|
export declare function createConfirmationStore(options?: ConfirmationStoreOptions): ConfirmationStore;
|
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
import { randomBytes } from "node:crypto";
|
|
1
|
+
import { createHmac, randomBytes, timingSafeEqual, } from "node:crypto";
|
|
2
2
|
import { normalizeSql, sqlHash } from "../utils/hash.js";
|
|
3
3
|
const DEFAULT_TTL_SECONDS = 300;
|
|
4
4
|
const DEFAULT_CLEANUP_INTERVAL_MS = 60_000;
|
|
5
|
+
const DEFAULT_MAX_ENTRIES = 1_000;
|
|
6
|
+
const REQUEST_PREFIX = "creq_";
|
|
5
7
|
const TOKEN_PREFIX = "ctok_";
|
|
6
|
-
function allowResult() {
|
|
8
|
+
function allowResult(actor) {
|
|
7
9
|
return {
|
|
8
10
|
valid: true,
|
|
11
|
+
actor,
|
|
9
12
|
action: "allow",
|
|
10
13
|
riskLevel: "low",
|
|
11
14
|
reasonCodes: [],
|
|
@@ -36,16 +39,90 @@ function parseTtlSeconds(ttlSeconds, fallback) {
|
|
|
36
39
|
}
|
|
37
40
|
return resolved;
|
|
38
41
|
}
|
|
42
|
+
function parseMaxEntries(value) {
|
|
43
|
+
const resolved = value ?? DEFAULT_MAX_ENTRIES;
|
|
44
|
+
if (!Number.isInteger(resolved) || resolved <= 0) {
|
|
45
|
+
throw new Error("maxEntries must be a positive integer.");
|
|
46
|
+
}
|
|
47
|
+
return resolved;
|
|
48
|
+
}
|
|
49
|
+
function encodePayload(payload) {
|
|
50
|
+
return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
|
|
51
|
+
}
|
|
52
|
+
function decodeJson(encoded) {
|
|
53
|
+
return JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
|
|
54
|
+
}
|
|
55
|
+
function normalizeSecret(secret) {
|
|
56
|
+
if (secret === undefined) {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
const normalized = Buffer.isBuffer(secret) ? Buffer.from(secret) : Buffer.from(secret, "utf8");
|
|
60
|
+
if (normalized.length < 32) {
|
|
61
|
+
throw new Error("Mutation approval secret must contain at least 32 bytes.");
|
|
62
|
+
}
|
|
63
|
+
return normalized;
|
|
64
|
+
}
|
|
65
|
+
function signEncodedPayload(encodedPayload, secret) {
|
|
66
|
+
return createHmac("sha256", secret).update(encodedPayload, "utf8").digest();
|
|
67
|
+
}
|
|
68
|
+
export function parseApprovalRequest(request) {
|
|
69
|
+
if (!request.startsWith(REQUEST_PREFIX)) {
|
|
70
|
+
throw new Error("Invalid approval request prefix.");
|
|
71
|
+
}
|
|
72
|
+
const payload = decodeJson(request.slice(REQUEST_PREFIX.length));
|
|
73
|
+
if (payload.version !== 1 ||
|
|
74
|
+
typeof payload.requestId !== "string" ||
|
|
75
|
+
typeof payload.sqlHash !== "string" ||
|
|
76
|
+
typeof payload.datasource !== "string" ||
|
|
77
|
+
typeof payload.issuedAt !== "number" ||
|
|
78
|
+
typeof payload.expiresAt !== "number") {
|
|
79
|
+
throw new Error("Invalid approval request payload.");
|
|
80
|
+
}
|
|
81
|
+
return payload;
|
|
82
|
+
}
|
|
83
|
+
export function signApprovalRequest(request, actor, secret) {
|
|
84
|
+
const normalizedActor = actor.trim();
|
|
85
|
+
if (!normalizedActor) {
|
|
86
|
+
throw new Error("Approval actor is required.");
|
|
87
|
+
}
|
|
88
|
+
const requestPayload = parseApprovalRequest(request);
|
|
89
|
+
const payload = {
|
|
90
|
+
...requestPayload,
|
|
91
|
+
actor: normalizedActor,
|
|
92
|
+
};
|
|
93
|
+
const encodedPayload = encodePayload(payload);
|
|
94
|
+
const signature = signEncodedPayload(encodedPayload, normalizeSecret(secret));
|
|
95
|
+
return `${TOKEN_PREFIX}${encodedPayload}.${signature.toString("base64url")}`;
|
|
96
|
+
}
|
|
97
|
+
function equalRequestPayload(pending, signed) {
|
|
98
|
+
return (pending.version === signed.version &&
|
|
99
|
+
pending.requestId === signed.requestId &&
|
|
100
|
+
pending.sqlHash === signed.sqlHash &&
|
|
101
|
+
pending.datasource === signed.datasource &&
|
|
102
|
+
pending.database === signed.database &&
|
|
103
|
+
pending.host === signed.host &&
|
|
104
|
+
pending.port === signed.port &&
|
|
105
|
+
pending.projectId === signed.projectId &&
|
|
106
|
+
pending.instanceId === signed.instanceId &&
|
|
107
|
+
pending.nodeId === signed.nodeId &&
|
|
108
|
+
pending.riskLevel === signed.riskLevel &&
|
|
109
|
+
pending.issuedAt === signed.issuedAt &&
|
|
110
|
+
pending.expiresAt === signed.expiresAt);
|
|
111
|
+
}
|
|
39
112
|
export class InMemoryConfirmationStore {
|
|
40
113
|
entries = new Map();
|
|
41
114
|
now;
|
|
42
115
|
ttlSeconds;
|
|
116
|
+
maxEntries;
|
|
43
117
|
randomBytesFn;
|
|
118
|
+
approvalSecret;
|
|
44
119
|
cleanupTimer;
|
|
45
120
|
constructor(options = {}) {
|
|
46
121
|
this.now = options.now ?? Date.now;
|
|
47
122
|
this.ttlSeconds = parseTtlSeconds(options.ttlSeconds, DEFAULT_TTL_SECONDS);
|
|
123
|
+
this.maxEntries = parseMaxEntries(options.maxEntries);
|
|
48
124
|
this.randomBytesFn = options.randomBytesFn ?? randomBytes;
|
|
125
|
+
this.approvalSecret = normalizeSecret(options.approvalSecret);
|
|
49
126
|
const cleanupIntervalMs = options.cleanupIntervalMs ?? DEFAULT_CLEANUP_INTERVAL_MS;
|
|
50
127
|
if (cleanupIntervalMs > 0) {
|
|
51
128
|
this.cleanupTimer = setInterval(() => this.cleanupExpired(), cleanupIntervalMs);
|
|
@@ -54,53 +131,100 @@ export class InMemoryConfirmationStore {
|
|
|
54
131
|
}
|
|
55
132
|
async issue(input) {
|
|
56
133
|
this.cleanupExpired();
|
|
134
|
+
if (this.entries.size >= this.maxEntries) {
|
|
135
|
+
throw new Error("Too many pending mutation approval requests.");
|
|
136
|
+
}
|
|
57
137
|
const ttlSeconds = parseTtlSeconds(input.ttlSeconds, this.ttlSeconds);
|
|
58
138
|
const issuedAt = this.now();
|
|
59
139
|
const expiresAt = issuedAt + ttlSeconds * 1000;
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
140
|
+
const requestId = this.generateUniqueRequestId();
|
|
141
|
+
const payload = {
|
|
142
|
+
version: 1,
|
|
143
|
+
requestId,
|
|
63
144
|
sqlHash: input.sqlHash,
|
|
64
|
-
normalizedSql: input.normalizedSql,
|
|
65
145
|
datasource: input.context.datasource,
|
|
66
146
|
database: normalizeDatabase(input.context.database),
|
|
147
|
+
host: input.context.host,
|
|
148
|
+
port: input.context.port,
|
|
149
|
+
projectId: input.context.projectId,
|
|
150
|
+
instanceId: input.context.instanceId,
|
|
151
|
+
nodeId: input.context.nodeId,
|
|
67
152
|
riskLevel: input.riskLevel,
|
|
68
153
|
issuedAt,
|
|
69
154
|
expiresAt,
|
|
70
|
-
}
|
|
155
|
+
};
|
|
156
|
+
this.entries.set(requestId, { payload });
|
|
71
157
|
return {
|
|
72
|
-
|
|
158
|
+
request: `${REQUEST_PREFIX}${encodePayload(payload)}`,
|
|
159
|
+
requestId,
|
|
73
160
|
issuedAt,
|
|
74
161
|
expiresAt,
|
|
75
162
|
};
|
|
76
163
|
}
|
|
77
164
|
async validate(token, currentSql, ctx) {
|
|
78
|
-
|
|
165
|
+
if (!this.approvalSecret) {
|
|
166
|
+
return blockResult("CF006", "External mutation approval is not configured.");
|
|
167
|
+
}
|
|
168
|
+
if (!token.startsWith(TOKEN_PREFIX)) {
|
|
169
|
+
return blockResult("CF001", "Invalid approval token prefix.");
|
|
170
|
+
}
|
|
171
|
+
const [encodedPayload, encodedSignature, ...extra] = token
|
|
172
|
+
.slice(TOKEN_PREFIX.length)
|
|
173
|
+
.split(".");
|
|
174
|
+
if (!encodedPayload || !encodedSignature || extra.length > 0) {
|
|
175
|
+
return blockResult("CF001", "Invalid approval token format.");
|
|
176
|
+
}
|
|
177
|
+
let payload;
|
|
178
|
+
let signature;
|
|
179
|
+
try {
|
|
180
|
+
payload = decodeJson(encodedPayload);
|
|
181
|
+
signature = Buffer.from(encodedSignature, "base64url");
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return blockResult("CF001", "Invalid approval token encoding.");
|
|
185
|
+
}
|
|
186
|
+
const expectedSignature = signEncodedPayload(encodedPayload, this.approvalSecret);
|
|
187
|
+
if (signature.length !== expectedSignature.length ||
|
|
188
|
+
!timingSafeEqual(signature, expectedSignature)) {
|
|
189
|
+
return blockResult("CF007", "Approval token signature is invalid.");
|
|
190
|
+
}
|
|
191
|
+
if (typeof payload.actor !== "string" || !payload.actor.trim()) {
|
|
192
|
+
return blockResult("CF008", "Approval token does not identify an actor.");
|
|
193
|
+
}
|
|
194
|
+
const entry = this.entries.get(payload.requestId);
|
|
79
195
|
if (!entry) {
|
|
80
|
-
return blockResult("CF001", "
|
|
196
|
+
return blockResult("CF001", "Approval request not found.");
|
|
81
197
|
}
|
|
82
198
|
const now = this.now();
|
|
83
|
-
if (entry.expiresAt <= now) {
|
|
84
|
-
this.entries.delete(
|
|
85
|
-
return blockResult("CF002", "
|
|
199
|
+
if (entry.payload.expiresAt <= now || payload.expiresAt <= now) {
|
|
200
|
+
this.entries.delete(payload.requestId);
|
|
201
|
+
return blockResult("CF002", "Approval request has expired.");
|
|
86
202
|
}
|
|
87
203
|
if (entry.usedAt !== undefined) {
|
|
88
|
-
return blockResult("CF005", "
|
|
204
|
+
return blockResult("CF005", "Approval token has already been used.");
|
|
205
|
+
}
|
|
206
|
+
if (!equalRequestPayload(entry.payload, payload)) {
|
|
207
|
+
return blockResult("CF009", "Approval token payload does not match the pending request.");
|
|
89
208
|
}
|
|
90
209
|
const normalizedCurrentSql = normalizeSql(currentSql);
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
return blockResult("CF003", "SQL hash mismatch for confirmation token.");
|
|
210
|
+
if (sqlHash(normalizedCurrentSql) !== payload.sqlHash) {
|
|
211
|
+
return blockResult("CF003", "SQL hash mismatch for approval token.");
|
|
94
212
|
}
|
|
95
213
|
const currentDatabase = normalizeDatabase(ctx.database);
|
|
96
|
-
if (ctx.datasource !==
|
|
97
|
-
|
|
214
|
+
if (ctx.datasource !== payload.datasource ||
|
|
215
|
+
currentDatabase !== payload.database ||
|
|
216
|
+
ctx.host !== payload.host ||
|
|
217
|
+
ctx.port !== payload.port ||
|
|
218
|
+
ctx.projectId !== payload.projectId ||
|
|
219
|
+
ctx.instanceId !== payload.instanceId ||
|
|
220
|
+
ctx.nodeId !== payload.nodeId) {
|
|
221
|
+
return blockResult("CF004", "Datasource, database, or target mismatch for approval token.");
|
|
98
222
|
}
|
|
99
223
|
entry.usedAt = now;
|
|
100
|
-
return allowResult();
|
|
224
|
+
return allowResult(payload.actor.trim());
|
|
101
225
|
}
|
|
102
|
-
async revoke(
|
|
103
|
-
this.entries.delete(
|
|
226
|
+
async revoke(requestId) {
|
|
227
|
+
this.entries.delete(requestId);
|
|
104
228
|
}
|
|
105
229
|
stop() {
|
|
106
230
|
if (this.cleanupTimer) {
|
|
@@ -109,20 +233,20 @@ export class InMemoryConfirmationStore {
|
|
|
109
233
|
}
|
|
110
234
|
}
|
|
111
235
|
cleanupExpired(now = this.now()) {
|
|
112
|
-
for (const [
|
|
113
|
-
if (entry.expiresAt <= now) {
|
|
114
|
-
this.entries.delete(
|
|
236
|
+
for (const [requestId, entry] of this.entries.entries()) {
|
|
237
|
+
if (entry.payload.expiresAt <= now) {
|
|
238
|
+
this.entries.delete(requestId);
|
|
115
239
|
}
|
|
116
240
|
}
|
|
117
241
|
}
|
|
118
|
-
|
|
242
|
+
generateUniqueRequestId() {
|
|
119
243
|
for (let i = 0; i < 5; i += 1) {
|
|
120
|
-
const
|
|
121
|
-
if (!this.entries.has(
|
|
122
|
-
return
|
|
244
|
+
const requestId = this.randomBytesFn(24).toString("base64url");
|
|
245
|
+
if (!this.entries.has(requestId)) {
|
|
246
|
+
return requestId;
|
|
123
247
|
}
|
|
124
248
|
}
|
|
125
|
-
throw new Error("Unable to generate unique
|
|
249
|
+
throw new Error("Unable to generate unique approval request id.");
|
|
126
250
|
}
|
|
127
251
|
}
|
|
128
252
|
export function createConfirmationStore(options = {}) {
|
|
@@ -8,6 +8,9 @@ export interface GuardrailRuntimeLimits {
|
|
|
8
8
|
maxRows: number;
|
|
9
9
|
maxColumns: number;
|
|
10
10
|
maxFieldChars: number;
|
|
11
|
+
maxResultBytes: number;
|
|
12
|
+
maxBlobBytes: number;
|
|
13
|
+
maskAllColumns: boolean;
|
|
11
14
|
}
|
|
12
15
|
export interface GuardrailDecision {
|
|
13
16
|
action: "allow" | "confirm" | "block";
|
package/dist/safety/guardrail.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createSqlParser } from "./parser/index.js";
|
|
2
2
|
import { classifySql } from "./sql-classifier.js";
|
|
3
|
-
import {
|
|
3
|
+
import { hasSensitiveColumnReference } from "./redaction.js";
|
|
4
|
+
import { validateStaticRules, validateDatabaseScope, validateToolScope, } from "./sql-validator.js";
|
|
4
5
|
function dedupeCaseInsensitive(values) {
|
|
5
6
|
const output = [];
|
|
6
7
|
const seen = new Set();
|
|
@@ -54,10 +55,13 @@ function buildBaseDecision(input, normalizedSql, sqlHash, action, riskLevel, rea
|
|
|
54
55
|
maxRows: input.context.limits.maxRows,
|
|
55
56
|
maxColumns: input.context.limits.maxColumns,
|
|
56
57
|
maxFieldChars: input.context.limits.maxFieldChars ?? 2048,
|
|
58
|
+
maxResultBytes: input.context.limits.maxResultBytes ?? 1048576,
|
|
59
|
+
maxBlobBytes: input.context.limits.maxBlobBytes ?? 65536,
|
|
60
|
+
maskAllColumns: false,
|
|
57
61
|
},
|
|
58
62
|
};
|
|
59
63
|
}
|
|
60
|
-
function mergeDecision(input, normalizedSql, sqlHash, validations, requiresExplain) {
|
|
64
|
+
function mergeDecision(input, normalizedSql, sqlHash, validations, requiresExplain, maskAllColumns = false) {
|
|
61
65
|
let action = "allow";
|
|
62
66
|
let riskLevel = "low";
|
|
63
67
|
const reasonCodes = [];
|
|
@@ -68,7 +72,9 @@ function mergeDecision(input, normalizedSql, sqlHash, validations, requiresExpla
|
|
|
68
72
|
reasonCodes.push(...validation.reasonCodes);
|
|
69
73
|
riskHints.push(...validation.riskHints);
|
|
70
74
|
}
|
|
71
|
-
|
|
75
|
+
const decision = buildBaseDecision(input, normalizedSql, sqlHash, action, riskLevel, reasonCodes, riskHints, requiresExplain);
|
|
76
|
+
decision.runtimeLimits.maskAllColumns = maskAllColumns;
|
|
77
|
+
return decision;
|
|
72
78
|
}
|
|
73
79
|
export class GuardrailImpl {
|
|
74
80
|
parserFactory;
|
|
@@ -85,13 +91,17 @@ export class GuardrailImpl {
|
|
|
85
91
|
const cls = classifySql(parseResult.ast, normalized, input.context.engine);
|
|
86
92
|
const d1 = validateToolScope(input.toolName, cls);
|
|
87
93
|
if (d1.action === "block") {
|
|
88
|
-
return mergeDecision(input, normalized.normalizedSql, normalized.sqlHash, [d1], false);
|
|
94
|
+
return mergeDecision(input, normalized.normalizedSql, normalized.sqlHash, [d1], false, hasSensitiveColumnReference(cls.referencedColumns));
|
|
95
|
+
}
|
|
96
|
+
const databaseScope = validateDatabaseScope(cls, input.context.database);
|
|
97
|
+
if (databaseScope.action === "block") {
|
|
98
|
+
return mergeDecision(input, normalized.normalizedSql, normalized.sqlHash, [d1, databaseScope], false, hasSensitiveColumnReference(cls.referencedColumns));
|
|
89
99
|
}
|
|
90
100
|
const d2 = validateStaticRules(cls);
|
|
91
101
|
if (d2.action === "block") {
|
|
92
|
-
return mergeDecision(input, normalized.normalizedSql, normalized.sqlHash, [d1, d2], false);
|
|
102
|
+
return mergeDecision(input, normalized.normalizedSql, normalized.sqlHash, [d1, databaseScope, d2], false, hasSensitiveColumnReference(cls.referencedColumns));
|
|
93
103
|
}
|
|
94
|
-
return mergeDecision(input, normalized.normalizedSql, normalized.sqlHash, [d1, d2], false);
|
|
104
|
+
return mergeDecision(input, normalized.normalizedSql, normalized.sqlHash, [d1, databaseScope, d2], false, hasSensitiveColumnReference(cls.referencedColumns));
|
|
95
105
|
}
|
|
96
106
|
}
|
|
97
107
|
export function createGuardrail(options = {}) {
|
|
@@ -12,6 +12,9 @@ export interface RedactionPolicy {
|
|
|
12
12
|
maxRows: number;
|
|
13
13
|
maxColumns: number;
|
|
14
14
|
maxFieldChars: number;
|
|
15
|
+
maxResultBytes?: number;
|
|
16
|
+
maxBlobBytes?: number;
|
|
17
|
+
maskAllColumns?: boolean;
|
|
15
18
|
sensitiveColumns?: Iterable<string>;
|
|
16
19
|
sensitiveStrategy?: SensitiveStrategy;
|
|
17
20
|
}
|
|
@@ -24,6 +27,8 @@ export interface RedactedQueryResult {
|
|
|
24
27
|
rowTruncated: boolean;
|
|
25
28
|
columnTruncated: boolean;
|
|
26
29
|
fieldTruncated: boolean;
|
|
30
|
+
byteTruncated: boolean;
|
|
31
|
+
returnedBytes: number;
|
|
27
32
|
redactedColumns: string[];
|
|
28
33
|
droppedColumns: string[];
|
|
29
34
|
truncatedColumns: string[];
|
|
@@ -31,4 +36,5 @@ export interface RedactedQueryResult {
|
|
|
31
36
|
export interface ResultRedactor {
|
|
32
37
|
redact(raw: RawQueryResult, policy: RedactionPolicy): RedactedQueryResult;
|
|
33
38
|
}
|
|
39
|
+
export declare function hasSensitiveColumnReference(columns: Iterable<string>): boolean;
|
|
34
40
|
export declare function createResultRedactor(): ResultRedactor;
|