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.
@@ -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
+ };