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 +11 -0
- package/dist/index.cjs +1 -0
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -0
- package/dist/multicorn-proxy.js +241 -3
- package/dist/multicorn-shield.js +238 -2
- package/dist/shield-extension.js +7 -1
- package/package.json +3 -1
- package/plugins/opencode/multicorn-shield.ts +485 -0
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
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
package/dist/multicorn-proxy.js
CHANGED
|
@@ -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.
|
|
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",
|
package/dist/multicorn-shield.js
CHANGED
|
@@ -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.
|
|
4362
|
+
version: "1.8.0"};
|
|
4127
4363
|
|
|
4128
4364
|
// src/package-meta.ts
|
|
4129
4365
|
var PACKAGE_VERSION = package_default.version;
|
package/dist/shield-extension.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
+
};
|