@provos/ironcurtain 0.7.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (259) hide show
  1. package/README.md +29 -100
  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/first-start.js +4 -1
  33. package/dist/config/first-start.js.map +1 -1
  34. package/dist/config/generated/test-scenarios.json +1 -1
  35. package/dist/config/generated/tool-annotations.json +10617 -296
  36. package/dist/config/generated-readonly/compiled-policy.json +787 -244
  37. package/dist/config/index.d.ts +2 -2
  38. package/dist/config/index.js +8 -0
  39. package/dist/config/index.js.map +1 -1
  40. package/dist/config/mcp-servers.json +31 -2
  41. package/dist/config/paths.d.ts +20 -0
  42. package/dist/config/paths.js +43 -0
  43. package/dist/config/paths.js.map +1 -1
  44. package/dist/config/types.d.ts +8 -0
  45. package/dist/config/user-config.d.ts +29 -0
  46. package/dist/config/user-config.js +30 -0
  47. package/dist/config/user-config.js.map +1 -1
  48. package/dist/cron/constitution-generator.d.ts +2 -1
  49. package/dist/cron/constitution-generator.js +35 -36
  50. package/dist/cron/constitution-generator.js.map +1 -1
  51. package/dist/cron/headless-transport.d.ts +4 -0
  52. package/dist/cron/headless-transport.js +1 -1
  53. package/dist/cron/headless-transport.js.map +1 -1
  54. package/dist/daemon/ironcurtain-daemon.js +7 -1
  55. package/dist/daemon/ironcurtain-daemon.js.map +1 -1
  56. package/dist/docker/adapters/claude-code.js +32 -5
  57. package/dist/docker/adapters/claude-code.js.map +1 -1
  58. package/dist/docker/agent-adapter.d.ts +40 -0
  59. package/dist/docker/claude-md-seed.d.ts +27 -0
  60. package/dist/docker/claude-md-seed.js +42 -0
  61. package/dist/docker/claude-md-seed.js.map +1 -0
  62. package/dist/docker/docker-agent-session.d.ts +3 -1
  63. package/dist/docker/docker-agent-session.js +51 -28
  64. package/dist/docker/docker-agent-session.js.map +1 -1
  65. package/dist/docker/docker-infrastructure.d.ts +15 -1
  66. package/dist/docker/docker-infrastructure.js +105 -9
  67. package/dist/docker/docker-infrastructure.js.map +1 -1
  68. package/dist/docker/docker-manager.d.ts +2 -0
  69. package/dist/docker/docker-manager.js +9 -1
  70. package/dist/docker/docker-manager.js.map +1 -1
  71. package/dist/docker/mitm-proxy.d.ts +65 -0
  72. package/dist/docker/mitm-proxy.js +288 -31
  73. package/dist/docker/mitm-proxy.js.map +1 -1
  74. package/dist/docker/package-types.d.ts +127 -0
  75. package/dist/docker/package-types.js +8 -0
  76. package/dist/docker/package-types.js.map +1 -0
  77. package/dist/docker/package-validator.d.ts +41 -0
  78. package/dist/docker/package-validator.js +90 -0
  79. package/dist/docker/package-validator.js.map +1 -0
  80. package/dist/docker/platform.d.ts +6 -6
  81. package/dist/docker/platform.js +8 -6
  82. package/dist/docker/platform.js.map +1 -1
  83. package/dist/docker/proxy-tools.d.ts +80 -0
  84. package/dist/docker/proxy-tools.js +248 -0
  85. package/dist/docker/proxy-tools.js.map +1 -0
  86. package/dist/docker/pty-session.d.ts +15 -0
  87. package/dist/docker/pty-session.js +253 -73
  88. package/dist/docker/pty-session.js.map +1 -1
  89. package/dist/docker/pty-types.d.ts +28 -0
  90. package/dist/docker/pty-types.js +2 -0
  91. package/dist/docker/pty-types.js.map +1 -1
  92. package/dist/docker/registry-proxy.d.ts +142 -0
  93. package/dist/docker/registry-proxy.js +856 -0
  94. package/dist/docker/registry-proxy.js.map +1 -0
  95. package/dist/escalation/escalation-watcher.d.ts +3 -1
  96. package/dist/escalation/escalation-watcher.js +6 -2
  97. package/dist/escalation/escalation-watcher.js.map +1 -1
  98. package/dist/escalation/listener-command.js +24 -10
  99. package/dist/escalation/listener-command.js.map +1 -1
  100. package/dist/escalation/listener-lock.d.ts +5 -0
  101. package/dist/escalation/listener-lock.js +14 -0
  102. package/dist/escalation/listener-lock.js.map +1 -1
  103. package/dist/index.js +16 -3
  104. package/dist/index.js.map +1 -1
  105. package/dist/memory/auto-save.d.ts +25 -0
  106. package/dist/memory/auto-save.js +89 -0
  107. package/dist/memory/auto-save.js.map +1 -0
  108. package/dist/memory/memory-annotations.d.ts +47 -0
  109. package/dist/memory/memory-annotations.js +87 -0
  110. package/dist/memory/memory-annotations.js.map +1 -0
  111. package/dist/memory/memory-prompt.d.ts +12 -0
  112. package/dist/memory/memory-prompt.js +20 -0
  113. package/dist/memory/memory-prompt.js.map +1 -0
  114. package/dist/memory/resolve-memory-path.d.ts +20 -0
  115. package/dist/memory/resolve-memory-path.js +30 -0
  116. package/dist/memory/resolve-memory-path.js.map +1 -0
  117. package/dist/mux/mux-app.d.ts +4 -0
  118. package/dist/mux/mux-app.js +246 -28
  119. package/dist/mux/mux-app.js.map +1 -1
  120. package/dist/mux/mux-command.d.ts +2 -2
  121. package/dist/mux/mux-command.js +14 -22
  122. package/dist/mux/mux-command.js.map +1 -1
  123. package/dist/mux/mux-escalation-manager.d.ts +9 -3
  124. package/dist/mux/mux-escalation-manager.js +69 -19
  125. package/dist/mux/mux-escalation-manager.js.map +1 -1
  126. package/dist/mux/mux-input-handler.d.ts +59 -3
  127. package/dist/mux/mux-input-handler.js +270 -32
  128. package/dist/mux/mux-input-handler.js.map +1 -1
  129. package/dist/mux/mux-renderer.d.ts +19 -1
  130. package/dist/mux/mux-renderer.js +466 -11
  131. package/dist/mux/mux-renderer.js.map +1 -1
  132. package/dist/mux/persona-scanner.d.ts +19 -0
  133. package/dist/mux/persona-scanner.js +53 -0
  134. package/dist/mux/persona-scanner.js.map +1 -0
  135. package/dist/mux/pty-bridge.d.ts +8 -0
  136. package/dist/mux/pty-bridge.js +19 -1
  137. package/dist/mux/pty-bridge.js.map +1 -1
  138. package/dist/mux/session-scanner.d.ts +18 -0
  139. package/dist/mux/session-scanner.js +65 -0
  140. package/dist/mux/session-scanner.js.map +1 -0
  141. package/dist/mux/types.d.ts +36 -1
  142. package/dist/mux/types.js +14 -1
  143. package/dist/mux/types.js.map +1 -1
  144. package/dist/persona/persona-command.js +130 -93
  145. package/dist/persona/persona-command.js.map +1 -1
  146. package/dist/persona/persona-prompt.d.ts +7 -7
  147. package/dist/persona/persona-prompt.js +14 -29
  148. package/dist/persona/persona-prompt.js.map +1 -1
  149. package/dist/persona/resolve.js +5 -2
  150. package/dist/persona/resolve.js.map +1 -1
  151. package/dist/pipeline/annotate.js +1 -7
  152. package/dist/pipeline/annotate.js.map +1 -1
  153. package/dist/pipeline/compile.d.ts +5 -2
  154. package/dist/pipeline/compile.js +23 -11
  155. package/dist/pipeline/compile.js.map +1 -1
  156. package/dist/pipeline/constitution-compiler.d.ts +302 -3
  157. package/dist/pipeline/constitution-compiler.js +285 -23
  158. package/dist/pipeline/constitution-compiler.js.map +1 -1
  159. package/dist/pipeline/generate-with-repair.d.ts +2 -2
  160. package/dist/pipeline/generate-with-repair.js +2 -2
  161. package/dist/pipeline/handwritten-scenarios.d.ts +20 -7
  162. package/dist/pipeline/handwritten-scenarios.js +20 -41
  163. package/dist/pipeline/handwritten-scenarios.js.map +1 -1
  164. package/dist/pipeline/list-resolver.d.ts +18 -20
  165. package/dist/pipeline/list-resolver.js +28 -36
  166. package/dist/pipeline/list-resolver.js.map +1 -1
  167. package/dist/pipeline/pipeline-runner.d.ts +102 -13
  168. package/dist/pipeline/pipeline-runner.js +497 -237
  169. package/dist/pipeline/pipeline-runner.js.map +1 -1
  170. package/dist/pipeline/pipeline-shared.d.ts +7 -9
  171. package/dist/pipeline/pipeline-shared.js +8 -18
  172. package/dist/pipeline/pipeline-shared.js.map +1 -1
  173. package/dist/pipeline/policy-verifier.d.ts +32 -5
  174. package/dist/pipeline/policy-verifier.js +137 -8
  175. package/dist/pipeline/policy-verifier.js.map +1 -1
  176. package/dist/pipeline/proxy-mcp-connections.d.ts +25 -0
  177. package/dist/pipeline/proxy-mcp-connections.js +121 -0
  178. package/dist/pipeline/proxy-mcp-connections.js.map +1 -0
  179. package/dist/pipeline/refresh-lists.js +21 -13
  180. package/dist/pipeline/refresh-lists.js.map +1 -1
  181. package/dist/pipeline/scenario-generator.d.ts +29 -49
  182. package/dist/pipeline/scenario-generator.js +180 -143
  183. package/dist/pipeline/scenario-generator.js.map +1 -1
  184. package/dist/pipeline/scenario-schema-validator.d.ts +51 -0
  185. package/dist/pipeline/scenario-schema-validator.js +132 -0
  186. package/dist/pipeline/scenario-schema-validator.js.map +1 -0
  187. package/dist/pipeline/tool-annotator.d.ts +9 -8
  188. package/dist/pipeline/tool-annotator.js +48 -95
  189. package/dist/pipeline/tool-annotator.js.map +1 -1
  190. package/dist/pipeline/types.d.ts +31 -24
  191. package/dist/sandbox/index.d.ts +26 -10
  192. package/dist/sandbox/index.js +151 -40
  193. package/dist/sandbox/index.js.map +1 -1
  194. package/dist/servers/fetch-server.js +10 -5
  195. package/dist/servers/fetch-server.js.map +1 -1
  196. package/dist/session/agent-session.d.ts +8 -20
  197. package/dist/session/agent-session.js +22 -70
  198. package/dist/session/agent-session.js.map +1 -1
  199. package/dist/session/base-transport.d.ts +17 -1
  200. package/dist/session/base-transport.js +25 -1
  201. package/dist/session/base-transport.js.map +1 -1
  202. package/dist/session/cli-transport.d.ts +5 -0
  203. package/dist/session/cli-transport.js +48 -25
  204. package/dist/session/cli-transport.js.map +1 -1
  205. package/dist/session/index.d.ts +25 -1
  206. package/dist/session/index.js +57 -8
  207. package/dist/session/index.js.map +1 -1
  208. package/dist/session/prompts.js +8 -9
  209. package/dist/session/prompts.js.map +1 -1
  210. package/dist/session/session-manager.d.ts +6 -0
  211. package/dist/session/session-manager.js +12 -0
  212. package/dist/session/session-manager.js.map +1 -1
  213. package/dist/session/types.d.ts +11 -1
  214. package/dist/session/types.js.map +1 -1
  215. package/dist/session/workspace-validation.js +37 -6
  216. package/dist/session/workspace-validation.js.map +1 -1
  217. package/dist/signal/signal-bot-daemon.js +24 -8
  218. package/dist/signal/signal-bot-daemon.js.map +1 -1
  219. package/dist/signal/signal-transport.d.ts +5 -2
  220. package/dist/signal/signal-transport.js +3 -3
  221. package/dist/signal/signal-transport.js.map +1 -1
  222. package/dist/trusted-process/approval-whitelist.d.ts +117 -0
  223. package/dist/trusted-process/approval-whitelist.js +237 -0
  224. package/dist/trusted-process/approval-whitelist.js.map +1 -0
  225. package/dist/trusted-process/gworkspace-credentials.d.ts +40 -0
  226. package/dist/trusted-process/gworkspace-credentials.js +45 -0
  227. package/dist/trusted-process/gworkspace-credentials.js.map +1 -0
  228. package/dist/trusted-process/index.d.ts +11 -1
  229. package/dist/trusted-process/index.js +83 -53
  230. package/dist/trusted-process/index.js.map +1 -1
  231. package/dist/trusted-process/mcp-proxy-server.d.ts +23 -0
  232. package/dist/trusted-process/mcp-proxy-server.js +409 -94
  233. package/dist/trusted-process/mcp-proxy-server.js.map +1 -1
  234. package/dist/trusted-process/policy-engine.d.ts +12 -3
  235. package/dist/trusted-process/policy-engine.js +33 -23
  236. package/dist/trusted-process/policy-engine.js.map +1 -1
  237. package/dist/trusted-process/policy-types.d.ts +17 -0
  238. package/dist/trusted-process/sandbox-integration.d.ts +23 -0
  239. package/dist/trusted-process/sandbox-integration.js +115 -3
  240. package/dist/trusted-process/sandbox-integration.js.map +1 -1
  241. package/dist/trusted-process/token-file-refresher.d.ts +69 -0
  242. package/dist/trusted-process/token-file-refresher.js +120 -0
  243. package/dist/trusted-process/token-file-refresher.js.map +1 -0
  244. package/dist/types/argument-roles.d.ts +7 -1
  245. package/dist/types/argument-roles.js +50 -0
  246. package/dist/types/argument-roles.js.map +1 -1
  247. package/dist/types/audit.d.ts +11 -0
  248. package/docker/Dockerfile.base +22 -0
  249. package/docker/Dockerfile.base.arm64 +44 -4
  250. package/docker/Dockerfile.claude-code +2 -2
  251. package/docker/entrypoint-claude-code.sh +24 -6
  252. package/package.json +8 -2
  253. package/src/config/constitution-user-base.md +15 -0
  254. package/src/config/generated/test-scenarios.json +1 -1
  255. package/src/config/generated/tool-annotations.json +10617 -296
  256. package/src/config/mcp-servers.json +31 -2
  257. package/dist/pipeline/mcp-connections.d.ts +0 -16
  258. package/dist/pipeline/mcp-connections.js +0 -61
  259. 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';
@@ -57,21 +57,43 @@ import { extractMcpErrorMessage } from './mcp-error-utils.js';
57
57
  import { updateServerContext, formatServerContext } from './server-context.js';
58
58
  import { permissiveJsonSchemaValidator } from './permissive-output-validator.js';
59
59
  import { VERSION } from '../version.js';
60
+ import { buildTrustedServerSet } from '../memory/memory-annotations.js';
60
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();
61
77
  /**
62
78
  * Adds a root to a client's root list and waits for the server to
63
79
  * fetch the updated list. No-op if the root URI is already present.
64
80
  * Times out after ROOTS_REFRESH_TIMEOUT_MS if the server never requests
65
81
  * the updated list (e.g. servers that don't implement Roots protocol).
82
+ *
83
+ * Returns `'added'` when the server acknowledged the update (called
84
+ * `roots/list`), `'timeout'` when the server didn't respond in time,
85
+ * or `false` when the root was already present.
66
86
  */
67
87
  async function addRootToClient(state, root) {
68
88
  if (state.roots.some((r) => r.uri === root.uri))
69
- return;
89
+ return false;
70
90
  state.roots.push(root);
71
91
  let timer;
92
+ let outcome = 'timeout';
72
93
  const refreshed = new Promise((resolve) => {
73
94
  state.rootsRefreshed = () => {
74
95
  clearTimeout(timer);
96
+ outcome = 'added';
75
97
  resolve();
76
98
  };
77
99
  });
@@ -84,6 +106,28 @@ async function addRootToClient(state, root) {
84
106
  });
85
107
  await state.client.sendRootsListChanged();
86
108
  await Promise.race([refreshed, timeout]);
109
+ return outcome;
110
+ }
111
+ /**
112
+ * Delay before retrying a tool call that returned "access denied" immediately
113
+ * after roots expansion. The filesystem server needs time to async-validate
114
+ * (fs.realpath, fs.stat) the new roots after receiving the rootsListChanged
115
+ * notification. Exported for test access.
116
+ */
117
+ export const ROOTS_RACE_RETRY_DELAY_MS = 200;
118
+ /**
119
+ * Checks whether an MCP tool call result looks like a roots-race "access denied"
120
+ * error -- the filesystem server rejected the call because it hasn't finished
121
+ * processing the updated roots yet.
122
+ */
123
+ function isRootsRaceError(result) {
124
+ if (!('isError' in result) || !result.isError)
125
+ return false;
126
+ const text = extractTextFromContent(result.content);
127
+ if (!text)
128
+ return false;
129
+ const lower = text.toLowerCase();
130
+ return lower.includes('access denied') || lower.includes('outside allowed directories');
87
131
  }
88
132
  /** Appends a timestamped line to the session log file. */
89
133
  function logToSessionFile(sessionLogPath, message) {
@@ -114,7 +158,7 @@ function getMissingEnvVars(args) {
114
158
  return missing;
115
159
  }
116
160
  /** Extracts concatenated text from MCP content blocks. */
117
- function extractTextFromContent(content) {
161
+ export function extractTextFromContent(content) {
118
162
  if (!Array.isArray(content))
119
163
  return undefined;
120
164
  const texts = content
@@ -201,13 +245,21 @@ function cleanupEscalationFiles(requestPath, responsePath) {
201
245
  }
202
246
  /**
203
247
  * Reads and parses the escalation response file if it exists.
204
- * Returns the decision, or undefined if the file is not present.
248
+ * Returns the response data, or undefined if the file is not present.
205
249
  */
206
250
  function readEscalationResponse(responsePath) {
207
251
  if (!existsSync(responsePath))
208
252
  return undefined;
209
- const response = JSON.parse(readFileSync(responsePath, 'utf-8'));
210
- 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
+ };
211
263
  }
212
264
  /**
213
265
  * Waits for a human decision via file-based IPC.
@@ -224,17 +276,17 @@ async function waitForEscalationDecision(escalationDir, request) {
224
276
  atomicWriteJsonSync(requestPath, request);
225
277
  const deadline = Date.now() + getEscalationTimeoutMs();
226
278
  while (Date.now() < deadline) {
227
- const decision = readEscalationResponse(responsePath);
228
- if (decision) {
279
+ const response = readEscalationResponse(responsePath);
280
+ if (response) {
229
281
  cleanupEscalationFiles(requestPath, responsePath);
230
- return decision;
282
+ return response;
231
283
  }
232
284
  await new Promise((r) => setTimeout(r, ESCALATION_POLL_INTERVAL_MS));
233
285
  }
234
286
  // Final check -- response may have arrived between last poll and deadline
235
- const lateDecision = readEscalationResponse(responsePath);
287
+ const lateResponse = readEscalationResponse(responsePath);
236
288
  cleanupEscalationFiles(requestPath, responsePath);
237
- return lateDecision ?? 'denied';
289
+ return lateResponse ?? { decision: 'denied' };
238
290
  }
239
291
  /**
240
292
  * Creates the auto-approve LLM model from environment variables.
@@ -303,12 +355,17 @@ export function parseProxyEnvConfig() {
303
355
  const allServersConfig = JSON.parse(serversConfigJson);
304
356
  const protectedPaths = JSON.parse(protectedPathsJson);
305
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.
306
359
  const serverFilter = process.env.SERVER_FILTER;
307
- const serversConfig = serverFilter
308
- ? { [serverFilter]: allServersConfig[serverFilter] }
309
- : 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;
310
367
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- defensive: Record index may be undefined at runtime
311
- if (serverFilter && !allServersConfig[serverFilter]) {
368
+ if (serverFilter && !allServersConfig[serverFilter] && !isVirtualOnly) {
312
369
  process.stderr.write(`SERVER_FILTER: unknown server "${serverFilter}"\n`);
313
370
  process.exit(1);
314
371
  }
@@ -376,6 +433,28 @@ export function resolveServerSandboxConfigs(serversConfig, allowedDirectory, san
376
433
  }
377
434
  return { resolvedSandboxConfigs, settingsDir, serverCwdPaths };
378
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
+ }
379
458
  /** Builds a lookup map from tool name to ProxiedTool for routing. */
380
459
  export function buildToolMap(allTools) {
381
460
  const toolMap = new Map();
@@ -401,6 +480,8 @@ export function buildAuditEntry(request, argsForTransport, policyDecision, resul
401
480
  durationMs,
402
481
  sandboxed: options.sandboxed || undefined,
403
482
  autoApproved: options.autoApproved || undefined,
483
+ whitelistApproved: options.whitelistApproved || undefined,
484
+ whitelistPatternId: options.whitelistPatternId,
404
485
  };
405
486
  }
406
487
  /**
@@ -418,9 +499,11 @@ export async function handleCallTool(toolName, rawArgs, deps) {
418
499
  isError: true,
419
500
  };
420
501
  }
421
- // Annotation-driven normalization: split into transport vs policy args
502
+ // Annotation-driven normalization: split into transport vs policy args.
503
+ // Trusted servers skip annotation lookup and prepareToolArgs — use raw args directly.
422
504
  const annotation = deps.policyEngine.getAnnotation(toolInfo.serverName, toolInfo.name, rawArgs);
423
- if (!annotation) {
505
+ const isTrusted = !annotation && deps.policyEngine.isTrustedServer(toolInfo.serverName);
506
+ if (!annotation && !isTrusted) {
424
507
  return {
425
508
  content: [
426
509
  {
@@ -431,34 +514,66 @@ export async function handleCallTool(toolName, rawArgs, deps) {
431
514
  isError: true,
432
515
  };
433
516
  }
434
- // Enrich git tool args with the locally tracked working directory when
435
- // the agent omits the `path` argument. The git MCP server tracks its own
436
- // CWD (set via git_set_working_dir) and uses it implicitly, but the
437
- // policy engine needs the explicit path to resolve sandbox containment
438
- // and the default remote URL for domain-based policy rules.
439
- // The serverContextMap mirrors the git server's working directory from
440
- // successful git_set_working_dir / git_clone calls, avoiding an RPC.
441
- let effectiveRawArgs = rawArgs;
442
- const hasEffectivePath = 'path' in rawArgs && typeof rawArgs.path === 'string' && rawArgs.path.trim() !== '';
443
- if (toolInfo.serverName === 'git' && 'path' in annotation.args && !hasEffectivePath) {
444
- const gitWorkDir = deps.serverContextMap.get('git')?.workingDirectory;
445
- if (!gitWorkDir) {
446
- const errorMsg = 'Git server has no working directory set. Call git_set_working_dir first.';
447
- deps.auditLog.log(buildAuditEntry({
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(),
448
524
  requestId: uuidv4(),
449
525
  serverName: toolInfo.serverName,
450
526
  toolName: toolInfo.name,
451
527
  arguments: rawArgs,
452
- timestamp: new Date().toISOString(),
453
- }, rawArgs, { status: 'deny', rule: 'git-path-enrichment-failed', reason: errorMsg }, { status: 'denied', error: errorMsg }, 0, {}));
528
+ policyDecision: { status: 'deny', rule: 'invalid-arguments', reason: validationError },
529
+ result: { status: 'denied', error: validationError },
530
+ durationMs: 0,
531
+ });
454
532
  return {
455
- content: [{ type: 'text', text: `Error: ${errorMsg}` }],
533
+ content: [{ type: 'text', text: validationError }],
456
534
  isError: true,
457
535
  };
458
536
  }
459
- effectiveRawArgs = { ...rawArgs, path: gitWorkDir };
460
537
  }
461
- const { argsForTransport, argsForPolicy } = prepareToolArgs(effectiveRawArgs, annotation, deps.allowedDirectory, CONTAINER_WORKSPACE_DIR);
538
+ let argsForTransport;
539
+ let argsForPolicy;
540
+ if (isTrusted) {
541
+ // Trusted server with no annotation: use raw args for both transport and policy
542
+ argsForTransport = rawArgs;
543
+ argsForPolicy = rawArgs;
544
+ }
545
+ else {
546
+ // annotation is guaranteed non-null here: we returned early if both are falsy
547
+ const resolvedAnnotation = annotation;
548
+ // Enrich git tool args with the locally tracked working directory when
549
+ // the agent omits the `path` argument. The git MCP server tracks its own
550
+ // CWD (set via git_set_working_dir) and uses it implicitly, but the
551
+ // policy engine needs the explicit path to resolve sandbox containment
552
+ // and the default remote URL for domain-based policy rules.
553
+ // The serverContextMap mirrors the git server's working directory from
554
+ // successful git_set_working_dir / git_clone calls, avoiding an RPC.
555
+ let effectiveRawArgs = rawArgs;
556
+ const hasEffectivePath = 'path' in rawArgs && typeof rawArgs.path === 'string' && rawArgs.path.trim() !== '';
557
+ if (toolInfo.serverName === 'git' && 'path' in resolvedAnnotation.args && !hasEffectivePath) {
558
+ const gitWorkDir = deps.serverContextMap.get('git')?.workingDirectory;
559
+ if (!gitWorkDir) {
560
+ const errorMsg = 'Git server has no working directory set. Call git_set_working_dir first.';
561
+ deps.auditLog.log(buildAuditEntry({
562
+ requestId: uuidv4(),
563
+ serverName: toolInfo.serverName,
564
+ toolName: toolInfo.name,
565
+ arguments: rawArgs,
566
+ timestamp: new Date().toISOString(),
567
+ }, rawArgs, { status: 'deny', rule: 'git-path-enrichment-failed', reason: errorMsg }, { status: 'denied', error: errorMsg }, 0, {}));
568
+ return {
569
+ content: [{ type: 'text', text: `Error: ${errorMsg}` }],
570
+ isError: true,
571
+ };
572
+ }
573
+ effectiveRawArgs = { ...rawArgs, path: gitWorkDir };
574
+ }
575
+ ({ argsForTransport, argsForPolicy } = prepareToolArgs(effectiveRawArgs, resolvedAnnotation, deps.allowedDirectory, CONTAINER_WORKSPACE_DIR));
576
+ }
462
577
  const request = {
463
578
  requestId: uuidv4(),
464
579
  serverName: toolInfo.serverName,
@@ -474,6 +589,9 @@ export async function handleCallTool(toolName, rawArgs, deps) {
474
589
  };
475
590
  let escalationResult;
476
591
  let autoApproved = false;
592
+ let whitelistApproved = false;
593
+ let whitelistPatternId;
594
+ let rootsExpanded = false;
477
595
  const serverSandboxConfig = deps.resolvedSandboxConfigs.get(toolInfo.serverName);
478
596
  const serverIsSandboxed = serverSandboxConfig?.sandboxed === true;
479
597
  function logAudit(result, durationMs, overrideEscalation) {
@@ -481,73 +599,114 @@ export async function handleCallTool(toolName, rawArgs, deps) {
481
599
  escalationResult: overrideEscalation ?? escalationResult,
482
600
  sandboxed: serverIsSandboxed,
483
601
  autoApproved,
602
+ whitelistApproved: whitelistApproved || undefined,
603
+ whitelistPatternId,
484
604
  });
485
605
  deps.auditLog.log(entry);
486
606
  }
487
607
  if (evaluation.decision === 'escalate') {
488
- if (!deps.escalationDir) {
489
- logAudit({ status: 'denied', error: evaluation.reason }, 0, 'denied');
490
- return {
491
- content: [
492
- {
493
- type: 'text',
494
- text: `ESCALATION REQUIRED: ${evaluation.reason}. Action denied (no escalation handler).`,
495
- },
496
- ],
497
- isError: true,
498
- };
499
- }
500
- // Try auto-approve before falling through to human escalation
501
- if (deps.autoApproveModel) {
502
- const userContext = readUserContext(deps.escalationDir);
503
- if (userContext) {
504
- const isPtySession = process.env.IRONCURTAIN_PTY_SESSION === '1';
505
- if (isUserContextTrusted(userContext, isPtySession)) {
506
- const autoResult = await autoApprove({
507
- userMessage: userContext.userMessage,
508
- toolName: `${toolInfo.serverName}/${toolInfo.name}`,
509
- escalationReason: evaluation.reason,
510
- arguments: extractArgsForAutoApprove(argsForPolicy, annotation),
511
- }, deps.autoApproveModel);
512
- if (autoResult.decision === 'approve') {
513
- autoApproved = true;
514
- escalationResult = 'approved';
515
- policyDecision.status = 'allow';
516
- policyDecision.reason = `Auto-approved: ${autoResult.reasoning}`;
517
- }
518
- }
519
- }
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}`;
520
618
  }
521
- if (!autoApproved) {
522
- const escalationId = uuidv4();
523
- const decision = await waitForEscalationDecision(deps.escalationDir, {
524
- escalationId,
525
- serverName: request.serverName,
526
- toolName: request.toolName,
527
- arguments: argsForTransport,
528
- reason: evaluation.reason,
529
- context: formatServerContext(deps.serverContextMap, toolInfo.serverName),
530
- });
531
- if (decision === 'denied') {
619
+ if (!whitelistApproved) {
620
+ if (!deps.escalationDir) {
532
621
  logAudit({ status: 'denied', error: evaluation.reason }, 0, 'denied');
533
622
  return {
534
- 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
+ ],
535
629
  isError: true,
536
630
  };
537
631
  }
538
- escalationResult = 'approved';
539
- policyDecision.status = 'allow';
540
- 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
+ }
541
698
  }
542
699
  // Expand roots for approved path arguments only (skip URLs, opaques)
543
700
  const state = deps.clientStates.get(toolInfo.serverName);
544
- if (state) {
701
+ if (state && annotation) {
545
702
  const pathValues = extractAnnotatedPaths(argsForTransport, annotation, getPathRoles());
546
703
  for (const p of pathValues) {
547
- await addRootToClient(state, {
704
+ const result = await addRootToClient(state, {
548
705
  uri: `file://${directoryForPath(p)}`,
549
706
  name: 'escalation-approved',
550
707
  });
708
+ if (result === 'added')
709
+ rootsExpanded = true;
551
710
  }
552
711
  }
553
712
  }
@@ -567,6 +726,20 @@ export async function handleCallTool(toolName, rawArgs, deps) {
567
726
  isError: true,
568
727
  };
569
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
+ }
570
743
  // Policy allows -- forward to the real MCP server with transport args
571
744
  const startTime = Date.now();
572
745
  try {
@@ -580,10 +753,18 @@ export async function handleCallTool(toolName, rawArgs, deps) {
580
753
  // CompatibilityCallToolResultSchema accepts the legacy `toolResult` response format
581
754
  // (protocol version 2024-10-07). Output schema validation is intentionally
582
755
  // bypassed by the permissiveJsonSchemaValidator injected at Client construction time.
583
- const result = await client.callTool({
584
- name: toolInfo.name,
585
- arguments: argsForTransport,
586
- }, CompatibilityCallToolResultSchema, { timeout: getEscalationTimeoutMs() });
756
+ const callToolArgs = { name: toolInfo.name, arguments: argsForTransport };
757
+ const callToolOpts = { timeout: getEscalationTimeoutMs() };
758
+ let result = await client.callTool(callToolArgs, CompatibilityCallToolResultSchema, callToolOpts);
759
+ // Race condition mitigation: after roots expansion the filesystem server
760
+ // has been notified but may not have finished async-validating the new
761
+ // roots (fs.realpath, fs.stat). Retry once after a short delay.
762
+ if (rootsExpanded && isRootsRaceError(result)) {
763
+ const initialError = extractTextFromContent(result.content) ?? 'access denied (roots race)';
764
+ logAudit({ status: 'error', error: `Roots race detected, retrying: ${initialError}` }, Date.now() - startTime);
765
+ await new Promise((r) => setTimeout(r, ROOTS_RACE_RETRY_DELAY_MS));
766
+ result = await client.callTool(callToolArgs, CompatibilityCallToolResultSchema, callToolOpts);
767
+ }
587
768
  // Reverse-rewrite host sandbox paths to container workspace paths in results
588
769
  const rewrittenContent = deps.allowedDirectory
589
770
  ? rewriteResultContent(result.content, deps.allowedDirectory, CONTAINER_WORKSPACE_DIR)
@@ -639,8 +820,16 @@ async function main() {
639
820
  toolAnnotationsDir,
640
821
  fallbackDir: getPackageGeneratedDir(),
641
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];
642
830
  const serverDomainAllowlists = extractServerDomainAllowlists(serversConfig);
643
- const policyEngine = new PolicyEngine(compiledPolicy, toolAnnotations, protectedPaths, allowedDirectory, serverDomainAllowlists, dynamicLists);
831
+ const trustedServers = buildTrustedServerSet(serversConfig);
832
+ const policyEngine = new PolicyEngine(compiledPolicy, toolAnnotations, protectedPaths, allowedDirectory, serverDomainAllowlists, dynamicLists, trustedServers);
644
833
  const auditLog = new AuditLog(auditLogPath, { redact: auditRedaction });
645
834
  const circuitBreaker = new CallCircuitBreaker();
646
835
  const autoApproveModel = await createAutoApproveModel(sessionLogPath);
@@ -649,9 +838,12 @@ async function main() {
649
838
  // ── Sandbox availability & config resolution ──────────────────────
650
839
  const { sandboxAvailable } = validateSandboxAvailability(sandboxPolicy, sessionLogPath, process.platform);
651
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();
652
843
  // ── Connect to real MCP servers ───────────────────────────────────
653
844
  const clientStates = new Map();
654
845
  const allTools = [];
846
+ const tokenRefreshers = new Map();
655
847
  for (const [serverName, config] of Object.entries(serversConfig)) {
656
848
  // Skip servers whose args reference env vars (Docker -e VAR_NAME syntax)
657
849
  // that aren't set — the server will fail to start without them.
@@ -663,14 +855,110 @@ async function main() {
663
855
  logToSessionFile(sessionLogPath, `[proxy] ${warning}`);
664
856
  continue;
665
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
+ }
666
938
  const resolved = resolvedSandboxConfigs.get(serverName);
667
939
  if (!resolved)
668
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
+ }
669
949
  const wrapped = wrapServerCommand(serverName, config.command, config.args, resolved, settingsDir);
670
950
  const transport = new StdioClientTransport({
671
951
  command: wrapped.command,
672
952
  args: wrapped.args,
673
- 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
+ },
674
962
  stderr: 'pipe',
675
963
  // Sandboxed servers use a per-server temp dir as CWD (not the sandbox)
676
964
  // to prevent srt/bwrap ghost dotfiles from polluting the sandbox directory.
@@ -684,7 +972,7 @@ async function main() {
684
972
  if (sessionLogPath) {
685
973
  const lines = text.trimEnd();
686
974
  if (lines) {
687
- const redacted = redactCredentials(lines, serverCredentials);
975
+ const redacted = redactCredentials(redactCredentials(lines, serverCredentials), oauthEnv);
688
976
  logToSessionFile(sessionLogPath, `[mcp:${serverName}] ${redacted}`);
689
977
  }
690
978
  }
@@ -722,6 +1010,26 @@ async function main() {
722
1010
  });
723
1011
  }
724
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
+ }
725
1033
  const toolMap = buildToolMap(allTools);
726
1034
  const toolDescriptionHints = loadToolDescriptionHints();
727
1035
  const hintedTools = applyToolDescriptionHints(allTools, toolDescriptionHints);
@@ -748,6 +1056,8 @@ async function main() {
748
1056
  escalationDir,
749
1057
  autoApproveModel,
750
1058
  serverContextMap,
1059
+ whitelist: createApprovalWhitelist(),
1060
+ controlApiClient,
751
1061
  };
752
1062
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
753
1063
  return handleCallTool(req.params.name, req.params.arguments ?? {}, callToolDeps);
@@ -775,6 +1085,11 @@ async function main() {
775
1085
  await server.connect(transport);
776
1086
  // ── Shutdown handler ──────────────────────────────────────────────
777
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();
778
1093
  for (const state of clientStates.values()) {
779
1094
  try {
780
1095
  await state.client.close();