@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/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
+ }