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