multicorn-shield 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +365 -0
- package/LICENSE +1 -1
- package/README.md +29 -1
- package/dist/index.cjs +295 -27
- package/dist/index.d.cts +105 -1
- package/dist/index.d.ts +105 -1
- package/dist/index.js +293 -28
- package/dist/multicorn-proxy.js +190 -6
- package/dist/multicorn-shield.js +1 -0
- package/dist/shield-extension.js +2 -1
- package/package.json +9 -2
- package/plugins/windsurf/README.md +54 -0
- package/plugins/windsurf/hooks/scripts/post-action.cjs +245 -0
- package/plugins/windsurf/hooks/scripts/pre-action.cjs +646 -0
|
@@ -0,0 +1,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
|
+
});
|