multicorn-shield 0.1.13 → 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/README.md +36 -0
- package/dist/multicorn-proxy.js +208 -85
- package/dist/openclaw-hook/handler.js +94 -24
- package/dist/openclaw-plugin/{index.js → multicorn-shield.js} +115 -36
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -155,6 +155,42 @@ await shield.logAction({
|
|
|
155
155
|
|
|
156
156
|
That gives you a consent screen, scoped permissions, and an audit trail.
|
|
157
157
|
|
|
158
|
+
## Dashboard
|
|
159
|
+
|
|
160
|
+
Every action, approval, and permission is visible in real time at [app.multicorn.ai](https://app.multicorn.ai).
|
|
161
|
+
|
|
162
|
+
**Sign up:** [https://app.multicorn.ai](https://app.multicorn.ai)
|
|
163
|
+
|
|
164
|
+
With the dashboard you can:
|
|
165
|
+
|
|
166
|
+
- See all agents and their activity
|
|
167
|
+
- Approve or reject pending actions
|
|
168
|
+
- Configure per-agent permissions (read/write/execute per service)
|
|
169
|
+
- Set spending limits
|
|
170
|
+
- View the full audit trail with hash-chain integrity
|
|
171
|
+
|
|
172
|
+
The dashboard works with both the SDK integration and the MCP proxy. No extra setup needed.
|
|
173
|
+
|
|
174
|
+
<p align="center">
|
|
175
|
+
<img src="https://multicorn.ai/images/screenshots/overview-page.png" alt="Dashboard overview showing total actions, blocked count, spend, and live activity feed" width="800" />
|
|
176
|
+
</p>
|
|
177
|
+
|
|
178
|
+
<p align="center">
|
|
179
|
+
<img src="https://multicorn.ai/images/screenshots/approvals-card.png" alt="Approval card with one-tap approve/reject and permission duration options" width="800" />
|
|
180
|
+
</p>
|
|
181
|
+
|
|
182
|
+
<p align="center">
|
|
183
|
+
<img src="https://multicorn.ai/images/screenshots/activity-log-list.png" alt="Filterable activity log showing every agent action with status" width="800" />
|
|
184
|
+
</p>
|
|
185
|
+
|
|
186
|
+
<p align="center">
|
|
187
|
+
<img src="https://multicorn.ai/images/screenshots/consent-screen.png" alt="Consent screen where users grant agent permissions" width="800" />
|
|
188
|
+
</p>
|
|
189
|
+
|
|
190
|
+
<p align="center">
|
|
191
|
+
<img src="https://multicorn.ai/images/screenshots/agent-page-with-stats.png" alt="Agent detail page with action stats and budget tracking" width="800" />
|
|
192
|
+
</p>
|
|
193
|
+
|
|
158
194
|
## Built with Shield
|
|
159
195
|
|
|
160
196
|
Multicorn is developed using AI coding agents. Primarily Cursor for code generation and GitHub Actions as the deployment agent. Every one of those agents runs under Shield.
|
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");
|
|
@@ -506,6 +507,9 @@ function dollarsToCents(dollars) {
|
|
|
506
507
|
// src/proxy/interceptor.ts
|
|
507
508
|
var BLOCKED_ERROR_CODE = -32e3;
|
|
508
509
|
var SPENDING_BLOCKED_ERROR_CODE = -32001;
|
|
510
|
+
var INTERNAL_ERROR_CODE = -32002;
|
|
511
|
+
var SERVICE_UNREACHABLE_ERROR_CODE = -32003;
|
|
512
|
+
var AUTH_ERROR_CODE = -32004;
|
|
509
513
|
function parseJsonRpcLine(line) {
|
|
510
514
|
const trimmed = line.trim();
|
|
511
515
|
if (trimmed.length === 0) return null;
|
|
@@ -550,6 +554,39 @@ function buildSpendingBlockedResponse(id, reason, dashboardUrl) {
|
|
|
550
554
|
}
|
|
551
555
|
};
|
|
552
556
|
}
|
|
557
|
+
function buildInternalErrorResponse(id) {
|
|
558
|
+
const message = "Action blocked: Shield encountered an internal error and cannot verify permissions. Check proxy logs for details.";
|
|
559
|
+
return {
|
|
560
|
+
jsonrpc: "2.0",
|
|
561
|
+
id,
|
|
562
|
+
error: {
|
|
563
|
+
code: INTERNAL_ERROR_CODE,
|
|
564
|
+
message
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
function buildServiceUnreachableResponse(id, dashboardUrl) {
|
|
569
|
+
const message = `Action blocked: Shield cannot verify permissions (service unreachable). Configure offline behaviour at ${dashboardUrl}`;
|
|
570
|
+
return {
|
|
571
|
+
jsonrpc: "2.0",
|
|
572
|
+
id,
|
|
573
|
+
error: {
|
|
574
|
+
code: SERVICE_UNREACHABLE_ERROR_CODE,
|
|
575
|
+
message
|
|
576
|
+
}
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
function buildAuthErrorResponse(id) {
|
|
580
|
+
const message = "Action blocked: Shield API key is invalid or has been revoked. Run npx multicorn-proxy init to reconfigure.";
|
|
581
|
+
return {
|
|
582
|
+
jsonrpc: "2.0",
|
|
583
|
+
id,
|
|
584
|
+
error: {
|
|
585
|
+
code: AUTH_ERROR_CODE,
|
|
586
|
+
message
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
}
|
|
553
590
|
function extractServiceFromToolName(toolName) {
|
|
554
591
|
const idx = toolName.indexOf("_");
|
|
555
592
|
return idx === -1 ? toolName : toolName.slice(0, idx);
|
|
@@ -574,44 +611,53 @@ function capitalize(str) {
|
|
|
574
611
|
}
|
|
575
612
|
var MULTICORN_DIR = join(homedir(), ".multicorn");
|
|
576
613
|
var SCOPES_PATH = join(MULTICORN_DIR, "scopes.json");
|
|
577
|
-
var
|
|
578
|
-
|
|
579
|
-
|
|
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;
|
|
580
621
|
try {
|
|
581
|
-
const
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
return url.toString();
|
|
586
|
-
}
|
|
587
|
-
if (url.hostname === "api.multicorn.ai") {
|
|
588
|
-
url.hostname = "app.multicorn.ai";
|
|
589
|
-
return url.toString();
|
|
590
|
-
}
|
|
591
|
-
if (url.hostname.includes("api")) {
|
|
592
|
-
url.hostname = url.hostname.replace("api", "app");
|
|
593
|
-
return url.toString();
|
|
594
|
-
}
|
|
595
|
-
if (url.protocol === "https:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1") {
|
|
596
|
-
return "https://app.multicorn.ai";
|
|
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;
|
|
597
626
|
}
|
|
598
|
-
return "https://app.multicorn.ai";
|
|
599
627
|
} catch {
|
|
600
|
-
|
|
628
|
+
}
|
|
629
|
+
if (storedHash === null || storedHash !== currentHash) {
|
|
630
|
+
try {
|
|
631
|
+
await unlink(SCOPES_PATH);
|
|
632
|
+
} catch {
|
|
633
|
+
}
|
|
634
|
+
}
|
|
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
|
+
});
|
|
601
641
|
}
|
|
602
642
|
}
|
|
603
|
-
async function loadCachedScopes(agentName) {
|
|
643
|
+
async function loadCachedScopes(agentName, apiKey) {
|
|
644
|
+
if (apiKey.length === 0) return null;
|
|
645
|
+
await ensureCacheIdentity(apiKey);
|
|
646
|
+
const key = cacheKey(agentName, apiKey);
|
|
604
647
|
try {
|
|
605
648
|
const raw = await readFile(SCOPES_PATH, "utf8");
|
|
606
649
|
const parsed = JSON.parse(raw);
|
|
607
650
|
if (!isScopesCacheFile(parsed)) return null;
|
|
608
|
-
const entry = parsed[
|
|
651
|
+
const entry = parsed[key];
|
|
609
652
|
return entry?.scopes ?? null;
|
|
610
653
|
} catch {
|
|
611
654
|
return null;
|
|
612
655
|
}
|
|
613
656
|
}
|
|
614
|
-
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);
|
|
615
661
|
await mkdir(MULTICORN_DIR, { recursive: true, mode: 448 });
|
|
616
662
|
let existing = {};
|
|
617
663
|
try {
|
|
@@ -622,7 +668,7 @@ async function saveCachedScopes(agentName, agentId, scopes) {
|
|
|
622
668
|
}
|
|
623
669
|
const updated = {
|
|
624
670
|
...existing,
|
|
625
|
-
[
|
|
671
|
+
[key]: {
|
|
626
672
|
agentId,
|
|
627
673
|
scopes,
|
|
628
674
|
fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
@@ -633,6 +679,44 @@ async function saveCachedScopes(agentName, agentId, scopes) {
|
|
|
633
679
|
mode: 384
|
|
634
680
|
});
|
|
635
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
|
+
};
|
|
636
720
|
async function findAgentByName(agentName, apiKey, baseUrl) {
|
|
637
721
|
let response;
|
|
638
722
|
try {
|
|
@@ -643,8 +727,18 @@ async function findAgentByName(agentName, apiKey, baseUrl) {
|
|
|
643
727
|
} catch {
|
|
644
728
|
return null;
|
|
645
729
|
}
|
|
646
|
-
if (!response.ok)
|
|
647
|
-
|
|
730
|
+
if (!response.ok) {
|
|
731
|
+
if (response.status === 401 || response.status === 403) {
|
|
732
|
+
return { id: "", name: agentName, scopes: [], authInvalid: true };
|
|
733
|
+
}
|
|
734
|
+
return null;
|
|
735
|
+
}
|
|
736
|
+
let body;
|
|
737
|
+
try {
|
|
738
|
+
body = await response.json();
|
|
739
|
+
} catch {
|
|
740
|
+
return null;
|
|
741
|
+
}
|
|
648
742
|
if (!isApiSuccessResponse(body)) return null;
|
|
649
743
|
const agents = body.data;
|
|
650
744
|
if (!Array.isArray(agents)) return null;
|
|
@@ -665,6 +759,11 @@ async function registerAgent(agentName, apiKey, baseUrl) {
|
|
|
665
759
|
signal: AbortSignal.timeout(8e3)
|
|
666
760
|
});
|
|
667
761
|
if (!response.ok) {
|
|
762
|
+
if (response.status === 401 || response.status === 403) {
|
|
763
|
+
throw new ShieldAuthError(
|
|
764
|
+
`Failed to register agent "${agentName}": service returned ${String(response.status)}.`
|
|
765
|
+
);
|
|
766
|
+
}
|
|
668
767
|
throw new Error(
|
|
669
768
|
`Failed to register agent "${agentName}": service returned ${String(response.status)}.`
|
|
670
769
|
);
|
|
@@ -738,12 +837,15 @@ Waiting for you to grant access in the Multicorn dashboard...
|
|
|
738
837
|
);
|
|
739
838
|
}
|
|
740
839
|
async function resolveAgentRecord(agentName, apiKey, baseUrl, logger) {
|
|
741
|
-
const cachedScopes = await loadCachedScopes(agentName);
|
|
840
|
+
const cachedScopes = await loadCachedScopes(agentName, apiKey);
|
|
742
841
|
if (cachedScopes !== null && cachedScopes.length > 0) {
|
|
743
842
|
logger.debug("Loaded scopes from cache.", { agent: agentName, count: cachedScopes.length });
|
|
744
843
|
return { id: "", name: agentName, scopes: cachedScopes };
|
|
745
844
|
}
|
|
746
845
|
let agent = await findAgentByName(agentName, apiKey, baseUrl);
|
|
846
|
+
if (agent?.authInvalid) {
|
|
847
|
+
return agent;
|
|
848
|
+
}
|
|
747
849
|
if (agent === null) {
|
|
748
850
|
try {
|
|
749
851
|
logger.info("Agent not found. Registering.", { agent: agentName });
|
|
@@ -751,6 +853,9 @@ async function resolveAgentRecord(agentName, apiKey, baseUrl, logger) {
|
|
|
751
853
|
agent = { id, name: agentName, scopes: [] };
|
|
752
854
|
logger.info("Agent registered.", { agent: agentName, id });
|
|
753
855
|
} catch (error) {
|
|
856
|
+
if (error instanceof ShieldAuthError) {
|
|
857
|
+
return { id: "", name: agentName, scopes: [], authInvalid: true };
|
|
858
|
+
}
|
|
754
859
|
const detail = error instanceof Error ? error.message : String(error);
|
|
755
860
|
logger.warn("Could not reach Multicorn service. Running with empty permissions.", {
|
|
756
861
|
error: detail
|
|
@@ -760,7 +865,7 @@ async function resolveAgentRecord(agentName, apiKey, baseUrl, logger) {
|
|
|
760
865
|
}
|
|
761
866
|
const scopes = await fetchGrantedScopes(agent.id, apiKey, baseUrl);
|
|
762
867
|
if (scopes.length > 0) {
|
|
763
|
-
await saveCachedScopes(agentName, agent.id, scopes);
|
|
868
|
+
await saveCachedScopes(agentName, agent.id, scopes, apiKey);
|
|
764
869
|
}
|
|
765
870
|
return { ...agent, scopes };
|
|
766
871
|
}
|
|
@@ -798,9 +903,6 @@ function isPermissionShape(value) {
|
|
|
798
903
|
const obj = value;
|
|
799
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");
|
|
800
905
|
}
|
|
801
|
-
function isScopesCacheFile(value) {
|
|
802
|
-
return typeof value === "object" && value !== null;
|
|
803
|
-
}
|
|
804
906
|
|
|
805
907
|
// src/proxy/index.ts
|
|
806
908
|
var DEFAULT_SCOPE_REFRESH_INTERVAL_MS = 6e4;
|
|
@@ -815,6 +917,7 @@ function createProxyServer(config) {
|
|
|
815
917
|
let spendingChecker = null;
|
|
816
918
|
let grantedScopes = [];
|
|
817
919
|
let agentId = "";
|
|
920
|
+
let authInvalid = false;
|
|
818
921
|
let refreshTimer = null;
|
|
819
922
|
let consentInProgress = false;
|
|
820
923
|
const pendingLines = [];
|
|
@@ -827,7 +930,7 @@ function createProxyServer(config) {
|
|
|
827
930
|
const scopes = await fetchGrantedScopes(agentId, config.apiKey, config.baseUrl);
|
|
828
931
|
grantedScopes = scopes;
|
|
829
932
|
if (scopes.length > 0) {
|
|
830
|
-
await saveCachedScopes(config.agentName, agentId, scopes);
|
|
933
|
+
await saveCachedScopes(config.agentName, agentId, scopes, config.apiKey);
|
|
831
934
|
}
|
|
832
935
|
config.logger.debug("Scopes refreshed.", { count: scopes.length });
|
|
833
936
|
} catch (error) {
|
|
@@ -856,7 +959,7 @@ function createProxyServer(config) {
|
|
|
856
959
|
scopeParam
|
|
857
960
|
);
|
|
858
961
|
grantedScopes = scopes;
|
|
859
|
-
await saveCachedScopes(config.agentName, agentId, scopes);
|
|
962
|
+
await saveCachedScopes(config.agentName, agentId, scopes, config.apiKey);
|
|
860
963
|
} finally {
|
|
861
964
|
consentInProgress = false;
|
|
862
965
|
}
|
|
@@ -867,40 +970,28 @@ function createProxyServer(config) {
|
|
|
867
970
|
if (request.method !== "tools/call") return null;
|
|
868
971
|
const toolParams = extractToolCallParams(request);
|
|
869
972
|
if (toolParams === null) return null;
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
const validation = validateScopeAccess(grantedScopes, requestedScope);
|
|
874
|
-
config.logger.debug("Tool call intercepted.", {
|
|
875
|
-
tool: toolParams.name,
|
|
876
|
-
service,
|
|
877
|
-
allowed: validation.allowed
|
|
878
|
-
});
|
|
879
|
-
if (!validation.allowed) {
|
|
880
|
-
await ensureConsent(requestedScope);
|
|
881
|
-
const revalidation = validateScopeAccess(grantedScopes, requestedScope);
|
|
882
|
-
if (!revalidation.allowed) {
|
|
883
|
-
if (actionLogger !== null) {
|
|
884
|
-
if (!config.agentName || config.agentName.trim().length === 0) {
|
|
885
|
-
process.stderr.write("[multicorn-proxy] Cannot log action: agent name not resolved\n");
|
|
886
|
-
} else {
|
|
887
|
-
await actionLogger.logAction({
|
|
888
|
-
agent: config.agentName,
|
|
889
|
-
service,
|
|
890
|
-
actionType: action,
|
|
891
|
-
status: "blocked"
|
|
892
|
-
});
|
|
893
|
-
}
|
|
894
|
-
}
|
|
895
|
-
const blocked = buildBlockedResponse(request.id, service, "execute", config.dashboardUrl);
|
|
973
|
+
try {
|
|
974
|
+
if (authInvalid) {
|
|
975
|
+
const blocked = buildAuthErrorResponse(request.id);
|
|
896
976
|
return JSON.stringify(blocked);
|
|
897
977
|
}
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
978
|
+
if (agentId.length === 0) {
|
|
979
|
+
const blocked = buildServiceUnreachableResponse(request.id, config.dashboardUrl);
|
|
980
|
+
return JSON.stringify(blocked);
|
|
981
|
+
}
|
|
982
|
+
const service = extractServiceFromToolName(toolParams.name);
|
|
983
|
+
const action = extractActionFromToolName(toolParams.name);
|
|
984
|
+
const requestedScope = { service, permissionLevel: "execute" };
|
|
985
|
+
const validation = validateScopeAccess(grantedScopes, requestedScope);
|
|
986
|
+
config.logger.debug("Tool call intercepted.", {
|
|
987
|
+
tool: toolParams.name,
|
|
988
|
+
service,
|
|
989
|
+
allowed: validation.allowed
|
|
990
|
+
});
|
|
991
|
+
if (!validation.allowed) {
|
|
992
|
+
await ensureConsent(requestedScope);
|
|
993
|
+
const revalidation = validateScopeAccess(grantedScopes, requestedScope);
|
|
994
|
+
if (!revalidation.allowed) {
|
|
904
995
|
if (actionLogger !== null) {
|
|
905
996
|
if (!config.agentName || config.agentName.trim().length === 0) {
|
|
906
997
|
process.stderr.write(
|
|
@@ -915,29 +1006,60 @@ function createProxyServer(config) {
|
|
|
915
1006
|
});
|
|
916
1007
|
}
|
|
917
1008
|
}
|
|
918
|
-
|
|
919
|
-
request.id,
|
|
920
|
-
spendResult.reason ?? "spending limit exceeded",
|
|
921
|
-
config.dashboardUrl
|
|
1009
|
+
return JSON.stringify(
|
|
1010
|
+
buildBlockedResponse(request.id, service, "execute", config.dashboardUrl)
|
|
922
1011
|
);
|
|
923
|
-
return JSON.stringify(blocked);
|
|
924
1012
|
}
|
|
925
|
-
spendingChecker.recordSpend(costCents);
|
|
926
1013
|
}
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
1014
|
+
if (spendingChecker !== null) {
|
|
1015
|
+
const costCents = extractCostCents(toolParams.arguments);
|
|
1016
|
+
if (costCents > 0) {
|
|
1017
|
+
const spendResult = spendingChecker.checkSpend(costCents);
|
|
1018
|
+
if (!spendResult.allowed) {
|
|
1019
|
+
if (actionLogger !== null) {
|
|
1020
|
+
if (!config.agentName || config.agentName.trim().length === 0) {
|
|
1021
|
+
process.stderr.write(
|
|
1022
|
+
"[multicorn-proxy] Cannot log action: agent name not resolved\n"
|
|
1023
|
+
);
|
|
1024
|
+
} else {
|
|
1025
|
+
await actionLogger.logAction({
|
|
1026
|
+
agent: config.agentName,
|
|
1027
|
+
service,
|
|
1028
|
+
actionType: action,
|
|
1029
|
+
status: "blocked"
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
const blocked = buildSpendingBlockedResponse(
|
|
1034
|
+
request.id,
|
|
1035
|
+
spendResult.reason ?? "spending limit exceeded",
|
|
1036
|
+
config.dashboardUrl
|
|
1037
|
+
);
|
|
1038
|
+
return JSON.stringify(blocked);
|
|
1039
|
+
}
|
|
1040
|
+
spendingChecker.recordSpend(costCents);
|
|
1041
|
+
}
|
|
938
1042
|
}
|
|
1043
|
+
if (actionLogger !== null) {
|
|
1044
|
+
if (!config.agentName || config.agentName.trim().length === 0) {
|
|
1045
|
+
process.stderr.write("[multicorn-proxy] Cannot log action: agent name not resolved\n");
|
|
1046
|
+
} else {
|
|
1047
|
+
await actionLogger.logAction({
|
|
1048
|
+
agent: config.agentName,
|
|
1049
|
+
service,
|
|
1050
|
+
actionType: action,
|
|
1051
|
+
status: "approved"
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
return null;
|
|
1056
|
+
} catch (error) {
|
|
1057
|
+
config.logger.error("Tool call handler error.", {
|
|
1058
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1059
|
+
});
|
|
1060
|
+
const blocked = buildInternalErrorResponse(request.id);
|
|
1061
|
+
return JSON.stringify(blocked);
|
|
939
1062
|
}
|
|
940
|
-
return null;
|
|
941
1063
|
}
|
|
942
1064
|
async function processLine(line) {
|
|
943
1065
|
const childProcess = child;
|
|
@@ -989,6 +1111,7 @@ function createProxyServer(config) {
|
|
|
989
1111
|
);
|
|
990
1112
|
agentId = agentRecord.id;
|
|
991
1113
|
grantedScopes = agentRecord.scopes;
|
|
1114
|
+
authInvalid = agentRecord.authInvalid === true;
|
|
992
1115
|
config.logger.info("Agent resolved.", {
|
|
993
1116
|
agent: config.agentName,
|
|
994
1117
|
id: agentId,
|
|
@@ -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()
|
|
@@ -156,19 +192,19 @@ function handleHttpError(status, logger, retryDelaySeconds) {
|
|
|
156
192
|
}
|
|
157
193
|
if (status === 429) {
|
|
158
194
|
{
|
|
159
|
-
const rateLimitMsg = "[multicorn-shield] Rate limited by Shield API. Action
|
|
195
|
+
const rateLimitMsg = "[multicorn-shield] Rate limited by Shield API. Action blocked: Shield cannot verify permissions.";
|
|
160
196
|
process.stderr.write(`${rateLimitMsg}
|
|
161
197
|
`);
|
|
162
198
|
}
|
|
163
|
-
return { shouldBlock:
|
|
199
|
+
return { shouldBlock: true };
|
|
164
200
|
}
|
|
165
201
|
if (status >= 500 && status < 600) {
|
|
166
|
-
const serverErrorMsg = `[multicorn-shield] Shield API error (${String(status)}). Action
|
|
202
|
+
const serverErrorMsg = `[multicorn-shield] Shield API error (${String(status)}). Action blocked: Shield cannot verify permissions.`;
|
|
167
203
|
process.stderr.write(`${serverErrorMsg}
|
|
168
204
|
`);
|
|
169
|
-
return { shouldBlock:
|
|
205
|
+
return { shouldBlock: true };
|
|
170
206
|
}
|
|
171
|
-
return { shouldBlock:
|
|
207
|
+
return { shouldBlock: true };
|
|
172
208
|
}
|
|
173
209
|
async function findAgentByName(agentName, apiKey, baseUrl, logger) {
|
|
174
210
|
try {
|
|
@@ -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()
|
|
@@ -162,21 +198,21 @@ function handleHttpError(status, logger, retryDelaySeconds) {
|
|
|
162
198
|
}
|
|
163
199
|
if (status === 429) {
|
|
164
200
|
{
|
|
165
|
-
const rateLimitMsg = "[multicorn-shield] Rate limited by Shield API. Action
|
|
201
|
+
const rateLimitMsg = "[multicorn-shield] Rate limited by Shield API. Action blocked: Shield cannot verify permissions.";
|
|
166
202
|
logger?.warn(rateLimitMsg);
|
|
167
203
|
process.stderr.write(`${rateLimitMsg}
|
|
168
204
|
`);
|
|
169
205
|
}
|
|
170
|
-
return { shouldBlock:
|
|
206
|
+
return { shouldBlock: true };
|
|
171
207
|
}
|
|
172
208
|
if (status >= 500 && status < 600) {
|
|
173
|
-
const serverErrorMsg = `[multicorn-shield] Shield API error (${String(status)}). Action
|
|
209
|
+
const serverErrorMsg = `[multicorn-shield] Shield API error (${String(status)}). Action blocked: Shield cannot verify permissions.`;
|
|
174
210
|
logger?.warn(serverErrorMsg);
|
|
175
211
|
process.stderr.write(`${serverErrorMsg}
|
|
176
212
|
`);
|
|
177
|
-
return { shouldBlock:
|
|
213
|
+
return { shouldBlock: true };
|
|
178
214
|
}
|
|
179
|
-
return { shouldBlock:
|
|
215
|
+
return { shouldBlock: true };
|
|
180
216
|
}
|
|
181
217
|
async function findAgentByName(agentName, apiKey, baseUrl, logger) {
|
|
182
218
|
try {
|
|
@@ -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 };
|