multicorn-shield 0.10.0 → 0.12.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.
@@ -22359,11 +22359,117 @@ async function writeExtensionBackup(claudeDesktopConfigPath, mcpServers) {
22359
22359
 
22360
22360
  // package.json
22361
22361
  var package_default = {
22362
- version: "0.10.0"};
22362
+ version: "0.12.0"};
22363
22363
 
22364
22364
  // src/package-meta.ts
22365
22365
  var PACKAGE_VERSION = package_default.version;
22366
22366
 
22367
+ // src/extension/proxy-url-validator.ts
22368
+ var ProxyUrlValidationError = class extends Error {
22369
+ constructor(message) {
22370
+ super(message);
22371
+ this.name = "ProxyUrlValidationError";
22372
+ }
22373
+ };
22374
+ function isPrivateOrReservedIpv4(hostname3) {
22375
+ const parts = hostname3.split(".");
22376
+ if (parts.length !== 4) {
22377
+ return false;
22378
+ }
22379
+ const octets = [];
22380
+ for (const p of parts) {
22381
+ if (!/^\d{1,3}$/.test(p)) {
22382
+ return false;
22383
+ }
22384
+ const n = Number.parseInt(p, 10);
22385
+ if (Number.isNaN(n) || n < 0 || n > 255) {
22386
+ return false;
22387
+ }
22388
+ octets.push(n);
22389
+ }
22390
+ const [a, b] = octets;
22391
+ if (a === void 0 || b === void 0) {
22392
+ return false;
22393
+ }
22394
+ if (a === 127) {
22395
+ return true;
22396
+ }
22397
+ if (a === 10) {
22398
+ return true;
22399
+ }
22400
+ if (a === 172 && b >= 16 && b <= 31) {
22401
+ return true;
22402
+ }
22403
+ if (a === 192 && b === 168) {
22404
+ return true;
22405
+ }
22406
+ if (a === 169 && b === 254) {
22407
+ return true;
22408
+ }
22409
+ if (a === 0 && b === 0 && octets[2] === 0 && octets[3] === 0) {
22410
+ return true;
22411
+ }
22412
+ return false;
22413
+ }
22414
+ function isBlockedIpv6(host) {
22415
+ const h = host.split("%")[0]?.toLowerCase() ?? host.toLowerCase();
22416
+ if (h === "::1") {
22417
+ return true;
22418
+ }
22419
+ if (h.startsWith("fe80:")) {
22420
+ return true;
22421
+ }
22422
+ if (h.startsWith("fc") || h.startsWith("fd")) {
22423
+ return true;
22424
+ }
22425
+ return false;
22426
+ }
22427
+ function hostForValidation(hostname3) {
22428
+ if (hostname3.startsWith("[") && hostname3.endsWith("]")) {
22429
+ return hostname3.slice(1, -1);
22430
+ }
22431
+ return hostname3;
22432
+ }
22433
+ function assertSafeProxyUrl(raw, options) {
22434
+ let url2;
22435
+ try {
22436
+ url2 = new URL(raw);
22437
+ } catch {
22438
+ throw new ProxyUrlValidationError(`Invalid proxy URL: ${raw}`);
22439
+ }
22440
+ if (url2.protocol !== "https:" && url2.protocol !== "http:") {
22441
+ throw new ProxyUrlValidationError(
22442
+ `Unsupported proxy URL scheme: ${url2.protocol} - only https: and http: are allowed`
22443
+ );
22444
+ }
22445
+ const hostname3 = url2.hostname;
22446
+ if (hostname3.length === 0) {
22447
+ throw new ProxyUrlValidationError(`Proxy URL has no hostname: ${raw}`);
22448
+ }
22449
+ if (options?.allowPrivateNetworks === true) {
22450
+ return;
22451
+ }
22452
+ const host = hostForValidation(hostname3);
22453
+ if (host.toLowerCase() === "localhost") {
22454
+ throw new ProxyUrlValidationError(
22455
+ `Proxy URL points to a private/reserved network address: ${url2.hostname}`
22456
+ );
22457
+ }
22458
+ if (host.includes(":")) {
22459
+ if (isBlockedIpv6(host)) {
22460
+ throw new ProxyUrlValidationError(
22461
+ `Proxy URL points to a private/reserved network address: ${url2.hostname}`
22462
+ );
22463
+ }
22464
+ return;
22465
+ }
22466
+ if (isPrivateOrReservedIpv4(host)) {
22467
+ throw new ProxyUrlValidationError(
22468
+ `Proxy URL points to a private/reserved network address: ${url2.hostname}`
22469
+ );
22470
+ }
22471
+ }
22472
+
22367
22473
  // src/extension/proxy-client.ts
22368
22474
  var MCP_PROTOCOL_VERSION = "2024-11-05";
22369
22475
  var ProxyConfigFetchError = class extends Error {
@@ -22404,8 +22510,12 @@ function isProxyConfigRow(v) {
22404
22510
  const o = v;
22405
22511
  return typeof o["proxy_url"] === "string" && o["proxy_url"].length > 0 && typeof o["server_name"] === "string" && typeof o["target_url"] === "string";
22406
22512
  }
22407
- async function fetchProxyConfigs(baseUrl, apiKey, timeoutMs) {
22513
+ async function fetchProxyConfigs(baseUrl, apiKey, timeoutMs, options) {
22408
22514
  const url2 = `${normalizeBaseUrl(baseUrl)}/api/v1/proxy/config`;
22515
+ assertSafeProxyUrl(
22516
+ url2,
22517
+ options?.allowPrivateNetworks === true ? { allowPrivateNetworks: true } : void 0
22518
+ );
22409
22519
  let response;
22410
22520
  try {
22411
22521
  response = await fetch(url2, {
@@ -22563,6 +22673,10 @@ var ProxySession = class {
22563
22673
  this.nextId = 1;
22564
22674
  this.sessionId = null;
22565
22675
  this.closed = false;
22676
+ assertSafeProxyUrl(
22677
+ proxyUrl,
22678
+ options?.allowPrivateNetworks === true ? { allowPrivateNetworks: true } : void 0
22679
+ );
22566
22680
  this.proxyUrl = proxyUrl.replace(/\/+$/, "") + "/mcp";
22567
22681
  this.apiKey = apiKey;
22568
22682
  this.requestTimeoutMs = options?.requestTimeoutMs ?? 6e4;
@@ -23554,6 +23668,8 @@ async function autoCreateProxyConfig(baseUrl, apiKey, serverName, entry, agentNa
23554
23668
  const targetUrl = `stdio://${entry.command}/${entry.args.join("/")}`;
23555
23669
  const url2 = `${baseUrl.replace(/\/+$/, "")}/api/v1/proxy/config`;
23556
23670
  debugLog2(`[SHIELD] Auto-creating proxy config for "${serverName}".`);
23671
+ const allowPrivateNetworks = process.env["MULTICORN_ALLOW_PRIVATE_PROXY_HOSTS"] === "1";
23672
+ assertSafeProxyUrl(url2, { allowPrivateNetworks });
23557
23673
  let response;
23558
23674
  try {
23559
23675
  response = await fetch(url2, {
@@ -23589,9 +23705,6 @@ async function autoCreateProxyConfig(baseUrl, apiKey, serverName, entry, agentNa
23589
23705
  return true;
23590
23706
  }
23591
23707
  async function runShieldExtension() {
23592
- const debugBaseUrl = process.env["MULTICORN_BASE_URL"] ?? "";
23593
- const debugApiKeyPrefix = process.env["MULTICORN_API_KEY"]?.slice(0, 8) ?? "";
23594
- console.error(`[SHIELD-DEBUG] BASE_URL=${debugBaseUrl} API_KEY=${debugApiKeyPrefix}...`);
23595
23708
  const logger = createLogger(readLogLevel());
23596
23709
  const apiKey = readApiKey();
23597
23710
  if (apiKey === null) {
@@ -23686,6 +23799,7 @@ async function runShieldExtension() {
23686
23799
  `[SHIELD] Config read; ${String(serverCount)} MCP server(s) discovered (excluding Shield).`
23687
23800
  );
23688
23801
  debugLog2("[SHIELD] Resolving proxy configs (local config or API).");
23802
+ const allowPrivateNetworks = process.env["MULTICORN_ALLOW_PRIVATE_PROXY_HOSTS"] === "1";
23689
23803
  let configs;
23690
23804
  const localConfigs = await readProxyConfigsFromLocalMulticornConfig();
23691
23805
  if (localConfigs.length > 0) {
@@ -23694,7 +23808,9 @@ async function runShieldExtension() {
23694
23808
  } else {
23695
23809
  debugLog2("[SHIELD] No local proxy configs; fetching from API.");
23696
23810
  try {
23697
- configs = await fetchProxyConfigs(baseUrl, apiKey, SETUP_TIMEOUT_MS);
23811
+ configs = await fetchProxyConfigs(baseUrl, apiKey, SETUP_TIMEOUT_MS, {
23812
+ allowPrivateNetworks
23813
+ });
23698
23814
  } catch (e) {
23699
23815
  clearTimeout(setupTimeout);
23700
23816
  if (e instanceof ProxyConfigFetchError) {
@@ -23728,7 +23844,9 @@ async function runShieldExtension() {
23728
23844
  `[SHIELD] Auto-created ${String(createdCount)} proxy config(s); re-fetching from API.`
23729
23845
  );
23730
23846
  try {
23731
- configs = await fetchProxyConfigs(baseUrl, apiKey, SETUP_TIMEOUT_MS);
23847
+ configs = await fetchProxyConfigs(baseUrl, apiKey, SETUP_TIMEOUT_MS, {
23848
+ allowPrivateNetworks
23849
+ });
23732
23850
  } catch (e) {
23733
23851
  const message = e instanceof Error ? e.message : String(e);
23734
23852
  debugLog2(`[SHIELD] Re-fetch after auto-creation failed: ${message}`);
@@ -23766,7 +23884,7 @@ async function runShieldExtension() {
23766
23884
  const toolsByProxy = /* @__PURE__ */ new Map();
23767
23885
  const sessionByProxyUrl = /* @__PURE__ */ new Map();
23768
23886
  for (const cfg of configs) {
23769
- const session = new ProxySession(cfg.proxy_url, apiKey);
23887
+ const session = new ProxySession(cfg.proxy_url, apiKey, { allowPrivateNetworks });
23770
23888
  try {
23771
23889
  debugLog2(`[SHIELD] Initializing proxy session for ${cfg.server_name}.`);
23772
23890
  await session.initialize();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "multicorn-shield",
3
- "version": "0.10.0",
3
+ "version": "0.12.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",
@@ -37,6 +37,7 @@
37
37
  "files": [
38
38
  "dist",
39
39
  "plugins/windsurf",
40
+ "plugins/cline",
40
41
  "LICENSE",
41
42
  "README.md",
42
43
  "CHANGELOG.md"
@@ -45,13 +46,18 @@
45
46
  "access": "public",
46
47
  "provenance": true
47
48
  },
48
- "sideEffects": false,
49
+ "sideEffects": [
50
+ "dist/index.js",
51
+ "dist/index.cjs",
52
+ "dist/badge.js",
53
+ "src/badge/multicorn-badge.ts"
54
+ ],
49
55
  "engines": {
50
56
  "node": ">=20"
51
57
  },
52
58
  "lint-staged": {
53
59
  "*.ts": [
54
- "eslint --fix",
60
+ "eslint --fix --no-warn-ignored",
55
61
  "prettier --write"
56
62
  ],
57
63
  "*.{json,md}": [
@@ -69,7 +75,7 @@
69
75
  "@open-wc/testing-helpers": "^3.0.1",
70
76
  "@size-limit/file": "^11.1.6",
71
77
  "@types/node": "^22.0.0",
72
- "@vitest/coverage-istanbul": "^3.0.5",
78
+ "@vitest/coverage-v8": "^3.0.5",
73
79
  "eslint": "^9.19.0",
74
80
  "eslint-config-prettier": "^10.0.1",
75
81
  "eslint-plugin-unicorn": "^57.0.0",
@@ -97,6 +103,11 @@
97
103
  "path": "dist/index.cjs",
98
104
  "limit": "50 kB",
99
105
  "gzip": true
106
+ },
107
+ {
108
+ "path": "dist/badge.js",
109
+ "limit": "5 kB",
110
+ "gzip": true
100
111
  }
101
112
  ],
102
113
  "keywords": [
@@ -123,8 +134,8 @@
123
134
  "scripts": {
124
135
  "build": "tsup",
125
136
  "dev": "tsup --watch",
126
- "lint": "eslint . && prettier --check .",
127
- "lint:fix": "eslint --fix . && prettier --write .",
137
+ "lint": "eslint . --no-warn-ignored && prettier --check .",
138
+ "lint:fix": "eslint --fix . --no-warn-ignored && prettier --write .",
128
139
  "test": "vitest run",
129
140
  "test:watch": "vitest",
130
141
  "test:coverage": "vitest run --coverage",
@@ -0,0 +1,61 @@
1
+ # Multicorn Shield Cline plugin
2
+
3
+ This folder ships **Shield hooks for the [Cline](https://github.com/cline/cline) VS Code extension**. The PreToolUse script asks Shield whether a pending tool call is allowed; the PostToolUse script records completed actions in the Shield audit trail.
4
+
5
+ ## Prerequisites
6
+
7
+ - **Node.js** 20 or newer (hooks run as standalone Node scripts).
8
+ - **Cline** v3.36 or newer with **Hooks** enabled in settings.
9
+
10
+ Keep these three scripts together whenever you install by hand:
11
+
12
+ - `hooks/scripts/pre-tool-use.cjs`
13
+ - `hooks/scripts/post-tool-use.cjs`
14
+ - `hooks/scripts/shared.cjs`
15
+
16
+ ## Installing the hooks
17
+
18
+ **CLI (recommended):** run `npx multicorn-shield init` and follow prompts so the scripts are copied into Cline's hooks directory.
19
+
20
+ **Manual:** copy the `hooks/scripts/` `.cjs` files into:
21
+
22
+ `~/Documents/Cline/Hooks/`
23
+
24
+ (or the equivalent Hooks path on your machine). Cline runs each hook with stdin JSON from its Hooks API.
25
+
26
+ ## Config file
27
+
28
+ Path: **`~/.multicorn/config.json`**
29
+
30
+ | Field | Required | Description |
31
+ | --------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
32
+ | `apiKey` | yes | Multicorn API key used in the Shield request (`X-Multicorn-Key`). |
33
+ | `baseUrl` | no | API root. Defaults to `https://api.multicorn.ai` (no trailing slash). Non-local URLs must use `https://` or the hooks disable Shield (fail-open). `localhost` / `127.0.0.1` may use HTTP. |
34
+ | `agents` | no | Array of `{ "name": "...", "platform": "cline" }` objects so the hooks know which Shield agent name to use. Legacy `agentName` string is still read if present. |
35
+
36
+ Example:
37
+
38
+ ```json
39
+ {
40
+ "apiKey": "mcs_your_key_here",
41
+ "baseUrl": "https://api.multicorn.ai",
42
+ "agents": [{ "name": "my-repo-agent", "platform": "cline" }]
43
+ }
44
+ ```
45
+
46
+ ## How it works
47
+
48
+ 1. **PreToolUse:** before Cline runs a tool, stdin carries the pending request. The script maps the tool name to a Shield **service** and **actionType**, POSTs `status: pending` to `/api/v1/actions`, and reads the response. It either allows the tool (`cancel: false`) or blocks with an error message and optional consent workflow.
49
+ 2. **PostToolUse:** after the tool completes, stdin includes the outcome. The script POSTs `status: approved` with metadata (scrubbed parameters and result) so the audit log stays usable without stuffing large secrets into the payload.
50
+
51
+ Both hooks reply with JSON on stdout. The post-hook always finishes with `{ "cancel": false }`.
52
+
53
+ ## Security model
54
+
55
+ Shield wiring is **fail-open by design**. If config is missing, invalid for remote HTTP, or the API errors or times out, **actions are allowed** so work is not silently blocked because Shield is down. Deployers treat Shield as governance and auditing, not as a cryptographic boundary against a hostile process on the same machine.
56
+
57
+ ## Troubleshooting
58
+
59
+ 1. **Confirm hooks run:** temporarily add stderr output (not recommended long term), or tail Cline's hook output/logs if exposed. Successful runs should not spam the developer console unless there is an API warning.
60
+ 2. **Nothing reaches Shield:** check `config.json` path and `apiKey`, that `agents` includes `platform: "cline"` with the right `name`, and that `baseUrl` uses HTTPS on non-local installs.
61
+ 3. **Windows consent / browser:** the pre-hook opens the consent URL via `cmd.exe start`; if that fails, open the URL from the blocking message manually.
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+ // Copyright (c) Multicorn AI Pty Ltd. MIT License. See LICENSE file.
3
+ /**
4
+ * Cline PostToolUse hook: logs completed actions to the Shield audit trail.
5
+ * Reads JSON from stdin (Cline Hooks API), posts to Shield API.
6
+ * Always returns {"cancel": false} - post hooks never block.
7
+ */
8
+
9
+ "use strict";
10
+
11
+ const {
12
+ buildScrubbedParametersJson,
13
+ loadConfig,
14
+ logPrefix,
15
+ mapToolName,
16
+ postJson,
17
+ readStdin,
18
+ scrubResultForMetadata,
19
+ } = require("./shared.cjs");
20
+
21
+ const HOOK_PREFIX = logPrefix("post-hook");
22
+
23
+ /**
24
+ * Outputs JSON response to stdout and exits.
25
+ */
26
+ function respond() {
27
+ process.stdout.write(JSON.stringify({ cancel: false }) + "\n");
28
+ process.exit(0);
29
+ }
30
+
31
+ async function main() {
32
+ let raw;
33
+ try {
34
+ raw = await readStdin();
35
+ } catch {
36
+ respond();
37
+ return;
38
+ }
39
+
40
+ const config = loadConfig();
41
+ if (config === null || config.apiKey.length === 0 || config.agentName.length === 0) {
42
+ respond();
43
+ return;
44
+ }
45
+
46
+ /** @type {Record<string, unknown>} */
47
+ let hookPayload;
48
+ try {
49
+ hookPayload = JSON.parse(raw.length > 0 ? raw : "{}");
50
+ } catch {
51
+ respond();
52
+ return;
53
+ }
54
+
55
+ const postToolUse = hookPayload.postToolUse;
56
+ if (postToolUse === null || typeof postToolUse !== "object") {
57
+ respond();
58
+ return;
59
+ }
60
+
61
+ const toolUse = /** @type {Record<string, unknown>} */ (postToolUse);
62
+ const toolName = typeof toolUse.tool === "string" ? toolUse.tool : "";
63
+
64
+ if (toolName.length === 0) {
65
+ respond();
66
+ return;
67
+ }
68
+
69
+ const { service, actionType } = mapToolName(toolName);
70
+
71
+ const paramsSerialized = buildScrubbedParametersJson(toolUse.parameters);
72
+
73
+ /** @type {Record<string, unknown>} */
74
+ const metadata = {
75
+ tool_name: toolName,
76
+ task_id: typeof hookPayload.taskId === "string" ? hookPayload.taskId : "",
77
+ cline_version: typeof hookPayload.clineVersion === "string" ? hookPayload.clineVersion : "",
78
+ parameters: paramsSerialized,
79
+ result: scrubResultForMetadata(toolUse.result),
80
+ timing: typeof toolUse.timing === "object" ? JSON.stringify(toolUse.timing) : "",
81
+ source: "cline",
82
+ };
83
+
84
+ /** @type {Record<string, unknown>} */
85
+ const payload = {
86
+ agent: config.agentName,
87
+ service,
88
+ actionType,
89
+ status: "approved",
90
+ metadata,
91
+ platform: "cline",
92
+ };
93
+
94
+ try {
95
+ const res = await postJson(config.baseUrl, config.apiKey, payload);
96
+ const code = res.statusCode ?? 0;
97
+ if (code < 200 || code >= 300) {
98
+ throw new Error(`HTTP ${String(code)}`);
99
+ }
100
+ } catch (e) {
101
+ const msg = e instanceof Error ? e.message : String(e);
102
+ process.stderr.write(
103
+ `${HOOK_PREFIX} Warning: failed to log action to Shield audit trail. Detail: ${msg}\n`,
104
+ );
105
+ }
106
+
107
+ respond();
108
+ }
109
+
110
+ main().catch((e) => {
111
+ const msg = e instanceof Error ? e.message : String(e);
112
+ process.stderr.write(
113
+ `${HOOK_PREFIX} Warning: failed to log action to Shield audit trail. Detail: ${msg}\n`,
114
+ );
115
+ respond();
116
+ });