multicorn-shield 0.11.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.
- package/CHANGELOG.md +30 -0
- package/dist/badge.js +4 -4
- package/dist/index.cjs +25 -19
- package/dist/index.d.cts +5 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.js +24 -19
- package/dist/multicorn-proxy.js +181 -9
- package/dist/openclaw-hook/handler.js +0 -1
- package/dist/openclaw-plugin/multicorn-shield.js +11 -17
- package/dist/openclaw-plugin/openclaw.plugin.json +3 -1
- package/dist/proxy.cjs +174 -0
- package/dist/proxy.d.cts +228 -1
- package/dist/proxy.d.ts +228 -1
- package/dist/proxy.js +174 -1
- package/dist/shield-extension.js +1 -4
- package/package.json +3 -2
- package/plugins/cline/README.md +61 -0
- package/plugins/cline/hooks/scripts/post-tool-use.cjs +116 -0
- package/plugins/cline/hooks/scripts/pre-tool-use.cjs +271 -0
- package/plugins/cline/hooks/scripts/shared.cjs +303 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// Copyright (c) Multicorn AI Pty Ltd. MIT License. See LICENSE file.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Shared helpers for Cline PreToolUse / PostToolUse Shield hooks.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
"use strict";
|
|
8
|
+
|
|
9
|
+
const fs = require("node:fs");
|
|
10
|
+
const http = require("node:http");
|
|
11
|
+
const https = require("node:https");
|
|
12
|
+
const os = require("node:os");
|
|
13
|
+
const path = require("node:path");
|
|
14
|
+
|
|
15
|
+
const AUTH_HEADER = "X-Multicorn-Key";
|
|
16
|
+
const HTTP_REQUEST_TIMEOUT_MS = 10000;
|
|
17
|
+
|
|
18
|
+
/** @type {Readonly<Record<string, { service: string; actionType: string }>>} */
|
|
19
|
+
const TOOL_MAP = {
|
|
20
|
+
read_file: { service: "filesystem", actionType: "read" },
|
|
21
|
+
write_to_file: { service: "filesystem", actionType: "write" },
|
|
22
|
+
replace_in_file: { service: "filesystem", actionType: "write" },
|
|
23
|
+
execute_command: { service: "terminal", actionType: "execute" },
|
|
24
|
+
browser_action: { service: "browser", actionType: "execute" },
|
|
25
|
+
list_files: { service: "filesystem", actionType: "read" },
|
|
26
|
+
search_files: { service: "filesystem", actionType: "read" },
|
|
27
|
+
list_code_definition_names: { service: "filesystem", actionType: "read" },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Builds the stderr log line prefix for a hook segment.
|
|
32
|
+
* @param {string} label e.g. "pre-hook", "post-hook", "config"
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
function logPrefix(label) {
|
|
36
|
+
return `[multicorn-shield] Cline ${label}:`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @returns {Promise<string>}
|
|
41
|
+
*/
|
|
42
|
+
function readStdin() {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const chunks = [];
|
|
45
|
+
process.stdin.setEncoding("utf8");
|
|
46
|
+
process.stdin.on("data", (c) => chunks.push(c));
|
|
47
|
+
process.stdin.on("end", () => resolve(chunks.join("")));
|
|
48
|
+
process.stdin.on("error", reject);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {Record<string, unknown>} obj
|
|
54
|
+
* @returns {string}
|
|
55
|
+
*/
|
|
56
|
+
function resolveClineAgentName(obj) {
|
|
57
|
+
const agents = obj.agents;
|
|
58
|
+
if (Array.isArray(agents)) {
|
|
59
|
+
for (const entry of agents) {
|
|
60
|
+
if (
|
|
61
|
+
entry &&
|
|
62
|
+
typeof entry === "object" &&
|
|
63
|
+
/** @type {{ platform?: string; name?: string }} */ (entry).platform === "cline" &&
|
|
64
|
+
typeof (/** @type {{ platform?: string; name?: string }} */ (entry).name) === "string"
|
|
65
|
+
) {
|
|
66
|
+
return /** @type {{ name: string }} */ (entry).name;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return typeof obj.agentName === "string" ? obj.agentName : "";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Reads ~/.multicorn/config.json. Rejects non-HTTPS remote baseUrl (fail-open).
|
|
75
|
+
* @returns {{ apiKey: string; baseUrl: string; agentName: string } | null}
|
|
76
|
+
* @example
|
|
77
|
+
* const cfg = loadConfig();
|
|
78
|
+
* if (cfg && cfg.apiKey) { await postJson(cfg.baseUrl, cfg.apiKey, body); }
|
|
79
|
+
*/
|
|
80
|
+
function loadConfig() {
|
|
81
|
+
try {
|
|
82
|
+
const configPath = path.join(os.homedir(), ".multicorn", "config.json");
|
|
83
|
+
const raw = fs.readFileSync(configPath, "utf8");
|
|
84
|
+
const obj = JSON.parse(raw);
|
|
85
|
+
const apiKey = typeof obj.apiKey === "string" ? obj.apiKey : "";
|
|
86
|
+
const baseUrl =
|
|
87
|
+
typeof obj.baseUrl === "string" && obj.baseUrl.length > 0
|
|
88
|
+
? obj.baseUrl.replace(/\/+$/, "")
|
|
89
|
+
: "https://api.multicorn.ai";
|
|
90
|
+
const baseLower = baseUrl.toLowerCase();
|
|
91
|
+
const isHttps = baseLower.startsWith("https://");
|
|
92
|
+
const isLocal = baseLower.includes("localhost") || baseLower.includes("127.0.0.1");
|
|
93
|
+
if (!isHttps && !isLocal) {
|
|
94
|
+
process.stderr.write(
|
|
95
|
+
`${logPrefix("config")} baseUrl must use HTTPS for non-local servers. Fail-open: Shield disabled.\n`,
|
|
96
|
+
);
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
const agentName = resolveClineAgentName(obj);
|
|
100
|
+
return { apiKey, baseUrl, agentName };
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Maps a Cline tool name to a Shield service/actionType pair.
|
|
108
|
+
* MCP tools (prefixed mcp_ or containing __) map to `mcp:server.tool`.
|
|
109
|
+
* @param {string} toolName
|
|
110
|
+
* @returns {{ service: string; actionType: string }}
|
|
111
|
+
* @example
|
|
112
|
+
* mapToolName("read_file"); // { service: "filesystem", actionType: "read" }
|
|
113
|
+
* mapToolName("mcp_foo__bar"); // { service: "mcp:foo.bar", actionType: "execute" }
|
|
114
|
+
*/
|
|
115
|
+
function mapToolName(toolName) {
|
|
116
|
+
const name = String(toolName || "").trim();
|
|
117
|
+
|
|
118
|
+
if (name.startsWith("mcp_") || name.includes("__")) {
|
|
119
|
+
const parts = name.startsWith("mcp_") ? name.slice(4) : name;
|
|
120
|
+
const sepIdx = parts.indexOf("__");
|
|
121
|
+
if (sepIdx > 0) {
|
|
122
|
+
const server = parts.slice(0, sepIdx).replace(/[^a-zA-Z0-9._-]+/g, "_");
|
|
123
|
+
const tool = parts.slice(sepIdx + 2).replace(/[^a-zA-Z0-9._-]+/g, "_");
|
|
124
|
+
return { service: `mcp:${server}.${tool}`, actionType: "execute" };
|
|
125
|
+
}
|
|
126
|
+
const safe = parts.replace(/[^a-zA-Z0-9._-]+/g, "_");
|
|
127
|
+
return { service: `mcp:${safe}`, actionType: "execute" };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const mapped = TOOL_MAP[name];
|
|
131
|
+
if (mapped !== undefined) {
|
|
132
|
+
return mapped;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { service: "unknown", actionType: "execute" };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* POST JSON to /api/v1/actions; returns status and raw body text.
|
|
140
|
+
* @param {string} baseUrl
|
|
141
|
+
* @param {string} apiKey
|
|
142
|
+
* @param {Record<string, unknown>} bodyObj
|
|
143
|
+
* @returns {Promise<{ statusCode: number; bodyText: string }>}
|
|
144
|
+
* @example
|
|
145
|
+
* const { statusCode, bodyText } = await postJson(
|
|
146
|
+
* "https://api.multicorn.ai",
|
|
147
|
+
* apiKey,
|
|
148
|
+
* { agent: "my-agent", service: "filesystem", actionType: "read", status: "pending", metadata: {}, platform: "cline" },
|
|
149
|
+
* );
|
|
150
|
+
*/
|
|
151
|
+
function postJson(baseUrl, apiKey, bodyObj) {
|
|
152
|
+
return new Promise((resolve, reject) => {
|
|
153
|
+
let u;
|
|
154
|
+
try {
|
|
155
|
+
const root = String(baseUrl).replace(/\/+$/, "");
|
|
156
|
+
u = new URL(`${root}/api/v1/actions`);
|
|
157
|
+
} catch (e) {
|
|
158
|
+
reject(e);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const payload = JSON.stringify(bodyObj);
|
|
162
|
+
const isHttps = u.protocol === "https:";
|
|
163
|
+
const lib = isHttps ? https : http;
|
|
164
|
+
const port = u.port || (isHttps ? 443 : 80);
|
|
165
|
+
const options = {
|
|
166
|
+
hostname: u.hostname,
|
|
167
|
+
port,
|
|
168
|
+
path: u.pathname + u.search,
|
|
169
|
+
method: "POST",
|
|
170
|
+
headers: {
|
|
171
|
+
Connection: "close",
|
|
172
|
+
"Content-Type": "application/json",
|
|
173
|
+
"Content-Length": Buffer.byteLength(payload, "utf8"),
|
|
174
|
+
[AUTH_HEADER]: apiKey,
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
const req = lib.request(options, (res) => {
|
|
178
|
+
const chunks = [];
|
|
179
|
+
res.on("data", (c) => chunks.push(c));
|
|
180
|
+
res.on("end", () => {
|
|
181
|
+
resolve({
|
|
182
|
+
statusCode: res.statusCode ?? 0,
|
|
183
|
+
bodyText: Buffer.concat(chunks).toString("utf8"),
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
req.setTimeout(HTTP_REQUEST_TIMEOUT_MS, () => {
|
|
188
|
+
req.destroy(new Error("request timeout"));
|
|
189
|
+
});
|
|
190
|
+
req.on("error", reject);
|
|
191
|
+
req.write(payload);
|
|
192
|
+
req.end();
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* @param {string} text
|
|
198
|
+
* @returns {unknown}
|
|
199
|
+
*/
|
|
200
|
+
function safeJsonParse(text) {
|
|
201
|
+
try {
|
|
202
|
+
return JSON.parse(text);
|
|
203
|
+
} catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* @param {unknown} body
|
|
210
|
+
* @returns {unknown}
|
|
211
|
+
*/
|
|
212
|
+
function unwrapData(body) {
|
|
213
|
+
if (typeof body !== "object" || body === null) return null;
|
|
214
|
+
const o = /** @type {Record<string, unknown>} */ (body);
|
|
215
|
+
return o.success === true ? o.data : null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Redact oversized or sensitive-ish command text for metadata.
|
|
220
|
+
* @param {Record<string, unknown>} parameters
|
|
221
|
+
*/
|
|
222
|
+
function applyParameterScrub(parameters) {
|
|
223
|
+
const scrubbedParams = { ...parameters };
|
|
224
|
+
if (typeof scrubbedParams.content === "string") {
|
|
225
|
+
scrubbedParams.content = `[${scrubbedParams.content.length} chars redacted]`;
|
|
226
|
+
}
|
|
227
|
+
if (typeof scrubbedParams.command === "string" && scrubbedParams.command.length > 200) {
|
|
228
|
+
scrubbedParams.command = scrubbedParams.command.slice(0, 200) + "... [truncated]";
|
|
229
|
+
}
|
|
230
|
+
return scrubbedParams;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Normalizes hook parameters (object or JSON string) and returns scrubbed JSON string.
|
|
235
|
+
* @param {unknown} parameters
|
|
236
|
+
* @param {number} [maxLen]
|
|
237
|
+
* @returns {string}
|
|
238
|
+
*/
|
|
239
|
+
function buildScrubbedParametersJson(parameters, maxLen = 4096) {
|
|
240
|
+
/** @type {Record<string, unknown>} */
|
|
241
|
+
let base = {};
|
|
242
|
+
if (typeof parameters === "object" && parameters !== null) {
|
|
243
|
+
base = { .../** @type {Record<string, unknown>} */ (parameters) };
|
|
244
|
+
} else if (typeof parameters === "string") {
|
|
245
|
+
try {
|
|
246
|
+
const p = JSON.parse(parameters);
|
|
247
|
+
if (p !== null && typeof p === "object") {
|
|
248
|
+
base = { .../** @type {Record<string, unknown>} */ (p) };
|
|
249
|
+
} else {
|
|
250
|
+
base = { raw: parameters };
|
|
251
|
+
}
|
|
252
|
+
} catch {
|
|
253
|
+
base = { raw: parameters };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const scrubbed = applyParameterScrub(base);
|
|
258
|
+
let paramsSerialized;
|
|
259
|
+
try {
|
|
260
|
+
paramsSerialized = JSON.stringify(scrubbed);
|
|
261
|
+
} catch {
|
|
262
|
+
paramsSerialized = "{}";
|
|
263
|
+
}
|
|
264
|
+
if (paramsSerialized.length > maxLen) {
|
|
265
|
+
paramsSerialized = paramsSerialized.slice(0, maxLen);
|
|
266
|
+
}
|
|
267
|
+
return paramsSerialized;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Strip likely secrets from tool result strings for audit logging.
|
|
272
|
+
* @param {unknown} result
|
|
273
|
+
* @returns {string}
|
|
274
|
+
*/
|
|
275
|
+
function scrubResultForMetadata(result) {
|
|
276
|
+
if (typeof result !== "string") return "";
|
|
277
|
+
let s = result;
|
|
278
|
+
s = s.replace(/\bsk-[A-Za-z0-9_-]{8,}\b/g, "[REDACTED]");
|
|
279
|
+
s = s.replace(/\bmcs_[A-Za-z0-9_-]+\b/g, "[REDACTED]");
|
|
280
|
+
s = s.replace(/\bghp_[A-Za-z0-9]{20,}\b/g, "[REDACTED]");
|
|
281
|
+
s = s.replace(/Bearer\s+[^\s]+/gi, "[REDACTED]");
|
|
282
|
+
s = s.replace(/\b(password|token)\s*[:=]\s*[^\s]+\b/gi, "[REDACTED]");
|
|
283
|
+
if (s.length > 500) {
|
|
284
|
+
s = s.slice(0, 500) + "[truncated]";
|
|
285
|
+
}
|
|
286
|
+
return s;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
module.exports = {
|
|
290
|
+
AUTH_HEADER,
|
|
291
|
+
logPrefix,
|
|
292
|
+
HTTP_REQUEST_TIMEOUT_MS,
|
|
293
|
+
TOOL_MAP,
|
|
294
|
+
readStdin,
|
|
295
|
+
loadConfig,
|
|
296
|
+
resolveClineAgentName,
|
|
297
|
+
mapToolName,
|
|
298
|
+
postJson,
|
|
299
|
+
safeJsonParse,
|
|
300
|
+
unwrapData,
|
|
301
|
+
buildScrubbedParametersJson,
|
|
302
|
+
scrubResultForMetadata,
|
|
303
|
+
};
|