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