multicorn-shield 0.1.0
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/LICENSE +21 -0
- package/README.md +453 -0
- package/dist/index.cjs +2507 -0
- package/dist/index.d.cts +2182 -0
- package/dist/index.d.ts +2182 -0
- package/dist/index.js +2477 -0
- package/dist/multicorn-proxy.js +1153 -0
- package/dist/openclaw-hook/HOOK.md +75 -0
- package/dist/openclaw-hook/handler.js +447 -0
- package/dist/openclaw-plugin/index.js +692 -0
- package/dist/openclaw-plugin/openclaw.plugin.json +51 -0
- package/package.json +122 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: multicorn-shield
|
|
3
|
+
description: "Multicorn Shield governance for OpenClaw. Checks permissions, logs actions, and enforces controls via the Shield API."
|
|
4
|
+
metadata:
|
|
5
|
+
{
|
|
6
|
+
"openclaw":
|
|
7
|
+
{
|
|
8
|
+
"emoji": "shield",
|
|
9
|
+
"events": ["agent:tool_call"],
|
|
10
|
+
"requires": { "env": ["MULTICORN_API_KEY"] },
|
|
11
|
+
"primaryEnv": "MULTICORN_API_KEY",
|
|
12
|
+
},
|
|
13
|
+
}
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
# Multicorn Shield (Gateway Hook - Deprecated)
|
|
17
|
+
|
|
18
|
+
> **This gateway hook is deprecated.** Gateway hooks cannot intercept tool calls.
|
|
19
|
+
> Use the **Plugin API** version instead:
|
|
20
|
+
>
|
|
21
|
+
> ```bash
|
|
22
|
+
> cd multicorn-shield && npm run build
|
|
23
|
+
> openclaw plugins install --link ./dist/openclaw-plugin/index.js
|
|
24
|
+
> openclaw plugins enable multicorn-shield
|
|
25
|
+
> openclaw gateway restart
|
|
26
|
+
> ```
|
|
27
|
+
>
|
|
28
|
+
> See the plugin README for full instructions.
|
|
29
|
+
|
|
30
|
+
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.
|
|
31
|
+
|
|
32
|
+
## What it does
|
|
33
|
+
|
|
34
|
+
- Checks every tool call (read, write, exec, browser, message) against your Shield permissions
|
|
35
|
+
- Blocks tools you haven't granted access to, with a clear message to the agent
|
|
36
|
+
- Opens the Shield consent page in your browser on first use
|
|
37
|
+
- Logs all activity to the Shield dashboard (fire-and-forget, doesn't slow down the agent)
|
|
38
|
+
|
|
39
|
+
## Setup (Deprecated - use the plugin instead)
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# 1. Copy the hook
|
|
43
|
+
cp -r multicorn-shield ~/.openclaw/hooks/
|
|
44
|
+
|
|
45
|
+
# 2. Set your API key
|
|
46
|
+
export MULTICORN_API_KEY=mcs_your_key_here
|
|
47
|
+
|
|
48
|
+
# 3. Enable it
|
|
49
|
+
openclaw hooks enable multicorn-shield
|
|
50
|
+
|
|
51
|
+
# 4. Restart the gateway
|
|
52
|
+
openclaw gateway restart
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Environment variables
|
|
56
|
+
|
|
57
|
+
| Variable | Required | Default | Description |
|
|
58
|
+
| -------------------- | -------- | ------------------------ | ----------------------------------------------------------------------------- |
|
|
59
|
+
| MULTICORN_API_KEY | Yes | - | Your Multicorn API key (starts with `mcs_`) |
|
|
60
|
+
| MULTICORN_BASE_URL | No | https://api.multicorn.ai | Shield API base URL |
|
|
61
|
+
| MULTICORN_AGENT_NAME | No | Derived from session | Override the agent name shown in the dashboard |
|
|
62
|
+
| MULTICORN_FAIL_MODE | No | open | `open` = allow tool calls when the API is unreachable. `closed` = block them. |
|
|
63
|
+
|
|
64
|
+
## How permissions map
|
|
65
|
+
|
|
66
|
+
| OpenClaw tool | Shield permission |
|
|
67
|
+
| -------------- | ----------------- |
|
|
68
|
+
| read | filesystem:read |
|
|
69
|
+
| write, edit | filesystem:write |
|
|
70
|
+
| exec, process | terminal:execute |
|
|
71
|
+
| browser | browser:execute |
|
|
72
|
+
| message | messaging:write |
|
|
73
|
+
| sessions_spawn | agents:execute |
|
|
74
|
+
|
|
75
|
+
Tools not in this list are tracked under their own name with `execute` permission.
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import { readFile, mkdir, writeFile } from 'fs/promises';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import { spawn } from 'child_process';
|
|
5
|
+
|
|
6
|
+
// Multicorn Shield hook for OpenClaw (DEPRECATED - use the plugin instead) - https://multicorn.ai
|
|
7
|
+
|
|
8
|
+
// src/openclaw/types.ts
|
|
9
|
+
function isToolCallEvent(event) {
|
|
10
|
+
if (event.type !== "agent" || event.action !== "tool_call") return false;
|
|
11
|
+
const ctx = event.context;
|
|
12
|
+
return typeof ctx["toolName"] === "string" && typeof ctx["toolArguments"] === "object" && ctx["toolArguments"] !== null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// src/openclaw/tool-mapper.ts
|
|
16
|
+
var TOOL_MAP = {
|
|
17
|
+
// OpenClaw built-in tools
|
|
18
|
+
read: { service: "filesystem", permissionLevel: "read" },
|
|
19
|
+
write: { service: "filesystem", permissionLevel: "write" },
|
|
20
|
+
edit: { service: "filesystem", permissionLevel: "write" },
|
|
21
|
+
exec: { service: "terminal", permissionLevel: "execute" },
|
|
22
|
+
browser: { service: "browser", permissionLevel: "execute" },
|
|
23
|
+
message: { service: "messaging", permissionLevel: "write" },
|
|
24
|
+
process: { service: "terminal", permissionLevel: "execute" },
|
|
25
|
+
sessions_spawn: { service: "agents", permissionLevel: "execute" },
|
|
26
|
+
// Common integration tools (MCP servers, skills, etc.)
|
|
27
|
+
// Gmail
|
|
28
|
+
gmail: { service: "gmail", permissionLevel: "execute" },
|
|
29
|
+
gmail_send: { service: "gmail", permissionLevel: "write" },
|
|
30
|
+
gmail_read: { service: "gmail", permissionLevel: "read" },
|
|
31
|
+
// Google Calendar
|
|
32
|
+
google_calendar: { service: "google_calendar", permissionLevel: "execute" },
|
|
33
|
+
calendar: { service: "google_calendar", permissionLevel: "execute" },
|
|
34
|
+
calendar_create: { service: "google_calendar", permissionLevel: "write" },
|
|
35
|
+
calendar_read: { service: "google_calendar", permissionLevel: "read" },
|
|
36
|
+
// Google Drive
|
|
37
|
+
google_drive: { service: "google_drive", permissionLevel: "execute" },
|
|
38
|
+
drive: { service: "google_drive", permissionLevel: "execute" },
|
|
39
|
+
drive_read: { service: "google_drive", permissionLevel: "read" },
|
|
40
|
+
drive_write: { service: "google_drive", permissionLevel: "write" },
|
|
41
|
+
// Slack
|
|
42
|
+
slack: { service: "slack", permissionLevel: "execute" },
|
|
43
|
+
slack_send: { service: "slack", permissionLevel: "write" },
|
|
44
|
+
slack_read: { service: "slack", permissionLevel: "read" },
|
|
45
|
+
slack_message: { service: "slack", permissionLevel: "write" },
|
|
46
|
+
// Payments
|
|
47
|
+
payments: { service: "payments", permissionLevel: "execute" },
|
|
48
|
+
payment: { service: "payments", permissionLevel: "execute" },
|
|
49
|
+
stripe: { service: "payments", permissionLevel: "execute" }
|
|
50
|
+
};
|
|
51
|
+
function mapToolToScope(toolName, command) {
|
|
52
|
+
const normalized = toolName.trim().toLowerCase();
|
|
53
|
+
if (normalized.length === 0) {
|
|
54
|
+
return { service: "unknown", permissionLevel: "execute" };
|
|
55
|
+
}
|
|
56
|
+
const known = TOOL_MAP[normalized];
|
|
57
|
+
if (known !== void 0) {
|
|
58
|
+
return known;
|
|
59
|
+
}
|
|
60
|
+
const integrationPrefixes = {
|
|
61
|
+
gmail: "gmail",
|
|
62
|
+
google_calendar: "google_calendar",
|
|
63
|
+
calendar: "google_calendar",
|
|
64
|
+
google_drive: "google_drive",
|
|
65
|
+
drive: "google_drive",
|
|
66
|
+
slack: "slack",
|
|
67
|
+
payments: "payments",
|
|
68
|
+
payment: "payments",
|
|
69
|
+
stripe: "payments"
|
|
70
|
+
};
|
|
71
|
+
for (const [prefix, service] of Object.entries(integrationPrefixes)) {
|
|
72
|
+
if (normalized.startsWith(prefix + "_") || normalized === prefix) {
|
|
73
|
+
let permissionLevel = "execute";
|
|
74
|
+
if (normalized.includes("_read") || normalized.includes("_get") || normalized.includes("_list")) {
|
|
75
|
+
permissionLevel = "read";
|
|
76
|
+
} else if (normalized.includes("_write") || normalized.includes("_send") || normalized.includes("_create") || normalized.includes("_update") || normalized.includes("_delete")) {
|
|
77
|
+
permissionLevel = "write";
|
|
78
|
+
}
|
|
79
|
+
return { service, permissionLevel };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { service: normalized, permissionLevel: "execute" };
|
|
83
|
+
}
|
|
84
|
+
var MULTICORN_DIR = join(homedir(), ".multicorn");
|
|
85
|
+
var SCOPES_PATH = join(MULTICORN_DIR, "scopes.json");
|
|
86
|
+
async function loadCachedScopes(agentName) {
|
|
87
|
+
try {
|
|
88
|
+
const raw = await readFile(SCOPES_PATH, "utf8");
|
|
89
|
+
const parsed = JSON.parse(raw);
|
|
90
|
+
if (!isScopesCacheFile(parsed)) return null;
|
|
91
|
+
const entry = parsed[agentName];
|
|
92
|
+
return entry?.scopes ?? null;
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async function saveCachedScopes(agentName, agentId, scopes) {
|
|
98
|
+
await mkdir(MULTICORN_DIR, { recursive: true, mode: 448 });
|
|
99
|
+
let existing = {};
|
|
100
|
+
try {
|
|
101
|
+
const raw = await readFile(SCOPES_PATH, "utf8");
|
|
102
|
+
const parsed = JSON.parse(raw);
|
|
103
|
+
if (isScopesCacheFile(parsed)) existing = parsed;
|
|
104
|
+
} catch {
|
|
105
|
+
}
|
|
106
|
+
const updated = {
|
|
107
|
+
...existing,
|
|
108
|
+
[agentName]: {
|
|
109
|
+
agentId,
|
|
110
|
+
scopes,
|
|
111
|
+
fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
await writeFile(SCOPES_PATH, JSON.stringify(updated, null, 2) + "\n", {
|
|
115
|
+
encoding: "utf8",
|
|
116
|
+
mode: 384
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
function isScopesCacheFile(value) {
|
|
120
|
+
return typeof value === "object" && value !== null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// src/openclaw/shield-client.ts
|
|
124
|
+
var REQUEST_TIMEOUT_MS = 5e3;
|
|
125
|
+
var AUTH_HEADER = "X-Multicorn-Key";
|
|
126
|
+
function isApiSuccess(value) {
|
|
127
|
+
if (typeof value !== "object" || value === null) return false;
|
|
128
|
+
const obj = value;
|
|
129
|
+
return obj["success"] === true;
|
|
130
|
+
}
|
|
131
|
+
function isAgentSummary(value) {
|
|
132
|
+
if (typeof value !== "object" || value === null) return false;
|
|
133
|
+
const obj = value;
|
|
134
|
+
return typeof obj["id"] === "string" && typeof obj["name"] === "string";
|
|
135
|
+
}
|
|
136
|
+
function isAgentDetail(value) {
|
|
137
|
+
if (typeof value !== "object" || value === null) return false;
|
|
138
|
+
const obj = value;
|
|
139
|
+
return Array.isArray(obj["permissions"]);
|
|
140
|
+
}
|
|
141
|
+
function isPermissionEntry(value) {
|
|
142
|
+
if (typeof value !== "object" || value === null) return false;
|
|
143
|
+
const obj = value;
|
|
144
|
+
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
|
+
async function findAgentByName(agentName, apiKey, baseUrl) {
|
|
147
|
+
try {
|
|
148
|
+
const response = await fetch(`${baseUrl}/api/v1/agents`, {
|
|
149
|
+
headers: { [AUTH_HEADER]: apiKey },
|
|
150
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
151
|
+
});
|
|
152
|
+
if (!response.ok) return null;
|
|
153
|
+
const body = await response.json();
|
|
154
|
+
if (!isApiSuccess(body)) return null;
|
|
155
|
+
const agents = body.data;
|
|
156
|
+
if (!Array.isArray(agents)) return null;
|
|
157
|
+
const match = agents.find((a) => isAgentSummary(a) && a.name === agentName);
|
|
158
|
+
return match ?? null;
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
async function registerAgent(agentName, apiKey, baseUrl) {
|
|
164
|
+
const response = await fetch(`${baseUrl}/api/v1/agents`, {
|
|
165
|
+
method: "POST",
|
|
166
|
+
headers: {
|
|
167
|
+
"Content-Type": "application/json",
|
|
168
|
+
[AUTH_HEADER]: apiKey
|
|
169
|
+
},
|
|
170
|
+
body: JSON.stringify({ name: agentName }),
|
|
171
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
172
|
+
});
|
|
173
|
+
if (!response.ok) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
`Failed to register agent "${agentName}": service returned ${String(response.status)}.`
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
const body = await response.json();
|
|
179
|
+
if (!isApiSuccess(body) || !isAgentSummary(body.data)) {
|
|
180
|
+
throw new Error(`Failed to register agent "${agentName}": unexpected response format.`);
|
|
181
|
+
}
|
|
182
|
+
return body.data.id;
|
|
183
|
+
}
|
|
184
|
+
async function findOrRegisterAgent(agentName, apiKey, baseUrl) {
|
|
185
|
+
const existing = await findAgentByName(agentName, apiKey, baseUrl);
|
|
186
|
+
if (existing !== null) return existing;
|
|
187
|
+
try {
|
|
188
|
+
const id = await registerAgent(agentName, apiKey, baseUrl);
|
|
189
|
+
return { id, name: agentName };
|
|
190
|
+
} catch {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
async function fetchGrantedScopes(agentId, apiKey, baseUrl) {
|
|
195
|
+
try {
|
|
196
|
+
const response = await fetch(`${baseUrl}/api/v1/agents/${agentId}`, {
|
|
197
|
+
headers: { [AUTH_HEADER]: apiKey },
|
|
198
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
199
|
+
});
|
|
200
|
+
if (!response.ok) return [];
|
|
201
|
+
const body = await response.json();
|
|
202
|
+
if (!isApiSuccess(body)) return [];
|
|
203
|
+
const detail = body.data;
|
|
204
|
+
if (!isAgentDetail(detail)) return [];
|
|
205
|
+
const scopes = [];
|
|
206
|
+
for (const perm of detail.permissions) {
|
|
207
|
+
if (!isPermissionEntry(perm)) continue;
|
|
208
|
+
if (perm.revoked_at !== null) continue;
|
|
209
|
+
if (perm.read) scopes.push({ service: perm.service, permissionLevel: "read" });
|
|
210
|
+
if (perm.write) scopes.push({ service: perm.service, permissionLevel: "write" });
|
|
211
|
+
if (perm.execute) scopes.push({ service: perm.service, permissionLevel: "execute" });
|
|
212
|
+
}
|
|
213
|
+
return scopes;
|
|
214
|
+
} catch {
|
|
215
|
+
return [];
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
async function logAction(payload, apiKey, baseUrl) {
|
|
219
|
+
try {
|
|
220
|
+
const response = await fetch(`${baseUrl}/api/v1/actions`, {
|
|
221
|
+
method: "POST",
|
|
222
|
+
headers: {
|
|
223
|
+
"Content-Type": "application/json",
|
|
224
|
+
[AUTH_HEADER]: apiKey
|
|
225
|
+
},
|
|
226
|
+
body: JSON.stringify(payload),
|
|
227
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
228
|
+
});
|
|
229
|
+
if (!response.ok) {
|
|
230
|
+
process.stderr.write(
|
|
231
|
+
`[multicorn-shield] Action log failed: HTTP ${String(response.status)}.
|
|
232
|
+
`
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
} catch (error) {
|
|
236
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
237
|
+
process.stderr.write(`[multicorn-shield] Action log failed: ${detail}.
|
|
238
|
+
`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
var POLL_INTERVAL_MS2 = 3e3;
|
|
242
|
+
var POLL_TIMEOUT_MS2 = 5 * 60 * 1e3;
|
|
243
|
+
function deriveDashboardUrl(baseUrl) {
|
|
244
|
+
try {
|
|
245
|
+
const url = new URL(baseUrl);
|
|
246
|
+
if (url.hostname === "localhost" || url.hostname === "127.0.0.1") {
|
|
247
|
+
url.port = "5173";
|
|
248
|
+
url.protocol = "http:";
|
|
249
|
+
return url.toString();
|
|
250
|
+
}
|
|
251
|
+
if (url.hostname === "api.multicorn.ai") {
|
|
252
|
+
url.hostname = "app.multicorn.ai";
|
|
253
|
+
return url.toString();
|
|
254
|
+
}
|
|
255
|
+
if (url.hostname.includes("api")) {
|
|
256
|
+
url.hostname = url.hostname.replace("api", "app");
|
|
257
|
+
return url.toString();
|
|
258
|
+
}
|
|
259
|
+
return "https://app.multicorn.ai";
|
|
260
|
+
} catch {
|
|
261
|
+
return "https://app.multicorn.ai";
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function buildConsentUrl(agentName, dashboardUrl) {
|
|
265
|
+
const base = dashboardUrl.replace(/\/+$/, "");
|
|
266
|
+
const params = new URLSearchParams({ agent: agentName });
|
|
267
|
+
return `${base}/consent?${params.toString()}`;
|
|
268
|
+
}
|
|
269
|
+
function openBrowser(url) {
|
|
270
|
+
const platform = process.platform;
|
|
271
|
+
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
|
|
272
|
+
try {
|
|
273
|
+
spawn(cmd, [url], { detached: true, stdio: "ignore" }).unref();
|
|
274
|
+
} catch {
|
|
275
|
+
process.stderr.write(
|
|
276
|
+
`[multicorn-shield] Could not open browser. Visit this URL to grant permissions:
|
|
277
|
+
${url}
|
|
278
|
+
`
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
async function waitForConsent(agentId, agentName, apiKey, baseUrl) {
|
|
283
|
+
const dashboardUrl = deriveDashboardUrl(baseUrl);
|
|
284
|
+
const consentUrl = buildConsentUrl(agentName, dashboardUrl);
|
|
285
|
+
process.stderr.write(
|
|
286
|
+
`[multicorn-shield] Opening consent page...
|
|
287
|
+
${consentUrl}
|
|
288
|
+
Waiting for you to grant access in the Multicorn dashboard...
|
|
289
|
+
`
|
|
290
|
+
);
|
|
291
|
+
openBrowser(consentUrl);
|
|
292
|
+
const deadline = Date.now() + POLL_TIMEOUT_MS2;
|
|
293
|
+
while (Date.now() < deadline) {
|
|
294
|
+
await sleep(POLL_INTERVAL_MS2);
|
|
295
|
+
const scopes = await fetchGrantedScopes(agentId, apiKey, baseUrl);
|
|
296
|
+
if (scopes.length > 0) {
|
|
297
|
+
process.stderr.write("[multicorn-shield] Permissions granted.\n");
|
|
298
|
+
return scopes;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
throw new Error(
|
|
302
|
+
`Consent not granted within ${String(POLL_TIMEOUT_MS2 / 6e4)} minutes. Grant access at ${dashboardUrl} and restart the gateway.`
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
function sleep(ms) {
|
|
306
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// src/openclaw/hook/handler.ts
|
|
310
|
+
var agentRecord = null;
|
|
311
|
+
var grantedScopes = [];
|
|
312
|
+
var consentInProgress = false;
|
|
313
|
+
var lastScopeRefresh = 0;
|
|
314
|
+
var SCOPE_REFRESH_INTERVAL_MS = 6e4;
|
|
315
|
+
function readConfig() {
|
|
316
|
+
const apiKey = process.env["MULTICORN_API_KEY"] ?? "";
|
|
317
|
+
const baseUrl = process.env["MULTICORN_BASE_URL"] ?? "https://api.multicorn.ai";
|
|
318
|
+
const agentName = process.env["MULTICORN_AGENT_NAME"] ?? null;
|
|
319
|
+
const failModeRaw = process.env["MULTICORN_FAIL_MODE"] ?? "open";
|
|
320
|
+
const failMode = failModeRaw === "closed" ? "closed" : "open";
|
|
321
|
+
return { apiKey, baseUrl, agentName, failMode };
|
|
322
|
+
}
|
|
323
|
+
function resolveAgentName(sessionKey, envOverride) {
|
|
324
|
+
if (envOverride !== null && envOverride.trim().length > 0) {
|
|
325
|
+
return envOverride.trim();
|
|
326
|
+
}
|
|
327
|
+
const parts = sessionKey.split(":");
|
|
328
|
+
const name = parts[1];
|
|
329
|
+
if (name !== void 0 && name.trim().length > 0) {
|
|
330
|
+
return name.trim();
|
|
331
|
+
}
|
|
332
|
+
return "openclaw";
|
|
333
|
+
}
|
|
334
|
+
async function ensureAgent(agentName, apiKey, baseUrl, failMode) {
|
|
335
|
+
if (agentRecord !== null && Date.now() - lastScopeRefresh < SCOPE_REFRESH_INTERVAL_MS) {
|
|
336
|
+
return "ready";
|
|
337
|
+
}
|
|
338
|
+
if (agentRecord === null) {
|
|
339
|
+
const cached = await loadCachedScopes(agentName);
|
|
340
|
+
if (cached !== null && cached.length > 0) {
|
|
341
|
+
grantedScopes = cached;
|
|
342
|
+
void findOrRegisterAgent(agentName, apiKey, baseUrl).then((record) => {
|
|
343
|
+
if (record !== null) agentRecord = record;
|
|
344
|
+
});
|
|
345
|
+
lastScopeRefresh = Date.now();
|
|
346
|
+
return "ready";
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
if (agentRecord === null) {
|
|
350
|
+
const record = await findOrRegisterAgent(agentName, apiKey, baseUrl);
|
|
351
|
+
if (record === null) {
|
|
352
|
+
if (failMode === "closed") {
|
|
353
|
+
return "block";
|
|
354
|
+
}
|
|
355
|
+
process.stderr.write(
|
|
356
|
+
"[multicorn-shield] Could not reach Shield API. Running without permission checks.\n"
|
|
357
|
+
);
|
|
358
|
+
return "skip";
|
|
359
|
+
}
|
|
360
|
+
agentRecord = record;
|
|
361
|
+
}
|
|
362
|
+
const scopes = await fetchGrantedScopes(agentRecord.id, apiKey, baseUrl);
|
|
363
|
+
grantedScopes = scopes;
|
|
364
|
+
lastScopeRefresh = Date.now();
|
|
365
|
+
if (scopes.length > 0) {
|
|
366
|
+
await saveCachedScopes(agentName, agentRecord.id, scopes).catch(() => {
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
return "ready";
|
|
370
|
+
}
|
|
371
|
+
async function ensureConsent(agentName, apiKey, baseUrl) {
|
|
372
|
+
if (grantedScopes.length > 0 || consentInProgress || agentRecord === null) return;
|
|
373
|
+
consentInProgress = true;
|
|
374
|
+
try {
|
|
375
|
+
const scopes = await waitForConsent(agentRecord.id, agentName, apiKey, baseUrl);
|
|
376
|
+
grantedScopes = scopes;
|
|
377
|
+
await saveCachedScopes(agentName, agentRecord.id, scopes).catch(() => {
|
|
378
|
+
});
|
|
379
|
+
} finally {
|
|
380
|
+
consentInProgress = false;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
function isPermitted(event) {
|
|
384
|
+
const mapping = mapToolToScope(event.context.toolName);
|
|
385
|
+
return grantedScopes.some(
|
|
386
|
+
(scope) => scope.service === mapping.service && scope.permissionLevel === mapping.permissionLevel
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
var handler = async (event) => {
|
|
390
|
+
if (!isToolCallEvent(event)) return;
|
|
391
|
+
const config = readConfig();
|
|
392
|
+
if (config.apiKey.length === 0) {
|
|
393
|
+
process.stderr.write(
|
|
394
|
+
"[multicorn-shield] MULTICORN_API_KEY is not set. Skipping permission checks.\n"
|
|
395
|
+
);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
const agentName = resolveAgentName(event.sessionKey, config.agentName);
|
|
399
|
+
const readiness = await ensureAgent(agentName, config.apiKey, config.baseUrl, config.failMode);
|
|
400
|
+
if (readiness === "block") {
|
|
401
|
+
event.messages.push(
|
|
402
|
+
"Permission denied: Multicorn Shield could not verify permissions. The Shield API is unreachable and fail-closed mode is enabled."
|
|
403
|
+
);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (readiness === "skip") {
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
await ensureConsent(agentName, config.apiKey, config.baseUrl);
|
|
410
|
+
const mapping = mapToolToScope(event.context.toolName);
|
|
411
|
+
const permitted = isPermitted(event);
|
|
412
|
+
if (!permitted) {
|
|
413
|
+
const capitalizedService = mapping.service.charAt(0).toUpperCase() + mapping.service.slice(1);
|
|
414
|
+
event.messages.push(
|
|
415
|
+
`Permission denied: ${capitalizedService} ${mapping.permissionLevel} access is not allowed. Visit the Multicorn Shield dashboard to manage permissions.`
|
|
416
|
+
);
|
|
417
|
+
void logAction(
|
|
418
|
+
{
|
|
419
|
+
agent: agentName,
|
|
420
|
+
service: mapping.service,
|
|
421
|
+
actionType: event.context.toolName,
|
|
422
|
+
status: "blocked"
|
|
423
|
+
},
|
|
424
|
+
config.apiKey,
|
|
425
|
+
config.baseUrl
|
|
426
|
+
);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
void logAction(
|
|
430
|
+
{
|
|
431
|
+
agent: agentName,
|
|
432
|
+
service: mapping.service,
|
|
433
|
+
actionType: event.context.toolName,
|
|
434
|
+
status: "approved"
|
|
435
|
+
},
|
|
436
|
+
config.apiKey,
|
|
437
|
+
config.baseUrl
|
|
438
|
+
);
|
|
439
|
+
};
|
|
440
|
+
function resetState() {
|
|
441
|
+
agentRecord = null;
|
|
442
|
+
grantedScopes = [];
|
|
443
|
+
consentInProgress = false;
|
|
444
|
+
lastScopeRefresh = 0;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export { handler, readConfig, resetState, resolveAgentName };
|