multicorn-shield 0.12.0 → 0.13.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 CHANGED
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ### Added
11
+
12
+ - Gemini CLI native plugin: BeforeTool/AfterTool hook scripts for full governance
13
+ - Gemini CLI hosted proxy support with httpUrl config field
14
+ - CLI wizard: Gemini CLI platform with native plugin and hosted proxy integration modes
15
+ - CLI wizard: platform prerequisite detection (warns if target platform is not installed)
16
+
8
17
  ## [X.Y.Z] - YYYY-MM-DD
9
18
 
10
19
  ### Added
package/dist/index.cjs CHANGED
@@ -14,6 +14,18 @@ var __decorateClass = (decorators, target, key, kind) => {
14
14
  };
15
15
 
16
16
  // src/types/index.ts
17
+ var AGENT_PLATFORM_SLUGS = [
18
+ "openclaw",
19
+ "claude-code",
20
+ "claude-desktop",
21
+ "cursor",
22
+ "windsurf",
23
+ "cline",
24
+ "gemini-cli",
25
+ "other-mcp",
26
+ "github-actions",
27
+ "unknown"
28
+ ];
17
29
  var AGENT_STATUSES = {
18
30
  Active: "active",
19
31
  Paused: "paused",
@@ -2893,6 +2905,7 @@ function validateApiKey(apiKey) {
2893
2905
  }
2894
2906
 
2895
2907
  exports.ACTION_STATUSES = ACTION_STATUSES;
2908
+ exports.AGENT_PLATFORM_SLUGS = AGENT_PLATFORM_SLUGS;
2896
2909
  exports.AGENT_STATUSES = AGENT_STATUSES;
2897
2910
  exports.BUILT_IN_SERVICES = BUILT_IN_SERVICES;
2898
2911
  exports.CONSENT_ELEMENT_TAG = CONSENT_ELEMENT_TAG;
package/dist/index.d.cts CHANGED
@@ -9,6 +9,11 @@ import { LitElement, PropertyValues, HTMLTemplateResult } from 'lit';
9
9
  *
10
10
  * @module types
11
11
  */
12
+ /**
13
+ * Agent client platforms supported by hosted proxy and native hooks (aligned with API validation).
14
+ */
15
+ declare const AGENT_PLATFORM_SLUGS: readonly ["openclaw", "claude-code", "claude-desktop", "cursor", "windsurf", "cline", "gemini-cli", "other-mcp", "github-actions", "unknown"];
16
+ type AgentPlatformSlug = (typeof AGENT_PLATFORM_SLUGS)[number];
12
17
  /**
13
18
  * Possible operational states for an agent.
14
19
  *
@@ -2310,4 +2315,4 @@ interface ContentReviewRequestPayload {
2310
2315
  */
2311
2316
  declare function requestContentReview(payload: ContentReviewRequestPayload, apiKey: string, baseUrl: string, logger?: PluginLogger): Promise<ContentReviewResult>;
2312
2317
 
2313
- export { ACTION_STATUSES, AGENT_STATUSES, type Action, type ActionInput, type ActionLogger, type ActionLoggerConfig, type ActionPayload, type ActionStatus, type Agent, type AgentStatus, type ApiError, BUILT_IN_SERVICES, type BatchModeConfig, type BuiltInServiceName, CONSENT_ELEMENT_TAG, type ConsentDecision, type ConsentDeniedEventDetail, type ConsentEventDetail, type ConsentEventMap, type ConsentEventName, type ConsentGrantedEventDetail, type ConsentOptions, type ConsentPartialEventDetail, type ContentReviewRequestPayload, type ContentReviewResult, type ContentReviewStatusResponse, type FocusTrap, type McpAdapter, type McpAdapterConfig, type McpAdapterResult, type McpBlockedResult, type McpToolCall, type McpToolHandler, type McpToolResult, MulticornBadge, MulticornConsent, MulticornShield, type MulticornShieldConfig, PERMISSION_LEVELS, type Permission, type PermissionLevel, type RemainingBudget, SERVICE_NAME_PATTERN, type Scope, ScopeParseError, type ScopeParseResult, type ScopeRegistry, type ScopeRequest, type ServiceDefinition, type SpendCheckResult, type SpendingCheckResult, type SpendingChecker, type SpendingLimit, type SpendingLimits, type SpendingTrackerConfig, type ValidationResult, centsToDollars, createActionLogger, createFocusTrap, createMcpAdapter, createScopeRegistry, createSpendingChecker, dollarsToCents, formatScope, getPermissionLabel, getScopeLabel, getScopeShortLabel, getServiceDisplayName, getServiceIcon, hasScope, isBlockedResult, isPublicContentAction, isValidScopeString, parseScope, parseScopes, requestContentReview, requiresContentReview, tryParseScope, validateAllScopesAccess, validateScopeAccess };
2318
+ export { ACTION_STATUSES, AGENT_PLATFORM_SLUGS, AGENT_STATUSES, type Action, type ActionInput, type ActionLogger, type ActionLoggerConfig, type ActionPayload, type ActionStatus, type Agent, type AgentPlatformSlug, type AgentStatus, type ApiError, BUILT_IN_SERVICES, type BatchModeConfig, type BuiltInServiceName, CONSENT_ELEMENT_TAG, type ConsentDecision, type ConsentDeniedEventDetail, type ConsentEventDetail, type ConsentEventMap, type ConsentEventName, type ConsentGrantedEventDetail, type ConsentOptions, type ConsentPartialEventDetail, type ContentReviewRequestPayload, type ContentReviewResult, type ContentReviewStatusResponse, type FocusTrap, type McpAdapter, type McpAdapterConfig, type McpAdapterResult, type McpBlockedResult, type McpToolCall, type McpToolHandler, type McpToolResult, MulticornBadge, MulticornConsent, MulticornShield, type MulticornShieldConfig, PERMISSION_LEVELS, type Permission, type PermissionLevel, type RemainingBudget, SERVICE_NAME_PATTERN, type Scope, ScopeParseError, type ScopeParseResult, type ScopeRegistry, type ScopeRequest, type ServiceDefinition, type SpendCheckResult, type SpendingCheckResult, type SpendingChecker, type SpendingLimit, type SpendingLimits, type SpendingTrackerConfig, type ValidationResult, centsToDollars, createActionLogger, createFocusTrap, createMcpAdapter, createScopeRegistry, createSpendingChecker, dollarsToCents, formatScope, getPermissionLabel, getScopeLabel, getScopeShortLabel, getServiceDisplayName, getServiceIcon, hasScope, isBlockedResult, isPublicContentAction, isValidScopeString, parseScope, parseScopes, requestContentReview, requiresContentReview, tryParseScope, validateAllScopesAccess, validateScopeAccess };
package/dist/index.d.ts CHANGED
@@ -9,6 +9,11 @@ import { LitElement, PropertyValues, HTMLTemplateResult } from 'lit';
9
9
  *
10
10
  * @module types
11
11
  */
12
+ /**
13
+ * Agent client platforms supported by hosted proxy and native hooks (aligned with API validation).
14
+ */
15
+ declare const AGENT_PLATFORM_SLUGS: readonly ["openclaw", "claude-code", "claude-desktop", "cursor", "windsurf", "cline", "gemini-cli", "other-mcp", "github-actions", "unknown"];
16
+ type AgentPlatformSlug = (typeof AGENT_PLATFORM_SLUGS)[number];
12
17
  /**
13
18
  * Possible operational states for an agent.
14
19
  *
@@ -2310,4 +2315,4 @@ interface ContentReviewRequestPayload {
2310
2315
  */
2311
2316
  declare function requestContentReview(payload: ContentReviewRequestPayload, apiKey: string, baseUrl: string, logger?: PluginLogger): Promise<ContentReviewResult>;
2312
2317
 
2313
- export { ACTION_STATUSES, AGENT_STATUSES, type Action, type ActionInput, type ActionLogger, type ActionLoggerConfig, type ActionPayload, type ActionStatus, type Agent, type AgentStatus, type ApiError, BUILT_IN_SERVICES, type BatchModeConfig, type BuiltInServiceName, CONSENT_ELEMENT_TAG, type ConsentDecision, type ConsentDeniedEventDetail, type ConsentEventDetail, type ConsentEventMap, type ConsentEventName, type ConsentGrantedEventDetail, type ConsentOptions, type ConsentPartialEventDetail, type ContentReviewRequestPayload, type ContentReviewResult, type ContentReviewStatusResponse, type FocusTrap, type McpAdapter, type McpAdapterConfig, type McpAdapterResult, type McpBlockedResult, type McpToolCall, type McpToolHandler, type McpToolResult, MulticornBadge, MulticornConsent, MulticornShield, type MulticornShieldConfig, PERMISSION_LEVELS, type Permission, type PermissionLevel, type RemainingBudget, SERVICE_NAME_PATTERN, type Scope, ScopeParseError, type ScopeParseResult, type ScopeRegistry, type ScopeRequest, type ServiceDefinition, type SpendCheckResult, type SpendingCheckResult, type SpendingChecker, type SpendingLimit, type SpendingLimits, type SpendingTrackerConfig, type ValidationResult, centsToDollars, createActionLogger, createFocusTrap, createMcpAdapter, createScopeRegistry, createSpendingChecker, dollarsToCents, formatScope, getPermissionLabel, getScopeLabel, getScopeShortLabel, getServiceDisplayName, getServiceIcon, hasScope, isBlockedResult, isPublicContentAction, isValidScopeString, parseScope, parseScopes, requestContentReview, requiresContentReview, tryParseScope, validateAllScopesAccess, validateScopeAccess };
2318
+ export { ACTION_STATUSES, AGENT_PLATFORM_SLUGS, AGENT_STATUSES, type Action, type ActionInput, type ActionLogger, type ActionLoggerConfig, type ActionPayload, type ActionStatus, type Agent, type AgentPlatformSlug, type AgentStatus, type ApiError, BUILT_IN_SERVICES, type BatchModeConfig, type BuiltInServiceName, CONSENT_ELEMENT_TAG, type ConsentDecision, type ConsentDeniedEventDetail, type ConsentEventDetail, type ConsentEventMap, type ConsentEventName, type ConsentGrantedEventDetail, type ConsentOptions, type ConsentPartialEventDetail, type ContentReviewRequestPayload, type ContentReviewResult, type ContentReviewStatusResponse, type FocusTrap, type McpAdapter, type McpAdapterConfig, type McpAdapterResult, type McpBlockedResult, type McpToolCall, type McpToolHandler, type McpToolResult, MulticornBadge, MulticornConsent, MulticornShield, type MulticornShieldConfig, PERMISSION_LEVELS, type Permission, type PermissionLevel, type RemainingBudget, SERVICE_NAME_PATTERN, type Scope, ScopeParseError, type ScopeParseResult, type ScopeRegistry, type ScopeRequest, type ServiceDefinition, type SpendCheckResult, type SpendingCheckResult, type SpendingChecker, type SpendingLimit, type SpendingLimits, type SpendingTrackerConfig, type ValidationResult, centsToDollars, createActionLogger, createFocusTrap, createMcpAdapter, createScopeRegistry, createSpendingChecker, dollarsToCents, formatScope, getPermissionLabel, getScopeLabel, getScopeShortLabel, getServiceDisplayName, getServiceIcon, hasScope, isBlockedResult, isPublicContentAction, isValidScopeString, parseScope, parseScopes, requestContentReview, requiresContentReview, tryParseScope, validateAllScopesAccess, validateScopeAccess };
package/dist/index.js CHANGED
@@ -12,6 +12,18 @@ var __decorateClass = (decorators, target, key, kind) => {
12
12
  };
13
13
 
14
14
  // src/types/index.ts
15
+ var AGENT_PLATFORM_SLUGS = [
16
+ "openclaw",
17
+ "claude-code",
18
+ "claude-desktop",
19
+ "cursor",
20
+ "windsurf",
21
+ "cline",
22
+ "gemini-cli",
23
+ "other-mcp",
24
+ "github-actions",
25
+ "unknown"
26
+ ];
15
27
  var AGENT_STATUSES = {
16
28
  Active: "active",
17
29
  Paused: "paused",
@@ -2890,4 +2902,4 @@ function validateApiKey(apiKey) {
2890
2902
  }
2891
2903
  }
2892
2904
 
2893
- export { ACTION_STATUSES, AGENT_STATUSES, BUILT_IN_SERVICES, CONSENT_ELEMENT_TAG, MulticornBadge, MulticornConsent, MulticornShield, PERMISSION_LEVELS, SERVICE_NAME_PATTERN, ScopeParseError, centsToDollars, createActionLogger, createFocusTrap, createMcpAdapter, createScopeRegistry, createSpendingChecker, dollarsToCents, formatScope, getPermissionLabel, getScopeLabel, getScopeShortLabel, getServiceDisplayName, getServiceIcon, hasScope, isBlockedResult, isPublicContentAction, isValidScopeString, parseScope, parseScopes, requestContentReview, requiresContentReview, tryParseScope, validateAllScopesAccess, validateScopeAccess };
2905
+ export { ACTION_STATUSES, AGENT_PLATFORM_SLUGS, AGENT_STATUSES, BUILT_IN_SERVICES, CONSENT_ELEMENT_TAG, MulticornBadge, MulticornConsent, MulticornShield, PERMISSION_LEVELS, SERVICE_NAME_PATTERN, ScopeParseError, centsToDollars, createActionLogger, createFocusTrap, createMcpAdapter, createScopeRegistry, createSpendingChecker, dollarsToCents, formatScope, getPermissionLabel, getScopeLabel, getScopeShortLabel, getServiceDisplayName, getServiceIcon, hasScope, isBlockedResult, isPublicContentAction, isValidScopeString, parseScope, parseScopes, requestContentReview, requiresContentReview, tryParseScope, validateAllScopesAccess, validateScopeAccess };
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync } from 'fs';
2
+ import { existsSync, statSync } from 'fs';
3
3
  import { mkdir, writeFile, readFile, copyFile, chmod, unlink } from 'fs/promises';
4
4
  import { join, dirname } from 'path';
5
5
  import { homedir } from 'os';
@@ -43,6 +43,23 @@ function withSpinner(message) {
43
43
  }
44
44
  };
45
45
  }
46
+ var NativePluginPrerequisiteMissingError = class extends Error {
47
+ constructor() {
48
+ super("Native plugin prerequisites not met");
49
+ this.name = "NativePluginPrerequisiteMissingError";
50
+ }
51
+ };
52
+ function isExistingDirectory(path) {
53
+ try {
54
+ if (!existsSync(path)) return false;
55
+ return statSync(path).isDirectory();
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+ function nativePluginSkippedSaveNote(wizardCommand, productName) {
61
+ return "\n" + style.dim("Your agent config has been saved. Run ") + style.cyan(wizardCommand) + style.dim(` again after installing ${productName} to complete hook setup.`) + "\n";
62
+ }
46
63
  var CONFIG_DIR = join(homedir(), ".multicorn");
47
64
  var CONFIG_PATH = join(CONFIG_DIR, "config.json");
48
65
  var OPENCLAW_CONFIG_PATH = join(homedir(), ".openclaw", "openclaw.json");
@@ -426,6 +443,18 @@ async function installWindsurfNativeHooks() {
426
443
  `Could not find Shield Windsurf hook scripts at ${srcPre}. If you use npm, install the latest multicorn-shield package.`
427
444
  );
428
445
  }
446
+ const windsurfConfigDir = join(homedir(), ".codeium", "windsurf");
447
+ if (!isExistingDirectory(windsurfConfigDir)) {
448
+ process.stderr.write(
449
+ style.yellow("\u26A0") + " Windsurf does not appear to be installed (~/.codeium/windsurf/ not found).\n\n"
450
+ );
451
+ process.stderr.write(
452
+ "Open Windsurf at least once so this folder exists, or install from:\n " + style.cyan("https://windsurf.com/download") + "\n\n"
453
+ );
454
+ process.stderr.write("Then run this wizard again:\n");
455
+ process.stderr.write(" " + style.cyan("npx multicorn-proxy init") + "\n");
456
+ throw new NativePluginPrerequisiteMissingError();
457
+ }
429
458
  const installDir = getWindsurfHooksInstallDir();
430
459
  await mkdir(installDir, { recursive: true });
431
460
  const destPre = join(installDir, "pre-action.cjs");
@@ -489,6 +518,19 @@ async function installClineNativeHooks() {
489
518
  `Could not find Shield Cline hook scripts at ${srcPre}. If you use npm, install the latest multicorn-shield package.`
490
519
  );
491
520
  }
521
+ const clineDocsDir = join(homedir(), "Documents", "Cline");
522
+ if (!isExistingDirectory(clineDocsDir)) {
523
+ process.stderr.write(
524
+ style.yellow("\u26A0") + " Cline does not appear to be installed (~/Documents/Cline/ not found).\n\n"
525
+ );
526
+ process.stderr.write("Install the Cline VS Code extension first. See:\n");
527
+ process.stderr.write(
528
+ " " + style.cyan("https://docs.cline.bot/getting-started/installing-cline") + "\n\n"
529
+ );
530
+ process.stderr.write("Then run this wizard again:\n");
531
+ process.stderr.write(" " + style.cyan("npx multicorn-shield init") + "\n");
532
+ throw new NativePluginPrerequisiteMissingError();
533
+ }
492
534
  const installDir = getClineHooksInstallDir();
493
535
  await mkdir(installDir, { recursive: true });
494
536
  const destPre = join(installDir, "pre-tool-use.cjs");
@@ -531,12 +573,211 @@ async function promptClineIntegrationMode(ask) {
531
573
  }
532
574
  return choice === 1 ? "native" : "hosted";
533
575
  }
576
+ function getGeminiCliHooksInstallDir() {
577
+ return join(homedir(), ".multicorn", "gemini-cli-hooks");
578
+ }
579
+ function getGeminiCliSettingsPath() {
580
+ return join(homedir(), ".gemini", "settings.json");
581
+ }
582
+ function geminiInnerHooksReferenceShield(inner, multicornName) {
583
+ if (!Array.isArray(inner)) return false;
584
+ for (const h of inner) {
585
+ if (typeof h !== "object" || h === null) continue;
586
+ const rec = h;
587
+ if (rec["name"] === multicornName) return true;
588
+ const cmd = rec["command"];
589
+ if (typeof cmd === "string" && cmd.includes("gemini-cli-hooks")) return true;
590
+ }
591
+ return false;
592
+ }
593
+ function geminiHookEventsReferenceShield(arr) {
594
+ if (!Array.isArray(arr)) return false;
595
+ for (const entry of arr) {
596
+ if (typeof entry !== "object" || entry === null) continue;
597
+ const hooks = entry["hooks"];
598
+ if (geminiInnerHooksReferenceShield(hooks, "multicorn-shield") || geminiInnerHooksReferenceShield(hooks, "multicorn-shield-log")) {
599
+ return true;
600
+ }
601
+ }
602
+ return false;
603
+ }
604
+ function geminiSettingsHasMulticornHooks(hooks) {
605
+ if (hooks === null || typeof hooks !== "object" || Array.isArray(hooks)) return false;
606
+ const h = hooks;
607
+ return geminiHookEventsReferenceShield(h["BeforeTool"]) || geminiHookEventsReferenceShield(h["AfterTool"]);
608
+ }
609
+ function geminiFilterInnerHooks(inner) {
610
+ if (!Array.isArray(inner)) return [];
611
+ return inner.filter((h) => {
612
+ if (typeof h !== "object" || h === null) return true;
613
+ const rec = h;
614
+ if (rec["name"] === "multicorn-shield" || rec["name"] === "multicorn-shield-log") return false;
615
+ const cmd = rec["command"];
616
+ if (typeof cmd === "string" && cmd.includes("gemini-cli-hooks")) return false;
617
+ return true;
618
+ });
619
+ }
620
+ function geminiStripMatcherGroups(arr) {
621
+ if (!Array.isArray(arr)) return [];
622
+ const out = [];
623
+ for (const entry of arr) {
624
+ if (typeof entry !== "object" || entry === null) continue;
625
+ const e = entry;
626
+ const filtered = geminiFilterInnerHooks(e["hooks"]);
627
+ if (filtered.length > 0) {
628
+ out.push({ ...e, hooks: filtered });
629
+ }
630
+ }
631
+ return out;
632
+ }
633
+ function geminiStripMulticornHookEntries(hooks) {
634
+ const out = { ...hooks };
635
+ out["BeforeTool"] = geminiStripMatcherGroups(out["BeforeTool"]);
636
+ out["AfterTool"] = geminiStripMatcherGroups(out["AfterTool"]);
637
+ return out;
638
+ }
639
+ async function installGeminiCliNativeHooks(ask) {
640
+ const root = multicornShieldPackageRoot();
641
+ const srcBefore = join(root, "plugins", "gemini-cli", "hooks", "scripts", "before-tool.cjs");
642
+ const srcAfter = join(root, "plugins", "gemini-cli", "hooks", "scripts", "after-tool.cjs");
643
+ const srcShared = join(root, "plugins", "gemini-cli", "hooks", "scripts", "shared.cjs");
644
+ if (!existsSync(srcBefore) || !existsSync(srcAfter) || !existsSync(srcShared)) {
645
+ throw new Error(
646
+ `Could not find Shield Gemini CLI hook scripts at ${srcBefore}. If you use npm, install the latest multicorn-shield package.`
647
+ );
648
+ }
649
+ const geminiConfigDir = join(homedir(), ".gemini");
650
+ if (!isExistingDirectory(geminiConfigDir)) {
651
+ process.stderr.write(
652
+ style.yellow("\u26A0") + " Gemini CLI does not appear to be installed (~/.gemini/ not found).\n\n"
653
+ );
654
+ process.stderr.write("Install Gemini CLI first:\n");
655
+ process.stderr.write(" " + style.cyan("npm install -g @google/gemini-cli") + "\n\n");
656
+ process.stderr.write("Then run this wizard again:\n");
657
+ process.stderr.write(" " + style.cyan("npx multicorn-shield init") + "\n");
658
+ throw new NativePluginPrerequisiteMissingError();
659
+ }
660
+ const installDir = getGeminiCliHooksInstallDir();
661
+ await mkdir(installDir, { recursive: true });
662
+ const destBefore = join(installDir, "before-tool.cjs");
663
+ const destAfter = join(installDir, "after-tool.cjs");
664
+ const destShared = join(installDir, "shared.cjs");
665
+ await copyFile(srcBefore, destBefore);
666
+ await copyFile(srcAfter, destAfter);
667
+ await copyFile(srcShared, destShared);
668
+ const mode = 493;
669
+ await chmod(destBefore, mode);
670
+ await chmod(destAfter, mode);
671
+ await chmod(destShared, mode);
672
+ const settingsPath = getGeminiCliSettingsPath();
673
+ let existing = {};
674
+ try {
675
+ const rawText = await readFile(settingsPath, "utf8");
676
+ const parsed = JSON.parse(rawText);
677
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
678
+ existing = parsed;
679
+ }
680
+ } catch (err) {
681
+ if (isErrnoException(err) && err.code === "ENOENT") {
682
+ existing = {};
683
+ } else {
684
+ process.stderr.write(
685
+ style.yellow("\u26A0") + ` Could not parse ${settingsPath}. Create valid JSON or remove the file, then run init again.
686
+ `
687
+ );
688
+ throw new Error(`Invalid Gemini CLI settings at ${settingsPath}`);
689
+ }
690
+ }
691
+ const hooksRaw = existing["hooks"];
692
+ const hooksObj = typeof hooksRaw === "object" && hooksRaw !== null && !Array.isArray(hooksRaw) ? hooksRaw : {};
693
+ if (geminiSettingsHasMulticornHooks(hooksObj)) {
694
+ const answer = await ask(
695
+ "Existing Multicorn Shield hooks were found in ~/.gemini/settings.json. Overwrite? (Y/n) "
696
+ );
697
+ if (answer.trim().toLowerCase() === "n") {
698
+ throw new Error("Installation cancelled: existing Shield hooks left unchanged.");
699
+ }
700
+ }
701
+ const cleaned = geminiStripMulticornHookEntries({ ...hooksObj });
702
+ const beforeArr = Array.isArray(cleaned["BeforeTool"]) ? [...cleaned["BeforeTool"]] : [];
703
+ const afterArr = Array.isArray(cleaned["AfterTool"]) ? [...cleaned["AfterTool"]] : [];
704
+ const beforeCmd = `node ${destBefore}`;
705
+ const afterCmd = `node ${destAfter}`;
706
+ beforeArr.push({
707
+ matcher: ".*",
708
+ hooks: [
709
+ {
710
+ type: "command",
711
+ name: "multicorn-shield",
712
+ command: beforeCmd,
713
+ timeout: 6e4
714
+ }
715
+ ]
716
+ });
717
+ afterArr.push({
718
+ matcher: ".*",
719
+ hooks: [
720
+ {
721
+ type: "command",
722
+ name: "multicorn-shield-log",
723
+ command: afterCmd,
724
+ timeout: 1e4
725
+ }
726
+ ]
727
+ });
728
+ existing["hooks"] = {
729
+ ...cleaned,
730
+ BeforeTool: beforeArr,
731
+ AfterTool: afterArr
732
+ };
733
+ await mkdir(dirname(settingsPath), { recursive: true });
734
+ await writeFile(settingsPath, JSON.stringify(existing, null, 2) + "\n", "utf8");
735
+ }
736
+ async function promptGeminiCliIntegrationMode(ask) {
737
+ process.stderr.write("\n" + style.bold("Gemini CLI integration") + "\n");
738
+ process.stderr.write(
739
+ " " + style.violet("1") + ". Native plugin (recommended) - Gemini CLI Hooks see every file, terminal, web, and MCP action\n"
740
+ );
741
+ process.stderr.write(
742
+ " " + style.violet("2") + ". Hosted proxy - govern MCP traffic only (paste proxy URL into Gemini CLI settings)\n"
743
+ );
744
+ let choice = 0;
745
+ while (choice === 0) {
746
+ const input = await ask("Choose integration (1-2): ");
747
+ const num = parseInt(input.trim(), 10);
748
+ if (num === 1) choice = 1;
749
+ if (num === 2) choice = 2;
750
+ }
751
+ return choice === 1 ? "native" : "hosted";
752
+ }
753
+ function getClaudeDesktopConfigPath() {
754
+ switch (process.platform) {
755
+ case "win32":
756
+ return join(
757
+ process.env["APPDATA"] ?? join(homedir(), "AppData", "Roaming"),
758
+ "Claude",
759
+ "claude_desktop_config.json"
760
+ );
761
+ case "linux":
762
+ return join(homedir(), ".config", "Claude", "claude_desktop_config.json");
763
+ default:
764
+ return join(
765
+ homedir(),
766
+ "Library",
767
+ "Application Support",
768
+ "Claude",
769
+ "claude_desktop_config.json"
770
+ );
771
+ }
772
+ }
534
773
  var PLATFORM_LABELS = [
535
774
  "OpenClaw",
536
775
  "Claude Code",
537
776
  "Cursor",
538
777
  "Windsurf",
539
778
  "Cline",
779
+ "Claude Desktop",
780
+ "Gemini CLI",
540
781
  "Local MCP / Other"
541
782
  ];
542
783
  var PLATFORM_BY_SELECTION = {
@@ -545,14 +786,18 @@ var PLATFORM_BY_SELECTION = {
545
786
  3: "cursor",
546
787
  4: "windsurf",
547
788
  5: "cline",
548
- 6: "other-mcp"
789
+ 6: "claude-desktop",
790
+ 7: "gemini-cli",
791
+ 8: "other-mcp"
549
792
  };
550
793
  var DEFAULT_AGENT_NAMES = {
551
794
  openclaw: "my-openclaw-agent",
552
795
  "claude-code": "my-claude-code-agent",
553
796
  cursor: "my-cursor-agent",
554
797
  windsurf: "my-windsurf-agent",
555
- cline: "my-cline-agent"
798
+ cline: "my-cline-agent",
799
+ "claude-desktop": "my-claude-desktop-agent",
800
+ "gemini-cli": "my-gemini-cli-agent"
556
801
  };
557
802
  async function promptPlatformSelection(ask) {
558
803
  process.stderr.write(
@@ -572,13 +817,13 @@ async function promptPlatformSelection(ask) {
572
817
  );
573
818
  }
574
819
  process.stderr.write(
575
- style.dim(" Pick 6 if you want to wrap a local MCP server with multicorn-proxy --wrap.") + "\n"
820
+ style.dim(" Pick 8 if you want to wrap a local MCP server with multicorn-proxy --wrap.") + "\n"
576
821
  );
577
822
  let selection = 0;
578
823
  while (selection === 0) {
579
- const input = await ask("Select (1-6): ");
824
+ const input = await ask("Select (1-8): ");
580
825
  const num = parseInt(input.trim(), 10);
581
- if (num >= 1 && num <= 6) {
826
+ if (num >= 1 && num <= 8) {
582
827
  selection = num;
583
828
  }
584
829
  }
@@ -587,10 +832,10 @@ async function promptPlatformSelection(ask) {
587
832
  async function promptWindsurfIntegrationMode(ask) {
588
833
  process.stderr.write("\n" + style.bold("Windsurf integration") + "\n");
589
834
  process.stderr.write(
590
- " " + style.violet("1") + ". Native plugin (recommended) \u2014 Cascade Hooks see every file, terminal, and MCP action\n"
835
+ " " + style.violet("1") + ". Native plugin (recommended) - Cascade Hooks see every file, terminal, and MCP action\n"
591
836
  );
592
837
  process.stderr.write(
593
- " " + style.violet("2") + ". Hosted proxy \u2014 govern MCP traffic only (paste proxy URL into mcp_config)\n"
838
+ " " + style.violet("2") + ". Hosted proxy - govern MCP traffic only (paste proxy URL into mcp_config)\n"
594
839
  );
595
840
  let choice = 0;
596
841
  while (choice === 0) {
@@ -699,9 +944,9 @@ async function createProxyConfig(baseUrl, apiKey, agentName, targetUrl, serverNa
699
944
  return typeof data?.["proxy_url"] === "string" ? data["proxy_url"] : "";
700
945
  }
701
946
  function printPlatformSnippet(platform, routingToken, shortName, apiKey) {
702
- const usesInlineKey = platform === "cursor" || platform === "windsurf" || platform === "cline";
947
+ const usesInlineKey = platform === "cursor" || platform === "claude-desktop" || platform === "windsurf" || platform === "cline" || platform === "gemini-cli";
703
948
  const authHeader = usesInlineKey ? `Bearer ${apiKey}` : "Bearer YOUR_SHIELD_API_KEY";
704
- const urlKey = platform === "windsurf" ? "serverUrl" : "url";
949
+ const urlKey = platform === "windsurf" ? "serverUrl" : platform === "gemini-cli" ? "httpUrl" : "url";
705
950
  const mcpSnippet = JSON.stringify(
706
951
  {
707
952
  mcpServers: {
@@ -720,6 +965,8 @@ function printPlatformSnippet(platform, routingToken, shortName, apiKey) {
720
965
  process.stderr.write("\n" + style.dim("Add this to your OpenClaw agent config:") + "\n\n");
721
966
  } else if (platform === "claude-code") {
722
967
  process.stderr.write("\n" + style.dim("Add this to your Claude Code MCP config:") + "\n\n");
968
+ } else if (platform === "claude-desktop") {
969
+ process.stderr.write("\n" + style.dim(`Add this to ${getClaudeDesktopConfigPath()}:`) + "\n\n");
723
970
  } else if (platform === "windsurf") {
724
971
  process.stderr.write(
725
972
  "\n" + style.dim("Add this to ~/.codeium/windsurf/mcp_config.json:") + "\n\n"
@@ -741,6 +988,12 @@ function printPlatformSnippet(platform, routingToken, shortName, apiKey) {
741
988
  " Linux: ~/.config/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json"
742
989
  ) + "\n\n"
743
990
  );
991
+ } else if (platform === "gemini-cli") {
992
+ process.stderr.write(
993
+ "\n" + style.dim(
994
+ "Add this to ~/.gemini/settings.json (create the file if it does not exist). For project-specific config, use .gemini/settings.json in your project root. Restart Gemini CLI after saving. Run /mcp to verify the server is connected."
995
+ ) + "\n\n"
996
+ );
744
997
  } else {
745
998
  process.stderr.write("\n" + style.dim("Add this to ~/.cursor/mcp.json:") + "\n\n");
746
999
  }
@@ -764,6 +1017,9 @@ function printPlatformSnippet(platform, routingToken, shortName, apiKey) {
764
1017
  ) + "\n"
765
1018
  );
766
1019
  }
1020
+ if (platform === "claude-desktop") {
1021
+ process.stderr.write(style.dim("Then restart Claude Desktop to load the MCP server.") + "\n");
1022
+ }
767
1023
  if (platform === "cline") {
768
1024
  process.stderr.write(
769
1025
  style.dim(
@@ -865,10 +1121,11 @@ async function runInit(explicitBaseUrl) {
865
1121
  };
866
1122
  let configuring = true;
867
1123
  while (configuring) {
1124
+ let postSaveNativeSkipNote = null;
868
1125
  const selection = await promptPlatformSelection(ask);
869
1126
  const selectedPlatform = PLATFORM_BY_SELECTION[selection] ?? "cursor";
870
1127
  const selectedLabel = PLATFORM_LABELS[selection - 1] ?? "Cursor";
871
- if (selection === 6) {
1128
+ if (selection === 8) {
872
1129
  const raw = existing !== null ? { ...existing } : {};
873
1130
  raw["apiKey"] = apiKey;
874
1131
  raw["baseUrl"] = resolvedBaseUrl;
@@ -1047,8 +1304,22 @@ An agent for ${selectedLabel} already exists: ${style.cyan(existingForPlatform.n
1047
1304
  });
1048
1305
  setupSucceeded = true;
1049
1306
  } catch (error) {
1050
- const detail = error instanceof Error ? error.message : String(error);
1051
- process.stderr.write(style.red("\u2717 ") + detail + "\n");
1307
+ if (error instanceof NativePluginPrerequisiteMissingError) {
1308
+ postSaveNativeSkipNote = nativePluginSkippedSaveNote(
1309
+ "npx multicorn-proxy init",
1310
+ "Windsurf"
1311
+ );
1312
+ configuredAgents.push({
1313
+ selection,
1314
+ platform: selectedPlatform,
1315
+ platformLabel: selectedLabel,
1316
+ agentName
1317
+ });
1318
+ setupSucceeded = true;
1319
+ } else {
1320
+ const detail = error instanceof Error ? error.message : String(error);
1321
+ process.stderr.write(style.red("\u2717 ") + detail + "\n");
1322
+ }
1052
1323
  }
1053
1324
  } else {
1054
1325
  const { targetUrl, shortName } = await promptProxyConfig(ask, agentName);
@@ -1092,6 +1363,91 @@ An agent for ${selectedLabel} already exists: ${style.cyan(existingForPlatform.n
1092
1363
  setupSucceeded = true;
1093
1364
  }
1094
1365
  }
1366
+ } else if (selection === 7) {
1367
+ const geminiMode = await promptGeminiCliIntegrationMode(ask);
1368
+ if (geminiMode === "native") {
1369
+ try {
1370
+ await installGeminiCliNativeHooks(ask);
1371
+ process.stderr.write("\n" + style.bold("Shield Gemini CLI hooks installed") + "\n\n");
1372
+ process.stderr.write(
1373
+ style.dim("Hook scripts: ") + style.cyan(getGeminiCliHooksInstallDir()) + "\n"
1374
+ );
1375
+ process.stderr.write(
1376
+ style.dim("Settings updated at ") + style.cyan("~/.gemini/settings.json") + "\n"
1377
+ );
1378
+ process.stderr.write(
1379
+ style.dim(
1380
+ "The Shield hook runs with your user permissions. It intercepts Gemini CLI tool calls to check permissions and log activity. Review the scripts if that is a concern."
1381
+ ) + "\n"
1382
+ );
1383
+ configuredAgents.push({
1384
+ selection,
1385
+ platform: selectedPlatform,
1386
+ platformLabel: selectedLabel,
1387
+ agentName,
1388
+ geminiCliIntegration: "native"
1389
+ });
1390
+ setupSucceeded = true;
1391
+ } catch (error) {
1392
+ if (error instanceof NativePluginPrerequisiteMissingError) {
1393
+ postSaveNativeSkipNote = nativePluginSkippedSaveNote(
1394
+ "npx multicorn-shield init",
1395
+ "Gemini CLI"
1396
+ );
1397
+ configuredAgents.push({
1398
+ selection,
1399
+ platform: selectedPlatform,
1400
+ platformLabel: selectedLabel,
1401
+ agentName
1402
+ });
1403
+ setupSucceeded = true;
1404
+ } else {
1405
+ const detail = error instanceof Error ? error.message : String(error);
1406
+ process.stderr.write(style.red("\u2717 ") + detail + "\n");
1407
+ }
1408
+ }
1409
+ } else {
1410
+ const { targetUrl, shortName } = await promptProxyConfig(ask, agentName);
1411
+ let proxyUrl = "";
1412
+ let created = false;
1413
+ while (!created) {
1414
+ const spinner = withSpinner("Creating proxy config...");
1415
+ try {
1416
+ proxyUrl = await createProxyConfig(
1417
+ resolvedBaseUrl,
1418
+ apiKey,
1419
+ agentName,
1420
+ targetUrl,
1421
+ shortName,
1422
+ selectedPlatform
1423
+ );
1424
+ spinner.stop(true, "Proxy config created!");
1425
+ created = true;
1426
+ } catch (error) {
1427
+ const detail = error instanceof Error ? error.message : String(error);
1428
+ spinner.stop(false, detail);
1429
+ const retry = await ask("Try again? (Y/n) ");
1430
+ if (retry.trim().toLowerCase() === "n") {
1431
+ break;
1432
+ }
1433
+ }
1434
+ }
1435
+ if (created && proxyUrl.length > 0) {
1436
+ process.stderr.write("\n" + style.bold("Your Shield proxy URL:") + "\n");
1437
+ process.stderr.write(" " + style.cyan(proxyUrl) + "\n");
1438
+ printPlatformSnippet(selectedPlatform, proxyUrl, shortName, apiKey);
1439
+ configuredAgents.push({
1440
+ selection,
1441
+ platform: selectedPlatform,
1442
+ platformLabel: selectedLabel,
1443
+ agentName,
1444
+ shortName,
1445
+ proxyUrl,
1446
+ geminiCliIntegration: "hosted"
1447
+ });
1448
+ setupSucceeded = true;
1449
+ }
1450
+ }
1095
1451
  } else if (selection === 5) {
1096
1452
  const clineMode = await promptClineIntegrationMode(ask);
1097
1453
  if (clineMode === "native") {
@@ -1112,8 +1468,22 @@ An agent for ${selectedLabel} already exists: ${style.cyan(existingForPlatform.n
1112
1468
  });
1113
1469
  setupSucceeded = true;
1114
1470
  } catch (error) {
1115
- const detail = error instanceof Error ? error.message : String(error);
1116
- process.stderr.write(style.red("\u2717 ") + detail + "\n");
1471
+ if (error instanceof NativePluginPrerequisiteMissingError) {
1472
+ postSaveNativeSkipNote = nativePluginSkippedSaveNote(
1473
+ "npx multicorn-shield init",
1474
+ "Cline"
1475
+ );
1476
+ configuredAgents.push({
1477
+ selection,
1478
+ platform: selectedPlatform,
1479
+ platformLabel: selectedLabel,
1480
+ agentName
1481
+ });
1482
+ setupSucceeded = true;
1483
+ } else {
1484
+ const detail = error instanceof Error ? error.message : String(error);
1485
+ process.stderr.write(style.red("\u2717 ") + detail + "\n");
1486
+ }
1117
1487
  }
1118
1488
  } else {
1119
1489
  const { targetUrl, shortName } = await promptProxyConfig(ask, agentName);
@@ -1215,9 +1585,14 @@ An agent for ${selectedLabel} already exists: ${style.cyan(existingForPlatform.n
1215
1585
  style.green("\u2713") + ` Config saved to ${style.cyan(CONFIG_PATH)}
1216
1586
  `
1217
1587
  );
1588
+ if (postSaveNativeSkipNote != null) {
1589
+ process.stderr.write(postSaveNativeSkipNote);
1590
+ postSaveNativeSkipNote = null;
1591
+ }
1218
1592
  } catch (error) {
1219
1593
  const detail = error instanceof Error ? error.message : String(error);
1220
1594
  process.stderr.write(style.red(`Failed to save config: ${detail}`) + "\n");
1595
+ postSaveNativeSkipNote = null;
1221
1596
  }
1222
1597
  }
1223
1598
  const another = await ask("\nConnect another agent? (Y/n) ");
@@ -1291,6 +1666,22 @@ An agent for ${selectedLabel} already exists: ${style.cyan(existingForPlatform.n
1291
1666
  "\n" + style.bold("To complete your Cline hosted-proxy setup:") + "\n 1. If you don't have Cline yet, install it from the VS Code marketplace\n 2. Open your Cline MCP settings file and paste the config snippet shown above\n 3. Restart Cline or reload the VS Code window\n"
1292
1667
  );
1293
1668
  }
1669
+ const geminiCliNativeConfigured = configuredAgents.some(
1670
+ (a) => a.platform === "gemini-cli" && a.geminiCliIntegration === "native"
1671
+ );
1672
+ const geminiCliHostedConfigured = configuredAgents.some(
1673
+ (a) => a.platform === "gemini-cli" && a.geminiCliIntegration === "hosted"
1674
+ );
1675
+ if (geminiCliNativeConfigured) {
1676
+ blocks.push(
1677
+ "\n" + style.bold("Gemini CLI native hooks:") + "\n Your Gemini CLI hooks are installed. Restart Gemini CLI to activate Shield governance.\n"
1678
+ );
1679
+ }
1680
+ if (geminiCliHostedConfigured) {
1681
+ blocks.push(
1682
+ "\n" + style.bold("To complete your Gemini CLI setup:") + "\n 1. Open " + style.cyan("~/.gemini/settings.json") + "\n 2. Paste the config snippet shown above\n 3. Restart Gemini CLI, then run /mcp to verify\n"
1683
+ );
1684
+ }
1294
1685
  if (blocks.length > 0) {
1295
1686
  process.stderr.write("\n" + style.bold(style.violet("Next steps")) + "\n");
1296
1687
  process.stderr.write(blocks.join("") + "\n");
@@ -22359,7 +22359,7 @@ async function writeExtensionBackup(claudeDesktopConfigPath, mcpServers) {
22359
22359
 
22360
22360
  // package.json
22361
22361
  var package_default = {
22362
- version: "0.12.0"};
22362
+ version: "0.13.0"};
22363
22363
 
22364
22364
  // src/package-meta.ts
22365
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.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "The control layer for AI agents: permissions, consent, spending limits, and audit logging.",
5
5
  "license": "MIT",
6
6
  "author": "Multicorn AI Pty Ltd",
@@ -38,6 +38,7 @@
38
38
  "dist",
39
39
  "plugins/windsurf",
40
40
  "plugins/cline",
41
+ "plugins/gemini-cli",
41
42
  "LICENSE",
42
43
  "README.md",
43
44
  "CHANGELOG.md"
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env node
2
+ // Copyright (c) Multicorn AI Pty Ltd. MIT License. See LICENSE file.
3
+ /**
4
+ * Gemini CLI AfterTool hook: audit logging to Shield.
5
+ * Always returns { "decision": "allow" } on stdout.
6
+ */
7
+
8
+ "use strict";
9
+
10
+ const {
11
+ loadConfig,
12
+ logPrefix,
13
+ mapToolName,
14
+ postJson,
15
+ readStdin,
16
+ scrubParameters,
17
+ scrubResultForMetadata,
18
+ } = require("./shared.cjs");
19
+
20
+ const HOOK_PREFIX = logPrefix("after-tool");
21
+
22
+ function respond() {
23
+ process.stdout.write(JSON.stringify({ decision: "allow" }) + "\n");
24
+ process.exit(0);
25
+ }
26
+
27
+ async function main() {
28
+ let raw;
29
+ try {
30
+ raw = await readStdin();
31
+ } catch {
32
+ respond();
33
+ return;
34
+ }
35
+
36
+ /** @type {Record<string, unknown>} */
37
+ let hookPayload;
38
+ try {
39
+ hookPayload = JSON.parse(raw.length > 0 ? raw : "{}");
40
+ } catch {
41
+ respond();
42
+ return;
43
+ }
44
+
45
+ const toolName = typeof hookPayload.tool_name === "string" ? hookPayload.tool_name : "";
46
+ const mapped = mapToolName(toolName);
47
+
48
+ if (mapped === null) {
49
+ respond();
50
+ return;
51
+ }
52
+ const { service, actionType } = mapped;
53
+
54
+ const config = loadConfig();
55
+ if (config === null || config.apiKey.length === 0 || config.agentName.length === 0) {
56
+ respond();
57
+ return;
58
+ }
59
+
60
+ const toolInput =
61
+ typeof hookPayload.tool_input === "object" && hookPayload.tool_input !== null
62
+ ? /** @type {Record<string, unknown>} */ (hookPayload.tool_input)
63
+ : {};
64
+
65
+ const paramsSerialized = scrubParameters(toolInput);
66
+ const toolResponse = hookPayload.tool_response;
67
+
68
+ /** @type {Record<string, unknown>} */
69
+ const metadata = {
70
+ tool_name: toolName,
71
+ session_id: typeof hookPayload.session_id === "string" ? hookPayload.session_id : "",
72
+ cwd: typeof hookPayload.cwd === "string" ? hookPayload.cwd : "",
73
+ parameters: paramsSerialized,
74
+ result: scrubResultForMetadata(toolResponse),
75
+ source: "gemini-cli",
76
+ };
77
+
78
+ /** @type {Record<string, unknown>} */
79
+ const payload = {
80
+ agent: config.agentName,
81
+ service,
82
+ actionType,
83
+ status: "approved",
84
+ metadata,
85
+ platform: "gemini-cli",
86
+ };
87
+
88
+ try {
89
+ const res = await postJson(config.baseUrl, config.apiKey, payload);
90
+ const code = res.statusCode ?? 0;
91
+ if (code < 200 || code >= 300) {
92
+ throw new Error(`HTTP ${String(code)}`);
93
+ }
94
+ } catch (e) {
95
+ const msg = e instanceof Error ? e.message : String(e);
96
+ process.stderr.write(
97
+ `${HOOK_PREFIX} Warning: failed to log action to Shield audit trail. Detail: ${msg}\n`,
98
+ );
99
+ }
100
+
101
+ respond();
102
+ }
103
+
104
+ main().catch((e) => {
105
+ const msg = e instanceof Error ? e.message : String(e);
106
+ process.stderr.write(
107
+ `${HOOK_PREFIX} Warning: failed to log action to Shield audit trail. Detail: ${msg}\n`,
108
+ );
109
+ respond();
110
+ });
@@ -0,0 +1,197 @@
1
+ #!/usr/bin/env node
2
+ // Copyright (c) Multicorn AI Pty Ltd. MIT License. See LICENSE file.
3
+ /**
4
+ * Gemini CLI BeforeTool hook: asks Shield whether a tool call is allowed.
5
+ * Reads JSON from stdin. Writes JSON to stdout only (decision allow/deny). Logs to stderr.
6
+ * Fail-open on missing config or unreachable API.
7
+ */
8
+
9
+ "use strict";
10
+
11
+ const {
12
+ loadConfig,
13
+ logPrefix,
14
+ mapToolName,
15
+ postJson,
16
+ readStdin,
17
+ safeJsonParse,
18
+ scrubParameters,
19
+ unwrapData,
20
+ consentUrl,
21
+ openBrowser,
22
+ } = require("./shared.cjs");
23
+
24
+ const HOOK_PREFIX = logPrefix("before-tool");
25
+
26
+ /**
27
+ * @param {string} decision
28
+ * @param {string} [reason]
29
+ */
30
+ function respond(decision, reason) {
31
+ /** @type {Record<string, unknown>} */
32
+ const out = { decision };
33
+ if (reason !== undefined && reason.length > 0) {
34
+ out.reason = reason;
35
+ }
36
+ process.stdout.write(JSON.stringify(out) + "\n");
37
+ process.exit(0);
38
+ }
39
+
40
+ function dashboardHintUrl(apiBaseUrl) {
41
+ try {
42
+ const raw = String(apiBaseUrl).replace(/\/+$/, "");
43
+ const lower = raw.toLowerCase();
44
+ if (lower.includes("localhost:8080") || lower.includes("127.0.0.1:8080")) {
45
+ return "http://localhost:5173/approvals";
46
+ }
47
+ const u = new URL(raw);
48
+ if (u.hostname.startsWith("api.")) {
49
+ u.hostname = "app." + u.hostname.slice(4);
50
+ }
51
+ return `${u.origin}/approvals`;
52
+ } catch {
53
+ return "https://app.multicorn.ai/approvals";
54
+ }
55
+ }
56
+
57
+ /**
58
+ * @param {unknown} data
59
+ * @param {string} approvalsUrl
60
+ */
61
+ function blockedReason(data, approvalsUrl) {
62
+ if (data !== null && typeof data === "object") {
63
+ const d = /** @type {Record<string, unknown>} */ (data);
64
+ const meta = d.metadata;
65
+ if (typeof meta === "string" && meta.length > 0) {
66
+ try {
67
+ const parsed = JSON.parse(meta);
68
+ if (parsed !== null && typeof parsed === "object" && "block_reason" in parsed) {
69
+ const br = /** @type {Record<string, unknown>} */ (parsed).block_reason;
70
+ if (typeof br === "string" && br.length > 0) {
71
+ return `Blocked by Multicorn Shield: ${br}. Grant access at ${approvalsUrl}`;
72
+ }
73
+ }
74
+ } catch {
75
+ /* ignore */
76
+ }
77
+ }
78
+ }
79
+ return `Blocked by Multicorn Shield. Grant access at ${approvalsUrl}`;
80
+ }
81
+
82
+ async function main() {
83
+ let raw;
84
+ try {
85
+ raw = await readStdin();
86
+ } catch (e) {
87
+ const msg = e instanceof Error ? e.message : String(e);
88
+ process.stderr.write(`${HOOK_PREFIX} could not read stdin (${msg}). Allowing.\n`);
89
+ respond("allow");
90
+ return;
91
+ }
92
+
93
+ /** @type {Record<string, unknown>} */
94
+ let hookPayload;
95
+ try {
96
+ hookPayload = JSON.parse(raw.length > 0 ? raw : "{}");
97
+ } catch (e) {
98
+ const msg = e instanceof Error ? e.message : String(e);
99
+ process.stderr.write(`${HOOK_PREFIX} invalid JSON (${msg}). Allowing.\n`);
100
+ respond("allow");
101
+ return;
102
+ }
103
+
104
+ const toolName = typeof hookPayload.tool_name === "string" ? hookPayload.tool_name : "";
105
+
106
+ const mapped = mapToolName(toolName);
107
+ if (mapped === null) {
108
+ respond("allow");
109
+ return;
110
+ }
111
+ const { service, actionType } = mapped;
112
+
113
+ const toolInput =
114
+ typeof hookPayload.tool_input === "object" && hookPayload.tool_input !== null
115
+ ? /** @type {Record<string, unknown>} */ (hookPayload.tool_input)
116
+ : {};
117
+
118
+ const config = loadConfig();
119
+ if (config === null || config.apiKey.length === 0 || config.agentName.length === 0) {
120
+ respond("allow");
121
+ return;
122
+ }
123
+
124
+ const paramsSerialized = scrubParameters(toolInput);
125
+ const approvalsUrl = dashboardHintUrl(config.baseUrl);
126
+
127
+ /** @type {Record<string, unknown>} */
128
+ const metadata = {
129
+ tool_name: toolName,
130
+ session_id: typeof hookPayload.session_id === "string" ? hookPayload.session_id : "",
131
+ cwd: typeof hookPayload.cwd === "string" ? hookPayload.cwd : "",
132
+ parameters: paramsSerialized,
133
+ source: "gemini-cli",
134
+ };
135
+
136
+ /** @type {Record<string, unknown>} */
137
+ const payload = {
138
+ agent: config.agentName,
139
+ service,
140
+ actionType,
141
+ status: "pending",
142
+ metadata,
143
+ platform: "gemini-cli",
144
+ };
145
+
146
+ let statusCode;
147
+ let bodyText;
148
+ try {
149
+ const res = await postJson(config.baseUrl, config.apiKey, payload);
150
+ statusCode = res.statusCode;
151
+ bodyText = res.bodyText;
152
+ } catch (e) {
153
+ const msg = e instanceof Error ? e.message : String(e);
154
+ process.stderr.write(`${HOOK_PREFIX} Shield API unreachable (${msg}). Allowing.\n`);
155
+ respond("allow");
156
+ return;
157
+ }
158
+
159
+ const parsed = safeJsonParse(bodyText);
160
+ const data = unwrapData(parsed);
161
+
162
+ if (statusCode === 202) {
163
+ const url = consentUrl(config.baseUrl, config.agentName, service, actionType);
164
+ openBrowser(url);
165
+ respond("deny", `Action blocked by Multicorn Shield. Authorise at: ${url}`);
166
+ return;
167
+ }
168
+
169
+ if (statusCode === 201) {
170
+ if (data === null || typeof data !== "object") {
171
+ const url = consentUrl(config.baseUrl, config.agentName, service, actionType);
172
+ respond("deny", `Action blocked by Multicorn Shield. Authorise at: ${url}`);
173
+ return;
174
+ }
175
+ const st = String(/** @type {Record<string, unknown>} */ (data).status || "").toLowerCase();
176
+ if (st === "approved") {
177
+ respond("allow");
178
+ return;
179
+ }
180
+ if (st === "blocked") {
181
+ respond("deny", blockedReason(data, approvalsUrl));
182
+ return;
183
+ }
184
+ const url = consentUrl(config.baseUrl, config.agentName, service, actionType);
185
+ respond("deny", `Action blocked by Multicorn Shield. Authorise at: ${url}`);
186
+ return;
187
+ }
188
+
189
+ const url = consentUrl(config.baseUrl, config.agentName, service, actionType);
190
+ respond("deny", `Action blocked by Multicorn Shield. Authorise at: ${url}`);
191
+ }
192
+
193
+ main().catch((e) => {
194
+ const msg = e instanceof Error ? e.message : String(e);
195
+ process.stderr.write(`${HOOK_PREFIX} unexpected error (${msg}). Allowing.\n`);
196
+ respond("allow");
197
+ });
@@ -0,0 +1,319 @@
1
+ // Copyright (c) Multicorn AI Pty Ltd. MIT License. See LICENSE file.
2
+
3
+ /**
4
+ * @file Shared helpers for Gemini CLI BeforeTool / AfterTool Shield hooks.
5
+ */
6
+
7
+ "use strict";
8
+
9
+ const fs = require("node:fs");
10
+ const http = require("node:http");
11
+ const https = require("node:https");
12
+ const os = require("node:os");
13
+ const path = require("node:path");
14
+
15
+ const AUTH_HEADER = "X-Multicorn-Key";
16
+ const HTTP_REQUEST_TIMEOUT_MS = 10000;
17
+
18
+ /** Tools that should pass through without calling Shield (internal / UX-only). */
19
+ const SKIP_TOOLS = new Set([
20
+ "save_memory",
21
+ "activate_skill",
22
+ "get_internal_docs",
23
+ "ask_user",
24
+ "write_todos",
25
+ "enter_plan_mode",
26
+ "exit_plan_mode",
27
+ "update_topic",
28
+ "complete_task",
29
+ ]);
30
+
31
+ /** @type {Readonly<Record<string, { service: string; actionType: string }>>} */
32
+ const TOOL_MAP = {
33
+ read_file: { service: "filesystem", actionType: "read" },
34
+ read_many_files: { service: "filesystem", actionType: "read" },
35
+ list_directory: { service: "filesystem", actionType: "read" },
36
+ glob: { service: "filesystem", actionType: "read" },
37
+ grep_search: { service: "filesystem", actionType: "read" },
38
+ write_file: { service: "filesystem", actionType: "write" },
39
+ replace: { service: "filesystem", actionType: "write" },
40
+ run_shell_command: { service: "terminal", actionType: "execute" },
41
+ google_web_search: { service: "browser", actionType: "execute" },
42
+ web_fetch: { service: "browser", actionType: "execute" },
43
+ };
44
+
45
+ function logPrefix(label) {
46
+ return `[multicorn-shield] Gemini CLI ${label}:`;
47
+ }
48
+
49
+ function readStdin() {
50
+ return new Promise((resolve, reject) => {
51
+ const chunks = [];
52
+ process.stdin.setEncoding("utf8");
53
+ process.stdin.on("data", (c) => chunks.push(c));
54
+ process.stdin.on("end", () => resolve(chunks.join("")));
55
+ process.stdin.on("error", reject);
56
+ });
57
+ }
58
+
59
+ function resolveGeminiCliAgentName(obj) {
60
+ const agents = obj.agents;
61
+ if (Array.isArray(agents)) {
62
+ for (const entry of agents) {
63
+ if (
64
+ entry &&
65
+ typeof entry === "object" &&
66
+ /** @type {{ platform?: string; name?: string }} */ (entry).platform === "gemini-cli" &&
67
+ typeof (/** @type {{ platform?: string; name?: string }} */ (entry).name) === "string"
68
+ ) {
69
+ return /** @type {{ name: string }} */ (entry).name;
70
+ }
71
+ }
72
+ }
73
+ return typeof obj.agentName === "string" ? obj.agentName : "";
74
+ }
75
+
76
+ function loadConfig() {
77
+ try {
78
+ const configPath = path.join(os.homedir(), ".multicorn", "config.json");
79
+ const raw = fs.readFileSync(configPath, "utf8");
80
+ const obj = JSON.parse(raw);
81
+ const apiKey = typeof obj.apiKey === "string" ? obj.apiKey : "";
82
+ const baseUrl =
83
+ typeof obj.baseUrl === "string" && obj.baseUrl.length > 0
84
+ ? obj.baseUrl.replace(/\/+$/, "")
85
+ : "https://api.multicorn.ai";
86
+ const baseLower = baseUrl.toLowerCase();
87
+ const isHttps = baseLower.startsWith("https://");
88
+ const isLocal = baseLower.includes("localhost") || baseLower.includes("127.0.0.1");
89
+ if (!isHttps && !isLocal) {
90
+ process.stderr.write(
91
+ `${logPrefix("config")} baseUrl must use HTTPS for non-local servers. Fail-open: Shield disabled.\n`,
92
+ );
93
+ return null;
94
+ }
95
+ const agentName = resolveGeminiCliAgentName(obj);
96
+ return { apiKey, baseUrl, agentName };
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * @param {string} toolName
104
+ * @returns {{ service: string; actionType: string } | null} null = skip hook API calls
105
+ */
106
+ function mapToolName(toolName) {
107
+ const name = String(toolName || "").trim();
108
+ if (name.length === 0) return null;
109
+ if (SKIP_TOOLS.has(name)) return null;
110
+
111
+ if (name.startsWith("mcp_")) {
112
+ const rest = name.slice(4);
113
+ const idx = rest.indexOf("_");
114
+ if (idx <= 0) {
115
+ const safe = rest.replace(/[^a-zA-Z0-9._-]+/g, "_");
116
+ return { service: `mcp:${safe}`, actionType: "execute" };
117
+ }
118
+ const server = rest.slice(0, idx).replace(/[^a-zA-Z0-9._-]+/g, "_");
119
+ const tool = rest.slice(idx + 1).replace(/[^a-zA-Z0-9._-]+/g, "_");
120
+ return { service: `mcp:${server}.${tool}`, actionType: "execute" };
121
+ }
122
+
123
+ const mapped = TOOL_MAP[name];
124
+ if (mapped !== undefined) {
125
+ return mapped;
126
+ }
127
+
128
+ return { service: "unknown", actionType: "execute" };
129
+ }
130
+
131
+ function postJson(baseUrl, apiKey, bodyObj) {
132
+ return new Promise((resolve, reject) => {
133
+ let u;
134
+ try {
135
+ const root = String(baseUrl).replace(/\/+$/, "");
136
+ u = new URL(`${root}/api/v1/actions`);
137
+ } catch (e) {
138
+ reject(e);
139
+ return;
140
+ }
141
+ const payload = JSON.stringify(bodyObj);
142
+ const isHttps = u.protocol === "https:";
143
+ const lib = isHttps ? https : http;
144
+ const port = u.port || (isHttps ? 443 : 80);
145
+ const hostname = u.hostname;
146
+ /** @type {string} */
147
+ const pathnamePlusSearch = u.pathname + u.search;
148
+ const options = {
149
+ hostname,
150
+ port,
151
+ path: pathnamePlusSearch,
152
+ method: "POST",
153
+ headers: {
154
+ Connection: "close",
155
+ "Content-Type": "application/json",
156
+ "Content-Length": Buffer.byteLength(payload, "utf8"),
157
+ [AUTH_HEADER]: apiKey,
158
+ },
159
+ };
160
+ const req = lib.request(options, (res) => {
161
+ const chunks = [];
162
+ res.on("data", (c) => chunks.push(c));
163
+ res.on("end", () => {
164
+ resolve({
165
+ statusCode: res.statusCode ?? 0,
166
+ bodyText: Buffer.concat(chunks).toString("utf8"),
167
+ });
168
+ });
169
+ });
170
+ req.setTimeout(HTTP_REQUEST_TIMEOUT_MS, () => {
171
+ req.destroy(new Error("request timeout"));
172
+ });
173
+ req.on("error", reject);
174
+ req.write(payload);
175
+ req.end();
176
+ });
177
+ }
178
+
179
+ function safeJsonParse(text) {
180
+ try {
181
+ return JSON.parse(text);
182
+ } catch {
183
+ return null;
184
+ }
185
+ }
186
+
187
+ function unwrapData(body) {
188
+ if (typeof body !== "object" || body === null) return null;
189
+ const o = /** @type {Record<string, unknown>} */ (body);
190
+ return o.success === true ? o.data : null;
191
+ }
192
+
193
+ function applyParameterScrub(parameters) {
194
+ const scrubbedParams = { ...parameters };
195
+ if (typeof scrubbedParams.content === "string") {
196
+ scrubbedParams.content = `[${scrubbedParams.content.length} chars redacted]`;
197
+ }
198
+ if (typeof scrubbedParams.command === "string" && scrubbedParams.command.length > 200) {
199
+ scrubbedParams.command = scrubbedParams.command.slice(0, 200) + "... [truncated]";
200
+ }
201
+ return scrubbedParams;
202
+ }
203
+
204
+ function scrubParameters(parameters, maxLen = 4096) {
205
+ /** @type {Record<string, unknown>} */
206
+ let base = {};
207
+ if (typeof parameters === "object" && parameters !== null) {
208
+ base = { .../** @type {Record<string, unknown>} */ (parameters) };
209
+ } else if (typeof parameters === "string") {
210
+ try {
211
+ const p = JSON.parse(parameters);
212
+ if (p !== null && typeof p === "object") {
213
+ base = { .../** @type {Record<string, unknown>} */ (p) };
214
+ } else {
215
+ base = { raw: parameters };
216
+ }
217
+ } catch {
218
+ base = { raw: parameters };
219
+ }
220
+ }
221
+
222
+ const scrubbed = applyParameterScrub(base);
223
+ let paramsSerialized;
224
+ try {
225
+ paramsSerialized = JSON.stringify(scrubbed);
226
+ } catch {
227
+ paramsSerialized = "{}";
228
+ }
229
+ if (paramsSerialized.length > maxLen) {
230
+ paramsSerialized = paramsSerialized.slice(0, maxLen);
231
+ }
232
+ return paramsSerialized;
233
+ }
234
+
235
+ function scrubResultForMetadata(result) {
236
+ if (result === null || result === undefined) return "";
237
+ let s;
238
+ try {
239
+ s = typeof result === "string" ? result : JSON.stringify(result);
240
+ } catch {
241
+ s = String(result);
242
+ }
243
+ s = s.replace(/\bsk-[A-Za-z0-9_-]{8,}\b/g, "[REDACTED]");
244
+ s = s.replace(/\bmcs_[A-Za-z0-9_-]+\b/g, "[REDACTED]");
245
+ s = s.replace(/\bghp_[A-Za-z0-9]{20,}\b/g, "[REDACTED]");
246
+ s = s.replace(/Bearer\s+[^\s]+/gi, "[REDACTED]");
247
+ s = s.replace(/\b(password|token)\s*[:=]\s*[^\s]+\b/gi, "[REDACTED]");
248
+ if (s.length > 500) {
249
+ s = s.slice(0, 500) + "[truncated]";
250
+ }
251
+ return s;
252
+ }
253
+
254
+ /**
255
+ * @param {string} apiBaseUrl
256
+ * @param {string} agentName
257
+ * @param {string} service
258
+ * @param {string} actionType
259
+ */
260
+ function consentUrl(apiBaseUrl, agentName, service, actionType) {
261
+ let origin = "https://app.multicorn.ai";
262
+ try {
263
+ const raw = String(apiBaseUrl).replace(/\/+$/, "");
264
+ const lower = raw.toLowerCase();
265
+ if (lower.includes("localhost:8080") || lower.includes("127.0.0.1:8080")) {
266
+ origin = "http://localhost:5173";
267
+ } else {
268
+ const u = new URL(raw);
269
+ if (u.hostname.startsWith("api.")) {
270
+ u.hostname = "app." + u.hostname.slice(4);
271
+ }
272
+ origin = u.origin;
273
+ }
274
+ } catch {
275
+ /* keep default */
276
+ }
277
+ const params = new URLSearchParams();
278
+ params.set("agent", agentName);
279
+ params.set("scopes", `${service}:${actionType}`);
280
+ params.set("platform", "gemini-cli");
281
+ return `${origin}/consent?${params.toString()}`;
282
+ }
283
+
284
+ /** @param {string} url */
285
+ function openBrowser(url) {
286
+ try {
287
+ const { execFileSync } = require("node:child_process");
288
+ if (process.platform === "darwin") {
289
+ execFileSync("open", [url], { stdio: "ignore" });
290
+ } else if (process.platform === "win32") {
291
+ execFileSync("cmd.exe", ["/c", "start", "", url], {
292
+ stdio: "ignore",
293
+ windowsHide: true,
294
+ });
295
+ } else {
296
+ execFileSync("xdg-open", [url], { stdio: "ignore" });
297
+ }
298
+ } catch {
299
+ /* ignore */
300
+ }
301
+ }
302
+
303
+ module.exports = {
304
+ AUTH_HEADER,
305
+ logPrefix,
306
+ HTTP_REQUEST_TIMEOUT_MS,
307
+ TOOL_MAP,
308
+ readStdin,
309
+ loadConfig,
310
+ resolveGeminiCliAgentName,
311
+ mapToolName,
312
+ postJson,
313
+ safeJsonParse,
314
+ unwrapData,
315
+ scrubParameters,
316
+ scrubResultForMetadata,
317
+ consentUrl,
318
+ openBrowser,
319
+ };