multicorn-shield 0.1.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/LICENSE +21 -0
- package/README.md +453 -0
- package/dist/index.cjs +2507 -0
- package/dist/index.d.cts +2182 -0
- package/dist/index.d.ts +2182 -0
- package/dist/index.js +2477 -0
- package/dist/multicorn-proxy.js +1153 -0
- package/dist/openclaw-hook/HOOK.md +75 -0
- package/dist/openclaw-hook/handler.js +447 -0
- package/dist/openclaw-plugin/index.js +692 -0
- package/dist/openclaw-plugin/openclaw.plugin.json +51 -0
- package/package.json +122 -0
|
@@ -0,0 +1,1153 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile, mkdir, writeFile } from 'fs/promises';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
import { createInterface } from 'readline';
|
|
6
|
+
import { spawn } from 'child_process';
|
|
7
|
+
import 'stream';
|
|
8
|
+
|
|
9
|
+
var CONFIG_DIR = join(homedir(), ".multicorn");
|
|
10
|
+
var CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
11
|
+
async function loadConfig() {
|
|
12
|
+
try {
|
|
13
|
+
const raw = await readFile(CONFIG_PATH, "utf8");
|
|
14
|
+
const parsed = JSON.parse(raw);
|
|
15
|
+
if (!isProxyConfig(parsed)) return null;
|
|
16
|
+
return parsed;
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
async function saveConfig(config) {
|
|
22
|
+
await mkdir(CONFIG_DIR, { recursive: true, mode: 448 });
|
|
23
|
+
await writeFile(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", {
|
|
24
|
+
encoding: "utf8",
|
|
25
|
+
mode: 384
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
async function validateApiKey(apiKey, baseUrl) {
|
|
29
|
+
try {
|
|
30
|
+
const response = await fetch(`${baseUrl}/api/v1/agents`, {
|
|
31
|
+
headers: { "X-Multicorn-Key": apiKey },
|
|
32
|
+
signal: AbortSignal.timeout(8e3)
|
|
33
|
+
});
|
|
34
|
+
if (response.status === 401) {
|
|
35
|
+
return { valid: false, error: "API key not recognised. Check the key and try again." };
|
|
36
|
+
}
|
|
37
|
+
if (!response.ok) {
|
|
38
|
+
return {
|
|
39
|
+
valid: false,
|
|
40
|
+
error: `Service returned ${String(response.status)}. Check your base URL and try again.`
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return { valid: true };
|
|
44
|
+
} catch (error) {
|
|
45
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
46
|
+
return {
|
|
47
|
+
valid: false,
|
|
48
|
+
error: `Could not reach ${baseUrl}. Check your network connection. (${detail})`
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async function runInit(baseUrl = "https://api.multicorn.ai") {
|
|
53
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
54
|
+
function ask(question) {
|
|
55
|
+
return new Promise((resolve) => {
|
|
56
|
+
rl.question(question, (answer) => {
|
|
57
|
+
resolve(answer);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
process.stderr.write("Multicorn Shield proxy setup\n\n");
|
|
62
|
+
process.stderr.write("Get your API key at https://app.multicorn.ai/settings/api-keys\n\n");
|
|
63
|
+
let config = null;
|
|
64
|
+
while (config === null) {
|
|
65
|
+
const input = await ask("API key (starts with mcs_): ");
|
|
66
|
+
const apiKey = input.trim();
|
|
67
|
+
if (apiKey.length === 0) {
|
|
68
|
+
process.stderr.write("API key is required.\n");
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
process.stderr.write("Validating key...\n");
|
|
72
|
+
const result = await validateApiKey(apiKey, baseUrl);
|
|
73
|
+
if (!result.valid) {
|
|
74
|
+
process.stderr.write(`${result.error ?? "Validation failed. Try again."}
|
|
75
|
+
`);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
config = { apiKey, baseUrl };
|
|
79
|
+
}
|
|
80
|
+
rl.close();
|
|
81
|
+
await saveConfig(config);
|
|
82
|
+
process.stderr.write(`
|
|
83
|
+
Config saved to ${CONFIG_PATH}
|
|
84
|
+
`);
|
|
85
|
+
process.stderr.write("Run your agent with: npx multicorn-proxy --wrap <your-mcp-server>\n");
|
|
86
|
+
return config;
|
|
87
|
+
}
|
|
88
|
+
function isProxyConfig(value) {
|
|
89
|
+
if (typeof value !== "object" || value === null) return false;
|
|
90
|
+
const obj = value;
|
|
91
|
+
return typeof obj["apiKey"] === "string" && typeof obj["baseUrl"] === "string";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// src/types/index.ts
|
|
95
|
+
var PERMISSION_LEVELS = {
|
|
96
|
+
Read: "read",
|
|
97
|
+
Write: "write",
|
|
98
|
+
Execute: "execute",
|
|
99
|
+
Publish: "publish",
|
|
100
|
+
Create: "create"
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// src/scopes/scope-parser.ts
|
|
104
|
+
var VALID_PERMISSION_LEVELS = new Set(Object.values(PERMISSION_LEVELS));
|
|
105
|
+
[...VALID_PERMISSION_LEVELS].join(", ");
|
|
106
|
+
function formatScope(scope) {
|
|
107
|
+
return `${scope.permissionLevel}:${scope.service}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// src/scopes/scope-validator.ts
|
|
111
|
+
function validateScopeAccess(grantedScopes, requested) {
|
|
112
|
+
const isGranted = grantedScopes.some(
|
|
113
|
+
(granted) => granted.service === requested.service && granted.permissionLevel === requested.permissionLevel
|
|
114
|
+
);
|
|
115
|
+
if (isGranted) {
|
|
116
|
+
return { allowed: true };
|
|
117
|
+
}
|
|
118
|
+
const serviceScopes = grantedScopes.filter((g) => g.service === requested.service);
|
|
119
|
+
if (serviceScopes.length > 0) {
|
|
120
|
+
const grantedLevels = serviceScopes.map((g) => `"${g.permissionLevel}"`).join(", ");
|
|
121
|
+
return {
|
|
122
|
+
allowed: false,
|
|
123
|
+
reason: `Permission "${requested.permissionLevel}" is not granted for service "${requested.service}". Currently granted permission level(s): ${grantedLevels}. Requested scope "${formatScope(requested)}" requires explicit consent.`
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
allowed: false,
|
|
128
|
+
reason: `No permissions granted for service "${requested.service}". The agent has not been authorised to access this service. Request scope "${formatScope(requested)}" via the consent screen.`
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// src/logger/action-logger.ts
|
|
133
|
+
function createActionLogger(config) {
|
|
134
|
+
if (!config.apiKey || config.apiKey.trim().length === 0) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
"[ActionLogger] API key is required. Provide it via the 'apiKey' config option."
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
const baseUrl = config.baseUrl ?? "https://api.multicorn.ai";
|
|
140
|
+
const timeout = config.timeout ?? 5e3;
|
|
141
|
+
if (!baseUrl.startsWith("https://") && !baseUrl.startsWith("http://localhost")) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`[ActionLogger] Base URL must use HTTPS for security. Received: "${baseUrl}". Use https:// or http://localhost for local development.`
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
const endpoint = `${baseUrl}/api/v1/actions`;
|
|
147
|
+
const batchEnabled = config.batchMode?.enabled ?? false;
|
|
148
|
+
const maxBatchSize = config.batchMode?.maxSize ?? 10;
|
|
149
|
+
const flushInterval = config.batchMode?.flushIntervalMs ?? 5e3;
|
|
150
|
+
const queue = [];
|
|
151
|
+
let flushTimer;
|
|
152
|
+
let isShutdown = false;
|
|
153
|
+
async function sendActions(actions) {
|
|
154
|
+
if (actions.length === 0) return;
|
|
155
|
+
const convertAction = (action) => ({
|
|
156
|
+
agent: action.agent,
|
|
157
|
+
service: action.service,
|
|
158
|
+
actionType: action.actionType,
|
|
159
|
+
status: action.status,
|
|
160
|
+
...action.cost !== void 0 ? { cost: action.cost } : {},
|
|
161
|
+
...action.metadata !== void 0 ? { metadata: action.metadata } : {}
|
|
162
|
+
});
|
|
163
|
+
const convertedActions = actions.map(convertAction);
|
|
164
|
+
const payload = batchEnabled ? { actions: convertedActions } : convertedActions[0];
|
|
165
|
+
let lastError;
|
|
166
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
167
|
+
try {
|
|
168
|
+
const controller = new AbortController();
|
|
169
|
+
const timeoutId = setTimeout(() => {
|
|
170
|
+
controller.abort();
|
|
171
|
+
}, timeout);
|
|
172
|
+
const response = await fetch(endpoint, {
|
|
173
|
+
method: "POST",
|
|
174
|
+
headers: {
|
|
175
|
+
"Content-Type": "application/json",
|
|
176
|
+
"X-Multicorn-Key": config.apiKey
|
|
177
|
+
},
|
|
178
|
+
body: JSON.stringify(payload),
|
|
179
|
+
signal: controller.signal
|
|
180
|
+
});
|
|
181
|
+
clearTimeout(timeoutId);
|
|
182
|
+
if (response.ok) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (response.status >= 400 && response.status < 500) {
|
|
186
|
+
const body = await response.text().catch(() => "");
|
|
187
|
+
throw new Error(
|
|
188
|
+
`[ActionLogger] Client error (${String(response.status)}): ${response.statusText}. Response: ${body}. Check your API key and payload format.`
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
if (response.status >= 500 && attempt === 0) {
|
|
192
|
+
lastError = new Error(
|
|
193
|
+
`[ActionLogger] Server error (${String(response.status)}): ${response.statusText}. Retrying once...`
|
|
194
|
+
);
|
|
195
|
+
await sleep(100 * Math.pow(2, attempt));
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
throw new Error(
|
|
199
|
+
`[ActionLogger] Server error (${String(response.status)}) after retry: ${response.statusText}. Multicorn API may be experiencing issues.`
|
|
200
|
+
);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if (error instanceof Error) {
|
|
203
|
+
if (error.name === "AbortError") {
|
|
204
|
+
lastError = new Error(
|
|
205
|
+
`[ActionLogger] Request timeout after ${String(timeout)}ms. Increase the 'timeout' config option or check your network connection.`
|
|
206
|
+
);
|
|
207
|
+
} else if (error.message.includes("Client error") || error.message.includes("Server error")) {
|
|
208
|
+
lastError = error;
|
|
209
|
+
} else {
|
|
210
|
+
lastError = new Error(
|
|
211
|
+
`[ActionLogger] Network error: ${error.message}. Check your network connection and API endpoint.`
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
} else {
|
|
215
|
+
lastError = new Error(`[ActionLogger] Unknown error: ${String(error)}`);
|
|
216
|
+
}
|
|
217
|
+
if (attempt === 0 && !lastError.message.includes("Client error")) {
|
|
218
|
+
await sleep(100 * Math.pow(2, attempt));
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (lastError) {
|
|
225
|
+
if (config.onError) {
|
|
226
|
+
config.onError(lastError);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
async function flushQueue() {
|
|
231
|
+
if (queue.length === 0) return;
|
|
232
|
+
const actions = queue.map((item) => item.payload);
|
|
233
|
+
queue.length = 0;
|
|
234
|
+
await sendActions(actions);
|
|
235
|
+
}
|
|
236
|
+
function startFlushTimer() {
|
|
237
|
+
if (flushTimer !== void 0) return;
|
|
238
|
+
flushTimer = setInterval(() => {
|
|
239
|
+
flushQueue().catch(() => {
|
|
240
|
+
});
|
|
241
|
+
}, flushInterval);
|
|
242
|
+
const timer = flushTimer;
|
|
243
|
+
if (typeof timer.unref === "function") {
|
|
244
|
+
timer.unref();
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
function stopFlushTimer() {
|
|
248
|
+
if (flushTimer) {
|
|
249
|
+
clearInterval(flushTimer);
|
|
250
|
+
flushTimer = void 0;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (batchEnabled) {
|
|
254
|
+
startFlushTimer();
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
logAction(action) {
|
|
258
|
+
if (isShutdown) {
|
|
259
|
+
throw new Error(
|
|
260
|
+
"[ActionLogger] Cannot log action after shutdown. Create a new logger instance."
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
if (action.agent.trim().length === 0) {
|
|
264
|
+
throw new Error("[ActionLogger] Action must have a non-empty 'agent' field.");
|
|
265
|
+
}
|
|
266
|
+
if (action.service.trim().length === 0) {
|
|
267
|
+
throw new Error("[ActionLogger] Action must have a non-empty 'service' field.");
|
|
268
|
+
}
|
|
269
|
+
if (action.actionType.trim().length === 0) {
|
|
270
|
+
throw new Error("[ActionLogger] Action must have a non-empty 'actionType' field.");
|
|
271
|
+
}
|
|
272
|
+
if (action.status.trim().length === 0) {
|
|
273
|
+
throw new Error("[ActionLogger] Action must have a non-empty 'status' field.");
|
|
274
|
+
}
|
|
275
|
+
if (batchEnabled) {
|
|
276
|
+
queue.push({ payload: action, timestamp: Date.now() });
|
|
277
|
+
if (queue.length >= maxBatchSize) {
|
|
278
|
+
flushQueue().catch(() => {
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
} else {
|
|
282
|
+
sendActions([action]).catch(() => {
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
return Promise.resolve();
|
|
286
|
+
},
|
|
287
|
+
async flush() {
|
|
288
|
+
if (!batchEnabled) return;
|
|
289
|
+
await flushQueue();
|
|
290
|
+
},
|
|
291
|
+
async shutdown() {
|
|
292
|
+
if (isShutdown) return;
|
|
293
|
+
isShutdown = true;
|
|
294
|
+
stopFlushTimer();
|
|
295
|
+
if (batchEnabled) {
|
|
296
|
+
await flushQueue();
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
function sleep(ms) {
|
|
302
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// src/spending/spending-checker.ts
|
|
306
|
+
function createSpendingChecker(config) {
|
|
307
|
+
validateLimits(config.limits);
|
|
308
|
+
let dailySpendCents = 0;
|
|
309
|
+
let monthlySpendCents = 0;
|
|
310
|
+
let lastDailyReset = /* @__PURE__ */ new Date();
|
|
311
|
+
let lastMonthlyReset = /* @__PURE__ */ new Date();
|
|
312
|
+
function validateAmount(amountCents, context) {
|
|
313
|
+
if (!Number.isInteger(amountCents)) {
|
|
314
|
+
throw new Error(
|
|
315
|
+
`[SpendingChecker] ${context} must be an integer (cents). Received: ${String(amountCents)}. Convert dollars to cents by multiplying by 100.`
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
if (amountCents < 0) {
|
|
319
|
+
throw new Error(
|
|
320
|
+
`[SpendingChecker] ${context} must be non-negative. Received: ${String(amountCents)} cents.`
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
function formatCents(cents) {
|
|
325
|
+
const dollars = cents / 100;
|
|
326
|
+
return `$${dollars.toLocaleString("en-US", {
|
|
327
|
+
minimumFractionDigits: 2,
|
|
328
|
+
maximumFractionDigits: 2
|
|
329
|
+
})}`;
|
|
330
|
+
}
|
|
331
|
+
function checkAndResetDaily() {
|
|
332
|
+
const now = /* @__PURE__ */ new Date();
|
|
333
|
+
if (shouldResetDaily(lastDailyReset, now)) {
|
|
334
|
+
dailySpendCents = 0;
|
|
335
|
+
lastDailyReset = now;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
function checkAndResetMonthly() {
|
|
339
|
+
const now = /* @__PURE__ */ new Date();
|
|
340
|
+
if (shouldResetMonthly(lastMonthlyReset, now)) {
|
|
341
|
+
monthlySpendCents = 0;
|
|
342
|
+
lastMonthlyReset = now;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
function shouldResetDaily(lastReset, now) {
|
|
346
|
+
return lastReset.getDate() !== now.getDate() || lastReset.getMonth() !== now.getMonth() || lastReset.getFullYear() !== now.getFullYear();
|
|
347
|
+
}
|
|
348
|
+
function shouldResetMonthly(lastReset, now) {
|
|
349
|
+
return lastReset.getMonth() !== now.getMonth() || lastReset.getFullYear() !== now.getFullYear();
|
|
350
|
+
}
|
|
351
|
+
function calculateRemainingBudget() {
|
|
352
|
+
return {
|
|
353
|
+
transaction: config.limits.perTransaction,
|
|
354
|
+
daily: Math.max(0, config.limits.perDay - dailySpendCents),
|
|
355
|
+
monthly: Math.max(0, config.limits.perMonth - monthlySpendCents)
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
return {
|
|
359
|
+
checkSpend(amountCents) {
|
|
360
|
+
validateAmount(amountCents, "Spend amount");
|
|
361
|
+
checkAndResetDaily();
|
|
362
|
+
checkAndResetMonthly();
|
|
363
|
+
if (amountCents > config.limits.perTransaction) {
|
|
364
|
+
return {
|
|
365
|
+
allowed: false,
|
|
366
|
+
reason: `Action blocked: ${formatCents(amountCents)} exceeds per-transaction limit of ${formatCents(config.limits.perTransaction)}`,
|
|
367
|
+
remainingBudget: calculateRemainingBudget()
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
const projectedDaily = dailySpendCents + amountCents;
|
|
371
|
+
if (projectedDaily > config.limits.perDay) {
|
|
372
|
+
return {
|
|
373
|
+
allowed: false,
|
|
374
|
+
reason: `Action blocked: ${formatCents(amountCents)} would exceed per-day limit. Current spend today: ${formatCents(dailySpendCents)}, limit: ${formatCents(config.limits.perDay)}`,
|
|
375
|
+
remainingBudget: calculateRemainingBudget()
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
const projectedMonthly = monthlySpendCents + amountCents;
|
|
379
|
+
if (projectedMonthly > config.limits.perMonth) {
|
|
380
|
+
return {
|
|
381
|
+
allowed: false,
|
|
382
|
+
reason: `Action blocked: ${formatCents(amountCents)} would exceed per-month limit. Current spend this month: ${formatCents(monthlySpendCents)}, limit: ${formatCents(config.limits.perMonth)}`,
|
|
383
|
+
remainingBudget: calculateRemainingBudget()
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
return {
|
|
387
|
+
allowed: true,
|
|
388
|
+
remainingBudget: calculateRemainingBudget()
|
|
389
|
+
};
|
|
390
|
+
},
|
|
391
|
+
recordSpend(amountCents) {
|
|
392
|
+
validateAmount(amountCents, "Spend amount");
|
|
393
|
+
checkAndResetDaily();
|
|
394
|
+
checkAndResetMonthly();
|
|
395
|
+
dailySpendCents += amountCents;
|
|
396
|
+
monthlySpendCents += amountCents;
|
|
397
|
+
},
|
|
398
|
+
getCurrentSpend() {
|
|
399
|
+
checkAndResetDaily();
|
|
400
|
+
checkAndResetMonthly();
|
|
401
|
+
return {
|
|
402
|
+
daily: dailySpendCents,
|
|
403
|
+
monthly: monthlySpendCents
|
|
404
|
+
};
|
|
405
|
+
},
|
|
406
|
+
reset() {
|
|
407
|
+
dailySpendCents = 0;
|
|
408
|
+
monthlySpendCents = 0;
|
|
409
|
+
lastDailyReset = /* @__PURE__ */ new Date();
|
|
410
|
+
lastMonthlyReset = /* @__PURE__ */ new Date();
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
function validateLimits(limits) {
|
|
415
|
+
const checks = [
|
|
416
|
+
{ value: limits.perTransaction, name: "perTransaction" },
|
|
417
|
+
{ value: limits.perDay, name: "perDay" },
|
|
418
|
+
{ value: limits.perMonth, name: "perMonth" }
|
|
419
|
+
];
|
|
420
|
+
for (const check of checks) {
|
|
421
|
+
if (!Number.isInteger(check.value)) {
|
|
422
|
+
throw new Error(
|
|
423
|
+
`[SpendingChecker] Limit "${check.name}" must be an integer (cents). Received: ${String(check.value)}. All limits must be specified in integer cents.`
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
if (check.value < 0) {
|
|
427
|
+
throw new Error(
|
|
428
|
+
`[SpendingChecker] Limit "${check.name}" must be non-negative. Received: ${String(check.value)} cents.`
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
function dollarsToCents(dollars) {
|
|
434
|
+
return Math.round(dollars * 100);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// src/proxy/interceptor.ts
|
|
438
|
+
var BLOCKED_ERROR_CODE = -32e3;
|
|
439
|
+
var SPENDING_BLOCKED_ERROR_CODE = -32001;
|
|
440
|
+
function parseJsonRpcLine(line) {
|
|
441
|
+
const trimmed = line.trim();
|
|
442
|
+
if (trimmed.length === 0) return null;
|
|
443
|
+
let parsed;
|
|
444
|
+
try {
|
|
445
|
+
parsed = JSON.parse(trimmed);
|
|
446
|
+
} catch {
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
return isJsonRpcRequest(parsed) ? parsed : null;
|
|
450
|
+
}
|
|
451
|
+
function extractToolCallParams(request) {
|
|
452
|
+
if (request.method !== "tools/call") return null;
|
|
453
|
+
if (typeof request.params !== "object" || request.params === null) return null;
|
|
454
|
+
const params = request.params;
|
|
455
|
+
const name = params["name"];
|
|
456
|
+
const args = params["arguments"];
|
|
457
|
+
if (typeof name !== "string") return null;
|
|
458
|
+
if (typeof args !== "object" || args === null) return null;
|
|
459
|
+
return { name, arguments: args };
|
|
460
|
+
}
|
|
461
|
+
function buildBlockedResponse(id, service, permissionLevel, dashboardUrl) {
|
|
462
|
+
const displayService = capitalize(service);
|
|
463
|
+
const message = `Action blocked by Multicorn Shield: agent does not have ${permissionLevel} access to ${displayService}. Configure permissions at ${dashboardUrl}`;
|
|
464
|
+
return {
|
|
465
|
+
jsonrpc: "2.0",
|
|
466
|
+
id,
|
|
467
|
+
error: {
|
|
468
|
+
code: BLOCKED_ERROR_CODE,
|
|
469
|
+
message
|
|
470
|
+
}
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
function buildSpendingBlockedResponse(id, reason, dashboardUrl) {
|
|
474
|
+
const message = `Action blocked by Multicorn Shield: ${reason}. Review spending limits at ${dashboardUrl}`;
|
|
475
|
+
return {
|
|
476
|
+
jsonrpc: "2.0",
|
|
477
|
+
id,
|
|
478
|
+
error: {
|
|
479
|
+
code: SPENDING_BLOCKED_ERROR_CODE,
|
|
480
|
+
message
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
function extractServiceFromToolName(toolName) {
|
|
485
|
+
const idx = toolName.indexOf("_");
|
|
486
|
+
return idx === -1 ? toolName : toolName.slice(0, idx);
|
|
487
|
+
}
|
|
488
|
+
function extractActionFromToolName(toolName) {
|
|
489
|
+
const idx = toolName.indexOf("_");
|
|
490
|
+
return idx === -1 ? "call" : toolName.slice(idx + 1);
|
|
491
|
+
}
|
|
492
|
+
function isJsonRpcRequest(value) {
|
|
493
|
+
if (typeof value !== "object" || value === null) return false;
|
|
494
|
+
const obj = value;
|
|
495
|
+
if (obj["jsonrpc"] !== "2.0") return false;
|
|
496
|
+
if (typeof obj["method"] !== "string") return false;
|
|
497
|
+
const id = obj["id"];
|
|
498
|
+
const validId = id === null || id === void 0 || typeof id === "string" || typeof id === "number";
|
|
499
|
+
return validId;
|
|
500
|
+
}
|
|
501
|
+
function capitalize(str) {
|
|
502
|
+
if (str.length === 0) return str;
|
|
503
|
+
const first = str[0];
|
|
504
|
+
return first !== void 0 ? first.toUpperCase() + str.slice(1) : str;
|
|
505
|
+
}
|
|
506
|
+
var MULTICORN_DIR = join(homedir(), ".multicorn");
|
|
507
|
+
var SCOPES_PATH = join(MULTICORN_DIR, "scopes.json");
|
|
508
|
+
var CONSENT_POLL_INTERVAL_MS = 3e3;
|
|
509
|
+
var CONSENT_POLL_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
510
|
+
function deriveDashboardUrl(baseUrl) {
|
|
511
|
+
try {
|
|
512
|
+
const url = new URL(baseUrl);
|
|
513
|
+
if (url.hostname === "localhost" || url.hostname === "127.0.0.1") {
|
|
514
|
+
url.port = "5173";
|
|
515
|
+
url.protocol = "http:";
|
|
516
|
+
return url.toString();
|
|
517
|
+
}
|
|
518
|
+
if (url.hostname === "api.multicorn.ai") {
|
|
519
|
+
url.hostname = "app.multicorn.ai";
|
|
520
|
+
return url.toString();
|
|
521
|
+
}
|
|
522
|
+
if (url.hostname.includes("api")) {
|
|
523
|
+
url.hostname = url.hostname.replace("api", "app");
|
|
524
|
+
return url.toString();
|
|
525
|
+
}
|
|
526
|
+
if (url.protocol === "https:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1") {
|
|
527
|
+
return "https://app.multicorn.ai";
|
|
528
|
+
}
|
|
529
|
+
return "https://app.multicorn.ai";
|
|
530
|
+
} catch {
|
|
531
|
+
return "https://app.multicorn.ai";
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
async function loadCachedScopes(agentName) {
|
|
535
|
+
try {
|
|
536
|
+
const raw = await readFile(SCOPES_PATH, "utf8");
|
|
537
|
+
const parsed = JSON.parse(raw);
|
|
538
|
+
if (!isScopesCacheFile(parsed)) return null;
|
|
539
|
+
const entry = parsed[agentName];
|
|
540
|
+
return entry?.scopes ?? null;
|
|
541
|
+
} catch {
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
async function saveCachedScopes(agentName, agentId, scopes) {
|
|
546
|
+
await mkdir(MULTICORN_DIR, { recursive: true, mode: 448 });
|
|
547
|
+
let existing = {};
|
|
548
|
+
try {
|
|
549
|
+
const raw = await readFile(SCOPES_PATH, "utf8");
|
|
550
|
+
const parsed = JSON.parse(raw);
|
|
551
|
+
if (isScopesCacheFile(parsed)) existing = parsed;
|
|
552
|
+
} catch {
|
|
553
|
+
}
|
|
554
|
+
const updated = {
|
|
555
|
+
...existing,
|
|
556
|
+
[agentName]: {
|
|
557
|
+
agentId,
|
|
558
|
+
scopes,
|
|
559
|
+
fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
await writeFile(SCOPES_PATH, JSON.stringify(updated, null, 2) + "\n", {
|
|
563
|
+
encoding: "utf8",
|
|
564
|
+
mode: 384
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
async function findAgentByName(agentName, apiKey, baseUrl) {
|
|
568
|
+
let response;
|
|
569
|
+
try {
|
|
570
|
+
response = await fetch(`${baseUrl}/api/v1/agents`, {
|
|
571
|
+
headers: { "X-Multicorn-Key": apiKey },
|
|
572
|
+
signal: AbortSignal.timeout(8e3)
|
|
573
|
+
});
|
|
574
|
+
} catch {
|
|
575
|
+
return null;
|
|
576
|
+
}
|
|
577
|
+
if (!response.ok) return null;
|
|
578
|
+
const body = await response.json();
|
|
579
|
+
if (!isApiSuccessResponse(body)) return null;
|
|
580
|
+
const agents = body.data;
|
|
581
|
+
if (!Array.isArray(agents)) return null;
|
|
582
|
+
const match = agents.find(
|
|
583
|
+
(a) => isAgentSummaryShape(a) && a.name === agentName
|
|
584
|
+
);
|
|
585
|
+
if (match === void 0) return null;
|
|
586
|
+
return { id: match.id, name: match.name, scopes: [] };
|
|
587
|
+
}
|
|
588
|
+
async function registerAgent(agentName, apiKey, baseUrl) {
|
|
589
|
+
const response = await fetch(`${baseUrl}/api/v1/agents`, {
|
|
590
|
+
method: "POST",
|
|
591
|
+
headers: {
|
|
592
|
+
"Content-Type": "application/json",
|
|
593
|
+
"X-Multicorn-Key": apiKey
|
|
594
|
+
},
|
|
595
|
+
body: JSON.stringify({ name: agentName }),
|
|
596
|
+
signal: AbortSignal.timeout(8e3)
|
|
597
|
+
});
|
|
598
|
+
if (!response.ok) {
|
|
599
|
+
throw new Error(
|
|
600
|
+
`Failed to register agent "${agentName}": service returned ${String(response.status)}.`
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
const body = await response.json();
|
|
604
|
+
if (!isApiSuccessResponse(body)) {
|
|
605
|
+
throw new Error(`Failed to register agent "${agentName}": unexpected response format.`);
|
|
606
|
+
}
|
|
607
|
+
if (!isAgentSummaryShape(body.data)) {
|
|
608
|
+
throw new Error(`Failed to register agent "${agentName}": response missing agent ID.`);
|
|
609
|
+
}
|
|
610
|
+
return body.data.id;
|
|
611
|
+
}
|
|
612
|
+
async function fetchGrantedScopes(agentId, apiKey, baseUrl) {
|
|
613
|
+
let response;
|
|
614
|
+
try {
|
|
615
|
+
response = await fetch(`${baseUrl}/api/v1/agents/${agentId}`, {
|
|
616
|
+
headers: { "X-Multicorn-Key": apiKey },
|
|
617
|
+
signal: AbortSignal.timeout(8e3)
|
|
618
|
+
});
|
|
619
|
+
} catch {
|
|
620
|
+
return [];
|
|
621
|
+
}
|
|
622
|
+
if (!response.ok) return [];
|
|
623
|
+
const body = await response.json();
|
|
624
|
+
if (!isApiSuccessResponse(body)) return [];
|
|
625
|
+
const agentDetail = body.data;
|
|
626
|
+
if (!isAgentDetailShape(agentDetail)) return [];
|
|
627
|
+
const scopes = [];
|
|
628
|
+
for (const perm of agentDetail.permissions) {
|
|
629
|
+
if (!isPermissionShape(perm)) continue;
|
|
630
|
+
if (perm.revoked_at !== null) continue;
|
|
631
|
+
if (perm.read) scopes.push({ service: perm.service, permissionLevel: "read" });
|
|
632
|
+
if (perm.write) scopes.push({ service: perm.service, permissionLevel: "write" });
|
|
633
|
+
if (perm.execute) scopes.push({ service: perm.service, permissionLevel: "execute" });
|
|
634
|
+
}
|
|
635
|
+
return scopes;
|
|
636
|
+
}
|
|
637
|
+
function openBrowser(url) {
|
|
638
|
+
const platform = process.platform;
|
|
639
|
+
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
|
|
640
|
+
spawn(cmd, [url], { detached: true, stdio: "ignore" }).unref();
|
|
641
|
+
}
|
|
642
|
+
async function waitForConsent(agentId, agentName, apiKey, baseUrl, dashboardUrl, logger) {
|
|
643
|
+
const detectedScopes = detectScopeHints();
|
|
644
|
+
const consentUrl = buildConsentUrl(agentName, detectedScopes, dashboardUrl);
|
|
645
|
+
logger.info("Opening consent page in your browser.", { url: consentUrl });
|
|
646
|
+
process.stderr.write(
|
|
647
|
+
`
|
|
648
|
+
Action requires permission. Opening consent page...
|
|
649
|
+
${consentUrl}
|
|
650
|
+
|
|
651
|
+
Waiting for you to grant access in the Multicorn dashboard...
|
|
652
|
+
`
|
|
653
|
+
);
|
|
654
|
+
openBrowser(consentUrl);
|
|
655
|
+
const deadline = Date.now() + CONSENT_POLL_TIMEOUT_MS;
|
|
656
|
+
while (Date.now() < deadline) {
|
|
657
|
+
await sleep2(CONSENT_POLL_INTERVAL_MS);
|
|
658
|
+
const scopes = await fetchGrantedScopes(agentId, apiKey, baseUrl);
|
|
659
|
+
if (scopes.length > 0) {
|
|
660
|
+
logger.info("Permissions granted.", { agent: agentName, scopeCount: scopes.length });
|
|
661
|
+
return scopes;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
throw new Error(
|
|
665
|
+
`Consent not granted within ${String(CONSENT_POLL_TIMEOUT_MS / 6e4)} minutes. Grant access at ${dashboardUrl} and restart the proxy.`
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
async function resolveAgentRecord(agentName, apiKey, baseUrl, logger) {
|
|
669
|
+
const cachedScopes = await loadCachedScopes(agentName);
|
|
670
|
+
if (cachedScopes !== null && cachedScopes.length > 0) {
|
|
671
|
+
logger.debug("Loaded scopes from cache.", { agent: agentName, count: cachedScopes.length });
|
|
672
|
+
return { id: "", name: agentName, scopes: cachedScopes };
|
|
673
|
+
}
|
|
674
|
+
let agent = await findAgentByName(agentName, apiKey, baseUrl);
|
|
675
|
+
if (agent === null) {
|
|
676
|
+
try {
|
|
677
|
+
logger.info("Agent not found. Registering.", { agent: agentName });
|
|
678
|
+
const id = await registerAgent(agentName, apiKey, baseUrl);
|
|
679
|
+
agent = { id, name: agentName, scopes: [] };
|
|
680
|
+
logger.info("Agent registered.", { agent: agentName, id });
|
|
681
|
+
} catch (error) {
|
|
682
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
683
|
+
logger.warn("Could not reach Multicorn service. Running with empty permissions.", {
|
|
684
|
+
error: detail
|
|
685
|
+
});
|
|
686
|
+
return { id: "", name: agentName, scopes: [] };
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
const scopes = await fetchGrantedScopes(agent.id, apiKey, baseUrl);
|
|
690
|
+
if (scopes.length > 0) {
|
|
691
|
+
await saveCachedScopes(agentName, agent.id, scopes);
|
|
692
|
+
}
|
|
693
|
+
return { ...agent, scopes };
|
|
694
|
+
}
|
|
695
|
+
function buildConsentUrl(agentName, scopes, dashboardUrl) {
|
|
696
|
+
const params = new URLSearchParams({ agent: agentName });
|
|
697
|
+
if (scopes.length > 0) {
|
|
698
|
+
params.set("scopes", scopes.join(","));
|
|
699
|
+
}
|
|
700
|
+
return `${dashboardUrl}/consent?${params.toString()}`;
|
|
701
|
+
}
|
|
702
|
+
function detectScopeHints() {
|
|
703
|
+
return [];
|
|
704
|
+
}
|
|
705
|
+
function sleep2(ms) {
|
|
706
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
707
|
+
}
|
|
708
|
+
function isApiSuccessResponse(value) {
|
|
709
|
+
if (typeof value !== "object" || value === null) return false;
|
|
710
|
+
const obj = value;
|
|
711
|
+
return obj["success"] === true;
|
|
712
|
+
}
|
|
713
|
+
function isAgentSummaryShape(value) {
|
|
714
|
+
if (typeof value !== "object" || value === null) return false;
|
|
715
|
+
const obj = value;
|
|
716
|
+
return typeof obj["id"] === "string" && typeof obj["name"] === "string";
|
|
717
|
+
}
|
|
718
|
+
function isAgentDetailShape(value) {
|
|
719
|
+
if (typeof value !== "object" || value === null) return false;
|
|
720
|
+
const obj = value;
|
|
721
|
+
return Array.isArray(obj["permissions"]);
|
|
722
|
+
}
|
|
723
|
+
function isPermissionShape(value) {
|
|
724
|
+
if (typeof value !== "object" || value === null) return false;
|
|
725
|
+
const obj = value;
|
|
726
|
+
return typeof obj["service"] === "string" && typeof obj["read"] === "boolean" && typeof obj["write"] === "boolean" && typeof obj["execute"] === "boolean" && (obj["revoked_at"] === null || typeof obj["revoked_at"] === "string");
|
|
727
|
+
}
|
|
728
|
+
function isScopesCacheFile(value) {
|
|
729
|
+
return typeof value === "object" && value !== null;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// src/proxy/index.ts
|
|
733
|
+
var DEFAULT_SCOPE_REFRESH_INTERVAL_MS = 6e4;
|
|
734
|
+
function createProxyServer(config) {
|
|
735
|
+
if (!config.baseUrl.startsWith("https://") && !config.baseUrl.startsWith("http://localhost") && !config.baseUrl.startsWith("http://127.0.0.1")) {
|
|
736
|
+
throw new Error(
|
|
737
|
+
`[multicorn-proxy] Base URL must use HTTPS. Received: "${config.baseUrl}". Use https:// or http://localhost for local development.`
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
let child = null;
|
|
741
|
+
let actionLogger = null;
|
|
742
|
+
let spendingChecker = null;
|
|
743
|
+
let grantedScopes = [];
|
|
744
|
+
let agentId = "";
|
|
745
|
+
let refreshTimer = null;
|
|
746
|
+
let consentInProgress = false;
|
|
747
|
+
const pendingLines = [];
|
|
748
|
+
let draining = false;
|
|
749
|
+
async function refreshScopes() {
|
|
750
|
+
if (agentId.length === 0) return;
|
|
751
|
+
try {
|
|
752
|
+
const scopes = await fetchGrantedScopes(agentId, config.apiKey, config.baseUrl);
|
|
753
|
+
grantedScopes = scopes;
|
|
754
|
+
if (scopes.length > 0) {
|
|
755
|
+
await saveCachedScopes(config.agentName, agentId, scopes);
|
|
756
|
+
}
|
|
757
|
+
config.logger.debug("Scopes refreshed.", { count: scopes.length });
|
|
758
|
+
} catch (error) {
|
|
759
|
+
config.logger.warn("Scope refresh failed.", {
|
|
760
|
+
error: error instanceof Error ? error.message : String(error)
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
async function ensureConsent() {
|
|
765
|
+
if (grantedScopes.length > 0 || consentInProgress) return;
|
|
766
|
+
if (agentId.length === 0) return;
|
|
767
|
+
consentInProgress = true;
|
|
768
|
+
try {
|
|
769
|
+
const scopes = await waitForConsent(
|
|
770
|
+
agentId,
|
|
771
|
+
config.agentName,
|
|
772
|
+
config.apiKey,
|
|
773
|
+
config.baseUrl,
|
|
774
|
+
config.dashboardUrl,
|
|
775
|
+
config.logger
|
|
776
|
+
);
|
|
777
|
+
grantedScopes = scopes;
|
|
778
|
+
await saveCachedScopes(config.agentName, agentId, scopes);
|
|
779
|
+
} finally {
|
|
780
|
+
consentInProgress = false;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
async function handleToolCall(line) {
|
|
784
|
+
const request = parseJsonRpcLine(line);
|
|
785
|
+
if (request === null) return null;
|
|
786
|
+
if (request.method !== "tools/call") return null;
|
|
787
|
+
const toolParams = extractToolCallParams(request);
|
|
788
|
+
if (toolParams === null) return null;
|
|
789
|
+
await ensureConsent();
|
|
790
|
+
const service = extractServiceFromToolName(toolParams.name);
|
|
791
|
+
const action = extractActionFromToolName(toolParams.name);
|
|
792
|
+
const requestedScope = { service, permissionLevel: "execute" };
|
|
793
|
+
const validation = validateScopeAccess(grantedScopes, requestedScope);
|
|
794
|
+
config.logger.debug("Tool call intercepted.", {
|
|
795
|
+
tool: toolParams.name,
|
|
796
|
+
service,
|
|
797
|
+
allowed: validation.allowed
|
|
798
|
+
});
|
|
799
|
+
if (!validation.allowed) {
|
|
800
|
+
if (actionLogger !== null) {
|
|
801
|
+
if (!config.agentName || config.agentName.trim().length === 0) {
|
|
802
|
+
process.stderr.write("[multicorn-proxy] Cannot log action: agent name not resolved\n");
|
|
803
|
+
} else {
|
|
804
|
+
await actionLogger.logAction({
|
|
805
|
+
agent: config.agentName,
|
|
806
|
+
service,
|
|
807
|
+
actionType: action,
|
|
808
|
+
status: "blocked"
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
const blocked = buildBlockedResponse(request.id, service, "execute", config.dashboardUrl);
|
|
813
|
+
return JSON.stringify(blocked);
|
|
814
|
+
}
|
|
815
|
+
if (spendingChecker !== null) {
|
|
816
|
+
const costCents = extractCostCents(toolParams.arguments);
|
|
817
|
+
if (costCents > 0) {
|
|
818
|
+
const spendResult = spendingChecker.checkSpend(costCents);
|
|
819
|
+
if (!spendResult.allowed) {
|
|
820
|
+
if (actionLogger !== null) {
|
|
821
|
+
if (!config.agentName || config.agentName.trim().length === 0) {
|
|
822
|
+
process.stderr.write(
|
|
823
|
+
"[multicorn-proxy] Cannot log action: agent name not resolved\n"
|
|
824
|
+
);
|
|
825
|
+
} else {
|
|
826
|
+
await actionLogger.logAction({
|
|
827
|
+
agent: config.agentName,
|
|
828
|
+
service,
|
|
829
|
+
actionType: action,
|
|
830
|
+
status: "blocked"
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
const blocked = buildSpendingBlockedResponse(
|
|
835
|
+
request.id,
|
|
836
|
+
spendResult.reason ?? "spending limit exceeded",
|
|
837
|
+
config.dashboardUrl
|
|
838
|
+
);
|
|
839
|
+
return JSON.stringify(blocked);
|
|
840
|
+
}
|
|
841
|
+
spendingChecker.recordSpend(costCents);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
if (actionLogger !== null) {
|
|
845
|
+
if (!config.agentName || config.agentName.trim().length === 0) {
|
|
846
|
+
process.stderr.write("[multicorn-proxy] Cannot log action: agent name not resolved\n");
|
|
847
|
+
} else {
|
|
848
|
+
await actionLogger.logAction({
|
|
849
|
+
agent: config.agentName,
|
|
850
|
+
service,
|
|
851
|
+
actionType: action,
|
|
852
|
+
status: "approved"
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
return null;
|
|
857
|
+
}
|
|
858
|
+
async function processLine(line) {
|
|
859
|
+
const childProcess = child;
|
|
860
|
+
if (childProcess?.stdin === null || childProcess === null) return;
|
|
861
|
+
const override = await handleToolCall(line);
|
|
862
|
+
if (override !== null) {
|
|
863
|
+
process.stdout.write(override + "\n");
|
|
864
|
+
} else {
|
|
865
|
+
childProcess.stdin.write(line + "\n");
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
async function drainQueue() {
|
|
869
|
+
if (draining) return;
|
|
870
|
+
draining = true;
|
|
871
|
+
while (pendingLines.length > 0) {
|
|
872
|
+
const line = pendingLines.shift();
|
|
873
|
+
if (line === void 0) break;
|
|
874
|
+
await processLine(line);
|
|
875
|
+
}
|
|
876
|
+
draining = false;
|
|
877
|
+
}
|
|
878
|
+
function enqueueLine(line) {
|
|
879
|
+
pendingLines.push(line);
|
|
880
|
+
void drainQueue();
|
|
881
|
+
}
|
|
882
|
+
async function stop() {
|
|
883
|
+
if (refreshTimer !== null) {
|
|
884
|
+
clearInterval(refreshTimer);
|
|
885
|
+
refreshTimer = null;
|
|
886
|
+
}
|
|
887
|
+
if (actionLogger !== null) {
|
|
888
|
+
await actionLogger.shutdown();
|
|
889
|
+
actionLogger = null;
|
|
890
|
+
}
|
|
891
|
+
const childProcess = child;
|
|
892
|
+
if (childProcess !== null) {
|
|
893
|
+
childProcess.kill("SIGTERM");
|
|
894
|
+
child = null;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
async function start() {
|
|
898
|
+
config.logger.info("Proxy starting.", { agent: config.agentName, command: config.command });
|
|
899
|
+
const agentRecord = await resolveAgentRecord(
|
|
900
|
+
config.agentName,
|
|
901
|
+
config.apiKey,
|
|
902
|
+
config.baseUrl,
|
|
903
|
+
config.logger
|
|
904
|
+
);
|
|
905
|
+
agentId = agentRecord.id;
|
|
906
|
+
grantedScopes = agentRecord.scopes;
|
|
907
|
+
config.logger.info("Agent resolved.", {
|
|
908
|
+
agent: config.agentName,
|
|
909
|
+
id: agentId,
|
|
910
|
+
scopeCount: grantedScopes.length
|
|
911
|
+
});
|
|
912
|
+
actionLogger = createActionLogger({
|
|
913
|
+
apiKey: config.apiKey,
|
|
914
|
+
baseUrl: config.baseUrl,
|
|
915
|
+
batchMode: { enabled: false },
|
|
916
|
+
onError: (err) => {
|
|
917
|
+
config.logger.warn("Action log failed.", { error: err.message });
|
|
918
|
+
}
|
|
919
|
+
});
|
|
920
|
+
if (config.spendingLimits !== void 0) {
|
|
921
|
+
spendingChecker = createSpendingChecker({ limits: config.spendingLimits });
|
|
922
|
+
}
|
|
923
|
+
child = spawn(config.command, config.commandArgs, {
|
|
924
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
925
|
+
});
|
|
926
|
+
const childProcess = child;
|
|
927
|
+
childProcess.on("error", (err) => {
|
|
928
|
+
config.logger.error("Child process error.", { error: err.message });
|
|
929
|
+
});
|
|
930
|
+
childProcess.on("exit", (code, signal) => {
|
|
931
|
+
config.logger.info("Child process exited.", {
|
|
932
|
+
code: code ?? void 0,
|
|
933
|
+
signal: signal ?? void 0
|
|
934
|
+
});
|
|
935
|
+
});
|
|
936
|
+
if (childProcess.stdout !== null) {
|
|
937
|
+
childProcess.stdout.on("data", (chunk) => {
|
|
938
|
+
process.stdout.write(chunk);
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
if (childProcess.stderr !== null) {
|
|
942
|
+
childProcess.stderr.on("data", (chunk) => {
|
|
943
|
+
config.logger.debug("Child stderr.", { data: chunk.toString().trim() });
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
const rl = createInterface({ input: process.stdin, terminal: false });
|
|
947
|
+
rl.on("line", (line) => {
|
|
948
|
+
enqueueLine(line);
|
|
949
|
+
});
|
|
950
|
+
rl.on("close", () => {
|
|
951
|
+
config.logger.info("Agent disconnected. Shutting down.");
|
|
952
|
+
void stop();
|
|
953
|
+
});
|
|
954
|
+
const refreshIntervalMs = config.scopeRefreshIntervalMs ?? DEFAULT_SCOPE_REFRESH_INTERVAL_MS;
|
|
955
|
+
refreshTimer = setInterval(() => {
|
|
956
|
+
void refreshScopes();
|
|
957
|
+
}, refreshIntervalMs);
|
|
958
|
+
const timer = refreshTimer;
|
|
959
|
+
if (typeof timer.unref === "function") {
|
|
960
|
+
timer.unref();
|
|
961
|
+
}
|
|
962
|
+
config.logger.info("Proxy ready.", { agent: config.agentName });
|
|
963
|
+
return new Promise((resolve) => {
|
|
964
|
+
childProcess.on("exit", () => {
|
|
965
|
+
resolve();
|
|
966
|
+
});
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
return { start, stop };
|
|
970
|
+
}
|
|
971
|
+
function extractCostCents(args) {
|
|
972
|
+
const amount = args["amount"];
|
|
973
|
+
if (typeof amount !== "number" || !Number.isFinite(amount) || amount <= 0) return 0;
|
|
974
|
+
return dollarsToCents(amount);
|
|
975
|
+
}
|
|
976
|
+
var LOG_LEVELS = {
|
|
977
|
+
debug: 0,
|
|
978
|
+
info: 1,
|
|
979
|
+
warn: 2,
|
|
980
|
+
error: 3
|
|
981
|
+
};
|
|
982
|
+
function createLogger(level, output = process.stderr) {
|
|
983
|
+
const minLevel = LOG_LEVELS[level];
|
|
984
|
+
function write(logLevel, msg, data) {
|
|
985
|
+
if (LOG_LEVELS[logLevel] < minLevel) return;
|
|
986
|
+
const entry = {
|
|
987
|
+
level: logLevel,
|
|
988
|
+
time: (/* @__PURE__ */ new Date()).toISOString(),
|
|
989
|
+
msg,
|
|
990
|
+
...data
|
|
991
|
+
};
|
|
992
|
+
output.write(JSON.stringify(entry) + "\n");
|
|
993
|
+
}
|
|
994
|
+
return {
|
|
995
|
+
debug: (msg, data) => {
|
|
996
|
+
write("debug", msg, data);
|
|
997
|
+
},
|
|
998
|
+
info: (msg, data) => {
|
|
999
|
+
write("info", msg, data);
|
|
1000
|
+
},
|
|
1001
|
+
warn: (msg, data) => {
|
|
1002
|
+
write("warn", msg, data);
|
|
1003
|
+
},
|
|
1004
|
+
error: (msg, data) => {
|
|
1005
|
+
write("error", msg, data);
|
|
1006
|
+
}
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
function isValidLogLevel(value) {
|
|
1010
|
+
return typeof value === "string" && Object.hasOwn(LOG_LEVELS, value);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// bin/multicorn-proxy.ts
|
|
1014
|
+
function parseArgs(argv) {
|
|
1015
|
+
const args = argv.slice(2);
|
|
1016
|
+
let subcommand = "help";
|
|
1017
|
+
let wrapCommand = "";
|
|
1018
|
+
let wrapArgs = [];
|
|
1019
|
+
let logLevel = "info";
|
|
1020
|
+
let baseUrl = "https://api.multicorn.ai";
|
|
1021
|
+
let dashboardUrl = "";
|
|
1022
|
+
let agentName = "";
|
|
1023
|
+
for (let i = 0; i < args.length; i++) {
|
|
1024
|
+
const arg = args[i];
|
|
1025
|
+
if (arg === "init") {
|
|
1026
|
+
subcommand = "init";
|
|
1027
|
+
} else if (arg === "--wrap") {
|
|
1028
|
+
subcommand = "wrap";
|
|
1029
|
+
const next = args[i + 1];
|
|
1030
|
+
if (next === void 0 || next.startsWith("-")) {
|
|
1031
|
+
process.stderr.write("Error: --wrap requires a command to run.\n");
|
|
1032
|
+
process.stderr.write("Example: npx multicorn-proxy --wrap my-mcp-server\n");
|
|
1033
|
+
process.exit(1);
|
|
1034
|
+
}
|
|
1035
|
+
wrapCommand = next;
|
|
1036
|
+
wrapArgs = args.slice(i + 2);
|
|
1037
|
+
break;
|
|
1038
|
+
} else if (arg === "--log-level") {
|
|
1039
|
+
const next = args[i + 1];
|
|
1040
|
+
if (next !== void 0 && isValidLogLevel(next)) {
|
|
1041
|
+
logLevel = next;
|
|
1042
|
+
i++;
|
|
1043
|
+
}
|
|
1044
|
+
} else if (arg === "--base-url") {
|
|
1045
|
+
const next = args[i + 1];
|
|
1046
|
+
if (next !== void 0) {
|
|
1047
|
+
baseUrl = next;
|
|
1048
|
+
i++;
|
|
1049
|
+
}
|
|
1050
|
+
} else if (arg === "--dashboard-url") {
|
|
1051
|
+
const next = args[i + 1];
|
|
1052
|
+
if (next !== void 0) {
|
|
1053
|
+
dashboardUrl = next;
|
|
1054
|
+
i++;
|
|
1055
|
+
}
|
|
1056
|
+
} else if (arg === "--agent-name") {
|
|
1057
|
+
const next = args[i + 1];
|
|
1058
|
+
if (next !== void 0) {
|
|
1059
|
+
agentName = next;
|
|
1060
|
+
i++;
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
return { subcommand, wrapCommand, wrapArgs, logLevel, baseUrl, dashboardUrl, agentName };
|
|
1065
|
+
}
|
|
1066
|
+
function printHelp() {
|
|
1067
|
+
process.stderr.write(
|
|
1068
|
+
[
|
|
1069
|
+
"multicorn-proxy: MCP permission proxy",
|
|
1070
|
+
"",
|
|
1071
|
+
"Usage:",
|
|
1072
|
+
" npx multicorn-proxy init",
|
|
1073
|
+
" Interactive setup. Saves API key to ~/.multicorn/config.json.",
|
|
1074
|
+
"",
|
|
1075
|
+
" npx multicorn-proxy --wrap <command> [args...]",
|
|
1076
|
+
" Start <command> as an MCP server and proxy all tool calls through",
|
|
1077
|
+
" Shield's permission layer.",
|
|
1078
|
+
"",
|
|
1079
|
+
"Options:",
|
|
1080
|
+
" --log-level <level> Log level: debug | info | warn | error (default: info)",
|
|
1081
|
+
" --base-url <url> Multicorn API base URL (default: https://api.multicorn.ai)",
|
|
1082
|
+
" --dashboard-url <url> Dashboard URL for consent page (default: derived from --base-url)",
|
|
1083
|
+
" --agent-name <name> Override agent name derived from the wrapped command",
|
|
1084
|
+
"",
|
|
1085
|
+
"Examples:",
|
|
1086
|
+
" npx multicorn-proxy init",
|
|
1087
|
+
" npx multicorn-proxy --wrap npx @modelcontextprotocol/server-filesystem /tmp",
|
|
1088
|
+
" npx multicorn-proxy --wrap my-mcp-server --log-level debug",
|
|
1089
|
+
""
|
|
1090
|
+
].join("\n")
|
|
1091
|
+
);
|
|
1092
|
+
}
|
|
1093
|
+
async function main() {
|
|
1094
|
+
const cli = parseArgs(process.argv);
|
|
1095
|
+
const logger = createLogger(cli.logLevel);
|
|
1096
|
+
if (cli.subcommand === "help") {
|
|
1097
|
+
printHelp();
|
|
1098
|
+
process.exit(0);
|
|
1099
|
+
}
|
|
1100
|
+
if (cli.subcommand === "init") {
|
|
1101
|
+
await runInit(cli.baseUrl);
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
if (!cli.baseUrl.startsWith("https://") && !cli.baseUrl.startsWith("http://localhost") && !cli.baseUrl.startsWith("http://127.0.0.1")) {
|
|
1105
|
+
process.stderr.write(
|
|
1106
|
+
`Error: --base-url must use HTTPS. Received: "${cli.baseUrl}"
|
|
1107
|
+
Use https:// or http://localhost for local development.
|
|
1108
|
+
`
|
|
1109
|
+
);
|
|
1110
|
+
process.exit(1);
|
|
1111
|
+
}
|
|
1112
|
+
const config = await loadConfig();
|
|
1113
|
+
if (config === null) {
|
|
1114
|
+
process.stderr.write(
|
|
1115
|
+
"No config found. Run `npx multicorn-proxy init` to set up your API key.\n"
|
|
1116
|
+
);
|
|
1117
|
+
process.exit(1);
|
|
1118
|
+
}
|
|
1119
|
+
const agentName = cli.agentName.length > 0 ? cli.agentName : deriveAgentName(cli.wrapCommand);
|
|
1120
|
+
const finalBaseUrl = cli.baseUrl !== "https://api.multicorn.ai" ? cli.baseUrl : config.baseUrl;
|
|
1121
|
+
const finalDashboardUrl = cli.dashboardUrl !== "" ? cli.dashboardUrl : deriveDashboardUrl(finalBaseUrl);
|
|
1122
|
+
const proxy = createProxyServer({
|
|
1123
|
+
command: cli.wrapCommand,
|
|
1124
|
+
commandArgs: cli.wrapArgs,
|
|
1125
|
+
apiKey: config.apiKey,
|
|
1126
|
+
agentName,
|
|
1127
|
+
baseUrl: finalBaseUrl,
|
|
1128
|
+
dashboardUrl: finalDashboardUrl,
|
|
1129
|
+
logger
|
|
1130
|
+
});
|
|
1131
|
+
async function shutdown() {
|
|
1132
|
+
logger.info("Shutting down.");
|
|
1133
|
+
await proxy.stop();
|
|
1134
|
+
process.exit(0);
|
|
1135
|
+
}
|
|
1136
|
+
process.on("SIGTERM", () => {
|
|
1137
|
+
void shutdown();
|
|
1138
|
+
});
|
|
1139
|
+
process.on("SIGINT", () => {
|
|
1140
|
+
void shutdown();
|
|
1141
|
+
});
|
|
1142
|
+
await proxy.start();
|
|
1143
|
+
}
|
|
1144
|
+
function deriveAgentName(command) {
|
|
1145
|
+
const base = command.split("/").pop() ?? command;
|
|
1146
|
+
return base.replace(/\.[cm]?[jt]s$/, "");
|
|
1147
|
+
}
|
|
1148
|
+
main().catch((error) => {
|
|
1149
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1150
|
+
process.stderr.write(`Fatal error: ${message}
|
|
1151
|
+
`);
|
|
1152
|
+
process.exit(1);
|
|
1153
|
+
});
|