multicorn-shield 0.1.9 → 0.1.10
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/openclaw-plugin/index.js +117 -188
- package/package.json +1 -1
|
@@ -128,9 +128,6 @@ function isScopesCacheFile(value) {
|
|
|
128
128
|
// src/openclaw/shield-client.ts
|
|
129
129
|
var REQUEST_TIMEOUT_MS = 5e3;
|
|
130
130
|
var AUTH_HEADER = "X-Multicorn-Key";
|
|
131
|
-
var POLL_INTERVAL_MS = 3e3;
|
|
132
|
-
var MAX_POLLS = 100;
|
|
133
|
-
var POLL_TIMEOUT_MS = POLL_INTERVAL_MS * MAX_POLLS;
|
|
134
131
|
var authErrorLogged = false;
|
|
135
132
|
function isApiSuccess(value) {
|
|
136
133
|
if (typeof value !== "object" || value === null) return false;
|
|
@@ -152,11 +149,6 @@ function isPermissionEntry(value) {
|
|
|
152
149
|
const obj = value;
|
|
153
150
|
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");
|
|
154
151
|
}
|
|
155
|
-
function isApprovalResponse(value) {
|
|
156
|
-
if (typeof value !== "object" || value === null) return false;
|
|
157
|
-
const obj = value;
|
|
158
|
-
return typeof obj["id"] === "string" && typeof obj["status"] === "string" && ["pending", "approved", "rejected", "expired"].includes(obj["status"]) && (obj["decided_at"] === null || typeof obj["decided_at"] === "string");
|
|
159
|
-
}
|
|
160
152
|
function handleHttpError(status, logger, retryDelaySeconds) {
|
|
161
153
|
if (status === 401 || status === 403) {
|
|
162
154
|
if (!authErrorLogged) {
|
|
@@ -169,12 +161,7 @@ function handleHttpError(status, logger, retryDelaySeconds) {
|
|
|
169
161
|
return { shouldBlock: true };
|
|
170
162
|
}
|
|
171
163
|
if (status === 429) {
|
|
172
|
-
|
|
173
|
-
const rateLimitMsg = `[multicorn-shield] Rate limited by Shield API. Retrying in ${String(retryDelaySeconds)}s.`;
|
|
174
|
-
logger?.warn(rateLimitMsg);
|
|
175
|
-
process.stderr.write(`${rateLimitMsg}
|
|
176
|
-
`);
|
|
177
|
-
} else {
|
|
164
|
+
{
|
|
178
165
|
const rateLimitMsg = "[multicorn-shield] Rate limited by Shield API. Action was not checked \u2014 proceeding with fail-open.";
|
|
179
166
|
logger?.warn(rateLimitMsg);
|
|
180
167
|
process.stderr.write(`${rateLimitMsg}
|
|
@@ -272,6 +259,14 @@ async function fetchGrantedScopes(agentId, apiKey, baseUrl, logger) {
|
|
|
272
259
|
}
|
|
273
260
|
async function checkActionPermission(payload, apiKey, baseUrl, logger) {
|
|
274
261
|
try {
|
|
262
|
+
const requestBody = {
|
|
263
|
+
agent: payload.agent,
|
|
264
|
+
service: payload.service,
|
|
265
|
+
actionType: payload.actionType,
|
|
266
|
+
status: payload.status,
|
|
267
|
+
metadata: payload.metadata
|
|
268
|
+
};
|
|
269
|
+
console.error("[SHIELD-CLIENT] POST /api/v1/actions request: " + JSON.stringify(requestBody));
|
|
275
270
|
const response = await fetch(`${baseUrl}/api/v1/actions`, {
|
|
276
271
|
method: "POST",
|
|
277
272
|
headers: {
|
|
@@ -282,15 +277,22 @@ async function checkActionPermission(payload, apiKey, baseUrl, logger) {
|
|
|
282
277
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
283
278
|
});
|
|
284
279
|
if (response.status === 201) {
|
|
280
|
+
console.error(
|
|
281
|
+
"[SHIELD-CLIENT] response status=201, returning approved (body not read - backend may have failed approval creation)"
|
|
282
|
+
);
|
|
285
283
|
return { status: "approved" };
|
|
286
284
|
}
|
|
287
285
|
if (response.status === 202) {
|
|
288
286
|
const body = await response.json();
|
|
289
|
-
|
|
287
|
+
const data = isApiSuccess(body) ? body.data : null;
|
|
288
|
+
console.error("[SHIELD-CLIENT] response status=202 body=" + JSON.stringify(data ?? body));
|
|
289
|
+
if (!isApiSuccess(body) || data === null) {
|
|
290
290
|
return { status: "blocked" };
|
|
291
291
|
}
|
|
292
|
-
const
|
|
293
|
-
|
|
292
|
+
const approvalId = typeof data["approval_id"] === "string" ? data["approval_id"] : void 0;
|
|
293
|
+
console.error(
|
|
294
|
+
"[SHIELD-CLIENT] extracted: status=" + String(data["status"]) + " approval_id=" + (approvalId ?? "undefined")
|
|
295
|
+
);
|
|
294
296
|
if (approvalId === void 0) {
|
|
295
297
|
return { status: "blocked" };
|
|
296
298
|
}
|
|
@@ -309,85 +311,6 @@ async function checkActionPermission(payload, apiKey, baseUrl, logger) {
|
|
|
309
311
|
return { status: "blocked" };
|
|
310
312
|
}
|
|
311
313
|
}
|
|
312
|
-
async function pollApprovalStatus(approvalId, apiKey, baseUrl, logger) {
|
|
313
|
-
const startTime = Date.now();
|
|
314
|
-
const logDebug = logger?.debug?.bind(logger);
|
|
315
|
-
for (let pollCount = 0; pollCount < MAX_POLLS; pollCount++) {
|
|
316
|
-
if (Date.now() - startTime >= POLL_TIMEOUT_MS) {
|
|
317
|
-
return "timeout";
|
|
318
|
-
}
|
|
319
|
-
let approval = null;
|
|
320
|
-
for (let retry = 0; retry < 3; retry++) {
|
|
321
|
-
try {
|
|
322
|
-
const response = await fetch(`${baseUrl}/api/v1/approvals/${approvalId}`, {
|
|
323
|
-
headers: { [AUTH_HEADER]: apiKey },
|
|
324
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
325
|
-
});
|
|
326
|
-
if (!response.ok) {
|
|
327
|
-
if (response.status === 401 || response.status === 403) {
|
|
328
|
-
handleHttpError(response.status, logger);
|
|
329
|
-
return "timeout";
|
|
330
|
-
}
|
|
331
|
-
if (response.status === 429 || response.status >= 500 && response.status < 600) {
|
|
332
|
-
const retryDelay = retry < 2 ? Math.pow(2, retry) : void 0;
|
|
333
|
-
handleHttpError(response.status, logger, retryDelay);
|
|
334
|
-
}
|
|
335
|
-
logDebug?.(
|
|
336
|
-
`Poll ${String(pollCount + 1)} failed: HTTP ${String(response.status)}. Retrying...`
|
|
337
|
-
);
|
|
338
|
-
if (retry < 2) {
|
|
339
|
-
await new Promise((resolve) => setTimeout(resolve, 1e3 * Math.pow(2, retry)));
|
|
340
|
-
}
|
|
341
|
-
continue;
|
|
342
|
-
}
|
|
343
|
-
const body = await response.json();
|
|
344
|
-
if (!isApiSuccess(body)) {
|
|
345
|
-
logDebug?.(`Poll ${String(pollCount + 1)} failed: invalid response format. Retrying...`);
|
|
346
|
-
if (retry < 2) {
|
|
347
|
-
await new Promise((resolve) => setTimeout(resolve, 1e3 * Math.pow(2, retry)));
|
|
348
|
-
}
|
|
349
|
-
continue;
|
|
350
|
-
}
|
|
351
|
-
const approvalData = body.data;
|
|
352
|
-
if (!isApprovalResponse(approvalData)) {
|
|
353
|
-
logDebug?.(`Poll ${String(pollCount + 1)} failed: invalid approval data. Retrying...`);
|
|
354
|
-
if (retry < 2) {
|
|
355
|
-
await new Promise((resolve) => setTimeout(resolve, 1e3 * Math.pow(2, retry)));
|
|
356
|
-
}
|
|
357
|
-
continue;
|
|
358
|
-
}
|
|
359
|
-
approval = approvalData;
|
|
360
|
-
break;
|
|
361
|
-
} catch (error) {
|
|
362
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
363
|
-
logDebug?.(`Poll ${String(pollCount + 1)} failed: ${errorMessage}. Retrying...`);
|
|
364
|
-
if (retry < 2) {
|
|
365
|
-
await new Promise((resolve) => setTimeout(resolve, 1e3 * Math.pow(2, retry)));
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
if (approval !== null) {
|
|
370
|
-
if (approval.status === "approved") {
|
|
371
|
-
return "approved";
|
|
372
|
-
}
|
|
373
|
-
if (approval.status === "rejected") {
|
|
374
|
-
return "rejected";
|
|
375
|
-
}
|
|
376
|
-
if (approval.status === "expired") {
|
|
377
|
-
return "expired";
|
|
378
|
-
}
|
|
379
|
-
if (pollCount < MAX_POLLS - 1) {
|
|
380
|
-
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
381
|
-
}
|
|
382
|
-
} else {
|
|
383
|
-
logDebug?.(`All retries failed for poll ${String(pollCount + 1)}. Continuing...`);
|
|
384
|
-
if (pollCount < MAX_POLLS - 1) {
|
|
385
|
-
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
return "timeout";
|
|
390
|
-
}
|
|
391
314
|
async function logAction(payload, apiKey, baseUrl, logger) {
|
|
392
315
|
try {
|
|
393
316
|
const response = await fetch(`${baseUrl}/api/v1/actions`, {
|
|
@@ -507,11 +430,10 @@ function loadMulticornConfig() {
|
|
|
507
430
|
}
|
|
508
431
|
function readConfig() {
|
|
509
432
|
const pc = pluginConfig ?? {};
|
|
510
|
-
const resolvedApiKey = asString(process.env["MULTICORN_API_KEY"]) ??
|
|
511
|
-
const resolvedBaseUrl = asString(process.env["MULTICORN_BASE_URL"]) ??
|
|
433
|
+
const resolvedApiKey = asString(cachedMulticornConfig?.apiKey) ?? asString(process.env["MULTICORN_API_KEY"]) ?? "";
|
|
434
|
+
const resolvedBaseUrl = asString(cachedMulticornConfig?.baseUrl) ?? asString(process.env["MULTICORN_BASE_URL"]) ?? "https://api.multicorn.ai";
|
|
512
435
|
const agentName = asString(pc["agentName"]) ?? process.env["MULTICORN_AGENT_NAME"] ?? null;
|
|
513
|
-
const
|
|
514
|
-
const failMode = failModeRaw === "closed" ? "closed" : "open";
|
|
436
|
+
const failMode = "closed";
|
|
515
437
|
return { apiKey: resolvedApiKey, baseUrl: resolvedBaseUrl, agentName, failMode };
|
|
516
438
|
}
|
|
517
439
|
function asString(value) {
|
|
@@ -683,110 +605,116 @@ function buildApprovalDescription(agentName, permissionLevel, service, toolName,
|
|
|
683
605
|
return truncated;
|
|
684
606
|
}
|
|
685
607
|
async function beforeToolCall(event, ctx) {
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
608
|
+
try {
|
|
609
|
+
console.error("[SHIELD] beforeToolCall ENTRY: tool=" + event.toolName);
|
|
610
|
+
const config = readConfig();
|
|
611
|
+
console.error(
|
|
612
|
+
"[SHIELD] config loaded: baseUrl=" + config.baseUrl + " apiKey=" + (config.apiKey ? "present" : "MISSING") + " failMode=" + config.failMode
|
|
690
613
|
);
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
const readiness = await ensureAgent(agentName, config.apiKey, config.baseUrl, config.failMode);
|
|
695
|
-
if (readiness === "block") {
|
|
696
|
-
return {
|
|
697
|
-
block: true,
|
|
698
|
-
blockReason: "Multicorn Shield could not verify permissions. The Shield API is unreachable and fail-closed mode is enabled."
|
|
699
|
-
};
|
|
700
|
-
}
|
|
701
|
-
if (readiness === "skip") {
|
|
702
|
-
return void 0;
|
|
703
|
-
}
|
|
704
|
-
const command = event.toolName === "exec" && typeof event.params["command"] === "string" ? event.params["command"] : void 0;
|
|
705
|
-
const mapping = mapToolToScope(event.toolName, command);
|
|
706
|
-
pluginLogger?.info(
|
|
707
|
-
`Multicorn Shield: tool=${event.toolName}, service=${mapping.service}, permissionLevel=${mapping.permissionLevel}`
|
|
708
|
-
);
|
|
709
|
-
const actionType = mapping.permissionLevel === "write" && event.toolName === "exec" ? "exec_write" : event.toolName;
|
|
710
|
-
const description = buildApprovalDescription(
|
|
711
|
-
agentName,
|
|
712
|
-
mapping.permissionLevel,
|
|
713
|
-
mapping.service,
|
|
714
|
-
event.toolName,
|
|
715
|
-
event.params
|
|
716
|
-
);
|
|
717
|
-
const permissionResult = await checkActionPermission(
|
|
718
|
-
{
|
|
719
|
-
agent: agentName,
|
|
720
|
-
service: mapping.service,
|
|
721
|
-
actionType,
|
|
722
|
-
status: "approved",
|
|
723
|
-
// Status doesn't matter for permission check
|
|
724
|
-
metadata: {
|
|
725
|
-
description
|
|
726
|
-
}
|
|
727
|
-
},
|
|
728
|
-
config.apiKey,
|
|
729
|
-
config.baseUrl,
|
|
730
|
-
pluginLogger ?? void 0
|
|
731
|
-
);
|
|
732
|
-
if (permissionResult.status === "approved") {
|
|
733
|
-
if (agentRecord !== null) {
|
|
734
|
-
const scopes = await fetchGrantedScopes(
|
|
735
|
-
agentRecord.id,
|
|
736
|
-
config.apiKey,
|
|
737
|
-
config.baseUrl,
|
|
738
|
-
pluginLogger ?? void 0
|
|
614
|
+
if (config.apiKey.length === 0) {
|
|
615
|
+
pluginLogger?.warn(
|
|
616
|
+
"Multicorn Shield: No API key found. Run 'npx multicorn-proxy init' or set MULTICORN_API_KEY."
|
|
739
617
|
);
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
if (scopes.length > 0) {
|
|
743
|
-
await saveCachedScopes(agentName, agentRecord.id, scopes).catch(() => {
|
|
744
|
-
});
|
|
745
|
-
}
|
|
618
|
+
console.error("[SHIELD] DECISION: allow (no API key)");
|
|
619
|
+
return void 0;
|
|
746
620
|
}
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
621
|
+
const agentName = resolveAgentName(ctx.sessionKey ?? "", config.agentName);
|
|
622
|
+
const readiness = await ensureAgent(agentName, config.apiKey, config.baseUrl, config.failMode);
|
|
623
|
+
console.error("[SHIELD] ensureAgent result: " + JSON.stringify(readiness));
|
|
624
|
+
if (readiness === "block") {
|
|
625
|
+
const returnValue2 = {
|
|
626
|
+
block: true,
|
|
627
|
+
blockReason: "Multicorn Shield could not verify permissions. The Shield API is unreachable and fail-closed mode is enabled."
|
|
628
|
+
};
|
|
629
|
+
console.error("[SHIELD] DECISION: " + JSON.stringify(returnValue2));
|
|
630
|
+
return returnValue2;
|
|
631
|
+
}
|
|
632
|
+
if (readiness === "skip") {
|
|
633
|
+
console.error("[SHIELD] DECISION: allow (skip mode)");
|
|
634
|
+
return void 0;
|
|
635
|
+
}
|
|
636
|
+
const command = event.toolName === "exec" && typeof event.params["command"] === "string" ? event.params["command"] : void 0;
|
|
637
|
+
const mapping = mapToolToScope(event.toolName, command);
|
|
750
638
|
pluginLogger?.info(
|
|
751
|
-
`Multicorn Shield:
|
|
639
|
+
`Multicorn Shield: tool=${event.toolName}, service=${mapping.service}, permissionLevel=${mapping.permissionLevel}`
|
|
640
|
+
);
|
|
641
|
+
const actionType = mapping.permissionLevel === "write" && event.toolName === "exec" ? "exec_write" : event.toolName;
|
|
642
|
+
const description = buildApprovalDescription(
|
|
643
|
+
agentName,
|
|
644
|
+
mapping.permissionLevel,
|
|
645
|
+
mapping.service,
|
|
646
|
+
event.toolName,
|
|
647
|
+
event.params
|
|
752
648
|
);
|
|
753
|
-
|
|
754
|
-
|
|
649
|
+
if (grantedScopes.length === 0 && agentRecord !== null) {
|
|
650
|
+
await ensureConsent(agentName, config.apiKey, config.baseUrl, mapping);
|
|
651
|
+
console.error("[SHIELD] ensureConsent result: completed (zero-scopes path)");
|
|
652
|
+
}
|
|
653
|
+
console.error(
|
|
654
|
+
"[SHIELD] calling checkActionPermission: service=" + mapping.service + " actionType=" + actionType
|
|
655
|
+
);
|
|
656
|
+
const permissionResult = await checkActionPermission(
|
|
657
|
+
{
|
|
658
|
+
agent: agentName,
|
|
659
|
+
service: mapping.service,
|
|
660
|
+
actionType,
|
|
661
|
+
status: "approved",
|
|
662
|
+
// Status doesn't matter for permission check
|
|
663
|
+
metadata: {
|
|
664
|
+
description
|
|
665
|
+
}
|
|
666
|
+
},
|
|
755
667
|
config.apiKey,
|
|
756
668
|
config.baseUrl,
|
|
757
669
|
pluginLogger ?? void 0
|
|
758
670
|
);
|
|
759
|
-
|
|
671
|
+
console.error("[SHIELD] permission result: " + JSON.stringify(permissionResult));
|
|
672
|
+
if (permissionResult.status === "approved") {
|
|
673
|
+
if (agentRecord !== null) {
|
|
674
|
+
const scopes = await fetchGrantedScopes(
|
|
675
|
+
agentRecord.id,
|
|
676
|
+
config.apiKey,
|
|
677
|
+
config.baseUrl,
|
|
678
|
+
pluginLogger ?? void 0
|
|
679
|
+
);
|
|
680
|
+
grantedScopes = scopes;
|
|
681
|
+
lastScopeRefresh = Date.now();
|
|
682
|
+
if (Array.isArray(scopes) && scopes.length > 0) {
|
|
683
|
+
await saveCachedScopes(agentName, agentRecord.id, scopes).catch(() => {
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
console.error("[SHIELD] DECISION: allow (approved)");
|
|
760
688
|
return void 0;
|
|
761
689
|
}
|
|
762
|
-
if (
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
blockReason: "Action was reviewed and rejected."
|
|
766
|
-
};
|
|
767
|
-
}
|
|
768
|
-
if (pollResult === "expired") {
|
|
769
|
-
return {
|
|
690
|
+
if (permissionResult.status === "pending" && permissionResult.approvalId !== void 0) {
|
|
691
|
+
const dashboardUrl2 = deriveDashboardUrl(config.baseUrl);
|
|
692
|
+
const returnValue2 = {
|
|
770
693
|
block: true,
|
|
771
|
-
blockReason:
|
|
694
|
+
blockReason: `Action pending approval. Visit ${dashboardUrl2}approvals to approve or reject, then try again.`
|
|
772
695
|
};
|
|
696
|
+
console.error("[SHIELD] DECISION: " + JSON.stringify(returnValue2));
|
|
697
|
+
return returnValue2;
|
|
773
698
|
}
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
699
|
+
const requestedScope = {
|
|
700
|
+
service: mapping.service,
|
|
701
|
+
permissionLevel: mapping.permissionLevel
|
|
777
702
|
};
|
|
703
|
+
if (!hasScope(grantedScopes, requestedScope) && agentRecord !== null) {
|
|
704
|
+
await ensureConsent(agentName, config.apiKey, config.baseUrl, mapping);
|
|
705
|
+
console.error("[SHIELD] ensureConsent result: completed (blocked path)");
|
|
706
|
+
}
|
|
707
|
+
const capitalizedService = mapping.service.charAt(0).toUpperCase() + mapping.service.slice(1);
|
|
708
|
+
const dashboardUrl = deriveDashboardUrl(config.baseUrl);
|
|
709
|
+
const reason = `${capitalizedService} ${mapping.permissionLevel} access is not allowed. Check pending approvals at ${dashboardUrl}/approvals `;
|
|
710
|
+
const returnValue = { block: true, blockReason: reason };
|
|
711
|
+
console.error("[SHIELD] DECISION: " + JSON.stringify(returnValue));
|
|
712
|
+
return returnValue;
|
|
713
|
+
} catch (e) {
|
|
714
|
+
console.error("[SHIELD] CRASH in beforeToolCall: " + String(e));
|
|
715
|
+
console.error("[SHIELD] Stack: " + ((e instanceof Error ? e.stack : void 0) ?? "no stack"));
|
|
716
|
+
return { block: true, blockReason: "Shield internal error: " + String(e) };
|
|
778
717
|
}
|
|
779
|
-
const requestedScope = {
|
|
780
|
-
service: mapping.service,
|
|
781
|
-
permissionLevel: mapping.permissionLevel
|
|
782
|
-
};
|
|
783
|
-
if (!hasScope(grantedScopes, requestedScope) && agentRecord !== null) {
|
|
784
|
-
await ensureConsent(agentName, config.apiKey, config.baseUrl, mapping);
|
|
785
|
-
}
|
|
786
|
-
const capitalizedService = mapping.service.charAt(0).toUpperCase() + mapping.service.slice(1);
|
|
787
|
-
const dashboardUrl = deriveDashboardUrl(config.baseUrl);
|
|
788
|
-
const reason = `${capitalizedService} ${mapping.permissionLevel} access is not allowed. Check pending approvals at ${dashboardUrl}/approvals `;
|
|
789
|
-
return { block: true, blockReason: reason };
|
|
790
718
|
}
|
|
791
719
|
function afterToolCall(event, ctx) {
|
|
792
720
|
const config = readConfig();
|
|
@@ -819,6 +747,7 @@ var plugin = {
|
|
|
819
747
|
pluginLogger = api.logger;
|
|
820
748
|
pluginConfig = api.pluginConfig;
|
|
821
749
|
cachedMulticornConfig = loadMulticornConfig();
|
|
750
|
+
console.error("[SHIELD-DIAG] cachedMulticornConfig: " + JSON.stringify(cachedMulticornConfig));
|
|
822
751
|
api.on("before_tool_call", beforeToolCall, { priority: 10 });
|
|
823
752
|
api.on("after_tool_call", afterToolCall);
|
|
824
753
|
api.logger.info("Multicorn Shield plugin registered.");
|