sandal-db 1.0.7 → 1.0.8
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/README.md +5 -5
- package/dist/cli.js +442 -109
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -136,7 +136,7 @@ npx sandal-db@latest md [options] [fileOrText]
|
|
|
136
136
|
| `-k, --key <key>` | `string` | API key corresponding to the selected provider. | Environment / Config |
|
|
137
137
|
| `--strict` | `boolean` | Hard-blocks full database drops and bulk table truncations. | `true` |
|
|
138
138
|
| `--no-strict` | `flag` | Disables strict safety mode. | `false` |
|
|
139
|
-
| `--allow-full-wipe` | `flag` | Explicitly permits database
|
|
139
|
+
| `--allow-full-wipe` | `flag` | Explicitly permits full database wipes and db admin tasks after interactive confirmation. | `false` |
|
|
140
140
|
| `--threshold <number>` | `integer` | Row count threshold above which updates are classified as dangerous operations. | `50` |
|
|
141
141
|
| `--markdown` | `boolean` | Formats and renders LLM responses as rich terminal markdown. | `true` |
|
|
142
142
|
| `--no-markdown` | `flag` | Disables terminal markdown rendering and outputs raw text. | `false` |
|
|
@@ -404,16 +404,16 @@ Any mutation query lacking a filter or `WHERE` clause poses extreme operational
|
|
|
404
404
|
? Destructive operation with no filter. Type "DELETE ALL" to confirm:
|
|
405
405
|
```
|
|
406
406
|
|
|
407
|
-
### 6. Strict Mode
|
|
407
|
+
### 6. Strict Mode, Full-Wipe, and Admin Tasks Guardrails
|
|
408
408
|
|
|
409
|
-
By default, `--strict` mode is enabled. Any operation that attempts to drop an entire database
|
|
409
|
+
By default, `--strict` mode is enabled and `--allow-full-wipe` is disabled. Any operation that attempts to drop an entire database, drop all tables, or execute database administration tasks (e.g. user/role drops, privilege changes, maintenance locks, backend termination) is blocked unconditionally:
|
|
410
410
|
|
|
411
411
|
```text
|
|
412
|
-
Operation blocked by --strict mode:
|
|
412
|
+
Operation blocked by --strict mode: database admin tasks are prohibited.
|
|
413
413
|
To allow this, start sandal-db with --allow-full-wipe.
|
|
414
414
|
```
|
|
415
415
|
|
|
416
|
-
To permit database-level destructions, the user must explicitly supply `--allow-full-wipe` at startup and subsequently
|
|
416
|
+
To permit database-level destructions and administrative tasks, the user must explicitly supply `--allow-full-wipe` at startup and subsequently confirm the operation during interactive execution.
|
|
417
417
|
|
|
418
418
|
---
|
|
419
419
|
|
package/dist/cli.js
CHANGED
|
@@ -916,16 +916,33 @@ var PostgresAdapter = class {
|
|
|
916
916
|
const startTime = Date.now();
|
|
917
917
|
const client = await this.pool.connect();
|
|
918
918
|
try {
|
|
919
|
-
const
|
|
919
|
+
const hasParams = Array.isArray(query.params) && query.params.length > 0;
|
|
920
|
+
const res = hasParams ? await client.query(sql, query.params) : await client.query(sql);
|
|
920
921
|
const durationMs = Date.now() - startTime;
|
|
921
922
|
let rows = [];
|
|
922
923
|
let fields = [];
|
|
923
924
|
let rowCount = 0;
|
|
924
925
|
if (Array.isArray(res)) {
|
|
926
|
+
let totalAffected = 0;
|
|
927
|
+
const allRows = [];
|
|
928
|
+
let lastFields = [];
|
|
929
|
+
for (const stmtRes of res) {
|
|
930
|
+
if (stmtRes) {
|
|
931
|
+
if (typeof stmtRes.rowCount === "number") {
|
|
932
|
+
totalAffected += stmtRes.rowCount;
|
|
933
|
+
}
|
|
934
|
+
if (stmtRes.rows && stmtRes.rows.length > 0) {
|
|
935
|
+
allRows.push(...stmtRes.rows);
|
|
936
|
+
}
|
|
937
|
+
if (stmtRes.fields && stmtRes.fields.length > 0) {
|
|
938
|
+
lastFields = stmtRes.fields.map((f) => f.name);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
}
|
|
925
942
|
const lastResult = res[res.length - 1];
|
|
926
|
-
rows = lastResult?.rows || [];
|
|
927
|
-
fields = (lastResult?.fields || []).map((f) => f.name);
|
|
928
|
-
rowCount = lastResult?.rowCount ?? rows.length;
|
|
943
|
+
rows = allRows.length > 0 ? allRows : lastResult?.rows || [];
|
|
944
|
+
fields = lastFields.length > 0 ? lastFields : (lastResult?.fields || []).map((f) => f.name);
|
|
945
|
+
rowCount = totalAffected > 0 ? totalAffected : lastResult?.rowCount ?? rows.length;
|
|
929
946
|
} else if (res) {
|
|
930
947
|
rows = res.rows || [];
|
|
931
948
|
fields = (res.fields || []).map((f) => f.name);
|
|
@@ -938,7 +955,7 @@ var PostgresAdapter = class {
|
|
|
938
955
|
rowCount,
|
|
939
956
|
affectedRows: rowCount,
|
|
940
957
|
durationMs,
|
|
941
|
-
command: Array.isArray(res) ? res
|
|
958
|
+
command: Array.isArray(res) ? res.map((r) => r?.command).filter(Boolean).join("; ") : res?.command
|
|
942
959
|
};
|
|
943
960
|
} catch (err) {
|
|
944
961
|
const durationMs = Date.now() - startTime;
|
|
@@ -1168,6 +1185,39 @@ var MongoAdapter = class {
|
|
|
1168
1185
|
rows: [result]
|
|
1169
1186
|
};
|
|
1170
1187
|
}
|
|
1188
|
+
let operationsList = query.operations;
|
|
1189
|
+
if (!operationsList && typeof query.rawDisplay === "string") {
|
|
1190
|
+
try {
|
|
1191
|
+
const parsedDisplay = JSON.parse(query.rawDisplay);
|
|
1192
|
+
if (Array.isArray(parsedDisplay.operations)) {
|
|
1193
|
+
operationsList = parsedDisplay.operations;
|
|
1194
|
+
}
|
|
1195
|
+
} catch {
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
if (Array.isArray(operationsList) && operationsList.length > 0) {
|
|
1199
|
+
let totalAffected = 0;
|
|
1200
|
+
const allRows = [];
|
|
1201
|
+
for (const op of operationsList) {
|
|
1202
|
+
const subResult = await this.executeQuery({
|
|
1203
|
+
...op,
|
|
1204
|
+
rawDisplay: typeof op === "string" ? op : JSON.stringify(op)
|
|
1205
|
+
});
|
|
1206
|
+
if (!subResult.success) {
|
|
1207
|
+
return subResult;
|
|
1208
|
+
}
|
|
1209
|
+
totalAffected += subResult.affectedRows ?? subResult.rowCount ?? 0;
|
|
1210
|
+
if (subResult.rows) allRows.push(...subResult.rows);
|
|
1211
|
+
}
|
|
1212
|
+
return {
|
|
1213
|
+
success: true,
|
|
1214
|
+
rows: allRows,
|
|
1215
|
+
rowCount: allRows.length,
|
|
1216
|
+
affectedRows: totalAffected,
|
|
1217
|
+
durationMs: Date.now() - startTime,
|
|
1218
|
+
command: "multi-operation"
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1171
1221
|
let collectionName = query.collection;
|
|
1172
1222
|
let operation = query.operation;
|
|
1173
1223
|
let filter = query.filter || {};
|
|
@@ -1191,6 +1241,19 @@ var MongoAdapter = class {
|
|
|
1191
1241
|
}
|
|
1192
1242
|
}
|
|
1193
1243
|
if (!collectionName) {
|
|
1244
|
+
if (operation === "dropDatabase") {
|
|
1245
|
+
const res = await db.dropDatabase();
|
|
1246
|
+
const durationMs2 = Date.now() - startTime;
|
|
1247
|
+
return {
|
|
1248
|
+
success: true,
|
|
1249
|
+
rows: [{ droppedDatabase: res }],
|
|
1250
|
+
rowCount: res ? 1 : 0,
|
|
1251
|
+
affectedRows: 0,
|
|
1252
|
+
fields: ["droppedDatabase"],
|
|
1253
|
+
durationMs: durationMs2,
|
|
1254
|
+
command: "dropDatabase"
|
|
1255
|
+
};
|
|
1256
|
+
}
|
|
1194
1257
|
throw new Error("Mongo operation requires a target collection.");
|
|
1195
1258
|
}
|
|
1196
1259
|
const collection = db.collection(collectionName);
|
|
@@ -1280,6 +1343,12 @@ var MongoAdapter = class {
|
|
|
1280
1343
|
rowCount = 1;
|
|
1281
1344
|
break;
|
|
1282
1345
|
}
|
|
1346
|
+
case "dropDatabase": {
|
|
1347
|
+
const res = await db.dropDatabase();
|
|
1348
|
+
rows = [{ droppedDatabase: res }];
|
|
1349
|
+
rowCount = res ? 1 : 0;
|
|
1350
|
+
break;
|
|
1351
|
+
}
|
|
1283
1352
|
default:
|
|
1284
1353
|
throw new Error(`Unsupported MongoDB operation: ${operation}`);
|
|
1285
1354
|
}
|
|
@@ -1308,6 +1377,15 @@ var MongoAdapter = class {
|
|
|
1308
1377
|
await this.connect();
|
|
1309
1378
|
}
|
|
1310
1379
|
try {
|
|
1380
|
+
if (Array.isArray(query.operations)) {
|
|
1381
|
+
let total = 0;
|
|
1382
|
+
for (const op of query.operations) {
|
|
1383
|
+
const opObj = typeof op === "object" && op !== null && op.rawDisplay ? op : { ...op, rawDisplay: JSON.stringify(op) };
|
|
1384
|
+
const count = await this.dryRunCount(opObj);
|
|
1385
|
+
if (count !== null) total += count;
|
|
1386
|
+
}
|
|
1387
|
+
return total > 0 ? total : null;
|
|
1388
|
+
}
|
|
1311
1389
|
let collectionName = query.collection;
|
|
1312
1390
|
let operation = query.operation;
|
|
1313
1391
|
let filter = query.filter || {};
|
|
@@ -1523,6 +1601,7 @@ function classifyPostgresQuery(sql, query, rowThreshold, options) {
|
|
|
1523
1601
|
isDestructive: true,
|
|
1524
1602
|
isStructural: false,
|
|
1525
1603
|
isFullWipe: true,
|
|
1604
|
+
isAdmin: true,
|
|
1526
1605
|
requiresLiteralWord: true,
|
|
1527
1606
|
literalWord: "DROP DATABASE",
|
|
1528
1607
|
hasWhereClause: false,
|
|
@@ -1530,94 +1609,89 @@ function classifyPostgresQuery(sql, query, rowThreshold, options) {
|
|
|
1530
1609
|
explanation: "Permanently deletes database or schema cascade."
|
|
1531
1610
|
};
|
|
1532
1611
|
}
|
|
1533
|
-
const
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
if (isCreateIndex) {
|
|
1539
|
-
const isConcurrently = /CREATE\s+(UNIQUE\s+)?INDEX\s+CONCURRENTLY/i.test(cleanSql);
|
|
1540
|
-
let suggestion;
|
|
1541
|
-
if (!isConcurrently) {
|
|
1542
|
-
warnings.push(
|
|
1543
|
-
"Index is not being created CONCURRENTLY. This will lock write operations on the table during index creation."
|
|
1544
|
-
);
|
|
1545
|
-
suggestion = cleanSql.replace(/CREATE\s+(UNIQUE\s+)?INDEX/i, (m) => `${m} CONCURRENTLY`);
|
|
1546
|
-
}
|
|
1547
|
-
const tableMatch = cleanSql.match(/ON\s+([^\s;(]+)/i);
|
|
1548
|
-
const tableName = tableMatch ? tableMatch[1] : void 0;
|
|
1612
|
+
const isUserOrRole = /^(?:CREATE|ALTER|DROP)\s+(?:USER|ROLE)\b/i.test(cleanSql);
|
|
1613
|
+
if (isUserOrRole) {
|
|
1614
|
+
const isDrop = /^DROP\s+(?:USER|ROLE)\b/i.test(cleanSql);
|
|
1615
|
+
const isRole = /\bROLE\b/i.test(cleanSql);
|
|
1616
|
+
const word = isRole ? "DROP ROLE" : "DROP USER";
|
|
1549
1617
|
return {
|
|
1550
|
-
category: "
|
|
1551
|
-
isDestructive:
|
|
1552
|
-
isStructural:
|
|
1618
|
+
category: "admin",
|
|
1619
|
+
isDestructive: isDrop,
|
|
1620
|
+
isStructural: false,
|
|
1553
1621
|
isFullWipe: false,
|
|
1554
|
-
|
|
1622
|
+
isAdmin: true,
|
|
1623
|
+
requiresLiteralWord: isDrop,
|
|
1624
|
+
literalWord: isDrop ? word : void 0,
|
|
1555
1625
|
hasWhereClause: false,
|
|
1556
|
-
|
|
1557
|
-
explanation:
|
|
1558
|
-
warnings,
|
|
1559
|
-
suggestedAlternative: suggestion
|
|
1626
|
+
warnings: isDrop ? ["Permanently deletes database credentials and associated privileges."] : [],
|
|
1627
|
+
explanation: `${isDrop ? "Drops" : "Manages"} database ${isRole ? "role" : "user"}.`
|
|
1560
1628
|
};
|
|
1561
1629
|
}
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1630
|
+
const isGrant = /^GRANT\s+/i.test(cleanSql);
|
|
1631
|
+
const isRevoke = /^REVOKE\s+/i.test(cleanSql);
|
|
1632
|
+
if (isGrant || isRevoke) {
|
|
1565
1633
|
return {
|
|
1566
|
-
category: "
|
|
1634
|
+
category: "admin",
|
|
1567
1635
|
isDestructive: false,
|
|
1568
|
-
isStructural:
|
|
1636
|
+
isStructural: false,
|
|
1569
1637
|
isFullWipe: false,
|
|
1638
|
+
isAdmin: true,
|
|
1570
1639
|
requiresLiteralWord: false,
|
|
1571
1640
|
hasWhereClause: false,
|
|
1572
|
-
|
|
1573
|
-
explanation:
|
|
1574
|
-
warnings
|
|
1641
|
+
warnings: isRevoke ? ["Revoking privileges modifies access control permissions."] : [],
|
|
1642
|
+
explanation: isGrant ? "Grants database privileges." : "Revokes database privileges."
|
|
1575
1643
|
};
|
|
1576
1644
|
}
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1645
|
+
const isVacuum = /^VACUUM\b/i.test(cleanSql);
|
|
1646
|
+
const isAnalyze = /^ANALYZE\b/i.test(cleanSql);
|
|
1647
|
+
const isReindex = /^REINDEX\b/i.test(cleanSql);
|
|
1648
|
+
const isCheckpoint = /^CHECKPOINT\b/i.test(cleanSql);
|
|
1649
|
+
if (isVacuum || isAnalyze || isReindex || isCheckpoint) {
|
|
1650
|
+
const isVacuumFull = /^VACUUM\s+FULL\b/i.test(cleanSql);
|
|
1651
|
+
const maintenanceWarnings = [];
|
|
1652
|
+
if (isVacuumFull) {
|
|
1653
|
+
maintenanceWarnings.push(
|
|
1654
|
+
"VACUUM FULL exclusively locks tables and may cause operational downtime on active tables."
|
|
1655
|
+
);
|
|
1656
|
+
}
|
|
1580
1657
|
return {
|
|
1581
|
-
category: "
|
|
1658
|
+
category: "admin",
|
|
1582
1659
|
isDestructive: false,
|
|
1583
|
-
isStructural:
|
|
1660
|
+
isStructural: false,
|
|
1584
1661
|
isFullWipe: false,
|
|
1662
|
+
isAdmin: true,
|
|
1585
1663
|
requiresLiteralWord: false,
|
|
1586
1664
|
hasWhereClause: false,
|
|
1587
|
-
|
|
1588
|
-
explanation:
|
|
1589
|
-
warnings
|
|
1665
|
+
warnings: maintenanceWarnings,
|
|
1666
|
+
explanation: isVacuum ? "Performs database vacuum maintenance." : isReindex ? "Reindexes database tables or indexes." : isAnalyze ? "Collects database statistics for query planner." : "Forces a database checkpoint."
|
|
1590
1667
|
};
|
|
1591
1668
|
}
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
const tableName = match ? match[1] : void 0;
|
|
1669
|
+
const isAlterSystemOrDb = /^ALTER\s+(?:SYSTEM|DATABASE)\b/i.test(cleanSql);
|
|
1670
|
+
if (isAlterSystemOrDb) {
|
|
1595
1671
|
return {
|
|
1596
|
-
category: "
|
|
1672
|
+
category: "admin",
|
|
1597
1673
|
isDestructive: false,
|
|
1598
|
-
isStructural:
|
|
1674
|
+
isStructural: false,
|
|
1599
1675
|
isFullWipe: false,
|
|
1676
|
+
isAdmin: true,
|
|
1600
1677
|
requiresLiteralWord: false,
|
|
1601
1678
|
hasWhereClause: false,
|
|
1602
|
-
|
|
1603
|
-
explanation:
|
|
1604
|
-
warnings
|
|
1679
|
+
warnings: ["Alters database server or cluster configuration settings."],
|
|
1680
|
+
explanation: "Modifies database or system-level configuration."
|
|
1605
1681
|
};
|
|
1606
1682
|
}
|
|
1607
|
-
const
|
|
1608
|
-
if (
|
|
1609
|
-
const match = cleanSql.match(/ALTER\s+TABLE\s+([^\s;(]+)/i);
|
|
1610
|
-
const tableName = match ? match[1] : void 0;
|
|
1683
|
+
const isKillBackend = /\bpg_(?:terminate|cancel)_backend\s*\(/i.test(cleanSql);
|
|
1684
|
+
if (isKillBackend) {
|
|
1611
1685
|
return {
|
|
1612
|
-
category: "
|
|
1686
|
+
category: "admin",
|
|
1613
1687
|
isDestructive: true,
|
|
1614
1688
|
isStructural: false,
|
|
1615
1689
|
isFullWipe: false,
|
|
1690
|
+
isAdmin: true,
|
|
1616
1691
|
requiresLiteralWord: false,
|
|
1617
1692
|
hasWhereClause: false,
|
|
1618
|
-
|
|
1619
|
-
explanation:
|
|
1620
|
-
warnings: ["Removing a column is permanent and irreversible."]
|
|
1693
|
+
warnings: ["Terminates active database backend connection process."],
|
|
1694
|
+
explanation: "Terminates active backend query connection."
|
|
1621
1695
|
};
|
|
1622
1696
|
}
|
|
1623
1697
|
const isDropTable = /DROP\s+TABLE/i.test(cleanSql);
|
|
@@ -1638,9 +1712,25 @@ function classifyPostgresQuery(sql, query, rowThreshold, options) {
|
|
|
1638
1712
|
warnings: ["All rows will be permanently deleted."]
|
|
1639
1713
|
};
|
|
1640
1714
|
}
|
|
1641
|
-
const
|
|
1715
|
+
const isAlterDrop = /ALTER\s+TABLE\s+([^\s;]+)\s+DROP\s+COLUMN/i.test(cleanSql);
|
|
1716
|
+
if (isAlterDrop) {
|
|
1717
|
+
const match = cleanSql.match(/ALTER\s+TABLE\s+([^\s;(]+)/i);
|
|
1718
|
+
const tableName = match ? match[1] : void 0;
|
|
1719
|
+
return {
|
|
1720
|
+
category: "dangerous",
|
|
1721
|
+
isDestructive: true,
|
|
1722
|
+
isStructural: false,
|
|
1723
|
+
isFullWipe: false,
|
|
1724
|
+
requiresLiteralWord: false,
|
|
1725
|
+
hasWhereClause: false,
|
|
1726
|
+
tableOrCollection: tableName,
|
|
1727
|
+
explanation: `Removes a column from table "${tableName || "unknown"}", permanently discarding column data.`,
|
|
1728
|
+
warnings: ["Removing a column is permanent and irreversible."]
|
|
1729
|
+
};
|
|
1730
|
+
}
|
|
1731
|
+
const isDelete = /\bDELETE\s+FROM\b/i.test(cleanSql);
|
|
1642
1732
|
if (isDelete) {
|
|
1643
|
-
const match = cleanSql.match(
|
|
1733
|
+
const match = cleanSql.match(/DELETE\s+FROM\s+([^\s;(]+)(?:\s+WHERE\s+([\s\S]+?))?(?:;|$)/i);
|
|
1644
1734
|
const tableName = match ? match[1] : void 0;
|
|
1645
1735
|
const hasWhere = Boolean(match && match[2] && match[2].trim().length > 0);
|
|
1646
1736
|
if (!hasWhere) {
|
|
@@ -1657,21 +1747,10 @@ function classifyPostgresQuery(sql, query, rowThreshold, options) {
|
|
|
1657
1747
|
warnings: ["No WHERE clause specified: EVERY row in the table will be deleted!"]
|
|
1658
1748
|
};
|
|
1659
1749
|
}
|
|
1660
|
-
return {
|
|
1661
|
-
category: "dangerous",
|
|
1662
|
-
isDestructive: true,
|
|
1663
|
-
isStructural: false,
|
|
1664
|
-
isFullWipe: false,
|
|
1665
|
-
requiresLiteralWord: false,
|
|
1666
|
-
hasWhereClause: true,
|
|
1667
|
-
tableOrCollection: tableName,
|
|
1668
|
-
explanation: `Deletes filtered rows from table "${tableName || "unknown"}".`,
|
|
1669
|
-
warnings: ["Mutates existing database records."]
|
|
1670
|
-
};
|
|
1671
1750
|
}
|
|
1672
|
-
const isUpdate =
|
|
1751
|
+
const isUpdate = /\bUPDATE\s+[^\s;(]+\s+SET\b/i.test(cleanSql);
|
|
1673
1752
|
if (isUpdate) {
|
|
1674
|
-
const match = cleanSql.match(
|
|
1753
|
+
const match = cleanSql.match(/UPDATE\s+([^\s;(]+)\s+SET\s+[\s\S]+?(?:\s+WHERE\s+([\s\S]+?))?(?:;|$)/i);
|
|
1675
1754
|
const tableName = match ? match[1] : void 0;
|
|
1676
1755
|
const hasWhere = Boolean(match && match[2] && match[2].trim().length > 0);
|
|
1677
1756
|
if (!hasWhere) {
|
|
@@ -1688,6 +1767,101 @@ function classifyPostgresQuery(sql, query, rowThreshold, options) {
|
|
|
1688
1767
|
warnings: ["No WHERE clause specified: EVERY row in the table will be updated!"]
|
|
1689
1768
|
};
|
|
1690
1769
|
}
|
|
1770
|
+
}
|
|
1771
|
+
const isCreateIndex = /CREATE\s+(UNIQUE\s+)?INDEX/i.test(cleanSql);
|
|
1772
|
+
const isCreateTable = /CREATE\s+TABLE/i.test(cleanSql);
|
|
1773
|
+
const isCreateSchema = /CREATE\s+SCHEMA/i.test(cleanSql);
|
|
1774
|
+
const isAlterAdd = /ALTER\s+TABLE\s+([^\s;]+)\s+ADD\s+(COLUMN|CONSTRAINT)/i.test(cleanSql);
|
|
1775
|
+
const isAlterOtherSafe = /ALTER\s+TABLE\s+([^\s;]+)\s+(ALTER\s+COLUMN|RENAME)/i.test(cleanSql);
|
|
1776
|
+
if (isCreateIndex) {
|
|
1777
|
+
const isConcurrently = /CREATE\s+(UNIQUE\s+)?INDEX\s+CONCURRENTLY/i.test(cleanSql);
|
|
1778
|
+
let suggestion;
|
|
1779
|
+
if (!isConcurrently) {
|
|
1780
|
+
warnings.push(
|
|
1781
|
+
"Index is not being created CONCURRENTLY. This will lock write operations on the table during index creation."
|
|
1782
|
+
);
|
|
1783
|
+
suggestion = cleanSql.replace(/CREATE\s+(UNIQUE\s+)?INDEX/i, (m) => `${m} CONCURRENTLY`);
|
|
1784
|
+
}
|
|
1785
|
+
const tableMatch = cleanSql.match(/ON\s+([^\s;(]+)/i);
|
|
1786
|
+
const tableName = tableMatch ? tableMatch[1] : void 0;
|
|
1787
|
+
return {
|
|
1788
|
+
category: "structural",
|
|
1789
|
+
isDestructive: false,
|
|
1790
|
+
isStructural: true,
|
|
1791
|
+
isFullWipe: false,
|
|
1792
|
+
requiresLiteralWord: false,
|
|
1793
|
+
hasWhereClause: false,
|
|
1794
|
+
tableOrCollection: tableName,
|
|
1795
|
+
explanation: `Creates an index on ${tableName || "table"}${isConcurrently ? " concurrently (non-blocking)" : " (may lock table during build)"}.`,
|
|
1796
|
+
warnings,
|
|
1797
|
+
suggestedAlternative: suggestion
|
|
1798
|
+
};
|
|
1799
|
+
}
|
|
1800
|
+
if (isCreateTable) {
|
|
1801
|
+
const tableMatches = [...cleanSql.matchAll(/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([^\s;(]+)/gi)].map((m) => m[1]);
|
|
1802
|
+
const hasInserts = /\bINSERT\s+INTO\b/i.test(cleanSql);
|
|
1803
|
+
const tableName = tableMatches[0] || "unknown";
|
|
1804
|
+
const explanation = tableMatches.length > 1 ? `Creates tables (${tableMatches.join(", ")})${hasInserts ? " and populates seed data" : ""}.` : `Creates a new table "${tableName}"${hasInserts ? " and populates seed data" : ""}.`;
|
|
1805
|
+
return {
|
|
1806
|
+
category: "structural",
|
|
1807
|
+
isDestructive: false,
|
|
1808
|
+
isStructural: true,
|
|
1809
|
+
isFullWipe: false,
|
|
1810
|
+
requiresLiteralWord: false,
|
|
1811
|
+
hasWhereClause: false,
|
|
1812
|
+
tableOrCollection: tableName,
|
|
1813
|
+
explanation,
|
|
1814
|
+
warnings
|
|
1815
|
+
};
|
|
1816
|
+
}
|
|
1817
|
+
if (isCreateSchema) {
|
|
1818
|
+
const match = cleanSql.match(/CREATE\s+SCHEMA\s+(?:IF\s+NOT\s+EXISTS\s+)?([^\s;(]+)/i);
|
|
1819
|
+
const schemaName = match ? match[1] : void 0;
|
|
1820
|
+
return {
|
|
1821
|
+
category: "structural",
|
|
1822
|
+
isDestructive: false,
|
|
1823
|
+
isStructural: true,
|
|
1824
|
+
isFullWipe: false,
|
|
1825
|
+
requiresLiteralWord: false,
|
|
1826
|
+
hasWhereClause: false,
|
|
1827
|
+
tableOrCollection: schemaName,
|
|
1828
|
+
explanation: `Creates a new schema "${schemaName || "unknown"}".`,
|
|
1829
|
+
warnings
|
|
1830
|
+
};
|
|
1831
|
+
}
|
|
1832
|
+
if (isAlterAdd || isAlterOtherSafe) {
|
|
1833
|
+
const match = cleanSql.match(/ALTER\s+TABLE\s+([^\s;(]+)/i);
|
|
1834
|
+
const tableName = match ? match[1] : void 0;
|
|
1835
|
+
return {
|
|
1836
|
+
category: "structural",
|
|
1837
|
+
isDestructive: false,
|
|
1838
|
+
isStructural: true,
|
|
1839
|
+
isFullWipe: false,
|
|
1840
|
+
requiresLiteralWord: false,
|
|
1841
|
+
hasWhereClause: false,
|
|
1842
|
+
tableOrCollection: tableName,
|
|
1843
|
+
explanation: `Alters table "${tableName || "unknown"}" to add or modify structural attributes.`,
|
|
1844
|
+
warnings
|
|
1845
|
+
};
|
|
1846
|
+
}
|
|
1847
|
+
if (isDelete) {
|
|
1848
|
+
const match = cleanSql.match(/DELETE\s+FROM\s+([^\s;(]+)(?:\s+WHERE\s+([\s\S]+?))?(?:;|$)/i);
|
|
1849
|
+
const tableName = match ? match[1] : void 0;
|
|
1850
|
+
return {
|
|
1851
|
+
category: "dangerous",
|
|
1852
|
+
isDestructive: true,
|
|
1853
|
+
isStructural: false,
|
|
1854
|
+
isFullWipe: false,
|
|
1855
|
+
requiresLiteralWord: false,
|
|
1856
|
+
hasWhereClause: true,
|
|
1857
|
+
tableOrCollection: tableName,
|
|
1858
|
+
explanation: `Deletes filtered rows from table "${tableName || "unknown"}".`,
|
|
1859
|
+
warnings: ["Mutates existing database records."]
|
|
1860
|
+
};
|
|
1861
|
+
}
|
|
1862
|
+
if (isUpdate) {
|
|
1863
|
+
const match = cleanSql.match(/UPDATE\s+([^\s;(]+)\s+SET\s+[\s\S]+?(?:\s+WHERE\s+([\s\S]+?))?(?:;|$)/i);
|
|
1864
|
+
const tableName = match ? match[1] : void 0;
|
|
1691
1865
|
return {
|
|
1692
1866
|
category: "write",
|
|
1693
1867
|
isDestructive: false,
|
|
@@ -1700,10 +1874,12 @@ function classifyPostgresQuery(sql, query, rowThreshold, options) {
|
|
|
1700
1874
|
warnings: ["Mutates existing database records."]
|
|
1701
1875
|
};
|
|
1702
1876
|
}
|
|
1703
|
-
const isInsert =
|
|
1877
|
+
const isInsert = /\bINSERT\s+INTO\b/i.test(cleanSql);
|
|
1704
1878
|
if (isInsert) {
|
|
1705
|
-
const
|
|
1706
|
-
const
|
|
1879
|
+
const insertMatches = [...cleanSql.matchAll(/INSERT\s+INTO\s+([^\s;(]+)/gi)].map((m) => m[1]);
|
|
1880
|
+
const uniqueTables = [...new Set(insertMatches)];
|
|
1881
|
+
const tableName = uniqueTables[0] || "unknown";
|
|
1882
|
+
const explanation = uniqueTables.length > 1 ? `Inserts new records into tables (${uniqueTables.join(", ")}).` : `Inserts new records into table "${tableName}".`;
|
|
1707
1883
|
return {
|
|
1708
1884
|
category: "write",
|
|
1709
1885
|
isDestructive: false,
|
|
@@ -1712,7 +1888,7 @@ function classifyPostgresQuery(sql, query, rowThreshold, options) {
|
|
|
1712
1888
|
requiresLiteralWord: false,
|
|
1713
1889
|
hasWhereClause: false,
|
|
1714
1890
|
tableOrCollection: tableName,
|
|
1715
|
-
explanation
|
|
1891
|
+
explanation,
|
|
1716
1892
|
warnings: []
|
|
1717
1893
|
};
|
|
1718
1894
|
}
|
|
@@ -1743,22 +1919,54 @@ function classifyMongoQuery(rawText, query, rowThreshold, options) {
|
|
|
1743
1919
|
let collectionName = query.collection;
|
|
1744
1920
|
let operation = query.operation;
|
|
1745
1921
|
let filter = query.filter;
|
|
1922
|
+
let operations = query.operations;
|
|
1746
1923
|
if (!collectionName || !operation) {
|
|
1747
1924
|
try {
|
|
1748
1925
|
const parsed = JSON.parse(rawText);
|
|
1749
1926
|
collectionName = parsed.collection || collectionName;
|
|
1750
1927
|
operation = parsed.operation || operation;
|
|
1751
1928
|
filter = parsed.filter || filter;
|
|
1929
|
+
operations = parsed.operations || operations;
|
|
1752
1930
|
} catch {
|
|
1753
1931
|
}
|
|
1754
1932
|
}
|
|
1933
|
+
if (Array.isArray(operations) && operations.length > 0) {
|
|
1934
|
+
const subClassifications = operations.map((op) => {
|
|
1935
|
+
const display = typeof op === "string" ? op : op.rawDisplay || JSON.stringify(op);
|
|
1936
|
+
const opObj = typeof op === "object" && op !== null && op.rawDisplay ? op : { ...op, rawDisplay: display };
|
|
1937
|
+
return classifyMongoQuery(display, opObj, rowThreshold, options);
|
|
1938
|
+
});
|
|
1939
|
+
const fullWipe = subClassifications.find((c) => c.isFullWipe);
|
|
1940
|
+
if (fullWipe) return fullWipe;
|
|
1941
|
+
const admin = subClassifications.find((c) => c.category === "admin" || c.isAdmin);
|
|
1942
|
+
if (admin) return admin;
|
|
1943
|
+
const dangerous = subClassifications.find((c) => c.category === "dangerous");
|
|
1944
|
+
if (dangerous) return dangerous;
|
|
1945
|
+
const structural = subClassifications.find((c) => c.category === "structural");
|
|
1946
|
+
if (structural) return structural;
|
|
1947
|
+
const targetCols = [
|
|
1948
|
+
...new Set(operations.map((o) => o.collection).filter(Boolean))
|
|
1949
|
+
];
|
|
1950
|
+
return {
|
|
1951
|
+
category: "write",
|
|
1952
|
+
isDestructive: false,
|
|
1953
|
+
isStructural: false,
|
|
1954
|
+
isFullWipe: false,
|
|
1955
|
+
requiresLiteralWord: false,
|
|
1956
|
+
hasWhereClause: false,
|
|
1957
|
+
tableOrCollection: targetCols.join(", "),
|
|
1958
|
+
explanation: `Executes batch operations across collections (${targetCols.join(", ")}).`,
|
|
1959
|
+
warnings: []
|
|
1960
|
+
};
|
|
1961
|
+
}
|
|
1755
1962
|
const warnings = [];
|
|
1756
|
-
if (operation === "command" && (query.rawCommand?.dropDatabase || rawText.includes("dropDatabase"))) {
|
|
1963
|
+
if (operation === "dropDatabase" || operation === "command" && (query.rawCommand?.dropDatabase || rawText.includes("dropDatabase"))) {
|
|
1757
1964
|
return {
|
|
1758
1965
|
category: "full_wipe",
|
|
1759
1966
|
isDestructive: true,
|
|
1760
1967
|
isStructural: false,
|
|
1761
1968
|
isFullWipe: true,
|
|
1969
|
+
isAdmin: true,
|
|
1762
1970
|
requiresLiteralWord: true,
|
|
1763
1971
|
literalWord: "DROP DATABASE",
|
|
1764
1972
|
hasWhereClause: false,
|
|
@@ -1766,6 +1974,25 @@ function classifyMongoQuery(rawText, query, rowThreshold, options) {
|
|
|
1766
1974
|
explanation: "Drops the entire database."
|
|
1767
1975
|
};
|
|
1768
1976
|
}
|
|
1977
|
+
const isAdminCmd = operation === "createUser" || operation === "dropUser" || operation === "grantRolesToUser" || operation === "revokeRolesFromUser" || operation === "repairDatabase" || operation === "compact" || operation === "reIndex" || operation === "command" && Boolean(
|
|
1978
|
+
query.rawCommand?.createUser || query.rawCommand?.dropUser || query.rawCommand?.grantRolesToUser || query.rawCommand?.revokeRolesFromUser || query.rawCommand?.repairDatabase || query.rawCommand?.compact || query.rawCommand?.reIndex || query.rawCommand?.killOp || query.rawCommand?.shutdown || rawText.includes("createUser") || rawText.includes("dropUser") || rawText.includes("killOp") || rawText.includes("repairDatabase")
|
|
1979
|
+
);
|
|
1980
|
+
if (isAdminCmd) {
|
|
1981
|
+
const isDrop = operation === "dropUser" || query.rawCommand?.dropUser || rawText.includes("dropUser");
|
|
1982
|
+
return {
|
|
1983
|
+
category: "admin",
|
|
1984
|
+
isDestructive: isDrop,
|
|
1985
|
+
isStructural: false,
|
|
1986
|
+
isFullWipe: false,
|
|
1987
|
+
isAdmin: true,
|
|
1988
|
+
requiresLiteralWord: isDrop,
|
|
1989
|
+
literalWord: isDrop ? "DROP USER" : void 0,
|
|
1990
|
+
hasWhereClause: false,
|
|
1991
|
+
tableOrCollection: collectionName,
|
|
1992
|
+
warnings: isDrop ? ["Permanently removes database user credentials and roles."] : [],
|
|
1993
|
+
explanation: `Performs MongoDB administrative operation (${operation || "command"}).`
|
|
1994
|
+
};
|
|
1995
|
+
}
|
|
1769
1996
|
if (operation === "createIndex") {
|
|
1770
1997
|
const isBackground = query.options?.background === true;
|
|
1771
1998
|
let suggestion;
|
|
@@ -2021,6 +2248,50 @@ async function requestUserConfirmation(query, safety, affectedRows, ask = defaul
|
|
|
2021
2248
|
);
|
|
2022
2249
|
return { confirmed: answer2 };
|
|
2023
2250
|
}
|
|
2251
|
+
if (safety.category === "admin" || safety.isAdmin) {
|
|
2252
|
+
const lines2 = [
|
|
2253
|
+
chalk2.bold.hex("#DA70D6")("\u{1F6E1}\uFE0F DATABASE ADMIN OPERATION"),
|
|
2254
|
+
"",
|
|
2255
|
+
chalk2.white(queryDisplay),
|
|
2256
|
+
"",
|
|
2257
|
+
chalk2.hex("#DA70D6")(`Action: ${safety.explanation || "Administrative database operation."}`)
|
|
2258
|
+
];
|
|
2259
|
+
if (safety.warnings.length > 0) {
|
|
2260
|
+
lines2.push("");
|
|
2261
|
+
for (const w of safety.warnings) {
|
|
2262
|
+
lines2.push(chalk2.yellow(`\u2022 ${w}`));
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
console.log(
|
|
2266
|
+
boxen(lines2.join("\n"), {
|
|
2267
|
+
padding: 1,
|
|
2268
|
+
borderColor: "magenta",
|
|
2269
|
+
borderStyle: "round",
|
|
2270
|
+
margin: { top: 1, bottom: 1 }
|
|
2271
|
+
})
|
|
2272
|
+
);
|
|
2273
|
+
if (safety.requiresLiteralWord && safety.literalWord) {
|
|
2274
|
+
const typed = await promptInput(
|
|
2275
|
+
chalk2.hex("#DA70D6").bold(
|
|
2276
|
+
`Administrative operation. Type "${safety.literalWord}" to confirm: `
|
|
2277
|
+
),
|
|
2278
|
+
ask
|
|
2279
|
+
);
|
|
2280
|
+
if (typed.trim() === safety.literalWord) {
|
|
2281
|
+
return { confirmed: true };
|
|
2282
|
+
}
|
|
2283
|
+
return {
|
|
2284
|
+
confirmed: false,
|
|
2285
|
+
reason: `Confirmation word mismatch (expected "${safety.literalWord}"). Operation cancelled.`
|
|
2286
|
+
};
|
|
2287
|
+
}
|
|
2288
|
+
const answer2 = await promptConfirm(
|
|
2289
|
+
chalk2.hex("#DA70D6").bold("Execute database admin operation?"),
|
|
2290
|
+
false,
|
|
2291
|
+
ask
|
|
2292
|
+
);
|
|
2293
|
+
return { confirmed: answer2 };
|
|
2294
|
+
}
|
|
2024
2295
|
if (safety.isStructural) {
|
|
2025
2296
|
const lines2 = [
|
|
2026
2297
|
chalk2.bold.cyan("\u{1F6E0}\uFE0F STRUCTURAL DDL OPERATION"),
|
|
@@ -2107,7 +2378,8 @@ import { HumanMessage } from "@langchain/core/messages";
|
|
|
2107
2378
|
|
|
2108
2379
|
// src/agent/prompts.ts
|
|
2109
2380
|
var REFUSAL_MESSAGE = "I'm scoped to database operations on your connected DB only (schema inspection, queries, CRUD, analysis). I can't help with that here.";
|
|
2110
|
-
function getSystemPrompt(dbType, schemaSummary) {
|
|
2381
|
+
function getSystemPrompt(dbType, schemaSummary, options) {
|
|
2382
|
+
const allowFullWipe = options?.allowFullWipe === true;
|
|
2111
2383
|
return `You are "SANDAL" (Safe Agentic Natural-language Database Access Layer), an expert, production-grade agentic database assistant.
|
|
2112
2384
|
You are connected directly to a live ${dbType === "postgres" ? "PostgreSQL" : "MongoDB"} database.
|
|
2113
2385
|
|
|
@@ -2119,6 +2391,9 @@ STRICT SCOPE & SAFETY RULES
|
|
|
2119
2391
|
- Data analysis, querying, filtering, sorting, aggregations, and statistics
|
|
2120
2392
|
- CRUD operations (SELECT/find, INSERT/insertOne, UPDATE/updateOne, DELETE/deleteOne)
|
|
2121
2393
|
- Structural DDL operations (CREATE TABLE, CREATE INDEX, ALTER TABLE ADD COLUMN, createCollection, createIndex)
|
|
2394
|
+
- Generating and seeding realistic fake, mock, or sample data across tables or collections
|
|
2395
|
+
- You ARE fully authorized, equipped, and expected to generate DDL (CREATE TABLE) and DML (INSERT) to fulfill the user's database tasks.
|
|
2396
|
+
${allowFullWipe ? ` - Database administrative tasks and full database operations (e.g. DROP DATABASE, DROP SCHEMA CASCADE, user/role management like CREATE/ALTER/DROP USER/ROLE, GRANT/REVOKE privileges, maintenance tasks like VACUUM/REINDEX/CHECKPOINT, connection management like pg_terminate_backend/killOp, and admin commands)` : ` - Database administrative tasks (when requested, generate the appropriate query so security guardrails can inspect it)`}
|
|
2122
2397
|
2. REFUSE ANY OUT-OF-SCOPE REQUEST:
|
|
2123
2398
|
- If the user asks for general programming (e.g. "write a Python script", "build a web scraper"), general Q&A, creative writing, math tutoring, or any non-database task, politely refuse immediately with:
|
|
2124
2399
|
"${REFUSAL_MESSAGE}"
|
|
@@ -2126,33 +2401,62 @@ STRICT SCOPE & SAFETY RULES
|
|
|
2126
2401
|
4. NEVER expose raw credentials, passwords, or API keys in your responses.
|
|
2127
2402
|
5. NEVER hallucinate data. Ground your answers ONLY in the actual query results returned from the database.
|
|
2128
2403
|
|
|
2404
|
+
${allowFullWipe ? `\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
2405
|
+
ADMIN & FULL-WIPE AUTHORIZATION (--allow-full-wipe ENABLED)
|
|
2406
|
+
\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
2407
|
+
\u2022 The user has launched SANDAL with --allow-full-wipe.
|
|
2408
|
+
\u2022 You ARE fully authorized to generate database administrative tasks, table drops, and full database wipes (such as DROP DATABASE, DROP SCHEMA CASCADE, CREATE/ALTER/DROP USER/ROLE, GRANT/REVOKE, VACUUM FULL, REINDEX, pg_terminate_backend, or MongoDB administrative commands).
|
|
2409
|
+
\u2022 When the user requests a database administration task or full wipe, fulfill it by generating the appropriate database query. Our multi-stage security pipeline will handle confirmation before execution.` : `\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
2410
|
+
ADMIN & FULL-WIPE SAFETY (--allow-full-wipe DISABLED)
|
|
2411
|
+
\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
2412
|
+
\u2022 Full database drops and destructive database admin tasks require starting sandal-db with --allow-full-wipe.
|
|
2413
|
+
\u2022 If the user requests a database administrative task or full wipe, generate the corresponding query so that the static security guardrails can intercept it and clearly inform the user that --allow-full-wipe is required.`}
|
|
2414
|
+
|
|
2129
2415
|
\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
2130
2416
|
QUERY GENERATION RULES
|
|
2131
2417
|
\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
2132
2418
|
${dbType === "postgres" ? `\u2022 Target Dialect: PostgreSQL.
|
|
2133
|
-
\u2022 Produce valid SQL. For queries with
|
|
2419
|
+
\u2022 Produce valid SQL. For single queries with parameters, use parameterized queries ($1, $2) with params array, or clean SQL literals.
|
|
2420
|
+
\u2022 Multi-table & Complex Operations (Creating tables & Seeding data):
|
|
2421
|
+
- When the user asks to add, generate, mock, or seed data across tables (e.g. "add fake users and their spendings in different tables"):
|
|
2422
|
+
1. If the required tables do not exist in the database schema, automatically generate "CREATE TABLE IF NOT EXISTS" statements for all required tables with appropriate primary keys (e.g. SERIAL PRIMARY KEY or UUID), data types, foreign key references, and timestamps.
|
|
2423
|
+
2. IMPORTANT FOR MANAGED SCHEMAS (e.g. Supabase auth.users): Do NOT insert fake users or mock data directly into managed internal schemas like "auth.users". Instead, create and populate application tables in the "public" schema (e.g. "public.customers" or "public.users", "public.spendings", "public.products", "public.order_items").
|
|
2424
|
+
3. Generate realistic, coherent fake data (realistic names, prices, dates, quantities) with properly matched foreign key relationships between tables.
|
|
2425
|
+
4. Combine all statements into a single, cohesive multi-statement SQL script separated by semicolons (e.g. "CREATE TABLE IF NOT EXISTS ...; CREATE TABLE IF NOT EXISTS ...; INSERT INTO ...; INSERT INTO ...;").
|
|
2426
|
+
5. In multi-statement scripts, embed values directly as clean SQL literals and keep "params": [] so the entire script executes cleanly.
|
|
2134
2427
|
\u2022 For UPDATE or DELETE queries, ALWAYS include a specific, narrow WHERE clause unless the user explicitly requested modifying all rows.
|
|
2135
2428
|
\u2022 For index creation, prefer "CREATE INDEX CONCURRENTLY" to avoid write-locking tables.
|
|
2136
2429
|
\u2022 Return your query formatted in a json object:
|
|
2137
2430
|
{
|
|
2138
2431
|
"type": "postgres",
|
|
2139
|
-
"sql": "<the SQL statement>",
|
|
2432
|
+
"sql": "<the SQL statement or multi-statement script>",
|
|
2140
2433
|
"params": [],
|
|
2141
|
-
"targetTables": ["table1"],
|
|
2434
|
+
"targetTables": ["table1", "table2"],
|
|
2142
2435
|
"explanation": "<brief summary of what the query does>"
|
|
2143
2436
|
}` : `\u2022 Target Dialect: MongoDB.
|
|
2144
|
-
\u2022 Produce valid MongoDB operations in structured JSON format
|
|
2437
|
+
\u2022 Produce valid MongoDB operations in structured JSON format.
|
|
2438
|
+
\u2022 Single-collection operation:
|
|
2145
2439
|
{
|
|
2146
2440
|
"type": "mongodb",
|
|
2147
2441
|
"collection": "<collection_name>",
|
|
2148
|
-
"operation": "find" | "aggregate" | "countDocuments" | "insertOne" | "insertMany" | "updateOne" | "updateMany" | "deleteOne" | "deleteMany" | "createIndex" | "createCollection",
|
|
2442
|
+
"operation": "find" | "aggregate" | "countDocuments" | "insertOne" | "insertMany" | "updateOne" | "updateMany" | "deleteOne" | "deleteMany" | "createIndex" | "createCollection"${allowFullWipe ? ' | "drop" | "dropDatabase" | "command"' : ""},
|
|
2149
2443
|
"filter": {},
|
|
2150
2444
|
"update": {},
|
|
2151
2445
|
"pipeline": [],
|
|
2152
2446
|
"document": {},
|
|
2447
|
+
"documents": [],
|
|
2153
2448
|
"options": {},
|
|
2154
2449
|
"explanation": "<brief summary of what the operation does>"
|
|
2155
2450
|
}
|
|
2451
|
+
\u2022 Multi-collection operations (e.g. adding fake data across multiple collections):
|
|
2452
|
+
{
|
|
2453
|
+
"type": "mongodb",
|
|
2454
|
+
"operations": [
|
|
2455
|
+
{ "collection": "users", "operation": "insertMany", "documents": [...] },
|
|
2456
|
+
{ "collection": "spendings", "operation": "insertMany", "documents": [...] }
|
|
2457
|
+
],
|
|
2458
|
+
"explanation": "<brief summary of what the operations do>"
|
|
2459
|
+
}
|
|
2156
2460
|
\u2022 For UPDATE or DELETE operations, ALWAYS include a specific filter unless the user explicitly requested modifying all documents.
|
|
2157
2461
|
\u2022 For createIndex, prefer options like {"background": true}.`}
|
|
2158
2462
|
|
|
@@ -2198,7 +2502,7 @@ var INTENT_CLASSIFICATION_PROMPT = `You are a strict security and intent classif
|
|
|
2198
2502
|
Determine whether the following user message is a "database_task" or "out_of_scope".
|
|
2199
2503
|
|
|
2200
2504
|
Definitions:
|
|
2201
|
-
- "database_task": The user asks about database schema, tables, collections, fields, running queries, counting rows, inserting/updating/deleting data, creating tables or indexes, filtering, grouping, aggregating,
|
|
2505
|
+
- "database_task": The user asks about database schema, tables, collections, fields, running queries, counting rows, inserting/updating/deleting data, creating tables or indexes, generating or populating fake/mock/sample data or test fixtures across tables or collections, filtering, grouping, aggregating, analyzing database records, database administration tasks (e.g. database/schema wipes or drops, user/role management like CREATE/DROP USER or ROLE, GRANT/REVOKE permissions, VACUUM, REINDEX, maintenance, killing backend processes/connections, database configuration, or admin commands), or conversational follow-ups directly relating to the database session.
|
|
2202
2506
|
- "out_of_scope": The user asks for general coding help (e.g., "write a python script to scrape a website", "create a React app", "write a bash script"), general programming questions, creative writing, math tutoring, conversational chat unrelated to the DB, weather, philosophy, history, or anything not acting on the connected database.
|
|
2203
2507
|
|
|
2204
2508
|
Respond with EXACTLY ONE JSON OBJECT and nothing else:
|
|
@@ -2224,12 +2528,15 @@ var OBVIOUS_OUT_OF_SCOPE_PATTERNS = [
|
|
|
2224
2528
|
/acting\s+as\s+a\s+general\s+chatbot/i
|
|
2225
2529
|
];
|
|
2226
2530
|
var OBVIOUS_DATABASE_PATTERNS = [
|
|
2227
|
-
/^(?:SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|EXPLAIN|SHOW|DESCRIBE|WITH)\b/i,
|
|
2531
|
+
/^(?:SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|EXPLAIN|SHOW|DESCRIBE|WITH|GRANT|REVOKE|VACUUM|REINDEX|CHECKPOINT|DISCARD|RESET|LOCK)\b/i,
|
|
2228
2532
|
/\b(?:table|tables|collection|collections|schema|column|columns|index|indexes|foreign key|primary key)\b/i,
|
|
2229
2533
|
/\b(?:find|aggregate|count|where|group by|order by|having|limit|join|left join|inner join)\b/i,
|
|
2230
2534
|
/\b(?:how many (?:users|rows|records|orders|items|products|documents))\b/i,
|
|
2231
2535
|
/\b(?:database|postgres|mongodb|mongo|pg_)\b/i,
|
|
2232
|
-
/\b(?:query|queries|sql|result|results|rows?|records?)\b/i
|
|
2536
|
+
/\b(?:query|queries|sql|result|results|rows?|records?)\b/i,
|
|
2537
|
+
/\b(?:role|roles|permission|permissions|privilege|privileges|admin|administration|vacuum|reindex|checkpoint|terminate|kill\s+query|backend)\b/i,
|
|
2538
|
+
/\b(?:fake|mock|seed|populate|sample|test)\s+(?:users?|orders?|products?|customers?|data|rows?|records?|documents?|tables?|collections?|spendings?|purchases?)\b/i,
|
|
2539
|
+
/\b(?:add|insert|create|populate|seed|generate)\b.*?\b(?:users?|orders?|products?|customers?|data|rows?|records?|documents?|tables?|collections?|spendings?|purchases?)\b/i
|
|
2233
2540
|
];
|
|
2234
2541
|
function isExplicitNoQuery(input3) {
|
|
2235
2542
|
const trimmed = input3.trim();
|
|
@@ -2318,11 +2625,19 @@ ${trimmed}`;
|
|
|
2318
2625
|
function checkStrictWipe(classification, options = {}) {
|
|
2319
2626
|
const strictMode = options.strictMode !== false;
|
|
2320
2627
|
const allowFullWipe = options.allowFullWipe === true;
|
|
2321
|
-
if (
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2628
|
+
if (strictMode && !allowFullWipe) {
|
|
2629
|
+
if (classification.isFullWipe) {
|
|
2630
|
+
return {
|
|
2631
|
+
blocked: true,
|
|
2632
|
+
message: "Operation blocked by --strict mode: full database or all-table drop is prohibited. To allow this, start sandal-db with --allow-full-wipe."
|
|
2633
|
+
};
|
|
2634
|
+
}
|
|
2635
|
+
if (classification.category === "admin" || classification.isAdmin) {
|
|
2636
|
+
return {
|
|
2637
|
+
blocked: true,
|
|
2638
|
+
message: "Operation blocked by --strict mode: database admin tasks are prohibited. To allow this, start sandal-db with --allow-full-wipe."
|
|
2639
|
+
};
|
|
2640
|
+
}
|
|
2326
2641
|
}
|
|
2327
2642
|
return { blocked: false };
|
|
2328
2643
|
}
|
|
@@ -2442,7 +2757,7 @@ function createDatabaseAgent(config) {
|
|
|
2442
2757
|
requiresQuery: false
|
|
2443
2758
|
};
|
|
2444
2759
|
}
|
|
2445
|
-
const isObviousLiveQuery = /^(?:SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|GRANT|REVOKE)\b/i.test(userInput) || /^(?:find|search|fetch|get|select|show|display|list|count|delete|update|insert|remove)\s+(?:all\s+|the\s+|every\s+|top\s+\d+\s+)?(?:users?|orders?|products?|customers?|items?|rows?|records?|documents?|accounts?|entries)\b/i.test(userInput) || /\bhow\s+many\s+(?:users|orders|products|items|rows|records|documents|customers|accounts)\b/i.test(userInput);
|
|
2760
|
+
const isObviousLiveQuery = /^(?:SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|GRANT|REVOKE|VACUUM|REINDEX|CHECKPOINT|DISCARD|LOCK)\b/i.test(userInput) || /\b(?:add|insert|populate|seed|generate|mock|create|make|fill|build|setup|load|import)\s+(?:fake\s+|mock\s+|sample\s+|test\s+|some\s+|new\s+|\d+\s+)?(?:users?|orders?|products?|customers?|items?|rows?|records?|documents?|accounts?|entries|data|tables?|collections?|spendings?|purchases?|transactions?|sales?)\b/i.test(userInput) || /^(?:find|search|fetch|get|select|show|display|list|count|delete|update|insert|remove|add|seed|populate)\s+(?:all\s+|the\s+|every\s+|top\s+\d+\s+)?(?:users?|orders?|products?|customers?|items?|rows?|records?|documents?|accounts?|entries|spendings?|purchases?|transactions?)\b/i.test(userInput) || /\bhow\s+many\s+(?:users|orders|products|items|rows|records|documents|customers|accounts)\b/i.test(userInput) || /\b(?:grant|revoke|vacuum|reindex|checkpoint|kill\s+query|terminate\s+backend|drop\s+database|wipe\s+database|create\s+(?:user|role)|drop\s+(?:user|role)|alter\s+(?:user|role|system|database))\b/i.test(userInput);
|
|
2446
2761
|
if (isObviousLiveQuery) {
|
|
2447
2762
|
return {
|
|
2448
2763
|
...baseReset,
|
|
@@ -2459,7 +2774,8 @@ function createDatabaseAgent(config) {
|
|
|
2459
2774
|
requiresQuery: false
|
|
2460
2775
|
};
|
|
2461
2776
|
}
|
|
2462
|
-
const
|
|
2777
|
+
const isMutationOrAction = /\b(?:add|insert|create|populate|seed|generate|mock|make|fill|build|setup|drop|delete|update|alter|truncate|remove)\b/i.test(userInput);
|
|
2778
|
+
const isSchemaOrMetaQuestion = !isMutationOrAction && (/\b(?:what|which)\s+(?:tables|collections|columns|indexes)\s+(?:exist|are\s+there|do\s+we\s+have|are\s+available)\b/i.test(userInput) || /\b(?:show|list|describe|explain|print|display|view)\s+(?:the\s+)?(?:schema|tables?|collections?|columns?|indexes?|foreign\s+keys?|database\s+structure)\b/i.test(userInput) || /\b(?:explain|describe|show|list)\s+what\s+(?:tables|collections|columns|indexes)\b/i.test(userInput) || /\b(?:what\s+is\s+the\s+(?:schema|database\s+structure))\b/i.test(userInput) || /\b(?:what\s+was\s+the\s+(?:last|previous)\s+query|why\s+did\s+(?:the\s+last\s+query|it)\s+fail)\b/i.test(userInput));
|
|
2463
2779
|
if (isSchemaOrMetaQuestion && (state.schemaSummary || state.messages.length > 0)) {
|
|
2464
2780
|
return {
|
|
2465
2781
|
...baseReset,
|
|
@@ -2481,8 +2797,8 @@ Recent Context:
|
|
|
2481
2797
|
Rules:
|
|
2482
2798
|
1. If the user explicitly asks NOT to run a query (e.g., "don't run query", "without querying", "do not execute queries"), requiresQuery MUST be false.
|
|
2483
2799
|
2. If the user asks to format, summarize, filter, inspect, or calculate based on the previous results or conversation context, requiresQuery MUST be false.
|
|
2484
|
-
3. If the user asks about the schema structure, columns, or general knowledge answerable from context, requiresQuery MUST be false.
|
|
2485
|
-
4. If the user asks to retrieve fresh records from a table/collection, count rows in the database, modify, insert, delete,
|
|
2800
|
+
3. If the user asks purely about the existing schema structure, list of tables/columns, or general knowledge answerable from context, requiresQuery MUST be false.
|
|
2801
|
+
4. If the user asks to retrieve fresh records from a table/collection, count rows in the database, modify, insert, delete, create database objects, or generate/populate/seed fake, mock, or sample data (including creating tables for it), requiresQuery MUST be true.
|
|
2486
2802
|
|
|
2487
2803
|
Respond with JSON only:
|
|
2488
2804
|
{"requiresQuery": boolean, "reason": "<short explanation>"}`;
|
|
@@ -2517,10 +2833,11 @@ Respond with JSON only:
|
|
|
2517
2833
|
const historyMessages = state.messages.slice(-4);
|
|
2518
2834
|
const prompt = `Based on this user request: "${state.userInput}"
|
|
2519
2835
|
And the available database schema:
|
|
2520
|
-
${state.schemaSummary}
|
|
2836
|
+
${state.schemaSummary || "No schema loaded."}
|
|
2521
2837
|
|
|
2522
2838
|
List the relevant table names (PostgreSQL) or collection names (MongoDB) needed to fulfill the request.
|
|
2523
|
-
|
|
2839
|
+
Include existing tables as well as any new tables or collections that should be created or populated for this request.
|
|
2840
|
+
Respond with a JSON array of string names only, e.g. ["users", "spendings", "products"]. Do not include markdown codeblocks or extra text.`;
|
|
2524
2841
|
try {
|
|
2525
2842
|
const response = await model.invoke([
|
|
2526
2843
|
...historyMessages,
|
|
@@ -2535,7 +2852,9 @@ Respond with a JSON array of string names only, e.g. ["users", "orders"]. Do not
|
|
|
2535
2852
|
}
|
|
2536
2853
|
}
|
|
2537
2854
|
async function generateQueryNode(state) {
|
|
2538
|
-
const systemPrompt = getSystemPrompt(adapter.type, state.schemaSummary
|
|
2855
|
+
const systemPrompt = getSystemPrompt(adapter.type, state.schemaSummary, {
|
|
2856
|
+
allowFullWipe: classifierOptions?.allowFullWipe
|
|
2857
|
+
});
|
|
2539
2858
|
const historyMessages = state.messages.slice(-6);
|
|
2540
2859
|
const userPrompt = `User request: "${state.userInput}"
|
|
2541
2860
|
Target entities: ${JSON.stringify(state.targetEntities)}
|
|
@@ -2566,7 +2885,7 @@ Do not wrap with markdown or code fences.`;
|
|
|
2566
2885
|
rawDisplay: sql
|
|
2567
2886
|
};
|
|
2568
2887
|
} else {
|
|
2569
|
-
if (parsed && parsed.collection && parsed.operation) {
|
|
2888
|
+
if (parsed && (parsed.collection && parsed.operation || parsed.operations)) {
|
|
2570
2889
|
query = {
|
|
2571
2890
|
collection: parsed.collection,
|
|
2572
2891
|
operation: parsed.operation,
|
|
@@ -2576,6 +2895,7 @@ Do not wrap with markdown or code fences.`;
|
|
|
2576
2895
|
document: parsed.document,
|
|
2577
2896
|
documents: parsed.documents,
|
|
2578
2897
|
options: parsed.options,
|
|
2898
|
+
operations: parsed.operations,
|
|
2579
2899
|
rawDisplay: JSON.stringify(parsed, null, 2)
|
|
2580
2900
|
};
|
|
2581
2901
|
} else {
|
|
@@ -2638,10 +2958,23 @@ Do not wrap with markdown or code fences.`;
|
|
|
2638
2958
|
return {};
|
|
2639
2959
|
}
|
|
2640
2960
|
const result = await adapter.executeQuery(state.generatedQuery);
|
|
2641
|
-
const isStructuralSuccess = Boolean(
|
|
2961
|
+
const isStructuralSuccess = Boolean(
|
|
2962
|
+
(state.safety?.isStructural || state.safety?.isAdmin || state.safety?.isFullWipe) && result.success
|
|
2963
|
+
);
|
|
2964
|
+
let updatedSchema = state.schema;
|
|
2965
|
+
let updatedSummary = state.schemaSummary;
|
|
2966
|
+
if (isStructuralSuccess) {
|
|
2967
|
+
try {
|
|
2968
|
+
updatedSchema = await adapter.inspectSchema(true);
|
|
2969
|
+
updatedSummary = updatedSchema ? formatSchemaForPrompt(updatedSchema) : state.schemaSummary;
|
|
2970
|
+
} catch {
|
|
2971
|
+
}
|
|
2972
|
+
}
|
|
2642
2973
|
return {
|
|
2643
2974
|
queryResult: result,
|
|
2644
|
-
|
|
2975
|
+
schema: updatedSchema,
|
|
2976
|
+
schemaSummary: updatedSummary,
|
|
2977
|
+
requiresSchemaRefresh: false
|
|
2645
2978
|
};
|
|
2646
2979
|
}
|
|
2647
2980
|
async function analyzeResultsNode(state) {
|
|
@@ -2787,8 +3120,7 @@ Guidelines:
|
|
|
2787
3120
|
1. Provide a direct, helpful, and accurate natural-language response to the user's request.
|
|
2788
3121
|
2. If the user asked to do something with previous results (e.g. summarize, format, count, calculate, extract, sort), perform that operation using the PREVIOUS QUERY EXECUTION DETAILS above.
|
|
2789
3122
|
3. If the user asked to use previous results but NO previous results exist in the session, politely explain that no previous query results are available in this session.
|
|
2790
|
-
4.
|
|
2791
|
-
5. Ground your answer strictly in the available schema, history, or previous query results. Never hallucinate data.`;
|
|
3123
|
+
4. Ground your answer strictly in the available schema, history, or previous query results. Never hallucinate data.`;
|
|
2792
3124
|
try {
|
|
2793
3125
|
const response = await model.invoke([
|
|
2794
3126
|
...historyMessages,
|
|
@@ -3902,7 +4234,8 @@ Schema for collection: ${collection.name}`));
|
|
|
3902
4234
|
`${chalk4.bold("Database:")} ${chalk4.green(this.adapter.type.toUpperCase())} (${this.adapter.getMaskedUrl()})`,
|
|
3903
4235
|
`${chalk4.bold("LLM Provider:")} ${chalk4.blue(this.provider)} [${chalk4.white(this.modelName)}]`,
|
|
3904
4236
|
`${chalk4.bold("Chat Session:")} ${chalk4.yellow(this.sessionId)}`,
|
|
3905
|
-
`${chalk4.bold("Strict Mode:")} ${this.strictMode ? chalk4.green("ON (
|
|
4237
|
+
`${chalk4.bold("Strict Mode:")} ${this.strictMode ? this.allowFullWipe ? chalk4.yellow("ON (Wipes & admin allowed)") : chalk4.green("ON (Wipes & admin blocked)") : chalk4.yellow("OFF")}`,
|
|
4238
|
+
`${chalk4.bold("Admin Tasks:")} ${this.allowFullWipe ? chalk4.green("ALLOWED (--allow-full-wipe)") : chalk4.gray("BLOCKED (start with --allow-full-wipe)")}`,
|
|
3906
4239
|
`${chalk4.bold("Markdown:")} ${this.renderMarkdown ? chalk4.green("ENABLED") : chalk4.gray("DISABLED")}`,
|
|
3907
4240
|
"",
|
|
3908
4241
|
chalk4.gray("Type your natural language request or SQL/Mongo query."),
|
|
@@ -3992,7 +4325,7 @@ program.name("sandal-db").description("SANDAL - Safe Agentic Natural-language Da
|
|
|
3992
4325
|
true
|
|
3993
4326
|
).option("--no-strict", "Disable strict safety mode").option(
|
|
3994
4327
|
"--allow-full-wipe",
|
|
3995
|
-
"Explicitly allow full database / all-table wipe queries with confirmation",
|
|
4328
|
+
"Explicitly allow full database / all-table wipe queries and db admin tasks with confirmation",
|
|
3996
4329
|
false
|
|
3997
4330
|
).option(
|
|
3998
4331
|
"--threshold <number>",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sandal-db",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.8",
|
|
4
4
|
"description": "SANDAL - Safe Agentic Natural-language Database Access Layer. Production-grade agentic database assistant CLI using LangGraph and LLMs (Google Gemini, OpenAI, Anthropic)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|