@solongate/proxy 0.18.0 → 0.20.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 +168 -14
  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,80 @@ 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
+ }
459
564
 
460
565
  // 7a. Inline interpreter execution — TOTAL BLOCK (no content scan needed)
461
566
  // These can construct ANY string at runtime, bypassing all static analysis
@@ -476,17 +581,24 @@ process.stdin.on('end', async () => {
476
581
  }
477
582
  }
478
583
 
479
- // 7b. Base64 decode in ANY context block when piped to anything
584
+ // 7b. Pipe-to-interpreterTOTAL BLOCK
585
+ // Any content piped to an interpreter can construct arbitrary commands at runtime
586
+ const pipeToInterpreter = /\|\s*(?:node|bash|sh|python[23]?|perl|ruby|php)\b/i;
587
+ if (pipeToInterpreter.test(fullCmd)) {
588
+ await blockSelfProtection('SOLONGATE: Pipe to interpreter blocked — runtime bypass risk');
589
+ }
590
+
591
+ // 7c. Base64 decode in ANY context — block when piped to anything
480
592
  if (/\bbase64\s+(?:-d|--decode)\b/i.test(fullCmd) && /\|/i.test(fullCmd)) {
481
593
  await blockSelfProtection('SOLONGATE: base64 decode in pipe chain — blocked');
482
594
  }
483
595
 
484
- // 7c. Temp/arbitrary script file execution
596
+ // 7d. Temp/arbitrary script file execution
485
597
  if (/\b(?:bash|sh)\s+(?:\/tmp\/|\/var\/tmp\/|~\/|\/dev\/)/i.test(fullCmd)) {
486
598
  await blockSelfProtection('SOLONGATE: Script execution from temp path — blocked');
487
599
  }
488
600
 
489
- // 7d. xargs with destructive operations
601
+ // 7e. xargs with destructive operations
490
602
  if (/\bxargs\b.*\b(?:rm|mv|cp|rmdir|unlink|del)\b/i.test(fullCmd)) {
491
603
  for (const p of protectedPaths) {
492
604
  if (fullCmd.includes(p.slice(0, 4))) {
@@ -495,7 +607,7 @@ process.stdin.on('end', async () => {
495
607
  }
496
608
  }
497
609
 
498
- // 7e. cmd.exe /c with encoded/constructed commands
610
+ // 7f. cmd.exe /c with encoded/constructed commands
499
611
  if (/\bcmd(?:\.exe)?\s+\/c\b/i.test(fullCmd)) {
500
612
  for (const p of protectedPaths) {
501
613
  if (fullCmd.includes(p) || fullCmd.includes(p.slice(0, 4))) {
@@ -504,6 +616,48 @@ process.stdin.on('end', async () => {
504
616
  }
505
617
  }
506
618
 
619
+ // 7g. Script file execution — scan file content for discovery+destruction combo
620
+ // Catches: bash script.sh / node script.mjs where the script uses readdirSync + rmSync
621
+ const scriptExecMatch = fullCmd.match(/\b(?:bash|sh|node|python[23]?|perl|ruby)\s+([^\s;&|]+)/i);
622
+ if (scriptExecMatch) {
623
+ const scriptPath = scriptExecMatch[1];
624
+ try {
625
+ const hookCwdForScript = data.cwd || process.cwd();
626
+ const absPath = scriptPath.startsWith('/') || scriptPath.includes(':')
627
+ ? scriptPath
628
+ : resolve(hookCwdForScript, scriptPath);
629
+ if (existsSync(absPath)) {
630
+ const scriptContent = readFileSync(absPath, 'utf-8').toLowerCase();
631
+ // Check for discovery+destruction combo
632
+ const hasDiscovery = /\breaddirsync\b|\breaddir\b|\bos\.listdir\b|\bscandir\b|\bglob(?:sync)?\b|\bls\s+-[adl]|\bls\s+\.\b|\bopendir\b|\bdir\.entries\b|\bwalkdir\b|\bls\b.*\.\[/.test(scriptContent);
633
+ const hasDestruction = /\brmsync\b|\brm\s+-rf\b|\bunlinksync\b|\brmdirsync\b|\bunlink\s*\(|\brimraf\b|\bremovesync\b|\bremove_tree\b|\bshutil\.rmtree\b|\bwritefilesync\b|\bexecsync\b.*\brm\b|\bchild_process\b|\bfs\.\s*(?:rm|unlink|rmdir|write)/.test(scriptContent);
634
+ if (hasDiscovery && hasDestruction) {
635
+ await blockSelfProtection('SOLONGATE: Script contains directory discovery + destructive ops — blocked');
636
+ }
637
+ // Also check for protected path names in script content (existing check, now centralized)
638
+ for (const p of protectedPaths) {
639
+ if (scriptContent.includes(p)) {
640
+ await blockSelfProtection('SOLONGATE: Script references protected path "' + p + '" — blocked');
641
+ }
642
+ }
643
+ }
644
+ } catch {} // File read error — skip
645
+ }
646
+
647
+ // 7h. Write tool content scanning — detect discovery+destruction in file content being written
648
+ // Catches: Write tool creating a script that uses readdirSync('.') + rmSync
649
+ const toolName_ = data.tool_name || '';
650
+ if (toolName_.toLowerCase() === 'write' || toolName_.toLowerCase() === 'edit') {
651
+ const fileContent = (args.content || args.new_string || '').toLowerCase();
652
+ if (fileContent.length > 0) {
653
+ const hasDiscovery = /\breaddirsync\b|\breaddir\b|\bos\.listdir\b|\bscandir\b|\bglob(?:sync)?\b|\bls\s+-[adl]|\bls\s+\.\b|\bopendir\b|\bdir\.entries\b|\bwalkdir\b|\bls\b.*\.\[/.test(fileContent);
654
+ const hasDestruction = /\brmsync\b|\brm\s+-rf\b|\bunlinksync\b|\brmdirsync\b|\bunlink\b|\brimraf\b|\bremovesync\b|\bremove_tree\b|\bshutil\.rmtree\b|\bwritefilesync\b|\bexecsync\b.*\brm\b|\bchild_process\b.*\brm\b|\bfs\.\s*(?:rm|unlink|rmdir)/.test(fileContent);
655
+ if (hasDiscovery && hasDestruction) {
656
+ await blockSelfProtection('SOLONGATE: File content contains discovery + destructive ops — write blocked');
657
+ }
658
+ }
659
+ }
660
+
507
661
  // ── Fetch PI config from Cloud ──
508
662
  let piCfg = { piEnabled: true, piThreshold: 0.5, piMode: 'block', piWhitelist: [], piToolConfig: {}, piCustomPatterns: [], piWebhookUrl: null };
509
663
  if (API_KEY && API_KEY.startsWith('sg_live_')) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solongate/proxy",
3
- "version": "0.18.0",
3
+ "version": "0.20.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": {