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.
@@ -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
- if (retryDelaySeconds !== void 0) {
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
- if (!isApiSuccess(body)) {
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 data = body.data;
293
- const approvalId = typeof data["approvalId"] === "string" ? data["approvalId"] : void 0;
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"]) ?? asString(cachedMulticornConfig?.apiKey) ?? "";
511
- const resolvedBaseUrl = asString(process.env["MULTICORN_BASE_URL"]) ?? asString(cachedMulticornConfig?.baseUrl) ?? "https://api.multicorn.ai";
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 failModeRaw = asString(pc["failMode"]) ?? process.env["MULTICORN_FAIL_MODE"] ?? "open";
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
- const config = readConfig();
687
- if (config.apiKey.length === 0) {
688
- pluginLogger?.warn(
689
- "Multicorn Shield: No API key found. Run 'npx multicorn-proxy init' or set MULTICORN_API_KEY."
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
- return void 0;
692
- }
693
- const agentName = resolveAgentName(ctx.sessionKey ?? "", config.agentName);
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
- grantedScopes = scopes;
741
- lastScopeRefresh = Date.now();
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
- return void 0;
748
- }
749
- if (permissionResult.status === "pending" && permissionResult.approvalId !== void 0) {
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: action pending approval (ID: ${permissionResult.approvalId}). Polling for status...`
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
- const pollResult = await pollApprovalStatus(
754
- permissionResult.approvalId,
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
- if (pollResult === "approved") {
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 (pollResult === "rejected") {
763
- return {
764
- block: true,
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: "Approval request expired before a decision was made."
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
- return {
775
- block: true,
776
- blockReason: "Approval request timed out after 5 minutes."
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.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "multicorn-shield",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "description": "The control layer for AI agents: permissions, consent, spending limits, and audit logging.",
5
5
  "license": "MIT",
6
6
  "type": "module",