multicorn-shield 1.6.0 → 1.8.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.
@@ -10,6 +10,7 @@ import { createHash } from 'crypto';
10
10
  import 'url';
11
11
  import 'module';
12
12
  import 'readline';
13
+ import 'yaml';
13
14
 
14
15
  // Multicorn Shield Claude Desktop Extension - https://multicorn.ai
15
16
  var __create = Object.create;
@@ -22353,6 +22354,12 @@ var INIT_WIZARD_PLATFORM_REGISTRY = [
22353
22354
  { slug: "windsurf", displayName: "Windsurf", section: "native" },
22354
22355
  { slug: "cline", displayName: "Cline", section: "native" },
22355
22356
  { slug: "gemini-cli", displayName: "Gemini CLI", section: "native" },
22357
+ {
22358
+ slug: "opencode",
22359
+ displayName: "OpenCode",
22360
+ section: "native",
22361
+ prereqUrl: "https://opencode.ai"
22362
+ },
22356
22363
  {
22357
22364
  slug: "cursor",
22358
22365
  displayName: "Cursor",
@@ -22504,7 +22511,7 @@ async function writeExtensionBackup(claudeDesktopConfigPath, mcpServers) {
22504
22511
 
22505
22512
  // package.json
22506
22513
  var package_default = {
22507
- version: "1.6.0"};
22514
+ version: "1.8.0"};
22508
22515
 
22509
22516
  // src/package-meta.ts
22510
22517
  var PACKAGE_VERSION = package_default.version;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "multicorn-shield",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "description": "The control layer for AI agents: permissions, consent, spending limits, and audit logging.",
5
5
  "license": "MIT",
6
6
  "author": "Multicorn AI Pty Ltd",
@@ -40,6 +40,7 @@
40
40
  "plugins/windsurf",
41
41
  "plugins/cline",
42
42
  "plugins/gemini-cli",
43
+ "plugins/opencode",
43
44
  "LICENSE",
44
45
  "README.md",
45
46
  "CHANGELOG.md"
@@ -69,12 +70,14 @@
69
70
  "dependencies": {
70
71
  "@modelcontextprotocol/sdk": "^1.27.1",
71
72
  "lit": "^3.2.0",
73
+ "yaml": "^2.8.2",
72
74
  "zod": "^4.3.6"
73
75
  },
74
76
  "devDependencies": {
75
77
  "@anthropic-ai/mcpb": "^2.1.2",
76
78
  "@eslint/js": "^9.19.0",
77
79
  "@open-wc/testing-helpers": "^3.0.1",
80
+ "@opencode-ai/plugin": "^1.14.48",
78
81
  "@size-limit/file": "^11.1.6",
79
82
  "@types/node": "^22.0.0",
80
83
  "@vitest/coverage-v8": "^3.0.5",
@@ -0,0 +1,485 @@
1
+ // Copyright (c) Multicorn AI Pty Ltd. MIT License. See LICENSE file.
2
+
3
+ /**
4
+ * Shield native plugin for OpenCode: permission checks via tool.execute.before,
5
+ * audit logging via tool.execute.after.
6
+ */
7
+
8
+ import type { Hooks, Plugin, PluginInput } from "@opencode-ai/plugin";
9
+ import { execFileSync } from "node:child_process";
10
+ import * as fs from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import * as path from "node:path";
13
+
14
+ const MULTICORN_CONFIG = path.join(homedir(), ".multicorn", "config.json");
15
+ const HTTP_MS = 10_000;
16
+ const PLATFORM = "opencode";
17
+
18
+ interface ShieldConfigLoaded {
19
+ readonly apiKey: string;
20
+ readonly baseUrl: string;
21
+ readonly agentName: string;
22
+ }
23
+
24
+ function cwdUnderWorkspacePath(cwdResolved: string, workspacePath: string): boolean {
25
+ const w = path.resolve(workspacePath);
26
+ if (cwdResolved === w) return true;
27
+ const prefix = w.endsWith(path.sep) ? w : w + path.sep;
28
+ return cwdResolved.startsWith(prefix);
29
+ }
30
+
31
+ function pickAgentName(obj: Record<string, unknown>, cwd: string): string {
32
+ const agents = obj["agents"];
33
+ if (!Array.isArray(agents)) {
34
+ return typeof obj["agentName"] === "string" ? obj["agentName"] : "";
35
+ }
36
+ const matches = agents.filter((e) => {
37
+ if (!e || typeof e !== "object") return false;
38
+ const row = e as Record<string, unknown>;
39
+ return row["platform"] === PLATFORM && typeof row["name"] === "string";
40
+ }) as readonly { name: string; workspacePath?: string }[];
41
+ if (matches.length === 0) {
42
+ return typeof obj["agentName"] === "string" ? obj["agentName"] : "";
43
+ }
44
+ const withWs = matches.filter(
45
+ (m) => typeof m.workspacePath === "string" && m.workspacePath.length > 0,
46
+ );
47
+ if (withWs.length === 0) {
48
+ const fb = matches[0];
49
+ return fb !== undefined ? fb.name : "";
50
+ }
51
+ const resolvedCwd = path.resolve(cwd);
52
+ let best: { name: string; workspacePath: string } | null = null;
53
+ let bestLen = -1;
54
+ for (const m of withWs) {
55
+ const wp = m.workspacePath;
56
+ if (typeof wp !== "string" || !cwdUnderWorkspacePath(resolvedCwd, wp)) continue;
57
+ const len = path.resolve(wp).length;
58
+ if (len > bestLen) {
59
+ bestLen = len;
60
+ best = { name: m.name, workspacePath: wp };
61
+ }
62
+ }
63
+ if (best !== null) {
64
+ return best.name;
65
+ }
66
+ const fb2 = matches[0];
67
+ return fb2 !== undefined ? fb2.name : "";
68
+ }
69
+
70
+ function loadShieldConfig(cwd: string): ShieldConfigLoaded | null {
71
+ try {
72
+ const raw = fs.readFileSync(MULTICORN_CONFIG, "utf8");
73
+ const parsed: unknown = JSON.parse(raw);
74
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
75
+ const obj = parsed as Record<string, unknown>;
76
+ const apiKey = typeof obj["apiKey"] === "string" ? obj["apiKey"] : "";
77
+ const baseUrl =
78
+ typeof obj["baseUrl"] === "string" && obj["baseUrl"].length > 0
79
+ ? obj["baseUrl"].replace(/\/+$/, "")
80
+ : "https://api.multicorn.ai";
81
+ const baseLower = baseUrl.toLowerCase();
82
+ const isHttps = baseLower.startsWith("https://");
83
+ const isLocal = baseLower.includes("localhost") || baseLower.includes("127.0.0.1");
84
+ if (!isHttps && !isLocal) {
85
+ return null;
86
+ }
87
+ const agentName = pickAgentName(obj, cwd);
88
+ return { apiKey, baseUrl, agentName };
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ type ToolTriple = readonly [service: string, actionType: string, skipShieldCheck: boolean];
95
+
96
+ /** Maps OpenCode tool names to Shield service/actionType. Third element skips pre-tool Shield check only. */
97
+ function mapTool(toolName: string): ToolTriple {
98
+ const name = toolName.trim();
99
+ if (name === "task") {
100
+ return ["agent", "delegate", true] as const;
101
+ }
102
+ if (name.startsWith("mcp_") || name.includes(":")) {
103
+ if (name.startsWith("mcp_")) {
104
+ const rest = name.slice(4);
105
+ const sanitized = rest.replace(/[^a-zA-Z0-9._-]+/g, "_");
106
+ return [`mcp:${sanitized}`, "execute", false] as const;
107
+ }
108
+ const idx = name.indexOf(":");
109
+ if (idx > 0) {
110
+ const server = name.slice(0, idx).replace(/[^a-zA-Z0-9._-]+/g, "_");
111
+ const tool = name.slice(idx + 1).replace(/[^a-zA-Z0-9._-]+/g, "_");
112
+ return [`mcp:${server}.${tool}`, "execute", false] as const;
113
+ }
114
+ }
115
+ const builtin: Record<string, readonly [string, string]> = {
116
+ bash: ["terminal", "execute"],
117
+ read: ["filesystem", "read"],
118
+ write: ["filesystem", "write"],
119
+ edit: ["filesystem", "write"],
120
+ apply_patch: ["filesystem", "write"],
121
+ glob: ["filesystem", "read"],
122
+ grep: ["filesystem", "read"],
123
+ list: ["filesystem", "read"],
124
+ webfetch: ["network", "request"],
125
+ websearch: ["network", "request"],
126
+ };
127
+ const hit = builtin[name];
128
+ if (hit !== undefined) {
129
+ return [hit[0], hit[1], false] as const;
130
+ }
131
+ return ["other", "execute", false] as const;
132
+ }
133
+
134
+ function unwrapData(body: unknown): unknown {
135
+ if (typeof body !== "object" || body === null) return null;
136
+ const o = body as Record<string, unknown>;
137
+ return o["success"] === true ? o["data"] : null;
138
+ }
139
+
140
+ function safeParseJson(text: string): unknown {
141
+ try {
142
+ return JSON.parse(text);
143
+ } catch {
144
+ return null;
145
+ }
146
+ }
147
+
148
+ function dashboardHintUrl(apiBaseUrl: string): string {
149
+ try {
150
+ const raw = apiBaseUrl.replace(/\/+$/, "");
151
+ const lower = raw.toLowerCase();
152
+ if (lower.includes("localhost:8080") || lower.includes("127.0.0.1:8080")) {
153
+ return "http://localhost:5173/approvals";
154
+ }
155
+ const u = new URL(raw);
156
+ if (u.hostname.startsWith("api.")) {
157
+ u.hostname = "app." + u.hostname.slice(4);
158
+ }
159
+ return `${u.origin}/approvals`;
160
+ } catch {
161
+ return "https://app.multicorn.ai/approvals";
162
+ }
163
+ }
164
+
165
+ function consentUrl(
166
+ apiBaseUrl: string,
167
+ agentName: string,
168
+ service: string,
169
+ actionType: string,
170
+ ): string {
171
+ const raw = apiBaseUrl.replace(/\/+$/, "");
172
+ let origin: string;
173
+ try {
174
+ const lower = raw.toLowerCase();
175
+ if (lower.includes("localhost:8080") || lower.includes("127.0.0.1:8080")) {
176
+ origin = "http://localhost:5173";
177
+ } else {
178
+ const u = new URL(raw);
179
+ if (u.hostname.startsWith("api.")) {
180
+ u.hostname = "app." + u.hostname.slice(4);
181
+ }
182
+ origin = u.origin;
183
+ }
184
+ } catch {
185
+ origin = "https://app.multicorn.ai";
186
+ }
187
+ const params = new URLSearchParams();
188
+ params.set("agent", agentName);
189
+ params.set("scopes", `${service}:${actionType}`);
190
+ params.set("platform", PLATFORM);
191
+ return `${origin}/consent?${params.toString()}`;
192
+ }
193
+
194
+ function openBrowser(url: string): void {
195
+ try {
196
+ if (process.platform === "darwin") {
197
+ execFileSync("open", [url], { stdio: "ignore" });
198
+ } else if (process.platform === "win32") {
199
+ execFileSync("cmd.exe", ["/c", "start", "", url], {
200
+ stdio: "ignore",
201
+ windowsHide: true,
202
+ });
203
+ } else {
204
+ execFileSync("xdg-open", [url], { stdio: "ignore" });
205
+ }
206
+ } catch {
207
+ /* ignore */
208
+ }
209
+ }
210
+
211
+ function blockedMessage(
212
+ data: unknown,
213
+ service: string,
214
+ actionType: string,
215
+ approvalsUrl: string,
216
+ ): string {
217
+ if (data !== null && typeof data === "object") {
218
+ const d = data as Record<string, unknown>;
219
+ const meta = d["metadata"];
220
+ if (typeof meta === "string" && meta.length > 0) {
221
+ const parsed = safeParseJson(meta);
222
+ if (parsed !== null && typeof parsed === "object") {
223
+ const br = (parsed as Record<string, unknown>)["block_reason"];
224
+ if (typeof br === "string" && br.length > 0) {
225
+ return `Shield: Action blocked - ${br}. Grant access at ${approvalsUrl}`;
226
+ }
227
+ }
228
+ }
229
+ }
230
+ return `Shield: Action blocked. Required permission: ${service} (${actionType}). Grant access at ${approvalsUrl}`;
231
+ }
232
+
233
+ function scrubMetadataArgs(args: unknown): string {
234
+ try {
235
+ if (typeof args !== "object" || args === null) return "{}";
236
+ const clone = { ...(args as Record<string, unknown>) };
237
+ const contentKey = clone["content"];
238
+ if (typeof contentKey === "string") {
239
+ clone["content"] = "[" + contentKey.length.toString() + " chars redacted]";
240
+ }
241
+ const cmd = clone["command"];
242
+ if (typeof cmd === "string" && cmd.length > 200) {
243
+ clone["command"] = cmd.slice(0, 200) + "... [truncated]";
244
+ }
245
+ let out = JSON.stringify(clone);
246
+ if (out.length > 4096) out = out.slice(0, 4096);
247
+ return out;
248
+ } catch {
249
+ return "{}";
250
+ }
251
+ }
252
+
253
+ function scrubResultSnippet(text: unknown): string {
254
+ if (typeof text !== "string") return "";
255
+ let s = text;
256
+ s = s.replace(/\bsk-[A-Za-z0-9_-]{8,}\b/g, "[REDACTED]");
257
+ s = s.replace(/\bmcs_[A-Za-z0-9_-]+\b/g, "[REDACTED]");
258
+ s = s.replace(/\bghp_[A-Za-z0-9]{20,}\b/g, "[REDACTED]");
259
+ s = s.replace(/Bearer\s+[^\s]+/gi, "[REDACTED]");
260
+ if (s.length > 500) {
261
+ return s.slice(0, 500) + "[truncated]";
262
+ }
263
+ return s;
264
+ }
265
+
266
+ async function shieldPostActions(
267
+ baseUrl: string,
268
+ apiKey: string,
269
+ body: Record<string, unknown>,
270
+ ): Promise<{ readonly statusCode: number; readonly bodyText: string }> {
271
+ const url = `${baseUrl.replace(/\/+$/, "")}/api/v1/actions`;
272
+ const ac = new AbortController();
273
+ const t = setTimeout(() => {
274
+ ac.abort();
275
+ }, HTTP_MS);
276
+ try {
277
+ const res = await fetch(url, {
278
+ method: "POST",
279
+ headers: {
280
+ Connection: "close",
281
+ "Content-Type": "application/json",
282
+ "X-Multicorn-Key": apiKey,
283
+ },
284
+ body: JSON.stringify(body),
285
+ signal: ac.signal,
286
+ });
287
+ const bodyText = await res.text();
288
+ return { statusCode: res.status, bodyText };
289
+ } finally {
290
+ clearTimeout(t);
291
+ }
292
+ }
293
+
294
+ async function notifyPluginLog(
295
+ client: PluginInput["client"],
296
+ level: "info" | "warn" | "error",
297
+ message: string,
298
+ ): Promise<void> {
299
+ try {
300
+ const appUnknown = (
301
+ client as unknown as {
302
+ app?: {
303
+ log?: (p: unknown) => Promise<void>;
304
+ };
305
+ }
306
+ ).app;
307
+ if (appUnknown?.log === undefined) return;
308
+ await appUnknown.log({
309
+ body: { service: "multicorn-shield-opencode", level, message },
310
+ });
311
+ } catch {
312
+ /* ignore */
313
+ }
314
+ }
315
+
316
+ async function shieldBeforeDecision(
317
+ cfg: ShieldConfigLoaded,
318
+ toolName: string,
319
+ args: Record<string, unknown>,
320
+ approvalsUrlApp: string,
321
+ ): Promise<{ readonly allow: true } | { readonly allow: false; readonly msg: string }> {
322
+ const [service, actionType, skipCheck] = mapTool(toolName);
323
+ if (skipCheck || cfg.apiKey.length === 0 || cfg.agentName.length === 0) {
324
+ return { allow: true };
325
+ }
326
+
327
+ /** @type {Record<string, unknown>} */
328
+ const metadata = {
329
+ tool_name: toolName,
330
+ parameters: scrubMetadataArgs(args),
331
+ source: PLATFORM,
332
+ };
333
+
334
+ /** @type {Record<string, unknown>} */
335
+ const payload = {
336
+ agent: cfg.agentName,
337
+ service,
338
+ actionType,
339
+ status: "pending",
340
+ metadata,
341
+ platform: PLATFORM,
342
+ };
343
+
344
+ let statusCode: number;
345
+ let bodyText: string;
346
+ try {
347
+ const res = await shieldPostActions(cfg.baseUrl, cfg.apiKey, payload);
348
+ statusCode = res.statusCode;
349
+ bodyText = res.bodyText;
350
+ } catch {
351
+ return { allow: true };
352
+ }
353
+
354
+ const parsed = typeof bodyText === "string" ? safeParseJson(bodyText) : null;
355
+ const data = unwrapData(parsed);
356
+
357
+ if (statusCode === 202) {
358
+ const url = consentUrl(cfg.baseUrl, cfg.agentName, service, actionType);
359
+ openBrowser(url);
360
+ return {
361
+ allow: false,
362
+ msg: `Shield: ${cfg.agentName} needs ${service}:${actionType} permission. Authorize at ${url} then retry this action.`,
363
+ };
364
+ }
365
+
366
+ if (statusCode === 201) {
367
+ if (data === null || typeof data !== "object") {
368
+ const u = consentUrl(cfg.baseUrl, cfg.agentName, service, actionType);
369
+ return {
370
+ allow: false,
371
+ msg: `Shield: ${cfg.agentName} needs ${service}:${actionType} permission. Approve at ${u} or review at ${approvalsUrlApp}`,
372
+ };
373
+ }
374
+ const row = data as Record<string, unknown>;
375
+ const rawStatus = row["status"];
376
+ const st = typeof rawStatus === "string" ? rawStatus.toLowerCase() : "";
377
+ if (st === "approved") {
378
+ return { allow: true };
379
+ }
380
+ if (st === "blocked" || st === "requires_approval") {
381
+ return {
382
+ allow: false,
383
+ msg: blockedMessage(data, service, actionType, approvalsUrlApp),
384
+ };
385
+ }
386
+ const u = consentUrl(cfg.baseUrl, cfg.agentName, service, actionType);
387
+ return {
388
+ allow: false,
389
+ msg: `Shield: ${cfg.agentName} needs ${service}:${actionType} permission. Approve at ${u} or review at ${approvalsUrlApp}`,
390
+ };
391
+ }
392
+
393
+ const u = consentUrl(cfg.baseUrl, cfg.agentName, service, actionType);
394
+ return {
395
+ allow: false,
396
+ msg: `Shield: ${cfg.agentName} needs ${service}:${actionType} permission. Approve at ${u} or review at ${approvalsUrlApp}`,
397
+ };
398
+ }
399
+
400
+ function scheduleLogApproved(
401
+ cfg: ShieldConfigLoaded,
402
+ toolName: string,
403
+ resultPreview: string,
404
+ ): void {
405
+ const [service, actionType] = mapTool(toolName);
406
+ /** @type {Record<string, unknown>} */
407
+ const metadata = {
408
+ tool_name: toolName,
409
+ result: resultPreview,
410
+ source: PLATFORM,
411
+ };
412
+
413
+ /** @type {Record<string, unknown>} */
414
+ const payload = {
415
+ agent: cfg.agentName,
416
+ service,
417
+ actionType,
418
+ status: "approved",
419
+ metadata,
420
+ platform: PLATFORM,
421
+ };
422
+
423
+ void shieldPostActions(cfg.baseUrl, cfg.apiKey, payload).catch(() => {
424
+ /* intentionally ignored */
425
+ });
426
+ }
427
+
428
+ export const MulticornShieldPlugin: Plugin = (input: PluginInput): Promise<Hooks> => {
429
+ const directoryResolved = typeof input.directory === "string" ? input.directory : process.cwd();
430
+
431
+ return Promise.resolve({
432
+ "tool.execute.before": async ({ tool: toolNameRaw }, output) => {
433
+ const cfg = loadShieldConfig(directoryResolved);
434
+ if (cfg === null || cfg.apiKey.length === 0 || cfg.agentName.length === 0) {
435
+ return;
436
+ }
437
+
438
+ const toolName = typeof toolNameRaw === "string" ? toolNameRaw : "";
439
+ if (toolName.length === 0) return;
440
+
441
+ const args =
442
+ output.args !== null && typeof output.args === "object" && !Array.isArray(output.args)
443
+ ? (output.args as Record<string, unknown>)
444
+ : {};
445
+
446
+ const approvalsUrl = dashboardHintUrl(cfg.baseUrl);
447
+
448
+ try {
449
+ const verdict = await shieldBeforeDecision(cfg, toolName, args, approvalsUrl);
450
+ if (!verdict.allow && "msg" in verdict) {
451
+ throw new Error(verdict.msg);
452
+ }
453
+ } catch (e) {
454
+ if (e instanceof Error && e.message.startsWith("Shield:")) throw e;
455
+ void notifyPluginLog(
456
+ input.client,
457
+ "warn",
458
+ `Shield pre-tool check skipped: ${e instanceof Error ? e.message : String(e)}`,
459
+ );
460
+ }
461
+ },
462
+ "tool.execute.after": (hookInput, output): Promise<void> => {
463
+ const cfg = loadShieldConfig(directoryResolved);
464
+ if (cfg === null || cfg.apiKey.length === 0 || cfg.agentName.length === 0) {
465
+ return Promise.resolve();
466
+ }
467
+
468
+ const toolName = typeof hookInput.tool === "string" ? hookInput.tool : "";
469
+ if (toolName.length === 0) {
470
+ return Promise.resolve();
471
+ }
472
+
473
+ let snippet = "";
474
+ if (typeof output === "object" && "output" in output) {
475
+ const rawOut = (output as Record<string, unknown>)["output"];
476
+ if (typeof rawOut === "string") {
477
+ snippet = scrubResultSnippet(rawOut);
478
+ }
479
+ }
480
+
481
+ scheduleLogApproved(cfg, toolName, snippet);
482
+ return Promise.resolve();
483
+ },
484
+ });
485
+ };