@agent-wall/core 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/.turbo/turbo-build.log +17 -0
- package/.turbo/turbo-test.log +30 -0
- package/LICENSE +21 -0
- package/README.md +80 -0
- package/dist/index.d.ts +1297 -0
- package/dist/index.js +3067 -0
- package/dist/index.js.map +1 -0
- package/package.json +48 -0
- package/src/audit-logger-security.test.ts +225 -0
- package/src/audit-logger.test.ts +93 -0
- package/src/audit-logger.ts +458 -0
- package/src/chain-detector.test.ts +100 -0
- package/src/chain-detector.ts +269 -0
- package/src/dashboard-server.test.ts +362 -0
- package/src/dashboard-server.ts +454 -0
- package/src/egress-control.test.ts +177 -0
- package/src/egress-control.ts +274 -0
- package/src/index.ts +137 -0
- package/src/injection-detector.test.ts +207 -0
- package/src/injection-detector.ts +397 -0
- package/src/kill-switch.test.ts +119 -0
- package/src/kill-switch.ts +198 -0
- package/src/policy-engine-security.test.ts +227 -0
- package/src/policy-engine.test.ts +453 -0
- package/src/policy-engine.ts +414 -0
- package/src/policy-loader.test.ts +202 -0
- package/src/policy-loader.ts +485 -0
- package/src/proxy.ts +786 -0
- package/src/read-buffer-security.test.ts +59 -0
- package/src/read-buffer.test.ts +135 -0
- package/src/read-buffer.ts +126 -0
- package/src/response-scanner.test.ts +464 -0
- package/src/response-scanner.ts +587 -0
- package/src/types.test.ts +152 -0
- package/src/types.ts +146 -0
- package/tsconfig.json +8 -0
- package/tsup.config.ts +9 -0
- package/vitest.config.ts +12 -0
package/src/proxy.ts
ADDED
|
@@ -0,0 +1,786 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Wall Stdio Proxy
|
|
3
|
+
*
|
|
4
|
+
* The core of Agent Wall. Sits between an MCP client and server,
|
|
5
|
+
* intercepting every JSON-RPC message over stdio (stdin/stdout).
|
|
6
|
+
*
|
|
7
|
+
* Architecture:
|
|
8
|
+
* MCP Client (e.g. Claude Code)
|
|
9
|
+
* ↕ stdin/stdout
|
|
10
|
+
* Agent Wall Proxy (this file)
|
|
11
|
+
* ↕ stdin/stdout (child process)
|
|
12
|
+
* MCP Server (e.g. filesystem server)
|
|
13
|
+
*
|
|
14
|
+
* The proxy:
|
|
15
|
+
* 1. Spawns the real MCP server as a child process
|
|
16
|
+
* 2. Reads JSON-RPC from its own stdin (from the MCP client)
|
|
17
|
+
* 3. For tools/call requests: evaluates against the policy engine
|
|
18
|
+
* 4. If allowed: forwards to child's stdin
|
|
19
|
+
* 5. If denied: returns a JSON-RPC error without forwarding
|
|
20
|
+
* 6. Pipes child's stdout back to its own stdout (to the MCP client)
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { type ChildProcess } from "node:child_process";
|
|
24
|
+
import spawn from "cross-spawn";
|
|
25
|
+
import * as crypto from "node:crypto";
|
|
26
|
+
import * as fs from "node:fs";
|
|
27
|
+
import * as readline from "node:readline";
|
|
28
|
+
import { EventEmitter } from "node:events";
|
|
29
|
+
import { ReadBuffer, BufferOverflowError, serializeMessage } from "./read-buffer.js";
|
|
30
|
+
import {
|
|
31
|
+
type JsonRpcMessage,
|
|
32
|
+
type JsonRpcRequest,
|
|
33
|
+
type JsonRpcResponse,
|
|
34
|
+
type McpContentBlock,
|
|
35
|
+
isRequest,
|
|
36
|
+
isResponse,
|
|
37
|
+
isToolCall,
|
|
38
|
+
getToolCallParams,
|
|
39
|
+
createDenyResponse,
|
|
40
|
+
} from "./types.js";
|
|
41
|
+
import { PolicyEngine, type PolicyVerdict } from "./policy-engine.js";
|
|
42
|
+
import { AuditLogger, type AuditEntry } from "./audit-logger.js";
|
|
43
|
+
import { ResponseScanner, type ScanResult } from "./response-scanner.js";
|
|
44
|
+
import { InjectionDetector } from "./injection-detector.js";
|
|
45
|
+
import { EgressControl } from "./egress-control.js";
|
|
46
|
+
import { KillSwitch } from "./kill-switch.js";
|
|
47
|
+
import { ChainDetector } from "./chain-detector.js";
|
|
48
|
+
|
|
49
|
+
// ── Proxy Options ───────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
export interface ProxyOptions {
|
|
52
|
+
/** The command to spawn (e.g. "npx") */
|
|
53
|
+
command: string;
|
|
54
|
+
/** Arguments for the command (e.g. ["@modelcontextprotocol/server-filesystem", "/path"]) */
|
|
55
|
+
args?: string[];
|
|
56
|
+
/** Environment variables for the child process */
|
|
57
|
+
env?: Record<string, string>;
|
|
58
|
+
/** Working directory for the child process */
|
|
59
|
+
cwd?: string;
|
|
60
|
+
/** The policy engine instance */
|
|
61
|
+
policyEngine: PolicyEngine;
|
|
62
|
+
/** The response scanner instance (optional — if not provided, responses pass through) */
|
|
63
|
+
responseScanner?: ResponseScanner;
|
|
64
|
+
/** The audit logger instance */
|
|
65
|
+
logger: AuditLogger;
|
|
66
|
+
/** Session ID for audit logging */
|
|
67
|
+
sessionId?: string;
|
|
68
|
+
/** Callback for prompt actions — return true to allow, false to deny */
|
|
69
|
+
onPrompt?: (
|
|
70
|
+
tool: string,
|
|
71
|
+
args: Record<string, unknown>,
|
|
72
|
+
message: string
|
|
73
|
+
) => Promise<boolean>;
|
|
74
|
+
/** Called when the proxy is ready (child process spawned) */
|
|
75
|
+
onReady?: () => void;
|
|
76
|
+
/** Called when the proxy exits */
|
|
77
|
+
onExit?: (code: number | null) => void;
|
|
78
|
+
/** Called on error */
|
|
79
|
+
onError?: (error: Error) => void;
|
|
80
|
+
/** Maximum buffer size in bytes (default: 10MB) */
|
|
81
|
+
maxBufferSize?: number;
|
|
82
|
+
/** TTL for pending tool calls in ms (default: 30000) — prevents memory leaks */
|
|
83
|
+
pendingCallTtlMs?: number;
|
|
84
|
+
/** Prompt injection detector (optional) */
|
|
85
|
+
injectionDetector?: InjectionDetector;
|
|
86
|
+
/** Egress/SSRF control (optional) */
|
|
87
|
+
egressControl?: EgressControl;
|
|
88
|
+
/** Emergency kill switch (optional) */
|
|
89
|
+
killSwitch?: KillSwitch;
|
|
90
|
+
/** Tool call chain detector (optional) */
|
|
91
|
+
chainDetector?: ChainDetector;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Proxy Events ────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
export interface ProxyEvents {
|
|
97
|
+
ready: [];
|
|
98
|
+
exit: [code: number | null];
|
|
99
|
+
error: [error: Error];
|
|
100
|
+
denied: [tool: string, message: string];
|
|
101
|
+
allowed: [tool: string];
|
|
102
|
+
prompted: [tool: string, message: string];
|
|
103
|
+
responseBlocked: [tool: string, findings: string];
|
|
104
|
+
responseRedacted: [tool: string, findings: string];
|
|
105
|
+
injectionDetected: [tool: string, summary: string];
|
|
106
|
+
egressBlocked: [tool: string, summary: string];
|
|
107
|
+
killSwitchActive: [tool: string];
|
|
108
|
+
chainDetected: [tool: string, summary: string];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── Stdio Proxy ─────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
/** Default TTL for pending tool calls: 30 seconds */
|
|
114
|
+
const DEFAULT_PENDING_CALL_TTL_MS = 30_000;
|
|
115
|
+
/** Cleanup interval for expired pending calls: 10 seconds */
|
|
116
|
+
const PENDING_CALL_CLEANUP_INTERVAL_MS = 10_000;
|
|
117
|
+
|
|
118
|
+
export class StdioProxy extends EventEmitter {
|
|
119
|
+
private child: ChildProcess | null = null;
|
|
120
|
+
private clientBuffer: ReadBuffer;
|
|
121
|
+
private serverBuffer: ReadBuffer;
|
|
122
|
+
private options: ProxyOptions;
|
|
123
|
+
private sessionId: string;
|
|
124
|
+
private running = false;
|
|
125
|
+
private stats = { forwarded: 0, denied: 0, prompted: 0, total: 0, scanned: 0, responseBlocked: 0, responseRedacted: 0 };
|
|
126
|
+
/** Track pending tools/call requests by JSON-RPC id, so we can correlate responses */
|
|
127
|
+
private pendingToolCalls = new Map<string | number, { tool: string; args: Record<string, unknown>; timestamp: number }>();
|
|
128
|
+
/** Cleanup timer for expired pending calls */
|
|
129
|
+
private pendingCleanupTimer: ReturnType<typeof setInterval> | null = null;
|
|
130
|
+
private pendingCallTtlMs: number;
|
|
131
|
+
|
|
132
|
+
constructor(options: ProxyOptions) {
|
|
133
|
+
super();
|
|
134
|
+
this.options = options;
|
|
135
|
+
const maxBuf = options.maxBufferSize;
|
|
136
|
+
this.clientBuffer = new ReadBuffer(maxBuf);
|
|
137
|
+
this.serverBuffer = new ReadBuffer(maxBuf);
|
|
138
|
+
this.sessionId =
|
|
139
|
+
options.sessionId ?? `ag-${crypto.randomUUID()}`;
|
|
140
|
+
this.pendingCallTtlMs = options.pendingCallTtlMs ?? DEFAULT_PENDING_CALL_TTL_MS;
|
|
141
|
+
|
|
142
|
+
// Prevent unhandled 'error' event crash — route to onError callback if set
|
|
143
|
+
this.on("error", (err: Error) => {
|
|
144
|
+
this.options.onError?.(err);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Start the proxy — spawn the child MCP server and begin intercepting.
|
|
150
|
+
*/
|
|
151
|
+
async start(): Promise<void> {
|
|
152
|
+
return new Promise<void>((resolve, reject) => {
|
|
153
|
+
try {
|
|
154
|
+
this.child = spawn(this.options.command, this.options.args ?? [], {
|
|
155
|
+
stdio: ["pipe", "pipe", "inherit"],
|
|
156
|
+
env: {
|
|
157
|
+
...process.env,
|
|
158
|
+
...this.options.env,
|
|
159
|
+
},
|
|
160
|
+
cwd: this.options.cwd,
|
|
161
|
+
shell: false,
|
|
162
|
+
windowsHide: true,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
this.child.on("error", (err) => {
|
|
166
|
+
this.options.onError?.(err);
|
|
167
|
+
this.emit("error", err);
|
|
168
|
+
reject(err);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
this.child.on("spawn", () => {
|
|
172
|
+
this.running = true;
|
|
173
|
+
this.setupPipelines();
|
|
174
|
+
this.startPendingCallCleanup();
|
|
175
|
+
this.options.onReady?.();
|
|
176
|
+
this.emit("ready");
|
|
177
|
+
resolve();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
this.child.on("close", (code) => {
|
|
181
|
+
this.running = false;
|
|
182
|
+
this.options.onExit?.(code);
|
|
183
|
+
this.emit("exit", code);
|
|
184
|
+
});
|
|
185
|
+
} catch (err) {
|
|
186
|
+
reject(err);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Stop the proxy — gracefully shut down the child process.
|
|
193
|
+
* Follows the MCP SDK pattern: stdin.end() → SIGTERM → SIGKILL
|
|
194
|
+
*/
|
|
195
|
+
async stop(): Promise<void> {
|
|
196
|
+
if (!this.child) return;
|
|
197
|
+
|
|
198
|
+
const child = this.child;
|
|
199
|
+
this.child = null;
|
|
200
|
+
this.running = false;
|
|
201
|
+
|
|
202
|
+
const closePromise = new Promise<void>((resolve) => {
|
|
203
|
+
child.once("close", () => resolve());
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// 1. End stdin gracefully
|
|
207
|
+
try {
|
|
208
|
+
child.stdin?.end();
|
|
209
|
+
} catch {
|
|
210
|
+
/* ignore */
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 2. Wait up to 2s for process to exit
|
|
214
|
+
const timeout = (ms: number) =>
|
|
215
|
+
new Promise<void>((resolve) => {
|
|
216
|
+
const t = setTimeout(resolve, ms);
|
|
217
|
+
t.unref();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
await Promise.race([closePromise, timeout(2000)]);
|
|
221
|
+
|
|
222
|
+
// 3. SIGTERM if still alive
|
|
223
|
+
if (child.exitCode === null) {
|
|
224
|
+
try {
|
|
225
|
+
child.kill("SIGTERM");
|
|
226
|
+
} catch {
|
|
227
|
+
/* ignore */
|
|
228
|
+
}
|
|
229
|
+
await Promise.race([closePromise, timeout(2000)]);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// 4. SIGKILL as last resort
|
|
233
|
+
if (child.exitCode === null) {
|
|
234
|
+
try {
|
|
235
|
+
child.kill("SIGKILL");
|
|
236
|
+
} catch {
|
|
237
|
+
/* ignore */
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
this.stopPendingCallCleanup();
|
|
242
|
+
this.pendingToolCalls.clear();
|
|
243
|
+
this.clientBuffer.clear();
|
|
244
|
+
this.serverBuffer.clear();
|
|
245
|
+
this.options.killSwitch?.dispose();
|
|
246
|
+
this.options.chainDetector?.reset();
|
|
247
|
+
this.options.logger.close();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Get proxy statistics.
|
|
252
|
+
*/
|
|
253
|
+
getStats() {
|
|
254
|
+
return { ...this.stats };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Wire up the stdin/stdout pipelines with interception.
|
|
259
|
+
*/
|
|
260
|
+
private setupPipelines(): void {
|
|
261
|
+
// ── Client → Proxy → Server ──
|
|
262
|
+
process.stdin.on("data", (chunk: Buffer) => {
|
|
263
|
+
try {
|
|
264
|
+
this.clientBuffer.append(chunk);
|
|
265
|
+
this.processClientMessages();
|
|
266
|
+
} catch (err) {
|
|
267
|
+
if (err instanceof BufferOverflowError) {
|
|
268
|
+
this.emit("error", err);
|
|
269
|
+
this.clientBuffer.clear();
|
|
270
|
+
} else {
|
|
271
|
+
this.emit("error", new Error(`Client buffer error: ${err}`));
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
process.stdin.on("end", () => {
|
|
277
|
+
this.stop();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// ── Server → Proxy → Client ──
|
|
281
|
+
this.child?.stdout?.on("data", (chunk: Buffer) => {
|
|
282
|
+
try {
|
|
283
|
+
this.serverBuffer.append(chunk);
|
|
284
|
+
this.processServerMessages();
|
|
285
|
+
} catch (err) {
|
|
286
|
+
if (err instanceof BufferOverflowError) {
|
|
287
|
+
this.emit("error", err);
|
|
288
|
+
this.serverBuffer.clear();
|
|
289
|
+
} else {
|
|
290
|
+
this.emit("error", new Error(`Server buffer error: ${err}`));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Start periodic cleanup of expired pending tool calls.
|
|
298
|
+
*/
|
|
299
|
+
private startPendingCallCleanup(): void {
|
|
300
|
+
this.pendingCleanupTimer = setInterval(() => {
|
|
301
|
+
const now = Date.now();
|
|
302
|
+
for (const [id, entry] of this.pendingToolCalls) {
|
|
303
|
+
if (now - entry.timestamp > this.pendingCallTtlMs) {
|
|
304
|
+
this.pendingToolCalls.delete(id);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}, PENDING_CALL_CLEANUP_INTERVAL_MS);
|
|
308
|
+
this.pendingCleanupTimer.unref();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Stop the pending call cleanup timer.
|
|
313
|
+
*/
|
|
314
|
+
private stopPendingCallCleanup(): void {
|
|
315
|
+
if (this.pendingCleanupTimer) {
|
|
316
|
+
clearInterval(this.pendingCleanupTimer);
|
|
317
|
+
this.pendingCleanupTimer = null;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Process buffered messages from the MCP client.
|
|
323
|
+
* This is where policy enforcement happens.
|
|
324
|
+
*/
|
|
325
|
+
private processClientMessages(): void {
|
|
326
|
+
try {
|
|
327
|
+
const messages = this.clientBuffer.readAllMessages();
|
|
328
|
+
for (const msg of messages) {
|
|
329
|
+
this.handleClientMessage(msg);
|
|
330
|
+
}
|
|
331
|
+
} catch (err) {
|
|
332
|
+
// Malformed JSON from client — log and continue
|
|
333
|
+
this.emit("error", new Error(`Invalid JSON from client: ${err}`));
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Process buffered messages from the MCP server.
|
|
339
|
+
* Applies response scanning before forwarding to the client.
|
|
340
|
+
*/
|
|
341
|
+
private processServerMessages(): void {
|
|
342
|
+
try {
|
|
343
|
+
const messages = this.serverBuffer.readAllMessages();
|
|
344
|
+
for (const msg of messages) {
|
|
345
|
+
this.handleServerMessage(msg);
|
|
346
|
+
}
|
|
347
|
+
} catch (err) {
|
|
348
|
+
// Malformed JSON from server — log and continue
|
|
349
|
+
this.emit("error", new Error(`Invalid JSON from server: ${err}`));
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Handle a single message from the MCP server.
|
|
355
|
+
* If it's a response to a tools/call we tracked, scan it.
|
|
356
|
+
*/
|
|
357
|
+
private handleServerMessage(msg: JsonRpcMessage): void {
|
|
358
|
+
// Only scan responses (not requests/notifications from server)
|
|
359
|
+
if (!isResponse(msg)) {
|
|
360
|
+
this.writeToClient(msg);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const response = msg as JsonRpcResponse;
|
|
365
|
+
const pending = this.pendingToolCalls.get(response.id);
|
|
366
|
+
|
|
367
|
+
// If we don't have a pending tool call for this id, or no scanner, pass through
|
|
368
|
+
if (!pending || !this.options.responseScanner) {
|
|
369
|
+
if (pending) this.pendingToolCalls.delete(response.id);
|
|
370
|
+
this.writeToClient(msg);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// We have a tracked tool call response — scan it
|
|
375
|
+
this.pendingToolCalls.delete(response.id);
|
|
376
|
+
this.stats.scanned++;
|
|
377
|
+
|
|
378
|
+
// Scan both successful responses AND error responses (secrets can leak in error.message/error.data)
|
|
379
|
+
const scanResult = response.error
|
|
380
|
+
? this.options.responseScanner.scan(this.extractErrorText(response))
|
|
381
|
+
: response.result !== undefined
|
|
382
|
+
? this.options.responseScanner.scanMcpResponse(response.result)
|
|
383
|
+
: null;
|
|
384
|
+
|
|
385
|
+
if (!scanResult || scanResult.clean) {
|
|
386
|
+
this.writeToClient(msg);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const findingSummary = scanResult.findings
|
|
391
|
+
.map((f) => `${f.pattern}: ${f.message}`)
|
|
392
|
+
.join("; ");
|
|
393
|
+
|
|
394
|
+
switch (scanResult.action) {
|
|
395
|
+
case "block": {
|
|
396
|
+
this.stats.responseBlocked++;
|
|
397
|
+
this.options.logger.log({
|
|
398
|
+
timestamp: new Date().toISOString(),
|
|
399
|
+
sessionId: this.sessionId,
|
|
400
|
+
direction: "response",
|
|
401
|
+
method: "tools/call",
|
|
402
|
+
tool: pending.tool,
|
|
403
|
+
arguments: pending.args,
|
|
404
|
+
verdict: { action: "deny", rule: "__response_scanner__", message: `Response blocked: ${findingSummary}` },
|
|
405
|
+
});
|
|
406
|
+
this.emit("responseBlocked", pending.tool, findingSummary);
|
|
407
|
+
// Send error back to client instead of the response
|
|
408
|
+
const errorResponse = createDenyResponse(
|
|
409
|
+
response.id,
|
|
410
|
+
`Response blocked by Agent Wall scanner: ${findingSummary}`
|
|
411
|
+
);
|
|
412
|
+
this.writeToClient(errorResponse);
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
case "redact": {
|
|
416
|
+
this.stats.responseRedacted++;
|
|
417
|
+
this.options.logger.log({
|
|
418
|
+
timestamp: new Date().toISOString(),
|
|
419
|
+
sessionId: this.sessionId,
|
|
420
|
+
direction: "response",
|
|
421
|
+
method: "tools/call",
|
|
422
|
+
tool: pending.tool,
|
|
423
|
+
arguments: pending.args,
|
|
424
|
+
verdict: { action: "allow", rule: "__response_scanner__", message: `Response redacted: ${findingSummary}` },
|
|
425
|
+
});
|
|
426
|
+
this.emit("responseRedacted", pending.tool, findingSummary);
|
|
427
|
+
// Replace the result content with redacted text
|
|
428
|
+
const redacted = this.buildRedactedResponse(response, scanResult);
|
|
429
|
+
this.writeToClient(redacted);
|
|
430
|
+
break;
|
|
431
|
+
}
|
|
432
|
+
default:
|
|
433
|
+
// "pass" findings — forward as-is (informational only)
|
|
434
|
+
this.writeToClient(msg);
|
|
435
|
+
break;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Build a redacted MCP response by replacing text content.
|
|
441
|
+
*/
|
|
442
|
+
private buildRedactedResponse(original: JsonRpcResponse, scanResult: ScanResult): JsonRpcResponse {
|
|
443
|
+
if (!scanResult.redactedText || !original.result) {
|
|
444
|
+
return original;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const result = original.result as Record<string, unknown>;
|
|
448
|
+
|
|
449
|
+
// MCP standard: result.content is an array of content blocks
|
|
450
|
+
if (Array.isArray(result.content)) {
|
|
451
|
+
const redactedContent = result.content.map((block: McpContentBlock) => {
|
|
452
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
453
|
+
return { ...block, text: scanResult.redactedText };
|
|
454
|
+
}
|
|
455
|
+
return block;
|
|
456
|
+
});
|
|
457
|
+
return {
|
|
458
|
+
...original,
|
|
459
|
+
result: { ...result, content: redactedContent },
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Fallback: replace the whole result
|
|
464
|
+
return {
|
|
465
|
+
...original,
|
|
466
|
+
result: { content: [{ type: "text", text: scanResult.redactedText }] },
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Handle a single message from the MCP client.
|
|
472
|
+
*
|
|
473
|
+
* Security check order (defense in depth):
|
|
474
|
+
* 1. Kill switch — if active, deny ALL calls immediately
|
|
475
|
+
* 2. Injection detection — scan arguments for prompt injection
|
|
476
|
+
* 3. Egress control — check for blocked URLs/IPs
|
|
477
|
+
* 4. Policy engine — evaluate rules (existing behavior)
|
|
478
|
+
* 5. Chain detection — record call and check for suspicious sequences
|
|
479
|
+
*/
|
|
480
|
+
private handleClientMessage(msg: JsonRpcMessage): void {
|
|
481
|
+
this.stats.total++;
|
|
482
|
+
|
|
483
|
+
// Only intercept tools/call requests
|
|
484
|
+
if (!isToolCall(msg)) {
|
|
485
|
+
// Pass through all other messages (initialize, tools/list, notifications, etc.)
|
|
486
|
+
this.writeToServer(msg);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// It's a tools/call — run security pipeline
|
|
491
|
+
const request = msg as JsonRpcRequest;
|
|
492
|
+
const toolCall = getToolCallParams(request);
|
|
493
|
+
|
|
494
|
+
if (!toolCall) {
|
|
495
|
+
// Malformed tools/call — forward anyway (let the server handle it)
|
|
496
|
+
this.writeToServer(msg);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const args = toolCall.arguments ?? {};
|
|
501
|
+
|
|
502
|
+
// ── 1. Kill switch check ──
|
|
503
|
+
if (this.options.killSwitch?.isActive()) {
|
|
504
|
+
const reason = this.options.killSwitch.getStatus().reason;
|
|
505
|
+
this.emit("killSwitchActive", toolCall.name);
|
|
506
|
+
this.handleDeny(request, toolCall.name, args, {
|
|
507
|
+
action: "deny",
|
|
508
|
+
rule: "__kill_switch__",
|
|
509
|
+
message: `Kill switch active: ${reason}. All tool calls denied.`,
|
|
510
|
+
});
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ── 2. Injection detection ──
|
|
515
|
+
if (this.options.injectionDetector) {
|
|
516
|
+
const injection = this.options.injectionDetector.scan(toolCall);
|
|
517
|
+
if (injection.detected && injection.confidence !== "low") {
|
|
518
|
+
this.emit("injectionDetected", toolCall.name, injection.summary);
|
|
519
|
+
this.handleDeny(request, toolCall.name, args, {
|
|
520
|
+
action: "deny",
|
|
521
|
+
rule: "__injection_detector__",
|
|
522
|
+
message: `Prompt injection blocked: ${injection.summary}`,
|
|
523
|
+
});
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ── 3. Egress control ──
|
|
529
|
+
if (this.options.egressControl) {
|
|
530
|
+
const egress = this.options.egressControl.check(toolCall);
|
|
531
|
+
if (!egress.allowed) {
|
|
532
|
+
this.emit("egressBlocked", toolCall.name, egress.summary);
|
|
533
|
+
this.handleDeny(request, toolCall.name, args, {
|
|
534
|
+
action: "deny",
|
|
535
|
+
rule: "__egress_control__",
|
|
536
|
+
message: `Egress blocked: ${egress.summary}`,
|
|
537
|
+
});
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// ── 4. Policy engine — rule evaluation ──
|
|
543
|
+
const verdict = this.options.policyEngine.evaluate(toolCall);
|
|
544
|
+
|
|
545
|
+
// ── 5. Chain detection — record this call and check patterns ──
|
|
546
|
+
if (this.options.chainDetector && verdict.action !== "deny") {
|
|
547
|
+
const chain = this.options.chainDetector.record(toolCall);
|
|
548
|
+
if (chain.detected) {
|
|
549
|
+
const critical = chain.matches.some((m) => m.severity === "critical");
|
|
550
|
+
const summary = chain.matches.map((m) => `${m.chain}(${m.severity})`).join(", ");
|
|
551
|
+
this.emit("chainDetected", toolCall.name, summary);
|
|
552
|
+
// Critical chains are blocked, others are logged as warnings
|
|
553
|
+
if (critical) {
|
|
554
|
+
this.handleDeny(request, toolCall.name, args, {
|
|
555
|
+
action: "deny",
|
|
556
|
+
rule: "__chain_detector__",
|
|
557
|
+
message: `Critical tool chain blocked: ${summary}`,
|
|
558
|
+
});
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
// Non-critical chains: log the warning but let the call proceed
|
|
562
|
+
this.options.logger.log({
|
|
563
|
+
timestamp: new Date().toISOString(),
|
|
564
|
+
sessionId: this.sessionId,
|
|
565
|
+
direction: "request",
|
|
566
|
+
method: "tools/call",
|
|
567
|
+
tool: toolCall.name,
|
|
568
|
+
arguments: args,
|
|
569
|
+
verdict: {
|
|
570
|
+
action: "allow",
|
|
571
|
+
rule: "__chain_detector__",
|
|
572
|
+
message: `Suspicious tool chain (warning): ${summary}`,
|
|
573
|
+
},
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
switch (verdict.action) {
|
|
579
|
+
case "allow":
|
|
580
|
+
this.handleAllow(request, toolCall.name, args, verdict);
|
|
581
|
+
break;
|
|
582
|
+
case "deny":
|
|
583
|
+
this.handleDeny(request, toolCall.name, args, verdict);
|
|
584
|
+
break;
|
|
585
|
+
case "prompt":
|
|
586
|
+
this.handlePrompt(request, toolCall.name, args, verdict);
|
|
587
|
+
break;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Handle an allowed tool call — forward to server.
|
|
593
|
+
*/
|
|
594
|
+
private handleAllow(
|
|
595
|
+
request: JsonRpcRequest,
|
|
596
|
+
tool: string,
|
|
597
|
+
args: Record<string, unknown>,
|
|
598
|
+
verdict: PolicyVerdict
|
|
599
|
+
): void {
|
|
600
|
+
this.stats.forwarded++;
|
|
601
|
+
|
|
602
|
+
// Track this request so we can scan the response when it comes back
|
|
603
|
+
if (this.options.responseScanner) {
|
|
604
|
+
this.pendingToolCalls.set(request.id, { tool, args, timestamp: Date.now() });
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
this.options.logger.logAllow(
|
|
608
|
+
this.sessionId,
|
|
609
|
+
tool,
|
|
610
|
+
args,
|
|
611
|
+
verdict.rule,
|
|
612
|
+
verdict.message
|
|
613
|
+
);
|
|
614
|
+
this.emit("allowed", tool);
|
|
615
|
+
this.writeToServer(request);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Handle a denied tool call — return error to client, never forward.
|
|
620
|
+
*/
|
|
621
|
+
private handleDeny(
|
|
622
|
+
request: JsonRpcRequest,
|
|
623
|
+
tool: string,
|
|
624
|
+
args: Record<string, unknown>,
|
|
625
|
+
verdict: PolicyVerdict
|
|
626
|
+
): void {
|
|
627
|
+
this.stats.denied++;
|
|
628
|
+
this.options.logger.logDeny(
|
|
629
|
+
this.sessionId,
|
|
630
|
+
tool,
|
|
631
|
+
args,
|
|
632
|
+
verdict.rule,
|
|
633
|
+
verdict.message
|
|
634
|
+
);
|
|
635
|
+
this.emit("denied", tool, verdict.message);
|
|
636
|
+
|
|
637
|
+
// Send error back to client
|
|
638
|
+
const errorResponse = createDenyResponse(request.id, verdict.message);
|
|
639
|
+
this.writeToClient(errorResponse);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Handle a prompt tool call — ask for human approval.
|
|
644
|
+
*/
|
|
645
|
+
private async handlePrompt(
|
|
646
|
+
request: JsonRpcRequest,
|
|
647
|
+
tool: string,
|
|
648
|
+
args: Record<string, unknown>,
|
|
649
|
+
verdict: PolicyVerdict
|
|
650
|
+
): Promise<void> {
|
|
651
|
+
this.emit("prompted", tool, verdict.message);
|
|
652
|
+
|
|
653
|
+
if (!this.options.onPrompt) {
|
|
654
|
+
// No prompt handler — deny by default (fail-secure)
|
|
655
|
+
this.handleDeny(request, tool, args, {
|
|
656
|
+
...verdict,
|
|
657
|
+
action: "deny",
|
|
658
|
+
message: `${verdict.message} (auto-denied: no prompt handler)`,
|
|
659
|
+
});
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
try {
|
|
664
|
+
const approved = await this.options.onPrompt(
|
|
665
|
+
tool,
|
|
666
|
+
args,
|
|
667
|
+
verdict.message
|
|
668
|
+
);
|
|
669
|
+
|
|
670
|
+
if (approved) {
|
|
671
|
+
this.handleAllow(request, tool, args, {
|
|
672
|
+
...verdict,
|
|
673
|
+
action: "allow",
|
|
674
|
+
message: `${verdict.message} (manually approved)`,
|
|
675
|
+
});
|
|
676
|
+
} else {
|
|
677
|
+
this.handleDeny(request, tool, args, {
|
|
678
|
+
...verdict,
|
|
679
|
+
action: "deny",
|
|
680
|
+
message: `${verdict.message} (manually denied)`,
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
} catch (err) {
|
|
684
|
+
// Prompt failed — deny (fail-secure)
|
|
685
|
+
this.handleDeny(request, tool, args, {
|
|
686
|
+
...verdict,
|
|
687
|
+
action: "deny",
|
|
688
|
+
message: `${verdict.message} (prompt error: ${err})`,
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Extract scannable text from a JSON-RPC error response.
|
|
695
|
+
*/
|
|
696
|
+
private extractErrorText(response: JsonRpcResponse): string {
|
|
697
|
+
const parts: string[] = [];
|
|
698
|
+
if (response.error?.message) parts.push(response.error.message);
|
|
699
|
+
if (response.error?.data !== undefined) {
|
|
700
|
+
parts.push(typeof response.error.data === "string"
|
|
701
|
+
? response.error.data
|
|
702
|
+
: JSON.stringify(response.error.data));
|
|
703
|
+
}
|
|
704
|
+
return parts.join("\n");
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Write a JSON-RPC message to the MCP server (child's stdin).
|
|
709
|
+
*/
|
|
710
|
+
private writeToServer(msg: JsonRpcMessage): void {
|
|
711
|
+
if (!this.child?.stdin?.writable) return;
|
|
712
|
+
const data = serializeMessage(msg);
|
|
713
|
+
this.child.stdin.write(data);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Write a JSON-RPC message to the MCP client (our stdout).
|
|
718
|
+
*/
|
|
719
|
+
private writeToClient(msg: JsonRpcMessage): void {
|
|
720
|
+
const data = serializeMessage(msg);
|
|
721
|
+
process.stdout.write(data);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Create a terminal-based prompt handler.
|
|
727
|
+
*
|
|
728
|
+
* IMPORTANT: stdin/stdout are reserved for MCP JSON-RPC protocol.
|
|
729
|
+
* We open /dev/tty (Unix) or CON (Windows) directly to read from
|
|
730
|
+
* the controlling terminal. This prevents conflicts with the
|
|
731
|
+
* MCP message stream on stdin.
|
|
732
|
+
*
|
|
733
|
+
* Output goes to stderr (safe — MCP only uses stdout).
|
|
734
|
+
*/
|
|
735
|
+
export function createTerminalPromptHandler(): (
|
|
736
|
+
tool: string,
|
|
737
|
+
args: Record<string, unknown>,
|
|
738
|
+
message: string
|
|
739
|
+
) => Promise<boolean> {
|
|
740
|
+
return async (tool, args, message) => {
|
|
741
|
+
// Open the controlling terminal directly.
|
|
742
|
+
// stdin is the MCP protocol pipe — we MUST NOT read from it.
|
|
743
|
+
let ttyFd: number;
|
|
744
|
+
try {
|
|
745
|
+
// Works on Unix, macOS, and MINGW/Git Bash on Windows
|
|
746
|
+
ttyFd = fs.openSync("/dev/tty", "r");
|
|
747
|
+
} catch {
|
|
748
|
+
try {
|
|
749
|
+
// Fallback for native Windows (cmd.exe, PowerShell)
|
|
750
|
+
ttyFd = fs.openSync("CON", "r");
|
|
751
|
+
} catch {
|
|
752
|
+
// No controlling terminal (headless, CI, non-interactive MCP client)
|
|
753
|
+
process.stderr.write(
|
|
754
|
+
"\n[agent-wall] No terminal available for prompt — auto-denying (fail-secure)\n"
|
|
755
|
+
);
|
|
756
|
+
return false;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
const ttyInput = fs.createReadStream("", { fd: ttyFd, autoClose: false });
|
|
761
|
+
|
|
762
|
+
const rl = readline.createInterface({
|
|
763
|
+
input: ttyInput,
|
|
764
|
+
output: process.stderr,
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
// Show the tool call details on stderr
|
|
768
|
+
process.stderr.write("\n");
|
|
769
|
+
process.stderr.write("╔══════════════════════════════════════════════════╗\n");
|
|
770
|
+
process.stderr.write("║ Agent Wall: Approval Required ║\n");
|
|
771
|
+
process.stderr.write("╠══════════════════════════════════════════════════╣\n");
|
|
772
|
+
process.stderr.write(`║ Tool: ${tool}\n`);
|
|
773
|
+
process.stderr.write(`║ Rule: ${message}\n`);
|
|
774
|
+
process.stderr.write(`║ Args: ${JSON.stringify(args, null, 0).slice(0, 120)}\n`);
|
|
775
|
+
process.stderr.write("╚══════════════════════════════════════════════════╝\n");
|
|
776
|
+
|
|
777
|
+
return new Promise<boolean>((resolve) => {
|
|
778
|
+
rl.question(" Allow this call? [y/N]: ", (answer) => {
|
|
779
|
+
rl.close();
|
|
780
|
+
ttyInput.destroy();
|
|
781
|
+
try { fs.closeSync(ttyFd); } catch { /* ignore */ }
|
|
782
|
+
resolve(answer.trim().toLowerCase() === "y");
|
|
783
|
+
});
|
|
784
|
+
});
|
|
785
|
+
};
|
|
786
|
+
}
|