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.
@@ -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 };