@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/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
- break;
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 delimiter = sql.slice(i).match(/^\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/)?.[0];
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] ?? "") && !/[A-Za-z0-9_$]/.test(sql[quoteIndex - 2] ?? "");
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
- function checkPermission(sql, permission) {
17079
- const classification = classifyStatement(sql);
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;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dbcli-agent",
3
- "version": "1.47.0",
3
+ "version": "1.47.1",
4
4
  "description": "Database CLI skill and command reference for AI agents.",
5
5
  "contextFileName": "AGENTS.md"
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carllee1983/dbcli",
3
- "version": "1.47.0",
3
+ "version": "1.47.1",
4
4
  "description": "Database CLI for AI agents",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dbcli-agent",
3
- "version": "1.47.0",
3
+ "version": "1.47.1",
4
4
  "description": "Database CLI skill and command reference for AI agents",
5
5
  "author": {
6
6
  "name": "Carl Lee",
@@ -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 top-level `$out` / `$merge`; Elasticsearch permits searches.
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
@@ -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 top-level `$out` / `$merge`; Elasticsearch permits searches.
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