@solongate/proxy 0.83.6 → 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.
- package/dist/index.js +74 -8
- package/dist/tui/index.js +74 -8
- package/dist/tui/local-log.d.ts +21 -0
- package/hooks/guard.bundled.mjs +47 -20
- package/hooks/guard.mjs +66 -14
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -7573,6 +7573,41 @@ import { closeSync, existsSync as existsSync4, openSync as openSync3, readdirSyn
|
|
|
7573
7573
|
import { homedir as homedir6 } from "os";
|
|
7574
7574
|
import { createHash as createHash2 } from "crypto";
|
|
7575
7575
|
import { isAbsolute, join as join8 } from "path";
|
|
7576
|
+
function localLogsSetting() {
|
|
7577
|
+
const off = { enabled: false, configuredPath: null, usableHere: true, file: DEFAULT_LOCAL_LOG };
|
|
7578
|
+
try {
|
|
7579
|
+
for (const cache of policyCachesNewestFirst()) {
|
|
7580
|
+
try {
|
|
7581
|
+
const c2 = JSON.parse(readFileSync8(cache, "utf-8"));
|
|
7582
|
+
const l = c2?.security?.localLogs;
|
|
7583
|
+
if (!l || typeof l.enabled !== "boolean") continue;
|
|
7584
|
+
if (!l.enabled) return { ...off, configuredPath: typeof l.path === "string" ? l.path.trim() || null : null };
|
|
7585
|
+
const raw = typeof l.path === "string" ? l.path.trim() : "";
|
|
7586
|
+
const dir = raw.replace(/[\\/]+$/, "");
|
|
7587
|
+
if (!dir) return { enabled: true, configuredPath: null, usableHere: true, file: DEFAULT_LOCAL_LOG };
|
|
7588
|
+
if (!isAbsolute(dir)) return { enabled: true, configuredPath: raw, usableHere: false, file: DEFAULT_LOCAL_LOG };
|
|
7589
|
+
const file = join8(dir, "solongate-audit.jsonl");
|
|
7590
|
+
const usable = existsSync4(file) || existsSync4(dir);
|
|
7591
|
+
return { enabled: true, configuredPath: raw, usableHere: usable, file: usable ? file : DEFAULT_LOCAL_LOG };
|
|
7592
|
+
} catch {
|
|
7593
|
+
}
|
|
7594
|
+
}
|
|
7595
|
+
} catch {
|
|
7596
|
+
}
|
|
7597
|
+
return off;
|
|
7598
|
+
}
|
|
7599
|
+
function policyCachesNewestFirst() {
|
|
7600
|
+
const sgDir = join8(homedir6(), ".solongate");
|
|
7601
|
+
return readdirSync(sgDir).filter((f) => f.startsWith(".policy-cache-") && f.endsWith(".json")).map((f) => {
|
|
7602
|
+
const p = join8(sgDir, f);
|
|
7603
|
+
let mtime = 0;
|
|
7604
|
+
try {
|
|
7605
|
+
mtime = statSync(p).mtimeMs;
|
|
7606
|
+
} catch {
|
|
7607
|
+
}
|
|
7608
|
+
return { p, mtime };
|
|
7609
|
+
}).sort((a, b) => b.mtime - a.mtime).map((x) => x.p);
|
|
7610
|
+
}
|
|
7576
7611
|
function localLogFile() {
|
|
7577
7612
|
try {
|
|
7578
7613
|
const sgDir = join8(homedir6(), ".solongate");
|
|
@@ -8498,6 +8533,7 @@ function LivePanel({ active: active2 }) {
|
|
|
8498
8533
|
const [cloudBuf, setCloudBuf] = useState2([]);
|
|
8499
8534
|
const [localBuf, setLocalBuf] = useState2([]);
|
|
8500
8535
|
const [localOn, setLocalOn] = useState2(null);
|
|
8536
|
+
const [localPath, setLocalPath] = useState2(null);
|
|
8501
8537
|
const [ring, setRing] = useState2(null);
|
|
8502
8538
|
const [log3, setLog] = useState2([]);
|
|
8503
8539
|
const [filter, setFilter] = useState2("all");
|
|
@@ -8552,12 +8588,11 @@ function LivePanel({ active: active2 }) {
|
|
|
8552
8588
|
const paused = () => frozenRef.current || Date.now() < pausedUntil.current;
|
|
8553
8589
|
const pollLocal = useCallback2(() => {
|
|
8554
8590
|
if (frozenRef.current) return;
|
|
8555
|
-
const
|
|
8556
|
-
|
|
8557
|
-
|
|
8558
|
-
|
|
8559
|
-
|
|
8560
|
-
setLocalOn(true);
|
|
8591
|
+
const setting = localLogsSetting();
|
|
8592
|
+
setLocalOn(setting.enabled);
|
|
8593
|
+
setLocalPath(setting);
|
|
8594
|
+
const lines = tailLines(setting.file);
|
|
8595
|
+
if (!lines.length) return;
|
|
8561
8596
|
const fresh = [];
|
|
8562
8597
|
for (const line of lines) {
|
|
8563
8598
|
try {
|
|
@@ -9369,7 +9404,17 @@ function LivePanel({ active: active2 }) {
|
|
|
9369
9404
|
searchRow,
|
|
9370
9405
|
localOn === false ? /* @__PURE__ */ jsxs2(Text2, { wrap: "truncate", children: [
|
|
9371
9406
|
/* @__PURE__ */ jsx2(Text2, { color: theme.warn, children: "local logs off" }),
|
|
9372
|
-
/* @__PURE__ */ jsx2(Text2, { color: theme.dim, children:
|
|
9407
|
+
/* @__PURE__ */ jsx2(Text2, { color: theme.dim, children: " \u2014 enable: dataroom \u2192 Settings (local logs) or dashboard \u2192 Settings" })
|
|
9408
|
+
] }) : localOn && localPath && !localPath.usableHere ? (
|
|
9409
|
+
// The folder is a PROJECT setting, so it can name a path that exists
|
|
9410
|
+
// on another machine. Saying so beats showing a path nothing writes to.
|
|
9411
|
+
/* @__PURE__ */ jsxs2(Text2, { wrap: "truncate", children: [
|
|
9412
|
+
/* @__PURE__ */ jsx2(Text2, { color: theme.warn, children: "local logs on" }),
|
|
9413
|
+
/* @__PURE__ */ jsx2(Text2, { color: theme.dim, children: ` \u2014 ${localPath.configuredPath} is not a folder on this device \xB7 writing to ${localPath.file}` })
|
|
9414
|
+
] })
|
|
9415
|
+
) : localOn && localBuf.length === 0 ? /* @__PURE__ */ jsxs2(Text2, { wrap: "truncate", children: [
|
|
9416
|
+
/* @__PURE__ */ jsx2(Text2, { color: theme.ok, children: "local logs on" }),
|
|
9417
|
+
/* @__PURE__ */ jsx2(Text2, { color: theme.dim, children: ` \u2014 no entries yet \xB7 hooks write ${localPath?.file ?? ""}` })
|
|
9373
9418
|
] }) : null,
|
|
9374
9419
|
windowed.length === 0 ? (
|
|
9375
9420
|
// With a source filter on, "awaiting traffic" reads as "nothing is
|
|
@@ -11573,6 +11618,18 @@ function SettingsPanel({
|
|
|
11573
11618
|
const [diagBusy, setDiagBusy] = useState8(null);
|
|
11574
11619
|
const [autoUp, setAutoUp] = useState8(() => autoUpdateEnabled());
|
|
11575
11620
|
const [updBusy, setUpdBusy] = useState8(false);
|
|
11621
|
+
const [llSetting, setLlSetting] = useState8(null);
|
|
11622
|
+
useEffect7(() => {
|
|
11623
|
+
const read = () => {
|
|
11624
|
+
try {
|
|
11625
|
+
setLlSetting(localLogsSetting());
|
|
11626
|
+
} catch {
|
|
11627
|
+
}
|
|
11628
|
+
};
|
|
11629
|
+
read();
|
|
11630
|
+
const t = setInterval(read, 5e3);
|
|
11631
|
+
return () => clearInterval(t);
|
|
11632
|
+
}, []);
|
|
11576
11633
|
const [canInstall, setCanInstall] = useState8(null);
|
|
11577
11634
|
useEffect7(() => {
|
|
11578
11635
|
let live2 = true;
|
|
@@ -12229,7 +12286,16 @@ function SettingsPanel({
|
|
|
12229
12286
|
return /* @__PURE__ */ jsxs8(Text8, { wrap: "truncate", children: [
|
|
12230
12287
|
cursor(r),
|
|
12231
12288
|
/* @__PURE__ */ jsx8(Text8, { color: theme.dim, children: "path ".padEnd(11) }),
|
|
12232
|
-
local?.path ?
|
|
12289
|
+
local?.path ? (
|
|
12290
|
+
// The folder is a PROJECT setting shared by every device on it, so
|
|
12291
|
+
// it can name a path that only exists on another OS. The hooks then
|
|
12292
|
+
// quietly fall back and this row was the last place still claiming
|
|
12293
|
+
// the configured path was in use.
|
|
12294
|
+
llSetting && local.enabled && !llSetting.usableHere ? /* @__PURE__ */ jsxs8(Fragment5, { children: [
|
|
12295
|
+
/* @__PURE__ */ jsx8(Text8, { color: theme.warn, children: truncate2(local.path, Math.max(12, Math.floor((cols - 20) / 2))) }),
|
|
12296
|
+
/* @__PURE__ */ jsx8(Text8, { color: theme.dim, children: ` not valid here \u2192 ${truncate2(llSetting.file, Math.max(12, Math.floor((cols - 20) / 2)))}` })
|
|
12297
|
+
] }) : /* @__PURE__ */ jsx8(Text8, { color: theme.accent, children: truncate2(local.path, cols - 14) })
|
|
12298
|
+
) : /* @__PURE__ */ jsx8(Text8, { color: theme.dim, children: "not set \u2014 enter to edit" })
|
|
12233
12299
|
] });
|
|
12234
12300
|
case "ll-server":
|
|
12235
12301
|
return /* @__PURE__ */ jsxs8(Text8, { wrap: "truncate", children: [
|
package/dist/tui/index.js
CHANGED
|
@@ -741,6 +741,41 @@ import { homedir as homedir2 } from "os";
|
|
|
741
741
|
import { createHash } from "crypto";
|
|
742
742
|
import { isAbsolute, join as join2 } from "path";
|
|
743
743
|
var DEFAULT_LOCAL_LOG = join2(homedir2(), ".solongate", "local-logs", "solongate-audit.jsonl");
|
|
744
|
+
function localLogsSetting() {
|
|
745
|
+
const off = { enabled: false, configuredPath: null, usableHere: true, file: DEFAULT_LOCAL_LOG };
|
|
746
|
+
try {
|
|
747
|
+
for (const cache of policyCachesNewestFirst()) {
|
|
748
|
+
try {
|
|
749
|
+
const c2 = JSON.parse(readFileSync2(cache, "utf-8"));
|
|
750
|
+
const l = c2?.security?.localLogs;
|
|
751
|
+
if (!l || typeof l.enabled !== "boolean") continue;
|
|
752
|
+
if (!l.enabled) return { ...off, configuredPath: typeof l.path === "string" ? l.path.trim() || null : null };
|
|
753
|
+
const raw = typeof l.path === "string" ? l.path.trim() : "";
|
|
754
|
+
const dir = raw.replace(/[\\/]+$/, "");
|
|
755
|
+
if (!dir) return { enabled: true, configuredPath: null, usableHere: true, file: DEFAULT_LOCAL_LOG };
|
|
756
|
+
if (!isAbsolute(dir)) return { enabled: true, configuredPath: raw, usableHere: false, file: DEFAULT_LOCAL_LOG };
|
|
757
|
+
const file = join2(dir, "solongate-audit.jsonl");
|
|
758
|
+
const usable = existsSync(file) || existsSync(dir);
|
|
759
|
+
return { enabled: true, configuredPath: raw, usableHere: usable, file: usable ? file : DEFAULT_LOCAL_LOG };
|
|
760
|
+
} catch {
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
} catch {
|
|
764
|
+
}
|
|
765
|
+
return off;
|
|
766
|
+
}
|
|
767
|
+
function policyCachesNewestFirst() {
|
|
768
|
+
const sgDir = join2(homedir2(), ".solongate");
|
|
769
|
+
return readdirSync(sgDir).filter((f) => f.startsWith(".policy-cache-") && f.endsWith(".json")).map((f) => {
|
|
770
|
+
const p = join2(sgDir, f);
|
|
771
|
+
let mtime = 0;
|
|
772
|
+
try {
|
|
773
|
+
mtime = statSync(p).mtimeMs;
|
|
774
|
+
} catch {
|
|
775
|
+
}
|
|
776
|
+
return { p, mtime };
|
|
777
|
+
}).sort((a, b) => b.mtime - a.mtime).map((x) => x.p);
|
|
778
|
+
}
|
|
744
779
|
function localLogFile() {
|
|
745
780
|
try {
|
|
746
781
|
const sgDir = join2(homedir2(), ".solongate");
|
|
@@ -1619,6 +1654,7 @@ function LivePanel({ active: active2 }) {
|
|
|
1619
1654
|
const [cloudBuf, setCloudBuf] = useState2([]);
|
|
1620
1655
|
const [localBuf, setLocalBuf] = useState2([]);
|
|
1621
1656
|
const [localOn, setLocalOn] = useState2(null);
|
|
1657
|
+
const [localPath, setLocalPath] = useState2(null);
|
|
1622
1658
|
const [ring, setRing] = useState2(null);
|
|
1623
1659
|
const [log, setLog] = useState2([]);
|
|
1624
1660
|
const [filter, setFilter] = useState2("all");
|
|
@@ -1673,12 +1709,11 @@ function LivePanel({ active: active2 }) {
|
|
|
1673
1709
|
const paused = () => frozenRef.current || Date.now() < pausedUntil.current;
|
|
1674
1710
|
const pollLocal = useCallback2(() => {
|
|
1675
1711
|
if (frozenRef.current) return;
|
|
1676
|
-
const
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
setLocalOn(true);
|
|
1712
|
+
const setting = localLogsSetting();
|
|
1713
|
+
setLocalOn(setting.enabled);
|
|
1714
|
+
setLocalPath(setting);
|
|
1715
|
+
const lines = tailLines(setting.file);
|
|
1716
|
+
if (!lines.length) return;
|
|
1682
1717
|
const fresh = [];
|
|
1683
1718
|
for (const line of lines) {
|
|
1684
1719
|
try {
|
|
@@ -2490,7 +2525,17 @@ function LivePanel({ active: active2 }) {
|
|
|
2490
2525
|
searchRow,
|
|
2491
2526
|
localOn === false ? /* @__PURE__ */ jsxs2(Text2, { wrap: "truncate", children: [
|
|
2492
2527
|
/* @__PURE__ */ jsx2(Text2, { color: theme.warn, children: "local logs off" }),
|
|
2493
|
-
/* @__PURE__ */ jsx2(Text2, { color: theme.dim, children:
|
|
2528
|
+
/* @__PURE__ */ jsx2(Text2, { color: theme.dim, children: " \u2014 enable: dataroom \u2192 Settings (local logs) or dashboard \u2192 Settings" })
|
|
2529
|
+
] }) : localOn && localPath && !localPath.usableHere ? (
|
|
2530
|
+
// The folder is a PROJECT setting, so it can name a path that exists
|
|
2531
|
+
// on another machine. Saying so beats showing a path nothing writes to.
|
|
2532
|
+
/* @__PURE__ */ jsxs2(Text2, { wrap: "truncate", children: [
|
|
2533
|
+
/* @__PURE__ */ jsx2(Text2, { color: theme.warn, children: "local logs on" }),
|
|
2534
|
+
/* @__PURE__ */ jsx2(Text2, { color: theme.dim, children: ` \u2014 ${localPath.configuredPath} is not a folder on this device \xB7 writing to ${localPath.file}` })
|
|
2535
|
+
] })
|
|
2536
|
+
) : localOn && localBuf.length === 0 ? /* @__PURE__ */ jsxs2(Text2, { wrap: "truncate", children: [
|
|
2537
|
+
/* @__PURE__ */ jsx2(Text2, { color: theme.ok, children: "local logs on" }),
|
|
2538
|
+
/* @__PURE__ */ jsx2(Text2, { color: theme.dim, children: ` \u2014 no entries yet \xB7 hooks write ${localPath?.file ?? ""}` })
|
|
2494
2539
|
] }) : null,
|
|
2495
2540
|
windowed.length === 0 ? (
|
|
2496
2541
|
// With a source filter on, "awaiting traffic" reads as "nothing is
|
|
@@ -4699,6 +4744,18 @@ function SettingsPanel({
|
|
|
4699
4744
|
const [diagBusy, setDiagBusy] = useState8(null);
|
|
4700
4745
|
const [autoUp, setAutoUp] = useState8(() => autoUpdateEnabled());
|
|
4701
4746
|
const [updBusy, setUpdBusy] = useState8(false);
|
|
4747
|
+
const [llSetting, setLlSetting] = useState8(null);
|
|
4748
|
+
useEffect7(() => {
|
|
4749
|
+
const read = () => {
|
|
4750
|
+
try {
|
|
4751
|
+
setLlSetting(localLogsSetting());
|
|
4752
|
+
} catch {
|
|
4753
|
+
}
|
|
4754
|
+
};
|
|
4755
|
+
read();
|
|
4756
|
+
const t = setInterval(read, 5e3);
|
|
4757
|
+
return () => clearInterval(t);
|
|
4758
|
+
}, []);
|
|
4702
4759
|
const [canInstall, setCanInstall] = useState8(null);
|
|
4703
4760
|
useEffect7(() => {
|
|
4704
4761
|
let live2 = true;
|
|
@@ -5355,7 +5412,16 @@ function SettingsPanel({
|
|
|
5355
5412
|
return /* @__PURE__ */ jsxs8(Text8, { wrap: "truncate", children: [
|
|
5356
5413
|
cursor(r),
|
|
5357
5414
|
/* @__PURE__ */ jsx8(Text8, { color: theme.dim, children: "path ".padEnd(11) }),
|
|
5358
|
-
local?.path ?
|
|
5415
|
+
local?.path ? (
|
|
5416
|
+
// The folder is a PROJECT setting shared by every device on it, so
|
|
5417
|
+
// it can name a path that only exists on another OS. The hooks then
|
|
5418
|
+
// quietly fall back and this row was the last place still claiming
|
|
5419
|
+
// the configured path was in use.
|
|
5420
|
+
llSetting && local.enabled && !llSetting.usableHere ? /* @__PURE__ */ jsxs8(Fragment5, { children: [
|
|
5421
|
+
/* @__PURE__ */ jsx8(Text8, { color: theme.warn, children: truncate(local.path, Math.max(12, Math.floor((cols - 20) / 2))) }),
|
|
5422
|
+
/* @__PURE__ */ jsx8(Text8, { color: theme.dim, children: ` not valid here \u2192 ${truncate(llSetting.file, Math.max(12, Math.floor((cols - 20) / 2)))}` })
|
|
5423
|
+
] }) : /* @__PURE__ */ jsx8(Text8, { color: theme.accent, children: truncate(local.path, cols - 14) })
|
|
5424
|
+
) : /* @__PURE__ */ jsx8(Text8, { color: theme.dim, children: "not set \u2014 enter to edit" })
|
|
5359
5425
|
] });
|
|
5360
5426
|
case "ll-server":
|
|
5361
5427
|
return /* @__PURE__ */ jsxs8(Text8, { wrap: "truncate", children: [
|
package/dist/tui/local-log.d.ts
CHANGED
|
@@ -16,6 +16,27 @@ export declare const DEFAULT_LOCAL_LOG: string;
|
|
|
16
16
|
* it the same way they do, preferring the most recently refreshed cache. Falls
|
|
17
17
|
* back to the default path when nothing is configured or readable.
|
|
18
18
|
*/
|
|
19
|
+
/** What local logging is set to on this device, and where it can actually go. */
|
|
20
|
+
export interface LocalLogSetting {
|
|
21
|
+
/** The project setting, as the hooks read it. */
|
|
22
|
+
enabled: boolean;
|
|
23
|
+
/** The folder the project asked for, verbatim (null when nothing is set). */
|
|
24
|
+
configuredPath: string | null;
|
|
25
|
+
/** False when that folder cannot be used HERE — e.g. a Windows path on Linux. */
|
|
26
|
+
usableHere: boolean;
|
|
27
|
+
/** The file entries actually land in, after any fallback. */
|
|
28
|
+
file: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Read the local-logging SETTING, not its output.
|
|
32
|
+
*
|
|
33
|
+
* Live used to decide on/off by whether the log file had any lines in it, so an
|
|
34
|
+
* enabled log that happened to be empty — a fresh install, a folder just
|
|
35
|
+
* changed, an account just switched — reported "local logs off" and told the
|
|
36
|
+
* user to go and enable something that was already on. The setting and the
|
|
37
|
+
* evidence of the setting are different questions; this answers the first.
|
|
38
|
+
*/
|
|
39
|
+
export declare function localLogsSetting(): LocalLogSetting;
|
|
19
40
|
export declare function localLogFile(): string;
|
|
20
41
|
/** Resolved once per process for callers that want a plain path (the folder can
|
|
21
42
|
* only change via a dashboard edit, which needs a new session anyway). Prefer
|
package/hooks/guard.bundled.mjs
CHANGED
|
@@ -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 =
|
|
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
|
|
7923
|
+
const dir = resolve(homedir(), ".solongate");
|
|
7924
|
+
const file = join(dir, ".ratelimit-" + agentKey + ".log");
|
|
7921
7925
|
const now = Date.now();
|
|
7922
|
-
|
|
7923
|
-
|
|
7924
|
-
|
|
7925
|
-
|
|
7926
|
-
|
|
7927
|
-
|
|
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
|
|
7963
|
+
if (count > limit)
|
|
7940
7964
|
return { window: w.label, limit };
|
|
7941
7965
|
}
|
|
7942
7966
|
}
|
|
7943
|
-
|
|
7944
|
-
|
|
7945
|
-
|
|
7946
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
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
|
-
|
|
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
|
|
1809
|
+
if (count > limit) return { window: w.label, limit };
|
|
1767
1810
|
}
|
|
1768
1811
|
}
|
|
1769
|
-
|
|
1770
|
-
|
|
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.
|
|
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": {
|