@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/dist/index.js ADDED
@@ -0,0 +1,622 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * MCP-Shield — entry point.
4
+ *
5
+ * Transparent security proxy between an MCP client (e.g. Claude Code) and a
6
+ * target MCP server. The client speaks line-delimited JSON-RPC 2.0 over stdio;
7
+ * MCP-Shield launches the real server as a child process and sits inline,
8
+ * relaying every packet through its security hooks:
9
+ *
10
+ * client (process.stdin) ──▶ [firewall + approval hooks] ──▶ target.stdin
11
+ * target.stdout ──▶ [sanitizer hook] ──▶ client (process.stdout)
12
+ *
13
+ * process.stdout carries protocol traffic ONLY — every diagnostic goes to
14
+ * stderr, otherwise the client would try to parse it as JSON-RPC.
15
+ */
16
+ import { spawn } from 'node:child_process';
17
+ import { readFile, realpath, rename, rm, writeFile } from 'node:fs/promises';
18
+ import { homedir } from 'node:os';
19
+ import path from 'node:path';
20
+ import process from 'node:process';
21
+ import { Firewall, screenClientLine } from './security/firewall.js';
22
+ import { hasSession, runCliLoginFlow } from './enterprise/auth.js';
23
+ import { runInstallCommand, runUninstallCommand, shieldInvocation } from './install.js';
24
+ import { tryStartEnterpriseSync } from './enterprise/ruleSync.js';
25
+ import { tryStartEnterpriseTelemetry } from './enterprise/telemetry.js';
26
+ import { sanitizeServerMessage } from './security/sanitizer.js';
27
+ import { startDashboardServer } from './server/dashboardServer.js';
28
+ import { createAccessDeniedResponse, isJsonRpcMessage, isToolCallRequest, } from './types/mcp.js';
29
+ // ---------------------------------------------------------------------------
30
+ // Diagnostics — stderr only, stdout belongs to the protocol
31
+ // ---------------------------------------------------------------------------
32
+ function log(message) {
33
+ process.stderr.write(`[mcp-shield] ${message}\n`);
34
+ }
35
+ function fail(message) {
36
+ log(message);
37
+ process.exit(1);
38
+ }
39
+ // ---------------------------------------------------------------------------
40
+ // CLI parsing
41
+ // ---------------------------------------------------------------------------
42
+ const USAGE = 'usage: mcp-shield [--port <0-65535>] -- <target-command> [target-args...] (0 = OS-assigned)';
43
+ const DEFAULT_PORT = 3000;
44
+ function parseCliArgs(argv) {
45
+ const sepIndex = argv.indexOf('--');
46
+ if (sepIndex === -1) {
47
+ fail(`missing "--" separator before the target command.\n${USAGE}`);
48
+ }
49
+ const shieldArgs = argv.slice(0, sepIndex);
50
+ const [targetCommand, ...targetArgs] = argv.slice(sepIndex + 1);
51
+ if (targetCommand === undefined) {
52
+ fail(`missing target command after "--".\n${USAGE}`);
53
+ }
54
+ let port = DEFAULT_PORT;
55
+ for (let i = 0; i < shieldArgs.length; i += 1) {
56
+ const arg = shieldArgs[i];
57
+ if (arg === undefined) {
58
+ continue;
59
+ }
60
+ if (arg === '--port' || arg === '-p') {
61
+ i += 1;
62
+ const raw = shieldArgs[i];
63
+ // 0 is valid: it asks the OS for any free port (the dashboard logs the
64
+ // actual one). 1..65535 are the usual explicit ports.
65
+ if (raw === undefined || !/^\d+$/.test(raw) || Number(raw) < 0 || Number(raw) > 65535) {
66
+ fail(`invalid --port value: ${raw ?? '(missing)'}\n${USAGE}`);
67
+ }
68
+ port = Number(raw);
69
+ }
70
+ else {
71
+ fail(`unknown option: ${arg}\n${USAGE}`);
72
+ }
73
+ }
74
+ return { port, targetCommand, targetArgs };
75
+ }
76
+ // ---------------------------------------------------------------------------
77
+ // `mcp-shield register` — write the shield entry into the Claude MCP config
78
+ // ---------------------------------------------------------------------------
79
+ const REGISTER_USAGE = 'usage: mcp-shield register [--local] -- <target-command> [target-args...]';
80
+ /**
81
+ * Registers the proxy in Claude's MCP config so users don't have to hand-edit
82
+ * JSON or fight CLI quoting. Global scope writes ~/.claude.json; --local (or
83
+ * --project) writes .mcp.json in the current directory instead.
84
+ */
85
+ async function runRegisterCommand(args) {
86
+ const sepIndex = args.indexOf('--');
87
+ const flags = sepIndex === -1 ? args : args.slice(0, sepIndex);
88
+ const target = sepIndex === -1 ? [] : args.slice(sepIndex + 1);
89
+ let local = false;
90
+ for (const flag of flags) {
91
+ if (flag === '--local' || flag === '--project') {
92
+ local = true;
93
+ }
94
+ else {
95
+ fail(`unknown option: ${flag}\n${REGISTER_USAGE}`);
96
+ }
97
+ }
98
+ if (target.length === 0) {
99
+ fail(`missing target command after "--".\n${REGISTER_USAGE}`);
100
+ }
101
+ const configPath = local
102
+ ? path.join(process.cwd(), '.mcp.json')
103
+ : path.join(homedir(), '.claude.json');
104
+ // ~/.claude.json holds the user's entire Claude Code state, not just MCP
105
+ // servers — anything unreadable or unparseable must abort untouched rather
106
+ // than get clobbered with a fresh skeleton.
107
+ let raw = null;
108
+ try {
109
+ raw = await readFile(configPath, 'utf8');
110
+ }
111
+ catch (err) {
112
+ if (err.code !== 'ENOENT') {
113
+ fail(`cannot read ${configPath}: ${err instanceof Error ? err.message : String(err)}`);
114
+ }
115
+ }
116
+ let config = { mcpServers: {} };
117
+ if (raw !== null) {
118
+ let parsed;
119
+ try {
120
+ parsed = JSON.parse(raw);
121
+ }
122
+ catch (err) {
123
+ fail(`${configPath} is not valid JSON — fix or remove it, then retry: ${err instanceof Error ? err.message : String(err)}`);
124
+ }
125
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
126
+ fail(`${configPath} does not contain a JSON object at the top level`);
127
+ }
128
+ config = parsed;
129
+ }
130
+ const existingServers = config['mcpServers'];
131
+ const servers = typeof existingServers === 'object' && existingServers !== null && !Array.isArray(existingServers)
132
+ ? existingServers
133
+ : {};
134
+ config['mcpServers'] = servers;
135
+ // Absolute invocation, never a bare "mcp-shield": the client resolves the
136
+ // command against ITS OWN PATH at spawn time (see shieldInvocation).
137
+ const invocation = shieldInvocation();
138
+ servers['shield'] = { command: invocation[0], args: [...invocation.slice(1), '--', ...target] };
139
+ // An in-place writeFile truncates before it streams, so a crash or Ctrl-C
140
+ // mid-write would destroy the very state this command promises to keep
141
+ // intact. Write a sibling temp file and rename it over the target instead —
142
+ // the swap is atomic on POSIX. realpath first, so an intentionally
143
+ // symlinked config keeps working: the rename lands on the link's target
144
+ // rather than replacing the link with a regular file.
145
+ let destPath = configPath;
146
+ try {
147
+ destPath = await realpath(configPath);
148
+ }
149
+ catch {
150
+ // Fresh file (ENOENT): rename straight to the literal path.
151
+ }
152
+ const tmpPath = `${destPath}.${process.pid}.tmp`;
153
+ try {
154
+ await writeFile(tmpPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
155
+ await rename(tmpPath, destPath);
156
+ }
157
+ catch (err) {
158
+ await rm(tmpPath, { force: true }).catch(() => undefined);
159
+ fail(`cannot write ${destPath}: ${err instanceof Error ? err.message : String(err)}`);
160
+ }
161
+ const message = `[mcp-shield] Successfully registered "shield" server in ${configPath}. Please restart Claude Code to apply changes.`;
162
+ const line = process.stderr.isTTY ? `\x1b[32m${message}\x1b[0m\n` : `${message}\n`;
163
+ // Wait for the write callback: stderr on a pipe is async, and process.exit
164
+ // right after would truncate the one line of feedback this command produces.
165
+ await new Promise((resolve) => {
166
+ process.stderr.write(line, () => resolve());
167
+ });
168
+ }
169
+ // ---------------------------------------------------------------------------
170
+ // Command dispatch — before any proxy subsystem initializes, so a proxy-only
171
+ // startup failure (e.g. a poisoned rules cache making Firewall.create throw)
172
+ // can never take `login` or `register` down with it
173
+ // ---------------------------------------------------------------------------
174
+ const rawArgs = process.argv.slice(2);
175
+ async function main() {
176
+ // ── `mcp-shield login` ──────────────────────────────────────────────────────
177
+ // Dedicated command: runs the SSO loopback flow, writes ~/.mcp-shield/session.json
178
+ // and exits. No MCP target server is spawned.
179
+ if (rawArgs[0] === 'login') {
180
+ try {
181
+ await runCliLoginFlow();
182
+ }
183
+ catch (err) {
184
+ fail(`Login failed: ${err instanceof Error ? err.message : String(err)}`);
185
+ }
186
+ process.exit(0);
187
+ }
188
+ // ── `mcp-shield register` ───────────────────────────────────────────────────
189
+ // Dedicated command: writes the "shield" proxy entry into the Claude MCP
190
+ // config (global ~/.claude.json, or ./.mcp.json with --local/--project) and
191
+ // exits. No MCP target server is spawned.
192
+ if (rawArgs[0] === 'register') {
193
+ await runRegisterCommand(rawArgs.slice(1));
194
+ process.exit(0);
195
+ }
196
+ // ── `mcp-shield install` ────────────────────────────────────────────────────
197
+ // Dedicated command: wraps the servers of a known MCP client (claude, cursor,
198
+ // vscode, windsurf, claude-code) behind the shield. With no argument it
199
+ // auto-detects every client installed on this machine and shields them all.
200
+ if (rawArgs[0] === 'install') {
201
+ await runInstallCommand(rawArgs[1]);
202
+ process.exit(0);
203
+ }
204
+ // ── `mcp-shield uninstall` ──────────────────────────────────────────────────
205
+ // Dedicated command: reverts the auto-configuration (same auto-detect sweep)
206
+ if (rawArgs[0] === 'uninstall') {
207
+ await runUninstallCommand(rawArgs[1]);
208
+ process.exit(0);
209
+ }
210
+ // ---------------------------------------------------------------------------
211
+ // Line-delimited JSON-RPC framing
212
+ // ---------------------------------------------------------------------------
213
+ /**
214
+ * Accumulates raw stdio chunks and yields complete lines. MCP stdio framing
215
+ * is one JSON-RPC message per '\n'-terminated line, but a chunk boundary can
216
+ * fall anywhere — even mid-message — so the tail is kept until its newline
217
+ * arrives.
218
+ */
219
+ class LineBuffer {
220
+ chunks = [];
221
+ *feed(chunk) {
222
+ // A chunk without a newline just extends the pending line: O(chunk) work,
223
+ // never re-scanning or re-copying what is already buffered. Join + split
224
+ // happen once per newline, so a multi-megabyte single-line message (e.g.
225
+ // a base64 image in a tools/call result) stays linear instead of
226
+ // quadratic — string += concatenation here measured ~20x slower at 48 MiB.
227
+ if (!chunk.includes('\n')) {
228
+ if (chunk !== '') {
229
+ this.chunks.push(chunk);
230
+ }
231
+ return;
232
+ }
233
+ this.chunks.push(chunk);
234
+ const parts = this.chunks.join('').split('\n');
235
+ this.chunks.length = 0;
236
+ const tail = parts.pop();
237
+ if (tail !== undefined && tail !== '') {
238
+ this.chunks.push(tail);
239
+ }
240
+ for (const part of parts) {
241
+ const line = part.replace(/\r$/, '');
242
+ if (line.trim() !== '') {
243
+ yield line;
244
+ }
245
+ }
246
+ }
247
+ /** Whatever remains once the stream ends: a final line missing its newline. */
248
+ flush() {
249
+ const rest = this.chunks.join('').trim();
250
+ this.chunks.length = 0;
251
+ return rest === '' ? null : rest;
252
+ }
253
+ }
254
+ /**
255
+ * Serializes async message handling per direction: a held or slow message
256
+ * must not let a later one overtake it, or JSON-RPC ordering breaks. This is
257
+ * also what will make Phase 4 work: while an 'ask' verdict keeps one message
258
+ * parked awaiting dashboard approval, everything behind it waits in order.
259
+ */
260
+ function createSequentialQueue(label) {
261
+ let tail = Promise.resolve();
262
+ return (task) => {
263
+ tail = tail.then(task).catch((err) => {
264
+ log(`${label} pipeline error: ${err instanceof Error ? err.message : String(err)}`);
265
+ });
266
+ };
267
+ }
268
+ // ---------------------------------------------------------------------------
269
+ // Security hooks — firewall, sanitizer, and manual approval (Phases 3 & 4)
270
+ // ---------------------------------------------------------------------------
271
+ const firewall = await Firewall.create();
272
+ // Set once the dashboard is up. null means no dashboard (startup failed), in
273
+ // which case an 'ask' verdict falls back to failing closed.
274
+ let dashboard = null;
275
+ // null when no Enterprise session is active — telemetry calls are no-ops.
276
+ let telemetry = null;
277
+ /**
278
+ * HOOK (a) — firewall: every structurally valid tools/call is judged against
279
+ * the SecurityRules (src/config/rules.ts) by the engine in
280
+ * src/security/firewall.ts.
281
+ *
282
+ * HOOK (b) — manual approval (src/server/wsHandler.ts): an 'ask' verdict parks
283
+ * this promise, emits 'pending_approval' over Socket.io, and waits for the
284
+ * operator's verdict from the dashboard:
285
+ * approve → { action: 'forward' }
286
+ * deny → { action: 'block' } (also on timeout / dashboard gone)
287
+ * modify → { action: 'forward-modified' } with operator-edited arguments
288
+ * The sequential client queue guarantees messages behind a parked one wait
289
+ * their turn. With no dashboard, 'ask' FAILS CLOSED (denied).
290
+ */
291
+ async function authorizeToolCall(request) {
292
+ const verdict = firewall.evaluateToolCall(request);
293
+ if (verdict.action === 'allow') {
294
+ return { action: 'forward' };
295
+ }
296
+ if (verdict.action === 'block') {
297
+ return { action: 'block', ruleName: verdict.rule.name };
298
+ }
299
+ // ask: hold for manual approval.
300
+ if (dashboard === null) {
301
+ log(`ask: "${request.params.name}" (rule: ${verdict.rule.name}) — no dashboard, failing closed`);
302
+ return { action: 'block', ruleName: verdict.rule.name };
303
+ }
304
+ log(`ask: "${request.params.name}" (rule: ${verdict.rule.name}) — awaiting dashboard approval`);
305
+ const outcome = await dashboard.queue.enqueue(request, verdict.rule.name, verdict.reason);
306
+ // viaApproval marks a decision that already surfaced on the dashboard as
307
+ // pending_approval → request_resolved. handleClientLine must NOT also emit an
308
+ // 'activity' feed event for it, or the dashboard would show the request twice
309
+ // (a stale pending row plus a duplicate resolved row) and double-count stats.
310
+ switch (outcome.action) {
311
+ case 'approve':
312
+ return { action: 'forward', viaApproval: true };
313
+ case 'modify':
314
+ return {
315
+ action: 'forward-modified',
316
+ message: { ...request, params: { ...request.params, arguments: outcome.arguments } },
317
+ viaApproval: true,
318
+ };
319
+ case 'deny':
320
+ log(`ask: "${request.params.name}" denied (${outcome.reason})`);
321
+ return { action: 'block', ruleName: verdict.rule.name, viaApproval: true };
322
+ }
323
+ }
324
+ // ---------------------------------------------------------------------------
325
+ // Startup
326
+ // ---------------------------------------------------------------------------
327
+ const options = parseCliArgs(rawArgs);
328
+ // Start the dashboard first so the approval queue exists before any traffic
329
+ // flows. A startup failure is non-fatal: the proxy still firewalls and
330
+ // sanitizes, and 'ask' verdicts fall back to failing closed.
331
+ try {
332
+ dashboard = await startDashboardServer(options.port, { log });
333
+ }
334
+ catch (err) {
335
+ log(`dashboard failed to start: ${err instanceof Error ? err.message : String(err)} — 'ask' will fail closed`);
336
+ }
337
+ // Enterprise features are gated on an active session. A missing or invalid
338
+ // session file means the developer is using the free local-first tier — we
339
+ // print a one-time hint and continue without cloud features.
340
+ if (await hasSession()) {
341
+ await tryStartEnterpriseSync((rules) => { firewall.reloadRules(rules); });
342
+ telemetry = await tryStartEnterpriseTelemetry();
343
+ }
344
+ else {
345
+ log('Running in local-first mode. To enable Enterprise security policy syncing and audit logging, run "mcp-shield login".');
346
+ }
347
+ log(`spawning target server: ${options.targetCommand} ${options.targetArgs.join(' ')}`.trimEnd());
348
+ const child = spawn(options.targetCommand, options.targetArgs, {
349
+ // stdin/stdout piped (the protocol channel we intercept); stderr inherited
350
+ // so the target server's own diagnostics stay visible on our stderr.
351
+ stdio: ['pipe', 'pipe', 'inherit'],
352
+ });
353
+ child.on('error', (err) => {
354
+ fail(`failed to spawn target server "${options.targetCommand}": ${err.message}`);
355
+ });
356
+ if (child.stdin === null || child.stdout === null) {
357
+ fail('target server spawned without stdio pipes');
358
+ }
359
+ const targetStdin = child.stdin;
360
+ const targetStdout = child.stdout;
361
+ targetStdin.on('error', (err) => {
362
+ log(`target stdin error: ${err.message}`);
363
+ });
364
+ // ---------------------------------------------------------------------------
365
+ // Shutdown escalation
366
+ // ---------------------------------------------------------------------------
367
+ const KILL_GRACE_MS = 5000;
368
+ let killTimer = null;
369
+ /**
370
+ * After asking the target to stop (signal forwarded or stdin EOF propagated),
371
+ * force-kill it if it lingers: the proxy must never outlive a shutdown
372
+ * request just because the target traps the signal or ignores EOF — that
373
+ * would block the supervising client forever and, once it escalates to
374
+ * SIGKILL on the proxy, orphan the very server the shield is meant to control.
375
+ */
376
+ function armKillTimer() {
377
+ if (killTimer !== null) {
378
+ return;
379
+ }
380
+ killTimer = setTimeout(() => {
381
+ log(`target server still alive ${KILL_GRACE_MS}ms after shutdown request, sending SIGKILL`);
382
+ child.kill('SIGKILL');
383
+ }, KILL_GRACE_MS);
384
+ // Never keeps the proxy alive on its own; kill on an already-dead pid is a no-op.
385
+ killTimer.unref();
386
+ }
387
+ // ---------------------------------------------------------------------------
388
+ // Wire writers
389
+ // ---------------------------------------------------------------------------
390
+ function writeToServer(line) {
391
+ targetStdin.write(`${line}\n`);
392
+ }
393
+ function writeToClient(line) {
394
+ process.stdout.write(`${line}\n`);
395
+ }
396
+ function emitActivity(fields) {
397
+ if (dashboard === null) {
398
+ return;
399
+ }
400
+ // Best-effort telemetry: Socket.io serializes eagerly, so a hostile
401
+ // non-serializable argument (a BigInt slipping through, a circular ref)
402
+ // would throw synchronously here. That must never abort the message's
403
+ // forwarding, so swallow it — the packet still gets proxied.
404
+ try {
405
+ dashboard.broadcast('activity', { ...fields, ts: Date.now() });
406
+ }
407
+ catch (err) {
408
+ log(`activity broadcast failed (ignored): ${err instanceof Error ? err.message : String(err)}`);
409
+ }
410
+ }
411
+ // ---------------------------------------------------------------------------
412
+ // Message handling
413
+ // ---------------------------------------------------------------------------
414
+ function describeMessage(message) {
415
+ if ('method' in message) {
416
+ return 'id' in message
417
+ ? `request ${message.method} (id=${message.id})`
418
+ : `notification ${message.method}`;
419
+ }
420
+ return 'error' in message
421
+ ? `error response (id=${message.id})`
422
+ : `response (id=${message.id})`;
423
+ }
424
+ /** client → server: screen the wire (deny-by-default), authorize tool calls, forward canonically. */
425
+ async function handleClientLine(line) {
426
+ const screened = screenClientLine(line);
427
+ if (screened.kind === 'respond') {
428
+ log(`REJECTED client line: ${screened.reason}`);
429
+ emitActivity({ status: 'blocked', reason: screened.reason, rule: 'wire-screening' });
430
+ writeToClient(JSON.stringify(screened.response));
431
+ return;
432
+ }
433
+ if (screened.kind === 'drop') {
434
+ log(`DROPPED client line: ${screened.reason}`);
435
+ emitActivity({ status: 'blocked', reason: screened.reason, rule: 'wire-screening' });
436
+ return;
437
+ }
438
+ const { message } = screened;
439
+ if (isToolCallRequest(message)) {
440
+ const decision = await authorizeToolCall(message);
441
+ // A viaApproval decision already reached the dashboard as
442
+ // pending_approval → request_resolved, which drives its feed row and
443
+ // stats; emitting activity too would duplicate it. So only immediate
444
+ // (non-approval) decisions produce an activity event.
445
+ if (decision.action === 'block') {
446
+ log(`BLOCKED tools/call "${message.params.name}" (rule: ${decision.ruleName})`);
447
+ telemetry?.record({
448
+ tool: message.params.name,
449
+ arguments: message.params.arguments,
450
+ verdict: 'blocked',
451
+ triggered_rule_name: decision.ruleName,
452
+ sanitized_output_detected: false,
453
+ });
454
+ if (!decision.viaApproval) {
455
+ emitActivity({
456
+ status: 'blocked',
457
+ tool: message.params.name,
458
+ arguments: message.params.arguments,
459
+ rule: decision.ruleName,
460
+ id: message.id,
461
+ });
462
+ }
463
+ writeToClient(JSON.stringify(createAccessDeniedResponse(message.id, decision.ruleName)));
464
+ return;
465
+ }
466
+ if (decision.action === 'forward-modified') {
467
+ log(`MODIFIED tools/call "${message.params.name}" forwarded`);
468
+ telemetry?.record({
469
+ tool: message.params.name,
470
+ arguments: decision.message.params.arguments,
471
+ verdict: 'modified',
472
+ sanitized_output_detected: false,
473
+ });
474
+ // forward-modified only ever comes from the approval flow (viaApproval),
475
+ // so request_resolved already covers it — no activity event here.
476
+ writeToServer(JSON.stringify(decision.message));
477
+ return;
478
+ }
479
+ log(`ALLOWED tools/call "${message.params.name}"`);
480
+ telemetry?.record({
481
+ tool: message.params.name,
482
+ arguments: message.params.arguments,
483
+ verdict: 'allowed',
484
+ sanitized_output_detected: false,
485
+ });
486
+ if (!decision.viaApproval) {
487
+ emitActivity({
488
+ status: 'allowed',
489
+ tool: message.params.name,
490
+ arguments: message.params.arguments,
491
+ id: message.id,
492
+ });
493
+ }
494
+ }
495
+ else {
496
+ log(`client → server: ${describeMessage(message)}`);
497
+ }
498
+ // Always the canonical re-serialization, never the client's raw bytes:
499
+ // duplicate-key parser differentials die here.
500
+ writeToServer(screened.wire);
501
+ }
502
+ /** server → client: parse, sanitize external text (HOOK c), forward canonically. */
503
+ async function handleServerLine(line) {
504
+ let parsed;
505
+ try {
506
+ parsed = JSON.parse(line);
507
+ }
508
+ catch {
509
+ // Server-side noise (a banner, a stray print) — the client's parser will
510
+ // skip it just like ours did. Only client input is deny-by-default.
511
+ log('server → client: forwarding non-JSON line untouched');
512
+ writeToClient(line);
513
+ return;
514
+ }
515
+ if (!isJsonRpcMessage(parsed)) {
516
+ writeToClient(line);
517
+ return;
518
+ }
519
+ const { message, modified, detections } = sanitizeServerMessage(parsed);
520
+ if (modified) {
521
+ log(`NEUTRALIZED prompt injection in server output (${detections.join(', ')})`);
522
+ telemetry?.record({
523
+ tool: 'server-output',
524
+ verdict: 'modified',
525
+ sanitized_output_detected: true,
526
+ });
527
+ }
528
+ // Canonical re-serialization: the client receives exactly the object the
529
+ // sanitizer judged, closing duplicate-key differentials in this direction too.
530
+ writeToClient(JSON.stringify(message));
531
+ }
532
+ // ---------------------------------------------------------------------------
533
+ // stdio pumps
534
+ // ---------------------------------------------------------------------------
535
+ const clientBuffer = new LineBuffer();
536
+ const serverBuffer = new LineBuffer();
537
+ const enqueueClientTask = createSequentialQueue('client → server');
538
+ const enqueueServerTask = createSequentialQueue('server → client');
539
+ process.stdin.setEncoding('utf8');
540
+ process.stdin.on('data', (chunk) => {
541
+ for (const line of clientBuffer.feed(chunk.toString())) {
542
+ enqueueClientTask(() => handleClientLine(line));
543
+ }
544
+ });
545
+ process.stdin.on('end', () => {
546
+ const rest = clientBuffer.flush();
547
+ if (rest !== null) {
548
+ enqueueClientTask(() => handleClientLine(rest));
549
+ }
550
+ // The client hung up: fail closed on anything still parked awaiting a
551
+ // verdict. Otherwise the EOF propagation below — enqueued BEHIND the parked
552
+ // task on the sequential client queue — would wait out the full 120 s
553
+ // approval timeout, so the target never gets EOF and the SIGKILL fallback
554
+ // never arms. Mirrors the dashboard last-client-disconnect backstop.
555
+ dashboard?.queue.denyAll('MCP client disconnected before a verdict was given');
556
+ // Propagate EOF so the target knows the client hung up — after queued work.
557
+ // Per the MCP stdio shutdown convention (close stdin → wait → escalate),
558
+ // arm the SIGKILL fallback in case the target ignores EOF.
559
+ enqueueClientTask(async () => {
560
+ targetStdin.end();
561
+ armKillTimer();
562
+ });
563
+ });
564
+ process.stdin.on('error', (err) => {
565
+ log(`client stdin error: ${err.message}`);
566
+ });
567
+ targetStdout.setEncoding('utf8');
568
+ targetStdout.on('data', (chunk) => {
569
+ for (const line of serverBuffer.feed(chunk.toString())) {
570
+ enqueueServerTask(() => handleServerLine(line));
571
+ }
572
+ });
573
+ targetStdout.on('end', () => {
574
+ const rest = serverBuffer.flush();
575
+ if (rest !== null) {
576
+ enqueueServerTask(() => handleServerLine(rest));
577
+ }
578
+ });
579
+ // ---------------------------------------------------------------------------
580
+ // Lifecycle
581
+ // ---------------------------------------------------------------------------
582
+ // If the client goes away mid-write, exit quietly instead of crashing — but
583
+ // take the target server down with us: nothing supervises it once we're gone.
584
+ process.stdout.on('error', (err) => {
585
+ if (err.code === 'EPIPE') {
586
+ if (child.exitCode === null && child.signalCode === null) {
587
+ child.kill('SIGTERM');
588
+ armKillTimer();
589
+ child.once('close', () => process.exit(0));
590
+ }
591
+ else {
592
+ process.exit(0);
593
+ }
594
+ return;
595
+ }
596
+ log(`client stdout error: ${err.message}`);
597
+ });
598
+ // 'close' (not 'exit') so the target's stdout has fully drained first.
599
+ child.on('close', (code, signal) => {
600
+ log(`target server closed (code=${code ?? 'none'}, signal=${signal ?? 'none'})`);
601
+ enqueueServerTask(async () => {
602
+ const rest = serverBuffer.flush();
603
+ if (rest !== null) {
604
+ await handleServerLine(rest);
605
+ }
606
+ // Empty write: its callback fires only after everything queued before it
607
+ // has been handed to the client pipe, so exiting here cannot truncate.
608
+ process.stdout.write('', () => {
609
+ process.exit(code ?? (signal !== null ? 1 : 0));
610
+ });
611
+ });
612
+ });
613
+ for (const signal of ['SIGINT', 'SIGTERM']) {
614
+ process.on(signal, () => {
615
+ log(`received ${signal}, stopping target server`);
616
+ child.kill(signal);
617
+ armKillTimer();
618
+ });
619
+ }
620
+ } // end of main()
621
+ main().catch(err => fail(`Unhandled error: ${err instanceof Error ? err.message : String(err)}`));
622
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,21 @@
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
+ /**
11
+ * Command (+ leading args) that re-invokes the currently running mcp-shield
12
+ * using absolute paths only. A bare "mcp-shield" would be resolved against
13
+ * the CLIENT's PATH at spawn time, which breaks under every mainstream
14
+ * install channel: `npx @jrooig/mcpshield install` leaves nothing on PATH once it
15
+ * exits, GUI-launched clients on macOS inherit launchd's minimal PATH, and
16
+ * Windows clients can't exec the npm .cmd shim without a shell. Also used
17
+ * by `mcp-shield register`, which writes the same shape.
18
+ */
19
+ export declare function shieldInvocation(): string[];
20
+ export declare function runInstallCommand(targetApp?: string): Promise<void>;
21
+ export declare function runUninstallCommand(targetApp?: string): Promise<void>;