@solongate/proxy 0.83.7 → 0.83.8

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.
@@ -6530,13 +6530,13 @@ var init_src = __esm({
6530
6530
  });
6531
6531
 
6532
6532
  // hooks/guard.mjs
6533
- import { readFileSync, existsSync, statSync, readdirSync, writeFileSync, mkdirSync, chmodSync, renameSync, appendFileSync, rmSync } from "node:fs";
6533
+ import { readFileSync, existsSync, statSync, readdirSync, writeFileSync, mkdirSync, chmodSync, renameSync, appendFileSync, rmSync, openSync, readSync, closeSync } from "node:fs";
6534
6534
  import { spawn } from "node:child_process";
6535
6535
  import { resolve, join, dirname, isAbsolute } from "node:path";
6536
6536
  import { homedir } from "node:os";
6537
6537
  import { gunzipSync } from "node:zlib";
6538
6538
  import { createHash } from "node:crypto";
6539
- var HOOK_VERSION = 72;
6539
+ var HOOK_VERSION = 73;
6540
6540
  function localLogsOnly(security) {
6541
6541
  if (security !== void 0) {
6542
6542
  const l = security && security.localLogs;
@@ -7915,35 +7915,62 @@ var RL_WINDOWS = [
7915
7915
  { key: "perHour", ms: 36e5, label: "hour" },
7916
7916
  { key: "perMinute", ms: 6e4, label: "minute" }
7917
7917
  ];
7918
+ var RL_REC = 14;
7919
+ var RL_MAX_READ = 1048576;
7920
+ var RL_MAX_FILE = 4194304;
7918
7921
  function rateLimitCheck(agentKey, limits) {
7919
7922
  try {
7920
- const file = join(resolve(homedir(), ".solongate"), ".ratelimit-" + agentKey + ".json");
7923
+ const dir = resolve(homedir(), ".solongate");
7924
+ const file = join(dir, ".ratelimit-" + agentKey + ".log");
7921
7925
  const now = Date.now();
7922
- let stamps = [];
7923
- if (existsSync(file)) {
7924
- try {
7925
- stamps = JSON.parse(readFileSync(file, "utf-8"));
7926
- } catch {
7927
- stamps = [];
7928
- }
7926
+ try {
7927
+ mkdirSync(dir, { recursive: true });
7928
+ } catch {
7929
+ }
7930
+ try {
7931
+ appendFileSync(file, String(now).padStart(13, "0") + "\n");
7932
+ } catch {
7933
+ return null;
7934
+ }
7935
+ let size = 0;
7936
+ try {
7937
+ size = statSync(file).size;
7938
+ } catch {
7939
+ return null;
7940
+ }
7941
+ const start = Math.max(0, size - RL_MAX_READ);
7942
+ const from = start - start % RL_REC;
7943
+ let buf = "";
7944
+ try {
7945
+ const fd = openSync(file, "r");
7946
+ const b = Buffer.alloc(size - from);
7947
+ readSync(fd, b, 0, b.length, from);
7948
+ closeSync(fd);
7949
+ buf = b.toString("latin1");
7950
+ } catch {
7951
+ return null;
7952
+ }
7953
+ const stamps = [];
7954
+ for (let i = 0; i + RL_REC <= buf.length; i += RL_REC) {
7955
+ const t = parseInt(buf.slice(i, i + 13), 10);
7956
+ if (Number.isFinite(t) && now - t < 864e5)
7957
+ stamps.push(t);
7929
7958
  }
7930
- if (!Array.isArray(stamps))
7931
- stamps = [];
7932
- stamps = stamps.filter((t) => typeof t === "number" && now - t < 864e5);
7933
- if (stamps.length > 5e4)
7934
- stamps = stamps.slice(-5e4);
7935
7959
  for (const w of RL_WINDOWS) {
7936
7960
  const limit = limits[w.key];
7937
7961
  if (limit > 0) {
7938
7962
  const count = stamps.reduce((n, t) => now - t < w.ms ? n + 1 : n, 0);
7939
- if (count >= limit)
7963
+ if (count > limit)
7940
7964
  return { window: w.label, limit };
7941
7965
  }
7942
7966
  }
7943
- stamps.push(now);
7944
- try {
7945
- writeFileSync(file, JSON.stringify(stamps));
7946
- } catch {
7967
+ if (size > RL_MAX_FILE) {
7968
+ try {
7969
+ const tmp = file + "." + process.pid + ".tmp";
7970
+ writeFileSync(tmp, stamps.map((t) => String(t).padStart(13, "0") + "\n").join(""));
7971
+ renameSync(tmp, file);
7972
+ } catch {
7973
+ }
7947
7974
  }
7948
7975
  return null;
7949
7976
  } catch {
package/hooks/guard.mjs CHANGED
@@ -21,7 +21,7 @@
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, readdirSync, writeFileSync, mkdirSync, chmodSync, renameSync, appendFileSync, rmSync } from 'node:fs';
24
+ import { readFileSync, existsSync, statSync, readdirSync, writeFileSync, mkdirSync, chmodSync, renameSync, appendFileSync, rmSync, openSync, readSync, closeSync } from 'node:fs';
25
25
  import { spawn } from 'node:child_process';
26
26
  import { resolve, join, dirname, isAbsolute } from 'node:path';
27
27
  import { homedir } from 'node:os';
@@ -32,7 +32,7 @@ import { createHash } from 'node:crypto';
32
32
  // the installed hook self-updates when the cloud version is higher (see
33
33
  // maybeSelfUpdate). This is what makes guard fixes propagate without a manual
34
34
  // reinstall — the same trust model as the OPA WASM this hook already runs.
35
- const HOOK_VERSION = 72;
35
+ const HOOK_VERSION = 73;
36
36
 
37
37
  // True when local log storage is ON. In that mode logs are kept LOCAL ONLY and
38
38
  // nothing is sent to the cloud audit log.
@@ -1746,28 +1746,80 @@ const RL_WINDOWS = [
1746
1746
  { key: 'perHour', ms: 3600000, label: 'hour' },
1747
1747
  { key: 'perMinute', ms: 60000, label: 'minute' },
1748
1748
  ];
1749
+ // One call = one fixed-width record, appended. 13 digits of epoch ms + newline;
1750
+ // good until the year 2286, and fixed width is what lets the file be read by
1751
+ // offset without parsing it all.
1752
+ const RL_REC = 14;
1753
+ const RL_MAX_READ = 1_048_576; // tail we scan: ~74k calls, far past any window
1754
+ const RL_MAX_FILE = 4_194_304; // compact past this
1755
+
1756
+ /**
1757
+ * Rate limit, counted with an append-only log.
1758
+ *
1759
+ * This used to read a JSON array of timestamps, count, push one and write the
1760
+ * whole array back. Under a burst of parallel tool calls — the exact thing a
1761
+ * rate limit is for — every hook read the same array and wrote back its own
1762
+ * copy, so increments were lost and the limit did not hold: measured at 14
1763
+ * calls through a limit of 5, from 30 fired at once.
1764
+ *
1765
+ * An O_APPEND write of a short record does not interleave, so no call can erase
1766
+ * another's. The reservation is made BEFORE the decision, which is what makes
1767
+ * the count exact under concurrency: every process appends, then counts what is
1768
+ * in the window, and the ones past the limit are the ones refused. A refused
1769
+ * call therefore also occupies a slot, which is the honest reading of a rate
1770
+ * limit — thirty attempts in a minute IS thirty calls a minute, whatever came
1771
+ * back. (This is the same trick the guest allowance used for the same reason.)
1772
+ */
1749
1773
  function rateLimitCheck(agentKey, limits) {
1750
1774
  try {
1751
- const file = join(resolve(homedir(), '.solongate'), '.ratelimit-' + agentKey + '.json');
1775
+ const dir = resolve(homedir(), '.solongate');
1776
+ // New name: the old .json holds an array this format cannot read, and one
1777
+ // window resetting on upgrade is better than parsing ambiguity.
1778
+ const file = join(dir, '.ratelimit-' + agentKey + '.log');
1752
1779
  const now = Date.now();
1753
- let stamps = [];
1754
- if (existsSync(file)) {
1755
- try { stamps = JSON.parse(readFileSync(file, 'utf-8')); } catch { stamps = []; }
1780
+ try { mkdirSync(dir, { recursive: true }); } catch {}
1781
+ // Reserve first. A failed append must not hand out a free call, so treat it
1782
+ // as "cannot account for this" and fall open the same way the catch does.
1783
+ try { appendFileSync(file, String(now).padStart(13, '0') + '\n'); } catch { return null; }
1784
+
1785
+ let size = 0;
1786
+ try { size = statSync(file).size; } catch { return null; }
1787
+ const start = Math.max(0, size - RL_MAX_READ);
1788
+ // Align to a record boundary so a truncated head is never parsed.
1789
+ const from = start - (start % RL_REC);
1790
+ let buf = '';
1791
+ try {
1792
+ const fd = openSync(file, 'r');
1793
+ const b = Buffer.alloc(size - from);
1794
+ readSync(fd, b, 0, b.length, from);
1795
+ closeSync(fd);
1796
+ buf = b.toString('latin1');
1797
+ } catch { return null; }
1798
+
1799
+ const stamps = [];
1800
+ for (let i = 0; i + RL_REC <= buf.length; i += RL_REC) {
1801
+ const t = parseInt(buf.slice(i, i + 13), 10);
1802
+ if (Number.isFinite(t) && now - t < 86400000) stamps.push(t);
1756
1803
  }
1757
- if (!Array.isArray(stamps)) stamps = [];
1758
- // Prune to the longest window (24h) and cap size to bound work/IO.
1759
- stamps = stamps.filter((t) => typeof t === 'number' && now - t < 86400000);
1760
- if (stamps.length > 50000) stamps = stamps.slice(-50000);
1761
- // Check each enabled window against the current count (before adding now).
1804
+ // Our own record is in here, so the limit is crossed at > rather than >=.
1762
1805
  for (const w of RL_WINDOWS) {
1763
1806
  const limit = limits[w.key];
1764
1807
  if (limit > 0) {
1765
1808
  const count = stamps.reduce((n, t) => (now - t < w.ms ? n + 1 : n), 0);
1766
- if (count >= limit) return { window: w.label, limit };
1809
+ if (count > limit) return { window: w.label, limit };
1767
1810
  }
1768
1811
  }
1769
- stamps.push(now);
1770
- try { writeFileSync(file, JSON.stringify(stamps)); } catch {}
1812
+
1813
+ // Compaction, rarely: keep the last 24h. Written beside and renamed, so a
1814
+ // reader never sees a half-written file. An append landing during the swap
1815
+ // is lost, which costs one call of accuracy on a file this size.
1816
+ if (size > RL_MAX_FILE) {
1817
+ try {
1818
+ const tmp = file + '.' + process.pid + '.tmp';
1819
+ writeFileSync(tmp, stamps.map((t) => String(t).padStart(13, '0') + '\n').join(''));
1820
+ renameSync(tmp, file);
1821
+ } catch { /* keep growing rather than risk the file */ }
1822
+ }
1771
1823
  return null;
1772
1824
  } catch {
1773
1825
  return null; // fail open
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solongate/proxy",
3
- "version": "0.83.7",
3
+ "version": "0.83.8",
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": {