multicorn-shield 0.7.0 → 0.9.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/dist/multicorn-proxy.js +245 -14
- package/dist/multicorn-shield.js +1 -0
- package/dist/shield-extension.js +2 -1
- package/package.json +2 -1
- 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
package/dist/multicorn-proxy.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { existsSync } from 'fs';
|
|
3
|
-
import { mkdir, writeFile, readFile, unlink } from 'fs/promises';
|
|
4
|
-
import { join } from 'path';
|
|
3
|
+
import { mkdir, writeFile, readFile, copyFile, unlink } from 'fs/promises';
|
|
4
|
+
import { join, dirname } from 'path';
|
|
5
5
|
import { homedir } from 'os';
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
6
7
|
import { createInterface } from 'readline';
|
|
7
8
|
import { spawn } from 'child_process';
|
|
8
9
|
import { createHash } from 'crypto';
|
|
@@ -366,17 +367,125 @@ async function isCursorConnected() {
|
|
|
366
367
|
return false;
|
|
367
368
|
}
|
|
368
369
|
}
|
|
369
|
-
|
|
370
|
+
function getWindsurfConfigPath() {
|
|
371
|
+
return join(homedir(), ".codeium", "windsurf", "mcp_config.json");
|
|
372
|
+
}
|
|
373
|
+
async function isWindsurfConnected() {
|
|
374
|
+
try {
|
|
375
|
+
const raw = await readFile(getWindsurfConfigPath(), "utf8");
|
|
376
|
+
const obj = JSON.parse(raw);
|
|
377
|
+
const mcpServers = obj["mcpServers"];
|
|
378
|
+
if (mcpServers === void 0 || typeof mcpServers !== "object") return false;
|
|
379
|
+
for (const entry of Object.values(mcpServers)) {
|
|
380
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
381
|
+
const rec = entry;
|
|
382
|
+
const url = rec["serverUrl"];
|
|
383
|
+
if (typeof url === "string" && url.includes("multicorn")) return true;
|
|
384
|
+
}
|
|
385
|
+
return false;
|
|
386
|
+
} catch {
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
function multicornShieldPackageRoot() {
|
|
391
|
+
return join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
392
|
+
}
|
|
393
|
+
function getWindsurfHooksInstallDir() {
|
|
394
|
+
return join(homedir(), ".multicorn", "windsurf-hooks");
|
|
395
|
+
}
|
|
396
|
+
function getWindsurfCascadeHooksJsonPath() {
|
|
397
|
+
return join(homedir(), ".codeium", "windsurf", "hooks.json");
|
|
398
|
+
}
|
|
399
|
+
function isShieldWindsurfHookCommand(cmd) {
|
|
400
|
+
return cmd.includes("windsurf-hooks/pre-action.cjs") || cmd.includes("windsurf-hooks\\pre-action.cjs") || cmd.includes("windsurf-hooks/post-action.cjs") || cmd.includes("windsurf-hooks\\post-action.cjs");
|
|
401
|
+
}
|
|
402
|
+
function filterOutShieldWindsurfHooks(entries) {
|
|
403
|
+
if (!Array.isArray(entries)) return [];
|
|
404
|
+
const out = [];
|
|
405
|
+
for (const e of entries) {
|
|
406
|
+
if (typeof e !== "object" || e === null) continue;
|
|
407
|
+
const rec = e;
|
|
408
|
+
const cmd = rec["command"];
|
|
409
|
+
if (typeof cmd !== "string" || isShieldWindsurfHookCommand(cmd)) continue;
|
|
410
|
+
const powershell = rec["powershell"];
|
|
411
|
+
const show_output = rec["show_output"];
|
|
412
|
+
out.push({
|
|
413
|
+
command: cmd,
|
|
414
|
+
...typeof powershell === "string" ? { powershell } : {},
|
|
415
|
+
...show_output === true ? { show_output: true } : {}
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
return out;
|
|
419
|
+
}
|
|
420
|
+
async function installWindsurfNativeHooks() {
|
|
421
|
+
const root = multicornShieldPackageRoot();
|
|
422
|
+
const srcPre = join(root, "plugins", "windsurf", "hooks", "scripts", "pre-action.cjs");
|
|
423
|
+
const srcPost = join(root, "plugins", "windsurf", "hooks", "scripts", "post-action.cjs");
|
|
424
|
+
if (!existsSync(srcPre) || !existsSync(srcPost)) {
|
|
425
|
+
throw new Error(
|
|
426
|
+
`Could not find Shield Windsurf hook scripts at ${srcPre}. If you use npm, install the latest multicorn-shield package.`
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
const installDir = getWindsurfHooksInstallDir();
|
|
430
|
+
await mkdir(installDir, { recursive: true });
|
|
431
|
+
const destPre = join(installDir, "pre-action.cjs");
|
|
432
|
+
const destPost = join(installDir, "post-action.cjs");
|
|
433
|
+
await copyFile(srcPre, destPre);
|
|
434
|
+
await copyFile(srcPost, destPost);
|
|
435
|
+
const preCmd = `node ${JSON.stringify(destPre)}`;
|
|
436
|
+
const postCmd = `node ${JSON.stringify(destPost)}`;
|
|
437
|
+
const preEntry = { command: preCmd, powershell: preCmd, show_output: true };
|
|
438
|
+
const postEntry = { command: postCmd, powershell: postCmd };
|
|
439
|
+
const hooksPath = getWindsurfCascadeHooksJsonPath();
|
|
440
|
+
let base = { hooks: {} };
|
|
441
|
+
try {
|
|
442
|
+
const raw = await readFile(hooksPath, "utf8");
|
|
443
|
+
base = JSON.parse(raw);
|
|
444
|
+
} catch (err) {
|
|
445
|
+
if (!isErrnoException(err) || err.code !== "ENOENT") {
|
|
446
|
+
throw err;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
const hooks = base["hooks"] ?? {};
|
|
450
|
+
const preKeys = [
|
|
451
|
+
"pre_read_code",
|
|
452
|
+
"pre_write_code",
|
|
453
|
+
"pre_run_command",
|
|
454
|
+
"pre_mcp_tool_use"
|
|
455
|
+
];
|
|
456
|
+
const postKeys = [
|
|
457
|
+
"post_read_code",
|
|
458
|
+
"post_write_code",
|
|
459
|
+
"post_run_command",
|
|
460
|
+
"post_mcp_tool_use"
|
|
461
|
+
];
|
|
462
|
+
const nextHooks = { ...hooks };
|
|
463
|
+
for (const k of preKeys) {
|
|
464
|
+
const merged = filterOutShieldWindsurfHooks(nextHooks[k]);
|
|
465
|
+
nextHooks[k] = [...merged, preEntry];
|
|
466
|
+
}
|
|
467
|
+
for (const k of postKeys) {
|
|
468
|
+
const merged = filterOutShieldWindsurfHooks(nextHooks[k]);
|
|
469
|
+
nextHooks[k] = [...merged, postEntry];
|
|
470
|
+
}
|
|
471
|
+
base["hooks"] = nextHooks;
|
|
472
|
+
const hooksDir = dirname(hooksPath);
|
|
473
|
+
await mkdir(hooksDir, { recursive: true });
|
|
474
|
+
await writeFile(hooksPath, JSON.stringify(base, null, 2) + "\n", { encoding: "utf8" });
|
|
475
|
+
}
|
|
476
|
+
var PLATFORM_LABELS = ["OpenClaw", "Claude Code", "Cursor", "Windsurf", "Local MCP / Other"];
|
|
370
477
|
var PLATFORM_BY_SELECTION = {
|
|
371
478
|
1: "openclaw",
|
|
372
479
|
2: "claude-code",
|
|
373
480
|
3: "cursor",
|
|
374
|
-
4: "
|
|
481
|
+
4: "windsurf",
|
|
482
|
+
5: "other-mcp"
|
|
375
483
|
};
|
|
376
484
|
var DEFAULT_AGENT_NAMES = {
|
|
377
485
|
openclaw: "my-openclaw-agent",
|
|
378
486
|
"claude-code": "my-claude-code-agent",
|
|
379
|
-
cursor: "my-cursor-agent"
|
|
487
|
+
cursor: "my-cursor-agent",
|
|
488
|
+
windsurf: "my-windsurf-agent"
|
|
380
489
|
};
|
|
381
490
|
async function promptPlatformSelection(ask) {
|
|
382
491
|
process.stderr.write(
|
|
@@ -385,7 +494,8 @@ async function promptPlatformSelection(ask) {
|
|
|
385
494
|
const connectedFlags = [
|
|
386
495
|
await isOpenClawConnected(),
|
|
387
496
|
isClaudeCodeConnected(),
|
|
388
|
-
await isCursorConnected()
|
|
497
|
+
await isCursorConnected(),
|
|
498
|
+
await isWindsurfConnected()
|
|
389
499
|
];
|
|
390
500
|
for (let i = 0; i < PLATFORM_LABELS.length; i++) {
|
|
391
501
|
const marker = i < connectedFlags.length && connectedFlags[i] ? " " + style.dim("\u25CF detected locally") : "";
|
|
@@ -395,18 +505,35 @@ async function promptPlatformSelection(ask) {
|
|
|
395
505
|
);
|
|
396
506
|
}
|
|
397
507
|
process.stderr.write(
|
|
398
|
-
style.dim(" Pick
|
|
508
|
+
style.dim(" Pick 5 if you want to wrap a local MCP server with multicorn-proxy --wrap.") + "\n"
|
|
399
509
|
);
|
|
400
510
|
let selection = 0;
|
|
401
511
|
while (selection === 0) {
|
|
402
|
-
const input = await ask("Select (1-
|
|
512
|
+
const input = await ask("Select (1-5): ");
|
|
403
513
|
const num = parseInt(input.trim(), 10);
|
|
404
|
-
if (num >= 1 && num <=
|
|
514
|
+
if (num >= 1 && num <= 5) {
|
|
405
515
|
selection = num;
|
|
406
516
|
}
|
|
407
517
|
}
|
|
408
518
|
return selection;
|
|
409
519
|
}
|
|
520
|
+
async function promptWindsurfIntegrationMode(ask) {
|
|
521
|
+
process.stderr.write("\n" + style.bold("Windsurf integration") + "\n");
|
|
522
|
+
process.stderr.write(
|
|
523
|
+
" " + style.violet("1") + ". Native plugin (recommended) \u2014 Cascade Hooks see every file, terminal, and MCP action\n"
|
|
524
|
+
);
|
|
525
|
+
process.stderr.write(
|
|
526
|
+
" " + style.violet("2") + ". Hosted proxy \u2014 govern MCP traffic only (paste proxy URL into mcp_config)\n"
|
|
527
|
+
);
|
|
528
|
+
let choice = 0;
|
|
529
|
+
while (choice === 0) {
|
|
530
|
+
const input = await ask("Choose integration (1-2): ");
|
|
531
|
+
const num = parseInt(input.trim(), 10);
|
|
532
|
+
if (num === 1) choice = 1;
|
|
533
|
+
if (num === 2) choice = 2;
|
|
534
|
+
}
|
|
535
|
+
return choice === 1 ? "native" : "hosted";
|
|
536
|
+
}
|
|
410
537
|
async function promptAgentName(ask, platform) {
|
|
411
538
|
const defaultAgentName = DEFAULT_AGENT_NAMES[platform] ?? "my-agent";
|
|
412
539
|
let agentName = "";
|
|
@@ -505,12 +632,14 @@ async function createProxyConfig(baseUrl, apiKey, agentName, targetUrl, serverNa
|
|
|
505
632
|
return typeof data?.["proxy_url"] === "string" ? data["proxy_url"] : "";
|
|
506
633
|
}
|
|
507
634
|
function printPlatformSnippet(platform, routingToken, shortName, apiKey) {
|
|
508
|
-
const
|
|
635
|
+
const usesInlineKey = platform === "cursor" || platform === "windsurf";
|
|
636
|
+
const authHeader = usesInlineKey ? `Bearer ${apiKey}` : "Bearer YOUR_SHIELD_API_KEY";
|
|
637
|
+
const urlKey = platform === "windsurf" ? "serverUrl" : "url";
|
|
509
638
|
const mcpSnippet = JSON.stringify(
|
|
510
639
|
{
|
|
511
640
|
mcpServers: {
|
|
512
641
|
[shortName]: {
|
|
513
|
-
|
|
642
|
+
[urlKey]: routingToken,
|
|
514
643
|
headers: {
|
|
515
644
|
Authorization: authHeader
|
|
516
645
|
}
|
|
@@ -524,11 +653,15 @@ function printPlatformSnippet(platform, routingToken, shortName, apiKey) {
|
|
|
524
653
|
process.stderr.write("\n" + style.dim("Add this to your OpenClaw agent config:") + "\n\n");
|
|
525
654
|
} else if (platform === "claude-code") {
|
|
526
655
|
process.stderr.write("\n" + style.dim("Add this to your Claude Code MCP config:") + "\n\n");
|
|
656
|
+
} else if (platform === "windsurf") {
|
|
657
|
+
process.stderr.write(
|
|
658
|
+
"\n" + style.dim("Add this to ~/.codeium/windsurf/mcp_config.json:") + "\n\n"
|
|
659
|
+
);
|
|
527
660
|
} else {
|
|
528
661
|
process.stderr.write("\n" + style.dim("Add this to ~/.cursor/mcp.json:") + "\n\n");
|
|
529
662
|
}
|
|
530
663
|
process.stderr.write(style.cyan(mcpSnippet) + "\n\n");
|
|
531
|
-
if (
|
|
664
|
+
if (!usesInlineKey) {
|
|
532
665
|
process.stderr.write(
|
|
533
666
|
style.dim(
|
|
534
667
|
"Replace YOUR_SHIELD_API_KEY with your API key. Find it in Settings > API keys at https://app.multicorn.ai/settings#api-keys"
|
|
@@ -547,6 +680,14 @@ function printPlatformSnippet(platform, routingToken, shortName, apiKey) {
|
|
|
547
680
|
) + "\n"
|
|
548
681
|
);
|
|
549
682
|
}
|
|
683
|
+
if (platform === "windsurf") {
|
|
684
|
+
process.stderr.write(style.dim("Then restart Windsurf (Cmd/Ctrl+Q, then reopen).") + "\n");
|
|
685
|
+
process.stderr.write(
|
|
686
|
+
style.dim(
|
|
687
|
+
"Open the Cascade panel and verify the server appears with a green status indicator."
|
|
688
|
+
) + "\n"
|
|
689
|
+
);
|
|
690
|
+
}
|
|
550
691
|
}
|
|
551
692
|
var DEFAULT_SHIELD_API_BASE_URL = "https://api.multicorn.ai";
|
|
552
693
|
async function runInit(explicitBaseUrl) {
|
|
@@ -636,7 +777,7 @@ async function runInit(explicitBaseUrl) {
|
|
|
636
777
|
const selection = await promptPlatformSelection(ask);
|
|
637
778
|
const selectedPlatform = PLATFORM_BY_SELECTION[selection] ?? "cursor";
|
|
638
779
|
const selectedLabel = PLATFORM_LABELS[selection - 1] ?? "Cursor";
|
|
639
|
-
if (selection ===
|
|
780
|
+
if (selection === 5) {
|
|
640
781
|
const raw = existing !== null ? { ...existing } : {};
|
|
641
782
|
raw["apiKey"] = apiKey;
|
|
642
783
|
raw["baseUrl"] = resolvedBaseUrl;
|
|
@@ -786,6 +927,80 @@ An agent for ${selectedLabel} already exists: ${style.cyan(existingForPlatform.n
|
|
|
786
927
|
agentName
|
|
787
928
|
});
|
|
788
929
|
setupSucceeded = true;
|
|
930
|
+
} else if (selection === 4) {
|
|
931
|
+
const windsurfMode = await promptWindsurfIntegrationMode(ask);
|
|
932
|
+
if (windsurfMode === "native") {
|
|
933
|
+
try {
|
|
934
|
+
await installWindsurfNativeHooks();
|
|
935
|
+
process.stderr.write("\n" + style.bold("Shield Windsurf hooks installed") + "\n");
|
|
936
|
+
process.stderr.write(
|
|
937
|
+
style.dim("Scripts: ") + style.cyan(getWindsurfHooksInstallDir()) + "\n"
|
|
938
|
+
);
|
|
939
|
+
process.stderr.write(
|
|
940
|
+
style.dim("Hooks config: ") + style.cyan(getWindsurfCascadeHooksJsonPath()) + "\n"
|
|
941
|
+
);
|
|
942
|
+
process.stderr.write(
|
|
943
|
+
"\n" + style.dim(
|
|
944
|
+
"The Shield hook runs with your user permissions. It intercepts Cascade actions to check permissions and log activity. Review the scripts under "
|
|
945
|
+
) + style.cyan("~/.multicorn/windsurf-hooks") + style.dim(" if that is a concern.") + "\n\n"
|
|
946
|
+
);
|
|
947
|
+
process.stderr.write(
|
|
948
|
+
style.dim("Restart Windsurf (quit fully, then reopen) so hooks load.") + "\n"
|
|
949
|
+
);
|
|
950
|
+
configuredAgents.push({
|
|
951
|
+
selection,
|
|
952
|
+
platform: selectedPlatform,
|
|
953
|
+
platformLabel: selectedLabel,
|
|
954
|
+
agentName,
|
|
955
|
+
windsurfIntegration: "native"
|
|
956
|
+
});
|
|
957
|
+
setupSucceeded = true;
|
|
958
|
+
} catch (error) {
|
|
959
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
960
|
+
process.stderr.write(style.red("\u2717 ") + detail + "\n");
|
|
961
|
+
}
|
|
962
|
+
} else {
|
|
963
|
+
const { targetUrl, shortName } = await promptProxyConfig(ask, agentName);
|
|
964
|
+
let proxyUrl = "";
|
|
965
|
+
let created = false;
|
|
966
|
+
while (!created) {
|
|
967
|
+
const spinner = withSpinner("Creating proxy config...");
|
|
968
|
+
try {
|
|
969
|
+
proxyUrl = await createProxyConfig(
|
|
970
|
+
resolvedBaseUrl,
|
|
971
|
+
apiKey,
|
|
972
|
+
agentName,
|
|
973
|
+
targetUrl,
|
|
974
|
+
shortName,
|
|
975
|
+
selectedPlatform
|
|
976
|
+
);
|
|
977
|
+
spinner.stop(true, "Proxy config created!");
|
|
978
|
+
created = true;
|
|
979
|
+
} catch (error) {
|
|
980
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
981
|
+
spinner.stop(false, detail);
|
|
982
|
+
const retry = await ask("Try again? (Y/n) ");
|
|
983
|
+
if (retry.trim().toLowerCase() === "n") {
|
|
984
|
+
break;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
if (created && proxyUrl.length > 0) {
|
|
989
|
+
process.stderr.write("\n" + style.bold("Your Shield proxy URL:") + "\n");
|
|
990
|
+
process.stderr.write(" " + style.cyan(proxyUrl) + "\n");
|
|
991
|
+
printPlatformSnippet(selectedPlatform, proxyUrl, shortName, apiKey);
|
|
992
|
+
configuredAgents.push({
|
|
993
|
+
selection,
|
|
994
|
+
platform: selectedPlatform,
|
|
995
|
+
platformLabel: selectedLabel,
|
|
996
|
+
agentName,
|
|
997
|
+
shortName,
|
|
998
|
+
proxyUrl,
|
|
999
|
+
windsurfIntegration: "hosted"
|
|
1000
|
+
});
|
|
1001
|
+
setupSucceeded = true;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
789
1004
|
} else {
|
|
790
1005
|
const { targetUrl, shortName } = await promptProxyConfig(ask, agentName);
|
|
791
1006
|
let proxyUrl = "";
|
|
@@ -885,7 +1100,23 @@ An agent for ${selectedLabel} already exists: ${style.cyan(existingForPlatform.n
|
|
|
885
1100
|
}
|
|
886
1101
|
if (configuredPlatforms.has("cursor")) {
|
|
887
1102
|
blocks.push(
|
|
888
|
-
"\n" + style.bold("To complete your Cursor setup:") + "\n \
|
|
1103
|
+
"\n" + style.bold("To complete your Cursor setup:") + "\n 1. If you don't have Cursor yet, download it from " + style.cyan("https://cursor.com/downloads") + "\n 2. Open " + style.cyan("~/.cursor/mcp.json") + " and paste the config snippet shown above\n 3. Restart Cursor (or launch it for the first time) to load the new MCP server\n"
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
1106
|
+
const windsurfNativeConfigured = configuredAgents.some(
|
|
1107
|
+
(a) => a.platform === "windsurf" && a.windsurfIntegration === "native"
|
|
1108
|
+
);
|
|
1109
|
+
const windsurfHostedConfigured = configuredAgents.some(
|
|
1110
|
+
(a) => a.platform === "windsurf" && a.windsurfIntegration === "hosted"
|
|
1111
|
+
);
|
|
1112
|
+
if (windsurfNativeConfigured) {
|
|
1113
|
+
blocks.push(
|
|
1114
|
+
"\n" + style.bold("To complete native Windsurf (Shield) setup:") + "\n 1. Hook scripts: " + style.cyan(getWindsurfHooksInstallDir()) + "\n 2. Hooks config: " + style.cyan(getWindsurfCascadeHooksJsonPath()) + "\n 3. Restart Windsurf (quit fully, then reopen)\n"
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
if (windsurfHostedConfigured) {
|
|
1118
|
+
blocks.push(
|
|
1119
|
+
"\n" + style.bold("To complete your Windsurf hosted-proxy setup:") + "\n 1. If you don't have Windsurf yet, download it from " + style.cyan("https://windsurf.com/download") + "\n 2. Open " + style.cyan("~/.codeium/windsurf/mcp_config.json") + " and paste the config snippet shown above\n 3. Restart Windsurf (or launch it for the first time) to load the new MCP server\n"
|
|
889
1120
|
);
|
|
890
1121
|
}
|
|
891
1122
|
if (blocks.length > 0) {
|
package/dist/multicorn-shield.js
CHANGED
package/dist/shield-extension.js
CHANGED
|
@@ -7,6 +7,7 @@ import process3 from 'process';
|
|
|
7
7
|
import 'stream';
|
|
8
8
|
import { spawn } from 'child_process';
|
|
9
9
|
import { createHash } from 'crypto';
|
|
10
|
+
import 'url';
|
|
10
11
|
import 'readline';
|
|
11
12
|
|
|
12
13
|
// Multicorn Shield Claude Desktop Extension - https://multicorn.ai
|
|
@@ -22358,7 +22359,7 @@ async function writeExtensionBackup(claudeDesktopConfigPath, mcpServers) {
|
|
|
22358
22359
|
|
|
22359
22360
|
// package.json
|
|
22360
22361
|
var package_default = {
|
|
22361
|
-
version: "0.
|
|
22362
|
+
version: "0.9.0"};
|
|
22362
22363
|
|
|
22363
22364
|
// src/package-meta.ts
|
|
22364
22365
|
var PACKAGE_VERSION = package_default.version;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "multicorn-shield",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "The control layer for AI agents: permissions, consent, spending limits, and audit logging.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
},
|
|
36
36
|
"files": [
|
|
37
37
|
"dist",
|
|
38
|
+
"plugins/windsurf",
|
|
38
39
|
"LICENSE",
|
|
39
40
|
"README.md"
|
|
40
41
|
],
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Multicorn Shield for Windsurf (Cascade Hooks)
|
|
2
|
+
|
|
3
|
+
Native Shield integration for [Windsurf](https://windsurf.com) using [Cascade Hooks](https://docs.windsurf.com/windsurf/cascade/hooks). Every governed pre-hook asks the Shield API whether the action may run; post-hooks log completed actions to your audit trail.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
1. Install the CLI package (or use `npx`).
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g multicorn-shield
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
2. Run the wizard and pick **Windsurf**, then **Native plugin (recommended)**.
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx multicorn-proxy init
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
3. Restart Windsurf (quit fully, then reopen) so hooks load.
|
|
20
|
+
|
|
21
|
+
The wizard copies `pre-action.cjs` and `post-action.cjs` to `~/.multicorn/windsurf-hooks/` and merges entries into `~/.codeium/windsurf/hooks.json`.
|
|
22
|
+
|
|
23
|
+
## How it works
|
|
24
|
+
|
|
25
|
+
- **Config** is read from `~/.multicorn/config.json` (same file as other Shield integrations). The agent row must use `platform: "windsurf"`.
|
|
26
|
+
- **Permission check**: `POST /api/v1/actions` with `status: "pending"` and `X-Multicorn-Key`. Exit code `0` allows the action; `2` blocks and prints guidance on stderr (see Windsurf hook docs). (Exit code `2` tells Windsurf to cancel the action and show the message to the user.)
|
|
27
|
+
- **Audit log**: post-hooks send `POST /api/v1/actions` with `status: "approved"` after the action completes.
|
|
28
|
+
|
|
29
|
+
### Event to Shield mapping
|
|
30
|
+
|
|
31
|
+
| Windsurf `agent_action_name` | Shield `service` | Shield `actionType` |
|
|
32
|
+
| ----------------------------- | --------------------- | ------------------- |
|
|
33
|
+
| `pre_read_code` / `post_*` | `filesystem` | `read` |
|
|
34
|
+
| `pre_write_code` / `post_*` | `filesystem` | `write` |
|
|
35
|
+
| `pre_run_command` / `post_*` | `terminal` | `execute` |
|
|
36
|
+
| `pre_mcp_tool_use` / `post_*` | `mcp:<server>.<tool>` | `execute` |
|
|
37
|
+
|
|
38
|
+
Stdin includes `trajectory_id`, `execution_id`, and `tool_info`; those are forwarded in `metadata` for auditing.
|
|
39
|
+
|
|
40
|
+
## Trust model
|
|
41
|
+
|
|
42
|
+
Hooks run shell commands with **your user permissions**. They can read the JSON on stdin and call the network. Review the scripts under `~/.multicorn/windsurf-hooks/` before you rely on them in sensitive environments.
|
|
43
|
+
|
|
44
|
+
## Hosted proxy alternative
|
|
45
|
+
|
|
46
|
+
If you only need MCP traffic governed, use **Hosted proxy** in `npx multicorn-proxy init` and paste the proxy URL into `~/.codeium/windsurf/mcp_config.json` instead.
|
|
47
|
+
|
|
48
|
+
## Windows
|
|
49
|
+
|
|
50
|
+
Hooks include a `powershell` field for Windsurf on Windows. Full Windows support may be incomplete compared to macOS and Linux; if something breaks, open an issue with your Windsurf and Node versions.
|
|
51
|
+
|
|
52
|
+
## References
|
|
53
|
+
|
|
54
|
+
- [Cascade Hooks](https://docs.windsurf.com/windsurf/cascade/hooks)
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windsurf Cascade post-hook: logs completed actions to the Shield audit trail.
|
|
3
|
+
* Routes by agent_action_name. Never blocks; always exit 0.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
"use strict";
|
|
7
|
+
|
|
8
|
+
const fs = require("node:fs");
|
|
9
|
+
const http = require("node:http");
|
|
10
|
+
const https = require("node:https");
|
|
11
|
+
const os = require("node:os");
|
|
12
|
+
const path = require("node:path");
|
|
13
|
+
|
|
14
|
+
const AUTH_HEADER = "X-Multicorn-Key";
|
|
15
|
+
const LOG_PREFIX = "[multicorn-shield] Windsurf post-hook:";
|
|
16
|
+
const HTTP_REQUEST_TIMEOUT_MS =
|
|
17
|
+
process.env.MULTICORN_SHIELD_WINDSURF_PRE_HOOK_TEST_FAST_POLL === "1" ? 100 : 10000;
|
|
18
|
+
|
|
19
|
+
/** @type {Readonly<Record<string, { service: string; actionType: string }>>} */
|
|
20
|
+
const POST_EVENT_MAP = {
|
|
21
|
+
post_read_code: { service: "filesystem", actionType: "read" },
|
|
22
|
+
post_write_code: { service: "filesystem", actionType: "write" },
|
|
23
|
+
post_run_command: { service: "terminal", actionType: "execute" },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @returns {Promise<string>}
|
|
28
|
+
*/
|
|
29
|
+
function readStdin() {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
const chunks = [];
|
|
32
|
+
process.stdin.setEncoding("utf8");
|
|
33
|
+
process.stdin.on("data", (c) => chunks.push(c));
|
|
34
|
+
process.stdin.on("end", () => resolve(chunks.join("")));
|
|
35
|
+
process.stdin.on("error", reject);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Duplicated in pre-action.cjs. CJS hooks cannot import shared TypeScript modules.
|
|
40
|
+
/**
|
|
41
|
+
* @param {Record<string, unknown>} obj
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
function resolveWindsurfAgentName(obj) {
|
|
45
|
+
const agents = obj.agents;
|
|
46
|
+
if (Array.isArray(agents)) {
|
|
47
|
+
for (const entry of agents) {
|
|
48
|
+
if (
|
|
49
|
+
entry &&
|
|
50
|
+
typeof entry === "object" &&
|
|
51
|
+
/** @type {{ platform?: string; name?: string }} */ (entry).platform === "windsurf" &&
|
|
52
|
+
typeof (/** @type {{ platform?: string; name?: string }} */ (entry).name) === "string"
|
|
53
|
+
) {
|
|
54
|
+
return /** @type {{ name: string }} */ (entry).name;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return typeof obj.agentName === "string" ? obj.agentName : "";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @returns {{ apiKey: string; baseUrl: string; agentName: string } | null}
|
|
63
|
+
*/
|
|
64
|
+
function loadConfig() {
|
|
65
|
+
try {
|
|
66
|
+
const configPath = path.join(os.homedir(), ".multicorn", "config.json");
|
|
67
|
+
const raw = fs.readFileSync(configPath, "utf8");
|
|
68
|
+
const obj = JSON.parse(raw);
|
|
69
|
+
const apiKey = typeof obj.apiKey === "string" ? obj.apiKey : "";
|
|
70
|
+
const baseUrl =
|
|
71
|
+
typeof obj.baseUrl === "string" && obj.baseUrl.length > 0
|
|
72
|
+
? obj.baseUrl.replace(/\/+$/, "")
|
|
73
|
+
: "https://api.multicorn.ai";
|
|
74
|
+
const agentName = resolveWindsurfAgentName(obj);
|
|
75
|
+
return { apiKey, baseUrl, agentName };
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {unknown} toolInfo
|
|
83
|
+
* @returns {{ service: string; actionType: string }}
|
|
84
|
+
*/
|
|
85
|
+
function mapMcpPost(toolInfo) {
|
|
86
|
+
if (toolInfo === null || typeof toolInfo !== "object") {
|
|
87
|
+
return { service: "mcp", actionType: "execute" };
|
|
88
|
+
}
|
|
89
|
+
const t = /** @type {Record<string, unknown>} */ (toolInfo);
|
|
90
|
+
const server = String(t.mcp_server_name ?? "unknown").trim() || "unknown";
|
|
91
|
+
const tool = String(t.mcp_tool_name ?? "unknown").trim() || "unknown";
|
|
92
|
+
const safeServer = server.replace(/[^a-zA-Z0-9._-]+/g, "_");
|
|
93
|
+
const safeTool = tool.replace(/[^a-zA-Z0-9._-]+/g, "_");
|
|
94
|
+
return { service: `mcp:${safeServer}.${safeTool}`, actionType: "execute" };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @param {string} agentActionName
|
|
99
|
+
* @param {unknown} toolInfo
|
|
100
|
+
* @returns {{ service: string; actionType: string } | null}
|
|
101
|
+
*/
|
|
102
|
+
function mapPostEvent(agentActionName, toolInfo) {
|
|
103
|
+
const name = String(agentActionName || "").trim();
|
|
104
|
+
if (name === "post_mcp_tool_use") {
|
|
105
|
+
return mapMcpPost(toolInfo);
|
|
106
|
+
}
|
|
107
|
+
const mapped = POST_EVENT_MAP[name];
|
|
108
|
+
if (mapped !== undefined) {
|
|
109
|
+
return mapped;
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @param {string} baseUrl
|
|
116
|
+
* @param {string} apiKey
|
|
117
|
+
* @param {Record<string, unknown>} bodyObj
|
|
118
|
+
* @returns {Promise<void>}
|
|
119
|
+
*/
|
|
120
|
+
function postJson(baseUrl, apiKey, bodyObj) {
|
|
121
|
+
return new Promise((resolve, reject) => {
|
|
122
|
+
let u;
|
|
123
|
+
try {
|
|
124
|
+
const root = String(baseUrl).replace(/\/+$/, "");
|
|
125
|
+
u = new URL(`${root}/api/v1/actions`);
|
|
126
|
+
} catch (e) {
|
|
127
|
+
reject(e);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const payload = JSON.stringify(bodyObj);
|
|
131
|
+
const isHttps = u.protocol === "https:";
|
|
132
|
+
const lib = isHttps ? https : http;
|
|
133
|
+
const port = u.port || (isHttps ? 443 : 80);
|
|
134
|
+
const options = {
|
|
135
|
+
hostname: u.hostname,
|
|
136
|
+
port,
|
|
137
|
+
path: u.pathname + u.search,
|
|
138
|
+
method: "POST",
|
|
139
|
+
headers: {
|
|
140
|
+
Connection: "close",
|
|
141
|
+
"Content-Type": "application/json",
|
|
142
|
+
"Content-Length": Buffer.byteLength(payload, "utf8"),
|
|
143
|
+
[AUTH_HEADER]: apiKey,
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
const req = lib.request(options, (res) => {
|
|
147
|
+
res.resume();
|
|
148
|
+
res.on("end", () => {
|
|
149
|
+
const code = res.statusCode ?? 0;
|
|
150
|
+
if (code >= 200 && code < 300) {
|
|
151
|
+
resolve();
|
|
152
|
+
} else {
|
|
153
|
+
reject(new Error(`HTTP ${String(code)}`));
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
req.setTimeout(HTTP_REQUEST_TIMEOUT_MS, () => {
|
|
158
|
+
req.destroy(new Error("request timeout"));
|
|
159
|
+
});
|
|
160
|
+
req.on("error", reject);
|
|
161
|
+
req.write(payload);
|
|
162
|
+
req.end();
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function main() {
|
|
167
|
+
let raw;
|
|
168
|
+
try {
|
|
169
|
+
raw = await readStdin();
|
|
170
|
+
} catch {
|
|
171
|
+
process.exit(0);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const config = loadConfig();
|
|
175
|
+
if (config === null || config.apiKey.length === 0 || config.agentName.length === 0) {
|
|
176
|
+
process.exit(0);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** @type {Record<string, unknown>} */
|
|
180
|
+
let hookPayload;
|
|
181
|
+
try {
|
|
182
|
+
hookPayload = JSON.parse(raw.length > 0 ? raw : "{}");
|
|
183
|
+
} catch {
|
|
184
|
+
process.exit(0);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const agentActionName =
|
|
188
|
+
typeof hookPayload.agent_action_name === "string" ? hookPayload.agent_action_name : "";
|
|
189
|
+
const toolInfo = hookPayload.tool_info;
|
|
190
|
+
|
|
191
|
+
const mapped = mapPostEvent(agentActionName, toolInfo);
|
|
192
|
+
if (mapped === null) {
|
|
193
|
+
process.exit(0);
|
|
194
|
+
}
|
|
195
|
+
const { service, actionType } = mapped;
|
|
196
|
+
|
|
197
|
+
let toolInfoSerialized;
|
|
198
|
+
try {
|
|
199
|
+
toolInfoSerialized =
|
|
200
|
+
typeof toolInfo === "string"
|
|
201
|
+
? toolInfo
|
|
202
|
+
: JSON.stringify(toolInfo === undefined ? null : toolInfo);
|
|
203
|
+
} catch {
|
|
204
|
+
process.exit(0);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** @type {Record<string, unknown>} */
|
|
208
|
+
const metadata = {
|
|
209
|
+
agent_action_name: agentActionName,
|
|
210
|
+
trajectory_id: typeof hookPayload.trajectory_id === "string" ? hookPayload.trajectory_id : "",
|
|
211
|
+
execution_id: typeof hookPayload.execution_id === "string" ? hookPayload.execution_id : "",
|
|
212
|
+
model_name: typeof hookPayload.model_name === "string" ? hookPayload.model_name : "",
|
|
213
|
+
tool_info: toolInfoSerialized,
|
|
214
|
+
source: "windsurf",
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
/** @type {Record<string, unknown>} */
|
|
218
|
+
const payload = {
|
|
219
|
+
agent: config.agentName,
|
|
220
|
+
service,
|
|
221
|
+
actionType,
|
|
222
|
+
status: "approved",
|
|
223
|
+
metadata,
|
|
224
|
+
platform: "windsurf",
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
await postJson(config.baseUrl, config.apiKey, payload);
|
|
229
|
+
} catch (e) {
|
|
230
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
231
|
+
process.stderr.write(
|
|
232
|
+
`${LOG_PREFIX} Warning: failed to log action to Shield audit trail. Check your network connection and that your API key in ~/.multicorn/config.json is valid.\n Detail: ${msg}\n`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
process.exit(0);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
main().catch((e) => {
|
|
240
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
241
|
+
process.stderr.write(
|
|
242
|
+
`${LOG_PREFIX} Warning: failed to log action to Shield audit trail. Check your network connection and that your API key in ~/.multicorn/config.json is valid.\n Detail: ${msg}\n`,
|
|
243
|
+
);
|
|
244
|
+
process.exit(0);
|
|
245
|
+
});
|
|
@@ -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
|
+
});
|