dsh-sql 0.1.1 → 0.3.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 CHANGED
@@ -38,6 +38,8 @@ dsh plugin --profile web add dsh-sql
38
38
  user: root
39
39
  database: legacy
40
40
  maxRows: 1000 # query row cap (1-10000)
41
+ queryTimeoutMs: 60000 # per-query timeout (default 60s, 5s - 10min)
42
+ execTimeoutMs: 120000 # per-write timeout (default 120s, 5s - 10min)
41
43
  readOnly: false # true disables sql_exec
42
44
  writeApproval: true # approve write operations first (default true)
43
45
  ```
@@ -63,10 +65,10 @@ sql_exec { sql: UPDATE orders SET status = 'paid' WHERE id = 42 }
63
65
 
64
66
  ## Safety
65
67
 
66
- - **Read-only whitelist**: sql_query only allows SELECT/PRAGMA/EXPLAIN/SHOW/DESCRIBE/WITH; multi-statement queries are rejected outright
68
+ - **Lexer-grade read-only guard**: sql_query strips strings/comments before validation, then rejects data-modifying CTEs (WITH…DELETE/UPDATE), SELECT INTO, FOR UPDATE/FOR SHARE, PRAGMA assignment, and multi-statement input
67
69
  - **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
70
  - **readOnly mode**: lock out writes entirely for production databases
69
- - **Row clamping**: maxRows capped at 10000, overflow flagged with truncated
71
+ - **Streaming row cap**: SQLite iterator / MySQL stream / PostgreSQL portal all stop at maxRows+1, so large queries are never fully materialized; overflow is flagged with truncated
70
72
  - **Identifier validation**: table names restricted to alphanumerics and underscores — no schema injection
71
73
  - **Secrets stay out of config**: passwords via `DSH_SQL_PASSWORD_<CONNECTION>` env vars
72
74
 
@@ -80,7 +82,7 @@ sql_exec { sql: UPDATE orders SET status = 'paid' WHERE id = 42 }
80
82
 
81
83
  ```bash
82
84
  pnpm install
83
- pnpm test # build + 27 tests, including a real SQLite integration suite
85
+ pnpm test # build + 35 tests, including a real SQLite integration suite
84
86
  ```
85
87
 
86
88
  ## License
package/README.md CHANGED
@@ -38,6 +38,8 @@ dsh plugin --profile web add dsh-sql
38
38
  user: root
39
39
  database: legacy
40
40
  maxRows: 1000 # 查询返回行数上限(1-10000)
41
+ queryTimeoutMs: 60000 # 单次查询超时(默认 60 秒,5 秒 - 10 分钟)
42
+ execTimeoutMs: 120000 # 单次写操作超时(默认 120 秒,5 秒 - 10 分钟)
41
43
  readOnly: false # true 时禁用 sql_exec
42
44
  writeApproval: true # 写操作先弹审批(默认 true)
43
45
  ```
@@ -63,10 +65,10 @@ sql_exec { sql: UPDATE orders SET status = 'paid' WHERE id = 42 }
63
65
 
64
66
  ## 安全设计
65
67
 
66
- - **只读白名单**:sql_query 只放行 SELECT/PRAGMA/EXPLAIN/SHOW/DESCRIBE/WITH,多语句直接拒绝
68
+ - **词法级只读保护**:sql_query 先剥离字符串/注释再校验,拒绝 data-modifying CTE(WITH…DELETE/UPDATE)、SELECT INTO、FOR UPDATE/FOR SHARE、PRAGMA 赋值与多语句
67
69
  - **写审批门**:sql_exec 默认弹审批(对齐 dsh-email 的发信审批),headless 环境无审批通道时拒绝执行
68
70
  - **readOnly 模式**:生产库可整体禁用写
69
- - **行数钳制**:maxRows 上限 10000,超量截断并标记 truncated
71
+ - **流式行数钳制**:SQLite 迭代器 / MySQL stream / PostgreSQL portal 都按 maxRows+1 停表,大查询不会全量载入内存,超量标记 truncated
70
72
  - **标识符校验**:表名只允许字母/数字/下划线,杜绝 schema 注入
71
73
  - **密钥不落配置**:密码支持 `DSH_SQL_PASSWORD_<连接名>` 环境变量
72
74
 
@@ -80,7 +82,7 @@ sql_exec { sql: UPDATE orders SET status = 'paid' WHERE id = 42 }
80
82
 
81
83
  ```bash
82
84
  pnpm install
83
- pnpm test # 构建 + 27 个测试(含真实 SQLite 集成)
85
+ pnpm test # 构建 + 35 个测试(含真实 SQLite 集成)
84
86
  ```
85
87
 
86
88
  ## License
package/cordis.patch.yml CHANGED
@@ -12,6 +12,8 @@
12
12
  # engine: sqlite
13
13
  # file: E:\data\app.db
14
14
  # maxRows: 1000 # sql_query 返回行数上限(1-10000,默认 1000)
15
+ queryTimeoutMs: 60000 # 单次查询超时(5 秒 - 10 分钟)
16
+ execTimeoutMs: 120000 # 单次写操作超时(5 秒 - 10 分钟)
15
17
  # readOnly: false # true 时 sql_exec 被禁用
16
18
  # writeApproval: true # 写操作先弹审批(默认 true)
17
19
  #
package/lib/adapters.d.ts CHANGED
@@ -16,7 +16,7 @@ export interface DatabaseAdapter {
16
16
  engine: 'sqlite' | 'mysql' | 'postgres';
17
17
  listTables(): Promise<string[]>;
18
18
  describeTable(table: string): Promise<ColumnInfo[]>;
19
- query(sql: string): Promise<QueryResult>;
19
+ query(sql: string, limit?: number): Promise<QueryResult>;
20
20
  exec(sql: string): Promise<number>;
21
21
  ping(): Promise<void>;
22
22
  close(): Promise<void>;
package/lib/adapters.js CHANGED
@@ -49,9 +49,21 @@ class SqliteAdapter {
49
49
  primaryKey: Number(row.pk) === 1,
50
50
  }));
51
51
  }
52
- async query(sql) {
53
- const rows = this.db.prepare(sql).all();
54
- return rowsToColumns(rows);
52
+ async query(sql, limit) {
53
+ const statement = this.db.prepare(sql);
54
+ if (limit === undefined || limit <= 0) {
55
+ const rows = statement.all();
56
+ return rowsToColumns(rows);
57
+ }
58
+ const columns = statement.columns().map((column) => column.name);
59
+ const rows = [];
60
+ for (const raw of statement.iterate()) {
61
+ const row = raw;
62
+ rows.push(columns.map((name) => toValue(row[name])));
63
+ if (rows.length >= limit)
64
+ break;
65
+ }
66
+ return { columns, rows };
55
67
  }
56
68
  async exec(sql) {
57
69
  const single = sql.replace(/;\s*$/, '').trim();
@@ -98,9 +110,42 @@ class MysqlAdapter {
98
110
  primaryKey: String(row.Key ?? '').toUpperCase() === 'PRI',
99
111
  }));
100
112
  }
101
- async query(sql) {
102
- const [rows] = await this.pool.query(sql);
103
- return rowsToColumns(rows);
113
+ async query(sql, limit) {
114
+ if (limit === undefined || limit <= 0) {
115
+ const [rows] = await this.pool.query(sql);
116
+ return rowsToColumns(rows);
117
+ }
118
+ const corePool = this.pool.pool;
119
+ return await new Promise((resolve, reject) => {
120
+ let settled = false;
121
+ let columns = [];
122
+ const rows = [];
123
+ const stream = corePool.query(sql).stream({ highWaterMark: 64 });
124
+ const finish = () => {
125
+ if (settled)
126
+ return;
127
+ settled = true;
128
+ resolve({ columns, rows });
129
+ };
130
+ stream.on('fields', (fields) => {
131
+ columns = fields.map((field) => field.name);
132
+ });
133
+ stream.on('result', (row) => {
134
+ if (columns.length === 0)
135
+ columns = Object.keys(row);
136
+ rows.push(columns.map((name) => toValue(row[name])));
137
+ if (rows.length >= limit)
138
+ stream.destroy();
139
+ });
140
+ stream.on('end', finish);
141
+ stream.on('close', finish);
142
+ stream.on('error', (error) => {
143
+ if (settled)
144
+ return;
145
+ settled = true;
146
+ reject(error instanceof Error ? error : new Error(String(error)));
147
+ });
148
+ });
104
149
  }
105
150
  async exec(sql) {
106
151
  const [result] = await this.pool.query(sql);
@@ -133,16 +178,36 @@ class PostgresAdapter {
133
178
  }
134
179
  async describeTable(table) {
135
180
  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]);
181
+ const result = await this.pool.query(`SELECT c.column_name, c.data_type, c.is_nullable,
182
+ EXISTS (
183
+ SELECT 1
184
+ FROM information_schema.table_constraints tc
185
+ JOIN information_schema.key_column_usage kcu
186
+ ON tc.constraint_name = kcu.constraint_name
187
+ AND tc.table_schema = kcu.table_schema
188
+ WHERE tc.table_schema = c.table_schema
189
+ AND tc.table_name = c.table_name
190
+ AND tc.constraint_type = 'PRIMARY KEY'
191
+ AND kcu.column_name = c.column_name
192
+ ) AS is_primary
193
+ FROM information_schema.columns c
194
+ WHERE c.table_schema = 'public'
195
+ AND c.table_name = $1
196
+ ORDER BY c.ordinal_position`, [name]);
137
197
  return result.rows.map((row) => ({
138
198
  name: String(row.column_name),
139
199
  type: String(row.data_type ?? ''),
140
200
  notNull: String(row.is_nullable) === 'NO',
141
- primaryKey: false,
201
+ primaryKey: row.is_primary === true,
142
202
  }));
143
203
  }
144
- async query(sql) {
145
- const result = await this.pool.query(sql);
204
+ async query(sql, limit) {
205
+ if (limit === undefined || limit <= 0) {
206
+ const result = await this.pool.query(sql);
207
+ const rows = result.rows;
208
+ return rowsToColumns(rows);
209
+ }
210
+ const result = await this.pool.query({ text: sql, values: [], rows: limit });
146
211
  const rows = result.rows;
147
212
  return rowsToColumns(rows);
148
213
  }
package/lib/config.d.ts CHANGED
@@ -20,6 +20,8 @@ export interface SqlConfig {
20
20
  maxRows?: number;
21
21
  readOnly?: boolean;
22
22
  writeApproval?: boolean;
23
+ queryTimeoutMs?: number;
24
+ execTimeoutMs?: number;
23
25
  }
24
26
  /** 解析后的配置。 */
25
27
  export interface ResolvedSqlConfig {
@@ -27,6 +29,8 @@ export interface ResolvedSqlConfig {
27
29
  maxRows: number;
28
30
  readOnly: boolean;
29
31
  writeApproval: boolean;
32
+ queryTimeoutMs: number;
33
+ execTimeoutMs: number;
30
34
  }
31
35
  /** 连接密码环境变量名:DSH_SQL_PASSWORD_<NAME 大写>。 */
32
36
  export declare function passwordEnvName(name: string): string;
package/lib/config.js CHANGED
@@ -53,9 +53,21 @@ export function resolveConfig(config, env = process.env) {
53
53
  throw new Error('maxRows 必须是大于 0 的整数。');
54
54
  maxRows = Math.min(10000, cfg.maxRows);
55
55
  }
56
+ let queryTimeoutMs = 60000;
57
+ if (cfg.queryTimeoutMs !== undefined) {
58
+ if (typeof cfg.queryTimeoutMs !== 'number' || !Number.isFinite(cfg.queryTimeoutMs) || cfg.queryTimeoutMs <= 0)
59
+ throw new Error('queryTimeoutMs 必须是大于 0 的数字(毫秒)。');
60
+ queryTimeoutMs = Math.min(600000, Math.max(5000, Math.round(cfg.queryTimeoutMs)));
61
+ }
62
+ let execTimeoutMs = 120000;
63
+ if (cfg.execTimeoutMs !== undefined) {
64
+ if (typeof cfg.execTimeoutMs !== 'number' || !Number.isFinite(cfg.execTimeoutMs) || cfg.execTimeoutMs <= 0)
65
+ throw new Error('execTimeoutMs 必须是大于 0 的数字(毫秒)。');
66
+ execTimeoutMs = Math.min(600000, Math.max(5000, Math.round(cfg.execTimeoutMs)));
67
+ }
56
68
  const readOnly = cfg.readOnly === true;
57
69
  const writeApproval = cfg.writeApproval !== false;
58
- return { connections, maxRows, readOnly, writeApproval };
70
+ return { connections, maxRows, readOnly, writeApproval, queryTimeoutMs, execTimeoutMs };
59
71
  }
60
72
  /** 校验表名/标识符,防注入到 schema 语句。 */
61
73
  export function assertIdentifier(name, label) {
package/lib/tools.d.ts CHANGED
@@ -27,6 +27,8 @@ export interface SqlToolDefinition {
27
27
  gate?(exec: unknown, next: () => Promise<unknown>): Promise<unknown>;
28
28
  timeoutMs?: number;
29
29
  }
30
+ /** 校验只读查询:词法去噪后白名单开头 + 写关键字扫描 + 单语句。 */
31
+ export declare function assertReadQuery(sql: string): string;
30
32
  /** 审批执行上下文的最小面。 */
31
33
  export interface SqlExecGateContext {
32
34
  agent?: unknown;
package/lib/tools.js CHANGED
@@ -34,16 +34,128 @@ function requiredString(args, key, label) {
34
34
  }
35
35
  /** 只读语句关键字白名单。 */
36
36
  const READ_KEYWORDS = /^(select|pragma|explain|show|describe|desc|with)\b/i;
37
- /** 校验只读查询:白名单开头 + 拒绝多语句。 */
38
- function assertReadQuery(sql) {
37
+ /** 去掉字符串、引号标识符与注释,保留真实 SQL 关键字与分号。 */
38
+ function stripSqlNoise(sql) {
39
+ let out = '';
40
+ let i = 0;
41
+ while (i < sql.length) {
42
+ const ch = sql[i];
43
+ const next = sql[i + 1];
44
+ if (ch === '-' && next === '-') {
45
+ i += 2;
46
+ while (i < sql.length && sql[i] !== '\n')
47
+ i += 1;
48
+ continue;
49
+ }
50
+ if (ch === '/' && next === '*') {
51
+ i += 2;
52
+ while (i + 1 < sql.length && !(sql[i] === '*' && sql[i + 1] === '/'))
53
+ i += 1;
54
+ i += 2;
55
+ continue;
56
+ }
57
+ if (ch === '#' && (i === 0 || /\s/.test(sql[i - 1]))) {
58
+ if (/^#(?:>>?|-)/.test(sql.slice(i))) {
59
+ out += ch;
60
+ i += 1;
61
+ continue;
62
+ }
63
+ i += 1;
64
+ while (i < sql.length && sql[i] !== '\n')
65
+ i += 1;
66
+ continue;
67
+ }
68
+ if (ch === "'") {
69
+ out += ' ';
70
+ i += 1;
71
+ while (i < sql.length) {
72
+ if (sql[i] === "'" && sql[i + 1] === "'") {
73
+ i += 2;
74
+ continue;
75
+ }
76
+ if (sql[i] === "'") {
77
+ i += 1;
78
+ break;
79
+ }
80
+ if (sql[i] === '\\') {
81
+ i += 2;
82
+ continue;
83
+ }
84
+ i += 1;
85
+ }
86
+ continue;
87
+ }
88
+ if (ch === '"' || ch === '`') {
89
+ out += ' ';
90
+ i += 1;
91
+ while (i < sql.length) {
92
+ if (sql[i] === ch) {
93
+ i += 1;
94
+ break;
95
+ }
96
+ if (sql[i] === '\\') {
97
+ i += 2;
98
+ continue;
99
+ }
100
+ i += 1;
101
+ }
102
+ continue;
103
+ }
104
+ if (ch === '[') {
105
+ out += ' ';
106
+ i += 1;
107
+ while (i < sql.length && sql[i] !== ']')
108
+ i += 1;
109
+ i += 1;
110
+ continue;
111
+ }
112
+ if (ch === '$') {
113
+ const dollar = /^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/.exec(sql.slice(i));
114
+ if (dollar !== null) {
115
+ out += ' ';
116
+ i += dollar[0].length;
117
+ const end = sql.indexOf(dollar[0], i);
118
+ i = end === -1 ? sql.length : end + dollar[0].length;
119
+ continue;
120
+ }
121
+ }
122
+ out += ch;
123
+ i += 1;
124
+ }
125
+ return out;
126
+ }
127
+ /** 写操作关键字:出在 SELECT/EXPLAIN/WITH 语句里即拒绝。 */
128
+ const WRITE_KEYWORDS = /\b(insert|update|delete|replace|merge|drop|alter|create|truncate|call|execute|copy|grant|revoke|attach|detach|vacuum|reindex|refresh|set|reset|begin|commit|rollback|savepoint|release|analyze|load_extension)\b/gi;
129
+ /** 校验只读查询:词法去噪后白名单开头 + 写关键字扫描 + 单语句。 */
130
+ export function assertReadQuery(sql) {
39
131
  const trimmed = sql.trim();
40
- if (!READ_KEYWORDS.test(trimmed)) {
132
+ const clean = stripSqlNoise(trimmed);
133
+ const first = /^[a-z]+/i.exec(clean.trim())?.[0]?.toLowerCase() ?? '';
134
+ if (!READ_KEYWORDS.test(clean.trim())) {
41
135
  throw new Error('sql_query 只接受只读语句(SELECT / PRAGMA / EXPLAIN / SHOW / DESCRIBE / WITH)。写操作请用 sql_exec。');
42
136
  }
43
- const statements = trimmed.split(';').filter((part) => part.trim() !== '');
137
+ const statements = clean.split(';').filter((part) => part.trim() !== '');
44
138
  if (statements.length > 1)
45
139
  throw new Error('sql_query 一次只允许一条语句。');
46
- return statements[0].trim();
140
+ const single = statements[0]?.trim() ?? '';
141
+ if (first === 'pragma') {
142
+ if (/=/.test(single))
143
+ throw new Error('sql_query 不接受带赋值参数的 PRAGMA 写操作(如 PRAGMA journal_mode=WAL),请用 sql_exec。');
144
+ }
145
+ else if (first === 'show' || first === 'describe' || first === 'desc') {
146
+ // SHOW / DESCRIBE 本身只读,不再扫写关键字(避免误伤 SHOW CREATE TABLE)。
147
+ }
148
+ else {
149
+ if (/\binto\b/i.test(single))
150
+ throw new Error('sql_query 不接受 SELECT INTO(写表或 OUTFILE),请用 sql_exec。');
151
+ if (/\bfor\s+(update|no\s+key\s+update|share|key\s+share)\b/i.test(single))
152
+ throw new Error('sql_query 不接受 FOR UPDATE/FOR SHARE 行锁查询,请用 sql_exec。');
153
+ const hit = WRITE_KEYWORDS.exec(single);
154
+ if (hit !== null) {
155
+ throw new Error('检测到写操作关键字 ' + hit[0].toUpperCase() + ',sql_query 只接受只读查询。写操作请用 sql_exec。');
156
+ }
157
+ }
158
+ return trimmed.replace(/;+\s*$/, '').trim();
47
159
  }
48
160
  const querySchema = {
49
161
  type: 'object',
@@ -159,7 +271,7 @@ export function buildSqlTools(config) {
159
271
  };
160
272
  const sqlQuery = {
161
273
  name: 'sql_query',
162
- description: '执行只读 SQL 查询(SELECT / PRAGMA / EXPLAIN / SHOW / DESCRIBE / WITH)。connection 为连接名(缺省第一个连接)。返回列名与行数据,最多 maxRows 行(超出 truncated=true)。写操作请用 sql_exec。',
274
+ description: '执行只读 SQL 查询(SELECT / PRAGMA / EXPLAIN / SHOW / DESCRIBE / WITH)。词法级校验会拒绝 data-modifying CTE、SELECT INTO、FOR UPDATE/FOR SHARE、PRAGMA 赋值与多语句。connection 为连接名(缺省第一个连接)。返回列名与行数据,最多 maxRows 行(超出 truncated=true)。写操作请用 sql_exec。',
163
275
  parameters: compileParameters({
164
276
  sql: { type: 'string', required: true, description: '只读 SQL 语句(必填,单条)。' },
165
277
  connection: { type: 'string', description: '连接名(可选,缺省第一个连接)。' },
@@ -182,7 +294,7 @@ export function buildSqlTools(config) {
182
294
  const args = asRecord(rawArgs);
183
295
  const sql = assertReadQuery(requiredString(args, 'sql', 'SQL 语句'));
184
296
  const { adapter, name } = getAdapter(optionalString(args, 'connection'));
185
- const result = await adapter.query(sql);
297
+ const result = await adapter.query(sql, cfg.maxRows + 1);
186
298
  const total = result.rows.length;
187
299
  const rows = result.rows.slice(0, cfg.maxRows);
188
300
  return {
@@ -194,7 +306,7 @@ export function buildSqlTools(config) {
194
306
  maxRows: cfg.maxRows,
195
307
  };
196
308
  },
197
- timeoutMs: 60000,
309
+ timeoutMs: cfg.queryTimeoutMs,
198
310
  };
199
311
  const sqlExec = {
200
312
  name: 'sql_exec',
@@ -222,7 +334,7 @@ export function buildSqlTools(config) {
222
334
  gate(exec, next) {
223
335
  return next(); // 审批门由 index.ts 注入(需要宿主 ctx)
224
336
  },
225
- timeoutMs: 120000,
337
+ timeoutMs: cfg.execTimeoutMs,
226
338
  };
227
339
  const sqlSchema = {
228
340
  name: 'sql_schema',
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-sql",
3
- "version": "0.1.1",
4
- "description": "DSH 工程师级数据库插件:sql_list/query/exec/schema 四工具,SQLite/MySQL/PostgreSQL 三引擎、多连接、只读模式与写操作审批门、行数钳制。",
3
+ "version": "0.3.0",
4
+ "description": "DSH 工程师级数据库插件:sql_list/query/exec/schema 四工具,SQLite/MySQL/PostgreSQL 三引擎、多连接、只读模式与写操作审批门、词法级只读保护、流式行数钳制与可配置查询/写超时。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",