@provos/ironcurtain 0.4.0 → 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.
- package/dist/config/index.js +6 -0
- package/dist/config/index.js.map +1 -1
- package/dist/config/mcp-servers.json +3 -0
- package/dist/config/types.d.ts +2 -0
- package/dist/docker/adapters/claude-code.d.ts +4 -0
- package/dist/docker/adapters/claude-code.js +32 -29
- package/dist/docker/adapters/claude-code.js.map +1 -1
- package/dist/docker/agent-adapter.d.ts +4 -12
- package/dist/docker/code-mode-proxy.d.ts +34 -0
- package/dist/docker/code-mode-proxy.js +137 -0
- package/dist/docker/code-mode-proxy.js.map +1 -0
- package/dist/docker/docker-agent-session.d.ts +7 -6
- package/dist/docker/docker-agent-session.js +94 -40
- package/dist/docker/docker-agent-session.js.map +1 -1
- package/dist/docker/docker-manager.js +24 -0
- package/dist/docker/docker-manager.js.map +1 -1
- package/dist/docker/orientation.d.ts +3 -2
- package/dist/docker/orientation.js +3 -3
- package/dist/docker/orientation.js.map +1 -1
- package/dist/docker/types.d.ts +12 -2
- package/dist/sandbox/index.d.ts +25 -1
- package/dist/sandbox/index.js +78 -9
- package/dist/sandbox/index.js.map +1 -1
- package/dist/servers/fetch-server.d.ts +6 -1
- package/dist/servers/fetch-server.js +24 -12
- package/dist/servers/fetch-server.js.map +1 -1
- package/dist/session/agent-session.js +9 -5
- package/dist/session/agent-session.js.map +1 -1
- package/dist/session/index.js +3 -31
- package/dist/session/index.js.map +1 -1
- package/dist/session/preflight.js +6 -0
- package/dist/session/preflight.js.map +1 -1
- package/dist/session/prompts.d.ts +9 -3
- package/dist/session/prompts.js +28 -10
- package/dist/session/prompts.js.map +1 -1
- package/dist/trusted-process/mcp-proxy-server.d.ts +111 -1
- package/dist/trusted-process/mcp-proxy-server.js +307 -266
- package/dist/trusted-process/mcp-proxy-server.js.map +1 -1
- package/dist/trusted-process/policy-engine.d.ts +3 -1
- package/dist/trusted-process/policy-engine.js +17 -6
- package/dist/trusted-process/policy-engine.js.map +1 -1
- package/dist/trusted-process/sandbox-integration.d.ts +11 -4
- package/dist/trusted-process/sandbox-integration.js +13 -7
- package/dist/trusted-process/sandbox-integration.js.map +1 -1
- package/dist/types/argument-roles.d.ts +7 -0
- package/dist/types/argument-roles.js +12 -2
- package/dist/types/argument-roles.js.map +1 -1
- package/package.json +4 -2
- package/src/config/mcp-servers.json +3 -0
- package/dist/docker/managed-proxy.d.ts +0 -31
- package/dist/docker/managed-proxy.js +0 -265
- package/dist/docker/managed-proxy.js.map +0 -1
|
@@ -34,6 +34,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
|
34
34
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
35
35
|
import { appendFileSync, existsSync, mkdtempSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
36
36
|
import { join, resolve } from 'node:path';
|
|
37
|
+
import { fileURLToPath } from 'node:url';
|
|
37
38
|
import { tmpdir } from 'node:os';
|
|
38
39
|
import { v4 as uuidv4 } from 'uuid';
|
|
39
40
|
import { loadGeneratedPolicy, extractServerDomainAllowlists, getPackageGeneratedDir } from '../config/index.js';
|
|
@@ -205,7 +206,12 @@ async function createAutoApproveModel() {
|
|
|
205
206
|
return null;
|
|
206
207
|
}
|
|
207
208
|
}
|
|
208
|
-
|
|
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() {
|
|
209
215
|
const auditLogPath = process.env.AUDIT_LOG_PATH ?? './audit.jsonl';
|
|
210
216
|
const serversConfigJson = process.env.MCP_SERVERS_CONFIG;
|
|
211
217
|
const generatedDir = process.env.GENERATED_DIR;
|
|
@@ -232,7 +238,6 @@ async function main() {
|
|
|
232
238
|
const allServersConfig = JSON.parse(serversConfigJson);
|
|
233
239
|
const protectedPaths = JSON.parse(protectedPathsJson);
|
|
234
240
|
// When SERVER_FILTER is set, only connect to that single backend server.
|
|
235
|
-
// This allows per-server proxy processes with clean tool naming.
|
|
236
241
|
const serverFilter = process.env.SERVER_FILTER;
|
|
237
242
|
const serversConfig = serverFilter
|
|
238
243
|
? { [serverFilter]: allServersConfig[serverFilter] }
|
|
@@ -242,17 +247,25 @@ async function main() {
|
|
|
242
247
|
process.stderr.write(`SERVER_FILTER: unknown server "${serverFilter}"\n`);
|
|
243
248
|
process.exit(1);
|
|
244
249
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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) {
|
|
256
269
|
const { platformSupported, errors: depErrors, warnings: depWarnings } = checkSandboxAvailability();
|
|
257
270
|
if (sessionLogPath) {
|
|
258
271
|
for (const warning of depWarnings) {
|
|
@@ -260,7 +273,7 @@ async function main() {
|
|
|
260
273
|
}
|
|
261
274
|
}
|
|
262
275
|
if (sandboxPolicy === 'enforce' && (!platformSupported || depErrors.length > 0)) {
|
|
263
|
-
const reasons = !platformSupported ? [`Platform ${
|
|
276
|
+
const reasons = !platformSupported ? [`Platform ${platform} not supported`] : depErrors;
|
|
264
277
|
throw new Error(`[sandbox] FATAL: sandboxPolicy is "enforce" but sandboxing is unavailable:\n` +
|
|
265
278
|
reasons.map((r) => ` - ${r}`).join('\n') +
|
|
266
279
|
'\n' +
|
|
@@ -268,23 +281,263 @@ async function main() {
|
|
|
268
281
|
}
|
|
269
282
|
const sandboxAvailable = platformSupported && depErrors.length === 0;
|
|
270
283
|
if (!sandboxAvailable && sessionLogPath) {
|
|
271
|
-
const missing = depErrors.length > 0 ? depErrors.join(', ') : `platform ${
|
|
284
|
+
const missing = depErrors.length > 0 ? depErrors.join(', ') : `platform ${platform}`;
|
|
272
285
|
logToSessionFile(sessionLogPath, `[sandbox] WARNING: OS-level sandboxing unavailable (${missing}). ` +
|
|
273
286
|
`Servers will run without OS containment. ` +
|
|
274
287
|
`Set SANDBOX_POLICY=enforce to require sandboxing.`);
|
|
275
288
|
}
|
|
276
|
-
|
|
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) {
|
|
277
297
|
const resolvedSandboxConfigs = new Map();
|
|
298
|
+
const serverCwdPaths = new Map();
|
|
278
299
|
const settingsDir = mkdtempSync(join(tmpdir(), 'ironcurtain-srt-'));
|
|
279
300
|
for (const [serverName, config] of Object.entries(serversConfig)) {
|
|
280
301
|
const resolved = resolveSandboxConfig(config, allowedDirectory ?? '/tmp', sandboxAvailable, sandboxPolicy);
|
|
281
302
|
resolvedSandboxConfigs.set(serverName, resolved);
|
|
282
303
|
if (resolved.sandboxed) {
|
|
283
|
-
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.`);
|
|
284
516
|
}
|
|
517
|
+
return { kind: 'tcp', port: parsedPort, portFilePath: process.env.PROXY_PORT_FILE };
|
|
518
|
+
}
|
|
519
|
+
if (proxySocketPath) {
|
|
520
|
+
return { kind: 'uds', socketPath: proxySocketPath };
|
|
285
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 ───────────────────────────────────
|
|
286
540
|
const clientStates = new Map();
|
|
287
|
-
// Connect to real MCP servers as clients, wrapping sandboxed ones with srt
|
|
288
541
|
const allTools = [];
|
|
289
542
|
for (const [serverName, config] of Object.entries(serversConfig)) {
|
|
290
543
|
const resolved = resolvedSandboxConfigs.get(serverName);
|
|
@@ -294,22 +547,13 @@ async function main() {
|
|
|
294
547
|
const transport = new StdioClientTransport({
|
|
295
548
|
command: wrapped.command,
|
|
296
549
|
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
550
|
env: { ...process.env, ...(config.env ?? {}), ...serverCredentials },
|
|
302
|
-
stderr: 'pipe',
|
|
303
|
-
// Sandboxed servers
|
|
304
|
-
//
|
|
305
|
-
|
|
306
|
-
...(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) } : {}),
|
|
307
555
|
});
|
|
308
|
-
// Capture stderr per-server for diagnostic output on connection failure
|
|
309
556
|
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
557
|
if (transport.stderr) {
|
|
314
558
|
transport.stderr.on('data', (chunk) => {
|
|
315
559
|
const text = chunk.toString();
|
|
@@ -324,12 +568,7 @@ async function main() {
|
|
|
324
568
|
});
|
|
325
569
|
}
|
|
326
570
|
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
571
|
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
572
|
client.setRequestHandler(ListRootsRequestSchema, () => {
|
|
334
573
|
if (state.rootsRefreshed) {
|
|
335
574
|
state.rootsRefreshed();
|
|
@@ -356,16 +595,10 @@ async function main() {
|
|
|
356
595
|
});
|
|
357
596
|
}
|
|
358
597
|
}
|
|
359
|
-
|
|
360
|
-
|
|
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
|
|
598
|
+
const toolMap = buildToolMap(allTools);
|
|
599
|
+
// ── Create the proxy MCP server ───────────────────────────────────
|
|
366
600
|
// eslint-disable-next-line @typescript-eslint/no-deprecated -- intentional use of low-level Server for raw JSON schema passthrough
|
|
367
601
|
const server = new Server({ name: 'ironcurtain-proxy', version: VERSION }, { capabilities: { tools: {} } });
|
|
368
|
-
// Handle tools/list -- return all proxied tool schemas verbatim
|
|
369
602
|
server.setRequestHandler(ListToolsRequestSchema, () => {
|
|
370
603
|
return {
|
|
371
604
|
tools: allTools.map((t) => ({
|
|
@@ -375,238 +608,43 @@ async function main() {
|
|
|
375
608
|
})),
|
|
376
609
|
};
|
|
377
610
|
});
|
|
378
|
-
|
|
611
|
+
const callToolDeps = {
|
|
612
|
+
toolMap,
|
|
613
|
+
policyEngine,
|
|
614
|
+
auditLog,
|
|
615
|
+
circuitBreaker,
|
|
616
|
+
clientStates,
|
|
617
|
+
resolvedSandboxConfigs,
|
|
618
|
+
allowedDirectory,
|
|
619
|
+
containerWorkspaceDir,
|
|
620
|
+
escalationDir,
|
|
621
|
+
autoApproveModel,
|
|
622
|
+
};
|
|
379
623
|
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
380
|
-
|
|
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
|
-
}
|
|
624
|
+
return handleCallTool(req.params.name, req.params.arguments ?? {}, callToolDeps);
|
|
574
625
|
});
|
|
575
|
-
//
|
|
576
|
-
const
|
|
577
|
-
const proxyTcpPort = process.env.PROXY_TCP_PORT;
|
|
626
|
+
// ── Transport selection ───────────────────────────────────────────
|
|
627
|
+
const transportConfig = selectTransportConfig();
|
|
578
628
|
let transport;
|
|
579
|
-
if (
|
|
580
|
-
const
|
|
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);
|
|
629
|
+
if (transportConfig.kind === 'tcp') {
|
|
630
|
+
const tcpTransport = new TcpServerTransport('0.0.0.0', transportConfig.port);
|
|
589
631
|
transport = tcpTransport;
|
|
590
|
-
// start() must be called before connect() to bind the port
|
|
591
632
|
await tcpTransport.start();
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
if (portFilePath) {
|
|
595
|
-
writeFileSync(portFilePath, String(tcpTransport.port));
|
|
633
|
+
if (transportConfig.portFilePath) {
|
|
634
|
+
writeFileSync(transportConfig.portFilePath, String(tcpTransport.port));
|
|
596
635
|
}
|
|
597
636
|
if (sessionLogPath) {
|
|
598
|
-
logToSessionFile(sessionLogPath, `MCP proxy listening on
|
|
637
|
+
logToSessionFile(sessionLogPath, `MCP proxy listening on 0.0.0.0:${tcpTransport.port}`);
|
|
599
638
|
}
|
|
600
639
|
}
|
|
601
|
-
else if (
|
|
602
|
-
transport = new UdsServerTransport(
|
|
640
|
+
else if (transportConfig.kind === 'uds') {
|
|
641
|
+
transport = new UdsServerTransport(transportConfig.socketPath);
|
|
603
642
|
}
|
|
604
643
|
else {
|
|
605
644
|
transport = new StdioServerTransport();
|
|
606
645
|
}
|
|
607
646
|
await server.connect(transport);
|
|
608
|
-
//
|
|
609
|
-
// is spawned as a child by Code Mode and may receive either signal.
|
|
647
|
+
// ── Shutdown handler ──────────────────────────────────────────────
|
|
610
648
|
async function shutdown() {
|
|
611
649
|
for (const state of clientStates.values()) {
|
|
612
650
|
try {
|
|
@@ -633,8 +671,11 @@ async function main() {
|
|
|
633
671
|
void shutdown();
|
|
634
672
|
});
|
|
635
673
|
}
|
|
636
|
-
main()
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
});
|
|
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
|
+
}
|
|
640
681
|
//# sourceMappingURL=mcp-proxy-server.js.map
|