multicorn-shield 0.1.2 → 0.1.5

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,6 +128,11 @@ function validateScopeAccess(grantedScopes, requested) {
128
128
  reason: `No permissions granted for service "${requested.service}". The agent has not been authorised to access this service. Request scope "${formatScope(requested)}" via the consent screen.`
129
129
  };
130
130
  }
131
+ function hasScope(grantedScopes, requested) {
132
+ return grantedScopes.some(
133
+ (granted) => granted.service === requested.service && granted.permissionLevel === requested.permissionLevel
134
+ );
135
+ }
131
136
 
132
137
  // src/logger/action-logger.ts
133
138
  function createActionLogger(config) {
@@ -639,9 +644,9 @@ function openBrowser(url) {
639
644
  const cmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
640
645
  spawn(cmd, [url], { detached: true, stdio: "ignore" }).unref();
641
646
  }
642
- async function waitForConsent(agentId, agentName, apiKey, baseUrl, dashboardUrl, logger) {
643
- const detectedScopes = detectScopeHints();
644
- const consentUrl = buildConsentUrl(agentName, detectedScopes, dashboardUrl);
647
+ async function waitForConsent(agentId, agentName, apiKey, baseUrl, dashboardUrl, logger, scope) {
648
+ const scopeStrings = scope ? [`${scope.service}:${scope.permissionLevel}`] : detectScopeHints();
649
+ const consentUrl = buildConsentUrl(agentName, scopeStrings, dashboardUrl);
645
650
  logger.info("Opening consent page in your browser.", { url: consentUrl });
646
651
  process.stderr.write(
647
652
  `
@@ -693,11 +698,12 @@ async function resolveAgentRecord(agentName, apiKey, baseUrl, logger) {
693
698
  return { ...agent, scopes };
694
699
  }
695
700
  function buildConsentUrl(agentName, scopes, dashboardUrl) {
701
+ const base = dashboardUrl.replace(/\/+$/, "");
696
702
  const params = new URLSearchParams({ agent: agentName });
697
703
  if (scopes.length > 0) {
698
704
  params.set("scopes", scopes.join(","));
699
705
  }
700
- return `${dashboardUrl}/consent?${params.toString()}`;
706
+ return `${base}/consent?${params.toString()}`;
701
707
  }
702
708
  function detectScopeHints() {
703
709
  return [];
@@ -746,7 +752,9 @@ function createProxyServer(config) {
746
752
  let consentInProgress = false;
747
753
  const pendingLines = [];
748
754
  let draining = false;
755
+ let stopped = false;
749
756
  async function refreshScopes() {
757
+ if (stopped) return;
750
758
  if (agentId.length === 0) return;
751
759
  try {
752
760
  const scopes = await fetchGrantedScopes(agentId, config.apiKey, config.baseUrl);
@@ -761,18 +769,24 @@ function createProxyServer(config) {
761
769
  });
762
770
  }
763
771
  }
764
- async function ensureConsent() {
765
- if (grantedScopes.length > 0 || consentInProgress) return;
772
+ async function ensureConsent(requestedScope) {
766
773
  if (agentId.length === 0) return;
774
+ if (requestedScope !== void 0) {
775
+ if (hasScope(grantedScopes, requestedScope) || consentInProgress) return;
776
+ } else {
777
+ if (grantedScopes.length > 0 || consentInProgress) return;
778
+ }
767
779
  consentInProgress = true;
768
780
  try {
781
+ const scopeParam = requestedScope !== void 0 ? { service: requestedScope.service, permissionLevel: requestedScope.permissionLevel } : void 0;
769
782
  const scopes = await waitForConsent(
770
783
  agentId,
771
784
  config.agentName,
772
785
  config.apiKey,
773
786
  config.baseUrl,
774
787
  config.dashboardUrl,
775
- config.logger
788
+ config.logger,
789
+ scopeParam
776
790
  );
777
791
  grantedScopes = scopes;
778
792
  await saveCachedScopes(config.agentName, agentId, scopes);
@@ -786,7 +800,6 @@ function createProxyServer(config) {
786
800
  if (request.method !== "tools/call") return null;
787
801
  const toolParams = extractToolCallParams(request);
788
802
  if (toolParams === null) return null;
789
- await ensureConsent();
790
803
  const service = extractServiceFromToolName(toolParams.name);
791
804
  const action = extractActionFromToolName(toolParams.name);
792
805
  const requestedScope = { service, permissionLevel: "execute" };
@@ -797,20 +810,24 @@ function createProxyServer(config) {
797
810
  allowed: validation.allowed
798
811
  });
799
812
  if (!validation.allowed) {
800
- if (actionLogger !== null) {
801
- if (!config.agentName || config.agentName.trim().length === 0) {
802
- process.stderr.write("[multicorn-proxy] Cannot log action: agent name not resolved\n");
803
- } else {
804
- await actionLogger.logAction({
805
- agent: config.agentName,
806
- service,
807
- actionType: action,
808
- status: "blocked"
809
- });
813
+ await ensureConsent(requestedScope);
814
+ const revalidation = validateScopeAccess(grantedScopes, requestedScope);
815
+ if (!revalidation.allowed) {
816
+ if (actionLogger !== null) {
817
+ if (!config.agentName || config.agentName.trim().length === 0) {
818
+ process.stderr.write("[multicorn-proxy] Cannot log action: agent name not resolved\n");
819
+ } else {
820
+ await actionLogger.logAction({
821
+ agent: config.agentName,
822
+ service,
823
+ actionType: action,
824
+ status: "blocked"
825
+ });
826
+ }
810
827
  }
828
+ const blocked = buildBlockedResponse(request.id, service, "execute", config.dashboardUrl);
829
+ return JSON.stringify(blocked);
811
830
  }
812
- const blocked = buildBlockedResponse(request.id, service, "execute", config.dashboardUrl);
813
- return JSON.stringify(blocked);
814
831
  }
815
832
  if (spendingChecker !== null) {
816
833
  const costCents = extractCostCents(toolParams.arguments);
@@ -880,6 +897,7 @@ function createProxyServer(config) {
880
897
  void drainQueue();
881
898
  }
882
899
  async function stop() {
900
+ stopped = true;
883
901
  if (refreshTimer !== null) {
884
902
  clearInterval(refreshTimer);
885
903
  refreshTimer = null;
@@ -25,6 +25,24 @@ metadata:
25
25
  > openclaw gateway restart
26
26
  > ```
27
27
  >
28
+ > **Plugin Configuration:** Configure the plugin in `~/.openclaw/openclaw.json`:
29
+ >
30
+ > ```json
31
+ > {
32
+ > "plugins": {
33
+ > "entries": {
34
+ > "multicorn-shield": {
35
+ > "enabled": true,
36
+ > "env": {
37
+ > "MULTICORN_API_KEY": "mcs_your_key_here",
38
+ > "MULTICORN_BASE_URL": "https://api.multicorn.ai"
39
+ > }
40
+ > }
41
+ > }
42
+ > }
43
+ > }
44
+ > ```
45
+ >
28
46
  > See the plugin README for full instructions.
29
47
 
30
48
  Governance layer for OpenClaw agents. Every tool call is checked against your Shield permissions before it runs. Blocked actions never reach the tool. All activity - approved and blocked - shows up in your Shield dashboard.
@@ -123,6 +123,7 @@ function isScopesCacheFile(value) {
123
123
  // src/openclaw/shield-client.ts
124
124
  var REQUEST_TIMEOUT_MS = 5e3;
125
125
  var AUTH_HEADER = "X-Multicorn-Key";
126
+ var authErrorLogged = false;
126
127
  function isApiSuccess(value) {
127
128
  if (typeof value !== "object" || value === null) return false;
128
129
  const obj = value;
@@ -143,13 +144,42 @@ function isPermissionEntry(value) {
143
144
  const obj = value;
144
145
  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");
145
146
  }
146
- async function findAgentByName(agentName, apiKey, baseUrl) {
147
+ function handleHttpError(status, logger, retryDelaySeconds) {
148
+ if (status === 401 || status === 403) {
149
+ if (!authErrorLogged) {
150
+ authErrorLogged = true;
151
+ const errorMsg = "[multicorn-shield] ERROR: Authentication failed. Your MULTICORN_API_KEY is invalid or expired. Check the key in your OpenClaw config (~/.openclaw/openclaw.json \u2192 plugins.entries.multicorn-shield.env.MULTICORN_API_KEY). Get a valid key from your Multicorn dashboard (Settings \u2192 API Keys).";
152
+ process.stderr.write(`${errorMsg}
153
+ `);
154
+ }
155
+ return { shouldBlock: true };
156
+ }
157
+ if (status === 429) {
158
+ {
159
+ const rateLimitMsg = "[multicorn-shield] Rate limited by Shield API. Action was not checked \u2014 proceeding with fail-open.";
160
+ process.stderr.write(`${rateLimitMsg}
161
+ `);
162
+ }
163
+ return { shouldBlock: false };
164
+ }
165
+ if (status >= 500 && status < 600) {
166
+ const serverErrorMsg = `[multicorn-shield] Shield API error (${String(status)}). Action was not checked \u2014 proceeding with fail-open.`;
167
+ process.stderr.write(`${serverErrorMsg}
168
+ `);
169
+ return { shouldBlock: false };
170
+ }
171
+ return { shouldBlock: false };
172
+ }
173
+ async function findAgentByName(agentName, apiKey, baseUrl, logger) {
147
174
  try {
148
175
  const response = await fetch(`${baseUrl}/api/v1/agents`, {
149
176
  headers: { [AUTH_HEADER]: apiKey },
150
177
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
151
178
  });
152
- if (!response.ok) return null;
179
+ if (!response.ok) {
180
+ handleHttpError(response.status, logger);
181
+ return null;
182
+ }
153
183
  const body = await response.json();
154
184
  if (!isApiSuccess(body)) return null;
155
185
  const agents = body.data;
@@ -160,7 +190,7 @@ async function findAgentByName(agentName, apiKey, baseUrl) {
160
190
  return null;
161
191
  }
162
192
  }
163
- async function registerAgent(agentName, apiKey, baseUrl) {
193
+ async function registerAgent(agentName, apiKey, baseUrl, logger) {
164
194
  const response = await fetch(`${baseUrl}/api/v1/agents`, {
165
195
  method: "POST",
166
196
  headers: {
@@ -171,6 +201,7 @@ async function registerAgent(agentName, apiKey, baseUrl) {
171
201
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
172
202
  });
173
203
  if (!response.ok) {
204
+ handleHttpError(response.status);
174
205
  throw new Error(
175
206
  `Failed to register agent "${agentName}": service returned ${String(response.status)}.`
176
207
  );
@@ -181,23 +212,26 @@ async function registerAgent(agentName, apiKey, baseUrl) {
181
212
  }
182
213
  return body.data.id;
183
214
  }
184
- async function findOrRegisterAgent(agentName, apiKey, baseUrl) {
185
- const existing = await findAgentByName(agentName, apiKey, baseUrl);
215
+ async function findOrRegisterAgent(agentName, apiKey, baseUrl, logger) {
216
+ const existing = await findAgentByName(agentName, apiKey, baseUrl, logger);
186
217
  if (existing !== null) return existing;
187
218
  try {
188
- const id = await registerAgent(agentName, apiKey, baseUrl);
219
+ const id = await registerAgent(agentName, apiKey, baseUrl, logger);
189
220
  return { id, name: agentName };
190
221
  } catch {
191
222
  return null;
192
223
  }
193
224
  }
194
- async function fetchGrantedScopes(agentId, apiKey, baseUrl) {
225
+ async function fetchGrantedScopes(agentId, apiKey, baseUrl, logger) {
195
226
  try {
196
227
  const response = await fetch(`${baseUrl}/api/v1/agents/${agentId}`, {
197
228
  headers: { [AUTH_HEADER]: apiKey },
198
229
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
199
230
  });
200
- if (!response.ok) return [];
231
+ if (!response.ok) {
232
+ handleHttpError(response.status, logger);
233
+ return [];
234
+ }
201
235
  const body = await response.json();
202
236
  if (!isApiSuccess(body)) return [];
203
237
  const detail = body.data;
@@ -215,7 +249,7 @@ async function fetchGrantedScopes(agentId, apiKey, baseUrl) {
215
249
  return [];
216
250
  }
217
251
  }
218
- async function logAction(payload, apiKey, baseUrl) {
252
+ async function logAction(payload, apiKey, baseUrl, logger) {
219
253
  try {
220
254
  const response = await fetch(`${baseUrl}/api/v1/actions`, {
221
255
  method: "POST",
@@ -227,10 +261,7 @@ async function logAction(payload, apiKey, baseUrl) {
227
261
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
228
262
  });
229
263
  if (!response.ok) {
230
- process.stderr.write(
231
- `[multicorn-shield] Action log failed: HTTP ${String(response.status)}.
232
- `
233
- );
264
+ handleHttpError(response.status, logger);
234
265
  }
235
266
  } catch (error) {
236
267
  const detail = error instanceof Error ? error.message : String(error);
@@ -261,9 +292,12 @@ function deriveDashboardUrl(baseUrl) {
261
292
  return "https://app.multicorn.ai";
262
293
  }
263
294
  }
264
- function buildConsentUrl(agentName, dashboardUrl) {
295
+ function buildConsentUrl(agentName, dashboardUrl, scope) {
265
296
  const base = dashboardUrl.replace(/\/+$/, "");
266
297
  const params = new URLSearchParams({ agent: agentName });
298
+ if (scope) {
299
+ params.set("scopes", `${scope.service}:${scope.permissionLevel}`);
300
+ }
267
301
  return `${base}/consent?${params.toString()}`;
268
302
  }
269
303
  function openBrowser(url) {
@@ -279,9 +313,9 @@ ${url}
279
313
  );
280
314
  }
281
315
  }
282
- async function waitForConsent(agentId, agentName, apiKey, baseUrl) {
316
+ async function waitForConsent(agentId, agentName, apiKey, baseUrl, scope, logger) {
283
317
  const dashboardUrl = deriveDashboardUrl(baseUrl);
284
- const consentUrl = buildConsentUrl(agentName, dashboardUrl);
318
+ const consentUrl = buildConsentUrl(agentName, dashboardUrl, scope);
285
319
  process.stderr.write(
286
320
  `[multicorn-shield] Opening consent page...
287
321
  ${consentUrl}
@@ -292,7 +326,7 @@ Waiting for you to grant access in the Multicorn dashboard...
292
326
  const deadline = Date.now() + POLL_TIMEOUT_MS2;
293
327
  while (Date.now() < deadline) {
294
328
  await sleep(POLL_INTERVAL_MS2);
295
- const scopes = await fetchGrantedScopes(agentId, apiKey, baseUrl);
329
+ const scopes = await fetchGrantedScopes(agentId, apiKey, baseUrl, logger);
296
330
  if (scopes.length > 0) {
297
331
  process.stderr.write("[multicorn-shield] Permissions granted.\n");
298
332
  return scopes;
@@ -306,6 +340,13 @@ function sleep(ms) {
306
340
  return new Promise((resolve) => setTimeout(resolve, ms));
307
341
  }
308
342
 
343
+ // src/scopes/scope-validator.ts
344
+ function hasScope(grantedScopes2, requested) {
345
+ return grantedScopes2.some(
346
+ (granted) => granted.service === requested.service && granted.permissionLevel === requested.permissionLevel
347
+ );
348
+ }
349
+
309
350
  // src/openclaw/hook/handler.ts
310
351
  var agentRecord = null;
311
352
  var grantedScopes = [];
@@ -368,11 +409,20 @@ async function ensureAgent(agentName, apiKey, baseUrl, failMode) {
368
409
  }
369
410
  return "ready";
370
411
  }
371
- async function ensureConsent(agentName, apiKey, baseUrl) {
372
- if (grantedScopes.length > 0 || consentInProgress || agentRecord === null) return;
412
+ async function ensureConsent(agentName, apiKey, baseUrl, scope) {
413
+ if (agentRecord === null) return;
414
+ if (scope !== void 0) {
415
+ const requestedScope = {
416
+ service: scope.service,
417
+ permissionLevel: scope.permissionLevel
418
+ };
419
+ if (hasScope(grantedScopes, requestedScope) || consentInProgress) return;
420
+ } else {
421
+ if (grantedScopes.length > 0 || consentInProgress) return;
422
+ }
373
423
  consentInProgress = true;
374
424
  try {
375
- const scopes = await waitForConsent(agentRecord.id, agentName, apiKey, baseUrl);
425
+ const scopes = await waitForConsent(agentRecord.id, agentName, apiKey, baseUrl, scope);
376
426
  grantedScopes = scopes;
377
427
  await saveCachedScopes(agentName, agentRecord.id, scopes).catch(() => {
378
428
  });
@@ -382,6 +432,7 @@ async function ensureConsent(agentName, apiKey, baseUrl) {
382
432
  }
383
433
  function isPermitted(event) {
384
434
  const mapping = mapToolToScope(event.context.toolName);
435
+ if (!grantedScopes || grantedScopes.length === 0) return false;
385
436
  return grantedScopes.some(
386
437
  (scope) => scope.service === mapping.service && scope.permissionLevel === mapping.permissionLevel
387
438
  );
@@ -406,8 +457,14 @@ var handler = async (event) => {
406
457
  if (readiness === "skip") {
407
458
  return;
408
459
  }
409
- await ensureConsent(agentName, config.apiKey, config.baseUrl);
410
460
  const mapping = mapToolToScope(event.context.toolName);
461
+ const requestedScope = {
462
+ service: mapping.service,
463
+ permissionLevel: mapping.permissionLevel
464
+ };
465
+ if (!hasScope(grantedScopes, requestedScope)) {
466
+ await ensureConsent(agentName, config.apiKey, config.baseUrl, mapping);
467
+ }
411
468
  const permitted = isPermitted(event);
412
469
  if (!permitted) {
413
470
  const capitalizedService = mapping.service.charAt(0).toUpperCase() + mapping.service.slice(1);
@@ -1,10 +1,14 @@
1
- import { readFile, mkdir, writeFile } from 'fs/promises';
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
2
3
  import { join } from 'path';
4
+ import * as os from 'os';
3
5
  import { homedir } from 'os';
6
+ import { mkdir, readFile, writeFile } from 'fs/promises';
4
7
  import { spawn } from 'child_process';
5
8
 
6
9
  // Multicorn Shield plugin for OpenClaw - https://multicorn.ai
7
10
 
11
+
8
12
  // src/openclaw/tool-mapper.ts
9
13
  var TOOL_MAP = {
10
14
  // OpenClaw built-in tools
@@ -127,6 +131,7 @@ var AUTH_HEADER = "X-Multicorn-Key";
127
131
  var POLL_INTERVAL_MS = 3e3;
128
132
  var MAX_POLLS = 100;
129
133
  var POLL_TIMEOUT_MS = POLL_INTERVAL_MS * MAX_POLLS;
134
+ var authErrorLogged = false;
130
135
  function isApiSuccess(value) {
131
136
  if (typeof value !== "object" || value === null) return false;
132
137
  const obj = value;
@@ -152,13 +157,50 @@ function isApprovalResponse(value) {
152
157
  const obj = value;
153
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");
154
159
  }
155
- async function findAgentByName(agentName, apiKey, baseUrl) {
160
+ function handleHttpError(status, logger, retryDelaySeconds) {
161
+ if (status === 401 || status === 403) {
162
+ if (!authErrorLogged) {
163
+ authErrorLogged = true;
164
+ const errorMsg = "[multicorn-shield] ERROR: Authentication failed. Your MULTICORN_API_KEY is invalid or expired. Check the key in your OpenClaw config (~/.openclaw/openclaw.json \u2192 plugins.entries.multicorn-shield.env.MULTICORN_API_KEY). Get a valid key from your Multicorn dashboard (Settings \u2192 API Keys).";
165
+ logger?.error(errorMsg);
166
+ process.stderr.write(`${errorMsg}
167
+ `);
168
+ }
169
+ return { shouldBlock: true };
170
+ }
171
+ 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 {
178
+ const rateLimitMsg = "[multicorn-shield] Rate limited by Shield API. Action was not checked \u2014 proceeding with fail-open.";
179
+ logger?.warn(rateLimitMsg);
180
+ process.stderr.write(`${rateLimitMsg}
181
+ `);
182
+ }
183
+ return { shouldBlock: false };
184
+ }
185
+ if (status >= 500 && status < 600) {
186
+ const serverErrorMsg = `[multicorn-shield] Shield API error (${String(status)}). Action was not checked \u2014 proceeding with fail-open.`;
187
+ logger?.warn(serverErrorMsg);
188
+ process.stderr.write(`${serverErrorMsg}
189
+ `);
190
+ return { shouldBlock: false };
191
+ }
192
+ return { shouldBlock: false };
193
+ }
194
+ async function findAgentByName(agentName, apiKey, baseUrl, logger) {
156
195
  try {
157
196
  const response = await fetch(`${baseUrl}/api/v1/agents`, {
158
197
  headers: { [AUTH_HEADER]: apiKey },
159
198
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
160
199
  });
161
- if (!response.ok) return null;
200
+ if (!response.ok) {
201
+ handleHttpError(response.status, logger);
202
+ return null;
203
+ }
162
204
  const body = await response.json();
163
205
  if (!isApiSuccess(body)) return null;
164
206
  const agents = body.data;
@@ -169,7 +211,7 @@ async function findAgentByName(agentName, apiKey, baseUrl) {
169
211
  return null;
170
212
  }
171
213
  }
172
- async function registerAgent(agentName, apiKey, baseUrl) {
214
+ async function registerAgent(agentName, apiKey, baseUrl, logger) {
173
215
  const response = await fetch(`${baseUrl}/api/v1/agents`, {
174
216
  method: "POST",
175
217
  headers: {
@@ -180,6 +222,7 @@ async function registerAgent(agentName, apiKey, baseUrl) {
180
222
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
181
223
  });
182
224
  if (!response.ok) {
225
+ handleHttpError(response.status, logger);
183
226
  throw new Error(
184
227
  `Failed to register agent "${agentName}": service returned ${String(response.status)}.`
185
228
  );
@@ -190,23 +233,26 @@ async function registerAgent(agentName, apiKey, baseUrl) {
190
233
  }
191
234
  return body.data.id;
192
235
  }
193
- async function findOrRegisterAgent(agentName, apiKey, baseUrl) {
194
- const existing = await findAgentByName(agentName, apiKey, baseUrl);
236
+ async function findOrRegisterAgent(agentName, apiKey, baseUrl, logger) {
237
+ const existing = await findAgentByName(agentName, apiKey, baseUrl, logger);
195
238
  if (existing !== null) return existing;
196
239
  try {
197
- const id = await registerAgent(agentName, apiKey, baseUrl);
240
+ const id = await registerAgent(agentName, apiKey, baseUrl, logger);
198
241
  return { id, name: agentName };
199
242
  } catch {
200
243
  return null;
201
244
  }
202
245
  }
203
- async function fetchGrantedScopes(agentId, apiKey, baseUrl) {
246
+ async function fetchGrantedScopes(agentId, apiKey, baseUrl, logger) {
204
247
  try {
205
248
  const response = await fetch(`${baseUrl}/api/v1/agents/${agentId}`, {
206
249
  headers: { [AUTH_HEADER]: apiKey },
207
250
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
208
251
  });
209
- if (!response.ok) return [];
252
+ if (!response.ok) {
253
+ handleHttpError(response.status, logger);
254
+ return [];
255
+ }
210
256
  const body = await response.json();
211
257
  if (!isApiSuccess(body)) return [];
212
258
  const detail = body.data;
@@ -224,7 +270,7 @@ async function fetchGrantedScopes(agentId, apiKey, baseUrl) {
224
270
  return [];
225
271
  }
226
272
  }
227
- async function checkActionPermission(payload, apiKey, baseUrl) {
273
+ async function checkActionPermission(payload, apiKey, baseUrl, logger) {
228
274
  try {
229
275
  const response = await fetch(`${baseUrl}/api/v1/actions`, {
230
276
  method: "POST",
@@ -250,6 +296,14 @@ async function checkActionPermission(payload, apiKey, baseUrl) {
250
296
  }
251
297
  return { status: "pending", approvalId };
252
298
  }
299
+ if (response.status === 401 || response.status === 403) {
300
+ handleHttpError(response.status, logger);
301
+ return { status: "blocked" };
302
+ }
303
+ if (response.status === 429 || response.status >= 500 && response.status < 600) {
304
+ handleHttpError(response.status, logger);
305
+ return { status: "blocked" };
306
+ }
253
307
  return { status: "blocked" };
254
308
  } catch {
255
309
  return { status: "blocked" };
@@ -270,6 +324,14 @@ async function pollApprovalStatus(approvalId, apiKey, baseUrl, logger) {
270
324
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
271
325
  });
272
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
+ }
273
335
  logDebug?.(
274
336
  `Poll ${String(pollCount + 1)} failed: HTTP ${String(response.status)}. Retrying...`
275
337
  );
@@ -326,7 +388,7 @@ async function pollApprovalStatus(approvalId, apiKey, baseUrl, logger) {
326
388
  }
327
389
  return "timeout";
328
390
  }
329
- async function logAction(payload, apiKey, baseUrl) {
391
+ async function logAction(payload, apiKey, baseUrl, logger) {
330
392
  try {
331
393
  const response = await fetch(`${baseUrl}/api/v1/actions`, {
332
394
  method: "POST",
@@ -338,10 +400,7 @@ async function logAction(payload, apiKey, baseUrl) {
338
400
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
339
401
  });
340
402
  if (!response.ok) {
341
- process.stderr.write(
342
- `[multicorn-shield] Action log failed: HTTP ${String(response.status)}.
343
- `
344
- );
403
+ handleHttpError(response.status, logger);
345
404
  }
346
405
  } catch (error) {
347
406
  const detail = error instanceof Error ? error.message : String(error);
@@ -372,9 +431,12 @@ function deriveDashboardUrl(baseUrl) {
372
431
  return "https://app.multicorn.ai";
373
432
  }
374
433
  }
375
- function buildConsentUrl(agentName, dashboardUrl) {
434
+ function buildConsentUrl(agentName, dashboardUrl, scope) {
376
435
  const base = dashboardUrl.replace(/\/+$/, "");
377
436
  const params = new URLSearchParams({ agent: agentName });
437
+ if (scope) {
438
+ params.set("scopes", `${scope.service}:${scope.permissionLevel}`);
439
+ }
378
440
  return `${base}/consent?${params.toString()}`;
379
441
  }
380
442
  function openBrowser(url) {
@@ -390,9 +452,9 @@ ${url}
390
452
  );
391
453
  }
392
454
  }
393
- async function waitForConsent(agentId, agentName, apiKey, baseUrl) {
455
+ async function waitForConsent(agentId, agentName, apiKey, baseUrl, scope, logger) {
394
456
  const dashboardUrl = deriveDashboardUrl(baseUrl);
395
- const consentUrl = buildConsentUrl(agentName, dashboardUrl);
457
+ const consentUrl = buildConsentUrl(agentName, dashboardUrl, scope);
396
458
  process.stderr.write(
397
459
  `[multicorn-shield] Opening consent page...
398
460
  ${consentUrl}
@@ -403,7 +465,7 @@ Waiting for you to grant access in the Multicorn dashboard...
403
465
  const deadline = Date.now() + POLL_TIMEOUT_MS2;
404
466
  while (Date.now() < deadline) {
405
467
  await sleep(POLL_INTERVAL_MS2);
406
- const scopes = await fetchGrantedScopes(agentId, apiKey, baseUrl);
468
+ const scopes = await fetchGrantedScopes(agentId, apiKey, baseUrl, logger);
407
469
  if (scopes.length > 0) {
408
470
  process.stderr.write("[multicorn-shield] Permissions granted.\n");
409
471
  return scopes;
@@ -417,6 +479,13 @@ function sleep(ms) {
417
479
  return new Promise((resolve) => setTimeout(resolve, ms));
418
480
  }
419
481
 
482
+ // src/scopes/scope-validator.ts
483
+ function hasScope(grantedScopes2, requested) {
484
+ return grantedScopes2.some(
485
+ (granted) => granted.service === requested.service && granted.permissionLevel === requested.permissionLevel
486
+ );
487
+ }
488
+
420
489
  // src/openclaw/plugin/index.ts
421
490
  var agentRecord = null;
422
491
  var grantedScopes = [];
@@ -424,15 +493,45 @@ var consentInProgress = false;
424
493
  var lastScopeRefresh = 0;
425
494
  var pluginLogger = null;
426
495
  var pluginConfig;
496
+ var connectionLogged = false;
427
497
  var SCOPE_REFRESH_INTERVAL_MS = 6e4;
428
498
  function readConfig() {
429
499
  const pc = pluginConfig ?? {};
430
- const apiKey = asString(pc["apiKey"]) ?? process.env["MULTICORN_API_KEY"] ?? "";
431
- const baseUrl = asString(pc["baseUrl"]) ?? process.env["MULTICORN_BASE_URL"] ?? "https://api.multicorn.ai";
500
+ let resolvedApiKey = asString(pc["apiKey"]) ?? process.env["MULTICORN_API_KEY"] ?? "";
501
+ let resolvedBaseUrl = asString(pc["baseUrl"]) ?? process.env["MULTICORN_BASE_URL"] ?? "";
502
+ if (!resolvedApiKey) {
503
+ try {
504
+ const configPath = path.join(os.homedir(), ".openclaw", "openclaw.json");
505
+ const configContent = fs.readFileSync(configPath, "utf-8");
506
+ const config = JSON.parse(configContent);
507
+ const hooks = config["hooks"];
508
+ const internal = hooks?.["internal"];
509
+ const entries = internal?.["entries"];
510
+ const shieldEntry = entries?.["multicorn-shield"];
511
+ const env = shieldEntry?.env;
512
+ if (env) {
513
+ const hookApiKey = asString(env["MULTICORN_API_KEY"]);
514
+ const hookBaseUrl = asString(env["MULTICORN_BASE_URL"]);
515
+ if (hookApiKey) {
516
+ resolvedApiKey = hookApiKey;
517
+ resolvedBaseUrl = resolvedBaseUrl || (hookBaseUrl ?? "https://api.multicorn.ai");
518
+ pluginLogger?.warn(
519
+ "Multicorn Shield: Reading config from hooks.internal.entries. For cleaner setup, set MULTICORN_API_KEY as a system environment variable."
520
+ );
521
+ } else if (hookBaseUrl) {
522
+ resolvedBaseUrl = resolvedBaseUrl || hookBaseUrl;
523
+ }
524
+ }
525
+ } catch {
526
+ }
527
+ }
528
+ if (!resolvedBaseUrl) {
529
+ resolvedBaseUrl = "https://api.multicorn.ai";
530
+ }
432
531
  const agentName = asString(pc["agentName"]) ?? process.env["MULTICORN_AGENT_NAME"] ?? null;
433
532
  const failModeRaw = asString(pc["failMode"]) ?? process.env["MULTICORN_FAIL_MODE"] ?? "open";
434
533
  const failMode = failModeRaw === "closed" ? "closed" : "open";
435
- return { apiKey, baseUrl, agentName, failMode };
534
+ return { apiKey: resolvedApiKey, baseUrl: resolvedBaseUrl, agentName, failMode };
436
535
  }
437
536
  function asString(value) {
438
537
  return typeof value === "string" && value.length > 0 ? value : void 0;
@@ -456,15 +555,17 @@ async function ensureAgent(agentName, apiKey, baseUrl, failMode) {
456
555
  const cached = await loadCachedScopes(agentName);
457
556
  if (cached !== null && cached.length > 0) {
458
557
  grantedScopes = cached;
459
- void findOrRegisterAgent(agentName, apiKey, baseUrl).then((record) => {
460
- if (record !== null) agentRecord = record;
461
- });
558
+ void findOrRegisterAgent(agentName, apiKey, baseUrl, pluginLogger ?? void 0).then(
559
+ (record) => {
560
+ if (record !== null) agentRecord = record;
561
+ }
562
+ );
462
563
  lastScopeRefresh = Date.now();
463
564
  return "ready";
464
565
  }
465
566
  }
466
567
  if (agentRecord === null) {
467
- const record = await findOrRegisterAgent(agentName, apiKey, baseUrl);
568
+ const record = await findOrRegisterAgent(agentName, apiKey, baseUrl, pluginLogger ?? void 0);
468
569
  if (record === null) {
469
570
  if (failMode === "closed") {
470
571
  return "block";
@@ -474,20 +575,45 @@ async function ensureAgent(agentName, apiKey, baseUrl, failMode) {
474
575
  }
475
576
  agentRecord = record;
476
577
  }
477
- const scopes = await fetchGrantedScopes(agentRecord.id, apiKey, baseUrl);
578
+ const scopes = await fetchGrantedScopes(
579
+ agentRecord.id,
580
+ apiKey,
581
+ baseUrl,
582
+ pluginLogger ?? void 0
583
+ );
478
584
  grantedScopes = scopes;
479
585
  lastScopeRefresh = Date.now();
480
586
  if (scopes.length > 0) {
481
587
  await saveCachedScopes(agentName, agentRecord.id, scopes).catch(() => {
482
588
  });
483
589
  }
590
+ if (!connectionLogged) {
591
+ connectionLogged = true;
592
+ pluginLogger?.info(`Multicorn Shield connected. Agent: ${agentName}`);
593
+ }
484
594
  return "ready";
485
595
  }
486
- async function ensureConsent(agentName, apiKey, baseUrl) {
487
- if (grantedScopes.length > 0 || consentInProgress || agentRecord === null) return;
596
+ async function ensureConsent(agentName, apiKey, baseUrl, scope) {
597
+ if (agentRecord === null) return;
598
+ if (scope !== void 0) {
599
+ const requestedScope = {
600
+ service: scope.service,
601
+ permissionLevel: scope.permissionLevel
602
+ };
603
+ if (hasScope(grantedScopes, requestedScope) || consentInProgress) return;
604
+ } else {
605
+ if (grantedScopes.length > 0 || consentInProgress) return;
606
+ }
488
607
  consentInProgress = true;
489
608
  try {
490
- const scopes = await waitForConsent(agentRecord.id, agentName, apiKey, baseUrl);
609
+ const scopes = await waitForConsent(
610
+ agentRecord.id,
611
+ agentName,
612
+ apiKey,
613
+ baseUrl,
614
+ scope,
615
+ pluginLogger ?? void 0
616
+ );
491
617
  grantedScopes = scopes;
492
618
  await saveCachedScopes(agentName, agentRecord.id, scopes).catch(() => {
493
619
  });
@@ -581,7 +707,6 @@ async function beforeToolCall(event, ctx) {
581
707
  if (readiness === "skip") {
582
708
  return void 0;
583
709
  }
584
- await ensureConsent(agentName, config.apiKey, config.baseUrl);
585
710
  const command = event.toolName === "exec" && typeof event.params["command"] === "string" ? event.params["command"] : void 0;
586
711
  const mapping = mapToolToScope(event.toolName, command);
587
712
  pluginLogger?.info(
@@ -607,9 +732,24 @@ async function beforeToolCall(event, ctx) {
607
732
  }
608
733
  },
609
734
  config.apiKey,
610
- config.baseUrl
735
+ config.baseUrl,
736
+ pluginLogger ?? void 0
611
737
  );
612
738
  if (permissionResult.status === "approved") {
739
+ if (agentRecord !== null) {
740
+ const scopes = await fetchGrantedScopes(
741
+ agentRecord.id,
742
+ config.apiKey,
743
+ config.baseUrl,
744
+ pluginLogger ?? void 0
745
+ );
746
+ grantedScopes = scopes;
747
+ lastScopeRefresh = Date.now();
748
+ if (scopes.length > 0) {
749
+ await saveCachedScopes(agentName, agentRecord.id, scopes).catch(() => {
750
+ });
751
+ }
752
+ }
613
753
  return void 0;
614
754
  }
615
755
  if (permissionResult.status === "pending" && permissionResult.approvalId !== void 0) {
@@ -642,6 +782,13 @@ async function beforeToolCall(event, ctx) {
642
782
  blockReason: "Approval request timed out after 5 minutes."
643
783
  };
644
784
  }
785
+ const requestedScope = {
786
+ service: mapping.service,
787
+ permissionLevel: mapping.permissionLevel
788
+ };
789
+ if (!hasScope(grantedScopes, requestedScope) && agentRecord !== null) {
790
+ await ensureConsent(agentName, config.apiKey, config.baseUrl, mapping);
791
+ }
645
792
  const capitalizedService = mapping.service.charAt(0).toUpperCase() + mapping.service.slice(1);
646
793
  const reason = `${capitalizedService} ${mapping.permissionLevel} access is not allowed. Visit the Multicorn Shield dashboard to manage permissions.`;
647
794
  return { block: true, blockReason: reason };
@@ -663,7 +810,8 @@ function afterToolCall(event, ctx) {
663
810
  }
664
811
  },
665
812
  config.apiKey,
666
- config.baseUrl
813
+ config.baseUrl,
814
+ pluginLogger ?? void 0
667
815
  );
668
816
  return Promise.resolve();
669
817
  }
@@ -678,6 +826,14 @@ var plugin = {
678
826
  api.on("before_tool_call", beforeToolCall, { priority: 10 });
679
827
  api.on("after_tool_call", afterToolCall);
680
828
  api.logger.info("Multicorn Shield plugin registered.");
829
+ const config = readConfig();
830
+ if (config.apiKey.length === 0) {
831
+ api.logger.error(
832
+ "Multicorn Shield: No API key found. Set MULTICORN_API_KEY in your OpenClaw config (~/.openclaw/openclaw.json \u2192 plugins.entries.multicorn-shield.env.MULTICORN_API_KEY). Get a key from your Multicorn dashboard (Settings \u2192 API Keys)."
833
+ );
834
+ } else {
835
+ api.logger.info(`Multicorn Shield connecting to ${config.baseUrl}`);
836
+ }
681
837
  }
682
838
  };
683
839
  function register(api) {
@@ -692,6 +848,7 @@ function resetState() {
692
848
  lastScopeRefresh = 0;
693
849
  pluginLogger = null;
694
850
  pluginConfig = void 0;
851
+ connectionLogged = false;
695
852
  }
696
853
 
697
854
  export { afterToolCall, beforeToolCall, plugin, readConfig, register, resetState, resolveAgentName };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "multicorn-shield",
3
- "version": "0.1.2",
3
+ "version": "0.1.5",
4
4
  "description": "The control layer for AI agents: permissions, consent, spending limits, and audit logging.",
5
5
  "license": "MIT",
6
6
  "type": "module",