dsh-sql 0.1.1 → 0.2.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
@@ -63,10 +63,10 @@ sql_exec { sql: UPDATE orders SET status = 'paid' WHERE id = 42 }
63
63
 
64
64
  ## Safety
65
65
 
66
- - **Read-only whitelist**: sql_query only allows SELECT/PRAGMA/EXPLAIN/SHOW/DESCRIBE/WITH; multi-statement queries are rejected outright
66
+ - **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
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
68
  - **readOnly mode**: lock out writes entirely for production databases
69
- - **Row clamping**: maxRows capped at 10000, overflow flagged with truncated
69
+ - **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
70
  - **Identifier validation**: table names restricted to alphanumerics and underscores — no schema injection
71
71
  - **Secrets stay out of config**: passwords via `DSH_SQL_PASSWORD_<CONNECTION>` env vars
72
72
 
@@ -80,7 +80,7 @@ sql_exec { sql: UPDATE orders SET status = 'paid' WHERE id = 42 }
80
80
 
81
81
  ```bash
82
82
  pnpm install
83
- pnpm test # build + 27 tests, including a real SQLite integration suite
83
+ pnpm test # build + 33 tests, including a real SQLite integration suite
84
84
  ```
85
85
 
86
86
  ## License
package/README.md CHANGED
@@ -63,10 +63,10 @@ sql_exec { sql: UPDATE orders SET status = 'paid' WHERE id = 42 }
63
63
 
64
64
  ## 安全设计
65
65
 
66
- - **只读白名单**:sql_query 只放行 SELECT/PRAGMA/EXPLAIN/SHOW/DESCRIBE/WITH,多语句直接拒绝
66
+ - **词法级只读保护**:sql_query 先剥离字符串/注释再校验,拒绝 data-modifying CTE(WITH…DELETE/UPDATE)、SELECT INTO、FOR UPDATE/FOR SHARE、PRAGMA 赋值与多语句
67
67
  - **写审批门**:sql_exec 默认弹审批(对齐 dsh-email 的发信审批),headless 环境无审批通道时拒绝执行
68
68
  - **readOnly 模式**:生产库可整体禁用写
69
- - **行数钳制**:maxRows 上限 10000,超量截断并标记 truncated
69
+ - **流式行数钳制**:SQLite 迭代器 / MySQL stream / PostgreSQL portal 都按 maxRows+1 停表,大查询不会全量载入内存,超量标记 truncated
70
70
  - **标识符校验**:表名只允许字母/数字/下划线,杜绝 schema 注入
71
71
  - **密钥不落配置**:密码支持 `DSH_SQL_PASSWORD_<连接名>` 环境变量
72
72
 
@@ -80,7 +80,7 @@ sql_exec { sql: UPDATE orders SET status = 'paid' WHERE id = 42 }
80
80
 
81
81
  ```bash
82
82
  pnpm install
83
- pnpm test # 构建 + 27 个测试(含真实 SQLite 集成)
83
+ pnpm test # 构建 + 33 个测试(含真实 SQLite 集成)
84
84
  ```
85
85
 
86
86
  ## License
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/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 {
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.2.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",