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 +21 -0
- package/README.md +144 -0
- package/mcp-server.mjs +307 -0
- package/package.json +44 -0
- package/rules.mjs +241 -0
- package/vektor-guard.mjs +100 -0
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
|
+
}
|
package/vektor-guard.mjs
ADDED
|
@@ -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
|
+
}
|