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 +79 -1
- package/dist/multicorn-proxy.js +3 -0
- package/dist/openclaw-hook/handler.js +14 -0
- package/dist/openclaw-plugin/index.js +171 -188
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,6 +10,12 @@ The permissions and control layer for AI agents. Open source.
|
|
|
10
10
|
[](LICENSE)
|
|
11
11
|
[](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:
|
|
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
|
|
package/dist/multicorn-proxy.js
CHANGED
|
@@ -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
|
-
|
|
173
|
-
const rateLimitMsg = `[multicorn-shield] Rate limited by Shield API. Retrying in ${String(retryDelaySeconds)}s.`;
|
|
174
|
-
logger?.warn(rateLimitMsg);
|
|
175
|
-
process.stderr.write(`${rateLimitMsg}
|
|
176
|
-
`);
|
|
177
|
-
} else {
|
|
164
|
+
{
|
|
178
165
|
const rateLimitMsg = "[multicorn-shield] Rate limited by Shield API. Action was not checked \u2014 proceeding with fail-open.";
|
|
179
166
|
logger?.warn(rateLimitMsg);
|
|
180
167
|
process.stderr.write(`${rateLimitMsg}
|
|
@@ -272,6 +259,14 @@ async function fetchGrantedScopes(agentId, apiKey, baseUrl, logger) {
|
|
|
272
259
|
}
|
|
273
260
|
async function checkActionPermission(payload, apiKey, baseUrl, logger) {
|
|
274
261
|
try {
|
|
262
|
+
const requestBody = {
|
|
263
|
+
agent: payload.agent,
|
|
264
|
+
service: payload.service,
|
|
265
|
+
actionType: payload.actionType,
|
|
266
|
+
status: payload.status,
|
|
267
|
+
metadata: payload.metadata
|
|
268
|
+
};
|
|
269
|
+
console.error("[SHIELD-CLIENT] POST /api/v1/actions request: " + JSON.stringify(requestBody));
|
|
275
270
|
const response = await fetch(`${baseUrl}/api/v1/actions`, {
|
|
276
271
|
method: "POST",
|
|
277
272
|
headers: {
|
|
@@ -282,15 +277,22 @@ async function checkActionPermission(payload, apiKey, baseUrl, logger) {
|
|
|
282
277
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
283
278
|
});
|
|
284
279
|
if (response.status === 201) {
|
|
280
|
+
console.error(
|
|
281
|
+
"[SHIELD-CLIENT] response status=201, returning approved (body not read - backend may have failed approval creation)"
|
|
282
|
+
);
|
|
285
283
|
return { status: "approved" };
|
|
286
284
|
}
|
|
287
285
|
if (response.status === 202) {
|
|
288
286
|
const body = await response.json();
|
|
289
|
-
|
|
287
|
+
const data = isApiSuccess(body) ? body.data : null;
|
|
288
|
+
console.error("[SHIELD-CLIENT] response status=202 body=" + JSON.stringify(data ?? body));
|
|
289
|
+
if (!isApiSuccess(body) || data === null) {
|
|
290
290
|
return { status: "blocked" };
|
|
291
291
|
}
|
|
292
|
-
const
|
|
293
|
-
|
|
292
|
+
const approvalId = typeof data["approval_id"] === "string" ? data["approval_id"] : void 0;
|
|
293
|
+
console.error(
|
|
294
|
+
"[SHIELD-CLIENT] extracted: status=" + String(data["status"]) + " approval_id=" + (approvalId ?? "undefined")
|
|
295
|
+
);
|
|
294
296
|
if (approvalId === void 0) {
|
|
295
297
|
return { status: "blocked" };
|
|
296
298
|
}
|
|
@@ -309,85 +311,6 @@ async function checkActionPermission(payload, apiKey, baseUrl, logger) {
|
|
|
309
311
|
return { status: "blocked" };
|
|
310
312
|
}
|
|
311
313
|
}
|
|
312
|
-
async function pollApprovalStatus(approvalId, apiKey, baseUrl, logger) {
|
|
313
|
-
const startTime = Date.now();
|
|
314
|
-
const logDebug = logger?.debug?.bind(logger);
|
|
315
|
-
for (let pollCount = 0; pollCount < MAX_POLLS; pollCount++) {
|
|
316
|
-
if (Date.now() - startTime >= POLL_TIMEOUT_MS) {
|
|
317
|
-
return "timeout";
|
|
318
|
-
}
|
|
319
|
-
let approval = null;
|
|
320
|
-
for (let retry = 0; retry < 3; retry++) {
|
|
321
|
-
try {
|
|
322
|
-
const response = await fetch(`${baseUrl}/api/v1/approvals/${approvalId}`, {
|
|
323
|
-
headers: { [AUTH_HEADER]: apiKey },
|
|
324
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
325
|
-
});
|
|
326
|
-
if (!response.ok) {
|
|
327
|
-
if (response.status === 401 || response.status === 403) {
|
|
328
|
-
handleHttpError(response.status, logger);
|
|
329
|
-
return "timeout";
|
|
330
|
-
}
|
|
331
|
-
if (response.status === 429 || response.status >= 500 && response.status < 600) {
|
|
332
|
-
const retryDelay = retry < 2 ? Math.pow(2, retry) : void 0;
|
|
333
|
-
handleHttpError(response.status, logger, retryDelay);
|
|
334
|
-
}
|
|
335
|
-
logDebug?.(
|
|
336
|
-
`Poll ${String(pollCount + 1)} failed: HTTP ${String(response.status)}. Retrying...`
|
|
337
|
-
);
|
|
338
|
-
if (retry < 2) {
|
|
339
|
-
await new Promise((resolve) => setTimeout(resolve, 1e3 * Math.pow(2, retry)));
|
|
340
|
-
}
|
|
341
|
-
continue;
|
|
342
|
-
}
|
|
343
|
-
const body = await response.json();
|
|
344
|
-
if (!isApiSuccess(body)) {
|
|
345
|
-
logDebug?.(`Poll ${String(pollCount + 1)} failed: invalid response format. Retrying...`);
|
|
346
|
-
if (retry < 2) {
|
|
347
|
-
await new Promise((resolve) => setTimeout(resolve, 1e3 * Math.pow(2, retry)));
|
|
348
|
-
}
|
|
349
|
-
continue;
|
|
350
|
-
}
|
|
351
|
-
const approvalData = body.data;
|
|
352
|
-
if (!isApprovalResponse(approvalData)) {
|
|
353
|
-
logDebug?.(`Poll ${String(pollCount + 1)} failed: invalid approval data. Retrying...`);
|
|
354
|
-
if (retry < 2) {
|
|
355
|
-
await new Promise((resolve) => setTimeout(resolve, 1e3 * Math.pow(2, retry)));
|
|
356
|
-
}
|
|
357
|
-
continue;
|
|
358
|
-
}
|
|
359
|
-
approval = approvalData;
|
|
360
|
-
break;
|
|
361
|
-
} catch (error) {
|
|
362
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
363
|
-
logDebug?.(`Poll ${String(pollCount + 1)} failed: ${errorMessage}. Retrying...`);
|
|
364
|
-
if (retry < 2) {
|
|
365
|
-
await new Promise((resolve) => setTimeout(resolve, 1e3 * Math.pow(2, retry)));
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
if (approval !== null) {
|
|
370
|
-
if (approval.status === "approved") {
|
|
371
|
-
return "approved";
|
|
372
|
-
}
|
|
373
|
-
if (approval.status === "rejected") {
|
|
374
|
-
return "rejected";
|
|
375
|
-
}
|
|
376
|
-
if (approval.status === "expired") {
|
|
377
|
-
return "expired";
|
|
378
|
-
}
|
|
379
|
-
if (pollCount < MAX_POLLS - 1) {
|
|
380
|
-
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
381
|
-
}
|
|
382
|
-
} else {
|
|
383
|
-
logDebug?.(`All retries failed for poll ${String(pollCount + 1)}. Continuing...`);
|
|
384
|
-
if (pollCount < MAX_POLLS - 1) {
|
|
385
|
-
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
return "timeout";
|
|
390
|
-
}
|
|
391
314
|
async function logAction(payload, apiKey, baseUrl, logger) {
|
|
392
315
|
try {
|
|
393
316
|
const response = await fetch(`${baseUrl}/api/v1/actions`, {
|
|
@@ -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"]) ??
|
|
511
|
-
const resolvedBaseUrl = asString(process.env["MULTICORN_BASE_URL"]) ??
|
|
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
|
|
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
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
const readiness = await ensureAgent(agentName, config.apiKey, config.baseUrl, config.failMode);
|
|
695
|
-
if (readiness === "block") {
|
|
696
|
-
return {
|
|
697
|
-
block: true,
|
|
698
|
-
blockReason: "Multicorn Shield could not verify permissions. The Shield API is unreachable and fail-closed mode is enabled."
|
|
699
|
-
};
|
|
700
|
-
}
|
|
701
|
-
if (readiness === "skip") {
|
|
702
|
-
return void 0;
|
|
703
|
-
}
|
|
704
|
-
const command = event.toolName === "exec" && typeof event.params["command"] === "string" ? event.params["command"] : void 0;
|
|
705
|
-
const mapping = mapToolToScope(event.toolName, command);
|
|
706
|
-
pluginLogger?.info(
|
|
707
|
-
`Multicorn Shield: tool=${event.toolName}, service=${mapping.service}, permissionLevel=${mapping.permissionLevel}`
|
|
708
|
-
);
|
|
709
|
-
const actionType = mapping.permissionLevel === "write" && event.toolName === "exec" ? "exec_write" : event.toolName;
|
|
710
|
-
const description = buildApprovalDescription(
|
|
711
|
-
agentName,
|
|
712
|
-
mapping.permissionLevel,
|
|
713
|
-
mapping.service,
|
|
714
|
-
event.toolName,
|
|
715
|
-
event.params
|
|
716
|
-
);
|
|
717
|
-
const permissionResult = await checkActionPermission(
|
|
718
|
-
{
|
|
719
|
-
agent: agentName,
|
|
720
|
-
service: mapping.service,
|
|
721
|
-
actionType,
|
|
722
|
-
status: "approved",
|
|
723
|
-
// Status doesn't matter for permission check
|
|
724
|
-
metadata: {
|
|
725
|
-
description
|
|
726
|
-
}
|
|
727
|
-
},
|
|
728
|
-
config.apiKey,
|
|
729
|
-
config.baseUrl,
|
|
730
|
-
pluginLogger ?? void 0
|
|
731
|
-
);
|
|
732
|
-
if (permissionResult.status === "approved") {
|
|
733
|
-
if (agentRecord !== null) {
|
|
734
|
-
const scopes = await fetchGrantedScopes(
|
|
735
|
-
agentRecord.id,
|
|
736
|
-
config.apiKey,
|
|
737
|
-
config.baseUrl,
|
|
738
|
-
pluginLogger ?? void 0
|
|
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
|
-
|
|
741
|
-
|
|
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
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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:
|
|
653
|
+
`Multicorn Shield: tool=${event.toolName}, service=${mapping.service}, permissionLevel=${mapping.permissionLevel}`
|
|
752
654
|
);
|
|
753
|
-
const
|
|
754
|
-
|
|
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
|
-
|
|
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 (
|
|
763
|
-
|
|
764
|
-
|
|
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:
|
|
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
|
-
|
|
775
|
-
|
|
776
|
-
|
|
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.");
|