@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.
- package/README.md +29 -100
- package/dist/auth/auth-command.d.ts +15 -0
- package/dist/auth/auth-command.js +424 -0
- package/dist/auth/auth-command.js.map +1 -0
- package/dist/auth/oauth-flow.d.ts +46 -0
- package/dist/auth/oauth-flow.js +279 -0
- package/dist/auth/oauth-flow.js.map +1 -0
- package/dist/auth/oauth-provider.d.ts +55 -0
- package/dist/auth/oauth-provider.js +73 -0
- package/dist/auth/oauth-provider.js.map +1 -0
- package/dist/auth/oauth-registry.d.ts +34 -0
- package/dist/auth/oauth-registry.js +69 -0
- package/dist/auth/oauth-registry.js.map +1 -0
- package/dist/auth/oauth-token-provider.d.ts +52 -0
- package/dist/auth/oauth-token-provider.js +159 -0
- package/dist/auth/oauth-token-provider.js.map +1 -0
- package/dist/auth/oauth-token-store.d.ts +30 -0
- package/dist/auth/oauth-token-store.js +93 -0
- package/dist/auth/oauth-token-store.js.map +1 -0
- package/dist/auth/providers/google-scopes.d.ts +35 -0
- package/dist/auth/providers/google-scopes.js +183 -0
- package/dist/auth/providers/google-scopes.js.map +1 -0
- package/dist/auth/providers/google.d.ts +2 -0
- package/dist/auth/providers/google.js +28 -0
- package/dist/auth/providers/google.js.map +1 -0
- package/dist/cli.js +6 -0
- package/dist/cli.js.map +1 -1
- package/dist/config/config-command.js +39 -1
- package/dist/config/config-command.js.map +1 -1
- package/dist/config/constitution-readonly.md +14 -11
- package/dist/config/constitution-user-base.md +15 -0
- package/dist/config/first-start.js +4 -1
- package/dist/config/first-start.js.map +1 -1
- package/dist/config/generated/test-scenarios.json +1 -1
- package/dist/config/generated/tool-annotations.json +10617 -296
- package/dist/config/generated-readonly/compiled-policy.json +787 -244
- package/dist/config/index.d.ts +2 -2
- package/dist/config/index.js +8 -0
- package/dist/config/index.js.map +1 -1
- package/dist/config/mcp-servers.json +31 -2
- package/dist/config/paths.d.ts +20 -0
- package/dist/config/paths.js +43 -0
- package/dist/config/paths.js.map +1 -1
- package/dist/config/types.d.ts +8 -0
- package/dist/config/user-config.d.ts +29 -0
- package/dist/config/user-config.js +30 -0
- package/dist/config/user-config.js.map +1 -1
- package/dist/cron/constitution-generator.d.ts +2 -1
- package/dist/cron/constitution-generator.js +35 -36
- package/dist/cron/constitution-generator.js.map +1 -1
- package/dist/cron/headless-transport.d.ts +4 -0
- package/dist/cron/headless-transport.js +1 -1
- package/dist/cron/headless-transport.js.map +1 -1
- package/dist/daemon/ironcurtain-daemon.js +7 -1
- package/dist/daemon/ironcurtain-daemon.js.map +1 -1
- package/dist/docker/adapters/claude-code.js +32 -5
- package/dist/docker/adapters/claude-code.js.map +1 -1
- package/dist/docker/agent-adapter.d.ts +40 -0
- package/dist/docker/claude-md-seed.d.ts +27 -0
- package/dist/docker/claude-md-seed.js +42 -0
- package/dist/docker/claude-md-seed.js.map +1 -0
- package/dist/docker/docker-agent-session.d.ts +3 -1
- package/dist/docker/docker-agent-session.js +51 -28
- package/dist/docker/docker-agent-session.js.map +1 -1
- package/dist/docker/docker-infrastructure.d.ts +15 -1
- package/dist/docker/docker-infrastructure.js +105 -9
- package/dist/docker/docker-infrastructure.js.map +1 -1
- package/dist/docker/docker-manager.d.ts +2 -0
- package/dist/docker/docker-manager.js +9 -1
- package/dist/docker/docker-manager.js.map +1 -1
- package/dist/docker/mitm-proxy.d.ts +65 -0
- package/dist/docker/mitm-proxy.js +288 -31
- package/dist/docker/mitm-proxy.js.map +1 -1
- package/dist/docker/package-types.d.ts +127 -0
- package/dist/docker/package-types.js +8 -0
- package/dist/docker/package-types.js.map +1 -0
- package/dist/docker/package-validator.d.ts +41 -0
- package/dist/docker/package-validator.js +90 -0
- package/dist/docker/package-validator.js.map +1 -0
- package/dist/docker/platform.d.ts +6 -6
- package/dist/docker/platform.js +8 -6
- package/dist/docker/platform.js.map +1 -1
- package/dist/docker/proxy-tools.d.ts +80 -0
- package/dist/docker/proxy-tools.js +248 -0
- package/dist/docker/proxy-tools.js.map +1 -0
- package/dist/docker/pty-session.d.ts +15 -0
- package/dist/docker/pty-session.js +253 -73
- package/dist/docker/pty-session.js.map +1 -1
- package/dist/docker/pty-types.d.ts +28 -0
- package/dist/docker/pty-types.js +2 -0
- package/dist/docker/pty-types.js.map +1 -1
- package/dist/docker/registry-proxy.d.ts +142 -0
- package/dist/docker/registry-proxy.js +856 -0
- package/dist/docker/registry-proxy.js.map +1 -0
- package/dist/escalation/escalation-watcher.d.ts +3 -1
- package/dist/escalation/escalation-watcher.js +6 -2
- package/dist/escalation/escalation-watcher.js.map +1 -1
- package/dist/escalation/listener-command.js +24 -10
- package/dist/escalation/listener-command.js.map +1 -1
- package/dist/escalation/listener-lock.d.ts +5 -0
- package/dist/escalation/listener-lock.js +14 -0
- package/dist/escalation/listener-lock.js.map +1 -1
- package/dist/index.js +16 -3
- package/dist/index.js.map +1 -1
- package/dist/memory/auto-save.d.ts +25 -0
- package/dist/memory/auto-save.js +89 -0
- package/dist/memory/auto-save.js.map +1 -0
- package/dist/memory/memory-annotations.d.ts +47 -0
- package/dist/memory/memory-annotations.js +87 -0
- package/dist/memory/memory-annotations.js.map +1 -0
- package/dist/memory/memory-prompt.d.ts +12 -0
- package/dist/memory/memory-prompt.js +20 -0
- package/dist/memory/memory-prompt.js.map +1 -0
- package/dist/memory/resolve-memory-path.d.ts +20 -0
- package/dist/memory/resolve-memory-path.js +30 -0
- package/dist/memory/resolve-memory-path.js.map +1 -0
- package/dist/mux/mux-app.d.ts +4 -0
- package/dist/mux/mux-app.js +246 -28
- package/dist/mux/mux-app.js.map +1 -1
- package/dist/mux/mux-command.d.ts +2 -2
- package/dist/mux/mux-command.js +14 -22
- package/dist/mux/mux-command.js.map +1 -1
- package/dist/mux/mux-escalation-manager.d.ts +9 -3
- package/dist/mux/mux-escalation-manager.js +69 -19
- package/dist/mux/mux-escalation-manager.js.map +1 -1
- package/dist/mux/mux-input-handler.d.ts +59 -3
- package/dist/mux/mux-input-handler.js +270 -32
- package/dist/mux/mux-input-handler.js.map +1 -1
- package/dist/mux/mux-renderer.d.ts +19 -1
- package/dist/mux/mux-renderer.js +466 -11
- package/dist/mux/mux-renderer.js.map +1 -1
- package/dist/mux/persona-scanner.d.ts +19 -0
- package/dist/mux/persona-scanner.js +53 -0
- package/dist/mux/persona-scanner.js.map +1 -0
- package/dist/mux/pty-bridge.d.ts +8 -0
- package/dist/mux/pty-bridge.js +19 -1
- package/dist/mux/pty-bridge.js.map +1 -1
- package/dist/mux/session-scanner.d.ts +18 -0
- package/dist/mux/session-scanner.js +65 -0
- package/dist/mux/session-scanner.js.map +1 -0
- package/dist/mux/types.d.ts +36 -1
- package/dist/mux/types.js +14 -1
- package/dist/mux/types.js.map +1 -1
- package/dist/persona/persona-command.js +130 -93
- package/dist/persona/persona-command.js.map +1 -1
- package/dist/persona/persona-prompt.d.ts +7 -7
- package/dist/persona/persona-prompt.js +14 -29
- package/dist/persona/persona-prompt.js.map +1 -1
- package/dist/persona/resolve.js +5 -2
- package/dist/persona/resolve.js.map +1 -1
- package/dist/pipeline/annotate.js +1 -7
- package/dist/pipeline/annotate.js.map +1 -1
- package/dist/pipeline/compile.d.ts +5 -2
- package/dist/pipeline/compile.js +23 -11
- package/dist/pipeline/compile.js.map +1 -1
- package/dist/pipeline/constitution-compiler.d.ts +302 -3
- package/dist/pipeline/constitution-compiler.js +285 -23
- package/dist/pipeline/constitution-compiler.js.map +1 -1
- package/dist/pipeline/generate-with-repair.d.ts +2 -2
- package/dist/pipeline/generate-with-repair.js +2 -2
- package/dist/pipeline/handwritten-scenarios.d.ts +20 -7
- package/dist/pipeline/handwritten-scenarios.js +20 -41
- package/dist/pipeline/handwritten-scenarios.js.map +1 -1
- package/dist/pipeline/list-resolver.d.ts +18 -20
- package/dist/pipeline/list-resolver.js +28 -36
- package/dist/pipeline/list-resolver.js.map +1 -1
- package/dist/pipeline/pipeline-runner.d.ts +102 -13
- package/dist/pipeline/pipeline-runner.js +497 -237
- package/dist/pipeline/pipeline-runner.js.map +1 -1
- package/dist/pipeline/pipeline-shared.d.ts +7 -9
- package/dist/pipeline/pipeline-shared.js +8 -18
- package/dist/pipeline/pipeline-shared.js.map +1 -1
- package/dist/pipeline/policy-verifier.d.ts +32 -5
- package/dist/pipeline/policy-verifier.js +137 -8
- package/dist/pipeline/policy-verifier.js.map +1 -1
- package/dist/pipeline/proxy-mcp-connections.d.ts +25 -0
- package/dist/pipeline/proxy-mcp-connections.js +121 -0
- package/dist/pipeline/proxy-mcp-connections.js.map +1 -0
- package/dist/pipeline/refresh-lists.js +21 -13
- package/dist/pipeline/refresh-lists.js.map +1 -1
- package/dist/pipeline/scenario-generator.d.ts +29 -49
- package/dist/pipeline/scenario-generator.js +180 -143
- package/dist/pipeline/scenario-generator.js.map +1 -1
- package/dist/pipeline/scenario-schema-validator.d.ts +51 -0
- package/dist/pipeline/scenario-schema-validator.js +132 -0
- package/dist/pipeline/scenario-schema-validator.js.map +1 -0
- package/dist/pipeline/tool-annotator.d.ts +9 -8
- package/dist/pipeline/tool-annotator.js +48 -95
- package/dist/pipeline/tool-annotator.js.map +1 -1
- package/dist/pipeline/types.d.ts +31 -24
- package/dist/sandbox/index.d.ts +26 -10
- package/dist/sandbox/index.js +151 -40
- package/dist/sandbox/index.js.map +1 -1
- package/dist/servers/fetch-server.js +10 -5
- package/dist/servers/fetch-server.js.map +1 -1
- package/dist/session/agent-session.d.ts +8 -20
- package/dist/session/agent-session.js +22 -70
- package/dist/session/agent-session.js.map +1 -1
- package/dist/session/base-transport.d.ts +17 -1
- package/dist/session/base-transport.js +25 -1
- package/dist/session/base-transport.js.map +1 -1
- package/dist/session/cli-transport.d.ts +5 -0
- package/dist/session/cli-transport.js +48 -25
- package/dist/session/cli-transport.js.map +1 -1
- package/dist/session/index.d.ts +25 -1
- package/dist/session/index.js +57 -8
- package/dist/session/index.js.map +1 -1
- package/dist/session/prompts.js +8 -9
- package/dist/session/prompts.js.map +1 -1
- package/dist/session/session-manager.d.ts +6 -0
- package/dist/session/session-manager.js +12 -0
- package/dist/session/session-manager.js.map +1 -1
- package/dist/session/types.d.ts +11 -1
- package/dist/session/types.js.map +1 -1
- package/dist/session/workspace-validation.js +37 -6
- package/dist/session/workspace-validation.js.map +1 -1
- package/dist/signal/signal-bot-daemon.js +24 -8
- package/dist/signal/signal-bot-daemon.js.map +1 -1
- package/dist/signal/signal-transport.d.ts +5 -2
- package/dist/signal/signal-transport.js +3 -3
- package/dist/signal/signal-transport.js.map +1 -1
- package/dist/trusted-process/approval-whitelist.d.ts +117 -0
- package/dist/trusted-process/approval-whitelist.js +237 -0
- package/dist/trusted-process/approval-whitelist.js.map +1 -0
- package/dist/trusted-process/gworkspace-credentials.d.ts +40 -0
- package/dist/trusted-process/gworkspace-credentials.js +45 -0
- package/dist/trusted-process/gworkspace-credentials.js.map +1 -0
- package/dist/trusted-process/index.d.ts +11 -1
- package/dist/trusted-process/index.js +83 -53
- package/dist/trusted-process/index.js.map +1 -1
- package/dist/trusted-process/mcp-proxy-server.d.ts +23 -0
- package/dist/trusted-process/mcp-proxy-server.js +409 -94
- package/dist/trusted-process/mcp-proxy-server.js.map +1 -1
- package/dist/trusted-process/policy-engine.d.ts +12 -3
- package/dist/trusted-process/policy-engine.js +33 -23
- package/dist/trusted-process/policy-engine.js.map +1 -1
- package/dist/trusted-process/policy-types.d.ts +17 -0
- package/dist/trusted-process/sandbox-integration.d.ts +23 -0
- package/dist/trusted-process/sandbox-integration.js +115 -3
- package/dist/trusted-process/sandbox-integration.js.map +1 -1
- package/dist/trusted-process/token-file-refresher.d.ts +69 -0
- package/dist/trusted-process/token-file-refresher.js +120 -0
- package/dist/trusted-process/token-file-refresher.js.map +1 -0
- package/dist/types/argument-roles.d.ts +7 -1
- package/dist/types/argument-roles.js +50 -0
- package/dist/types/argument-roles.js.map +1 -1
- package/dist/types/audit.d.ts +11 -0
- package/docker/Dockerfile.base +22 -0
- package/docker/Dockerfile.base.arm64 +44 -4
- package/docker/Dockerfile.claude-code +2 -2
- package/docker/entrypoint-claude-code.sh +24 -6
- package/package.json +8 -2
- package/src/config/constitution-user-base.md +15 -0
- package/src/config/generated/test-scenarios.json +1 -1
- package/src/config/generated/tool-annotations.json +10617 -296
- package/src/config/mcp-servers.json +31 -2
- package/dist/pipeline/mcp-connections.d.ts +0 -16
- package/dist/pipeline/mcp-connections.js +0 -61
- 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
|
|
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
|
|
210
|
-
|
|
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
|
|
228
|
-
if (
|
|
279
|
+
const response = readEscalationResponse(responsePath);
|
|
280
|
+
if (response) {
|
|
229
281
|
cleanupEscalationFiles(requestPath, responsePath);
|
|
230
|
-
return
|
|
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
|
|
287
|
+
const lateResponse = readEscalationResponse(responsePath);
|
|
236
288
|
cleanupEscalationFiles(requestPath, responsePath);
|
|
237
|
-
return
|
|
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
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
435
|
-
//
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
453
|
-
|
|
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:
|
|
533
|
+
content: [{ type: 'text', text: validationError }],
|
|
456
534
|
isError: true,
|
|
457
535
|
};
|
|
458
536
|
}
|
|
459
|
-
effectiveRawArgs = { ...rawArgs, path: gitWorkDir };
|
|
460
537
|
}
|
|
461
|
-
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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 (!
|
|
522
|
-
|
|
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: [
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
|
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: {
|
|
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();
|