@solongate/proxy 0.24.0 → 0.25.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 +270 -3
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -133,6 +133,11 @@ function parseArgs(argv) {
|
|
|
133
133
|
let port;
|
|
134
134
|
let policyId;
|
|
135
135
|
let advancedDetection = true;
|
|
136
|
+
let aiJudgeEnabled = false;
|
|
137
|
+
let aiJudgeModel = "llama-3.1-8b-instant";
|
|
138
|
+
let aiJudgeEndpoint = "https://api.groq.com/openai";
|
|
139
|
+
let aiJudgeApiKey;
|
|
140
|
+
let aiJudgeTimeout = 5e3;
|
|
136
141
|
let separatorIndex = args.indexOf("--");
|
|
137
142
|
const flags = separatorIndex >= 0 ? args.slice(0, separatorIndex) : args;
|
|
138
143
|
const upstreamArgs = separatorIndex >= 0 ? args.slice(separatorIndex + 1) : [];
|
|
@@ -178,8 +183,36 @@ function parseArgs(argv) {
|
|
|
178
183
|
case "--no-advanced-detection":
|
|
179
184
|
advancedDetection = false;
|
|
180
185
|
break;
|
|
186
|
+
case "--ai-judge":
|
|
187
|
+
aiJudgeEnabled = true;
|
|
188
|
+
break;
|
|
189
|
+
case "--ai-judge-model":
|
|
190
|
+
aiJudgeModel = flags[++i];
|
|
191
|
+
aiJudgeEnabled = true;
|
|
192
|
+
break;
|
|
193
|
+
case "--ai-judge-endpoint":
|
|
194
|
+
aiJudgeEndpoint = flags[++i];
|
|
195
|
+
aiJudgeEnabled = true;
|
|
196
|
+
break;
|
|
197
|
+
case "--ai-judge-api-key":
|
|
198
|
+
aiJudgeApiKey = flags[++i];
|
|
199
|
+
aiJudgeEnabled = true;
|
|
200
|
+
break;
|
|
201
|
+
case "--ai-judge-timeout":
|
|
202
|
+
aiJudgeTimeout = parseInt(flags[++i], 10);
|
|
203
|
+
break;
|
|
181
204
|
}
|
|
182
205
|
}
|
|
206
|
+
if (!aiJudgeApiKey) {
|
|
207
|
+
aiJudgeApiKey = process.env.GROQ_API_KEY;
|
|
208
|
+
}
|
|
209
|
+
const aiJudge = aiJudgeEnabled ? {
|
|
210
|
+
enabled: true,
|
|
211
|
+
model: aiJudgeModel,
|
|
212
|
+
endpoint: aiJudgeEndpoint,
|
|
213
|
+
apiKey: aiJudgeApiKey,
|
|
214
|
+
timeoutMs: aiJudgeTimeout
|
|
215
|
+
} : void 0;
|
|
183
216
|
if (apiKey && /^\$\{.+\}$/.test(apiKey)) {
|
|
184
217
|
apiKey = void 0;
|
|
185
218
|
}
|
|
@@ -227,7 +260,8 @@ function parseArgs(argv) {
|
|
|
227
260
|
port: port ?? fileConfig.port,
|
|
228
261
|
policyPath: resolvePolicyPath(cfgPolicySource) ?? void 0,
|
|
229
262
|
policyId: policyId ?? fileConfig.policyId,
|
|
230
|
-
advancedDetection: advancedDetection ? { enabled: true } : void 0
|
|
263
|
+
advancedDetection: advancedDetection ? { enabled: true } : void 0,
|
|
264
|
+
aiJudge
|
|
231
265
|
};
|
|
232
266
|
}
|
|
233
267
|
if (upstreamUrl) {
|
|
@@ -249,7 +283,8 @@ function parseArgs(argv) {
|
|
|
249
283
|
port,
|
|
250
284
|
policyPath: resolvedPolicyPath ?? void 0,
|
|
251
285
|
policyId,
|
|
252
|
-
advancedDetection: advancedDetection ? { enabled: true } : void 0
|
|
286
|
+
advancedDetection: advancedDetection ? { enabled: true } : void 0,
|
|
287
|
+
aiJudge
|
|
253
288
|
};
|
|
254
289
|
}
|
|
255
290
|
if (upstreamArgs.length === 0) {
|
|
@@ -275,7 +310,8 @@ function parseArgs(argv) {
|
|
|
275
310
|
port,
|
|
276
311
|
policyPath: resolvedPolicyPath ?? void 0,
|
|
277
312
|
policyId,
|
|
278
|
-
advancedDetection: advancedDetection ? { enabled: true } : void 0
|
|
313
|
+
advancedDetection: advancedDetection ? { enabled: true } : void 0,
|
|
314
|
+
aiJudge
|
|
279
315
|
};
|
|
280
316
|
}
|
|
281
317
|
function resolvePolicyPath(source) {
|
|
@@ -6066,6 +6102,179 @@ var PolicySyncManager = class {
|
|
|
6066
6102
|
}
|
|
6067
6103
|
};
|
|
6068
6104
|
|
|
6105
|
+
// src/ai-judge.ts
|
|
6106
|
+
var SYSTEM_PROMPT = `You are a security judge for an MCP (Model Context Protocol) proxy. You evaluate tool calls and decide if they should be ALLOWED or DENIED.
|
|
6107
|
+
|
|
6108
|
+
You will receive a JSON object with:
|
|
6109
|
+
- "tool": the MCP tool name being called
|
|
6110
|
+
- "arguments": the tool's arguments
|
|
6111
|
+
- "protected_files": list of files that must NEVER be read, written, copied, moved, or accessed
|
|
6112
|
+
- "protected_paths": list of directories/paths that must NEVER be accessed
|
|
6113
|
+
- "denied_actions": list of actions that are never allowed
|
|
6114
|
+
|
|
6115
|
+
DENY if the tool call could, directly or indirectly:
|
|
6116
|
+
- Read, display, copy, move, or exfiltrate any protected file \u2014 even through:
|
|
6117
|
+
- Shell glob patterns (e.g., "cred*" could match "credentials.json")
|
|
6118
|
+
- Command substitution ($(...), backticks)
|
|
6119
|
+
- Variable interpolation or string concatenation
|
|
6120
|
+
- Input redirection (< file)
|
|
6121
|
+
- Script files that might read protected files
|
|
6122
|
+
- Find/exec patterns
|
|
6123
|
+
- Any Unix/Windows utility that reads file content (cat, head, tail, less, more, type, perl, awk, sed, sort, diff, comm, paste, tee, xxd, od, strings, dd, tr, etc.)
|
|
6124
|
+
- Delete, modify, or overwrite any protected file or path
|
|
6125
|
+
- Send data to external URLs, webhooks, or attacker-controlled endpoints
|
|
6126
|
+
- Execute remotely downloaded code (curl|bash, wget|sh, etc.)
|
|
6127
|
+
- Leak environment variables (printenv, env, /proc/self/environ, npm run env, process.env)
|
|
6128
|
+
- Create scripts that bypass security controls
|
|
6129
|
+
|
|
6130
|
+
ALLOW if:
|
|
6131
|
+
- The action is a normal development operation (ls, git status, npm build, etc.)
|
|
6132
|
+
- The action does not touch any protected file or path
|
|
6133
|
+
- The action is clearly benign (creating hello.js, reading public docs, etc.)
|
|
6134
|
+
|
|
6135
|
+
When in doubt, DENY. False positives are acceptable; false negatives are not.
|
|
6136
|
+
|
|
6137
|
+
Respond with ONLY valid JSON, no markdown, no explanation outside the JSON:
|
|
6138
|
+
{"decision": "ALLOW" or "DENY", "reason": "brief one-line explanation", "confidence": 0.0 to 1.0}`;
|
|
6139
|
+
var AiJudge = class {
|
|
6140
|
+
config;
|
|
6141
|
+
protectedFiles;
|
|
6142
|
+
protectedPaths;
|
|
6143
|
+
deniedActions;
|
|
6144
|
+
isOllamaEndpoint;
|
|
6145
|
+
constructor(config, protectedFiles, protectedPaths, deniedActions = [
|
|
6146
|
+
"file deletion",
|
|
6147
|
+
"data exfiltration",
|
|
6148
|
+
"remote code execution",
|
|
6149
|
+
"environment variable leak",
|
|
6150
|
+
"security control bypass"
|
|
6151
|
+
]) {
|
|
6152
|
+
this.config = config;
|
|
6153
|
+
this.protectedFiles = protectedFiles;
|
|
6154
|
+
this.protectedPaths = protectedPaths;
|
|
6155
|
+
this.deniedActions = deniedActions;
|
|
6156
|
+
this.isOllamaEndpoint = config.endpoint.includes("11434") || config.endpoint.includes("ollama");
|
|
6157
|
+
}
|
|
6158
|
+
/**
|
|
6159
|
+
* Evaluate a tool call. Returns ALLOW or DENY verdict.
|
|
6160
|
+
* Fail-closed: any error (timeout, parse failure, connection refused) → DENY.
|
|
6161
|
+
*/
|
|
6162
|
+
async evaluate(toolName, args) {
|
|
6163
|
+
const userMessage = JSON.stringify({
|
|
6164
|
+
tool: toolName,
|
|
6165
|
+
arguments: args,
|
|
6166
|
+
protected_files: this.protectedFiles,
|
|
6167
|
+
protected_paths: this.protectedPaths,
|
|
6168
|
+
denied_actions: this.deniedActions
|
|
6169
|
+
});
|
|
6170
|
+
try {
|
|
6171
|
+
const response = await this.callLLM(userMessage);
|
|
6172
|
+
return this.parseVerdict(response);
|
|
6173
|
+
} catch (err) {
|
|
6174
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
6175
|
+
return {
|
|
6176
|
+
decision: "DENY",
|
|
6177
|
+
reason: `AI Judge error (fail-closed): ${message}`,
|
|
6178
|
+
confidence: 1
|
|
6179
|
+
};
|
|
6180
|
+
}
|
|
6181
|
+
}
|
|
6182
|
+
/**
|
|
6183
|
+
* Call the LLM endpoint. Supports Groq, OpenAI, and Ollama.
|
|
6184
|
+
*/
|
|
6185
|
+
async callLLM(userMessage) {
|
|
6186
|
+
const controller = new AbortController();
|
|
6187
|
+
const timeout = setTimeout(() => controller.abort(), this.config.timeoutMs);
|
|
6188
|
+
try {
|
|
6189
|
+
let url;
|
|
6190
|
+
let body;
|
|
6191
|
+
const headers = { "Content-Type": "application/json" };
|
|
6192
|
+
if (this.isOllamaEndpoint) {
|
|
6193
|
+
url = `${this.config.endpoint}/api/chat`;
|
|
6194
|
+
body = JSON.stringify({
|
|
6195
|
+
model: this.config.model,
|
|
6196
|
+
messages: [
|
|
6197
|
+
{ role: "system", content: SYSTEM_PROMPT },
|
|
6198
|
+
{ role: "user", content: userMessage }
|
|
6199
|
+
],
|
|
6200
|
+
stream: false,
|
|
6201
|
+
options: { temperature: 0, num_predict: 200 }
|
|
6202
|
+
});
|
|
6203
|
+
} else {
|
|
6204
|
+
url = `${this.config.endpoint}/v1/chat/completions`;
|
|
6205
|
+
body = JSON.stringify({
|
|
6206
|
+
model: this.config.model,
|
|
6207
|
+
messages: [
|
|
6208
|
+
{ role: "system", content: SYSTEM_PROMPT },
|
|
6209
|
+
{ role: "user", content: userMessage }
|
|
6210
|
+
],
|
|
6211
|
+
temperature: 0,
|
|
6212
|
+
max_tokens: 200
|
|
6213
|
+
});
|
|
6214
|
+
if (this.config.apiKey) {
|
|
6215
|
+
headers["Authorization"] = `Bearer ${this.config.apiKey}`;
|
|
6216
|
+
}
|
|
6217
|
+
}
|
|
6218
|
+
const res = await fetch(url, {
|
|
6219
|
+
method: "POST",
|
|
6220
|
+
headers,
|
|
6221
|
+
body,
|
|
6222
|
+
signal: controller.signal
|
|
6223
|
+
});
|
|
6224
|
+
if (!res.ok) {
|
|
6225
|
+
const errBody = await res.text().catch(() => "");
|
|
6226
|
+
throw new Error(`LLM endpoint returned ${res.status}: ${errBody.slice(0, 200)}`);
|
|
6227
|
+
}
|
|
6228
|
+
const data = await res.json();
|
|
6229
|
+
if (this.isOllamaEndpoint) {
|
|
6230
|
+
const message = data.message;
|
|
6231
|
+
return message?.content ?? "";
|
|
6232
|
+
} else {
|
|
6233
|
+
const choices = data.choices;
|
|
6234
|
+
const first = choices?.[0];
|
|
6235
|
+
const message = first?.message;
|
|
6236
|
+
return message?.content ?? "";
|
|
6237
|
+
}
|
|
6238
|
+
} finally {
|
|
6239
|
+
clearTimeout(timeout);
|
|
6240
|
+
}
|
|
6241
|
+
}
|
|
6242
|
+
/**
|
|
6243
|
+
* Parse the LLM response into a structured verdict.
|
|
6244
|
+
* If parsing fails → DENY (fail-closed).
|
|
6245
|
+
*/
|
|
6246
|
+
parseVerdict(response) {
|
|
6247
|
+
try {
|
|
6248
|
+
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
|
6249
|
+
if (!jsonMatch) {
|
|
6250
|
+
return {
|
|
6251
|
+
decision: "DENY",
|
|
6252
|
+
reason: `AI Judge could not parse response (fail-closed): ${response.slice(0, 100)}`,
|
|
6253
|
+
confidence: 1
|
|
6254
|
+
};
|
|
6255
|
+
}
|
|
6256
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
6257
|
+
const decision = String(parsed.decision ?? "").toUpperCase();
|
|
6258
|
+
const reason = String(parsed.reason ?? "no reason provided");
|
|
6259
|
+
const confidence = typeof parsed.confidence === "number" ? Math.min(1, Math.max(0, parsed.confidence)) : 0.5;
|
|
6260
|
+
if (decision !== "ALLOW" && decision !== "DENY") {
|
|
6261
|
+
return {
|
|
6262
|
+
decision: "DENY",
|
|
6263
|
+
reason: `AI Judge returned invalid decision "${decision}" (fail-closed)`,
|
|
6264
|
+
confidence: 1
|
|
6265
|
+
};
|
|
6266
|
+
}
|
|
6267
|
+
return { decision, reason, confidence };
|
|
6268
|
+
} catch {
|
|
6269
|
+
return {
|
|
6270
|
+
decision: "DENY",
|
|
6271
|
+
reason: `AI Judge JSON parse error (fail-closed): ${response.slice(0, 100)}`,
|
|
6272
|
+
confidence: 1
|
|
6273
|
+
};
|
|
6274
|
+
}
|
|
6275
|
+
}
|
|
6276
|
+
};
|
|
6277
|
+
|
|
6069
6278
|
// src/proxy.ts
|
|
6070
6279
|
var log2 = (...args) => process.stderr.write(`[SolonGate] ${args.map(String).join(" ")}
|
|
6071
6280
|
`);
|
|
@@ -6117,6 +6326,7 @@ var SolonGateProxy = class {
|
|
|
6117
6326
|
server = null;
|
|
6118
6327
|
toolMutexes = new ToolMutexMap();
|
|
6119
6328
|
syncManager = null;
|
|
6329
|
+
aiJudge = null;
|
|
6120
6330
|
upstreamTools = [];
|
|
6121
6331
|
constructor(config) {
|
|
6122
6332
|
this.config = config;
|
|
@@ -6198,6 +6408,16 @@ var SolonGateProxy = class {
|
|
|
6198
6408
|
this.registerToolsToCloud();
|
|
6199
6409
|
this.registerServerToCloud();
|
|
6200
6410
|
this.startPolicySync();
|
|
6411
|
+
if (this.config.aiJudge?.enabled) {
|
|
6412
|
+
const protectedFiles = this.extractProtectedFiles();
|
|
6413
|
+
const protectedPaths = this.extractProtectedPaths();
|
|
6414
|
+
this.aiJudge = new AiJudge(
|
|
6415
|
+
this.config.aiJudge,
|
|
6416
|
+
protectedFiles,
|
|
6417
|
+
protectedPaths
|
|
6418
|
+
);
|
|
6419
|
+
log2(`AI Judge enabled \u2014 model: ${this.config.aiJudge.model}, endpoint: ${this.config.aiJudge.endpoint}`);
|
|
6420
|
+
}
|
|
6201
6421
|
this.createServer();
|
|
6202
6422
|
await this.serve();
|
|
6203
6423
|
}
|
|
@@ -6360,6 +6580,23 @@ var SolonGateProxy = class {
|
|
|
6360
6580
|
{ name, arguments: args ?? {} },
|
|
6361
6581
|
async (params) => {
|
|
6362
6582
|
if (!this.client) throw new Error("Upstream client disconnected");
|
|
6583
|
+
if (this.aiJudge) {
|
|
6584
|
+
const verdict = await this.aiJudge.evaluate(
|
|
6585
|
+
params.name,
|
|
6586
|
+
params.arguments ?? {}
|
|
6587
|
+
);
|
|
6588
|
+
if (verdict.decision === "DENY") {
|
|
6589
|
+
log2(`AI Judge DENY: ${params.name} \u2014 ${verdict.reason} (confidence: ${verdict.confidence})`);
|
|
6590
|
+
return {
|
|
6591
|
+
content: [{
|
|
6592
|
+
type: "text",
|
|
6593
|
+
text: `[SolonGate AI Judge] Blocked: ${verdict.reason}`
|
|
6594
|
+
}],
|
|
6595
|
+
isError: true
|
|
6596
|
+
};
|
|
6597
|
+
}
|
|
6598
|
+
log2(`AI Judge ALLOW: ${params.name} \u2014 ${verdict.reason}`);
|
|
6599
|
+
}
|
|
6363
6600
|
const upstreamResult = await this.client.callTool({
|
|
6364
6601
|
name: params.name,
|
|
6365
6602
|
arguments: params.arguments
|
|
@@ -6609,6 +6846,36 @@ ${msg.content.text}`;
|
|
|
6609
6846
|
* - Polls cloud API for dashboard changes → writes to local policy.json
|
|
6610
6847
|
* - Version number determines which is newer (higher wins, cloud wins on tie)
|
|
6611
6848
|
*/
|
|
6849
|
+
/**
|
|
6850
|
+
* Extract protected filenames from policy DENY rules (filenameConstraints.denied).
|
|
6851
|
+
*/
|
|
6852
|
+
extractProtectedFiles() {
|
|
6853
|
+
const files = /* @__PURE__ */ new Set();
|
|
6854
|
+
for (const rule of this.config.policy.rules) {
|
|
6855
|
+
if (rule.effect === "DENY" && rule.enabled !== false) {
|
|
6856
|
+
const denied = rule.filenameConstraints?.denied;
|
|
6857
|
+
if (denied) {
|
|
6858
|
+
for (const f of denied) files.add(f);
|
|
6859
|
+
}
|
|
6860
|
+
}
|
|
6861
|
+
}
|
|
6862
|
+
return [...files];
|
|
6863
|
+
}
|
|
6864
|
+
/**
|
|
6865
|
+
* Extract protected paths from policy DENY rules (pathConstraints.denied).
|
|
6866
|
+
*/
|
|
6867
|
+
extractProtectedPaths() {
|
|
6868
|
+
const paths = /* @__PURE__ */ new Set();
|
|
6869
|
+
for (const rule of this.config.policy.rules) {
|
|
6870
|
+
if (rule.effect === "DENY" && rule.enabled !== false) {
|
|
6871
|
+
const denied = rule.pathConstraints?.denied;
|
|
6872
|
+
if (denied) {
|
|
6873
|
+
for (const p of denied) paths.add(p);
|
|
6874
|
+
}
|
|
6875
|
+
}
|
|
6876
|
+
}
|
|
6877
|
+
return [...paths];
|
|
6878
|
+
}
|
|
6612
6879
|
startPolicySync() {
|
|
6613
6880
|
const apiKey = this.config.apiKey;
|
|
6614
6881
|
if (!apiKey) return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solongate/proxy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.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": {
|