dsh-sql 0.5.3 → 0.5.5
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/CHANGELOG.md +11 -0
- package/README.en.md +1 -1
- package/README.md +1 -1
- package/docs/USAGE.en.md +4 -0
- package/docs/USAGE.md +6 -1
- package/docs/VALIDATION.md +12 -0
- package/docs/validation-0.5.5.md +7 -0
- package/lib/adapters.d.ts +2 -3
- package/lib/adapters.js +67 -27
- package/lib/approval.d.ts +13 -0
- package/lib/approval.js +18 -0
- package/lib/index.d.ts +2 -1
- package/lib/index.js +11 -1
- package/lib/tools.d.ts +1 -1
- package/lib/tools.js +11 -5
- package/lib/update-client.js +1 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
[返回简介](README.md) · [使用说明](docs/USAGE.md) · [验证记录](docs/VALIDATION.md)
|
|
4
4
|
|
|
5
|
+
## 0.5.5 (2026-10-28)
|
|
6
|
+
|
|
7
|
+
- 写入确认与健康检查正确读取会话审批策略。never 或未知策略明确说明操作未执行及恢复方式;保留宿主拒绝、只读限制与完整 SQL 审批摘要。
|
|
8
|
+
|
|
9
|
+
## 0.5.4 (2026-10-28)
|
|
10
|
+
|
|
11
|
+
- 修复 PostgreSQL 查询能修改会话默认设置,再通过写函数绕过只读保护的问题。每次查询强制使用独立只读事务并回滚;允许审批写入时查询工具仍保持只读。
|
|
12
|
+
- PostgreSQL 查询使用扩展协议,服务器拒绝同一请求中的多条命令。MySQL 同步等待只读事务建立,设置失败时拒绝查询;不再忽略连接初始化错误。
|
|
13
|
+
- 查询成功或失败后回滚;取消或达到行数上限时销毁连接;回滚失败的连接不放回池中。保留行数限制、同名列和 64 位整数处理。
|
|
14
|
+
- 增加真实 PostgreSQL 可选回归,覆盖设置修改、写函数、多语句、截流、取消与合法审批写入;默认测试不启动或下载数据库。
|
|
15
|
+
|
|
5
16
|
## 0.5.3 (2026-10-27)
|
|
6
17
|
|
|
7
18
|
- 修复分批更新时的新旧更新控件混用:保留原页面、草稿与安装保护,空闲后接管旧控制器,兼容旧入口。
|
package/README.en.md
CHANGED
|
@@ -13,7 +13,7 @@ Feedback and contributions are welcome: report [issues](https://github.com/STARD
|
|
|
13
13
|
## What it does
|
|
14
14
|
|
|
15
15
|
- Manage connections and inspect schemas or database summaries.
|
|
16
|
-
- Limit rows and timeouts for read-only queries.
|
|
16
|
+
- Limit rows and timeouts for read-only queries; MySQL / PostgreSQL enforce a separate read-only transaction for each query.
|
|
17
17
|
- Keep writes under host approval and check connections.
|
|
18
18
|
|
|
19
19
|
## Install
|
package/README.md
CHANGED
package/docs/USAGE.en.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# dsh-sql usage guide
|
|
2
2
|
|
|
3
|
+
## Confirmation in Full Access
|
|
4
|
+
|
|
5
|
+
The plugin keeps its confirmation requirement. With approval policy `never`, it reports that confirmation is unavailable and performs no write. Switch to a mode that can ask, or explicitly set `writeApproval: false` yourself. Host denials still apply.
|
|
6
|
+
|
|
3
7
|
[Overview](../README.en.md) · [Changelog](../CHANGELOG.md) · [Validation](VALIDATION.md)
|
|
4
8
|
|
|
5
9
|
## Current improvements
|
package/docs/USAGE.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# dsh-sql 使用说明
|
|
2
2
|
|
|
3
|
+
## 完全权限下的确认
|
|
4
|
+
|
|
5
|
+
插件保留单独确认要求。会话审批策略为 `never` 时不能弹框,会明确说明操作未执行;请切到可询问模式,或由用户明确设置 `writeApproval: false`。其它宿主拒绝仍生效,不自动放宽权限。
|
|
6
|
+
|
|
3
7
|
[返回简介](../README.md) · [更新记录](../CHANGELOG.md) · [验证记录](VALIDATION.md)
|
|
4
8
|
|
|
5
9
|
## 本次改进
|
|
@@ -77,7 +81,8 @@ sql_exec { sql: UPDATE orders SET status = 'paid' WHERE id = 42 }
|
|
|
77
81
|
|
|
78
82
|
- **词法级只读保护**:sql_query 先剥离字符串/注释再校验,拒绝 data-modifying CTE(WITH…DELETE/UPDATE)、SELECT INTO、FOR UPDATE/FOR SHARE、多语句,以及不在只读白名单里的 PRAGMA。去噪按引擎处理反斜杠(MySQL 默认转义、PostgreSQL 只有 `E'…'`/`U&'…'` 转义、SQLite 与引号标识符里都不转义),避免「校验看到一条、服务器执行两条」
|
|
79
83
|
- **写审批门**:sql_exec 默认弹审批(对齐 dsh-email 的发信审批),headless 环境无审批通道时拒绝执行
|
|
80
|
-
-
|
|
84
|
+
- **查询事务**:MySQL / PostgreSQL 的 `sql_query` 每次在独立只读事务中运行,完成或报错后回滚,达到行数上限或取消时销毁专用连接。PostgreSQL 查询采用扩展协议,服务器拒绝同一请求内的多条命令;`set_config` 对默认设置的修改随回滚撤销。MySQL 必须成功执行 `START TRANSACTION READ ONLY`,不支持或设置失败时拒绝运行查询。即使允许审批写入,这些查询约束仍然生效。
|
|
85
|
+
- **readOnly 模式**:生产库可整体禁用 `sql_exec`。SQLite 文件以只读方式打开;PostgreSQL 还保留只读启动默认值。建议使用仅授予所需读取权限的数据库账号;事务只读不等同于数据库账号、文件系统或外部函数的权限隔离。
|
|
81
86
|
- **审批卡不藏语句**:sql_exec 的审批文本逐条列出每条语句,并给出一行语句类型统计;超过 800 字符时把完整 SQL 指回工具参数,第二条 DROP / DELETE 不会因为截断而从审批卡里消失
|
|
82
87
|
- **流式行数钳制**:SQLite 迭代器 / MySQL Readable / PostgreSQL Query 行事件最多收集 maxRows+1 行,超量标记 truncated;MySQL 和 PostgreSQL 在达到上限时关闭该查询的专用连接,未达上限则正常归还连接池,避免全量结果驻留内存
|
|
83
88
|
- **可取消执行**:查询与写操作遵守 Harness 的 `exec.signal`;取消时会中止等待并销毁正在工作的 MySQL/PostgreSQL 专用连接
|
package/docs/VALIDATION.md
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
# dsh-sql 验证记录
|
|
2
2
|
|
|
3
|
+
## 2026-10-28:0.5.5
|
|
4
|
+
|
|
5
|
+
[本次修复与验收边界](validation-0.5.5.md)。
|
|
6
|
+
|
|
3
7
|
本页整理原 README 的历史验证说明,保留当时的版本、日期与范围。自动测试、启动检查、浏览器操作和真实服务验收分别记录,不能相互替代。更详细的版本验收文件仍保留在仓库中。
|
|
4
8
|
|
|
9
|
+
## 0.5.4(2026-10-28)
|
|
10
|
+
|
|
11
|
+
- Windows / Node 24.16.0:71 项检查通过,包含真实 PostgreSQL 17.10 的可选集成回归。测试数据库、表、函数与账号均为隔离夹具。
|
|
12
|
+
- 已在 0.5.3 复现:查询修改 `default_transaction_read_only` 后,后续 SELECT 写函数能落盘。0.5.4 在同一真实数据库拒绝该写入,查询始终处于只读事务。
|
|
13
|
+
- 真实 PostgreSQL 覆盖 readOnly 开关两种状态、有界和无界查询、修改当前事务设置、多命令拒绝、写函数拒绝、错误后恢复、行数截流、取消以及合法 exec 写入。MySQL 只验证真实 mysql2 流事件与连接生命周期夹具,未连接真实 MySQL 服务。
|
|
14
|
+
- 官方 DSH 0.2.1-rc.3 原生网页版:工具入口拒绝原 Issue #3 的多语句和 PRAGMA 写入;浏览器审批卡显示 200 字符后面的 DROP,点击拒绝后真实 SQLite 数据保持不变。浏览器使用后台 Chrome,未验收 Electron 原生窗口。
|
|
15
|
+
- 运行可选 PostgreSQL 回归:在专用测试环境中把连接对象 JSON 写入环境变量 `DSH_SQL_TEST_POSTGRES`,再执行 `pnpm test`;未设置时明确跳过该集成项,不启动或下载数据库。
|
|
16
|
+
|
|
5
17
|
## 原中文记录
|
|
6
18
|
|
|
7
19
|
验证宿主:官方源码构建的 Harness `0.2.0-rc.1`(commit `407e65c8`)+ Node `24.16.0`(2026-09-28)。53 项插件测试在隔离环境全部通过;同一个宿主里 18 个插件共同加载,注册 6 个工具,工具 schema 与健康检查契约通过。本轮未启用真实端口与外部服务。
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# 0.5.5 验证记录(2026-10-28)
|
|
2
|
+
|
|
3
|
+
Windows 74 项回归:73 通过、1 项真实 PostgreSQL 因本轮未配置服务而跳过。数据库适配器和只读事务代码本轮未改动;不以此冒充本轮真实 MySQL/PostgreSQL 验收。
|
|
4
|
+
|
|
5
|
+
官方 DSH 0.2.1-rc.4 的真实 agent 回合调用 sql_exec:策略 never 返回插件的明确原因,未请求无效确认,也没有声称用户点了拒绝。原生 SQLite 查询通过;sql_health 将连接正常与写确认可用分开,never 时整体检查不误报正常。
|
|
6
|
+
|
|
7
|
+
回归覆盖可询问策略、未知服务、保留宿主拒绝、只读配置、显式关闭审批和完整 SQL 摘要。未自动放宽任何写入保护;Electron 原生窗口未验收。
|
package/lib/adapters.d.ts
CHANGED
|
@@ -30,9 +30,8 @@ export interface AdapterOptions {
|
|
|
30
30
|
/** MySQL 连接池配置(导出以便测试断言,不含连接)。 */
|
|
31
31
|
export declare function mysqlPoolConfig(connection: SqlConnectionConfig): mysql.PoolOptions;
|
|
32
32
|
/**
|
|
33
|
-
* PostgreSQL
|
|
34
|
-
*
|
|
35
|
-
* "cannot execute ... in a read-only transaction" 拒绝写操作。
|
|
33
|
+
* PostgreSQL 连接池配置。启动参数仅作为默认值;用户查询还必须使用
|
|
34
|
+
* 独立只读事务,不能依赖可被 set_config 修改的会话默认设置。
|
|
36
35
|
*/
|
|
37
36
|
export declare function postgresPoolConfig(connection: SqlConnectionConfig, readOnly?: boolean): pg.PoolConfig;
|
|
38
37
|
/** 按连接配置创建适配器;readOnly 会下沉到引擎自身的只读约束。 */
|
package/lib/adapters.js
CHANGED
|
@@ -83,7 +83,8 @@ function streamPostgresQuery(client, sql, limit, discard) {
|
|
|
83
83
|
let settled = false;
|
|
84
84
|
let columns = [];
|
|
85
85
|
const rows = [];
|
|
86
|
-
|
|
86
|
+
// The extended protocol rejects multiple commands in a single Parse message.
|
|
87
|
+
const queryConfig = { text: sql, rowMode: 'array', queryMode: 'extended' };
|
|
87
88
|
const query = new pg.Query(queryConfig);
|
|
88
89
|
query.on('row', (row, result) => {
|
|
89
90
|
if (settled)
|
|
@@ -134,9 +135,8 @@ export function mysqlPoolConfig(connection) {
|
|
|
134
135
|
};
|
|
135
136
|
}
|
|
136
137
|
/**
|
|
137
|
-
* PostgreSQL
|
|
138
|
-
*
|
|
139
|
-
* "cannot execute ... in a read-only transaction" 拒绝写操作。
|
|
138
|
+
* PostgreSQL 连接池配置。启动参数仅作为默认值;用户查询还必须使用
|
|
139
|
+
* 独立只读事务,不能依赖可被 set_config 修改的会话默认设置。
|
|
140
140
|
*/
|
|
141
141
|
export function postgresPoolConfig(connection, readOnly = false) {
|
|
142
142
|
const config = {
|
|
@@ -230,17 +230,12 @@ class SqliteAdapter {
|
|
|
230
230
|
}
|
|
231
231
|
/** MySQL 适配器(mysql2 连接池)。 */
|
|
232
232
|
class MysqlAdapter {
|
|
233
|
+
readOnly;
|
|
233
234
|
engine = 'mysql';
|
|
234
235
|
pool;
|
|
235
236
|
constructor(connection, readOnly = false) {
|
|
237
|
+
this.readOnly = readOnly;
|
|
236
238
|
this.pool = mysql.createPool(mysqlPoolConfig(connection));
|
|
237
|
-
if (readOnly) {
|
|
238
|
-
// 会话级只读:MySQL 5.6.5+ 的 SET SESSION TRANSACTION READ ONLY 会让
|
|
239
|
-
// 该连接上后续任何 DML/DDL 被服务器拒绝,不依赖词法校验。
|
|
240
|
-
this.pool.on('connection', (session) => {
|
|
241
|
-
void session.query('SET SESSION TRANSACTION READ ONLY').catch(() => { });
|
|
242
|
-
});
|
|
243
|
-
}
|
|
244
239
|
}
|
|
245
240
|
async withSignalConnection(signal, work) {
|
|
246
241
|
signal?.throwIfAborted();
|
|
@@ -261,7 +256,7 @@ class MysqlAdapter {
|
|
|
261
256
|
signal?.addEventListener('abort', onAbort, { once: true });
|
|
262
257
|
try {
|
|
263
258
|
signal?.throwIfAborted();
|
|
264
|
-
return await Promise.race([work(connection, discard), aborted]);
|
|
259
|
+
return await Promise.race([work(connection, discard, () => destroyed), aborted]);
|
|
265
260
|
}
|
|
266
261
|
finally {
|
|
267
262
|
signal?.removeEventListener('abort', onAbort);
|
|
@@ -269,6 +264,26 @@ class MysqlAdapter {
|
|
|
269
264
|
connection.release();
|
|
270
265
|
}
|
|
271
266
|
}
|
|
267
|
+
async withReadOnlyConnection(signal, work) {
|
|
268
|
+
return await this.withSignalConnection(signal, async (connection, discard, isDiscarded) => {
|
|
269
|
+
try {
|
|
270
|
+
// Await setup and fail closed; never silently ignore an unsupported mode.
|
|
271
|
+
await connection.query('START TRANSACTION READ ONLY');
|
|
272
|
+
return await work(connection, discard);
|
|
273
|
+
}
|
|
274
|
+
finally {
|
|
275
|
+
if (!isDiscarded()) {
|
|
276
|
+
try {
|
|
277
|
+
await connection.query('ROLLBACK');
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
discard();
|
|
281
|
+
throw error;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
}
|
|
272
287
|
async queryRows(sql, signal) {
|
|
273
288
|
if (signal === undefined)
|
|
274
289
|
return typeof sql === 'string' ? await this.pool.query(sql) : await this.pool.query(sql);
|
|
@@ -289,18 +304,20 @@ class MysqlAdapter {
|
|
|
289
304
|
}));
|
|
290
305
|
}
|
|
291
306
|
async query(sql, limit, signal) {
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
307
|
+
return await this.withReadOnlyConnection(signal, async (connection, discard) => {
|
|
308
|
+
if (limit === undefined || limit <= 0) {
|
|
309
|
+
const [rows, fields] = await connection.query({ sql, rowsAsArray: true });
|
|
310
|
+
if (fields?.length)
|
|
311
|
+
return { columns: fields.map(field => field.name), rows: rows.map(row => Array.isArray(row) ? row.map(toValue) : fields.map(field => toValue(row[field.name]))) };
|
|
312
|
+
return rowsToColumns(rows);
|
|
313
|
+
}
|
|
299
314
|
const coreConnection = connection.connection;
|
|
300
315
|
return await streamMysqlQuery(coreConnection, sql, limit, discard);
|
|
301
316
|
});
|
|
302
317
|
}
|
|
303
318
|
async exec(sql, signal) {
|
|
319
|
+
if (this.readOnly)
|
|
320
|
+
throw new Error('当前配置 readOnly=true,禁止写入数据库。');
|
|
304
321
|
const [result] = await this.queryRows(sql, signal);
|
|
305
322
|
return Number(result?.affectedRows ?? 0);
|
|
306
323
|
}
|
|
@@ -313,9 +330,11 @@ class MysqlAdapter {
|
|
|
313
330
|
}
|
|
314
331
|
/** PostgreSQL 适配器(pg 连接池)。 */
|
|
315
332
|
class PostgresAdapter {
|
|
333
|
+
readOnly;
|
|
316
334
|
engine = 'postgres';
|
|
317
335
|
pool;
|
|
318
336
|
constructor(connection, readOnly = false) {
|
|
337
|
+
this.readOnly = readOnly;
|
|
319
338
|
this.pool = new pg.Pool(postgresPoolConfig(connection, readOnly));
|
|
320
339
|
}
|
|
321
340
|
async withSignalClient(signal, work) {
|
|
@@ -337,7 +356,7 @@ class PostgresAdapter {
|
|
|
337
356
|
signal?.addEventListener('abort', onAbort, { once: true });
|
|
338
357
|
try {
|
|
339
358
|
signal?.throwIfAborted();
|
|
340
|
-
return await Promise.race([work(client, discard), aborted]);
|
|
359
|
+
return await Promise.race([work(client, discard, () => destroyed), aborted]);
|
|
341
360
|
}
|
|
342
361
|
finally {
|
|
343
362
|
signal?.removeEventListener('abort', onAbort);
|
|
@@ -345,6 +364,27 @@ class PostgresAdapter {
|
|
|
345
364
|
client.release();
|
|
346
365
|
}
|
|
347
366
|
}
|
|
367
|
+
async withReadOnlyClient(signal, work) {
|
|
368
|
+
return await this.withSignalClient(signal, async (client, discard, isDiscarded) => {
|
|
369
|
+
try {
|
|
370
|
+
await client.query('BEGIN READ ONLY');
|
|
371
|
+
return await work(client, discard);
|
|
372
|
+
}
|
|
373
|
+
finally {
|
|
374
|
+
// Rollback also reverts set_config(..., false). A capped/cancelled query
|
|
375
|
+
// destroys its connection instead, causing server-side rollback.
|
|
376
|
+
if (!isDiscarded()) {
|
|
377
|
+
try {
|
|
378
|
+
await client.query('ROLLBACK');
|
|
379
|
+
}
|
|
380
|
+
catch (error) {
|
|
381
|
+
discard();
|
|
382
|
+
throw error;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
}
|
|
348
388
|
async queryWithSignal(query, values, signal) {
|
|
349
389
|
const run = async (client) => {
|
|
350
390
|
return values === undefined ? await client.query(query) : await client.query(query, values);
|
|
@@ -383,18 +423,18 @@ class PostgresAdapter {
|
|
|
383
423
|
}));
|
|
384
424
|
}
|
|
385
425
|
async query(sql, limit, signal) {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
426
|
+
return await this.withReadOnlyClient(signal, async (client, discard) => {
|
|
427
|
+
if (limit === undefined || limit <= 0) {
|
|
428
|
+
const queryConfig = { text: sql, rowMode: 'array', queryMode: 'extended' };
|
|
429
|
+
const result = await client.query(queryConfig);
|
|
389
430
|
return { columns: result.fields.map((field) => field.name), rows: result.rows.map((row) => row.map(toValue)) };
|
|
390
|
-
|
|
391
|
-
return rowsToColumns(rows);
|
|
392
|
-
}
|
|
393
|
-
return await this.withSignalClient(signal, async (client, discard) => {
|
|
431
|
+
}
|
|
394
432
|
return await streamPostgresQuery(client, sql, limit, discard);
|
|
395
433
|
});
|
|
396
434
|
}
|
|
397
435
|
async exec(sql, signal) {
|
|
436
|
+
if (this.readOnly)
|
|
437
|
+
throw new Error('当前配置 readOnly=true,禁止写入数据库。');
|
|
398
438
|
const result = await this.queryWithSignal(sql, undefined, signal);
|
|
399
439
|
return Number(result.rowCount ?? 0);
|
|
400
440
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Read the effective Host policy without importing a second copy of its services. */
|
|
2
|
+
export type ApprovalPolicy = 'ask' | 'never' | 'unknown';
|
|
3
|
+
export interface ApprovalContext {
|
|
4
|
+
get?(name: string): unknown;
|
|
5
|
+
approval?: {
|
|
6
|
+
config?: {
|
|
7
|
+
policy?: unknown;
|
|
8
|
+
};
|
|
9
|
+
overrideOf?(session: unknown): unknown;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export declare function approvalPolicy(ctx: ApprovalContext, execution: unknown): ApprovalPolicy;
|
|
13
|
+
export declare function approvalProblem(policy: ApprovalPolicy, setting: string): string | undefined;
|
package/lib/approval.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function approvalPolicy(ctx, execution) {
|
|
2
|
+
try {
|
|
3
|
+
const service = (ctx.get ? ctx.get('approval') : ctx.approval);
|
|
4
|
+
const session = execution?.agent?.session;
|
|
5
|
+
const policy = (session === undefined ? undefined : service?.overrideOf?.(session)) ?? service?.config?.policy;
|
|
6
|
+
return policy === 'ask' || policy === 'never' ? policy : 'unknown';
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return 'unknown';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function approvalProblem(policy, setting) {
|
|
13
|
+
if (policy === 'ask')
|
|
14
|
+
return undefined;
|
|
15
|
+
if (policy === 'never')
|
|
16
|
+
return '插件要求确认,但当前会话审批策略 never 不允许询问,操作未执行。请切换到可询问模式,或由用户明确设置 ' + setting + '=false / Confirmation is required but this session cannot ask; no action was performed.';
|
|
17
|
+
return '无法读取宿主审批策略,操作未执行。请检查 DSH 审批服务或插件配置 / Host approval policy unavailable; no action was performed.';
|
|
18
|
+
}
|
package/lib/index.d.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { type SqlConfig } from './config.js';
|
|
12
12
|
import { type SqlToolDefinition } from './tools.js';
|
|
13
|
+
import { type ApprovalContext } from './approval.js';
|
|
13
14
|
/** cordis 服务注入:apply 里要用 ctx.tools,必须显式声明。 */
|
|
14
15
|
export declare const name = "sql";
|
|
15
16
|
export declare const inject: string[];
|
|
@@ -31,7 +32,7 @@ interface SqlToolExecution {
|
|
|
31
32
|
/** Harness `tools/pre-execute` waterfall 监听器。 */
|
|
32
33
|
type SqlPreExecuteListener = (exec: SqlToolExecution, next: () => Promise<SqlPreToolDecision>) => Promise<SqlPreToolDecision>;
|
|
33
34
|
/** 插件所需的最小 ctx 面。 */
|
|
34
|
-
export interface SqlPluginContext {
|
|
35
|
+
export interface SqlPluginContext extends ApprovalContext {
|
|
35
36
|
tools: {
|
|
36
37
|
register(definition: SqlToolDefinition): () => void;
|
|
37
38
|
};
|
package/lib/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import { registerUpdates } from '@stardustlc/dsh-updates';
|
|
|
11
11
|
*/
|
|
12
12
|
import { resolveConfig } from './config.js';
|
|
13
13
|
import { buildSqlTools, summarizeWriteScript } from './tools.js';
|
|
14
|
+
import { approvalPolicy, approvalProblem } from './approval.js';
|
|
14
15
|
/** cordis 服务注入:apply 里要用 ctx.tools,必须显式声明。 */
|
|
15
16
|
export const name = 'sql';
|
|
16
17
|
export const inject = ['tools'];
|
|
@@ -20,11 +21,20 @@ export const inject = ['tools'];
|
|
|
20
21
|
export function apply(ctx, config) {
|
|
21
22
|
registerUpdates(ctx, { name: "dsh-sql" });
|
|
22
23
|
const cfg = resolveConfig(config);
|
|
23
|
-
const
|
|
24
|
+
const policyOf = (exec) => approvalPolicy(ctx, exec);
|
|
25
|
+
const { tools, adapters } = buildSqlTools(cfg, policyOf);
|
|
24
26
|
if (cfg.writeApproval) {
|
|
25
27
|
ctx.on('tools/pre-execute', async (exec, next) => {
|
|
26
28
|
if (exec.name !== 'sql_exec')
|
|
27
29
|
return next();
|
|
30
|
+
const downstream = await next();
|
|
31
|
+
if (downstream.kind === 'deny')
|
|
32
|
+
return downstream;
|
|
33
|
+
if (cfg.readOnly)
|
|
34
|
+
return { kind: 'deny', reason: '当前连接为只读配置,sql_exec 已禁用 / Read-only configuration; sql_exec is disabled.' };
|
|
35
|
+
const problem = approvalProblem(policyOf(exec), 'writeApproval');
|
|
36
|
+
if (problem)
|
|
37
|
+
return { kind: 'deny', reason: problem };
|
|
28
38
|
const args = (typeof exec.arguments === 'object' && exec.arguments !== null ? exec.arguments : {});
|
|
29
39
|
const sql = typeof args.sql === 'string' ? args.sql : '';
|
|
30
40
|
return {
|
package/lib/tools.d.ts
CHANGED
|
@@ -52,7 +52,7 @@ export interface SqlExecGateContext {
|
|
|
52
52
|
signal?: unknown;
|
|
53
53
|
}
|
|
54
54
|
/** 构建六个工具定义;adapters 惰性创建并按连接名缓存。 */
|
|
55
|
-
export declare function buildSqlTools(config: ResolvedSqlConfig): {
|
|
55
|
+
export declare function buildSqlTools(config: ResolvedSqlConfig, policyOf?: (execution: unknown) => 'ask' | 'never' | 'unknown'): {
|
|
56
56
|
tools: SqlToolDefinition[];
|
|
57
57
|
adapters: Map<string, DatabaseAdapter>;
|
|
58
58
|
};
|
package/lib/tools.js
CHANGED
|
@@ -402,7 +402,7 @@ const healthSchema = {
|
|
|
402
402
|
additionalProperties: true,
|
|
403
403
|
};
|
|
404
404
|
/** 构建六个工具定义;adapters 惰性创建并按连接名缓存。 */
|
|
405
|
-
export function buildSqlTools(config) {
|
|
405
|
+
export function buildSqlTools(config, policyOf = () => 'unknown') {
|
|
406
406
|
const cfg = config;
|
|
407
407
|
const adapters = new Map();
|
|
408
408
|
/** 只解析连接配置(不建连接),用于在打开数据库前挑选词法规则。 */
|
|
@@ -473,7 +473,7 @@ export function buildSqlTools(config) {
|
|
|
473
473
|
};
|
|
474
474
|
const sqlQuery = {
|
|
475
475
|
name: 'sql_query',
|
|
476
|
-
description: '执行只读 SQL 查询(SELECT / PRAGMA / EXPLAIN / SHOW / DESCRIBE / WITH)。词法级校验按引擎处理反斜杠转义,拒绝 data-modifying CTE、SELECT INTO、FOR UPDATE/FOR SHARE、非白名单 PRAGMA
|
|
476
|
+
description: '执行只读 SQL 查询(SELECT / PRAGMA / EXPLAIN / SHOW / DESCRIBE / WITH)。词法级校验按引擎处理反斜杠转义,拒绝 data-modifying CTE、SELECT INTO、FOR UPDATE/FOR SHARE、非白名单 PRAGMA 与多语句。MySQL / PostgreSQL 每次查询使用独立只读事务并回滚,不受 readOnly 开关影响;PostgreSQL 还以扩展协议拒绝多命令。connection 为连接名(缺省第一个连接)。返回列名与行数据,最多 maxRows 行(超出 truncated=true)。写操作请用 sql_exec。',
|
|
477
477
|
parameters: compileParameters({
|
|
478
478
|
sql: { type: 'string', required: true, description: '只读 SQL 语句(必填,单条)。' },
|
|
479
479
|
connection: { type: 'string', description: '连接名(可选,缺省第一个连接)。' },
|
|
@@ -669,13 +669,13 @@ export function buildSqlTools(config) {
|
|
|
669
669
|
const rec = asRecord(value);
|
|
670
670
|
const connections = Array.isArray(rec.connections) ? rec.connections : [];
|
|
671
671
|
const bad = connections.filter((c) => asRecord(c).ok !== true);
|
|
672
|
-
const lines = ['dsh-sql 自检' + (
|
|
672
|
+
const lines = ['dsh-sql 自检' + (rec.ok === true ? ':正常。' : ':发现问题。')];
|
|
673
673
|
for (const item of connections) {
|
|
674
674
|
const c = asRecord(item);
|
|
675
675
|
lines.push('- ' + c.name + '(' + c.engine + ')' + (c.ok === true ? ' ✅' : ' ❌ ' + String(c.error ?? '')));
|
|
676
676
|
}
|
|
677
677
|
lines.push('- 只读模式:' + (rec.readOnly === true ? '开(sql_exec 已禁用)' : '关'));
|
|
678
|
-
lines.push('- 写审批门:' + (rec.writeApproval === true ? '开' : '关'));
|
|
678
|
+
lines.push('- 写审批门:' + String(rec.writeApprovalDetail ?? (rec.writeApproval === true ? '开' : '关')));
|
|
679
679
|
lines.push('- 行数上限:' + String(rec.maxRows) + ';查询超时 ' + String(rec.queryTimeoutMs) + 'ms;写超时 ' + String(rec.execTimeoutMs) + 'ms');
|
|
680
680
|
return [{ type: 'text', text: lines.join('\n') }];
|
|
681
681
|
},
|
|
@@ -683,12 +683,18 @@ export function buildSqlTools(config) {
|
|
|
683
683
|
async execute(_rawArgs, exec) {
|
|
684
684
|
const connections = await pingAllConnections(executionSignal(exec));
|
|
685
685
|
const bad = connections.filter((c) => c.ok !== true);
|
|
686
|
+
const policy = policyOf(exec);
|
|
687
|
+
const writeApprovalReady = cfg.readOnly || !cfg.writeApproval || policy === 'ask';
|
|
688
|
+
const writeApprovalDetail = cfg.readOnly ? '只读配置,不允许写入' : !cfg.writeApproval ? '关闭;其它宿主策略仍生效' : policy === 'ask' ? '开启;经宿主询问' : policy === 'never' ? '当前策略 never 无法询问;请切换模式或由用户明确设置 writeApproval=false' : '无法读取宿主审批策略';
|
|
686
689
|
return {
|
|
687
|
-
ok: bad.length === 0,
|
|
690
|
+
ok: bad.length === 0 && writeApprovalReady,
|
|
688
691
|
plugin: 'dsh-sql',
|
|
689
692
|
connections,
|
|
690
693
|
readOnly: cfg.readOnly,
|
|
691
694
|
writeApproval: cfg.writeApproval,
|
|
695
|
+
approvalPolicy: policy,
|
|
696
|
+
writeApprovalReady,
|
|
697
|
+
writeApprovalDetail,
|
|
692
698
|
maxRows: cfg.maxRows,
|
|
693
699
|
queryTimeoutMs: cfg.queryTimeoutMs,
|
|
694
700
|
execTimeoutMs: cfg.execTimeoutMs,
|
package/lib/update-client.js
CHANGED
|
@@ -437,7 +437,7 @@ function __stardustUpdatesClient(ctx, React, require, shared) {
|
|
|
437
437
|
}
|
|
438
438
|
|
|
439
439
|
/* STARDUSTLC_UPDATE_CLIENT_BODY_START */
|
|
440
|
-
window.__ModuleLoader__.load(__stardustUpdatesWrap({id:"dsh-sql",factory:require=>({apply(){}})},{"name":"dsh-sql","version":"0.1.0","hasWorkbench":false,"packageVersion":"0.5.
|
|
440
|
+
window.__ModuleLoader__.load(__stardustUpdatesWrap({id:"dsh-sql",factory:require=>({apply(){}})},{"name":"dsh-sql","version":"0.1.0","hasWorkbench":false,"packageVersion":"0.5.5","label":"数据库","labelEn":"SQL","suiteComponent":true}));
|
|
441
441
|
|
|
442
442
|
/* STARDUSTLC_UPDATE_CLIENT_BODY_END */
|
|
443
443
|
})();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-sql",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.5",
|
|
4
4
|
"description": "连接 SQLite、MySQL 或 PostgreSQL,查询数据并检查数据库结构。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
},
|
|
60
60
|
"dependencies": {
|
|
61
61
|
"mysql2": "^3.11.0",
|
|
62
|
-
"pg": "^8.
|
|
62
|
+
"pg": "^8.23.0",
|
|
63
63
|
"@stardustlc/dsh-updates": "0.1.3"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|