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