multicorn-shield 0.1.9 → 0.1.13

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 CHANGED
@@ -10,6 +10,12 @@ The permissions and control layer for AI agents. Open source.
10
10
  [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
11
11
  [![Bundle Size](https://img.shields.io/bundlephobia/minzip/multicorn-shield)](https://bundlephobia.com/package/multicorn-shield)
12
12
 
13
+ ## Demo
14
+
15
+ <p align="center">
16
+ <img src="https://multicorn.ai/images/demo.gif" alt="Multicorn Shield demo: agent blocked, user approves in dashboard, agent proceeds" width="800" />
17
+ </p>
18
+
13
19
  ## Why?
14
20
 
15
21
  AI agents are getting access to your email, calendar, bank accounts, and code repositories. Today, most agents operate with no permission boundaries: they can read, write, and spend with no oversight. Multicorn Shield gives developers a single SDK to enforce what agents can do, track what they did, and let users stay in control.
@@ -48,7 +54,79 @@ That's it. Every tool call now goes through Shield's permission layer, and activ
48
54
 
49
55
  See the [full MCP proxy guide](https://multicorn.ai/docs/mcp-proxy) for Claude Code, OpenClaw, and generic MCP client examples.
50
56
 
51
- ### Option 2: Integrate the SDK
57
+ ### Option 2: OpenClaw Plugin (native integration)
58
+
59
+ If you're running [OpenClaw](https://openclaw.ai), Shield integrates directly as a plugin. No proxy layer, no code changes. The plugin intercepts every tool call at the infrastructure level before it executes.
60
+
61
+ **Step 1: Install and configure**
62
+
63
+ ```bash
64
+ npm install -g multicorn-shield
65
+ npx multicorn-proxy init
66
+ ```
67
+
68
+ Enter your API key when prompted. This saves your key to `~/.multicorn/config.json` and configures the OpenClaw hook environment.
69
+
70
+ **Step 2: Build the plugin**
71
+
72
+ ```bash
73
+ cd $(npm root -g)/multicorn-shield
74
+ npm run build
75
+ ```
76
+
77
+ **Step 3: Register with OpenClaw**
78
+
79
+ Add the plugin path to your `~/.openclaw/openclaw.json`:
80
+
81
+ ```json
82
+ {
83
+ "plugins": {
84
+ "load": {
85
+ "paths": ["<npm-root>/multicorn-shield/dist/openclaw-plugin/index.js"]
86
+ },
87
+ "entries": {
88
+ "multicorn-shield": {
89
+ "enabled": true
90
+ }
91
+ }
92
+ }
93
+ }
94
+ ```
95
+
96
+ Replace `<npm-root>` with the output of `npm root -g`.
97
+
98
+ **Step 4: Restart and verify**
99
+
100
+ ```bash
101
+ openclaw gateway restart
102
+ openclaw plugins list
103
+ ```
104
+
105
+ You should see `multicorn-shield` in the loaded plugins list.
106
+
107
+ **How it works**
108
+
109
+ 1. Agent tries to use a tool (read files, run commands, send emails)
110
+ 2. Shield intercepts via `before_tool_call` and checks permissions
111
+ 3. First time: A consent screen opens in your browser so you can authorize the agent
112
+ 4. Authorized actions: Proceed immediately
113
+ 5. New or elevated actions: Blocked with a link to the dashboard where you approve or reject
114
+ 6. Everything is logged to your Multicorn dashboard
115
+
116
+ The plugin maps OpenClaw tools to Shield permission scopes automatically:
117
+
118
+ | OpenClaw Tool | Shield Scope |
119
+ | ------------------- | ---------------- |
120
+ | read | filesystem:read |
121
+ | write, edit | filesystem:write |
122
+ | exec | terminal:execute |
123
+ | exec (rm, mv, sudo) | terminal:write |
124
+ | browser | browser:execute |
125
+ | message | messaging:write |
126
+
127
+ Destructive commands (rm, mv, sudo, chmod) are detected automatically and require separate write-level approval.
128
+
129
+ ### Option 3: Integrate the SDK
52
130
 
53
131
  For full control over consent screens, spending limits, and action logging, use the SDK directly in your application code.
54
132
 
@@ -704,6 +704,9 @@ async function fetchGrantedScopes(agentId, apiKey, baseUrl) {
704
704
  return scopes;
705
705
  }
706
706
  function openBrowser(url) {
707
+ if (process.env["NODE_ENV"] === "test" || process.env["VITEST"] === "true") {
708
+ return;
709
+ }
707
710
  const platform = process.platform;
708
711
  const cmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
709
712
  spawn(cmd, [url], { detached: true, stdio: "ignore" }).unref();
@@ -273,6 +273,16 @@ var POLL_INTERVAL_MS2 = 3e3;
273
273
  var POLL_TIMEOUT_MS2 = 5 * 60 * 1e3;
274
274
  function deriveDashboardUrl(baseUrl) {
275
275
  try {
276
+ const envBase = process.env["MULTICORN_BASE_URL"];
277
+ if (typeof envBase === "string" && envBase.trim().length > 0) {
278
+ const trimmed = envBase.trim();
279
+ if (trimmed.includes("localhost") || trimmed.includes("127.0.0.1")) {
280
+ baseUrl = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`;
281
+ }
282
+ }
283
+ if (!/^https?:\/\//i.test(baseUrl) && (baseUrl.includes("localhost") || baseUrl.includes("127.0.0.1"))) {
284
+ baseUrl = `http://${baseUrl}`;
285
+ }
276
286
  const url = new URL(baseUrl);
277
287
  if (url.hostname === "localhost" || url.hostname === "127.0.0.1") {
278
288
  url.port = "5173";
@@ -301,6 +311,9 @@ function buildConsentUrl(agentName, dashboardUrl, scope) {
301
311
  return `${base}/consent?${params.toString()}`;
302
312
  }
303
313
  function openBrowser(url) {
314
+ if (process.env["NODE_ENV"] === "test" || process.env["VITEST"] === "true") {
315
+ return;
316
+ }
304
317
  const platform = process.platform;
305
318
  const cmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
306
319
  try {
@@ -315,6 +328,7 @@ ${url}
315
328
  }
316
329
  async function waitForConsent(agentId, agentName, apiKey, baseUrl, scope, logger) {
317
330
  const dashboardUrl = deriveDashboardUrl(baseUrl);
331
+ console.error("[SHIELD] buildConsentUrl baseUrl:", baseUrl);
318
332
  const consentUrl = buildConsentUrl(agentName, dashboardUrl, scope);
319
333
  process.stderr.write(
320
334
  `[multicorn-shield] Opening consent page...
@@ -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`, {
@@ -412,6 +335,16 @@ var POLL_INTERVAL_MS2 = 3e3;
412
335
  var POLL_TIMEOUT_MS2 = 5 * 60 * 1e3;
413
336
  function deriveDashboardUrl(baseUrl) {
414
337
  try {
338
+ const envBase = process.env["MULTICORN_BASE_URL"];
339
+ if (typeof envBase === "string" && envBase.trim().length > 0) {
340
+ const trimmed = envBase.trim();
341
+ if (trimmed.includes("localhost") || trimmed.includes("127.0.0.1")) {
342
+ baseUrl = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`;
343
+ }
344
+ }
345
+ if (!/^https?:\/\//i.test(baseUrl) && (baseUrl.includes("localhost") || baseUrl.includes("127.0.0.1"))) {
346
+ baseUrl = `http://${baseUrl}`;
347
+ }
415
348
  const url = new URL(baseUrl);
416
349
  if (url.hostname === "localhost" || url.hostname === "127.0.0.1") {
417
350
  url.port = "5173";
@@ -440,6 +373,9 @@ function buildConsentUrl(agentName, dashboardUrl, scope) {
440
373
  return `${base}/consent?${params.toString()}`;
441
374
  }
442
375
  function openBrowser(url) {
376
+ if (process.env["NODE_ENV"] === "test" || process.env["VITEST"] === "true") {
377
+ return;
378
+ }
443
379
  const platform = process.platform;
444
380
  const cmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
445
381
  try {
@@ -454,6 +390,7 @@ ${url}
454
390
  }
455
391
  async function waitForConsent(agentId, agentName, apiKey, baseUrl, scope, logger) {
456
392
  const dashboardUrl = deriveDashboardUrl(baseUrl);
393
+ console.error("[SHIELD] buildConsentUrl baseUrl:", baseUrl);
457
394
  const consentUrl = buildConsentUrl(agentName, dashboardUrl, scope);
458
395
  process.stderr.write(
459
396
  `[multicorn-shield] Opening consent page...
@@ -507,11 +444,10 @@ function loadMulticornConfig() {
507
444
  }
508
445
  function readConfig() {
509
446
  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";
447
+ const resolvedApiKey = asString(cachedMulticornConfig?.apiKey) ?? asString(process.env["MULTICORN_API_KEY"]) ?? "";
448
+ const resolvedBaseUrl = asString(cachedMulticornConfig?.baseUrl) ?? asString(process.env["MULTICORN_BASE_URL"]) ?? "https://api.multicorn.ai";
512
449
  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";
450
+ const failMode = "closed";
515
451
  return { apiKey: resolvedApiKey, baseUrl: resolvedBaseUrl, agentName, failMode };
516
452
  }
517
453
  function asString(value) {
@@ -683,110 +619,156 @@ function buildApprovalDescription(agentName, permissionLevel, service, toolName,
683
619
  return truncated;
684
620
  }
685
621
  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."
622
+ try {
623
+ console.error("[SHIELD] beforeToolCall ENTRY: tool=" + event.toolName);
624
+ const config = readConfig();
625
+ console.error(
626
+ "[SHIELD] config loaded: baseUrl=" + config.baseUrl + " apiKey=" + (config.apiKey ? "present" : "MISSING") + " failMode=" + config.failMode
690
627
  );
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
628
+ if (config.apiKey.length === 0) {
629
+ pluginLogger?.warn(
630
+ "Multicorn Shield: No API key found. Run 'npx multicorn-proxy init' or set MULTICORN_API_KEY."
739
631
  );
740
- grantedScopes = scopes;
741
- lastScopeRefresh = Date.now();
742
- if (scopes.length > 0) {
743
- await saveCachedScopes(agentName, agentRecord.id, scopes).catch(() => {
744
- });
745
- }
632
+ console.error("[SHIELD] DECISION: allow (no API key)");
633
+ return void 0;
746
634
  }
747
- return void 0;
748
- }
749
- if (permissionResult.status === "pending" && permissionResult.approvalId !== void 0) {
635
+ const agentName = resolveAgentName(ctx.sessionKey ?? "", config.agentName);
636
+ const readiness = await ensureAgent(agentName, config.apiKey, config.baseUrl, config.failMode);
637
+ console.error("[SHIELD] ensureAgent result: " + JSON.stringify(readiness));
638
+ if (readiness === "block") {
639
+ const returnValue2 = {
640
+ block: true,
641
+ blockReason: "Multicorn Shield could not verify permissions. The Shield API is unreachable and fail-closed mode is enabled."
642
+ };
643
+ console.error("[SHIELD] DECISION: " + JSON.stringify(returnValue2));
644
+ return returnValue2;
645
+ }
646
+ if (readiness === "skip") {
647
+ console.error("[SHIELD] DECISION: allow (skip mode)");
648
+ return void 0;
649
+ }
650
+ const command = event.toolName === "exec" && typeof event.params["command"] === "string" ? event.params["command"] : void 0;
651
+ const mapping = mapToolToScope(event.toolName, command);
750
652
  pluginLogger?.info(
751
- `Multicorn Shield: action pending approval (ID: ${permissionResult.approvalId}). Polling for status...`
653
+ `Multicorn Shield: tool=${event.toolName}, service=${mapping.service}, permissionLevel=${mapping.permissionLevel}`
752
654
  );
753
- const pollResult = await pollApprovalStatus(
754
- permissionResult.approvalId,
655
+ const actionType = mapping.permissionLevel === "write" && event.toolName === "exec" ? "exec_write" : event.toolName;
656
+ const description = buildApprovalDescription(
657
+ agentName,
658
+ mapping.permissionLevel,
659
+ mapping.service,
660
+ event.toolName,
661
+ event.params
662
+ );
663
+ if (grantedScopes.length === 0 && agentRecord !== null) {
664
+ await ensureConsent(agentName, config.apiKey, config.baseUrl, mapping);
665
+ console.error("[SHIELD] ensureConsent result: completed (zero-scopes path)");
666
+ }
667
+ console.error(
668
+ "[SHIELD] calling checkActionPermission: service=" + mapping.service + " actionType=" + actionType
669
+ );
670
+ const permissionResult = await checkActionPermission(
671
+ {
672
+ agent: agentName,
673
+ service: mapping.service,
674
+ actionType,
675
+ status: "approved",
676
+ // Status doesn't matter for permission check
677
+ metadata: {
678
+ description
679
+ }
680
+ },
755
681
  config.apiKey,
756
682
  config.baseUrl,
757
683
  pluginLogger ?? void 0
758
684
  );
759
- if (pollResult === "approved") {
685
+ console.error("[SHIELD] permission result: " + JSON.stringify(permissionResult));
686
+ if (permissionResult.status === "approved") {
687
+ if (agentRecord !== null) {
688
+ const scopes = await fetchGrantedScopes(
689
+ agentRecord.id,
690
+ config.apiKey,
691
+ config.baseUrl,
692
+ pluginLogger ?? void 0
693
+ );
694
+ grantedScopes = scopes;
695
+ lastScopeRefresh = Date.now();
696
+ if (Array.isArray(scopes) && scopes.length > 0) {
697
+ await saveCachedScopes(agentName, agentRecord.id, scopes).catch(() => {
698
+ });
699
+ }
700
+ }
701
+ console.error("[SHIELD] DECISION: allow (approved)");
760
702
  return void 0;
761
703
  }
762
- if (pollResult === "rejected") {
763
- return {
764
- block: true,
765
- blockReason: "Action was reviewed and rejected."
766
- };
767
- }
768
- if (pollResult === "expired") {
769
- return {
704
+ if (permissionResult.status === "pending" && permissionResult.approvalId !== void 0) {
705
+ const dashboardUrl2 = deriveDashboardUrl(config.baseUrl);
706
+ const returnValue2 = {
770
707
  block: true,
771
- blockReason: "Approval request expired before a decision was made."
708
+ blockReason: `Action pending approval. Visit ${dashboardUrl2}approvals to approve or reject, then try again.`
772
709
  };
710
+ console.error("[SHIELD] DECISION: " + JSON.stringify(returnValue2));
711
+ return returnValue2;
773
712
  }
774
- return {
775
- block: true,
776
- blockReason: "Approval request timed out after 5 minutes."
713
+ const requestedScope = {
714
+ service: mapping.service,
715
+ permissionLevel: mapping.permissionLevel
777
716
  };
717
+ if (!hasScope(grantedScopes, requestedScope) && agentRecord !== null) {
718
+ await ensureConsent(agentName, config.apiKey, config.baseUrl, mapping);
719
+ console.error("[SHIELD] ensureConsent result: completed (blocked path)");
720
+ const scopes = await fetchGrantedScopes(
721
+ agentRecord.id,
722
+ config.apiKey,
723
+ config.baseUrl,
724
+ pluginLogger ?? void 0
725
+ );
726
+ grantedScopes = scopes;
727
+ lastScopeRefresh = Date.now();
728
+ if (Array.isArray(scopes) && scopes.length > 0) {
729
+ await saveCachedScopes(agentName, agentRecord.id, scopes).catch(() => {
730
+ });
731
+ }
732
+ const recheckResult = await checkActionPermission(
733
+ {
734
+ agent: agentName,
735
+ service: mapping.service,
736
+ actionType,
737
+ status: "approved",
738
+ metadata: { description }
739
+ },
740
+ config.apiKey,
741
+ config.baseUrl,
742
+ pluginLogger ?? void 0
743
+ );
744
+ if (recheckResult.status === "approved") {
745
+ const refreshedScopes = await fetchGrantedScopes(
746
+ agentRecord.id,
747
+ config.apiKey,
748
+ config.baseUrl,
749
+ pluginLogger ?? void 0
750
+ );
751
+ grantedScopes = refreshedScopes;
752
+ lastScopeRefresh = Date.now();
753
+ if (Array.isArray(refreshedScopes) && refreshedScopes.length > 0) {
754
+ await saveCachedScopes(agentName, agentRecord.id, refreshedScopes).catch(() => {
755
+ });
756
+ }
757
+ console.error("[SHIELD] DECISION: allow (re-check after consent)");
758
+ return void 0;
759
+ }
760
+ }
761
+ const capitalizedService = mapping.service.charAt(0).toUpperCase() + mapping.service.slice(1);
762
+ const dashboardUrl = deriveDashboardUrl(config.baseUrl);
763
+ const reason = `${capitalizedService} ${mapping.permissionLevel} access is not allowed. Check pending approvals at ${dashboardUrl}/approvals `;
764
+ const returnValue = { block: true, blockReason: reason };
765
+ console.error("[SHIELD] DECISION: " + JSON.stringify(returnValue));
766
+ return returnValue;
767
+ } catch (e) {
768
+ console.error("[SHIELD] CRASH in beforeToolCall: " + String(e));
769
+ console.error("[SHIELD] Stack: " + ((e instanceof Error ? e.stack : void 0) ?? "no stack"));
770
+ return { block: true, blockReason: "Shield internal error: " + String(e) };
778
771
  }
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
772
  }
791
773
  function afterToolCall(event, ctx) {
792
774
  const config = readConfig();
@@ -819,6 +801,7 @@ var plugin = {
819
801
  pluginLogger = api.logger;
820
802
  pluginConfig = api.pluginConfig;
821
803
  cachedMulticornConfig = loadMulticornConfig();
804
+ console.error("[SHIELD-DIAG] cachedMulticornConfig: " + JSON.stringify(cachedMulticornConfig));
822
805
  api.on("before_tool_call", beforeToolCall, { priority: 10 });
823
806
  api.on("after_tool_call", afterToolCall);
824
807
  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.13",
4
4
  "description": "The control layer for AI agents: permissions, consent, spending limits, and audit logging.",
5
5
  "license": "MIT",
6
6
  "type": "module",