multicorn-shield 0.1.15 → 0.1.16
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/dist/multicorn-proxy.js +78 -40
- package/dist/openclaw-hook/handler.js +89 -19
- package/dist/openclaw-plugin/multicorn-shield.js +110 -31
- package/package.json +1 -1
package/dist/multicorn-proxy.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFile, mkdir, writeFile } from 'fs/promises';
|
|
2
|
+
import { readFile, mkdir, writeFile, unlink } from 'fs/promises';
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
import { homedir } from 'os';
|
|
5
5
|
import { createInterface } from 'readline';
|
|
6
6
|
import { spawn } from 'child_process';
|
|
7
|
+
import { createHash } from 'crypto';
|
|
7
8
|
import 'stream';
|
|
8
9
|
|
|
9
10
|
var CONFIG_DIR = join(homedir(), ".multicorn");
|
|
@@ -610,51 +611,53 @@ function capitalize(str) {
|
|
|
610
611
|
}
|
|
611
612
|
var MULTICORN_DIR = join(homedir(), ".multicorn");
|
|
612
613
|
var SCOPES_PATH = join(MULTICORN_DIR, "scopes.json");
|
|
613
|
-
var
|
|
614
|
-
|
|
615
|
-
|
|
614
|
+
var CACHE_META_PATH = join(MULTICORN_DIR, "cache-meta.json");
|
|
615
|
+
function cacheKey(agentName, apiKey) {
|
|
616
|
+
return createHash("sha256").update(`${agentName}:${apiKey}`).digest("hex").slice(0, 16);
|
|
617
|
+
}
|
|
618
|
+
async function ensureCacheIdentity(apiKey) {
|
|
619
|
+
const currentHash = createHash("sha256").update(apiKey).digest("hex");
|
|
620
|
+
let storedHash = null;
|
|
616
621
|
try {
|
|
617
|
-
const
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
return url.toString();
|
|
622
|
+
const raw = await readFile(CACHE_META_PATH, "utf8");
|
|
623
|
+
const meta = JSON.parse(raw);
|
|
624
|
+
if (typeof meta === "object" && meta !== null && "apiKeyHash" in meta) {
|
|
625
|
+
storedHash = meta.apiKeyHash;
|
|
622
626
|
}
|
|
623
|
-
if (url.hostname === "api.multicorn.ai") {
|
|
624
|
-
url.hostname = "app.multicorn.ai";
|
|
625
|
-
return url.toString();
|
|
626
|
-
}
|
|
627
|
-
if (url.hostname.includes("api")) {
|
|
628
|
-
url.hostname = url.hostname.replace("api", "app");
|
|
629
|
-
return url.toString();
|
|
630
|
-
}
|
|
631
|
-
if (url.protocol === "https:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1") {
|
|
632
|
-
return "https://app.multicorn.ai";
|
|
633
|
-
}
|
|
634
|
-
return "https://app.multicorn.ai";
|
|
635
627
|
} catch {
|
|
636
|
-
return "https://app.multicorn.ai";
|
|
637
628
|
}
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
Object.setPrototypeOf(this, _ShieldAuthError.prototype);
|
|
629
|
+
if (storedHash === null || storedHash !== currentHash) {
|
|
630
|
+
try {
|
|
631
|
+
await unlink(SCOPES_PATH);
|
|
632
|
+
} catch {
|
|
633
|
+
}
|
|
644
634
|
}
|
|
645
|
-
|
|
646
|
-
|
|
635
|
+
if (storedHash !== currentHash) {
|
|
636
|
+
await mkdir(MULTICORN_DIR, { recursive: true, mode: 448 });
|
|
637
|
+
await writeFile(CACHE_META_PATH, JSON.stringify({ apiKeyHash: currentHash }, null, 2) + "\n", {
|
|
638
|
+
encoding: "utf8",
|
|
639
|
+
mode: 384
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
async function loadCachedScopes(agentName, apiKey) {
|
|
644
|
+
if (apiKey.length === 0) return null;
|
|
645
|
+
await ensureCacheIdentity(apiKey);
|
|
646
|
+
const key = cacheKey(agentName, apiKey);
|
|
647
647
|
try {
|
|
648
648
|
const raw = await readFile(SCOPES_PATH, "utf8");
|
|
649
649
|
const parsed = JSON.parse(raw);
|
|
650
650
|
if (!isScopesCacheFile(parsed)) return null;
|
|
651
|
-
const entry = parsed[
|
|
651
|
+
const entry = parsed[key];
|
|
652
652
|
return entry?.scopes ?? null;
|
|
653
653
|
} catch {
|
|
654
654
|
return null;
|
|
655
655
|
}
|
|
656
656
|
}
|
|
657
|
-
async function saveCachedScopes(agentName, agentId, scopes) {
|
|
657
|
+
async function saveCachedScopes(agentName, agentId, scopes, apiKey) {
|
|
658
|
+
if (apiKey.length === 0) return;
|
|
659
|
+
await ensureCacheIdentity(apiKey);
|
|
660
|
+
const key = cacheKey(agentName, apiKey);
|
|
658
661
|
await mkdir(MULTICORN_DIR, { recursive: true, mode: 448 });
|
|
659
662
|
let existing = {};
|
|
660
663
|
try {
|
|
@@ -665,7 +668,7 @@ async function saveCachedScopes(agentName, agentId, scopes) {
|
|
|
665
668
|
}
|
|
666
669
|
const updated = {
|
|
667
670
|
...existing,
|
|
668
|
-
[
|
|
671
|
+
[key]: {
|
|
669
672
|
agentId,
|
|
670
673
|
scopes,
|
|
671
674
|
fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
@@ -676,6 +679,44 @@ async function saveCachedScopes(agentName, agentId, scopes) {
|
|
|
676
679
|
mode: 384
|
|
677
680
|
});
|
|
678
681
|
}
|
|
682
|
+
function isScopesCacheFile(value) {
|
|
683
|
+
return typeof value === "object" && value !== null;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// src/proxy/consent.ts
|
|
687
|
+
var CONSENT_POLL_INTERVAL_MS = 3e3;
|
|
688
|
+
var CONSENT_POLL_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
689
|
+
function deriveDashboardUrl(baseUrl) {
|
|
690
|
+
try {
|
|
691
|
+
const url = new URL(baseUrl);
|
|
692
|
+
if (url.hostname === "localhost" || url.hostname === "127.0.0.1") {
|
|
693
|
+
url.port = "5173";
|
|
694
|
+
url.protocol = "http:";
|
|
695
|
+
return url.toString();
|
|
696
|
+
}
|
|
697
|
+
if (url.hostname === "api.multicorn.ai") {
|
|
698
|
+
url.hostname = "app.multicorn.ai";
|
|
699
|
+
return url.toString();
|
|
700
|
+
}
|
|
701
|
+
if (url.hostname.includes("api")) {
|
|
702
|
+
url.hostname = url.hostname.replace("api", "app");
|
|
703
|
+
return url.toString();
|
|
704
|
+
}
|
|
705
|
+
if (url.protocol === "https:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1") {
|
|
706
|
+
return "https://app.multicorn.ai";
|
|
707
|
+
}
|
|
708
|
+
return "https://app.multicorn.ai";
|
|
709
|
+
} catch {
|
|
710
|
+
return "https://app.multicorn.ai";
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
var ShieldAuthError = class _ShieldAuthError extends Error {
|
|
714
|
+
constructor(message) {
|
|
715
|
+
super(message);
|
|
716
|
+
this.name = "ShieldAuthError";
|
|
717
|
+
Object.setPrototypeOf(this, _ShieldAuthError.prototype);
|
|
718
|
+
}
|
|
719
|
+
};
|
|
679
720
|
async function findAgentByName(agentName, apiKey, baseUrl) {
|
|
680
721
|
let response;
|
|
681
722
|
try {
|
|
@@ -796,7 +837,7 @@ Waiting for you to grant access in the Multicorn dashboard...
|
|
|
796
837
|
);
|
|
797
838
|
}
|
|
798
839
|
async function resolveAgentRecord(agentName, apiKey, baseUrl, logger) {
|
|
799
|
-
const cachedScopes = await loadCachedScopes(agentName);
|
|
840
|
+
const cachedScopes = await loadCachedScopes(agentName, apiKey);
|
|
800
841
|
if (cachedScopes !== null && cachedScopes.length > 0) {
|
|
801
842
|
logger.debug("Loaded scopes from cache.", { agent: agentName, count: cachedScopes.length });
|
|
802
843
|
return { id: "", name: agentName, scopes: cachedScopes };
|
|
@@ -824,7 +865,7 @@ async function resolveAgentRecord(agentName, apiKey, baseUrl, logger) {
|
|
|
824
865
|
}
|
|
825
866
|
const scopes = await fetchGrantedScopes(agent.id, apiKey, baseUrl);
|
|
826
867
|
if (scopes.length > 0) {
|
|
827
|
-
await saveCachedScopes(agentName, agent.id, scopes);
|
|
868
|
+
await saveCachedScopes(agentName, agent.id, scopes, apiKey);
|
|
828
869
|
}
|
|
829
870
|
return { ...agent, scopes };
|
|
830
871
|
}
|
|
@@ -862,9 +903,6 @@ function isPermissionShape(value) {
|
|
|
862
903
|
const obj = value;
|
|
863
904
|
return typeof obj["service"] === "string" && typeof obj["read"] === "boolean" && typeof obj["write"] === "boolean" && typeof obj["execute"] === "boolean" && (obj["revoked_at"] === null || typeof obj["revoked_at"] === "string");
|
|
864
905
|
}
|
|
865
|
-
function isScopesCacheFile(value) {
|
|
866
|
-
return typeof value === "object" && value !== null;
|
|
867
|
-
}
|
|
868
906
|
|
|
869
907
|
// src/proxy/index.ts
|
|
870
908
|
var DEFAULT_SCOPE_REFRESH_INTERVAL_MS = 6e4;
|
|
@@ -892,7 +930,7 @@ function createProxyServer(config) {
|
|
|
892
930
|
const scopes = await fetchGrantedScopes(agentId, config.apiKey, config.baseUrl);
|
|
893
931
|
grantedScopes = scopes;
|
|
894
932
|
if (scopes.length > 0) {
|
|
895
|
-
await saveCachedScopes(config.agentName, agentId, scopes);
|
|
933
|
+
await saveCachedScopes(config.agentName, agentId, scopes, config.apiKey);
|
|
896
934
|
}
|
|
897
935
|
config.logger.debug("Scopes refreshed.", { count: scopes.length });
|
|
898
936
|
} catch (error) {
|
|
@@ -921,7 +959,7 @@ function createProxyServer(config) {
|
|
|
921
959
|
scopeParam
|
|
922
960
|
);
|
|
923
961
|
grantedScopes = scopes;
|
|
924
|
-
await saveCachedScopes(config.agentName, agentId, scopes);
|
|
962
|
+
await saveCachedScopes(config.agentName, agentId, scopes, config.apiKey);
|
|
925
963
|
} finally {
|
|
926
964
|
consentInProgress = false;
|
|
927
965
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import { readFile, mkdir, writeFile, unlink } from 'fs/promises';
|
|
2
3
|
import { join } from 'path';
|
|
3
4
|
import { homedir } from 'os';
|
|
4
5
|
import { spawn } from 'child_process';
|
|
@@ -83,18 +84,53 @@ function mapToolToScope(toolName, command) {
|
|
|
83
84
|
}
|
|
84
85
|
var MULTICORN_DIR = join(homedir(), ".multicorn");
|
|
85
86
|
var SCOPES_PATH = join(MULTICORN_DIR, "scopes.json");
|
|
86
|
-
|
|
87
|
+
var CACHE_META_PATH = join(MULTICORN_DIR, "cache-meta.json");
|
|
88
|
+
function cacheKey(agentName, apiKey) {
|
|
89
|
+
return createHash("sha256").update(`${agentName}:${apiKey}`).digest("hex").slice(0, 16);
|
|
90
|
+
}
|
|
91
|
+
async function ensureCacheIdentity(apiKey) {
|
|
92
|
+
const currentHash = createHash("sha256").update(apiKey).digest("hex");
|
|
93
|
+
let storedHash = null;
|
|
94
|
+
try {
|
|
95
|
+
const raw = await readFile(CACHE_META_PATH, "utf8");
|
|
96
|
+
const meta = JSON.parse(raw);
|
|
97
|
+
if (typeof meta === "object" && meta !== null && "apiKeyHash" in meta) {
|
|
98
|
+
storedHash = meta.apiKeyHash;
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
}
|
|
102
|
+
if (storedHash === null || storedHash !== currentHash) {
|
|
103
|
+
try {
|
|
104
|
+
await unlink(SCOPES_PATH);
|
|
105
|
+
} catch {
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (storedHash !== currentHash) {
|
|
109
|
+
await mkdir(MULTICORN_DIR, { recursive: true, mode: 448 });
|
|
110
|
+
await writeFile(CACHE_META_PATH, JSON.stringify({ apiKeyHash: currentHash }, null, 2) + "\n", {
|
|
111
|
+
encoding: "utf8",
|
|
112
|
+
mode: 384
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async function loadCachedScopes(agentName, apiKey) {
|
|
117
|
+
if (apiKey.length === 0) return null;
|
|
118
|
+
await ensureCacheIdentity(apiKey);
|
|
119
|
+
const key = cacheKey(agentName, apiKey);
|
|
87
120
|
try {
|
|
88
121
|
const raw = await readFile(SCOPES_PATH, "utf8");
|
|
89
122
|
const parsed = JSON.parse(raw);
|
|
90
123
|
if (!isScopesCacheFile(parsed)) return null;
|
|
91
|
-
const entry = parsed[
|
|
124
|
+
const entry = parsed[key];
|
|
92
125
|
return entry?.scopes ?? null;
|
|
93
126
|
} catch {
|
|
94
127
|
return null;
|
|
95
128
|
}
|
|
96
129
|
}
|
|
97
|
-
async function saveCachedScopes(agentName, agentId, scopes) {
|
|
130
|
+
async function saveCachedScopes(agentName, agentId, scopes, apiKey) {
|
|
131
|
+
if (apiKey.length === 0) return;
|
|
132
|
+
await ensureCacheIdentity(apiKey);
|
|
133
|
+
const key = cacheKey(agentName, apiKey);
|
|
98
134
|
await mkdir(MULTICORN_DIR, { recursive: true, mode: 448 });
|
|
99
135
|
let existing = {};
|
|
100
136
|
try {
|
|
@@ -105,7 +141,7 @@ async function saveCachedScopes(agentName, agentId, scopes) {
|
|
|
105
141
|
}
|
|
106
142
|
const updated = {
|
|
107
143
|
...existing,
|
|
108
|
-
[
|
|
144
|
+
[key]: {
|
|
109
145
|
agentId,
|
|
110
146
|
scopes,
|
|
111
147
|
fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
@@ -201,6 +237,13 @@ async function registerAgent(agentName, apiKey, baseUrl, logger) {
|
|
|
201
237
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
202
238
|
});
|
|
203
239
|
if (!response.ok) {
|
|
240
|
+
const body2 = await response.json().catch(() => null);
|
|
241
|
+
if (response.status === 403) {
|
|
242
|
+
const msg = (body2?.error?.message ?? "").toLowerCase();
|
|
243
|
+
if (msg.includes("agent limit") || msg.includes("maximum")) {
|
|
244
|
+
throw new Error("Agent limit reached. Upgrade your plan at app.multicorn.ai/settings.");
|
|
245
|
+
}
|
|
246
|
+
}
|
|
204
247
|
handleHttpError(response.status);
|
|
205
248
|
throw new Error(
|
|
206
249
|
`Failed to register agent "${agentName}": service returned ${String(response.status)}.`
|
|
@@ -212,15 +255,28 @@ async function registerAgent(agentName, apiKey, baseUrl, logger) {
|
|
|
212
255
|
}
|
|
213
256
|
return body.data.id;
|
|
214
257
|
}
|
|
258
|
+
var findOrRegisterInflight = /* @__PURE__ */ new Map();
|
|
215
259
|
async function findOrRegisterAgent(agentName, apiKey, baseUrl, logger) {
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
260
|
+
const key = `${agentName}:${apiKey}:${baseUrl}`;
|
|
261
|
+
const existing = findOrRegisterInflight.get(key);
|
|
262
|
+
if (existing !== void 0) return existing;
|
|
263
|
+
const promise = (async () => {
|
|
264
|
+
const found = await findAgentByName(agentName, apiKey, baseUrl, logger);
|
|
265
|
+
if (found !== null) return found;
|
|
266
|
+
try {
|
|
267
|
+
const id = await registerAgent(agentName, apiKey, baseUrl, logger);
|
|
268
|
+
return { id, name: agentName };
|
|
269
|
+
} catch (err) {
|
|
270
|
+
if (err instanceof Error && err.message.includes("Agent limit reached")) {
|
|
271
|
+
throw err;
|
|
272
|
+
}
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
})().finally(() => {
|
|
276
|
+
findOrRegisterInflight.delete(key);
|
|
277
|
+
});
|
|
278
|
+
findOrRegisterInflight.set(key, promise);
|
|
279
|
+
return promise;
|
|
224
280
|
}
|
|
225
281
|
async function fetchGrantedScopes(agentId, apiKey, baseUrl, logger) {
|
|
226
282
|
try {
|
|
@@ -366,6 +422,7 @@ var agentRecord = null;
|
|
|
366
422
|
var grantedScopes = [];
|
|
367
423
|
var consentInProgress = false;
|
|
368
424
|
var lastScopeRefresh = 0;
|
|
425
|
+
var pinnedAgentName = null;
|
|
369
426
|
var SCOPE_REFRESH_INTERVAL_MS = 6e4;
|
|
370
427
|
function readConfig() {
|
|
371
428
|
const apiKey = process.env["MULTICORN_API_KEY"] ?? "";
|
|
@@ -386,12 +443,20 @@ function resolveAgentName(sessionKey, envOverride) {
|
|
|
386
443
|
}
|
|
387
444
|
return "openclaw";
|
|
388
445
|
}
|
|
446
|
+
function getAgentName(sessionKey, envOverride) {
|
|
447
|
+
if (pinnedAgentName !== null) return pinnedAgentName;
|
|
448
|
+
const resolved = resolveAgentName(sessionKey, envOverride);
|
|
449
|
+
if (resolved !== "openclaw") {
|
|
450
|
+
pinnedAgentName = resolved;
|
|
451
|
+
}
|
|
452
|
+
return resolved;
|
|
453
|
+
}
|
|
389
454
|
async function ensureAgent(agentName, apiKey, baseUrl, failMode) {
|
|
390
455
|
if (agentRecord !== null && Date.now() - lastScopeRefresh < SCOPE_REFRESH_INTERVAL_MS) {
|
|
391
456
|
return "ready";
|
|
392
457
|
}
|
|
393
458
|
if (agentRecord === null) {
|
|
394
|
-
const cached = await loadCachedScopes(agentName);
|
|
459
|
+
const cached = await loadCachedScopes(agentName, apiKey);
|
|
395
460
|
if (cached !== null && cached.length > 0) {
|
|
396
461
|
grantedScopes = cached;
|
|
397
462
|
void findOrRegisterAgent(agentName, apiKey, baseUrl).then((record) => {
|
|
@@ -418,7 +483,7 @@ async function ensureAgent(agentName, apiKey, baseUrl, failMode) {
|
|
|
418
483
|
grantedScopes = scopes;
|
|
419
484
|
lastScopeRefresh = Date.now();
|
|
420
485
|
if (scopes.length > 0) {
|
|
421
|
-
await saveCachedScopes(agentName, agentRecord.id, scopes).catch(() => {
|
|
486
|
+
await saveCachedScopes(agentName, agentRecord.id, scopes, apiKey).catch(() => {
|
|
422
487
|
});
|
|
423
488
|
}
|
|
424
489
|
return "ready";
|
|
@@ -444,7 +509,7 @@ async function ensureConsent(agentName, apiKey, baseUrl, scope) {
|
|
|
444
509
|
try {
|
|
445
510
|
const scopes = await waitForConsent(agentRecord.id, agentName, apiKey, baseUrl, scope);
|
|
446
511
|
grantedScopes = scopes;
|
|
447
|
-
await saveCachedScopes(agentName, agentRecord.id, scopes).catch(() => {
|
|
512
|
+
await saveCachedScopes(agentName, agentRecord.id, scopes, apiKey).catch(() => {
|
|
448
513
|
});
|
|
449
514
|
} finally {
|
|
450
515
|
consentInProgress = false;
|
|
@@ -466,7 +531,10 @@ var handler = async (event) => {
|
|
|
466
531
|
);
|
|
467
532
|
return;
|
|
468
533
|
}
|
|
469
|
-
|
|
534
|
+
if (config.agentName !== null) {
|
|
535
|
+
pinnedAgentName = config.agentName;
|
|
536
|
+
}
|
|
537
|
+
const agentName = getAgentName(event.sessionKey, config.agentName);
|
|
470
538
|
const readiness = await ensureAgent(agentName, config.apiKey, config.baseUrl, config.failMode);
|
|
471
539
|
if (readiness === "block") {
|
|
472
540
|
event.messages.push(
|
|
@@ -488,9 +556,10 @@ var handler = async (event) => {
|
|
|
488
556
|
const permitted = isPermitted(event);
|
|
489
557
|
if (!permitted) {
|
|
490
558
|
const capitalizedService = mapping.service.charAt(0).toUpperCase() + mapping.service.slice(1);
|
|
491
|
-
const
|
|
559
|
+
const base = deriveDashboardUrl(config.baseUrl).replace(/\/+$/, "");
|
|
492
560
|
event.messages.push(
|
|
493
|
-
`Permission denied: ${capitalizedService} ${mapping.permissionLevel} access is not allowed. Check pending approvals at
|
|
561
|
+
`Permission denied: ${capitalizedService} ${mapping.permissionLevel} access is not allowed. Check pending approvals at:
|
|
562
|
+
${base}/approvals`
|
|
494
563
|
);
|
|
495
564
|
void logAction(
|
|
496
565
|
{
|
|
@@ -520,6 +589,7 @@ function resetState() {
|
|
|
520
589
|
grantedScopes = [];
|
|
521
590
|
consentInProgress = false;
|
|
522
591
|
lastScopeRefresh = 0;
|
|
592
|
+
pinnedAgentName = null;
|
|
523
593
|
}
|
|
524
594
|
|
|
525
595
|
export { handler, readConfig, resetState, resolveAgentName };
|
|
@@ -3,7 +3,8 @@ import * as path from 'path';
|
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
import * as os from 'os';
|
|
5
5
|
import { homedir } from 'os';
|
|
6
|
-
import {
|
|
6
|
+
import { createHash } from 'crypto';
|
|
7
|
+
import { mkdir, readFile, writeFile, unlink } from 'fs/promises';
|
|
7
8
|
import { spawn } from 'child_process';
|
|
8
9
|
|
|
9
10
|
// Multicorn Shield plugin for OpenClaw - https://multicorn.ai
|
|
@@ -88,18 +89,53 @@ function mapToolToScope(toolName, command) {
|
|
|
88
89
|
}
|
|
89
90
|
var MULTICORN_DIR = join(homedir(), ".multicorn");
|
|
90
91
|
var SCOPES_PATH = join(MULTICORN_DIR, "scopes.json");
|
|
91
|
-
|
|
92
|
+
var CACHE_META_PATH = join(MULTICORN_DIR, "cache-meta.json");
|
|
93
|
+
function cacheKey(agentName, apiKey) {
|
|
94
|
+
return createHash("sha256").update(`${agentName}:${apiKey}`).digest("hex").slice(0, 16);
|
|
95
|
+
}
|
|
96
|
+
async function ensureCacheIdentity(apiKey) {
|
|
97
|
+
const currentHash = createHash("sha256").update(apiKey).digest("hex");
|
|
98
|
+
let storedHash = null;
|
|
99
|
+
try {
|
|
100
|
+
const raw = await readFile(CACHE_META_PATH, "utf8");
|
|
101
|
+
const meta = JSON.parse(raw);
|
|
102
|
+
if (typeof meta === "object" && meta !== null && "apiKeyHash" in meta) {
|
|
103
|
+
storedHash = meta.apiKeyHash;
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
}
|
|
107
|
+
if (storedHash === null || storedHash !== currentHash) {
|
|
108
|
+
try {
|
|
109
|
+
await unlink(SCOPES_PATH);
|
|
110
|
+
} catch {
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (storedHash !== currentHash) {
|
|
114
|
+
await mkdir(MULTICORN_DIR, { recursive: true, mode: 448 });
|
|
115
|
+
await writeFile(CACHE_META_PATH, JSON.stringify({ apiKeyHash: currentHash }, null, 2) + "\n", {
|
|
116
|
+
encoding: "utf8",
|
|
117
|
+
mode: 384
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
async function loadCachedScopes(agentName, apiKey) {
|
|
122
|
+
if (apiKey.length === 0) return null;
|
|
123
|
+
await ensureCacheIdentity(apiKey);
|
|
124
|
+
const key = cacheKey(agentName, apiKey);
|
|
92
125
|
try {
|
|
93
126
|
const raw = await readFile(SCOPES_PATH, "utf8");
|
|
94
127
|
const parsed = JSON.parse(raw);
|
|
95
128
|
if (!isScopesCacheFile(parsed)) return null;
|
|
96
|
-
const entry = parsed[
|
|
129
|
+
const entry = parsed[key];
|
|
97
130
|
return entry?.scopes ?? null;
|
|
98
131
|
} catch {
|
|
99
132
|
return null;
|
|
100
133
|
}
|
|
101
134
|
}
|
|
102
|
-
async function saveCachedScopes(agentName, agentId, scopes) {
|
|
135
|
+
async function saveCachedScopes(agentName, agentId, scopes, apiKey) {
|
|
136
|
+
if (apiKey.length === 0) return;
|
|
137
|
+
await ensureCacheIdentity(apiKey);
|
|
138
|
+
const key = cacheKey(agentName, apiKey);
|
|
103
139
|
await mkdir(MULTICORN_DIR, { recursive: true, mode: 448 });
|
|
104
140
|
let existing = {};
|
|
105
141
|
try {
|
|
@@ -110,7 +146,7 @@ async function saveCachedScopes(agentName, agentId, scopes) {
|
|
|
110
146
|
}
|
|
111
147
|
const updated = {
|
|
112
148
|
...existing,
|
|
113
|
-
[
|
|
149
|
+
[key]: {
|
|
114
150
|
agentId,
|
|
115
151
|
scopes,
|
|
116
152
|
fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
@@ -209,6 +245,13 @@ async function registerAgent(agentName, apiKey, baseUrl, logger) {
|
|
|
209
245
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
210
246
|
});
|
|
211
247
|
if (!response.ok) {
|
|
248
|
+
const body2 = await response.json().catch(() => null);
|
|
249
|
+
if (response.status === 403) {
|
|
250
|
+
const msg = (body2?.error?.message ?? "").toLowerCase();
|
|
251
|
+
if (msg.includes("agent limit") || msg.includes("maximum")) {
|
|
252
|
+
throw new Error("Agent limit reached. Upgrade your plan at app.multicorn.ai/settings.");
|
|
253
|
+
}
|
|
254
|
+
}
|
|
212
255
|
handleHttpError(response.status, logger);
|
|
213
256
|
throw new Error(
|
|
214
257
|
`Failed to register agent "${agentName}": service returned ${String(response.status)}.`
|
|
@@ -220,15 +263,28 @@ async function registerAgent(agentName, apiKey, baseUrl, logger) {
|
|
|
220
263
|
}
|
|
221
264
|
return body.data.id;
|
|
222
265
|
}
|
|
266
|
+
var findOrRegisterInflight = /* @__PURE__ */ new Map();
|
|
223
267
|
async function findOrRegisterAgent(agentName, apiKey, baseUrl, logger) {
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
268
|
+
const key = `${agentName}:${apiKey}:${baseUrl}`;
|
|
269
|
+
const existing = findOrRegisterInflight.get(key);
|
|
270
|
+
if (existing !== void 0) return existing;
|
|
271
|
+
const promise = (async () => {
|
|
272
|
+
const found = await findAgentByName(agentName, apiKey, baseUrl, logger);
|
|
273
|
+
if (found !== null) return found;
|
|
274
|
+
try {
|
|
275
|
+
const id = await registerAgent(agentName, apiKey, baseUrl, logger);
|
|
276
|
+
return { id, name: agentName };
|
|
277
|
+
} catch (err) {
|
|
278
|
+
if (err instanceof Error && err.message.includes("Agent limit reached")) {
|
|
279
|
+
throw err;
|
|
280
|
+
}
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
})().finally(() => {
|
|
284
|
+
findOrRegisterInflight.delete(key);
|
|
285
|
+
});
|
|
286
|
+
findOrRegisterInflight.set(key, promise);
|
|
287
|
+
return promise;
|
|
232
288
|
}
|
|
233
289
|
async function fetchGrantedScopes(agentId, apiKey, baseUrl, logger) {
|
|
234
290
|
try {
|
|
@@ -431,6 +487,7 @@ var lastScopeRefresh = 0;
|
|
|
431
487
|
var pluginLogger = null;
|
|
432
488
|
var pluginConfig;
|
|
433
489
|
var connectionLogged = false;
|
|
490
|
+
var pinnedAgentName = null;
|
|
434
491
|
var SCOPE_REFRESH_INTERVAL_MS = 6e4;
|
|
435
492
|
var cachedMulticornConfig = null;
|
|
436
493
|
function loadMulticornConfig() {
|
|
@@ -453,9 +510,12 @@ function readConfig() {
|
|
|
453
510
|
function asString(value) {
|
|
454
511
|
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
455
512
|
}
|
|
456
|
-
function resolveAgentName(sessionKey,
|
|
457
|
-
if (
|
|
458
|
-
return
|
|
513
|
+
function resolveAgentName(sessionKey, configOverride, ctxAgentId) {
|
|
514
|
+
if (configOverride !== null && configOverride.trim().length > 0) {
|
|
515
|
+
return configOverride.trim();
|
|
516
|
+
}
|
|
517
|
+
if (ctxAgentId !== void 0 && ctxAgentId.trim().length > 0) {
|
|
518
|
+
return ctxAgentId.trim();
|
|
459
519
|
}
|
|
460
520
|
const parts = sessionKey.split(":");
|
|
461
521
|
const name = parts[1];
|
|
@@ -464,12 +524,23 @@ function resolveAgentName(sessionKey, envOverride) {
|
|
|
464
524
|
}
|
|
465
525
|
return "openclaw";
|
|
466
526
|
}
|
|
527
|
+
function getAgentName(sessionKey, configOverride, ctxAgentId) {
|
|
528
|
+
if (pinnedAgentName !== null) return pinnedAgentName;
|
|
529
|
+
const resolved = resolveAgentName(sessionKey, configOverride, ctxAgentId);
|
|
530
|
+
if (resolved !== "openclaw") {
|
|
531
|
+
pinnedAgentName = resolved;
|
|
532
|
+
}
|
|
533
|
+
return resolved;
|
|
534
|
+
}
|
|
467
535
|
async function ensureAgent(agentName, apiKey, baseUrl, failMode) {
|
|
468
|
-
if (agentRecord !== null && Date.now() - lastScopeRefresh < SCOPE_REFRESH_INTERVAL_MS) {
|
|
536
|
+
if (agentRecord !== null && agentRecord.name === agentName && Date.now() - lastScopeRefresh < SCOPE_REFRESH_INTERVAL_MS) {
|
|
469
537
|
return "ready";
|
|
470
538
|
}
|
|
539
|
+
if (agentRecord !== null && agentRecord.name !== agentName) {
|
|
540
|
+
agentRecord = null;
|
|
541
|
+
}
|
|
471
542
|
if (agentRecord === null) {
|
|
472
|
-
const cached = await loadCachedScopes(agentName);
|
|
543
|
+
const cached = await loadCachedScopes(agentName, apiKey);
|
|
473
544
|
if (cached !== null && cached.length > 0) {
|
|
474
545
|
grantedScopes = cached;
|
|
475
546
|
void findOrRegisterAgent(agentName, apiKey, baseUrl, pluginLogger ?? void 0).then(
|
|
@@ -501,7 +572,7 @@ async function ensureAgent(agentName, apiKey, baseUrl, failMode) {
|
|
|
501
572
|
grantedScopes = scopes;
|
|
502
573
|
lastScopeRefresh = Date.now();
|
|
503
574
|
if (scopes.length > 0) {
|
|
504
|
-
await saveCachedScopes(agentName, agentRecord.id, scopes).catch(() => {
|
|
575
|
+
await saveCachedScopes(agentName, agentRecord.id, scopes, apiKey).catch(() => {
|
|
505
576
|
});
|
|
506
577
|
}
|
|
507
578
|
if (!connectionLogged) {
|
|
@@ -543,7 +614,7 @@ async function ensureConsent(agentName, apiKey, baseUrl, scope) {
|
|
|
543
614
|
pluginLogger ?? void 0
|
|
544
615
|
);
|
|
545
616
|
grantedScopes = scopes;
|
|
546
|
-
await saveCachedScopes(agentName, agentRecord.id, scopes).catch(() => {
|
|
617
|
+
await saveCachedScopes(agentName, agentRecord.id, scopes, apiKey).catch(() => {
|
|
547
618
|
});
|
|
548
619
|
} finally {
|
|
549
620
|
consentInProgress = false;
|
|
@@ -632,7 +703,7 @@ async function beforeToolCall(event, ctx) {
|
|
|
632
703
|
console.error("[SHIELD] DECISION: allow (no API key)");
|
|
633
704
|
return void 0;
|
|
634
705
|
}
|
|
635
|
-
const agentName =
|
|
706
|
+
const agentName = getAgentName(ctx.sessionKey ?? "", config.agentName, ctx.agentId);
|
|
636
707
|
const readiness = await ensureAgent(agentName, config.apiKey, config.baseUrl, config.failMode);
|
|
637
708
|
console.error("[SHIELD] ensureAgent result: " + JSON.stringify(readiness));
|
|
638
709
|
if (readiness === "block") {
|
|
@@ -694,7 +765,7 @@ async function beforeToolCall(event, ctx) {
|
|
|
694
765
|
grantedScopes = scopes;
|
|
695
766
|
lastScopeRefresh = Date.now();
|
|
696
767
|
if (Array.isArray(scopes) && scopes.length > 0) {
|
|
697
|
-
await saveCachedScopes(agentName, agentRecord.id, scopes).catch(() => {
|
|
768
|
+
await saveCachedScopes(agentName, agentRecord.id, scopes, config.apiKey).catch(() => {
|
|
698
769
|
});
|
|
699
770
|
}
|
|
700
771
|
}
|
|
@@ -702,10 +773,11 @@ async function beforeToolCall(event, ctx) {
|
|
|
702
773
|
return void 0;
|
|
703
774
|
}
|
|
704
775
|
if (permissionResult.status === "pending" && permissionResult.approvalId !== void 0) {
|
|
705
|
-
const
|
|
776
|
+
const base2 = deriveDashboardUrl(config.baseUrl).replace(/\/+$/, "");
|
|
706
777
|
const returnValue2 = {
|
|
707
778
|
block: true,
|
|
708
|
-
blockReason: `Action pending approval.
|
|
779
|
+
blockReason: `Action pending approval.
|
|
780
|
+
Visit ${base2}/approvals to approve or reject, then try again.`
|
|
709
781
|
};
|
|
710
782
|
console.error("[SHIELD] DECISION: " + JSON.stringify(returnValue2));
|
|
711
783
|
return returnValue2;
|
|
@@ -726,7 +798,7 @@ async function beforeToolCall(event, ctx) {
|
|
|
726
798
|
grantedScopes = scopes;
|
|
727
799
|
lastScopeRefresh = Date.now();
|
|
728
800
|
if (Array.isArray(scopes) && scopes.length > 0) {
|
|
729
|
-
await saveCachedScopes(agentName, agentRecord.id, scopes).catch(() => {
|
|
801
|
+
await saveCachedScopes(agentName, agentRecord.id, scopes, config.apiKey).catch(() => {
|
|
730
802
|
});
|
|
731
803
|
}
|
|
732
804
|
const recheckResult = await checkActionPermission(
|
|
@@ -751,16 +823,19 @@ async function beforeToolCall(event, ctx) {
|
|
|
751
823
|
grantedScopes = refreshedScopes;
|
|
752
824
|
lastScopeRefresh = Date.now();
|
|
753
825
|
if (Array.isArray(refreshedScopes) && refreshedScopes.length > 0) {
|
|
754
|
-
await saveCachedScopes(agentName, agentRecord.id, refreshedScopes).catch(
|
|
755
|
-
|
|
826
|
+
await saveCachedScopes(agentName, agentRecord.id, refreshedScopes, config.apiKey).catch(
|
|
827
|
+
() => {
|
|
828
|
+
}
|
|
829
|
+
);
|
|
756
830
|
}
|
|
757
831
|
console.error("[SHIELD] DECISION: allow (re-check after consent)");
|
|
758
832
|
return void 0;
|
|
759
833
|
}
|
|
760
834
|
}
|
|
761
835
|
const capitalizedService = mapping.service.charAt(0).toUpperCase() + mapping.service.slice(1);
|
|
762
|
-
const
|
|
763
|
-
const reason = `${capitalizedService} ${mapping.permissionLevel} access is not allowed. Check pending approvals at
|
|
836
|
+
const base = deriveDashboardUrl(config.baseUrl).replace(/\/+$/, "");
|
|
837
|
+
const reason = `${capitalizedService} ${mapping.permissionLevel} access is not allowed. Check pending approvals at:
|
|
838
|
+
${base}/approvals`;
|
|
764
839
|
const returnValue = { block: true, blockReason: reason };
|
|
765
840
|
console.error("[SHIELD] DECISION: " + JSON.stringify(returnValue));
|
|
766
841
|
return returnValue;
|
|
@@ -773,7 +848,7 @@ async function beforeToolCall(event, ctx) {
|
|
|
773
848
|
function afterToolCall(event, ctx) {
|
|
774
849
|
const config = readConfig();
|
|
775
850
|
if (config.apiKey.length === 0) return Promise.resolve();
|
|
776
|
-
const agentName =
|
|
851
|
+
const agentName = getAgentName(ctx.sessionKey ?? "", config.agentName, ctx.agentId);
|
|
777
852
|
const mapping = mapToolToScope(event.toolName);
|
|
778
853
|
void logAction(
|
|
779
854
|
{
|
|
@@ -801,11 +876,14 @@ var plugin = {
|
|
|
801
876
|
pluginLogger = api.logger;
|
|
802
877
|
pluginConfig = api.pluginConfig;
|
|
803
878
|
cachedMulticornConfig = loadMulticornConfig();
|
|
879
|
+
const config = readConfig();
|
|
880
|
+
if (config.agentName !== null) {
|
|
881
|
+
pinnedAgentName = config.agentName;
|
|
882
|
+
}
|
|
804
883
|
console.error("[SHIELD-DIAG] cachedMulticornConfig: " + JSON.stringify(cachedMulticornConfig));
|
|
805
884
|
api.on("before_tool_call", beforeToolCall, { priority: 10 });
|
|
806
885
|
api.on("after_tool_call", afterToolCall);
|
|
807
886
|
api.logger.info("Multicorn Shield plugin registered.");
|
|
808
|
-
const config = readConfig();
|
|
809
887
|
if (config.apiKey.length === 0) {
|
|
810
888
|
api.logger.error(
|
|
811
889
|
"Multicorn Shield: No API key found. Run 'npx multicorn-proxy init' or set MULTICORN_API_KEY."
|
|
@@ -829,6 +907,7 @@ function resetState() {
|
|
|
829
907
|
pluginConfig = void 0;
|
|
830
908
|
cachedMulticornConfig = null;
|
|
831
909
|
connectionLogged = false;
|
|
910
|
+
pinnedAgentName = null;
|
|
832
911
|
}
|
|
833
912
|
|
|
834
913
|
export { afterToolCall, beforeToolCall, plugin, readConfig, register, resetState, resolveAgentName };
|