dsh-sql 0.1.0

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.en.md ADDED
@@ -0,0 +1,88 @@
1
+ [中文](README.md)
2
+
3
+ # dsh-sql
4
+
5
+ > **Your agent can query databases now**: SQLite / MySQL / PostgreSQL engines, read-only whitelist + write approval gate.
6
+
7
+ DSH (DeepSeek Harness) engineer-grade database plugin: four tools covering connection management, read-only queries, write operations, and schema introspection.
8
+
9
+ ![npm version](https://img.shields.io/npm/v/dsh-sql?label=npm&color=blue) ![npm downloads](https://img.shields.io/npm/dm/dsh-sql) ![license](https://img.shields.io/npm/l/dsh-sql) ![stars](https://img.shields.io/github/stars/STARDUSTLC666/dsh-sql?style=social)
10
+
11
+ [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ dsh plugin --profile web add dsh-sql
17
+ ```
18
+
19
+ ## Configuration
20
+
21
+ ```yaml
22
+ - id: sql
23
+ name: 'dsh-sql'
24
+ config:
25
+ connections:
26
+ - name: local
27
+ engine: sqlite
28
+ file: E:\data\app.db # or :memory:
29
+ - name: prod
30
+ engine: postgres
31
+ host: db.internal
32
+ database: app
33
+ # password: xxx # prefer env var DSH_SQL_PASSWORD_PROD
34
+ - name: legacy
35
+ engine: mysql
36
+ host: 127.0.0.1
37
+ port: 3306
38
+ user: root
39
+ database: legacy
40
+ maxRows: 1000 # query row cap (1-10000)
41
+ readOnly: false # true disables sql_exec
42
+ writeApproval: true # approve write operations first (default true)
43
+ ```
44
+
45
+ ## Tools
46
+
47
+ | Tool | Purpose | Safety |
48
+ | :-- | :-- | :-- |
49
+ | `sql_list` | List connections + connectivity test | — |
50
+ | `sql_query` | Read-only queries (SELECT/PRAGMA/EXPLAIN/SHOW/DESCRIBE/WITH) | Keyword whitelist + rejects multi-statement |
51
+ | `sql_exec` | Writes / DDL (multi-statement scripts allowed) | readOnly lock + approval gate |
52
+ | `sql_schema` | Table list / table structure | Identifier whitelist validation |
53
+
54
+ ### Examples
55
+
56
+ ```text
57
+ sql_list {}
58
+ sql_schema {} # list all tables
59
+ sql_schema { table: users } # inspect the users table
60
+ sql_query { sql: SELECT * FROM orders WHERE status = 'pending' LIMIT 50 }
61
+ sql_exec { sql: UPDATE orders SET status = 'paid' WHERE id = 42 }
62
+ ```
63
+
64
+ ## Safety
65
+
66
+ - **Read-only whitelist**: sql_query only allows SELECT/PRAGMA/EXPLAIN/SHOW/DESCRIBE/WITH; multi-statement queries are rejected outright
67
+ - **Write approval gate**: sql_exec asks for approval by default (mirroring dsh-email's send approval); headless environments without an approval channel are denied
68
+ - **readOnly mode**: lock out writes entirely for production databases
69
+ - **Row clamping**: maxRows capped at 10000, overflow flagged with truncated
70
+ - **Identifier validation**: table names restricted to alphanumerics and underscores — no schema injection
71
+ - **Secrets stay out of config**: passwords via `DSH_SQL_PASSWORD_<CONNECTION>` env vars
72
+
73
+ ## Engines
74
+
75
+ - **SQLite**: built-in `node:sqlite` (Node 22.13+), zero dependencies
76
+ - **MySQL**: mysql2 pool
77
+ - **PostgreSQL**: pg pool
78
+
79
+ ## Development
80
+
81
+ ```bash
82
+ pnpm install
83
+ pnpm test # build + 27 tests, including a real SQLite integration suite
84
+ ```
85
+
86
+ ## License
87
+
88
+ MIT
package/README.md ADDED
@@ -0,0 +1,88 @@
1
+ [English](README.en.md)
2
+
3
+ # dsh-sql
4
+
5
+ > **你的 agent 会查库了**:SQLite / MySQL / PostgreSQL 三引擎,只读白名单 + 写审批门。
6
+
7
+ DSH(DeepSeek Harness)工程师级数据库插件:四个工具覆盖连接管理、只读查询、写操作与结构探查。
8
+
9
+ ![npm version](https://img.shields.io/npm/v/dsh-sql?label=npm&color=blue) ![npm downloads](https://img.shields.io/npm/dm/dsh-sql) ![license](https://img.shields.io/npm/l/dsh-sql) ![stars](https://img.shields.io/github/stars/STARDUSTLC666/dsh-sql?style=social)
10
+
11
+ [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
12
+
13
+ ## 安装
14
+
15
+ ```bash
16
+ dsh plugin --profile web add dsh-sql
17
+ ```
18
+
19
+ ## 配置
20
+
21
+ ```yaml
22
+ - id: sql
23
+ name: 'dsh-sql'
24
+ config:
25
+ connections:
26
+ - name: local
27
+ engine: sqlite
28
+ file: E:\data\app.db # 或 :memory:
29
+ - name: prod
30
+ engine: postgres
31
+ host: db.internal
32
+ database: app
33
+ # password: xxx # 推荐环境变量 DSH_SQL_PASSWORD_PROD
34
+ - name: legacy
35
+ engine: mysql
36
+ host: 127.0.0.1
37
+ port: 3306
38
+ user: root
39
+ database: legacy
40
+ maxRows: 1000 # 查询返回行数上限(1-10000)
41
+ readOnly: false # true 时禁用 sql_exec
42
+ writeApproval: true # 写操作先弹审批(默认 true)
43
+ ```
44
+
45
+ ## 工具一览
46
+
47
+ | 工具 | 作用 | 安全 |
48
+ | :-- | :-- | :-- |
49
+ | `sql_list` | 列出连接 + 连通性测试 | — |
50
+ | `sql_query` | 只读查询(SELECT/PRAGMA/EXPLAIN/SHOW/DESCRIBE/WITH)| 关键字白名单 + 拒绝多语句 |
51
+ | `sql_exec` | 写操作/DDL(可多语句脚本)| readOnly 禁用 + 审批门 |
52
+ | `sql_schema` | 表清单 / 表结构 | 标识符白名单校验 |
53
+
54
+ ### 示例
55
+
56
+ ```text
57
+ sql_list {}
58
+ sql_schema {} # 列出所有表
59
+ sql_schema { table: users } # 看 users 表结构
60
+ sql_query { sql: SELECT * FROM orders WHERE status = 'pending' LIMIT 50 }
61
+ sql_exec { sql: UPDATE orders SET status = 'paid' WHERE id = 42 }
62
+ ```
63
+
64
+ ## 安全设计
65
+
66
+ - **只读白名单**:sql_query 只放行 SELECT/PRAGMA/EXPLAIN/SHOW/DESCRIBE/WITH,多语句直接拒绝
67
+ - **写审批门**:sql_exec 默认弹审批(对齐 dsh-email 的发信审批),headless 环境无审批通道时拒绝执行
68
+ - **readOnly 模式**:生产库可整体禁用写
69
+ - **行数钳制**:maxRows 上限 10000,超量截断并标记 truncated
70
+ - **标识符校验**:表名只允许字母/数字/下划线,杜绝 schema 注入
71
+ - **密钥不落配置**:密码支持 `DSH_SQL_PASSWORD_<连接名>` 环境变量
72
+
73
+ ## 引擎
74
+
75
+ - **SQLite**:Node 22.13+ 内置 `node:sqlite`,零依赖
76
+ - **MySQL**:mysql2 连接池
77
+ - **PostgreSQL**:pg 连接池
78
+
79
+ ## 开发
80
+
81
+ ```bash
82
+ pnpm install
83
+ pnpm test # 构建 + 27 个测试(含真实 SQLite 集成)
84
+ ```
85
+
86
+ ## License
87
+
88
+ MIT
@@ -0,0 +1,22 @@
1
+ # dsh-sql 组合包补丁:把数据库工具插件行插入 profile。
2
+ # 本补丁按 id 覆盖;用户应在自己 profile 的 cordis.patch.yml 里覆盖本行的整个
3
+ # config(见 README 的配置示例)。配置缺失时插件仍会加载,默认注册一个
4
+ # 内存 SQLite 连接(sql_list 可见)。
5
+ #
6
+ # 示例(用户 profile 的 cordis.patch.yml):
7
+ # - id: sql
8
+ # name: 'dsh-sql'
9
+ # config:
10
+ # connections:
11
+ # - name: local
12
+ # engine: sqlite
13
+ # file: E:\data\app.db
14
+ # maxRows: 1000 # sql_query 返回行数上限(1-10000,默认 1000)
15
+ # readOnly: false # true 时 sql_exec 被禁用
16
+ # writeApproval: true # 写操作先弹审批(默认 true)
17
+ #
18
+ - insert:
19
+ - id: sql
20
+ name: 'dsh-sql'
21
+ config:
22
+ maxRows: 1000
@@ -0,0 +1,25 @@
1
+ import { type SqlConnectionConfig } from './config.js';
2
+ /** 查询结果:列名 + 行(值数组,损失 JSON 友好)。 */
3
+ export interface QueryResult {
4
+ columns: string[];
5
+ rows: unknown[][];
6
+ }
7
+ /** 表列信息。 */
8
+ export interface ColumnInfo {
9
+ name: string;
10
+ type: string;
11
+ notNull: boolean;
12
+ primaryKey: boolean;
13
+ }
14
+ /** 统一适配器接口。 */
15
+ export interface DatabaseAdapter {
16
+ engine: 'sqlite' | 'mysql' | 'postgres';
17
+ listTables(): Promise<string[]>;
18
+ describeTable(table: string): Promise<ColumnInfo[]>;
19
+ query(sql: string): Promise<QueryResult>;
20
+ exec(sql: string): Promise<number>;
21
+ ping(): Promise<void>;
22
+ close(): Promise<void>;
23
+ }
24
+ /** 按连接配置创建适配器。 */
25
+ export declare function createAdapter(connection: SqlConnectionConfig): DatabaseAdapter;
@@ -0,0 +1,167 @@
1
+ /**
2
+ * 数据库适配器层:sqlite(node:sqlite 内置)/ mysql(mysql2)/ postgres(pg)三实现。
3
+ * 统一接口:listTables / describeTable / query / exec / ping / close。
4
+ *
5
+ * @module dsh-sql/adapters
6
+ */
7
+ import { DatabaseSync } from 'node:sqlite';
8
+ import mysql from 'mysql2/promise';
9
+ import pg from 'pg';
10
+ import { assertIdentifier } from './config.js';
11
+ function toValue(value) {
12
+ if (typeof value === 'bigint')
13
+ return Number(value);
14
+ if (value instanceof Date)
15
+ return value.toISOString();
16
+ if (value instanceof Uint8Array)
17
+ return Array.from(value);
18
+ if (value instanceof Map)
19
+ return Object.fromEntries(value);
20
+ return value;
21
+ }
22
+ function rowsToColumns(rows) {
23
+ const columns = rows.length > 0 ? Object.keys(rows[0]) : [];
24
+ const values = rows.map((row) => columns.map((column) => toValue(row[column])));
25
+ return { columns, rows: values };
26
+ }
27
+ function quoteSqliteIdentifier(name) {
28
+ return '"' + name.replace(/"/g, '""') + '"';
29
+ }
30
+ /** SQLite 适配器(node:sqlite,零依赖)。 */
31
+ class SqliteAdapter {
32
+ engine = 'sqlite';
33
+ db;
34
+ constructor(file) {
35
+ this.db = new DatabaseSync(file === ':memory:' ? ':memory:' : file);
36
+ this.db.exec('PRAGMA busy_timeout = 5000');
37
+ }
38
+ async listTables() {
39
+ const result = this.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all();
40
+ return result.map((row) => String(row.name));
41
+ }
42
+ async describeTable(table) {
43
+ const name = assertIdentifier(table, '表名');
44
+ const rows = this.db.prepare('PRAGMA table_info(' + quoteSqliteIdentifier(name) + ')').all();
45
+ return rows.map((row) => ({
46
+ name: String(row.name),
47
+ type: String(row.type ?? ''),
48
+ notNull: Number(row.notnull) === 1,
49
+ primaryKey: Number(row.pk) === 1,
50
+ }));
51
+ }
52
+ async query(sql) {
53
+ const rows = this.db.prepare(sql).all();
54
+ return rowsToColumns(rows);
55
+ }
56
+ async exec(sql) {
57
+ const single = sql.replace(/;\s*$/, '').trim();
58
+ if (single.includes(';')) {
59
+ this.db.exec(sql);
60
+ return 0;
61
+ }
62
+ const result = this.db.prepare(single).run();
63
+ return Number(result.changes);
64
+ }
65
+ async ping() {
66
+ this.db.prepare('SELECT 1').get();
67
+ }
68
+ async close() {
69
+ this.db.close();
70
+ }
71
+ }
72
+ /** MySQL 适配器(mysql2 连接池)。 */
73
+ class MysqlAdapter {
74
+ engine = 'mysql';
75
+ pool;
76
+ constructor(connection) {
77
+ this.pool = mysql.createPool({
78
+ host: connection.host ?? 'localhost',
79
+ port: connection.port ?? 3306,
80
+ user: connection.user ?? '',
81
+ password: connection.password ?? '',
82
+ database: connection.database ?? '',
83
+ connectionLimit: 5,
84
+ enableKeepAlive: true,
85
+ });
86
+ }
87
+ async listTables() {
88
+ const [rows] = await this.pool.query('SHOW TABLES');
89
+ return rows.map((row) => String(Object.values(row)[0] ?? ''));
90
+ }
91
+ async describeTable(table) {
92
+ const name = assertIdentifier(table, '表名');
93
+ const [rows] = await this.pool.query('DESCRIBE `' + name + '`');
94
+ return rows.map((row) => ({
95
+ name: String(row.Field),
96
+ type: String(row.Type ?? ''),
97
+ notNull: String(row.Null ?? '').toUpperCase() === 'NO',
98
+ primaryKey: String(row.Key ?? '').toUpperCase() === 'PRI',
99
+ }));
100
+ }
101
+ async query(sql) {
102
+ const [rows] = await this.pool.query(sql);
103
+ return rowsToColumns(rows);
104
+ }
105
+ async exec(sql) {
106
+ const [result] = await this.pool.query(sql);
107
+ return Number(result?.affectedRows ?? 0);
108
+ }
109
+ async ping() {
110
+ await this.pool.query('SELECT 1');
111
+ }
112
+ async close() {
113
+ await this.pool.end();
114
+ }
115
+ }
116
+ /** PostgreSQL 适配器(pg 连接池)。 */
117
+ class PostgresAdapter {
118
+ engine = 'postgres';
119
+ pool;
120
+ constructor(connection) {
121
+ this.pool = new pg.Pool({
122
+ host: connection.host ?? 'localhost',
123
+ port: connection.port ?? 5432,
124
+ user: connection.user ?? '',
125
+ password: connection.password ?? '',
126
+ database: connection.database ?? '',
127
+ max: 5,
128
+ });
129
+ }
130
+ async listTables() {
131
+ const result = await this.pool.query("SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' ORDER BY table_name");
132
+ return result.rows.map((row) => String(row.table_name));
133
+ }
134
+ async describeTable(table) {
135
+ const name = assertIdentifier(table, '表名');
136
+ const result = await this.pool.query('SELECT column_name, data_type, is_nullable FROM information_schema.columns WHERE table_name = $1 ORDER BY ordinal_position', [name]);
137
+ return result.rows.map((row) => ({
138
+ name: String(row.column_name),
139
+ type: String(row.data_type ?? ''),
140
+ notNull: String(row.is_nullable) === 'NO',
141
+ primaryKey: false,
142
+ }));
143
+ }
144
+ async query(sql) {
145
+ const result = await this.pool.query(sql);
146
+ const rows = result.rows;
147
+ return rowsToColumns(rows);
148
+ }
149
+ async exec(sql) {
150
+ const result = await this.pool.query(sql);
151
+ return Number(result.rowCount ?? 0);
152
+ }
153
+ async ping() {
154
+ await this.pool.query('SELECT 1');
155
+ }
156
+ async close() {
157
+ await this.pool.end();
158
+ }
159
+ }
160
+ /** 按连接配置创建适配器。 */
161
+ export function createAdapter(connection) {
162
+ if (connection.engine === 'sqlite')
163
+ return new SqliteAdapter(connection.file ?? ':memory:');
164
+ if (connection.engine === 'mysql')
165
+ return new MysqlAdapter(connection);
166
+ return new PostgresAdapter(connection);
167
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * dsh-sql 配置解析:多连接定义、行数上限、只读模式与写审批策略。
3
+ *
4
+ * @module dsh-sql/config
5
+ */
6
+ /** 单个数据库连接(行配置)。 */
7
+ export interface SqlConnectionConfig {
8
+ name: string;
9
+ engine: 'sqlite' | 'mysql' | 'postgres';
10
+ file?: string;
11
+ host?: string;
12
+ port?: number;
13
+ user?: string;
14
+ password?: string;
15
+ database?: string;
16
+ }
17
+ /** 插件行配置。 */
18
+ export interface SqlConfig {
19
+ connections?: SqlConnectionConfig[];
20
+ maxRows?: number;
21
+ readOnly?: boolean;
22
+ writeApproval?: boolean;
23
+ }
24
+ /** 解析后的配置。 */
25
+ export interface ResolvedSqlConfig {
26
+ connections: SqlConnectionConfig[];
27
+ maxRows: number;
28
+ readOnly: boolean;
29
+ writeApproval: boolean;
30
+ }
31
+ /** 连接密码环境变量名:DSH_SQL_PASSWORD_<NAME 大写>。 */
32
+ export declare function passwordEnvName(name: string): string;
33
+ /**
34
+ * 解析并校验配置;无连接时给一个内存 SQLite 兜底连接。
35
+ */
36
+ export declare function resolveConfig(config: SqlConfig | undefined | null, env?: NodeJS.ProcessEnv): ResolvedSqlConfig;
37
+ /** 校验表名/标识符,防注入到 schema 语句。 */
38
+ export declare function assertIdentifier(name: string, label: string): string;
package/lib/config.js ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * dsh-sql 配置解析:多连接定义、行数上限、只读模式与写审批策略。
3
+ *
4
+ * @module dsh-sql/config
5
+ */
6
+ const ENGINES = ['sqlite', 'mysql', 'postgres'];
7
+ /** 连接密码环境变量名:DSH_SQL_PASSWORD_<NAME 大写>。 */
8
+ export function passwordEnvName(name) {
9
+ return 'DSH_SQL_PASSWORD_' + name.toUpperCase().replace(/[^A-Z0-9_]/g, '_');
10
+ }
11
+ /**
12
+ * 解析并校验配置;无连接时给一个内存 SQLite 兜底连接。
13
+ */
14
+ export function resolveConfig(config, env = process.env) {
15
+ const cfg = config ?? {};
16
+ const rawConnections = Array.isArray(cfg.connections) ? cfg.connections : [];
17
+ const connections = [];
18
+ const seen = new Set();
19
+ for (const raw of rawConnections) {
20
+ if (typeof raw !== 'object' || raw === null)
21
+ continue;
22
+ const name = typeof raw.name === 'string' ? raw.name.trim() : '';
23
+ if (name === '')
24
+ throw new Error('connections 里每个连接都需要 name 字段。');
25
+ if (seen.has(name.toLowerCase()))
26
+ throw new Error('连接名重复:' + name + '。');
27
+ seen.add(name.toLowerCase());
28
+ const engine = raw.engine;
29
+ if (!ENGINES.includes(engine))
30
+ throw new Error('连接 ' + name + ' 的 engine 必须是 sqlite / mysql / postgres 之一。');
31
+ const connection = { name, engine: engine };
32
+ if (connection.engine === 'sqlite') {
33
+ connection.file = typeof raw.file === 'string' && raw.file.trim() !== '' ? raw.file.trim() : ':memory:';
34
+ }
35
+ else {
36
+ connection.host = typeof raw.host === 'string' && raw.host.trim() !== '' ? raw.host.trim() : 'localhost';
37
+ connection.port = typeof raw.port === 'number' && Number.isInteger(raw.port) && raw.port > 0 ? raw.port : (connection.engine === 'postgres' ? 5432 : 3306);
38
+ connection.user = typeof raw.user === 'string' && raw.user.trim() !== '' ? raw.user.trim() : '';
39
+ connection.database = typeof raw.database === 'string' && raw.database.trim() !== '' ? raw.database.trim() : '';
40
+ const direct = typeof raw.password === 'string' ? raw.password.trim() : '';
41
+ connection.password = direct !== '' ? direct : (env[passwordEnvName(name)]?.trim() ?? '');
42
+ if (connection.database === '')
43
+ throw new Error('连接 ' + name + ' 缺少 database 字段。');
44
+ }
45
+ connections.push(connection);
46
+ }
47
+ if (connections.length === 0) {
48
+ connections.push({ name: 'default', engine: 'sqlite', file: ':memory:' });
49
+ }
50
+ let maxRows = 1000;
51
+ if (cfg.maxRows !== undefined) {
52
+ if (typeof cfg.maxRows !== 'number' || !Number.isInteger(cfg.maxRows) || cfg.maxRows <= 0)
53
+ throw new Error('maxRows 必须是大于 0 的整数。');
54
+ maxRows = Math.min(10000, cfg.maxRows);
55
+ }
56
+ const readOnly = cfg.readOnly === true;
57
+ const writeApproval = cfg.writeApproval !== false;
58
+ return { connections, maxRows, readOnly, writeApproval };
59
+ }
60
+ /** 校验表名/标识符,防注入到 schema 语句。 */
61
+ export function assertIdentifier(name, label) {
62
+ const trimmed = name.trim();
63
+ if (!/^[A-Za-z0-9_$]+$/.test(trimmed)) {
64
+ throw new Error(label + ' 非法(只允许字母/数字/下划线/美元符):' + name);
65
+ }
66
+ return trimmed;
67
+ }
package/lib/index.d.ts ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * dsh-sql —— 工程师级数据库工具插件(node 半身,配置走 cordis.patch.yml)。
3
+ *
4
+ * 插件导出 apply(ctx, config):注册四个面向模型的工具(sql_list / sql_query /
5
+ * sql_exec / sql_schema),支持 SQLite / MySQL / PostgreSQL 三引擎与多连接。
6
+ * sql_exec 默认走宿主审批门(对齐 dsh-email 的发信审批),readOnly 模式可整体禁用写。
7
+ *
8
+ * @module dsh-sql
9
+ */
10
+ import { type SqlConfig } from './config.js';
11
+ import { type SqlToolDefinition } from './tools.js';
12
+ /** cordis 服务注入:apply 里要用 ctx.tools,必须显式声明。 */
13
+ export declare const inject: string[];
14
+ /** 审批服务最小面(对齐 dsh-email)。 */
15
+ export interface SqlApproval {
16
+ request(options: {
17
+ agent?: unknown;
18
+ toolName?: unknown;
19
+ callId?: unknown;
20
+ reason: string;
21
+ signal?: unknown;
22
+ }): Promise<'allowed-once' | 'cancelled' | 'unavailable' | string>;
23
+ }
24
+ /** 插件所需的最小 ctx 面。 */
25
+ export interface SqlPluginContext {
26
+ tools: {
27
+ register(definition: SqlToolDefinition, options?: {
28
+ prepend?: boolean;
29
+ }): () => void;
30
+ };
31
+ get?(name: 'approval'): SqlApproval | undefined;
32
+ on?(event: string, listener: () => void): () => void;
33
+ }
34
+ /**
35
+ * 插件入口:解析配置、构建四工具、给 sql_exec 注入审批门。
36
+ */
37
+ export declare function apply(ctx: SqlPluginContext, config?: SqlConfig | null): void;
38
+ export * from './adapters.js';
39
+ export * from './config.js';
40
+ export * from './tools.js';
package/lib/index.js ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * dsh-sql —— 工程师级数据库工具插件(node 半身,配置走 cordis.patch.yml)。
3
+ *
4
+ * 插件导出 apply(ctx, config):注册四个面向模型的工具(sql_list / sql_query /
5
+ * sql_exec / sql_schema),支持 SQLite / MySQL / PostgreSQL 三引擎与多连接。
6
+ * sql_exec 默认走宿主审批门(对齐 dsh-email 的发信审批),readOnly 模式可整体禁用写。
7
+ *
8
+ * @module dsh-sql
9
+ */
10
+ import { resolveConfig } from './config.js';
11
+ import { buildSqlTools } from './tools.js';
12
+ /** cordis 服务注入:apply 里要用 ctx.tools,必须显式声明。 */
13
+ export const inject = ['tools'];
14
+ /**
15
+ * 插件入口:解析配置、构建四工具、给 sql_exec 注入审批门。
16
+ */
17
+ export function apply(ctx, config) {
18
+ let cfg;
19
+ try {
20
+ cfg = resolveConfig(config);
21
+ }
22
+ catch (error) {
23
+ console.warn('[dsh-sql] ' + (error instanceof Error ? error.message : String(error)));
24
+ cfg = resolveConfig(null);
25
+ }
26
+ const { tools, adapters } = buildSqlTools(cfg);
27
+ const disposers = [];
28
+ for (const definition of tools) {
29
+ if (definition.name === 'sql_exec' && cfg.writeApproval) {
30
+ const originalGate = definition.gate;
31
+ definition.gate = async (exec, next) => {
32
+ const approval = ctx.get?.('approval');
33
+ if (approval === undefined) {
34
+ return {
35
+ kind: 'deny',
36
+ reason: 'sql_exec 需要确认,但当前环境没有审批通道(如 headless)。如确定安全,可在配置中设置 writeApproval: false 后直接执行。',
37
+ };
38
+ }
39
+ const execRecord = (typeof exec === 'object' && exec !== null ? exec : {});
40
+ const args = (typeof execRecord.args === 'object' && execRecord.args !== null ? execRecord.args : {});
41
+ const sql = typeof args.sql === 'string' ? args.sql : '';
42
+ const outcome = await approval.request({
43
+ agent: execRecord.agent,
44
+ toolName: execRecord.name,
45
+ callId: execRecord.callId,
46
+ reason: '执行数据库写操作:' + sql.slice(0, 200) + (sql.length > 200 ? '…' : ''),
47
+ signal: execRecord.signal,
48
+ });
49
+ if (outcome === 'allowed-once')
50
+ return originalGate ? originalGate(exec, next) : next();
51
+ if (outcome === 'cancelled')
52
+ return { kind: 'deny', reason: '写操作确认被取消,SQL 未执行。' };
53
+ if (outcome === 'unavailable')
54
+ return { kind: 'deny', reason: '写操作确认不可用(没有可用的审批界面),SQL 未执行。' };
55
+ return {
56
+ kind: 'deny',
57
+ reason: '写操作未获批准:要么你拒绝了,要么当前会话处于 Full Access(审批策略 never,不会弹框)。若在 Full Access:切到 Read Only / Write 再执行,或关闭 writeApproval(自行承担风险)。',
58
+ };
59
+ };
60
+ }
61
+ disposers.push(ctx.tools.register(definition, { prepend: true }));
62
+ }
63
+ if (typeof ctx.on === 'function') {
64
+ ctx.on('dispose', () => {
65
+ for (const dispose of disposers)
66
+ dispose();
67
+ for (const adapter of adapters.values())
68
+ void adapter.close();
69
+ });
70
+ }
71
+ }
72
+ export * from './adapters.js';
73
+ export * from './config.js';
74
+ export * from './tools.js';
package/lib/tools.d.ts ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * 四个面向模型的数据库工具:sql_list / sql_query / sql_exec / sql_schema。
3
+ *
4
+ * @module dsh-sql/tools
5
+ */
6
+ import { type DatabaseAdapter } from './adapters.js';
7
+ import { type ResolvedSqlConfig } from './config.js';
8
+ /** 模型可见的内容块。 */
9
+ export interface ContentBlock {
10
+ type: 'text';
11
+ text: string;
12
+ }
13
+ /** 注册给 ctx.tools.register 的原始工具定义。 */
14
+ export interface SqlToolDefinition {
15
+ name: string;
16
+ description: string;
17
+ parameters: {
18
+ type: 'object';
19
+ properties: Record<string, unknown>;
20
+ required?: string[];
21
+ };
22
+ output: {
23
+ schema: Record<string, unknown>;
24
+ render(args: unknown, value: unknown): ContentBlock[];
25
+ };
26
+ execute(args: unknown, exec: unknown): Promise<unknown>;
27
+ gate?(exec: unknown, next: () => Promise<unknown>): Promise<unknown>;
28
+ timeoutMs?: number;
29
+ }
30
+ /** 审批执行上下文的最小面。 */
31
+ export interface SqlExecGateContext {
32
+ agent?: unknown;
33
+ name?: unknown;
34
+ callId?: unknown;
35
+ signal?: unknown;
36
+ }
37
+ /** 构建四个工具定义;adapters 惰性创建并按连接名缓存。 */
38
+ export declare function buildSqlTools(config: ResolvedSqlConfig): {
39
+ tools: SqlToolDefinition[];
40
+ adapters: Map<string, DatabaseAdapter>;
41
+ };
package/lib/tools.js ADDED
@@ -0,0 +1,265 @@
1
+ /**
2
+ * 四个面向模型的数据库工具:sql_list / sql_query / sql_exec / sql_schema。
3
+ *
4
+ * @module dsh-sql/tools
5
+ */
6
+ import { createAdapter } from './adapters.js';
7
+ function compileParameters(spec) {
8
+ const properties = {};
9
+ const required = [];
10
+ for (const [key, prop] of Object.entries(spec)) {
11
+ if (prop?.required === true)
12
+ required.push(key);
13
+ const node = {};
14
+ if (typeof prop?.type === 'string')
15
+ node.type = prop.type;
16
+ if (typeof prop?.description === 'string')
17
+ node.description = prop.description;
18
+ properties[key] = node;
19
+ }
20
+ return { type: 'object', properties, ...(required.length > 0 ? { required } : {}) };
21
+ }
22
+ function asRecord(value) {
23
+ return typeof value === 'object' && value !== null ? value : {};
24
+ }
25
+ function optionalString(args, key) {
26
+ const value = args[key];
27
+ return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
28
+ }
29
+ function requiredString(args, key, label) {
30
+ const value = optionalString(args, key);
31
+ if (value === undefined)
32
+ throw new Error(label + '(参数 ' + key + ')为必填,请提供非空字符串。');
33
+ return value;
34
+ }
35
+ /** 只读语句关键字白名单。 */
36
+ const READ_KEYWORDS = /^(select|pragma|explain|show|describe|desc|with)\b/i;
37
+ /** 校验只读查询:白名单开头 + 拒绝多语句。 */
38
+ function assertReadQuery(sql) {
39
+ const trimmed = sql.trim();
40
+ if (!READ_KEYWORDS.test(trimmed)) {
41
+ throw new Error('sql_query 只接受只读语句(SELECT / PRAGMA / EXPLAIN / SHOW / DESCRIBE / WITH)。写操作请用 sql_exec。');
42
+ }
43
+ const statements = trimmed.split(';').filter((part) => part.trim() !== '');
44
+ if (statements.length > 1)
45
+ throw new Error('sql_query 一次只允许一条语句。');
46
+ return statements[0].trim();
47
+ }
48
+ const querySchema = {
49
+ type: 'object',
50
+ properties: {
51
+ connection: { type: 'string' },
52
+ columns: { type: 'array', items: { type: 'string' } },
53
+ rows: { type: 'array', items: { type: 'array', items: {} } },
54
+ rowCount: { type: 'integer' },
55
+ truncated: { type: 'boolean' },
56
+ maxRows: { type: 'integer' },
57
+ },
58
+ additionalProperties: true,
59
+ };
60
+ const execSchema = {
61
+ type: 'object',
62
+ properties: {
63
+ connection: { type: 'string' },
64
+ changes: { type: 'integer' },
65
+ readOnly: { type: 'boolean' },
66
+ },
67
+ additionalProperties: true,
68
+ };
69
+ const listSchema = {
70
+ type: 'object',
71
+ properties: {
72
+ connections: {
73
+ type: 'array',
74
+ items: {
75
+ type: 'object',
76
+ properties: { name: { type: 'string' }, engine: { type: 'string' }, host: { type: 'string' }, database: { type: 'string' }, file: { type: 'string' }, ok: { type: 'boolean' }, error: { type: 'string' } },
77
+ additionalProperties: true,
78
+ },
79
+ },
80
+ },
81
+ additionalProperties: true,
82
+ };
83
+ const schemaToolSchema = {
84
+ type: 'object',
85
+ properties: {
86
+ connection: { type: 'string' },
87
+ tables: { type: 'array', items: { type: 'string' } },
88
+ columns: {
89
+ type: 'array',
90
+ items: {
91
+ type: 'object',
92
+ properties: { name: { type: 'string' }, type: { type: 'string' }, notNull: { type: 'boolean' }, primaryKey: { type: 'boolean' } },
93
+ additionalProperties: true,
94
+ },
95
+ },
96
+ },
97
+ additionalProperties: true,
98
+ };
99
+ /** 构建四个工具定义;adapters 惰性创建并按连接名缓存。 */
100
+ export function buildSqlTools(config) {
101
+ const cfg = config;
102
+ const adapters = new Map();
103
+ const getAdapter = (name) => {
104
+ const target = name ?? cfg.connections[0].name;
105
+ const connection = cfg.connections.find((item) => item.name.toLowerCase() === target.toLowerCase());
106
+ if (connection === undefined) {
107
+ throw new Error('未找到名为 ' + target + ' 的数据库连接。可用 sql_list 查看连接清单。');
108
+ }
109
+ let adapter = adapters.get(connection.name);
110
+ if (adapter === undefined) {
111
+ adapter = createAdapter(connection);
112
+ adapters.set(connection.name, adapter);
113
+ }
114
+ return { adapter, name: connection.name };
115
+ };
116
+ const sqlList = {
117
+ name: 'sql_list',
118
+ description: '列出配置的数据库连接并逐一做连通性测试(SELECT 1)。返回连接名、引擎、目标与健康状态。',
119
+ parameters: compileParameters({}),
120
+ output: {
121
+ schema: listSchema,
122
+ render: (_args, value) => {
123
+ const rec = asRecord(value);
124
+ const connections = Array.isArray(rec.connections) ? rec.connections : [];
125
+ const lines = ['共 ' + connections.length + ' 个数据库连接:'];
126
+ for (const item of connections) {
127
+ const c = asRecord(item);
128
+ const target = c.file !== undefined && c.file !== '' ? c.file : c.host + '/' + c.database;
129
+ lines.push('- ' + c.name + '(' + c.engine + ' @ ' + target + ')' + (c.ok === true ? ' ✅' : ' ❌ ' + String(c.error ?? '')));
130
+ }
131
+ return [{ type: 'text', text: lines.join('\n') }];
132
+ },
133
+ },
134
+ async execute() {
135
+ const rows = [];
136
+ for (const connection of cfg.connections) {
137
+ const entry = { name: connection.name, engine: connection.engine };
138
+ if (connection.engine === 'sqlite')
139
+ entry.file = connection.file ?? ':memory:';
140
+ else {
141
+ entry.host = connection.host ?? '';
142
+ entry.database = connection.database ?? '';
143
+ }
144
+ try {
145
+ const { adapter } = getAdapter(connection.name);
146
+ await adapter.ping();
147
+ entry.ok = true;
148
+ entry.error = '';
149
+ }
150
+ catch (error) {
151
+ entry.ok = false;
152
+ entry.error = error instanceof Error ? error.message : String(error);
153
+ }
154
+ rows.push(entry);
155
+ }
156
+ return { connections: rows };
157
+ },
158
+ timeoutMs: 30000,
159
+ };
160
+ const sqlQuery = {
161
+ name: 'sql_query',
162
+ description: '执行只读 SQL 查询(SELECT / PRAGMA / EXPLAIN / SHOW / DESCRIBE / WITH)。connection 为连接名(缺省第一个连接)。返回列名与行数据,最多 maxRows 行(超出 truncated=true)。写操作请用 sql_exec。',
163
+ parameters: compileParameters({
164
+ sql: { type: 'string', required: true, description: '只读 SQL 语句(必填,单条)。' },
165
+ connection: { type: 'string', description: '连接名(可选,缺省第一个连接)。' },
166
+ }),
167
+ output: {
168
+ schema: querySchema,
169
+ render: (_args, value) => {
170
+ const rec = asRecord(value);
171
+ const rows = Array.isArray(rec.rows) ? rec.rows : [];
172
+ const lines = ['查询返回 ' + rec.rowCount + ' 行' + (rec.truncated === true ? '(截断到 ' + rec.maxRows + ' 行)' : '') + ',列:' + (Array.isArray(rec.columns) ? rec.columns.join(', ') : '')];
173
+ for (const row of rows.slice(0, 20)) {
174
+ lines.push('- ' + (Array.isArray(row) ? row.map((cell) => String(cell)).join(' | ') : String(row)));
175
+ }
176
+ if (rows.length > 20)
177
+ lines.push('…仅展示前 20 行');
178
+ return [{ type: 'text', text: lines.join('\n') }];
179
+ },
180
+ },
181
+ async execute(rawArgs) {
182
+ const args = asRecord(rawArgs);
183
+ const sql = assertReadQuery(requiredString(args, 'sql', 'SQL 语句'));
184
+ const { adapter, name } = getAdapter(optionalString(args, 'connection'));
185
+ const result = await adapter.query(sql);
186
+ const total = result.rows.length;
187
+ const rows = result.rows.slice(0, cfg.maxRows);
188
+ return {
189
+ connection: name,
190
+ columns: result.columns,
191
+ rows,
192
+ rowCount: total,
193
+ truncated: total > cfg.maxRows,
194
+ maxRows: cfg.maxRows,
195
+ };
196
+ },
197
+ timeoutMs: 60000,
198
+ };
199
+ const sqlExec = {
200
+ name: 'sql_exec',
201
+ description: '执行写操作或 DDL(INSERT / UPDATE / DELETE / CREATE / ALTER / DROP 等,可多语句脚本)。受 readOnly 模式与写审批门双重保护。返回影响行数(多语句时为 0)。',
202
+ parameters: compileParameters({
203
+ sql: { type: 'string', required: true, description: '写操作/DDL SQL(必填)。' },
204
+ connection: { type: 'string', description: '连接名(可选,缺省第一个连接)。' },
205
+ }),
206
+ output: {
207
+ schema: execSchema,
208
+ render: (_args, value) => {
209
+ const rec = asRecord(value);
210
+ return [{ type: 'text', text: '执行完成(' + rec.connection + '):影响 ' + rec.changes + ' 行。' }];
211
+ },
212
+ },
213
+ async execute(rawArgs) {
214
+ if (cfg.readOnly)
215
+ throw new Error('当前配置 readOnly=true,sql_exec 已被禁用。需要写操作请把插件配置里的 readOnly 改为 false 后重启。');
216
+ const args = asRecord(rawArgs);
217
+ const sql = requiredString(args, 'sql', 'SQL 语句');
218
+ const { adapter, name } = getAdapter(optionalString(args, 'connection'));
219
+ const changes = await adapter.exec(sql);
220
+ return { connection: name, changes, readOnly: false };
221
+ },
222
+ gate(exec, next) {
223
+ return next(); // 审批门由 index.ts 注入(需要宿主 ctx)
224
+ },
225
+ timeoutMs: 120000,
226
+ };
227
+ const sqlSchema = {
228
+ name: 'sql_schema',
229
+ description: '查看数据库结构:不给 table 列出全部表;给 table(表名)返回该表的列信息(名称/类型/非空/主键)。',
230
+ parameters: compileParameters({
231
+ table: { type: 'string', description: '表名(可选;缺省列出全部表)。' },
232
+ connection: { type: 'string', description: '连接名(可选,缺省第一个连接)。' },
233
+ }),
234
+ output: {
235
+ schema: schemaToolSchema,
236
+ render: (_args, value) => {
237
+ const rec = asRecord(value);
238
+ const columns = Array.isArray(rec.columns) ? rec.columns : [];
239
+ if (columns.length > 0) {
240
+ const lines = ['表 ' + rec.table + ' 的列:'];
241
+ for (const item of columns) {
242
+ const c = asRecord(item);
243
+ lines.push('- ' + c.name + ' ' + c.type + (c.primaryKey === true ? '(主键)' : '') + (c.notNull === true ? '(非空)' : ''));
244
+ }
245
+ return [{ type: 'text', text: lines.join('\n') }];
246
+ }
247
+ const tables = Array.isArray(rec.tables) ? rec.tables : [];
248
+ return [{ type: 'text', text: '共 ' + tables.length + ' 张表:' + tables.join(', ') }];
249
+ },
250
+ },
251
+ async execute(rawArgs) {
252
+ const args = asRecord(rawArgs);
253
+ const { adapter, name } = getAdapter(optionalString(args, 'connection'));
254
+ const table = optionalString(args, 'table');
255
+ if (table !== undefined) {
256
+ const columns = await adapter.describeTable(table);
257
+ return { connection: name, table, columns, tables: [] };
258
+ }
259
+ const tables = await adapter.listTables();
260
+ return { connection: name, tables, columns: [] };
261
+ },
262
+ timeoutMs: 30000,
263
+ };
264
+ return { tools: [sqlList, sqlQuery, sqlExec, sqlSchema], adapters };
265
+ }
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "dsh-sql",
3
+ "version": "0.1.0",
4
+ "description": "DSH 工程师级数据库插件:sql_list/query/exec/schema 四工具,SQLite/MySQL/PostgreSQL 三引擎、多连接、只读模式与写操作审批门、行数钳制。",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "types": "lib/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/index.d.ts",
11
+ "default": "./lib/index.js"
12
+ },
13
+ "./cordis.patch.yml": "./cordis.patch.yml",
14
+ "./package.json": "./package.json"
15
+ },
16
+ "files": [
17
+ "lib",
18
+ "cordis.patch.yml",
19
+ "README.md",
20
+ "README.en.md"
21
+ ],
22
+ "scripts": {
23
+ "build": "tsc -p tsconfig.json",
24
+ "prepare": "tsc -p tsconfig.json",
25
+ "typecheck": "tsc -p tsconfig.json --noEmit",
26
+ "test": "pnpm run build && node --test \"test/*.test.mjs\"",
27
+ "prepublishOnly": "pnpm run build"
28
+ },
29
+ "dsh": {
30
+ "bundle": {
31
+ "patch": "./cordis.patch.yml"
32
+ }
33
+ },
34
+ "keywords": [
35
+ "dsh",
36
+ "deepseek-harness",
37
+ "plugin",
38
+ "sql",
39
+ "sqlite",
40
+ "mysql",
41
+ "postgresql",
42
+ "database"
43
+ ],
44
+ "license": "MIT",
45
+ "engines": {
46
+ "node": ">=22.13"
47
+ },
48
+ "dependencies": {
49
+ "mysql2": "^3.11.0",
50
+ "pg": "^8.13.0"
51
+ },
52
+ "devDependencies": {
53
+ "@types/node": "^24.0.0",
54
+ "@types/pg": "^8.21.0",
55
+ "typescript": "^5.6.0"
56
+ },
57
+ "repository": {
58
+ "type": "git",
59
+ "url": "git+https://github.com/STARDUSTLC666/dsh-sql.git"
60
+ },
61
+ "bugs": {
62
+ "url": "https://github.com/STARDUSTLC666/dsh-sql/issues"
63
+ },
64
+ "homepage": "https://github.com/STARDUSTLC666/dsh-sql#readme",
65
+ "author": "stardustlc",
66
+ "packageManager": "pnpm@11.7.0"
67
+ }