multicorn-shield 1.2.0 → 1.3.1
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 +22 -1
- package/dist/multicorn-proxy.js +263 -179
- package/dist/multicorn-shield.js +263 -179
- package/dist/openclaw-hook/handler.js +3 -3
- package/dist/openclaw-plugin/multicorn-shield.js +3 -3
- package/dist/shield-extension.js +14 -19
- package/package.json +2 -1
- package/plugins/multicorn-shield/.claude-plugin/plugin.json +8 -0
- package/plugins/multicorn-shield/hooks/hooks.json +26 -0
- package/plugins/multicorn-shield/hooks/scripts/claude-code-tool-map.cjs +138 -0
- package/plugins/multicorn-shield/hooks/scripts/post-tool-use.cjs +253 -0
- package/plugins/multicorn-shield/hooks/scripts/pre-tool-use.cjs +690 -0
- package/plugins/multicorn-shield/skills/shield-governance/SKILL.md +24 -0
- package/plugins/windsurf/hooks/scripts/pre-action.cjs +69 -25
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code PreToolUse hook: asks Shield whether a tool call is allowed.
|
|
3
|
+
* Fail-closed on API errors once config is loaded. Fail-open only if Shield is not configured (no config file, no API key).
|
|
4
|
+
*
|
|
5
|
+
* Tool → service/actionType mapping lives in `src/hooks/claude-code-tool-map.ts` and is bundled to
|
|
6
|
+
* `./claude-code-tool-map.cjs` (run `pnpm build` in this package after changing the map).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
"use strict";
|
|
10
|
+
|
|
11
|
+
const { execFileSync, execSync } = require("node:child_process");
|
|
12
|
+
const fs = require("node:fs");
|
|
13
|
+
const http = require("node:http");
|
|
14
|
+
const https = require("node:https");
|
|
15
|
+
const os = require("node:os");
|
|
16
|
+
const path = require("node:path");
|
|
17
|
+
|
|
18
|
+
const { mapClaudeCodeToolToShield } = require("./claude-code-tool-map.cjs");
|
|
19
|
+
|
|
20
|
+
const AUTH_HEADER = "X-Multicorn-Key";
|
|
21
|
+
const HOOK_TEST_FAST_POLL = process.env.MULTICORN_SHIELD_PRE_HOOK_TEST_FAST_POLL === "1";
|
|
22
|
+
const POLL_INTERVAL_MS = HOOK_TEST_FAST_POLL ? 1 : 3000;
|
|
23
|
+
const MAX_APPROVAL_POLLS = HOOK_TEST_FAST_POLL ? 3 : 100;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @returns {Promise<string>}
|
|
27
|
+
*/
|
|
28
|
+
function readStdin() {
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
const chunks = [];
|
|
31
|
+
process.stdin.setEncoding("utf8");
|
|
32
|
+
process.stdin.on("data", (c) => chunks.push(c));
|
|
33
|
+
process.stdin.on("end", () => resolve(chunks.join("")));
|
|
34
|
+
process.stdin.on("error", reject);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Agent resolution for Claude Code (duplicated in post-tool-use.cjs).
|
|
39
|
+
/**
|
|
40
|
+
* @param {string} cwdResolved
|
|
41
|
+
* @param {string} workspacePath
|
|
42
|
+
* @returns {boolean}
|
|
43
|
+
*/
|
|
44
|
+
function cwdUnderWorkspacePath(cwdResolved, workspacePath) {
|
|
45
|
+
const w = path.resolve(workspacePath);
|
|
46
|
+
if (cwdResolved === w) return true;
|
|
47
|
+
const prefix = w.endsWith(path.sep) ? w : w + path.sep;
|
|
48
|
+
return cwdResolved.startsWith(prefix);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {Record<string, unknown>} obj
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
function resolveClaudeCodeAgentName(obj) {
|
|
56
|
+
const cwdRaw =
|
|
57
|
+
process.env.PWD !== undefined && String(process.env.PWD).length > 0
|
|
58
|
+
? process.env.PWD
|
|
59
|
+
: process.cwd();
|
|
60
|
+
const agents = obj.agents;
|
|
61
|
+
const defaultAgentRaw = obj.defaultAgent;
|
|
62
|
+
const defaultAgentName =
|
|
63
|
+
typeof defaultAgentRaw === "string" && defaultAgentRaw.length > 0 ? defaultAgentRaw : "";
|
|
64
|
+
|
|
65
|
+
if (!Array.isArray(agents)) {
|
|
66
|
+
return typeof obj.agentName === "string" ? obj.agentName : "";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const matches = [];
|
|
70
|
+
for (const entry of agents) {
|
|
71
|
+
if (
|
|
72
|
+
entry &&
|
|
73
|
+
typeof entry === "object" &&
|
|
74
|
+
/** @type {{ platform?: string; name?: string; workspacePath?: string }} */ (entry)
|
|
75
|
+
.platform === "claude-code" &&
|
|
76
|
+
typeof (/** @type {{ name?: string }} */ (entry).name) === "string"
|
|
77
|
+
) {
|
|
78
|
+
matches.push(/** @type {{ name: string; workspacePath?: string }} */ (entry));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (matches.length === 0) {
|
|
83
|
+
return typeof obj.agentName === "string" ? obj.agentName : "";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const withWs = matches.filter(
|
|
87
|
+
(m) => typeof m.workspacePath === "string" && m.workspacePath.length > 0,
|
|
88
|
+
);
|
|
89
|
+
const resolvedCwd = path.resolve(cwdRaw);
|
|
90
|
+
let best = null;
|
|
91
|
+
let bestLen = -1;
|
|
92
|
+
for (const m of withWs) {
|
|
93
|
+
if (!cwdUnderWorkspacePath(resolvedCwd, /** @type {string} */ (m.workspacePath))) continue;
|
|
94
|
+
const len = path.resolve(/** @type {string} */ (m.workspacePath)).length;
|
|
95
|
+
if (len > bestLen) {
|
|
96
|
+
bestLen = len;
|
|
97
|
+
best = m;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (best !== null) {
|
|
101
|
+
return best.name;
|
|
102
|
+
}
|
|
103
|
+
if (defaultAgentName.length > 0) {
|
|
104
|
+
const d = matches.find((m) => m.name === defaultAgentName);
|
|
105
|
+
if (d !== undefined) return d.name;
|
|
106
|
+
}
|
|
107
|
+
return matches[0].name;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @returns {{ apiKey: string; baseUrl: string; agentName: string } | null}
|
|
112
|
+
*/
|
|
113
|
+
function loadConfig() {
|
|
114
|
+
try {
|
|
115
|
+
const configPath = path.join(os.homedir(), ".multicorn", "config.json");
|
|
116
|
+
const raw = fs.readFileSync(configPath, "utf8");
|
|
117
|
+
const obj = JSON.parse(raw);
|
|
118
|
+
const apiKey = typeof obj.apiKey === "string" ? obj.apiKey : "";
|
|
119
|
+
const baseUrl =
|
|
120
|
+
typeof obj.baseUrl === "string" && obj.baseUrl.length > 0
|
|
121
|
+
? obj.baseUrl.replace(/\/+$/, "")
|
|
122
|
+
: "https://api.multicorn.ai";
|
|
123
|
+
const agentName = resolveClaudeCodeAgentName(obj);
|
|
124
|
+
return { apiKey, baseUrl, agentName };
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Dashboard web app origin (not API origin).
|
|
132
|
+
* @param {string} apiBaseUrl
|
|
133
|
+
* @returns {string}
|
|
134
|
+
*/
|
|
135
|
+
function dashboardOrigin(apiBaseUrl) {
|
|
136
|
+
try {
|
|
137
|
+
const raw = String(apiBaseUrl).replace(/\/+$/, "");
|
|
138
|
+
const lower = raw.toLowerCase();
|
|
139
|
+
if (lower.includes("localhost:8080") || lower.includes("127.0.0.1:8080")) {
|
|
140
|
+
return "http://localhost:5173";
|
|
141
|
+
}
|
|
142
|
+
const u = new URL(raw);
|
|
143
|
+
if (u.hostname.startsWith("api.")) {
|
|
144
|
+
u.hostname = "app." + u.hostname.slice(4);
|
|
145
|
+
}
|
|
146
|
+
return u.origin;
|
|
147
|
+
} catch {
|
|
148
|
+
return "https://app.multicorn.ai";
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @param {string} apiBaseUrl
|
|
154
|
+
* @returns {string}
|
|
155
|
+
*/
|
|
156
|
+
function dashboardHintUrl(apiBaseUrl) {
|
|
157
|
+
return `${dashboardOrigin(apiBaseUrl)}/approvals`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* @param {string} apiBaseUrl
|
|
162
|
+
* @param {string} agentName
|
|
163
|
+
* @param {string} service
|
|
164
|
+
* @param {string} actionType
|
|
165
|
+
* @returns {string}
|
|
166
|
+
*/
|
|
167
|
+
function consentUrl(apiBaseUrl, agentName, service, actionType) {
|
|
168
|
+
const origin = dashboardOrigin(apiBaseUrl);
|
|
169
|
+
const params = new URLSearchParams();
|
|
170
|
+
params.set("agent", agentName);
|
|
171
|
+
params.set("scopes", `${service}:${actionType}`);
|
|
172
|
+
params.set("platform", "claude-code");
|
|
173
|
+
return `${origin}/consent?${params.toString()}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* @param {string} baseUrl
|
|
178
|
+
* @param {string} apiKey
|
|
179
|
+
* @param {string} path
|
|
180
|
+
* @returns {Promise<{ statusCode: number; bodyText: string }>}
|
|
181
|
+
*/
|
|
182
|
+
function getJson(baseUrl, apiKey, path) {
|
|
183
|
+
return new Promise((resolve, reject) => {
|
|
184
|
+
let u;
|
|
185
|
+
try {
|
|
186
|
+
const root = String(baseUrl).replace(/\/+$/, "");
|
|
187
|
+
const p = path.startsWith("/") ? path : `/${path}`;
|
|
188
|
+
u = new URL(`${root}${p}`);
|
|
189
|
+
} catch (e) {
|
|
190
|
+
reject(e);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const isHttps = u.protocol === "https:";
|
|
194
|
+
const lib = isHttps ? https : http;
|
|
195
|
+
const port = u.port || (isHttps ? 443 : 80);
|
|
196
|
+
const options = {
|
|
197
|
+
hostname: u.hostname,
|
|
198
|
+
port,
|
|
199
|
+
path: u.pathname + u.search,
|
|
200
|
+
method: "GET",
|
|
201
|
+
headers: {
|
|
202
|
+
[AUTH_HEADER]: apiKey,
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
const req = lib.request(options, (res) => {
|
|
206
|
+
const chunks = [];
|
|
207
|
+
res.on("data", (c) => chunks.push(c));
|
|
208
|
+
res.on("end", () => {
|
|
209
|
+
resolve({
|
|
210
|
+
statusCode: res.statusCode ?? 0,
|
|
211
|
+
bodyText: Buffer.concat(chunks).toString("utf8"),
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
req.on("error", reject);
|
|
216
|
+
req.end();
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* @param {string} baseUrl
|
|
222
|
+
* @param {string} apiKey
|
|
223
|
+
* @param {Record<string, unknown>} bodyObj
|
|
224
|
+
* @returns {Promise<{ statusCode: number; bodyText: string }>}
|
|
225
|
+
*/
|
|
226
|
+
function postJson(baseUrl, apiKey, bodyObj) {
|
|
227
|
+
return new Promise((resolve, reject) => {
|
|
228
|
+
let u;
|
|
229
|
+
try {
|
|
230
|
+
const root = String(baseUrl).replace(/\/+$/, "");
|
|
231
|
+
u = new URL(`${root}/api/v1/actions`);
|
|
232
|
+
} catch (e) {
|
|
233
|
+
reject(e);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const payload = JSON.stringify(bodyObj);
|
|
237
|
+
const isHttps = u.protocol === "https:";
|
|
238
|
+
const lib = isHttps ? https : http;
|
|
239
|
+
const port = u.port || (isHttps ? 443 : 80);
|
|
240
|
+
const options = {
|
|
241
|
+
hostname: u.hostname,
|
|
242
|
+
port,
|
|
243
|
+
path: u.pathname + u.search,
|
|
244
|
+
method: "POST",
|
|
245
|
+
headers: {
|
|
246
|
+
"Content-Type": "application/json",
|
|
247
|
+
"Content-Length": Buffer.byteLength(payload, "utf8"),
|
|
248
|
+
[AUTH_HEADER]: apiKey,
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
const req = lib.request(options, (res) => {
|
|
252
|
+
const chunks = [];
|
|
253
|
+
res.on("data", (c) => chunks.push(c));
|
|
254
|
+
res.on("end", () => {
|
|
255
|
+
resolve({
|
|
256
|
+
statusCode: res.statusCode ?? 0,
|
|
257
|
+
bodyText: Buffer.concat(chunks).toString("utf8"),
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
req.on("error", reject);
|
|
262
|
+
req.write(payload);
|
|
263
|
+
req.end();
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* @param {string} text
|
|
269
|
+
* @returns {unknown}
|
|
270
|
+
*/
|
|
271
|
+
function safeJsonParse(text) {
|
|
272
|
+
try {
|
|
273
|
+
return JSON.parse(text);
|
|
274
|
+
} catch {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* @param {unknown} body
|
|
281
|
+
* @returns {unknown}
|
|
282
|
+
*/
|
|
283
|
+
function unwrapData(body) {
|
|
284
|
+
if (typeof body !== "object" || body === null) return null;
|
|
285
|
+
const o = /** @type {Record<string, unknown>} */ (body);
|
|
286
|
+
return o.success === true ? o.data : null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* @param {unknown} data
|
|
291
|
+
* @param {string} service
|
|
292
|
+
* @param {string} actionType
|
|
293
|
+
* @param {string} approvalsUrl
|
|
294
|
+
* @returns {string}
|
|
295
|
+
*/
|
|
296
|
+
function blockedMessage(data, service, actionType, approvalsUrl) {
|
|
297
|
+
if (data !== null && typeof data === "object") {
|
|
298
|
+
const d = /** @type {Record<string, unknown>} */ (data);
|
|
299
|
+
const meta = d.metadata;
|
|
300
|
+
if (typeof meta === "string" && meta.length > 0) {
|
|
301
|
+
try {
|
|
302
|
+
const parsed = JSON.parse(meta);
|
|
303
|
+
if (parsed !== null && typeof parsed === "object" && "block_reason" in parsed) {
|
|
304
|
+
const br = /** @type {Record<string, unknown>} */ (parsed).block_reason;
|
|
305
|
+
if (typeof br === "string" && br.length > 0) {
|
|
306
|
+
return (
|
|
307
|
+
`[multicorn-shield] PreToolUse: Action blocked: ${br}\n` +
|
|
308
|
+
` Grant access in the Shield dashboard and retry.\n` +
|
|
309
|
+
` Detail: ${approvalsUrl}\n`
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
} catch {
|
|
314
|
+
/* ignore */
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return (
|
|
319
|
+
`[multicorn-shield] PreToolUse: Action blocked: Multicorn Shield blocked this tool call. Required permission: ${service} (${actionType}).\n` +
|
|
320
|
+
` Grant access in the Shield dashboard and retry.\n` +
|
|
321
|
+
` Detail: ${approvalsUrl}\n`
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* @param {string} agentName
|
|
327
|
+
* @returns {string}
|
|
328
|
+
*/
|
|
329
|
+
function consentMarkerPath(agentName) {
|
|
330
|
+
const safe = agentName.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
331
|
+
return path.join(os.homedir(), ".multicorn", `.consent-${safe}`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* @param {string} agentName
|
|
336
|
+
* @returns {boolean}
|
|
337
|
+
*/
|
|
338
|
+
function hasConsentMarker(agentName) {
|
|
339
|
+
try {
|
|
340
|
+
fs.accessSync(consentMarkerPath(agentName));
|
|
341
|
+
return true;
|
|
342
|
+
} catch {
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* @param {string} agentName
|
|
349
|
+
*/
|
|
350
|
+
function writeConsentMarker(agentName) {
|
|
351
|
+
try {
|
|
352
|
+
const marker = consentMarkerPath(agentName);
|
|
353
|
+
fs.mkdirSync(path.dirname(marker), { recursive: true });
|
|
354
|
+
fs.writeFileSync(marker, String(Date.now()), "utf8");
|
|
355
|
+
} catch {
|
|
356
|
+
/* ignore */
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* @param {string} agentName
|
|
362
|
+
*/
|
|
363
|
+
function removeConsentMarker(agentName) {
|
|
364
|
+
try {
|
|
365
|
+
fs.unlinkSync(consentMarkerPath(agentName));
|
|
366
|
+
} catch {
|
|
367
|
+
/* ignore */
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* @param {string} url
|
|
373
|
+
*/
|
|
374
|
+
function openBrowser(url) {
|
|
375
|
+
try {
|
|
376
|
+
if (process.platform === "win32") {
|
|
377
|
+
execSync(`start "" ${JSON.stringify(url)}`, {
|
|
378
|
+
shell: true,
|
|
379
|
+
stdio: "ignore",
|
|
380
|
+
windowsHide: true,
|
|
381
|
+
});
|
|
382
|
+
} else if (process.platform === "darwin") {
|
|
383
|
+
execFileSync("open", [url], { stdio: "ignore" });
|
|
384
|
+
} else {
|
|
385
|
+
execFileSync("xdg-open", [url], { stdio: "ignore" });
|
|
386
|
+
}
|
|
387
|
+
} catch {
|
|
388
|
+
/* ignore */
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* @param {number} ms
|
|
394
|
+
* @returns {Promise<void>}
|
|
395
|
+
*/
|
|
396
|
+
function sleep(ms) {
|
|
397
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Polls GET /api/v1/approvals/{id} until the approval is decided or timeout.
|
|
402
|
+
* Returns true if approved (caller should exit 0), false on error/unknown.
|
|
403
|
+
* Exits the process on denial/expiry.
|
|
404
|
+
*
|
|
405
|
+
* @param {{ apiKey: string; baseUrl: string; agentName: string }} config
|
|
406
|
+
* @param {string} approvalId
|
|
407
|
+
* @param {string} approvalsUrl
|
|
408
|
+
* @returns {Promise<boolean>}
|
|
409
|
+
*/
|
|
410
|
+
async function pollApprovalStatus(config, approvalId, approvalsUrl) {
|
|
411
|
+
for (let i = 0; i < MAX_APPROVAL_POLLS; i++) {
|
|
412
|
+
if (i > 0) {
|
|
413
|
+
await sleep(POLL_INTERVAL_MS);
|
|
414
|
+
}
|
|
415
|
+
let statusCode;
|
|
416
|
+
let bodyText;
|
|
417
|
+
try {
|
|
418
|
+
const res = await getJson(config.baseUrl, config.apiKey, `/api/v1/approvals/${approvalId}`);
|
|
419
|
+
statusCode = res.statusCode;
|
|
420
|
+
bodyText = res.bodyText;
|
|
421
|
+
} catch {
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
if (statusCode < 200 || statusCode >= 300) {
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
const parsed = safeJsonParse(bodyText);
|
|
428
|
+
const data = unwrapData(parsed);
|
|
429
|
+
if (data === null || typeof data !== "object") {
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
const d = /** @type {Record<string, unknown>} */ (data);
|
|
433
|
+
const st = String(d.status ?? "").toLowerCase();
|
|
434
|
+
if (st === "approved") {
|
|
435
|
+
return true;
|
|
436
|
+
}
|
|
437
|
+
if (st === "blocked" || st === "denied" || st === "rejected") {
|
|
438
|
+
const reason =
|
|
439
|
+
typeof d.reason === "string" && d.reason.length > 0 ? d.reason : "Approval denied.";
|
|
440
|
+
process.stderr.write(
|
|
441
|
+
`[multicorn-shield] PreToolUse: Action blocked: Shield denied this approval request.\n` +
|
|
442
|
+
` Request access again from the Shield dashboard and retry.\n` +
|
|
443
|
+
` Detail: ${reason}\n`,
|
|
444
|
+
);
|
|
445
|
+
process.exit(2);
|
|
446
|
+
}
|
|
447
|
+
if (st === "expired") {
|
|
448
|
+
process.stderr.write(
|
|
449
|
+
`[multicorn-shield] PreToolUse: Action blocked: this approval request expired.\n` +
|
|
450
|
+
` Start the tool call again and complete approval when prompted.\n` +
|
|
451
|
+
` Detail: status=expired\n`,
|
|
452
|
+
);
|
|
453
|
+
process.exit(2);
|
|
454
|
+
}
|
|
455
|
+
if (st === "pending") {
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return false;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* @param {{ apiKey: string; baseUrl: string; agentName: string }} config
|
|
464
|
+
* @param {string} approvalId
|
|
465
|
+
* @param {string} service
|
|
466
|
+
* @param {string} actionType
|
|
467
|
+
* @returns {Promise<void>}
|
|
468
|
+
*/
|
|
469
|
+
async function handlePendingWithConsentAndPoll(
|
|
470
|
+
config,
|
|
471
|
+
approvalId,
|
|
472
|
+
service,
|
|
473
|
+
actionType,
|
|
474
|
+
approvalsUrl,
|
|
475
|
+
) {
|
|
476
|
+
if (hasConsentMarker(config.agentName)) {
|
|
477
|
+
// Consent was previously completed. Poll for the approval decision.
|
|
478
|
+
// If the marker is stale (agent was re-created with no permissions),
|
|
479
|
+
// the API will keep returning "pending" and we'll detect it below.
|
|
480
|
+
process.stderr.write(
|
|
481
|
+
`[multicorn-shield] PreToolUse: Waiting for approval (up to 5 min)...\n` +
|
|
482
|
+
` Approve in the Shield dashboard: ${approvalsUrl}\n`,
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
const approved = await pollApprovalStatus(config, approvalId, approvalsUrl);
|
|
486
|
+
if (approved) {
|
|
487
|
+
process.exit(0);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Timed out waiting. The consent marker may be stale (agent re-created
|
|
491
|
+
// on the server without permissions). Remove it so the next tool call
|
|
492
|
+
// triggers the consent flow instead of looping on approvals forever.
|
|
493
|
+
removeConsentMarker(config.agentName);
|
|
494
|
+
|
|
495
|
+
process.stderr.write(
|
|
496
|
+
`[multicorn-shield] PreToolUse: Action blocked: approval timed out after 5 minutes.\n` +
|
|
497
|
+
` Approve in the Shield dashboard, then retry the tool call.\n` +
|
|
498
|
+
` Detail: approvalsUrl=${approvalsUrl}\n`,
|
|
499
|
+
);
|
|
500
|
+
process.exit(2);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// No consent marker: first-time flow. Open the consent screen.
|
|
504
|
+
const url = consentUrl(config.baseUrl, config.agentName, service, actionType);
|
|
505
|
+
writeConsentMarker(config.agentName);
|
|
506
|
+
openBrowser(url);
|
|
507
|
+
process.stderr.write("Opening Shield consent screen... Waiting for approval (up to 5 min).\n");
|
|
508
|
+
|
|
509
|
+
const approved = await pollApprovalStatus(config, approvalId, approvalsUrl);
|
|
510
|
+
if (approved) {
|
|
511
|
+
process.exit(0);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
process.stderr.write(
|
|
515
|
+
`[multicorn-shield] PreToolUse: Action blocked: approval timed out after 5 minutes.\n` +
|
|
516
|
+
` Approve in the Shield dashboard, then retry the tool call.\n` +
|
|
517
|
+
` Detail: approvalsUrl=${approvalsUrl}\n`,
|
|
518
|
+
);
|
|
519
|
+
process.exit(2);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async function main() {
|
|
523
|
+
let raw;
|
|
524
|
+
try {
|
|
525
|
+
raw = await readStdin();
|
|
526
|
+
} catch (e) {
|
|
527
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
528
|
+
process.stderr.write(
|
|
529
|
+
`[multicorn-shield] PreToolUse: could not read stdin (${msg}). Allowing tool.\n`,
|
|
530
|
+
);
|
|
531
|
+
process.exit(0);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const config = loadConfig();
|
|
535
|
+
if (config === null) {
|
|
536
|
+
process.exit(0);
|
|
537
|
+
}
|
|
538
|
+
if (config.apiKey.length === 0 || config.agentName.length === 0) {
|
|
539
|
+
process.exit(0);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** @type {Record<string, unknown>} */
|
|
543
|
+
let hookPayload;
|
|
544
|
+
try {
|
|
545
|
+
hookPayload = JSON.parse(raw.length > 0 ? raw : "{}");
|
|
546
|
+
} catch (e) {
|
|
547
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
548
|
+
process.stderr.write(`[multicorn-shield] PreToolUse: invalid JSON (${msg}). Allowing tool.\n`);
|
|
549
|
+
process.exit(0);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (process.env.MULTICORN_SHIELD_PRE_HOOK_TEST_SERIALIZE_FAIL === "1") {
|
|
553
|
+
hookPayload.tool_input = {
|
|
554
|
+
toJSON() {
|
|
555
|
+
throw new TypeError("MULTICORN_SHIELD_PRE_HOOK_TEST_SERIALIZE_FAIL");
|
|
556
|
+
},
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const toolNameRaw =
|
|
561
|
+
(typeof hookPayload.tool_name === "string" && hookPayload.tool_name) ||
|
|
562
|
+
(typeof hookPayload.toolName === "string" && hookPayload.toolName) ||
|
|
563
|
+
"";
|
|
564
|
+
const toolInput =
|
|
565
|
+
hookPayload.tool_input !== undefined ? hookPayload.tool_input : hookPayload.toolInput;
|
|
566
|
+
|
|
567
|
+
let toolInputSerialized;
|
|
568
|
+
try {
|
|
569
|
+
toolInputSerialized =
|
|
570
|
+
typeof toolInput === "string"
|
|
571
|
+
? toolInput
|
|
572
|
+
: JSON.stringify(toolInput === undefined ? null : toolInput);
|
|
573
|
+
} catch (e) {
|
|
574
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
575
|
+
process.stderr.write(
|
|
576
|
+
`[multicorn-shield] PreToolUse: could not serialize tool_input (${msg}). Allowing tool.\n`,
|
|
577
|
+
);
|
|
578
|
+
process.exit(0);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const { service, actionType } = mapClaudeCodeToolToShield(toolNameRaw, toolInput);
|
|
582
|
+
const approvalsUrl = dashboardHintUrl(config.baseUrl);
|
|
583
|
+
|
|
584
|
+
/** @type {Record<string, unknown>} */
|
|
585
|
+
const metadata = {
|
|
586
|
+
tool_name: toolNameRaw,
|
|
587
|
+
tool_input: toolInputSerialized,
|
|
588
|
+
source: "claude-code",
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
/** @type {Record<string, unknown>} */
|
|
592
|
+
const payload = {
|
|
593
|
+
agent: config.agentName,
|
|
594
|
+
service,
|
|
595
|
+
actionType,
|
|
596
|
+
status: "pending",
|
|
597
|
+
metadata,
|
|
598
|
+
platform: "claude-code",
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
if (process.env.MULTICORN_SHIELD_PRE_HOOK_TEST_THROW === "1") {
|
|
602
|
+
throw new Error("MULTICORN_SHIELD_PRE_HOOK_TEST_THROW");
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
let statusCode;
|
|
606
|
+
let bodyText;
|
|
607
|
+
try {
|
|
608
|
+
const res = await postJson(config.baseUrl, config.apiKey, payload);
|
|
609
|
+
statusCode = res.statusCode;
|
|
610
|
+
bodyText = res.bodyText;
|
|
611
|
+
} catch (e) {
|
|
612
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
613
|
+
process.stderr.write(
|
|
614
|
+
`[multicorn-shield] PreToolUse: Action blocked: Shield API unreachable, cannot verify permissions.\n` +
|
|
615
|
+
` Check that the Shield service is running and retry.\n` +
|
|
616
|
+
` Detail: ${msg}\n`,
|
|
617
|
+
);
|
|
618
|
+
process.exit(2);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const parsed = safeJsonParse(bodyText);
|
|
622
|
+
const data = unwrapData(parsed);
|
|
623
|
+
|
|
624
|
+
if (statusCode === 202) {
|
|
625
|
+
if (data === null || typeof data !== "object") {
|
|
626
|
+
process.stderr.write(
|
|
627
|
+
`[multicorn-shield] PreToolUse: Action blocked: this action needs approval in the Shield dashboard before it can run.\n` +
|
|
628
|
+
` Open the approvals page and complete approval, then retry.\n` +
|
|
629
|
+
` Detail: missing approval data in Shield response\n`,
|
|
630
|
+
);
|
|
631
|
+
process.exit(2);
|
|
632
|
+
}
|
|
633
|
+
const approvalIdRaw = /** @type {Record<string, unknown>} */ (data).approval_id;
|
|
634
|
+
const approvalId = typeof approvalIdRaw === "string" ? approvalIdRaw : "";
|
|
635
|
+
if (approvalId.length === 0) {
|
|
636
|
+
process.stderr.write(
|
|
637
|
+
`[multicorn-shield] PreToolUse: Action blocked: this action needs approval in the Shield dashboard before it can run.\n` +
|
|
638
|
+
` Open the approvals page and complete approval, then retry.\n` +
|
|
639
|
+
` Detail: approval_id missing in Shield response\n`,
|
|
640
|
+
);
|
|
641
|
+
process.exit(2);
|
|
642
|
+
}
|
|
643
|
+
await handlePendingWithConsentAndPoll(config, approvalId, service, actionType, approvalsUrl);
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (statusCode === 201) {
|
|
648
|
+
if (data === null || typeof data !== "object") {
|
|
649
|
+
const detail = bodyText.length > 500 ? `${bodyText.slice(0, 500)}...` : bodyText;
|
|
650
|
+
process.stderr.write(
|
|
651
|
+
`[multicorn-shield] PreToolUse: Action blocked: unexpected Shield response, cannot verify permissions.\n` +
|
|
652
|
+
` Check that the Shield service is healthy and retry.\n` +
|
|
653
|
+
` Detail: ${detail}\n`,
|
|
654
|
+
);
|
|
655
|
+
process.exit(2);
|
|
656
|
+
}
|
|
657
|
+
const st = String(/** @type {Record<string, unknown>} */ (data).status || "").toLowerCase();
|
|
658
|
+
if (st === "approved") {
|
|
659
|
+
process.exit(0);
|
|
660
|
+
}
|
|
661
|
+
if (st === "blocked") {
|
|
662
|
+
process.stderr.write(blockedMessage(data, service, actionType, approvalsUrl));
|
|
663
|
+
process.exit(2);
|
|
664
|
+
}
|
|
665
|
+
process.stderr.write(
|
|
666
|
+
`[multicorn-shield] PreToolUse: Action blocked: ambiguous Shield status, cannot verify permissions.\n` +
|
|
667
|
+
` Check that your Shield API and plugin versions match, then retry.\n` +
|
|
668
|
+
` Detail: status=${JSON.stringify(/** @type {Record<string, unknown>} */ (data).status)}\n`,
|
|
669
|
+
);
|
|
670
|
+
process.exit(2);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const httpDetail = bodyText.length > 300 ? `${bodyText.slice(0, 300)}...` : bodyText;
|
|
674
|
+
process.stderr.write(
|
|
675
|
+
`[multicorn-shield] PreToolUse: Action blocked: Shield returned HTTP ${String(statusCode)}, cannot verify permissions.\n` +
|
|
676
|
+
` Check your API key, Shield service status, and rate limits, then retry.\n` +
|
|
677
|
+
` Detail: HTTP ${String(statusCode)} body=${httpDetail}\n`,
|
|
678
|
+
);
|
|
679
|
+
process.exit(2);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
main().catch((e) => {
|
|
683
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
684
|
+
process.stderr.write(
|
|
685
|
+
`[multicorn-shield] PreToolUse: Action blocked: unexpected error, cannot verify permissions.\n` +
|
|
686
|
+
` Retry the tool call. If it keeps failing, check Shield logs.\n` +
|
|
687
|
+
` Detail: ${msg}\n`,
|
|
688
|
+
);
|
|
689
|
+
process.exit(2);
|
|
690
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Shield Governance
|
|
2
|
+
|
|
3
|
+
You are working in a project protected by Multicorn Shield. Shield intercepts tool calls, checks permissions, and logs all actions for audit.
|
|
4
|
+
|
|
5
|
+
## What Shield does
|
|
6
|
+
|
|
7
|
+
- Intercepts tool calls before they execute
|
|
8
|
+
- Checks if the agent has the required permissions (read/write/execute per service)
|
|
9
|
+
- Enforces spending limits
|
|
10
|
+
- Logs all actions (approved and blocked) to a permanent, tamper-proof audit log
|
|
11
|
+
- Opens a consent screen in the browser when new permissions are needed
|
|
12
|
+
|
|
13
|
+
## How to work with Shield
|
|
14
|
+
|
|
15
|
+
- If a tool call is blocked, tell the user which permission is missing and suggest they grant it in the Shield dashboard at https://app.multicorn.ai
|
|
16
|
+
- Do not attempt to bypass or work around blocked actions
|
|
17
|
+
- If you see a "Permission denied" error from Shield, explain it clearly to the user
|
|
18
|
+
- Shield's consent screen will open automatically in the browser when new scopes are requested
|
|
19
|
+
|
|
20
|
+
## Configuration
|
|
21
|
+
|
|
22
|
+
Shield config is stored at `~/.multicorn/config.json`. The API key and base URL are configured there. Agent name is set during `npx multicorn-shield init`.
|
|
23
|
+
|
|
24
|
+
Note: These guidelines are advisory. Enforcement is handled by the Shield plugin's hook system, not by this skill file.
|