@solongate/proxy 0.26.4 → 0.27.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,10 +169,36 @@ 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
- const upstreamArgs = separatorIndex >= 0 ? args.slice(separatorIndex + 1) : [];
175
+ let upstreamArgs = separatorIndex >= 0 ? args.slice(separatorIndex + 1) : [];
176
+ const flagsWithValue = /* @__PURE__ */ new Set([
177
+ "--policy",
178
+ "--name",
179
+ "--rate-limit",
180
+ "--global-rate-limit",
181
+ "--config",
182
+ "--api-key",
183
+ "--api-url",
184
+ "--upstream-url",
185
+ "--upstream-transport",
186
+ "--port",
187
+ "--policy-id",
188
+ "--id",
189
+ "--ai-judge-model",
190
+ "--ai-judge-endpoint",
191
+ "--ai-judge-api-key",
192
+ "--ai-judge-timeout",
193
+ "--agent-name"
194
+ ]);
171
195
  for (let i = 0; i < flags.length; i++) {
196
+ if (!flags[i].startsWith("--")) {
197
+ if (upstreamArgs.length === 0) {
198
+ upstreamArgs.push(...flags.slice(i));
199
+ }
200
+ break;
201
+ }
172
202
  switch (flags[i]) {
173
203
  case "--policy":
174
204
  policySource = flags[++i];
@@ -228,6 +258,9 @@ function parseArgs(argv) {
228
258
  case "--ai-judge-timeout":
229
259
  aiJudgeTimeout = parseInt(flags[++i], 10);
230
260
  break;
261
+ case "--agent-name":
262
+ agentName = flags[++i];
263
+ break;
231
264
  }
232
265
  }
233
266
  if (!aiJudgeApiKey) {
@@ -296,7 +329,8 @@ function parseArgs(argv) {
296
329
  policyPath: resolvePolicyPath(cfgPolicySource) ?? void 0,
297
330
  policyId: policyId ?? fileConfig.policyId,
298
331
  advancedDetection: advancedDetection ? { enabled: true } : void 0,
299
- aiJudge
332
+ aiJudge,
333
+ agentName
300
334
  };
301
335
  }
302
336
  if (upstreamUrl) {
@@ -319,7 +353,8 @@ function parseArgs(argv) {
319
353
  policyPath: resolvedPolicyPath ?? void 0,
320
354
  policyId,
321
355
  advancedDetection: advancedDetection ? { enabled: true } : void 0,
322
- aiJudge
356
+ aiJudge,
357
+ agentName
323
358
  };
324
359
  }
325
360
  if (upstreamArgs.length === 0) {
@@ -346,7 +381,8 @@ function parseArgs(argv) {
346
381
  policyPath: resolvedPolicyPath ?? void 0,
347
382
  policyId,
348
383
  advancedDetection: advancedDetection ? { enabled: true } : void 0,
349
- aiJudge
384
+ aiJudge,
385
+ agentName
350
386
  };
351
387
  }
352
388
  function resolvePolicyPath(source) {
@@ -481,10 +517,11 @@ function isAlreadyProtected(server) {
481
517
  }
482
518
  return false;
483
519
  }
484
- function wrapServer(server, policy) {
520
+ function wrapServer(serverName, server, policy) {
485
521
  const env = { ...server.env ?? {} };
486
522
  env.SOLONGATE_API_KEY = "${SOLONGATE_API_KEY}";
487
523
  const proxyArgs = ["-y", "@solongate/proxy@latest"];
524
+ proxyArgs.push("--agent-name", serverName);
488
525
  if (policy) {
489
526
  proxyArgs.push("--policy", policy);
490
527
  }
@@ -885,7 +922,7 @@ async function main() {
885
922
  const newConfig = { mcpServers: {} };
886
923
  for (const name of serverNames) {
887
924
  if (toProtect.includes(name)) {
888
- newConfig.mcpServers[name] = wrapServer(config.mcpServers[name], policyValue);
925
+ newConfig.mcpServers[name] = wrapServer(name, config.mcpServers[name], policyValue);
889
926
  } else {
890
927
  newConfig.mcpServers[name] = config.mcpServers[name];
891
928
  }
@@ -2889,25 +2926,25 @@ function checkEntropyLimits(value) {
2889
2926
  }
2890
2927
  function calculateShannonEntropy(str) {
2891
2928
  const freq = new Uint32Array(128);
2892
- let nonAsciiCount = 0;
2929
+ const nonAsciiFreq = /* @__PURE__ */ new Map();
2893
2930
  for (let i = 0; i < str.length; i++) {
2894
2931
  const code = str.charCodeAt(i);
2895
2932
  if (code < 128) {
2896
- freq[code] = (freq[code] ?? 0) + 1;
2933
+ freq[code] = freq[code] + 1;
2897
2934
  } else {
2898
- nonAsciiCount++;
2935
+ nonAsciiFreq.set(code, (nonAsciiFreq.get(code) || 0) + 1);
2899
2936
  }
2900
2937
  }
2901
2938
  let entropy = 0;
2902
2939
  const len = str.length;
2903
2940
  for (let i = 0; i < 128; i++) {
2904
- if ((freq[i] ?? 0) > 0) {
2905
- const p = (freq[i] ?? 0) / len;
2941
+ if (freq[i] > 0) {
2942
+ const p = freq[i] / len;
2906
2943
  entropy -= p * Math.log2(p);
2907
2944
  }
2908
2945
  }
2909
- if (nonAsciiCount > 0) {
2910
- const p = nonAsciiCount / len;
2946
+ for (const count of nonAsciiFreq.values()) {
2947
+ const p = count / len;
2911
2948
  entropy -= p * Math.log2(p);
2912
2949
  }
2913
2950
  return entropy;
@@ -3978,7 +4015,7 @@ function urlConstraintsMatch(constraints, args) {
3978
4015
  }
3979
4016
  function evaluatePolicy(policySet, request) {
3980
4017
  const startTime = performance.now();
3981
- const sortedRules = policySet.rules;
4018
+ const sortedRules = [...policySet.rules].sort((a, b) => a.priority - b.priority);
3982
4019
  for (const rule of sortedRules) {
3983
4020
  if (ruleMatchesRequest(rule, request)) {
3984
4021
  const endTime2 = performance.now();
@@ -5457,9 +5494,6 @@ var PolicySyncManager = class {
5457
5494
  */
5458
5495
  policiesEqual(a, b) {
5459
5496
  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
5497
  return JSON.stringify(a.rules) === JSON.stringify(b.rules);
5464
5498
  }
5465
5499
  };
@@ -5499,12 +5533,17 @@ CRITICAL: Only DENY access to files EXPLICITLY in the protected_files list. "cat
5499
5533
 
5500
5534
  Respond with ONLY valid JSON, no markdown, no explanation outside the JSON:
5501
5535
  {"decision": "ALLOW" or "DENY", "reason": "brief one-line explanation", "confidence": 0.0 to 1.0}`;
5502
- var AiJudge = class {
5536
+ var AiJudge = class _AiJudge {
5503
5537
  config;
5504
5538
  protectedFiles;
5505
5539
  protectedPaths;
5506
5540
  deniedActions;
5507
5541
  isOllamaEndpoint;
5542
+ // Circuit breaker: after 3 consecutive failures in 60s, skip AI Judge
5543
+ consecutiveFailures = 0;
5544
+ lastFailureTime = 0;
5545
+ static CIRCUIT_BREAKER_THRESHOLD = 3;
5546
+ static CIRCUIT_BREAKER_RESET_MS = 6e4;
5508
5547
  constructor(config, protectedFiles, protectedPaths, deniedActions = [
5509
5548
  "file deletion",
5510
5549
  "data exfiltration",
@@ -5523,6 +5562,17 @@ var AiJudge = class {
5523
5562
  * Fail-closed: any error (timeout, parse failure, connection refused) → DENY.
5524
5563
  */
5525
5564
  async evaluate(toolName, args) {
5565
+ if (this.consecutiveFailures >= _AiJudge.CIRCUIT_BREAKER_THRESHOLD) {
5566
+ const elapsed = Date.now() - this.lastFailureTime;
5567
+ if (elapsed < _AiJudge.CIRCUIT_BREAKER_RESET_MS) {
5568
+ return {
5569
+ decision: "ALLOW",
5570
+ reason: `AI Judge circuit breaker open (${this.consecutiveFailures} consecutive failures) \u2014 falling back to policy-only`,
5571
+ confidence: 0.5
5572
+ };
5573
+ }
5574
+ this.consecutiveFailures = 0;
5575
+ }
5526
5576
  const sanitizedArgs = this.sanitizeArgs(args);
5527
5577
  const userMessage = JSON.stringify({
5528
5578
  tool: toolName,
@@ -5533,12 +5583,15 @@ var AiJudge = class {
5533
5583
  });
5534
5584
  try {
5535
5585
  const response = await this.callLLM(userMessage);
5586
+ this.consecutiveFailures = 0;
5536
5587
  return this.parseVerdict(response);
5537
5588
  } catch (err) {
5589
+ this.consecutiveFailures++;
5590
+ this.lastFailureTime = Date.now();
5538
5591
  const message = err instanceof Error ? err.message : String(err);
5539
5592
  return {
5540
5593
  decision: "DENY",
5541
- reason: `AI Judge error (fail-closed): ${message}`,
5594
+ reason: `AI Judge error (fail-closed): ${message.slice(0, 100)}`,
5542
5595
  confidence: 1
5543
5596
  };
5544
5597
  }
@@ -5551,8 +5604,15 @@ var AiJudge = class {
5551
5604
  const sanitize = (val, depth = 0) => {
5552
5605
  if (depth > 10) return "[nested]";
5553
5606
  if (typeof val === "string") {
5554
- const truncated = val.length > maxStringLen ? val.slice(0, maxStringLen) + "...[truncated]" : val;
5555
- return truncated;
5607
+ let s = val.length > maxStringLen ? val.slice(0, maxStringLen) + "...[truncated]" : val;
5608
+ s = s.replace(/\{[^{}]*"decision"\s*:/gi, "{[redacted]:");
5609
+ s = s.replace(/\{[^{}]*"ALLOW"[^{}]*\}/gi, "[redacted-json]");
5610
+ s = s.replace(/\{[^{}]*"DENY"[^{}]*\}/gi, "[redacted-json]");
5611
+ s = s.replace(/respond\s+with\s*:/gi, "[redacted]");
5612
+ s = s.replace(/ignore\s+(previous|above|all)\s+(instructions?|prompts?)/gi, "[redacted]");
5613
+ s = s.replace(/you\s+are\s+now\s+/gi, "[redacted] ");
5614
+ s = s.replace(/system\s*:\s*/gi, "[redacted]: ");
5615
+ return s;
5556
5616
  }
5557
5617
  if (Array.isArray(val)) return val.slice(0, 50).map((v) => sanitize(v, depth + 1));
5558
5618
  if (val && typeof val === "object") {
@@ -5595,7 +5655,8 @@ var AiJudge = class {
5595
5655
  { role: "user", content: userMessage }
5596
5656
  ],
5597
5657
  temperature: 0,
5598
- max_tokens: 200
5658
+ max_tokens: 200,
5659
+ response_format: { type: "json_object" }
5599
5660
  });
5600
5661
  if (this.config.apiKey) {
5601
5662
  headers["Authorization"] = `Bearer ${this.config.apiKey}`;
@@ -5721,9 +5782,20 @@ var SolonGateProxy = class {
5721
5782
  aiJudge = null;
5722
5783
  upstreamTools = [];
5723
5784
  guardConfig;
5785
+ /** Agent identity for trust map — resolved from CLI flag, HTTP headers, or MCP clientInfo */
5786
+ agentId = null;
5787
+ agentName = null;
5788
+ /** Per-session agent info for HTTP mode (keyed by session ID) */
5789
+ httpAgentInfo = /* @__PURE__ */ new Map();
5790
+ /** Per-request sub-agent info from HTTP headers (transient, overwritten per request) */
5791
+ httpSubAgent = null;
5724
5792
  constructor(config) {
5725
5793
  this.config = config;
5726
5794
  this.guardConfig = config.advancedDetection ? { ...DEFAULT_INPUT_GUARD_CONFIG, advancedDetection: config.advancedDetection } : DEFAULT_INPUT_GUARD_CONFIG;
5795
+ if (config.agentName) {
5796
+ this.agentName = config.agentName;
5797
+ this.agentId = config.agentName.toLowerCase().replace(/\s+/g, "-");
5798
+ }
5727
5799
  this.gate = new SolonGate({
5728
5800
  name: config.name ?? "solongate-proxy",
5729
5801
  apiKey: "sg_test_proxy_internal_00000000",
@@ -5740,6 +5812,19 @@ var SolonGateProxy = class {
5740
5812
  log2("WARNING:", w);
5741
5813
  }
5742
5814
  }
5815
+ /** Extract sub-agent identity from MCP _meta field */
5816
+ extractSubAgent(request) {
5817
+ const meta = request?.params?._meta;
5818
+ if (meta && typeof meta === "object") {
5819
+ const solonMeta = meta["io.solongate/agent"];
5820
+ if (solonMeta && typeof solonMeta === "object") {
5821
+ const agent = solonMeta;
5822
+ const id = agent.id ? String(agent.id) : null;
5823
+ if (id) return { subAgentId: id, subAgentName: agent.name ? String(agent.name) : id };
5824
+ }
5825
+ }
5826
+ return null;
5827
+ }
5743
5828
  /**
5744
5829
  * Start the proxy: connect to upstream, then serve downstream.
5745
5830
  */
@@ -5920,6 +6005,16 @@ var SolonGateProxy = class {
5920
6005
  }
5921
6006
  }
5922
6007
  );
6008
+ this.server.oninitialized = () => {
6009
+ if (!this.agentId && this.server) {
6010
+ const clientVersion = this.server.getClientVersion();
6011
+ if (clientVersion?.name) {
6012
+ this.agentId = clientVersion.version ? `${clientVersion.name}/${clientVersion.version}` : clientVersion.name;
6013
+ this.agentName = clientVersion.name;
6014
+ log2(`Agent identified from MCP clientInfo: ${this.agentName} (${this.agentId})`);
6015
+ }
6016
+ }
6017
+ };
5923
6018
  this.server.setRequestHandler(ListToolsRequestSchema, async () => {
5924
6019
  return { tools: this.upstreamTools };
5925
6020
  });
@@ -5927,6 +6022,7 @@ var SolonGateProxy = class {
5927
6022
  const MUTEX_TIMEOUT_MS = 3e4;
5928
6023
  this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
5929
6024
  const { name, arguments: args } = request.params;
6025
+ const subAgent = this.extractSubAgent(request) || this.httpSubAgent;
5930
6026
  const argsSize = TEXT_ENCODER.encode(JSON.stringify(args ?? {})).length;
5931
6027
  if (argsSize > MAX_ARGUMENT_SIZE) {
5932
6028
  log2(`DENY: ${name} \u2014 payload size ${argsSize} exceeds limit ${MAX_ARGUMENT_SIZE}`);
@@ -5964,7 +6060,11 @@ var SolonGateProxy = class {
5964
6060
  decision: "DENY",
5965
6061
  reason: `Prompt injection detected: ${threats}`,
5966
6062
  evaluationTimeMs: 0,
5967
- promptInjection: piResult
6063
+ promptInjection: piResult,
6064
+ agent_id: this.agentId ?? void 0,
6065
+ agent_name: this.agentName ?? void 0,
6066
+ sub_agent_id: subAgent?.subAgentId,
6067
+ sub_agent_name: subAgent?.subAgentName
5968
6068
  });
5969
6069
  }
5970
6070
  return {
@@ -6056,7 +6156,11 @@ var SolonGateProxy = class {
6056
6156
  reason,
6057
6157
  matchedRule,
6058
6158
  evaluationTimeMs,
6059
- promptInjection: piResult
6159
+ promptInjection: piResult,
6160
+ agent_id: this.agentId ?? void 0,
6161
+ agent_name: this.agentName ?? void 0,
6162
+ sub_agent_id: subAgent?.subAgentId,
6163
+ sub_agent_name: subAgent?.subAgentName
6060
6164
  });
6061
6165
  } else {
6062
6166
  log2(`Skipping audit log (apiKey: ${this.config.apiKey ? "test key" : "not set"})`);
@@ -6341,6 +6445,18 @@ ${msg.content.text}`;
6341
6445
  await this.server.connect(httpTransport);
6342
6446
  const httpServer = createHttpServer(async (req, res) => {
6343
6447
  if (req.url === "/mcp" || req.url?.startsWith("/mcp?")) {
6448
+ if (!this.agentId) {
6449
+ const headerAgentId = req.headers["x-agent-id"];
6450
+ const headerAgentName = req.headers["x-agent-name"];
6451
+ if (headerAgentId) {
6452
+ this.agentId = headerAgentId;
6453
+ this.agentName = headerAgentName || headerAgentId;
6454
+ log2(`Agent identified from HTTP headers: ${this.agentName} (${this.agentId})`);
6455
+ }
6456
+ }
6457
+ const subAgentId = req.headers["x-sub-agent-id"];
6458
+ const subAgentName = req.headers["x-sub-agent-name"];
6459
+ this.httpSubAgent = subAgentId ? { subAgentId, subAgentName: subAgentName || subAgentId } : null;
6344
6460
  await httpTransport.handleRequest(req, res);
6345
6461
  } else if (req.url === "/health") {
6346
6462
  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.1",
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": {