@provos/ironcurtain 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (216) hide show
  1. package/README.md +11 -10
  2. package/dist/auth/auth-command.d.ts +15 -0
  3. package/dist/auth/auth-command.js +424 -0
  4. package/dist/auth/auth-command.js.map +1 -0
  5. package/dist/auth/oauth-flow.d.ts +46 -0
  6. package/dist/auth/oauth-flow.js +279 -0
  7. package/dist/auth/oauth-flow.js.map +1 -0
  8. package/dist/auth/oauth-provider.d.ts +55 -0
  9. package/dist/auth/oauth-provider.js +73 -0
  10. package/dist/auth/oauth-provider.js.map +1 -0
  11. package/dist/auth/oauth-registry.d.ts +34 -0
  12. package/dist/auth/oauth-registry.js +69 -0
  13. package/dist/auth/oauth-registry.js.map +1 -0
  14. package/dist/auth/oauth-token-provider.d.ts +52 -0
  15. package/dist/auth/oauth-token-provider.js +159 -0
  16. package/dist/auth/oauth-token-provider.js.map +1 -0
  17. package/dist/auth/oauth-token-store.d.ts +30 -0
  18. package/dist/auth/oauth-token-store.js +93 -0
  19. package/dist/auth/oauth-token-store.js.map +1 -0
  20. package/dist/auth/providers/google-scopes.d.ts +35 -0
  21. package/dist/auth/providers/google-scopes.js +183 -0
  22. package/dist/auth/providers/google-scopes.js.map +1 -0
  23. package/dist/auth/providers/google.d.ts +2 -0
  24. package/dist/auth/providers/google.js +28 -0
  25. package/dist/auth/providers/google.js.map +1 -0
  26. package/dist/cli.js +6 -0
  27. package/dist/cli.js.map +1 -1
  28. package/dist/config/config-command.js +39 -1
  29. package/dist/config/config-command.js.map +1 -1
  30. package/dist/config/constitution-readonly.md +14 -11
  31. package/dist/config/constitution-user-base.md +15 -0
  32. package/dist/config/generated/test-scenarios.json +1 -1
  33. package/dist/config/generated/tool-annotations.json +10617 -296
  34. package/dist/config/generated-readonly/compiled-policy.json +787 -244
  35. package/dist/config/index.d.ts +2 -2
  36. package/dist/config/index.js +8 -0
  37. package/dist/config/index.js.map +1 -1
  38. package/dist/config/mcp-servers.json +23 -0
  39. package/dist/config/paths.d.ts +15 -0
  40. package/dist/config/paths.js +35 -0
  41. package/dist/config/paths.js.map +1 -1
  42. package/dist/config/types.d.ts +8 -0
  43. package/dist/config/user-config.d.ts +2 -0
  44. package/dist/config/user-config.js +2 -0
  45. package/dist/config/user-config.js.map +1 -1
  46. package/dist/cron/headless-transport.d.ts +4 -0
  47. package/dist/cron/headless-transport.js +1 -1
  48. package/dist/cron/headless-transport.js.map +1 -1
  49. package/dist/daemon/ironcurtain-daemon.js +6 -1
  50. package/dist/daemon/ironcurtain-daemon.js.map +1 -1
  51. package/dist/docker/docker-agent-session.d.ts +3 -1
  52. package/dist/docker/docker-agent-session.js +49 -28
  53. package/dist/docker/docker-agent-session.js.map +1 -1
  54. package/dist/docker/docker-infrastructure.js +39 -11
  55. package/dist/docker/docker-infrastructure.js.map +1 -1
  56. package/dist/docker/docker-manager.d.ts +2 -0
  57. package/dist/docker/docker-manager.js +9 -1
  58. package/dist/docker/docker-manager.js.map +1 -1
  59. package/dist/docker/mitm-proxy.d.ts +50 -0
  60. package/dist/docker/mitm-proxy.js +220 -31
  61. package/dist/docker/mitm-proxy.js.map +1 -1
  62. package/dist/docker/oauth-credentials.d.ts +45 -6
  63. package/dist/docker/oauth-credentials.js +151 -24
  64. package/dist/docker/oauth-credentials.js.map +1 -1
  65. package/dist/docker/oauth-token-manager.d.ts +15 -16
  66. package/dist/docker/oauth-token-manager.js +23 -17
  67. package/dist/docker/oauth-token-manager.js.map +1 -1
  68. package/dist/docker/platform.d.ts +6 -6
  69. package/dist/docker/platform.js +8 -6
  70. package/dist/docker/platform.js.map +1 -1
  71. package/dist/docker/proxy-tools.d.ts +80 -0
  72. package/dist/docker/proxy-tools.js +248 -0
  73. package/dist/docker/proxy-tools.js.map +1 -0
  74. package/dist/docker/pty-session.js +48 -29
  75. package/dist/docker/pty-session.js.map +1 -1
  76. package/dist/docker/pty-types.d.ts +4 -0
  77. package/dist/docker/pty-types.js.map +1 -1
  78. package/dist/escalation/escalation-watcher.d.ts +3 -1
  79. package/dist/escalation/escalation-watcher.js +6 -2
  80. package/dist/escalation/escalation-watcher.js.map +1 -1
  81. package/dist/escalation/listener-command.js +24 -10
  82. package/dist/escalation/listener-command.js.map +1 -1
  83. package/dist/escalation/listener-lock.d.ts +5 -0
  84. package/dist/escalation/listener-lock.js +14 -0
  85. package/dist/escalation/listener-lock.js.map +1 -1
  86. package/dist/index.js +6 -1
  87. package/dist/index.js.map +1 -1
  88. package/dist/memory/auto-save.d.ts +25 -0
  89. package/dist/memory/auto-save.js +89 -0
  90. package/dist/memory/auto-save.js.map +1 -0
  91. package/dist/mux/mux-app.d.ts +4 -0
  92. package/dist/mux/mux-app.js +186 -21
  93. package/dist/mux/mux-app.js.map +1 -1
  94. package/dist/mux/mux-command.d.ts +2 -2
  95. package/dist/mux/mux-command.js +14 -22
  96. package/dist/mux/mux-command.js.map +1 -1
  97. package/dist/mux/mux-escalation-manager.d.ts +9 -3
  98. package/dist/mux/mux-escalation-manager.js +69 -19
  99. package/dist/mux/mux-escalation-manager.js.map +1 -1
  100. package/dist/mux/mux-input-handler.d.ts +33 -1
  101. package/dist/mux/mux-input-handler.js +112 -29
  102. package/dist/mux/mux-input-handler.js.map +1 -1
  103. package/dist/mux/mux-renderer.d.ts +17 -1
  104. package/dist/mux/mux-renderer.js +288 -7
  105. package/dist/mux/mux-renderer.js.map +1 -1
  106. package/dist/mux/pty-bridge.d.ts +4 -0
  107. package/dist/mux/pty-bridge.js +13 -1
  108. package/dist/mux/pty-bridge.js.map +1 -1
  109. package/dist/mux/types.d.ts +24 -1
  110. package/dist/mux/types.js +10 -1
  111. package/dist/mux/types.js.map +1 -1
  112. package/dist/persona/persona-command.js +0 -5
  113. package/dist/persona/persona-command.js.map +1 -1
  114. package/dist/pipeline/annotate.js +1 -7
  115. package/dist/pipeline/annotate.js.map +1 -1
  116. package/dist/pipeline/compile.d.ts +5 -2
  117. package/dist/pipeline/compile.js +23 -11
  118. package/dist/pipeline/compile.js.map +1 -1
  119. package/dist/pipeline/constitution-compiler.d.ts +302 -3
  120. package/dist/pipeline/constitution-compiler.js +285 -23
  121. package/dist/pipeline/constitution-compiler.js.map +1 -1
  122. package/dist/pipeline/generate-with-repair.d.ts +2 -2
  123. package/dist/pipeline/generate-with-repair.js +2 -2
  124. package/dist/pipeline/handwritten-scenarios.d.ts +20 -7
  125. package/dist/pipeline/handwritten-scenarios.js +20 -41
  126. package/dist/pipeline/handwritten-scenarios.js.map +1 -1
  127. package/dist/pipeline/list-resolver.d.ts +18 -20
  128. package/dist/pipeline/list-resolver.js +28 -36
  129. package/dist/pipeline/list-resolver.js.map +1 -1
  130. package/dist/pipeline/pipeline-runner.d.ts +102 -13
  131. package/dist/pipeline/pipeline-runner.js +497 -237
  132. package/dist/pipeline/pipeline-runner.js.map +1 -1
  133. package/dist/pipeline/pipeline-shared.d.ts +7 -9
  134. package/dist/pipeline/pipeline-shared.js +8 -18
  135. package/dist/pipeline/pipeline-shared.js.map +1 -1
  136. package/dist/pipeline/policy-verifier.d.ts +32 -5
  137. package/dist/pipeline/policy-verifier.js +137 -8
  138. package/dist/pipeline/policy-verifier.js.map +1 -1
  139. package/dist/pipeline/proxy-mcp-connections.d.ts +25 -0
  140. package/dist/pipeline/proxy-mcp-connections.js +121 -0
  141. package/dist/pipeline/proxy-mcp-connections.js.map +1 -0
  142. package/dist/pipeline/refresh-lists.js +21 -13
  143. package/dist/pipeline/refresh-lists.js.map +1 -1
  144. package/dist/pipeline/scenario-generator.d.ts +29 -49
  145. package/dist/pipeline/scenario-generator.js +180 -143
  146. package/dist/pipeline/scenario-generator.js.map +1 -1
  147. package/dist/pipeline/scenario-schema-validator.d.ts +51 -0
  148. package/dist/pipeline/scenario-schema-validator.js +132 -0
  149. package/dist/pipeline/scenario-schema-validator.js.map +1 -0
  150. package/dist/pipeline/tool-annotator.d.ts +9 -8
  151. package/dist/pipeline/tool-annotator.js +48 -95
  152. package/dist/pipeline/tool-annotator.js.map +1 -1
  153. package/dist/pipeline/types.d.ts +31 -24
  154. package/dist/sandbox/index.js +20 -0
  155. package/dist/sandbox/index.js.map +1 -1
  156. package/dist/servers/fetch-server.js +10 -5
  157. package/dist/servers/fetch-server.js.map +1 -1
  158. package/dist/session/agent-session.d.ts +6 -19
  159. package/dist/session/agent-session.js +18 -69
  160. package/dist/session/agent-session.js.map +1 -1
  161. package/dist/session/base-transport.d.ts +17 -1
  162. package/dist/session/base-transport.js +25 -1
  163. package/dist/session/base-transport.js.map +1 -1
  164. package/dist/session/cli-transport.d.ts +5 -0
  165. package/dist/session/cli-transport.js +48 -25
  166. package/dist/session/cli-transport.js.map +1 -1
  167. package/dist/session/preflight.js +15 -16
  168. package/dist/session/preflight.js.map +1 -1
  169. package/dist/session/prompts.js +0 -1
  170. package/dist/session/prompts.js.map +1 -1
  171. package/dist/session/session-manager.d.ts +6 -0
  172. package/dist/session/session-manager.js +12 -0
  173. package/dist/session/session-manager.js.map +1 -1
  174. package/dist/session/types.d.ts +6 -1
  175. package/dist/session/types.js.map +1 -1
  176. package/dist/signal/signal-bot-daemon.js +24 -8
  177. package/dist/signal/signal-bot-daemon.js.map +1 -1
  178. package/dist/signal/signal-transport.d.ts +5 -2
  179. package/dist/signal/signal-transport.js +3 -3
  180. package/dist/signal/signal-transport.js.map +1 -1
  181. package/dist/trusted-process/approval-whitelist.d.ts +117 -0
  182. package/dist/trusted-process/approval-whitelist.js +237 -0
  183. package/dist/trusted-process/approval-whitelist.js.map +1 -0
  184. package/dist/trusted-process/gworkspace-credentials.d.ts +40 -0
  185. package/dist/trusted-process/gworkspace-credentials.js +45 -0
  186. package/dist/trusted-process/gworkspace-credentials.js.map +1 -0
  187. package/dist/trusted-process/index.d.ts +11 -1
  188. package/dist/trusted-process/index.js +63 -27
  189. package/dist/trusted-process/index.js.map +1 -1
  190. package/dist/trusted-process/mcp-proxy-server.d.ts +14 -0
  191. package/dist/trusted-process/mcp-proxy-server.js +325 -64
  192. package/dist/trusted-process/mcp-proxy-server.js.map +1 -1
  193. package/dist/trusted-process/policy-engine.d.ts +8 -2
  194. package/dist/trusted-process/policy-engine.js +18 -22
  195. package/dist/trusted-process/policy-engine.js.map +1 -1
  196. package/dist/trusted-process/policy-types.d.ts +17 -0
  197. package/dist/trusted-process/sandbox-integration.d.ts +23 -0
  198. package/dist/trusted-process/sandbox-integration.js +115 -3
  199. package/dist/trusted-process/sandbox-integration.js.map +1 -1
  200. package/dist/trusted-process/token-file-refresher.d.ts +69 -0
  201. package/dist/trusted-process/token-file-refresher.js +120 -0
  202. package/dist/trusted-process/token-file-refresher.js.map +1 -0
  203. package/dist/types/argument-roles.d.ts +7 -1
  204. package/dist/types/argument-roles.js +50 -0
  205. package/dist/types/argument-roles.js.map +1 -1
  206. package/dist/types/audit.d.ts +11 -0
  207. package/docker/Dockerfile.claude-code +2 -2
  208. package/docker/entrypoint-claude-code.sh +15 -5
  209. package/package.json +3 -2
  210. package/src/config/constitution-user-base.md +15 -0
  211. package/src/config/generated/test-scenarios.json +1 -1
  212. package/src/config/generated/tool-annotations.json +10617 -296
  213. package/src/config/mcp-servers.json +23 -0
  214. package/dist/pipeline/mcp-connections.d.ts +0 -16
  215. package/dist/pipeline/mcp-connections.js +0 -61
  216. package/dist/pipeline/mcp-connections.js.map +0 -1
@@ -37,16 +37,16 @@ import { appendFileSync, existsSync, mkdtempSync, readFileSync, writeFileSync, u
37
37
  import { atomicWriteJsonSync } from '../escalation/escalation-watcher.js';
38
38
  import { join, resolve } from 'node:path';
39
39
  import { fileURLToPath } from 'node:url';
40
- import { tmpdir } from 'node:os';
40
+ import { tmpdir, homedir } from 'node:os';
41
41
  import { v4 as uuidv4 } from 'uuid';
42
42
  import { loadGeneratedPolicy, extractServerDomainAllowlists, getPackageGeneratedDir } from '../config/index.js';
43
43
  import { PolicyEngine, extractAnnotatedPaths } from './policy-engine.js';
44
- import { getPathRoles } from '../types/argument-roles.js';
44
+ import { getPathRoles, expandTilde } from '../types/argument-roles.js';
45
45
  import { AuditLog } from './audit-log.js';
46
46
  import { prepareToolArgs, rewriteResultContent } from './path-utils.js';
47
47
  import { CONTAINER_WORKSPACE_DIR } from '../docker/agent-adapter.js';
48
48
  import { extractPolicyRoots, toMcpRoots, directoryForPath } from './policy-roots.js';
49
- import { checkSandboxAvailability, resolveSandboxConfig, writeServerSettings, wrapServerCommand, cleanupSettingsFiles, annotateSandboxViolation, } from './sandbox-integration.js';
49
+ import { checkSandboxAvailability, resolveSandboxConfig, writeServerSettings, wrapServerCommand, cleanupSettingsFiles, annotateSandboxViolation, discoverNodePaths, rewriteServerSettings, } from './sandbox-integration.js';
50
50
  import { ROOTS_REFRESH_TIMEOUT_MS } from './mcp-client-manager.js';
51
51
  import { CallCircuitBreaker } from './call-circuit-breaker.js';
52
52
  import { autoApprove, extractArgsForAutoApprove, readUserContext } from './auto-approver.js';
@@ -59,6 +59,21 @@ import { permissiveJsonSchemaValidator } from './permissive-output-validator.js'
59
59
  import { VERSION } from '../version.js';
60
60
  import { buildTrustedServerSet } from '../memory/memory-annotations.js';
61
61
  import { loadToolDescriptionHints, applyToolDescriptionHints } from './tool-description-hints.js';
62
+ import { createApprovalWhitelist, extractWhitelistCandidates, } from './approval-whitelist.js';
63
+ import { getProviderForServer } from '../auth/oauth-registry.js';
64
+ import { loadClientCredentials } from '../auth/oauth-provider.js';
65
+ import { OAuthTokenProvider } from '../auth/oauth-token-provider.js';
66
+ import { loadOAuthToken } from '../auth/oauth-token-store.js';
67
+ import { writeGWorkspaceCredentialFile } from './gworkspace-credentials.js';
68
+ import { TokenFileRefresher } from './token-file-refresher.js';
69
+ import { proxyAnnotations, proxyPolicyRules, proxyToolDefinitions, handleVirtualProxyTool, createControlApiClient, } from '../docker/proxy-tools.js';
70
+ /**
71
+ * Pending whitelist candidates keyed by escalation ID.
72
+ * Populated when a request file is written with candidates;
73
+ * consumed when the response comes back with a selection.
74
+ * Module-level state — the proxy is a singleton process.
75
+ */
76
+ const pendingWhitelistCandidates = new Map();
62
77
  /**
63
78
  * Adds a root to a client's root list and waits for the server to
64
79
  * fetch the updated list. No-op if the root URI is already present.
@@ -230,13 +245,21 @@ function cleanupEscalationFiles(requestPath, responsePath) {
230
245
  }
231
246
  /**
232
247
  * Reads and parses the escalation response file if it exists.
233
- * Returns the decision, or undefined if the file is not present.
248
+ * Returns the response data, or undefined if the file is not present.
234
249
  */
235
250
  function readEscalationResponse(responsePath) {
236
251
  if (!existsSync(responsePath))
237
252
  return undefined;
238
- const response = JSON.parse(readFileSync(responsePath, 'utf-8'));
239
- return response.decision;
253
+ const raw = JSON.parse(readFileSync(responsePath, 'utf-8'));
254
+ // Validate whitelistSelection: must be an integer if present, discard otherwise
255
+ let whitelistSelection;
256
+ if (typeof raw.whitelistSelection === 'number' && Number.isInteger(raw.whitelistSelection)) {
257
+ whitelistSelection = raw.whitelistSelection;
258
+ }
259
+ return {
260
+ decision: raw.decision,
261
+ ...(whitelistSelection !== undefined ? { whitelistSelection } : {}),
262
+ };
240
263
  }
241
264
  /**
242
265
  * Waits for a human decision via file-based IPC.
@@ -253,17 +276,17 @@ async function waitForEscalationDecision(escalationDir, request) {
253
276
  atomicWriteJsonSync(requestPath, request);
254
277
  const deadline = Date.now() + getEscalationTimeoutMs();
255
278
  while (Date.now() < deadline) {
256
- const decision = readEscalationResponse(responsePath);
257
- if (decision) {
279
+ const response = readEscalationResponse(responsePath);
280
+ if (response) {
258
281
  cleanupEscalationFiles(requestPath, responsePath);
259
- return decision;
282
+ return response;
260
283
  }
261
284
  await new Promise((r) => setTimeout(r, ESCALATION_POLL_INTERVAL_MS));
262
285
  }
263
286
  // Final check -- response may have arrived between last poll and deadline
264
- const lateDecision = readEscalationResponse(responsePath);
287
+ const lateResponse = readEscalationResponse(responsePath);
265
288
  cleanupEscalationFiles(requestPath, responsePath);
266
- return lateDecision ?? 'denied';
289
+ return lateResponse ?? { decision: 'denied' };
267
290
  }
268
291
  /**
269
292
  * Creates the auto-approve LLM model from environment variables.
@@ -332,12 +355,17 @@ export function parseProxyEnvConfig() {
332
355
  const allServersConfig = JSON.parse(serversConfigJson);
333
356
  const protectedPaths = JSON.parse(protectedPathsJson);
334
357
  // When SERVER_FILTER is set, only connect to that single backend server.
358
+ // Special case: SERVER_FILTER=proxy with no matching backend enters virtual-only mode.
335
359
  const serverFilter = process.env.SERVER_FILTER;
336
- const serversConfig = serverFilter
337
- ? { [serverFilter]: allServersConfig[serverFilter] }
338
- : allServersConfig;
360
+ const isVirtualOnly = serverFilter === 'proxy' && !allServersConfig[serverFilter];
361
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- isVirtualOnly is defensive; TS can't prove it's false at runtime
362
+ const serversConfig = isVirtualOnly
363
+ ? {}
364
+ : serverFilter
365
+ ? { [serverFilter]: allServersConfig[serverFilter] }
366
+ : allServersConfig;
339
367
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- defensive: Record index may be undefined at runtime
340
- if (serverFilter && !allServersConfig[serverFilter]) {
368
+ if (serverFilter && !allServersConfig[serverFilter] && !isVirtualOnly) {
341
369
  process.stderr.write(`SERVER_FILTER: unknown server "${serverFilter}"\n`);
342
370
  process.exit(1);
343
371
  }
@@ -405,6 +433,28 @@ export function resolveServerSandboxConfigs(serversConfig, allowedDirectory, san
405
433
  }
406
434
  return { resolvedSandboxConfigs, settingsDir, serverCwdPaths };
407
435
  }
436
+ /**
437
+ * Validates that all argument keys exist in the tool's inputSchema.
438
+ * Returns null if valid, or an error message listing unknown and valid keys.
439
+ * Skips validation when the schema has no properties or allows additional properties.
440
+ */
441
+ export function validateToolArguments(args, inputSchema) {
442
+ const properties = inputSchema.properties;
443
+ if (!properties || typeof properties !== 'object')
444
+ return null;
445
+ if (inputSchema.additionalProperties)
446
+ return null;
447
+ const validKeys = new Set(Object.keys(properties));
448
+ const unknownKeys = Object.keys(args).filter((k) => !validKeys.has(k));
449
+ if (unknownKeys.length === 0)
450
+ return null;
451
+ const unknownList = unknownKeys.map((k) => `"${k}"`).join(', ');
452
+ const validList = [...validKeys]
453
+ .sort()
454
+ .map((k) => `"${k}"`)
455
+ .join(', ');
456
+ return `Unknown argument(s): ${unknownList}. Valid parameters are: ${validList}`;
457
+ }
408
458
  /** Builds a lookup map from tool name to ProxiedTool for routing. */
409
459
  export function buildToolMap(allTools) {
410
460
  const toolMap = new Map();
@@ -430,6 +480,8 @@ export function buildAuditEntry(request, argsForTransport, policyDecision, resul
430
480
  durationMs,
431
481
  sandboxed: options.sandboxed || undefined,
432
482
  autoApproved: options.autoApproved || undefined,
483
+ whitelistApproved: options.whitelistApproved || undefined,
484
+ whitelistPatternId: options.whitelistPatternId,
433
485
  };
434
486
  }
435
487
  /**
@@ -462,6 +514,27 @@ export async function handleCallTool(toolName, rawArgs, deps) {
462
514
  isError: true,
463
515
  };
464
516
  }
517
+ // Validate argument names against the tool's schema.
518
+ // Skip for trusted servers (check directly, not via isTrusted which also requires missing annotation).
519
+ if (!deps.policyEngine.isTrustedServer(toolInfo.serverName)) {
520
+ const validationError = validateToolArguments(rawArgs, toolInfo.inputSchema);
521
+ if (validationError) {
522
+ deps.auditLog.log({
523
+ timestamp: new Date().toISOString(),
524
+ requestId: uuidv4(),
525
+ serverName: toolInfo.serverName,
526
+ toolName: toolInfo.name,
527
+ arguments: rawArgs,
528
+ policyDecision: { status: 'deny', rule: 'invalid-arguments', reason: validationError },
529
+ result: { status: 'denied', error: validationError },
530
+ durationMs: 0,
531
+ });
532
+ return {
533
+ content: [{ type: 'text', text: validationError }],
534
+ isError: true,
535
+ };
536
+ }
537
+ }
465
538
  let argsForTransport;
466
539
  let argsForPolicy;
467
540
  if (isTrusted) {
@@ -516,6 +589,8 @@ export async function handleCallTool(toolName, rawArgs, deps) {
516
589
  };
517
590
  let escalationResult;
518
591
  let autoApproved = false;
592
+ let whitelistApproved = false;
593
+ let whitelistPatternId;
519
594
  let rootsExpanded = false;
520
595
  const serverSandboxConfig = deps.resolvedSandboxConfigs.get(toolInfo.serverName);
521
596
  const serverIsSandboxed = serverSandboxConfig?.sandboxed === true;
@@ -524,63 +599,102 @@ export async function handleCallTool(toolName, rawArgs, deps) {
524
599
  escalationResult: overrideEscalation ?? escalationResult,
525
600
  sandboxed: serverIsSandboxed,
526
601
  autoApproved,
602
+ whitelistApproved: whitelistApproved || undefined,
603
+ whitelistPatternId,
527
604
  });
528
605
  deps.auditLog.log(entry);
529
606
  }
530
607
  if (evaluation.decision === 'escalate') {
531
- if (!deps.escalationDir) {
532
- logAudit({ status: 'denied', error: evaluation.reason }, 0, 'denied');
533
- return {
534
- content: [
535
- {
536
- type: 'text',
537
- text: `ESCALATION REQUIRED: ${evaluation.reason}. Action denied (no escalation handler).`,
538
- },
539
- ],
540
- isError: true,
541
- };
542
- }
543
- // Try auto-approve before falling through to human escalation
544
- if (deps.autoApproveModel) {
545
- const userContext = readUserContext(deps.escalationDir);
546
- if (userContext) {
547
- const isPtySession = process.env.IRONCURTAIN_PTY_SESSION === '1';
548
- if (isUserContextTrusted(userContext, isPtySession)) {
549
- const autoResult = await autoApprove({
550
- userMessage: userContext.userMessage,
551
- toolName: `${toolInfo.serverName}/${toolInfo.name}`,
552
- escalationReason: evaluation.reason,
553
- arguments: extractArgsForAutoApprove(argsForPolicy, annotation),
554
- }, deps.autoApproveModel);
555
- if (autoResult.decision === 'approve') {
556
- autoApproved = true;
557
- escalationResult = 'approved';
558
- policyDecision.status = 'allow';
559
- policyDecision.reason = `Auto-approved: ${autoResult.reasoning}`;
560
- }
561
- }
562
- }
608
+ // Annotation is guaranteed non-null here: trusted servers always allow (never escalate),
609
+ // and missing-annotation returns early above. Narrow the type for TypeScript.
610
+ const resolvedAnnotation = annotation;
611
+ const whitelistMatch = deps.whitelist.match(toolInfo.serverName, toolInfo.name, argsForPolicy, resolvedAnnotation);
612
+ if (whitelistMatch.matched) {
613
+ whitelistApproved = true;
614
+ whitelistPatternId = whitelistMatch.patternId;
615
+ escalationResult = 'approved';
616
+ policyDecision.status = 'allow';
617
+ policyDecision.reason = `Whitelist-approved: ${whitelistMatch.pattern.description}`;
563
618
  }
564
- if (!autoApproved) {
565
- const escalationId = uuidv4();
566
- const decision = await waitForEscalationDecision(deps.escalationDir, {
567
- escalationId,
568
- serverName: request.serverName,
569
- toolName: request.toolName,
570
- arguments: argsForTransport,
571
- reason: evaluation.reason,
572
- context: formatServerContext(deps.serverContextMap, toolInfo.serverName),
573
- });
574
- if (decision === 'denied') {
619
+ if (!whitelistApproved) {
620
+ if (!deps.escalationDir) {
575
621
  logAudit({ status: 'denied', error: evaluation.reason }, 0, 'denied');
576
622
  return {
577
- content: [{ type: 'text', text: `ESCALATION DENIED: ${evaluation.reason}` }],
623
+ content: [
624
+ {
625
+ type: 'text',
626
+ text: `ESCALATION REQUIRED: ${evaluation.reason}. Action denied (no escalation handler).`,
627
+ },
628
+ ],
578
629
  isError: true,
579
630
  };
580
631
  }
581
- escalationResult = 'approved';
582
- policyDecision.status = 'allow';
583
- policyDecision.reason = 'Approved by human during escalation';
632
+ const escalationId = uuidv4();
633
+ // Try auto-approve before falling through to human escalation
634
+ if (deps.autoApproveModel) {
635
+ const userContext = readUserContext(deps.escalationDir);
636
+ if (userContext) {
637
+ const isPtySession = process.env.IRONCURTAIN_PTY_SESSION === '1';
638
+ if (isUserContextTrusted(userContext, isPtySession)) {
639
+ const autoResult = await autoApprove({
640
+ userMessage: userContext.userMessage,
641
+ toolName: `${toolInfo.serverName}/${toolInfo.name}`,
642
+ escalationReason: evaluation.reason,
643
+ arguments: extractArgsForAutoApprove(argsForPolicy, annotation),
644
+ }, deps.autoApproveModel);
645
+ if (autoResult.decision === 'approve') {
646
+ autoApproved = true;
647
+ escalationResult = 'approved';
648
+ policyDecision.status = 'allow';
649
+ policyDecision.reason = `Auto-approved: ${autoResult.reasoning}`;
650
+ }
651
+ }
652
+ }
653
+ }
654
+ if (!autoApproved) {
655
+ // Extract whitelist candidates just before human escalation (avoids
656
+ // wasted work when auto-approve succeeds).
657
+ const { patterns: candidatePatterns, ipcs: candidateIpcs } = extractWhitelistCandidates(toolInfo.serverName, toolInfo.name, argsForPolicy, resolvedAnnotation, evaluation.escalatedRoles, escalationId, evaluation.reason);
658
+ if (candidatePatterns.length > 0) {
659
+ // Store full patterns in proxy memory, keyed by escalation ID
660
+ pendingWhitelistCandidates.set(escalationId, candidatePatterns);
661
+ }
662
+ try {
663
+ const response = await waitForEscalationDecision(deps.escalationDir, {
664
+ escalationId,
665
+ serverName: request.serverName,
666
+ toolName: request.toolName,
667
+ arguments: argsForTransport,
668
+ reason: evaluation.reason,
669
+ context: formatServerContext(deps.serverContextMap, toolInfo.serverName),
670
+ whitelistCandidates: candidateIpcs.length > 0 ? candidateIpcs : undefined,
671
+ });
672
+ if (response.decision === 'denied') {
673
+ logAudit({ status: 'denied', error: evaluation.reason }, 0, 'denied');
674
+ return {
675
+ content: [{ type: 'text', text: `ESCALATION DENIED: ${evaluation.reason}` }],
676
+ isError: true,
677
+ };
678
+ }
679
+ escalationResult = 'approved';
680
+ policyDecision.status = 'allow';
681
+ policyDecision.reason = 'Approved by human during escalation';
682
+ // Handle whitelist selection from the response
683
+ if (response.whitelistSelection !== undefined) {
684
+ const storedPatterns = pendingWhitelistCandidates.get(escalationId);
685
+ if (storedPatterns &&
686
+ response.whitelistSelection >= 0 &&
687
+ response.whitelistSelection < storedPatterns.length) {
688
+ const selectedPattern = storedPatterns[response.whitelistSelection];
689
+ deps.whitelist.add(selectedPattern);
690
+ }
691
+ }
692
+ }
693
+ finally {
694
+ // Issue 12: ensure cleanup even if waitForEscalationDecision throws
695
+ pendingWhitelistCandidates.delete(escalationId);
696
+ }
697
+ }
584
698
  }
585
699
  // Expand roots for approved path arguments only (skip URLs, opaques)
586
700
  const state = deps.clientStates.get(toolInfo.serverName);
@@ -612,6 +726,20 @@ export async function handleCallTool(toolName, rawArgs, deps) {
612
726
  isError: true,
613
727
  };
614
728
  }
729
+ // Virtual proxy tools: handle locally, no backend forwarding
730
+ if (toolInfo.serverName === 'proxy' && deps.controlApiClient) {
731
+ const startTime = Date.now();
732
+ try {
733
+ const result = await handleVirtualProxyTool(toolInfo.name, argsForTransport, deps.controlApiClient);
734
+ logAudit({ status: 'success' }, Date.now() - startTime);
735
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
736
+ }
737
+ catch (err) {
738
+ const errorMessage = err instanceof Error ? err.message : String(err);
739
+ logAudit({ status: 'error', error: errorMessage }, Date.now() - startTime);
740
+ return { content: [{ type: 'text', text: `Error: ${errorMessage}` }], isError: true };
741
+ }
742
+ }
615
743
  // Policy allows -- forward to the real MCP server with transport args
616
744
  const startTime = Date.now();
617
745
  try {
@@ -692,6 +820,13 @@ async function main() {
692
820
  toolAnnotationsDir,
693
821
  fallbackDir: getPackageGeneratedDir(),
694
822
  });
823
+ // Merge proxy tool annotations and policy rules into the loaded artifacts
824
+ // so the PolicyEngine can evaluate proxy tool calls.
825
+ toolAnnotations.servers.proxy = {
826
+ inputHash: 'hardcoded',
827
+ tools: proxyAnnotations,
828
+ };
829
+ compiledPolicy.rules = [...proxyPolicyRules, ...compiledPolicy.rules];
695
830
  const serverDomainAllowlists = extractServerDomainAllowlists(serversConfig);
696
831
  const trustedServers = buildTrustedServerSet(serversConfig);
697
832
  const policyEngine = new PolicyEngine(compiledPolicy, toolAnnotations, protectedPaths, allowedDirectory, serverDomainAllowlists, dynamicLists, trustedServers);
@@ -703,9 +838,12 @@ async function main() {
703
838
  // ── Sandbox availability & config resolution ──────────────────────
704
839
  const { sandboxAvailable } = validateSandboxAvailability(sandboxPolicy, sessionLogPath, process.platform);
705
840
  const { resolvedSandboxConfigs, settingsDir, serverCwdPaths } = resolveServerSandboxConfigs(serversConfig, allowedDirectory, sandboxAvailable, sandboxPolicy);
841
+ // Discover node/npm paths once for all sandboxed servers with denyRead: ["~"]
842
+ const dynamicNodePaths = discoverNodePaths();
706
843
  // ── Connect to real MCP servers ───────────────────────────────────
707
844
  const clientStates = new Map();
708
845
  const allTools = [];
846
+ const tokenRefreshers = new Map();
709
847
  for (const [serverName, config] of Object.entries(serversConfig)) {
710
848
  // Skip servers whose args reference env vars (Docker -e VAR_NAME syntax)
711
849
  // that aren't set — the server will fail to start without them.
@@ -717,14 +855,110 @@ async function main() {
717
855
  logToSessionFile(sessionLogPath, `[proxy] ${warning}`);
718
856
  continue;
719
857
  }
858
+ // ── OAuth token injection for servers backed by OAuth providers ──
859
+ const oauthEnv = {};
860
+ const oauthProvider = getProviderForServer(serverName);
861
+ if (oauthProvider) {
862
+ const clientCreds = loadClientCredentials(oauthProvider);
863
+ if (!clientCreds) {
864
+ const warning = `Skipping MCP server "${serverName}": no OAuth credentials. Run 'ironcurtain auth import ${oauthProvider.id} <file>'`;
865
+ process.stderr.write(`WARNING: ${warning}\n`);
866
+ if (sessionLogPath)
867
+ logToSessionFile(sessionLogPath, `[proxy] ${warning}`);
868
+ continue;
869
+ }
870
+ const tokenProvider = new OAuthTokenProvider(oauthProvider, clientCreds);
871
+ if (!tokenProvider.isAuthorized()) {
872
+ const warning = `Skipping MCP server "${serverName}": not authorized. Run 'ironcurtain auth ${oauthProvider.id}'`;
873
+ process.stderr.write(`WARNING: ${warning}\n`);
874
+ if (sessionLogPath)
875
+ logToSessionFile(sessionLogPath, `[proxy] ${warning}`);
876
+ continue;
877
+ }
878
+ try {
879
+ const accessToken = await tokenProvider.getValidAccessToken();
880
+ const storedToken = loadOAuthToken(oauthProvider.id);
881
+ if (!storedToken) {
882
+ throw new Error(`Token file disappeared after validation for "${oauthProvider.id}"`);
883
+ }
884
+ // Create per-session credential directory
885
+ const credsDir = join(settingsDir, `${serverName}-creds`);
886
+ writeGWorkspaceCredentialFile(credsDir, accessToken, storedToken.expiresAt, storedToken.scopes);
887
+ // Inject env vars for the MCP server
888
+ oauthEnv.GWORKSPACE_CREDS_DIR = credsDir;
889
+ oauthEnv.CLIENT_ID = clientCreds.clientId;
890
+ oauthEnv.CLIENT_SECRET = clientCreds.clientSecret;
891
+ // Redirect npm cache into the credential directory for npx sandbox compatibility
892
+ oauthEnv.npm_config_cache = join(credsDir, '.npm-cache');
893
+ // Start proactive token refresh
894
+ const refresher = new TokenFileRefresher({
895
+ providerId: oauthProvider.id,
896
+ getAccessToken: async () => {
897
+ // Force refresh: match the refresher's 10-min threshold
898
+ // (getValidAccessToken's 5-min threshold would skip near-expiry tokens)
899
+ const token = await tokenProvider.forceRefresh();
900
+ const stored = loadOAuthToken(oauthProvider.id);
901
+ if (!stored) {
902
+ throw new Error(`Token file missing for "${oauthProvider.id}"`);
903
+ }
904
+ return { accessToken: token, expiresAt: stored.expiresAt, scopes: stored.scopes };
905
+ },
906
+ writeCredentialFile: (token, expiry, scopes) => {
907
+ writeGWorkspaceCredentialFile(credsDir, token, expiry, scopes);
908
+ },
909
+ logToSession: sessionLogPath ? (msg) => logToSessionFile(sessionLogPath, msg) : undefined,
910
+ }, storedToken.expiresAt);
911
+ refresher.start();
912
+ tokenRefreshers.set(serverName, refresher);
913
+ // Rewrite srt settings to allow the credential directory and
914
+ // node paths (for denyRead: ["~"] servers) in a single call
915
+ const resolvedOAuth = resolvedSandboxConfigs.get(serverName);
916
+ if (resolvedOAuth?.sandboxed) {
917
+ const hasDenyHome = resolvedOAuth.config.denyRead.some((p) => expandTilde(p) === homedir());
918
+ const extraAllowRead = hasDenyHome ? [...dynamicNodePaths, credsDir] : [credsDir];
919
+ const settingsPath = join(settingsDir, `${serverName}.srt-settings.json`);
920
+ rewriteServerSettings(settingsPath, {
921
+ allowRead: extraAllowRead,
922
+ allowWrite: [credsDir],
923
+ });
924
+ }
925
+ if (sessionLogPath) {
926
+ logToSessionFile(sessionLogPath, `[proxy] OAuth token prepared for "${serverName}" (provider: ${oauthProvider.id})`);
927
+ }
928
+ }
929
+ catch (err) {
930
+ const message = err instanceof Error ? err.message : String(err);
931
+ const warning = `Skipping MCP server "${serverName}": OAuth token error: ${message}. Run 'ironcurtain auth ${oauthProvider.id}'`;
932
+ process.stderr.write(`WARNING: ${warning}\n`);
933
+ if (sessionLogPath)
934
+ logToSessionFile(sessionLogPath, `[proxy] ${warning}`);
935
+ continue;
936
+ }
937
+ }
720
938
  const resolved = resolvedSandboxConfigs.get(serverName);
721
939
  if (!resolved)
722
940
  throw new Error(`Missing sandbox config for server "${serverName}"`);
941
+ // For non-OAuth sandboxed servers with denyRead: ["~"], inject node paths
942
+ if (!oauthProvider && resolved.sandboxed && dynamicNodePaths.length > 0) {
943
+ const hasDenyHome = resolved.config.denyRead.some((p) => expandTilde(p) === homedir());
944
+ if (hasDenyHome) {
945
+ const settingsPath = join(settingsDir, `${serverName}.srt-settings.json`);
946
+ rewriteServerSettings(settingsPath, { allowRead: dynamicNodePaths });
947
+ }
948
+ }
723
949
  const wrapped = wrapServerCommand(serverName, config.command, config.args, resolved, settingsDir);
724
950
  const transport = new StdioClientTransport({
725
951
  command: wrapped.command,
726
952
  args: wrapped.args,
727
- env: { ...process.env, ...(config.env ?? {}), ...serverCredentials },
953
+ env: {
954
+ ...process.env,
955
+ // Strip NODE_OPTIONS for sandboxed servers to prevent IDE debugger preloads
956
+ // (e.g., Cursor/VS Code) from referencing paths under ~ that denyRead blocks.
957
+ ...(resolved.sandboxed ? { NODE_OPTIONS: '' } : {}),
958
+ ...(config.env ?? {}),
959
+ ...serverCredentials,
960
+ ...oauthEnv,
961
+ },
728
962
  stderr: 'pipe',
729
963
  // Sandboxed servers use a per-server temp dir as CWD (not the sandbox)
730
964
  // to prevent srt/bwrap ghost dotfiles from polluting the sandbox directory.
@@ -738,7 +972,7 @@ async function main() {
738
972
  if (sessionLogPath) {
739
973
  const lines = text.trimEnd();
740
974
  if (lines) {
741
- const redacted = redactCredentials(lines, serverCredentials);
975
+ const redacted = redactCredentials(redactCredentials(lines, serverCredentials), oauthEnv);
742
976
  logToSessionFile(sessionLogPath, `[mcp:${serverName}] ${redacted}`);
743
977
  }
744
978
  }
@@ -776,6 +1010,26 @@ async function main() {
776
1010
  });
777
1011
  }
778
1012
  }
1013
+ // In virtual-only mode (SERVER_FILTER=proxy), register proxy tool definitions
1014
+ // and create the control API client for communicating with the MITM proxy.
1015
+ const isVirtualOnlyMode = process.env.SERVER_FILTER === 'proxy' && Object.keys(serversConfig).length === 0;
1016
+ let controlApiClient = null;
1017
+ if (isVirtualOnlyMode) {
1018
+ for (const toolDef of proxyToolDefinitions) {
1019
+ allTools.push({
1020
+ serverName: 'proxy',
1021
+ name: toolDef.name,
1022
+ description: toolDef.description,
1023
+ inputSchema: toolDef.inputSchema,
1024
+ });
1025
+ }
1026
+ const mitmControlAddr = process.env.MITM_CONTROL_ADDR;
1027
+ if (!mitmControlAddr) {
1028
+ throw new Error('MITM_CONTROL_ADDR must be set when running in virtual-only proxy mode ' +
1029
+ '(SERVER_FILTER=proxy with no other MCP servers).');
1030
+ }
1031
+ controlApiClient = createControlApiClient(mitmControlAddr);
1032
+ }
779
1033
  const toolMap = buildToolMap(allTools);
780
1034
  const toolDescriptionHints = loadToolDescriptionHints();
781
1035
  const hintedTools = applyToolDescriptionHints(allTools, toolDescriptionHints);
@@ -802,6 +1056,8 @@ async function main() {
802
1056
  escalationDir,
803
1057
  autoApproveModel,
804
1058
  serverContextMap,
1059
+ whitelist: createApprovalWhitelist(),
1060
+ controlApiClient,
805
1061
  };
806
1062
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
807
1063
  return handleCallTool(req.params.name, req.params.arguments ?? {}, callToolDeps);
@@ -829,6 +1085,11 @@ async function main() {
829
1085
  await server.connect(transport);
830
1086
  // ── Shutdown handler ──────────────────────────────────────────────
831
1087
  async function shutdown() {
1088
+ // Stop token refreshers before closing clients
1089
+ for (const refresher of tokenRefreshers.values()) {
1090
+ refresher.stop();
1091
+ }
1092
+ tokenRefreshers.clear();
832
1093
  for (const state of clientStates.values()) {
833
1094
  try {
834
1095
  await state.client.close();