multicorn-shield 0.1.2 → 0.1.3
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-hook/HOOK.md +18 -0
- package/dist/openclaw-hook/handler.js +47 -16
- package/dist/openclaw-plugin/index.js +169 -32
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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,7 +292,7 @@ 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 });
|
|
267
298
|
return `${base}/consent?${params.toString()}`;
|
|
@@ -279,7 +310,7 @@ ${url}
|
|
|
279
310
|
);
|
|
280
311
|
}
|
|
281
312
|
}
|
|
282
|
-
async function waitForConsent(agentId, agentName, apiKey, baseUrl) {
|
|
313
|
+
async function waitForConsent(agentId, agentName, apiKey, baseUrl, scope, logger) {
|
|
283
314
|
const dashboardUrl = deriveDashboardUrl(baseUrl);
|
|
284
315
|
const consentUrl = buildConsentUrl(agentName, dashboardUrl);
|
|
285
316
|
process.stderr.write(
|
|
@@ -292,7 +323,7 @@ Waiting for you to grant access in the Multicorn dashboard...
|
|
|
292
323
|
const deadline = Date.now() + POLL_TIMEOUT_MS2;
|
|
293
324
|
while (Date.now() < deadline) {
|
|
294
325
|
await sleep(POLL_INTERVAL_MS2);
|
|
295
|
-
const scopes = await fetchGrantedScopes(agentId, apiKey, baseUrl);
|
|
326
|
+
const scopes = await fetchGrantedScopes(agentId, apiKey, baseUrl, logger);
|
|
296
327
|
if (scopes.length > 0) {
|
|
297
328
|
process.stderr.write("[multicorn-shield] Permissions granted.\n");
|
|
298
329
|
return scopes;
|
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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;
|
|
@@ -424,15 +486,45 @@ var consentInProgress = false;
|
|
|
424
486
|
var lastScopeRefresh = 0;
|
|
425
487
|
var pluginLogger = null;
|
|
426
488
|
var pluginConfig;
|
|
489
|
+
var connectionLogged = false;
|
|
427
490
|
var SCOPE_REFRESH_INTERVAL_MS = 6e4;
|
|
428
491
|
function readConfig() {
|
|
429
492
|
const pc = pluginConfig ?? {};
|
|
430
|
-
|
|
431
|
-
|
|
493
|
+
let resolvedApiKey = asString(pc["apiKey"]) ?? process.env["MULTICORN_API_KEY"] ?? "";
|
|
494
|
+
let resolvedBaseUrl = asString(pc["baseUrl"]) ?? process.env["MULTICORN_BASE_URL"] ?? "";
|
|
495
|
+
if (!resolvedApiKey) {
|
|
496
|
+
try {
|
|
497
|
+
const configPath = path.join(os.homedir(), ".openclaw", "openclaw.json");
|
|
498
|
+
const configContent = fs.readFileSync(configPath, "utf-8");
|
|
499
|
+
const config = JSON.parse(configContent);
|
|
500
|
+
const hooks = config["hooks"];
|
|
501
|
+
const internal = hooks?.["internal"];
|
|
502
|
+
const entries = internal?.["entries"];
|
|
503
|
+
const shieldEntry = entries?.["multicorn-shield"];
|
|
504
|
+
const env = shieldEntry?.env;
|
|
505
|
+
if (env) {
|
|
506
|
+
const hookApiKey = asString(env["MULTICORN_API_KEY"]);
|
|
507
|
+
const hookBaseUrl = asString(env["MULTICORN_BASE_URL"]);
|
|
508
|
+
if (hookApiKey) {
|
|
509
|
+
resolvedApiKey = hookApiKey;
|
|
510
|
+
resolvedBaseUrl = resolvedBaseUrl || (hookBaseUrl ?? "https://api.multicorn.ai");
|
|
511
|
+
pluginLogger?.warn(
|
|
512
|
+
"Multicorn Shield: Reading config from hooks.internal.entries. For cleaner setup, set MULTICORN_API_KEY as a system environment variable."
|
|
513
|
+
);
|
|
514
|
+
} else if (hookBaseUrl) {
|
|
515
|
+
resolvedBaseUrl = resolvedBaseUrl || hookBaseUrl;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
} catch {
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
if (!resolvedBaseUrl) {
|
|
522
|
+
resolvedBaseUrl = "https://api.multicorn.ai";
|
|
523
|
+
}
|
|
432
524
|
const agentName = asString(pc["agentName"]) ?? process.env["MULTICORN_AGENT_NAME"] ?? null;
|
|
433
525
|
const failModeRaw = asString(pc["failMode"]) ?? process.env["MULTICORN_FAIL_MODE"] ?? "open";
|
|
434
526
|
const failMode = failModeRaw === "closed" ? "closed" : "open";
|
|
435
|
-
return { apiKey, baseUrl, agentName, failMode };
|
|
527
|
+
return { apiKey: resolvedApiKey, baseUrl: resolvedBaseUrl, agentName, failMode };
|
|
436
528
|
}
|
|
437
529
|
function asString(value) {
|
|
438
530
|
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
@@ -456,15 +548,17 @@ async function ensureAgent(agentName, apiKey, baseUrl, failMode) {
|
|
|
456
548
|
const cached = await loadCachedScopes(agentName);
|
|
457
549
|
if (cached !== null && cached.length > 0) {
|
|
458
550
|
grantedScopes = cached;
|
|
459
|
-
void findOrRegisterAgent(agentName, apiKey, baseUrl).then(
|
|
460
|
-
|
|
461
|
-
|
|
551
|
+
void findOrRegisterAgent(agentName, apiKey, baseUrl, pluginLogger ?? void 0).then(
|
|
552
|
+
(record) => {
|
|
553
|
+
if (record !== null) agentRecord = record;
|
|
554
|
+
}
|
|
555
|
+
);
|
|
462
556
|
lastScopeRefresh = Date.now();
|
|
463
557
|
return "ready";
|
|
464
558
|
}
|
|
465
559
|
}
|
|
466
560
|
if (agentRecord === null) {
|
|
467
|
-
const record = await findOrRegisterAgent(agentName, apiKey, baseUrl);
|
|
561
|
+
const record = await findOrRegisterAgent(agentName, apiKey, baseUrl, pluginLogger ?? void 0);
|
|
468
562
|
if (record === null) {
|
|
469
563
|
if (failMode === "closed") {
|
|
470
564
|
return "block";
|
|
@@ -474,20 +568,36 @@ async function ensureAgent(agentName, apiKey, baseUrl, failMode) {
|
|
|
474
568
|
}
|
|
475
569
|
agentRecord = record;
|
|
476
570
|
}
|
|
477
|
-
const scopes = await fetchGrantedScopes(
|
|
571
|
+
const scopes = await fetchGrantedScopes(
|
|
572
|
+
agentRecord.id,
|
|
573
|
+
apiKey,
|
|
574
|
+
baseUrl,
|
|
575
|
+
pluginLogger ?? void 0
|
|
576
|
+
);
|
|
478
577
|
grantedScopes = scopes;
|
|
479
578
|
lastScopeRefresh = Date.now();
|
|
480
579
|
if (scopes.length > 0) {
|
|
481
580
|
await saveCachedScopes(agentName, agentRecord.id, scopes).catch(() => {
|
|
482
581
|
});
|
|
483
582
|
}
|
|
583
|
+
if (!connectionLogged) {
|
|
584
|
+
connectionLogged = true;
|
|
585
|
+
pluginLogger?.info(`Multicorn Shield connected. Agent: ${agentName}`);
|
|
586
|
+
}
|
|
484
587
|
return "ready";
|
|
485
588
|
}
|
|
486
|
-
async function ensureConsent(agentName, apiKey, baseUrl) {
|
|
589
|
+
async function ensureConsent(agentName, apiKey, baseUrl, scope) {
|
|
487
590
|
if (grantedScopes.length > 0 || consentInProgress || agentRecord === null) return;
|
|
488
591
|
consentInProgress = true;
|
|
489
592
|
try {
|
|
490
|
-
const scopes = await waitForConsent(
|
|
593
|
+
const scopes = await waitForConsent(
|
|
594
|
+
agentRecord.id,
|
|
595
|
+
agentName,
|
|
596
|
+
apiKey,
|
|
597
|
+
baseUrl,
|
|
598
|
+
scope,
|
|
599
|
+
pluginLogger ?? void 0
|
|
600
|
+
);
|
|
491
601
|
grantedScopes = scopes;
|
|
492
602
|
await saveCachedScopes(agentName, agentRecord.id, scopes).catch(() => {
|
|
493
603
|
});
|
|
@@ -581,7 +691,6 @@ async function beforeToolCall(event, ctx) {
|
|
|
581
691
|
if (readiness === "skip") {
|
|
582
692
|
return void 0;
|
|
583
693
|
}
|
|
584
|
-
await ensureConsent(agentName, config.apiKey, config.baseUrl);
|
|
585
694
|
const command = event.toolName === "exec" && typeof event.params["command"] === "string" ? event.params["command"] : void 0;
|
|
586
695
|
const mapping = mapToolToScope(event.toolName, command);
|
|
587
696
|
pluginLogger?.info(
|
|
@@ -607,9 +716,24 @@ async function beforeToolCall(event, ctx) {
|
|
|
607
716
|
}
|
|
608
717
|
},
|
|
609
718
|
config.apiKey,
|
|
610
|
-
config.baseUrl
|
|
719
|
+
config.baseUrl,
|
|
720
|
+
pluginLogger ?? void 0
|
|
611
721
|
);
|
|
612
722
|
if (permissionResult.status === "approved") {
|
|
723
|
+
if (agentRecord !== null) {
|
|
724
|
+
const scopes = await fetchGrantedScopes(
|
|
725
|
+
agentRecord.id,
|
|
726
|
+
config.apiKey,
|
|
727
|
+
config.baseUrl,
|
|
728
|
+
pluginLogger ?? void 0
|
|
729
|
+
);
|
|
730
|
+
grantedScopes = scopes;
|
|
731
|
+
lastScopeRefresh = Date.now();
|
|
732
|
+
if (scopes.length > 0) {
|
|
733
|
+
await saveCachedScopes(agentName, agentRecord.id, scopes).catch(() => {
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
}
|
|
613
737
|
return void 0;
|
|
614
738
|
}
|
|
615
739
|
if (permissionResult.status === "pending" && permissionResult.approvalId !== void 0) {
|
|
@@ -642,6 +766,9 @@ async function beforeToolCall(event, ctx) {
|
|
|
642
766
|
blockReason: "Approval request timed out after 5 minutes."
|
|
643
767
|
};
|
|
644
768
|
}
|
|
769
|
+
if (grantedScopes.length === 0 && agentRecord !== null) {
|
|
770
|
+
await ensureConsent(agentName, config.apiKey, config.baseUrl, mapping);
|
|
771
|
+
}
|
|
645
772
|
const capitalizedService = mapping.service.charAt(0).toUpperCase() + mapping.service.slice(1);
|
|
646
773
|
const reason = `${capitalizedService} ${mapping.permissionLevel} access is not allowed. Visit the Multicorn Shield dashboard to manage permissions.`;
|
|
647
774
|
return { block: true, blockReason: reason };
|
|
@@ -663,7 +790,8 @@ function afterToolCall(event, ctx) {
|
|
|
663
790
|
}
|
|
664
791
|
},
|
|
665
792
|
config.apiKey,
|
|
666
|
-
config.baseUrl
|
|
793
|
+
config.baseUrl,
|
|
794
|
+
pluginLogger ?? void 0
|
|
667
795
|
);
|
|
668
796
|
return Promise.resolve();
|
|
669
797
|
}
|
|
@@ -678,6 +806,14 @@ var plugin = {
|
|
|
678
806
|
api.on("before_tool_call", beforeToolCall, { priority: 10 });
|
|
679
807
|
api.on("after_tool_call", afterToolCall);
|
|
680
808
|
api.logger.info("Multicorn Shield plugin registered.");
|
|
809
|
+
const config = readConfig();
|
|
810
|
+
if (config.apiKey.length === 0) {
|
|
811
|
+
api.logger.error(
|
|
812
|
+
"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)."
|
|
813
|
+
);
|
|
814
|
+
} else {
|
|
815
|
+
api.logger.info(`Multicorn Shield connecting to ${config.baseUrl}`);
|
|
816
|
+
}
|
|
681
817
|
}
|
|
682
818
|
};
|
|
683
819
|
function register(api) {
|
|
@@ -692,6 +828,7 @@ function resetState() {
|
|
|
692
828
|
lastScopeRefresh = 0;
|
|
693
829
|
pluginLogger = null;
|
|
694
830
|
pluginConfig = void 0;
|
|
831
|
+
connectionLogged = false;
|
|
695
832
|
}
|
|
696
833
|
|
|
697
834
|
export { afterToolCall, beforeToolCall, plugin, readConfig, register, resetState, resolveAgentName };
|