@solongate/proxy 0.26.4 → 0.27.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/dist/index.js CHANGED
@@ -47,7 +47,11 @@ async function fetchCloudPolicy(apiKey, apiUrl, policyId) {
47
47
  }
48
48
  async function sendAuditLog(apiKey, apiUrl, entry) {
49
49
  const url = `${apiUrl}/api/v1/audit-logs`;
50
- const body = JSON.stringify(entry);
50
+ const body = JSON.stringify({
51
+ ...entry,
52
+ agent_id: entry.agent_id,
53
+ agent_name: entry.agent_name
54
+ });
51
55
  for (let attempt = 0; attempt < AUDIT_MAX_RETRIES; attempt++) {
52
56
  try {
53
57
  const res = await fetch(url, {
@@ -165,6 +169,7 @@ function parseArgs(argv) {
165
169
  let aiJudgeEndpoint = "https://api.groq.com/openai";
166
170
  let aiJudgeApiKey;
167
171
  let aiJudgeTimeout = 5e3;
172
+ let agentName;
168
173
  let separatorIndex = args.indexOf("--");
169
174
  const flags = separatorIndex >= 0 ? args.slice(0, separatorIndex) : args;
170
175
  const upstreamArgs = separatorIndex >= 0 ? args.slice(separatorIndex + 1) : [];
@@ -228,6 +233,9 @@ function parseArgs(argv) {
228
233
  case "--ai-judge-timeout":
229
234
  aiJudgeTimeout = parseInt(flags[++i], 10);
230
235
  break;
236
+ case "--agent-name":
237
+ agentName = flags[++i];
238
+ break;
231
239
  }
232
240
  }
233
241
  if (!aiJudgeApiKey) {
@@ -296,7 +304,8 @@ function parseArgs(argv) {
296
304
  policyPath: resolvePolicyPath(cfgPolicySource) ?? void 0,
297
305
  policyId: policyId ?? fileConfig.policyId,
298
306
  advancedDetection: advancedDetection ? { enabled: true } : void 0,
299
- aiJudge
307
+ aiJudge,
308
+ agentName
300
309
  };
301
310
  }
302
311
  if (upstreamUrl) {
@@ -319,7 +328,8 @@ function parseArgs(argv) {
319
328
  policyPath: resolvedPolicyPath ?? void 0,
320
329
  policyId,
321
330
  advancedDetection: advancedDetection ? { enabled: true } : void 0,
322
- aiJudge
331
+ aiJudge,
332
+ agentName
323
333
  };
324
334
  }
325
335
  if (upstreamArgs.length === 0) {
@@ -346,7 +356,8 @@ function parseArgs(argv) {
346
356
  policyPath: resolvedPolicyPath ?? void 0,
347
357
  policyId,
348
358
  advancedDetection: advancedDetection ? { enabled: true } : void 0,
349
- aiJudge
359
+ aiJudge,
360
+ agentName
350
361
  };
351
362
  }
352
363
  function resolvePolicyPath(source) {
@@ -481,10 +492,11 @@ function isAlreadyProtected(server) {
481
492
  }
482
493
  return false;
483
494
  }
484
- function wrapServer(server, policy) {
495
+ function wrapServer(serverName, server, policy) {
485
496
  const env = { ...server.env ?? {} };
486
497
  env.SOLONGATE_API_KEY = "${SOLONGATE_API_KEY}";
487
498
  const proxyArgs = ["-y", "@solongate/proxy@latest"];
499
+ proxyArgs.push("--agent-name", serverName);
488
500
  if (policy) {
489
501
  proxyArgs.push("--policy", policy);
490
502
  }
@@ -885,7 +897,7 @@ async function main() {
885
897
  const newConfig = { mcpServers: {} };
886
898
  for (const name of serverNames) {
887
899
  if (toProtect.includes(name)) {
888
- newConfig.mcpServers[name] = wrapServer(config.mcpServers[name], policyValue);
900
+ newConfig.mcpServers[name] = wrapServer(name, config.mcpServers[name], policyValue);
889
901
  } else {
890
902
  newConfig.mcpServers[name] = config.mcpServers[name];
891
903
  }
@@ -2889,25 +2901,25 @@ function checkEntropyLimits(value) {
2889
2901
  }
2890
2902
  function calculateShannonEntropy(str) {
2891
2903
  const freq = new Uint32Array(128);
2892
- let nonAsciiCount = 0;
2904
+ const nonAsciiFreq = /* @__PURE__ */ new Map();
2893
2905
  for (let i = 0; i < str.length; i++) {
2894
2906
  const code = str.charCodeAt(i);
2895
2907
  if (code < 128) {
2896
- freq[code] = (freq[code] ?? 0) + 1;
2908
+ freq[code] = freq[code] + 1;
2897
2909
  } else {
2898
- nonAsciiCount++;
2910
+ nonAsciiFreq.set(code, (nonAsciiFreq.get(code) || 0) + 1);
2899
2911
  }
2900
2912
  }
2901
2913
  let entropy = 0;
2902
2914
  const len = str.length;
2903
2915
  for (let i = 0; i < 128; i++) {
2904
- if ((freq[i] ?? 0) > 0) {
2905
- const p = (freq[i] ?? 0) / len;
2916
+ if (freq[i] > 0) {
2917
+ const p = freq[i] / len;
2906
2918
  entropy -= p * Math.log2(p);
2907
2919
  }
2908
2920
  }
2909
- if (nonAsciiCount > 0) {
2910
- const p = nonAsciiCount / len;
2921
+ for (const count of nonAsciiFreq.values()) {
2922
+ const p = count / len;
2911
2923
  entropy -= p * Math.log2(p);
2912
2924
  }
2913
2925
  return entropy;
@@ -3978,7 +3990,7 @@ function urlConstraintsMatch(constraints, args) {
3978
3990
  }
3979
3991
  function evaluatePolicy(policySet, request) {
3980
3992
  const startTime = performance.now();
3981
- const sortedRules = policySet.rules;
3993
+ const sortedRules = [...policySet.rules].sort((a, b) => a.priority - b.priority);
3982
3994
  for (const rule of sortedRules) {
3983
3995
  if (ruleMatchesRequest(rule, request)) {
3984
3996
  const endTime2 = performance.now();
@@ -5457,9 +5469,6 @@ var PolicySyncManager = class {
5457
5469
  */
5458
5470
  policiesEqual(a, b) {
5459
5471
  if (a.name !== b.name || a.rules.length !== b.rules.length) return false;
5460
- if (a.version !== void 0 && b.version !== void 0 && a.version === b.version && a.id === b.id) {
5461
- return true;
5462
- }
5463
5472
  return JSON.stringify(a.rules) === JSON.stringify(b.rules);
5464
5473
  }
5465
5474
  };
@@ -5499,12 +5508,17 @@ CRITICAL: Only DENY access to files EXPLICITLY in the protected_files list. "cat
5499
5508
 
5500
5509
  Respond with ONLY valid JSON, no markdown, no explanation outside the JSON:
5501
5510
  {"decision": "ALLOW" or "DENY", "reason": "brief one-line explanation", "confidence": 0.0 to 1.0}`;
5502
- var AiJudge = class {
5511
+ var AiJudge = class _AiJudge {
5503
5512
  config;
5504
5513
  protectedFiles;
5505
5514
  protectedPaths;
5506
5515
  deniedActions;
5507
5516
  isOllamaEndpoint;
5517
+ // Circuit breaker: after 3 consecutive failures in 60s, skip AI Judge
5518
+ consecutiveFailures = 0;
5519
+ lastFailureTime = 0;
5520
+ static CIRCUIT_BREAKER_THRESHOLD = 3;
5521
+ static CIRCUIT_BREAKER_RESET_MS = 6e4;
5508
5522
  constructor(config, protectedFiles, protectedPaths, deniedActions = [
5509
5523
  "file deletion",
5510
5524
  "data exfiltration",
@@ -5523,6 +5537,17 @@ var AiJudge = class {
5523
5537
  * Fail-closed: any error (timeout, parse failure, connection refused) → DENY.
5524
5538
  */
5525
5539
  async evaluate(toolName, args) {
5540
+ if (this.consecutiveFailures >= _AiJudge.CIRCUIT_BREAKER_THRESHOLD) {
5541
+ const elapsed = Date.now() - this.lastFailureTime;
5542
+ if (elapsed < _AiJudge.CIRCUIT_BREAKER_RESET_MS) {
5543
+ return {
5544
+ decision: "ALLOW",
5545
+ reason: `AI Judge circuit breaker open (${this.consecutiveFailures} consecutive failures) \u2014 falling back to policy-only`,
5546
+ confidence: 0.5
5547
+ };
5548
+ }
5549
+ this.consecutiveFailures = 0;
5550
+ }
5526
5551
  const sanitizedArgs = this.sanitizeArgs(args);
5527
5552
  const userMessage = JSON.stringify({
5528
5553
  tool: toolName,
@@ -5533,12 +5558,15 @@ var AiJudge = class {
5533
5558
  });
5534
5559
  try {
5535
5560
  const response = await this.callLLM(userMessage);
5561
+ this.consecutiveFailures = 0;
5536
5562
  return this.parseVerdict(response);
5537
5563
  } catch (err) {
5564
+ this.consecutiveFailures++;
5565
+ this.lastFailureTime = Date.now();
5538
5566
  const message = err instanceof Error ? err.message : String(err);
5539
5567
  return {
5540
5568
  decision: "DENY",
5541
- reason: `AI Judge error (fail-closed): ${message}`,
5569
+ reason: `AI Judge error (fail-closed): ${message.slice(0, 100)}`,
5542
5570
  confidence: 1
5543
5571
  };
5544
5572
  }
@@ -5551,8 +5579,15 @@ var AiJudge = class {
5551
5579
  const sanitize = (val, depth = 0) => {
5552
5580
  if (depth > 10) return "[nested]";
5553
5581
  if (typeof val === "string") {
5554
- const truncated = val.length > maxStringLen ? val.slice(0, maxStringLen) + "...[truncated]" : val;
5555
- return truncated;
5582
+ let s = val.length > maxStringLen ? val.slice(0, maxStringLen) + "...[truncated]" : val;
5583
+ s = s.replace(/\{[^{}]*"decision"\s*:/gi, "{[redacted]:");
5584
+ s = s.replace(/\{[^{}]*"ALLOW"[^{}]*\}/gi, "[redacted-json]");
5585
+ s = s.replace(/\{[^{}]*"DENY"[^{}]*\}/gi, "[redacted-json]");
5586
+ s = s.replace(/respond\s+with\s*:/gi, "[redacted]");
5587
+ s = s.replace(/ignore\s+(previous|above|all)\s+(instructions?|prompts?)/gi, "[redacted]");
5588
+ s = s.replace(/you\s+are\s+now\s+/gi, "[redacted] ");
5589
+ s = s.replace(/system\s*:\s*/gi, "[redacted]: ");
5590
+ return s;
5556
5591
  }
5557
5592
  if (Array.isArray(val)) return val.slice(0, 50).map((v) => sanitize(v, depth + 1));
5558
5593
  if (val && typeof val === "object") {
@@ -5595,7 +5630,8 @@ var AiJudge = class {
5595
5630
  { role: "user", content: userMessage }
5596
5631
  ],
5597
5632
  temperature: 0,
5598
- max_tokens: 200
5633
+ max_tokens: 200,
5634
+ response_format: { type: "json_object" }
5599
5635
  });
5600
5636
  if (this.config.apiKey) {
5601
5637
  headers["Authorization"] = `Bearer ${this.config.apiKey}`;
@@ -5721,9 +5757,20 @@ var SolonGateProxy = class {
5721
5757
  aiJudge = null;
5722
5758
  upstreamTools = [];
5723
5759
  guardConfig;
5760
+ /** Agent identity for trust map — resolved from CLI flag, HTTP headers, or MCP clientInfo */
5761
+ agentId = null;
5762
+ agentName = null;
5763
+ /** Per-session agent info for HTTP mode (keyed by session ID) */
5764
+ httpAgentInfo = /* @__PURE__ */ new Map();
5765
+ /** Per-request sub-agent info from HTTP headers (transient, overwritten per request) */
5766
+ httpSubAgent = null;
5724
5767
  constructor(config) {
5725
5768
  this.config = config;
5726
5769
  this.guardConfig = config.advancedDetection ? { ...DEFAULT_INPUT_GUARD_CONFIG, advancedDetection: config.advancedDetection } : DEFAULT_INPUT_GUARD_CONFIG;
5770
+ if (config.agentName) {
5771
+ this.agentName = config.agentName;
5772
+ this.agentId = config.agentName.toLowerCase().replace(/\s+/g, "-");
5773
+ }
5727
5774
  this.gate = new SolonGate({
5728
5775
  name: config.name ?? "solongate-proxy",
5729
5776
  apiKey: "sg_test_proxy_internal_00000000",
@@ -5740,6 +5787,19 @@ var SolonGateProxy = class {
5740
5787
  log2("WARNING:", w);
5741
5788
  }
5742
5789
  }
5790
+ /** Extract sub-agent identity from MCP _meta field */
5791
+ extractSubAgent(request) {
5792
+ const meta = request?.params?._meta;
5793
+ if (meta && typeof meta === "object") {
5794
+ const solonMeta = meta["io.solongate/agent"];
5795
+ if (solonMeta && typeof solonMeta === "object") {
5796
+ const agent = solonMeta;
5797
+ const id = agent.id ? String(agent.id) : null;
5798
+ if (id) return { subAgentId: id, subAgentName: agent.name ? String(agent.name) : id };
5799
+ }
5800
+ }
5801
+ return null;
5802
+ }
5743
5803
  /**
5744
5804
  * Start the proxy: connect to upstream, then serve downstream.
5745
5805
  */
@@ -5920,6 +5980,16 @@ var SolonGateProxy = class {
5920
5980
  }
5921
5981
  }
5922
5982
  );
5983
+ this.server.oninitialized = () => {
5984
+ if (!this.agentId && this.server) {
5985
+ const clientVersion = this.server.getClientVersion();
5986
+ if (clientVersion?.name) {
5987
+ this.agentId = clientVersion.version ? `${clientVersion.name}/${clientVersion.version}` : clientVersion.name;
5988
+ this.agentName = clientVersion.name;
5989
+ log2(`Agent identified from MCP clientInfo: ${this.agentName} (${this.agentId})`);
5990
+ }
5991
+ }
5992
+ };
5923
5993
  this.server.setRequestHandler(ListToolsRequestSchema, async () => {
5924
5994
  return { tools: this.upstreamTools };
5925
5995
  });
@@ -5927,6 +5997,7 @@ var SolonGateProxy = class {
5927
5997
  const MUTEX_TIMEOUT_MS = 3e4;
5928
5998
  this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
5929
5999
  const { name, arguments: args } = request.params;
6000
+ const subAgent = this.extractSubAgent(request) || this.httpSubAgent;
5930
6001
  const argsSize = TEXT_ENCODER.encode(JSON.stringify(args ?? {})).length;
5931
6002
  if (argsSize > MAX_ARGUMENT_SIZE) {
5932
6003
  log2(`DENY: ${name} \u2014 payload size ${argsSize} exceeds limit ${MAX_ARGUMENT_SIZE}`);
@@ -5964,7 +6035,11 @@ var SolonGateProxy = class {
5964
6035
  decision: "DENY",
5965
6036
  reason: `Prompt injection detected: ${threats}`,
5966
6037
  evaluationTimeMs: 0,
5967
- promptInjection: piResult
6038
+ promptInjection: piResult,
6039
+ agent_id: this.agentId ?? void 0,
6040
+ agent_name: this.agentName ?? void 0,
6041
+ sub_agent_id: subAgent?.subAgentId,
6042
+ sub_agent_name: subAgent?.subAgentName
5968
6043
  });
5969
6044
  }
5970
6045
  return {
@@ -6056,7 +6131,11 @@ var SolonGateProxy = class {
6056
6131
  reason,
6057
6132
  matchedRule,
6058
6133
  evaluationTimeMs,
6059
- promptInjection: piResult
6134
+ promptInjection: piResult,
6135
+ agent_id: this.agentId ?? void 0,
6136
+ agent_name: this.agentName ?? void 0,
6137
+ sub_agent_id: subAgent?.subAgentId,
6138
+ sub_agent_name: subAgent?.subAgentName
6060
6139
  });
6061
6140
  } else {
6062
6141
  log2(`Skipping audit log (apiKey: ${this.config.apiKey ? "test key" : "not set"})`);
@@ -6341,6 +6420,18 @@ ${msg.content.text}`;
6341
6420
  await this.server.connect(httpTransport);
6342
6421
  const httpServer = createHttpServer(async (req, res) => {
6343
6422
  if (req.url === "/mcp" || req.url?.startsWith("/mcp?")) {
6423
+ if (!this.agentId) {
6424
+ const headerAgentId = req.headers["x-agent-id"];
6425
+ const headerAgentName = req.headers["x-agent-name"];
6426
+ if (headerAgentId) {
6427
+ this.agentId = headerAgentId;
6428
+ this.agentName = headerAgentName || headerAgentId;
6429
+ log2(`Agent identified from HTTP headers: ${this.agentName} (${this.agentId})`);
6430
+ }
6431
+ }
6432
+ const subAgentId = req.headers["x-sub-agent-id"];
6433
+ const subAgentName = req.headers["x-sub-agent-name"];
6434
+ this.httpSubAgent = subAgentId ? { subAgentId, subAgentName: subAgentName || subAgentId } : null;
6344
6435
  await httpTransport.handleRequest(req, res);
6345
6436
  } else if (req.url === "/health") {
6346
6437
  res.writeHead(200, { "Content-Type": "application/json" });
package/dist/init.js CHANGED
@@ -98,10 +98,11 @@ function isAlreadyProtected(server) {
98
98
  }
99
99
  return false;
100
100
  }
101
- function wrapServer(server, policy) {
101
+ function wrapServer(serverName, server, policy) {
102
102
  const env = { ...server.env ?? {} };
103
103
  env.SOLONGATE_API_KEY = "${SOLONGATE_API_KEY}";
104
104
  const proxyArgs = ["-y", "@solongate/proxy@latest"];
105
+ proxyArgs.push("--agent-name", serverName);
105
106
  if (policy) {
106
107
  proxyArgs.push("--policy", policy);
107
108
  }
@@ -504,7 +505,7 @@ async function main() {
504
505
  const newConfig = { mcpServers: {} };
505
506
  for (const name of serverNames) {
506
507
  if (toProtect.includes(name)) {
507
- newConfig.mcpServers[name] = wrapServer(config.mcpServers[name], policyValue);
508
+ newConfig.mcpServers[name] = wrapServer(name, config.mcpServers[name], policyValue);
508
509
  } else {
509
510
  newConfig.mcpServers[name] = config.mcpServers[name];
510
511
  }
package/dist/lib.js CHANGED
@@ -1064,25 +1064,25 @@ function checkEntropyLimits(value) {
1064
1064
  }
1065
1065
  function calculateShannonEntropy(str) {
1066
1066
  const freq = new Uint32Array(128);
1067
- let nonAsciiCount = 0;
1067
+ const nonAsciiFreq = /* @__PURE__ */ new Map();
1068
1068
  for (let i = 0; i < str.length; i++) {
1069
1069
  const code = str.charCodeAt(i);
1070
1070
  if (code < 128) {
1071
- freq[code] = (freq[code] ?? 0) + 1;
1071
+ freq[code] = freq[code] + 1;
1072
1072
  } else {
1073
- nonAsciiCount++;
1073
+ nonAsciiFreq.set(code, (nonAsciiFreq.get(code) || 0) + 1);
1074
1074
  }
1075
1075
  }
1076
1076
  let entropy = 0;
1077
1077
  const len = str.length;
1078
1078
  for (let i = 0; i < 128; i++) {
1079
- if ((freq[i] ?? 0) > 0) {
1080
- const p = (freq[i] ?? 0) / len;
1079
+ if (freq[i] > 0) {
1080
+ const p = freq[i] / len;
1081
1081
  entropy -= p * Math.log2(p);
1082
1082
  }
1083
1083
  }
1084
- if (nonAsciiCount > 0) {
1085
- const p = nonAsciiCount / len;
1084
+ for (const count of nonAsciiFreq.values()) {
1085
+ const p = count / len;
1086
1086
  entropy -= p * Math.log2(p);
1087
1087
  }
1088
1088
  return entropy;
@@ -2178,7 +2178,7 @@ function urlConstraintsMatch(constraints, args) {
2178
2178
  }
2179
2179
  function evaluatePolicy(policySet, request) {
2180
2180
  const startTime = performance.now();
2181
- const sortedRules = policySet.rules;
2181
+ const sortedRules = [...policySet.rules].sort((a, b) => a.priority - b.priority);
2182
2182
  for (const rule of sortedRules) {
2183
2183
  if (ruleMatchesRequest(rule, request)) {
2184
2184
  const endTime2 = performance.now();
@@ -3661,7 +3661,11 @@ var AUDIT_MAX_RETRIES = 3;
3661
3661
  var AUDIT_LOG_BACKUP_PATH = resolve(".solongate-audit-backup.jsonl");
3662
3662
  async function sendAuditLog(apiKey, apiUrl, entry) {
3663
3663
  const url = `${apiUrl}/api/v1/audit-logs`;
3664
- const body = JSON.stringify(entry);
3664
+ const body = JSON.stringify({
3665
+ ...entry,
3666
+ agent_id: entry.agent_id,
3667
+ agent_name: entry.agent_name
3668
+ });
3665
3669
  for (let attempt = 0; attempt < AUDIT_MAX_RETRIES; attempt++) {
3666
3670
  try {
3667
3671
  const res = await fetch(url, {
@@ -3930,9 +3934,6 @@ var PolicySyncManager = class {
3930
3934
  */
3931
3935
  policiesEqual(a, b) {
3932
3936
  if (a.name !== b.name || a.rules.length !== b.rules.length) return false;
3933
- if (a.version !== void 0 && b.version !== void 0 && a.version === b.version && a.id === b.id) {
3934
- return true;
3935
- }
3936
3937
  return JSON.stringify(a.rules) === JSON.stringify(b.rules);
3937
3938
  }
3938
3939
  };
@@ -3972,12 +3973,17 @@ CRITICAL: Only DENY access to files EXPLICITLY in the protected_files list. "cat
3972
3973
 
3973
3974
  Respond with ONLY valid JSON, no markdown, no explanation outside the JSON:
3974
3975
  {"decision": "ALLOW" or "DENY", "reason": "brief one-line explanation", "confidence": 0.0 to 1.0}`;
3975
- var AiJudge = class {
3976
+ var AiJudge = class _AiJudge {
3976
3977
  config;
3977
3978
  protectedFiles;
3978
3979
  protectedPaths;
3979
3980
  deniedActions;
3980
3981
  isOllamaEndpoint;
3982
+ // Circuit breaker: after 3 consecutive failures in 60s, skip AI Judge
3983
+ consecutiveFailures = 0;
3984
+ lastFailureTime = 0;
3985
+ static CIRCUIT_BREAKER_THRESHOLD = 3;
3986
+ static CIRCUIT_BREAKER_RESET_MS = 6e4;
3981
3987
  constructor(config, protectedFiles, protectedPaths, deniedActions = [
3982
3988
  "file deletion",
3983
3989
  "data exfiltration",
@@ -3996,6 +4002,17 @@ var AiJudge = class {
3996
4002
  * Fail-closed: any error (timeout, parse failure, connection refused) → DENY.
3997
4003
  */
3998
4004
  async evaluate(toolName, args) {
4005
+ if (this.consecutiveFailures >= _AiJudge.CIRCUIT_BREAKER_THRESHOLD) {
4006
+ const elapsed = Date.now() - this.lastFailureTime;
4007
+ if (elapsed < _AiJudge.CIRCUIT_BREAKER_RESET_MS) {
4008
+ return {
4009
+ decision: "ALLOW",
4010
+ reason: `AI Judge circuit breaker open (${this.consecutiveFailures} consecutive failures) \u2014 falling back to policy-only`,
4011
+ confidence: 0.5
4012
+ };
4013
+ }
4014
+ this.consecutiveFailures = 0;
4015
+ }
3999
4016
  const sanitizedArgs = this.sanitizeArgs(args);
4000
4017
  const userMessage = JSON.stringify({
4001
4018
  tool: toolName,
@@ -4006,12 +4023,15 @@ var AiJudge = class {
4006
4023
  });
4007
4024
  try {
4008
4025
  const response = await this.callLLM(userMessage);
4026
+ this.consecutiveFailures = 0;
4009
4027
  return this.parseVerdict(response);
4010
4028
  } catch (err) {
4029
+ this.consecutiveFailures++;
4030
+ this.lastFailureTime = Date.now();
4011
4031
  const message = err instanceof Error ? err.message : String(err);
4012
4032
  return {
4013
4033
  decision: "DENY",
4014
- reason: `AI Judge error (fail-closed): ${message}`,
4034
+ reason: `AI Judge error (fail-closed): ${message.slice(0, 100)}`,
4015
4035
  confidence: 1
4016
4036
  };
4017
4037
  }
@@ -4024,8 +4044,15 @@ var AiJudge = class {
4024
4044
  const sanitize = (val, depth = 0) => {
4025
4045
  if (depth > 10) return "[nested]";
4026
4046
  if (typeof val === "string") {
4027
- const truncated = val.length > maxStringLen ? val.slice(0, maxStringLen) + "...[truncated]" : val;
4028
- return truncated;
4047
+ let s = val.length > maxStringLen ? val.slice(0, maxStringLen) + "...[truncated]" : val;
4048
+ s = s.replace(/\{[^{}]*"decision"\s*:/gi, "{[redacted]:");
4049
+ s = s.replace(/\{[^{}]*"ALLOW"[^{}]*\}/gi, "[redacted-json]");
4050
+ s = s.replace(/\{[^{}]*"DENY"[^{}]*\}/gi, "[redacted-json]");
4051
+ s = s.replace(/respond\s+with\s*:/gi, "[redacted]");
4052
+ s = s.replace(/ignore\s+(previous|above|all)\s+(instructions?|prompts?)/gi, "[redacted]");
4053
+ s = s.replace(/you\s+are\s+now\s+/gi, "[redacted] ");
4054
+ s = s.replace(/system\s*:\s*/gi, "[redacted]: ");
4055
+ return s;
4029
4056
  }
4030
4057
  if (Array.isArray(val)) return val.slice(0, 50).map((v) => sanitize(v, depth + 1));
4031
4058
  if (val && typeof val === "object") {
@@ -4068,7 +4095,8 @@ var AiJudge = class {
4068
4095
  { role: "user", content: userMessage }
4069
4096
  ],
4070
4097
  temperature: 0,
4071
- max_tokens: 200
4098
+ max_tokens: 200,
4099
+ response_format: { type: "json_object" }
4072
4100
  });
4073
4101
  if (this.config.apiKey) {
4074
4102
  headers["Authorization"] = `Bearer ${this.config.apiKey}`;
@@ -4194,9 +4222,20 @@ var SolonGateProxy = class {
4194
4222
  aiJudge = null;
4195
4223
  upstreamTools = [];
4196
4224
  guardConfig;
4225
+ /** Agent identity for trust map — resolved from CLI flag, HTTP headers, or MCP clientInfo */
4226
+ agentId = null;
4227
+ agentName = null;
4228
+ /** Per-session agent info for HTTP mode (keyed by session ID) */
4229
+ httpAgentInfo = /* @__PURE__ */ new Map();
4230
+ /** Per-request sub-agent info from HTTP headers (transient, overwritten per request) */
4231
+ httpSubAgent = null;
4197
4232
  constructor(config) {
4198
4233
  this.config = config;
4199
4234
  this.guardConfig = config.advancedDetection ? { ...DEFAULT_INPUT_GUARD_CONFIG, advancedDetection: config.advancedDetection } : DEFAULT_INPUT_GUARD_CONFIG;
4235
+ if (config.agentName) {
4236
+ this.agentName = config.agentName;
4237
+ this.agentId = config.agentName.toLowerCase().replace(/\s+/g, "-");
4238
+ }
4200
4239
  this.gate = new SolonGate({
4201
4240
  name: config.name ?? "solongate-proxy",
4202
4241
  apiKey: "sg_test_proxy_internal_00000000",
@@ -4213,6 +4252,19 @@ var SolonGateProxy = class {
4213
4252
  log2("WARNING:", w);
4214
4253
  }
4215
4254
  }
4255
+ /** Extract sub-agent identity from MCP _meta field */
4256
+ extractSubAgent(request) {
4257
+ const meta = request?.params?._meta;
4258
+ if (meta && typeof meta === "object") {
4259
+ const solonMeta = meta["io.solongate/agent"];
4260
+ if (solonMeta && typeof solonMeta === "object") {
4261
+ const agent = solonMeta;
4262
+ const id = agent.id ? String(agent.id) : null;
4263
+ if (id) return { subAgentId: id, subAgentName: agent.name ? String(agent.name) : id };
4264
+ }
4265
+ }
4266
+ return null;
4267
+ }
4216
4268
  /**
4217
4269
  * Start the proxy: connect to upstream, then serve downstream.
4218
4270
  */
@@ -4393,6 +4445,16 @@ var SolonGateProxy = class {
4393
4445
  }
4394
4446
  }
4395
4447
  );
4448
+ this.server.oninitialized = () => {
4449
+ if (!this.agentId && this.server) {
4450
+ const clientVersion = this.server.getClientVersion();
4451
+ if (clientVersion?.name) {
4452
+ this.agentId = clientVersion.version ? `${clientVersion.name}/${clientVersion.version}` : clientVersion.name;
4453
+ this.agentName = clientVersion.name;
4454
+ log2(`Agent identified from MCP clientInfo: ${this.agentName} (${this.agentId})`);
4455
+ }
4456
+ }
4457
+ };
4396
4458
  this.server.setRequestHandler(ListToolsRequestSchema, async () => {
4397
4459
  return { tools: this.upstreamTools };
4398
4460
  });
@@ -4400,6 +4462,7 @@ var SolonGateProxy = class {
4400
4462
  const MUTEX_TIMEOUT_MS = 3e4;
4401
4463
  this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
4402
4464
  const { name, arguments: args } = request.params;
4465
+ const subAgent = this.extractSubAgent(request) || this.httpSubAgent;
4403
4466
  const argsSize = TEXT_ENCODER.encode(JSON.stringify(args ?? {})).length;
4404
4467
  if (argsSize > MAX_ARGUMENT_SIZE) {
4405
4468
  log2(`DENY: ${name} \u2014 payload size ${argsSize} exceeds limit ${MAX_ARGUMENT_SIZE}`);
@@ -4437,7 +4500,11 @@ var SolonGateProxy = class {
4437
4500
  decision: "DENY",
4438
4501
  reason: `Prompt injection detected: ${threats}`,
4439
4502
  evaluationTimeMs: 0,
4440
- promptInjection: piResult
4503
+ promptInjection: piResult,
4504
+ agent_id: this.agentId ?? void 0,
4505
+ agent_name: this.agentName ?? void 0,
4506
+ sub_agent_id: subAgent?.subAgentId,
4507
+ sub_agent_name: subAgent?.subAgentName
4441
4508
  });
4442
4509
  }
4443
4510
  return {
@@ -4529,7 +4596,11 @@ var SolonGateProxy = class {
4529
4596
  reason,
4530
4597
  matchedRule,
4531
4598
  evaluationTimeMs,
4532
- promptInjection: piResult
4599
+ promptInjection: piResult,
4600
+ agent_id: this.agentId ?? void 0,
4601
+ agent_name: this.agentName ?? void 0,
4602
+ sub_agent_id: subAgent?.subAgentId,
4603
+ sub_agent_name: subAgent?.subAgentName
4533
4604
  });
4534
4605
  } else {
4535
4606
  log2(`Skipping audit log (apiKey: ${this.config.apiKey ? "test key" : "not set"})`);
@@ -4814,6 +4885,18 @@ ${msg.content.text}`;
4814
4885
  await this.server.connect(httpTransport);
4815
4886
  const httpServer = createHttpServer(async (req, res) => {
4816
4887
  if (req.url === "/mcp" || req.url?.startsWith("/mcp?")) {
4888
+ if (!this.agentId) {
4889
+ const headerAgentId = req.headers["x-agent-id"];
4890
+ const headerAgentName = req.headers["x-agent-name"];
4891
+ if (headerAgentId) {
4892
+ this.agentId = headerAgentId;
4893
+ this.agentName = headerAgentName || headerAgentId;
4894
+ log2(`Agent identified from HTTP headers: ${this.agentName} (${this.agentId})`);
4895
+ }
4896
+ }
4897
+ const subAgentId = req.headers["x-sub-agent-id"];
4898
+ const subAgentName = req.headers["x-sub-agent-name"];
4899
+ this.httpSubAgent = subAgentId ? { subAgentId, subAgentName: subAgentName || subAgentId } : null;
4817
4900
  await httpTransport.handleRequest(req, res);
4818
4901
  } else if (req.url === "/health") {
4819
4902
  res.writeHead(200, { "Content-Type": "application/json" });
package/hooks/guard.mjs CHANGED
@@ -7,9 +7,19 @@
7
7
  * Logs ALL decisions (ALLOW + DENY) to SolonGate Cloud.
8
8
  * Auto-installed by: npx @solongate/proxy init
9
9
  */
10
- import { readFileSync, existsSync } from 'node:fs';
10
+ import { readFileSync, existsSync, statSync } from 'node:fs';
11
11
  import { resolve } from 'node:path';
12
12
 
13
+ // Safe file read with size limit (1MB max) to prevent DoS via large files
14
+ const MAX_FILE_READ = 1024 * 1024; // 1MB
15
+ function safeReadFileSync(filePath, encoding = 'utf-8') {
16
+ try {
17
+ const stat = statSync(filePath);
18
+ if (stat.size > MAX_FILE_READ) return '';
19
+ return readFileSync(filePath, encoding);
20
+ } catch { return ''; }
21
+ }
22
+
13
23
  // ── Load API key from .env file (Claude Code doesn't load .env into process.env) ──
14
24
  function loadEnvKey(dir) {
15
25
  try {
@@ -157,6 +167,32 @@ function matchPathGlob(path, pattern) {
157
167
  return matchGlob(p, g);
158
168
  }
159
169
 
170
+ // ── Safe Webhook URL Validation (prevent SSRF) ──
171
+ function isSafeWebhookUrl(urlStr) {
172
+ try {
173
+ const u = new URL(urlStr);
174
+ if (u.protocol !== 'https:') return false;
175
+ const host = u.hostname.toLowerCase();
176
+ // Block private/reserved IPs and metadata endpoints
177
+ if (host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0' || host === '::1') return false;
178
+ if (host.startsWith('10.') || host.startsWith('192.168.') || host.startsWith('172.')) return false;
179
+ if (host === '169.254.169.254' || host === 'metadata.google.internal') return false;
180
+ if (host.endsWith('.internal') || host.endsWith('.local')) return false;
181
+ return true;
182
+ } catch { return false; }
183
+ }
184
+
185
+ // ── Safe Regex Validation (prevent ReDoS from cloud-supplied patterns) ──
186
+ function isSafeRegex(pattern) {
187
+ if (typeof pattern !== 'string' || pattern.length > 512) return false;
188
+ // Block nested quantifiers: (a+)+, (a*)+, (a{1,})+, etc.
189
+ if (/(\+|\*|\{[^}]+\})\s*(\+|\*|\{[^}]+\})/.test(pattern)) return false;
190
+ if (/\([^)]*(\+|\*|\{[^}]+\})[^)]*\)\s*(\+|\*|\{[^}]+\})/.test(pattern)) return false;
191
+ // Block excessive alternation groups (>10 alternatives)
192
+ if ((pattern.match(/\|/g) || []).length > 10) return false;
193
+ try { new RegExp(pattern); return true; } catch { return false; }
194
+ }
195
+
160
196
  // ── Extract Functions (deep scan all string values) ──
161
197
  function scanStrings(obj) {
162
198
  const strings = [];
@@ -643,7 +679,7 @@ process.stdin.on('end', async () => {
643
679
  try {
644
680
  const targetPath = resolve(hookCwdForNpm, scriptFileMatch[1]);
645
681
  if (existsSync(targetPath)) {
646
- const targetContent = readFileSync(targetPath, 'utf-8').toLowerCase();
682
+ const targetContent = safeReadFileSync(targetPath).toLowerCase();
647
683
  // Check target file for protected paths
648
684
  for (const pp of [...protectedPaths, ...writeProtectedPaths]) {
649
685
  if (targetContent.includes(pp)) {
@@ -670,7 +706,7 @@ process.stdin.on('end', async () => {
670
706
  const candidates = [impAbs, impAbs + '.mjs', impAbs + '.js', impAbs + '.cjs'];
671
707
  for (const c of candidates) {
672
708
  if (existsSync(c)) {
673
- const impContent = readFileSync(c, 'utf-8').toLowerCase();
709
+ const impContent = safeReadFileSync(c).toLowerCase();
674
710
  const iDisc = /\breaddirsync\b|\breaddir\b|\bos\.listdir\b|\bglob\b/i.test(impContent);
675
711
  const iDest = /\brmsync\b|\bunlinksync\b|\bfs\.\s*(?:rm|unlink|rmdir)/i.test(impContent);
676
712
  if ((iDisc && tDest) || (tDisc && iDest)) {
@@ -756,7 +792,7 @@ process.stdin.on('end', async () => {
756
792
  ? scriptPath
757
793
  : resolve(hookCwdForScript, scriptPath);
758
794
  if (existsSync(absPath)) {
759
- const scriptContent = readFileSync(absPath, 'utf-8').toLowerCase();
795
+ const scriptContent = safeReadFileSync(absPath).toLowerCase();
760
796
  // Check for discovery+destruction combo
761
797
  const hasDiscovery = /\breaddirsync\b|\breaddir\b|\bos\.listdir\b|\bscandir\b|\bglob(?:sync)?\b|\bls\s+-[adl]|\bls\s+\.\b|\bopendir\b|\bdir\.entries\b|\bwalkdir\b|\bls\b.*\.\[/.test(scriptContent);
762
798
  const hasDestruction = /\brmsync\b|\brm\s+-rf\b|\bunlinksync\b|\brmdirsync\b|\bunlink\b|\brimraf\b|\bremovesync\b|\bremove_tree\b|\bshutil\.rmtree\b|\bwritefilesync\b|\bexecsync\b.*\brm\b|\bchild_process\b|\bfs\.\s*(?:rm|unlink|rmdir|write)/.test(scriptContent);
@@ -778,7 +814,7 @@ process.stdin.on('end', async () => {
778
814
  const candidates = [importAbs, importAbs + '.mjs', importAbs + '.js', importAbs + '.cjs'];
779
815
  for (const candidate of candidates) {
780
816
  if (existsSync(candidate)) {
781
- const importContent = readFileSync(candidate, 'utf-8').toLowerCase();
817
+ const importContent = safeReadFileSync(candidate).toLowerCase();
782
818
  // Cross-module: check if imported module has discovery/destruction/string construction
783
819
  const importDisc = /\breaddirsync\b|\breaddir\b|\bos\.listdir\b|\bscandir\b|\bglob(?:sync)?\b/i.test(importContent);
784
820
  const importDest = /\brmsync\b|\bunlinksync\b|\brmdirsync\b|\bunlink\b|\brimraf\b|\bfs\.\s*(?:rm|unlink|rmdir)/i.test(importContent);
@@ -890,6 +926,7 @@ process.stdin.on('end', async () => {
890
926
  if (piCfg.piEnabled !== false && Array.isArray(piCfg.piWhitelist) && piCfg.piWhitelist.length > 0) {
891
927
  for (const wlPattern of piCfg.piWhitelist) {
892
928
  try {
929
+ if (!isSafeRegex(wlPattern)) continue;
893
930
  if (new RegExp(wlPattern, 'i').test(allText)) {
894
931
  whitelisted = true;
895
932
  break;
@@ -902,7 +939,7 @@ process.stdin.on('end', async () => {
902
939
  const customCategories = [];
903
940
  if (piCfg.piEnabled !== false && Array.isArray(piCfg.piCustomPatterns)) {
904
941
  for (const cp of piCfg.piCustomPatterns) {
905
- if (cp && cp.pattern) {
942
+ if (cp && cp.pattern && isSafeRegex(cp.pattern)) {
906
943
  try {
907
944
  customCategories.push({
908
945
  name: cp.name || 'custom_pattern',
@@ -950,8 +987,8 @@ process.stdin.on('end', async () => {
950
987
  });
951
988
  } catch {}
952
989
 
953
- // Webhook notification
954
- if (piCfg.piWebhookUrl) {
990
+ // Webhook notification (SSRF-safe: HTTPS only, no private IPs)
991
+ if (piCfg.piWebhookUrl && isSafeWebhookUrl(piCfg.piWebhookUrl)) {
955
992
  try {
956
993
  await fetch(piCfg.piWebhookUrl, {
957
994
  method: 'POST',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solongate/proxy",
3
- "version": "0.26.4",
3
+ "version": "0.27.0",
4
4
  "description": "MCP security proxy — protect any MCP server with customizable policies, path/command constraints, rate limiting, and audit logging. Zero code changes required.",
5
5
  "type": "module",
6
6
  "bin": {