@solongate/proxy 0.47.1 → 0.47.3

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.
package/hooks/guard.mjs CHANGED
@@ -31,7 +31,7 @@ import { createHash } from 'node:crypto';
31
31
  // the installed hook self-updates when the cloud version is higher (see
32
32
  // maybeSelfUpdate). This is what makes guard fixes propagate without a manual
33
33
  // reinstall — the same trust model as the OPA WASM this hook already runs.
34
- const HOOK_VERSION = 5;
34
+ const HOOK_VERSION = 14;
35
35
 
36
36
  // Safe file read with size limit (1MB max) to prevent DoS via large files
37
37
  const MAX_FILE_READ = 1024 * 1024; // 1MB
@@ -526,6 +526,7 @@ const TAMPER_PROTECTED_GLOBS = [
526
526
  '**' + TAMPER_SG + '/.pi-config-cache.json',
527
527
  '**' + TAMPER_SG + '/cloud-guard.json',
528
528
  '**' + TAMPER_SG + '/.opa-wasm-*.json',
529
+ '**' + TAMPER_SG + '/.ratelimit-*.json',
529
530
  // Persistent host data (DB + audit JSONL) at ~/.solongate/data
530
531
  '**' + TAMPER_SG + '/data/**',
531
532
  // Customer install layout (zip extracted as solongate/)
@@ -626,6 +627,107 @@ function tamperCheck(toolName, args) {
626
627
  return null;
627
628
  }
628
629
 
630
+ // ── Extra security layers (rate limit, egress allowlist, DLP block) ──
631
+ // These are configured per-project in the dashboard and delivered to the guard
632
+ // via /policies/active (security). All fail OPEN: any error here returns null
633
+ // (allow) so a config glitch never bricks the agent. Tamper protection and
634
+ // policy are unaffected and still run.
635
+
636
+ // DLP patterns mirror the server's set (apps/api/src/lib/security-layers.ts).
637
+ // Mirrors apps/api/src/lib/security-layers.ts DLP_PATTERNS. Provider-specific
638
+ // rules plus generic Bearer / secret-assignment catch-alls for the long tail.
639
+ const DLP_PATTERNS = [
640
+ { name: 'AWS access key', re: /AKIA[0-9A-Z]{16}/ },
641
+ { name: 'private key block', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
642
+ { name: 'Anthropic key', re: /sk-ant-[A-Za-z0-9_-]{20,}/ },
643
+ { name: 'OpenAI key', re: /sk-(proj-)?[A-Za-z0-9_-]{20,}/ },
644
+ { name: 'GitHub token', re: /gh[pousr]_[A-Za-z0-9]{20,}/ },
645
+ { name: 'GitHub fine-grained PAT', re: /github_pat_[A-Za-z0-9_]{20,}/ },
646
+ { name: 'GitLab token', re: /glpat-[A-Za-z0-9_-]{20,}/ },
647
+ { name: 'Slack token', re: /xox[baprs]-[A-Za-z0-9-]{10,}/ },
648
+ { name: 'Google API key', re: /AIza[0-9A-Za-z_-]{35}/ },
649
+ { name: 'Stripe key', re: /[sr]k_(live|test)_[A-Za-z0-9]{20,}/ },
650
+ { name: 'SendGrid key', re: /SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/ },
651
+ { name: 'Twilio key', re: /SK[0-9a-fA-F]{32}/ },
652
+ { name: 'npm token', re: /npm_[A-Za-z0-9]{36}/ },
653
+ { name: 'JWT', re: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/ },
654
+ { name: 'Bearer token', re: /bearer\s+[A-Za-z0-9._-]{20,}/i },
655
+ { name: 'secret assignment', re: /(api[_-]?key|secret|token|password|passwd|access[_-]?key)["']?\s*[:=]\s*["']?[A-Za-z0-9/+_.-]{12,}/i },
656
+ ];
657
+
658
+ // Scan args against the enabled built-in patterns + any user custom patterns.
659
+ // `cfg` = { patterns: string[], custom: {name,re}[] }.
660
+ function dlpScan(args, cfg) {
661
+ if (!cfg) return null;
662
+ let text = '';
663
+ try { text = JSON.stringify(args || {}); } catch { return null; }
664
+ const allow = new Set(Array.isArray(cfg.patterns) ? cfg.patterns : []);
665
+ for (const p of DLP_PATTERNS) {
666
+ if (allow.has(p.name) && p.re.test(text)) return p.name;
667
+ }
668
+ for (const c of Array.isArray(cfg.custom) ? cfg.custom : []) {
669
+ try { if (new RegExp(c.re).test(text)) return c.name || 'custom pattern'; } catch { /* skip invalid */ }
670
+ }
671
+ return null;
672
+ }
673
+
674
+ // Multi-window sliding rate limit, persisted under ~/.solongate (tamper-protected
675
+ // from the agent, writable by the guard). One timestamps file per agent, pruned
676
+ // to the last 24h and capped for performance; counts this agent's calls within
677
+ // each enabled window (minute/hour/day). Returns the exceeded window or null.
678
+ const RL_WINDOWS = [
679
+ { key: 'perDay', ms: 86400000, label: 'day' },
680
+ { key: 'perHour', ms: 3600000, label: 'hour' },
681
+ { key: 'perMinute', ms: 60000, label: 'minute' },
682
+ ];
683
+ function rateLimitCheck(agentKey, limits) {
684
+ try {
685
+ const file = join(resolve(homedir(), '.solongate'), '.ratelimit-' + agentKey + '.json');
686
+ const now = Date.now();
687
+ let stamps = [];
688
+ if (existsSync(file)) {
689
+ try { stamps = JSON.parse(readFileSync(file, 'utf-8')); } catch { stamps = []; }
690
+ }
691
+ if (!Array.isArray(stamps)) stamps = [];
692
+ // Prune to the longest window (24h) and cap size to bound work/IO.
693
+ stamps = stamps.filter((t) => typeof t === 'number' && now - t < 86400000);
694
+ if (stamps.length > 50000) stamps = stamps.slice(-50000);
695
+ // Check each enabled window against the current count (before adding now).
696
+ for (const w of RL_WINDOWS) {
697
+ const limit = limits[w.key];
698
+ if (limit > 0) {
699
+ const count = stamps.reduce((n, t) => (now - t < w.ms ? n + 1 : n), 0);
700
+ if (count >= limit) return { window: w.label, limit };
701
+ }
702
+ }
703
+ stamps.push(now);
704
+ try { writeFileSync(file, JSON.stringify(stamps)); } catch {}
705
+ return null;
706
+ } catch {
707
+ return null; // fail open
708
+ }
709
+ }
710
+
711
+ // Runs all enabled enforcement layers; returns a deny reason or null (allow).
712
+ function securityLayerCheck(toolName, args, cfg, agentKey) {
713
+ if (!cfg) return null;
714
+ try {
715
+ if (cfg.dlpBlock) {
716
+ const hit = dlpScan(args, cfg.dlpBlock);
717
+ if (hit) return 'Security layer (DLP): blocked - arguments contain a ' + hit +
718
+ '. Blocked by SolonGate - check your dashboard for details.';
719
+ }
720
+ if (cfg.rateLimit) {
721
+ const hit = rateLimitCheck(agentKey, cfg.rateLimit);
722
+ if (hit) {
723
+ return 'Security layer (rate limit): exceeded ' + hit.limit + ' calls/' + hit.window +
724
+ ' for this agent. Blocked by SolonGate - check your dashboard to review or adjust the limit.';
725
+ }
726
+ }
727
+ } catch { /* fail open */ }
728
+ return null;
729
+ }
730
+
629
731
  // ── Policy Evaluation ──
630
732
 
631
733
  // Permission filter: a rule with rule.permission set only applies to tool
@@ -1055,6 +1157,13 @@ process.stdin.on('end', async () => {
1055
1157
  // fallback for when the API is unreachable.
1056
1158
  const hookCwd = data.cwd || process.cwd();
1057
1159
  let policy;
1160
+ // Self-protection (tamper guard) defaults ON. The cloud per-project setting
1161
+ // can turn it off; delivered via /policies/active and cached alongside the
1162
+ // policy. Any failure to read it leaves protection ON (fail safe).
1163
+ let selfProtectEnabled = true;
1164
+ // Extra security layers (rate limit, egress, DLP block) delivered by the
1165
+ // cloud. Null = none configured. Fail open if unread.
1166
+ let securityCfg = null;
1058
1167
  // Cache keyed by agent_id so different agents in different terminals
1059
1168
  // don't share a stale cached policy.
1060
1169
  const agentKey = (AGENT_ID || 'default').replace(/[^a-zA-Z0-9_-]/g, '_');
@@ -1066,21 +1175,26 @@ process.stdin.on('end', async () => {
1066
1175
  try {
1067
1176
  if (existsSync(policyCacheFile)) {
1068
1177
  const cached = JSON.parse(readFileSync(policyCacheFile, 'utf-8'));
1069
- if (cached && cached._ts && Date.now() - cached._ts < POLICY_TTL_MS && cached.policy) {
1070
- dashboardPolicy = cached.policy;
1178
+ if (cached && cached._ts && Date.now() - cached._ts < POLICY_TTL_MS) {
1179
+ if (cached.policy) dashboardPolicy = cached.policy;
1180
+ if (typeof cached.selfProtect === 'boolean') selfProtectEnabled = cached.selfProtect;
1181
+ if (cached.security !== undefined) securityCfg = cached.security;
1071
1182
  }
1072
1183
  }
1073
1184
  } catch {}
1074
1185
  // Refresh from API if cache expired
1075
1186
  if (!dashboardPolicy) {
1076
1187
  try {
1077
- const res = await fetch(API_URL + '/api/v1/policies/active?agent_id=' + encodeURIComponent(AGENT_ID || ''), { headers: AUTH_HEADERS, signal: AbortSignal.timeout(8000) });
1188
+ // Report our installed version (hv) so the dashboard can show whether
1189
+ // this guard is on the latest build.
1190
+ const res = await fetch(API_URL + '/api/v1/policies/active?agent_id=' + encodeURIComponent(AGENT_ID || '') + '&hv=' + HOOK_VERSION, { headers: AUTH_HEADERS, signal: AbortSignal.timeout(8000) });
1078
1191
  if (res.ok) {
1079
1192
  const body = await res.json();
1080
- if (body && body.policy) {
1081
- dashboardPolicy = body.policy;
1082
- try { writeFileSync(policyCacheFile, JSON.stringify({ _ts: Date.now(), policy: dashboardPolicy })); } catch {}
1083
- }
1193
+ // Capture the self-protection flag even when no cloud policy is set.
1194
+ if (typeof body?.self_protection_enabled === 'boolean') selfProtectEnabled = body.self_protection_enabled;
1195
+ if (body?.security !== undefined) securityCfg = body.security;
1196
+ if (body && body.policy) dashboardPolicy = body.policy;
1197
+ try { writeFileSync(policyCacheFile, JSON.stringify({ _ts: Date.now(), policy: dashboardPolicy || null, selfProtect: selfProtectEnabled, security: securityCfg })); } catch {}
1084
1198
  }
1085
1199
  } catch {}
1086
1200
  }
@@ -1121,8 +1235,12 @@ process.stdin.on('end', async () => {
1121
1235
 
1122
1236
  if (process.env.SOLONGATE_DEBUG) {
1123
1237
  }
1124
- // Hardcoded tamper protection — runs unconditionally, before policy eval.
1125
- let reason = tamperCheck(toolName, args);
1238
+ // Tamper / self-protection — runs before policy eval. ON by default; the
1239
+ // per-project cloud setting can disable it (fail safe: stays on if unread).
1240
+ let reason = selfProtectEnabled ? tamperCheck(toolName, args) : null;
1241
+ // Extra security layers run after tamper, before policy. Block reason wins
1242
+ // immediately (BLACK). Fail-open by design.
1243
+ if (!reason) reason = securityLayerCheck(toolName, args, securityCfg, agentKey);
1126
1244
  if (process.env.SOLONGATE_DEBUG) {
1127
1245
  }
1128
1246
  // OPA WASM is the SOLE policy engine. With no policy configured for this
@@ -1143,17 +1261,19 @@ process.stdin.on('end', async () => {
1143
1261
  } else if (policy && policy.rules) {
1144
1262
  const opaResult = await evaluateWithOpa(policy, args, toolName, hookCwd);
1145
1263
  if (opaResult === undefined) {
1146
- // OPA produced no decision (no WASM bundle, runtime missing, fetch error).
1147
- // Honor policy-mode defaults instead of a blanket fail-closed:
1148
- // whitelist (default-deny): fail CLOSED nothing is allowed without an
1149
- // explicit ALLOW match, so an unavailable engine must block.
1150
- // denylist (default-allow): fail OPEN the engine being down means no
1151
- // DENY rule could match, and denylist's default IS allow. Blocking
1152
- // everything here is whitelist behavior and wrong for a denylist policy
1153
- // (it bricks every tool call during any transient WASM hiccup).
1154
- // Tamper protection already ran above and is unaffected either way.
1155
- if (policy.mode === 'whitelist') {
1156
- reason = '[SolonGate OPA] Policy WASM unavailable — failing closed (DENY). Ensure the SolonGate API is reachable so policies compile to WASM.';
1264
+ // OPA produced no decision (no WASM bundle yet, runtime missing, fetch
1265
+ // error). This happens on COLD START — the first call(s) in a session
1266
+ // before the policy + WASM are cached. Don't leave an enforcement gap:
1267
+ // run the deterministic, WASM-free JS evaluator so DENY rules (e.g.
1268
+ // secret-file protection) and whitelist defaults apply IMMEDIATELY, from
1269
+ // the very first call. evaluate() implements both modes:
1270
+ // - returns a deny reason → block (DENY match, or whitelist no-match)
1271
+ // - returns null → allow (denylist default / whitelist match)
1272
+ // This closes the "worked, but late" window where a denylist policy used
1273
+ // to fail OPEN until WASM warmed up.
1274
+ const legacy = evaluate(policy, args, toolName);
1275
+ if (typeof legacy === 'string') {
1276
+ reason = legacy;
1157
1277
  opaRoute = 'black';
1158
1278
  } else {
1159
1279
  opaRoute = 'white';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solongate/proxy",
3
- "version": "0.47.1",
3
+ "version": "0.47.3",
4
4
  "description": "AI tool security proxy — protect any AI tool server with customizable policies, path/command constraints, rate limiting, and audit logging. Zero code changes required.",
5
5
  "type": "module",
6
6
  "bin": {