@carllee1983/dbcli 1.47.0 → 1.47.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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/dbcli.mdc +4 -1
- package/.cursor/skills/dbcli/reference.md +15 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/.github/skills/dbcli/SKILL.md +4 -1
- package/.github/skills/dbcli/reference.md +15 -1
- package/CHANGELOG.md +30 -0
- package/assets/SKILL.md +4 -1
- package/assets/reference.md +15 -1
- package/dist/cli.mjs +19876 -8978
- package/dist/core.d.ts +17 -0
- package/dist/core.mjs +76 -9
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
- package/plugins/dbcli-agent/.codex-plugin/plugin.json +1 -1
- package/plugins/dbcli-agent/skills/dbcli/SKILL.md +4 -1
- package/plugins/dbcli-agent/skills/dbcli/reference.md +15 -1
- package/skills/dbcli/SKILL.md +4 -1
- package/skills/dbcli/reference.md +15 -1
package/dist/core.d.ts
CHANGED
|
@@ -560,6 +560,12 @@ export interface QueryResult<T> {
|
|
|
560
560
|
/** Internal row-limit proof, mapped into public JSON metadata by the formatter. */
|
|
561
561
|
appliedLimit?: AppliedLimitMetadata;
|
|
562
562
|
}
|
|
563
|
+
declare const SQL_DIALECTS: readonly [
|
|
564
|
+
"postgresql",
|
|
565
|
+
"mysql",
|
|
566
|
+
"mariadb"
|
|
567
|
+
];
|
|
568
|
+
type SqlDialect = (typeof SQL_DIALECTS)[number];
|
|
563
569
|
/**
|
|
564
570
|
* Manager class for loading and querying blacklist rules.
|
|
565
571
|
* Instantiate once per CLI invocation.
|
|
@@ -2833,7 +2839,18 @@ export declare class QueryExecutor {
|
|
|
2833
2839
|
connectionName?: string;
|
|
2834
2840
|
recovery?: boolean;
|
|
2835
2841
|
deferDiagnostics?: boolean;
|
|
2842
|
+
/**
|
|
2843
|
+
* Quoting rules that decide what counts as a statement separator differ
|
|
2844
|
+
* per dialect. Without this, the stacking check has to fail closed.
|
|
2845
|
+
*/
|
|
2846
|
+
dialect?: SqlDialect;
|
|
2836
2847
|
});
|
|
2848
|
+
/**
|
|
2849
|
+
* The dialect the statement will actually run under. Falls back to the
|
|
2850
|
+
* connection config, then to undefined — where the stacking check fails
|
|
2851
|
+
* closed rather than guessing.
|
|
2852
|
+
*/
|
|
2853
|
+
private resolveDialect;
|
|
2837
2854
|
takeDiagnostics(): string[];
|
|
2838
2855
|
/**
|
|
2839
2856
|
* Execute a SQL query with permission enforcement and error handling
|
package/dist/core.mjs
CHANGED
|
@@ -16817,6 +16817,7 @@ function normalizeSQL(sql) {
|
|
|
16817
16817
|
return sql.replace(/--[^\n]*\n/g, `
|
|
16818
16818
|
`).replace(/\/\*[\s\S]*?\*\//g, " ").trim().replace(/\s+/g, " ");
|
|
16819
16819
|
}
|
|
16820
|
+
var IDENTIFIER_CONTINUATION = /[A-Za-z0-9_$]|[\u0080-\uFFFF]/;
|
|
16820
16821
|
function stripCommentsAndStrings(sql, options = {}) {
|
|
16821
16822
|
let result = "";
|
|
16822
16823
|
let i = 0;
|
|
@@ -16859,11 +16860,19 @@ function stripCommentsAndStrings(sql, options = {}) {
|
|
|
16859
16860
|
i = closingIndex === -1 ? sql.length : closingIndex + 2;
|
|
16860
16861
|
continue;
|
|
16861
16862
|
}
|
|
16863
|
+
const nests = options.dialect === "postgresql";
|
|
16864
|
+
let depth = 1;
|
|
16862
16865
|
i += 2;
|
|
16863
|
-
while (i < sql.length) {
|
|
16866
|
+
while (i < sql.length && depth > 0) {
|
|
16867
|
+
if (nests && sql[i] === "/" && sql[i + 1] === "*") {
|
|
16868
|
+
depth++;
|
|
16869
|
+
i += 2;
|
|
16870
|
+
continue;
|
|
16871
|
+
}
|
|
16864
16872
|
if (sql[i] === "*" && sql[i + 1] === "/") {
|
|
16873
|
+
depth--;
|
|
16865
16874
|
i += 2;
|
|
16866
|
-
|
|
16875
|
+
continue;
|
|
16867
16876
|
}
|
|
16868
16877
|
i++;
|
|
16869
16878
|
}
|
|
@@ -16871,7 +16880,8 @@ function stripCommentsAndStrings(sql, options = {}) {
|
|
|
16871
16880
|
continue;
|
|
16872
16881
|
}
|
|
16873
16882
|
if (options.dialect === "postgresql" && char === "$") {
|
|
16874
|
-
const
|
|
16883
|
+
const opensToken = !IDENTIFIER_CONTINUATION.test(sql[i - 1] ?? "");
|
|
16884
|
+
const delimiter = opensToken ? sql.slice(i).match(/^\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/)?.[0] : undefined;
|
|
16875
16885
|
if (delimiter) {
|
|
16876
16886
|
i += delimiter.length;
|
|
16877
16887
|
const closingIndex = sql.indexOf(delimiter, i);
|
|
@@ -16899,7 +16909,7 @@ function stripCommentsAndStrings(sql, options = {}) {
|
|
|
16899
16909
|
if (char === "'" || char === '"') {
|
|
16900
16910
|
const quote = char;
|
|
16901
16911
|
const quoteIndex = i;
|
|
16902
|
-
const postgresEscapeString = options.dialect === "postgresql" && quote === "'" && /[eE]/.test(sql[quoteIndex - 1] ?? "") &&
|
|
16912
|
+
const postgresEscapeString = options.dialect === "postgresql" && quote === "'" && /[eE]/.test(sql[quoteIndex - 1] ?? "") && !IDENTIFIER_CONTINUATION.test(sql[quoteIndex - 2] ?? "");
|
|
16903
16913
|
const backslashEscapes = options.dialect === undefined || postgresEscapeString;
|
|
16904
16914
|
i++;
|
|
16905
16915
|
while (i < sql.length) {
|
|
@@ -17075,8 +17085,59 @@ function classifyStatement(sql) {
|
|
|
17075
17085
|
confidence: determineConfidence(type, firstKeyword, upper)
|
|
17076
17086
|
};
|
|
17077
17087
|
}
|
|
17078
|
-
|
|
17079
|
-
|
|
17088
|
+
var SQL_DIALECTS = ["postgresql", "mysql", "mariadb"];
|
|
17089
|
+
var SQL_WRITE_OR_DDL_KEYWORDS = /(?<![.\w])(INSERT|UPDATE|DELETE|MERGE|UPSERT|REPLACE|TRUNCATE|DROP|ALTER|CREATE|GRANT|REVOKE|RENAME|INTO)\b(?!\s*\()/i;
|
|
17090
|
+
var SQL_LOCK_CLAUSE = /\bFOR\s+(?:NO\s+KEY\s+)?UPDATE\b|\bFOR\s+(?:KEY\s+)?SHARE\b/gi;
|
|
17091
|
+
function findWriteKeyword(sql, dialects) {
|
|
17092
|
+
const candidates = dialects && dialects.length > 0 ? dialects : SQL_DIALECTS;
|
|
17093
|
+
for (const dialect of candidates) {
|
|
17094
|
+
const executable = stripCommentsAndStrings(sql, { dialect }).replace(SQL_LOCK_CLAUSE, " ");
|
|
17095
|
+
const match = executable.match(SQL_WRITE_OR_DDL_KEYWORDS);
|
|
17096
|
+
if (match?.[1])
|
|
17097
|
+
return match[1].toUpperCase();
|
|
17098
|
+
}
|
|
17099
|
+
return;
|
|
17100
|
+
}
|
|
17101
|
+
function containsMultipleStatements(sql, dialect) {
|
|
17102
|
+
const statementCount = (candidate) => stripCommentsAndStrings(sql, { dialect: candidate }).split(";").filter((part) => part.trim().length > 0).length;
|
|
17103
|
+
if (dialect)
|
|
17104
|
+
return statementCount(dialect) > 1;
|
|
17105
|
+
return SQL_DIALECTS.some((candidate) => statementCount(candidate) > 1);
|
|
17106
|
+
}
|
|
17107
|
+
var ESCALATABLE_READ_TYPES = new Set(["SELECT", "EXPLAIN", "DESCRIBE"]);
|
|
17108
|
+
function escalateHiddenWrite(sql, classification, dialect) {
|
|
17109
|
+
if (!ESCALATABLE_READ_TYPES.has(classification.type))
|
|
17110
|
+
return classification;
|
|
17111
|
+
const plansOnly = classification.type === "EXPLAIN" || classification.type === "DESCRIBE";
|
|
17112
|
+
if (plansOnly && !/\bANALYZE\b/i.test(sql))
|
|
17113
|
+
return classification;
|
|
17114
|
+
const hidden = findWriteKeyword(sql, dialect ? [dialect] : undefined);
|
|
17115
|
+
if (!hidden)
|
|
17116
|
+
return classification;
|
|
17117
|
+
return {
|
|
17118
|
+
...classification,
|
|
17119
|
+
type: "UNKNOWN",
|
|
17120
|
+
isDangerous: true,
|
|
17121
|
+
confidence: "HIGH",
|
|
17122
|
+
escalatedFrom: hidden
|
|
17123
|
+
};
|
|
17124
|
+
}
|
|
17125
|
+
function checkPermission(sql, permission, dialect) {
|
|
17126
|
+
const classification = escalateHiddenWrite(sql, classifyStatement(sql), dialect);
|
|
17127
|
+
if (permission !== "admin" && containsMultipleStatements(sql, dialect)) {
|
|
17128
|
+
return {
|
|
17129
|
+
allowed: false,
|
|
17130
|
+
reason: "SQL containing multiple statements is refused below admin permission, because only " + "the first statement determines the permission check. Run each statement separately.",
|
|
17131
|
+
classification
|
|
17132
|
+
};
|
|
17133
|
+
}
|
|
17134
|
+
if (permission !== "admin" && classification.escalatedFrom) {
|
|
17135
|
+
return {
|
|
17136
|
+
allowed: false,
|
|
17137
|
+
reason: `This statement opens as a read but contains an executable ` + `${classification.escalatedFrom}. A write hidden inside a read statement ` + `requires admin permission (current level: ${permission}).`,
|
|
17138
|
+
classification
|
|
17139
|
+
};
|
|
17140
|
+
}
|
|
17080
17141
|
if (permission === "admin") {
|
|
17081
17142
|
return {
|
|
17082
17143
|
allowed: true,
|
|
@@ -17136,8 +17197,8 @@ function checkPermission(sql, permission) {
|
|
|
17136
17197
|
classification
|
|
17137
17198
|
};
|
|
17138
17199
|
}
|
|
17139
|
-
function enforcePermission(sql, permission) {
|
|
17140
|
-
const result = checkPermission(sql, permission);
|
|
17200
|
+
function enforcePermission(sql, permission, dialect) {
|
|
17201
|
+
const result = checkPermission(sql, permission, dialect);
|
|
17141
17202
|
if (!result.allowed) {
|
|
17142
17203
|
throw new PermissionError(result.reason, result.classification, permission);
|
|
17143
17204
|
}
|
|
@@ -22885,6 +22946,12 @@ class QueryExecutor {
|
|
|
22885
22946
|
this.config = config;
|
|
22886
22947
|
this.options = options;
|
|
22887
22948
|
}
|
|
22949
|
+
resolveDialect() {
|
|
22950
|
+
if (this.options.dialect)
|
|
22951
|
+
return this.options.dialect;
|
|
22952
|
+
const system = this.config?.connection?.system;
|
|
22953
|
+
return SQL_DIALECTS.find((dialect) => dialect === system);
|
|
22954
|
+
}
|
|
22888
22955
|
takeDiagnostics() {
|
|
22889
22956
|
const diagnostics = this.pendingDiagnostics;
|
|
22890
22957
|
this.pendingDiagnostics = [];
|
|
@@ -22894,7 +22961,7 @@ class QueryExecutor {
|
|
|
22894
22961
|
const start = performance.now();
|
|
22895
22962
|
this.pendingDiagnostics = [];
|
|
22896
22963
|
try {
|
|
22897
|
-
const classification = enforcePermission(sql, this.permission);
|
|
22964
|
+
const classification = enforcePermission(sql, this.permission, this.resolveDialect());
|
|
22898
22965
|
const dangerousOperationWarning = classification.isDangerous && this.permission === "admin" ? `\u26A0 Warning: executing ${classification.type} operation (admin mode)` : undefined;
|
|
22899
22966
|
const AUTO_LIMIT_TYPES = new Set(["SELECT"]);
|
|
22900
22967
|
let executeSql = sql;
|
package/gemini-extension.json
CHANGED
package/package.json
CHANGED
|
@@ -217,7 +217,10 @@ or `doctor` / `status` reports a missing or invalid config, follow this flow.
|
|
|
217
217
|
`--password` / `--name` (and `--system`).
|
|
218
218
|
3. **What permission tier?** Default to the **lowest** that satisfies the task:
|
|
219
219
|
`query-only` → `read-write` → `data-admin` → `admin`. Set with `--permission`
|
|
220
|
-
(defaults to `query-only`).
|
|
220
|
+
(defaults to `query-only`). Tiers judge what a statement does, not how it
|
|
221
|
+
opens: below `admin`, multi-statement SQL is rejected; snippets must be free
|
|
222
|
+
of write and DDL keywords; MongoDB `$out` / `$merge` need `data-admin` and are
|
|
223
|
+
refused entirely in snippets and `export`.
|
|
221
224
|
4. **Verify, never assume.** After init: `dbcli status` (system + permission +
|
|
222
225
|
blacklist summary, no creds) and `dbcli doctor --format json` (env, config
|
|
223
226
|
shape, connectivity, schema-cache age, Mongo SRV path).
|
|
@@ -223,6 +223,13 @@ dbcli query "SELECT * FROM orders" --format html > orders.html # pipe to stdou
|
|
|
223
223
|
**Options:** `--format <table|json|csv|html>`, `--ui` (open the dashboard in the system browser; implies `--format html`), `--limit <number>`, `--no-limit`, `--collection <name>` (MongoDB / Elasticsearch), `--index <name>` (Elasticsearch alias for `--collection`), `--fields <list>`, `--truncate <number>` / `--no-truncate`, `-f, --query-file <path>`, `--use <name[,name]>`, `--recovery`
|
|
224
224
|
**Permission:** query-only+ (Redis: per-command; Elasticsearch: per HTTP method/path)
|
|
225
225
|
|
|
226
|
+
Below `admin`, SQL holding more than one statement is rejected, because only the
|
|
227
|
+
first statement would decide the permission check while a driver on the simple
|
|
228
|
+
query protocol executes them all. Semicolons inside string literals, backtick
|
|
229
|
+
identifiers, and `#` comments are not separators. A MongoDB pipeline containing
|
|
230
|
+
`$out` or `$merge` requires `data-admin`, and is rejected outright on `export`,
|
|
231
|
+
in snippets, and in multi-connection fan-out.
|
|
232
|
+
|
|
226
233
|
#### Field projection (`--fields`)
|
|
227
234
|
|
|
228
235
|
```bash
|
|
@@ -279,7 +286,7 @@ instead of silently running its only connection.
|
|
|
279
286
|
An explicit comma-separated `--use primary,staging` fans one query out to several named
|
|
280
287
|
connections. `DBCLI_CONNECTION` always names one literal connection and never enables
|
|
281
288
|
fan-out. SQL permits `SELECT`, `SHOW`, `DESCRIBE`, and `EXPLAIN`; MongoDB permits filters and
|
|
282
|
-
read-only pipelines without
|
|
289
|
+
read-only pipelines without `$out` / `$merge`; Elasticsearch permits searches.
|
|
283
290
|
Redis, writes, `--recovery`, `--ui`, CSV, and HTML are rejected before execution. Each
|
|
284
291
|
connection keeps its own blacklist, limit metadata, audit entry, and error. Aggregate exit
|
|
285
292
|
codes are `0` when all succeed, `2` for mixed outcomes, and `1` when all fail or preflight
|
|
@@ -531,6 +538,13 @@ dbcli q @analytics/revenue --param days=30 --format html > report.html
|
|
|
531
538
|
|
|
532
539
|
Each `.sql` file is plain SQL with optional YAML frontmatter inside a leading `-- ---` block. Lines outside frontmatter form the SQL body.
|
|
533
540
|
|
|
541
|
+
Snippets are read-only by contract, at every permission level including `admin`.
|
|
542
|
+
A body must be a single statement opening with `SELECT` or `WITH` **and** free of
|
|
543
|
+
write or DDL keywords, so a data-modifying CTE (`WITH x AS (DELETE … RETURNING *)
|
|
544
|
+
SELECT * FROM x`) and `SELECT … INTO` are rejected at parse time rather than at
|
|
545
|
+
execution. A MongoDB body may not contain `$out` or `$merge`. The same rule
|
|
546
|
+
applies to `verify.query` in frontmatter, which `q --verify` executes verbatim.
|
|
547
|
+
|
|
534
548
|
```sql
|
|
535
549
|
-- ---
|
|
536
550
|
-- name: DAU
|
package/skills/dbcli/SKILL.md
CHANGED
|
@@ -217,7 +217,10 @@ or `doctor` / `status` reports a missing or invalid config, follow this flow.
|
|
|
217
217
|
`--password` / `--name` (and `--system`).
|
|
218
218
|
3. **What permission tier?** Default to the **lowest** that satisfies the task:
|
|
219
219
|
`query-only` → `read-write` → `data-admin` → `admin`. Set with `--permission`
|
|
220
|
-
(defaults to `query-only`).
|
|
220
|
+
(defaults to `query-only`). Tiers judge what a statement does, not how it
|
|
221
|
+
opens: below `admin`, multi-statement SQL is rejected; snippets must be free
|
|
222
|
+
of write and DDL keywords; MongoDB `$out` / `$merge` need `data-admin` and are
|
|
223
|
+
refused entirely in snippets and `export`.
|
|
221
224
|
4. **Verify, never assume.** After init: `dbcli status` (system + permission +
|
|
222
225
|
blacklist summary, no creds) and `dbcli doctor --format json` (env, config
|
|
223
226
|
shape, connectivity, schema-cache age, Mongo SRV path).
|
|
@@ -223,6 +223,13 @@ dbcli query "SELECT * FROM orders" --format html > orders.html # pipe to stdou
|
|
|
223
223
|
**Options:** `--format <table|json|csv|html>`, `--ui` (open the dashboard in the system browser; implies `--format html`), `--limit <number>`, `--no-limit`, `--collection <name>` (MongoDB / Elasticsearch), `--index <name>` (Elasticsearch alias for `--collection`), `--fields <list>`, `--truncate <number>` / `--no-truncate`, `-f, --query-file <path>`, `--use <name[,name]>`, `--recovery`
|
|
224
224
|
**Permission:** query-only+ (Redis: per-command; Elasticsearch: per HTTP method/path)
|
|
225
225
|
|
|
226
|
+
Below `admin`, SQL holding more than one statement is rejected, because only the
|
|
227
|
+
first statement would decide the permission check while a driver on the simple
|
|
228
|
+
query protocol executes them all. Semicolons inside string literals, backtick
|
|
229
|
+
identifiers, and `#` comments are not separators. A MongoDB pipeline containing
|
|
230
|
+
`$out` or `$merge` requires `data-admin`, and is rejected outright on `export`,
|
|
231
|
+
in snippets, and in multi-connection fan-out.
|
|
232
|
+
|
|
226
233
|
#### Field projection (`--fields`)
|
|
227
234
|
|
|
228
235
|
```bash
|
|
@@ -279,7 +286,7 @@ instead of silently running its only connection.
|
|
|
279
286
|
An explicit comma-separated `--use primary,staging` fans one query out to several named
|
|
280
287
|
connections. `DBCLI_CONNECTION` always names one literal connection and never enables
|
|
281
288
|
fan-out. SQL permits `SELECT`, `SHOW`, `DESCRIBE`, and `EXPLAIN`; MongoDB permits filters and
|
|
282
|
-
read-only pipelines without
|
|
289
|
+
read-only pipelines without `$out` / `$merge`; Elasticsearch permits searches.
|
|
283
290
|
Redis, writes, `--recovery`, `--ui`, CSV, and HTML are rejected before execution. Each
|
|
284
291
|
connection keeps its own blacklist, limit metadata, audit entry, and error. Aggregate exit
|
|
285
292
|
codes are `0` when all succeed, `2` for mixed outcomes, and `1` when all fail or preflight
|
|
@@ -531,6 +538,13 @@ dbcli q @analytics/revenue --param days=30 --format html > report.html
|
|
|
531
538
|
|
|
532
539
|
Each `.sql` file is plain SQL with optional YAML frontmatter inside a leading `-- ---` block. Lines outside frontmatter form the SQL body.
|
|
533
540
|
|
|
541
|
+
Snippets are read-only by contract, at every permission level including `admin`.
|
|
542
|
+
A body must be a single statement opening with `SELECT` or `WITH` **and** free of
|
|
543
|
+
write or DDL keywords, so a data-modifying CTE (`WITH x AS (DELETE … RETURNING *)
|
|
544
|
+
SELECT * FROM x`) and `SELECT … INTO` are rejected at parse time rather than at
|
|
545
|
+
execution. A MongoDB body may not contain `$out` or `$merge`. The same rule
|
|
546
|
+
applies to `verify.query` in frontmatter, which `q --verify` executes verbatim.
|
|
547
|
+
|
|
534
548
|
```sql
|
|
535
549
|
-- ---
|
|
536
550
|
-- name: DAU
|