multicorn-shield 0.8.0 → 0.10.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 +365 -0
- package/LICENSE +1 -1
- package/README.md +29 -1
- package/dist/index.cjs +295 -27
- package/dist/index.d.cts +105 -1
- package/dist/index.d.ts +105 -1
- package/dist/index.js +293 -28
- package/dist/multicorn-proxy.js +190 -6
- package/dist/multicorn-shield.js +1 -0
- package/dist/shield-extension.js +2 -1
- package/package.json +9 -2
- package/plugins/windsurf/README.md +54 -0
- package/plugins/windsurf/hooks/scripts/post-action.cjs +245 -0
- package/plugins/windsurf/hooks/scripts/pre-action.cjs +646 -0
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windsurf Cascade pre-hook: permission check before read, write, terminal, or MCP tool use.
|
|
3
|
+
* Routes by stdin JSON field agent_action_name (see Windsurf Cascade Hooks docs).
|
|
4
|
+
* Fail-closed on API errors once config is loaded. Fail-open if Shield is not configured.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
"use strict";
|
|
8
|
+
|
|
9
|
+
const { execFileSync, execSync } = require("node:child_process");
|
|
10
|
+
const fs = require("node:fs");
|
|
11
|
+
const http = require("node:http");
|
|
12
|
+
const https = require("node:https");
|
|
13
|
+
const os = require("node:os");
|
|
14
|
+
const path = require("node:path");
|
|
15
|
+
|
|
16
|
+
const AUTH_HEADER = "X-Multicorn-Key";
|
|
17
|
+
const LOG_PREFIX = "[multicorn-shield] Windsurf pre-hook:";
|
|
18
|
+
const HOOK_TEST_FAST_POLL = process.env.MULTICORN_SHIELD_WINDSURF_PRE_HOOK_TEST_FAST_POLL === "1";
|
|
19
|
+
const POLL_INTERVAL_MS = HOOK_TEST_FAST_POLL ? 1 : 3000;
|
|
20
|
+
const MAX_APPROVAL_POLLS = HOOK_TEST_FAST_POLL ? 3 : 100;
|
|
21
|
+
const HTTP_REQUEST_TIMEOUT_MS = HOOK_TEST_FAST_POLL ? 100 : 10000;
|
|
22
|
+
|
|
23
|
+
/** @type {Readonly<Record<string, { service: string; actionType: string }>>} */
|
|
24
|
+
const PRE_EVENT_MAP = {
|
|
25
|
+
pre_read_code: { service: "filesystem", actionType: "read" },
|
|
26
|
+
pre_write_code: { service: "filesystem", actionType: "write" },
|
|
27
|
+
pre_run_command: { service: "terminal", actionType: "execute" },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @returns {Promise<string>}
|
|
32
|
+
*/
|
|
33
|
+
function readStdin() {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const chunks = [];
|
|
36
|
+
process.stdin.setEncoding("utf8");
|
|
37
|
+
process.stdin.on("data", (c) => chunks.push(c));
|
|
38
|
+
process.stdin.on("end", () => resolve(chunks.join("")));
|
|
39
|
+
process.stdin.on("error", reject);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Duplicated in post-action.cjs. CJS hooks cannot import shared TypeScript modules.
|
|
44
|
+
/**
|
|
45
|
+
* @param {Record<string, unknown>} obj
|
|
46
|
+
* @returns {string}
|
|
47
|
+
*/
|
|
48
|
+
function resolveWindsurfAgentName(obj) {
|
|
49
|
+
const agents = obj.agents;
|
|
50
|
+
if (Array.isArray(agents)) {
|
|
51
|
+
for (const entry of agents) {
|
|
52
|
+
if (
|
|
53
|
+
entry &&
|
|
54
|
+
typeof entry === "object" &&
|
|
55
|
+
/** @type {{ platform?: string; name?: string }} */ (entry).platform === "windsurf" &&
|
|
56
|
+
typeof (/** @type {{ platform?: string; name?: string }} */ (entry).name) === "string"
|
|
57
|
+
) {
|
|
58
|
+
return /** @type {{ name: string }} */ (entry).name;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return typeof obj.agentName === "string" ? obj.agentName : "";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @returns {{ apiKey: string; baseUrl: string; agentName: string } | null}
|
|
67
|
+
*/
|
|
68
|
+
function loadConfig() {
|
|
69
|
+
try {
|
|
70
|
+
const configPath = path.join(os.homedir(), ".multicorn", "config.json");
|
|
71
|
+
const raw = fs.readFileSync(configPath, "utf8");
|
|
72
|
+
const obj = JSON.parse(raw);
|
|
73
|
+
const apiKey = typeof obj.apiKey === "string" ? obj.apiKey : "";
|
|
74
|
+
const baseUrl =
|
|
75
|
+
typeof obj.baseUrl === "string" && obj.baseUrl.length > 0
|
|
76
|
+
? obj.baseUrl.replace(/\/+$/, "")
|
|
77
|
+
: "https://api.multicorn.ai";
|
|
78
|
+
const agentName = resolveWindsurfAgentName(obj);
|
|
79
|
+
return { apiKey, baseUrl, agentName };
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {string} apiBaseUrl
|
|
87
|
+
* @returns {string}
|
|
88
|
+
*/
|
|
89
|
+
function dashboardOrigin(apiBaseUrl) {
|
|
90
|
+
try {
|
|
91
|
+
const raw = String(apiBaseUrl).replace(/\/+$/, "");
|
|
92
|
+
const lower = raw.toLowerCase();
|
|
93
|
+
if (lower.includes("localhost:8080") || lower.includes("127.0.0.1:8080")) {
|
|
94
|
+
return "http://localhost:5173";
|
|
95
|
+
}
|
|
96
|
+
const u = new URL(raw);
|
|
97
|
+
if (u.hostname.startsWith("api.")) {
|
|
98
|
+
u.hostname = "app." + u.hostname.slice(4);
|
|
99
|
+
}
|
|
100
|
+
return u.origin;
|
|
101
|
+
} catch {
|
|
102
|
+
return "https://app.multicorn.ai";
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* @param {string} apiBaseUrl
|
|
108
|
+
* @returns {string}
|
|
109
|
+
*/
|
|
110
|
+
function dashboardHintUrl(apiBaseUrl) {
|
|
111
|
+
return `${dashboardOrigin(apiBaseUrl)}/approvals`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @param {string} apiBaseUrl
|
|
116
|
+
* @param {string} agentName
|
|
117
|
+
* @param {string} service
|
|
118
|
+
* @param {string} actionType
|
|
119
|
+
* @returns {string}
|
|
120
|
+
*/
|
|
121
|
+
function consentUrl(apiBaseUrl, agentName, service, actionType) {
|
|
122
|
+
const origin = dashboardOrigin(apiBaseUrl);
|
|
123
|
+
const params = new URLSearchParams();
|
|
124
|
+
params.set("agent", agentName);
|
|
125
|
+
params.set("scopes", `${service}:${actionType}`);
|
|
126
|
+
params.set("platform", "windsurf");
|
|
127
|
+
return `${origin}/consent?${params.toString()}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* @param {unknown} toolInfo
|
|
132
|
+
* @returns {{ service: string; actionType: string }}
|
|
133
|
+
*/
|
|
134
|
+
function mapMcpPre(toolInfo) {
|
|
135
|
+
if (toolInfo === null || typeof toolInfo !== "object") {
|
|
136
|
+
return { service: "mcp", actionType: "execute" };
|
|
137
|
+
}
|
|
138
|
+
const t = /** @type {Record<string, unknown>} */ (toolInfo);
|
|
139
|
+
const server = String(t.mcp_server_name ?? "unknown").trim() || "unknown";
|
|
140
|
+
const tool = String(t.mcp_tool_name ?? "unknown").trim() || "unknown";
|
|
141
|
+
const safeServer = server.replace(/[^a-zA-Z0-9._-]+/g, "_");
|
|
142
|
+
const safeTool = tool.replace(/[^a-zA-Z0-9._-]+/g, "_");
|
|
143
|
+
return { service: `mcp:${safeServer}.${safeTool}`, actionType: "execute" };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* @param {string} agentActionName
|
|
148
|
+
* @param {unknown} toolInfo
|
|
149
|
+
* @returns {{ service: string; actionType: string } | null}
|
|
150
|
+
*/
|
|
151
|
+
function mapPreEvent(agentActionName, toolInfo) {
|
|
152
|
+
const name = String(agentActionName || "").trim();
|
|
153
|
+
if (name === "pre_mcp_tool_use") {
|
|
154
|
+
return mapMcpPre(toolInfo);
|
|
155
|
+
}
|
|
156
|
+
const mapped = PRE_EVENT_MAP[name];
|
|
157
|
+
if (mapped !== undefined) {
|
|
158
|
+
return mapped;
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* @param {string} baseUrl
|
|
165
|
+
* @param {string} apiKey
|
|
166
|
+
* @param {string} reqPath
|
|
167
|
+
* @returns {Promise<{ statusCode: number; bodyText: string }>}
|
|
168
|
+
*/
|
|
169
|
+
function getJson(baseUrl, apiKey, reqPath) {
|
|
170
|
+
return new Promise((resolve, reject) => {
|
|
171
|
+
let u;
|
|
172
|
+
try {
|
|
173
|
+
const root = String(baseUrl).replace(/\/+$/, "");
|
|
174
|
+
const p = reqPath.startsWith("/") ? reqPath : `/${reqPath}`;
|
|
175
|
+
u = new URL(`${root}${p}`);
|
|
176
|
+
} catch (e) {
|
|
177
|
+
reject(e);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const isHttps = u.protocol === "https:";
|
|
181
|
+
const lib = isHttps ? https : http;
|
|
182
|
+
const port = u.port || (isHttps ? 443 : 80);
|
|
183
|
+
const options = {
|
|
184
|
+
hostname: u.hostname,
|
|
185
|
+
port,
|
|
186
|
+
path: u.pathname + u.search,
|
|
187
|
+
method: "GET",
|
|
188
|
+
headers: {
|
|
189
|
+
Connection: "close",
|
|
190
|
+
[AUTH_HEADER]: apiKey,
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
const req = lib.request(options, (res) => {
|
|
194
|
+
const chunks = [];
|
|
195
|
+
res.on("data", (c) => chunks.push(c));
|
|
196
|
+
res.on("end", () => {
|
|
197
|
+
resolve({
|
|
198
|
+
statusCode: res.statusCode ?? 0,
|
|
199
|
+
bodyText: Buffer.concat(chunks).toString("utf8"),
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
req.setTimeout(HTTP_REQUEST_TIMEOUT_MS, () => {
|
|
204
|
+
req.destroy(new Error("request timeout"));
|
|
205
|
+
});
|
|
206
|
+
req.on("error", reject);
|
|
207
|
+
req.end();
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* @param {string} baseUrl
|
|
213
|
+
* @param {string} apiKey
|
|
214
|
+
* @param {Record<string, unknown>} bodyObj
|
|
215
|
+
* @returns {Promise<{ statusCode: number; bodyText: string }>}
|
|
216
|
+
*/
|
|
217
|
+
function postJson(baseUrl, apiKey, bodyObj) {
|
|
218
|
+
return new Promise((resolve, reject) => {
|
|
219
|
+
let u;
|
|
220
|
+
try {
|
|
221
|
+
const root = String(baseUrl).replace(/\/+$/, "");
|
|
222
|
+
u = new URL(`${root}/api/v1/actions`);
|
|
223
|
+
} catch (e) {
|
|
224
|
+
reject(e);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const payload = JSON.stringify(bodyObj);
|
|
228
|
+
const isHttps = u.protocol === "https:";
|
|
229
|
+
const lib = isHttps ? https : http;
|
|
230
|
+
const port = u.port || (isHttps ? 443 : 80);
|
|
231
|
+
const options = {
|
|
232
|
+
hostname: u.hostname,
|
|
233
|
+
port,
|
|
234
|
+
path: u.pathname + u.search,
|
|
235
|
+
method: "POST",
|
|
236
|
+
headers: {
|
|
237
|
+
Connection: "close",
|
|
238
|
+
"Content-Type": "application/json",
|
|
239
|
+
"Content-Length": Buffer.byteLength(payload, "utf8"),
|
|
240
|
+
[AUTH_HEADER]: apiKey,
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
const req = lib.request(options, (res) => {
|
|
244
|
+
const chunks = [];
|
|
245
|
+
res.on("data", (c) => chunks.push(c));
|
|
246
|
+
res.on("end", () => {
|
|
247
|
+
resolve({
|
|
248
|
+
statusCode: res.statusCode ?? 0,
|
|
249
|
+
bodyText: Buffer.concat(chunks).toString("utf8"),
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
req.setTimeout(HTTP_REQUEST_TIMEOUT_MS, () => {
|
|
254
|
+
req.destroy(new Error("request timeout"));
|
|
255
|
+
});
|
|
256
|
+
req.on("error", reject);
|
|
257
|
+
req.write(payload);
|
|
258
|
+
req.end();
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* @param {string} text
|
|
264
|
+
* @returns {unknown}
|
|
265
|
+
*/
|
|
266
|
+
function safeJsonParse(text) {
|
|
267
|
+
try {
|
|
268
|
+
return JSON.parse(text);
|
|
269
|
+
} catch {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* @param {unknown} body
|
|
276
|
+
* @returns {unknown}
|
|
277
|
+
*/
|
|
278
|
+
function unwrapData(body) {
|
|
279
|
+
if (typeof body !== "object" || body === null) return null;
|
|
280
|
+
const o = /** @type {Record<string, unknown>} */ (body);
|
|
281
|
+
return o.success === true ? o.data : null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* @param {unknown} data
|
|
286
|
+
* @param {string} service
|
|
287
|
+
* @param {string} actionType
|
|
288
|
+
* @param {string} approvalsUrl
|
|
289
|
+
* @returns {string}
|
|
290
|
+
*/
|
|
291
|
+
function blockedMessage(data, service, actionType, approvalsUrl) {
|
|
292
|
+
if (data !== null && typeof data === "object") {
|
|
293
|
+
const d = /** @type {Record<string, unknown>} */ (data);
|
|
294
|
+
const meta = d.metadata;
|
|
295
|
+
if (typeof meta === "string" && meta.length > 0) {
|
|
296
|
+
try {
|
|
297
|
+
const parsed = JSON.parse(meta);
|
|
298
|
+
if (parsed !== null && typeof parsed === "object" && "block_reason" in parsed) {
|
|
299
|
+
const br = /** @type {Record<string, unknown>} */ (parsed).block_reason;
|
|
300
|
+
if (typeof br === "string" && br.length > 0) {
|
|
301
|
+
return (
|
|
302
|
+
`${LOG_PREFIX} Action blocked: ${br}\n` +
|
|
303
|
+
` Grant access in the Shield dashboard and retry.\n` +
|
|
304
|
+
` Detail: ${approvalsUrl}\n`
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
} catch {
|
|
309
|
+
/* ignore */
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return (
|
|
314
|
+
`${LOG_PREFIX} Action blocked: Multicorn Shield blocked this action. Required permission: ${service} (${actionType}).\n` +
|
|
315
|
+
` Grant access in the Shield dashboard and retry.\n` +
|
|
316
|
+
` Detail: ${approvalsUrl}\n`
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* @param {string} agentName
|
|
322
|
+
* @returns {string}
|
|
323
|
+
*/
|
|
324
|
+
function consentMarkerPath(agentName) {
|
|
325
|
+
const safe = agentName.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
326
|
+
return path.join(os.homedir(), ".multicorn", `.consent-windsurf-${safe}`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* @param {string} agentName
|
|
331
|
+
* @returns {boolean}
|
|
332
|
+
*/
|
|
333
|
+
function hasConsentMarker(agentName) {
|
|
334
|
+
try {
|
|
335
|
+
fs.accessSync(consentMarkerPath(agentName));
|
|
336
|
+
return true;
|
|
337
|
+
} catch {
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* @param {string} agentName
|
|
344
|
+
*/
|
|
345
|
+
function writeConsentMarker(agentName) {
|
|
346
|
+
try {
|
|
347
|
+
const marker = consentMarkerPath(agentName);
|
|
348
|
+
fs.mkdirSync(path.dirname(marker), { recursive: true });
|
|
349
|
+
fs.writeFileSync(marker, String(Date.now()), "utf8");
|
|
350
|
+
} catch {
|
|
351
|
+
/* ignore */
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* @param {string} url
|
|
357
|
+
*/
|
|
358
|
+
function openBrowser(url) {
|
|
359
|
+
try {
|
|
360
|
+
if (process.platform === "win32") {
|
|
361
|
+
execSync(`start "" ${JSON.stringify(url)}`, {
|
|
362
|
+
shell: true,
|
|
363
|
+
stdio: "ignore",
|
|
364
|
+
windowsHide: true,
|
|
365
|
+
});
|
|
366
|
+
} else if (process.platform === "darwin") {
|
|
367
|
+
execFileSync("open", [url], { stdio: "ignore" });
|
|
368
|
+
} else {
|
|
369
|
+
execFileSync("xdg-open", [url], { stdio: "ignore" });
|
|
370
|
+
}
|
|
371
|
+
} catch {
|
|
372
|
+
/* ignore */
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* @param {number} ms
|
|
378
|
+
* @returns {Promise<void>}
|
|
379
|
+
*/
|
|
380
|
+
function sleep(ms) {
|
|
381
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* @param {{ apiKey: string; baseUrl: string; agentName: string }} config
|
|
386
|
+
* @param {string} approvalId
|
|
387
|
+
* @param {string} service
|
|
388
|
+
* @param {string} actionType
|
|
389
|
+
* @param {string} approvalsUrl
|
|
390
|
+
* @returns {Promise<void>}
|
|
391
|
+
*/
|
|
392
|
+
async function handlePendingWithConsentAndPoll(
|
|
393
|
+
config,
|
|
394
|
+
approvalId,
|
|
395
|
+
service,
|
|
396
|
+
actionType,
|
|
397
|
+
approvalsUrl,
|
|
398
|
+
) {
|
|
399
|
+
if (hasConsentMarker(config.agentName)) {
|
|
400
|
+
process.stderr.write(
|
|
401
|
+
`${LOG_PREFIX} Action blocked: this action requires approval before it can run.\n` +
|
|
402
|
+
` Grant access in the Shield dashboard and retry.\n` +
|
|
403
|
+
` Detail: ${approvalsUrl}\n`,
|
|
404
|
+
);
|
|
405
|
+
process.exit(2);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const url = consentUrl(config.baseUrl, config.agentName, service, actionType);
|
|
409
|
+
writeConsentMarker(config.agentName);
|
|
410
|
+
openBrowser(url);
|
|
411
|
+
process.stderr.write("Opening Shield consent screen... Waiting for approval (up to 5 min).\n");
|
|
412
|
+
|
|
413
|
+
for (let i = 0; i < MAX_APPROVAL_POLLS; i++) {
|
|
414
|
+
if (i > 0) {
|
|
415
|
+
await sleep(POLL_INTERVAL_MS);
|
|
416
|
+
}
|
|
417
|
+
let statusCode;
|
|
418
|
+
let bodyText;
|
|
419
|
+
try {
|
|
420
|
+
const res = await getJson(config.baseUrl, config.apiKey, `/api/v1/approvals/${approvalId}`);
|
|
421
|
+
statusCode = res.statusCode;
|
|
422
|
+
bodyText = res.bodyText;
|
|
423
|
+
} catch {
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
if (statusCode < 200 || statusCode >= 300) {
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
const parsed = safeJsonParse(bodyText);
|
|
430
|
+
const data = unwrapData(parsed);
|
|
431
|
+
if (data === null || typeof data !== "object") {
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
const d = /** @type {Record<string, unknown>} */ (data);
|
|
435
|
+
const st = String(d.status ?? "").toLowerCase();
|
|
436
|
+
if (st === "approved") {
|
|
437
|
+
process.exit(0);
|
|
438
|
+
}
|
|
439
|
+
if (st === "blocked" || st === "denied" || st === "rejected") {
|
|
440
|
+
const reason =
|
|
441
|
+
typeof d.reason === "string" && d.reason.length > 0 ? d.reason : "Approval denied.";
|
|
442
|
+
process.stderr.write(
|
|
443
|
+
`${LOG_PREFIX} Action blocked: Shield denied this approval request.\n` +
|
|
444
|
+
` Request access again from the Shield dashboard and retry.\n` +
|
|
445
|
+
` Detail: ${reason}\n`,
|
|
446
|
+
);
|
|
447
|
+
process.exit(2);
|
|
448
|
+
}
|
|
449
|
+
if (st === "expired") {
|
|
450
|
+
process.stderr.write(
|
|
451
|
+
`${LOG_PREFIX} Action blocked: this approval request expired.\n` +
|
|
452
|
+
` Start the action again and complete approval when prompted.\n` +
|
|
453
|
+
` Detail: status=expired\n`,
|
|
454
|
+
);
|
|
455
|
+
process.exit(2);
|
|
456
|
+
}
|
|
457
|
+
if (st === "pending") {
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
process.stderr.write(
|
|
463
|
+
`${LOG_PREFIX} Action blocked: approval timed out after 5 minutes.\n` +
|
|
464
|
+
` Approve in the Shield dashboard, then retry.\n` +
|
|
465
|
+
` Detail: approvalsUrl=${approvalsUrl}\n`,
|
|
466
|
+
);
|
|
467
|
+
process.exit(2);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async function main() {
|
|
471
|
+
let raw;
|
|
472
|
+
try {
|
|
473
|
+
raw = await readStdin();
|
|
474
|
+
} catch (e) {
|
|
475
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
476
|
+
process.stderr.write(`${LOG_PREFIX} could not read stdin (${msg}). Allowing action.\n`);
|
|
477
|
+
process.exit(0);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const config = loadConfig();
|
|
481
|
+
if (config === null) {
|
|
482
|
+
process.exit(0);
|
|
483
|
+
}
|
|
484
|
+
if (config.apiKey.length === 0 || config.agentName.length === 0) {
|
|
485
|
+
process.exit(0);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** @type {Record<string, unknown>} */
|
|
489
|
+
let hookPayload;
|
|
490
|
+
try {
|
|
491
|
+
hookPayload = JSON.parse(raw.length > 0 ? raw : "{}");
|
|
492
|
+
} catch (e) {
|
|
493
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
494
|
+
process.stderr.write(`${LOG_PREFIX} invalid JSON (${msg}). Allowing action.\n`);
|
|
495
|
+
process.exit(0);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const agentActionName =
|
|
499
|
+
typeof hookPayload.agent_action_name === "string" ? hookPayload.agent_action_name : "";
|
|
500
|
+
|
|
501
|
+
if (process.env.MULTICORN_SHIELD_WINDSURF_PRE_HOOK_TEST_SERIALIZE_FAIL === "1") {
|
|
502
|
+
hookPayload.tool_info = {
|
|
503
|
+
toJSON() {
|
|
504
|
+
throw new TypeError("MULTICORN_SHIELD_WINDSURF_PRE_HOOK_TEST_SERIALIZE_FAIL");
|
|
505
|
+
},
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const toolInfo = hookPayload.tool_info;
|
|
510
|
+
|
|
511
|
+
const mapped = mapPreEvent(agentActionName, toolInfo);
|
|
512
|
+
if (mapped === null) {
|
|
513
|
+
process.exit(0);
|
|
514
|
+
}
|
|
515
|
+
const { service, actionType } = mapped;
|
|
516
|
+
|
|
517
|
+
let toolInfoSerialized;
|
|
518
|
+
try {
|
|
519
|
+
toolInfoSerialized =
|
|
520
|
+
typeof toolInfo === "string"
|
|
521
|
+
? toolInfo
|
|
522
|
+
: JSON.stringify(toolInfo === undefined ? null : toolInfo);
|
|
523
|
+
} catch (e) {
|
|
524
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
525
|
+
process.stderr.write(
|
|
526
|
+
`${LOG_PREFIX} could not serialize tool_info (${msg}). Allowing action.\n`,
|
|
527
|
+
);
|
|
528
|
+
process.exit(0);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (typeof toolInfoSerialized === "string" && toolInfoSerialized.length > 4096) {
|
|
532
|
+
toolInfoSerialized = toolInfoSerialized.slice(0, 4096);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const approvalsUrl = dashboardHintUrl(config.baseUrl);
|
|
536
|
+
|
|
537
|
+
/** @type {Record<string, unknown>} */
|
|
538
|
+
const metadata = {
|
|
539
|
+
agent_action_name: agentActionName,
|
|
540
|
+
trajectory_id: typeof hookPayload.trajectory_id === "string" ? hookPayload.trajectory_id : "",
|
|
541
|
+
execution_id: typeof hookPayload.execution_id === "string" ? hookPayload.execution_id : "",
|
|
542
|
+
model_name: typeof hookPayload.model_name === "string" ? hookPayload.model_name : "",
|
|
543
|
+
tool_info: toolInfoSerialized,
|
|
544
|
+
source: "windsurf",
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
/** @type {Record<string, unknown>} */
|
|
548
|
+
const payload = {
|
|
549
|
+
agent: config.agentName,
|
|
550
|
+
service,
|
|
551
|
+
actionType,
|
|
552
|
+
status: "pending",
|
|
553
|
+
metadata,
|
|
554
|
+
platform: "windsurf",
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
if (process.env.MULTICORN_SHIELD_WINDSURF_PRE_HOOK_TEST_THROW === "1") {
|
|
558
|
+
throw new Error("MULTICORN_SHIELD_WINDSURF_PRE_HOOK_TEST_THROW");
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
let statusCode;
|
|
562
|
+
let bodyText;
|
|
563
|
+
try {
|
|
564
|
+
const res = await postJson(config.baseUrl, config.apiKey, payload);
|
|
565
|
+
statusCode = res.statusCode;
|
|
566
|
+
bodyText = res.bodyText;
|
|
567
|
+
} catch (e) {
|
|
568
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
569
|
+
process.stderr.write(
|
|
570
|
+
`${LOG_PREFIX} Action blocked: Shield API unreachable, cannot verify permissions.\n` +
|
|
571
|
+
` Check that the Shield service is running and retry.\n` +
|
|
572
|
+
` Detail: ${msg}\n`,
|
|
573
|
+
);
|
|
574
|
+
process.exit(2);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const parsed = safeJsonParse(bodyText);
|
|
578
|
+
const data = unwrapData(parsed);
|
|
579
|
+
|
|
580
|
+
if (statusCode === 202) {
|
|
581
|
+
if (data === null || typeof data !== "object") {
|
|
582
|
+
process.stderr.write(
|
|
583
|
+
`${LOG_PREFIX} Action blocked: this action needs approval in the Shield dashboard before it can run.\n` +
|
|
584
|
+
` Open the approvals page and complete approval, then retry.\n` +
|
|
585
|
+
` Detail: missing approval data in Shield response\n`,
|
|
586
|
+
);
|
|
587
|
+
process.exit(2);
|
|
588
|
+
}
|
|
589
|
+
const approvalIdRaw = /** @type {Record<string, unknown>} */ (data).approval_id;
|
|
590
|
+
const approvalId = typeof approvalIdRaw === "string" ? approvalIdRaw : "";
|
|
591
|
+
if (approvalId.length === 0) {
|
|
592
|
+
process.stderr.write(
|
|
593
|
+
`${LOG_PREFIX} Action blocked: this action needs approval in the Shield dashboard before it can run.\n` +
|
|
594
|
+
` Open the approvals page and complete approval, then retry.\n` +
|
|
595
|
+
` Detail: approval_id missing in Shield response\n`,
|
|
596
|
+
);
|
|
597
|
+
process.exit(2);
|
|
598
|
+
}
|
|
599
|
+
await handlePendingWithConsentAndPoll(config, approvalId, service, actionType, approvalsUrl);
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (statusCode === 201) {
|
|
604
|
+
if (data === null || typeof data !== "object") {
|
|
605
|
+
const detail = bodyText.length > 500 ? `${bodyText.slice(0, 500)}...` : bodyText;
|
|
606
|
+
process.stderr.write(
|
|
607
|
+
`${LOG_PREFIX} Action blocked: unexpected Shield response, cannot verify permissions.\n` +
|
|
608
|
+
` Check that the Shield service is healthy and retry.\n` +
|
|
609
|
+
` Detail: ${detail}\n`,
|
|
610
|
+
);
|
|
611
|
+
process.exit(2);
|
|
612
|
+
}
|
|
613
|
+
const st = String(/** @type {Record<string, unknown>} */ (data).status || "").toLowerCase();
|
|
614
|
+
if (st === "approved") {
|
|
615
|
+
process.exit(0);
|
|
616
|
+
}
|
|
617
|
+
if (st === "blocked") {
|
|
618
|
+
process.stderr.write(blockedMessage(data, service, actionType, approvalsUrl));
|
|
619
|
+
process.exit(2);
|
|
620
|
+
}
|
|
621
|
+
process.stderr.write(
|
|
622
|
+
`${LOG_PREFIX} Action blocked: ambiguous Shield status, cannot verify permissions.\n` +
|
|
623
|
+
` Check that your Shield API and plugin versions match, then retry.\n` +
|
|
624
|
+
` Detail: status=${JSON.stringify(/** @type {Record<string, unknown>} */ (data).status)}\n`,
|
|
625
|
+
);
|
|
626
|
+
process.exit(2);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const httpDetail = bodyText.length > 300 ? `${bodyText.slice(0, 300)}...` : bodyText;
|
|
630
|
+
process.stderr.write(
|
|
631
|
+
`${LOG_PREFIX} Action blocked: Shield returned HTTP ${String(statusCode)}, cannot verify permissions.\n` +
|
|
632
|
+
` Check your API key, Shield service status, and rate limits, then retry.\n` +
|
|
633
|
+
` Detail: HTTP ${String(statusCode)} body=${httpDetail}\n`,
|
|
634
|
+
);
|
|
635
|
+
process.exit(2);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
main().catch((e) => {
|
|
639
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
640
|
+
process.stderr.write(
|
|
641
|
+
`${LOG_PREFIX} Action blocked: unexpected error, cannot verify permissions.\n` +
|
|
642
|
+
` Retry the action. If it keeps failing, check Shield logs.\n` +
|
|
643
|
+
` Detail: ${msg}\n`,
|
|
644
|
+
);
|
|
645
|
+
process.exit(2);
|
|
646
|
+
});
|