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.
@@ -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.