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.
@@ -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
- var PLATFORM_LABELS = ["OpenClaw", "Claude Code", "Cursor", "Local MCP / Other"];
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: "other-mcp"
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 4 if you want to wrap a local MCP server with multicorn-proxy --wrap.") + "\n"
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-4): ");
512
+ const input = await ask("Select (1-5): ");
403
513
  const num = parseInt(input.trim(), 10);
404
- if (num >= 1 && num <= 4) {
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 authHeader = platform === "cursor" ? `Bearer ${apiKey}` : "Bearer YOUR_SHIELD_API_KEY";
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
- url: routingToken,
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 (platform !== "cursor") {
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 === 4) {
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 \u2192 Restart Cursor to pick up MCP config changes\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) {
@@ -3,6 +3,7 @@ import { readFile, mkdir, writeFile } from 'fs/promises';
3
3
  import { join, dirname } from 'path';
4
4
  import 'fs';
5
5
  import { homedir } from 'os';
6
+ import 'url';
6
7
  import 'readline';
7
8
 
8
9
  var CONFIG_DIR = join(homedir(), ".multicorn");
@@ -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.7.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.7.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
+ });