@jrooig/mcpshield 0.1.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 +141 -0
- package/dist/config/rules.d.ts +29 -0
- package/dist/config/rules.js +141 -0
- package/dist/enterprise/apiClient.d.ts +16 -0
- package/dist/enterprise/apiClient.js +121 -0
- package/dist/enterprise/auth.d.ts +12 -0
- package/dist/enterprise/auth.js +230 -0
- package/dist/enterprise/ruleSync.d.ts +16 -0
- package/dist/enterprise/ruleSync.js +81 -0
- package/dist/enterprise/telemetry.d.ts +36 -0
- package/dist/enterprise/telemetry.js +136 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +622 -0
- package/dist/install.d.ts +21 -0
- package/dist/install.js +361 -0
- package/dist/security/firewall.d.ts +63 -0
- package/dist/security/firewall.js +202 -0
- package/dist/security/sanitizer.d.ts +31 -0
- package/dist/security/sanitizer.js +130 -0
- package/dist/server/dashboardServer.d.ts +38 -0
- package/dist/server/dashboardServer.js +92 -0
- package/dist/server/wsHandler.d.ts +94 -0
- package/dist/server/wsHandler.js +194 -0
- package/dist/types/mcp.d.ts +123 -0
- package/dist/types/mcp.js +97 -0
- package/package.json +51 -0
package/dist/install.js
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mcp-shield install` / `mcp-shield uninstall` — zero-friction (un)wrapping
|
|
3
|
+
* of MCP client configs.
|
|
4
|
+
*
|
|
5
|
+
* With an explicit app id (`install cursor`) only that client is touched and
|
|
6
|
+
* any problem is fatal. With no argument the known clients are probed and
|
|
7
|
+
* every config found is processed; per-client problems are reported but do
|
|
8
|
+
* not abort the sweep, so one corrupt config can't block shielding the rest.
|
|
9
|
+
*/
|
|
10
|
+
import { readFile, realpath, rename, rm, writeFile } from 'node:fs/promises';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import process from 'node:process';
|
|
14
|
+
function fail(message) {
|
|
15
|
+
process.stderr.write(`[mcp-shield] ${message}\n`);
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
/** Green when a human is watching; plain when piped. Awaits the write
|
|
19
|
+
* callback so a process.exit right after cannot truncate the feedback. */
|
|
20
|
+
async function writeFinal(message) {
|
|
21
|
+
const full = `[mcp-shield] ${message}`;
|
|
22
|
+
const line = process.stderr.isTTY ? `\x1b[32m${full}\x1b[0m\n` : `${full}\n`;
|
|
23
|
+
await new Promise((resolve) => {
|
|
24
|
+
process.stderr.write(line, () => resolve());
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
function appData() {
|
|
28
|
+
return process.env.APPDATA || path.join(homedir(), 'AppData', 'Roaming');
|
|
29
|
+
}
|
|
30
|
+
const CLIENTS = [
|
|
31
|
+
{
|
|
32
|
+
id: 'claude',
|
|
33
|
+
name: 'Claude Desktop',
|
|
34
|
+
serversKey: 'mcpServers',
|
|
35
|
+
autoDetect: true,
|
|
36
|
+
configPath: () => {
|
|
37
|
+
if (process.platform === 'win32') {
|
|
38
|
+
return path.join(appData(), 'Claude', 'claude_desktop_config.json');
|
|
39
|
+
}
|
|
40
|
+
if (process.platform === 'darwin') {
|
|
41
|
+
return path.join(homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
|
|
42
|
+
}
|
|
43
|
+
return null; // No official Linux build of Claude Desktop.
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: 'cursor',
|
|
48
|
+
name: 'Cursor',
|
|
49
|
+
serversKey: 'mcpServers',
|
|
50
|
+
autoDetect: true,
|
|
51
|
+
// Cursor's native MCP config lives in ~/.cursor on every platform. (The
|
|
52
|
+
// globalStorage/rooveterinaryinc.roo-cline path this used to target
|
|
53
|
+
// belongs to the Roo Code extension, not to Cursor itself.)
|
|
54
|
+
configPath: () => path.join(homedir(), '.cursor', 'mcp.json'),
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: 'vscode',
|
|
58
|
+
name: 'VS Code',
|
|
59
|
+
serversKey: 'servers',
|
|
60
|
+
autoDetect: true,
|
|
61
|
+
configPath: () => {
|
|
62
|
+
if (process.platform === 'win32') {
|
|
63
|
+
return path.join(appData(), 'Code', 'User', 'mcp.json');
|
|
64
|
+
}
|
|
65
|
+
if (process.platform === 'darwin') {
|
|
66
|
+
return path.join(homedir(), 'Library', 'Application Support', 'Code', 'User', 'mcp.json');
|
|
67
|
+
}
|
|
68
|
+
return path.join(homedir(), '.config', 'Code', 'User', 'mcp.json');
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: 'windsurf',
|
|
73
|
+
name: 'Windsurf',
|
|
74
|
+
serversKey: 'mcpServers',
|
|
75
|
+
autoDetect: true,
|
|
76
|
+
configPath: () => path.join(homedir(), '.codeium', 'windsurf', 'mcp_config.json'),
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: 'claude-code',
|
|
80
|
+
name: 'Claude Code (project .mcp.json)',
|
|
81
|
+
serversKey: 'mcpServers',
|
|
82
|
+
autoDetect: false,
|
|
83
|
+
configPath: () => path.join(process.cwd(), '.mcp.json'),
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
const SUPPORTED_IDS = CLIENTS.map((c) => c.id).join(', ');
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Shield invocation — how a client can re-launch THIS mcp-shield
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
/**
|
|
91
|
+
* Command (+ leading args) that re-invokes the currently running mcp-shield
|
|
92
|
+
* using absolute paths only. A bare "mcp-shield" would be resolved against
|
|
93
|
+
* the CLIENT's PATH at spawn time, which breaks under every mainstream
|
|
94
|
+
* install channel: `npx @jrooig/mcpshield install` leaves nothing on PATH once it
|
|
95
|
+
* exits, GUI-launched clients on macOS inherit launchd's minimal PATH, and
|
|
96
|
+
* Windows clients can't exec the npm .cmd shim without a shell. Also used
|
|
97
|
+
* by `mcp-shield register`, which writes the same shape.
|
|
98
|
+
*/
|
|
99
|
+
export function shieldInvocation() {
|
|
100
|
+
// pkg standalone binary: the executable itself is the shield.
|
|
101
|
+
if (process.pkg !== undefined) {
|
|
102
|
+
return [process.execPath];
|
|
103
|
+
}
|
|
104
|
+
// Plain node (npx, npm -g shim, local checkout): the same node binary
|
|
105
|
+
// running the same entry script.
|
|
106
|
+
return [process.execPath, path.resolve(process.argv[1] ?? '')];
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Does this command/arg string invoke mcp-shield? Deliberately exact — a
|
|
110
|
+
* loose substring test would make `uninstall` mangle an unrelated server
|
|
111
|
+
* whose command merely contains "mcp-shield" (e.g. "mcp-shield-audit").
|
|
112
|
+
* Matches: the bare/legacy name, our pkg binary names, and any path that
|
|
113
|
+
* traverses an "mcp-shield" directory (npm package dir, this repo).
|
|
114
|
+
*/
|
|
115
|
+
const SHIELD_BASENAMES = new Set(['mcp-shield', 'mcp-shield-linux', 'mcp-shield-macos', 'mcp-shield-win']);
|
|
116
|
+
function invokesShield(value) {
|
|
117
|
+
if (typeof value !== 'string' || value === '') {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
const segments = value.split(/[\\/]+/);
|
|
121
|
+
const last = segments[segments.length - 1] ?? '';
|
|
122
|
+
const basename = last.toLowerCase().replace(/\.(exe|cmd|ps1)$/, '');
|
|
123
|
+
return SHIELD_BASENAMES.has(basename) || segments.includes('mcp-shield');
|
|
124
|
+
}
|
|
125
|
+
function isShieldedEntry(serverConfig) {
|
|
126
|
+
if (invokesShield(serverConfig.command)) {
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
// node <abs .../mcp-shield/dist/index.js> -- <original...>
|
|
130
|
+
return Array.isArray(serverConfig.args) && invokesShield(serverConfig.args[0]);
|
|
131
|
+
}
|
|
132
|
+
/** Wraps every wrappable server in place; returns how many changed. */
|
|
133
|
+
function wrapServers(servers) {
|
|
134
|
+
const invocation = shieldInvocation();
|
|
135
|
+
let modified = 0;
|
|
136
|
+
for (const serverConfig of Object.values(servers)) {
|
|
137
|
+
if (!serverConfig || typeof serverConfig !== 'object')
|
|
138
|
+
continue;
|
|
139
|
+
if (isShieldedEntry(serverConfig))
|
|
140
|
+
continue;
|
|
141
|
+
// Entries without a string command are remote (http/sse) servers — most
|
|
142
|
+
// common in VS Code's mcp.json — and carry no child process to proxy.
|
|
143
|
+
if (typeof serverConfig.command !== 'string')
|
|
144
|
+
continue;
|
|
145
|
+
// A present-but-non-array args is malformed; wrapping would silently
|
|
146
|
+
// discard the value with no way for uninstall to restore it. Leave the
|
|
147
|
+
// entry for the user to fix.
|
|
148
|
+
if (serverConfig.args !== undefined && !Array.isArray(serverConfig.args))
|
|
149
|
+
continue;
|
|
150
|
+
const originalCommand = serverConfig.command;
|
|
151
|
+
const originalArgs = serverConfig.args ?? [];
|
|
152
|
+
serverConfig.command = invocation[0];
|
|
153
|
+
serverConfig.args = [...invocation.slice(1), '--', originalCommand, ...originalArgs];
|
|
154
|
+
modified += 1;
|
|
155
|
+
}
|
|
156
|
+
return modified;
|
|
157
|
+
}
|
|
158
|
+
/** Restores every shielded server in place; returns how many changed. */
|
|
159
|
+
function unwrapServers(servers) {
|
|
160
|
+
let modified = 0;
|
|
161
|
+
for (const serverConfig of Object.values(servers)) {
|
|
162
|
+
if (!serverConfig || typeof serverConfig !== 'object')
|
|
163
|
+
continue;
|
|
164
|
+
if (!isShieldedEntry(serverConfig))
|
|
165
|
+
continue;
|
|
166
|
+
const args = Array.isArray(serverConfig.args) ? serverConfig.args : [];
|
|
167
|
+
const sepIndex = args.indexOf('--');
|
|
168
|
+
if (sepIndex === -1 || args.length <= sepIndex + 1)
|
|
169
|
+
continue;
|
|
170
|
+
serverConfig.command = args[sepIndex + 1];
|
|
171
|
+
serverConfig.args = args.slice(sepIndex + 2);
|
|
172
|
+
if (serverConfig.args.length === 0) {
|
|
173
|
+
delete serverConfig.args;
|
|
174
|
+
}
|
|
175
|
+
modified += 1;
|
|
176
|
+
}
|
|
177
|
+
return modified;
|
|
178
|
+
}
|
|
179
|
+
/** Sibling-temp-file + rename: atomic on POSIX, and realpath first so an
|
|
180
|
+
* intentionally symlinked config keeps pointing at its real target. */
|
|
181
|
+
async function writeConfigAtomic(configPath, config) {
|
|
182
|
+
let destPath = configPath;
|
|
183
|
+
try {
|
|
184
|
+
destPath = await realpath(configPath);
|
|
185
|
+
}
|
|
186
|
+
catch { }
|
|
187
|
+
const tmpPath = `${destPath}.${process.pid}.tmp`;
|
|
188
|
+
try {
|
|
189
|
+
await writeFile(tmpPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
|
|
190
|
+
await rename(tmpPath, destPath);
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
await rm(tmpPath, { force: true }).catch(() => undefined);
|
|
194
|
+
throw new Error(`cannot write ${destPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
async function applyToClient(client, mode) {
|
|
198
|
+
const configPath = client.configPath();
|
|
199
|
+
if (configPath === null) {
|
|
200
|
+
return { kind: 'unsupported' };
|
|
201
|
+
}
|
|
202
|
+
let raw;
|
|
203
|
+
try {
|
|
204
|
+
raw = await readFile(configPath, 'utf8');
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
if (err.code === 'ENOENT') {
|
|
208
|
+
return { kind: 'not-found', configPath };
|
|
209
|
+
}
|
|
210
|
+
return { kind: 'error', configPath, reason: `cannot read ${configPath}: ${err instanceof Error ? err.message : String(err)}` };
|
|
211
|
+
}
|
|
212
|
+
let parsed;
|
|
213
|
+
try {
|
|
214
|
+
parsed = JSON.parse(raw);
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return { kind: 'error', configPath, reason: `${configPath} is not valid JSON. Please fix it manually first.` };
|
|
218
|
+
}
|
|
219
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
220
|
+
return { kind: 'error', configPath, reason: `${configPath} does not contain a JSON object at the top level` };
|
|
221
|
+
}
|
|
222
|
+
const config = parsed;
|
|
223
|
+
const existingServers = config[client.serversKey];
|
|
224
|
+
if (!existingServers || typeof existingServers !== 'object' || Array.isArray(existingServers)) {
|
|
225
|
+
return { kind: 'no-servers', configPath };
|
|
226
|
+
}
|
|
227
|
+
const servers = existingServers;
|
|
228
|
+
const modified = mode === 'install' ? wrapServers(servers) : unwrapServers(servers);
|
|
229
|
+
if (modified === 0) {
|
|
230
|
+
return { kind: 'unchanged', configPath };
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
await writeConfigAtomic(configPath, config);
|
|
234
|
+
}
|
|
235
|
+
catch (err) {
|
|
236
|
+
return { kind: 'error', configPath, reason: err instanceof Error ? err.message : String(err) };
|
|
237
|
+
}
|
|
238
|
+
return { kind: 'changed', configPath, count: modified };
|
|
239
|
+
}
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// Explicit mode — `mcp-shield install <app>`: any problem is fatal
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
async function runExplicit(client, mode) {
|
|
244
|
+
const outcome = await applyToClient(client, mode);
|
|
245
|
+
switch (outcome.kind) {
|
|
246
|
+
case 'unsupported':
|
|
247
|
+
fail(`${client.name} is currently only supported on Windows and macOS.`);
|
|
248
|
+
case 'not-found':
|
|
249
|
+
fail(mode === 'install'
|
|
250
|
+
? `Could not find config file at ${outcome.configPath}. Please ensure ${client.id} is installed and has been run at least once.`
|
|
251
|
+
: `Could not find config file at ${outcome.configPath}.`);
|
|
252
|
+
case 'error':
|
|
253
|
+
fail(outcome.reason);
|
|
254
|
+
case 'no-servers':
|
|
255
|
+
fail(mode === 'install'
|
|
256
|
+
? `No "${client.serversKey}" found in ${outcome.configPath} to protect. Add some servers first!`
|
|
257
|
+
: `No "${client.serversKey}" found in ${outcome.configPath}.`);
|
|
258
|
+
case 'unchanged':
|
|
259
|
+
process.stderr.write(mode === 'install'
|
|
260
|
+
? `[mcp-shield] All servers in ${client.id} are already protected!\n`
|
|
261
|
+
: `[mcp-shield] No servers were protected by mcp-shield in ${client.id}.\n`);
|
|
262
|
+
return;
|
|
263
|
+
case 'changed':
|
|
264
|
+
await writeFinal(mode === 'install'
|
|
265
|
+
? `Successfully shielded ${outcome.count} server(s) in ${client.id}. Please restart ${client.id} to apply changes.`
|
|
266
|
+
: `Successfully UNINSTALLED from ${outcome.count} server(s) in ${client.id}.`);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
// Auto-detect mode — `mcp-shield install`: probe every client, keep going
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
async function runAutoDetect(mode) {
|
|
274
|
+
let totalChanged = 0;
|
|
275
|
+
let clientsChanged = 0;
|
|
276
|
+
let detectedOk = 0;
|
|
277
|
+
let errors = 0;
|
|
278
|
+
for (const client of CLIENTS) {
|
|
279
|
+
if (!client.autoDetect) {
|
|
280
|
+
process.stderr.write(`[mcp-shield] - ${client.name}: skipped in auto mode — run "mcp-shield ${mode} ${client.id}" inside a project to opt in\n`);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
const outcome = await applyToClient(client, mode);
|
|
284
|
+
switch (outcome.kind) {
|
|
285
|
+
case 'unsupported':
|
|
286
|
+
case 'not-found':
|
|
287
|
+
process.stderr.write(`[mcp-shield] - ${client.name}: not detected\n`);
|
|
288
|
+
break;
|
|
289
|
+
case 'error':
|
|
290
|
+
errors += 1;
|
|
291
|
+
process.stderr.write(`[mcp-shield] ! ${client.name}: ${outcome.reason}\n`);
|
|
292
|
+
break;
|
|
293
|
+
case 'no-servers':
|
|
294
|
+
detectedOk += 1;
|
|
295
|
+
process.stderr.write(`[mcp-shield] ✓ Detected ${client.name} — no MCP servers configured, nothing to ${mode === 'install' ? 'shield' : 'restore'}\n`);
|
|
296
|
+
break;
|
|
297
|
+
case 'unchanged':
|
|
298
|
+
detectedOk += 1;
|
|
299
|
+
process.stderr.write(mode === 'install'
|
|
300
|
+
? `[mcp-shield] ✓ Detected ${client.name} — all servers already protected\n`
|
|
301
|
+
: `[mcp-shield] ✓ Detected ${client.name} — no shielded servers to restore\n`);
|
|
302
|
+
break;
|
|
303
|
+
case 'changed':
|
|
304
|
+
detectedOk += 1;
|
|
305
|
+
clientsChanged += 1;
|
|
306
|
+
totalChanged += outcome.count;
|
|
307
|
+
process.stderr.write(mode === 'install'
|
|
308
|
+
? `[mcp-shield] ✓ Detected ${client.name} — shielded ${outcome.count} server(s)\n`
|
|
309
|
+
: `[mcp-shield] ✓ Detected ${client.name} — restored ${outcome.count} server(s)\n`);
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (detectedOk === 0 && errors === 0) {
|
|
314
|
+
fail(`No supported MCP clients found on this machine. Supported: ${SUPPORTED_IDS}. You can also target one explicitly: mcp-shield ${mode} <app>.`);
|
|
315
|
+
}
|
|
316
|
+
if (detectedOk === 0) {
|
|
317
|
+
// Every config we found was unusable — that is a failure, not a sweep.
|
|
318
|
+
fail(`No client config could be ${mode === 'install' ? 'shielded' : 'restored'} (${errors} error(s) above).`);
|
|
319
|
+
}
|
|
320
|
+
if (errors > 0) {
|
|
321
|
+
// Partial success must not look like success: a scripted caller
|
|
322
|
+
// (`mcp-shield install && ...`) would otherwise believe the whole
|
|
323
|
+
// machine is protected when a detected client was left unshielded.
|
|
324
|
+
fail(`${mode === 'install' ? 'Shielded' : 'Restored'} ${totalChanged} server(s) across ${clientsChanged} client(s), but ${errors} client config(s) had errors (see above) and were left untouched.`);
|
|
325
|
+
}
|
|
326
|
+
if (totalChanged > 0) {
|
|
327
|
+
await writeFinal(mode === 'install'
|
|
328
|
+
? `Proxy injected into ${totalChanged} server(s) across ${clientsChanged} client(s). Zero-trust policies active — restart the affected apps to apply changes.`
|
|
329
|
+
: `Restored ${totalChanged} server(s) across ${clientsChanged} client(s). Restart the affected apps to apply changes.`);
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
await writeFinal(mode === 'install'
|
|
333
|
+
? 'Nothing new to shield — every detected client is already protected.'
|
|
334
|
+
: 'Nothing to restore — no shielded servers found in the detected clients.');
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
// Entry points
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
function resolveClient(targetApp) {
|
|
341
|
+
const client = CLIENTS.find((c) => c.id === targetApp);
|
|
342
|
+
if (client === undefined) {
|
|
343
|
+
fail(`unknown app "${targetApp}". Supported: ${SUPPORTED_IDS} — or run with no argument to auto-detect all of them.`);
|
|
344
|
+
}
|
|
345
|
+
return client;
|
|
346
|
+
}
|
|
347
|
+
export async function runInstallCommand(targetApp) {
|
|
348
|
+
if (targetApp === undefined || targetApp === '') {
|
|
349
|
+
await runAutoDetect('install');
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
await runExplicit(resolveClient(targetApp), 'install');
|
|
353
|
+
}
|
|
354
|
+
export async function runUninstallCommand(targetApp) {
|
|
355
|
+
if (targetApp === undefined || targetApp === '') {
|
|
356
|
+
await runAutoDetect('uninstall');
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
await runExplicit(resolveClient(targetApp), 'uninstall');
|
|
360
|
+
}
|
|
361
|
+
//# sourceMappingURL=install.js.map
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP-Shield firewall (blueprint Component C — hook (a) in src/index.ts).
|
|
3
|
+
*
|
|
4
|
+
* Two layers:
|
|
5
|
+
*
|
|
6
|
+
* 1. screenClientLine() — wire-level screening of every client → server line:
|
|
7
|
+
* - Deny by default: input that is not valid JSON is answered with a
|
|
8
|
+
* ParseError and never reaches the target server (a laxer parser on the
|
|
9
|
+
* server side might otherwise still act on it).
|
|
10
|
+
* - JSON-RPC batches (arrays) are rejected outright: MCP is strictly
|
|
11
|
+
* one-message-per-line and a batch is only useful here as a vehicle to
|
|
12
|
+
* sneak a tools/call past inspection.
|
|
13
|
+
* - Anything claiming `method: "tools/call"` is held to the strict shape
|
|
14
|
+
* (params.name a non-empty string, a respondable id) or blocked.
|
|
15
|
+
* - Canonical re-serialization: what gets forwarded is OUR JSON.stringify
|
|
16
|
+
* of the object we judged — never the client's raw bytes — so
|
|
17
|
+
* duplicate-key parser differentials like
|
|
18
|
+
* {"command":"safe","command":"malicious"} cannot smuggle a second
|
|
19
|
+
* reading past the firewall.
|
|
20
|
+
*
|
|
21
|
+
* 2. Firewall — the rule engine: matches SecurityRules (allow / block / ask)
|
|
22
|
+
* against tools/call requests via contains / regex / outside_dir
|
|
23
|
+
* conditions on dot-path fields of the call params.
|
|
24
|
+
*/
|
|
25
|
+
import { type SecurityRule } from '../config/rules.js';
|
|
26
|
+
import { type JsonRpcErrorResponse, type JsonRpcMessage, type McpToolCallRequest } from '../types/mcp.js';
|
|
27
|
+
export type FirewallVerdict = {
|
|
28
|
+
action: 'allow';
|
|
29
|
+
} | {
|
|
30
|
+
action: 'block' | 'ask';
|
|
31
|
+
rule: SecurityRule;
|
|
32
|
+
reason: string;
|
|
33
|
+
};
|
|
34
|
+
export declare class Firewall {
|
|
35
|
+
private rules;
|
|
36
|
+
private compiledRegexes;
|
|
37
|
+
constructor(rules?: SecurityRule[]);
|
|
38
|
+
/** Loads from `~/.mcp-shield/rules.cache.json` if available, falls back to default rules. */
|
|
39
|
+
static create(): Promise<Firewall>;
|
|
40
|
+
/**
|
|
41
|
+
* Hot-reloads the active rule set in place. Compiles regexes first so that a
|
|
42
|
+
* bad rule from the cloud leaves the existing policy intact and throws instead
|
|
43
|
+
* of silently disabling protection.
|
|
44
|
+
*/
|
|
45
|
+
reloadRules(rules: SecurityRule[]): void;
|
|
46
|
+
/** First matching rule wins; a call no rule claims is allowed (the rules define the restrictions). */
|
|
47
|
+
evaluateToolCall(request: McpToolCallRequest): FirewallVerdict;
|
|
48
|
+
private compileRules;
|
|
49
|
+
private conditionMatches;
|
|
50
|
+
}
|
|
51
|
+
export type ClientScreenResult = {
|
|
52
|
+
kind: 'forward';
|
|
53
|
+
message: JsonRpcMessage;
|
|
54
|
+
wire: string;
|
|
55
|
+
} | {
|
|
56
|
+
kind: 'respond';
|
|
57
|
+
response: JsonRpcErrorResponse;
|
|
58
|
+
reason: string;
|
|
59
|
+
} | {
|
|
60
|
+
kind: 'drop';
|
|
61
|
+
reason: string;
|
|
62
|
+
};
|
|
63
|
+
export declare function screenClientLine(line: string): ClientScreenResult;
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP-Shield firewall (blueprint Component C — hook (a) in src/index.ts).
|
|
3
|
+
*
|
|
4
|
+
* Two layers:
|
|
5
|
+
*
|
|
6
|
+
* 1. screenClientLine() — wire-level screening of every client → server line:
|
|
7
|
+
* - Deny by default: input that is not valid JSON is answered with a
|
|
8
|
+
* ParseError and never reaches the target server (a laxer parser on the
|
|
9
|
+
* server side might otherwise still act on it).
|
|
10
|
+
* - JSON-RPC batches (arrays) are rejected outright: MCP is strictly
|
|
11
|
+
* one-message-per-line and a batch is only useful here as a vehicle to
|
|
12
|
+
* sneak a tools/call past inspection.
|
|
13
|
+
* - Anything claiming `method: "tools/call"` is held to the strict shape
|
|
14
|
+
* (params.name a non-empty string, a respondable id) or blocked.
|
|
15
|
+
* - Canonical re-serialization: what gets forwarded is OUR JSON.stringify
|
|
16
|
+
* of the object we judged — never the client's raw bytes — so
|
|
17
|
+
* duplicate-key parser differentials like
|
|
18
|
+
* {"command":"safe","command":"malicious"} cannot smuggle a second
|
|
19
|
+
* reading past the firewall.
|
|
20
|
+
*
|
|
21
|
+
* 2. Firewall — the rule engine: matches SecurityRules (allow / block / ask)
|
|
22
|
+
* against tools/call requests via contains / regex / outside_dir
|
|
23
|
+
* conditions on dot-path fields of the call params.
|
|
24
|
+
*/
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { defaultRules, loadCachedRules } from '../config/rules.js';
|
|
27
|
+
import { createAccessDeniedResponse, createErrorResponse, isJsonRpcMessage, isToolCallRequest, JsonRpcErrorCode, McpMethod, } from '../types/mcp.js';
|
|
28
|
+
export class Firewall {
|
|
29
|
+
rules;
|
|
30
|
+
compiledRegexes;
|
|
31
|
+
constructor(rules = defaultRules) {
|
|
32
|
+
this.compiledRegexes = this.compileRules(rules);
|
|
33
|
+
this.rules = rules;
|
|
34
|
+
}
|
|
35
|
+
/** Loads from `~/.mcp-shield/rules.cache.json` if available, falls back to default rules. */
|
|
36
|
+
static async create() {
|
|
37
|
+
const cached = await loadCachedRules();
|
|
38
|
+
return new Firewall(cached ?? defaultRules);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Hot-reloads the active rule set in place. Compiles regexes first so that a
|
|
42
|
+
* bad rule from the cloud leaves the existing policy intact and throws instead
|
|
43
|
+
* of silently disabling protection.
|
|
44
|
+
*/
|
|
45
|
+
reloadRules(rules) {
|
|
46
|
+
const newRegexes = this.compileRules(rules);
|
|
47
|
+
this.compiledRegexes = newRegexes;
|
|
48
|
+
this.rules = rules;
|
|
49
|
+
}
|
|
50
|
+
/** First matching rule wins; a call no rule claims is allowed (the rules define the restrictions). */
|
|
51
|
+
evaluateToolCall(request) {
|
|
52
|
+
for (const rule of this.rules) {
|
|
53
|
+
if (rule.tool !== '*' && rule.tool !== request.params.name) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (rule.condition !== undefined && !this.conditionMatches(rule, rule.condition, request)) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (rule.action === 'allow') {
|
|
60
|
+
return { action: 'allow' };
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
action: rule.action,
|
|
64
|
+
rule,
|
|
65
|
+
reason: rule.condition
|
|
66
|
+
? `${rule.condition.field} ${rule.condition.operator} "${rule.condition.value}"`
|
|
67
|
+
: `tool "${rule.tool}"`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return { action: 'allow' };
|
|
71
|
+
}
|
|
72
|
+
compileRules(rules) {
|
|
73
|
+
const regexes = new Map();
|
|
74
|
+
for (const rule of rules) {
|
|
75
|
+
if (rule.condition?.operator === 'regex') {
|
|
76
|
+
try {
|
|
77
|
+
regexes.set(rule.id, new RegExp(rule.condition.value, 'i'));
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
throw new Error(`invalid regex in security rule "${rule.id}": ${err instanceof Error ? err.message : String(err)}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return regexes;
|
|
85
|
+
}
|
|
86
|
+
conditionMatches(rule, condition, request) {
|
|
87
|
+
const value = getByPath(request.params, condition.field);
|
|
88
|
+
if (value === undefined) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
// Nested objects/arrays are matched against their JSON form, so a payload
|
|
92
|
+
// cannot dodge a text rule by wrapping the string one level deeper.
|
|
93
|
+
const haystack = typeof value === 'string' ? value : JSON.stringify(value);
|
|
94
|
+
switch (condition.operator) {
|
|
95
|
+
case 'contains':
|
|
96
|
+
return haystack.toLowerCase().includes(condition.value.toLowerCase());
|
|
97
|
+
case 'regex':
|
|
98
|
+
return this.compiledRegexes.get(rule.id).test(haystack);
|
|
99
|
+
case 'outside_dir':
|
|
100
|
+
// A non-string path is unjudgeable — fail closed and let the rule fire.
|
|
101
|
+
return typeof value !== 'string' || isOutsideDir(value, condition.value);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function getByPath(root, fieldPath) {
|
|
106
|
+
let current = root;
|
|
107
|
+
for (const key of fieldPath.split('.')) {
|
|
108
|
+
if (typeof current !== 'object' || current === null || Array.isArray(current)) {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
current = current[key];
|
|
112
|
+
}
|
|
113
|
+
return current;
|
|
114
|
+
}
|
|
115
|
+
function isOutsideDir(candidate, root) {
|
|
116
|
+
const resolvedRoot = path.resolve(root);
|
|
117
|
+
const relative = path.relative(resolvedRoot, path.resolve(resolvedRoot, candidate));
|
|
118
|
+
return relative.startsWith('..') || path.isAbsolute(relative);
|
|
119
|
+
}
|
|
120
|
+
export function screenClientLine(line) {
|
|
121
|
+
let parsed;
|
|
122
|
+
try {
|
|
123
|
+
parsed = JSON.parse(line);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// Deny by default: bytes we cannot parse are bytes we cannot judge.
|
|
127
|
+
return {
|
|
128
|
+
kind: 'respond',
|
|
129
|
+
reason: 'non-JSON input (deny-by-default)',
|
|
130
|
+
response: createErrorResponse(null, JsonRpcErrorCode.ParseError, 'Parse error: MCP-Shield forwards valid JSON-RPC 2.0 only'),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
if (Array.isArray(parsed)) {
|
|
134
|
+
return {
|
|
135
|
+
kind: 'respond',
|
|
136
|
+
reason: `JSON-RPC batch of ${parsed.length} messages rejected`,
|
|
137
|
+
response: createErrorResponse(null, JsonRpcErrorCode.InvalidRequest, 'Invalid Request: batch requests are not permitted by MCP-Shield'),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
if (!isJsonRpcMessage(parsed)) {
|
|
141
|
+
return {
|
|
142
|
+
kind: 'respond',
|
|
143
|
+
reason: 'not a JSON-RPC 2.0 message',
|
|
144
|
+
response: createErrorResponse(null, JsonRpcErrorCode.InvalidRequest, 'Invalid Request: not a JSON-RPC 2.0 message'),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
// Anything that even LOOKS like the tools/call method — after trimming and
|
|
148
|
+
// case-folding — is treated as a tool invocation. A lenient server that
|
|
149
|
+
// route-matches "Tools/Call" or "tools/call " (trailing space) would still
|
|
150
|
+
// dispatch it, so we must not let the disguise skip the firewall: the
|
|
151
|
+
// padded/case variant is held to the exact strict shape or rejected. This
|
|
152
|
+
// covers id-less "notification" variants too (they execute unanswerably).
|
|
153
|
+
const method = 'method' in parsed ? parsed.method : undefined;
|
|
154
|
+
const claimsToolsCall = typeof method === 'string' && method.trim().toLowerCase() === McpMethod.ToolsCall;
|
|
155
|
+
if (claimsToolsCall && !isStrictToolCall(parsed)) {
|
|
156
|
+
const id = extractRespondableId(parsed);
|
|
157
|
+
if (id === undefined) {
|
|
158
|
+
return { kind: 'drop', reason: 'malformed tools/call without an id (would execute unanswerably)' };
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
kind: 'respond',
|
|
162
|
+
reason: 'malformed tools/call (disguised method, or params.name missing/empty/padded/not a string, or bad arguments/id)',
|
|
163
|
+
response: createAccessDeniedResponse(id, 'malformed-tool-call'),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
// Canonical re-serialization: the server receives OUR serialization of the
|
|
167
|
+
// object the firewall judged — never the client's raw bytes.
|
|
168
|
+
return { kind: 'forward', message: parsed, wire: JSON.stringify(parsed) };
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* The only tools/call shape allowed through: the exact method (no whitespace
|
|
172
|
+
* or case disguise), a routable id, a tool name with no surrounding whitespace
|
|
173
|
+
* (a padded name would dodge the rule engine's exact tool match yet still
|
|
174
|
+
* dispatch on a trimming server), and — if present — an `arguments` object
|
|
175
|
+
* that is a real object, so a positional-array `arguments` cannot make the
|
|
176
|
+
* rules' `arguments.command` field unresolvable and slip through as allowed.
|
|
177
|
+
*/
|
|
178
|
+
function isStrictToolCall(value) {
|
|
179
|
+
if (!isToolCallRequest(value) ||
|
|
180
|
+
value.method !== McpMethod.ToolsCall ||
|
|
181
|
+
!(typeof value.id === 'string' || typeof value.id === 'number') ||
|
|
182
|
+
value.params.name.trim() === '' ||
|
|
183
|
+
value.params.name !== value.params.name.trim()) {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
const args = value.params['arguments'];
|
|
187
|
+
return args === undefined || (typeof args === 'object' && args !== null && !Array.isArray(args));
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* The id to address a rejection to: the request's own id when it is a legal
|
|
191
|
+
* one, null when an id field exists but is unusable (JSON-RPC prescribes a
|
|
192
|
+
* null id for errors on undeterminable requests), undefined when there is no
|
|
193
|
+
* id at all — a notification, which cannot be answered.
|
|
194
|
+
*/
|
|
195
|
+
function extractRespondableId(message) {
|
|
196
|
+
if (!('id' in message)) {
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
const { id } = message;
|
|
200
|
+
return typeof id === 'string' || typeof id === 'number' ? id : null;
|
|
201
|
+
}
|
|
202
|
+
//# sourceMappingURL=firewall.js.map
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP-Shield output sanitizer (blueprint Component C — hook (c) in src/index.ts).
|
|
3
|
+
*
|
|
4
|
+
* Tool results and resource contents are EXTERNAL DATA (web pages, files,
|
|
5
|
+
* API responses) that land directly in the client model's context window. A
|
|
6
|
+
* hostile document can carry instruction-override phrasing that hijacks the
|
|
7
|
+
* agent's goals ("ignore previous instructions", "SYSTEM OVERRIDE: ...").
|
|
8
|
+
* The sanitizer scans every text surface of a server → client result and
|
|
9
|
+
* replaces a detected block with a safe marker before it reaches the client.
|
|
10
|
+
*/
|
|
11
|
+
import type { JsonRpcMessage } from '../types/mcp.js';
|
|
12
|
+
export declare const NEUTRALIZED_MARKER = "[PROMPT INJECTION DETECTED AND NEUTRALIZED BY MCP-SHIELD]";
|
|
13
|
+
/** Labels of every injection pattern the text triggers; empty means clean. */
|
|
14
|
+
export declare function scanText(text: string): string[];
|
|
15
|
+
export interface SanitizationResult {
|
|
16
|
+
message: JsonRpcMessage;
|
|
17
|
+
modified: boolean;
|
|
18
|
+
detections: string[];
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Scan a server → client message and neutralize injected text blocks.
|
|
22
|
+
*
|
|
23
|
+
* Covers both content shapes external data travels in:
|
|
24
|
+
* - tools/call results: result.content[] ({type:'text'} blocks and
|
|
25
|
+
* {type:'resource'} embedded resources)
|
|
26
|
+
* - resources/read results: result.contents[] (text resource items)
|
|
27
|
+
*
|
|
28
|
+
* A clean message is returned as the SAME object (`modified: false`), so the
|
|
29
|
+
* caller can tell whether anything was rewritten.
|
|
30
|
+
*/
|
|
31
|
+
export declare function sanitizeServerMessage(message: JsonRpcMessage): SanitizationResult;
|