@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
|
@@ -0,0 +1,130 @@
|
|
|
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
|
+
export const NEUTRALIZED_MARKER = '[PROMPT INJECTION DETECTED AND NEUTRALIZED BY MCP-SHIELD]';
|
|
12
|
+
// Case-insensitive, with \s+ wherever a space is expected so a phrase cannot
|
|
13
|
+
// dodge detection by line-wrapping or padding. Kept deliberately tight: every
|
|
14
|
+
// pattern is an instruction *aimed at the model*, not prose that could appear
|
|
15
|
+
// in ordinary tool output.
|
|
16
|
+
const INJECTION_PATTERNS = [
|
|
17
|
+
{
|
|
18
|
+
label: 'ignore-previous-instructions',
|
|
19
|
+
pattern: /\b(?:ignore|disregard|forget|override)\s+(?:all\s+|any\s+|the\s+|everything\s+)?(?:previous|prior|above|earlier|preceding|your)\s+(?:instructions?|prompts?|directives?|rules?|messages?|guidelines?|context|training)\b/i,
|
|
20
|
+
},
|
|
21
|
+
{ label: 'system-override', pattern: /\bsystem[\s\-_]*override\b/i },
|
|
22
|
+
{ label: 'you-must-now', pattern: /\byou\s+must\s+now\b/i },
|
|
23
|
+
// "New/updated instructions" followed by a colon, dash or period — the
|
|
24
|
+
// punctuation an attacker uses to introduce the injected directive.
|
|
25
|
+
{ label: 'new-instructions', pattern: /\b(?:new|updated|revised)\s+instructions?\s*[:.\-]/i },
|
|
26
|
+
{
|
|
27
|
+
label: 'goal-hijack',
|
|
28
|
+
pattern: /\byour\s+(?:new|real|true|actual)\s+(?:task|goal|objective|mission|instructions?)\s+(?:is|are)\b/i,
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
label: 'hide-from-user',
|
|
32
|
+
// "do not" and its contraction "don't" (any apostrophe), same verb set.
|
|
33
|
+
pattern: /\bdo(?:\s+not|n['’]?t)\s+(?:tell|inform|alert|notify|warn)\s+the\s+user\b/i,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
label: 'fake-role-tag',
|
|
37
|
+
pattern: /<\/?\s*(?:system|assistant)(?:_?(?:prompt|message))?\s*>/i,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
label: 'act-as-unrestricted',
|
|
41
|
+
pattern: /\bact\s+as\s+(?:an?\s+)?(?:unrestricted|jailbroken|uncensored)\b/i,
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
// Zero-width and invisible formatting characters an attacker can splice into
|
|
45
|
+
// a phrase ("ig<ZWSP>nore all previous instructions") to break \b/\s while a
|
|
46
|
+
// model still reads the intact words. Stripped before scanning.
|
|
47
|
+
const INVISIBLE_CHARS = /[\u00AD\u200B-\u200F\u202A-\u202E\u2060-\u2064\uFEFF]/g;
|
|
48
|
+
/** Labels of every injection pattern the text triggers; empty means clean. */
|
|
49
|
+
export function scanText(text) {
|
|
50
|
+
// NFKC folds confusable/compatibility forms to their canonical characters,
|
|
51
|
+
// then invisibles are removed, so obfuscated phrasings collapse back to the
|
|
52
|
+
// plain text the patterns match.
|
|
53
|
+
const normalized = text.normalize('NFKC').replace(INVISIBLE_CHARS, '');
|
|
54
|
+
const hits = [];
|
|
55
|
+
for (const { label, pattern } of INJECTION_PATTERNS) {
|
|
56
|
+
if (pattern.test(normalized)) {
|
|
57
|
+
hits.push(label);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return hits;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Scan a server → client message and neutralize injected text blocks.
|
|
64
|
+
*
|
|
65
|
+
* Covers both content shapes external data travels in:
|
|
66
|
+
* - tools/call results: result.content[] ({type:'text'} blocks and
|
|
67
|
+
* {type:'resource'} embedded resources)
|
|
68
|
+
* - resources/read results: result.contents[] (text resource items)
|
|
69
|
+
*
|
|
70
|
+
* A clean message is returned as the SAME object (`modified: false`), so the
|
|
71
|
+
* caller can tell whether anything was rewritten.
|
|
72
|
+
*/
|
|
73
|
+
export function sanitizeServerMessage(message) {
|
|
74
|
+
if (!('result' in message) || typeof message.result !== 'object' || message.result === null) {
|
|
75
|
+
return { message, modified: false, detections: [] };
|
|
76
|
+
}
|
|
77
|
+
const result = message.result;
|
|
78
|
+
const sanitizedResult = { ...result };
|
|
79
|
+
const detections = [];
|
|
80
|
+
let modified = false;
|
|
81
|
+
for (const key of ['content', 'contents']) {
|
|
82
|
+
const blocks = result[key];
|
|
83
|
+
if (!Array.isArray(blocks)) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const sanitizedBlocks = blocks.map((block) => sanitizeBlock(block, detections));
|
|
87
|
+
if (sanitizedBlocks.some((block, i) => block !== blocks[i])) {
|
|
88
|
+
sanitizedResult[key] = sanitizedBlocks;
|
|
89
|
+
modified = true;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (!modified) {
|
|
93
|
+
return { message, modified: false, detections: [] };
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
message: { ...message, result: sanitizedResult },
|
|
97
|
+
modified: true,
|
|
98
|
+
detections: [...new Set(detections)],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/** Returns the same block when clean, a rewritten copy when a text surface is hostile. */
|
|
102
|
+
function sanitizeBlock(block, detections) {
|
|
103
|
+
if (typeof block !== 'object' || block === null) {
|
|
104
|
+
return block;
|
|
105
|
+
}
|
|
106
|
+
const record = block;
|
|
107
|
+
// {type:'text', text} blocks and resources/read items both carry top-level text.
|
|
108
|
+
const text = record['text'];
|
|
109
|
+
if (typeof text === 'string') {
|
|
110
|
+
const hits = scanText(text);
|
|
111
|
+
if (hits.length > 0) {
|
|
112
|
+
detections.push(...hits);
|
|
113
|
+
return { ...record, text: NEUTRALIZED_MARKER };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// {type:'resource', resource:{text}} embedded resources.
|
|
117
|
+
const resource = record['resource'];
|
|
118
|
+
if (typeof resource === 'object' && resource !== null) {
|
|
119
|
+
const resourceText = resource['text'];
|
|
120
|
+
if (typeof resourceText === 'string') {
|
|
121
|
+
const hits = scanText(resourceText);
|
|
122
|
+
if (hits.length > 0) {
|
|
123
|
+
detections.push(...hits);
|
|
124
|
+
return { ...record, resource: { ...resource, text: NEUTRALIZED_MARKER } };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return block;
|
|
129
|
+
}
|
|
130
|
+
//# sourceMappingURL=sanitizer.js.map
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard server (blueprint Component D): Express for the static Web UI plus
|
|
3
|
+
* a Socket.io channel carrying the manual-approval flow.
|
|
4
|
+
*
|
|
5
|
+
* The proxy launches this on the CLI `--port` and hands the returned
|
|
6
|
+
* `ApprovalQueue` to its `ask` hook. If the preferred port is busy the server
|
|
7
|
+
* walks forward to the next free one rather than failing, so a second shielded
|
|
8
|
+
* server on the same machine still gets a dashboard.
|
|
9
|
+
*
|
|
10
|
+
* Diagnostics go to stderr only: stdout is the MCP protocol channel.
|
|
11
|
+
*/
|
|
12
|
+
import { ApprovalQueue } from './wsHandler.js';
|
|
13
|
+
export interface DashboardServer {
|
|
14
|
+
/** The port actually bound (may differ from the requested one). */
|
|
15
|
+
port: number;
|
|
16
|
+
/** The approval queue the proxy parks `ask` requests in. */
|
|
17
|
+
queue: ApprovalQueue;
|
|
18
|
+
/** Push an event to every connected dashboard client (activity feed, etc.). */
|
|
19
|
+
broadcast(event: string, payload: unknown): void;
|
|
20
|
+
/** Number of connected dashboard clients right now. */
|
|
21
|
+
clientCount(): number;
|
|
22
|
+
/** Stop accepting connections and release the port. */
|
|
23
|
+
close(): Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
export interface DashboardOptions {
|
|
26
|
+
/** Diagnostic sink (stderr in the proxy). */
|
|
27
|
+
log?: (message: string) => void;
|
|
28
|
+
/** Per-request approval timeout; forwarded to the ApprovalQueue. */
|
|
29
|
+
approvalTimeoutMs?: number;
|
|
30
|
+
/** How many sequential ports to try before giving up. */
|
|
31
|
+
maxPortProbes?: number;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Start the dashboard, probing forward from `preferredPort` on EADDRINUSE.
|
|
35
|
+
* Resolves once a port is bound; rejects if none is free within the probe
|
|
36
|
+
* budget or on any non-address error.
|
|
37
|
+
*/
|
|
38
|
+
export declare function startDashboardServer(preferredPort: number, options?: DashboardOptions): Promise<DashboardServer>;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard server (blueprint Component D): Express for the static Web UI plus
|
|
3
|
+
* a Socket.io channel carrying the manual-approval flow.
|
|
4
|
+
*
|
|
5
|
+
* The proxy launches this on the CLI `--port` and hands the returned
|
|
6
|
+
* `ApprovalQueue` to its `ask` hook. If the preferred port is busy the server
|
|
7
|
+
* walks forward to the next free one rather than failing, so a second shielded
|
|
8
|
+
* server on the same machine still gets a dashboard.
|
|
9
|
+
*
|
|
10
|
+
* Diagnostics go to stderr only: stdout is the MCP protocol channel.
|
|
11
|
+
*/
|
|
12
|
+
import { createServer } from 'node:http';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import express from 'express';
|
|
16
|
+
import { Server as SocketIoServer } from 'socket.io';
|
|
17
|
+
import { ApprovalQueue, attachApprovalHandlers } from './wsHandler.js';
|
|
18
|
+
/** Static assets live in <project>/public; this file compiles to <project>/dist/server/. */
|
|
19
|
+
const PUBLIC_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'public');
|
|
20
|
+
const MAX_PORT_PROBES = 20;
|
|
21
|
+
/**
|
|
22
|
+
* Start the dashboard, probing forward from `preferredPort` on EADDRINUSE.
|
|
23
|
+
* Resolves once a port is bound; rejects if none is free within the probe
|
|
24
|
+
* budget or on any non-address error.
|
|
25
|
+
*/
|
|
26
|
+
export async function startDashboardServer(preferredPort, options = {}) {
|
|
27
|
+
const log = options.log ?? (() => { });
|
|
28
|
+
const maxProbes = options.maxPortProbes ?? MAX_PORT_PROBES;
|
|
29
|
+
const app = express();
|
|
30
|
+
app.disable('x-powered-by');
|
|
31
|
+
app.use(express.static(PUBLIC_DIR));
|
|
32
|
+
const httpServer = createServer(app);
|
|
33
|
+
const io = new SocketIoServer(httpServer, {
|
|
34
|
+
// The dashboard is a local operator tool; allow any origin to load it.
|
|
35
|
+
cors: { origin: '*' },
|
|
36
|
+
});
|
|
37
|
+
const queue = new ApprovalQueue({
|
|
38
|
+
broadcast: (event, payload) => io.emit(event, payload),
|
|
39
|
+
timeoutMs: options.approvalTimeoutMs,
|
|
40
|
+
});
|
|
41
|
+
attachApprovalHandlers(io, queue);
|
|
42
|
+
const port = await listenWithFallback(httpServer, preferredPort, maxProbes, log);
|
|
43
|
+
// Keep a permanent 'error' listener once bound: listenWithFallback removes
|
|
44
|
+
// its own on success, and an EventEmitter 'error' with no listener throws —
|
|
45
|
+
// which would crash the whole proxy, taking the stdout protocol channel with
|
|
46
|
+
// it. A dashboard-side failure must never do that; log and carry on.
|
|
47
|
+
httpServer.on('error', (err) => {
|
|
48
|
+
log(`dashboard server error (ignored): ${err.message}`);
|
|
49
|
+
});
|
|
50
|
+
log(`dashboard listening on http://localhost:${port} (static: ${PUBLIC_DIR})`);
|
|
51
|
+
return {
|
|
52
|
+
port,
|
|
53
|
+
queue,
|
|
54
|
+
broadcast: (event, payload) => io.emit(event, payload),
|
|
55
|
+
clientCount: () => io.engine.clientsCount,
|
|
56
|
+
close: () => new Promise((resolve) => {
|
|
57
|
+
io.close(() => httpServer.close(() => resolve()));
|
|
58
|
+
}),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function listenWithFallback(httpServer, startPort, maxProbes, log) {
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
let port = startPort;
|
|
64
|
+
let attempts = 0;
|
|
65
|
+
const onError = (err) => {
|
|
66
|
+
if (err.code === 'EADDRINUSE' && attempts < maxProbes) {
|
|
67
|
+
log(`port ${port} in use, trying ${port + 1}`);
|
|
68
|
+
attempts += 1;
|
|
69
|
+
port += 1;
|
|
70
|
+
// Retry on the next port; the 'error' listener stays attached.
|
|
71
|
+
setImmediate(tryListen);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
httpServer.removeListener('error', onError);
|
|
75
|
+
reject(err.code === 'EADDRINUSE'
|
|
76
|
+
? new Error(`no free port found in ${startPort}..${startPort + maxProbes}`)
|
|
77
|
+
: err);
|
|
78
|
+
};
|
|
79
|
+
const tryListen = () => {
|
|
80
|
+
httpServer.listen(port, () => {
|
|
81
|
+
httpServer.removeListener('error', onError);
|
|
82
|
+
// Read the bound port from the socket, so port 0 (OS-assigned, used by
|
|
83
|
+
// tests for a hermetic ephemeral port) resolves to the real number.
|
|
84
|
+
const address = httpServer.address();
|
|
85
|
+
resolve(typeof address === 'object' && address !== null ? address.port : port);
|
|
86
|
+
});
|
|
87
|
+
};
|
|
88
|
+
httpServer.on('error', onError);
|
|
89
|
+
tryListen();
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=dashboardServer.js.map
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manual-approval queue (blueprint Component D) — hook (b) from src/index.ts.
|
|
3
|
+
*
|
|
4
|
+
* When the firewall returns `ask`, the client → server pipeline parks the
|
|
5
|
+
* message here: an unresolved promise is stored alongside the captured
|
|
6
|
+
* JSON-RPC request, the dashboard is told over Socket.io, and nothing behind
|
|
7
|
+
* it moves until the user decides (approve / deny / modify). Because the
|
|
8
|
+
* proxy's client queue is sequential, at most one request is parked at a time
|
|
9
|
+
* in the current design, but the Map generalises to more.
|
|
10
|
+
*
|
|
11
|
+
* Every wait is bounded and fails CLOSED — the security default of the whole
|
|
12
|
+
* proxy. A request is denied automatically if:
|
|
13
|
+
* - the user does not answer within `timeoutMs` (default 120 s), or
|
|
14
|
+
* - the dashboard goes away (last client disconnects) with it still pending.
|
|
15
|
+
* so a hostile or absent operator can never leave the MCP client hanging.
|
|
16
|
+
*
|
|
17
|
+
* This module is transport-agnostic: `ApprovalQueue` takes a `broadcast`
|
|
18
|
+
* callback and is driven by plain method calls, so it is unit-testable
|
|
19
|
+
* without a live socket. `attachApprovalHandlers` is the thin Socket.io glue.
|
|
20
|
+
*/
|
|
21
|
+
import type { Server as SocketIoServer } from 'socket.io';
|
|
22
|
+
import { type JsonRpcErrorResponse, type McpToolCallRequest } from '../types/mcp.js';
|
|
23
|
+
export declare const DEFAULT_APPROVAL_TIMEOUT_MS = 120000;
|
|
24
|
+
/** How a parked request was released. `modify` swaps in operator-edited arguments. */
|
|
25
|
+
export type ApprovalOutcome = {
|
|
26
|
+
action: 'approve';
|
|
27
|
+
} | {
|
|
28
|
+
action: 'deny';
|
|
29
|
+
reason: string;
|
|
30
|
+
} | {
|
|
31
|
+
action: 'modify';
|
|
32
|
+
arguments: Record<string, unknown>;
|
|
33
|
+
};
|
|
34
|
+
/** The record the dashboard receives so it can render the pending card. */
|
|
35
|
+
export interface PendingView {
|
|
36
|
+
id: string;
|
|
37
|
+
ruleName: string;
|
|
38
|
+
/** Human-readable condition that fired (e.g. `arguments.command regex "..."`). */
|
|
39
|
+
reason: string;
|
|
40
|
+
createdAt: number;
|
|
41
|
+
tool: string;
|
|
42
|
+
arguments: Record<string, unknown> | undefined;
|
|
43
|
+
rpcRequest: McpToolCallRequest;
|
|
44
|
+
}
|
|
45
|
+
export interface ApprovalQueueOptions {
|
|
46
|
+
/** Push an event to every connected dashboard client. */
|
|
47
|
+
broadcast: (event: string, payload: unknown) => void;
|
|
48
|
+
timeoutMs?: number;
|
|
49
|
+
/** Injectable for deterministic tests; defaults to a monotonic counter. */
|
|
50
|
+
idFactory?: () => string;
|
|
51
|
+
/** Injectable clock for deterministic tests. */
|
|
52
|
+
now?: () => number;
|
|
53
|
+
}
|
|
54
|
+
export declare class ApprovalQueue {
|
|
55
|
+
private readonly pending;
|
|
56
|
+
private readonly broadcast;
|
|
57
|
+
private readonly timeoutMs;
|
|
58
|
+
private readonly now;
|
|
59
|
+
private readonly nextId;
|
|
60
|
+
private seq;
|
|
61
|
+
constructor(options: ApprovalQueueOptions);
|
|
62
|
+
/**
|
|
63
|
+
* Park a request pending the operator's verdict. Resolves with their choice,
|
|
64
|
+
* or auto-denies on timeout. Never rejects — the caller always gets a
|
|
65
|
+
* decision it can act on, so the MCP client is never left hanging.
|
|
66
|
+
*/
|
|
67
|
+
enqueue(rpcRequest: McpToolCallRequest, ruleName: string, reason?: string): Promise<ApprovalOutcome>;
|
|
68
|
+
/** approve → let the original message through unchanged. */
|
|
69
|
+
approve(id: string): boolean;
|
|
70
|
+
/** deny → the caller returns Access Denied to the MCP client. */
|
|
71
|
+
deny(id: string, reason?: string): boolean;
|
|
72
|
+
/** modify → forward the call with operator-edited arguments instead. */
|
|
73
|
+
modify(id: string, newArguments: Record<string, unknown>): boolean;
|
|
74
|
+
/** Fail closed on every still-parked request (used when the dashboard vanishes). */
|
|
75
|
+
denyAll(reason: string): void;
|
|
76
|
+
/** Snapshot for a newly connected dashboard so it can render what is waiting. */
|
|
77
|
+
snapshot(): PendingView[];
|
|
78
|
+
get size(): number;
|
|
79
|
+
private settle;
|
|
80
|
+
private toView;
|
|
81
|
+
}
|
|
82
|
+
/** Build the Access Denied response the proxy returns for a denied/timed-out request. */
|
|
83
|
+
export declare function deniedResponse(request: McpToolCallRequest, reason: string): JsonRpcErrorResponse;
|
|
84
|
+
/**
|
|
85
|
+
* Wire a Socket.io server to an ApprovalQueue:
|
|
86
|
+
* - a new client receives the current pending snapshot,
|
|
87
|
+
* - `approve` / `deny` / `modify` events settle the matching request,
|
|
88
|
+
* - when the LAST client disconnects, every still-pending request is denied
|
|
89
|
+
* (fail closed) so no verdict can hang waiting on an operator who left.
|
|
90
|
+
*
|
|
91
|
+
* `modify` payloads are validated to a plain-object `arguments` map; a
|
|
92
|
+
* malformed edit is rejected rather than forwarded blindly.
|
|
93
|
+
*/
|
|
94
|
+
export declare function attachApprovalHandlers(io: SocketIoServer, queue: ApprovalQueue): void;
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manual-approval queue (blueprint Component D) — hook (b) from src/index.ts.
|
|
3
|
+
*
|
|
4
|
+
* When the firewall returns `ask`, the client → server pipeline parks the
|
|
5
|
+
* message here: an unresolved promise is stored alongside the captured
|
|
6
|
+
* JSON-RPC request, the dashboard is told over Socket.io, and nothing behind
|
|
7
|
+
* it moves until the user decides (approve / deny / modify). Because the
|
|
8
|
+
* proxy's client queue is sequential, at most one request is parked at a time
|
|
9
|
+
* in the current design, but the Map generalises to more.
|
|
10
|
+
*
|
|
11
|
+
* Every wait is bounded and fails CLOSED — the security default of the whole
|
|
12
|
+
* proxy. A request is denied automatically if:
|
|
13
|
+
* - the user does not answer within `timeoutMs` (default 120 s), or
|
|
14
|
+
* - the dashboard goes away (last client disconnects) with it still pending.
|
|
15
|
+
* so a hostile or absent operator can never leave the MCP client hanging.
|
|
16
|
+
*
|
|
17
|
+
* This module is transport-agnostic: `ApprovalQueue` takes a `broadcast`
|
|
18
|
+
* callback and is driven by plain method calls, so it is unit-testable
|
|
19
|
+
* without a live socket. `attachApprovalHandlers` is the thin Socket.io glue.
|
|
20
|
+
*/
|
|
21
|
+
import { createAccessDeniedResponse, } from '../types/mcp.js';
|
|
22
|
+
export const DEFAULT_APPROVAL_TIMEOUT_MS = 120_000;
|
|
23
|
+
export class ApprovalQueue {
|
|
24
|
+
pending = new Map();
|
|
25
|
+
broadcast;
|
|
26
|
+
timeoutMs;
|
|
27
|
+
now;
|
|
28
|
+
nextId;
|
|
29
|
+
seq = 0;
|
|
30
|
+
constructor(options) {
|
|
31
|
+
this.broadcast = options.broadcast;
|
|
32
|
+
this.timeoutMs = options.timeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS;
|
|
33
|
+
this.now = options.now ?? Date.now;
|
|
34
|
+
this.nextId = options.idFactory ?? (() => `ask-${++this.seq}`);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Park a request pending the operator's verdict. Resolves with their choice,
|
|
38
|
+
* or auto-denies on timeout. Never rejects — the caller always gets a
|
|
39
|
+
* decision it can act on, so the MCP client is never left hanging.
|
|
40
|
+
*/
|
|
41
|
+
enqueue(rpcRequest, ruleName, reason = '') {
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
const id = this.nextId();
|
|
44
|
+
// A ref'd timer on purpose: while a request is parked we are waiting for
|
|
45
|
+
// the operator, so this pending approval SHOULD keep the process alive
|
|
46
|
+
// until it is answered or auto-denied at the deadline.
|
|
47
|
+
const timer = setTimeout(() => {
|
|
48
|
+
this.settle(id, { action: 'deny', reason: `approval timed out after ${this.timeoutMs}ms` });
|
|
49
|
+
}, this.timeoutMs);
|
|
50
|
+
const entry = {
|
|
51
|
+
id,
|
|
52
|
+
rpcRequest,
|
|
53
|
+
ruleName,
|
|
54
|
+
reason,
|
|
55
|
+
createdAt: this.now(),
|
|
56
|
+
resolve,
|
|
57
|
+
reject: () => { },
|
|
58
|
+
timer,
|
|
59
|
+
};
|
|
60
|
+
this.pending.set(id, entry);
|
|
61
|
+
this.broadcast('pending_approval', this.toView(entry));
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/** approve → let the original message through unchanged. */
|
|
65
|
+
approve(id) {
|
|
66
|
+
return this.settle(id, { action: 'approve' });
|
|
67
|
+
}
|
|
68
|
+
/** deny → the caller returns Access Denied to the MCP client. */
|
|
69
|
+
deny(id, reason = 'denied by operator') {
|
|
70
|
+
return this.settle(id, { action: 'deny', reason });
|
|
71
|
+
}
|
|
72
|
+
/** modify → forward the call with operator-edited arguments instead. */
|
|
73
|
+
modify(id, newArguments) {
|
|
74
|
+
return this.settle(id, { action: 'modify', arguments: newArguments });
|
|
75
|
+
}
|
|
76
|
+
/** Fail closed on every still-parked request (used when the dashboard vanishes). */
|
|
77
|
+
denyAll(reason) {
|
|
78
|
+
for (const id of [...this.pending.keys()]) {
|
|
79
|
+
this.settle(id, { action: 'deny', reason });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** Snapshot for a newly connected dashboard so it can render what is waiting. */
|
|
83
|
+
snapshot() {
|
|
84
|
+
return [...this.pending.values()].map((entry) => this.toView(entry));
|
|
85
|
+
}
|
|
86
|
+
get size() {
|
|
87
|
+
return this.pending.size;
|
|
88
|
+
}
|
|
89
|
+
settle(id, outcome) {
|
|
90
|
+
const entry = this.pending.get(id);
|
|
91
|
+
if (entry === undefined) {
|
|
92
|
+
return false; // already resolved, timed out, or an unknown id from the UI
|
|
93
|
+
}
|
|
94
|
+
clearTimeout(entry.timer);
|
|
95
|
+
this.pending.delete(id);
|
|
96
|
+
this.broadcast('request_resolved', { id, outcome });
|
|
97
|
+
entry.resolve(outcome);
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
toView(entry) {
|
|
101
|
+
return {
|
|
102
|
+
id: entry.id,
|
|
103
|
+
ruleName: entry.ruleName,
|
|
104
|
+
reason: entry.reason,
|
|
105
|
+
createdAt: entry.createdAt,
|
|
106
|
+
tool: entry.rpcRequest.params.name,
|
|
107
|
+
arguments: entry.rpcRequest.params.arguments,
|
|
108
|
+
rpcRequest: entry.rpcRequest,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** Build the Access Denied response the proxy returns for a denied/timed-out request. */
|
|
113
|
+
export function deniedResponse(request, reason) {
|
|
114
|
+
return createAccessDeniedResponse(request.id, reason);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Wire a Socket.io server to an ApprovalQueue:
|
|
118
|
+
* - a new client receives the current pending snapshot,
|
|
119
|
+
* - `approve` / `deny` / `modify` events settle the matching request,
|
|
120
|
+
* - when the LAST client disconnects, every still-pending request is denied
|
|
121
|
+
* (fail closed) so no verdict can hang waiting on an operator who left.
|
|
122
|
+
*
|
|
123
|
+
* `modify` payloads are validated to a plain-object `arguments` map; a
|
|
124
|
+
* malformed edit is rejected rather than forwarded blindly.
|
|
125
|
+
*/
|
|
126
|
+
export function attachApprovalHandlers(io, queue) {
|
|
127
|
+
// Track clients ourselves instead of reading io.engine.clientsCount inside
|
|
128
|
+
// the disconnect handler: engine.io decrements that count asynchronously,
|
|
129
|
+
// AFTER the 'disconnect' event, so it still reads the leaving socket at that
|
|
130
|
+
// instant. A counter we bump on connect and drop in the disconnect handler
|
|
131
|
+
// is exact.
|
|
132
|
+
let clients = 0;
|
|
133
|
+
io.on('connection', (socket) => {
|
|
134
|
+
clients += 1;
|
|
135
|
+
socket.emit('pending_snapshot', queue.snapshot());
|
|
136
|
+
socket.on('approve', (payload) => {
|
|
137
|
+
const id = extractId(payload);
|
|
138
|
+
if (id !== undefined) {
|
|
139
|
+
queue.approve(id);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
socket.on('deny', (payload) => {
|
|
143
|
+
const id = extractId(payload);
|
|
144
|
+
if (id !== undefined) {
|
|
145
|
+
queue.deny(id);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
socket.on('modify', (payload) => {
|
|
149
|
+
const id = extractId(payload);
|
|
150
|
+
const args = extractArguments(payload);
|
|
151
|
+
if (id !== undefined && args !== undefined) {
|
|
152
|
+
queue.modify(id, args);
|
|
153
|
+
}
|
|
154
|
+
else if (id !== undefined) {
|
|
155
|
+
// A modify with an unusable payload must not silently approve; deny it.
|
|
156
|
+
queue.deny(id, 'modify payload was not a valid arguments object');
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
socket.on('disconnect', () => {
|
|
160
|
+
clients -= 1;
|
|
161
|
+
// If this was the last dashboard, fail closed on anything still parked:
|
|
162
|
+
// no operator remains to give a verdict. (A refresh briefly hits zero
|
|
163
|
+
// too; denying is the safe default the whole proxy is built on.)
|
|
164
|
+
if (clients === 0 && queue.size > 0) {
|
|
165
|
+
queue.denyAll('dashboard disconnected before a verdict was given');
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
function extractId(payload) {
|
|
171
|
+
if (typeof payload === 'string') {
|
|
172
|
+
return payload;
|
|
173
|
+
}
|
|
174
|
+
if (typeof payload === 'object' && payload !== null) {
|
|
175
|
+
const id = payload['id'];
|
|
176
|
+
if (typeof id === 'string') {
|
|
177
|
+
return id;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
function extractArguments(payload) {
|
|
183
|
+
if (typeof payload !== 'object' || payload === null) {
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
// Accept either { id, arguments: {...} } or { id, modifiedPayload: {...} }.
|
|
187
|
+
const record = payload;
|
|
188
|
+
const candidate = record['arguments'] ?? record['modifiedPayload'];
|
|
189
|
+
if (typeof candidate === 'object' && candidate !== null && !Array.isArray(candidate)) {
|
|
190
|
+
return candidate;
|
|
191
|
+
}
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
//# sourceMappingURL=wsHandler.js.map
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON-RPC 2.0 & Model Context Protocol (MCP) type definitions.
|
|
3
|
+
*
|
|
4
|
+
* These types model the wire format that MCP-Shield intercepts on stdio:
|
|
5
|
+
* every packet exchanged between an MCP client (e.g. Claude Code) and an
|
|
6
|
+
* MCP server is a JSON-RPC 2.0 message, and MCP layers its own methods
|
|
7
|
+
* (`initialize`, `tools/call`, `resources/read`, ...) on top of it.
|
|
8
|
+
*/
|
|
9
|
+
export declare const JSONRPC_VERSION: "2.0";
|
|
10
|
+
/** A request id. The spec allows string, number or null (null only in error responses to unparseable requests). */
|
|
11
|
+
export type JsonRpcId = string | number | null;
|
|
12
|
+
/** A request expects a response and therefore always carries an id. */
|
|
13
|
+
export interface JsonRpcRequest {
|
|
14
|
+
jsonrpc: typeof JSONRPC_VERSION;
|
|
15
|
+
id: string | number;
|
|
16
|
+
method: string;
|
|
17
|
+
params?: Record<string, unknown> | unknown[];
|
|
18
|
+
}
|
|
19
|
+
/** A notification is fire-and-forget: same shape as a request but without id. */
|
|
20
|
+
export interface JsonRpcNotification {
|
|
21
|
+
jsonrpc: typeof JSONRPC_VERSION;
|
|
22
|
+
method: string;
|
|
23
|
+
params?: Record<string, unknown> | unknown[];
|
|
24
|
+
}
|
|
25
|
+
export interface JsonRpcErrorObject {
|
|
26
|
+
code: number;
|
|
27
|
+
message: string;
|
|
28
|
+
data?: unknown;
|
|
29
|
+
}
|
|
30
|
+
export interface JsonRpcSuccessResponse {
|
|
31
|
+
jsonrpc: typeof JSONRPC_VERSION;
|
|
32
|
+
id: JsonRpcId;
|
|
33
|
+
result: unknown;
|
|
34
|
+
}
|
|
35
|
+
export interface JsonRpcErrorResponse {
|
|
36
|
+
jsonrpc: typeof JSONRPC_VERSION;
|
|
37
|
+
id: JsonRpcId;
|
|
38
|
+
error: JsonRpcErrorObject;
|
|
39
|
+
}
|
|
40
|
+
export type JsonRpcResponse = JsonRpcSuccessResponse | JsonRpcErrorResponse;
|
|
41
|
+
/** Any valid JSON-RPC 2.0 packet that can travel over the stdio pipe. */
|
|
42
|
+
export type JsonRpcMessage = JsonRpcRequest | JsonRpcNotification | JsonRpcResponse;
|
|
43
|
+
/** Standard JSON-RPC 2.0 error codes, plus the code MCP-Shield uses for policy denials. */
|
|
44
|
+
export declare const JsonRpcErrorCode: {
|
|
45
|
+
readonly ParseError: -32700;
|
|
46
|
+
readonly InvalidRequest: -32600;
|
|
47
|
+
readonly MethodNotFound: -32601;
|
|
48
|
+
readonly InvalidParams: -32602;
|
|
49
|
+
readonly InternalError: -32603;
|
|
50
|
+
/** MCP-Shield policy denial. The spec reserves -32000..-32099 for implementation-defined server errors. */
|
|
51
|
+
readonly AccessDenied: -32003;
|
|
52
|
+
};
|
|
53
|
+
export type JsonRpcErrorCodeValue = (typeof JsonRpcErrorCode)[keyof typeof JsonRpcErrorCode];
|
|
54
|
+
/** MCP methods relevant to interception. Servers may expose more; the firewall matches on these. */
|
|
55
|
+
export declare const McpMethod: {
|
|
56
|
+
readonly Initialize: "initialize";
|
|
57
|
+
readonly Initialized: "notifications/initialized";
|
|
58
|
+
readonly ToolsList: "tools/list";
|
|
59
|
+
readonly ToolsCall: "tools/call";
|
|
60
|
+
readonly ResourcesList: "resources/list";
|
|
61
|
+
readonly ResourcesRead: "resources/read";
|
|
62
|
+
readonly PromptsList: "prompts/list";
|
|
63
|
+
readonly PromptsGet: "prompts/get";
|
|
64
|
+
readonly Ping: "ping";
|
|
65
|
+
};
|
|
66
|
+
export type McpMethodValue = (typeof McpMethod)[keyof typeof McpMethod];
|
|
67
|
+
/** Params of a `tools/call` request — the packet the firewall cares most about. */
|
|
68
|
+
export interface McpToolCallParams {
|
|
69
|
+
name: string;
|
|
70
|
+
arguments?: Record<string, unknown>;
|
|
71
|
+
}
|
|
72
|
+
/** A fully-typed `tools/call` request as seen on the client → server pipe. */
|
|
73
|
+
export interface McpToolCallRequest extends JsonRpcRequest {
|
|
74
|
+
method: typeof McpMethod.ToolsCall;
|
|
75
|
+
params: McpToolCallParams & Record<string, unknown>;
|
|
76
|
+
}
|
|
77
|
+
/** Tool definition as returned by `tools/list`. */
|
|
78
|
+
export interface McpTool {
|
|
79
|
+
name: string;
|
|
80
|
+
description?: string;
|
|
81
|
+
inputSchema: {
|
|
82
|
+
type: 'object';
|
|
83
|
+
properties?: Record<string, unknown>;
|
|
84
|
+
required?: string[];
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
export interface McpToolListResult {
|
|
88
|
+
tools: McpTool[];
|
|
89
|
+
}
|
|
90
|
+
export interface McpTextContent {
|
|
91
|
+
type: 'text';
|
|
92
|
+
text: string;
|
|
93
|
+
}
|
|
94
|
+
export interface McpImageContent {
|
|
95
|
+
type: 'image';
|
|
96
|
+
data: string;
|
|
97
|
+
mimeType: string;
|
|
98
|
+
}
|
|
99
|
+
export interface McpEmbeddedResource {
|
|
100
|
+
type: 'resource';
|
|
101
|
+
resource: {
|
|
102
|
+
uri: string;
|
|
103
|
+
mimeType?: string;
|
|
104
|
+
text?: string;
|
|
105
|
+
blob?: string;
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
export type McpContent = McpTextContent | McpImageContent | McpEmbeddedResource;
|
|
109
|
+
/** Result of a `tools/call` — travels server → client and passes through the sanitizer. */
|
|
110
|
+
export interface McpToolCallResult {
|
|
111
|
+
content: McpContent[];
|
|
112
|
+
isError?: boolean;
|
|
113
|
+
}
|
|
114
|
+
export declare function isJsonRpcMessage(value: unknown): value is JsonRpcMessage;
|
|
115
|
+
export declare function isJsonRpcRequest(value: unknown): value is JsonRpcRequest;
|
|
116
|
+
export declare function isJsonRpcNotification(value: unknown): value is JsonRpcNotification;
|
|
117
|
+
export declare function isJsonRpcResponse(value: unknown): value is JsonRpcResponse;
|
|
118
|
+
export declare function isJsonRpcErrorResponse(value: unknown): value is JsonRpcErrorResponse;
|
|
119
|
+
/** Narrow an intercepted packet down to the tool invocation the firewall must judge. */
|
|
120
|
+
export declare function isToolCallRequest(value: unknown): value is McpToolCallRequest;
|
|
121
|
+
/** Build the error response MCP-Shield returns to the client when a request is blocked by policy. */
|
|
122
|
+
export declare function createAccessDeniedResponse(id: JsonRpcId, ruleName?: string): JsonRpcErrorResponse;
|
|
123
|
+
export declare function createErrorResponse(id: JsonRpcId, code: JsonRpcErrorCodeValue | number, message: string, data?: unknown): JsonRpcErrorResponse;
|