@solongate/proxy 0.59.3 → 0.59.4

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.
@@ -6531,11 +6531,12 @@ var init_src = __esm({
6531
6531
 
6532
6532
  // packages/proxy/hooks/guard.mjs
6533
6533
  import { readFileSync, existsSync, statSync, writeFileSync, mkdirSync, chmodSync, renameSync, appendFileSync } from "node:fs";
6534
+ import { spawn } from "node:child_process";
6534
6535
  import { resolve, join, dirname, isAbsolute } from "node:path";
6535
6536
  import { homedir } from "node:os";
6536
6537
  import { gunzipSync } from "node:zlib";
6537
6538
  import { createHash } from "node:crypto";
6538
- var HOOK_VERSION = 32;
6539
+ var HOOK_VERSION = 33;
6539
6540
  function localLogsOnly(security) {
6540
6541
  const l = security && security.localLogs;
6541
6542
  return !!(l && l.enabled && typeof l.path === "string" && l.path.trim());
@@ -6713,6 +6714,51 @@ var AGENT_TYPE = process.argv[2] || "claude-code";
6713
6714
  var POLICY_SELECTOR = process.env.SOLONGATE_AGENT_ID || "";
6714
6715
  var AGENT_ID = POLICY_SELECTOR || AGENT_TYPE;
6715
6716
  var AGENT_NAME = process.env.SOLONGATE_AGENT_NAME || process.argv[3] || AGENT_TYPE;
6717
+ var REFRESH_MODE = process.argv.includes("--sg-refresh-policy");
6718
+ async function refreshPolicyCache() {
6719
+ try {
6720
+ const agentKey = (AGENT_ID || "default").replace(/[^a-zA-Z0-9_-]/g, "_");
6721
+ const cacheFile = join(resolve(homedir(), ".solongate"), ".policy-cache-" + agentKey + ".json");
6722
+ let selfProtect = true, security = null, hookVersions = null, policy = null;
6723
+ try {
6724
+ if (existsSync(cacheFile)) {
6725
+ const c = JSON.parse(readFileSync(cacheFile, "utf-8"));
6726
+ if (c) {
6727
+ policy = c.policy ?? null;
6728
+ if (typeof c.selfProtect === "boolean")
6729
+ selfProtect = c.selfProtect;
6730
+ if (c.security !== void 0)
6731
+ security = c.security;
6732
+ if (c.hookVersions)
6733
+ hookVersions = c.hookVersions;
6734
+ }
6735
+ }
6736
+ } catch {
6737
+ }
6738
+ try {
6739
+ const res = await fetch(API_URL + "/api/v1/policies/active?agent_id=" + encodeURIComponent(AGENT_ID || "") + "&hv=" + HOOK_VERSION, { headers: AUTH_HEADERS, signal: AbortSignal.timeout(8e3) });
6740
+ if (res.ok) {
6741
+ const body = await res.json();
6742
+ if (typeof body?.self_protection_enabled === "boolean")
6743
+ selfProtect = body.self_protection_enabled;
6744
+ if (body?.security !== void 0)
6745
+ security = body.security;
6746
+ if (body?.hook_versions && typeof body.hook_versions === "object")
6747
+ hookVersions = body.hook_versions;
6748
+ policy = body && body.policy ? body.policy : null;
6749
+ }
6750
+ } catch {
6751
+ }
6752
+ try {
6753
+ writeFileSync(cacheFile, JSON.stringify({ _ts: Date.now(), policy, selfProtect, security, hookVersions }));
6754
+ } catch {
6755
+ }
6756
+ } catch {
6757
+ }
6758
+ }
6759
+ if (REFRESH_MODE) {
6760
+ refreshPolicyCache().finally(() => process.exit(0));
6761
+ }
6716
6762
  function blockTool(reason) {
6717
6763
  if (AGENT_TYPE === "gemini-cli") {
6718
6764
  process.stdout.write(JSON.stringify({
@@ -7610,6 +7656,8 @@ function readReferencedFiles(args, cwd) {
7610
7656
  }
7611
7657
  process.stdin.on("data", (c) => input += c);
7612
7658
  process.stdin.on("end", async () => {
7659
+ if (REFRESH_MODE)
7660
+ return;
7613
7661
  if (process.env.SOLONGATE_DEBUG) {
7614
7662
  }
7615
7663
  if (!API_KEY) {
@@ -7671,21 +7719,6 @@ process.stdin.on("end", async () => {
7671
7719
  } catch {
7672
7720
  }
7673
7721
  if (refreshDue) {
7674
- try {
7675
- const res = await fetch(API_URL + "/api/v1/policies/active?agent_id=" + encodeURIComponent(AGENT_ID || "") + "&hv=" + HOOK_VERSION, { headers: AUTH_HEADERS, signal: AbortSignal.timeout(2e3) });
7676
- if (res.ok) {
7677
- const body = await res.json();
7678
- if (typeof body?.self_protection_enabled === "boolean")
7679
- selfProtectEnabled = body.self_protection_enabled;
7680
- if (body?.security !== void 0)
7681
- securityCfg = body.security;
7682
- if (body?.hook_versions && typeof body.hook_versions === "object")
7683
- CLOUD_HOOK_VERSIONS = body.hook_versions;
7684
- if (body && body.policy)
7685
- dashboardPolicy = body.policy;
7686
- }
7687
- } catch {
7688
- }
7689
7722
  if (!dashboardPolicy && staleCache) {
7690
7723
  dashboardPolicy = staleCache.policy;
7691
7724
  if (typeof staleCache.selfProtect === "boolean")
@@ -7695,10 +7728,19 @@ process.stdin.on("end", async () => {
7695
7728
  if (staleCache.hookVersions)
7696
7729
  CLOUD_HOOK_VERSIONS = staleCache.hookVersions;
7697
7730
  }
7698
- const keepPolicy = dashboardPolicy || staleCache && staleCache.policy || null;
7699
- try {
7700
- writeFileSync(policyCacheFile, JSON.stringify({ _ts: Date.now(), policy: keepPolicy, selfProtect: selfProtectEnabled, security: securityCfg, hookVersions: CLOUD_HOOK_VERSIONS }));
7701
- } catch {
7731
+ if (API_KEY) {
7732
+ try {
7733
+ const lock = join(resolve(homedir(), ".solongate"), ".policy-refresh-" + agentKey + ".lock");
7734
+ const due = !existsSync(lock) || Date.now() - statSync(lock).mtimeMs > 3e3;
7735
+ if (due) {
7736
+ try {
7737
+ writeFileSync(lock, String(Date.now()));
7738
+ } catch {
7739
+ }
7740
+ spawn(process.execPath, [process.argv[1], AGENT_TYPE, AGENT_NAME, "--sg-refresh-policy"], { detached: true, stdio: "ignore", env: process.env }).unref();
7741
+ }
7742
+ } catch {
7743
+ }
7702
7744
  }
7703
7745
  }
7704
7746
  if (process.env.SOLONGATE_DEBUG) {
package/hooks/guard.mjs CHANGED
@@ -22,6 +22,7 @@
22
22
  * Auto-installed by: npx @solongate/proxy init --global
23
23
  */
24
24
  import { readFileSync, existsSync, statSync, writeFileSync, mkdirSync, chmodSync, renameSync, appendFileSync } from 'node:fs';
25
+ import { spawn } from 'node:child_process';
25
26
  import { resolve, join, dirname, isAbsolute } from 'node:path';
26
27
  import { homedir } from 'node:os';
27
28
  import { gunzipSync } from 'node:zlib';
@@ -31,7 +32,7 @@ import { createHash } from 'node:crypto';
31
32
  // the installed hook self-updates when the cloud version is higher (see
32
33
  // maybeSelfUpdate). This is what makes guard fixes propagate without a manual
33
34
  // reinstall — the same trust model as the OPA WASM this hook already runs.
34
- const HOOK_VERSION = 32;
35
+ const HOOK_VERSION = 33;
35
36
 
36
37
  // True when local log storage is ON. In that mode logs are kept LOCAL ONLY and
37
38
  // nothing is sent to the cloud audit log.
@@ -248,6 +249,40 @@ const POLICY_SELECTOR = process.env.SOLONGATE_AGENT_ID || '';
248
249
  const AGENT_ID = POLICY_SELECTOR || AGENT_TYPE;
249
250
  const AGENT_NAME = process.env.SOLONGATE_AGENT_NAME || process.argv[3] || AGENT_TYPE;
250
251
 
252
+ // ── Background policy refresh (stale-while-revalidate) ──
253
+ // The hot path NEVER blocks on the network: when the policy cache is stale it
254
+ // serves the cached (or local) policy INSTANTLY and spawns this detached mode to
255
+ // fetch a fresh policy and rewrite the cache for the NEXT call. Result: fast tool
256
+ // calls AND ~one-call propagation of policy changes — no long-TTL tradeoff.
257
+ const REFRESH_MODE = process.argv.includes('--sg-refresh-policy');
258
+ async function refreshPolicyCache() {
259
+ try {
260
+ const agentKey = (AGENT_ID || 'default').replace(/[^a-zA-Z0-9_-]/g, '_');
261
+ const cacheFile = join(resolve(homedir(), '.solongate'), '.policy-cache-' + agentKey + '.json');
262
+ // Preserve existing fields so a failed fetch never erases the last-known-good.
263
+ let selfProtect = true, security = null, hookVersions = null, policy = null;
264
+ try {
265
+ if (existsSync(cacheFile)) {
266
+ const c = JSON.parse(readFileSync(cacheFile, 'utf-8'));
267
+ if (c) { policy = c.policy ?? null; if (typeof c.selfProtect === 'boolean') selfProtect = c.selfProtect; if (c.security !== undefined) security = c.security; if (c.hookVersions) hookVersions = c.hookVersions; }
268
+ }
269
+ } catch {}
270
+ try {
271
+ 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) });
272
+ if (res.ok) {
273
+ const body = await res.json();
274
+ if (typeof body?.self_protection_enabled === 'boolean') selfProtect = body.self_protection_enabled;
275
+ if (body?.security !== undefined) security = body.security;
276
+ if (body?.hook_versions && typeof body.hook_versions === 'object') hookVersions = body.hook_versions;
277
+ policy = (body && body.policy) ? body.policy : null;
278
+ }
279
+ } catch {}
280
+ // Always advance _ts (success or failure) so the hot path backs off between refreshes.
281
+ try { writeFileSync(cacheFile, JSON.stringify({ _ts: Date.now(), policy, selfProtect, security, hookVersions })); } catch {}
282
+ } catch {}
283
+ }
284
+ if (REFRESH_MODE) { refreshPolicyCache().finally(() => process.exit(0)); }
285
+
251
286
  // ── Per-tool block/allow output ──
252
287
  // Response format depends on the agent:
253
288
  // Claude Code: exit 2 + stderr = BLOCK, exit 0 = ALLOW
@@ -1369,6 +1404,9 @@ function readReferencedFiles(args, cwd) {
1369
1404
 
1370
1405
  process.stdin.on('data', c => input += c);
1371
1406
  process.stdin.on('end', async () => {
1407
+ // Background-refresh invocation has no tool call to evaluate — it already ran
1408
+ // refreshPolicyCache() at startup; do nothing on the (empty) stdin.
1409
+ if (REFRESH_MODE) return;
1372
1410
  // No policy selected => no enforcement. A plain launch (no SOLONGATE_AGENT_ID)
1373
1411
  // is intentionally unrestricted.
1374
1412
  if (process.env.SOLONGATE_DEBUG) {
@@ -1466,32 +1504,31 @@ process.stdin.on('end', async () => {
1466
1504
  }
1467
1505
  }
1468
1506
  } catch {}
1469
- // Refresh from the API at most once per TTL window.
1507
+ // Cache stale → DO NOT block the tool on the network. Serve the last-known-good
1508
+ // policy right now (or the local policy.json fallback below if there is none)
1509
+ // and kick off a DETACHED background refresh that rewrites the cache for the
1510
+ // NEXT call. Stale-while-revalidate: tool calls stay instant (~ms), yet a
1511
+ // policy change still lands within ~one call, because the fetch runs in
1512
+ // parallel instead of on the hot path.
1470
1513
  if (refreshDue) {
1471
- try {
1472
- const res = await fetch(API_URL + '/api/v1/policies/active?agent_id=' + encodeURIComponent(AGENT_ID || '') + '&hv=' + HOOK_VERSION, { headers: AUTH_HEADERS, signal: AbortSignal.timeout(2000) });
1473
- if (res.ok) {
1474
- const body = await res.json();
1475
- if (typeof body?.self_protection_enabled === 'boolean') selfProtectEnabled = body.self_protection_enabled;
1476
- if (body?.security !== undefined) securityCfg = body.security;
1477
- if (body?.hook_versions && typeof body.hook_versions === 'object') CLOUD_HOOK_VERSIONS = body.hook_versions;
1478
- if (body && body.policy) dashboardPolicy = body.policy;
1479
- }
1480
- } catch {}
1481
- // API slow/unreachable → keep enforcing the last-known-good policy.
1482
1514
  if (!dashboardPolicy && staleCache) {
1483
1515
  dashboardPolicy = staleCache.policy;
1484
1516
  if (typeof staleCache.selfProtect === 'boolean') selfProtectEnabled = staleCache.selfProtect;
1485
1517
  if (staleCache.security !== undefined) securityCfg = staleCache.security;
1486
1518
  if (staleCache.hookVersions) CLOUD_HOOK_VERSIONS = staleCache.hookVersions;
1487
1519
  }
1488
- // ALWAYS advance _ts after an attempt success, stale fallback, or even a
1489
- // cold miss with no policy so a slow/down endpoint is retried at most
1490
- // once per TTL (≈2s per 10s worst case, not 2s per call). Keep the
1491
- // last-known-good policy in the cache so a failed attempt never erases it;
1492
- // when nothing is available, enforcement falls back to ~/.solongate/policy.json below.
1493
- const keepPolicy = dashboardPolicy || (staleCache && staleCache.policy) || null;
1494
- try { writeFileSync(policyCacheFile, JSON.stringify({ _ts: Date.now(), policy: keepPolicy, selfProtect: selfProtectEnabled, security: securityCfg, hookVersions: CLOUD_HOOK_VERSIONS })); } catch {}
1520
+ // Debounce: at most one background refresh in flight per ~3s, so a burst of
1521
+ // stale calls (or several agents sharing this cache) doesn't spawn a swarm.
1522
+ if (API_KEY) {
1523
+ try {
1524
+ const lock = join(resolve(homedir(), '.solongate'), '.policy-refresh-' + agentKey + '.lock');
1525
+ const due = !existsSync(lock) || (Date.now() - statSync(lock).mtimeMs) > 3000;
1526
+ if (due) {
1527
+ try { writeFileSync(lock, String(Date.now())); } catch {}
1528
+ spawn(process.execPath, [process.argv[1], AGENT_TYPE, AGENT_NAME, '--sg-refresh-policy'], { detached: true, stdio: 'ignore', env: process.env }).unref();
1529
+ }
1530
+ } catch {}
1531
+ }
1495
1532
  }
1496
1533
 
1497
1534
  if (process.env.SOLONGATE_DEBUG) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solongate/proxy",
3
- "version": "0.59.3",
3
+ "version": "0.59.4",
4
4
  "description": "AI tool security proxy: protect any AI tool server with customizable policies, path/command constraints, rate limiting, and audit logging. No code changes required.",
5
5
  "type": "module",
6
6
  "bin": {