@solongate/proxy 0.19.0 → 0.21.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 +144 -11
  2. package/package.json +1 -1
package/hooks/guard.mjs CHANGED
@@ -291,10 +291,13 @@ process.stdin.on('end', async () => {
291
291
 
292
292
  // ── Self-protection: block access to hook files and settings ──
293
293
  // Hardcoded, no bypass possible — runs before policy/PI config
294
+ // Fully protected: block ALL access (read, write, delete, move)
294
295
  const protectedPaths = [
295
296
  '.solongate', '.claude', '.cursor', '.gemini', '.antigravity', '.openclaw', '.perplexity',
296
297
  'policy.json', '.mcp.json',
297
298
  ];
299
+ // Write-protected: block write/delete/modify, allow read (cat, grep, head, etc.)
300
+ const writeProtectedPaths = ['.env', '.gitignore'];
298
301
 
299
302
  // Block helper — logs to cloud + exits with code 2
300
303
  async function blockSelfProtection(reason) {
@@ -346,7 +349,7 @@ process.stdin.on('end', async () => {
346
349
  return stripShellQuotes(decodeAnsiC(s));
347
350
  }
348
351
 
349
- // 4. Extract inner commands from eval, bash -c, sh -c, pipe to bash/sh
352
+ // 4. Extract inner commands from eval, bash -c, sh -c, pipe to bash/sh, heredoc, process substitution
350
353
  function extractInnerCommands(s) {
351
354
  const inner = [];
352
355
  // eval "cmd" or eval 'cmd' or eval cmd
@@ -358,6 +361,13 @@ process.stdin.on('end', async () => {
358
361
  for (const m of s.matchAll(/(?:echo|printf)\s+["']([^"']+)["']\s*\|\s*(?:bash|sh)\b/gi)) inner.push(m[1]);
359
362
  // find ... -name "pattern" ... -exec ...
360
363
  for (const m of s.matchAll(/-name\s+["']?([^\s"']+)["']?/gi)) inner.push(m[1]);
364
+ // Heredoc: bash << 'EOF'\n...\nEOF or bash <<- EOF\n...\nEOF
365
+ for (const m of s.matchAll(/<<-?\s*['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\s*\1/gi)) inner.push(m[2]);
366
+ // Herestring: bash <<< "cmd"
367
+ for (const m of s.matchAll(/<<<\s*["']([^"']+)["']/gi)) inner.push(m[1]);
368
+ for (const m of s.matchAll(/<<<\s*([^\s;&|]+)/gi)) inner.push(m[1]);
369
+ // Process substitution: <(cmd) or >(cmd)
370
+ for (const m of s.matchAll(/[<>]\(\s*([^)]+)\s*\)/gi)) inner.push(m[1]);
361
371
  return inner;
362
372
  }
363
373
 
@@ -377,25 +387,50 @@ process.stdin.on('end', async () => {
377
387
 
378
388
  // 6. Check if a glob/wildcard could match any protected path
379
389
  function globMatchesProtected(s) {
380
- if (!s.includes('*') && !s.includes('?')) return null;
390
+ const hasWild = s.includes('*') || s.includes('?') || /\[.+\]/.test(s);
391
+ if (!hasWild) return null;
381
392
  const segments = s.split('/').filter(Boolean);
382
393
  const candidates = [s, ...segments];
383
394
  for (const candidate of candidates) {
384
- if (!candidate.includes('*') && !candidate.includes('?')) continue;
395
+ const candHasWild = candidate.includes('*') || candidate.includes('?') || /\[.+\]/.test(candidate);
396
+ if (!candHasWild) continue;
385
397
  // 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);
398
+ const firstWild = Math.min(
399
+ candidate.includes('*') ? candidate.indexOf('*') : Infinity,
400
+ candidate.includes('?') ? candidate.indexOf('?') : Infinity,
401
+ /\[/.test(candidate) ? candidate.indexOf('[') : Infinity,
402
+ );
389
403
  const prefix = candidate.slice(0, firstWild).toLowerCase();
390
404
  if (prefix.length > 0) {
391
405
  for (const p of protectedPaths) {
392
406
  if (p.startsWith(prefix)) return p;
393
407
  }
394
408
  }
395
- // Regex match for patterns like ".cl?ude"
409
+ // Regex match convert shell glob to regex
410
+ // Handles *, ?, and [...] character classes
396
411
  try {
397
- const escaped = candidate.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\\\*/g, '.*').replace(/\\\?/g, '.');
398
- const re = new RegExp('^' + escaped + '$', 'i');
412
+ let regex = '';
413
+ let i = 0;
414
+ const c = candidate.toLowerCase();
415
+ while (i < c.length) {
416
+ if (c[i] === '*') { regex += '.*'; i++; }
417
+ else if (c[i] === '?') { regex += '.'; i++; }
418
+ else if (c[i] === '[') {
419
+ // Pass through [...] as regex character class
420
+ const close = c.indexOf(']', i + 1);
421
+ if (close !== -1) {
422
+ regex += c.slice(i, close + 1);
423
+ i = close + 1;
424
+ } else {
425
+ regex += '\\['; i++;
426
+ }
427
+ }
428
+ else {
429
+ regex += c[i].replace(/[.+^${}()|\\]/g, '\\$&');
430
+ i++;
431
+ }
432
+ }
433
+ const re = new RegExp('^' + regex + '$', 'i');
399
434
  for (const p of protectedPaths) {
400
435
  if (re.test(p)) return p;
401
436
  }
@@ -452,10 +487,107 @@ process.stdin.on('end', async () => {
452
487
  }
453
488
  }
454
489
 
490
+ // ── Write-protection for .env, .gitignore ──
491
+ // These files can be READ but not written/deleted/moved
492
+ const toolNameLower = (data.tool_name || '').toLowerCase();
493
+ const isWriteTool = toolNameLower === 'write' || toolNameLower === 'edit' || toolNameLower === 'notebookedit';
494
+ const isBashTool = toolNameLower === 'bash';
495
+ const bashDestructive = /\b(?:rm|rmdir|del|unlink|mv|move|rename|cp|chmod|chown|truncate|shred)\b/i;
496
+ const fullCmd = rawStrings.join(' ');
497
+
498
+ for (const wp of writeProtectedPaths) {
499
+ if (isWriteTool) {
500
+ // Check file_path and all strings for write-protected paths
501
+ for (const s of allStrings) {
502
+ if (s.includes(wp)) {
503
+ await blockSelfProtection('SOLONGATE: Write to protected file "' + wp + '" is blocked');
504
+ }
505
+ }
506
+ }
507
+ if (isBashTool && bashDestructive.test(fullCmd)) {
508
+ for (const s of allStrings) {
509
+ if (s.includes(wp)) {
510
+ await blockSelfProtection('SOLONGATE: Destructive operation on "' + wp + '" is blocked');
511
+ }
512
+ }
513
+ }
514
+ }
515
+
516
+ // Also apply glob/wildcard checks for write-protected paths in destructive contexts
517
+ if (isBashTool && bashDestructive.test(fullCmd)) {
518
+ for (const s of allStrings) {
519
+ const hasWild = s.includes('*') || s.includes('?') || /\[.+\]/.test(s);
520
+ if (!hasWild) continue;
521
+ // Convert glob to regex and test against write-protected paths
522
+ try {
523
+ let regex = '';
524
+ let i = 0;
525
+ const c = s.toLowerCase();
526
+ while (i < c.length) {
527
+ if (c[i] === '*') { regex += '.*'; i++; }
528
+ else if (c[i] === '?') { regex += '.'; i++; }
529
+ else if (c[i] === '[') {
530
+ const close = c.indexOf(']', i + 1);
531
+ if (close !== -1) { regex += c.slice(i, close + 1); i = close + 1; }
532
+ else { regex += '\\['; i++; }
533
+ }
534
+ else { regex += c[i].replace(/[.+^${}()|\\]/g, '\\$&'); i++; }
535
+ }
536
+ const re = new RegExp('^' + regex + '$', 'i');
537
+ for (const wp of writeProtectedPaths) {
538
+ if (re.test(wp)) {
539
+ await blockSelfProtection('SOLONGATE: Wildcard "' + s + '" matches write-protected "' + wp + '" — blocked');
540
+ }
541
+ }
542
+ } catch {}
543
+ }
544
+ }
545
+
455
546
  // ── Layer 7: Block ALL inline code execution & dangerous patterns ──
456
547
  // Runtime string construction (atob, Buffer.from, fromCharCode, array.join)
457
548
  // makes static analysis impossible. Blanket-block these patterns.
458
- const fullCmd = rawStrings.join(' ');
549
+
550
+ // 7-pre. Blanket rule: destructive command + dot-prefixed glob = BLOCK
551
+ // Catches: rm -rf .[a-z]*, mv .[!.]* /tmp, etc.
552
+ // Any glob starting with "." combined with a destructive op is dangerous
553
+ const destructiveOps = /\b(?:rm|rmdir|del|unlink|mv|move|rename)\b/i;
554
+ const dotGlob = /\.\[|\.[\*\?]|\.{[^}]+}/; // .[a-z]*, .*, .?, .{foo,bar}
555
+ if (destructiveOps.test(fullCmd) && dotGlob.test(fullCmd)) {
556
+ await blockSelfProtection('SOLONGATE: Destructive command + dot-glob pattern — blocked');
557
+ }
558
+
559
+ // 7-pre2. Heredoc to interpreter — BLOCK
560
+ // bash << 'EOF' / bash <<< "cmd" / sh << TAG
561
+ if (/\b(?:bash|sh|node|python[23]?|perl|ruby)\s+<</i.test(fullCmd)) {
562
+ await blockSelfProtection('SOLONGATE: Heredoc to interpreter — blocked');
563
+ }
564
+
565
+ // 7-pre3. Command substitution + destructive = BLOCK
566
+ // Catches: rm -rf $(node scan.mjs), rm `node scan.mjs`, rm $(bash scan.sh)
567
+ // Pattern: destructive command + $() or `` containing interpreter call
568
+ const hasDestructive = /\b(?:rm|rmdir|del|unlink|mv|move|rename|shred)\b/i.test(fullCmd);
569
+ const hasCmdSubInterpreter = /\$\(\s*(?:node|bash|sh|python[23]?|perl|ruby)\b/i.test(fullCmd)
570
+ || /`\s*(?:node|bash|sh|python[23]?|perl|ruby)\b/i.test(fullCmd);
571
+ if (hasDestructive && hasCmdSubInterpreter) {
572
+ await blockSelfProtection('SOLONGATE: Command substitution + destructive op — blocked');
573
+ }
574
+
575
+ // 7-pre4. Pipe from interpreter to destructive loop = BLOCK
576
+ // Catches: node scan.mjs | while read d; do rm -rf "$d"; done
577
+ // node scan.mjs | xargs rm -rf
578
+ // bash scan.sh | while read ...
579
+ const pipeFromInterpreter = /\b(?:node|bash|sh|python[23]?|perl|ruby)\s+\S+\s*\|/i;
580
+ if (pipeFromInterpreter.test(fullCmd) && hasDestructive) {
581
+ await blockSelfProtection('SOLONGATE: Pipe from script to destructive command — blocked');
582
+ }
583
+
584
+ // 7-pre5. Script chaining: interpreter + destructive in same command chain
585
+ // Catches: node scan.mjs && rm -rf $(cat /tmp/targets.txt)
586
+ // bash scan.sh; while read d < targets.txt; do rm -rf "$d"; done
587
+ const hasScriptExec = /\b(?:node|bash|sh|python[23]?|perl|ruby)\s+\S+\.\S+/i.test(fullCmd);
588
+ if (hasScriptExec && hasDestructive) {
589
+ await blockSelfProtection('SOLONGATE: Script execution + destructive command in chain — blocked');
590
+ }
459
591
 
460
592
  // 7a. Inline interpreter execution — TOTAL BLOCK (no content scan needed)
461
593
  // These can construct ANY string at runtime, bypassing all static analysis
@@ -478,7 +610,8 @@ process.stdin.on('end', async () => {
478
610
 
479
611
  // 7b. Pipe-to-interpreter — TOTAL BLOCK
480
612
  // Any content piped to an interpreter can construct arbitrary commands at runtime
481
- const pipeToInterpreter = /\|\s*(?:node|bash|sh|python[23]?|perl|ruby|php)\b/i;
613
+ // Also catches: | xargs node, | xargs bash, etc.
614
+ const pipeToInterpreter = /\|\s*(?:xargs\s+)?(?:node|bash|sh|python[23]?|perl|ruby|php)\b/i;
482
615
  if (pipeToInterpreter.test(fullCmd)) {
483
616
  await blockSelfProtection('SOLONGATE: Pipe to interpreter blocked — runtime bypass risk');
484
617
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solongate/proxy",
3
- "version": "0.19.0",
3
+ "version": "0.21.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": {