@solongate/proxy 0.29.0 → 0.30.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/README.md CHANGED
@@ -1,9 +1,9 @@
1
1
  # @solongate/proxy
2
2
 
3
- **MCP Security Proxy** — Protect any MCP server with security policies, input validation, rate limiting, and audit logging. Zero code changes required.
3
+ **AI Tool Security Proxy** — Protect any AI tool server with security policies, input validation, rate limiting, and audit logging. Zero code changes required.
4
4
 
5
5
  ```
6
- MCP Client ──(stdio)──> SolonGate Proxy ──(stdio)──> MCP Server
6
+ AI Client ──(stdio)──> SolonGate Proxy ──(stdio)──> Tool Server
7
7
 
8
8
  [rate limit]
9
9
  [input guard]
@@ -11,19 +11,19 @@ MCP Client ──(stdio)──> SolonGate Proxy ──(stdio)──> MCP Server
11
11
  [audit log]
12
12
  ```
13
13
 
14
- **Works with every MCP client:** Claude Code, Claude Desktop, Cursor, Windsurf, Cline, Zed, and any application that supports the Model Context Protocol over stdio.
14
+ **Works with every AI platform:** Claude Code, Claude Desktop, Cursor, Windsurf, Cline, Zed, and any application that uses AI tool calls.
15
15
 
16
16
  ## Quick Start
17
17
 
18
18
  ### Automatic Setup
19
19
 
20
- Run this in your project directory (where your `.mcp.json` lives):
20
+ Run this in your project directory:
21
21
 
22
22
  ```bash
23
23
  npx @solongate/proxy init --all
24
24
  ```
25
25
 
26
- Restart your MCP client. Done.
26
+ Restart your AI client. Done.
27
27
 
28
28
  ### Manual Setup
29
29
 
@@ -129,9 +129,9 @@ npx @solongate/proxy init --restore
129
129
 
130
130
  ## Why SolonGate?
131
131
 
132
- MCP servers give AI agents direct access to your system — shell commands, file system, databases, network. A single prompt injection attack can turn your AI assistant into an attacker.
132
+ AI tool servers give AI agents direct access to your system — shell commands, file system, databases, network. A single prompt injection attack can turn your AI assistant into an attacker.
133
133
 
134
- SolonGate sits between the AI client and the MCP server, enforcing security policies on every tool call before it reaches the server.
134
+ SolonGate sits between the AI client and the tool server, enforcing security policies on every tool call before it reaches the server.
135
135
 
136
136
  Learn more at [solongate.com](https://solongate.com)
137
137
 
package/dist/index.js CHANGED
@@ -461,6 +461,7 @@ var init_cli_utils = __esm({
461
461
  var init_exports = {};
462
462
  import { readFileSync as readFileSync4, writeFileSync as writeFileSync2, existsSync as existsSync4, mkdirSync as mkdirSync2 } from "fs";
463
463
  import { resolve as resolve3, join, dirname as dirname2 } from "path";
464
+ import { homedir } from "os";
464
465
  import { fileURLToPath } from "url";
465
466
  import { execFileSync } from "child_process";
466
467
  import { createInterface } from "readline";
@@ -684,7 +685,7 @@ function unlockProtectedDirs() {
684
685
  }
685
686
  }
686
687
  }
687
- function installHooks(selectedTools = []) {
688
+ function installHooks(selectedTools = [], wrappedMcpConfig) {
688
689
  unlockProtectedDirs();
689
690
  const hooksDir = resolve3(".solongate", "hooks");
690
691
  mkdirSync2(hooksDir, { recursive: true });
@@ -725,35 +726,25 @@ function installHooks(selectedTools = []) {
725
726
  for (const client of clients) {
726
727
  const clientDir = resolve3(client.dir);
727
728
  mkdirSync2(clientDir, { recursive: true });
728
- const settingsPath = join(clientDir, "settings.json");
729
729
  const guardCmd = `node .solongate/hooks/guard.mjs ${client.agentId} "${client.agentName}"`;
730
730
  const auditCmd = `node .solongate/hooks/audit.mjs ${client.agentId} "${client.agentName}"`;
731
731
  const stopCmd = `node .solongate/hooks/stop.mjs ${client.agentId} "${client.agentName}"`;
732
- const hookSettings = {
733
- PreToolUse: [
734
- { matcher: "", hooks: [{ type: "command", command: guardCmd }] }
735
- ],
736
- PostToolUse: [
737
- { matcher: "", hooks: [{ type: "command", command: auditCmd }] }
738
- ],
739
- Stop: [
740
- { matcher: "", hooks: [{ type: "command", command: stopCmd }] }
741
- ]
742
- };
743
- let existing = {};
744
- try {
745
- existing = JSON.parse(readFileSync4(settingsPath, "utf-8"));
746
- } catch {
747
- }
748
- const merged = { ...existing, hooks: hookSettings };
749
- const mergedStr = JSON.stringify(merged, null, 2) + "\n";
750
- const existingStr = existsSync4(settingsPath) ? readFileSync4(settingsPath, "utf-8") : "";
751
- if (mergedStr === existingStr) {
752
- skippedNames.push(client.name);
732
+ if (client.key === "cursor") {
733
+ const result = installCursorConfig(clientDir, guardCmd, auditCmd, stopCmd, wrappedMcpConfig);
734
+ if (result === "skipped") skippedNames.push(client.name);
735
+ else activatedNames.push(client.name);
736
+ } else if (client.key === "gemini") {
737
+ const result = installGeminiConfig(clientDir, guardCmd, auditCmd, stopCmd, wrappedMcpConfig);
738
+ if (result === "skipped") skippedNames.push(client.name);
739
+ else activatedNames.push(client.name);
740
+ } else if (client.key === "openclaw") {
741
+ const result = installOpenClawConfig(clientDir);
742
+ if (result === "skipped") skippedNames.push(client.name);
743
+ else activatedNames.push(client.name);
753
744
  } else {
754
- writeFileSync2(settingsPath, mergedStr);
755
- console.log(` ${existingStr ? "Updated" : "Created"} ${settingsPath}`);
756
- activatedNames.push(client.name);
745
+ const result = installStandardHookConfig(clientDir, client.name, guardCmd, auditCmd, stopCmd);
746
+ if (result === "skipped") skippedNames.push(client.name);
747
+ else activatedNames.push(client.name);
757
748
  }
758
749
  }
759
750
  console.log("");
@@ -768,6 +759,125 @@ function installHooks(selectedTools = []) {
768
759
  console.log(` Already configured: ${skippedNames.join(", ")}`);
769
760
  }
770
761
  }
762
+ function installStandardHookConfig(clientDir, clientName, guardCmd, auditCmd, stopCmd) {
763
+ const settingsPath = join(clientDir, "settings.json");
764
+ const hookSettings = {
765
+ PreToolUse: [
766
+ { matcher: "", hooks: [{ type: "command", command: guardCmd }] }
767
+ ],
768
+ PostToolUse: [
769
+ { matcher: "", hooks: [{ type: "command", command: auditCmd }] }
770
+ ],
771
+ Stop: [
772
+ { matcher: "", hooks: [{ type: "command", command: stopCmd }] }
773
+ ]
774
+ };
775
+ let existing = {};
776
+ try {
777
+ existing = JSON.parse(readFileSync4(settingsPath, "utf-8"));
778
+ } catch {
779
+ }
780
+ const merged = { ...existing, hooks: hookSettings };
781
+ const mergedStr = JSON.stringify(merged, null, 2) + "\n";
782
+ const existingStr = existsSync4(settingsPath) ? readFileSync4(settingsPath, "utf-8") : "";
783
+ if (mergedStr === existingStr) return "skipped";
784
+ writeFileSync2(settingsPath, mergedStr);
785
+ console.log(` ${existingStr ? "Updated" : "Created"} ${settingsPath}`);
786
+ return "installed";
787
+ }
788
+ function installCursorConfig(clientDir, guardCmd, auditCmd, _stopCmd, wrappedMcpConfig) {
789
+ let changed = false;
790
+ const hookConfig = {
791
+ preToolUse: [
792
+ { matcher: "", command: guardCmd, failClosed: true }
793
+ ],
794
+ postToolUse: [
795
+ { matcher: "", command: auditCmd }
796
+ ]
797
+ };
798
+ const hooksContent = JSON.stringify({ version: 1, hooks: hookConfig }, null, 2) + "\n";
799
+ const userCursorDir = join(homedir(), ".cursor");
800
+ if (existsSync4(userCursorDir)) {
801
+ const userHooksPath = join(userCursorDir, "hooks.json");
802
+ const existingUserHooks = existsSync4(userHooksPath) ? readFileSync4(userHooksPath, "utf-8") : "";
803
+ if (hooksContent !== existingUserHooks) {
804
+ writeFileSync2(userHooksPath, hooksContent);
805
+ console.log(` ${existingUserHooks ? "Updated" : "Created"} ${userHooksPath} (user-level)`);
806
+ changed = true;
807
+ }
808
+ }
809
+ const hooksPath = join(clientDir, "hooks.json");
810
+ const existingHooks = existsSync4(hooksPath) ? readFileSync4(hooksPath, "utf-8") : "";
811
+ if (hooksContent !== existingHooks) {
812
+ writeFileSync2(hooksPath, hooksContent);
813
+ console.log(` ${existingHooks ? "Updated" : "Created"} ${hooksPath}`);
814
+ changed = true;
815
+ }
816
+ if (wrappedMcpConfig) {
817
+ const mcpPath = join(clientDir, "mcp.json");
818
+ const mcpStr = JSON.stringify(wrappedMcpConfig, null, 2) + "\n";
819
+ const existingMcp = existsSync4(mcpPath) ? readFileSync4(mcpPath, "utf-8") : "";
820
+ if (mcpStr !== existingMcp) {
821
+ writeFileSync2(mcpPath, mcpStr);
822
+ console.log(` ${existingMcp ? "Updated" : "Created"} ${mcpPath}`);
823
+ changed = true;
824
+ }
825
+ }
826
+ const staleSettings = join(clientDir, "settings.json");
827
+ if (existsSync4(staleSettings)) {
828
+ try {
829
+ const content = JSON.parse(readFileSync4(staleSettings, "utf-8"));
830
+ if (content.hooks?.PreToolUse || content.hooks?.PostToolUse) {
831
+ writeFileSync2(staleSettings, JSON.stringify({}, null, 2) + "\n");
832
+ console.log(` Cleaned stale ${staleSettings} (had wrong hook format)`);
833
+ changed = true;
834
+ }
835
+ } catch {
836
+ }
837
+ }
838
+ return changed ? "installed" : "skipped";
839
+ }
840
+ function installGeminiConfig(clientDir, guardCmd, auditCmd, _stopCmd, wrappedMcpConfig) {
841
+ const settingsPath = join(clientDir, "settings.json");
842
+ let existing = {};
843
+ try {
844
+ existing = JSON.parse(readFileSync4(settingsPath, "utf-8"));
845
+ } catch {
846
+ }
847
+ const merged = { ...existing };
848
+ if (wrappedMcpConfig) {
849
+ merged.mcpServers = wrappedMcpConfig.mcpServers;
850
+ }
851
+ merged.hooks = {
852
+ BeforeTool: [
853
+ { matcher: ".*", command: guardCmd }
854
+ ],
855
+ AfterTool: [
856
+ { matcher: ".*", command: auditCmd }
857
+ ]
858
+ };
859
+ const mergedStr = JSON.stringify(merged, null, 2) + "\n";
860
+ const existingStr = existsSync4(settingsPath) ? readFileSync4(settingsPath, "utf-8") : "";
861
+ if (mergedStr === existingStr) return "skipped";
862
+ writeFileSync2(settingsPath, mergedStr);
863
+ console.log(` ${existingStr ? "Updated" : "Created"} ${settingsPath}`);
864
+ return "installed";
865
+ }
866
+ function installOpenClawConfig(clientDir) {
867
+ const yamlPath = resolve3("openclaw.yaml");
868
+ const pluginLine = "@solongate/openclaw-plugin";
869
+ if (existsSync4(yamlPath)) {
870
+ const content = readFileSync4(yamlPath, "utf-8");
871
+ if (content.includes(pluginLine)) {
872
+ return "skipped";
873
+ }
874
+ }
875
+ console.log(` OpenClaw uses plugins, not hooks. Add to your openclaw.yaml:`);
876
+ console.log(` plugins:`);
877
+ console.log(` - "${pluginLine}"`);
878
+ console.log(` Then: npm install ${pluginLine}`);
879
+ return "installed";
880
+ }
771
881
  function ensureEnvFile() {
772
882
  let envChanged = false;
773
883
  let gitignoreChanged = false;
@@ -918,7 +1028,7 @@ async function main() {
918
1028
  console.log(" All servers are already protected by SolonGate!");
919
1029
  ensureEnvFile();
920
1030
  console.log("");
921
- installHooks(options.tools);
1031
+ installHooks(options.tools, config);
922
1032
  process.exit(0);
923
1033
  }
924
1034
  if (!options.all) {
@@ -1021,7 +1131,7 @@ async function main() {
1021
1131
  startSpinner("Installing hooks...");
1022
1132
  await sleep(300);
1023
1133
  stopSpinner("\u2713 Hooks");
1024
- installHooks(options.tools);
1134
+ installHooks(options.tools, newConfig);
1025
1135
  console.log("");
1026
1136
  const allUpToDate = toProtect.length === 0 && alreadyProtected.length === serverNames.length;
1027
1137
  console.log(" \u2500\u2500 Summary \u2500\u2500");
package/dist/init.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // src/init.ts
4
4
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
5
5
  import { resolve, join, dirname } from "path";
6
+ import { homedir } from "os";
6
7
  import { fileURLToPath } from "url";
7
8
  import { execFileSync } from "child_process";
8
9
  import { createInterface } from "readline";
@@ -270,7 +271,7 @@ function unlockProtectedDirs() {
270
271
  }
271
272
  }
272
273
  }
273
- function installHooks(selectedTools = []) {
274
+ function installHooks(selectedTools = [], wrappedMcpConfig) {
274
275
  unlockProtectedDirs();
275
276
  const hooksDir = resolve(".solongate", "hooks");
276
277
  mkdirSync(hooksDir, { recursive: true });
@@ -311,35 +312,25 @@ function installHooks(selectedTools = []) {
311
312
  for (const client of clients) {
312
313
  const clientDir = resolve(client.dir);
313
314
  mkdirSync(clientDir, { recursive: true });
314
- const settingsPath = join(clientDir, "settings.json");
315
315
  const guardCmd = `node .solongate/hooks/guard.mjs ${client.agentId} "${client.agentName}"`;
316
316
  const auditCmd = `node .solongate/hooks/audit.mjs ${client.agentId} "${client.agentName}"`;
317
317
  const stopCmd = `node .solongate/hooks/stop.mjs ${client.agentId} "${client.agentName}"`;
318
- const hookSettings = {
319
- PreToolUse: [
320
- { matcher: "", hooks: [{ type: "command", command: guardCmd }] }
321
- ],
322
- PostToolUse: [
323
- { matcher: "", hooks: [{ type: "command", command: auditCmd }] }
324
- ],
325
- Stop: [
326
- { matcher: "", hooks: [{ type: "command", command: stopCmd }] }
327
- ]
328
- };
329
- let existing = {};
330
- try {
331
- existing = JSON.parse(readFileSync(settingsPath, "utf-8"));
332
- } catch {
333
- }
334
- const merged = { ...existing, hooks: hookSettings };
335
- const mergedStr = JSON.stringify(merged, null, 2) + "\n";
336
- const existingStr = existsSync(settingsPath) ? readFileSync(settingsPath, "utf-8") : "";
337
- if (mergedStr === existingStr) {
338
- skippedNames.push(client.name);
318
+ if (client.key === "cursor") {
319
+ const result = installCursorConfig(clientDir, guardCmd, auditCmd, stopCmd, wrappedMcpConfig);
320
+ if (result === "skipped") skippedNames.push(client.name);
321
+ else activatedNames.push(client.name);
322
+ } else if (client.key === "gemini") {
323
+ const result = installGeminiConfig(clientDir, guardCmd, auditCmd, stopCmd, wrappedMcpConfig);
324
+ if (result === "skipped") skippedNames.push(client.name);
325
+ else activatedNames.push(client.name);
326
+ } else if (client.key === "openclaw") {
327
+ const result = installOpenClawConfig(clientDir);
328
+ if (result === "skipped") skippedNames.push(client.name);
329
+ else activatedNames.push(client.name);
339
330
  } else {
340
- writeFileSync(settingsPath, mergedStr);
341
- console.log(` ${existingStr ? "Updated" : "Created"} ${settingsPath}`);
342
- activatedNames.push(client.name);
331
+ const result = installStandardHookConfig(clientDir, client.name, guardCmd, auditCmd, stopCmd);
332
+ if (result === "skipped") skippedNames.push(client.name);
333
+ else activatedNames.push(client.name);
343
334
  }
344
335
  }
345
336
  console.log("");
@@ -354,6 +345,125 @@ function installHooks(selectedTools = []) {
354
345
  console.log(` Already configured: ${skippedNames.join(", ")}`);
355
346
  }
356
347
  }
348
+ function installStandardHookConfig(clientDir, clientName, guardCmd, auditCmd, stopCmd) {
349
+ const settingsPath = join(clientDir, "settings.json");
350
+ const hookSettings = {
351
+ PreToolUse: [
352
+ { matcher: "", hooks: [{ type: "command", command: guardCmd }] }
353
+ ],
354
+ PostToolUse: [
355
+ { matcher: "", hooks: [{ type: "command", command: auditCmd }] }
356
+ ],
357
+ Stop: [
358
+ { matcher: "", hooks: [{ type: "command", command: stopCmd }] }
359
+ ]
360
+ };
361
+ let existing = {};
362
+ try {
363
+ existing = JSON.parse(readFileSync(settingsPath, "utf-8"));
364
+ } catch {
365
+ }
366
+ const merged = { ...existing, hooks: hookSettings };
367
+ const mergedStr = JSON.stringify(merged, null, 2) + "\n";
368
+ const existingStr = existsSync(settingsPath) ? readFileSync(settingsPath, "utf-8") : "";
369
+ if (mergedStr === existingStr) return "skipped";
370
+ writeFileSync(settingsPath, mergedStr);
371
+ console.log(` ${existingStr ? "Updated" : "Created"} ${settingsPath}`);
372
+ return "installed";
373
+ }
374
+ function installCursorConfig(clientDir, guardCmd, auditCmd, _stopCmd, wrappedMcpConfig) {
375
+ let changed = false;
376
+ const hookConfig = {
377
+ preToolUse: [
378
+ { matcher: "", command: guardCmd, failClosed: true }
379
+ ],
380
+ postToolUse: [
381
+ { matcher: "", command: auditCmd }
382
+ ]
383
+ };
384
+ const hooksContent = JSON.stringify({ version: 1, hooks: hookConfig }, null, 2) + "\n";
385
+ const userCursorDir = join(homedir(), ".cursor");
386
+ if (existsSync(userCursorDir)) {
387
+ const userHooksPath = join(userCursorDir, "hooks.json");
388
+ const existingUserHooks = existsSync(userHooksPath) ? readFileSync(userHooksPath, "utf-8") : "";
389
+ if (hooksContent !== existingUserHooks) {
390
+ writeFileSync(userHooksPath, hooksContent);
391
+ console.log(` ${existingUserHooks ? "Updated" : "Created"} ${userHooksPath} (user-level)`);
392
+ changed = true;
393
+ }
394
+ }
395
+ const hooksPath = join(clientDir, "hooks.json");
396
+ const existingHooks = existsSync(hooksPath) ? readFileSync(hooksPath, "utf-8") : "";
397
+ if (hooksContent !== existingHooks) {
398
+ writeFileSync(hooksPath, hooksContent);
399
+ console.log(` ${existingHooks ? "Updated" : "Created"} ${hooksPath}`);
400
+ changed = true;
401
+ }
402
+ if (wrappedMcpConfig) {
403
+ const mcpPath = join(clientDir, "mcp.json");
404
+ const mcpStr = JSON.stringify(wrappedMcpConfig, null, 2) + "\n";
405
+ const existingMcp = existsSync(mcpPath) ? readFileSync(mcpPath, "utf-8") : "";
406
+ if (mcpStr !== existingMcp) {
407
+ writeFileSync(mcpPath, mcpStr);
408
+ console.log(` ${existingMcp ? "Updated" : "Created"} ${mcpPath}`);
409
+ changed = true;
410
+ }
411
+ }
412
+ const staleSettings = join(clientDir, "settings.json");
413
+ if (existsSync(staleSettings)) {
414
+ try {
415
+ const content = JSON.parse(readFileSync(staleSettings, "utf-8"));
416
+ if (content.hooks?.PreToolUse || content.hooks?.PostToolUse) {
417
+ writeFileSync(staleSettings, JSON.stringify({}, null, 2) + "\n");
418
+ console.log(` Cleaned stale ${staleSettings} (had wrong hook format)`);
419
+ changed = true;
420
+ }
421
+ } catch {
422
+ }
423
+ }
424
+ return changed ? "installed" : "skipped";
425
+ }
426
+ function installGeminiConfig(clientDir, guardCmd, auditCmd, _stopCmd, wrappedMcpConfig) {
427
+ const settingsPath = join(clientDir, "settings.json");
428
+ let existing = {};
429
+ try {
430
+ existing = JSON.parse(readFileSync(settingsPath, "utf-8"));
431
+ } catch {
432
+ }
433
+ const merged = { ...existing };
434
+ if (wrappedMcpConfig) {
435
+ merged.mcpServers = wrappedMcpConfig.mcpServers;
436
+ }
437
+ merged.hooks = {
438
+ BeforeTool: [
439
+ { matcher: ".*", command: guardCmd }
440
+ ],
441
+ AfterTool: [
442
+ { matcher: ".*", command: auditCmd }
443
+ ]
444
+ };
445
+ const mergedStr = JSON.stringify(merged, null, 2) + "\n";
446
+ const existingStr = existsSync(settingsPath) ? readFileSync(settingsPath, "utf-8") : "";
447
+ if (mergedStr === existingStr) return "skipped";
448
+ writeFileSync(settingsPath, mergedStr);
449
+ console.log(` ${existingStr ? "Updated" : "Created"} ${settingsPath}`);
450
+ return "installed";
451
+ }
452
+ function installOpenClawConfig(clientDir) {
453
+ const yamlPath = resolve("openclaw.yaml");
454
+ const pluginLine = "@solongate/openclaw-plugin";
455
+ if (existsSync(yamlPath)) {
456
+ const content = readFileSync(yamlPath, "utf-8");
457
+ if (content.includes(pluginLine)) {
458
+ return "skipped";
459
+ }
460
+ }
461
+ console.log(` OpenClaw uses plugins, not hooks. Add to your openclaw.yaml:`);
462
+ console.log(` plugins:`);
463
+ console.log(` - "${pluginLine}"`);
464
+ console.log(` Then: npm install ${pluginLine}`);
465
+ return "installed";
466
+ }
357
467
  function ensureEnvFile() {
358
468
  let envChanged = false;
359
469
  let gitignoreChanged = false;
@@ -504,7 +614,7 @@ async function main() {
504
614
  console.log(" All servers are already protected by SolonGate!");
505
615
  ensureEnvFile();
506
616
  console.log("");
507
- installHooks(options.tools);
617
+ installHooks(options.tools, config);
508
618
  process.exit(0);
509
619
  }
510
620
  if (!options.all) {
@@ -607,7 +717,7 @@ async function main() {
607
717
  startSpinner("Installing hooks...");
608
718
  await sleep(300);
609
719
  stopSpinner("\u2713 Hooks");
610
- installHooks(options.tools);
720
+ installHooks(options.tools, newConfig);
611
721
  console.log("");
612
722
  const allUpToDate = toProtect.length === 0 && alreadyProtected.length === serverNames.length;
613
723
  console.log(" \u2500\u2500 Summary \u2500\u2500");
package/hooks/audit.mjs CHANGED
@@ -26,8 +26,9 @@ const API_KEY = process.env.SOLONGATE_API_KEY || dotenv.SOLONGATE_API_KEY || '';
26
26
  const API_URL = process.env.SOLONGATE_API_URL || dotenv.SOLONGATE_API_URL || 'https://api.solongate.com';
27
27
 
28
28
  // Agent identity from CLI args: node audit.mjs <agent_id> <agent_name>
29
- const AGENT_ID = process.argv[2] || 'claude-code';
30
- const AGENT_NAME = process.argv[3] || 'Claude Code';
29
+ // Can be overridden at runtime when stdin contains cursor_version or gemini_version
30
+ let AGENT_ID = process.argv[2] || 'claude-code';
31
+ let AGENT_NAME = process.argv[3] || 'Claude Code';
31
32
 
32
33
  if (!API_KEY || !API_KEY.startsWith('sg_live_')) process.exit(0);
33
34
 
@@ -36,16 +37,46 @@ process.stdin.on('data', c => input += c);
36
37
  process.stdin.on('end', async () => {
37
38
  try {
38
39
  const data = JSON.parse(input);
39
- const toolName = data.tool_name || 'unknown';
40
- const toolInput = data.tool_input || {};
40
+
41
+ // Auto-detect agent from stdin data (overrides CLI args if detected)
42
+ if (data.cursor_version) {
43
+ AGENT_ID = 'cursor';
44
+ AGENT_NAME = 'Cursor';
45
+ } else if (data.gemini_version) {
46
+ AGENT_ID = 'gemini-cli';
47
+ AGENT_NAME = 'Gemini CLI';
48
+ }
49
+
50
+ // Normalize field names across tools (Claude: tool_name, others may use toolName)
51
+ const toolName = data.tool_name || data.toolName || 'unknown';
52
+ const toolInput = data.tool_input || data.toolInput || data.params || {};
41
53
 
42
54
  if (toolName === 'Bash' && JSON.stringify(toolInput).includes('audit-logs')) {
43
55
  process.exit(0);
44
56
  }
45
57
 
46
- const hasError = data.tool_response?.error ||
47
- data.tool_response?.exitCode > 0 ||
48
- data.tool_response?.isError;
58
+ // Check if guard.mjs already logged a DENY for this tool (avoid duplicate ALLOW after DENY)
59
+ let guardDenied = false;
60
+ try {
61
+ const denyFlagPath = resolve('.solongate', '.last-deny');
62
+ if (existsSync(denyFlagPath)) {
63
+ const flag = JSON.parse(readFileSync(denyFlagPath, 'utf-8'));
64
+ // If deny was recent (< 10s) and same tool, this postToolUse is a duplicate
65
+ if (flag.ts && Date.now() - flag.ts < 10000 && flag.tool === toolName) {
66
+ guardDenied = true;
67
+ }
68
+ }
69
+ } catch {}
70
+
71
+ // Cursor uses result_json, Claude uses tool_response
72
+ const toolResponse = data.tool_response || data.toolResponse || {};
73
+ const resultJson = data.result_json ? (typeof data.result_json === 'string' ? data.result_json : JSON.stringify(data.result_json)) : '';
74
+
75
+ const hasError = guardDenied ||
76
+ toolResponse.error ||
77
+ toolResponse.exitCode > 0 ||
78
+ toolResponse.isError ||
79
+ (resultJson && resultJson.includes('"error"'));
49
80
 
50
81
  const argsSummary = {};
51
82
  for (const [k, v] of Object.entries(toolInput)) {
@@ -61,7 +92,8 @@ process.stdin.on('end', async () => {
61
92
  writeFileSync(join(flagDir, '.last-tool-call'), Date.now().toString());
62
93
  } catch {}
63
94
 
64
- await fetch(`${API_URL}/api/v1/audit-logs`, {
95
+ // Fire-and-forget: don't block tool execution waiting for API response
96
+ fetch(`${API_URL}/api/v1/audit-logs`, {
65
97
  method: 'POST',
66
98
  headers: {
67
99
  'Authorization': `Bearer ${API_KEY}`,
@@ -71,16 +103,17 @@ process.stdin.on('end', async () => {
71
103
  tool: toolName,
72
104
  arguments: argsSummary,
73
105
  decision: hasError ? 'DENY' : 'ALLOW',
74
- reason: hasError ? 'tool returned error' : 'allowed',
75
- source: 'claude-code-hook',
106
+ reason: guardDenied ? 'blocked by policy guard' : hasError ? 'tool returned error' : 'allowed',
107
+ source: `${AGENT_ID}-hook`,
76
108
  evaluationTimeMs: 0,
77
109
  agent_id: AGENT_ID,
78
110
  agent_name: AGENT_NAME,
79
111
  }),
80
112
  signal: AbortSignal.timeout(5000),
81
- });
113
+ }).catch(() => {}).finally(() => process.exit(0));
114
+ // Exit after short delay if fetch hangs on DNS/connect
115
+ setTimeout(() => process.exit(0), 3000);
82
116
  } catch {
83
- // Silent
117
+ process.exit(0);
84
118
  }
85
- process.exit(0);
86
119
  });
package/hooks/guard.mjs CHANGED
@@ -41,15 +41,53 @@ const API_KEY = process.env.SOLONGATE_API_KEY || dotenv.SOLONGATE_API_KEY || '';
41
41
  const API_URL = process.env.SOLONGATE_API_URL || dotenv.SOLONGATE_API_URL || 'https://api.solongate.com';
42
42
 
43
43
  // Agent identity from CLI args: node guard.mjs <agent_id> <agent_name>
44
- const AGENT_ID = process.argv[2] || 'claude-code';
45
- const AGENT_NAME = process.argv[3] || 'Claude Code';
44
+ // Can be overridden at runtime when stdin contains cursor_version or gemini_version
45
+ let AGENT_ID = process.argv[2] || 'claude-code';
46
+ let AGENT_NAME = process.argv[3] || 'Claude Code';
47
+
48
+ // ── Per-tool block/allow output ──
49
+ // Claude Code: exit 2 + stderr = BLOCK, exit 0 = ALLOW
50
+ // Cursor: JSON stdout {"permission": "deny", "user_message": "..."} = BLOCK
51
+ // Gemini CLI: JSON stdout {"decision": "deny", "reason": "..."} = BLOCK
52
+ function blockTool(reason) {
53
+ if (AGENT_ID === 'cursor') {
54
+ // Cursor: JSON stdout with permission: "deny", exit 0
55
+ process.stdout.write(JSON.stringify({
56
+ permission: 'deny',
57
+ user_message: `[SolonGate] ${reason}`,
58
+ agent_message: `BLOCKED by SolonGate security policy: ${reason}`,
59
+ }));
60
+ process.exit(0);
61
+ } else if (AGENT_ID === 'gemini-cli') {
62
+ process.stdout.write(JSON.stringify({
63
+ decision: 'deny',
64
+ reason: `[SolonGate] ${reason}`,
65
+ }));
66
+ process.exit(0);
67
+ } else {
68
+ // Claude Code, Antigravity, Perplexity — exit code 2
69
+ process.stderr.write(reason);
70
+ process.exit(2);
71
+ }
72
+ }
73
+
74
+ function allowTool() {
75
+ if (AGENT_ID === 'cursor') {
76
+ process.stdout.write(JSON.stringify({ permission: 'allow' }));
77
+ } else if (AGENT_ID === 'gemini-cli') {
78
+ process.stdout.write(JSON.stringify({ decision: 'allow' }));
79
+ }
80
+ process.exit(0);
81
+ }
46
82
 
47
83
  // Write flag file so stop.mjs knows a tool call (DENY) happened and doesn't log extra ALLOW
48
- function writeDenyFlag() {
84
+ function writeDenyFlag(toolName) {
49
85
  try {
50
86
  const flagDir = resolve('.solongate');
51
87
  mkdirSync(flagDir, { recursive: true });
52
88
  writeFileSync(join(flagDir, '.last-tool-call'), Date.now().toString());
89
+ // Write deny-specific flag so audit.mjs can detect and skip duplicate ALLOW logging
90
+ writeFileSync(join(flagDir, '.last-deny'), JSON.stringify({ tool: toolName, ts: Date.now() }));
53
91
  } catch {}
54
92
  }
55
93
 
@@ -335,8 +373,27 @@ let input = '';
335
373
  process.stdin.on('data', c => input += c);
336
374
  process.stdin.on('end', async () => {
337
375
  try {
338
- const data = JSON.parse(input);
339
- const args = data.tool_input || {};
376
+ const raw = JSON.parse(input);
377
+
378
+ // Auto-detect agent from stdin data (overrides CLI args if detected)
379
+ if (raw.cursor_version) {
380
+ AGENT_ID = 'cursor';
381
+ AGENT_NAME = 'Cursor';
382
+ } else if (raw.gemini_version) {
383
+ AGENT_ID = 'gemini-cli';
384
+ AGENT_NAME = 'Gemini CLI';
385
+ }
386
+
387
+ // Normalize field names across tools (Claude: tool_name/tool_input, others may use toolName/toolInput/params)
388
+ const data = {
389
+ ...raw,
390
+ tool_name: raw.tool_name || raw.toolName || '',
391
+ tool_input: raw.tool_input || raw.toolInput || raw.params || {},
392
+ tool_response: raw.tool_response || raw.toolResponse || {},
393
+ cwd: raw.cwd || process.cwd(),
394
+ session_id: raw.session_id || raw.sessionId || '',
395
+ };
396
+ const args = data.tool_input;
340
397
 
341
398
  // ── Self-protection: block access to hook files and settings ──
342
399
  // Hardcoded, no bypass possible — runs before policy/PI config
@@ -358,16 +415,15 @@ process.stdin.on('end', async () => {
358
415
  body: JSON.stringify({
359
416
  tool: data.tool_name || '', arguments: args,
360
417
  decision: 'DENY', reason,
361
- source: 'claude-code-guard',
418
+ source: `${AGENT_ID}-guard`,
362
419
  agent_id: AGENT_ID, agent_name: AGENT_NAME,
363
420
  }),
364
421
  signal: AbortSignal.timeout(3000),
365
422
  });
366
423
  } catch {}
367
424
  }
368
- writeDenyFlag();
369
- process.stderr.write(reason);
370
- process.exit(2);
425
+ writeDenyFlag(toolName);
426
+ blockTool(reason);
371
427
  }
372
428
 
373
429
  // ── Normalization layers ──
@@ -909,19 +965,34 @@ process.stdin.on('end', async () => {
909
965
  }
910
966
  }
911
967
 
912
- // ── Fetch PI config from Cloud ──
968
+ // ── Fetch PI config from Cloud (cached for 60s to avoid per-call latency) ──
913
969
  let piCfg = { piEnabled: true, piThreshold: 0.5, piMode: 'block', piWhitelist: [], piToolConfig: {}, piCustomPatterns: [], piWebhookUrl: null };
914
970
  if (API_KEY && API_KEY.startsWith('sg_live_')) {
971
+ const cacheFile = join(resolve('.solongate'), '.pi-config-cache.json');
972
+ let usedCache = false;
915
973
  try {
916
- const cfgRes = await fetch(API_URL + '/api/v1/project-config', {
917
- headers: { 'Authorization': 'Bearer ' + API_KEY },
918
- signal: AbortSignal.timeout(3000),
919
- });
920
- if (cfgRes.ok) {
921
- const cfg = await cfgRes.json();
922
- piCfg = { ...piCfg, ...cfg };
974
+ if (existsSync(cacheFile)) {
975
+ const cached = JSON.parse(readFileSync(cacheFile, 'utf-8'));
976
+ if (cached._ts && Date.now() - cached._ts < 60000) {
977
+ const { _ts, ...rest } = cached;
978
+ piCfg = { ...piCfg, ...rest };
979
+ usedCache = true;
980
+ }
923
981
  }
924
- } catch {} // Fallback: defaults (safe)
982
+ } catch {}
983
+ if (!usedCache) {
984
+ try {
985
+ const cfgRes = await fetch(API_URL + '/api/v1/project-config', {
986
+ headers: { 'Authorization': 'Bearer ' + API_KEY },
987
+ signal: AbortSignal.timeout(3000),
988
+ });
989
+ if (cfgRes.ok) {
990
+ const cfg = await cfgRes.json();
991
+ piCfg = { ...piCfg, ...cfg };
992
+ try { writeFileSync(cacheFile, JSON.stringify({ ...cfg, _ts: Date.now() })); } catch {}
993
+ }
994
+ } catch {} // Fallback: defaults (safe)
995
+ }
925
996
  }
926
997
 
927
998
  // ── Per-tool config: check if PI scanning is disabled for this tool ──
@@ -991,7 +1062,7 @@ process.stdin.on('end', async () => {
991
1062
  arguments: args,
992
1063
  decision: isLogOnly ? 'ALLOW' : 'DENY',
993
1064
  reason: msg,
994
- source: 'claude-code-guard',
1065
+ source: `${AGENT_ID}-guard`,
995
1066
  agent_id: AGENT_ID, agent_name: AGENT_NAME,
996
1067
  pi_detected: true,
997
1068
  pi_trust_score: piResult.trustScore,
@@ -1029,9 +1100,8 @@ process.stdin.on('end', async () => {
1029
1100
  process.stderr.write(msg);
1030
1101
  // Fall through to policy evaluation (don't exit)
1031
1102
  } else {
1032
- writeDenyFlag();
1033
- process.stderr.write(msg);
1034
- process.exit(2);
1103
+ writeDenyFlag(toolName);
1104
+ blockTool(msg);
1035
1105
  }
1036
1106
  }
1037
1107
 
@@ -1053,7 +1123,7 @@ process.stdin.on('end', async () => {
1053
1123
  arguments: args,
1054
1124
  decision: 'ALLOW',
1055
1125
  reason: 'Prompt injection detected but below threshold (trust: ' + (piResult.trustScore * 100).toFixed(0) + '%)',
1056
- source: 'claude-code-guard',
1126
+ source: `${AGENT_ID}-guard`,
1057
1127
  agent_id: AGENT_ID, agent_name: AGENT_NAME,
1058
1128
  pi_detected: true,
1059
1129
  pi_trust_score: piResult.trustScore,
@@ -1065,7 +1135,7 @@ process.stdin.on('end', async () => {
1065
1135
  });
1066
1136
  } catch {}
1067
1137
  }
1068
- process.exit(0); // No policy = allow all
1138
+ allowTool(); // No policy = allow all
1069
1139
  }
1070
1140
 
1071
1141
  let reason = evaluate(policy, args);
@@ -1078,8 +1148,23 @@ process.stdin.on('end', async () => {
1078
1148
  let aiJudgeEndpoint = 'https://api.groq.com/openai';
1079
1149
  let aiJudgeTimeout = 5000;
1080
1150
 
1081
- // Check cloud config for AI Judge settings
1151
+ // Check cloud config for AI Judge settings (cached for 60s)
1082
1152
  if (API_KEY && API_KEY.startsWith('sg_live_')) {
1153
+ const ajCacheFile = join(resolve('.solongate'), '.aj-config-cache.json');
1154
+ let ajCached = false;
1155
+ try {
1156
+ if (existsSync(ajCacheFile)) {
1157
+ const cached = JSON.parse(readFileSync(ajCacheFile, 'utf-8'));
1158
+ if (cached._ts && Date.now() - cached._ts < 60000) {
1159
+ aiJudgeEnabled = Boolean(cached.enabled);
1160
+ if (cached.model) aiJudgeModel = cached.model;
1161
+ if (cached.endpoint) aiJudgeEndpoint = cached.endpoint;
1162
+ if (cached.timeoutMs) aiJudgeTimeout = cached.timeoutMs;
1163
+ ajCached = true;
1164
+ }
1165
+ }
1166
+ } catch {}
1167
+ if (!ajCached) {
1083
1168
  try {
1084
1169
  const cfgRes = await fetch(API_URL + '/api/v1/project-config/ai-judge', {
1085
1170
  headers: { 'Authorization': 'Bearer ' + API_KEY },
@@ -1091,8 +1176,10 @@ process.stdin.on('end', async () => {
1091
1176
  if (cfg.model) aiJudgeModel = cfg.model;
1092
1177
  if (cfg.endpoint) aiJudgeEndpoint = cfg.endpoint;
1093
1178
  if (cfg.timeoutMs) aiJudgeTimeout = cfg.timeoutMs;
1179
+ try { writeFileSync(ajCacheFile, JSON.stringify({ ...cfg, _ts: Date.now() })); } catch {}
1094
1180
  }
1095
1181
  } catch {}
1182
+ }
1096
1183
  }
1097
1184
 
1098
1185
  if (aiJudgeEnabled && GROQ_KEY) {
@@ -1202,7 +1289,7 @@ Respond with ONLY valid JSON: {"decision": "ALLOW" or "DENY", "reason": "brief e
1202
1289
  const logEntry = {
1203
1290
  tool: toolName, arguments: args,
1204
1291
  decision: 'DENY', reason,
1205
- source: 'claude-code-guard',
1292
+ source: `${AGENT_ID}-guard`,
1206
1293
  agent_id: AGENT_ID, agent_name: AGENT_NAME,
1207
1294
  };
1208
1295
  if (piResult) {
@@ -1220,10 +1307,9 @@ Respond with ONLY valid JSON: {"decision": "ALLOW" or "DENY", "reason": "brief e
1220
1307
  });
1221
1308
  } catch {}
1222
1309
  }
1223
- writeDenyFlag();
1224
- process.stderr.write(reason);
1225
- process.exit(2);
1310
+ writeDenyFlag(toolName);
1311
+ blockTool(reason);
1226
1312
  }
1227
1313
  } catch {}
1228
- process.exit(0);
1314
+ allowTool();
1229
1315
  });
package/hooks/stop.mjs CHANGED
@@ -27,8 +27,9 @@ const API_KEY = process.env.SOLONGATE_API_KEY || dotenv.SOLONGATE_API_KEY || '';
27
27
  const API_URL = process.env.SOLONGATE_API_URL || dotenv.SOLONGATE_API_URL || 'https://api.solongate.com';
28
28
 
29
29
  // Agent identity from CLI args: node stop.mjs <agent_id> <agent_name>
30
- const AGENT_ID = process.argv[2] || 'claude-code';
31
- const AGENT_NAME = process.argv[3] || 'Claude Code';
30
+ // Can be overridden at runtime when stdin contains cursor_version or gemini_version
31
+ let AGENT_ID = process.argv[2] || 'claude-code';
32
+ let AGENT_NAME = process.argv[3] || 'Claude Code';
32
33
 
33
34
  if (!API_KEY || !API_KEY.startsWith('sg_live_')) process.exit(0);
34
35
 
@@ -42,6 +43,13 @@ let input = '';
42
43
  process.stdin.on('data', c => input += c);
43
44
  process.stdin.on('end', async () => {
44
45
  try {
46
+ // Auto-detect agent from stdin data
47
+ try {
48
+ const raw = JSON.parse(input);
49
+ if (raw.cursor_version) { AGENT_ID = 'cursor'; AGENT_NAME = 'Cursor'; }
50
+ else if (raw.gemini_version) { AGENT_ID = 'gemini-cli'; AGENT_NAME = 'Gemini CLI'; }
51
+ } catch {}
52
+
45
53
  // Check if tool calls were made in this turn
46
54
  if (existsSync(flagFile)) {
47
55
  // Tool calls happened → audit.mjs already logged them. Clean up flag.
@@ -49,8 +57,8 @@ process.stdin.on('end', async () => {
49
57
  process.exit(0);
50
58
  }
51
59
 
52
- // No tool calls → log 1 ALLOW for the text-only response
53
- await fetch(`${API_URL}/api/v1/audit-logs`, {
60
+ // No tool calls → log 1 ALLOW for the text-only response (fire-and-forget)
61
+ fetch(`${API_URL}/api/v1/audit-logs`, {
54
62
  method: 'POST',
55
63
  headers: {
56
64
  'Authorization': `Bearer ${API_KEY}`,
@@ -61,15 +69,15 @@ process.stdin.on('end', async () => {
61
69
  arguments: {},
62
70
  decision: 'ALLOW',
63
71
  reason: 'text response (no tool calls)',
64
- source: 'claude-code-hook',
72
+ source: `${AGENT_ID}-hook`,
65
73
  evaluationTimeMs: 0,
66
74
  agent_id: AGENT_ID,
67
75
  agent_name: AGENT_NAME,
68
76
  }),
69
77
  signal: AbortSignal.timeout(5000),
70
- });
78
+ }).catch(() => {}).finally(() => process.exit(0));
79
+ setTimeout(() => process.exit(0), 3000);
71
80
  } catch {
72
- // Silent
81
+ process.exit(0);
73
82
  }
74
- process.exit(0);
75
83
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@solongate/proxy",
3
- "version": "0.29.0",
4
- "description": "MCP security proxy — protect any MCP server with customizable policies, path/command constraints, rate limiting, and audit logging. Zero code changes required.",
3
+ "version": "0.30.0",
4
+ "description": "AI tool security proxy — protect any AI tool server with customizable policies, path/command constraints, rate limiting, and audit logging. Zero code changes required.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "solongate-proxy": "./dist/index.js",
@@ -29,8 +29,8 @@
29
29
  "clean": "rm -rf dist .turbo"
30
30
  },
31
31
  "keywords": [
32
- "mcp",
33
- "model-context-protocol",
32
+ "ai-tool-security",
33
+ "ai-tool-proxy",
34
34
  "security",
35
35
  "proxy",
36
36
  "gateway",