multicorn-shield 1.7.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.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
 
10
10
  - Bump `version` in `package.json` before publishing to npm.
11
11
 
12
+ ## [1.8.0] - 2026-05-11
13
+
14
+ ### Added
15
+
16
+ - OpenCode as a supported platform (native plugin + hosted proxy paths)
17
+ - Native Shield plugin for OpenCode (`plugins/opencode/multicorn-shield.ts`) using `tool.execute.before`/`tool.execute.after` hooks for permission checks and audit logging
18
+ - OpenCode in CLI init wizard with native plugin and hosted proxy integration modes
19
+ - Tool name mapping for OpenCode built-in tools (`bash`, `read`, `write`, `edit`, `apply_patch`, `glob`, `grep`, `list`, `webfetch`, `websearch`)
20
+ - Shell reload hint in CLI native plugin output for freshly installed tools
21
+ - `'opencode'` to `AGENT_PLATFORM_SLUGS`
22
+
12
23
  ## [1.7.0] - 2026-05-11
13
24
 
14
25
  ### Fixed
package/dist/index.cjs CHANGED
@@ -26,6 +26,7 @@ var AGENT_PLATFORM_SLUGS = [
26
26
  "github-copilot",
27
27
  "goose",
28
28
  "kilo-code",
29
+ "opencode",
29
30
  "other-mcp",
30
31
  "github-actions",
31
32
  "unknown"
package/dist/index.d.cts CHANGED
@@ -12,7 +12,7 @@ import { LitElement, PropertyValues, HTMLTemplateResult } from 'lit';
12
12
  /**
13
13
  * Agent client platforms supported by hosted proxy and native hooks (aligned with API validation).
14
14
  */
15
- declare const AGENT_PLATFORM_SLUGS: readonly ["openclaw", "claude-code", "claude-desktop", "cursor", "windsurf", "cline", "gemini-cli", "continue-dev", "github-copilot", "goose", "kilo-code", "other-mcp", "github-actions", "unknown"];
15
+ declare const AGENT_PLATFORM_SLUGS: readonly ["openclaw", "claude-code", "claude-desktop", "cursor", "windsurf", "cline", "gemini-cli", "continue-dev", "github-copilot", "goose", "kilo-code", "opencode", "other-mcp", "github-actions", "unknown"];
16
16
  type AgentPlatformSlug = (typeof AGENT_PLATFORM_SLUGS)[number];
17
17
  /**
18
18
  * Possible operational states for an agent.
package/dist/index.d.ts CHANGED
@@ -12,7 +12,7 @@ import { LitElement, PropertyValues, HTMLTemplateResult } from 'lit';
12
12
  /**
13
13
  * Agent client platforms supported by hosted proxy and native hooks (aligned with API validation).
14
14
  */
15
- declare const AGENT_PLATFORM_SLUGS: readonly ["openclaw", "claude-code", "claude-desktop", "cursor", "windsurf", "cline", "gemini-cli", "continue-dev", "github-copilot", "goose", "kilo-code", "other-mcp", "github-actions", "unknown"];
15
+ declare const AGENT_PLATFORM_SLUGS: readonly ["openclaw", "claude-code", "claude-desktop", "cursor", "windsurf", "cline", "gemini-cli", "continue-dev", "github-copilot", "goose", "kilo-code", "opencode", "other-mcp", "github-actions", "unknown"];
16
16
  type AgentPlatformSlug = (typeof AGENT_PLATFORM_SLUGS)[number];
17
17
  /**
18
18
  * Possible operational states for an agent.
package/dist/index.js CHANGED
@@ -24,6 +24,7 @@ var AGENT_PLATFORM_SLUGS = [
24
24
  "github-copilot",
25
25
  "goose",
26
26
  "kilo-code",
27
+ "opencode",
27
28
  "other-mcp",
28
29
  "github-actions",
29
30
  "unknown"
@@ -925,6 +925,22 @@ require(${JSON.stringify(destPost)});
925
925
  await writeFile(preWrapper, preContent, { encoding: "utf8", mode: 493 });
926
926
  await writeFile(postWrapper, postContent, { encoding: "utf8", mode: 493 });
927
927
  }
928
+ function getOpenCodeGlobalPluginsDir() {
929
+ return join(homedir(), ".config", "opencode", "plugins");
930
+ }
931
+ async function installOpenCodeNativePlugin() {
932
+ const root = multicornShieldPackageRoot();
933
+ const src = join(root, "plugins", "opencode", "multicorn-shield.ts");
934
+ if (!existsSync(src)) {
935
+ throw new Error(
936
+ `Could not find Shield OpenCode plugin at ${src}. If you use npm, install the latest multicorn-shield package.`
937
+ );
938
+ }
939
+ const destDir = getOpenCodeGlobalPluginsDir();
940
+ await mkdir(destDir, { recursive: true });
941
+ const dest = join(destDir, "multicorn-shield.ts");
942
+ await copyFile(src, dest);
943
+ }
928
944
  async function promptClineIntegrationMode(ask) {
929
945
  process.stderr.write("\n" + style.bold("Cline integration") + "\n");
930
946
  process.stderr.write(
@@ -1284,6 +1300,23 @@ async function promptGeminiCliIntegrationMode(ask) {
1284
1300
  }
1285
1301
  return choice === 1 ? "native" : "hosted";
1286
1302
  }
1303
+ async function promptOpencodeIntegrationMode(ask) {
1304
+ process.stderr.write("\n" + style.bold("OpenCode integration") + "\n");
1305
+ process.stderr.write(
1306
+ " " + style.violet("1") + ". Native plugin (recommended) - Shield checks primary-agent tool execution via OpenCode Hooks\n"
1307
+ );
1308
+ process.stderr.write(
1309
+ " " + style.violet("2") + ". Hosted proxy - govern MCP server traffic via opencode.json (full subagent coverage when tools use MCP through Shield)\n"
1310
+ );
1311
+ let choice = 0;
1312
+ while (choice === 0) {
1313
+ const input = await ask("Choose integration (1-2): ");
1314
+ const num = parseInt(input.trim(), 10);
1315
+ if (num === 1) choice = 1;
1316
+ if (num === 2) choice = 2;
1317
+ }
1318
+ return choice === 1 ? "native" : "hosted";
1319
+ }
1287
1320
  function getClaudeDesktopConfigPath() {
1288
1321
  switch (process.platform) {
1289
1322
  case "win32":
@@ -1828,6 +1861,49 @@ async function mergeKiloCodeProjectMcp(workspacePath, shortName, proxyUrl, apiKe
1828
1861
  }
1829
1862
  return "ok";
1830
1863
  }
1864
+ async function injectOpencodeSchemaIntoConfigIfMissing(filePath) {
1865
+ try {
1866
+ const raw = await readFile(filePath, "utf8");
1867
+ const stripped = raw.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
1868
+ let parsed;
1869
+ try {
1870
+ parsed = JSON.parse(stripped);
1871
+ } catch {
1872
+ return;
1873
+ }
1874
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return;
1875
+ const root = parsed;
1876
+ const existingSchema = root["$schema"];
1877
+ if (typeof existingSchema === "string" && existingSchema.length > 0) return;
1878
+ root["$schema"] = OPENCODE_CONFIG_SCHEMA_URL;
1879
+ await writeFile(filePath, JSON.stringify(root, null, 2) + "\n", SECRET_JSON_FILE_OPTIONS);
1880
+ } catch {
1881
+ }
1882
+ }
1883
+ async function mergeOpenCodeProjectMcp(workspacePath, shortName, proxyUrl, apiKey) {
1884
+ const filePath = join(workspacePath, "opencode.json");
1885
+ const result = await mergeTopLevelKeyedJsonFile(
1886
+ filePath,
1887
+ "mcp",
1888
+ shortName,
1889
+ {
1890
+ type: "remote",
1891
+ url: proxyUrl,
1892
+ headers: { Authorization: `Bearer ${apiKey}` },
1893
+ enabled: true
1894
+ },
1895
+ {
1896
+ stripJsonComments: true,
1897
+ onExisting: "skip"
1898
+ }
1899
+ );
1900
+ if (result === "parse-error") return "parse-error";
1901
+ await injectOpencodeSchemaIntoConfigIfMissing(filePath);
1902
+ if (result === "ok") {
1903
+ await warnIfApiKeyFileNotGitignored(workspacePath, "opencode.json");
1904
+ }
1905
+ return "ok";
1906
+ }
1831
1907
  function printHostedProxyJsonParseWarning(filePath) {
1832
1908
  process.stderr.write(
1833
1909
  style.yellow("\u26A0") + " Could not parse JSON at " + style.cyan(filePath) + style.dim(" - showing paste snippet instead.") + "\n"
@@ -1870,6 +1946,13 @@ function printHostedProxyPostWriteHints(platform, shortName) {
1870
1946
  style.dim("Restart Kilo Code or reload the window so it picks up .kilo/kilo.jsonc.") + "\n"
1871
1947
  );
1872
1948
  }
1949
+ if (platform === "opencode") {
1950
+ process.stderr.write(
1951
+ style.dim(
1952
+ "Restart OpenCode or start a new session so it picks up opencode.json. For global MCP, merge the same snippet into ~/.config/opencode/opencode.json."
1953
+ ) + "\n"
1954
+ );
1955
+ }
1873
1956
  }
1874
1957
  async function applyHostedProxyMcpConfig(platform, proxyUrl, shortName, apiKey, workspacePath) {
1875
1958
  const authHeader = `Bearer ${apiKey}`;
@@ -1966,6 +2049,16 @@ async function applyHostedProxyMcpConfig(platform, proxyUrl, shortName, apiKey,
1966
2049
  if (result === "parse-error") {
1967
2050
  printHostedProxyJsonParseWarning(join(workspacePath, ".kilo", "kilo.jsonc"));
1968
2051
  }
2052
+ } else if (platform === "opencode") {
2053
+ result = await mergeOpenCodeProjectMcp(
2054
+ workspacePath,
2055
+ shortName,
2056
+ proxyUrlWithKeyWhenNeeded,
2057
+ apiKey
2058
+ );
2059
+ if (result === "parse-error") {
2060
+ printHostedProxyJsonParseWarning(join(workspacePath, "opencode.json"));
2061
+ }
1969
2062
  } else if (platform === "continue-dev") {
1970
2063
  result = await mergeContinueHostedMcp(
1971
2064
  workspacePath,
@@ -2100,7 +2193,8 @@ function printPlatformSnippet(platform, routingToken, shortName, apiKey) {
2100
2193
  "kilo-code",
2101
2194
  "github-copilot",
2102
2195
  "continue-dev",
2103
- "goose"
2196
+ "goose",
2197
+ "opencode"
2104
2198
  ]);
2105
2199
  const usesInlineKey = hostedInlinePlatforms.has(platform);
2106
2200
  const authHeader = usesInlineKey ? `Bearer ${apiKey}` : "Bearer YOUR_SHIELD_API_KEY";
@@ -2183,6 +2277,24 @@ mcpServers:
2183
2277
  null,
2184
2278
  2
2185
2279
  );
2280
+ } else if (platform === "opencode") {
2281
+ snippetText = JSON.stringify(
2282
+ {
2283
+ $schema: OPENCODE_CONFIG_SCHEMA_URL,
2284
+ mcp: {
2285
+ [shortName]: {
2286
+ type: "remote",
2287
+ url: urlInSnippet,
2288
+ headers: {
2289
+ Authorization: authHeader
2290
+ },
2291
+ enabled: true
2292
+ }
2293
+ }
2294
+ },
2295
+ null,
2296
+ 2
2297
+ );
2186
2298
  } else {
2187
2299
  const urlKey = platform === "windsurf" ? "serverUrl" : "url";
2188
2300
  snippetText = JSON.stringify(
@@ -2220,6 +2332,12 @@ mcpServers:
2220
2332
  process.stderr.write(
2221
2333
  "\n" + style.dim(`Add this to ${join(resolve(process.cwd()), ".kilo", "kilo.jsonc")}:`) + "\n\n"
2222
2334
  );
2335
+ } else if (platform === "opencode") {
2336
+ process.stderr.write(
2337
+ "\n" + style.dim(
2338
+ "Add this to opencode.json in your project root (or ~/.config/opencode/opencode.json for global config). OpenCode detects configured MCP servers on the next session start."
2339
+ ) + "\n\n"
2340
+ );
2223
2341
  } else if (platform === "github-copilot") {
2224
2342
  process.stderr.write(
2225
2343
  "\n" + style.dim(
@@ -2283,6 +2401,11 @@ mcpServers:
2283
2401
  if (platform === "goose") {
2284
2402
  process.stderr.write(style.dim("Start a new Goose session after updating config.") + "\n");
2285
2403
  }
2404
+ if (platform === "opencode") {
2405
+ process.stderr.write(
2406
+ style.dim("Restart OpenCode or start a new session after saving opencode.json.") + "\n"
2407
+ );
2408
+ }
2286
2409
  }
2287
2410
  function agentDisplayNameDedupeKey(name) {
2288
2411
  return name.trim().normalize("NFKC").toLowerCase();
@@ -2842,6 +2965,95 @@ You have ${String(agentsForPlatform.length)} agent(s) connected for ${selectedLa
2842
2965
  setupSucceeded = true;
2843
2966
  }
2844
2967
  }
2968
+ } else if (selectedPlatform === "opencode") {
2969
+ const opencodeMode = await promptOpencodeIntegrationMode(ask);
2970
+ if (opencodeMode === "native") {
2971
+ try {
2972
+ await installOpenCodeNativePlugin();
2973
+ process.stderr.write("\n" + style.bold("Shield OpenCode plugin installed") + "\n\n");
2974
+ process.stderr.write(
2975
+ style.dim("Plugin file: ") + style.cyan(getOpenCodeGlobalPluginsDir()) + "\n"
2976
+ );
2977
+ process.stderr.write("\n");
2978
+ process.stderr.write(
2979
+ style.dim(
2980
+ "Shield plugin saved under ~/.config/opencode/plugins/. Restart OpenCode. Every tool call from the primary agent will be checked by Shield."
2981
+ ) + "\n"
2982
+ );
2983
+ process.stderr.write("\n");
2984
+ process.stderr.write(
2985
+ style.dim(
2986
+ "Note: Tool calls delegated to subagents via the task tool are not intercepted by this plugin (OpenCode limitation). Use the hosted proxy path for MCP traffic you route through Shield for broader coverage."
2987
+ ) + "\n"
2988
+ );
2989
+ configuredAgents.push({
2990
+ selection,
2991
+ platform: selectedPlatform,
2992
+ platformLabel: selectedLabel,
2993
+ agentName,
2994
+ opencodeCliIntegration: "native"
2995
+ });
2996
+ setupSucceeded = true;
2997
+ } catch (error) {
2998
+ const detail = error instanceof Error ? error.message : String(error);
2999
+ process.stderr.write(style.red("\u2717 ") + detail + "\n");
3000
+ }
3001
+ } else {
3002
+ const { targetUrl, shortName, upstreamHeaders } = await promptProxyConfig(ask, agentName);
3003
+ let proxyUrl = "";
3004
+ let created = false;
3005
+ while (!created) {
3006
+ const spinner = withSpinner("Creating proxy config...");
3007
+ try {
3008
+ proxyUrl = await createProxyConfig(
3009
+ resolvedBaseUrl,
3010
+ apiKey,
3011
+ agentName,
3012
+ targetUrl,
3013
+ shortName,
3014
+ selectedPlatform,
3015
+ upstreamHeaders
3016
+ );
3017
+ spinner.stop(true, "Proxy config created!");
3018
+ created = true;
3019
+ } catch (error) {
3020
+ const detail = error instanceof Error ? error.message : String(error);
3021
+ spinner.stop(false, detail);
3022
+ const retry = await ask("Try again? (Y/n) ");
3023
+ if (retry.trim().toLowerCase() === "n") {
3024
+ break;
3025
+ }
3026
+ }
3027
+ }
3028
+ if (created && proxyUrl.length > 0) {
3029
+ process.stderr.write("\n" + style.bold("Your Shield proxy URL:") + "\n");
3030
+ process.stderr.write(
3031
+ " " + style.cyan(formatHostedProxyUrlForStderr(selectedPlatform, proxyUrl, apiKey)) + "\n"
3032
+ );
3033
+ await applyHostedProxyMcpConfig(
3034
+ selectedPlatform,
3035
+ proxyUrl,
3036
+ shortName,
3037
+ apiKey,
3038
+ initWorkspacePath
3039
+ );
3040
+ process.stderr.write(
3041
+ "\n" + style.dim(
3042
+ "Add this to opencode.json in your project root (or ~/.config/opencode/opencode.json for global config). OpenCode detects configured MCP servers on the next session start."
3043
+ ) + "\n"
3044
+ );
3045
+ configuredAgents.push({
3046
+ selection,
3047
+ platform: selectedPlatform,
3048
+ platformLabel: selectedLabel,
3049
+ agentName,
3050
+ shortName,
3051
+ proxyUrl,
3052
+ opencodeCliIntegration: "hosted"
3053
+ });
3054
+ setupSucceeded = true;
3055
+ }
3056
+ }
2845
3057
  } else if (selectedPlatform === "cline") {
2846
3058
  const clineMode = await promptClineIntegrationMode(ask);
2847
3059
  if (clineMode === "native") {
@@ -3137,6 +3349,23 @@ You have ${String(agentsForPlatform.length)} agent(s) connected for ${selectedLa
3137
3349
  "\n" + style.bold("Gemini CLI (hosted)") + "\n \u2192 Try it: make a request in Gemini CLI - Shield will intercept the first tool call and ask for your consent\n"
3138
3350
  );
3139
3351
  }
3352
+ const opencodeNativeConfigured = configuredAgents.some(
3353
+ (a) => a.platform === "opencode" && a.opencodeCliIntegration === "native"
3354
+ );
3355
+ const opencodeHostedConfigured = configuredAgents.some(
3356
+ (a) => a.platform === "opencode" && a.opencodeCliIntegration === "hosted"
3357
+ );
3358
+ if (opencodeNativeConfigured) {
3359
+ blocks.push(
3360
+ "\n" + style.bold("OpenCode (native)") + "\n \u2192 If you just installed OpenCode, open a new terminal tab (or run: source ~/.zshrc)\n \u2192 Restart OpenCode so it loads ~/.config/opencode/plugins/multicorn-shield.ts\n \u2192 Try it: trigger a primary-agent tool call - Shield will intercept the first actionable tool and ask for your consent\n"
3361
+ );
3362
+ }
3363
+ if (opencodeHostedConfigured) {
3364
+ const ocLabel = mcpPromptLabel2("opencode");
3365
+ blocks.push(
3366
+ "\n" + style.bold("OpenCode (hosted)") + '\n \u2192 Restart OpenCode or start a new session after saving opencode.json\n \u2192 Try it: paste this into OpenCode:\n "Use the ' + ocLabel + ' MCP server to list allowed directories"\n'
3367
+ );
3368
+ }
3140
3369
  if (configuredPlatforms.has("other-mcp")) {
3141
3370
  blocks.push(
3142
3371
  "\n" + style.bold("Local MCP / Other") + "\n \u2192 Run your configured wrap command (for example " + style.cyan("npx multicorn-shield --wrap ...") + ")\n \u2192 Try it: make a request in your coding agent - Shield will intercept the first tool call and ask for your consent\n"
@@ -3156,7 +3385,7 @@ You have ${String(agentsForPlatform.length)} agent(s) connected for ${selectedLa
3156
3385
  }
3157
3386
  return lastConfig;
3158
3387
  }
3159
- var SECRET_JSON_FILE_OPTIONS, style, BANNER, NativePluginPrerequisiteMissingError, CONFIG_DIR, CONFIG_PATH, OPENCLAW_CONFIG_PATH, ANSI_PATTERN, UPSTREAM_AUTH_KNOWN_SCHEME_WITH_PAYLOAD, OPENCLAW_MIN_VERSION, INIT_WIZARD_PLATFORM_REGISTRY, INIT_WIZARD_MENU_SECTIONS, INIT_WIZARD_SELECTION_MAX, PLATFORM_BY_SELECTION, HOSTED_PROXY_PLATFORMS_WITH_URL_KEY, DEFAULT_SHIELD_API_BASE_URL;
3388
+ var SECRET_JSON_FILE_OPTIONS, style, BANNER, NativePluginPrerequisiteMissingError, CONFIG_DIR, CONFIG_PATH, OPENCLAW_CONFIG_PATH, ANSI_PATTERN, UPSTREAM_AUTH_KNOWN_SCHEME_WITH_PAYLOAD, OPENCLAW_MIN_VERSION, INIT_WIZARD_PLATFORM_REGISTRY, INIT_WIZARD_MENU_SECTIONS, INIT_WIZARD_SELECTION_MAX, PLATFORM_BY_SELECTION, HOSTED_PROXY_PLATFORMS_WITH_URL_KEY, OPENCODE_CONFIG_SCHEMA_URL, DEFAULT_SHIELD_API_BASE_URL;
3160
3389
  var init_config = __esm({
3161
3390
  "src/proxy/config.ts"() {
3162
3391
  init_consent();
@@ -3196,6 +3425,12 @@ var init_config = __esm({
3196
3425
  { slug: "windsurf", displayName: "Windsurf", section: "native" },
3197
3426
  { slug: "cline", displayName: "Cline", section: "native" },
3198
3427
  { slug: "gemini-cli", displayName: "Gemini CLI", section: "native" },
3428
+ {
3429
+ slug: "opencode",
3430
+ displayName: "OpenCode",
3431
+ section: "native",
3432
+ prereqUrl: "https://opencode.ai"
3433
+ },
3199
3434
  {
3200
3435
  slug: "cursor",
3201
3436
  displayName: "Cursor",
@@ -3256,6 +3491,7 @@ var init_config = __esm({
3256
3491
  "continue-dev",
3257
3492
  "goose"
3258
3493
  ]);
3494
+ OPENCODE_CONFIG_SCHEMA_URL = "https://opencode.ai/config.json";
3259
3495
  DEFAULT_SHIELD_API_BASE_URL = "https://api.multicorn.ai";
3260
3496
  }
3261
3497
  });
@@ -4200,7 +4436,7 @@ var init_package = __esm({
4200
4436
  "package.json"() {
4201
4437
  package_default = {
4202
4438
  name: "multicorn-shield",
4203
- version: "1.7.0",
4439
+ version: "1.8.0",
4204
4440
  description: "The control layer for AI agents: permissions, consent, spending limits, and audit logging.",
4205
4441
  license: "MIT",
4206
4442
  author: "Multicorn AI Pty Ltd",
@@ -4240,6 +4476,7 @@ var init_package = __esm({
4240
4476
  "plugins/windsurf",
4241
4477
  "plugins/cline",
4242
4478
  "plugins/gemini-cli",
4479
+ "plugins/opencode",
4243
4480
  "LICENSE",
4244
4481
  "README.md",
4245
4482
  "CHANGELOG.md"
@@ -4298,6 +4535,7 @@ var init_package = __esm({
4298
4535
  "@anthropic-ai/mcpb": "^2.1.2",
4299
4536
  "@eslint/js": "^9.19.0",
4300
4537
  "@open-wc/testing-helpers": "^3.0.1",
4538
+ "@opencode-ai/plugin": "^1.14.48",
4301
4539
  "@size-limit/file": "^11.1.6",
4302
4540
  "@types/node": "^22.0.0",
4303
4541
  "@vitest/coverage-v8": "^3.0.5",
@@ -939,6 +939,22 @@ require(${JSON.stringify(destPost)});
939
939
  await writeFile(preWrapper, preContent, { encoding: "utf8", mode: 493 });
940
940
  await writeFile(postWrapper, postContent, { encoding: "utf8", mode: 493 });
941
941
  }
942
+ function getOpenCodeGlobalPluginsDir() {
943
+ return join(homedir(), ".config", "opencode", "plugins");
944
+ }
945
+ async function installOpenCodeNativePlugin() {
946
+ const root = multicornShieldPackageRoot();
947
+ const src = join(root, "plugins", "opencode", "multicorn-shield.ts");
948
+ if (!existsSync(src)) {
949
+ throw new Error(
950
+ `Could not find Shield OpenCode plugin at ${src}. If you use npm, install the latest multicorn-shield package.`
951
+ );
952
+ }
953
+ const destDir = getOpenCodeGlobalPluginsDir();
954
+ await mkdir(destDir, { recursive: true });
955
+ const dest = join(destDir, "multicorn-shield.ts");
956
+ await copyFile(src, dest);
957
+ }
942
958
  async function promptClineIntegrationMode(ask) {
943
959
  process.stderr.write("\n" + style.bold("Cline integration") + "\n");
944
960
  process.stderr.write(
@@ -1298,6 +1314,23 @@ async function promptGeminiCliIntegrationMode(ask) {
1298
1314
  }
1299
1315
  return choice === 1 ? "native" : "hosted";
1300
1316
  }
1317
+ async function promptOpencodeIntegrationMode(ask) {
1318
+ process.stderr.write("\n" + style.bold("OpenCode integration") + "\n");
1319
+ process.stderr.write(
1320
+ " " + style.violet("1") + ". Native plugin (recommended) - Shield checks primary-agent tool execution via OpenCode Hooks\n"
1321
+ );
1322
+ process.stderr.write(
1323
+ " " + style.violet("2") + ". Hosted proxy - govern MCP server traffic via opencode.json (full subagent coverage when tools use MCP through Shield)\n"
1324
+ );
1325
+ let choice = 0;
1326
+ while (choice === 0) {
1327
+ const input = await ask("Choose integration (1-2): ");
1328
+ const num = parseInt(input.trim(), 10);
1329
+ if (num === 1) choice = 1;
1330
+ if (num === 2) choice = 2;
1331
+ }
1332
+ return choice === 1 ? "native" : "hosted";
1333
+ }
1301
1334
  function getClaudeDesktopConfigPath() {
1302
1335
  switch (process.platform) {
1303
1336
  case "win32":
@@ -1367,6 +1400,12 @@ var INIT_WIZARD_PLATFORM_REGISTRY = [
1367
1400
  { slug: "windsurf", displayName: "Windsurf", section: "native" },
1368
1401
  { slug: "cline", displayName: "Cline", section: "native" },
1369
1402
  { slug: "gemini-cli", displayName: "Gemini CLI", section: "native" },
1403
+ {
1404
+ slug: "opencode",
1405
+ displayName: "OpenCode",
1406
+ section: "native",
1407
+ prereqUrl: "https://opencode.ai"
1408
+ },
1370
1409
  {
1371
1410
  slug: "cursor",
1372
1411
  displayName: "Cursor",
@@ -1908,6 +1947,50 @@ async function mergeKiloCodeProjectMcp(workspacePath, shortName, proxyUrl, apiKe
1908
1947
  }
1909
1948
  return "ok";
1910
1949
  }
1950
+ var OPENCODE_CONFIG_SCHEMA_URL = "https://opencode.ai/config.json";
1951
+ async function injectOpencodeSchemaIntoConfigIfMissing(filePath) {
1952
+ try {
1953
+ const raw = await readFile(filePath, "utf8");
1954
+ const stripped = raw.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
1955
+ let parsed;
1956
+ try {
1957
+ parsed = JSON.parse(stripped);
1958
+ } catch {
1959
+ return;
1960
+ }
1961
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return;
1962
+ const root = parsed;
1963
+ const existingSchema = root["$schema"];
1964
+ if (typeof existingSchema === "string" && existingSchema.length > 0) return;
1965
+ root["$schema"] = OPENCODE_CONFIG_SCHEMA_URL;
1966
+ await writeFile(filePath, JSON.stringify(root, null, 2) + "\n", SECRET_JSON_FILE_OPTIONS);
1967
+ } catch {
1968
+ }
1969
+ }
1970
+ async function mergeOpenCodeProjectMcp(workspacePath, shortName, proxyUrl, apiKey) {
1971
+ const filePath = join(workspacePath, "opencode.json");
1972
+ const result = await mergeTopLevelKeyedJsonFile(
1973
+ filePath,
1974
+ "mcp",
1975
+ shortName,
1976
+ {
1977
+ type: "remote",
1978
+ url: proxyUrl,
1979
+ headers: { Authorization: `Bearer ${apiKey}` },
1980
+ enabled: true
1981
+ },
1982
+ {
1983
+ stripJsonComments: true,
1984
+ onExisting: "skip"
1985
+ }
1986
+ );
1987
+ if (result === "parse-error") return "parse-error";
1988
+ await injectOpencodeSchemaIntoConfigIfMissing(filePath);
1989
+ if (result === "ok") {
1990
+ await warnIfApiKeyFileNotGitignored(workspacePath, "opencode.json");
1991
+ }
1992
+ return "ok";
1993
+ }
1911
1994
  function printHostedProxyJsonParseWarning(filePath) {
1912
1995
  process.stderr.write(
1913
1996
  style.yellow("\u26A0") + " Could not parse JSON at " + style.cyan(filePath) + style.dim(" - showing paste snippet instead.") + "\n"
@@ -1950,6 +2033,13 @@ function printHostedProxyPostWriteHints(platform, shortName) {
1950
2033
  style.dim("Restart Kilo Code or reload the window so it picks up .kilo/kilo.jsonc.") + "\n"
1951
2034
  );
1952
2035
  }
2036
+ if (platform === "opencode") {
2037
+ process.stderr.write(
2038
+ style.dim(
2039
+ "Restart OpenCode or start a new session so it picks up opencode.json. For global MCP, merge the same snippet into ~/.config/opencode/opencode.json."
2040
+ ) + "\n"
2041
+ );
2042
+ }
1953
2043
  }
1954
2044
  async function applyHostedProxyMcpConfig(platform, proxyUrl, shortName, apiKey, workspacePath) {
1955
2045
  const authHeader = `Bearer ${apiKey}`;
@@ -2046,6 +2136,16 @@ async function applyHostedProxyMcpConfig(platform, proxyUrl, shortName, apiKey,
2046
2136
  if (result === "parse-error") {
2047
2137
  printHostedProxyJsonParseWarning(join(workspacePath, ".kilo", "kilo.jsonc"));
2048
2138
  }
2139
+ } else if (platform === "opencode") {
2140
+ result = await mergeOpenCodeProjectMcp(
2141
+ workspacePath,
2142
+ shortName,
2143
+ proxyUrlWithKeyWhenNeeded,
2144
+ apiKey
2145
+ );
2146
+ if (result === "parse-error") {
2147
+ printHostedProxyJsonParseWarning(join(workspacePath, "opencode.json"));
2148
+ }
2049
2149
  } else if (platform === "continue-dev") {
2050
2150
  result = await mergeContinueHostedMcp(
2051
2151
  workspacePath,
@@ -2180,7 +2280,8 @@ function printPlatformSnippet(platform, routingToken, shortName, apiKey) {
2180
2280
  "kilo-code",
2181
2281
  "github-copilot",
2182
2282
  "continue-dev",
2183
- "goose"
2283
+ "goose",
2284
+ "opencode"
2184
2285
  ]);
2185
2286
  const usesInlineKey = hostedInlinePlatforms.has(platform);
2186
2287
  const authHeader = usesInlineKey ? `Bearer ${apiKey}` : "Bearer YOUR_SHIELD_API_KEY";
@@ -2263,6 +2364,24 @@ mcpServers:
2263
2364
  null,
2264
2365
  2
2265
2366
  );
2367
+ } else if (platform === "opencode") {
2368
+ snippetText = JSON.stringify(
2369
+ {
2370
+ $schema: OPENCODE_CONFIG_SCHEMA_URL,
2371
+ mcp: {
2372
+ [shortName]: {
2373
+ type: "remote",
2374
+ url: urlInSnippet,
2375
+ headers: {
2376
+ Authorization: authHeader
2377
+ },
2378
+ enabled: true
2379
+ }
2380
+ }
2381
+ },
2382
+ null,
2383
+ 2
2384
+ );
2266
2385
  } else {
2267
2386
  const urlKey = platform === "windsurf" ? "serverUrl" : "url";
2268
2387
  snippetText = JSON.stringify(
@@ -2300,6 +2419,12 @@ mcpServers:
2300
2419
  process.stderr.write(
2301
2420
  "\n" + style.dim(`Add this to ${join(resolve(process.cwd()), ".kilo", "kilo.jsonc")}:`) + "\n\n"
2302
2421
  );
2422
+ } else if (platform === "opencode") {
2423
+ process.stderr.write(
2424
+ "\n" + style.dim(
2425
+ "Add this to opencode.json in your project root (or ~/.config/opencode/opencode.json for global config). OpenCode detects configured MCP servers on the next session start."
2426
+ ) + "\n\n"
2427
+ );
2303
2428
  } else if (platform === "github-copilot") {
2304
2429
  process.stderr.write(
2305
2430
  "\n" + style.dim(
@@ -2363,6 +2488,11 @@ mcpServers:
2363
2488
  if (platform === "goose") {
2364
2489
  process.stderr.write(style.dim("Start a new Goose session after updating config.") + "\n");
2365
2490
  }
2491
+ if (platform === "opencode") {
2492
+ process.stderr.write(
2493
+ style.dim("Restart OpenCode or start a new session after saving opencode.json.") + "\n"
2494
+ );
2495
+ }
2366
2496
  }
2367
2497
  function agentDisplayNameDedupeKey(name) {
2368
2498
  return name.trim().normalize("NFKC").toLowerCase();
@@ -2923,6 +3053,95 @@ You have ${String(agentsForPlatform.length)} agent(s) connected for ${selectedLa
2923
3053
  setupSucceeded = true;
2924
3054
  }
2925
3055
  }
3056
+ } else if (selectedPlatform === "opencode") {
3057
+ const opencodeMode = await promptOpencodeIntegrationMode(ask);
3058
+ if (opencodeMode === "native") {
3059
+ try {
3060
+ await installOpenCodeNativePlugin();
3061
+ process.stderr.write("\n" + style.bold("Shield OpenCode plugin installed") + "\n\n");
3062
+ process.stderr.write(
3063
+ style.dim("Plugin file: ") + style.cyan(getOpenCodeGlobalPluginsDir()) + "\n"
3064
+ );
3065
+ process.stderr.write("\n");
3066
+ process.stderr.write(
3067
+ style.dim(
3068
+ "Shield plugin saved under ~/.config/opencode/plugins/. Restart OpenCode. Every tool call from the primary agent will be checked by Shield."
3069
+ ) + "\n"
3070
+ );
3071
+ process.stderr.write("\n");
3072
+ process.stderr.write(
3073
+ style.dim(
3074
+ "Note: Tool calls delegated to subagents via the task tool are not intercepted by this plugin (OpenCode limitation). Use the hosted proxy path for MCP traffic you route through Shield for broader coverage."
3075
+ ) + "\n"
3076
+ );
3077
+ configuredAgents.push({
3078
+ selection,
3079
+ platform: selectedPlatform,
3080
+ platformLabel: selectedLabel,
3081
+ agentName,
3082
+ opencodeCliIntegration: "native"
3083
+ });
3084
+ setupSucceeded = true;
3085
+ } catch (error) {
3086
+ const detail = error instanceof Error ? error.message : String(error);
3087
+ process.stderr.write(style.red("\u2717 ") + detail + "\n");
3088
+ }
3089
+ } else {
3090
+ const { targetUrl, shortName, upstreamHeaders } = await promptProxyConfig(ask, agentName);
3091
+ let proxyUrl = "";
3092
+ let created = false;
3093
+ while (!created) {
3094
+ const spinner = withSpinner("Creating proxy config...");
3095
+ try {
3096
+ proxyUrl = await createProxyConfig(
3097
+ resolvedBaseUrl,
3098
+ apiKey,
3099
+ agentName,
3100
+ targetUrl,
3101
+ shortName,
3102
+ selectedPlatform,
3103
+ upstreamHeaders
3104
+ );
3105
+ spinner.stop(true, "Proxy config created!");
3106
+ created = true;
3107
+ } catch (error) {
3108
+ const detail = error instanceof Error ? error.message : String(error);
3109
+ spinner.stop(false, detail);
3110
+ const retry = await ask("Try again? (Y/n) ");
3111
+ if (retry.trim().toLowerCase() === "n") {
3112
+ break;
3113
+ }
3114
+ }
3115
+ }
3116
+ if (created && proxyUrl.length > 0) {
3117
+ process.stderr.write("\n" + style.bold("Your Shield proxy URL:") + "\n");
3118
+ process.stderr.write(
3119
+ " " + style.cyan(formatHostedProxyUrlForStderr(selectedPlatform, proxyUrl, apiKey)) + "\n"
3120
+ );
3121
+ await applyHostedProxyMcpConfig(
3122
+ selectedPlatform,
3123
+ proxyUrl,
3124
+ shortName,
3125
+ apiKey,
3126
+ initWorkspacePath
3127
+ );
3128
+ process.stderr.write(
3129
+ "\n" + style.dim(
3130
+ "Add this to opencode.json in your project root (or ~/.config/opencode/opencode.json for global config). OpenCode detects configured MCP servers on the next session start."
3131
+ ) + "\n"
3132
+ );
3133
+ configuredAgents.push({
3134
+ selection,
3135
+ platform: selectedPlatform,
3136
+ platformLabel: selectedLabel,
3137
+ agentName,
3138
+ shortName,
3139
+ proxyUrl,
3140
+ opencodeCliIntegration: "hosted"
3141
+ });
3142
+ setupSucceeded = true;
3143
+ }
3144
+ }
2926
3145
  } else if (selectedPlatform === "cline") {
2927
3146
  const clineMode = await promptClineIntegrationMode(ask);
2928
3147
  if (clineMode === "native") {
@@ -3218,6 +3437,23 @@ You have ${String(agentsForPlatform.length)} agent(s) connected for ${selectedLa
3218
3437
  "\n" + style.bold("Gemini CLI (hosted)") + "\n \u2192 Try it: make a request in Gemini CLI - Shield will intercept the first tool call and ask for your consent\n"
3219
3438
  );
3220
3439
  }
3440
+ const opencodeNativeConfigured = configuredAgents.some(
3441
+ (a) => a.platform === "opencode" && a.opencodeCliIntegration === "native"
3442
+ );
3443
+ const opencodeHostedConfigured = configuredAgents.some(
3444
+ (a) => a.platform === "opencode" && a.opencodeCliIntegration === "hosted"
3445
+ );
3446
+ if (opencodeNativeConfigured) {
3447
+ blocks.push(
3448
+ "\n" + style.bold("OpenCode (native)") + "\n \u2192 If you just installed OpenCode, open a new terminal tab (or run: source ~/.zshrc)\n \u2192 Restart OpenCode so it loads ~/.config/opencode/plugins/multicorn-shield.ts\n \u2192 Try it: trigger a primary-agent tool call - Shield will intercept the first actionable tool and ask for your consent\n"
3449
+ );
3450
+ }
3451
+ if (opencodeHostedConfigured) {
3452
+ const ocLabel = mcpPromptLabel2("opencode");
3453
+ blocks.push(
3454
+ "\n" + style.bold("OpenCode (hosted)") + '\n \u2192 Restart OpenCode or start a new session after saving opencode.json\n \u2192 Try it: paste this into OpenCode:\n "Use the ' + ocLabel + ' MCP server to list allowed directories"\n'
3455
+ );
3456
+ }
3221
3457
  if (configuredPlatforms.has("other-mcp")) {
3222
3458
  blocks.push(
3223
3459
  "\n" + style.bold("Local MCP / Other") + "\n \u2192 Run your configured wrap command (for example " + style.cyan("npx multicorn-shield --wrap ...") + ")\n \u2192 Try it: make a request in your coding agent - Shield will intercept the first tool call and ask for your consent\n"
@@ -4123,7 +4359,7 @@ async function restoreClaudeDesktopMcpFromBackup() {
4123
4359
 
4124
4360
  // package.json
4125
4361
  var package_default = {
4126
- version: "1.7.0"};
4362
+ version: "1.8.0"};
4127
4363
 
4128
4364
  // src/package-meta.ts
4129
4365
  var PACKAGE_VERSION = package_default.version;
@@ -22354,6 +22354,12 @@ var INIT_WIZARD_PLATFORM_REGISTRY = [
22354
22354
  { slug: "windsurf", displayName: "Windsurf", section: "native" },
22355
22355
  { slug: "cline", displayName: "Cline", section: "native" },
22356
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
+ },
22357
22363
  {
22358
22364
  slug: "cursor",
22359
22365
  displayName: "Cursor",
@@ -22505,7 +22511,7 @@ async function writeExtensionBackup(claudeDesktopConfigPath, mcpServers) {
22505
22511
 
22506
22512
  // package.json
22507
22513
  var package_default = {
22508
- version: "1.7.0"};
22514
+ version: "1.8.0"};
22509
22515
 
22510
22516
  // src/package-meta.ts
22511
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.7.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"
@@ -76,6 +77,7 @@
76
77
  "@anthropic-ai/mcpb": "^2.1.2",
77
78
  "@eslint/js": "^9.19.0",
78
79
  "@open-wc/testing-helpers": "^3.0.1",
80
+ "@opencode-ai/plugin": "^1.14.48",
79
81
  "@size-limit/file": "^11.1.6",
80
82
  "@types/node": "^22.0.0",
81
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
+ };