taurusdb-core 0.4.0 → 0.5.0-rc.10
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 +6 -10
- package/dist/auth/sql-profile-loader/parsing.js +8 -5
- 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 +5 -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 +16 -0
- package/dist/config/schema.d.ts +78 -0
- package/dist/config/schema.js +19 -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 +5 -5
- package/dist/engine.js +32 -5
- 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 +35 -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 +8 -3
- package/dist/index.js +5 -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 +25 -1
- 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 +11 -2
- package/dist/utils/formatter.js +12 -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
|
@@ -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;
|
package/dist/safety/redaction.js
CHANGED
|
@@ -2,6 +2,8 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
const DEFAULT_MAX_ROWS = 200;
|
|
3
3
|
const DEFAULT_MAX_COLUMNS = 50;
|
|
4
4
|
const DEFAULT_MAX_FIELD_CHARS = 2048;
|
|
5
|
+
const DEFAULT_MAX_RESULT_BYTES = 1048576;
|
|
6
|
+
const DEFAULT_MAX_BLOB_BYTES = 65536;
|
|
5
7
|
const DEFAULT_SENSITIVE_STRATEGY = "mask";
|
|
6
8
|
const SENSITIVE_COLUMN_PATTERNS = [
|
|
7
9
|
/password|passwd|secret/i,
|
|
@@ -58,6 +60,14 @@ function isSensitiveColumn(name, explicitSensitive) {
|
|
|
58
60
|
const base = candidates[candidates.length - 1];
|
|
59
61
|
return SENSITIVE_COLUMN_PATTERNS.some((pattern) => pattern.test(base));
|
|
60
62
|
}
|
|
63
|
+
export function hasSensitiveColumnReference(columns) {
|
|
64
|
+
for (const column of columns) {
|
|
65
|
+
if (isSensitiveColumn(column, new Set())) {
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
61
71
|
function maskValue(columnName, value) {
|
|
62
72
|
if (value === null || value === undefined) {
|
|
63
73
|
return value;
|
|
@@ -87,7 +97,19 @@ function hashValue(value) {
|
|
|
87
97
|
const digest = createHash("sha256").update(plain).digest("hex").slice(0, 12);
|
|
88
98
|
return `[HASH:${digest}]`;
|
|
89
99
|
}
|
|
90
|
-
function truncateFieldValue(value, maxFieldChars) {
|
|
100
|
+
function truncateFieldValue(value, maxFieldChars, maxBlobBytes) {
|
|
101
|
+
if (Buffer.isBuffer(value)) {
|
|
102
|
+
if (value.byteLength > maxBlobBytes) {
|
|
103
|
+
return {
|
|
104
|
+
value: `[BINARY:${value.byteLength} bytes; omitted by max_blob_bytes]`,
|
|
105
|
+
truncated: true,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
value: `[BINARY:${value.byteLength} bytes;base64:${value.toString("base64")}]`,
|
|
110
|
+
truncated: false,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
91
113
|
if (typeof value !== "string") {
|
|
92
114
|
return { value, truncated: false };
|
|
93
115
|
}
|
|
@@ -104,12 +126,14 @@ class DefaultResultRedactor {
|
|
|
104
126
|
const maxRows = asPositiveInt(policy.maxRows, DEFAULT_MAX_ROWS);
|
|
105
127
|
const maxColumns = asPositiveInt(policy.maxColumns, DEFAULT_MAX_COLUMNS);
|
|
106
128
|
const maxFieldChars = asPositiveInt(policy.maxFieldChars, DEFAULT_MAX_FIELD_CHARS);
|
|
129
|
+
const maxResultBytes = asPositiveInt(policy.maxResultBytes, DEFAULT_MAX_RESULT_BYTES);
|
|
130
|
+
const maxBlobBytes = asPositiveInt(policy.maxBlobBytes, DEFAULT_MAX_BLOB_BYTES);
|
|
107
131
|
const sensitiveStrategy = policy.sensitiveStrategy ?? DEFAULT_SENSITIVE_STRATEGY;
|
|
108
132
|
const explicitSensitive = buildSensitiveSet(policy.sensitiveColumns);
|
|
109
133
|
const sourceColumns = Array.isArray(raw.columns) ? raw.columns : [];
|
|
110
134
|
const sourceRows = Array.isArray(raw.rows) ? raw.rows : [];
|
|
111
135
|
const originalRowCount = asNonNegativeInt(raw.rowCount, sourceRows.length);
|
|
112
|
-
|
|
136
|
+
let rowTruncated = sourceRows.length > maxRows || originalRowCount > maxRows;
|
|
113
137
|
const columnTruncated = sourceColumns.length > maxColumns;
|
|
114
138
|
const rowLimited = sourceRows.slice(0, maxRows);
|
|
115
139
|
const columnLimited = sourceColumns.slice(0, maxColumns);
|
|
@@ -120,7 +144,8 @@ class DefaultResultRedactor {
|
|
|
120
144
|
const droppedColumns = [];
|
|
121
145
|
for (let index = 0; index < columnLimited.length; index += 1) {
|
|
122
146
|
const column = columnLimited[index];
|
|
123
|
-
const sensitive =
|
|
147
|
+
const sensitive = policy.maskAllColumns === true ||
|
|
148
|
+
isSensitiveColumn(column.name, explicitSensitive);
|
|
124
149
|
if (sensitive && sensitiveStrategy === "drop") {
|
|
125
150
|
droppedColumns.push(column.name);
|
|
126
151
|
continue;
|
|
@@ -151,7 +176,7 @@ class DefaultResultRedactor {
|
|
|
151
176
|
}
|
|
152
177
|
}
|
|
153
178
|
else {
|
|
154
|
-
const truncated = truncateFieldValue(rawValue, maxFieldChars);
|
|
179
|
+
const truncated = truncateFieldValue(rawValue, maxFieldChars, maxBlobBytes);
|
|
155
180
|
value = truncated.value;
|
|
156
181
|
if (truncated.truncated) {
|
|
157
182
|
truncatedColumns.add(column.name);
|
|
@@ -162,15 +187,31 @@ class DefaultResultRedactor {
|
|
|
162
187
|
return outputRow;
|
|
163
188
|
});
|
|
164
189
|
const fieldTruncated = truncatedColumns.size > 0;
|
|
190
|
+
let returnedBytes = Buffer.byteLength(JSON.stringify({ columns: keepColumns, rows: [] }), "utf8");
|
|
191
|
+
let byteTruncated = false;
|
|
192
|
+
const byteLimitedRows = [];
|
|
193
|
+
for (const row of outputRows) {
|
|
194
|
+
const rowBytes = Buffer.byteLength(JSON.stringify(row), "utf8");
|
|
195
|
+
const delimiterBytes = byteLimitedRows.length > 0 ? 1 : 0;
|
|
196
|
+
if (returnedBytes + delimiterBytes + rowBytes > maxResultBytes) {
|
|
197
|
+
byteTruncated = true;
|
|
198
|
+
rowTruncated = true;
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
byteLimitedRows.push(row);
|
|
202
|
+
returnedBytes += delimiterBytes + rowBytes;
|
|
203
|
+
}
|
|
165
204
|
return {
|
|
166
205
|
columns: keepColumns,
|
|
167
|
-
rows:
|
|
206
|
+
rows: byteLimitedRows,
|
|
168
207
|
rowCount: originalRowCount,
|
|
169
208
|
originalRowCount,
|
|
170
|
-
truncated: rowTruncated || columnTruncated || fieldTruncated,
|
|
209
|
+
truncated: rowTruncated || columnTruncated || fieldTruncated || byteTruncated,
|
|
171
210
|
rowTruncated,
|
|
172
211
|
columnTruncated,
|
|
173
212
|
fieldTruncated,
|
|
213
|
+
byteTruncated,
|
|
214
|
+
returnedBytes,
|
|
174
215
|
redactedColumns: keepColumns
|
|
175
216
|
.map((column) => column.name)
|
|
176
217
|
.filter((name) => redactedColumns.has(name)),
|
|
@@ -32,6 +32,7 @@ export function classifySql(ast, normalized, engine) {
|
|
|
32
32
|
sqlHash: normalized.sqlHash,
|
|
33
33
|
isMultiStatement: ast.isMultiStatement,
|
|
34
34
|
referencedTables: extractTables(ast),
|
|
35
|
+
referencedSchemas: dedupeCaseInsensitive(ast.tables.flatMap((table) => (table.schema ? [table.schema] : []))),
|
|
35
36
|
referencedColumns: extractColumns(ast),
|
|
36
37
|
hasWhere: ast.where !== undefined,
|
|
37
38
|
hasLimit: ast.limit !== undefined,
|
|
@@ -16,4 +16,5 @@ export interface ExplainRiskSummary {
|
|
|
16
16
|
riskHints: string[];
|
|
17
17
|
}
|
|
18
18
|
export declare function validateToolScope(toolName: string, cls: SqlClassification): ValidationResult;
|
|
19
|
+
export declare function validateDatabaseScope(cls: SqlClassification, database: string | undefined): ValidationResult;
|
|
19
20
|
export declare function validateStaticRules(cls: SqlClassification): ValidationResult;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const READONLY_STATEMENTS = new Set(["select", "show", "explain", "describe"]);
|
|
2
2
|
const MUTATION_STATEMENTS = new Set(["insert", "update", "delete"]);
|
|
3
3
|
const STAR_COLUMN_PATTERN = /(^|\.)(\*|\(\.\*\))$/;
|
|
4
|
+
const READONLY_SIDE_EFFECT_PATTERN = /\b(?:INTO\s+(?:OUTFILE|DUMPFILE)|FOR\s+UPDATE|LOCK\s+IN\s+SHARE\s+MODE|GET_LOCK\s*\(|RELEASE_LOCK\s*\(|SLEEP\s*\(|BENCHMARK\s*\(|LOAD_FILE\s*\()/i;
|
|
4
5
|
function allow(riskLevel = "low") {
|
|
5
6
|
return {
|
|
6
7
|
action: "allow",
|
|
@@ -53,7 +54,7 @@ export function validateToolScope(toolName, cls) {
|
|
|
53
54
|
if (!READONLY_STATEMENTS.has(cls.statementType)) {
|
|
54
55
|
return block(["T001"], [
|
|
55
56
|
`Tool execute_readonly_sql only allows SELECT/SHOW/EXPLAIN/DESCRIBE, got ${cls.statementType}.`,
|
|
56
|
-
"Use
|
|
57
|
+
"Use analyze_mutation_sql to generate non-executing SQL Advice.",
|
|
57
58
|
]);
|
|
58
59
|
}
|
|
59
60
|
return allow("low");
|
|
@@ -75,6 +76,23 @@ export function validateToolScope(toolName, cls) {
|
|
|
75
76
|
}
|
|
76
77
|
return allow("low");
|
|
77
78
|
}
|
|
79
|
+
export function validateDatabaseScope(cls, database) {
|
|
80
|
+
const schemas = cls.referencedSchemas ?? [];
|
|
81
|
+
if (schemas.length === 0) {
|
|
82
|
+
return allow("low");
|
|
83
|
+
}
|
|
84
|
+
if (!database) {
|
|
85
|
+
return block(["D001"], ["SQL uses an explicit database but the session has no bound database."]);
|
|
86
|
+
}
|
|
87
|
+
const expected = database.toLowerCase();
|
|
88
|
+
const outOfScope = schemas.filter((schema) => schema.toLowerCase() !== expected);
|
|
89
|
+
if (outOfScope.length > 0) {
|
|
90
|
+
return block(["D002"], [
|
|
91
|
+
`SQL references database(s) outside the bound database "${database}": ${outOfScope.join(", ")}.`,
|
|
92
|
+
]);
|
|
93
|
+
}
|
|
94
|
+
return allow("low");
|
|
95
|
+
}
|
|
78
96
|
export function validateStaticRules(cls) {
|
|
79
97
|
const reasonCodes = [];
|
|
80
98
|
const riskHints = [];
|
|
@@ -117,7 +135,13 @@ export function validateStaticRules(cls) {
|
|
|
117
135
|
escalateToConfirm("R006", "Mutation SQL with WHERE requires confirmation.");
|
|
118
136
|
}
|
|
119
137
|
}
|
|
138
|
+
if (cls.statementType === "insert") {
|
|
139
|
+
escalateToConfirm("R006", "INSERT, REPLACE, and UPSERT statements require confirmation.");
|
|
140
|
+
}
|
|
120
141
|
if (cls.statementType === "select") {
|
|
142
|
+
if (READONLY_SIDE_EFFECT_PATTERN.test(cls.normalizedSql)) {
|
|
143
|
+
escalateToBlock("R009", "SELECT statements with side effects or blocking functions are blocked.");
|
|
144
|
+
}
|
|
121
145
|
if (!cls.hasLimit && !cls.hasAggregate) {
|
|
122
146
|
escalateToMediumAllow("R007", "Detail SELECT without LIMIT has medium risk.");
|
|
123
147
|
}
|
|
@@ -272,7 +272,9 @@ export class MySqlSchemaAdapter {
|
|
|
272
272
|
}));
|
|
273
273
|
}
|
|
274
274
|
async queryObjects(ctx, sql) {
|
|
275
|
-
const session = await this.connectionPool.acquire(ctx.datasource, "ro"
|
|
275
|
+
const session = await this.connectionPool.acquire(ctx.datasource, "ro", {
|
|
276
|
+
database: ctx.database,
|
|
277
|
+
});
|
|
276
278
|
try {
|
|
277
279
|
const result = await session.execute(sql, { timeoutMs: ctx.limits.timeoutMs });
|
|
278
280
|
return rowsToObjects(result);
|
package/dist/taurus/flashback.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createSqlParser } from "../safety/parser/index.js";
|
|
2
2
|
import { normalizeSql } from "../utils/hash.js";
|
|
3
|
-
|
|
3
|
+
import { quoteMysqlIdentifier } from "../utils/mysql-identifier.js";
|
|
4
4
|
const SQL_TIMESTAMP_PATTERN = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})(?:\.\d{1,6})?$/;
|
|
5
5
|
const RELATIVE_DURATION_PATTERN = /(\d+)\s*(ms|milliseconds?|s|sec|secs|seconds?|m|min|mins|minutes?|h|hr|hrs|hours?|d|days?)/gi;
|
|
6
6
|
const UNIT_TO_MS = {
|
|
@@ -26,12 +26,6 @@ function normalizeDurationUnit(unit) {
|
|
|
26
26
|
}
|
|
27
27
|
return "d";
|
|
28
28
|
}
|
|
29
|
-
function quoteIdentifier(identifier, fieldName) {
|
|
30
|
-
if (!IDENTIFIER_PATTERN.test(identifier)) {
|
|
31
|
-
throw new Error(`Invalid ${fieldName}: "${identifier}".`);
|
|
32
|
-
}
|
|
33
|
-
return `\`${identifier}\``;
|
|
34
|
-
}
|
|
35
29
|
export function normalizeFlashbackWhereClause(where) {
|
|
36
30
|
const parser = createSqlParser("mysql");
|
|
37
31
|
const candidate = parser.normalize(`SELECT 1 FROM placeholder WHERE (${where})`);
|
|
@@ -128,10 +122,10 @@ export function resolveFlashbackTimestamp(asOf, now = Date.now) {
|
|
|
128
122
|
throw new Error("Flashback query requires either as_of.timestamp or as_of.relative.");
|
|
129
123
|
}
|
|
130
124
|
export function buildFlashbackSql(input, defaultDatabase, now = Date.now) {
|
|
131
|
-
const database =
|
|
132
|
-
const table =
|
|
125
|
+
const database = quoteMysqlIdentifier(input.database ?? defaultDatabase, "database");
|
|
126
|
+
const table = quoteMysqlIdentifier(input.table, "table");
|
|
133
127
|
const columns = input.columns && input.columns.length > 0
|
|
134
|
-
? input.columns.map((column) =>
|
|
128
|
+
? input.columns.map((column) => quoteMysqlIdentifier(column, "column")).join(", ")
|
|
135
129
|
: "*";
|
|
136
130
|
const timestamp = resolveFlashbackTimestamp(input.asOf, now);
|
|
137
131
|
const clauses = [
|
|
@@ -1,12 +1,6 @@
|
|
|
1
|
+
import { quoteMysqlIdentifier } from "../utils/mysql-identifier.js";
|
|
1
2
|
export const RECYCLE_BIN_DATABASE = "__recyclebin__";
|
|
2
|
-
const SIMPLE_IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_$]*$/;
|
|
3
3
|
const RECYCLE_TABLE_PATTERN = /^[A-Za-z0-9_$@.-]+$/;
|
|
4
|
-
function quoteIdentifier(identifier, fieldName) {
|
|
5
|
-
if (!SIMPLE_IDENTIFIER_PATTERN.test(identifier)) {
|
|
6
|
-
throw new Error(`Invalid ${fieldName}: "${identifier}".`);
|
|
7
|
-
}
|
|
8
|
-
return `\`${identifier}\``;
|
|
9
|
-
}
|
|
10
4
|
function quoteRecycleTableName(table) {
|
|
11
5
|
if (!RECYCLE_TABLE_PATTERN.test(table)) {
|
|
12
6
|
throw new Error(`Invalid recycle_table: "${table}".`);
|
|
@@ -45,7 +39,7 @@ export function buildRestoreRecycleBinTableSql(input) {
|
|
|
45
39
|
if (!input.destinationDatabase || !input.destinationTable) {
|
|
46
40
|
throw new Error("insert_select restore requires destination_database and destination_table. Create the destination table with a compatible structure before calling this tool.");
|
|
47
41
|
}
|
|
48
|
-
return `INSERT INTO ${
|
|
42
|
+
return `INSERT INTO ${quoteMysqlIdentifier(input.destinationDatabase, "destination_database")}.${quoteMysqlIdentifier(input.destinationTable, "destination_table")} SELECT * FROM \`${RECYCLE_BIN_DATABASE}\`.${quoteRecycleTableName(input.recycleTable)}`;
|
|
49
43
|
}
|
|
50
44
|
throw new Error(`Unsupported restore method: ${method}.`);
|
|
51
45
|
}
|
|
@@ -10,7 +10,14 @@ export declare const ErrorCode: {
|
|
|
10
10
|
readonly QUERY_TIMEOUT: "QUERY_TIMEOUT";
|
|
11
11
|
readonly QUERY_CANCELLED: "QUERY_CANCELLED";
|
|
12
12
|
readonly CONNECTION_FAILED: "CONNECTION_FAILED";
|
|
13
|
+
readonly DB_ENDPOINT_UNREACHABLE: "DB_ENDPOINT_UNREACHABLE";
|
|
14
|
+
readonly DB_CONNECTION_REFUSED: "DB_CONNECTION_REFUSED";
|
|
15
|
+
readonly DB_TLS_FAILED: "DB_TLS_FAILED";
|
|
16
|
+
readonly DB_AUTH_FAILED: "DB_AUTH_FAILED";
|
|
17
|
+
readonly DB_VALIDATION_TIMEOUT: "DB_VALIDATION_TIMEOUT";
|
|
13
18
|
readonly RESULT_TOO_LARGE: "RESULT_TOO_LARGE";
|
|
19
|
+
readonly AUDIT_FAILED: "AUDIT_FAILED";
|
|
20
|
+
readonly SERVER_BUSY: "SERVER_BUSY";
|
|
14
21
|
readonly UNSUPPORTED_FEATURE: "UNSUPPORTED_FEATURE";
|
|
15
22
|
};
|
|
16
23
|
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
|
|
@@ -53,7 +60,8 @@ export type FormatBlockedOptions = {
|
|
|
53
60
|
details?: Record<string, unknown>;
|
|
54
61
|
};
|
|
55
62
|
export type FormatConfirmationRequiredOptions = {
|
|
56
|
-
|
|
63
|
+
approvalRequest: string;
|
|
64
|
+
requestId: string;
|
|
57
65
|
metadata: ResponseMetadata;
|
|
58
66
|
summary?: string;
|
|
59
67
|
message?: string;
|
|
@@ -64,7 +72,8 @@ export declare function formatSuccess<T>(data: T, options: FormatSuccessOptions)
|
|
|
64
72
|
export declare function formatError<T = unknown>(options: FormatErrorOptions<T>): ToolResponse<T>;
|
|
65
73
|
export declare function formatBlocked(options: FormatBlockedOptions): ToolResponse;
|
|
66
74
|
export declare function formatConfirmationRequired(options: FormatConfirmationRequiredOptions): ToolResponse<{
|
|
67
|
-
|
|
75
|
+
approval_request: string;
|
|
76
|
+
request_id: string;
|
|
68
77
|
risk_level?: string;
|
|
69
78
|
sql_hash?: string;
|
|
70
79
|
}>;
|