@mcpolyglot/connector-sql 0.2.0 → 0.3.1

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.
Files changed (41) hide show
  1. package/README.md +68 -8
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/__tests__/dialects.integration.test.d.ts +2 -0
  4. package/dist/__tests__/dialects.integration.test.d.ts.map +1 -0
  5. package/dist/__tests__/dialects.integration.test.js +183 -0
  6. package/dist/__tests__/dialects.integration.test.js.map +1 -0
  7. package/dist/__tests__/policy.test.d.ts +2 -0
  8. package/dist/__tests__/policy.test.d.ts.map +1 -0
  9. package/dist/__tests__/policy.test.js +190 -0
  10. package/dist/__tests__/policy.test.js.map +1 -0
  11. package/dist/__tests__/sqlite.integration.test.js +182 -4
  12. package/dist/__tests__/sqlite.integration.test.js.map +1 -1
  13. package/dist/connector.d.ts +30 -2
  14. package/dist/connector.d.ts.map +1 -1
  15. package/dist/connector.js +173 -14
  16. package/dist/connector.js.map +1 -1
  17. package/dist/dialect.d.ts +26 -1
  18. package/dist/dialect.d.ts.map +1 -1
  19. package/dist/dialect.js +4 -1
  20. package/dist/dialect.js.map +1 -1
  21. package/dist/dialects/mysql.d.ts +14 -3
  22. package/dist/dialects/mysql.d.ts.map +1 -1
  23. package/dist/dialects/mysql.js +67 -6
  24. package/dist/dialects/mysql.js.map +1 -1
  25. package/dist/dialects/postgres.d.ts +14 -3
  26. package/dist/dialects/postgres.d.ts.map +1 -1
  27. package/dist/dialects/postgres.js +70 -9
  28. package/dist/dialects/postgres.js.map +1 -1
  29. package/dist/dialects/sqlite.d.ts +12 -3
  30. package/dist/dialects/sqlite.d.ts.map +1 -1
  31. package/dist/dialects/sqlite.js +31 -4
  32. package/dist/dialects/sqlite.js.map +1 -1
  33. package/dist/index.d.ts +2 -1
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +2 -1
  36. package/dist/index.js.map +1 -1
  37. package/dist/policy.d.ts +56 -0
  38. package/dist/policy.d.ts.map +1 -0
  39. package/dist/policy.js +343 -0
  40. package/dist/policy.js.map +1 -0
  41. package/package.json +2 -2
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=dialects.integration.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dialects.integration.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/dialects.integration.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,183 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
2
+ import { SqlConnector } from '../connector.js';
3
+ import { PostgresDialect } from '../dialects/postgres.js';
4
+ import { MysqlDialect } from '../dialects/mysql.js';
5
+ // Real servers, same assertions for each dialect. Each block runs only when its URL is set
6
+ // (CI sets both via service containers):
7
+ // PG_TEST_URL=postgres://postgres:test@localhost:55432/postgres
8
+ // MYSQL_TEST_URL=mysql://root:test@localhost:53306/test
9
+ const SEED = [
10
+ 'DROP TABLE IF EXISTS orders',
11
+ 'DROP TABLE IF EXISTS users',
12
+ 'CREATE TABLE users (id INT PRIMARY KEY, email VARCHAR(100), password_hash VARCHAR(100))',
13
+ 'CREATE TABLE orders (id INT PRIMARY KEY, user_id INT, total_cents INT)',
14
+ "INSERT INTO users VALUES (1, 'alice@example.com', 'h1'), (2, 'bob@example.com', 'h2')",
15
+ 'INSERT INTO orders VALUES (1, 1, 4995), (2, 1, 12000), (3, 2, 2500)',
16
+ ];
17
+ const targets = [
18
+ {
19
+ name: 'postgres',
20
+ url: process.env.PG_TEST_URL,
21
+ make: (u) => new PostgresDialect(u),
22
+ param: '$1',
23
+ sleep: 'SELECT pg_sleep(3)',
24
+ seed: async (u) => {
25
+ const { Client } = await import('pg');
26
+ const c = new Client({ connectionString: u });
27
+ await c.connect();
28
+ for (const s of SEED)
29
+ await c.query(s);
30
+ await c.end();
31
+ },
32
+ },
33
+ {
34
+ name: 'mysql',
35
+ url: process.env.MYSQL_TEST_URL,
36
+ make: (u) => new MysqlDialect(u),
37
+ param: '?',
38
+ sleep: 'SELECT SLEEP(3) AS s',
39
+ seed: async (u) => {
40
+ const mysql = await import('mysql2/promise');
41
+ const c = await mysql.createConnection(u);
42
+ for (const s of SEED)
43
+ await c.query(s);
44
+ await c.end();
45
+ },
46
+ },
47
+ ];
48
+ const logger = { debug: () => { }, info: () => { }, warn: () => { }, error: () => { } };
49
+ const ctx = {
50
+ sessionId: 't',
51
+ scopes: new Set(['schema:read', 'tables:read', 'query:raw']),
52
+ signal: new AbortController().signal,
53
+ limits: { rowCap: 100, timeoutMs: 5_000, maxBytes: 256 * 1024 },
54
+ logger,
55
+ };
56
+ for (const t of targets) {
57
+ describe.skipIf(!t.url)(`${t.name} end-to-end`, () => {
58
+ const connectors = [];
59
+ async function open(policy) {
60
+ const c = new SqlConnector({
61
+ id: t.name,
62
+ dialect: t.make(t.url),
63
+ ...(policy && { policy }),
64
+ });
65
+ await c.init({ logger });
66
+ connectors.push(c);
67
+ const tools = Object.fromEntries(c.listPrimitiveTools().map((x) => [x.name.slice(t.name.length + 1), x]));
68
+ return tools;
69
+ }
70
+ beforeAll(async () => {
71
+ await t.seed(t.url);
72
+ });
73
+ afterAll(async () => {
74
+ for (const c of connectors)
75
+ await c.close();
76
+ });
77
+ it('reads with bind params', async () => {
78
+ const { query } = await open();
79
+ const r = await query.handler({ sql: `SELECT id FROM orders WHERE user_id = ${t.param} ORDER BY id`, params: [1] }, ctx);
80
+ expect(r.content[0].data.rows).toHaveLength(2);
81
+ });
82
+ it('caps rows and flags truncation', async () => {
83
+ const { query } = await open();
84
+ const r = await query.handler({ sql: 'SELECT id FROM orders' }, { ...ctx, limits: { ...ctx.limits, rowCap: 2 } });
85
+ expect(r.metadata).toMatchObject({ rows: 2, truncated: true });
86
+ });
87
+ it('policy denies DDL and writes before the DB sees them', async () => {
88
+ const { query } = await open();
89
+ for (const sql of ['DROP TABLE users', "UPDATE users SET email = 'x' WHERE id = 1"]) {
90
+ await expect(query.handler({ sql }, ctx)).rejects.toMatchObject({
91
+ code: 'forbidden.policy',
92
+ });
93
+ }
94
+ });
95
+ // Postgres: BEGIN READ ONLY rejects it. MySQL: the dialect's own AST gate rejects it first.
96
+ it('dialect read-only layer blocks a write the policy allowed', async () => {
97
+ const { query } = await open({
98
+ tables: { users: 'write' },
99
+ defaultAccess: 'read',
100
+ denyColumns: [],
101
+ maxWritesPerCall: 1,
102
+ });
103
+ await expect(query.handler({ sql: "UPDATE users SET email = 'pwned' WHERE id = 1" }, ctx)).rejects.toMatchObject({ code: 'forbidden.read_only' });
104
+ const check = await (await open()).query.handler({ sql: 'SELECT email FROM users WHERE id = 1' }, ctx);
105
+ expect(check.content[0].data.rows[0].email).toBe('alice@example.com');
106
+ });
107
+ it('denied columns are blocked in queries and hidden from list_tables', async () => {
108
+ const tools = await open({
109
+ tables: {},
110
+ defaultAccess: 'read',
111
+ denyColumns: ['users.password_hash'],
112
+ maxWritesPerCall: 1,
113
+ });
114
+ await expect(tools.query.handler({ sql: 'SELECT password_hash FROM users' }, ctx)).rejects.toMatchObject({ code: 'forbidden.policy' });
115
+ const list = await tools.list_tables.handler({}, ctx);
116
+ const users = list.content[0].data.find((x) => x.name === 'users');
117
+ expect(users.columns.map((c) => c.name)).toEqual(['id', 'email']);
118
+ });
119
+ it('the DB enforces the statement timeout', async () => {
120
+ const { query } = await open();
121
+ const t0 = Date.now();
122
+ await query
123
+ .handler({ sql: t.sleep }, { ...ctx, limits: { ...ctx.limits, timeoutMs: 300 } })
124
+ .catch(() => { }); // pg errors; MySQL's SLEEP returns early instead
125
+ expect(Date.now() - t0).toBeLessThan(2_000);
126
+ });
127
+ const writePolicy = { tables: { orders: 'write' }, maxWritesPerCall: 1 };
128
+ const count = async (where) => {
129
+ const { query } = await open();
130
+ const r = await query.handler({ sql: `SELECT count(*) AS n FROM orders WHERE ${where}` }, ctx);
131
+ return Number(r.content[0].data.rows[0].n);
132
+ };
133
+ it('read-only connection refuses a raw write even when the tool layer is bypassed', async () => {
134
+ const dialect = t.make(t.url);
135
+ await dialect.connect(); // no policy writes -> read-only connection
136
+ try {
137
+ await expect(dialect.runWrite('UPDATE orders SET total_cents = 0 WHERE id = 1', [], {
138
+ maxRowsAffected: 10,
139
+ timeoutMs: 5_000,
140
+ })).rejects.toMatchObject({ code: 'forbidden.read_only' });
141
+ }
142
+ finally {
143
+ await dialect.close();
144
+ }
145
+ expect(await count('total_cents = 0')).toBe(0);
146
+ });
147
+ it('execute is registered only when a table is writable', async () => {
148
+ expect((await open()).execute).toBeUndefined();
149
+ expect((await open(writePolicy)).execute).toBeDefined();
150
+ });
151
+ it('execute over maxWritesPerCall rolls back', async () => {
152
+ const { execute } = await open(writePolicy);
153
+ await expect(execute.handler({ sql: 'UPDATE orders SET total_cents = 0 WHERE user_id = 1' }, ctx)).rejects.toMatchObject({ code: 'forbidden.policy' });
154
+ expect(await count('total_cents = 0')).toBe(0);
155
+ const ok = await execute.handler({ sql: `UPDATE orders SET total_cents = 1 WHERE id = ${t.param}`, params: [3] }, ctx);
156
+ expect(ok.metadata?.rows).toBe(1);
157
+ });
158
+ it('idempotencyKey replays without re-executing; reuse with different args errors', async () => {
159
+ const { execute } = await open(writePolicy);
160
+ const args = { sql: `INSERT INTO orders VALUES (${t.param}, 2, 7)`, params: [50] };
161
+ await execute.handler({ ...args, idempotencyKey: 'k' }, ctx);
162
+ // A real re-run would hit the primary key and throw.
163
+ const again = await execute.handler({ ...args, idempotencyKey: 'k' }, ctx);
164
+ expect(again.metadata?.rows).toBe(1);
165
+ expect(await count('id = 50')).toBe(1);
166
+ await expect(execute.handler({ ...args, params: [51], idempotencyKey: 'k' }, ctx)).rejects.toMatchObject({ code: 'invalid_argument' });
167
+ expect(await count('id = 51')).toBe(0);
168
+ });
169
+ it('maxConcurrentQueries rejects the call over the cap', async () => {
170
+ const c = new SqlConnector({ id: 'cap', dialect: t.make(t.url), maxConcurrentQueries: 1 });
171
+ await c.init({ logger });
172
+ connectors.push(c);
173
+ const query = c.listPrimitiveTools().find((x) => x.name === 'cap.query');
174
+ const [a, b] = await Promise.allSettled([
175
+ query.handler({ sql: 'SELECT 1 AS x' }, ctx),
176
+ query.handler({ sql: 'SELECT 1 AS x' }, ctx),
177
+ ]);
178
+ expect(a.status).toBe('fulfilled');
179
+ expect(b).toMatchObject({ status: 'rejected', reason: { code: 'rate_limited' } });
180
+ });
181
+ });
182
+ }
183
+ //# sourceMappingURL=dialects.integration.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dialects.integration.test.js","sourceRoot":"","sources":["../../src/__tests__/dialects.integration.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=policy.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"policy.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/policy.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,190 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { classify, isColumnDenied, narrowPolicy, PolicySchema } from '../policy.js';
3
+ import { stripDeniedKeys } from '../connector.js';
4
+ const policy = PolicySchema.parse({
5
+ tables: { users: 'read', orders: 'write', 'public.secrets': 'none', audit_log: 'none' },
6
+ defaultAccess: 'read',
7
+ denyColumns: ['users.password_hash', '*.ssn'],
8
+ });
9
+ // [sql, allow, reason substring]
10
+ const cases = [
11
+ // reads
12
+ ['SELECT email FROM users', true, 'Allowed'],
13
+ ['SELECT u.email FROM users u JOIN orders o ON o.user_id = u.id', true, 'Allowed'],
14
+ ['SELECT count(*) FROM users', true, 'Allowed'],
15
+ ['SELECT id, total_cents FROM orders', true, 'Allowed'],
16
+ // `*.column` rules can't know which tables have the column, so any SELECT * is denied.
17
+ ['SELECT * FROM orders', false, '*.ssn'],
18
+ ['SELECT id FROM unlisted_table', true, 'Allowed'],
19
+ // table access
20
+ ['SELECT * FROM audit_log', false, 'not accessible'],
21
+ ['SELECT * FROM public.secrets', false, 'not accessible'],
22
+ ['SELECT * FROM secrets', false, 'not accessible'], // unqualified can't dodge schema rule
23
+ ['SELECT id FROM users WHERE id IN (SELECT user_id FROM audit_log)', false, 'not accessible'],
24
+ // column denies
25
+ ['SELECT password_hash FROM users', false, 'users.password_hash'],
26
+ ['SELECT u.password_hash AS p FROM users u', false, 'users.password_hash'],
27
+ ['SELECT email FROM users WHERE password_hash = $1', false, 'users.password_hash'],
28
+ ['SELECT (SELECT password_hash FROM users LIMIT 1)', false, 'users.password_hash'],
29
+ ['SELECT * FROM users', false, 'SELECT *'],
30
+ ['SELECT u.* FROM users u', false, 'SELECT *'],
31
+ ['SELECT ssn FROM anything', false, '*.ssn'],
32
+ ['SELECT PASSWORD_HASH FROM Users', false, 'password_hash'],
33
+ // whole-row references return every column, denied ones included, inside one value
34
+ ['SELECT u FROM users u', false, 'Whole-row'],
35
+ ['SELECT users FROM users', false, 'Whole-row'],
36
+ ['SELECT to_jsonb(u) FROM users u', false, 'Whole-row'],
37
+ ['SELECT json_agg(u) FROM users u', false, 'Whole-row'],
38
+ ['SELECT u::text FROM users u', false, 'Whole-row'],
39
+ ['SELECT row_to_json(users) FROM users', false, 'Whole-row'],
40
+ ['SELECT o FROM orders o', false, '*.ssn'], // `*.col` rule: any whole row could carry it
41
+ ['SELECT o.id FROM orders o JOIN users u ON u.id = o.user_id', true, 'Allowed'],
42
+ // writes
43
+ ['INSERT INTO orders (user_id, total_cents) VALUES (1, 100)', true, 'Allowed'],
44
+ ['UPDATE orders SET total_cents = 1 WHERE id = 1', true, 'Allowed'],
45
+ ['DELETE FROM orders WHERE id = 1', true, 'Allowed'],
46
+ ["INSERT INTO users (email) VALUES ('x')", false, 'read-only'],
47
+ ['INSERT INTO unlisted_table (id) VALUES (1)', false, 'read-only'], // default never grants write
48
+ ['INSERT INTO orders (user_id) SELECT id FROM audit_log', false, 'not accessible'],
49
+ ['UPDATE orders SET total_cents = 0', false, 'without a WHERE'],
50
+ ['DELETE FROM orders', false, 'without a WHERE'],
51
+ ['UPDATE orders SET total_cents = 0 WHERE 1=1', false, 'names no column'],
52
+ ['DELETE FROM orders WHERE true', false, 'names no column'],
53
+ // server access through functions / INTO / system catalogs
54
+ ["SELECT pg_read_file('/etc/passwd')", false, 'pg_read_file'],
55
+ ["SELECT PG_READ_FILE('/etc/passwd')", false, 'pg_read_file'],
56
+ ["SELECT pg_catalog.pg_read_file('/etc/passwd')", false, 'pg_read_file'],
57
+ ["SELECT * FROM pg_ls_dir('.')", false, 'pg_ls_dir'],
58
+ ["SELECT email FROM users WHERE id = (SELECT lo_import('/etc/passwd'))", false, 'lo_import'],
59
+ ["SELECT dblink_connect('host=x')", false, 'dblink_connect'],
60
+ ['SELECT pg_sleep(10)', false, 'pg_sleep'],
61
+ ['SELECT * FROM information_schema.tables', false, 'System table'],
62
+ ['SELECT * FROM pg_catalog.pg_shadow', false, 'System table'],
63
+ ['SELECT * FROM pg_shadow', false, 'System table'],
64
+ // DDL / admin / multi-statement / garbage
65
+ ['DROP TABLE orders', false, 'always blocked'],
66
+ ['CREATE TABLE x (id int)', false, 'always blocked'],
67
+ ['ALTER TABLE orders ADD COLUMN y int', false, 'always blocked'],
68
+ ['TRUNCATE orders', false, 'always blocked'],
69
+ ['GRANT ALL ON orders TO public', false, ''],
70
+ ['SELECT 1; DROP TABLE orders', false, 'one statement'],
71
+ ['WITH x AS (DELETE FROM orders RETURNING id) SELECT * FROM x', false, ''],
72
+ ['SELEC oops', false, 'Could not parse'],
73
+ ];
74
+ describe('classify (postgres)', () => {
75
+ it.each(cases)('%s → allow=%s', (sql, allow, reason) => {
76
+ const d = classify(sql, policy, 'postgres');
77
+ expect(d.allow, d.reason).toBe(allow);
78
+ expect(d.reason).toContain(reason);
79
+ });
80
+ });
81
+ describe('classify (mysql): file access', () => {
82
+ it.each([
83
+ ["SELECT LOAD_FILE('/etc/passwd')", 'load_file'],
84
+ ["SELECT * FROM orders INTO OUTFILE '/tmp/x'", 'INTO'],
85
+ ["SELECT id FROM orders INTO DUMPFILE '/tmp/x'", 'INTO'],
86
+ ['SELECT SLEEP(10)', 'sleep'],
87
+ ['SELECT * FROM mysql.user', 'System table'],
88
+ ])('%s denied', (sql, reason) => {
89
+ const d = classify(sql, policy, 'mysql');
90
+ expect(d.allow, d.reason).toBe(false);
91
+ expect(d.reason).toContain(reason);
92
+ });
93
+ it('a system table can be exposed by listing it explicitly', () => {
94
+ const p = PolicySchema.parse({ tables: { 'information_schema.tables': 'read' } });
95
+ expect(classify('SELECT * FROM information_schema.tables', p, 'mysql').allow).toBe(true);
96
+ expect(classify('SELECT * FROM information_schema.columns', p, 'mysql').allow).toBe(false);
97
+ });
98
+ });
99
+ describe('classify (sqlite): extensions', () => {
100
+ it('denies load_extension and sqlite_master', () => {
101
+ expect(classify("SELECT load_extension('x')", policy, 'sqlite').allow).toBe(false);
102
+ expect(classify('SELECT * FROM sqlite_master', policy, 'sqlite').allow).toBe(false);
103
+ });
104
+ });
105
+ describe('classify across dialects', () => {
106
+ it.each(['mysql', 'sqlite'])('%s: denies column + DDL, allows plain read', (dialect) => {
107
+ expect(classify('SELECT email FROM users', policy, dialect).allow).toBe(true);
108
+ expect(classify('SELECT password_hash FROM users', policy, dialect).allow).toBe(false);
109
+ expect(classify('DROP TABLE users', policy, dialect).allow).toBe(false);
110
+ });
111
+ });
112
+ describe('policy defaults', () => {
113
+ it('empty policy: reads allowed, every write denied', () => {
114
+ const p = PolicySchema.parse({});
115
+ expect(classify('SELECT * FROM anything', p, 'postgres').allow).toBe(true);
116
+ expect(classify('DELETE FROM anything WHERE id = 1', p, 'postgres').allow).toBe(false);
117
+ });
118
+ it('defaultAccess cannot be write', () => {
119
+ expect(() => PolicySchema.parse({ defaultAccess: 'write' })).toThrow();
120
+ });
121
+ it('defaultAccess none hides unlisted tables', () => {
122
+ const p = PolicySchema.parse({ defaultAccess: 'none', tables: { users: 'read' } });
123
+ expect(classify('SELECT id FROM users', p, 'postgres').allow).toBe(true);
124
+ expect(classify('SELECT id FROM other', p, 'postgres').allow).toBe(false);
125
+ });
126
+ it('most restrictive matching key wins', () => {
127
+ const p = PolicySchema.parse({ tables: { users: 'write', 'public.users': 'none' } });
128
+ expect(classify('SELECT id FROM public.users', p, 'postgres').allow).toBe(false);
129
+ });
130
+ it('isColumnDenied', () => {
131
+ expect(isColumnDenied(policy, 'public.users', 'password_hash')).toBe(true);
132
+ expect(isColumnDenied(policy, 'users', 'email')).toBe(false);
133
+ expect(isColumnDenied(policy, 'whatever', 'SSN')).toBe(true);
134
+ });
135
+ });
136
+ describe('narrowPolicy', () => {
137
+ const base = PolicySchema.parse({
138
+ tables: { 'public.users': 'none', orders: 'write' },
139
+ defaultAccess: 'none',
140
+ denyColumns: ['orders.card'],
141
+ maxRows: 50,
142
+ });
143
+ const wider = PolicySchema.parse({
144
+ tables: { users: 'write', orders: 'write', items: 'write' },
145
+ defaultAccess: 'read',
146
+ denyColumns: ['orders.note'],
147
+ maxRows: 500,
148
+ maxWritesPerCall: 10,
149
+ });
150
+ const n = narrowPolicy(base, wider);
151
+ it('never widens table access, even via a differently-qualified key', () => {
152
+ expect(classify('SELECT id FROM users', n, 'postgres').allow).toBe(false);
153
+ expect(classify('SELECT id FROM public.users', n, 'postgres').allow).toBe(false);
154
+ expect(classify('SELECT id FROM items', n, 'postgres').allow).toBe(false); // base default none
155
+ expect(n.defaultAccess).toBe('none');
156
+ });
157
+ it('keeps grants both sides agree on', () => {
158
+ expect(classify('DELETE FROM orders WHERE id = 1', n, 'postgres').allow).toBe(true);
159
+ });
160
+ it('unions deny lists and takes the smaller caps', () => {
161
+ expect(n.denyColumns.sort()).toEqual(['orders.card', 'orders.note']);
162
+ expect(n.maxRows).toBe(50);
163
+ expect(n.maxWritesPerCall).toBe(1);
164
+ });
165
+ it('can narrow further', () => {
166
+ const tighter = narrowPolicy(base, PolicySchema.parse({ tables: { orders: 'read' } }));
167
+ expect(classify('DELETE FROM orders WHERE id = 1', tighter, 'postgres').allow).toBe(false);
168
+ expect(classify('SELECT id FROM orders', tighter, 'postgres').allow).toBe(true);
169
+ });
170
+ });
171
+ describe('stripDeniedKeys (output-side filter)', () => {
172
+ const denied = new Set(['password_hash', 'ssn']);
173
+ it('drops denied keys at any depth, in objects and arrays', () => {
174
+ const rows = [
175
+ { id: 1, password_hash: 'x', profile: { ssn: '1', name: 'a' } },
176
+ { agg: [{ id: 2, PASSWORD_HASH: 'y' }, { id: 3 }] },
177
+ ];
178
+ expect(stripDeniedKeys(rows, denied)).toEqual([
179
+ { id: 1, profile: { name: 'a' } },
180
+ { agg: [{ id: 2 }, { id: 3 }] },
181
+ ]);
182
+ });
183
+ it('leaves scalars, nulls, and class instances alone', () => {
184
+ const d = new Date(0);
185
+ expect(stripDeniedKeys([{ at: d, n: null, s: 'ssn' }], denied)).toEqual([
186
+ { at: d, n: null, s: 'ssn' },
187
+ ]);
188
+ });
189
+ });
190
+ //# sourceMappingURL=policy.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"policy.test.js","sourceRoot":"","sources":["../../src/__tests__/policy.test.ts"],"names":[],"mappings":"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"}
@@ -119,15 +119,82 @@ d('sqlite end-to-end via SqlConnector', () => {
119
119
  expect(data.truncated).toBe(true);
120
120
  expect(r.metadata?.truncated).toBe(true);
121
121
  });
122
- it('rejects non-read SQL with forbidden.read_only', async () => {
122
+ it('policy rejects writes before they reach the DB, with a reason', async () => {
123
123
  const t = tools['sqlite.test.query'];
124
- await expect(t.handler({ sql: 'UPDATE users SET name = ? WHERE id = ?', params: ['Eve', 1] }, ctx)).rejects.toMatchObject({ code: 'forbidden.read_only' });
124
+ await expect(t.handler({ sql: 'UPDATE users SET name = ? WHERE id = ?', params: ['Eve', 1] }, ctx)).rejects.toMatchObject({
125
+ code: 'forbidden.policy',
126
+ message: expect.stringContaining('read-only'),
127
+ });
125
128
  });
126
- it('rejects DDL with forbidden.read_only', async () => {
129
+ it('policy rejects DDL with a reason', async () => {
127
130
  const t = tools['sqlite.test.query'];
128
131
  await expect(t.handler({ sql: 'DROP TABLE users' }, ctx)).rejects.toMatchObject({
129
- code: 'forbidden.read_only',
132
+ code: 'forbidden.policy',
133
+ message: expect.stringContaining('always blocked'),
134
+ });
135
+ });
136
+ it('DB-level read-only still blocks a write the policy allows (defense in depth)', async () => {
137
+ const c = new SqlConnector({
138
+ id: 'sqlite.rw',
139
+ dialect: new SqliteDialect(dbPath),
140
+ policy: {
141
+ tables: { users: 'write' },
142
+ defaultAccess: 'read',
143
+ denyColumns: [],
144
+ maxWritesPerCall: 1,
145
+ },
130
146
  });
147
+ await c.init({ logger: ctx.logger });
148
+ try {
149
+ const q = c.listPrimitiveTools().find((x) => x.name === 'sqlite.rw.query');
150
+ await expect(q.handler({ sql: "UPDATE users SET name = 'Eve' WHERE id = 1" }, ctx)).rejects.toMatchObject({ code: 'forbidden.read_only' });
151
+ }
152
+ finally {
153
+ await c.close();
154
+ }
155
+ });
156
+ it('dryRun returns the decision without executing', async () => {
157
+ const t = tools['sqlite.test.query'];
158
+ const r = await t.handler({ sql: 'DROP TABLE users', dryRun: true }, ctx);
159
+ const data = r.content[0]?.data;
160
+ expect(data.dryRun).toBe(true);
161
+ expect(data.decision.allow).toBe(false);
162
+ // table still there
163
+ const ok = await t.handler({ sql: 'SELECT count(*) AS n FROM users' }, ctx);
164
+ expect((ok.content[0]?.data).rows[0]?.n).toBe(2);
165
+ });
166
+ it('policy hides denied columns and tables from list_tables and blocks querying them', async () => {
167
+ const c = new SqlConnector({
168
+ id: 'sqlite.p',
169
+ dialect: new SqliteDialect(dbPath),
170
+ policy: {
171
+ tables: { orders: 'none' },
172
+ defaultAccess: 'read',
173
+ denyColumns: ['users.password_hash'],
174
+ maxWritesPerCall: 1,
175
+ maxRows: 1,
176
+ },
177
+ });
178
+ await c.init({ logger: ctx.logger });
179
+ try {
180
+ const byName = Object.fromEntries(c.listPrimitiveTools().map((x) => [x.name, x]));
181
+ const list = await byName['sqlite.p.list_tables'].handler({}, ctx);
182
+ const tables = list.content[0]?.data;
183
+ expect(tables.map((x) => x.name)).toEqual(['users']);
184
+ expect(tables[0].columns.map((x) => x.name)).not.toContain('password_hash');
185
+ const q = byName['sqlite.p.query'];
186
+ await expect(q.handler({ sql: 'SELECT password_hash FROM users' }, ctx)).rejects.toMatchObject({
187
+ code: 'forbidden.policy',
188
+ });
189
+ await expect(q.handler({ sql: 'SELECT id FROM orders' }, ctx)).rejects.toMatchObject({
190
+ code: 'forbidden.policy',
191
+ });
192
+ const r = await q.handler({ sql: 'SELECT email FROM users' }, ctx);
193
+ expect((r.content[0]?.data).rowCount).toBe(1); // maxRows
194
+ }
195
+ finally {
196
+ await c.close();
197
+ }
131
198
  });
132
199
  it('health() returns ok with a real latency reading', async () => {
133
200
  const h = await connector.health();
@@ -142,4 +209,115 @@ d('sqlite end-to-end via SqlConnector', () => {
142
209
  expect(snap.tables.length).toBe(2);
143
210
  });
144
211
  });
212
+ d('sqlite writes via SqlConnector.execute', () => {
213
+ let tmpDir;
214
+ let dbPath;
215
+ const open = [];
216
+ const logger = { debug: () => { }, info: () => { }, warn: () => { }, error: () => { } };
217
+ const ctx = {
218
+ sessionId: 'w',
219
+ scopes: new Set(['tables:read', 'tables:write', 'query:raw']),
220
+ signal: new AbortController().signal,
221
+ limits: { rowCap: 100, timeoutMs: 5_000, maxBytes: 256 * 1024 },
222
+ logger,
223
+ };
224
+ async function connect(opts = {}) {
225
+ const c = new SqlConnector({
226
+ id: 'w',
227
+ dialect: new SqliteDialect(dbPath),
228
+ policy: { tables: { orders: 'write' }, maxWritesPerCall: 1 },
229
+ ...opts,
230
+ });
231
+ await c.init({ logger });
232
+ open.push(c);
233
+ return Object.fromEntries(c.listPrimitiveTools().map((t) => [t.name.slice(2), t]));
234
+ }
235
+ const count = async (q, where = '1=1') => ((await q.handler({ sql: `SELECT count(*) AS n FROM orders WHERE ${where}` }, ctx)).content[0]
236
+ ?.data).rows[0].n;
237
+ beforeAll(async () => {
238
+ tmpDir = mkdtempSync(join(tmpdir(), 'mcpolyglot-sqlite-w-'));
239
+ dbPath = join(tmpDir, 'w.db');
240
+ const Database = (await import('better-sqlite3')).default;
241
+ const seed = new Database(dbPath);
242
+ seed.exec(`
243
+ CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT);
244
+ CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER, total_cents INTEGER);
245
+ INSERT INTO users VALUES (1, 'a@x'), (2, 'b@x');
246
+ INSERT INTO orders VALUES (1, 1, 100), (2, 1, 200), (3, 2, 300);
247
+ `);
248
+ seed.close();
249
+ });
250
+ afterAll(async () => {
251
+ for (const c of open)
252
+ await c.close();
253
+ rmSync(tmpDir, { recursive: true, force: true });
254
+ });
255
+ it('execute is not registered when no table is writable', async () => {
256
+ const tools = await connect({ policy: { tables: { orders: 'read' } } });
257
+ expect(tools.execute).toBeUndefined();
258
+ });
259
+ it('read-only connection refuses a raw write even when the tool layer is bypassed', async () => {
260
+ const dialect = new SqliteDialect(dbPath);
261
+ await dialect.connect(); // default: read-only
262
+ try {
263
+ await expect(dialect.runWrite('UPDATE orders SET total_cents = 0 WHERE id = 1', [], {
264
+ maxRowsAffected: 10,
265
+ timeoutMs: 5_000,
266
+ })).rejects.toMatchObject({ code: 'forbidden.read_only' });
267
+ }
268
+ finally {
269
+ await dialect.close();
270
+ }
271
+ });
272
+ it('execute writes, returns rowsAffected, and is scoped tables:write', async () => {
273
+ const { execute, query } = await connect();
274
+ expect(execute.scopes).toEqual(['tables:write']);
275
+ expect(execute.readOnly).toBe(false);
276
+ const r = await execute.handler({ sql: 'UPDATE orders SET total_cents = ? WHERE id = ?', params: [101, 1] }, ctx);
277
+ expect(r.metadata?.rows).toBe(1);
278
+ expect(await count(query, 'total_cents = 101')).toBe(1);
279
+ });
280
+ it('execute over maxWritesPerCall rolls back', async () => {
281
+ const { execute, query } = await connect();
282
+ await expect(execute.handler({ sql: 'UPDATE orders SET total_cents = 0 WHERE user_id = 1' }, ctx)).rejects.toMatchObject({
283
+ code: 'forbidden.policy',
284
+ message: expect.stringMatching(/affected 2 rows.*maxWritesPerCall is 1/),
285
+ });
286
+ expect(await count(query, 'total_cents = 0')).toBe(0);
287
+ });
288
+ it('execute refuses reads and non-write tables, and honors dryRun', async () => {
289
+ const { execute, query } = await connect();
290
+ for (const sql of ['SELECT id FROM orders', "UPDATE users SET email = 'x' WHERE id = 1"]) {
291
+ await expect(execute.handler({ sql }, ctx)).rejects.toMatchObject({
292
+ code: 'forbidden.policy',
293
+ });
294
+ }
295
+ const before = await count(query);
296
+ const r = await execute.handler({ sql: 'INSERT INTO orders VALUES (99, 1, 1)', dryRun: true }, ctx);
297
+ expect((r.content[0]?.data).decision.allow).toBe(true);
298
+ expect(await count(query)).toBe(before);
299
+ });
300
+ it('idempotencyKey replays without re-executing; reuse with different args errors', async () => {
301
+ const { execute, query } = await connect();
302
+ const before = await count(query);
303
+ const args = { sql: 'INSERT INTO orders (user_id, total_cents) VALUES (?, ?)', params: [2, 7] };
304
+ const a = await execute.handler({ ...args, idempotencyKey: 'k1' }, ctx);
305
+ const b = await execute.handler({ ...args, idempotencyKey: 'k1' }, ctx);
306
+ expect(a.metadata?.rows).toBe(1);
307
+ expect(b.metadata?.rows).toBe(1);
308
+ expect(await count(query)).toBe(before + 1);
309
+ await expect(execute.handler({ ...args, params: [2, 8], idempotencyKey: 'k1' }, ctx)).rejects.toMatchObject({ code: 'invalid_argument' });
310
+ expect(await count(query)).toBe(before + 1);
311
+ });
312
+ it('maxConcurrentQueries rejects the call over the cap and frees the slot after', async () => {
313
+ const { query } = await connect({ maxConcurrentQueries: 1 });
314
+ const results = await Promise.allSettled([
315
+ query.handler({ sql: 'SELECT 1 AS x' }, ctx),
316
+ query.handler({ sql: 'SELECT 1 AS x' }, ctx),
317
+ ]);
318
+ expect(results[0].status).toBe('fulfilled');
319
+ expect(results[1]).toMatchObject({ status: 'rejected', reason: { code: 'rate_limited' } });
320
+ await expect(query.handler({ sql: 'SELECT 1 AS x' }, ctx)).resolves.toBeDefined();
321
+ });
322
+ });
145
323
  //# sourceMappingURL=sqlite.integration.test.js.map