@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.
- package/README.md +20 -1
- package/dist/cli.js +15 -0
- package/dist/cli.js.map +1 -1
- package/dist/config/index.js +12 -1
- 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/config/user-config.d.ts +11 -0
- package/dist/config/user-config.js +56 -2
- package/dist/config/user-config.js.map +1 -1
- package/dist/docker/adapters/claude-code.d.ts +4 -0
- package/dist/docker/adapters/claude-code.js +35 -21
- package/dist/docker/adapters/claude-code.js.map +1 -1
- package/dist/docker/agent-adapter.d.ts +6 -12
- package/dist/docker/audit-log-tailer.d.ts +4 -3
- package/dist/docker/audit-log-tailer.js +9 -7
- package/dist/docker/audit-log-tailer.js.map +1 -1
- 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 +17 -5
- package/dist/docker/docker-agent-session.js +171 -36
- package/dist/docker/docker-agent-session.js.map +1 -1
- package/dist/docker/docker-manager.js +106 -15
- package/dist/docker/docker-manager.js.map +1 -1
- package/dist/docker/mitm-proxy.d.ts +7 -4
- package/dist/docker/mitm-proxy.js +43 -13
- package/dist/docker/mitm-proxy.js.map +1 -1
- package/dist/docker/orientation.d.ts +3 -2
- package/dist/docker/orientation.js +6 -3
- package/dist/docker/orientation.js.map +1 -1
- package/dist/docker/platform.d.ts +20 -0
- package/dist/docker/platform.js +24 -0
- package/dist/docker/platform.js.map +1 -0
- package/dist/docker/types.d.ts +59 -7
- 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 +18 -36
- 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/signal/bot-command.d.ts +13 -0
- package/dist/signal/bot-command.js +54 -0
- package/dist/signal/bot-command.js.map +1 -0
- package/dist/signal/format.d.ts +31 -0
- package/dist/signal/format.js +101 -0
- package/dist/signal/format.js.map +1 -0
- package/dist/signal/index.d.ts +16 -0
- package/dist/signal/index.js +13 -0
- package/dist/signal/index.js.map +1 -0
- package/dist/signal/markdown-to-signal.d.ts +29 -0
- package/dist/signal/markdown-to-signal.js +154 -0
- package/dist/signal/markdown-to-signal.js.map +1 -0
- package/dist/signal/setup-signal.d.ts +48 -0
- package/dist/signal/setup-signal.js +443 -0
- package/dist/signal/setup-signal.js.map +1 -0
- package/dist/signal/signal-bot-daemon.d.ts +142 -0
- package/dist/signal/signal-bot-daemon.js +504 -0
- package/dist/signal/signal-bot-daemon.js.map +1 -0
- package/dist/signal/signal-config.d.ts +64 -0
- package/dist/signal/signal-config.js +53 -0
- package/dist/signal/signal-config.js.map +1 -0
- package/dist/signal/signal-container.d.ts +37 -0
- package/dist/signal/signal-container.js +112 -0
- package/dist/signal/signal-container.js.map +1 -0
- package/dist/signal/signal-transport.d.ts +34 -0
- package/dist/signal/signal-transport.js +70 -0
- package/dist/signal/signal-transport.js.map +1 -0
- package/dist/trusted-process/mcp-proxy-server.d.ts +111 -1
- package/dist/trusted-process/mcp-proxy-server.js +319 -247
- package/dist/trusted-process/mcp-proxy-server.js.map +1 -1
- package/dist/trusted-process/path-utils.d.ts +5 -1
- package/dist/trusted-process/path-utils.js +47 -4
- package/dist/trusted-process/path-utils.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/trusted-process/tcp-server-transport.d.ts +43 -0
- package/dist/trusted-process/tcp-server-transport.js +119 -0
- package/dist/trusted-process/tcp-server-transport.js.map +1 -0
- package/dist/types/argument-roles.d.ts +7 -0
- package/dist/types/argument-roles.js +30 -5
- package/dist/types/argument-roles.js.map +1 -1
- package/docker/Dockerfile.base.arm64 +23 -0
- package/package.json +9 -4
- package/src/config/mcp-servers.json +3 -0
- package/dist/docker/managed-proxy.d.ts +0 -27
- package/dist/docker/managed-proxy.js +0 -175
- 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
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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 ${
|
|
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 ${
|
|
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
|
-
|
|
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',
|
|
301
|
-
// Sandboxed servers
|
|
302
|
-
//
|
|
303
|
-
|
|
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
|
-
|
|
358
|
-
|
|
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
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
522
|
-
|
|
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
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
-
//
|
|
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()
|
|
606
|
-
|
|
607
|
-
|
|
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
|