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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +3 -1
  3. package/dist/audit/jsonl-writer.d.ts +47 -0
  4. package/dist/audit/jsonl-writer.js +124 -0
  5. package/dist/auth/sql-profile-loader/env-source.js +6 -10
  6. package/dist/auth/sql-profile-loader/parsing.js +8 -5
  7. package/dist/auth/sql-profile-loader/runtime-override.d.ts +1 -0
  8. package/dist/auth/sql-profile-loader/runtime-override.js +19 -1
  9. package/dist/auth/sql-profile-loader/types.d.ts +5 -0
  10. package/dist/capability/probe.js +3 -1
  11. package/dist/cloud/auth.d.ts +1 -0
  12. package/dist/cloud/auth.js +37 -0
  13. package/dist/config/env.js +16 -0
  14. package/dist/config/schema.d.ts +78 -0
  15. package/dist/config/schema.js +19 -0
  16. package/dist/context/datasource-resolver.js +7 -0
  17. package/dist/context/session-context.d.ts +7 -0
  18. package/dist/diagnostics/replication-lag.d.ts +4 -0
  19. package/dist/diagnostics/replication-lag.js +138 -0
  20. package/dist/diagnostics/types.d.ts +5 -1
  21. package/dist/diagnostics/types.js +7 -7
  22. package/dist/engine/runtime.d.ts +2 -2
  23. package/dist/engine/runtime.js +8 -14
  24. package/dist/engine/types.d.ts +3 -2
  25. package/dist/engine.d.ts +5 -5
  26. package/dist/engine.js +32 -5
  27. package/dist/executor/bounded-sql.d.ts +1 -0
  28. package/dist/executor/bounded-sql.js +10 -0
  29. package/dist/executor/concurrency-limiter.d.ts +15 -0
  30. package/dist/executor/concurrency-limiter.js +67 -0
  31. package/dist/executor/connection-pool.d.ts +3 -2
  32. package/dist/executor/connection-pool.js +35 -24
  33. package/dist/executor/sql-executor.d.ts +3 -0
  34. package/dist/executor/sql-executor.js +52 -9
  35. package/dist/executor/types.d.ts +5 -1
  36. package/dist/index.d.ts +8 -3
  37. package/dist/index.js +5 -1
  38. package/dist/safety/confirmation-store.d.ts +30 -7
  39. package/dist/safety/confirmation-store.js +154 -30
  40. package/dist/safety/guardrail.d.ts +3 -0
  41. package/dist/safety/guardrail.js +16 -6
  42. package/dist/safety/redaction.d.ts +6 -0
  43. package/dist/safety/redaction.js +47 -6
  44. package/dist/safety/sql-classifier.d.ts +1 -0
  45. package/dist/safety/sql-classifier.js +1 -0
  46. package/dist/safety/sql-validator.d.ts +1 -0
  47. package/dist/safety/sql-validator.js +25 -1
  48. package/dist/schema/adapters/mysql.js +3 -1
  49. package/dist/taurus/flashback.js +4 -10
  50. package/dist/taurus/recycle-bin.js +2 -8
  51. package/dist/utils/formatter.d.ts +11 -2
  52. package/dist/utils/formatter.js +12 -3
  53. package/dist/utils/logger.js +8 -0
  54. package/dist/utils/mysql-identifier.d.ts +1 -0
  55. package/dist/utils/mysql-identifier.js +9 -0
  56. 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 token = this.generateUniqueToken();
61
- this.entries.set(token, {
62
- token,
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
- token,
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
- const entry = this.entries.get(token);
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", "Confirmation token not found.");
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(token);
85
- return blockResult("CF002", "Confirmation token has expired.");
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", "Confirmation token has already been used.");
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
- const currentSqlHash = sqlHash(normalizedCurrentSql);
92
- if (currentSqlHash !== entry.sqlHash) {
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 !== entry.datasource || currentDatabase !== entry.database) {
97
- return blockResult("CF004", "Datasource or database mismatch for confirmation token.");
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(token) {
103
- this.entries.delete(token);
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 [token, entry] of this.entries.entries()) {
113
- if (entry.expiresAt <= now) {
114
- this.entries.delete(token);
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
- generateUniqueToken() {
242
+ generateUniqueRequestId() {
119
243
  for (let i = 0; i < 5; i += 1) {
120
- const token = `${TOKEN_PREFIX}${this.randomBytesFn(32).toString("base64url")}`;
121
- if (!this.entries.has(token)) {
122
- return token;
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 confirmation token.");
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";
@@ -1,6 +1,7 @@
1
1
  import { createSqlParser } from "./parser/index.js";
2
2
  import { classifySql } from "./sql-classifier.js";
3
- import { validateStaticRules, validateToolScope, } from "./sql-validator.js";
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
- return buildBaseDecision(input, normalizedSql, sqlHash, action, riskLevel, reasonCodes, riskHints, requiresExplain);
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;
@@ -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
- const rowTruncated = sourceRows.length > maxRows || originalRowCount > maxRows;
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 = isSensitiveColumn(column.name, explicitSensitive);
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: outputRows,
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)),
@@ -8,6 +8,7 @@ export interface SqlClassification {
8
8
  sqlHash: string;
9
9
  isMultiStatement: boolean;
10
10
  referencedTables: string[];
11
+ referencedSchemas: string[];
11
12
  referencedColumns: string[];
12
13
  hasWhere: boolean;
13
14
  hasLimit: boolean;
@@ -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 execute_sql for controlled mutations.",
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);
@@ -1,6 +1,6 @@
1
1
  import { createSqlParser } from "../safety/parser/index.js";
2
2
  import { normalizeSql } from "../utils/hash.js";
3
- const IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_$]*$/;
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 = quoteIdentifier(input.database ?? defaultDatabase, "database");
132
- const table = quoteIdentifier(input.table, "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) => quoteIdentifier(column, "column")).join(", ")
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 ${quoteIdentifier(input.destinationDatabase, "destination_database")}.${quoteIdentifier(input.destinationTable, "destination_table")} SELECT * FROM \`${RECYCLE_BIN_DATABASE}\`.${quoteRecycleTableName(input.recycleTable)}`;
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
- confirmationToken: string;
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
- confirmation_token: string;
75
+ approval_request: string;
76
+ request_id: string;
68
77
  risk_level?: string;
69
78
  sql_hash?: string;
70
79
  }>;