multicorn-shield 0.13.0 → 1.0.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/CHANGELOG.md +21 -13
- package/README.md +5 -5
- package/dist/multicorn-proxy.js +302 -133
- package/dist/multicorn-shield.js +3091 -31
- package/dist/openclaw-plugin/multicorn-shield.js +2 -2
- package/dist/proxy.cjs +1 -1
- package/dist/proxy.js +1 -1
- package/dist/shield-extension.js +1 -1
- package/package.json +3 -3
- package/plugins/windsurf/README.md +2 -2
package/dist/multicorn-shield.js
CHANGED
|
@@ -1,14 +1,756 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { existsSync, statSync } from 'fs';
|
|
3
|
+
import { readFile, mkdir, writeFile, copyFile, chmod, unlink } from 'fs/promises';
|
|
3
4
|
import { join, dirname } from 'path';
|
|
4
|
-
import 'fs';
|
|
5
5
|
import { homedir } from 'os';
|
|
6
|
-
import 'url';
|
|
7
|
-
import 'readline';
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
7
|
+
import { createInterface } from 'readline';
|
|
8
|
+
import { spawn } from 'child_process';
|
|
9
|
+
import { createHash } from 'crypto';
|
|
10
|
+
import 'stream';
|
|
8
11
|
|
|
12
|
+
var style = {
|
|
13
|
+
violet: (s) => `\x1B[38;2;124;58;237m${s}\x1B[0m`,
|
|
14
|
+
violetLight: (s) => `\x1B[38;2;167;139;250m${s}\x1B[0m`,
|
|
15
|
+
green: (s) => `\x1B[38;2;34;197;94m${s}\x1B[0m`,
|
|
16
|
+
yellow: (s) => `\x1B[38;2;245;158;11m${s}\x1B[0m`,
|
|
17
|
+
red: (s) => `\x1B[38;2;239;68;68m${s}\x1B[0m`,
|
|
18
|
+
cyan: (s) => `\x1B[38;2;6;182;212m${s}\x1B[0m`,
|
|
19
|
+
bold: (s) => `\x1B[1m${s}\x1B[0m`,
|
|
20
|
+
dim: (s) => `\x1B[2m${s}\x1B[0m`
|
|
21
|
+
};
|
|
22
|
+
var BANNER = [
|
|
23
|
+
" \u2588\u2588\u2588 \u2588 \u2588 \u2588 \u2588\u2588\u2588 \u2588 \u2588\u2588\u2584 ",
|
|
24
|
+
" \u2588 \u2588 \u2588 \u2588 \u2588 \u2588 \u2588 \u2588",
|
|
25
|
+
" \u2588\u2588\u2588 \u2588\u2588\u2588\u2588 \u2588 \u2588\u2588 \u2588 \u2588 \u2588",
|
|
26
|
+
" \u2588 \u2588 \u2588 \u2588 \u2588 \u2588 \u2588 \u2588",
|
|
27
|
+
" \u2588\u2588\u2588 \u2588 \u2588 \u2588 \u2588\u2588\u2588 \u2588\u2588\u2588 \u2588\u2588\u2580 "
|
|
28
|
+
].map((line) => style.violet(line)).join("\n");
|
|
29
|
+
function withSpinner(message) {
|
|
30
|
+
const frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
|
|
31
|
+
let i = 0;
|
|
32
|
+
const interval = setInterval(() => {
|
|
33
|
+
const frame = frames[i % frames.length];
|
|
34
|
+
process.stderr.write(`\r${style.violet(frame ?? "\u280B")} ${message}`);
|
|
35
|
+
i++;
|
|
36
|
+
}, 80);
|
|
37
|
+
return {
|
|
38
|
+
stop(success, result) {
|
|
39
|
+
clearInterval(interval);
|
|
40
|
+
const icon = success ? style.green("\u2713") : style.red("\u2717");
|
|
41
|
+
process.stderr.write(`\r\x1B[2K${icon} ${result}
|
|
42
|
+
`);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
var NativePluginPrerequisiteMissingError = class extends Error {
|
|
47
|
+
constructor() {
|
|
48
|
+
super("Native plugin prerequisites not met");
|
|
49
|
+
this.name = "NativePluginPrerequisiteMissingError";
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
function isExistingDirectory(path) {
|
|
53
|
+
try {
|
|
54
|
+
if (!existsSync(path)) return false;
|
|
55
|
+
return statSync(path).isDirectory();
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function nativePluginSkippedSaveNote(wizardCommand, productName) {
|
|
61
|
+
return "\n" + style.dim("Your agent config has been saved. Run ") + style.cyan(wizardCommand) + style.dim(` again after installing ${productName} to complete hook setup.`) + "\n";
|
|
62
|
+
}
|
|
9
63
|
var CONFIG_DIR = join(homedir(), ".multicorn");
|
|
10
|
-
join(CONFIG_DIR, "config.json");
|
|
11
|
-
join(homedir(), ".openclaw", "openclaw.json");
|
|
64
|
+
var CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
65
|
+
var OPENCLAW_CONFIG_PATH = join(homedir(), ".openclaw", "openclaw.json");
|
|
66
|
+
var ANSI_PATTERN = new RegExp(String.fromCharCode(27) + "\\[[0-9;]*[a-zA-Z]", "g");
|
|
67
|
+
function stripAnsi(str) {
|
|
68
|
+
return str.replace(ANSI_PATTERN, "");
|
|
69
|
+
}
|
|
70
|
+
function normalizeAgentName(raw) {
|
|
71
|
+
return raw.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "").replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "").slice(0, 50);
|
|
72
|
+
}
|
|
73
|
+
function isErrnoException(e) {
|
|
74
|
+
return typeof e === "object" && e !== null && "code" in e;
|
|
75
|
+
}
|
|
76
|
+
function isProxyConfig(value) {
|
|
77
|
+
if (typeof value !== "object" || value === null) return false;
|
|
78
|
+
const obj = value;
|
|
79
|
+
return typeof obj["apiKey"] === "string" && typeof obj["baseUrl"] === "string";
|
|
80
|
+
}
|
|
81
|
+
function isAgentEntry(value) {
|
|
82
|
+
if (typeof value !== "object" || value === null) return false;
|
|
83
|
+
const o = value;
|
|
84
|
+
return typeof o["name"] === "string" && typeof o["platform"] === "string";
|
|
85
|
+
}
|
|
86
|
+
function getAgentByPlatform(config, platform) {
|
|
87
|
+
const list = config.agents;
|
|
88
|
+
if (list === void 0 || list.length === 0) return void 0;
|
|
89
|
+
return list.find((a) => a.platform === platform);
|
|
90
|
+
}
|
|
91
|
+
function getDefaultAgent(config) {
|
|
92
|
+
const list = config.agents;
|
|
93
|
+
if (list === void 0 || list.length === 0) return void 0;
|
|
94
|
+
const defName = config.defaultAgent;
|
|
95
|
+
if (typeof defName === "string" && defName.length > 0) {
|
|
96
|
+
const match = list.find((a) => a.name === defName);
|
|
97
|
+
if (match !== void 0) return match;
|
|
98
|
+
}
|
|
99
|
+
return list[0];
|
|
100
|
+
}
|
|
101
|
+
function collectAgentsFromConfig(cfg) {
|
|
102
|
+
if (cfg === null) return [];
|
|
103
|
+
if (cfg.agents !== void 0 && cfg.agents.length > 0) {
|
|
104
|
+
return cfg.agents.map((a) => ({ name: a.name, platform: a.platform }));
|
|
105
|
+
}
|
|
106
|
+
const raw = cfg;
|
|
107
|
+
const legacyName = raw["agentName"];
|
|
108
|
+
const legacyPlatform = raw["platform"];
|
|
109
|
+
if (typeof legacyName === "string" && legacyName.length > 0) {
|
|
110
|
+
const plat = typeof legacyPlatform === "string" && legacyPlatform.length > 0 ? legacyPlatform : "unknown";
|
|
111
|
+
return [{ name: legacyName, platform: plat }];
|
|
112
|
+
}
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
async function parseConfigFile() {
|
|
116
|
+
try {
|
|
117
|
+
const raw = await readFile(CONFIG_PATH, "utf8");
|
|
118
|
+
try {
|
|
119
|
+
return { kind: "ok", value: JSON.parse(raw) };
|
|
120
|
+
} catch {
|
|
121
|
+
return { kind: "parseError" };
|
|
122
|
+
}
|
|
123
|
+
} catch (error) {
|
|
124
|
+
if (isErrnoException(error) && error.code === "ENOENT") {
|
|
125
|
+
return { kind: "missing" };
|
|
126
|
+
}
|
|
127
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
128
|
+
return { kind: "readError", message };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function isAllowedShieldApiBaseUrl(url) {
|
|
132
|
+
return url.startsWith("https://") || url.startsWith("http://localhost") || url.startsWith("http://127.0.0.1");
|
|
133
|
+
}
|
|
134
|
+
async function loadConfig() {
|
|
135
|
+
const result = await parseConfigFile();
|
|
136
|
+
if (result.kind !== "ok") return null;
|
|
137
|
+
const parsed = result.value;
|
|
138
|
+
if (!isProxyConfig(parsed)) return null;
|
|
139
|
+
const obj = parsed;
|
|
140
|
+
const agentNameRaw = obj["agentName"];
|
|
141
|
+
const agentsRaw = obj["agents"];
|
|
142
|
+
const hasNonEmptyAgents = Array.isArray(agentsRaw) && agentsRaw.length > 0 && agentsRaw.every((e) => isAgentEntry(e));
|
|
143
|
+
const needsMigrate = typeof agentNameRaw === "string" && agentNameRaw.length > 0 && !hasNonEmptyAgents;
|
|
144
|
+
if (!needsMigrate) {
|
|
145
|
+
return parsed;
|
|
146
|
+
}
|
|
147
|
+
const platform = typeof obj["platform"] === "string" && obj["platform"].length > 0 ? obj["platform"] : "unknown";
|
|
148
|
+
const next = { ...obj };
|
|
149
|
+
delete next["agentName"];
|
|
150
|
+
delete next["platform"];
|
|
151
|
+
next["agents"] = [{ name: agentNameRaw, platform }];
|
|
152
|
+
next["defaultAgent"] = agentNameRaw;
|
|
153
|
+
const migrated = next;
|
|
154
|
+
await saveConfig(migrated);
|
|
155
|
+
return migrated;
|
|
156
|
+
}
|
|
157
|
+
async function readBaseUrlFromConfig() {
|
|
158
|
+
const result = await parseConfigFile();
|
|
159
|
+
if (result.kind === "missing") return void 0;
|
|
160
|
+
if (result.kind === "readError") {
|
|
161
|
+
process.stderr.write(
|
|
162
|
+
style.yellow(`Warning: could not read base URL from config file: ${result.message}`) + "\n"
|
|
163
|
+
);
|
|
164
|
+
return void 0;
|
|
165
|
+
}
|
|
166
|
+
if (result.kind === "parseError") {
|
|
167
|
+
process.stderr.write(
|
|
168
|
+
style.yellow("Warning: could not parse ~/.multicorn/config.json as JSON.") + "\n"
|
|
169
|
+
);
|
|
170
|
+
return void 0;
|
|
171
|
+
}
|
|
172
|
+
const parsed = result.value;
|
|
173
|
+
if (typeof parsed !== "object" || parsed === null) return void 0;
|
|
174
|
+
const u = parsed["baseUrl"];
|
|
175
|
+
if (typeof u !== "string" || u.length === 0) return void 0;
|
|
176
|
+
return u;
|
|
177
|
+
}
|
|
178
|
+
async function deleteAgentByName(name) {
|
|
179
|
+
const config = await loadConfig();
|
|
180
|
+
if (config === null) return false;
|
|
181
|
+
const agents = collectAgentsFromConfig(config);
|
|
182
|
+
const idx = agents.findIndex((a) => a.name === name);
|
|
183
|
+
if (idx === -1) return false;
|
|
184
|
+
const nextAgents = agents.filter((_, i) => i !== idx);
|
|
185
|
+
let defaultAgent = config.defaultAgent;
|
|
186
|
+
if (defaultAgent === name) {
|
|
187
|
+
defaultAgent = void 0;
|
|
188
|
+
}
|
|
189
|
+
const raw = { ...config };
|
|
190
|
+
if (nextAgents.length > 0) {
|
|
191
|
+
raw["agents"] = nextAgents;
|
|
192
|
+
} else {
|
|
193
|
+
delete raw["agents"];
|
|
194
|
+
}
|
|
195
|
+
if (defaultAgent !== void 0 && defaultAgent.length > 0) {
|
|
196
|
+
raw["defaultAgent"] = defaultAgent;
|
|
197
|
+
} else {
|
|
198
|
+
delete raw["defaultAgent"];
|
|
199
|
+
}
|
|
200
|
+
await saveConfig(raw);
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
async function saveConfig(config) {
|
|
204
|
+
await mkdir(CONFIG_DIR, { recursive: true, mode: 448 });
|
|
205
|
+
await writeFile(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", {
|
|
206
|
+
encoding: "utf8",
|
|
207
|
+
mode: 384
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
var OPENCLAW_MIN_VERSION = "2026.2.26";
|
|
211
|
+
async function detectOpenClaw() {
|
|
212
|
+
let raw;
|
|
213
|
+
try {
|
|
214
|
+
raw = await readFile(OPENCLAW_CONFIG_PATH, "utf8");
|
|
215
|
+
} catch (e) {
|
|
216
|
+
if (isErrnoException(e) && e.code === "ENOENT") {
|
|
217
|
+
return { status: "not-found", version: null };
|
|
218
|
+
}
|
|
219
|
+
throw e;
|
|
220
|
+
}
|
|
221
|
+
let obj;
|
|
222
|
+
try {
|
|
223
|
+
obj = JSON.parse(raw);
|
|
224
|
+
} catch {
|
|
225
|
+
return { status: "parse-error", version: null };
|
|
226
|
+
}
|
|
227
|
+
const meta = obj["meta"];
|
|
228
|
+
if (typeof meta === "object" && meta !== null) {
|
|
229
|
+
const v = meta["lastTouchedVersion"];
|
|
230
|
+
if (typeof v === "string" && v.length > 0) {
|
|
231
|
+
return { status: "detected", version: v };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return { status: "detected", version: null };
|
|
235
|
+
}
|
|
236
|
+
function isVersionAtLeast(version, minimum) {
|
|
237
|
+
const vParts = version.split(".").map(Number);
|
|
238
|
+
const mParts = minimum.split(".").map(Number);
|
|
239
|
+
const len = Math.max(vParts.length, mParts.length);
|
|
240
|
+
for (let i = 0; i < len; i++) {
|
|
241
|
+
const v = vParts[i] ?? 0;
|
|
242
|
+
const m = mParts[i] ?? 0;
|
|
243
|
+
if (Number.isNaN(v) || Number.isNaN(m)) return false;
|
|
244
|
+
if (v > m) return true;
|
|
245
|
+
if (v < m) return false;
|
|
246
|
+
}
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
async function updateOpenClawConfigIfPresent(apiKey, baseUrl, agentName) {
|
|
250
|
+
let raw;
|
|
251
|
+
try {
|
|
252
|
+
raw = await readFile(OPENCLAW_CONFIG_PATH, "utf8");
|
|
253
|
+
} catch (e) {
|
|
254
|
+
if (isErrnoException(e) && e.code === "ENOENT") {
|
|
255
|
+
return "not-found";
|
|
256
|
+
}
|
|
257
|
+
throw e;
|
|
258
|
+
}
|
|
259
|
+
let obj;
|
|
260
|
+
try {
|
|
261
|
+
obj = JSON.parse(raw);
|
|
262
|
+
} catch {
|
|
263
|
+
return "parse-error";
|
|
264
|
+
}
|
|
265
|
+
let hooks = obj["hooks"];
|
|
266
|
+
if (hooks === void 0 || typeof hooks !== "object") {
|
|
267
|
+
hooks = {};
|
|
268
|
+
obj["hooks"] = hooks;
|
|
269
|
+
}
|
|
270
|
+
let internal = hooks["internal"];
|
|
271
|
+
if (internal === void 0 || typeof internal !== "object") {
|
|
272
|
+
internal = { enabled: true, entries: {} };
|
|
273
|
+
hooks["internal"] = internal;
|
|
274
|
+
}
|
|
275
|
+
let entries = internal["entries"];
|
|
276
|
+
if (entries === void 0 || typeof entries !== "object") {
|
|
277
|
+
entries = {};
|
|
278
|
+
internal["entries"] = entries;
|
|
279
|
+
}
|
|
280
|
+
let shield = entries["multicorn-shield"];
|
|
281
|
+
if (shield === void 0 || typeof shield !== "object") {
|
|
282
|
+
shield = { enabled: true, env: {} };
|
|
283
|
+
entries["multicorn-shield"] = shield;
|
|
284
|
+
}
|
|
285
|
+
let env = shield["env"];
|
|
286
|
+
if (env === void 0 || typeof env !== "object") {
|
|
287
|
+
env = {};
|
|
288
|
+
shield["env"] = env;
|
|
289
|
+
}
|
|
290
|
+
env["MULTICORN_API_KEY"] = apiKey;
|
|
291
|
+
env["MULTICORN_BASE_URL"] = baseUrl;
|
|
292
|
+
if (agentName !== void 0) {
|
|
293
|
+
env["MULTICORN_AGENT_NAME"] = agentName;
|
|
294
|
+
const agentsList = obj["agents"];
|
|
295
|
+
const list = agentsList?.["list"];
|
|
296
|
+
if (Array.isArray(list) && list.length > 0) {
|
|
297
|
+
const first = list[0];
|
|
298
|
+
if (first["id"] !== agentName) {
|
|
299
|
+
first["id"] = agentName;
|
|
300
|
+
first["name"] = agentName;
|
|
301
|
+
}
|
|
302
|
+
} else {
|
|
303
|
+
if (agentsList !== void 0 && typeof agentsList === "object") {
|
|
304
|
+
agentsList["list"] = [{ id: agentName, name: agentName }];
|
|
305
|
+
} else {
|
|
306
|
+
obj["agents"] = { list: [{ id: agentName, name: agentName }] };
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
await writeFile(OPENCLAW_CONFIG_PATH, JSON.stringify(obj, null, 2) + "\n", {
|
|
311
|
+
encoding: "utf8"
|
|
312
|
+
});
|
|
313
|
+
return "updated";
|
|
314
|
+
}
|
|
315
|
+
async function validateApiKey(apiKey, baseUrl) {
|
|
316
|
+
try {
|
|
317
|
+
const response = await fetch(`${baseUrl}/api/v1/agents`, {
|
|
318
|
+
headers: { "X-Multicorn-Key": apiKey },
|
|
319
|
+
signal: AbortSignal.timeout(8e3)
|
|
320
|
+
});
|
|
321
|
+
if (response.status === 401) {
|
|
322
|
+
return { valid: false, error: "API key not recognised. Check the key and try again." };
|
|
323
|
+
}
|
|
324
|
+
if (!response.ok) {
|
|
325
|
+
return {
|
|
326
|
+
valid: false,
|
|
327
|
+
error: `Service returned ${String(response.status)}. Check your base URL and try again.`
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
return { valid: true };
|
|
331
|
+
} catch (error) {
|
|
332
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
333
|
+
return {
|
|
334
|
+
valid: false,
|
|
335
|
+
error: `Could not reach ${baseUrl}. Check your network connection. (${detail})`
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
async function isOpenClawConnected() {
|
|
340
|
+
try {
|
|
341
|
+
const raw = await readFile(OPENCLAW_CONFIG_PATH, "utf8");
|
|
342
|
+
const obj = JSON.parse(raw);
|
|
343
|
+
const hooks = obj["hooks"];
|
|
344
|
+
const internal = hooks?.["internal"];
|
|
345
|
+
const entries = internal?.["entries"];
|
|
346
|
+
const shield = entries?.["multicorn-shield"];
|
|
347
|
+
const env = shield?.["env"];
|
|
348
|
+
const key = env?.["MULTICORN_API_KEY"];
|
|
349
|
+
return typeof key === "string" && key.length > 0;
|
|
350
|
+
} catch {
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
function isClaudeCodeConnected() {
|
|
355
|
+
try {
|
|
356
|
+
return existsSync(join(homedir(), ".claude", "plugins", "cache", "multicorn-shield"));
|
|
357
|
+
} catch {
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
function getCursorConfigPath() {
|
|
362
|
+
return join(homedir(), ".cursor", "mcp.json");
|
|
363
|
+
}
|
|
364
|
+
async function isCursorConnected() {
|
|
365
|
+
try {
|
|
366
|
+
const raw = await readFile(getCursorConfigPath(), "utf8");
|
|
367
|
+
const obj = JSON.parse(raw);
|
|
368
|
+
const mcpServers = obj["mcpServers"];
|
|
369
|
+
if (mcpServers === void 0 || typeof mcpServers !== "object") return false;
|
|
370
|
+
for (const entry of Object.values(mcpServers)) {
|
|
371
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
372
|
+
const rec = entry;
|
|
373
|
+
const url = rec["url"];
|
|
374
|
+
if (typeof url === "string" && url.includes("multicorn")) return true;
|
|
375
|
+
const args = rec["args"];
|
|
376
|
+
if (Array.isArray(args) && (args.includes("multicorn-shield") || args.includes("multicorn-proxy")))
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
return false;
|
|
380
|
+
} catch (err) {
|
|
381
|
+
process.stderr.write(
|
|
382
|
+
`Warning: could not check Cursor connection status: ${err instanceof Error ? err.message : String(err)}
|
|
383
|
+
`
|
|
384
|
+
);
|
|
385
|
+
return false;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
function getWindsurfConfigPath() {
|
|
389
|
+
return join(homedir(), ".codeium", "windsurf", "mcp_config.json");
|
|
390
|
+
}
|
|
391
|
+
async function isWindsurfConnected() {
|
|
392
|
+
try {
|
|
393
|
+
const raw = await readFile(getWindsurfConfigPath(), "utf8");
|
|
394
|
+
const obj = JSON.parse(raw);
|
|
395
|
+
const mcpServers = obj["mcpServers"];
|
|
396
|
+
if (mcpServers === void 0 || typeof mcpServers !== "object") return false;
|
|
397
|
+
for (const entry of Object.values(mcpServers)) {
|
|
398
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
399
|
+
const rec = entry;
|
|
400
|
+
const url = rec["serverUrl"];
|
|
401
|
+
if (typeof url === "string" && url.includes("multicorn")) return true;
|
|
402
|
+
}
|
|
403
|
+
return false;
|
|
404
|
+
} catch {
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
function multicornShieldPackageRoot() {
|
|
409
|
+
return join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
410
|
+
}
|
|
411
|
+
function getWindsurfHooksInstallDir() {
|
|
412
|
+
return join(homedir(), ".multicorn", "windsurf-hooks");
|
|
413
|
+
}
|
|
414
|
+
function getWindsurfCascadeHooksJsonPath() {
|
|
415
|
+
return join(homedir(), ".codeium", "windsurf", "hooks.json");
|
|
416
|
+
}
|
|
417
|
+
function isShieldWindsurfHookCommand(cmd) {
|
|
418
|
+
return cmd.includes("windsurf-hooks/pre-action.cjs") || cmd.includes("windsurf-hooks\\pre-action.cjs") || cmd.includes("windsurf-hooks/post-action.cjs") || cmd.includes("windsurf-hooks\\post-action.cjs");
|
|
419
|
+
}
|
|
420
|
+
function filterOutShieldWindsurfHooks(entries) {
|
|
421
|
+
if (!Array.isArray(entries)) return [];
|
|
422
|
+
const out = [];
|
|
423
|
+
for (const e of entries) {
|
|
424
|
+
if (typeof e !== "object" || e === null) continue;
|
|
425
|
+
const rec = e;
|
|
426
|
+
const cmd = rec["command"];
|
|
427
|
+
if (typeof cmd !== "string" || isShieldWindsurfHookCommand(cmd)) continue;
|
|
428
|
+
const powershell = rec["powershell"];
|
|
429
|
+
const show_output = rec["show_output"];
|
|
430
|
+
out.push({
|
|
431
|
+
command: cmd,
|
|
432
|
+
...typeof powershell === "string" ? { powershell } : {},
|
|
433
|
+
...show_output === true ? { show_output: true } : {}
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
return out;
|
|
437
|
+
}
|
|
438
|
+
async function installWindsurfNativeHooks() {
|
|
439
|
+
const root = multicornShieldPackageRoot();
|
|
440
|
+
const srcPre = join(root, "plugins", "windsurf", "hooks", "scripts", "pre-action.cjs");
|
|
441
|
+
const srcPost = join(root, "plugins", "windsurf", "hooks", "scripts", "post-action.cjs");
|
|
442
|
+
if (!existsSync(srcPre) || !existsSync(srcPost)) {
|
|
443
|
+
throw new Error(
|
|
444
|
+
`Could not find Shield Windsurf hook scripts at ${srcPre}. If you use npm, install the latest multicorn-shield package.`
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
const windsurfConfigDir = join(homedir(), ".codeium", "windsurf");
|
|
448
|
+
if (!isExistingDirectory(windsurfConfigDir)) {
|
|
449
|
+
process.stderr.write(
|
|
450
|
+
style.yellow("\u26A0") + " Windsurf does not appear to be installed (~/.codeium/windsurf/ not found).\n\n"
|
|
451
|
+
);
|
|
452
|
+
process.stderr.write(
|
|
453
|
+
"Open Windsurf at least once so this folder exists, or install from:\n " + style.cyan("https://windsurf.com/download") + "\n\n"
|
|
454
|
+
);
|
|
455
|
+
process.stderr.write("Then run this wizard again:\n");
|
|
456
|
+
process.stderr.write(" " + style.cyan("npx multicorn-shield init") + "\n");
|
|
457
|
+
throw new NativePluginPrerequisiteMissingError();
|
|
458
|
+
}
|
|
459
|
+
const installDir = getWindsurfHooksInstallDir();
|
|
460
|
+
await mkdir(installDir, { recursive: true });
|
|
461
|
+
const destPre = join(installDir, "pre-action.cjs");
|
|
462
|
+
const destPost = join(installDir, "post-action.cjs");
|
|
463
|
+
await copyFile(srcPre, destPre);
|
|
464
|
+
await copyFile(srcPost, destPost);
|
|
465
|
+
const preCmd = `node ${JSON.stringify(destPre)}`;
|
|
466
|
+
const postCmd = `node ${JSON.stringify(destPost)}`;
|
|
467
|
+
const preEntry = { command: preCmd, powershell: preCmd, show_output: true };
|
|
468
|
+
const postEntry = { command: postCmd, powershell: postCmd };
|
|
469
|
+
const hooksPath = getWindsurfCascadeHooksJsonPath();
|
|
470
|
+
let base = { hooks: {} };
|
|
471
|
+
try {
|
|
472
|
+
const raw = await readFile(hooksPath, "utf8");
|
|
473
|
+
base = JSON.parse(raw);
|
|
474
|
+
} catch (err) {
|
|
475
|
+
if (!isErrnoException(err) || err.code !== "ENOENT") {
|
|
476
|
+
throw err;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
const hooks = base["hooks"] ?? {};
|
|
480
|
+
const preKeys = [
|
|
481
|
+
"pre_read_code",
|
|
482
|
+
"pre_write_code",
|
|
483
|
+
"pre_run_command",
|
|
484
|
+
"pre_mcp_tool_use"
|
|
485
|
+
];
|
|
486
|
+
const postKeys = [
|
|
487
|
+
"post_read_code",
|
|
488
|
+
"post_write_code",
|
|
489
|
+
"post_run_command",
|
|
490
|
+
"post_mcp_tool_use"
|
|
491
|
+
];
|
|
492
|
+
const nextHooks = { ...hooks };
|
|
493
|
+
for (const k of preKeys) {
|
|
494
|
+
const merged = filterOutShieldWindsurfHooks(nextHooks[k]);
|
|
495
|
+
nextHooks[k] = [...merged, preEntry];
|
|
496
|
+
}
|
|
497
|
+
for (const k of postKeys) {
|
|
498
|
+
const merged = filterOutShieldWindsurfHooks(nextHooks[k]);
|
|
499
|
+
nextHooks[k] = [...merged, postEntry];
|
|
500
|
+
}
|
|
501
|
+
base["hooks"] = nextHooks;
|
|
502
|
+
const hooksDir = dirname(hooksPath);
|
|
503
|
+
await mkdir(hooksDir, { recursive: true });
|
|
504
|
+
await writeFile(hooksPath, JSON.stringify(base, null, 2) + "\n", { encoding: "utf8" });
|
|
505
|
+
}
|
|
506
|
+
function getClineHooksInstallDir() {
|
|
507
|
+
return join(homedir(), ".multicorn", "cline-hooks");
|
|
508
|
+
}
|
|
509
|
+
function getClineGlobalHooksDir() {
|
|
510
|
+
return join(homedir(), "Documents", "Cline", "Hooks");
|
|
511
|
+
}
|
|
512
|
+
async function installClineNativeHooks() {
|
|
513
|
+
const root = multicornShieldPackageRoot();
|
|
514
|
+
const srcPre = join(root, "plugins", "cline", "hooks", "scripts", "pre-tool-use.cjs");
|
|
515
|
+
const srcPost = join(root, "plugins", "cline", "hooks", "scripts", "post-tool-use.cjs");
|
|
516
|
+
const srcShared = join(root, "plugins", "cline", "hooks", "scripts", "shared.cjs");
|
|
517
|
+
if (!existsSync(srcPre) || !existsSync(srcPost) || !existsSync(srcShared)) {
|
|
518
|
+
throw new Error(
|
|
519
|
+
`Could not find Shield Cline hook scripts at ${srcPre}. If you use npm, install the latest multicorn-shield package.`
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
const clineDocsDir = join(homedir(), "Documents", "Cline");
|
|
523
|
+
if (!isExistingDirectory(clineDocsDir)) {
|
|
524
|
+
process.stderr.write(
|
|
525
|
+
style.yellow("\u26A0") + " Cline does not appear to be installed (~/Documents/Cline/ not found).\n\n"
|
|
526
|
+
);
|
|
527
|
+
process.stderr.write("Install the Cline VS Code extension first. See:\n");
|
|
528
|
+
process.stderr.write(
|
|
529
|
+
" " + style.cyan("https://docs.cline.bot/getting-started/installing-cline") + "\n\n"
|
|
530
|
+
);
|
|
531
|
+
process.stderr.write("Then run this wizard again:\n");
|
|
532
|
+
process.stderr.write(" " + style.cyan("npx multicorn-shield init") + "\n");
|
|
533
|
+
throw new NativePluginPrerequisiteMissingError();
|
|
534
|
+
}
|
|
535
|
+
const installDir = getClineHooksInstallDir();
|
|
536
|
+
await mkdir(installDir, { recursive: true });
|
|
537
|
+
const destPre = join(installDir, "pre-tool-use.cjs");
|
|
538
|
+
const destPost = join(installDir, "post-tool-use.cjs");
|
|
539
|
+
const destShared = join(installDir, "shared.cjs");
|
|
540
|
+
await copyFile(srcPre, destPre);
|
|
541
|
+
await copyFile(srcPost, destPost);
|
|
542
|
+
await copyFile(srcShared, destShared);
|
|
543
|
+
const hookScriptMode = 493;
|
|
544
|
+
await chmod(destPre, hookScriptMode);
|
|
545
|
+
await chmod(destPost, hookScriptMode);
|
|
546
|
+
await chmod(destShared, hookScriptMode);
|
|
547
|
+
const hooksDir = getClineGlobalHooksDir();
|
|
548
|
+
await mkdir(hooksDir, { recursive: true });
|
|
549
|
+
const preWrapper = join(hooksDir, "PreToolUse");
|
|
550
|
+
const postWrapper = join(hooksDir, "PostToolUse");
|
|
551
|
+
const preContent = `#!/usr/bin/env node
|
|
552
|
+
require(${JSON.stringify(destPre)});
|
|
553
|
+
`;
|
|
554
|
+
const postContent = `#!/usr/bin/env node
|
|
555
|
+
require(${JSON.stringify(destPost)});
|
|
556
|
+
`;
|
|
557
|
+
await writeFile(preWrapper, preContent, { encoding: "utf8", mode: 493 });
|
|
558
|
+
await writeFile(postWrapper, postContent, { encoding: "utf8", mode: 493 });
|
|
559
|
+
}
|
|
560
|
+
async function promptClineIntegrationMode(ask) {
|
|
561
|
+
process.stderr.write("\n" + style.bold("Cline integration") + "\n");
|
|
562
|
+
process.stderr.write(
|
|
563
|
+
" " + style.violet("1") + ". Native plugin (recommended) - Cline Hooks see every file, terminal, browser, and MCP action\n"
|
|
564
|
+
);
|
|
565
|
+
process.stderr.write(
|
|
566
|
+
" " + style.violet("2") + ". Hosted proxy - govern MCP traffic only (paste proxy URL into Cline MCP settings)\n"
|
|
567
|
+
);
|
|
568
|
+
let choice = 0;
|
|
569
|
+
while (choice === 0) {
|
|
570
|
+
const input = await ask("Choose integration (1-2): ");
|
|
571
|
+
const num = parseInt(input.trim(), 10);
|
|
572
|
+
if (num === 1) choice = 1;
|
|
573
|
+
if (num === 2) choice = 2;
|
|
574
|
+
}
|
|
575
|
+
return choice === 1 ? "native" : "hosted";
|
|
576
|
+
}
|
|
577
|
+
function getGeminiCliHooksInstallDir() {
|
|
578
|
+
return join(homedir(), ".multicorn", "gemini-cli-hooks");
|
|
579
|
+
}
|
|
580
|
+
function getGeminiCliSettingsPath() {
|
|
581
|
+
return join(homedir(), ".gemini", "settings.json");
|
|
582
|
+
}
|
|
583
|
+
function geminiInnerHooksReferenceShield(inner, multicornName) {
|
|
584
|
+
if (!Array.isArray(inner)) return false;
|
|
585
|
+
for (const h of inner) {
|
|
586
|
+
if (typeof h !== "object" || h === null) continue;
|
|
587
|
+
const rec = h;
|
|
588
|
+
if (rec["name"] === multicornName) return true;
|
|
589
|
+
const cmd = rec["command"];
|
|
590
|
+
if (typeof cmd === "string" && cmd.includes("gemini-cli-hooks")) return true;
|
|
591
|
+
}
|
|
592
|
+
return false;
|
|
593
|
+
}
|
|
594
|
+
function geminiHookEventsReferenceShield(arr) {
|
|
595
|
+
if (!Array.isArray(arr)) return false;
|
|
596
|
+
for (const entry of arr) {
|
|
597
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
598
|
+
const hooks = entry["hooks"];
|
|
599
|
+
if (geminiInnerHooksReferenceShield(hooks, "multicorn-shield") || geminiInnerHooksReferenceShield(hooks, "multicorn-shield-log")) {
|
|
600
|
+
return true;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return false;
|
|
604
|
+
}
|
|
605
|
+
function geminiSettingsHasMulticornHooks(hooks) {
|
|
606
|
+
if (hooks === null || typeof hooks !== "object" || Array.isArray(hooks)) return false;
|
|
607
|
+
const h = hooks;
|
|
608
|
+
return geminiHookEventsReferenceShield(h["BeforeTool"]) || geminiHookEventsReferenceShield(h["AfterTool"]);
|
|
609
|
+
}
|
|
610
|
+
function geminiFilterInnerHooks(inner) {
|
|
611
|
+
if (!Array.isArray(inner)) return [];
|
|
612
|
+
return inner.filter((h) => {
|
|
613
|
+
if (typeof h !== "object" || h === null) return true;
|
|
614
|
+
const rec = h;
|
|
615
|
+
if (rec["name"] === "multicorn-shield" || rec["name"] === "multicorn-shield-log") return false;
|
|
616
|
+
const cmd = rec["command"];
|
|
617
|
+
if (typeof cmd === "string" && cmd.includes("gemini-cli-hooks")) return false;
|
|
618
|
+
return true;
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
function geminiStripMatcherGroups(arr) {
|
|
622
|
+
if (!Array.isArray(arr)) return [];
|
|
623
|
+
const out = [];
|
|
624
|
+
for (const entry of arr) {
|
|
625
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
626
|
+
const e = entry;
|
|
627
|
+
const filtered = geminiFilterInnerHooks(e["hooks"]);
|
|
628
|
+
if (filtered.length > 0) {
|
|
629
|
+
out.push({ ...e, hooks: filtered });
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return out;
|
|
633
|
+
}
|
|
634
|
+
function geminiStripMulticornHookEntries(hooks) {
|
|
635
|
+
const out = { ...hooks };
|
|
636
|
+
out["BeforeTool"] = geminiStripMatcherGroups(out["BeforeTool"]);
|
|
637
|
+
out["AfterTool"] = geminiStripMatcherGroups(out["AfterTool"]);
|
|
638
|
+
return out;
|
|
639
|
+
}
|
|
640
|
+
async function installGeminiCliNativeHooks(ask) {
|
|
641
|
+
const root = multicornShieldPackageRoot();
|
|
642
|
+
const srcBefore = join(root, "plugins", "gemini-cli", "hooks", "scripts", "before-tool.cjs");
|
|
643
|
+
const srcAfter = join(root, "plugins", "gemini-cli", "hooks", "scripts", "after-tool.cjs");
|
|
644
|
+
const srcShared = join(root, "plugins", "gemini-cli", "hooks", "scripts", "shared.cjs");
|
|
645
|
+
if (!existsSync(srcBefore) || !existsSync(srcAfter) || !existsSync(srcShared)) {
|
|
646
|
+
throw new Error(
|
|
647
|
+
`Could not find Shield Gemini CLI hook scripts at ${srcBefore}. If you use npm, install the latest multicorn-shield package.`
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
const geminiConfigDir = join(homedir(), ".gemini");
|
|
651
|
+
if (!isExistingDirectory(geminiConfigDir)) {
|
|
652
|
+
process.stderr.write(
|
|
653
|
+
style.yellow("\u26A0") + " Gemini CLI does not appear to be installed (~/.gemini/ not found).\n\n"
|
|
654
|
+
);
|
|
655
|
+
process.stderr.write("Install Gemini CLI first:\n");
|
|
656
|
+
process.stderr.write(" " + style.cyan("npm install -g @google/gemini-cli") + "\n\n");
|
|
657
|
+
process.stderr.write("Then run this wizard again:\n");
|
|
658
|
+
process.stderr.write(" " + style.cyan("npx multicorn-shield init") + "\n");
|
|
659
|
+
throw new NativePluginPrerequisiteMissingError();
|
|
660
|
+
}
|
|
661
|
+
const installDir = getGeminiCliHooksInstallDir();
|
|
662
|
+
await mkdir(installDir, { recursive: true });
|
|
663
|
+
const destBefore = join(installDir, "before-tool.cjs");
|
|
664
|
+
const destAfter = join(installDir, "after-tool.cjs");
|
|
665
|
+
const destShared = join(installDir, "shared.cjs");
|
|
666
|
+
await copyFile(srcBefore, destBefore);
|
|
667
|
+
await copyFile(srcAfter, destAfter);
|
|
668
|
+
await copyFile(srcShared, destShared);
|
|
669
|
+
const mode = 493;
|
|
670
|
+
await chmod(destBefore, mode);
|
|
671
|
+
await chmod(destAfter, mode);
|
|
672
|
+
await chmod(destShared, mode);
|
|
673
|
+
const settingsPath = getGeminiCliSettingsPath();
|
|
674
|
+
let existing = {};
|
|
675
|
+
try {
|
|
676
|
+
const rawText = await readFile(settingsPath, "utf8");
|
|
677
|
+
const parsed = JSON.parse(rawText);
|
|
678
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
679
|
+
existing = parsed;
|
|
680
|
+
}
|
|
681
|
+
} catch (err) {
|
|
682
|
+
if (isErrnoException(err) && err.code === "ENOENT") {
|
|
683
|
+
existing = {};
|
|
684
|
+
} else {
|
|
685
|
+
process.stderr.write(
|
|
686
|
+
style.yellow("\u26A0") + ` Could not parse ${settingsPath}. Create valid JSON or remove the file, then run init again.
|
|
687
|
+
`
|
|
688
|
+
);
|
|
689
|
+
throw new Error(`Invalid Gemini CLI settings at ${settingsPath}`);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
const hooksRaw = existing["hooks"];
|
|
693
|
+
const hooksObj = typeof hooksRaw === "object" && hooksRaw !== null && !Array.isArray(hooksRaw) ? hooksRaw : {};
|
|
694
|
+
if (geminiSettingsHasMulticornHooks(hooksObj)) {
|
|
695
|
+
const answer = await ask(
|
|
696
|
+
"Existing Multicorn Shield hooks were found in ~/.gemini/settings.json. Overwrite? (Y/n) "
|
|
697
|
+
);
|
|
698
|
+
if (answer.trim().toLowerCase() === "n") {
|
|
699
|
+
throw new Error("Installation cancelled: existing Shield hooks left unchanged.");
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
const cleaned = geminiStripMulticornHookEntries({ ...hooksObj });
|
|
703
|
+
const beforeArr = Array.isArray(cleaned["BeforeTool"]) ? [...cleaned["BeforeTool"]] : [];
|
|
704
|
+
const afterArr = Array.isArray(cleaned["AfterTool"]) ? [...cleaned["AfterTool"]] : [];
|
|
705
|
+
const beforeCmd = `node ${destBefore}`;
|
|
706
|
+
const afterCmd = `node ${destAfter}`;
|
|
707
|
+
beforeArr.push({
|
|
708
|
+
matcher: ".*",
|
|
709
|
+
hooks: [
|
|
710
|
+
{
|
|
711
|
+
type: "command",
|
|
712
|
+
name: "multicorn-shield",
|
|
713
|
+
command: beforeCmd,
|
|
714
|
+
timeout: 6e4
|
|
715
|
+
}
|
|
716
|
+
]
|
|
717
|
+
});
|
|
718
|
+
afterArr.push({
|
|
719
|
+
matcher: ".*",
|
|
720
|
+
hooks: [
|
|
721
|
+
{
|
|
722
|
+
type: "command",
|
|
723
|
+
name: "multicorn-shield-log",
|
|
724
|
+
command: afterCmd,
|
|
725
|
+
timeout: 1e4
|
|
726
|
+
}
|
|
727
|
+
]
|
|
728
|
+
});
|
|
729
|
+
existing["hooks"] = {
|
|
730
|
+
...cleaned,
|
|
731
|
+
BeforeTool: beforeArr,
|
|
732
|
+
AfterTool: afterArr
|
|
733
|
+
};
|
|
734
|
+
await mkdir(dirname(settingsPath), { recursive: true });
|
|
735
|
+
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + "\n", "utf8");
|
|
736
|
+
}
|
|
737
|
+
async function promptGeminiCliIntegrationMode(ask) {
|
|
738
|
+
process.stderr.write("\n" + style.bold("Gemini CLI integration") + "\n");
|
|
739
|
+
process.stderr.write(
|
|
740
|
+
" " + style.violet("1") + ". Native plugin (recommended) - Gemini CLI Hooks see every file, terminal, web, and MCP action\n"
|
|
741
|
+
);
|
|
742
|
+
process.stderr.write(
|
|
743
|
+
" " + style.violet("2") + ". Hosted proxy - govern MCP traffic only (paste proxy URL into Gemini CLI settings)\n"
|
|
744
|
+
);
|
|
745
|
+
let choice = 0;
|
|
746
|
+
while (choice === 0) {
|
|
747
|
+
const input = await ask("Choose integration (1-2): ");
|
|
748
|
+
const num = parseInt(input.trim(), 10);
|
|
749
|
+
if (num === 1) choice = 1;
|
|
750
|
+
if (num === 2) choice = 2;
|
|
751
|
+
}
|
|
752
|
+
return choice === 1 ? "native" : "hosted";
|
|
753
|
+
}
|
|
12
754
|
function getClaudeDesktopConfigPath() {
|
|
13
755
|
switch (process.platform) {
|
|
14
756
|
case "win32":
|
|
@@ -17,17 +759,2043 @@ function getClaudeDesktopConfigPath() {
|
|
|
17
759
|
"Claude",
|
|
18
760
|
"claude_desktop_config.json"
|
|
19
761
|
);
|
|
20
|
-
case "linux":
|
|
21
|
-
return join(homedir(), ".config", "Claude", "claude_desktop_config.json");
|
|
22
|
-
default:
|
|
23
|
-
return join(
|
|
24
|
-
homedir(),
|
|
25
|
-
"Library",
|
|
26
|
-
"Application Support",
|
|
27
|
-
"Claude",
|
|
28
|
-
"claude_desktop_config.json"
|
|
762
|
+
case "linux":
|
|
763
|
+
return join(homedir(), ".config", "Claude", "claude_desktop_config.json");
|
|
764
|
+
default:
|
|
765
|
+
return join(
|
|
766
|
+
homedir(),
|
|
767
|
+
"Library",
|
|
768
|
+
"Application Support",
|
|
769
|
+
"Claude",
|
|
770
|
+
"claude_desktop_config.json"
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
var PLATFORM_LABELS = [
|
|
775
|
+
"OpenClaw",
|
|
776
|
+
"Claude Code",
|
|
777
|
+
"Cursor",
|
|
778
|
+
"Windsurf",
|
|
779
|
+
"Cline",
|
|
780
|
+
"Claude Desktop",
|
|
781
|
+
"Gemini CLI",
|
|
782
|
+
"Local MCP / Other"
|
|
783
|
+
];
|
|
784
|
+
var PLATFORM_BY_SELECTION = {
|
|
785
|
+
1: "openclaw",
|
|
786
|
+
2: "claude-code",
|
|
787
|
+
3: "cursor",
|
|
788
|
+
4: "windsurf",
|
|
789
|
+
5: "cline",
|
|
790
|
+
6: "claude-desktop",
|
|
791
|
+
7: "gemini-cli",
|
|
792
|
+
8: "other-mcp"
|
|
793
|
+
};
|
|
794
|
+
var DEFAULT_AGENT_NAMES = {
|
|
795
|
+
openclaw: "my-openclaw-agent",
|
|
796
|
+
"claude-code": "my-claude-code-agent",
|
|
797
|
+
cursor: "my-cursor-agent",
|
|
798
|
+
windsurf: "my-windsurf-agent",
|
|
799
|
+
cline: "my-cline-agent",
|
|
800
|
+
"claude-desktop": "my-claude-desktop-agent",
|
|
801
|
+
"gemini-cli": "my-gemini-cli-agent"
|
|
802
|
+
};
|
|
803
|
+
async function promptPlatformSelection(ask) {
|
|
804
|
+
process.stderr.write(
|
|
805
|
+
"\n" + style.bold(style.violet("Which platform are you connecting?")) + "\n"
|
|
806
|
+
);
|
|
807
|
+
const connectedFlags = [
|
|
808
|
+
await isOpenClawConnected(),
|
|
809
|
+
isClaudeCodeConnected(),
|
|
810
|
+
await isCursorConnected(),
|
|
811
|
+
await isWindsurfConnected()
|
|
812
|
+
];
|
|
813
|
+
for (let i = 0; i < PLATFORM_LABELS.length; i++) {
|
|
814
|
+
const marker = i < connectedFlags.length && connectedFlags[i] ? " " + style.dim("\u25CF detected locally") : "";
|
|
815
|
+
process.stderr.write(
|
|
816
|
+
` ${style.violet(String(i + 1))}. ${PLATFORM_LABELS[i] ?? ""}${marker}
|
|
817
|
+
`
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
process.stderr.write(
|
|
821
|
+
style.dim(" Pick 8 if you want to wrap a local MCP server with multicorn-shield --wrap.") + "\n"
|
|
822
|
+
);
|
|
823
|
+
let selection = 0;
|
|
824
|
+
while (selection === 0) {
|
|
825
|
+
const input = await ask("Select (1-8): ");
|
|
826
|
+
const num = parseInt(input.trim(), 10);
|
|
827
|
+
if (num >= 1 && num <= 8) {
|
|
828
|
+
selection = num;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
return selection;
|
|
832
|
+
}
|
|
833
|
+
async function promptWindsurfIntegrationMode(ask) {
|
|
834
|
+
process.stderr.write("\n" + style.bold("Windsurf integration") + "\n");
|
|
835
|
+
process.stderr.write(
|
|
836
|
+
" " + style.violet("1") + ". Native plugin (recommended) - Cascade Hooks see every file, terminal, and MCP action\n"
|
|
837
|
+
);
|
|
838
|
+
process.stderr.write(
|
|
839
|
+
" " + style.violet("2") + ". Hosted proxy - govern MCP traffic only (paste proxy URL into mcp_config)\n"
|
|
840
|
+
);
|
|
841
|
+
let choice = 0;
|
|
842
|
+
while (choice === 0) {
|
|
843
|
+
const input = await ask("Choose integration (1-2): ");
|
|
844
|
+
const num = parseInt(input.trim(), 10);
|
|
845
|
+
if (num === 1) choice = 1;
|
|
846
|
+
if (num === 2) choice = 2;
|
|
847
|
+
}
|
|
848
|
+
return choice === 1 ? "native" : "hosted";
|
|
849
|
+
}
|
|
850
|
+
async function promptAgentName(ask, platform) {
|
|
851
|
+
const defaultAgentName = DEFAULT_AGENT_NAMES[platform] ?? "my-agent";
|
|
852
|
+
let agentName = "";
|
|
853
|
+
while (agentName.length === 0) {
|
|
854
|
+
const input = await ask(
|
|
855
|
+
`
|
|
856
|
+
What would you like to call this agent? ${style.dim(`(${defaultAgentName})`)} `
|
|
857
|
+
);
|
|
858
|
+
const raw = input.trim().length > 0 ? input.trim() : defaultAgentName;
|
|
859
|
+
const transformed = normalizeAgentName(raw);
|
|
860
|
+
if (transformed.length === 0) {
|
|
861
|
+
process.stderr.write(
|
|
862
|
+
style.red("Agent name must contain letters or numbers. Please try again.") + "\n"
|
|
863
|
+
);
|
|
864
|
+
continue;
|
|
865
|
+
}
|
|
866
|
+
if (transformed !== raw) {
|
|
867
|
+
process.stderr.write(style.yellow("Agent name set to: ") + style.cyan(transformed) + "\n");
|
|
868
|
+
}
|
|
869
|
+
agentName = transformed;
|
|
870
|
+
}
|
|
871
|
+
return agentName;
|
|
872
|
+
}
|
|
873
|
+
async function promptProxyConfig(ask, agentName) {
|
|
874
|
+
let targetUrl = "";
|
|
875
|
+
while (targetUrl.length === 0) {
|
|
876
|
+
process.stderr.write(
|
|
877
|
+
"\n" + style.bold("Target MCP server URL:") + "\n" + style.dim(
|
|
878
|
+
"The URL of the MCP server you want Shield to protect. Example: https://your-server.example.com/mcp"
|
|
879
|
+
) + "\n"
|
|
880
|
+
);
|
|
881
|
+
const input = await ask("URL: ");
|
|
882
|
+
if (input.trim().length === 0) {
|
|
883
|
+
process.stderr.write(style.red("MCP server URL is required.") + "\n");
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
try {
|
|
887
|
+
new URL(input.trim());
|
|
888
|
+
} catch {
|
|
889
|
+
process.stderr.write(
|
|
890
|
+
style.red(
|
|
891
|
+
"\u2717 That does not look like a valid URL. Please enter a full URL including the scheme (e.g. https://your-server.example.com/mcp)."
|
|
892
|
+
) + "\n"
|
|
893
|
+
);
|
|
894
|
+
continue;
|
|
895
|
+
}
|
|
896
|
+
targetUrl = input.trim();
|
|
897
|
+
}
|
|
898
|
+
const defaultShortName = normalizeAgentName(agentName) || "shield-mcp";
|
|
899
|
+
const shortNameInput = await ask(
|
|
900
|
+
`
|
|
901
|
+
Short name (a nickname for this connection, used in your proxy URL): ${style.dim(`(${defaultShortName})`)} `
|
|
902
|
+
);
|
|
903
|
+
const shortName = shortNameInput.trim().length > 0 ? normalizeAgentName(shortNameInput.trim()) || defaultShortName : defaultShortName;
|
|
904
|
+
return { targetUrl, shortName };
|
|
905
|
+
}
|
|
906
|
+
async function createProxyConfig(baseUrl, apiKey, agentName, targetUrl, serverName, platform) {
|
|
907
|
+
let response;
|
|
908
|
+
try {
|
|
909
|
+
response = await fetch(`${baseUrl}/api/v1/proxy/config`, {
|
|
910
|
+
method: "POST",
|
|
911
|
+
headers: {
|
|
912
|
+
"Content-Type": "application/json",
|
|
913
|
+
"X-Multicorn-Key": apiKey
|
|
914
|
+
},
|
|
915
|
+
body: JSON.stringify({
|
|
916
|
+
server_name: serverName,
|
|
917
|
+
target_url: targetUrl,
|
|
918
|
+
platform,
|
|
919
|
+
agent_name: agentName
|
|
920
|
+
}),
|
|
921
|
+
signal: AbortSignal.timeout(1e4)
|
|
922
|
+
});
|
|
923
|
+
} catch (error) {
|
|
924
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
925
|
+
throw new Error(`Failed to create proxy config: ${detail}`);
|
|
926
|
+
}
|
|
927
|
+
if (!response.ok) {
|
|
928
|
+
let errorMsg = `Shield API returned an error (HTTP ${String(response.status)}). Check your agent name and target URL, then try again.`;
|
|
929
|
+
try {
|
|
930
|
+
const errBody = await response.json();
|
|
931
|
+
const errObj = errBody["error"];
|
|
932
|
+
if (typeof errObj?.["message"] === "string") {
|
|
933
|
+
errorMsg = stripAnsi(errObj["message"]);
|
|
934
|
+
} else if (typeof errBody["message"] === "string") {
|
|
935
|
+
errorMsg = stripAnsi(errBody["message"]);
|
|
936
|
+
} else if (typeof errBody["detail"] === "string") {
|
|
937
|
+
errorMsg = stripAnsi(errBody["detail"]);
|
|
938
|
+
}
|
|
939
|
+
} catch {
|
|
940
|
+
}
|
|
941
|
+
throw new Error(errorMsg);
|
|
942
|
+
}
|
|
943
|
+
const envelope = await response.json();
|
|
944
|
+
const data = envelope["data"];
|
|
945
|
+
return typeof data?.["proxy_url"] === "string" ? data["proxy_url"] : "";
|
|
946
|
+
}
|
|
947
|
+
function printPlatformSnippet(platform, routingToken, shortName, apiKey) {
|
|
948
|
+
const usesInlineKey = platform === "cursor" || platform === "claude-desktop" || platform === "windsurf" || platform === "cline" || platform === "gemini-cli";
|
|
949
|
+
const authHeader = usesInlineKey ? `Bearer ${apiKey}` : "Bearer YOUR_SHIELD_API_KEY";
|
|
950
|
+
const urlKey = platform === "windsurf" ? "serverUrl" : platform === "gemini-cli" ? "httpUrl" : "url";
|
|
951
|
+
const mcpSnippet = JSON.stringify(
|
|
952
|
+
{
|
|
953
|
+
mcpServers: {
|
|
954
|
+
[shortName]: {
|
|
955
|
+
[urlKey]: routingToken,
|
|
956
|
+
headers: {
|
|
957
|
+
Authorization: authHeader
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
},
|
|
962
|
+
null,
|
|
963
|
+
2
|
|
964
|
+
);
|
|
965
|
+
if (platform === "openclaw") {
|
|
966
|
+
process.stderr.write("\n" + style.dim("Add this to your OpenClaw agent config:") + "\n\n");
|
|
967
|
+
} else if (platform === "claude-code") {
|
|
968
|
+
process.stderr.write("\n" + style.dim("Add this to your Claude Code MCP config:") + "\n\n");
|
|
969
|
+
} else if (platform === "claude-desktop") {
|
|
970
|
+
process.stderr.write("\n" + style.dim(`Add this to ${getClaudeDesktopConfigPath()}:`) + "\n\n");
|
|
971
|
+
} else if (platform === "windsurf") {
|
|
972
|
+
process.stderr.write(
|
|
973
|
+
"\n" + style.dim("Add this to ~/.codeium/windsurf/mcp_config.json:") + "\n\n"
|
|
974
|
+
);
|
|
975
|
+
} else if (platform === "cline") {
|
|
976
|
+
process.stderr.write("\n" + style.dim("Add this to your Cline MCP settings file:") + "\n");
|
|
977
|
+
process.stderr.write(
|
|
978
|
+
style.dim(
|
|
979
|
+
" macOS: ~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json"
|
|
980
|
+
) + "\n"
|
|
981
|
+
);
|
|
982
|
+
process.stderr.write(
|
|
983
|
+
style.dim(
|
|
984
|
+
" Windows: %APPDATA%\\Code\\User\\globalStorage\\saoudrizwan.claude-dev\\settings\\cline_mcp_settings.json"
|
|
985
|
+
) + "\n"
|
|
986
|
+
);
|
|
987
|
+
process.stderr.write(
|
|
988
|
+
style.dim(
|
|
989
|
+
" Linux: ~/.config/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json"
|
|
990
|
+
) + "\n\n"
|
|
991
|
+
);
|
|
992
|
+
} else if (platform === "gemini-cli") {
|
|
993
|
+
process.stderr.write(
|
|
994
|
+
"\n" + style.dim(
|
|
995
|
+
"Add this to ~/.gemini/settings.json (create the file if it does not exist). For project-specific config, use .gemini/settings.json in your project root. Restart Gemini CLI after saving. Run /mcp to verify the server is connected."
|
|
996
|
+
) + "\n\n"
|
|
997
|
+
);
|
|
998
|
+
} else {
|
|
999
|
+
process.stderr.write("\n" + style.dim("Add this to ~/.cursor/mcp.json:") + "\n\n");
|
|
1000
|
+
}
|
|
1001
|
+
process.stderr.write(style.cyan(mcpSnippet) + "\n\n");
|
|
1002
|
+
if (!usesInlineKey) {
|
|
1003
|
+
process.stderr.write(
|
|
1004
|
+
style.dim(
|
|
1005
|
+
"Replace YOUR_SHIELD_API_KEY with your API key. Find it in Settings > API keys at https://app.multicorn.ai/settings#api-keys"
|
|
1006
|
+
) + "\n"
|
|
1007
|
+
);
|
|
1008
|
+
}
|
|
1009
|
+
if (platform === "cursor") {
|
|
1010
|
+
process.stderr.write(
|
|
1011
|
+
style.dim(
|
|
1012
|
+
"Then restart Cursor and check Settings > Tools & MCPs for a green status indicator."
|
|
1013
|
+
) + "\n"
|
|
1014
|
+
);
|
|
1015
|
+
process.stderr.write(
|
|
1016
|
+
style.dim(
|
|
1017
|
+
`Ask Cursor to use your MCP server by its short name. For example: "use the ${shortName} tool to list files in /tmp"`
|
|
1018
|
+
) + "\n"
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
if (platform === "claude-desktop") {
|
|
1022
|
+
process.stderr.write(style.dim("Then restart Claude Desktop to load the MCP server.") + "\n");
|
|
1023
|
+
}
|
|
1024
|
+
if (platform === "cline") {
|
|
1025
|
+
process.stderr.write(
|
|
1026
|
+
style.dim(
|
|
1027
|
+
"After pasting, restart Cline or reload the VS Code window. Cline will discover the Shield tools automatically."
|
|
1028
|
+
) + "\n"
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
1031
|
+
if (platform === "windsurf") {
|
|
1032
|
+
process.stderr.write(style.dim("Then restart Windsurf (Cmd/Ctrl+Q, then reopen).") + "\n");
|
|
1033
|
+
process.stderr.write(
|
|
1034
|
+
style.dim(
|
|
1035
|
+
"Open the Cascade panel and verify the server appears with a green status indicator."
|
|
1036
|
+
) + "\n"
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
var DEFAULT_SHIELD_API_BASE_URL = "https://api.multicorn.ai";
|
|
1041
|
+
async function runInit(explicitBaseUrl) {
|
|
1042
|
+
if (!process.stdin.isTTY) {
|
|
1043
|
+
process.stderr.write(
|
|
1044
|
+
style.red("Error: interactive terminal required. Cannot run init with piped input.") + "\n"
|
|
1045
|
+
);
|
|
1046
|
+
process.exit(1);
|
|
1047
|
+
}
|
|
1048
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
1049
|
+
const ask = (question) => new Promise((resolve) => {
|
|
1050
|
+
rl.question(question, resolve);
|
|
1051
|
+
});
|
|
1052
|
+
process.stderr.write("\n" + BANNER + "\n");
|
|
1053
|
+
process.stderr.write(style.dim("Agent governance for the AI era") + "\n\n");
|
|
1054
|
+
process.stderr.write(style.bold(style.violet("Multicorn Shield proxy setup")) + "\n\n");
|
|
1055
|
+
process.stderr.write(
|
|
1056
|
+
style.dim("Get your API key at https://app.multicorn.ai/settings#api-keys") + "\n\n"
|
|
1057
|
+
);
|
|
1058
|
+
const existing = await loadConfig().catch(() => null);
|
|
1059
|
+
let resolvedBaseUrl;
|
|
1060
|
+
if (explicitBaseUrl !== void 0 && explicitBaseUrl.trim().length > 0) {
|
|
1061
|
+
resolvedBaseUrl = explicitBaseUrl.trim();
|
|
1062
|
+
} else if (existing !== null && existing.baseUrl.length > 0) {
|
|
1063
|
+
resolvedBaseUrl = existing.baseUrl;
|
|
1064
|
+
} else {
|
|
1065
|
+
const fromFile = await readBaseUrlFromConfig();
|
|
1066
|
+
if (fromFile !== void 0 && fromFile.length > 0) {
|
|
1067
|
+
resolvedBaseUrl = fromFile;
|
|
1068
|
+
} else {
|
|
1069
|
+
const envBaseUrl = process.env["MULTICORN_BASE_URL"];
|
|
1070
|
+
resolvedBaseUrl = envBaseUrl !== void 0 && envBaseUrl.trim().length > 0 ? envBaseUrl.trim() : DEFAULT_SHIELD_API_BASE_URL;
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
if (!isAllowedShieldApiBaseUrl(resolvedBaseUrl)) {
|
|
1074
|
+
process.stderr.write(
|
|
1075
|
+
style.red(
|
|
1076
|
+
"Base URL must use HTTPS (or http://localhost for local development). Received a non-HTTPS URL from config. Use --base-url to override."
|
|
1077
|
+
) + "\n"
|
|
1078
|
+
);
|
|
1079
|
+
rl.close();
|
|
1080
|
+
return null;
|
|
1081
|
+
}
|
|
1082
|
+
let apiKey = "";
|
|
1083
|
+
if (existing !== null && existing.apiKey.startsWith("mcs_") && existing.apiKey.length >= 8) {
|
|
1084
|
+
const masked = "mcs_..." + existing.apiKey.slice(-4);
|
|
1085
|
+
process.stderr.write("Found existing API key: " + style.cyan(masked) + "\n");
|
|
1086
|
+
const answer = await ask("Use this key? (Y/n) ");
|
|
1087
|
+
if (answer.trim().toLowerCase() !== "n") {
|
|
1088
|
+
apiKey = existing.apiKey;
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
while (apiKey.length === 0) {
|
|
1092
|
+
const input = await ask("API key (starts with mcs_): ");
|
|
1093
|
+
const key = input.trim();
|
|
1094
|
+
if (key.length === 0) {
|
|
1095
|
+
process.stderr.write(style.red("API key is required.") + "\n");
|
|
1096
|
+
continue;
|
|
1097
|
+
}
|
|
1098
|
+
const spinner = withSpinner("Validating key...");
|
|
1099
|
+
let result;
|
|
1100
|
+
try {
|
|
1101
|
+
result = await validateApiKey(key, resolvedBaseUrl);
|
|
1102
|
+
} catch (error) {
|
|
1103
|
+
spinner.stop(false, "Validation failed");
|
|
1104
|
+
throw error;
|
|
1105
|
+
}
|
|
1106
|
+
if (!result.valid) {
|
|
1107
|
+
spinner.stop(false, result.error ?? "Validation failed. Try again.");
|
|
1108
|
+
continue;
|
|
1109
|
+
}
|
|
1110
|
+
spinner.stop(true, "Key validated");
|
|
1111
|
+
apiKey = key;
|
|
1112
|
+
}
|
|
1113
|
+
const configuredAgents = [];
|
|
1114
|
+
let currentAgents = collectAgentsFromConfig(existing);
|
|
1115
|
+
let lastConfig = {
|
|
1116
|
+
apiKey,
|
|
1117
|
+
baseUrl: resolvedBaseUrl,
|
|
1118
|
+
...currentAgents.length > 0 ? {
|
|
1119
|
+
agents: currentAgents,
|
|
1120
|
+
defaultAgent: existing !== null && typeof existing.defaultAgent === "string" && existing.defaultAgent.length > 0 ? existing.defaultAgent : currentAgents[currentAgents.length - 1]?.name ?? ""
|
|
1121
|
+
} : {}
|
|
1122
|
+
};
|
|
1123
|
+
let configuring = true;
|
|
1124
|
+
while (configuring) {
|
|
1125
|
+
let postSaveNativeSkipNote = null;
|
|
1126
|
+
const selection = await promptPlatformSelection(ask);
|
|
1127
|
+
const selectedPlatform = PLATFORM_BY_SELECTION[selection] ?? "cursor";
|
|
1128
|
+
const selectedLabel = PLATFORM_LABELS[selection - 1] ?? "Cursor";
|
|
1129
|
+
if (selection === 8) {
|
|
1130
|
+
const raw = existing !== null ? { ...existing } : {};
|
|
1131
|
+
raw["apiKey"] = apiKey;
|
|
1132
|
+
raw["baseUrl"] = resolvedBaseUrl;
|
|
1133
|
+
delete raw["agentName"];
|
|
1134
|
+
delete raw["platform"];
|
|
1135
|
+
lastConfig = raw;
|
|
1136
|
+
try {
|
|
1137
|
+
await saveConfig(lastConfig);
|
|
1138
|
+
process.stderr.write(
|
|
1139
|
+
style.green("\u2713") + ` Config saved to ${style.cyan(CONFIG_PATH)}
|
|
1140
|
+
`
|
|
1141
|
+
);
|
|
1142
|
+
process.stderr.write(
|
|
1143
|
+
"\n" + style.bold("Try it:") + " " + style.cyan(
|
|
1144
|
+
"npx multicorn-shield --wrap npx @modelcontextprotocol/server-filesystem /tmp"
|
|
1145
|
+
) + "\n"
|
|
1146
|
+
);
|
|
1147
|
+
} catch (error) {
|
|
1148
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1149
|
+
process.stderr.write(style.red(`Failed to save config: ${detail}`) + "\n");
|
|
1150
|
+
}
|
|
1151
|
+
configuredAgents.push({
|
|
1152
|
+
selection,
|
|
1153
|
+
platform: selectedPlatform,
|
|
1154
|
+
platformLabel: selectedLabel,
|
|
1155
|
+
agentName: ""
|
|
1156
|
+
});
|
|
1157
|
+
const another2 = await ask("\nConnect another agent? (Y/n) ");
|
|
1158
|
+
if (another2.trim().toLowerCase() === "n") {
|
|
1159
|
+
configuring = false;
|
|
1160
|
+
}
|
|
1161
|
+
continue;
|
|
1162
|
+
}
|
|
1163
|
+
const existingForPlatform = currentAgents.find((a) => a.platform === selectedPlatform);
|
|
1164
|
+
if (existingForPlatform !== void 0) {
|
|
1165
|
+
process.stderr.write(
|
|
1166
|
+
`
|
|
1167
|
+
An agent for ${selectedLabel} already exists: ${style.cyan(existingForPlatform.name)}
|
|
1168
|
+
`
|
|
1169
|
+
);
|
|
1170
|
+
const replace = await ask("Replace it? (Y/n) ");
|
|
1171
|
+
if (replace.trim().toLowerCase() === "n") {
|
|
1172
|
+
const another2 = await ask("\nConnect another agent? (Y/n) ");
|
|
1173
|
+
if (another2.trim().toLowerCase() === "n") {
|
|
1174
|
+
configuring = false;
|
|
1175
|
+
}
|
|
1176
|
+
continue;
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
const agentName = await promptAgentName(ask, selectedPlatform);
|
|
1180
|
+
let setupSucceeded = false;
|
|
1181
|
+
if (selection === 1) {
|
|
1182
|
+
let detection;
|
|
1183
|
+
try {
|
|
1184
|
+
detection = await detectOpenClaw();
|
|
1185
|
+
} catch (error) {
|
|
1186
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1187
|
+
process.stderr.write(style.red("\u2717") + ` Failed to read OpenClaw config: ${detail}
|
|
1188
|
+
`);
|
|
1189
|
+
rl.close();
|
|
1190
|
+
return null;
|
|
1191
|
+
}
|
|
1192
|
+
if (detection.status === "not-found") {
|
|
1193
|
+
process.stderr.write(
|
|
1194
|
+
style.red("\u2717") + " OpenClaw is not installed. Install OpenClaw first, then run npx multicorn-shield init again.\n"
|
|
1195
|
+
);
|
|
1196
|
+
rl.close();
|
|
1197
|
+
return null;
|
|
1198
|
+
}
|
|
1199
|
+
if (detection.status === "parse-error") {
|
|
1200
|
+
process.stderr.write(
|
|
1201
|
+
style.red("\u2717") + " Could not update OpenClaw config. Set MULTICORN_API_KEY in ~/.openclaw/openclaw.json manually.\n"
|
|
1202
|
+
);
|
|
1203
|
+
}
|
|
1204
|
+
if (detection.status === "detected") {
|
|
1205
|
+
if (detection.version !== null) {
|
|
1206
|
+
process.stderr.write(
|
|
1207
|
+
style.green("\u2713") + ` OpenClaw detected ${style.dim(`(${detection.version})`)}
|
|
1208
|
+
`
|
|
1209
|
+
);
|
|
1210
|
+
if (isVersionAtLeast(detection.version, OPENCLAW_MIN_VERSION)) {
|
|
1211
|
+
process.stderr.write(
|
|
1212
|
+
style.green("\u2713") + " " + style.green("Version compatible") + "\n"
|
|
1213
|
+
);
|
|
1214
|
+
} else {
|
|
1215
|
+
process.stderr.write(
|
|
1216
|
+
style.yellow("\u26A0") + ` Shield has been tested with OpenClaw ${style.cyan(OPENCLAW_MIN_VERSION)} and above. Your version (${detection.version}) may work but is untested. We recommend upgrading to at least ${style.cyan(OPENCLAW_MIN_VERSION)}.
|
|
1217
|
+
`
|
|
1218
|
+
);
|
|
1219
|
+
const answer = await ask("Continue anyway? (y/N) ");
|
|
1220
|
+
if (answer.trim().toLowerCase() !== "y") {
|
|
1221
|
+
rl.close();
|
|
1222
|
+
return null;
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
} else {
|
|
1226
|
+
process.stderr.write(
|
|
1227
|
+
style.yellow("\u26A0") + " Could not detect OpenClaw version. Continuing anyway.\n"
|
|
1228
|
+
);
|
|
1229
|
+
}
|
|
1230
|
+
const spinner = withSpinner("Updating OpenClaw config...");
|
|
1231
|
+
try {
|
|
1232
|
+
const result = await updateOpenClawConfigIfPresent(apiKey, resolvedBaseUrl, agentName);
|
|
1233
|
+
if (result === "not-found") {
|
|
1234
|
+
spinner.stop(false, "OpenClaw config disappeared unexpectedly.");
|
|
1235
|
+
rl.close();
|
|
1236
|
+
return null;
|
|
1237
|
+
}
|
|
1238
|
+
if (result === "parse-error") {
|
|
1239
|
+
spinner.stop(
|
|
1240
|
+
false,
|
|
1241
|
+
"Could not update OpenClaw config. Set MULTICORN_API_KEY in ~/.openclaw/openclaw.json manually."
|
|
1242
|
+
);
|
|
1243
|
+
} else {
|
|
1244
|
+
spinner.stop(
|
|
1245
|
+
true,
|
|
1246
|
+
"OpenClaw config updated at " + style.cyan("~/.openclaw/openclaw.json")
|
|
1247
|
+
);
|
|
1248
|
+
}
|
|
1249
|
+
} catch (error) {
|
|
1250
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1251
|
+
spinner.stop(false, `Failed to update OpenClaw config: ${detail}`);
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
configuredAgents.push({
|
|
1255
|
+
selection,
|
|
1256
|
+
platform: selectedPlatform,
|
|
1257
|
+
platformLabel: selectedLabel,
|
|
1258
|
+
agentName
|
|
1259
|
+
});
|
|
1260
|
+
setupSucceeded = true;
|
|
1261
|
+
} else if (selection === 2) {
|
|
1262
|
+
process.stderr.write("\nTo connect Claude Code to Shield:\n\n");
|
|
1263
|
+
process.stderr.write(
|
|
1264
|
+
" " + style.bold("Step 1") + " - Add the Multicorn marketplace:\n " + style.cyan("claude plugin marketplace add Multicorn-AI/multicorn-shield") + "\n\n"
|
|
1265
|
+
);
|
|
1266
|
+
process.stderr.write(
|
|
1267
|
+
" " + style.bold("Step 2") + " - Install the plugin:\n " + style.cyan("claude plugin install multicorn-shield@multicorn-shield") + "\n\n"
|
|
1268
|
+
);
|
|
1269
|
+
process.stderr.write(
|
|
1270
|
+
style.dim("Requires Claude Code to be installed. Get it at https://code.claude.com") + "\n"
|
|
1271
|
+
);
|
|
1272
|
+
configuredAgents.push({
|
|
1273
|
+
selection,
|
|
1274
|
+
platform: selectedPlatform,
|
|
1275
|
+
platformLabel: selectedLabel,
|
|
1276
|
+
agentName
|
|
1277
|
+
});
|
|
1278
|
+
setupSucceeded = true;
|
|
1279
|
+
} else if (selection === 4) {
|
|
1280
|
+
const windsurfMode = await promptWindsurfIntegrationMode(ask);
|
|
1281
|
+
if (windsurfMode === "native") {
|
|
1282
|
+
try {
|
|
1283
|
+
await installWindsurfNativeHooks();
|
|
1284
|
+
process.stderr.write("\n" + style.bold("Shield Windsurf hooks installed") + "\n");
|
|
1285
|
+
process.stderr.write(
|
|
1286
|
+
style.dim("Scripts: ") + style.cyan(getWindsurfHooksInstallDir()) + "\n"
|
|
1287
|
+
);
|
|
1288
|
+
process.stderr.write(
|
|
1289
|
+
style.dim("Hooks config: ") + style.cyan(getWindsurfCascadeHooksJsonPath()) + "\n"
|
|
1290
|
+
);
|
|
1291
|
+
process.stderr.write(
|
|
1292
|
+
"\n" + style.dim(
|
|
1293
|
+
"The Shield hook runs with your user permissions. It intercepts Cascade actions to check permissions and log activity. Review the scripts under "
|
|
1294
|
+
) + style.cyan("~/.multicorn/windsurf-hooks") + style.dim(" if that is a concern.") + "\n\n"
|
|
1295
|
+
);
|
|
1296
|
+
process.stderr.write(
|
|
1297
|
+
style.dim("Restart Windsurf (quit fully, then reopen) so hooks load.") + "\n"
|
|
1298
|
+
);
|
|
1299
|
+
configuredAgents.push({
|
|
1300
|
+
selection,
|
|
1301
|
+
platform: selectedPlatform,
|
|
1302
|
+
platformLabel: selectedLabel,
|
|
1303
|
+
agentName,
|
|
1304
|
+
windsurfIntegration: "native"
|
|
1305
|
+
});
|
|
1306
|
+
setupSucceeded = true;
|
|
1307
|
+
} catch (error) {
|
|
1308
|
+
if (error instanceof NativePluginPrerequisiteMissingError) {
|
|
1309
|
+
postSaveNativeSkipNote = nativePluginSkippedSaveNote(
|
|
1310
|
+
"npx multicorn-shield init",
|
|
1311
|
+
"Windsurf"
|
|
1312
|
+
);
|
|
1313
|
+
configuredAgents.push({
|
|
1314
|
+
selection,
|
|
1315
|
+
platform: selectedPlatform,
|
|
1316
|
+
platformLabel: selectedLabel,
|
|
1317
|
+
agentName
|
|
1318
|
+
});
|
|
1319
|
+
setupSucceeded = true;
|
|
1320
|
+
} else {
|
|
1321
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1322
|
+
process.stderr.write(style.red("\u2717 ") + detail + "\n");
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
} else {
|
|
1326
|
+
const { targetUrl, shortName } = await promptProxyConfig(ask, agentName);
|
|
1327
|
+
let proxyUrl = "";
|
|
1328
|
+
let created = false;
|
|
1329
|
+
while (!created) {
|
|
1330
|
+
const spinner = withSpinner("Creating proxy config...");
|
|
1331
|
+
try {
|
|
1332
|
+
proxyUrl = await createProxyConfig(
|
|
1333
|
+
resolvedBaseUrl,
|
|
1334
|
+
apiKey,
|
|
1335
|
+
agentName,
|
|
1336
|
+
targetUrl,
|
|
1337
|
+
shortName,
|
|
1338
|
+
selectedPlatform
|
|
1339
|
+
);
|
|
1340
|
+
spinner.stop(true, "Proxy config created!");
|
|
1341
|
+
created = true;
|
|
1342
|
+
} catch (error) {
|
|
1343
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1344
|
+
spinner.stop(false, detail);
|
|
1345
|
+
const retry = await ask("Try again? (Y/n) ");
|
|
1346
|
+
if (retry.trim().toLowerCase() === "n") {
|
|
1347
|
+
break;
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
if (created && proxyUrl.length > 0) {
|
|
1352
|
+
process.stderr.write("\n" + style.bold("Your Shield proxy URL:") + "\n");
|
|
1353
|
+
process.stderr.write(" " + style.cyan(proxyUrl) + "\n");
|
|
1354
|
+
printPlatformSnippet(selectedPlatform, proxyUrl, shortName, apiKey);
|
|
1355
|
+
configuredAgents.push({
|
|
1356
|
+
selection,
|
|
1357
|
+
platform: selectedPlatform,
|
|
1358
|
+
platformLabel: selectedLabel,
|
|
1359
|
+
agentName,
|
|
1360
|
+
shortName,
|
|
1361
|
+
proxyUrl,
|
|
1362
|
+
windsurfIntegration: "hosted"
|
|
1363
|
+
});
|
|
1364
|
+
setupSucceeded = true;
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
} else if (selection === 7) {
|
|
1368
|
+
const geminiMode = await promptGeminiCliIntegrationMode(ask);
|
|
1369
|
+
if (geminiMode === "native") {
|
|
1370
|
+
try {
|
|
1371
|
+
await installGeminiCliNativeHooks(ask);
|
|
1372
|
+
process.stderr.write("\n" + style.bold("Shield Gemini CLI hooks installed") + "\n\n");
|
|
1373
|
+
process.stderr.write(
|
|
1374
|
+
style.dim("Hook scripts: ") + style.cyan(getGeminiCliHooksInstallDir()) + "\n"
|
|
1375
|
+
);
|
|
1376
|
+
process.stderr.write(
|
|
1377
|
+
style.dim("Settings updated at ") + style.cyan("~/.gemini/settings.json") + "\n"
|
|
1378
|
+
);
|
|
1379
|
+
process.stderr.write(
|
|
1380
|
+
style.dim(
|
|
1381
|
+
"The Shield hook runs with your user permissions. It intercepts Gemini CLI tool calls to check permissions and log activity. Review the scripts if that is a concern."
|
|
1382
|
+
) + "\n"
|
|
1383
|
+
);
|
|
1384
|
+
configuredAgents.push({
|
|
1385
|
+
selection,
|
|
1386
|
+
platform: selectedPlatform,
|
|
1387
|
+
platformLabel: selectedLabel,
|
|
1388
|
+
agentName,
|
|
1389
|
+
geminiCliIntegration: "native"
|
|
1390
|
+
});
|
|
1391
|
+
setupSucceeded = true;
|
|
1392
|
+
} catch (error) {
|
|
1393
|
+
if (error instanceof NativePluginPrerequisiteMissingError) {
|
|
1394
|
+
postSaveNativeSkipNote = nativePluginSkippedSaveNote(
|
|
1395
|
+
"npx multicorn-shield init",
|
|
1396
|
+
"Gemini CLI"
|
|
1397
|
+
);
|
|
1398
|
+
configuredAgents.push({
|
|
1399
|
+
selection,
|
|
1400
|
+
platform: selectedPlatform,
|
|
1401
|
+
platformLabel: selectedLabel,
|
|
1402
|
+
agentName
|
|
1403
|
+
});
|
|
1404
|
+
setupSucceeded = true;
|
|
1405
|
+
} else {
|
|
1406
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1407
|
+
process.stderr.write(style.red("\u2717 ") + detail + "\n");
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
} else {
|
|
1411
|
+
const { targetUrl, shortName } = await promptProxyConfig(ask, agentName);
|
|
1412
|
+
let proxyUrl = "";
|
|
1413
|
+
let created = false;
|
|
1414
|
+
while (!created) {
|
|
1415
|
+
const spinner = withSpinner("Creating proxy config...");
|
|
1416
|
+
try {
|
|
1417
|
+
proxyUrl = await createProxyConfig(
|
|
1418
|
+
resolvedBaseUrl,
|
|
1419
|
+
apiKey,
|
|
1420
|
+
agentName,
|
|
1421
|
+
targetUrl,
|
|
1422
|
+
shortName,
|
|
1423
|
+
selectedPlatform
|
|
1424
|
+
);
|
|
1425
|
+
spinner.stop(true, "Proxy config created!");
|
|
1426
|
+
created = true;
|
|
1427
|
+
} catch (error) {
|
|
1428
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1429
|
+
spinner.stop(false, detail);
|
|
1430
|
+
const retry = await ask("Try again? (Y/n) ");
|
|
1431
|
+
if (retry.trim().toLowerCase() === "n") {
|
|
1432
|
+
break;
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
if (created && proxyUrl.length > 0) {
|
|
1437
|
+
process.stderr.write("\n" + style.bold("Your Shield proxy URL:") + "\n");
|
|
1438
|
+
process.stderr.write(" " + style.cyan(proxyUrl) + "\n");
|
|
1439
|
+
printPlatformSnippet(selectedPlatform, proxyUrl, shortName, apiKey);
|
|
1440
|
+
configuredAgents.push({
|
|
1441
|
+
selection,
|
|
1442
|
+
platform: selectedPlatform,
|
|
1443
|
+
platformLabel: selectedLabel,
|
|
1444
|
+
agentName,
|
|
1445
|
+
shortName,
|
|
1446
|
+
proxyUrl,
|
|
1447
|
+
geminiCliIntegration: "hosted"
|
|
1448
|
+
});
|
|
1449
|
+
setupSucceeded = true;
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
} else if (selection === 5) {
|
|
1453
|
+
const clineMode = await promptClineIntegrationMode(ask);
|
|
1454
|
+
if (clineMode === "native") {
|
|
1455
|
+
try {
|
|
1456
|
+
await installClineNativeHooks();
|
|
1457
|
+
process.stderr.write("\n" + style.bold("Shield Cline hooks installed") + "\n\n");
|
|
1458
|
+
process.stderr.write(
|
|
1459
|
+
style.dim(
|
|
1460
|
+
"The Shield hook runs with your user permissions. It intercepts Cline tool calls to check permissions and log activity. Review the scripts under "
|
|
1461
|
+
) + style.cyan("~/.multicorn/cline-hooks") + style.dim(" if that is a concern.") + "\n"
|
|
1462
|
+
);
|
|
1463
|
+
configuredAgents.push({
|
|
1464
|
+
selection,
|
|
1465
|
+
platform: selectedPlatform,
|
|
1466
|
+
platformLabel: selectedLabel,
|
|
1467
|
+
agentName,
|
|
1468
|
+
clineIntegration: "native"
|
|
1469
|
+
});
|
|
1470
|
+
setupSucceeded = true;
|
|
1471
|
+
} catch (error) {
|
|
1472
|
+
if (error instanceof NativePluginPrerequisiteMissingError) {
|
|
1473
|
+
postSaveNativeSkipNote = nativePluginSkippedSaveNote(
|
|
1474
|
+
"npx multicorn-shield init",
|
|
1475
|
+
"Cline"
|
|
1476
|
+
);
|
|
1477
|
+
configuredAgents.push({
|
|
1478
|
+
selection,
|
|
1479
|
+
platform: selectedPlatform,
|
|
1480
|
+
platformLabel: selectedLabel,
|
|
1481
|
+
agentName
|
|
1482
|
+
});
|
|
1483
|
+
setupSucceeded = true;
|
|
1484
|
+
} else {
|
|
1485
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1486
|
+
process.stderr.write(style.red("\u2717 ") + detail + "\n");
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
} else {
|
|
1490
|
+
const { targetUrl, shortName } = await promptProxyConfig(ask, agentName);
|
|
1491
|
+
let proxyUrl = "";
|
|
1492
|
+
let created = false;
|
|
1493
|
+
while (!created) {
|
|
1494
|
+
const spinner = withSpinner("Creating proxy config...");
|
|
1495
|
+
try {
|
|
1496
|
+
proxyUrl = await createProxyConfig(
|
|
1497
|
+
resolvedBaseUrl,
|
|
1498
|
+
apiKey,
|
|
1499
|
+
agentName,
|
|
1500
|
+
targetUrl,
|
|
1501
|
+
shortName,
|
|
1502
|
+
selectedPlatform
|
|
1503
|
+
);
|
|
1504
|
+
spinner.stop(true, "Proxy config created!");
|
|
1505
|
+
created = true;
|
|
1506
|
+
} catch (error) {
|
|
1507
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1508
|
+
spinner.stop(false, detail);
|
|
1509
|
+
const retry = await ask("Try again? (Y/n) ");
|
|
1510
|
+
if (retry.trim().toLowerCase() === "n") {
|
|
1511
|
+
break;
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
if (created && proxyUrl.length > 0) {
|
|
1516
|
+
process.stderr.write("\n" + style.bold("Your Shield proxy URL:") + "\n");
|
|
1517
|
+
process.stderr.write(" " + style.cyan(proxyUrl) + "\n");
|
|
1518
|
+
printPlatformSnippet(selectedPlatform, proxyUrl, shortName, apiKey);
|
|
1519
|
+
configuredAgents.push({
|
|
1520
|
+
selection,
|
|
1521
|
+
platform: selectedPlatform,
|
|
1522
|
+
platformLabel: selectedLabel,
|
|
1523
|
+
agentName,
|
|
1524
|
+
shortName,
|
|
1525
|
+
proxyUrl,
|
|
1526
|
+
clineIntegration: "hosted"
|
|
1527
|
+
});
|
|
1528
|
+
setupSucceeded = true;
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
} else {
|
|
1532
|
+
const { targetUrl, shortName } = await promptProxyConfig(ask, agentName);
|
|
1533
|
+
let proxyUrl = "";
|
|
1534
|
+
let created = false;
|
|
1535
|
+
while (!created) {
|
|
1536
|
+
const spinner = withSpinner("Creating proxy config...");
|
|
1537
|
+
try {
|
|
1538
|
+
proxyUrl = await createProxyConfig(
|
|
1539
|
+
resolvedBaseUrl,
|
|
1540
|
+
apiKey,
|
|
1541
|
+
agentName,
|
|
1542
|
+
targetUrl,
|
|
1543
|
+
shortName,
|
|
1544
|
+
selectedPlatform
|
|
1545
|
+
);
|
|
1546
|
+
spinner.stop(true, "Proxy config created!");
|
|
1547
|
+
created = true;
|
|
1548
|
+
} catch (error) {
|
|
1549
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1550
|
+
spinner.stop(false, detail);
|
|
1551
|
+
const retry = await ask("Try again? (Y/n) ");
|
|
1552
|
+
if (retry.trim().toLowerCase() === "n") {
|
|
1553
|
+
break;
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
if (created && proxyUrl.length > 0) {
|
|
1558
|
+
process.stderr.write("\n" + style.bold("Your Shield proxy URL:") + "\n");
|
|
1559
|
+
process.stderr.write(" " + style.cyan(proxyUrl) + "\n");
|
|
1560
|
+
printPlatformSnippet(selectedPlatform, proxyUrl, shortName, apiKey);
|
|
1561
|
+
configuredAgents.push({
|
|
1562
|
+
selection,
|
|
1563
|
+
platform: selectedPlatform,
|
|
1564
|
+
platformLabel: selectedLabel,
|
|
1565
|
+
agentName,
|
|
1566
|
+
shortName,
|
|
1567
|
+
proxyUrl
|
|
1568
|
+
});
|
|
1569
|
+
setupSucceeded = true;
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
if (setupSucceeded) {
|
|
1573
|
+
currentAgents = currentAgents.filter((a) => a.platform !== selectedPlatform);
|
|
1574
|
+
currentAgents.push({ name: agentName, platform: selectedPlatform });
|
|
1575
|
+
const raw = existing !== null ? { ...existing } : {};
|
|
1576
|
+
raw["apiKey"] = apiKey;
|
|
1577
|
+
raw["baseUrl"] = resolvedBaseUrl;
|
|
1578
|
+
raw["agents"] = currentAgents;
|
|
1579
|
+
raw["defaultAgent"] = agentName;
|
|
1580
|
+
delete raw["agentName"];
|
|
1581
|
+
delete raw["platform"];
|
|
1582
|
+
lastConfig = raw;
|
|
1583
|
+
try {
|
|
1584
|
+
await saveConfig(lastConfig);
|
|
1585
|
+
process.stderr.write(
|
|
1586
|
+
style.green("\u2713") + ` Config saved to ${style.cyan(CONFIG_PATH)}
|
|
1587
|
+
`
|
|
1588
|
+
);
|
|
1589
|
+
if (postSaveNativeSkipNote != null) {
|
|
1590
|
+
process.stderr.write(postSaveNativeSkipNote);
|
|
1591
|
+
postSaveNativeSkipNote = null;
|
|
1592
|
+
}
|
|
1593
|
+
} catch (error) {
|
|
1594
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1595
|
+
process.stderr.write(style.red(`Failed to save config: ${detail}`) + "\n");
|
|
1596
|
+
postSaveNativeSkipNote = null;
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
const another = await ask("\nConnect another agent? (Y/n) ");
|
|
1600
|
+
if (another.trim().toLowerCase() === "n") {
|
|
1601
|
+
configuring = false;
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
rl.close();
|
|
1605
|
+
if (configuredAgents.length > 0) {
|
|
1606
|
+
process.stderr.write("\n" + style.bold(style.violet("Setup complete")) + "\n\n");
|
|
1607
|
+
for (const agent of configuredAgents) {
|
|
1608
|
+
const namePart = agent.agentName.length > 0 ? ` - ${style.cyan(agent.agentName)}` : "";
|
|
1609
|
+
const urlPart = agent.proxyUrl != null ? ` ${style.dim(`(${agent.proxyUrl})`)}` : "";
|
|
1610
|
+
process.stderr.write(
|
|
1611
|
+
` ${style.green("\u2713")} ${agent.platformLabel}${namePart}${urlPart}
|
|
1612
|
+
`
|
|
1613
|
+
);
|
|
1614
|
+
}
|
|
1615
|
+
process.stderr.write("\n");
|
|
1616
|
+
const configuredPlatforms = new Set(configuredAgents.map((a) => a.platform));
|
|
1617
|
+
const blocks = [];
|
|
1618
|
+
if (configuredPlatforms.has("openclaw")) {
|
|
1619
|
+
blocks.push(
|
|
1620
|
+
"\n" + style.bold("To complete your OpenClaw setup:") + "\n \u2192 Restart your gateway: " + style.cyan("openclaw gateway restart") + "\n \u2192 Start a session: " + style.cyan("openclaw tui") + "\n"
|
|
1621
|
+
);
|
|
1622
|
+
}
|
|
1623
|
+
if (configuredPlatforms.has("claude-code")) {
|
|
1624
|
+
blocks.push(
|
|
1625
|
+
"\n" + style.bold("To complete your Claude Code setup:") + "\n \u2192 Add marketplace: " + style.cyan("claude plugin marketplace add Multicorn-AI/multicorn-shield") + "\n \u2192 Install plugin: " + style.cyan("claude plugin install multicorn-shield@multicorn-shield") + "\n"
|
|
1626
|
+
);
|
|
1627
|
+
}
|
|
1628
|
+
if (configuredPlatforms.has("claude-desktop")) {
|
|
1629
|
+
blocks.push(
|
|
1630
|
+
"\n" + style.bold("To complete your Claude Desktop setup:") + "\n \u2192 Restart Claude Desktop to pick up config changes\n"
|
|
1631
|
+
);
|
|
1632
|
+
}
|
|
1633
|
+
if (configuredPlatforms.has("cursor")) {
|
|
1634
|
+
blocks.push(
|
|
1635
|
+
"\n" + style.bold("To complete your Cursor setup:") + "\n 1. If you don't have Cursor yet, download it from " + style.cyan("https://cursor.com/downloads") + "\n 2. Open " + style.cyan("~/.cursor/mcp.json") + " and paste the config snippet shown above\n 3. Restart Cursor (or launch it for the first time) to load the new MCP server\n"
|
|
1636
|
+
);
|
|
1637
|
+
}
|
|
1638
|
+
const windsurfNativeConfigured = configuredAgents.some(
|
|
1639
|
+
(a) => a.platform === "windsurf" && a.windsurfIntegration === "native"
|
|
1640
|
+
);
|
|
1641
|
+
const windsurfHostedConfigured = configuredAgents.some(
|
|
1642
|
+
(a) => a.platform === "windsurf" && a.windsurfIntegration === "hosted"
|
|
1643
|
+
);
|
|
1644
|
+
if (windsurfNativeConfigured) {
|
|
1645
|
+
blocks.push(
|
|
1646
|
+
"\n" + style.bold("To complete native Windsurf (Shield) setup:") + "\n 1. Hook scripts: " + style.cyan(getWindsurfHooksInstallDir()) + "\n 2. Hooks config: " + style.cyan(getWindsurfCascadeHooksJsonPath()) + "\n 3. Restart Windsurf (quit fully, then reopen)\n"
|
|
1647
|
+
);
|
|
1648
|
+
}
|
|
1649
|
+
if (windsurfHostedConfigured) {
|
|
1650
|
+
blocks.push(
|
|
1651
|
+
"\n" + style.bold("To complete your Windsurf hosted-proxy setup:") + "\n 1. If you don't have Windsurf yet, download it from " + style.cyan("https://windsurf.com/download") + "\n 2. Open " + style.cyan("~/.codeium/windsurf/mcp_config.json") + " and paste the config snippet shown above\n 3. Restart Windsurf (or launch it for the first time) to load the new MCP server\n"
|
|
1652
|
+
);
|
|
1653
|
+
}
|
|
1654
|
+
const clineNativeConfigured = configuredAgents.some(
|
|
1655
|
+
(a) => a.platform === "cline" && a.clineIntegration === "native"
|
|
1656
|
+
);
|
|
1657
|
+
const clineHostedConfigured = configuredAgents.some(
|
|
1658
|
+
(a) => a.platform === "cline" && a.clineIntegration === "hosted"
|
|
1659
|
+
);
|
|
1660
|
+
if (clineNativeConfigured) {
|
|
1661
|
+
blocks.push(
|
|
1662
|
+
"\n" + style.bold("To complete native Cline (Shield) setup:") + "\n 1. Enable Hooks in Cline: open VS Code, click the Cline sidebar icon, click the gear icon,\n scroll down to the Advanced section, and toggle Hooks on.\n 2. Reload the VS Code window (Cmd+Shift+P > Reload Window)\n 3. Trigger any tool call to verify Shield is intercepting\n"
|
|
1663
|
+
);
|
|
1664
|
+
}
|
|
1665
|
+
if (clineHostedConfigured) {
|
|
1666
|
+
blocks.push(
|
|
1667
|
+
"\n" + style.bold("To complete your Cline hosted-proxy setup:") + "\n 1. If you don't have Cline yet, install it from the VS Code marketplace\n 2. Open your Cline MCP settings file and paste the config snippet shown above\n 3. Restart Cline or reload the VS Code window\n"
|
|
1668
|
+
);
|
|
1669
|
+
}
|
|
1670
|
+
const geminiCliNativeConfigured = configuredAgents.some(
|
|
1671
|
+
(a) => a.platform === "gemini-cli" && a.geminiCliIntegration === "native"
|
|
1672
|
+
);
|
|
1673
|
+
const geminiCliHostedConfigured = configuredAgents.some(
|
|
1674
|
+
(a) => a.platform === "gemini-cli" && a.geminiCliIntegration === "hosted"
|
|
1675
|
+
);
|
|
1676
|
+
if (geminiCliNativeConfigured) {
|
|
1677
|
+
blocks.push(
|
|
1678
|
+
"\n" + style.bold("Gemini CLI native hooks:") + "\n Your Gemini CLI hooks are installed. Restart Gemini CLI to activate Shield governance.\n"
|
|
1679
|
+
);
|
|
1680
|
+
}
|
|
1681
|
+
if (geminiCliHostedConfigured) {
|
|
1682
|
+
blocks.push(
|
|
1683
|
+
"\n" + style.bold("To complete your Gemini CLI setup:") + "\n 1. Open " + style.cyan("~/.gemini/settings.json") + "\n 2. Paste the config snippet shown above\n 3. Restart Gemini CLI, then run /mcp to verify\n"
|
|
1684
|
+
);
|
|
1685
|
+
}
|
|
1686
|
+
if (blocks.length > 0) {
|
|
1687
|
+
process.stderr.write("\n" + style.bold(style.violet("Next steps")) + "\n");
|
|
1688
|
+
process.stderr.write(blocks.join("") + "\n");
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
return lastConfig;
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
// src/types/index.ts
|
|
1695
|
+
var PERMISSION_LEVELS = {
|
|
1696
|
+
Read: "read",
|
|
1697
|
+
Write: "write",
|
|
1698
|
+
Execute: "execute",
|
|
1699
|
+
Publish: "publish",
|
|
1700
|
+
Create: "create"
|
|
1701
|
+
};
|
|
1702
|
+
|
|
1703
|
+
// src/scopes/scope-parser.ts
|
|
1704
|
+
var VALID_PERMISSION_LEVELS = new Set(Object.values(PERMISSION_LEVELS));
|
|
1705
|
+
[...VALID_PERMISSION_LEVELS].join(", ");
|
|
1706
|
+
function formatScope(scope) {
|
|
1707
|
+
return `${scope.permissionLevel}:${scope.service}`;
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
// src/scopes/scope-validator.ts
|
|
1711
|
+
function validateScopeAccess(grantedScopes, requested) {
|
|
1712
|
+
const isGranted = grantedScopes.some(
|
|
1713
|
+
(granted) => granted.service === requested.service && granted.permissionLevel === requested.permissionLevel
|
|
1714
|
+
);
|
|
1715
|
+
if (isGranted) {
|
|
1716
|
+
return { allowed: true };
|
|
1717
|
+
}
|
|
1718
|
+
const serviceScopes = grantedScopes.filter((g) => g.service === requested.service);
|
|
1719
|
+
if (serviceScopes.length > 0) {
|
|
1720
|
+
const grantedLevels = serviceScopes.map((g) => `"${g.permissionLevel}"`).join(", ");
|
|
1721
|
+
return {
|
|
1722
|
+
allowed: false,
|
|
1723
|
+
reason: `Permission "${requested.permissionLevel}" is not granted for service "${requested.service}". Currently granted permission level(s): ${grantedLevels}. Requested scope "${formatScope(requested)}" requires explicit consent.`
|
|
1724
|
+
};
|
|
1725
|
+
}
|
|
1726
|
+
return {
|
|
1727
|
+
allowed: false,
|
|
1728
|
+
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.`
|
|
1729
|
+
};
|
|
1730
|
+
}
|
|
1731
|
+
function hasScope(grantedScopes, requested) {
|
|
1732
|
+
return grantedScopes.some(
|
|
1733
|
+
(granted) => granted.service === requested.service && granted.permissionLevel === requested.permissionLevel
|
|
1734
|
+
);
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
// src/logger/action-logger.ts
|
|
1738
|
+
function createActionLogger(config) {
|
|
1739
|
+
if (!config.apiKey || config.apiKey.trim().length === 0) {
|
|
1740
|
+
throw new Error(
|
|
1741
|
+
"[ActionLogger] API key is required. Provide it via the 'apiKey' config option."
|
|
1742
|
+
);
|
|
1743
|
+
}
|
|
1744
|
+
const baseUrl = config.baseUrl ?? "https://api.multicorn.ai";
|
|
1745
|
+
const timeout = config.timeout ?? 5e3;
|
|
1746
|
+
if (!baseUrl.startsWith("https://") && !baseUrl.startsWith("http://localhost")) {
|
|
1747
|
+
throw new Error(
|
|
1748
|
+
`[ActionLogger] Base URL must use HTTPS for security. Received: "${baseUrl}". Use https:// or http://localhost for local development.`
|
|
1749
|
+
);
|
|
1750
|
+
}
|
|
1751
|
+
const endpoint = `${baseUrl}/api/v1/actions`;
|
|
1752
|
+
const batchEnabled = config.batchMode?.enabled ?? false;
|
|
1753
|
+
const maxBatchSize = config.batchMode?.maxSize ?? 10;
|
|
1754
|
+
const flushInterval = config.batchMode?.flushIntervalMs ?? 5e3;
|
|
1755
|
+
const queue = [];
|
|
1756
|
+
let flushTimer;
|
|
1757
|
+
let isShutdown = false;
|
|
1758
|
+
async function sendActions(actions) {
|
|
1759
|
+
if (actions.length === 0) return;
|
|
1760
|
+
const convertAction = (action) => ({
|
|
1761
|
+
agent: action.agent,
|
|
1762
|
+
service: action.service,
|
|
1763
|
+
actionType: action.actionType,
|
|
1764
|
+
status: action.status,
|
|
1765
|
+
...action.cost !== void 0 ? { cost: action.cost } : {},
|
|
1766
|
+
...action.metadata !== void 0 ? { metadata: action.metadata } : {}
|
|
1767
|
+
});
|
|
1768
|
+
const convertedActions = actions.map(convertAction);
|
|
1769
|
+
const payload = batchEnabled ? { actions: convertedActions } : convertedActions[0];
|
|
1770
|
+
let lastError;
|
|
1771
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
1772
|
+
try {
|
|
1773
|
+
const controller = new AbortController();
|
|
1774
|
+
const timeoutId = setTimeout(() => {
|
|
1775
|
+
controller.abort();
|
|
1776
|
+
}, timeout);
|
|
1777
|
+
const response = await fetch(endpoint, {
|
|
1778
|
+
method: "POST",
|
|
1779
|
+
headers: {
|
|
1780
|
+
"Content-Type": "application/json",
|
|
1781
|
+
"X-Multicorn-Key": config.apiKey
|
|
1782
|
+
},
|
|
1783
|
+
body: JSON.stringify(payload),
|
|
1784
|
+
signal: controller.signal
|
|
1785
|
+
});
|
|
1786
|
+
clearTimeout(timeoutId);
|
|
1787
|
+
if (response.ok) {
|
|
1788
|
+
return;
|
|
1789
|
+
}
|
|
1790
|
+
if (response.status >= 400 && response.status < 500) {
|
|
1791
|
+
const body = await response.text().catch(() => "");
|
|
1792
|
+
throw new Error(
|
|
1793
|
+
`[ActionLogger] Client error (${String(response.status)}): ${response.statusText}. Response: ${body}. Check your API key and payload format.`
|
|
1794
|
+
);
|
|
1795
|
+
}
|
|
1796
|
+
if (response.status >= 500 && attempt === 0) {
|
|
1797
|
+
lastError = new Error(
|
|
1798
|
+
`[ActionLogger] Server error (${String(response.status)}): ${response.statusText}. Retrying once...`
|
|
1799
|
+
);
|
|
1800
|
+
await sleep(100 * Math.pow(2, attempt));
|
|
1801
|
+
continue;
|
|
1802
|
+
}
|
|
1803
|
+
throw new Error(
|
|
1804
|
+
`[ActionLogger] Server error (${String(response.status)}) after retry: ${response.statusText}. Multicorn API may be experiencing issues.`
|
|
1805
|
+
);
|
|
1806
|
+
} catch (error) {
|
|
1807
|
+
if (error instanceof Error) {
|
|
1808
|
+
if (error.name === "AbortError") {
|
|
1809
|
+
lastError = new Error(
|
|
1810
|
+
`[ActionLogger] Request timeout after ${String(timeout)}ms. Increase the 'timeout' config option or check your network connection.`
|
|
1811
|
+
);
|
|
1812
|
+
} else if (error.message.includes("Client error") || error.message.includes("Server error")) {
|
|
1813
|
+
lastError = error;
|
|
1814
|
+
} else {
|
|
1815
|
+
lastError = new Error(
|
|
1816
|
+
`[ActionLogger] Network error: ${error.message}. Check your network connection and API endpoint.`
|
|
1817
|
+
);
|
|
1818
|
+
}
|
|
1819
|
+
} else {
|
|
1820
|
+
lastError = new Error(`[ActionLogger] Unknown error: ${String(error)}`);
|
|
1821
|
+
}
|
|
1822
|
+
if (attempt === 0 && !lastError.message.includes("Client error")) {
|
|
1823
|
+
await sleep(100 * Math.pow(2, attempt));
|
|
1824
|
+
continue;
|
|
1825
|
+
}
|
|
1826
|
+
break;
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
if (lastError) {
|
|
1830
|
+
if (config.onError) {
|
|
1831
|
+
config.onError(lastError);
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
async function flushQueue() {
|
|
1836
|
+
if (queue.length === 0) return;
|
|
1837
|
+
const actions = queue.map((item) => item.payload);
|
|
1838
|
+
queue.length = 0;
|
|
1839
|
+
await sendActions(actions);
|
|
1840
|
+
}
|
|
1841
|
+
function startFlushTimer() {
|
|
1842
|
+
if (flushTimer !== void 0) return;
|
|
1843
|
+
flushTimer = setInterval(() => {
|
|
1844
|
+
flushQueue().catch(() => {
|
|
1845
|
+
});
|
|
1846
|
+
}, flushInterval);
|
|
1847
|
+
const timer = flushTimer;
|
|
1848
|
+
if (typeof timer.unref === "function") {
|
|
1849
|
+
timer.unref();
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
function stopFlushTimer() {
|
|
1853
|
+
if (flushTimer) {
|
|
1854
|
+
clearInterval(flushTimer);
|
|
1855
|
+
flushTimer = void 0;
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
if (batchEnabled) {
|
|
1859
|
+
startFlushTimer();
|
|
1860
|
+
}
|
|
1861
|
+
return {
|
|
1862
|
+
logAction(action) {
|
|
1863
|
+
if (isShutdown) {
|
|
1864
|
+
throw new Error(
|
|
1865
|
+
"[ActionLogger] Cannot log action after shutdown. Create a new logger instance."
|
|
1866
|
+
);
|
|
1867
|
+
}
|
|
1868
|
+
if (action.agent.trim().length === 0) {
|
|
1869
|
+
throw new Error("[ActionLogger] Action must have a non-empty 'agent' field.");
|
|
1870
|
+
}
|
|
1871
|
+
if (action.service.trim().length === 0) {
|
|
1872
|
+
throw new Error("[ActionLogger] Action must have a non-empty 'service' field.");
|
|
1873
|
+
}
|
|
1874
|
+
if (action.actionType.trim().length === 0) {
|
|
1875
|
+
throw new Error("[ActionLogger] Action must have a non-empty 'actionType' field.");
|
|
1876
|
+
}
|
|
1877
|
+
if (action.status.trim().length === 0) {
|
|
1878
|
+
throw new Error("[ActionLogger] Action must have a non-empty 'status' field.");
|
|
1879
|
+
}
|
|
1880
|
+
if (batchEnabled) {
|
|
1881
|
+
queue.push({ payload: action, timestamp: Date.now() });
|
|
1882
|
+
if (queue.length >= maxBatchSize) {
|
|
1883
|
+
flushQueue().catch(() => {
|
|
1884
|
+
});
|
|
1885
|
+
}
|
|
1886
|
+
} else {
|
|
1887
|
+
sendActions([action]).catch(() => {
|
|
1888
|
+
});
|
|
1889
|
+
}
|
|
1890
|
+
return Promise.resolve();
|
|
1891
|
+
},
|
|
1892
|
+
async flush() {
|
|
1893
|
+
if (!batchEnabled) return;
|
|
1894
|
+
await flushQueue();
|
|
1895
|
+
},
|
|
1896
|
+
async shutdown() {
|
|
1897
|
+
if (isShutdown) return;
|
|
1898
|
+
isShutdown = true;
|
|
1899
|
+
stopFlushTimer();
|
|
1900
|
+
if (batchEnabled) {
|
|
1901
|
+
await flushQueue();
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
};
|
|
1905
|
+
}
|
|
1906
|
+
function sleep(ms) {
|
|
1907
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
// src/spending/spending-checker.ts
|
|
1911
|
+
function createSpendingChecker(config) {
|
|
1912
|
+
validateLimits(config.limits);
|
|
1913
|
+
let dailySpendCents = 0;
|
|
1914
|
+
let monthlySpendCents = 0;
|
|
1915
|
+
let lastDailyReset = /* @__PURE__ */ new Date();
|
|
1916
|
+
let lastMonthlyReset = /* @__PURE__ */ new Date();
|
|
1917
|
+
function validateAmount(amountCents, context) {
|
|
1918
|
+
if (!Number.isInteger(amountCents)) {
|
|
1919
|
+
throw new Error(
|
|
1920
|
+
`[SpendingChecker] ${context} must be an integer (cents). Received: ${String(amountCents)}. Convert dollars to cents by multiplying by 100.`
|
|
1921
|
+
);
|
|
1922
|
+
}
|
|
1923
|
+
if (amountCents < 0) {
|
|
1924
|
+
throw new Error(
|
|
1925
|
+
`[SpendingChecker] ${context} must be non-negative. Received: ${String(amountCents)} cents.`
|
|
1926
|
+
);
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
function formatCents(cents) {
|
|
1930
|
+
const dollars = cents / 100;
|
|
1931
|
+
return `$${dollars.toLocaleString("en-US", {
|
|
1932
|
+
minimumFractionDigits: 2,
|
|
1933
|
+
maximumFractionDigits: 2
|
|
1934
|
+
})}`;
|
|
1935
|
+
}
|
|
1936
|
+
function checkAndResetDaily() {
|
|
1937
|
+
const now = /* @__PURE__ */ new Date();
|
|
1938
|
+
if (shouldResetDaily(lastDailyReset, now)) {
|
|
1939
|
+
dailySpendCents = 0;
|
|
1940
|
+
lastDailyReset = now;
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
function checkAndResetMonthly() {
|
|
1944
|
+
const now = /* @__PURE__ */ new Date();
|
|
1945
|
+
if (shouldResetMonthly(lastMonthlyReset, now)) {
|
|
1946
|
+
monthlySpendCents = 0;
|
|
1947
|
+
lastMonthlyReset = now;
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
function shouldResetDaily(lastReset, now) {
|
|
1951
|
+
return lastReset.getDate() !== now.getDate() || lastReset.getMonth() !== now.getMonth() || lastReset.getFullYear() !== now.getFullYear();
|
|
1952
|
+
}
|
|
1953
|
+
function shouldResetMonthly(lastReset, now) {
|
|
1954
|
+
return lastReset.getMonth() !== now.getMonth() || lastReset.getFullYear() !== now.getFullYear();
|
|
1955
|
+
}
|
|
1956
|
+
function calculateRemainingBudget() {
|
|
1957
|
+
return {
|
|
1958
|
+
transaction: config.limits.perTransaction,
|
|
1959
|
+
daily: Math.max(0, config.limits.perDay - dailySpendCents),
|
|
1960
|
+
monthly: Math.max(0, config.limits.perMonth - monthlySpendCents)
|
|
1961
|
+
};
|
|
1962
|
+
}
|
|
1963
|
+
return {
|
|
1964
|
+
checkSpend(amountCents) {
|
|
1965
|
+
validateAmount(amountCents, "Spend amount");
|
|
1966
|
+
checkAndResetDaily();
|
|
1967
|
+
checkAndResetMonthly();
|
|
1968
|
+
if (amountCents > config.limits.perTransaction) {
|
|
1969
|
+
return {
|
|
1970
|
+
allowed: false,
|
|
1971
|
+
reason: `Action blocked: ${formatCents(amountCents)} exceeds per-transaction limit of ${formatCents(config.limits.perTransaction)}`,
|
|
1972
|
+
remainingBudget: calculateRemainingBudget()
|
|
1973
|
+
};
|
|
1974
|
+
}
|
|
1975
|
+
const projectedDaily = dailySpendCents + amountCents;
|
|
1976
|
+
if (projectedDaily > config.limits.perDay) {
|
|
1977
|
+
return {
|
|
1978
|
+
allowed: false,
|
|
1979
|
+
reason: `Action blocked: ${formatCents(amountCents)} would exceed per-day limit. Current spend today: ${formatCents(dailySpendCents)}, limit: ${formatCents(config.limits.perDay)}`,
|
|
1980
|
+
remainingBudget: calculateRemainingBudget()
|
|
1981
|
+
};
|
|
1982
|
+
}
|
|
1983
|
+
const projectedMonthly = monthlySpendCents + amountCents;
|
|
1984
|
+
if (projectedMonthly > config.limits.perMonth) {
|
|
1985
|
+
return {
|
|
1986
|
+
allowed: false,
|
|
1987
|
+
reason: `Action blocked: ${formatCents(amountCents)} would exceed per-month limit. Current spend this month: ${formatCents(monthlySpendCents)}, limit: ${formatCents(config.limits.perMonth)}`,
|
|
1988
|
+
remainingBudget: calculateRemainingBudget()
|
|
1989
|
+
};
|
|
1990
|
+
}
|
|
1991
|
+
return {
|
|
1992
|
+
allowed: true,
|
|
1993
|
+
remainingBudget: calculateRemainingBudget()
|
|
1994
|
+
};
|
|
1995
|
+
},
|
|
1996
|
+
recordSpend(amountCents) {
|
|
1997
|
+
validateAmount(amountCents, "Spend amount");
|
|
1998
|
+
checkAndResetDaily();
|
|
1999
|
+
checkAndResetMonthly();
|
|
2000
|
+
dailySpendCents += amountCents;
|
|
2001
|
+
monthlySpendCents += amountCents;
|
|
2002
|
+
},
|
|
2003
|
+
getCurrentSpend() {
|
|
2004
|
+
checkAndResetDaily();
|
|
2005
|
+
checkAndResetMonthly();
|
|
2006
|
+
return {
|
|
2007
|
+
daily: dailySpendCents,
|
|
2008
|
+
monthly: monthlySpendCents
|
|
2009
|
+
};
|
|
2010
|
+
},
|
|
2011
|
+
reset() {
|
|
2012
|
+
dailySpendCents = 0;
|
|
2013
|
+
monthlySpendCents = 0;
|
|
2014
|
+
lastDailyReset = /* @__PURE__ */ new Date();
|
|
2015
|
+
lastMonthlyReset = /* @__PURE__ */ new Date();
|
|
2016
|
+
}
|
|
2017
|
+
};
|
|
2018
|
+
}
|
|
2019
|
+
function validateLimits(limits) {
|
|
2020
|
+
const checks = [
|
|
2021
|
+
{ value: limits.perTransaction, name: "perTransaction" },
|
|
2022
|
+
{ value: limits.perDay, name: "perDay" },
|
|
2023
|
+
{ value: limits.perMonth, name: "perMonth" }
|
|
2024
|
+
];
|
|
2025
|
+
for (const check of checks) {
|
|
2026
|
+
if (!Number.isInteger(check.value)) {
|
|
2027
|
+
throw new Error(
|
|
2028
|
+
`[SpendingChecker] Limit "${check.name}" must be an integer (cents). Received: ${String(check.value)}. All limits must be specified in integer cents.`
|
|
2029
|
+
);
|
|
2030
|
+
}
|
|
2031
|
+
if (check.value < 0) {
|
|
2032
|
+
throw new Error(
|
|
2033
|
+
`[SpendingChecker] Limit "${check.name}" must be non-negative. Received: ${String(check.value)} cents.`
|
|
2034
|
+
);
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
function dollarsToCents(dollars) {
|
|
2039
|
+
return Math.round(dollars * 100);
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
// src/proxy/interceptor.ts
|
|
2043
|
+
var BLOCKED_ERROR_CODE = -32e3;
|
|
2044
|
+
var SPENDING_BLOCKED_ERROR_CODE = -32001;
|
|
2045
|
+
var INTERNAL_ERROR_CODE = -32002;
|
|
2046
|
+
var SERVICE_UNREACHABLE_ERROR_CODE = -32003;
|
|
2047
|
+
var AUTH_ERROR_CODE = -32004;
|
|
2048
|
+
function parseJsonRpcLine(line) {
|
|
2049
|
+
const trimmed = line.trim();
|
|
2050
|
+
if (trimmed.length === 0) return null;
|
|
2051
|
+
let parsed;
|
|
2052
|
+
try {
|
|
2053
|
+
parsed = JSON.parse(trimmed);
|
|
2054
|
+
} catch {
|
|
2055
|
+
return null;
|
|
2056
|
+
}
|
|
2057
|
+
return isJsonRpcRequest(parsed) ? parsed : null;
|
|
2058
|
+
}
|
|
2059
|
+
function extractToolCallParams(request) {
|
|
2060
|
+
if (request.method !== "tools/call") return null;
|
|
2061
|
+
if (typeof request.params !== "object" || request.params === null) return null;
|
|
2062
|
+
const params = request.params;
|
|
2063
|
+
const name = params["name"];
|
|
2064
|
+
const args = params["arguments"];
|
|
2065
|
+
if (typeof name !== "string") return null;
|
|
2066
|
+
if (typeof args !== "object" || args === null) return null;
|
|
2067
|
+
return { name, arguments: args };
|
|
2068
|
+
}
|
|
2069
|
+
function buildBlockedResponse(id, service, permissionLevel, dashboardUrl) {
|
|
2070
|
+
const displayService = capitalize(service);
|
|
2071
|
+
const message = `Action blocked by Multicorn Shield: agent does not have ${permissionLevel} access to ${displayService}. Configure permissions at ${dashboardUrl}`;
|
|
2072
|
+
return {
|
|
2073
|
+
jsonrpc: "2.0",
|
|
2074
|
+
id,
|
|
2075
|
+
error: {
|
|
2076
|
+
code: BLOCKED_ERROR_CODE,
|
|
2077
|
+
message
|
|
2078
|
+
}
|
|
2079
|
+
};
|
|
2080
|
+
}
|
|
2081
|
+
function buildSpendingBlockedResponse(id, reason, dashboardUrl) {
|
|
2082
|
+
const message = `Action blocked by Multicorn Shield: ${reason}. Review spending limits at ${dashboardUrl}`;
|
|
2083
|
+
return {
|
|
2084
|
+
jsonrpc: "2.0",
|
|
2085
|
+
id,
|
|
2086
|
+
error: {
|
|
2087
|
+
code: SPENDING_BLOCKED_ERROR_CODE,
|
|
2088
|
+
message
|
|
2089
|
+
}
|
|
2090
|
+
};
|
|
2091
|
+
}
|
|
2092
|
+
function buildInternalErrorResponse(id) {
|
|
2093
|
+
const message = "Action blocked: Shield encountered an internal error and cannot verify permissions. Check proxy logs for details.";
|
|
2094
|
+
return {
|
|
2095
|
+
jsonrpc: "2.0",
|
|
2096
|
+
id,
|
|
2097
|
+
error: {
|
|
2098
|
+
code: INTERNAL_ERROR_CODE,
|
|
2099
|
+
message
|
|
2100
|
+
}
|
|
2101
|
+
};
|
|
2102
|
+
}
|
|
2103
|
+
function buildServiceUnreachableResponse(id, dashboardUrl) {
|
|
2104
|
+
const message = `Action blocked: Shield cannot verify permissions (service unreachable). Configure offline behaviour at ${dashboardUrl}`;
|
|
2105
|
+
return {
|
|
2106
|
+
jsonrpc: "2.0",
|
|
2107
|
+
id,
|
|
2108
|
+
error: {
|
|
2109
|
+
code: SERVICE_UNREACHABLE_ERROR_CODE,
|
|
2110
|
+
message
|
|
2111
|
+
}
|
|
2112
|
+
};
|
|
2113
|
+
}
|
|
2114
|
+
function buildAuthErrorResponse(id) {
|
|
2115
|
+
const message = "Action blocked: Shield API key is invalid or has been revoked. Run npx multicorn-shield init to reconfigure.";
|
|
2116
|
+
return {
|
|
2117
|
+
jsonrpc: "2.0",
|
|
2118
|
+
id,
|
|
2119
|
+
error: {
|
|
2120
|
+
code: AUTH_ERROR_CODE,
|
|
2121
|
+
message
|
|
2122
|
+
}
|
|
2123
|
+
};
|
|
2124
|
+
}
|
|
2125
|
+
function extractServiceFromToolName(toolName) {
|
|
2126
|
+
const idx = toolName.indexOf("_");
|
|
2127
|
+
return idx === -1 ? toolName : toolName.slice(0, idx);
|
|
2128
|
+
}
|
|
2129
|
+
function extractActionFromToolName(toolName) {
|
|
2130
|
+
const idx = toolName.indexOf("_");
|
|
2131
|
+
return idx === -1 ? "call" : toolName.slice(idx + 1);
|
|
2132
|
+
}
|
|
2133
|
+
function isJsonRpcRequest(value) {
|
|
2134
|
+
if (typeof value !== "object" || value === null) return false;
|
|
2135
|
+
const obj = value;
|
|
2136
|
+
if (obj["jsonrpc"] !== "2.0") return false;
|
|
2137
|
+
if (typeof obj["method"] !== "string") return false;
|
|
2138
|
+
const id = obj["id"];
|
|
2139
|
+
const validId = id === null || id === void 0 || typeof id === "string" || typeof id === "number";
|
|
2140
|
+
return validId;
|
|
2141
|
+
}
|
|
2142
|
+
function capitalize(str) {
|
|
2143
|
+
if (str.length === 0) return str;
|
|
2144
|
+
const first = str[0];
|
|
2145
|
+
return first !== void 0 ? first.toUpperCase() + str.slice(1) : str;
|
|
2146
|
+
}
|
|
2147
|
+
var MULTICORN_DIR = join(homedir(), ".multicorn");
|
|
2148
|
+
var SCOPES_PATH = join(MULTICORN_DIR, "scopes.json");
|
|
2149
|
+
var CACHE_META_PATH = join(MULTICORN_DIR, "cache-meta.json");
|
|
2150
|
+
function cacheKey(agentName, apiKey) {
|
|
2151
|
+
return createHash("sha256").update(`${agentName}:${apiKey}`).digest("hex").slice(0, 16);
|
|
2152
|
+
}
|
|
2153
|
+
async function ensureCacheIdentity(apiKey) {
|
|
2154
|
+
const currentHash = createHash("sha256").update(apiKey).digest("hex");
|
|
2155
|
+
let storedHash = null;
|
|
2156
|
+
try {
|
|
2157
|
+
const raw = await readFile(CACHE_META_PATH, "utf8");
|
|
2158
|
+
const meta = JSON.parse(raw);
|
|
2159
|
+
if (typeof meta === "object" && meta !== null && "apiKeyHash" in meta) {
|
|
2160
|
+
storedHash = meta.apiKeyHash;
|
|
2161
|
+
}
|
|
2162
|
+
} catch {
|
|
2163
|
+
}
|
|
2164
|
+
if (storedHash === null || storedHash !== currentHash) {
|
|
2165
|
+
try {
|
|
2166
|
+
await unlink(SCOPES_PATH);
|
|
2167
|
+
} catch {
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
if (storedHash !== currentHash) {
|
|
2171
|
+
await mkdir(MULTICORN_DIR, { recursive: true, mode: 448 });
|
|
2172
|
+
await writeFile(CACHE_META_PATH, JSON.stringify({ apiKeyHash: currentHash }, null, 2) + "\n", {
|
|
2173
|
+
encoding: "utf8",
|
|
2174
|
+
mode: 384
|
|
2175
|
+
});
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
async function loadCachedScopes(agentName, apiKey) {
|
|
2179
|
+
if (apiKey.length === 0) return null;
|
|
2180
|
+
await ensureCacheIdentity(apiKey);
|
|
2181
|
+
const key = cacheKey(agentName, apiKey);
|
|
2182
|
+
try {
|
|
2183
|
+
const raw = await readFile(SCOPES_PATH, "utf8");
|
|
2184
|
+
const parsed = JSON.parse(raw);
|
|
2185
|
+
if (!isScopesCacheFile(parsed)) return null;
|
|
2186
|
+
const entry = parsed[key];
|
|
2187
|
+
return entry?.scopes ?? null;
|
|
2188
|
+
} catch {
|
|
2189
|
+
return null;
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
async function saveCachedScopes(agentName, agentId, scopes, apiKey) {
|
|
2193
|
+
if (apiKey.length === 0) return;
|
|
2194
|
+
await ensureCacheIdentity(apiKey);
|
|
2195
|
+
const key = cacheKey(agentName, apiKey);
|
|
2196
|
+
await mkdir(MULTICORN_DIR, { recursive: true, mode: 448 });
|
|
2197
|
+
let existing = {};
|
|
2198
|
+
try {
|
|
2199
|
+
const raw = await readFile(SCOPES_PATH, "utf8");
|
|
2200
|
+
const parsed = JSON.parse(raw);
|
|
2201
|
+
if (isScopesCacheFile(parsed)) existing = parsed;
|
|
2202
|
+
} catch {
|
|
2203
|
+
}
|
|
2204
|
+
const updated = {
|
|
2205
|
+
...existing,
|
|
2206
|
+
[key]: {
|
|
2207
|
+
agentId,
|
|
2208
|
+
scopes,
|
|
2209
|
+
fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2210
|
+
}
|
|
2211
|
+
};
|
|
2212
|
+
await writeFile(SCOPES_PATH, JSON.stringify(updated, null, 2) + "\n", {
|
|
2213
|
+
encoding: "utf8",
|
|
2214
|
+
mode: 384
|
|
2215
|
+
});
|
|
2216
|
+
}
|
|
2217
|
+
function isScopesCacheFile(value) {
|
|
2218
|
+
return typeof value === "object" && value !== null;
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
// src/proxy/consent.ts
|
|
2222
|
+
var CONSENT_POLL_INTERVAL_MS = 3e3;
|
|
2223
|
+
var CONSENT_POLL_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
2224
|
+
function deriveDashboardUrl(baseUrl) {
|
|
2225
|
+
try {
|
|
2226
|
+
const url = new URL(baseUrl);
|
|
2227
|
+
if (url.hostname === "localhost" || url.hostname === "127.0.0.1") {
|
|
2228
|
+
url.port = "5173";
|
|
2229
|
+
url.protocol = "http:";
|
|
2230
|
+
return url.toString();
|
|
2231
|
+
}
|
|
2232
|
+
if (url.hostname === "api.multicorn.ai") {
|
|
2233
|
+
url.hostname = "app.multicorn.ai";
|
|
2234
|
+
return url.toString();
|
|
2235
|
+
}
|
|
2236
|
+
if (url.hostname.includes("api")) {
|
|
2237
|
+
url.hostname = url.hostname.replace("api", "app");
|
|
2238
|
+
return url.toString();
|
|
2239
|
+
}
|
|
2240
|
+
if (url.protocol === "https:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1") {
|
|
2241
|
+
return "https://app.multicorn.ai";
|
|
2242
|
+
}
|
|
2243
|
+
return "https://app.multicorn.ai";
|
|
2244
|
+
} catch {
|
|
2245
|
+
return "https://app.multicorn.ai";
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
var ShieldAuthError = class _ShieldAuthError extends Error {
|
|
2249
|
+
constructor(message) {
|
|
2250
|
+
super(message);
|
|
2251
|
+
this.name = "ShieldAuthError";
|
|
2252
|
+
Object.setPrototypeOf(this, _ShieldAuthError.prototype);
|
|
2253
|
+
}
|
|
2254
|
+
};
|
|
2255
|
+
async function findAgentByName(agentName, apiKey, baseUrl) {
|
|
2256
|
+
let response;
|
|
2257
|
+
try {
|
|
2258
|
+
response = await fetch(`${baseUrl}/api/v1/agents`, {
|
|
2259
|
+
headers: { "X-Multicorn-Key": apiKey },
|
|
2260
|
+
signal: AbortSignal.timeout(8e3)
|
|
2261
|
+
});
|
|
2262
|
+
} catch {
|
|
2263
|
+
return null;
|
|
2264
|
+
}
|
|
2265
|
+
if (!response.ok) {
|
|
2266
|
+
if (response.status === 401 || response.status === 403) {
|
|
2267
|
+
return { id: "", name: agentName, scopes: [], authInvalid: true };
|
|
2268
|
+
}
|
|
2269
|
+
return null;
|
|
2270
|
+
}
|
|
2271
|
+
let body;
|
|
2272
|
+
try {
|
|
2273
|
+
body = await response.json();
|
|
2274
|
+
} catch {
|
|
2275
|
+
return null;
|
|
2276
|
+
}
|
|
2277
|
+
if (!isApiSuccessResponse(body)) return null;
|
|
2278
|
+
const agents = body.data;
|
|
2279
|
+
if (!Array.isArray(agents)) return null;
|
|
2280
|
+
const match = agents.find(
|
|
2281
|
+
(a) => isAgentSummaryShape(a) && a.name === agentName
|
|
2282
|
+
);
|
|
2283
|
+
if (match === void 0) return null;
|
|
2284
|
+
return { id: match.id, name: match.name, scopes: [] };
|
|
2285
|
+
}
|
|
2286
|
+
async function registerAgent(agentName, apiKey, baseUrl, platform) {
|
|
2287
|
+
const response = await fetch(`${baseUrl}/api/v1/agents`, {
|
|
2288
|
+
method: "POST",
|
|
2289
|
+
headers: {
|
|
2290
|
+
"Content-Type": "application/json",
|
|
2291
|
+
"X-Multicorn-Key": apiKey
|
|
2292
|
+
},
|
|
2293
|
+
body: JSON.stringify({ name: agentName, ...platform ? { platform } : {} }),
|
|
2294
|
+
signal: AbortSignal.timeout(8e3)
|
|
2295
|
+
});
|
|
2296
|
+
if (!response.ok) {
|
|
2297
|
+
if (response.status === 401 || response.status === 403) {
|
|
2298
|
+
throw new ShieldAuthError(
|
|
2299
|
+
`Failed to register agent "${agentName}": service returned ${String(response.status)}.`
|
|
2300
|
+
);
|
|
2301
|
+
}
|
|
2302
|
+
throw new Error(
|
|
2303
|
+
`Failed to register agent "${agentName}": service returned ${String(response.status)}.`
|
|
2304
|
+
);
|
|
2305
|
+
}
|
|
2306
|
+
const body = await response.json();
|
|
2307
|
+
if (!isApiSuccessResponse(body)) {
|
|
2308
|
+
throw new Error(`Failed to register agent "${agentName}": unexpected response format.`);
|
|
2309
|
+
}
|
|
2310
|
+
if (!isAgentSummaryShape(body.data)) {
|
|
2311
|
+
throw new Error(`Failed to register agent "${agentName}": response missing agent ID.`);
|
|
2312
|
+
}
|
|
2313
|
+
return body.data.id;
|
|
2314
|
+
}
|
|
2315
|
+
async function fetchGrantedScopes(agentId, apiKey, baseUrl) {
|
|
2316
|
+
let response;
|
|
2317
|
+
try {
|
|
2318
|
+
response = await fetch(`${baseUrl}/api/v1/agents/${agentId}`, {
|
|
2319
|
+
headers: { "X-Multicorn-Key": apiKey },
|
|
2320
|
+
signal: AbortSignal.timeout(8e3)
|
|
2321
|
+
});
|
|
2322
|
+
} catch {
|
|
2323
|
+
return [];
|
|
2324
|
+
}
|
|
2325
|
+
if (!response.ok) return [];
|
|
2326
|
+
const body = await response.json();
|
|
2327
|
+
if (!isApiSuccessResponse(body)) return [];
|
|
2328
|
+
const agentDetail = body.data;
|
|
2329
|
+
if (!isAgentDetailShape(agentDetail)) return [];
|
|
2330
|
+
const scopes = [];
|
|
2331
|
+
for (const perm of agentDetail.permissions) {
|
|
2332
|
+
if (!isPermissionShape(perm)) continue;
|
|
2333
|
+
if (perm.revoked_at !== null) continue;
|
|
2334
|
+
if (perm.read) scopes.push({ service: perm.service, permissionLevel: "read" });
|
|
2335
|
+
if (perm.write) scopes.push({ service: perm.service, permissionLevel: "write" });
|
|
2336
|
+
if (perm.execute) scopes.push({ service: perm.service, permissionLevel: "execute" });
|
|
2337
|
+
}
|
|
2338
|
+
return scopes;
|
|
2339
|
+
}
|
|
2340
|
+
function openBrowser(url) {
|
|
2341
|
+
if (process.env["NODE_ENV"] === "test" || process.env["VITEST"] === "true") {
|
|
2342
|
+
return;
|
|
2343
|
+
}
|
|
2344
|
+
const platform = process.platform;
|
|
2345
|
+
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
|
|
2346
|
+
spawn(cmd, [url], { detached: true, stdio: "ignore" }).unref();
|
|
2347
|
+
}
|
|
2348
|
+
async function waitForConsent(agentId, agentName, apiKey, baseUrl, dashboardUrl, logger, scope, platform) {
|
|
2349
|
+
const scopeStrings = scope ? [`${scope.service}:${scope.permissionLevel}`] : detectScopeHints();
|
|
2350
|
+
const consentUrl = buildConsentUrl(agentName, scopeStrings, dashboardUrl, platform);
|
|
2351
|
+
logger.info("Opening consent page in your browser.", { url: consentUrl });
|
|
2352
|
+
process.stderr.write(
|
|
2353
|
+
`
|
|
2354
|
+
Action requires permission. Opening consent page...
|
|
2355
|
+
${consentUrl}
|
|
2356
|
+
|
|
2357
|
+
Waiting for you to grant access in the Multicorn dashboard...
|
|
2358
|
+
`
|
|
2359
|
+
);
|
|
2360
|
+
openBrowser(consentUrl);
|
|
2361
|
+
const deadline = Date.now() + CONSENT_POLL_TIMEOUT_MS;
|
|
2362
|
+
while (Date.now() < deadline) {
|
|
2363
|
+
await sleep2(CONSENT_POLL_INTERVAL_MS);
|
|
2364
|
+
const scopes = await fetchGrantedScopes(agentId, apiKey, baseUrl);
|
|
2365
|
+
if (scopes.length > 0) {
|
|
2366
|
+
logger.info("Permissions granted.", { agent: agentName, scopeCount: scopes.length });
|
|
2367
|
+
return scopes;
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
throw new Error(
|
|
2371
|
+
`Consent not granted within ${String(CONSENT_POLL_TIMEOUT_MS / 6e4)} minutes. Grant access at ${dashboardUrl} and restart the proxy.`
|
|
2372
|
+
);
|
|
2373
|
+
}
|
|
2374
|
+
async function resolveAgentRecord(agentName, apiKey, baseUrl, logger, platform) {
|
|
2375
|
+
const cachedScopes = await loadCachedScopes(agentName, apiKey);
|
|
2376
|
+
if (cachedScopes !== null && cachedScopes.length > 0) {
|
|
2377
|
+
logger.debug("Loaded scopes from cache.", { agent: agentName, count: cachedScopes.length });
|
|
2378
|
+
}
|
|
2379
|
+
let agent = await findAgentByName(agentName, apiKey, baseUrl);
|
|
2380
|
+
if (agent?.authInvalid) {
|
|
2381
|
+
return agent;
|
|
2382
|
+
}
|
|
2383
|
+
if (agent === null) {
|
|
2384
|
+
try {
|
|
2385
|
+
logger.info("Agent not found. Registering.", { agent: agentName });
|
|
2386
|
+
const id = await registerAgent(agentName, apiKey, baseUrl, platform);
|
|
2387
|
+
agent = { id, name: agentName, scopes: [] };
|
|
2388
|
+
logger.info("Agent registered.", { agent: agentName, id });
|
|
2389
|
+
} catch (error) {
|
|
2390
|
+
if (error instanceof ShieldAuthError) {
|
|
2391
|
+
return { id: "", name: agentName, scopes: [], authInvalid: true };
|
|
2392
|
+
}
|
|
2393
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
2394
|
+
if (cachedScopes !== null && cachedScopes.length > 0) {
|
|
2395
|
+
logger.warn("Service unreachable. Using cached scopes.", { error: detail });
|
|
2396
|
+
return { id: "", name: agentName, scopes: cachedScopes };
|
|
2397
|
+
}
|
|
2398
|
+
logger.warn("Could not reach Multicorn service. Running with empty permissions.", {
|
|
2399
|
+
error: detail
|
|
2400
|
+
});
|
|
2401
|
+
return { id: "", name: agentName, scopes: [] };
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
const scopes = await fetchGrantedScopes(agent.id, apiKey, baseUrl);
|
|
2405
|
+
if (scopes.length > 0) {
|
|
2406
|
+
await saveCachedScopes(agentName, agent.id, scopes, apiKey);
|
|
2407
|
+
}
|
|
2408
|
+
return { ...agent, scopes };
|
|
2409
|
+
}
|
|
2410
|
+
function buildConsentUrl(agentName, scopes, dashboardUrl, platform) {
|
|
2411
|
+
const base = dashboardUrl.replace(/\/+$/, "");
|
|
2412
|
+
const params = new URLSearchParams({ agent: agentName });
|
|
2413
|
+
if (scopes.length > 0) {
|
|
2414
|
+
params.set("scopes", scopes.join(","));
|
|
2415
|
+
}
|
|
2416
|
+
if (platform) {
|
|
2417
|
+
params.set("platform", platform);
|
|
2418
|
+
}
|
|
2419
|
+
return `${base}/consent?${params.toString()}`;
|
|
2420
|
+
}
|
|
2421
|
+
function detectScopeHints() {
|
|
2422
|
+
return [];
|
|
2423
|
+
}
|
|
2424
|
+
function sleep2(ms) {
|
|
2425
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2426
|
+
}
|
|
2427
|
+
function isApiSuccessResponse(value) {
|
|
2428
|
+
if (typeof value !== "object" || value === null) return false;
|
|
2429
|
+
const obj = value;
|
|
2430
|
+
return obj["success"] === true;
|
|
2431
|
+
}
|
|
2432
|
+
function isAgentSummaryShape(value) {
|
|
2433
|
+
if (typeof value !== "object" || value === null) return false;
|
|
2434
|
+
const obj = value;
|
|
2435
|
+
return typeof obj["id"] === "string" && typeof obj["name"] === "string";
|
|
2436
|
+
}
|
|
2437
|
+
function isAgentDetailShape(value) {
|
|
2438
|
+
if (typeof value !== "object" || value === null) return false;
|
|
2439
|
+
const obj = value;
|
|
2440
|
+
return Array.isArray(obj["permissions"]);
|
|
2441
|
+
}
|
|
2442
|
+
function isPermissionShape(value) {
|
|
2443
|
+
if (typeof value !== "object" || value === null) return false;
|
|
2444
|
+
const obj = value;
|
|
2445
|
+
return typeof obj["service"] === "string" && typeof obj["read"] === "boolean" && typeof obj["write"] === "boolean" && typeof obj["execute"] === "boolean" && (obj["revoked_at"] === null || obj["revoked_at"] === void 0 || typeof obj["revoked_at"] === "string");
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
// src/proxy/index.ts
|
|
2449
|
+
var DEFAULT_SCOPE_REFRESH_INTERVAL_MS = 6e4;
|
|
2450
|
+
function createProxyServer(config) {
|
|
2451
|
+
if (!config.baseUrl.startsWith("https://") && !config.baseUrl.startsWith("http://localhost") && !config.baseUrl.startsWith("http://127.0.0.1")) {
|
|
2452
|
+
throw new Error(
|
|
2453
|
+
`[multicorn-shield] Base URL must use HTTPS. Received: "${config.baseUrl}". Use https:// or http://localhost for local development.`
|
|
2454
|
+
);
|
|
2455
|
+
}
|
|
2456
|
+
let child = null;
|
|
2457
|
+
let actionLogger = null;
|
|
2458
|
+
let spendingChecker = null;
|
|
2459
|
+
let grantedScopes = [];
|
|
2460
|
+
let agentId = "";
|
|
2461
|
+
let authInvalid = false;
|
|
2462
|
+
let refreshTimer = null;
|
|
2463
|
+
let consentInProgress = false;
|
|
2464
|
+
const pendingLines = [];
|
|
2465
|
+
let draining = false;
|
|
2466
|
+
let stopped = false;
|
|
2467
|
+
async function refreshScopes() {
|
|
2468
|
+
if (stopped) return;
|
|
2469
|
+
if (agentId.length === 0) return;
|
|
2470
|
+
try {
|
|
2471
|
+
const scopes = await fetchGrantedScopes(agentId, config.apiKey, config.baseUrl);
|
|
2472
|
+
grantedScopes = scopes;
|
|
2473
|
+
if (scopes.length > 0) {
|
|
2474
|
+
await saveCachedScopes(config.agentName, agentId, scopes, config.apiKey);
|
|
2475
|
+
}
|
|
2476
|
+
config.logger.debug("Scopes refreshed.", { count: scopes.length });
|
|
2477
|
+
} catch (error) {
|
|
2478
|
+
config.logger.warn("Scope refresh failed.", {
|
|
2479
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2480
|
+
});
|
|
2481
|
+
}
|
|
2482
|
+
}
|
|
2483
|
+
async function ensureConsent(requestedScope) {
|
|
2484
|
+
if (agentId.length === 0) return;
|
|
2485
|
+
if (requestedScope !== void 0) {
|
|
2486
|
+
if (hasScope(grantedScopes, requestedScope) || consentInProgress) return;
|
|
2487
|
+
} else {
|
|
2488
|
+
if (grantedScopes.length > 0 || consentInProgress) return;
|
|
2489
|
+
}
|
|
2490
|
+
consentInProgress = true;
|
|
2491
|
+
try {
|
|
2492
|
+
const scopeParam = requestedScope !== void 0 ? { service: requestedScope.service, permissionLevel: requestedScope.permissionLevel } : void 0;
|
|
2493
|
+
const scopes = await waitForConsent(
|
|
2494
|
+
agentId,
|
|
2495
|
+
config.agentName,
|
|
2496
|
+
config.apiKey,
|
|
2497
|
+
config.baseUrl,
|
|
2498
|
+
config.dashboardUrl,
|
|
2499
|
+
config.logger,
|
|
2500
|
+
scopeParam,
|
|
2501
|
+
config.platform ?? "other-mcp"
|
|
2502
|
+
);
|
|
2503
|
+
grantedScopes = scopes;
|
|
2504
|
+
await saveCachedScopes(config.agentName, agentId, scopes, config.apiKey);
|
|
2505
|
+
} finally {
|
|
2506
|
+
consentInProgress = false;
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
async function handleToolCall(line) {
|
|
2510
|
+
const request = parseJsonRpcLine(line);
|
|
2511
|
+
if (request === null) return null;
|
|
2512
|
+
if (request.method !== "tools/call") return null;
|
|
2513
|
+
const toolParams = extractToolCallParams(request);
|
|
2514
|
+
if (toolParams === null) return null;
|
|
2515
|
+
try {
|
|
2516
|
+
if (authInvalid) {
|
|
2517
|
+
const blocked = buildAuthErrorResponse(request.id);
|
|
2518
|
+
return JSON.stringify(blocked);
|
|
2519
|
+
}
|
|
2520
|
+
if (agentId.length === 0) {
|
|
2521
|
+
const blocked = buildServiceUnreachableResponse(request.id, config.dashboardUrl);
|
|
2522
|
+
return JSON.stringify(blocked);
|
|
2523
|
+
}
|
|
2524
|
+
const service = extractServiceFromToolName(toolParams.name);
|
|
2525
|
+
const action = extractActionFromToolName(toolParams.name);
|
|
2526
|
+
config.logger.debug("Extracted tool identity.", {
|
|
2527
|
+
tool: toolParams.name,
|
|
2528
|
+
service,
|
|
2529
|
+
action
|
|
2530
|
+
});
|
|
2531
|
+
const requestedScope = { service, permissionLevel: "execute" };
|
|
2532
|
+
const validation = validateScopeAccess(grantedScopes, requestedScope);
|
|
2533
|
+
config.logger.debug("Scope validation result.", {
|
|
2534
|
+
tool: toolParams.name,
|
|
2535
|
+
allowed: validation.allowed,
|
|
2536
|
+
scopeCount: grantedScopes.length
|
|
2537
|
+
});
|
|
2538
|
+
if (!validation.allowed) {
|
|
2539
|
+
await ensureConsent(requestedScope);
|
|
2540
|
+
const revalidation = validateScopeAccess(grantedScopes, requestedScope);
|
|
2541
|
+
config.logger.debug("Post-consent revalidation result.", {
|
|
2542
|
+
tool: toolParams.name,
|
|
2543
|
+
allowed: revalidation.allowed,
|
|
2544
|
+
scopeCount: grantedScopes.length
|
|
2545
|
+
});
|
|
2546
|
+
if (!revalidation.allowed) {
|
|
2547
|
+
if (actionLogger !== null) {
|
|
2548
|
+
if (!config.agentName || config.agentName.trim().length === 0) {
|
|
2549
|
+
process.stderr.write(
|
|
2550
|
+
"[multicorn-shield] Cannot log action: agent name not resolved\n"
|
|
2551
|
+
);
|
|
2552
|
+
} else {
|
|
2553
|
+
config.logger.debug("Logging blocked action (post-consent).", {
|
|
2554
|
+
agent: config.agentName,
|
|
2555
|
+
service,
|
|
2556
|
+
action
|
|
2557
|
+
});
|
|
2558
|
+
await actionLogger.logAction({
|
|
2559
|
+
agent: config.agentName,
|
|
2560
|
+
service,
|
|
2561
|
+
actionType: action,
|
|
2562
|
+
status: "blocked"
|
|
2563
|
+
});
|
|
2564
|
+
config.logger.debug("Blocked action logged.", { tool: toolParams.name });
|
|
2565
|
+
}
|
|
2566
|
+
}
|
|
2567
|
+
return JSON.stringify(
|
|
2568
|
+
buildBlockedResponse(request.id, service, "execute", config.dashboardUrl)
|
|
2569
|
+
);
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2572
|
+
if (spendingChecker !== null) {
|
|
2573
|
+
const costCents = extractCostCents(toolParams.arguments);
|
|
2574
|
+
if (costCents > 0) {
|
|
2575
|
+
const spendResult = spendingChecker.checkSpend(costCents);
|
|
2576
|
+
if (!spendResult.allowed) {
|
|
2577
|
+
if (actionLogger !== null) {
|
|
2578
|
+
if (!config.agentName || config.agentName.trim().length === 0) {
|
|
2579
|
+
process.stderr.write(
|
|
2580
|
+
"[multicorn-shield] Cannot log action: agent name not resolved\n"
|
|
2581
|
+
);
|
|
2582
|
+
} else {
|
|
2583
|
+
config.logger.debug("Logging blocked action (spending).", {
|
|
2584
|
+
agent: config.agentName,
|
|
2585
|
+
service,
|
|
2586
|
+
action
|
|
2587
|
+
});
|
|
2588
|
+
await actionLogger.logAction({
|
|
2589
|
+
agent: config.agentName,
|
|
2590
|
+
service,
|
|
2591
|
+
actionType: action,
|
|
2592
|
+
status: "blocked"
|
|
2593
|
+
});
|
|
2594
|
+
config.logger.debug("Spending-blocked action logged.", { tool: toolParams.name });
|
|
2595
|
+
}
|
|
2596
|
+
}
|
|
2597
|
+
const blocked = buildSpendingBlockedResponse(
|
|
2598
|
+
request.id,
|
|
2599
|
+
spendResult.reason ?? "spending limit exceeded",
|
|
2600
|
+
config.dashboardUrl
|
|
2601
|
+
);
|
|
2602
|
+
return JSON.stringify(blocked);
|
|
2603
|
+
}
|
|
2604
|
+
spendingChecker.recordSpend(costCents);
|
|
2605
|
+
}
|
|
2606
|
+
}
|
|
2607
|
+
if (actionLogger !== null) {
|
|
2608
|
+
if (!config.agentName || config.agentName.trim().length === 0) {
|
|
2609
|
+
process.stderr.write("[multicorn-shield] Cannot log action: agent name not resolved\n");
|
|
2610
|
+
} else {
|
|
2611
|
+
config.logger.debug("Logging approved action.", {
|
|
2612
|
+
agent: config.agentName,
|
|
2613
|
+
service,
|
|
2614
|
+
action
|
|
2615
|
+
});
|
|
2616
|
+
await actionLogger.logAction({
|
|
2617
|
+
agent: config.agentName,
|
|
2618
|
+
service,
|
|
2619
|
+
actionType: action,
|
|
2620
|
+
status: "approved"
|
|
2621
|
+
});
|
|
2622
|
+
config.logger.debug("Approved action logged.", { tool: toolParams.name });
|
|
2623
|
+
}
|
|
2624
|
+
}
|
|
2625
|
+
return null;
|
|
2626
|
+
} catch (error) {
|
|
2627
|
+
config.logger.error("Tool call handler error.", {
|
|
2628
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2629
|
+
});
|
|
2630
|
+
const blocked = buildInternalErrorResponse(request.id);
|
|
2631
|
+
return JSON.stringify(blocked);
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
async function processLine(line) {
|
|
2635
|
+
const childProcess = child;
|
|
2636
|
+
if (childProcess?.stdin === null || childProcess === null) return;
|
|
2637
|
+
const override = await handleToolCall(line);
|
|
2638
|
+
if (override !== null) {
|
|
2639
|
+
process.stdout.write(override + "\n");
|
|
2640
|
+
} else {
|
|
2641
|
+
childProcess.stdin.write(line + "\n");
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
async function drainQueue() {
|
|
2645
|
+
if (draining) return;
|
|
2646
|
+
draining = true;
|
|
2647
|
+
while (pendingLines.length > 0) {
|
|
2648
|
+
const line = pendingLines.shift();
|
|
2649
|
+
if (line === void 0) break;
|
|
2650
|
+
await processLine(line);
|
|
2651
|
+
}
|
|
2652
|
+
draining = false;
|
|
2653
|
+
}
|
|
2654
|
+
function enqueueLine(line) {
|
|
2655
|
+
pendingLines.push(line);
|
|
2656
|
+
void drainQueue();
|
|
2657
|
+
}
|
|
2658
|
+
async function stop() {
|
|
2659
|
+
stopped = true;
|
|
2660
|
+
if (refreshTimer !== null) {
|
|
2661
|
+
clearInterval(refreshTimer);
|
|
2662
|
+
refreshTimer = null;
|
|
2663
|
+
}
|
|
2664
|
+
if (actionLogger !== null) {
|
|
2665
|
+
await actionLogger.shutdown();
|
|
2666
|
+
actionLogger = null;
|
|
2667
|
+
}
|
|
2668
|
+
const childProcess = child;
|
|
2669
|
+
if (childProcess !== null) {
|
|
2670
|
+
childProcess.kill("SIGTERM");
|
|
2671
|
+
child = null;
|
|
2672
|
+
}
|
|
2673
|
+
}
|
|
2674
|
+
async function start() {
|
|
2675
|
+
config.logger.info("Proxy starting.", { agent: config.agentName, command: config.command });
|
|
2676
|
+
const agentRecord = await resolveAgentRecord(
|
|
2677
|
+
config.agentName,
|
|
2678
|
+
config.apiKey,
|
|
2679
|
+
config.baseUrl,
|
|
2680
|
+
config.logger,
|
|
2681
|
+
config.platform ?? "other-mcp"
|
|
2682
|
+
);
|
|
2683
|
+
agentId = agentRecord.id;
|
|
2684
|
+
grantedScopes = agentRecord.scopes;
|
|
2685
|
+
authInvalid = agentRecord.authInvalid === true;
|
|
2686
|
+
if (authInvalid) {
|
|
2687
|
+
config.logger.error("API key rejected by the Multicorn service.", {
|
|
2688
|
+
agent: config.agentName
|
|
2689
|
+
});
|
|
2690
|
+
process.stderr.write(
|
|
2691
|
+
"\nError: API key was rejected by the Multicorn service.\nCheck your key at https://app.multicorn.ai/settings#api-keys or run `npx multicorn-shield init` to reconfigure.\n\n"
|
|
29
2692
|
);
|
|
2693
|
+
throw new Error("API key was rejected by the Multicorn service.");
|
|
2694
|
+
}
|
|
2695
|
+
config.logger.info("Agent resolved.", {
|
|
2696
|
+
agent: config.agentName,
|
|
2697
|
+
id: agentId,
|
|
2698
|
+
scopeCount: grantedScopes.length
|
|
2699
|
+
});
|
|
2700
|
+
actionLogger = createActionLogger({
|
|
2701
|
+
apiKey: config.apiKey,
|
|
2702
|
+
baseUrl: config.baseUrl,
|
|
2703
|
+
batchMode: { enabled: false },
|
|
2704
|
+
onError: (err) => {
|
|
2705
|
+
config.logger.warn("Action log failed.", { error: err.message });
|
|
2706
|
+
}
|
|
2707
|
+
});
|
|
2708
|
+
if (config.spendingLimits !== void 0) {
|
|
2709
|
+
spendingChecker = createSpendingChecker({ limits: config.spendingLimits });
|
|
2710
|
+
}
|
|
2711
|
+
child = spawn(config.command, config.commandArgs, {
|
|
2712
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
2713
|
+
});
|
|
2714
|
+
const childProcess = child;
|
|
2715
|
+
childProcess.on("error", (err) => {
|
|
2716
|
+
config.logger.error("Child process error.", { error: err.message });
|
|
2717
|
+
});
|
|
2718
|
+
childProcess.on("exit", (code, signal) => {
|
|
2719
|
+
config.logger.info("Child process exited.", {
|
|
2720
|
+
code: code ?? void 0,
|
|
2721
|
+
signal: signal ?? void 0
|
|
2722
|
+
});
|
|
2723
|
+
});
|
|
2724
|
+
if (childProcess.stdout !== null) {
|
|
2725
|
+
childProcess.stdout.on("data", (chunk) => {
|
|
2726
|
+
process.stdout.write(chunk);
|
|
2727
|
+
});
|
|
2728
|
+
}
|
|
2729
|
+
if (childProcess.stderr !== null) {
|
|
2730
|
+
childProcess.stderr.on("data", (chunk) => {
|
|
2731
|
+
config.logger.debug("Child stderr.", { data: chunk.toString().trim() });
|
|
2732
|
+
});
|
|
2733
|
+
}
|
|
2734
|
+
const rl = createInterface({ input: process.stdin, terminal: false });
|
|
2735
|
+
rl.on("line", (line) => {
|
|
2736
|
+
enqueueLine(line);
|
|
2737
|
+
});
|
|
2738
|
+
rl.on("close", () => {
|
|
2739
|
+
config.logger.info("Agent disconnected. Shutting down.");
|
|
2740
|
+
void stop();
|
|
2741
|
+
});
|
|
2742
|
+
const refreshIntervalMs = config.scopeRefreshIntervalMs ?? DEFAULT_SCOPE_REFRESH_INTERVAL_MS;
|
|
2743
|
+
refreshTimer = setInterval(() => {
|
|
2744
|
+
void refreshScopes();
|
|
2745
|
+
}, refreshIntervalMs);
|
|
2746
|
+
const timer = refreshTimer;
|
|
2747
|
+
if (typeof timer.unref === "function") {
|
|
2748
|
+
timer.unref();
|
|
2749
|
+
}
|
|
2750
|
+
config.logger.info("Proxy ready.", { agent: config.agentName });
|
|
2751
|
+
return new Promise((resolve) => {
|
|
2752
|
+
childProcess.on("exit", () => {
|
|
2753
|
+
resolve();
|
|
2754
|
+
});
|
|
2755
|
+
});
|
|
2756
|
+
}
|
|
2757
|
+
return { start, stop };
|
|
2758
|
+
}
|
|
2759
|
+
function extractCostCents(args) {
|
|
2760
|
+
const amount = args["amount"];
|
|
2761
|
+
if (typeof amount !== "number" || !Number.isFinite(amount) || amount <= 0) return 0;
|
|
2762
|
+
return dollarsToCents(amount);
|
|
2763
|
+
}
|
|
2764
|
+
var LOG_LEVELS = {
|
|
2765
|
+
debug: 0,
|
|
2766
|
+
info: 1,
|
|
2767
|
+
warn: 2,
|
|
2768
|
+
error: 3
|
|
2769
|
+
};
|
|
2770
|
+
function createLogger(level, output = process.stderr) {
|
|
2771
|
+
const minLevel = LOG_LEVELS[level];
|
|
2772
|
+
function write(logLevel, msg, data) {
|
|
2773
|
+
if (LOG_LEVELS[logLevel] < minLevel) return;
|
|
2774
|
+
const entry = {
|
|
2775
|
+
level: logLevel,
|
|
2776
|
+
time: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2777
|
+
msg,
|
|
2778
|
+
...data
|
|
2779
|
+
};
|
|
2780
|
+
output.write(JSON.stringify(entry) + "\n");
|
|
30
2781
|
}
|
|
2782
|
+
return {
|
|
2783
|
+
debug: (msg, data) => {
|
|
2784
|
+
write("debug", msg, data);
|
|
2785
|
+
},
|
|
2786
|
+
info: (msg, data) => {
|
|
2787
|
+
write("info", msg, data);
|
|
2788
|
+
},
|
|
2789
|
+
warn: (msg, data) => {
|
|
2790
|
+
write("warn", msg, data);
|
|
2791
|
+
},
|
|
2792
|
+
error: (msg, data) => {
|
|
2793
|
+
write("error", msg, data);
|
|
2794
|
+
}
|
|
2795
|
+
};
|
|
2796
|
+
}
|
|
2797
|
+
function isValidLogLevel(value) {
|
|
2798
|
+
return typeof value === "string" && Object.hasOwn(LOG_LEVELS, value);
|
|
31
2799
|
}
|
|
32
2800
|
var EXTENSION_BACKUP_FILENAME = "extension-backup.json";
|
|
33
2801
|
function getExtensionBackupPath() {
|
|
@@ -58,7 +2826,7 @@ async function readExtensionBackup() {
|
|
|
58
2826
|
}
|
|
59
2827
|
|
|
60
2828
|
// src/extension/restore.ts
|
|
61
|
-
function
|
|
2829
|
+
function isErrnoException2(e) {
|
|
62
2830
|
return typeof e === "object" && e !== null && "code" in e;
|
|
63
2831
|
}
|
|
64
2832
|
function isRecord2(value) {
|
|
@@ -80,7 +2848,7 @@ async function restoreClaudeDesktopMcpFromBackup() {
|
|
|
80
2848
|
root = parsed;
|
|
81
2849
|
}
|
|
82
2850
|
} catch (error) {
|
|
83
|
-
if (!
|
|
2851
|
+
if (!isErrnoException2(error) || error.code !== "ENOENT") {
|
|
84
2852
|
throw error;
|
|
85
2853
|
}
|
|
86
2854
|
}
|
|
@@ -90,30 +2858,322 @@ async function restoreClaudeDesktopMcpFromBackup() {
|
|
|
90
2858
|
}
|
|
91
2859
|
|
|
92
2860
|
// bin/multicorn-shield.ts
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
2861
|
+
function parseArgs(argv) {
|
|
2862
|
+
const args = argv.slice(2);
|
|
2863
|
+
let subcommand = "help";
|
|
2864
|
+
let wrapCommand = "";
|
|
2865
|
+
let wrapArgs = [];
|
|
2866
|
+
let logLevel = "info";
|
|
2867
|
+
let baseUrl = void 0;
|
|
2868
|
+
let dashboardUrl = "";
|
|
2869
|
+
let agentName = "";
|
|
2870
|
+
let deleteAgentName = "";
|
|
2871
|
+
let apiKey = void 0;
|
|
2872
|
+
for (let i = 0; i < args.length; i++) {
|
|
2873
|
+
const arg = args[i];
|
|
2874
|
+
if (arg === "init") {
|
|
2875
|
+
subcommand = "init";
|
|
2876
|
+
} else if (arg === "agents") {
|
|
2877
|
+
subcommand = "agents";
|
|
2878
|
+
} else if (arg === "delete-agent") {
|
|
2879
|
+
subcommand = "delete-agent";
|
|
2880
|
+
const name = args[i + 1];
|
|
2881
|
+
if (name === void 0 || name.startsWith("-")) {
|
|
2882
|
+
process.stderr.write("Error: delete-agent requires an agent name.\n");
|
|
2883
|
+
process.stderr.write("Example: npx multicorn-shield delete-agent my-agent\n");
|
|
2884
|
+
process.exit(1);
|
|
2885
|
+
}
|
|
2886
|
+
deleteAgentName = name;
|
|
2887
|
+
i++;
|
|
2888
|
+
} else if (arg === "--wrap") {
|
|
2889
|
+
subcommand = "wrap";
|
|
2890
|
+
const tail = args.slice(i + 1);
|
|
2891
|
+
const remaining = [];
|
|
2892
|
+
for (let j = 0; j < tail.length; j++) {
|
|
2893
|
+
const token = tail[j];
|
|
2894
|
+
if (token === void 0) continue;
|
|
2895
|
+
if (remaining.length > 0) {
|
|
2896
|
+
remaining.push(token);
|
|
2897
|
+
continue;
|
|
2898
|
+
}
|
|
2899
|
+
if (token === "--agent-name") {
|
|
2900
|
+
const value = tail[j + 1];
|
|
2901
|
+
if (value !== void 0) {
|
|
2902
|
+
agentName = value;
|
|
2903
|
+
j++;
|
|
2904
|
+
}
|
|
2905
|
+
} else if (token === "--log-level") {
|
|
2906
|
+
const value = tail[j + 1];
|
|
2907
|
+
if (value !== void 0 && isValidLogLevel(value)) {
|
|
2908
|
+
logLevel = value;
|
|
2909
|
+
j++;
|
|
2910
|
+
}
|
|
2911
|
+
} else if (token === "--base-url") {
|
|
2912
|
+
const value = tail[j + 1];
|
|
2913
|
+
if (value !== void 0) {
|
|
2914
|
+
baseUrl = value;
|
|
2915
|
+
j++;
|
|
2916
|
+
}
|
|
2917
|
+
} else if (token === "--dashboard-url") {
|
|
2918
|
+
const value = tail[j + 1];
|
|
2919
|
+
if (value !== void 0) {
|
|
2920
|
+
dashboardUrl = value;
|
|
2921
|
+
j++;
|
|
2922
|
+
}
|
|
2923
|
+
} else if (token === "--api-key") {
|
|
2924
|
+
const value = tail[j + 1];
|
|
2925
|
+
if (value !== void 0) {
|
|
2926
|
+
apiKey = value;
|
|
2927
|
+
j++;
|
|
2928
|
+
}
|
|
2929
|
+
} else {
|
|
2930
|
+
remaining.push(token);
|
|
2931
|
+
}
|
|
2932
|
+
}
|
|
2933
|
+
if (remaining.length === 0) {
|
|
2934
|
+
process.stderr.write("Error: --wrap requires a command to run.\n");
|
|
2935
|
+
process.stderr.write("Example: npx multicorn-shield --wrap my-mcp-server\n");
|
|
2936
|
+
process.exit(1);
|
|
2937
|
+
}
|
|
2938
|
+
wrapCommand = remaining[0] ?? "";
|
|
2939
|
+
wrapArgs = remaining.slice(1);
|
|
2940
|
+
break;
|
|
2941
|
+
} else if (arg === "--log-level") {
|
|
2942
|
+
const next = args[i + 1];
|
|
2943
|
+
if (next !== void 0 && isValidLogLevel(next)) {
|
|
2944
|
+
logLevel = next;
|
|
2945
|
+
i++;
|
|
2946
|
+
}
|
|
2947
|
+
} else if (arg === "--base-url") {
|
|
2948
|
+
const next = args[i + 1];
|
|
2949
|
+
if (next !== void 0) {
|
|
2950
|
+
baseUrl = next;
|
|
2951
|
+
i++;
|
|
2952
|
+
}
|
|
2953
|
+
} else if (arg === "--dashboard-url") {
|
|
2954
|
+
const next = args[i + 1];
|
|
2955
|
+
if (next !== void 0) {
|
|
2956
|
+
dashboardUrl = next;
|
|
2957
|
+
i++;
|
|
2958
|
+
}
|
|
2959
|
+
} else if (arg === "--agent-name") {
|
|
2960
|
+
const next = args[i + 1];
|
|
2961
|
+
if (next !== void 0) {
|
|
2962
|
+
agentName = next;
|
|
2963
|
+
i++;
|
|
2964
|
+
}
|
|
2965
|
+
} else if (arg === "--api-key") {
|
|
2966
|
+
const next = args[i + 1];
|
|
2967
|
+
if (next !== void 0) {
|
|
2968
|
+
apiKey = next;
|
|
2969
|
+
i++;
|
|
2970
|
+
}
|
|
2971
|
+
}
|
|
101
2972
|
}
|
|
2973
|
+
return {
|
|
2974
|
+
subcommand,
|
|
2975
|
+
wrapCommand,
|
|
2976
|
+
wrapArgs,
|
|
2977
|
+
logLevel,
|
|
2978
|
+
baseUrl,
|
|
2979
|
+
dashboardUrl,
|
|
2980
|
+
agentName,
|
|
2981
|
+
deleteAgentName,
|
|
2982
|
+
apiKey
|
|
2983
|
+
};
|
|
2984
|
+
}
|
|
2985
|
+
function printHelp() {
|
|
102
2986
|
process.stderr.write(
|
|
103
2987
|
[
|
|
104
|
-
"
|
|
2988
|
+
"multicorn-shield: MCP permission proxy and Shield setup",
|
|
105
2989
|
"",
|
|
106
2990
|
"Usage:",
|
|
2991
|
+
" npx multicorn-shield init",
|
|
2992
|
+
" Interactive setup. Saves API key to ~/.multicorn/config.json.",
|
|
2993
|
+
"",
|
|
107
2994
|
" npx multicorn-shield restore",
|
|
108
2995
|
" Restore MCP servers in claude_desktop_config.json from the Shield extension backup.",
|
|
2996
|
+
"",
|
|
2997
|
+
" npx multicorn-shield agents",
|
|
2998
|
+
" List configured agents and show which is the default.",
|
|
2999
|
+
"",
|
|
3000
|
+
" npx multicorn-shield delete-agent <name>",
|
|
3001
|
+
" Remove a saved agent.",
|
|
3002
|
+
"",
|
|
3003
|
+
" npx multicorn-shield --wrap <command> [args...]",
|
|
3004
|
+
" Start <command> as an MCP server and proxy all tool calls through",
|
|
3005
|
+
" Shield's permission layer.",
|
|
3006
|
+
"",
|
|
3007
|
+
"Options:",
|
|
3008
|
+
" --api-key <key> Multicorn API key (overrides MULTICORN_API_KEY env var and config file)",
|
|
3009
|
+
" --log-level <level> Log level: debug | info | warn | error (default: info)",
|
|
3010
|
+
" --base-url <url> Multicorn API base URL (default: https://api.multicorn.ai)",
|
|
3011
|
+
" --dashboard-url <url> Dashboard URL for consent page (default: derived from --base-url)",
|
|
3012
|
+
" --agent-name <name> Override agent name derived from the wrapped command",
|
|
3013
|
+
"",
|
|
3014
|
+
"Examples:",
|
|
3015
|
+
" npx multicorn-shield init",
|
|
3016
|
+
" npx multicorn-shield --wrap npx @modelcontextprotocol/server-filesystem /tmp",
|
|
3017
|
+
" npx multicorn-shield --wrap my-mcp-server --log-level debug",
|
|
109
3018
|
""
|
|
110
3019
|
].join("\n")
|
|
111
3020
|
);
|
|
112
|
-
process.exit(arg === void 0 || arg === "help" || arg === "--help" ? 0 : 1);
|
|
113
3021
|
}
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
|
|
3022
|
+
async function runCli() {
|
|
3023
|
+
const first = process.argv[2];
|
|
3024
|
+
if (first === "restore") {
|
|
3025
|
+
await restoreClaudeDesktopMcpFromBackup();
|
|
3026
|
+
process.stderr.write(
|
|
3027
|
+
"Restored MCP server entries from ~/.multicorn/extension-backup.json into Claude Desktop config.\nRestart Claude Desktop to apply changes.\n"
|
|
3028
|
+
);
|
|
3029
|
+
return;
|
|
3030
|
+
}
|
|
3031
|
+
const cli = parseArgs(process.argv);
|
|
3032
|
+
const logger = createLogger(cli.logLevel);
|
|
3033
|
+
if (cli.subcommand === "help") {
|
|
3034
|
+
printHelp();
|
|
3035
|
+
process.exit(0);
|
|
3036
|
+
}
|
|
3037
|
+
if (cli.subcommand === "init") {
|
|
3038
|
+
await runInit(cli.baseUrl);
|
|
3039
|
+
return;
|
|
3040
|
+
}
|
|
3041
|
+
if (cli.subcommand === "agents") {
|
|
3042
|
+
const config2 = await loadConfig();
|
|
3043
|
+
if (config2 === null) {
|
|
3044
|
+
process.stderr.write(
|
|
3045
|
+
"No config found. Run `npx multicorn-shield init` to set up your API key.\n"
|
|
3046
|
+
);
|
|
3047
|
+
process.exit(1);
|
|
3048
|
+
}
|
|
3049
|
+
const agents = collectAgentsFromConfig(config2);
|
|
3050
|
+
if (agents.length === 0) {
|
|
3051
|
+
process.stderr.write("No agents configured. Run `npx multicorn-shield init` to add one.\n");
|
|
3052
|
+
process.exit(0);
|
|
3053
|
+
}
|
|
3054
|
+
const def = config2.defaultAgent;
|
|
3055
|
+
process.stdout.write("Configured agents:\n");
|
|
3056
|
+
for (const a of agents) {
|
|
3057
|
+
const mark = a.name === def ? " (default)" : "";
|
|
3058
|
+
process.stdout.write(`${a.name} (${a.platform})${mark}
|
|
3059
|
+
`);
|
|
3060
|
+
}
|
|
3061
|
+
return;
|
|
3062
|
+
}
|
|
3063
|
+
if (cli.subcommand === "delete-agent") {
|
|
3064
|
+
const safeName = cli.deleteAgentName.replace(/[^\x20-\x7E]/g, "");
|
|
3065
|
+
const ok = await deleteAgentByName(cli.deleteAgentName);
|
|
3066
|
+
if (!ok) {
|
|
3067
|
+
process.stderr.write(`No agent named "${safeName}" in config.
|
|
3068
|
+
`);
|
|
3069
|
+
process.exit(1);
|
|
3070
|
+
}
|
|
3071
|
+
process.stdout.write(`Removed agent "${safeName}".
|
|
117
3072
|
`);
|
|
3073
|
+
return;
|
|
3074
|
+
}
|
|
3075
|
+
if (cli.baseUrl !== void 0 && cli.baseUrl.length > 0) {
|
|
3076
|
+
if (!isAllowedShieldApiBaseUrl(cli.baseUrl)) {
|
|
3077
|
+
process.stderr.write(
|
|
3078
|
+
"Error: --base-url must use HTTPS. Received a non-HTTPS URL.\nUse https:// or http://localhost for local development.\n"
|
|
3079
|
+
);
|
|
3080
|
+
process.exit(1);
|
|
3081
|
+
}
|
|
3082
|
+
}
|
|
3083
|
+
const config = await resolveWrapConfig(cli, logger);
|
|
3084
|
+
const finalBaseUrl = cli.baseUrl !== void 0 && cli.baseUrl.length > 0 ? cli.baseUrl : config.baseUrl;
|
|
3085
|
+
if (cli.baseUrl === void 0 || cli.baseUrl.length === 0) {
|
|
3086
|
+
if (!isAllowedShieldApiBaseUrl(finalBaseUrl)) {
|
|
3087
|
+
process.stderr.write(
|
|
3088
|
+
"Error: --base-url must use HTTPS. Received a non-HTTPS URL.\nUse https:// or http://localhost for local development.\n"
|
|
3089
|
+
);
|
|
3090
|
+
process.exit(1);
|
|
3091
|
+
}
|
|
3092
|
+
}
|
|
3093
|
+
const agentName = resolveWrapAgentName(cli, config);
|
|
3094
|
+
const finalDashboardUrl = cli.dashboardUrl !== "" ? cli.dashboardUrl : deriveDashboardUrl(finalBaseUrl);
|
|
3095
|
+
const legacyPlatform = config.platform;
|
|
3096
|
+
const platformForServer = typeof legacyPlatform === "string" && legacyPlatform.length > 0 ? legacyPlatform : "other-mcp";
|
|
3097
|
+
const proxy = createProxyServer({
|
|
3098
|
+
command: cli.wrapCommand,
|
|
3099
|
+
commandArgs: cli.wrapArgs,
|
|
3100
|
+
apiKey: config.apiKey,
|
|
3101
|
+
agentName,
|
|
3102
|
+
baseUrl: finalBaseUrl,
|
|
3103
|
+
dashboardUrl: finalDashboardUrl,
|
|
3104
|
+
logger,
|
|
3105
|
+
platform: platformForServer
|
|
3106
|
+
});
|
|
3107
|
+
async function shutdown() {
|
|
3108
|
+
logger.info("Shutting down.");
|
|
3109
|
+
await proxy.stop();
|
|
3110
|
+
process.exit(0);
|
|
3111
|
+
}
|
|
3112
|
+
process.on("SIGTERM", () => {
|
|
3113
|
+
void shutdown();
|
|
3114
|
+
});
|
|
3115
|
+
process.on("SIGINT", () => {
|
|
3116
|
+
void shutdown();
|
|
3117
|
+
});
|
|
3118
|
+
await proxy.start();
|
|
3119
|
+
}
|
|
3120
|
+
async function resolveWrapConfig(cli, logger) {
|
|
3121
|
+
if (cli.apiKey !== void 0 && cli.apiKey.length > 0) {
|
|
3122
|
+
logger.debug("Using API key from --api-key flag.");
|
|
3123
|
+
return {
|
|
3124
|
+
apiKey: cli.apiKey,
|
|
3125
|
+
baseUrl: cli.baseUrl ?? DEFAULT_SHIELD_API_BASE_URL
|
|
3126
|
+
};
|
|
3127
|
+
}
|
|
3128
|
+
const envKey = process.env["MULTICORN_API_KEY"];
|
|
3129
|
+
if (typeof envKey === "string" && envKey.length > 0) {
|
|
3130
|
+
logger.debug("Using API key from MULTICORN_API_KEY environment variable.");
|
|
3131
|
+
return {
|
|
3132
|
+
apiKey: envKey,
|
|
3133
|
+
baseUrl: cli.baseUrl ?? DEFAULT_SHIELD_API_BASE_URL
|
|
3134
|
+
};
|
|
3135
|
+
}
|
|
3136
|
+
const config = await loadConfig();
|
|
3137
|
+
if (config !== null) {
|
|
3138
|
+
return config;
|
|
3139
|
+
}
|
|
3140
|
+
process.stderr.write(
|
|
3141
|
+
"No API key found. Provide one via the --api-key flag, the MULTICORN_API_KEY environment variable, or run `npx multicorn-shield init` to set up a config file.\n"
|
|
3142
|
+
);
|
|
118
3143
|
process.exit(1);
|
|
119
|
-
}
|
|
3144
|
+
}
|
|
3145
|
+
function resolveWrapAgentName(cli, config) {
|
|
3146
|
+
if (cli.agentName.length > 0) {
|
|
3147
|
+
return cli.agentName;
|
|
3148
|
+
}
|
|
3149
|
+
const legacyPlatform = config.platform;
|
|
3150
|
+
const legacyAgentName = config.agentName;
|
|
3151
|
+
const platformKey = typeof legacyPlatform === "string" && legacyPlatform.length > 0 ? legacyPlatform : "other-mcp";
|
|
3152
|
+
const fromPlatform = getAgentByPlatform(config, platformKey);
|
|
3153
|
+
if (fromPlatform !== void 0) {
|
|
3154
|
+
return fromPlatform.name;
|
|
3155
|
+
}
|
|
3156
|
+
const fallbackDefault = getDefaultAgent(config);
|
|
3157
|
+
if (fallbackDefault !== void 0) {
|
|
3158
|
+
return fallbackDefault.name;
|
|
3159
|
+
}
|
|
3160
|
+
if (typeof legacyAgentName === "string" && legacyAgentName.length > 0) {
|
|
3161
|
+
return legacyAgentName;
|
|
3162
|
+
}
|
|
3163
|
+
return deriveAgentName(cli.wrapCommand);
|
|
3164
|
+
}
|
|
3165
|
+
function deriveAgentName(command) {
|
|
3166
|
+
const base = command.split("/").pop() ?? command;
|
|
3167
|
+
return base.replace(/\.[cm]?[jt]s$/, "");
|
|
3168
|
+
}
|
|
3169
|
+
var isDirectRun = process.argv[1] !== void 0 && (import.meta.url.endsWith(process.argv[1]) || import.meta.url === `file://${process.argv[1]}` || import.meta.url.endsWith("/multicorn-shield.js") || import.meta.url.endsWith("/multicorn-shield.ts"));
|
|
3170
|
+
if (isDirectRun && process.env["VITEST"] === void 0) {
|
|
3171
|
+
runCli().catch((error) => {
|
|
3172
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3173
|
+
process.stderr.write(`Fatal error: ${message}
|
|
3174
|
+
`);
|
|
3175
|
+
process.exit(1);
|
|
3176
|
+
});
|
|
3177
|
+
}
|
|
3178
|
+
|
|
3179
|
+
export { parseArgs, resolveWrapConfig, runCli };
|