@solongate/proxy 0.13.0 → 0.14.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.
Files changed (2) hide show
  1. package/hooks/guard.mjs +94 -19
  2. package/package.json +1 -1
package/hooks/guard.mjs CHANGED
@@ -104,10 +104,11 @@ const PI_CATEGORIES = [
104
104
  },
105
105
  ];
106
106
 
107
- function detectPromptInjection(text) {
107
+ function detectPromptInjection(text, customCategories = [], threshold = 0.5) {
108
108
  const matched = [];
109
109
  let maxWeight = 0;
110
- for (const cat of PI_CATEGORIES) {
110
+ const allCategories = [...PI_CATEGORIES, ...customCategories];
111
+ for (const cat of allCategories) {
111
112
  for (const pat of cat.patterns) {
112
113
  if (pat.test(text)) {
113
114
  matched.push(cat.name);
@@ -119,7 +120,8 @@ function detectPromptInjection(text) {
119
120
  if (matched.length === 0) return null;
120
121
  const score = Math.min(1.0, maxWeight + 0.05 * (matched.length - 1));
121
122
  const trustScore = 1.0 - score;
122
- return { score, trustScore, categories: matched, blocked: trustScore < 0.5 };
123
+ const blocked = Math.round(trustScore * 1000) < Math.round(threshold * 1000);
124
+ return { score, trustScore, categories: matched, blocked };
123
125
  }
124
126
 
125
127
  // ── Glob Matching ──
@@ -314,8 +316,8 @@ process.stdin.on('end', async () => {
314
316
  }
315
317
  }
316
318
 
317
- // ── Check if PI detection is enabled for this project ──
318
- let piEnabled = true;
319
+ // ── Fetch PI config from Cloud ──
320
+ let piCfg = { piEnabled: true, piThreshold: 0.5, piMode: 'block', piWhitelist: [], piToolConfig: {}, piCustomPatterns: [], piWebhookUrl: null };
319
321
  if (API_KEY && API_KEY.startsWith('sg_live_')) {
320
322
  try {
321
323
  const cfgRes = await fetch(API_URL + '/api/v1/project-config', {
@@ -324,44 +326,117 @@ process.stdin.on('end', async () => {
324
326
  });
325
327
  if (cfgRes.ok) {
326
328
  const cfg = await cfgRes.json();
327
- piEnabled = cfg.piEnabled !== false;
329
+ piCfg = { ...piCfg, ...cfg };
328
330
  }
329
- } catch {} // Fallback: enabled (safe default)
331
+ } catch {} // Fallback: defaults (safe)
330
332
  }
331
333
 
332
- // ── Prompt Injection Detection (Stage 1: Rules) ──
334
+ // ── Per-tool config: check if PI scanning is disabled for this tool ──
335
+ const toolName = data.tool_name || '';
336
+ if (piCfg.piToolConfig && typeof piCfg.piToolConfig === 'object') {
337
+ if (piCfg.piToolConfig[toolName] === false) {
338
+ // PI scanning explicitly disabled for this tool — skip detection
339
+ piCfg.piEnabled = false;
340
+ }
341
+ }
342
+
343
+ // ── Prompt Injection Detection (Stage 1: Rules + Custom Patterns) ──
333
344
  const allText = scanStrings(args).join(' ');
334
- const piResult = piEnabled ? detectPromptInjection(allText) : null;
345
+
346
+ // Check whitelist — if input matches any whitelist pattern, skip PI detection
347
+ let whitelisted = false;
348
+ if (piCfg.piEnabled !== false && Array.isArray(piCfg.piWhitelist) && piCfg.piWhitelist.length > 0) {
349
+ for (const wlPattern of piCfg.piWhitelist) {
350
+ try {
351
+ if (new RegExp(wlPattern, 'i').test(allText)) {
352
+ whitelisted = true;
353
+ break;
354
+ }
355
+ } catch {} // Invalid regex — skip
356
+ }
357
+ }
358
+
359
+ // Build custom patterns from config
360
+ const customCategories = [];
361
+ if (piCfg.piEnabled !== false && Array.isArray(piCfg.piCustomPatterns)) {
362
+ for (const cp of piCfg.piCustomPatterns) {
363
+ if (cp && cp.pattern) {
364
+ try {
365
+ customCategories.push({
366
+ name: cp.name || 'custom_pattern',
367
+ weight: Math.max(0, Math.min(1, Number(cp.weight) || 0.8)),
368
+ patterns: [new RegExp(cp.pattern, 'iu')],
369
+ });
370
+ } catch {} // Invalid regex — skip
371
+ }
372
+ }
373
+ }
374
+
375
+ const piResult = (piCfg.piEnabled !== false && !whitelisted)
376
+ ? detectPromptInjection(allText, customCategories, piCfg.piThreshold)
377
+ : null;
335
378
 
336
379
  if (piResult && piResult.blocked) {
337
- const msg = 'SOLONGATE: Prompt injection detected (trust score: ' +
338
- (piResult.trustScore * 100).toFixed(0) + '%, categories: ' +
339
- piResult.categories.join(', ') + ')';
380
+ const isLogOnly = piCfg.piMode === 'log-only';
381
+ const msg = isLogOnly
382
+ ? 'SOLONGATE: Prompt injection detected [LOG-ONLY] (trust score: ' +
383
+ (piResult.trustScore * 100).toFixed(0) + '%, categories: ' +
384
+ piResult.categories.join(', ') + ')'
385
+ : 'SOLONGATE: Prompt injection detected (trust score: ' +
386
+ (piResult.trustScore * 100).toFixed(0) + '%, categories: ' +
387
+ piResult.categories.join(', ') + ')';
340
388
 
389
+ // Log to Cloud
341
390
  if (API_KEY && API_KEY.startsWith('sg_live_')) {
342
391
  try {
343
392
  await fetch(API_URL + '/api/v1/audit-logs', {
344
393
  method: 'POST',
345
394
  headers: { 'Authorization': 'Bearer ' + API_KEY, 'Content-Type': 'application/json' },
346
395
  body: JSON.stringify({
347
- tool: data.tool_name || '',
396
+ tool: toolName,
348
397
  arguments: args,
349
- decision: 'DENY',
398
+ decision: isLogOnly ? 'ALLOW' : 'DENY',
350
399
  reason: msg,
351
400
  source: 'claude-code-guard',
352
401
  pi_detected: true,
353
402
  pi_trust_score: piResult.trustScore,
354
- pi_blocked: true,
403
+ pi_blocked: !isLogOnly,
355
404
  pi_categories: JSON.stringify(piResult.categories),
356
405
  pi_stage_scores: JSON.stringify({ rules: piResult.score, embedding: 0, classifier: 0 }),
357
406
  }),
358
407
  signal: AbortSignal.timeout(3000),
359
408
  });
360
409
  } catch {}
410
+
411
+ // Webhook notification
412
+ if (piCfg.piWebhookUrl) {
413
+ try {
414
+ await fetch(piCfg.piWebhookUrl, {
415
+ method: 'POST',
416
+ headers: { 'Content-Type': 'application/json' },
417
+ body: JSON.stringify({
418
+ event: 'prompt_injection_detected',
419
+ tool: toolName,
420
+ trustScore: piResult.trustScore,
421
+ categories: piResult.categories,
422
+ blocked: !isLogOnly,
423
+ mode: piCfg.piMode,
424
+ timestamp: new Date().toISOString(),
425
+ }),
426
+ signal: AbortSignal.timeout(3000),
427
+ });
428
+ } catch {} // Webhook failure is non-blocking
429
+ }
361
430
  }
362
431
 
363
- process.stderr.write(msg);
364
- process.exit(2);
432
+ // In log-only mode, warn but don't block
433
+ if (isLogOnly) {
434
+ process.stderr.write(msg);
435
+ // Fall through to policy evaluation (don't exit)
436
+ } else {
437
+ process.stderr.write(msg);
438
+ process.exit(2);
439
+ }
365
440
  }
366
441
 
367
442
  // Load policy (use cwd from hook data if available)
@@ -378,7 +453,7 @@ process.stdin.on('end', async () => {
378
453
  method: 'POST',
379
454
  headers: { 'Authorization': 'Bearer ' + API_KEY, 'Content-Type': 'application/json' },
380
455
  body: JSON.stringify({
381
- tool: data.tool_name || '',
456
+ tool: toolName,
382
457
  arguments: args,
383
458
  decision: 'ALLOW',
384
459
  reason: 'Prompt injection detected but below threshold (trust: ' + (piResult.trustScore * 100).toFixed(0) + '%)',
@@ -403,7 +478,7 @@ process.stdin.on('end', async () => {
403
478
  if (API_KEY && API_KEY.startsWith('sg_live_')) {
404
479
  try {
405
480
  const logEntry = {
406
- tool: data.tool_name || '', arguments: args,
481
+ tool: toolName, arguments: args,
407
482
  decision, reason: reason || 'allowed by policy',
408
483
  source: 'claude-code-guard',
409
484
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solongate/proxy",
3
- "version": "0.13.0",
3
+ "version": "0.14.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": {