@solongate/proxy 0.47.0 → 0.47.2

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
@@ -21,10 +21,17 @@
21
21
  * Logs DENY decisions to SolonGate Cloud. ALLOWs are logged by audit.mjs.
22
22
  * Auto-installed by: npx @solongate/proxy init --global
23
23
  */
24
- import { readFileSync, existsSync, statSync, writeFileSync, mkdirSync } from 'node:fs';
24
+ import { readFileSync, existsSync, statSync, writeFileSync, mkdirSync, chmodSync, renameSync } from 'node:fs';
25
25
  import { resolve, join } from 'node:path';
26
26
  import { homedir } from 'node:os';
27
27
  import { gunzipSync } from 'node:zlib';
28
+ import { createHash } from 'node:crypto';
29
+
30
+ // Bump on every guard.mjs change. The cloud serves the newest bundle + version;
31
+ // the installed hook self-updates when the cloud version is higher (see
32
+ // maybeSelfUpdate). This is what makes guard fixes propagate without a manual
33
+ // reinstall — the same trust model as the OPA WASM this hook already runs.
34
+ const HOOK_VERSION = 14;
28
35
 
29
36
  // Safe file read with size limit (1MB max) to prevent DoS via large files
30
37
  const MAX_FILE_READ = 1024 * 1024; // 1MB
@@ -65,6 +72,23 @@ function loadGlobalCloudConfig() {
65
72
  } catch { return {}; }
66
73
  }
67
74
 
75
+ // A real cloud key is `sg_live_`/`sg_test_` followed by hex (see generateApiKey:
76
+ // 24 random bytes → 48 hex chars). Template/placeholder values shipped in sample
77
+ // .env files (e.g. `sg_live_your_key_here`) pass a naive truthiness check but are
78
+ // bogus — and because resolution prefers a project .env over the global login
79
+ // credential, a stray placeholder .env would shadow a valid login and 401 every
80
+ // API call, making the guard fail closed on EVERYTHING. Filter to real keys so a
81
+ // placeholder is skipped and the next real candidate (usually the login cred in
82
+ // cloud-guard.json) is used instead.
83
+ function isRealKey(k) {
84
+ if (typeof k !== 'string') return false;
85
+ const v = k.trim();
86
+ if (!/^sg_(live|test)_/.test(v)) return false;
87
+ const body = v.replace(/^sg_(live|test)_/, '');
88
+ if (/your_key_here|placeholder|example|^x+$/i.test(body)) return false;
89
+ return /^[a-f0-9]{16,}$/i.test(body);
90
+ }
91
+
68
92
  function guessPermission(toolName) {
69
93
  const name = (toolName || '').toLowerCase();
70
94
  if (name.includes('exec') || name.includes('shell') || name.includes('run') || name.includes('eval') || name === 'bash') return 'EXECUTE';
@@ -82,12 +106,46 @@ const API_URL = process.env.SOLONGATE_API_URL || dotenv.SOLONGATE_API_URL || glo
82
106
  // Cloud API key (sg_live_… / sg_test_…). The key identifies the project AND
83
107
  // authenticates every API call (active policy, compiled WASM, audit logs). When
84
108
  // absent, this hook does nothing — a machine with no key is intentionally
85
- // unenforced (the cloud has no policy to apply).
86
- const API_KEY = process.env.SOLONGATE_API_KEY || dotenv.SOLONGATE_API_KEY || globalCfg.apiKey || '';
109
+ // unenforced (the cloud has no policy to apply). Each candidate is filtered
110
+ // through isRealKey() so a placeholder .env (sg_live_your_key_here) can't shadow
111
+ // the real login credential and force a fail-closed on every call.
112
+ const API_KEY = [process.env.SOLONGATE_API_KEY, dotenv.SOLONGATE_API_KEY, globalCfg.apiKey].find(isRealKey) || '';
87
113
  // Auth headers attached to every cloud API request. Cloud accepts either the
88
114
  // Authorization: Bearer form or X-API-Key; we send both for robustness.
89
115
  const AUTH_HEADERS = API_KEY ? { 'Authorization': 'Bearer ' + API_KEY, 'X-API-Key': API_KEY } : {};
90
116
 
117
+ // ── Self-update (best-effort, throttled, integrity-checked) ──
118
+ // Once per ~6h the hook asks the cloud for the latest guard bundle. If the cloud
119
+ // version is higher AND the sha256 verifies AND the payload looks like this guard
120
+ // hook, it atomically replaces its own file. Any failure is swallowed so a bad
121
+ // update can never break enforcement — the current code simply keeps running.
122
+ async function maybeSelfUpdate() {
123
+ if (!API_KEY) return;
124
+ try {
125
+ const sgDir = resolve(homedir(), '.solongate');
126
+ const stamp = join(sgDir, '.hook-update-check');
127
+ const last = parseInt(safeReadFileSync(stamp) || '0', 10);
128
+ if (Number.isFinite(last) && Date.now() - last < 6 * 3600 * 1000) return;
129
+ try { writeFileSync(stamp, String(Date.now())); } catch { /* ignore */ }
130
+
131
+ const res = await fetch(API_URL + '/api/v1/hooks/guard', { headers: AUTH_HEADERS, signal: AbortSignal.timeout(5000) });
132
+ if (!res.ok) return;
133
+ const data = await res.json();
134
+ if (!data || typeof data.version !== 'number' || data.version <= HOOK_VERSION) return;
135
+ if (typeof data.content !== 'string' || typeof data.sha256 !== 'string') return;
136
+ const buf = Buffer.from(data.content, 'base64');
137
+ if (createHash('sha256').update(buf).digest('hex') !== data.sha256) return;
138
+ const text = buf.toString('utf-8');
139
+ // Sanity gate: must look like THIS guard hook before we overwrite ourselves.
140
+ if (!text.startsWith('#!/usr/bin/env node') || text.length < 50000 || !text.includes('SolonGate Cloud Policy Guard')) return;
141
+ const hooksDir = join(sgDir, 'hooks');
142
+ const tmp = join(hooksDir, '.guard.mjs.tmp');
143
+ writeFileSync(tmp, text);
144
+ try { chmodSync(join(hooksDir, 'guard.mjs'), 0o644); } catch { /* may be locked read-only */ }
145
+ renameSync(tmp, join(hooksDir, 'guard.mjs')); // atomic swap, takes effect next call
146
+ } catch { /* never break enforcement on update failure */ }
147
+ }
148
+
91
149
  // Two distinct identities, deliberately kept separate:
92
150
  //
93
151
  // AGENT_TYPE — the real AI client running this hook (claude-code /
@@ -410,14 +468,26 @@ function extractCommands(args) {
410
468
  return cmds;
411
469
  }
412
470
 
413
- function extractPaths(args) {
471
+ function extractPaths(args, isExec) {
414
472
  const paths = [];
415
- for (const s of scanStrings(args)) {
416
- if (/^https?:\/\//i.test(s)) continue;
473
+ const add = (t) => {
474
+ if (!t || /^https?:\/\//i.test(t)) return;
417
475
  // Normalize Windows backslashes to forward slashes so paths match the
418
476
  // compiled Rego patterns (which are also normalized to "/"). OPA glob.match
419
477
  // does no separator translation, so raw "C:\..." never matched "/" patterns.
420
- if (s.includes('/') || s.includes('\\') || s.startsWith('.')) paths.push(s.replace(/\\/g, '/'));
478
+ if (t.includes('/') || t.includes('\\') || t.startsWith('.')) paths.push(t.replace(/\\/g, '/'));
479
+ };
480
+ for (const s of scanStrings(args)) {
481
+ if (/^https?:\/\//i.test(s)) continue;
482
+ if (isExec && /\s/.test(s)) {
483
+ // A command line (exec tool): pull out individual path-like tokens instead
484
+ // of treating the whole command as one path. Otherwise `node src/app.js`
485
+ // becomes the path "node src/app.js", which no path glob can match — so a
486
+ // path-scoped EXECUTE rule would never fire. Tokenizing yields "src/app.js".
487
+ for (const tok of s.split(/[\s;|&><()`'"]+/)) add(tok);
488
+ } else {
489
+ add(s);
490
+ }
421
491
  }
422
492
  return paths;
423
493
  }
@@ -456,6 +526,7 @@ const TAMPER_PROTECTED_GLOBS = [
456
526
  '**' + TAMPER_SG + '/.pi-config-cache.json',
457
527
  '**' + TAMPER_SG + '/cloud-guard.json',
458
528
  '**' + TAMPER_SG + '/.opa-wasm-*.json',
529
+ '**' + TAMPER_SG + '/.ratelimit-*.json',
459
530
  // Persistent host data (DB + audit JSONL) at ~/.solongate/data
460
531
  '**' + TAMPER_SG + '/data/**',
461
532
  // Customer install layout (zip extracted as solongate/)
@@ -556,6 +627,107 @@ function tamperCheck(toolName, args) {
556
627
  return null;
557
628
  }
558
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
+
559
731
  // ── Policy Evaluation ──
560
732
 
561
733
  // Permission filter: a rule with rule.permission set only applies to tool
@@ -582,7 +754,7 @@ function patternsOf(constraint) {
582
754
  return Array.isArray(list) && list.length > 0 ? list : null;
583
755
  }
584
756
 
585
- function ruleMatches(rule, args) {
757
+ function ruleMatches(rule, args, isExec) {
586
758
  const fnPats = patternsOf(rule.filenameConstraints);
587
759
  if (fnPats) {
588
760
  const filenames = extractFilenames(args);
@@ -612,7 +784,7 @@ function ruleMatches(rule, args) {
612
784
  }
613
785
  const pathPats = patternsOf(rule.pathConstraints);
614
786
  if (pathPats) {
615
- const paths = extractPaths(args);
787
+ const paths = extractPaths(args, isExec);
616
788
  for (const p of paths) {
617
789
  for (const pat of pathPats) {
618
790
  if (matchPathGlob(p, pat)) return { kind: 'path', value: p, pattern: pat };
@@ -630,13 +802,14 @@ function evaluate(policy, args, toolName) {
630
802
  if (!policy || !policy.rules) return null;
631
803
  const enabledRules = policy.rules.filter(r => r.enabled !== false);
632
804
  const mode = policy.mode === 'whitelist' ? 'whitelist' : 'denylist';
805
+ const isExec = /bash|shell|exec|powershell|cmd|run|eval/.test((toolName || '').toLowerCase());
633
806
 
634
807
  // DENY pass — runs in both modes. DENY wins over ALLOW.
635
808
  const denyRules = enabledRules
636
809
  .filter(r => r.effect === 'DENY' && permissionApplies(r, toolName))
637
810
  .sort((a, b) => (a.priority || 100) - (b.priority || 100));
638
811
  for (const rule of denyRules) {
639
- const m = ruleMatches(rule, args);
812
+ const m = ruleMatches(rule, args, isExec);
640
813
  if (m) return 'Blocked by policy: ' + m.kind + ' "' + m.value + '" matches "' + m.pattern + '"';
641
814
  }
642
815
 
@@ -648,7 +821,7 @@ function evaluate(policy, args, toolName) {
648
821
  }
649
822
  let matched = false;
650
823
  for (const rule of allowRules) {
651
- if (ruleMatches(rule, args)) { matched = true; break; }
824
+ if (ruleMatches(rule, args, isExec)) { matched = true; break; }
652
825
  }
653
826
  if (!matched) {
654
827
  return 'Blocked by policy: strict whitelist mode — request does not match any ALLOW rule';
@@ -835,7 +1008,7 @@ async function evaluateWithOpa(policy, args, toolName, cwd) {
835
1008
  permission: guessPermission(toolName),
836
1009
  trust_level: 'TRUSTED',
837
1010
  arguments: expandedArgs,
838
- paths: extractPaths(accessArgs),
1011
+ paths: extractPaths(accessArgs, isExecTool),
839
1012
  commands: extractCommands(expandedArgs),
840
1013
  urls: extractUrls(accessArgs),
841
1014
  filenames: extractFilenames(accessArgs),
@@ -984,32 +1157,44 @@ process.stdin.on('end', async () => {
984
1157
  // fallback for when the API is unreachable.
985
1158
  const hookCwd = data.cwd || process.cwd();
986
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;
987
1167
  // Cache keyed by agent_id so different agents in different terminals
988
1168
  // don't share a stale cached policy.
989
1169
  const agentKey = (AGENT_ID || 'default').replace(/[^a-zA-Z0-9_-]/g, '_');
990
1170
  const policyCacheFile = join(resolve(homedir(), '.solongate'), '.policy-cache-' + agentKey + '.json');
991
- const POLICY_TTL_MS = 10_000;
1171
+ const POLICY_TTL_MS = 3_000;
992
1172
  try {
993
1173
  let dashboardPolicy = null;
994
1174
  // Try cache first
995
1175
  try {
996
1176
  if (existsSync(policyCacheFile)) {
997
1177
  const cached = JSON.parse(readFileSync(policyCacheFile, 'utf-8'));
998
- if (cached && cached._ts && Date.now() - cached._ts < POLICY_TTL_MS && cached.policy) {
999
- 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;
1000
1182
  }
1001
1183
  }
1002
1184
  } catch {}
1003
1185
  // Refresh from API if cache expired
1004
1186
  if (!dashboardPolicy) {
1005
1187
  try {
1006
- 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) });
1007
1191
  if (res.ok) {
1008
1192
  const body = await res.json();
1009
- if (body && body.policy) {
1010
- dashboardPolicy = body.policy;
1011
- try { writeFileSync(policyCacheFile, JSON.stringify({ _ts: Date.now(), policy: dashboardPolicy })); } catch {}
1012
- }
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 {}
1013
1198
  }
1014
1199
  } catch {}
1015
1200
  }
@@ -1050,16 +1235,21 @@ process.stdin.on('end', async () => {
1050
1235
 
1051
1236
  if (process.env.SOLONGATE_DEBUG) {
1052
1237
  }
1053
- // Hardcoded tamper protection — runs unconditionally, before policy eval.
1054
- 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);
1055
1244
  if (process.env.SOLONGATE_DEBUG) {
1056
1245
  }
1057
1246
  // OPA WASM is the SOLE policy engine. With no policy configured for this
1058
1247
  // agent we skip evaluation entirely (allow). With a policy present,
1059
1248
  // evaluateWithOpa returns a reason (DENY), null (ALLOW), or undefined when
1060
- // the WASM bundle could not be obtained at all — in which case we fail
1061
- // CLOSED rather than silently allowing. (The legacy JS evaluate() below is
1062
- // retained but no longer on the decision path OPA decides everything.)
1249
+ // the WASM bundle could not be obtained at all — in which case we fall back
1250
+ // to the policy mode's default (whitelist fail closed, denylist → fail
1251
+ // open); see the branch below. (The legacy JS evaluate() below is retained
1252
+ // but no longer on the decision path — OPA decides everything.)
1063
1253
  // Cloud routing is BINARY — WHITE (allow) / BLACK (block). There is NO AI
1064
1254
  // Judge in the cloud (that is an air-gap-only feature), so there is no GRAY
1065
1255
  // "send to the judge" lane: the OPA policy alone decides. Tamper protection
@@ -1071,8 +1261,23 @@ process.stdin.on('end', async () => {
1071
1261
  } else if (policy && policy.rules) {
1072
1262
  const opaResult = await evaluateWithOpa(policy, args, toolName, hookCwd);
1073
1263
  if (opaResult === undefined) {
1074
- reason = '[SolonGate OPA] Policy WASM unavailable — failing closed (DENY). Ensure the SolonGate API is reachable so policies compile to WASM.';
1075
- opaRoute = 'black';
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;
1277
+ opaRoute = 'black';
1278
+ } else {
1279
+ opaRoute = 'white';
1280
+ }
1076
1281
  } else if (typeof opaResult === 'string') {
1077
1282
  reason = opaResult; // explicit DENY
1078
1283
  opaRoute = 'black';
@@ -1106,8 +1311,10 @@ process.stdin.on('end', async () => {
1106
1311
  } catch {}
1107
1312
  }
1108
1313
  writeDenyFlag(toolName);
1314
+ await maybeSelfUpdate();
1109
1315
  blockTool(reason);
1110
1316
  }
1111
1317
  } catch {}
1318
+ await maybeSelfUpdate();
1112
1319
  allowTool();
1113
1320
  });
package/package.json CHANGED
@@ -1,76 +1,76 @@
1
- {
2
- "name": "@solongate/proxy",
3
- "version": "0.47.0",
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
- "type": "module",
6
- "bin": {
7
- "solongate": "./dist/index.js",
8
- "solongate-proxy": "./dist/index.js",
9
- "solongate-init": "./dist/init.js",
10
- "proxy": "./dist/index.js"
11
- },
12
- "main": "./dist/lib.js",
13
- "exports": {
14
- ".": {
15
- "import": "./dist/lib.js"
16
- },
17
- "./cli": {
18
- "import": "./dist/index.js"
19
- }
20
- },
21
- "files": [
22
- "dist",
23
- "hooks",
24
- "README.md"
25
- ],
26
- "scripts": {
27
- "build": "tsup && pnpm build:hooks",
28
- "build:hooks": "node scripts/bundle-hooks.mjs",
29
- "dev": "tsx src/index.ts",
30
- "typecheck": "tsc --noEmit",
31
- "clean": "rm -rf dist .turbo"
32
- },
33
- "keywords": [
34
- "ai-tool-security",
35
- "ai-tool-proxy",
36
- "security",
37
- "proxy",
38
- "gateway",
39
- "firewall",
40
- "ai-security",
41
- "tool-security",
42
- "claude",
43
- "openclaw",
44
- "solongate",
45
- "prompt-injection",
46
- "path-traversal",
47
- "rate-limiting"
48
- ],
49
- "author": "SolonGate <hello@solongate.com>",
50
- "license": "MIT",
51
- "homepage": "https://solongate.com",
52
- "repository": {
53
- "type": "git",
54
- "url": "https://github.com/solongate/solongate"
55
- },
56
- "engines": {
57
- "node": ">=18.0.0"
58
- },
59
- "dependencies": {
60
- "@modelcontextprotocol/sdk": "^1.26.0",
61
- "zod": "^3.25.0"
62
- },
63
- "optionalDependencies": {
64
- "@huggingface/transformers": ">=3.0.0"
65
- },
66
- "devDependencies": {
67
- "@open-policy-agent/opa-wasm": "^1.10.0",
68
- "@solongate/core": "workspace:*",
69
- "@solongate/policy-engine": "workspace:*",
70
- "@solongate/tsconfig": "workspace:*",
71
- "esbuild": "^0.19.12",
72
- "tsup": "^8.3.0",
73
- "tsx": "^4.19.0",
74
- "typescript": "^5.7.0"
75
- }
76
- }
1
+ {
2
+ "name": "@solongate/proxy",
3
+ "version": "0.47.2",
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
+ "type": "module",
6
+ "bin": {
7
+ "solongate": "./dist/index.js",
8
+ "solongate-proxy": "./dist/index.js",
9
+ "solongate-init": "./dist/init.js",
10
+ "proxy": "./dist/index.js"
11
+ },
12
+ "main": "./dist/lib.js",
13
+ "exports": {
14
+ ".": {
15
+ "import": "./dist/lib.js"
16
+ },
17
+ "./cli": {
18
+ "import": "./dist/index.js"
19
+ }
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "hooks",
24
+ "README.md"
25
+ ],
26
+ "scripts": {
27
+ "build": "tsup && pnpm build:hooks",
28
+ "build:hooks": "node scripts/bundle-hooks.mjs",
29
+ "dev": "tsx src/index.ts",
30
+ "typecheck": "tsc --noEmit",
31
+ "clean": "rm -rf dist .turbo"
32
+ },
33
+ "keywords": [
34
+ "ai-tool-security",
35
+ "ai-tool-proxy",
36
+ "security",
37
+ "proxy",
38
+ "gateway",
39
+ "firewall",
40
+ "ai-security",
41
+ "tool-security",
42
+ "claude",
43
+ "openclaw",
44
+ "solongate",
45
+ "prompt-injection",
46
+ "path-traversal",
47
+ "rate-limiting"
48
+ ],
49
+ "author": "SolonGate <hello@solongate.com>",
50
+ "license": "MIT",
51
+ "homepage": "https://solongate.com",
52
+ "repository": {
53
+ "type": "git",
54
+ "url": "https://github.com/solongate/solongate"
55
+ },
56
+ "engines": {
57
+ "node": ">=18.0.0"
58
+ },
59
+ "dependencies": {
60
+ "@modelcontextprotocol/sdk": "^1.26.0",
61
+ "zod": "^3.25.0"
62
+ },
63
+ "optionalDependencies": {
64
+ "@huggingface/transformers": ">=3.0.0"
65
+ },
66
+ "devDependencies": {
67
+ "@open-policy-agent/opa-wasm": "^1.10.0",
68
+ "@solongate/core": "workspace:*",
69
+ "@solongate/policy-engine": "workspace:*",
70
+ "@solongate/tsconfig": "workspace:*",
71
+ "esbuild": "^0.19.12",
72
+ "tsup": "^8.3.0",
73
+ "tsx": "^4.19.0",
74
+ "typescript": "^5.7.0"
75
+ }
76
+ }