vektor-guard 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vektor
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,144 @@
1
+ # vektor-guard
2
+
3
+ **Stop your AI agent from running `DROP TABLE` on production.**
4
+
5
+ Works inside Claude Desktop, Cursor, or wherever you already work — no new app to learn.
6
+
7
+ ---
8
+
9
+ ## Why this exists
10
+
11
+ In July 2026 a developer gave an AI agent write access to a production Supabase instance. A single `prisma migrate diff` — with `--shadow-database-url` resolving to the production connection — dropped every table. Two tables that were never defined in the `migrations` folder were gone for good.
12
+
13
+ The agent had safety instructions. It reasoned past them.
14
+
15
+ That is the problem this solves: instructions are not a control. An agent that *decides* not to run something is a different thing from an agent that *cannot* run it without a human saying yes. `vektor-guard` is the second kind.
16
+
17
+ ## Install (2 minutes)
18
+
19
+ Add this to your MCP client config:
20
+
21
+ **Claude Desktop** — `claude_desktop_config.json`
22
+ (macOS: `~/Library/Application Support/Claude/`, Windows: `%APPDATA%\Claude\`)
23
+
24
+ ```json
25
+ {
26
+ "mcpServers": {
27
+ "vektor-guard": {
28
+ "command": "npx",
29
+ "args": ["-y", "-p", "vektor-guard", "vektor-guard-mcp"]
30
+ }
31
+ }
32
+ }
33
+ ```
34
+
35
+ **Cursor** — `.cursor/mcp.json` in your project, same shape.
36
+
37
+ Restart the client. The agent now has an `inspect_sql` tool it can call before running anything.
38
+
39
+ Tell it once, in your project rules or system prompt:
40
+
41
+ > Before running any SQL that writes, alters, or drops, call `inspect_sql` first. If it returns BLOCKED, stop and ask me.
42
+
43
+ ## What it catches
44
+
45
+ | Operation | Why |
46
+ |---|---|
47
+ | `DROP TABLE` | Destroys a table and every row in it |
48
+ | `TRUNCATE` | Empties a table irreversibly |
49
+ | `DELETE` without `WHERE` | Removes every row |
50
+ | `ALTER TABLE … DROP COLUMN` | Destroys a column and its data |
51
+ | `DROP DATABASE` / `DROP SCHEMA` | Destroys everything |
52
+ | `CREATE DATABASE` | How a Prisma shadow-database step begins — the July case |
53
+
54
+ It is deliberately conservative, and it is not fooled by the usual ways a keyword hides:
55
+
56
+ ```sql
57
+ -- caught: a comment between the keywords
58
+ DROP /* nothing to see here */ TABLE users;
59
+
60
+ -- NOT flagged: the keyword is data, not structure
61
+ INSERT INTO audit (note) VALUES ('someone ran DROP TABLE users');
62
+ ```
63
+
64
+ Statements are split quote-, comment- and dollar-quote-aware, and string literals are masked before the rules run.
65
+
66
+ ## Two levels
67
+
68
+ **Free (what you just installed).** Local pattern matching. Nothing leaves your machine — no connection string, no SQL, no telemetry. It tells you *what* is dangerous.
69
+
70
+ It cannot tell you *how much* is at stake, because it never connects to anything. "Deletes every row" is as specific as it gets.
71
+
72
+ **With a Vektor API key.** The same call is answered by Vektor's engine against your actual database:
73
+
74
+ ```
75
+ BLOCKED — 1 destructive operation(s) found. Do NOT run this SQL.
76
+
77
+ [delete-without-where] DELETE without a WHERE clause removes every row in the table.
78
+ table: orders
79
+ rows at risk: 2,310,884
80
+ → DELETE FROM orders
81
+ ```
82
+
83
+ ```json
84
+ {
85
+ "mcpServers": {
86
+ "vektor-guard": {
87
+ "command": "npx",
88
+ "args": ["-y", "-p", "vektor-guard", "vektor-guard-mcp"],
89
+ "env": {
90
+ "VEKTOR_API_KEY": "vk_live_...",
91
+ "VEKTOR_DATABASE_URL": "postgresql://..."
92
+ }
93
+ }
94
+ }
95
+ }
96
+ ```
97
+
98
+ The connection is opened read-only and the credential is used for the call and discarded — there is no apply path in the connector, and nothing is stored server-side.
99
+
100
+ If the engine is unreachable, it falls back to the local check and **says so** in the result. An unavailable engine never reads as "safe".
101
+
102
+ Get a key at [vektor.dev](https://vektor.dev).
103
+
104
+ ## Also a CLI
105
+
106
+ Same rules, no MCP client needed — useful in CI:
107
+
108
+ ```bash
109
+ npx vektor-guard --file prisma/migrations/20260724_init/migration.sql
110
+ cat migration.sql | npx vektor-guard
111
+ npx vektor-guard "DELETE FROM users;"
112
+ ```
113
+
114
+ Exit codes: `0` clean · `2` blocked · `1` usage error. `--allow-destructive` downgrades blocks to warnings.
115
+
116
+ **GitHub Actions:**
117
+
118
+ ```yaml
119
+ - name: Check migrations for destructive operations
120
+ run: |
121
+ for file in $(git diff --name-only origin/main -- '**/migration.sql'); do
122
+ npx -y vektor-guard --file "$file"
123
+ done
124
+ ```
125
+
126
+ ## Limits, stated plainly
127
+
128
+ - It is a **lexical** check, not a full SQL parser. It errs toward flagging.
129
+ - It cannot stop an agent that never calls it — it is a tool the agent must be told to use, not a network-level proxy.
130
+ - The free tier has no idea how big your tables are.
131
+ - It does not replace backups, PITR, or least-privilege database credentials. It is one layer.
132
+
133
+ ## Tests
134
+
135
+ ```bash
136
+ node test.mjs # the rules
137
+ node test-mcp.mjs # the MCP protocol, end to end
138
+ ```
139
+
140
+ ## About
141
+
142
+ Extracted from the engine of [Vektor](https://vektor.dev) — a desktop IDE for designing and safely operating PostgreSQL schemas. This part is MIT and always will be.
143
+
144
+ MIT © 2026 Vektor
package/mcp-server.mjs ADDED
@@ -0,0 +1,307 @@
1
+ #!/usr/bin/env node
2
+ // vektor-guard MCP server — the safety layer between an AI agent and your database.
3
+ //
4
+ // Speaks the Model Context Protocol over stdio, so Claude Desktop, Cursor, or any
5
+ // other MCP client can call it. Add five lines to a config file and the agent gains
6
+ // a tool it must go through before it can propose destructive SQL.
7
+ //
8
+ // {
9
+ // "mcpServers": {
10
+ // "vektor-guard": { "command": "npx", "args": ["-y", "vektor-guard-mcp"] }
11
+ // }
12
+ // }
13
+ //
14
+ // TWO LEVELS, and the difference is honest:
15
+ //
16
+ // Free (no key) Local pattern matching. Catches DROP TABLE, TRUNCATE,
17
+ // DELETE without WHERE, ALTER … DROP COLUMN, and the Prisma
18
+ // shadow-database case. Nothing leaves the machine. It cannot
19
+ // tell you how many rows are at stake, because it never
20
+ // connects to anything.
21
+ //
22
+ // With VEKTOR_API_KEY The same call is answered by Vektor's engine against
23
+ // the real database: actual row counts, lock modes, and the
24
+ // full execution plan. "Deletes every row" becomes "deletes
25
+ // 2,310,884 rows and holds ACCESS EXCLUSIVE for ~8s".
26
+ //
27
+ // Protocol implemented directly rather than via a dependency: MCP over stdio is
28
+ // newline-delimited JSON-RPC 2.0, and a security tool with no supply chain is
29
+ // worth more than one with a convenient SDK.
30
+
31
+ import { createInterface } from "node:readline";
32
+
33
+ import { initParser, inspectSql, SEVERITY } from "./rules.mjs";
34
+
35
+ const PROTOCOL_VERSION = "2024-11-05";
36
+ const SERVER_NAME = "vektor-guard";
37
+ const SERVER_VERSION = "0.2.0";
38
+
39
+ const API_BASE = process.env.VEKTOR_API_URL ?? "https://api.vektor.dev";
40
+ const API_KEY = process.env.VEKTOR_API_KEY ?? null;
41
+ /** The database the paid tier inspects. Never read unless a key is present —
42
+ * the free tier must not touch a connection string at all. */
43
+ const DATABASE_URL = process.env.VEKTOR_DATABASE_URL ?? null;
44
+
45
+ // --------------------------------------------------------------------------- //
46
+ // The tools this server exposes
47
+ // --------------------------------------------------------------------------- //
48
+
49
+ const TOOLS = [
50
+ {
51
+ name: "inspect_sql",
52
+ description:
53
+ "Check SQL for destructive operations BEFORE running it. Call this on any " +
54
+ "statement that writes, alters, or drops — DROP TABLE, TRUNCATE, DELETE, " +
55
+ "ALTER TABLE, or a migration file. Returns whether it is safe and, when a " +
56
+ "Vektor API key is configured, how many real rows would be affected.",
57
+ inputSchema: {
58
+ type: "object",
59
+ properties: {
60
+ sql: {
61
+ type: "string",
62
+ description: "The SQL to inspect. May contain several statements.",
63
+ },
64
+ },
65
+ required: ["sql"],
66
+ },
67
+ },
68
+ {
69
+ name: "guard_status",
70
+ description:
71
+ "Report which mode the guard is running in (local pattern matching, or " +
72
+ "connected to the Vektor engine with real database analysis) and what " +
73
+ "quota remains. Useful when a user asks why a result lacks row counts.",
74
+ inputSchema: { type: "object", properties: {} },
75
+ },
76
+ ];
77
+
78
+ // --------------------------------------------------------------------------- //
79
+ // Talking to the Vektor engine (paid tier only)
80
+ // --------------------------------------------------------------------------- //
81
+
82
+ async function callEngine(path, body) {
83
+ const response = await fetch(`${API_BASE}${path}`, {
84
+ method: body ? "POST" : "GET",
85
+ headers: {
86
+ Authorization: `Bearer ${API_KEY}`,
87
+ ...(body ? { "Content-Type": "application/json" } : {}),
88
+ },
89
+ body: body ? JSON.stringify(body) : undefined,
90
+ });
91
+
92
+ if (!response.ok) {
93
+ const detail = await response.text().catch(() => "");
94
+ throw new Error(
95
+ `Vektor engine answered ${response.status}${detail ? `: ${detail.slice(0, 300)}` : ""}`,
96
+ );
97
+ }
98
+ return response.json();
99
+ }
100
+
101
+ // --------------------------------------------------------------------------- //
102
+ // Rendering a verdict for the agent to read
103
+ // --------------------------------------------------------------------------- //
104
+
105
+ function renderLocal(verdict) {
106
+ const violations = verdict.findings;
107
+ if (violations.length === 0) {
108
+ return (
109
+ "SAFE — no destructive operations found (local pattern check).\n\n" +
110
+ "Note: this was a local check with no database connection, so row counts " +
111
+ "and lock durations are unknown."
112
+ );
113
+ }
114
+
115
+ const blocked = violations.filter((v) => v.severity === SEVERITY.BLOCK);
116
+ const lines = [
117
+ blocked.length === 0
118
+ ? `WARNING — ${violations.length} finding(s). Review before running.`
119
+ : `BLOCKED — ${blocked.length} destructive operation(s) found. Do NOT run this SQL.`,
120
+ "",
121
+ ];
122
+ for (const violation of violations) {
123
+ lines.push(`[${violation.rule}] ${violation.reason}`);
124
+ lines.push(` → ${violation.statement.slice(0, 200)}`);
125
+ lines.push("");
126
+ }
127
+ lines.push(
128
+ "Ask the user to confirm explicitly before proceeding. If this is intentional, " +
129
+ "the user should run it themselves rather than having you do it.",
130
+ );
131
+ return lines.join("\n");
132
+ }
133
+
134
+ function renderEngine(verdict) {
135
+ const { allowed, findings, row_counts_supplied: withCounts } = verdict;
136
+
137
+ if (findings.length === 0) {
138
+ return withCounts
139
+ ? "SAFE — no destructive operations found, checked against the live database."
140
+ : "SAFE — no destructive operations found.";
141
+ }
142
+
143
+ const lines = [
144
+ allowed
145
+ ? `WARNING — ${findings.length} finding(s). Review before running.`
146
+ : `BLOCKED — ${findings.length} destructive operation(s) found. Do NOT run this SQL.`,
147
+ "",
148
+ ];
149
+ for (const finding of findings) {
150
+ lines.push(`[${finding.rule}] ${finding.reason}`);
151
+ if (finding.table) lines.push(` table: ${finding.table}`);
152
+ if (finding.estimated_rows !== null && finding.estimated_rows !== undefined) {
153
+ lines.push(` rows at risk: ${finding.estimated_rows.toLocaleString("en-US")}`);
154
+ }
155
+ lines.push(` → ${finding.statement.slice(0, 200)}`);
156
+ lines.push("");
157
+ }
158
+ lines.push(
159
+ "Ask the user to confirm explicitly before proceeding. If this is intentional, " +
160
+ "the user should run it themselves rather than having you do it.",
161
+ );
162
+ return lines.join("\n");
163
+ }
164
+
165
+ // --------------------------------------------------------------------------- //
166
+ // Tool dispatch
167
+ // --------------------------------------------------------------------------- //
168
+
169
+ async function runInspectSql(sql) {
170
+ if (!API_KEY) {
171
+ return renderLocal(inspectSql(sql));
172
+ }
173
+
174
+ try {
175
+ const body = await callEngine("/api/connector/inspect-sql", {
176
+ sql,
177
+ dsn: DATABASE_URL,
178
+ });
179
+ return renderEngine(body.verdict);
180
+ } catch (error) {
181
+ // The engine being unreachable must never mean "safe". Fall back to the
182
+ // local check, and say plainly that is what happened.
183
+ return (
184
+ `${renderLocal(inspectSql(sql))}\n\n` +
185
+ `(Vektor engine unavailable — ${error.message}. This was a local check only.)`
186
+ );
187
+ }
188
+ }
189
+
190
+ async function guardStatus() {
191
+ if (!API_KEY) {
192
+ return (
193
+ "Mode: LOCAL (free). Pattern matching only, nothing leaves this machine.\n" +
194
+ "Row counts and lock durations are not available in this mode.\n\n" +
195
+ "Set VEKTOR_API_KEY (and VEKTOR_DATABASE_URL) to analyse against the real database."
196
+ );
197
+ }
198
+
199
+ try {
200
+ const health = await callEngine("/api/connector/health");
201
+ return (
202
+ `Mode: ENGINE (${health.tier}). Connected to the Vektor engine.\n` +
203
+ `Quota: ${health.quota_used} of ${health.daily_quota} calls used today.\n` +
204
+ `Database analysis: ${DATABASE_URL ? "enabled" : "no VEKTOR_DATABASE_URL set — row counts unavailable"}.`
205
+ );
206
+ } catch (error) {
207
+ return `Mode: LOCAL (fallback). The Vektor engine is unreachable — ${error.message}`;
208
+ }
209
+ }
210
+
211
+ async function runTool(name, args) {
212
+ if (name === "inspect_sql") {
213
+ const sql = args?.sql;
214
+ if (typeof sql !== "string" || !sql.trim()) {
215
+ throw new Error("`sql` is required and must be a non-empty string.");
216
+ }
217
+ return runInspectSql(sql);
218
+ }
219
+ if (name === "guard_status") return guardStatus();
220
+ throw new Error(`Unknown tool: ${name}`);
221
+ }
222
+
223
+ // --------------------------------------------------------------------------- //
224
+ // JSON-RPC over stdio
225
+ // --------------------------------------------------------------------------- //
226
+
227
+ function send(message) {
228
+ process.stdout.write(`${JSON.stringify(message)}\n`);
229
+ }
230
+
231
+ function reply(id, result) {
232
+ send({ jsonrpc: "2.0", id, result });
233
+ }
234
+
235
+ function replyError(id, code, message) {
236
+ send({ jsonrpc: "2.0", id, error: { code, message } });
237
+ }
238
+
239
+ async function handle(request) {
240
+ const { id, method, params } = request;
241
+
242
+ // Notifications have no id and expect no response.
243
+ if (id === undefined || id === null) return;
244
+
245
+ if (method === "initialize") {
246
+ reply(id, {
247
+ protocolVersion: PROTOCOL_VERSION,
248
+ capabilities: { tools: {} },
249
+ serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
250
+ });
251
+ return;
252
+ }
253
+
254
+ if (method === "tools/list") {
255
+ reply(id, { tools: TOOLS });
256
+ return;
257
+ }
258
+
259
+ if (method === "tools/call") {
260
+ try {
261
+ const text = await runTool(params?.name, params?.arguments);
262
+ reply(id, { content: [{ type: "text", text }] });
263
+ } catch (error) {
264
+ // isError, rather than a protocol error: the agent should read the failure
265
+ // and tell the user, not treat the tool as broken and route around it.
266
+ reply(id, {
267
+ content: [{ type: "text", text: `vektor-guard failed: ${error.message}` }],
268
+ isError: true,
269
+ });
270
+ }
271
+ return;
272
+ }
273
+
274
+ if (method === "ping") {
275
+ reply(id, {});
276
+ return;
277
+ }
278
+
279
+ replyError(id, -32601, `Method not found: ${method}`);
280
+ }
281
+
282
+ async function main() {
283
+ // The parser is WebAssembly and must be loaded before the first tool call.
284
+ await initParser();
285
+ const lines = createInterface({ input: process.stdin });
286
+
287
+ lines.on("line", (line) => {
288
+ const trimmed = line.trim();
289
+ if (!trimmed) return;
290
+ let request;
291
+ try {
292
+ request = JSON.parse(trimmed);
293
+ } catch {
294
+ replyError(null, -32700, "Parse error");
295
+ return;
296
+ }
297
+ void handle(request);
298
+ });
299
+
300
+ lines.on("close", () => process.exit(0));
301
+ }
302
+
303
+ if (process.argv[1]?.endsWith("mcp-server.mjs")) {
304
+ void main();
305
+ }
306
+
307
+ export { renderLocal, renderEngine, runTool, handle };
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "vektor-guard",
3
+ "version": "0.3.0",
4
+ "description": "Stop your AI agent from running DROP TABLE on production. Decides on PostgreSQL's own parse tree, not patterns. MCP server for Claude Desktop and Cursor, plus a CLI.",
5
+ "type": "module",
6
+ "bin": {
7
+ "vektor-guard": "vektor-guard.mjs",
8
+ "vektor-guard-mcp": "mcp-server.mjs"
9
+ },
10
+ "exports": {
11
+ ".": "./vektor-guard.mjs",
12
+ "./mcp": "./mcp-server.mjs"
13
+ },
14
+ "files": [
15
+ "vektor-guard.mjs",
16
+ "rules.mjs",
17
+ "mcp-server.mjs",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "scripts": {
22
+ "test": "node test.mjs && node test-mcp.mjs"
23
+ },
24
+ "keywords": [
25
+ "mcp",
26
+ "model-context-protocol",
27
+ "claude",
28
+ "cursor",
29
+ "ai-safety",
30
+ "sql",
31
+ "postgres",
32
+ "prisma",
33
+ "migration",
34
+ "guard",
35
+ "drop-table"
36
+ ],
37
+ "license": "MIT",
38
+ "engines": {
39
+ "node": ">=18"
40
+ },
41
+ "dependencies": {
42
+ "libpg-query": "^17.7.4"
43
+ }
44
+ }
package/rules.mjs ADDED
@@ -0,0 +1,241 @@
1
+ // Destructive-operation rules, decided on PostgreSQL's own parse tree.
2
+ //
3
+ // This is the same ruleset as `src/vektor/guard/rules.py`, on the same parser:
4
+ // Python binds libpg_query through pglast, this binds it through libpg-query.
5
+ // One parser, two bindings — which is why the two can agree. The previous
6
+ // version matched patterns against text and disagreed with the engine within a
7
+ // day, passing eight valid destructive statements the engine caught.
8
+ //
9
+ // libpg-query ships as WebAssembly, so there is no native toolchain to install
10
+ // and no platform it fails to build on. That is the whole reason it is an
11
+ // acceptable dependency for a package whose selling point was having none.
12
+
13
+ import { loadModule, parseSync } from "libpg-query";
14
+
15
+ let ready = false;
16
+
17
+ /** The parser is WASM and must be initialised once before any parse. */
18
+ export async function initParser() {
19
+ if (!ready) {
20
+ await loadModule();
21
+ ready = true;
22
+ }
23
+ }
24
+
25
+ export const SEVERITY = { BLOCK: "block", WARN: "warn" };
26
+
27
+ // --------------------------------------------------------------------------- //
28
+ // Reading names out of the tree
29
+ // --------------------------------------------------------------------------- //
30
+
31
+ /** A table name without its schema — what a row-count lookup is keyed on. */
32
+ function bare(name) {
33
+ return name ? String(name).split(".").pop() : null;
34
+ }
35
+
36
+ /** The single node inside a libpg-query wrapper: {DropStmt: {...}} -> ["DropStmt", {...}] */
37
+ function unwrap(node) {
38
+ if (!node || typeof node !== "object") return [null, null];
39
+ const key = Object.keys(node)[0];
40
+ return [key, node[key]];
41
+ }
42
+
43
+ /** Names from a DropStmt.objects, each a List of String parts. */
44
+ function objectNames(objects) {
45
+ const names = [];
46
+ for (const obj of objects ?? []) {
47
+ const parts = (obj?.List?.items ?? []).map((p) => p?.String?.sval).filter(Boolean);
48
+ if (parts.length) names.push(parts[parts.length - 1]);
49
+ }
50
+ return names;
51
+ }
52
+
53
+ /**
54
+ * Whether a WHERE clause filters nothing — `WHERE 1=1`, `WHERE true`.
55
+ *
56
+ * Only the forms people actually write are recognised. A predicate this cannot
57
+ * evaluate counts as a real filter, which is the safe direction: `WHERE 1=2`
58
+ * deletes nothing and flagging it would be a false positive.
59
+ */
60
+ function filtersNothing(node) {
61
+ if (!node) return false;
62
+ const [kind, body] = unwrap(node);
63
+
64
+ if (kind === "A_Const") {
65
+ return body?.boolval?.boolval === true;
66
+ }
67
+
68
+ if (kind === "A_Expr") {
69
+ const op = body?.name?.[0]?.String?.sval;
70
+ if (op !== "=") return false;
71
+ const [lk, lv] = unwrap(body.lexpr);
72
+ const [rk, rv] = unwrap(body.rexpr);
73
+ if (lk !== "A_Const" || rk !== "A_Const") return false;
74
+ for (const field of ["ival", "sval", "fval", "boolval"]) {
75
+ if (lv?.[field] !== undefined && rv?.[field] !== undefined) {
76
+ return JSON.stringify(lv[field]) === JSON.stringify(rv[field]);
77
+ }
78
+ }
79
+ return false;
80
+ }
81
+
82
+ if (kind === "BoolExpr" && body?.boolop === "AND_EXPR") {
83
+ const args = body.args ?? [];
84
+ return args.length > 0 && args.every(filtersNothing);
85
+ }
86
+
87
+ return false;
88
+ }
89
+
90
+ // --------------------------------------------------------------------------- //
91
+ // The rules
92
+ // --------------------------------------------------------------------------- //
93
+
94
+ const DROP_KINDS = {
95
+ OBJECT_TABLE: ["drop-table", SEVERITY.BLOCK, "DROP TABLE destroys a table and every row in it."],
96
+ OBJECT_SCHEMA: ["drop-database", SEVERITY.BLOCK, "DROP SCHEMA destroys every object in the schema."],
97
+ OBJECT_SEQUENCE: ["drop-sequence", SEVERITY.BLOCK, "DROP SEQUENCE destroys a sequence; columns defaulting to it break."],
98
+ OBJECT_MATVIEW: ["drop-matview", SEVERITY.BLOCK, "DROP MATERIALIZED VIEW destroys the view and its stored rows."],
99
+ OBJECT_INDEX: ["drop-index", SEVERITY.WARN, "DROP INDEX loses no rows, but queries relying on it can slow by orders of magnitude, and rebuilding it locks writes."],
100
+ OBJECT_VIEW: ["drop-view", SEVERITY.WARN, "DROP VIEW loses no rows, but anything selecting from it breaks."],
101
+ };
102
+
103
+ const ALTER_KINDS = {
104
+ AT_DropColumn: ["drop-column", SEVERITY.BLOCK, "ALTER TABLE … DROP COLUMN destroys a column and all the data in it."],
105
+ AT_DropConstraint: ["drop-constraint", SEVERITY.WARN, "Dropping a constraint removes a guarantee the data currently satisfies; invalid rows can appear afterwards and are hard to find later."],
106
+ AT_AlterColumnType: ["column-type-rewrite", SEVERITY.WARN, "Changing a column's type rewrites every row under an ACCESS EXCLUSIVE lock, which blocks reads as well as writes for the duration."],
107
+ };
108
+
109
+ /** Rules for one statement: [rule, severity, reason, table] tuples. */
110
+ function findingsFor(stmtNode) {
111
+ const [kind, s] = unwrap(stmtNode);
112
+ const out = [];
113
+
114
+ if (kind === "DropStmt") {
115
+ const entry = DROP_KINDS[s.removeType];
116
+ if (entry) {
117
+ const names = objectNames(s.objects);
118
+ // One finding per object: `DROP TABLE a, b` risks two tables, and a
119
+ // single finding could only name one of them.
120
+ for (const name of names.length ? names : [null]) out.push([...entry, bare(name)]);
121
+ }
122
+ } else if (kind === "DropdbStmt") {
123
+ out.push(["drop-database", SEVERITY.BLOCK, "DROP DATABASE destroys an entire database.", null]);
124
+ } else if (kind === "DropOwnedStmt") {
125
+ out.push(["drop-owned", SEVERITY.BLOCK, "DROP OWNED BY destroys every object the named role owns — often the whole application schema.", null]);
126
+ } else if (kind === "TruncateStmt") {
127
+ for (const rel of s.relations ?? []) {
128
+ out.push(["truncate", SEVERITY.BLOCK, "TRUNCATE empties a table irreversibly and cannot be rolled back cheaply.", bare(rel?.RangeVar?.relname)]);
129
+ }
130
+ } else if (kind === "DeleteStmt") {
131
+ const table = bare(s.relation?.relname);
132
+ if (!s.whereClause) {
133
+ out.push(["delete-without-where", SEVERITY.BLOCK, "DELETE without a WHERE clause removes every row in the table.", table]);
134
+ } else if (filtersNothing(s.whereClause)) {
135
+ out.push(["delete-without-where", SEVERITY.BLOCK, "This DELETE has a WHERE clause that filters nothing, so it removes every row in the table.", table]);
136
+ }
137
+ } else if (kind === "UpdateStmt") {
138
+ const table = bare(s.relation?.relname);
139
+ if (!s.whereClause || filtersNothing(s.whereClause)) {
140
+ out.push(["update-without-where", SEVERITY.BLOCK, "This UPDATE has no effective WHERE clause, so it overwrites the named columns in every row. The previous values are gone.", table]);
141
+ }
142
+ } else if (kind === "AlterTableStmt") {
143
+ const table = bare(s.relation?.relname);
144
+ for (const cmd of s.cmds ?? []) {
145
+ const entry = ALTER_KINDS[cmd?.AlterTableCmd?.subtype];
146
+ if (entry) out.push([...entry, table]);
147
+ }
148
+ } else if (kind === "VacuumStmt") {
149
+ const full = (s.options ?? []).some((o) => o?.DefElem?.defname === "full");
150
+ if (full) {
151
+ for (const rel of s.rels ?? []) {
152
+ out.push(["vacuum-full", SEVERITY.WARN, "VACUUM FULL rewrites the whole table under an ACCESS EXCLUSIVE lock and needs room for a second copy on disk.", bare(rel?.VacuumRelation?.relation?.relname)]);
153
+ }
154
+ }
155
+ } else if (kind === "CreatedbStmt") {
156
+ out.push(["shadow-database", SEVERITY.WARN, "CREATE DATABASE is how a Prisma shadow-database step begins. Flagged because `prisma migrate diff --shadow-database-url` pointed at a real server is the documented way production data has been lost.", null]);
157
+ } else if (kind === "DoStmt") {
158
+ out.push(["opaque-block", SEVERITY.WARN, "A DO block runs procedural code whose statements are built at runtime, so what it will execute cannot be read from the SQL. Nothing inside it has been checked.", null]);
159
+ }
160
+
161
+ return out;
162
+ }
163
+
164
+ // --------------------------------------------------------------------------- //
165
+ // Entry point
166
+ // --------------------------------------------------------------------------- //
167
+
168
+ const collapse = (text) => String(text).replace(/\s+/g, " ").trim();
169
+
170
+ /**
171
+ * Weigh a script on the real parse tree.
172
+ *
173
+ * `rowCounts` maps a bare table name to a live row count; omit it and
174
+ * `estimatedRows` stays null, which reads as "nobody asked the database" rather
175
+ * than "zero".
176
+ *
177
+ * Call `initParser()` once before this.
178
+ */
179
+ export function inspectSql(sql, rowCounts = {}) {
180
+ const counts = Object.fromEntries(
181
+ Object.entries(rowCounts).map(([k, v]) => [k.toLowerCase(), v]),
182
+ );
183
+
184
+ let parsed;
185
+ try {
186
+ parsed = parseSync(sql);
187
+ } catch (error) {
188
+ // Fail closed. Unreadable is not safe.
189
+ return {
190
+ allowed: false,
191
+ findings: [
192
+ {
193
+ rule: "unparseable",
194
+ severity: SEVERITY.BLOCK,
195
+ reason:
196
+ `PostgreSQL could not parse this SQL, so nothing about it has been ` +
197
+ `verified (${error.message}). Refused rather than assumed safe. Note ` +
198
+ `that psql meta-commands such as \\d are not SQL and must be removed ` +
199
+ `before checking.`,
200
+ statement: collapse(sql).slice(0, 200),
201
+ table: null,
202
+ estimatedRows: null,
203
+ },
204
+ ],
205
+ statementsScanned: 0,
206
+ rowCountsSupplied: Object.keys(counts).length > 0,
207
+ };
208
+ }
209
+
210
+ const findings = [];
211
+ let scanned = 0;
212
+
213
+ for (const raw of parsed.stmts ?? []) {
214
+ if (!raw.stmt) continue;
215
+ scanned += 1;
216
+
217
+ // The original text, so a finding quotes what was written rather than a
218
+ // re-rendering of the tree.
219
+ const start = raw.stmt_location ?? 0;
220
+ const text = raw.stmt_len ? sql.slice(start, start + raw.stmt_len) : sql.slice(start);
221
+ const statement = collapse(text);
222
+
223
+ for (const [rule, severity, reason, table] of findingsFor(raw.stmt)) {
224
+ findings.push({
225
+ rule,
226
+ severity,
227
+ reason,
228
+ statement,
229
+ table: table ?? null,
230
+ estimatedRows: table && counts[table.toLowerCase()] !== undefined ? counts[table.toLowerCase()] : null,
231
+ });
232
+ }
233
+ }
234
+
235
+ return {
236
+ allowed: !findings.some((f) => f.severity === SEVERITY.BLOCK),
237
+ findings,
238
+ statementsScanned: scanned,
239
+ rowCountsSupplied: Object.keys(counts).length > 0,
240
+ };
241
+ }
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env node
2
+ // vektor-guard — a gate that refuses destructive SQL before anything runs it.
3
+ //
4
+ // The rules live in `rules.mjs` and are decided on PostgreSQL's own parse tree,
5
+ // so this CLI and the Vektor engine give the same answer to the same SQL. They
6
+ // used to disagree: this file matched patterns against text and passed eight
7
+ // valid destructive statements the engine caught.
8
+ //
9
+ // • DROP TABLE / SCHEMA / DATABASE • DELETE or UPDATE with no real WHERE
10
+ // • TRUNCATE • ALTER TABLE … DROP COLUMN
11
+ // • DROP OWNED BY • a Prisma shadow-database CREATE
12
+ //
13
+ // Usage:
14
+ // cat migration.sql | npx vektor-guard
15
+ // npx vektor-guard --file prisma/migrations/…/migration.sql
16
+ // npx vektor-guard "DELETE FROM users;" # inline
17
+ //
18
+ // Exit codes: 0 clean · 2 blocked · 1 usage error. `--allow-destructive`
19
+ // downgrades blocks to warnings (exit 0) for the rare intentional case.
20
+
21
+ import { initParser, inspectSql, SEVERITY } from "./rules.mjs";
22
+
23
+ export { inspectSql };
24
+
25
+ // --------------------------------------------------------------------------- //
26
+ // CLI
27
+ // --------------------------------------------------------------------------- //
28
+
29
+ function readStdin() {
30
+ return new Promise((resolve) => {
31
+ let data = "";
32
+ if (process.stdin.isTTY) return resolve("");
33
+ process.stdin.setEncoding("utf8");
34
+ process.stdin.on("data", (c) => (data += c));
35
+ process.stdin.on("end", () => resolve(data));
36
+ });
37
+ }
38
+
39
+ async function main() {
40
+ const argv = process.argv.slice(2);
41
+ const allow = argv.includes("--allow-destructive");
42
+ const fileIdx = argv.indexOf("--file");
43
+ let sql = "";
44
+
45
+ if (fileIdx !== -1) {
46
+ const { readFileSync } = await import("node:fs");
47
+ sql = readFileSync(argv[fileIdx + 1], "utf8");
48
+ } else {
49
+ // No --file, so nothing is the path argument. The previous version excluded
50
+ // `argv[fileIdx + 1]` here too — with fileIdx === -1 that is argv[0], which
51
+ // is the inline SQL itself, so `npx vektor-guard "DROP TABLE users;"` (the
52
+ // invocation the README documents) always answered "no SQL given".
53
+ const inline = argv.find((a) => !a.startsWith("--"));
54
+ sql = inline ?? (await readStdin());
55
+ }
56
+
57
+ if (!sql.trim()) {
58
+ process.stderr.write(
59
+ "vektor-guard: no SQL given. Pipe it in, pass --file <path>, or an inline string.\n",
60
+ );
61
+ process.exit(1);
62
+ }
63
+
64
+ await initParser();
65
+ const verdict = inspectSql(sql);
66
+
67
+ if (verdict.findings.length === 0) {
68
+ process.stderr.write("vektor-guard: ✓ no destructive operations found.\n");
69
+ process.exit(0);
70
+ }
71
+
72
+ // Warnings alone do not fail the run. They cover operations that are
73
+ // legitimate often enough that refusing them categorically is how a guard
74
+ // gets switched off — which is the failure mode that matters most.
75
+ const blocked = verdict.findings.filter((f) => f.severity === SEVERITY.BLOCK);
76
+ const label = blocked.length === 0 || allow ? "⚠ WARNING" : "✗ BLOCKED";
77
+
78
+ process.stderr.write(
79
+ `vektor-guard: ${label} — ${verdict.findings.length} finding(s) in ` +
80
+ `${verdict.statementsScanned} statement(s):\n`,
81
+ );
82
+ for (const f of verdict.findings) {
83
+ const preview = f.statement.length > 100 ? f.statement.slice(0, 100) + "…" : f.statement;
84
+ const rows = f.estimatedRows === null ? "" : `\n rows at risk: ${f.estimatedRows.toLocaleString("en-US")}`;
85
+ process.stderr.write(`\n [${f.rule}] ${f.reason}${rows}\n → ${preview}\n`);
86
+ }
87
+ process.stderr.write(
88
+ blocked.length === 0
89
+ ? "\nNothing blocking. Review the warnings above.\n"
90
+ : allow
91
+ ? "\nAllowed by --allow-destructive.\n"
92
+ : "\nRefused. Re-run with --allow-destructive only if this is deliberate.\n",
93
+ );
94
+ process.exit(blocked.length === 0 || allow ? 0 : 2);
95
+ }
96
+
97
+ // Run only as a CLI, not when imported for the exported functions.
98
+ if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith("vektor-guard.mjs")) {
99
+ main();
100
+ }