@solongate/proxy 0.15.4 → 0.16.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 +124 -57
  2. package/package.json +1 -1
package/hooks/guard.mjs CHANGED
@@ -296,92 +296,159 @@ process.stdin.on('end', async () => {
296
296
  'policy.json', '.mcp.json',
297
297
  ];
298
298
 
299
- // Strip shell quotes/escapes: .sol'on'gate .solongate, .sol"on"gate .solongate
299
+ // Block helper logs to cloud + exits with code 2
300
+ async function blockSelfProtection(reason) {
301
+ if (API_KEY && API_KEY.startsWith('sg_live_')) {
302
+ try {
303
+ await fetch(API_URL + '/api/v1/audit-logs', {
304
+ method: 'POST',
305
+ headers: { 'Authorization': 'Bearer ' + API_KEY, 'Content-Type': 'application/json' },
306
+ body: JSON.stringify({
307
+ tool: data.tool_name || '', arguments: args,
308
+ decision: 'DENY', reason,
309
+ source: 'claude-code-guard',
310
+ }),
311
+ signal: AbortSignal.timeout(3000),
312
+ });
313
+ } catch {}
314
+ }
315
+ process.stderr.write(reason);
316
+ process.exit(2);
317
+ }
318
+
319
+ // ── Normalization layers ──
320
+
321
+ // 1. Decode ANSI-C quoting: $'\x72' → r, $'\162' → r, $'\n' → newline
322
+ function decodeAnsiC(s) {
323
+ return s.replace(/\$'([^']*)'/g, (_, content) => {
324
+ return content
325
+ .replace(/\\x([0-9a-fA-F]{2})/g, (__, hex) => String.fromCharCode(parseInt(hex, 16)))
326
+ .replace(/\\([0-7]{1,3})/g, (__, oct) => String.fromCharCode(parseInt(oct, 8)))
327
+ .replace(/\\u([0-9a-fA-F]{4})/g, (__, hex) => String.fromCharCode(parseInt(hex, 16)))
328
+ .replace(/\\n/g, '\n').replace(/\\t/g, '\t').replace(/\\r/g, '\r')
329
+ .replace(/\\(.)/g, '$1');
330
+ });
331
+ }
332
+
333
+ // 2. Strip all shell quoting: empty quotes, single, double, backslash escapes
300
334
  function stripShellQuotes(s) {
301
- return s.replace(/\\(.)/g, '$1').replace(/'/g, '').replace(/"/g, '');
335
+ let r = s;
336
+ r = r.replace(/""/g, ''); // empty double quotes
337
+ r = r.replace(/''/g, ''); // empty single quotes
338
+ r = r.replace(/\\(.)/g, '$1'); // backslash escapes
339
+ r = r.replace(/'/g, ''); // remaining single quotes
340
+ r = r.replace(/"/g, ''); // remaining double quotes
341
+ return r;
342
+ }
343
+
344
+ // 3. Full normalization pipeline
345
+ function normalizeShell(s) {
346
+ return stripShellQuotes(decodeAnsiC(s));
302
347
  }
303
348
 
304
- // Check if a glob/wildcard pattern could match any protected path
305
- // e.g. ".antig*" matches ".antigravity", "/path/.sol*" matches ".solongate"
349
+ // 4. Extract inner commands from eval, bash -c, sh -c, pipe to bash/sh
350
+ function extractInnerCommands(s) {
351
+ const inner = [];
352
+ // eval "cmd" or eval 'cmd' or eval cmd
353
+ for (const m of s.matchAll(/\beval\s+["']([^"']+)["']/gi)) inner.push(m[1]);
354
+ for (const m of s.matchAll(/\beval\s+([^;"'|&]+)/gi)) inner.push(m[1]);
355
+ // bash -c "cmd" or sh -c "cmd"
356
+ for (const m of s.matchAll(/\b(?:bash|sh)\s+-c\s+["']([^"']+)["']/gi)) inner.push(m[1]);
357
+ // echo "cmd" | bash/sh or printf "cmd" | bash/sh
358
+ for (const m of s.matchAll(/(?:echo|printf)\s+["']([^"']+)["']\s*\|\s*(?:bash|sh)\b/gi)) inner.push(m[1]);
359
+ // find ... -name "pattern" ... -exec ...
360
+ for (const m of s.matchAll(/-name\s+["']?([^\s"']+)["']?/gi)) inner.push(m[1]);
361
+ return inner;
362
+ }
363
+
364
+ // 5. Check variable assignments for protected path fragments
365
+ // e.g. X=".solon" && rm -rf ${X}gate → detects ".solon" as prefix of ".solongate"
366
+ function checkVarAssignments(s) {
367
+ const assignments = [...s.matchAll(/(\w+)=["']?([^"'\s&|;]+)["']?/g)];
368
+ for (const [, , value] of assignments) {
369
+ const v = value.toLowerCase();
370
+ if (v.length < 3) continue; // avoid false positives
371
+ for (const p of protectedPaths) {
372
+ if (p.startsWith(v) || p.includes(v)) return p;
373
+ }
374
+ }
375
+ return null;
376
+ }
377
+
378
+ // 6. Check if a glob/wildcard could match any protected path
306
379
  function globMatchesProtected(s) {
307
380
  if (!s.includes('*') && !s.includes('?')) return null;
308
- // Extract all path segments and the full string to test
309
381
  const segments = s.split('/').filter(Boolean);
310
382
  const candidates = [s, ...segments];
311
383
  for (const candidate of candidates) {
312
384
  if (!candidate.includes('*') && !candidate.includes('?')) continue;
313
- for (const p of protectedPaths) {
314
- // Build regex from glob: * → .*, ? → .
315
- const escaped = candidate.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\\\*/g, '.*').replace(/\\\?/g, '.');
316
- try {
317
- if (new RegExp('^' + escaped + '$', 'i').test(p)) return p;
318
- } catch { /* invalid regex, skip */ }
385
+ // Prefix match: ".antig*" prefix ".antig"
386
+ const starIdx = candidate.indexOf('*');
387
+ const qIdx = candidate.indexOf('?');
388
+ const firstWild = starIdx === -1 ? qIdx : qIdx === -1 ? starIdx : Math.min(starIdx, qIdx);
389
+ const prefix = candidate.slice(0, firstWild).toLowerCase();
390
+ if (prefix.length > 0) {
391
+ for (const p of protectedPaths) {
392
+ if (p.startsWith(prefix)) return p;
393
+ }
319
394
  }
395
+ // Regex match for patterns like ".cl?ude"
396
+ try {
397
+ const escaped = candidate.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\\\*/g, '.*').replace(/\\\?/g, '.');
398
+ const re = new RegExp('^' + escaped + '$', 'i');
399
+ for (const p of protectedPaths) {
400
+ if (re.test(p)) return p;
401
+ }
402
+ } catch {}
320
403
  }
321
404
  return null;
322
405
  }
323
406
 
324
- // Normalize: lowercase, forward slashes, strip shell quotes
407
+ // ── Build all candidate strings from tool input ──
325
408
  const rawStrings = scanStrings(args).map(s => s.replace(/\\/g, '/').toLowerCase());
326
- const allStrings = [];
409
+ const allStrings = new Set();
410
+
327
411
  for (const s of rawStrings) {
328
- allStrings.push(s);
329
- // Also add quote-stripped version
330
- const stripped = stripShellQuotes(s);
331
- if (stripped !== s) allStrings.push(stripped);
332
- // Split by spaces (for commands like "rm -rf .sol'on'gate .cl*")
333
- for (const tok of s.split(/\s+/)) {
334
- if (tok !== s) {
335
- allStrings.push(tok);
336
- const strippedTok = stripShellQuotes(tok);
337
- if (strippedTok !== tok) allStrings.push(strippedTok);
412
+ // Raw string
413
+ allStrings.add(s);
414
+ // Normalized (ANSI-C decoded, quotes stripped)
415
+ const norm = normalizeShell(s);
416
+ allStrings.add(norm);
417
+ // Extract inner commands (eval, bash -c, pipe to bash, find -name)
418
+ for (const inner of extractInnerCommands(s)) {
419
+ allStrings.add(inner.toLowerCase());
420
+ allStrings.add(normalizeShell(inner.toLowerCase()));
421
+ }
422
+ // Split by spaces + shell operators for token-level checks
423
+ for (const tok of s.split(/[\s;&|]+/)) {
424
+ if (tok) {
425
+ allStrings.add(tok);
426
+ allStrings.add(normalizeShell(tok));
338
427
  }
339
428
  }
429
+ // Also split normalized version
430
+ for (const tok of norm.split(/[\s;&|]+/)) {
431
+ if (tok) allStrings.add(tok);
432
+ }
340
433
  }
341
434
 
435
+ // ── Check all candidates ──
342
436
  for (const s of allStrings) {
343
437
  // Direct match
344
438
  for (const p of protectedPaths) {
345
439
  if (s.includes(p)) {
346
- const msg = 'SOLONGATE: Access to protected file "' + p + '" is blocked';
347
- if (API_KEY && API_KEY.startsWith('sg_live_')) {
348
- try {
349
- await fetch(API_URL + '/api/v1/audit-logs', {
350
- method: 'POST',
351
- headers: { 'Authorization': 'Bearer ' + API_KEY, 'Content-Type': 'application/json' },
352
- body: JSON.stringify({
353
- tool: data.tool_name || '', arguments: args,
354
- decision: 'DENY', reason: msg,
355
- source: 'claude-code-guard',
356
- }),
357
- signal: AbortSignal.timeout(3000),
358
- });
359
- } catch {}
360
- }
361
- process.stderr.write(msg);
362
- process.exit(2);
440
+ await blockSelfProtection('SOLONGATE: Access to protected path "' + p + '" is blocked');
363
441
  }
364
442
  }
365
443
  // Wildcard/glob match
366
444
  const globHit = globMatchesProtected(s);
367
445
  if (globHit) {
368
- const msg = 'SOLONGATE: Wildcard pattern "' + s + '" matches protected path "' + globHit + '" — blocked';
369
- if (API_KEY && API_KEY.startsWith('sg_live_')) {
370
- try {
371
- await fetch(API_URL + '/api/v1/audit-logs', {
372
- method: 'POST',
373
- headers: { 'Authorization': 'Bearer ' + API_KEY, 'Content-Type': 'application/json' },
374
- body: JSON.stringify({
375
- tool: data.tool_name || '', arguments: args,
376
- decision: 'DENY', reason: msg,
377
- source: 'claude-code-guard',
378
- }),
379
- signal: AbortSignal.timeout(3000),
380
- });
381
- } catch {}
382
- }
383
- process.stderr.write(msg);
384
- process.exit(2);
446
+ await blockSelfProtection('SOLONGATE: Wildcard "' + s + '" matches protected "' + globHit + '" — blocked');
447
+ }
448
+ // Variable assignment targeting protected paths
449
+ const varHit = checkVarAssignments(s);
450
+ if (varHit) {
451
+ await blockSelfProtection('SOLONGATE: Variable assignment targets protected "' + varHit + '" — blocked');
385
452
  }
386
453
  }
387
454
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solongate/proxy",
3
- "version": "0.15.4",
3
+ "version": "0.16.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": {