@provos/ironcurtain 0.3.1 → 0.4.1

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.
Files changed (101) hide show
  1. package/README.md +20 -1
  2. package/dist/cli.js +15 -0
  3. package/dist/cli.js.map +1 -1
  4. package/dist/config/index.js +12 -1
  5. package/dist/config/index.js.map +1 -1
  6. package/dist/config/mcp-servers.json +3 -0
  7. package/dist/config/types.d.ts +2 -0
  8. package/dist/config/user-config.d.ts +11 -0
  9. package/dist/config/user-config.js +56 -2
  10. package/dist/config/user-config.js.map +1 -1
  11. package/dist/docker/adapters/claude-code.d.ts +4 -0
  12. package/dist/docker/adapters/claude-code.js +35 -21
  13. package/dist/docker/adapters/claude-code.js.map +1 -1
  14. package/dist/docker/agent-adapter.d.ts +6 -12
  15. package/dist/docker/audit-log-tailer.d.ts +4 -3
  16. package/dist/docker/audit-log-tailer.js +9 -7
  17. package/dist/docker/audit-log-tailer.js.map +1 -1
  18. package/dist/docker/code-mode-proxy.d.ts +34 -0
  19. package/dist/docker/code-mode-proxy.js +137 -0
  20. package/dist/docker/code-mode-proxy.js.map +1 -0
  21. package/dist/docker/docker-agent-session.d.ts +17 -5
  22. package/dist/docker/docker-agent-session.js +171 -36
  23. package/dist/docker/docker-agent-session.js.map +1 -1
  24. package/dist/docker/docker-manager.js +106 -15
  25. package/dist/docker/docker-manager.js.map +1 -1
  26. package/dist/docker/mitm-proxy.d.ts +7 -4
  27. package/dist/docker/mitm-proxy.js +43 -13
  28. package/dist/docker/mitm-proxy.js.map +1 -1
  29. package/dist/docker/orientation.d.ts +3 -2
  30. package/dist/docker/orientation.js +6 -3
  31. package/dist/docker/orientation.js.map +1 -1
  32. package/dist/docker/platform.d.ts +20 -0
  33. package/dist/docker/platform.js +24 -0
  34. package/dist/docker/platform.js.map +1 -0
  35. package/dist/docker/types.d.ts +59 -7
  36. package/dist/sandbox/index.d.ts +25 -1
  37. package/dist/sandbox/index.js +78 -9
  38. package/dist/sandbox/index.js.map +1 -1
  39. package/dist/servers/fetch-server.d.ts +6 -1
  40. package/dist/servers/fetch-server.js +24 -12
  41. package/dist/servers/fetch-server.js.map +1 -1
  42. package/dist/session/agent-session.js +9 -5
  43. package/dist/session/agent-session.js.map +1 -1
  44. package/dist/session/index.js +18 -36
  45. package/dist/session/index.js.map +1 -1
  46. package/dist/session/preflight.js +6 -0
  47. package/dist/session/preflight.js.map +1 -1
  48. package/dist/session/prompts.d.ts +9 -3
  49. package/dist/session/prompts.js +28 -10
  50. package/dist/session/prompts.js.map +1 -1
  51. package/dist/signal/bot-command.d.ts +13 -0
  52. package/dist/signal/bot-command.js +54 -0
  53. package/dist/signal/bot-command.js.map +1 -0
  54. package/dist/signal/format.d.ts +31 -0
  55. package/dist/signal/format.js +101 -0
  56. package/dist/signal/format.js.map +1 -0
  57. package/dist/signal/index.d.ts +16 -0
  58. package/dist/signal/index.js +13 -0
  59. package/dist/signal/index.js.map +1 -0
  60. package/dist/signal/markdown-to-signal.d.ts +29 -0
  61. package/dist/signal/markdown-to-signal.js +154 -0
  62. package/dist/signal/markdown-to-signal.js.map +1 -0
  63. package/dist/signal/setup-signal.d.ts +48 -0
  64. package/dist/signal/setup-signal.js +443 -0
  65. package/dist/signal/setup-signal.js.map +1 -0
  66. package/dist/signal/signal-bot-daemon.d.ts +142 -0
  67. package/dist/signal/signal-bot-daemon.js +504 -0
  68. package/dist/signal/signal-bot-daemon.js.map +1 -0
  69. package/dist/signal/signal-config.d.ts +64 -0
  70. package/dist/signal/signal-config.js +53 -0
  71. package/dist/signal/signal-config.js.map +1 -0
  72. package/dist/signal/signal-container.d.ts +37 -0
  73. package/dist/signal/signal-container.js +112 -0
  74. package/dist/signal/signal-container.js.map +1 -0
  75. package/dist/signal/signal-transport.d.ts +34 -0
  76. package/dist/signal/signal-transport.js +70 -0
  77. package/dist/signal/signal-transport.js.map +1 -0
  78. package/dist/trusted-process/mcp-proxy-server.d.ts +111 -1
  79. package/dist/trusted-process/mcp-proxy-server.js +319 -247
  80. package/dist/trusted-process/mcp-proxy-server.js.map +1 -1
  81. package/dist/trusted-process/path-utils.d.ts +5 -1
  82. package/dist/trusted-process/path-utils.js +47 -4
  83. package/dist/trusted-process/path-utils.js.map +1 -1
  84. package/dist/trusted-process/policy-engine.d.ts +3 -1
  85. package/dist/trusted-process/policy-engine.js +17 -6
  86. package/dist/trusted-process/policy-engine.js.map +1 -1
  87. package/dist/trusted-process/sandbox-integration.d.ts +11 -4
  88. package/dist/trusted-process/sandbox-integration.js +13 -7
  89. package/dist/trusted-process/sandbox-integration.js.map +1 -1
  90. package/dist/trusted-process/tcp-server-transport.d.ts +43 -0
  91. package/dist/trusted-process/tcp-server-transport.js +119 -0
  92. package/dist/trusted-process/tcp-server-transport.js.map +1 -0
  93. package/dist/types/argument-roles.d.ts +7 -0
  94. package/dist/types/argument-roles.js +30 -5
  95. package/dist/types/argument-roles.js.map +1 -1
  96. package/docker/Dockerfile.base.arm64 +23 -0
  97. package/package.json +9 -4
  98. package/src/config/mcp-servers.json +3 -0
  99. package/dist/docker/managed-proxy.d.ts +0 -27
  100. package/dist/docker/managed-proxy.js +0 -175
  101. package/dist/docker/managed-proxy.js.map +0 -1
@@ -28,11 +28,13 @@
28
28
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
29
29
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
30
30
  import { UdsServerTransport } from './uds-server-transport.js';
31
+ import { TcpServerTransport } from './tcp-server-transport.js';
31
32
  import { CallToolRequestSchema, CompatibilityCallToolResultSchema, ListToolsRequestSchema, ListRootsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
32
33
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
33
34
  import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
34
35
  import { appendFileSync, existsSync, mkdtempSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
35
36
  import { join, resolve } from 'node:path';
37
+ import { fileURLToPath } from 'node:url';
36
38
  import { tmpdir } from 'node:os';
37
39
  import { v4 as uuidv4 } from 'uuid';
38
40
  import { loadGeneratedPolicy, extractServerDomainAllowlists, getPackageGeneratedDir } from '../config/index.js';
@@ -204,13 +206,19 @@ async function createAutoApproveModel() {
204
206
  return null;
205
207
  }
206
208
  }
207
- async function main() {
209
+ // ── Extracted functions ────────────────────────────────────────────────
210
+ /**
211
+ * Reads and validates proxy environment variables.
212
+ * Returns a typed config object or calls process.exit(1) on missing required vars.
213
+ */
214
+ export function parseProxyEnvConfig() {
208
215
  const auditLogPath = process.env.AUDIT_LOG_PATH ?? './audit.jsonl';
209
216
  const serversConfigJson = process.env.MCP_SERVERS_CONFIG;
210
217
  const generatedDir = process.env.GENERATED_DIR;
211
218
  const protectedPathsJson = process.env.PROTECTED_PATHS ?? '[]';
212
219
  const sessionLogPath = process.env.SESSION_LOG_PATH;
213
220
  const allowedDirectory = process.env.ALLOWED_DIRECTORY;
221
+ const containerWorkspaceDir = process.env.CONTAINER_WORKSPACE_DIR;
214
222
  const escalationDir = process.env.ESCALATION_DIR;
215
223
  if (!serversConfigJson) {
216
224
  process.stderr.write('MCP_SERVERS_CONFIG environment variable is required\n');
@@ -230,7 +238,6 @@ async function main() {
230
238
  const allServersConfig = JSON.parse(serversConfigJson);
231
239
  const protectedPaths = JSON.parse(protectedPathsJson);
232
240
  // When SERVER_FILTER is set, only connect to that single backend server.
233
- // This allows per-server proxy processes with clean tool naming.
234
241
  const serverFilter = process.env.SERVER_FILTER;
235
242
  const serversConfig = serverFilter
236
243
  ? { [serverFilter]: allServersConfig[serverFilter] }
@@ -240,17 +247,25 @@ async function main() {
240
247
  process.stderr.write(`SERVER_FILTER: unknown server "${serverFilter}"\n`);
241
248
  process.exit(1);
242
249
  }
243
- const { compiledPolicy, toolAnnotations, dynamicLists } = loadGeneratedPolicy(generatedDir, getPackageGeneratedDir());
244
- const serverDomainAllowlists = extractServerDomainAllowlists(serversConfig);
245
- const policyEngine = new PolicyEngine(compiledPolicy, toolAnnotations, protectedPaths, allowedDirectory, serverDomainAllowlists, dynamicLists);
246
- const auditLog = new AuditLog(auditLogPath);
247
- const circuitBreaker = new CallCircuitBreaker();
248
- // ── Auto-approve model setup (once at startup) ──────────────────────
249
- const autoApproveModel = await createAutoApproveModel();
250
- // Compute initial roots from compiled policy for the MCP Roots protocol
251
- const policyRoots = extractPolicyRoots(compiledPolicy, allowedDirectory ?? '/tmp');
252
- const mcpRoots = toMcpRoots(policyRoots);
253
- // ── Sandbox availability check (once for all servers) ──────────────
250
+ return {
251
+ auditLogPath,
252
+ serversConfig,
253
+ generatedDir,
254
+ protectedPaths,
255
+ sessionLogPath,
256
+ allowedDirectory,
257
+ containerWorkspaceDir,
258
+ escalationDir,
259
+ serverCredentials,
260
+ sandboxPolicy,
261
+ };
262
+ }
263
+ /**
264
+ * Validates sandbox availability against the configured policy.
265
+ * Logs warnings to session log. Throws when enforce mode is active
266
+ * but sandboxing is unavailable.
267
+ */
268
+ export function validateSandboxAvailability(sandboxPolicy, sessionLogPath, platform) {
254
269
  const { platformSupported, errors: depErrors, warnings: depWarnings } = checkSandboxAvailability();
255
270
  if (sessionLogPath) {
256
271
  for (const warning of depWarnings) {
@@ -258,7 +273,7 @@ async function main() {
258
273
  }
259
274
  }
260
275
  if (sandboxPolicy === 'enforce' && (!platformSupported || depErrors.length > 0)) {
261
- const reasons = !platformSupported ? [`Platform ${process.platform} not supported`] : depErrors;
276
+ const reasons = !platformSupported ? [`Platform ${platform} not supported`] : depErrors;
262
277
  throw new Error(`[sandbox] FATAL: sandboxPolicy is "enforce" but sandboxing is unavailable:\n` +
263
278
  reasons.map((r) => ` - ${r}`).join('\n') +
264
279
  '\n' +
@@ -266,23 +281,263 @@ async function main() {
266
281
  }
267
282
  const sandboxAvailable = platformSupported && depErrors.length === 0;
268
283
  if (!sandboxAvailable && sessionLogPath) {
269
- const missing = depErrors.length > 0 ? depErrors.join(', ') : `platform ${process.platform}`;
284
+ const missing = depErrors.length > 0 ? depErrors.join(', ') : `platform ${platform}`;
270
285
  logToSessionFile(sessionLogPath, `[sandbox] WARNING: OS-level sandboxing unavailable (${missing}). ` +
271
286
  `Servers will run without OS containment. ` +
272
287
  `Set SANDBOX_POLICY=enforce to require sandboxing.`);
273
288
  }
274
- // ── Resolve sandbox configs and write per-server srt settings ─────
289
+ return { sandboxAvailable };
290
+ }
291
+ /**
292
+ * Resolves per-server sandbox configurations and writes srt settings
293
+ * files for sandboxed servers. Returns the config map and the temp
294
+ * settings directory path.
295
+ */
296
+ export function resolveServerSandboxConfigs(serversConfig, allowedDirectory, sandboxAvailable, sandboxPolicy) {
275
297
  const resolvedSandboxConfigs = new Map();
298
+ const serverCwdPaths = new Map();
276
299
  const settingsDir = mkdtempSync(join(tmpdir(), 'ironcurtain-srt-'));
277
300
  for (const [serverName, config] of Object.entries(serversConfig)) {
278
301
  const resolved = resolveSandboxConfig(config, allowedDirectory ?? '/tmp', sandboxAvailable, sandboxPolicy);
279
302
  resolvedSandboxConfigs.set(serverName, resolved);
280
303
  if (resolved.sandboxed) {
281
- writeServerSettings(serverName, resolved.config, settingsDir);
304
+ const { cwdPath } = writeServerSettings(serverName, resolved.config, settingsDir);
305
+ serverCwdPaths.set(serverName, cwdPath);
306
+ }
307
+ }
308
+ return { resolvedSandboxConfigs, settingsDir, serverCwdPaths };
309
+ }
310
+ /** Builds a lookup map from tool name to ProxiedTool for routing. */
311
+ export function buildToolMap(allTools) {
312
+ const toolMap = new Map();
313
+ for (const tool of allTools) {
314
+ toolMap.set(tool.name, tool);
315
+ }
316
+ return toolMap;
317
+ }
318
+ /**
319
+ * Creates an audit entry and logs it. Extracted so the audit shape
320
+ * construction is testable independently.
321
+ */
322
+ export function buildAuditEntry(request, argsForTransport, policyDecision, result, durationMs, options) {
323
+ return {
324
+ timestamp: request.timestamp,
325
+ requestId: request.requestId,
326
+ serverName: request.serverName,
327
+ toolName: request.toolName,
328
+ arguments: argsForTransport,
329
+ policyDecision,
330
+ escalationResult: options.escalationResult,
331
+ result,
332
+ durationMs,
333
+ sandboxed: options.sandboxed || undefined,
334
+ autoApproved: options.autoApproved || undefined,
335
+ };
336
+ }
337
+ /**
338
+ * Handles a single tool call request: policy evaluation, escalation,
339
+ * circuit breaker check, and forwarding to the real MCP server.
340
+ *
341
+ * This is the core security logic extracted from the CallTool handler
342
+ * for independent unit testing.
343
+ */
344
+ export async function handleCallTool(toolName, rawArgs, deps) {
345
+ const toolInfo = deps.toolMap.get(toolName);
346
+ if (!toolInfo) {
347
+ return {
348
+ content: [{ type: 'text', text: `Unknown tool: ${toolName}` }],
349
+ isError: true,
350
+ };
351
+ }
352
+ // Annotation-driven normalization: split into transport vs policy args
353
+ const annotation = deps.policyEngine.getAnnotation(toolInfo.serverName, toolInfo.name);
354
+ if (!annotation) {
355
+ return {
356
+ content: [
357
+ {
358
+ type: 'text',
359
+ text: `Missing annotation for tool: ${toolInfo.serverName}__${toolInfo.name}. Re-run 'ironcurtain annotate-tools' to update.`,
360
+ },
361
+ ],
362
+ isError: true,
363
+ };
364
+ }
365
+ const { argsForTransport, argsForPolicy } = prepareToolArgs(rawArgs, annotation, deps.allowedDirectory, deps.containerWorkspaceDir);
366
+ const request = {
367
+ requestId: uuidv4(),
368
+ serverName: toolInfo.serverName,
369
+ toolName: toolInfo.name,
370
+ arguments: argsForPolicy,
371
+ timestamp: new Date().toISOString(),
372
+ };
373
+ const evaluation = deps.policyEngine.evaluate(request);
374
+ const policyDecision = {
375
+ status: evaluation.decision,
376
+ rule: evaluation.rule,
377
+ reason: evaluation.reason,
378
+ };
379
+ let escalationResult;
380
+ let autoApproved = false;
381
+ const serverSandboxConfig = deps.resolvedSandboxConfigs.get(toolInfo.serverName);
382
+ const serverIsSandboxed = serverSandboxConfig?.sandboxed === true;
383
+ function logAudit(result, durationMs, overrideEscalation) {
384
+ const entry = buildAuditEntry(request, argsForTransport, policyDecision, result, durationMs, {
385
+ escalationResult: overrideEscalation ?? escalationResult,
386
+ sandboxed: serverIsSandboxed,
387
+ autoApproved,
388
+ });
389
+ deps.auditLog.log(entry);
390
+ }
391
+ if (evaluation.decision === 'escalate') {
392
+ if (!deps.escalationDir) {
393
+ logAudit({ status: 'denied', error: evaluation.reason }, 0, 'denied');
394
+ return {
395
+ content: [
396
+ {
397
+ type: 'text',
398
+ text: `ESCALATION REQUIRED: ${evaluation.reason}. Action denied (no escalation handler).`,
399
+ },
400
+ ],
401
+ isError: true,
402
+ };
403
+ }
404
+ // Try auto-approve before falling through to human escalation
405
+ if (deps.autoApproveModel) {
406
+ const userMessage = readUserContext(deps.escalationDir);
407
+ if (userMessage) {
408
+ const autoResult = await autoApprove({
409
+ userMessage,
410
+ toolName: `${toolInfo.serverName}/${toolInfo.name}`,
411
+ escalationReason: evaluation.reason,
412
+ arguments: extractArgsForAutoApprove(argsForPolicy, annotation),
413
+ }, deps.autoApproveModel);
414
+ if (autoResult.decision === 'approve') {
415
+ autoApproved = true;
416
+ escalationResult = 'approved';
417
+ policyDecision.status = 'allow';
418
+ policyDecision.reason = `Auto-approved: ${autoResult.reasoning}`;
419
+ }
420
+ }
421
+ }
422
+ if (!autoApproved) {
423
+ const escalationId = uuidv4();
424
+ const decision = await waitForEscalationDecision(deps.escalationDir, {
425
+ escalationId,
426
+ serverName: request.serverName,
427
+ toolName: request.toolName,
428
+ arguments: argsForTransport,
429
+ reason: evaluation.reason,
430
+ });
431
+ if (decision === 'denied') {
432
+ logAudit({ status: 'denied', error: evaluation.reason }, 0, 'denied');
433
+ return {
434
+ content: [{ type: 'text', text: `ESCALATION DENIED: ${evaluation.reason}` }],
435
+ isError: true,
436
+ };
437
+ }
438
+ escalationResult = 'approved';
439
+ policyDecision.status = 'allow';
440
+ policyDecision.reason = 'Approved by human during escalation';
441
+ }
442
+ // Expand roots for approved path arguments only (skip URLs, opaques)
443
+ const state = deps.clientStates.get(toolInfo.serverName);
444
+ if (state) {
445
+ const pathValues = extractAnnotatedPaths(argsForTransport, annotation, getPathRoles());
446
+ for (const p of pathValues) {
447
+ await addRootToClient(state, {
448
+ uri: `file://${directoryForPath(p)}`,
449
+ name: 'escalation-approved',
450
+ });
451
+ }
452
+ }
453
+ }
454
+ if (evaluation.decision === 'deny') {
455
+ logAudit({ status: 'denied', error: evaluation.reason }, 0);
456
+ return {
457
+ content: [{ type: 'text', text: `DENIED: ${evaluation.reason}` }],
458
+ isError: true,
459
+ };
460
+ }
461
+ // Circuit breaker: deny if the same tool+args is called too many times
462
+ const cbVerdict = deps.circuitBreaker.check(toolInfo.name, argsForTransport);
463
+ if (!cbVerdict.allowed) {
464
+ logAudit({ status: 'denied', error: cbVerdict.reason }, 0);
465
+ return {
466
+ content: [{ type: 'text', text: cbVerdict.reason }],
467
+ isError: true,
468
+ };
469
+ }
470
+ // Policy allows -- forward to the real MCP server with transport args
471
+ const startTime = Date.now();
472
+ try {
473
+ const clientState = deps.clientStates.get(toolInfo.serverName);
474
+ if (!clientState) {
475
+ const err = `Internal error: no client connection for server "${toolInfo.serverName}"`;
476
+ logAudit({ status: 'denied', error: err }, 0);
477
+ return { content: [{ type: 'text', text: err }], isError: true };
478
+ }
479
+ const client = clientState.client;
480
+ // TODO(workaround): Remove once @cyanheads/git-mcp-server fixes outputSchema declarations.
481
+ // Uses CompatibilityCallToolResultSchema for permissive response parsing.
482
+ const result = await client.callTool({
483
+ name: toolInfo.name,
484
+ arguments: argsForTransport,
485
+ }, CompatibilityCallToolResultSchema, { timeout: getEscalationTimeoutMs() });
486
+ if (result.isError) {
487
+ const errorText = extractTextFromContent(result.content) ?? 'Unknown tool error';
488
+ const errorMessage = annotateSandboxViolation(errorText, serverIsSandboxed);
489
+ logAudit({ status: 'error', error: errorMessage }, Date.now() - startTime);
490
+ return { content: result.content, isError: true };
491
+ }
492
+ logAudit({ status: 'success' }, Date.now() - startTime);
493
+ return { content: result.content };
494
+ }
495
+ catch (err) {
496
+ const rawError = err instanceof Error ? err.message : String(err);
497
+ const errorMessage = annotateSandboxViolation(rawError, serverIsSandboxed);
498
+ logAudit({ status: 'error', error: errorMessage }, Date.now() - startTime);
499
+ return {
500
+ content: [{ type: 'text', text: `Error: ${errorMessage}` }],
501
+ isError: true,
502
+ };
503
+ }
504
+ }
505
+ /**
506
+ * Selects and validates the proxy transport based on environment variables.
507
+ * Returns 'tcp', 'uds', or 'stdio' along with the transport options.
508
+ */
509
+ export function selectTransportConfig() {
510
+ const proxyTcpPort = process.env.PROXY_TCP_PORT;
511
+ const proxySocketPath = process.env.PROXY_SOCKET_PATH;
512
+ if (proxyTcpPort) {
513
+ const parsedPort = parseInt(proxyTcpPort, 10);
514
+ if (!Number.isFinite(parsedPort) || parsedPort < 0 || parsedPort > 65535) {
515
+ throw new Error(`Invalid PROXY_TCP_PORT value "${proxyTcpPort}". Expected an integer between 0 and 65535.`);
282
516
  }
517
+ return { kind: 'tcp', port: parsedPort, portFilePath: process.env.PROXY_PORT_FILE };
518
+ }
519
+ if (proxySocketPath) {
520
+ return { kind: 'uds', socketPath: proxySocketPath };
283
521
  }
522
+ return { kind: 'stdio' };
523
+ }
524
+ // ── main() -- thin orchestrator ────────────────────────────────────────
525
+ async function main() {
526
+ const envConfig = parseProxyEnvConfig();
527
+ const { auditLogPath, serversConfig, generatedDir, protectedPaths, sessionLogPath, allowedDirectory, containerWorkspaceDir, escalationDir, serverCredentials, sandboxPolicy, } = envConfig;
528
+ const { compiledPolicy, toolAnnotations, dynamicLists } = loadGeneratedPolicy(generatedDir, getPackageGeneratedDir());
529
+ const serverDomainAllowlists = extractServerDomainAllowlists(serversConfig);
530
+ const policyEngine = new PolicyEngine(compiledPolicy, toolAnnotations, protectedPaths, allowedDirectory, serverDomainAllowlists, dynamicLists);
531
+ const auditLog = new AuditLog(auditLogPath);
532
+ const circuitBreaker = new CallCircuitBreaker();
533
+ const autoApproveModel = await createAutoApproveModel();
534
+ const policyRoots = extractPolicyRoots(compiledPolicy, allowedDirectory ?? '/tmp');
535
+ const mcpRoots = toMcpRoots(policyRoots);
536
+ // ── Sandbox availability & config resolution ──────────────────────
537
+ const { sandboxAvailable } = validateSandboxAvailability(sandboxPolicy, sessionLogPath, process.platform);
538
+ const { resolvedSandboxConfigs, settingsDir, serverCwdPaths } = resolveServerSandboxConfigs(serversConfig, allowedDirectory, sandboxAvailable, sandboxPolicy);
539
+ // ── Connect to real MCP servers ───────────────────────────────────
284
540
  const clientStates = new Map();
285
- // Connect to real MCP servers as clients, wrapping sandboxed ones with srt
286
541
  const allTools = [];
287
542
  for (const [serverName, config] of Object.entries(serversConfig)) {
288
543
  const resolved = resolvedSandboxConfigs.get(serverName);
@@ -292,22 +547,13 @@ async function main() {
292
547
  const transport = new StdioClientTransport({
293
548
  command: wrapped.command,
294
549
  args: wrapped.args,
295
- // Always pass full process.env -- never rely on getDefaultEnvironment()
296
- // which strips vars that srt and MCP servers may need.
297
- // Server credentials are merged last so they override both system env
298
- // and static mcp-servers.json env values.
299
550
  env: { ...process.env, ...(config.env ?? {}), ...serverCredentials },
300
- stderr: 'pipe', // Prevent child server stderr from leaking to the terminal
301
- // Sandboxed servers get the sandbox dir as cwd so relative-path writes
302
- // (e.g., log files) land inside the writable sandbox instead of failing
303
- // with EROFS on the read-only host filesystem.
304
- ...(resolved.sandboxed && allowedDirectory ? { cwd: allowedDirectory } : {}),
551
+ stderr: 'pipe',
552
+ // Sandboxed servers use a per-server temp dir as CWD (not the sandbox)
553
+ // to prevent srt/bwrap ghost dotfiles from polluting the sandbox directory.
554
+ ...(resolved.sandboxed && serverCwdPaths.has(serverName) ? { cwd: serverCwdPaths.get(serverName) } : {}),
305
555
  });
306
- // Capture stderr per-server for diagnostic output on connection failure
307
556
  let serverStderr = '';
308
- // Drain the piped stderr to prevent buffer backpressure from blocking
309
- // the child process. Write output to the session log if configured.
310
- // Known credential values are redacted before writing to the log.
311
557
  if (transport.stderr) {
312
558
  transport.stderr.on('data', (chunk) => {
313
559
  const text = chunk.toString();
@@ -322,12 +568,7 @@ async function main() {
322
568
  });
323
569
  }
324
570
  const client = new Client({ name: 'ironcurtain-proxy', version: VERSION }, { capabilities: { roots: { listChanged: true } } });
325
- // Mutable copy per client -- root expansion pushes to this array
326
571
  const state = { client, roots: [...mcpRoots] };
327
- // When the server asks for roots, return the current set.
328
- // If a rootsRefreshed callback is registered (from escalation-triggered
329
- // root expansion), resolve it so the caller knows the server has
330
- // the latest roots.
331
572
  client.setRequestHandler(ListRootsRequestSchema, () => {
332
573
  if (state.rootsRefreshed) {
333
574
  state.rootsRefreshed();
@@ -354,16 +595,10 @@ async function main() {
354
595
  });
355
596
  }
356
597
  }
357
- // Build a lookup map for routing tool calls
358
- const toolMap = new Map();
359
- for (const tool of allTools) {
360
- toolMap.set(tool.name, tool);
361
- }
362
- // Create the proxy MCP server using the low-level Server API
363
- // so we can pass through raw JSON schemas without Zod conversion
598
+ const toolMap = buildToolMap(allTools);
599
+ // ── Create the proxy MCP server ───────────────────────────────────
364
600
  // eslint-disable-next-line @typescript-eslint/no-deprecated -- intentional use of low-level Server for raw JSON schema passthrough
365
601
  const server = new Server({ name: 'ironcurtain-proxy', version: VERSION }, { capabilities: { tools: {} } });
366
- // Handle tools/list -- return all proxied tool schemas verbatim
367
602
  server.setRequestHandler(ListToolsRequestSchema, () => {
368
603
  return {
369
604
  tools: allTools.map((t) => ({
@@ -373,209 +608,43 @@ async function main() {
373
608
  })),
374
609
  };
375
610
  });
376
- // Handle tools/call -- evaluate policy, then forward or deny
611
+ const callToolDeps = {
612
+ toolMap,
613
+ policyEngine,
614
+ auditLog,
615
+ circuitBreaker,
616
+ clientStates,
617
+ resolvedSandboxConfigs,
618
+ allowedDirectory,
619
+ containerWorkspaceDir,
620
+ escalationDir,
621
+ autoApproveModel,
622
+ };
377
623
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
378
- const toolName = req.params.name;
379
- const rawArgs = req.params.arguments ?? {};
380
- const toolInfo = toolMap.get(toolName);
381
- if (!toolInfo) {
382
- return {
383
- content: [{ type: 'text', text: `Unknown tool: ${toolName}` }],
384
- isError: true,
385
- };
386
- }
387
- // Annotation-driven normalization: split into transport vs policy args
388
- const annotation = policyEngine.getAnnotation(toolInfo.serverName, toolInfo.name);
389
- if (!annotation) {
390
- return {
391
- content: [
392
- {
393
- type: 'text',
394
- text: `Missing annotation for tool: ${toolInfo.serverName}__${toolInfo.name}. Re-run 'ironcurtain annotate-tools' to update.`,
395
- },
396
- ],
397
- isError: true,
398
- };
399
- }
400
- const { argsForTransport, argsForPolicy } = prepareToolArgs(rawArgs, annotation, allowedDirectory);
401
- const request = {
402
- requestId: uuidv4(),
403
- serverName: toolInfo.serverName,
404
- toolName: toolInfo.name,
405
- arguments: argsForPolicy,
406
- timestamp: new Date().toISOString(),
407
- };
408
- const evaluation = policyEngine.evaluate(request);
409
- const policyDecision = {
410
- status: evaluation.decision,
411
- rule: evaluation.rule,
412
- reason: evaluation.reason,
413
- };
414
- // Tracks the escalation outcome for audit logging when an approved
415
- // escalation falls through to the forwarding section below.
416
- let escalationResult;
417
- // Track whether auto-approver handled this escalation
418
- let autoApproved = false;
419
- // Look up whether this server is sandboxed for audit logging
420
- const serverSandboxConfig = resolvedSandboxConfigs.get(toolInfo.serverName);
421
- const serverIsSandboxed = serverSandboxConfig?.sandboxed === true;
422
- // Audit log records argsForTransport (what was actually sent to the MCP server)
423
- function logAudit(result, durationMs, overrideEscalation) {
424
- const entry = {
425
- timestamp: request.timestamp,
426
- requestId: request.requestId,
427
- serverName: request.serverName,
428
- toolName: request.toolName,
429
- arguments: argsForTransport,
430
- policyDecision,
431
- escalationResult: overrideEscalation ?? escalationResult,
432
- result,
433
- durationMs,
434
- sandboxed: serverIsSandboxed || undefined,
435
- autoApproved: autoApproved || undefined,
436
- };
437
- auditLog.log(entry);
438
- }
439
- if (evaluation.decision === 'escalate') {
440
- if (!escalationDir) {
441
- // No escalation directory configured -- auto-deny (backward compatible)
442
- logAudit({ status: 'denied', error: evaluation.reason }, 0, 'denied');
443
- return {
444
- content: [
445
- {
446
- type: 'text',
447
- text: `ESCALATION REQUIRED: ${evaluation.reason}. Action denied (no escalation handler).`,
448
- },
449
- ],
450
- isError: true,
451
- };
452
- }
453
- // Try auto-approve before falling through to human escalation
454
- if (autoApproveModel) {
455
- const userMessage = readUserContext(escalationDir);
456
- if (userMessage) {
457
- const autoResult = await autoApprove({
458
- userMessage,
459
- toolName: `${toolInfo.serverName}/${toolInfo.name}`,
460
- escalationReason: evaluation.reason,
461
- arguments: extractArgsForAutoApprove(argsForPolicy, annotation),
462
- }, autoApproveModel);
463
- if (autoResult.decision === 'approve') {
464
- autoApproved = true;
465
- escalationResult = 'approved';
466
- policyDecision.status = 'allow';
467
- policyDecision.reason = `Auto-approved: ${autoResult.reasoning}`;
468
- }
469
- }
470
- }
471
- if (!autoApproved) {
472
- // File-based escalation rendezvous: write request, poll for response
473
- const escalationId = uuidv4();
474
- const decision = await waitForEscalationDecision(escalationDir, {
475
- escalationId,
476
- serverName: request.serverName,
477
- toolName: request.toolName,
478
- arguments: argsForTransport,
479
- reason: evaluation.reason,
480
- });
481
- if (decision === 'denied') {
482
- logAudit({ status: 'denied', error: evaluation.reason }, 0, 'denied');
483
- return {
484
- content: [{ type: 'text', text: `ESCALATION DENIED: ${evaluation.reason}` }],
485
- isError: true,
486
- };
487
- }
488
- // Approved by human -- update policy decision and fall through
489
- escalationResult = 'approved';
490
- policyDecision.status = 'allow';
491
- policyDecision.reason = 'Approved by human during escalation';
492
- }
493
- // Expand roots for approved path arguments only (skip URLs, opaques)
494
- const state = clientStates.get(toolInfo.serverName);
495
- if (state) {
496
- const pathValues = extractAnnotatedPaths(argsForTransport, annotation, getPathRoles());
497
- for (const p of pathValues) {
498
- await addRootToClient(state, {
499
- uri: `file://${directoryForPath(p)}`,
500
- name: 'escalation-approved',
501
- });
502
- }
503
- }
504
- }
505
- if (evaluation.decision === 'deny') {
506
- logAudit({ status: 'denied', error: evaluation.reason }, 0);
507
- return {
508
- content: [{ type: 'text', text: `DENIED: ${evaluation.reason}` }],
509
- isError: true,
510
- };
511
- }
512
- // Circuit breaker: deny if the same tool+args is called too many times
513
- const cbVerdict = circuitBreaker.check(toolInfo.name, argsForTransport);
514
- if (!cbVerdict.allowed) {
515
- logAudit({ status: 'denied', error: cbVerdict.reason }, 0);
516
- return {
517
- content: [{ type: 'text', text: cbVerdict.reason }],
518
- isError: true,
519
- };
624
+ return handleCallTool(req.params.name, req.params.arguments ?? {}, callToolDeps);
625
+ });
626
+ // ── Transport selection ───────────────────────────────────────────
627
+ const transportConfig = selectTransportConfig();
628
+ let transport;
629
+ if (transportConfig.kind === 'tcp') {
630
+ const tcpTransport = new TcpServerTransport('0.0.0.0', transportConfig.port);
631
+ transport = tcpTransport;
632
+ await tcpTransport.start();
633
+ if (transportConfig.portFilePath) {
634
+ writeFileSync(transportConfig.portFilePath, String(tcpTransport.port));
520
635
  }
521
- // Policy allows -- forward to the real MCP server with transport args
522
- const startTime = Date.now();
523
- try {
524
- const clientState = clientStates.get(toolInfo.serverName);
525
- if (!clientState) {
526
- const err = `Internal error: no client connection for server "${toolInfo.serverName}"`;
527
- logAudit({ status: 'denied', error: err }, 0);
528
- return { content: [{ type: 'text', text: err }], isError: true };
529
- }
530
- const client = clientState.client;
531
- // TODO(workaround): Remove once @cyanheads/git-mcp-server fixes outputSchema declarations.
532
- //
533
- // WHY: The git MCP server v2.8.4 declares outputSchema for tools like git_add and
534
- // git_commit, but the structuredContent it actually returns does not match those schemas.
535
- // The MCP SDK v1.26.0 Client.callTool() validates responses client-side against the
536
- // declared outputSchema and throws McpError(-32602, "Structured content does not match
537
- // the tool's output schema: ...") when there is a mismatch.
538
- //
539
- // WHAT: git_add declares required properties {success, stagedFiles, totalFiles, status}
540
- // in its outputSchema, but actual responses (especially errors) are missing these and
541
- // include additional undeclared properties. git_commit similarly requires {success,
542
- // commitHash, author, timestamp, committedFiles, status} but returns different shapes.
543
- //
544
- // FIX: Passing CompatibilityCallToolResultSchema instead of the default
545
- // CallToolResultSchema makes the response parsing more permissive, which avoids the
546
- // client-side validation failure.
547
- //
548
- // CONSEQUENCE: By using CompatibilityCallToolResultSchema we lose client-side output
549
- // validation for ALL MCP servers proxied through this path, not just the git server.
550
- const result = await client.callTool({
551
- name: toolInfo.name,
552
- arguments: argsForTransport,
553
- }, CompatibilityCallToolResultSchema, { timeout: getEscalationTimeoutMs() });
554
- if (result.isError) {
555
- const errorText = extractTextFromContent(result.content) ?? 'Unknown tool error';
556
- const errorMessage = annotateSandboxViolation(errorText, serverIsSandboxed);
557
- logAudit({ status: 'error', error: errorMessage }, Date.now() - startTime);
558
- return { content: result.content, isError: true };
559
- }
560
- logAudit({ status: 'success' }, Date.now() - startTime);
561
- return { content: result.content };
636
+ if (sessionLogPath) {
637
+ logToSessionFile(sessionLogPath, `MCP proxy listening on 0.0.0.0:${tcpTransport.port}`);
562
638
  }
563
- catch (err) {
564
- const rawError = err instanceof Error ? err.message : String(err);
565
- const errorMessage = annotateSandboxViolation(rawError, serverIsSandboxed);
566
- logAudit({ status: 'error', error: errorMessage }, Date.now() - startTime);
567
- return {
568
- content: [{ type: 'text', text: `Error: ${errorMessage}` }],
569
- isError: true,
570
- };
571
- }
572
- });
573
- // Select transport: UDS for Docker agent sessions, stdio otherwise
574
- const proxySocketPath = process.env.PROXY_SOCKET_PATH;
575
- const transport = proxySocketPath ? new UdsServerTransport(proxySocketPath) : new StdioServerTransport();
639
+ }
640
+ else if (transportConfig.kind === 'uds') {
641
+ transport = new UdsServerTransport(transportConfig.socketPath);
642
+ }
643
+ else {
644
+ transport = new StdioServerTransport();
645
+ }
576
646
  await server.connect(transport);
577
- // Clean shutdown -- handle both SIGINT and SIGTERM since this process
578
- // is spawned as a child by Code Mode and may receive either signal.
647
+ // ── Shutdown handler ──────────────────────────────────────────────
579
648
  async function shutdown() {
580
649
  for (const state of clientStates.values()) {
581
650
  try {
@@ -602,8 +671,11 @@ async function main() {
602
671
  void shutdown();
603
672
  });
604
673
  }
605
- main().catch((err) => {
606
- process.stderr.write(`MCP Proxy Server fatal error: ${String(err)}\n`);
607
- process.exit(1);
608
- });
674
+ // Only run main() when this module is the entry point (not when imported for testing)
675
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
676
+ main().catch((err) => {
677
+ process.stderr.write(`MCP Proxy Server fatal error: ${String(err)}\n`);
678
+ process.exit(1);
679
+ });
680
+ }
609
681
  //# sourceMappingURL=mcp-proxy-server.js.map