multicorn-shield 0.8.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';
@@ -386,6 +387,92 @@ async function isWindsurfConnected() {
386
387
  return false;
387
388
  }
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
+ }
389
476
  var PLATFORM_LABELS = ["OpenClaw", "Claude Code", "Cursor", "Windsurf", "Local MCP / Other"];
390
477
  var PLATFORM_BY_SELECTION = {
391
478
  1: "openclaw",
@@ -430,6 +517,23 @@ async function promptPlatformSelection(ask) {
430
517
  }
431
518
  return selection;
432
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
+ }
433
537
  async function promptAgentName(ask, platform) {
434
538
  const defaultAgentName = DEFAULT_AGENT_NAMES[platform] ?? "my-agent";
435
539
  let agentName = "";
@@ -823,6 +927,80 @@ An agent for ${selectedLabel} already exists: ${style.cyan(existingForPlatform.n
823
927
  agentName
824
928
  });
825
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
+ }
826
1004
  } else {
827
1005
  const { targetUrl, shortName } = await promptProxyConfig(ask, agentName);
828
1006
  let proxyUrl = "";
@@ -925,14 +1103,20 @@ An agent for ${selectedLabel} already exists: ${style.cyan(existingForPlatform.n
925
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"
926
1104
  );
927
1105
  }
928
- if (configuredPlatforms.has("windsurf")) {
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) {
929
1113
  blocks.push(
930
- "\n" + style.bold("To complete your Windsurf 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"
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"
931
1115
  );
932
1116
  }
933
- if (configuredPlatforms.has("windsurf")) {
1117
+ if (windsurfHostedConfigured) {
934
1118
  blocks.push(
935
- "\n" + style.bold("To complete your Windsurf setup:") + "\n Config file: " + style.cyan("~/.codeium/windsurf/mcp_config.json") + "\n Restart Windsurf to load the new MCP server.\n"
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"
936
1120
  );
937
1121
  }
938
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.8.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.8.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
+ });