@solongate/proxy 0.15.5 → 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 +110 -54
  2. package/package.json +1 -1
package/hooks/guard.mjs CHANGED
@@ -296,21 +296,93 @@ 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));
347
+ }
348
+
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;
302
362
  }
303
363
 
304
- // Check if a glob/wildcard pattern could match any protected path
305
- // e.g. ".antig*" matches ".antigravity", "/path/.sol*" matches ".solongate"
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
- // Simple prefix match: ".antig*" → prefix ".antig", check if any protected path starts with it
385
+ // Prefix match: ".antig*" → prefix ".antig"
314
386
  const starIdx = candidate.indexOf('*');
315
387
  const qIdx = candidate.indexOf('?');
316
388
  const firstWild = starIdx === -1 ? qIdx : qIdx === -1 ? starIdx : Math.min(starIdx, qIdx);
@@ -320,79 +392,63 @@ process.stdin.on('end', async () => {
320
392
  if (p.startsWith(prefix)) return p;
321
393
  }
322
394
  }
323
- // Also try regex for complex patterns like ".cl?ude"
395
+ // Regex match for patterns like ".cl?ude"
324
396
  try {
325
397
  const escaped = candidate.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\\\*/g, '.*').replace(/\\\?/g, '.');
326
398
  const re = new RegExp('^' + escaped + '$', 'i');
327
399
  for (const p of protectedPaths) {
328
400
  if (re.test(p)) return p;
329
401
  }
330
- } catch { /* invalid regex, skip */ }
402
+ } catch {}
331
403
  }
332
404
  return null;
333
405
  }
334
406
 
335
- // Normalize: lowercase, forward slashes, strip shell quotes
407
+ // ── Build all candidate strings from tool input ──
336
408
  const rawStrings = scanStrings(args).map(s => s.replace(/\\/g, '/').toLowerCase());
337
- const allStrings = [];
409
+ const allStrings = new Set();
410
+
338
411
  for (const s of rawStrings) {
339
- allStrings.push(s);
340
- // Also add quote-stripped version
341
- const stripped = stripShellQuotes(s);
342
- if (stripped !== s) allStrings.push(stripped);
343
- // Split by spaces (for commands like "rm -rf .sol'on'gate .cl*")
344
- for (const tok of s.split(/\s+/)) {
345
- if (tok !== s) {
346
- allStrings.push(tok);
347
- const strippedTok = stripShellQuotes(tok);
348
- 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));
349
427
  }
350
428
  }
429
+ // Also split normalized version
430
+ for (const tok of norm.split(/[\s;&|]+/)) {
431
+ if (tok) allStrings.add(tok);
432
+ }
351
433
  }
352
434
 
435
+ // ── Check all candidates ──
353
436
  for (const s of allStrings) {
354
437
  // Direct match
355
438
  for (const p of protectedPaths) {
356
439
  if (s.includes(p)) {
357
- const msg = 'SOLONGATE: Access to protected file "' + p + '" is blocked';
358
- if (API_KEY && API_KEY.startsWith('sg_live_')) {
359
- try {
360
- await fetch(API_URL + '/api/v1/audit-logs', {
361
- method: 'POST',
362
- headers: { 'Authorization': 'Bearer ' + API_KEY, 'Content-Type': 'application/json' },
363
- body: JSON.stringify({
364
- tool: data.tool_name || '', arguments: args,
365
- decision: 'DENY', reason: msg,
366
- source: 'claude-code-guard',
367
- }),
368
- signal: AbortSignal.timeout(3000),
369
- });
370
- } catch {}
371
- }
372
- process.stderr.write(msg);
373
- process.exit(2);
440
+ await blockSelfProtection('SOLONGATE: Access to protected path "' + p + '" is blocked');
374
441
  }
375
442
  }
376
443
  // Wildcard/glob match
377
444
  const globHit = globMatchesProtected(s);
378
445
  if (globHit) {
379
- const msg = 'SOLONGATE: Wildcard pattern "' + s + '" matches protected path "' + globHit + '" — blocked';
380
- if (API_KEY && API_KEY.startsWith('sg_live_')) {
381
- try {
382
- await fetch(API_URL + '/api/v1/audit-logs', {
383
- method: 'POST',
384
- headers: { 'Authorization': 'Bearer ' + API_KEY, 'Content-Type': 'application/json' },
385
- body: JSON.stringify({
386
- tool: data.tool_name || '', arguments: args,
387
- decision: 'DENY', reason: msg,
388
- source: 'claude-code-guard',
389
- }),
390
- signal: AbortSignal.timeout(3000),
391
- });
392
- } catch {}
393
- }
394
- process.stderr.write(msg);
395
- 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');
396
452
  }
397
453
  }
398
454
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solongate/proxy",
3
- "version": "0.15.5",
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": {