@solongate/proxy 0.83.7 → 0.83.9
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 +0 -6
- package/dist/tui/index.js +0 -6
- package/hooks/audit.mjs +8 -6
- package/hooks/guard.bundled.mjs +78 -59
- package/hooks/guard.mjs +97 -32
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -12363,12 +12363,6 @@ function SettingsPanel({
|
|
|
12363
12363
|
] }, "h:" + sec)
|
|
12364
12364
|
);
|
|
12365
12365
|
lineKey.push("");
|
|
12366
|
-
if ((sec === "WEBHOOKS" || sec === "ALERTS") && local?.enabled) {
|
|
12367
|
-
lineEls.push(
|
|
12368
|
-
/* @__PURE__ */ jsx8(Text8, { wrap: "truncate", color: theme.warn, children: ` \u26A0 local logs on \u2014 real denials stay on this machine, so ${sec.toLowerCase()} do NOT fire${sec === "WEBHOOKS" ? " (t test still works)" : ""}` }, "warn:" + sec)
|
|
12369
|
-
);
|
|
12370
|
-
lineKey.push("");
|
|
12371
|
-
}
|
|
12372
12366
|
}
|
|
12373
12367
|
lineEls.push(/* @__PURE__ */ jsx8(Box8, { children: rowLine(r) }, keyOf(r)));
|
|
12374
12368
|
lineKey.push(keyOf(r));
|
package/dist/tui/index.js
CHANGED
|
@@ -5489,12 +5489,6 @@ function SettingsPanel({
|
|
|
5489
5489
|
] }, "h:" + sec)
|
|
5490
5490
|
);
|
|
5491
5491
|
lineKey.push("");
|
|
5492
|
-
if ((sec === "WEBHOOKS" || sec === "ALERTS") && local?.enabled) {
|
|
5493
|
-
lineEls.push(
|
|
5494
|
-
/* @__PURE__ */ jsx8(Text8, { wrap: "truncate", color: theme.warn, children: ` \u26A0 local logs on \u2014 real denials stay on this machine, so ${sec.toLowerCase()} do NOT fire${sec === "WEBHOOKS" ? " (t test still works)" : ""}` }, "warn:" + sec)
|
|
5495
|
-
);
|
|
5496
|
-
lineKey.push("");
|
|
5497
|
-
}
|
|
5498
5492
|
}
|
|
5499
5493
|
lineEls.push(/* @__PURE__ */ jsx8(Box8, { children: rowLine(r) }, keyOf(r)));
|
|
5500
5494
|
lineKey.push(keyOf(r));
|
package/hooks/audit.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import { homedir } from 'node:os';
|
|
|
11
11
|
// Bump on every audit hook change. The cloud serves the newest version; the guard
|
|
12
12
|
// hook installs it on its next run (no re-login needed). See guard.mjs
|
|
13
13
|
// fetchAndInstallHook / maybeSelfUpdate.
|
|
14
|
-
const HOOK_VERSION =
|
|
14
|
+
const HOOK_VERSION = 25;
|
|
15
15
|
|
|
16
16
|
function loadEnvKey(dir) {
|
|
17
17
|
try {
|
|
@@ -748,8 +748,11 @@ try { input += readFileSync(0, 'utf-8'); } catch {}
|
|
|
748
748
|
try { rateLimitBurst = rateLimitObserveBurst(AGENT_ID, loadRateLimitObserve()); } catch { rateLimitBurst = false; }
|
|
749
749
|
}
|
|
750
750
|
|
|
751
|
-
// Local log storage (opt-in):
|
|
752
|
-
//
|
|
751
|
+
// Local log storage (opt-in) MIRRORS: the entry is appended to the user's
|
|
752
|
+
// chosen file AND still sent to the cloud. It used to divert instead, so a
|
|
753
|
+
// project with local logs on saw an empty dashboard with nothing to say the
|
|
754
|
+
// emptiness was deliberate — while the dataroom called the same setting
|
|
755
|
+
// "mirror every decision to a file".
|
|
753
756
|
const localLogs = loadLocalLogs();
|
|
754
757
|
if (localLogs) {
|
|
755
758
|
appendLocalLog(localLogs, {
|
|
@@ -759,9 +762,8 @@ try { input += readFileSync(0, 'utf-8'); } catch {}
|
|
|
759
762
|
...(dlpMatches.length ? { dlp: dlpMatches } : {}),
|
|
760
763
|
...(rateLimitBurst ? { rate_limit_burst: true } : {}),
|
|
761
764
|
});
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
} else {
|
|
765
|
+
}
|
|
766
|
+
{
|
|
765
767
|
// Fire-and-forget: don't block tool execution waiting for API response
|
|
766
768
|
fetch(`${API_URL}/api/v1/audit-logs`, {
|
|
767
769
|
method: 'POST',
|
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 = 74;
|
|
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 {
|
|
@@ -8401,13 +8428,12 @@ if (!REFRESH_MODE) {
|
|
|
8401
8428
|
} catch {
|
|
8402
8429
|
}
|
|
8403
8430
|
try {
|
|
8404
|
-
|
|
8405
|
-
|
|
8406
|
-
|
|
8407
|
-
|
|
8408
|
-
|
|
8409
|
-
|
|
8410
|
-
});
|
|
8431
|
+
await fetch(API_URL + "/api/v1/audit-logs", {
|
|
8432
|
+
method: "POST",
|
|
8433
|
+
headers: { "Content-Type": "application/json", ...AUTH_HEADERS },
|
|
8434
|
+
body: JSON.stringify(_logEntry),
|
|
8435
|
+
signal: AbortSignal.timeout(3e3)
|
|
8436
|
+
});
|
|
8411
8437
|
} catch {
|
|
8412
8438
|
}
|
|
8413
8439
|
if (AGENT_TYPE !== "codex")
|
|
@@ -8434,7 +8460,7 @@ if (!REFRESH_MODE) {
|
|
|
8434
8460
|
try {
|
|
8435
8461
|
if (existsSync(policyCacheFile)) {
|
|
8436
8462
|
const cached = JSON.parse(readFileSync(policyCacheFile, "utf-8"));
|
|
8437
|
-
if (cached
|
|
8463
|
+
if (cached)
|
|
8438
8464
|
staleCache = cached;
|
|
8439
8465
|
if (cached && cached._ts && Date.now() - cached._ts < POLICY_TTL_MS) {
|
|
8440
8466
|
refreshDue = false;
|
|
@@ -8519,35 +8545,31 @@ if (!REFRESH_MODE) {
|
|
|
8519
8545
|
}
|
|
8520
8546
|
writeLocalLog(securityCfg, { ts: (/* @__PURE__ */ new Date()).toISOString(), tool: toolName, arguments: args, decision: "DENY", reason: "ghost path (hidden from agent)", permission: guessPermission(toolName), source: `${AGENT_TYPE}-guard`, agent_id: AGENT_TYPE, agent_name: AGENT_NAME, session_id: call.sessionId, evaluation_time_ms: Date.now() - _evalStart });
|
|
8521
8547
|
try {
|
|
8522
|
-
|
|
8523
|
-
|
|
8524
|
-
|
|
8525
|
-
|
|
8526
|
-
|
|
8527
|
-
|
|
8528
|
-
|
|
8529
|
-
|
|
8530
|
-
|
|
8531
|
-
|
|
8532
|
-
|
|
8533
|
-
|
|
8534
|
-
|
|
8535
|
-
|
|
8536
|
-
|
|
8537
|
-
|
|
8538
|
-
|
|
8539
|
-
});
|
|
8548
|
+
await fetch(API_URL + "/api/v1/audit-logs", {
|
|
8549
|
+
method: "POST",
|
|
8550
|
+
headers: { "Content-Type": "application/json", ...AUTH_HEADERS },
|
|
8551
|
+
body: JSON.stringify({
|
|
8552
|
+
tool: toolName,
|
|
8553
|
+
arguments: args,
|
|
8554
|
+
decision: "DENY",
|
|
8555
|
+
reason: "ghost path (hidden from agent)",
|
|
8556
|
+
permission: guessPermission(toolName),
|
|
8557
|
+
source: `${AGENT_TYPE}-guard`,
|
|
8558
|
+
agent_id: AGENT_TYPE,
|
|
8559
|
+
agent_name: AGENT_NAME,
|
|
8560
|
+
session_id: call.sessionId,
|
|
8561
|
+
evaluation_time_ms: Date.now() - _evalStart
|
|
8562
|
+
}),
|
|
8563
|
+
signal: AbortSignal.timeout(3e3)
|
|
8564
|
+
});
|
|
8540
8565
|
} catch {
|
|
8541
8566
|
}
|
|
8542
|
-
await maybeSelfUpdate();
|
|
8543
8567
|
blockTool(ghostHit, true);
|
|
8544
8568
|
}
|
|
8545
8569
|
if (toolName === "Bash" || toolName === "run_command" || guessPermission(toolName) === "EXECUTE") {
|
|
8546
8570
|
const rw = ghostListingRewrite(args, securityCfg.ghost);
|
|
8547
|
-
if (rw)
|
|
8548
|
-
await maybeSelfUpdate();
|
|
8571
|
+
if (rw)
|
|
8549
8572
|
rewriteTool({ command: rw });
|
|
8550
|
-
}
|
|
8551
8573
|
}
|
|
8552
8574
|
}
|
|
8553
8575
|
if (!reason)
|
|
@@ -8581,7 +8603,6 @@ if (!REFRESH_MODE) {
|
|
|
8581
8603
|
reason = "Security layer (DLP): reading a file that contains a secret is blocked. Blocked by SolonGate.";
|
|
8582
8604
|
opaRoute = "black";
|
|
8583
8605
|
} else if (plan && plan.rewrite) {
|
|
8584
|
-
await maybeSelfUpdate();
|
|
8585
8606
|
rewriteTool(plan.rewrite);
|
|
8586
8607
|
}
|
|
8587
8608
|
}
|
|
@@ -8621,18 +8642,16 @@ if (!REFRESH_MODE) {
|
|
|
8621
8642
|
evaluation_time_ms: Date.now() - _evalStart
|
|
8622
8643
|
};
|
|
8623
8644
|
writeLocalLog(securityCfg, { ts: (/* @__PURE__ */ new Date()).toISOString(), ...logEntry });
|
|
8624
|
-
|
|
8625
|
-
|
|
8626
|
-
|
|
8627
|
-
|
|
8628
|
-
|
|
8629
|
-
|
|
8630
|
-
});
|
|
8645
|
+
await fetch(API_URL + "/api/v1/audit-logs", {
|
|
8646
|
+
method: "POST",
|
|
8647
|
+
headers: { "Content-Type": "application/json", ...AUTH_HEADERS },
|
|
8648
|
+
body: JSON.stringify(logEntry),
|
|
8649
|
+
signal: AbortSignal.timeout(3e3)
|
|
8650
|
+
});
|
|
8631
8651
|
} catch {
|
|
8632
8652
|
}
|
|
8633
8653
|
}
|
|
8634
8654
|
writeDenyFlag(toolName);
|
|
8635
|
-
await maybeSelfUpdate();
|
|
8636
8655
|
blockTool(reason);
|
|
8637
8656
|
}
|
|
8638
8657
|
} 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,10 +32,13 @@ 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 = 74;
|
|
36
36
|
|
|
37
|
-
// True when local log storage is ON
|
|
38
|
-
//
|
|
37
|
+
// True when local log storage is ON — meaning a copy is written to this
|
|
38
|
+
// machine. It is a MIRROR, not a diversion: the cloud audit log is written
|
|
39
|
+
// either way. The dataroom always described it as "mirror every decision to a
|
|
40
|
+
// file"; the code diverted instead, so a project with local logs on had nothing
|
|
41
|
+
// in the dashboard and no way to tell that was deliberate.
|
|
39
42
|
//
|
|
40
43
|
// `undefined` is the ONLY value that means "we do not know yet". `null` is an
|
|
41
44
|
// answer: it is what the refresh writes when the API returns no security block,
|
|
@@ -125,14 +128,13 @@ function writeLocalLog(security, entry) {
|
|
|
125
128
|
const mark = accountMark();
|
|
126
129
|
if (mark) entry = { ...entry, acct: mark };
|
|
127
130
|
const l = security && security.localLogs;
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
// never disappear, so fall back to the per-device default folder and keep it.
|
|
131
|
+
// No usable folder in the resolved config. The answer can still be "local is
|
|
132
|
+
// on" from the persisted marker alone, i.e. WITHOUT a resolved config (empty
|
|
133
|
+
// or cold policy cache, or a refresh that failed while the key was being
|
|
134
|
+
// rotated) — in which case keep the copy in the per-device default folder
|
|
135
|
+
// rather than dropping it.
|
|
134
136
|
if (!l || !l.enabled || typeof l.path !== 'string' || !l.path.trim()) {
|
|
135
|
-
if (!localLogsOnly(security)) return; //
|
|
137
|
+
if (!localLogsOnly(security)) return; // local logging is off — cloud only
|
|
136
138
|
const fallbackDir = resolve(homedir(), '.solongate', 'local-logs');
|
|
137
139
|
const fallbackLine = JSON.stringify(entry) + '\n';
|
|
138
140
|
const fallbackPayload = Buffer.from(JSON.stringify({ dir: fallbackDir, line: fallbackLine }), 'utf-8').toString('base64');
|
|
@@ -1746,28 +1748,80 @@ const RL_WINDOWS = [
|
|
|
1746
1748
|
{ key: 'perHour', ms: 3600000, label: 'hour' },
|
|
1747
1749
|
{ key: 'perMinute', ms: 60000, label: 'minute' },
|
|
1748
1750
|
];
|
|
1751
|
+
// One call = one fixed-width record, appended. 13 digits of epoch ms + newline;
|
|
1752
|
+
// good until the year 2286, and fixed width is what lets the file be read by
|
|
1753
|
+
// offset without parsing it all.
|
|
1754
|
+
const RL_REC = 14;
|
|
1755
|
+
const RL_MAX_READ = 1_048_576; // tail we scan: ~74k calls, far past any window
|
|
1756
|
+
const RL_MAX_FILE = 4_194_304; // compact past this
|
|
1757
|
+
|
|
1758
|
+
/**
|
|
1759
|
+
* Rate limit, counted with an append-only log.
|
|
1760
|
+
*
|
|
1761
|
+
* This used to read a JSON array of timestamps, count, push one and write the
|
|
1762
|
+
* whole array back. Under a burst of parallel tool calls — the exact thing a
|
|
1763
|
+
* rate limit is for — every hook read the same array and wrote back its own
|
|
1764
|
+
* copy, so increments were lost and the limit did not hold: measured at 14
|
|
1765
|
+
* calls through a limit of 5, from 30 fired at once.
|
|
1766
|
+
*
|
|
1767
|
+
* An O_APPEND write of a short record does not interleave, so no call can erase
|
|
1768
|
+
* another's. The reservation is made BEFORE the decision, which is what makes
|
|
1769
|
+
* the count exact under concurrency: every process appends, then counts what is
|
|
1770
|
+
* in the window, and the ones past the limit are the ones refused. A refused
|
|
1771
|
+
* call therefore also occupies a slot, which is the honest reading of a rate
|
|
1772
|
+
* limit — thirty attempts in a minute IS thirty calls a minute, whatever came
|
|
1773
|
+
* back. (This is the same trick the guest allowance used for the same reason.)
|
|
1774
|
+
*/
|
|
1749
1775
|
function rateLimitCheck(agentKey, limits) {
|
|
1750
1776
|
try {
|
|
1751
|
-
const
|
|
1777
|
+
const dir = resolve(homedir(), '.solongate');
|
|
1778
|
+
// New name: the old .json holds an array this format cannot read, and one
|
|
1779
|
+
// window resetting on upgrade is better than parsing ambiguity.
|
|
1780
|
+
const file = join(dir, '.ratelimit-' + agentKey + '.log');
|
|
1752
1781
|
const now = Date.now();
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1782
|
+
try { mkdirSync(dir, { recursive: true }); } catch {}
|
|
1783
|
+
// Reserve first. A failed append must not hand out a free call, so treat it
|
|
1784
|
+
// as "cannot account for this" and fall open the same way the catch does.
|
|
1785
|
+
try { appendFileSync(file, String(now).padStart(13, '0') + '\n'); } catch { return null; }
|
|
1786
|
+
|
|
1787
|
+
let size = 0;
|
|
1788
|
+
try { size = statSync(file).size; } catch { return null; }
|
|
1789
|
+
const start = Math.max(0, size - RL_MAX_READ);
|
|
1790
|
+
// Align to a record boundary so a truncated head is never parsed.
|
|
1791
|
+
const from = start - (start % RL_REC);
|
|
1792
|
+
let buf = '';
|
|
1793
|
+
try {
|
|
1794
|
+
const fd = openSync(file, 'r');
|
|
1795
|
+
const b = Buffer.alloc(size - from);
|
|
1796
|
+
readSync(fd, b, 0, b.length, from);
|
|
1797
|
+
closeSync(fd);
|
|
1798
|
+
buf = b.toString('latin1');
|
|
1799
|
+
} catch { return null; }
|
|
1800
|
+
|
|
1801
|
+
const stamps = [];
|
|
1802
|
+
for (let i = 0; i + RL_REC <= buf.length; i += RL_REC) {
|
|
1803
|
+
const t = parseInt(buf.slice(i, i + 13), 10);
|
|
1804
|
+
if (Number.isFinite(t) && now - t < 86400000) stamps.push(t);
|
|
1756
1805
|
}
|
|
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).
|
|
1806
|
+
// Our own record is in here, so the limit is crossed at > rather than >=.
|
|
1762
1807
|
for (const w of RL_WINDOWS) {
|
|
1763
1808
|
const limit = limits[w.key];
|
|
1764
1809
|
if (limit > 0) {
|
|
1765
1810
|
const count = stamps.reduce((n, t) => (now - t < w.ms ? n + 1 : n), 0);
|
|
1766
|
-
if (count
|
|
1811
|
+
if (count > limit) return { window: w.label, limit };
|
|
1767
1812
|
}
|
|
1768
1813
|
}
|
|
1769
|
-
|
|
1770
|
-
|
|
1814
|
+
|
|
1815
|
+
// Compaction, rarely: keep the last 24h. Written beside and renamed, so a
|
|
1816
|
+
// reader never sees a half-written file. An append landing during the swap
|
|
1817
|
+
// is lost, which costs one call of accuracy on a file this size.
|
|
1818
|
+
if (size > RL_MAX_FILE) {
|
|
1819
|
+
try {
|
|
1820
|
+
const tmp = file + '.' + process.pid + '.tmp';
|
|
1821
|
+
writeFileSync(tmp, stamps.map((t) => String(t).padStart(13, '0') + '\n').join(''));
|
|
1822
|
+
renameSync(tmp, file);
|
|
1823
|
+
} catch { /* keep growing rather than risk the file */ }
|
|
1824
|
+
}
|
|
1771
1825
|
return null;
|
|
1772
1826
|
} catch {
|
|
1773
1827
|
return null; // fail open
|
|
@@ -2372,7 +2426,7 @@ if (!REFRESH_MODE) { try { input += readFileSync(0, 'utf-8'); } catch {} }
|
|
|
2372
2426
|
};
|
|
2373
2427
|
try { writeLocalLog(_sec, { ts: new Date().toISOString(), ..._logEntry }); } catch {}
|
|
2374
2428
|
try {
|
|
2375
|
-
|
|
2429
|
+
await fetch(API_URL + '/api/v1/audit-logs', {
|
|
2376
2430
|
method: 'POST',
|
|
2377
2431
|
headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
|
|
2378
2432
|
body: JSON.stringify(_logEntry),
|
|
@@ -2432,7 +2486,13 @@ if (!REFRESH_MODE) { try { input += readFileSync(0, 'utf-8'); } catch {} }
|
|
|
2432
2486
|
try {
|
|
2433
2487
|
if (existsSync(policyCacheFile)) {
|
|
2434
2488
|
const cached = JSON.parse(readFileSync(policyCacheFile, 'utf-8'));
|
|
2435
|
-
|
|
2489
|
+
// Kept whenever it parses, NOT only when it carries a policy. The
|
|
2490
|
+
// security layers (DLP, rate limit, ghost) are configured separately
|
|
2491
|
+
// from the policy, so gating the last-known-good on a policy existing
|
|
2492
|
+
// meant a project with DLP on and no policy lost DLP entirely the
|
|
2493
|
+
// moment this cache went past its 10s TTL — which is almost always,
|
|
2494
|
+
// since it only refreshes on activity.
|
|
2495
|
+
if (cached) staleCache = cached;
|
|
2436
2496
|
if (cached && cached._ts && Date.now() - cached._ts < POLICY_TTL_MS) {
|
|
2437
2497
|
refreshDue = false;
|
|
2438
2498
|
if (cached.policy) dashboardPolicy = cached.policy;
|
|
@@ -2519,7 +2579,7 @@ if (!REFRESH_MODE) { try { input += readFileSync(0, 'utf-8'); } catch {} }
|
|
|
2519
2579
|
try { writeDenyFlag(toolName); } catch {}
|
|
2520
2580
|
writeLocalLog(securityCfg, { ts: new Date().toISOString(), tool: toolName, arguments: args, decision: 'DENY', reason: 'ghost path (hidden from agent)', permission: guessPermission(toolName), source: `${AGENT_TYPE}-guard`, agent_id: AGENT_TYPE, agent_name: AGENT_NAME, session_id: call.sessionId, evaluation_time_ms: Date.now() - _evalStart });
|
|
2521
2581
|
try {
|
|
2522
|
-
|
|
2582
|
+
await fetch(API_URL + '/api/v1/audit-logs', {
|
|
2523
2583
|
method: 'POST',
|
|
2524
2584
|
headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
|
|
2525
2585
|
body: JSON.stringify({
|
|
@@ -2533,7 +2593,7 @@ if (!REFRESH_MODE) { try { input += readFileSync(0, 'utf-8'); } catch {} }
|
|
|
2533
2593
|
signal: AbortSignal.timeout(3000),
|
|
2534
2594
|
});
|
|
2535
2595
|
} catch {}
|
|
2536
|
-
|
|
2596
|
+
// NOT maybeSelfUpdate() first: see the deny at the end of this flow.
|
|
2537
2597
|
// Stealth deny: ghostHit is a bare "No such file or directory", and the
|
|
2538
2598
|
// stealth flag keeps the adapter from adding any SolonGate branding, so
|
|
2539
2599
|
// the path looks like it simply is not there on every client.
|
|
@@ -2545,7 +2605,7 @@ if (!REFRESH_MODE) { try { input += readFileSync(0, 'utf-8'); } catch {} }
|
|
|
2545
2605
|
// PreToolUse `overwrite` replaces the command before it runs (hooks.md).
|
|
2546
2606
|
if (toolName === 'Bash' || toolName === 'run_command' || guessPermission(toolName) === 'EXECUTE') {
|
|
2547
2607
|
const rw = ghostListingRewrite(args, securityCfg.ghost);
|
|
2548
|
-
if (rw)
|
|
2608
|
+
if (rw) rewriteTool({ command: rw }); // verdict first, update later
|
|
2549
2609
|
}
|
|
2550
2610
|
}
|
|
2551
2611
|
// Extra security layers run after tamper, before policy. Block reason wins
|
|
@@ -2615,8 +2675,7 @@ if (!REFRESH_MODE) { try { input += readFileSync(0, 'utf-8'); } catch {} }
|
|
|
2615
2675
|
reason = 'Security layer (DLP): reading a file that contains a secret is blocked. Blocked by SolonGate.';
|
|
2616
2676
|
opaRoute = 'black';
|
|
2617
2677
|
} else if (plan && plan.rewrite) {
|
|
2618
|
-
|
|
2619
|
-
rewriteTool(plan.rewrite);
|
|
2678
|
+
rewriteTool(plan.rewrite); // verdict first, update later
|
|
2620
2679
|
}
|
|
2621
2680
|
}
|
|
2622
2681
|
|
|
@@ -2662,7 +2721,7 @@ if (!REFRESH_MODE) { try { input += readFileSync(0, 'utf-8'); } catch {} }
|
|
|
2662
2721
|
writeLocalLog(securityCfg, { ts: new Date().toISOString(), ...logEntry });
|
|
2663
2722
|
// PI hook layer removed — piResult fields no longer attached.
|
|
2664
2723
|
// Local-only mode: keep the log on the user's machine, skip the cloud.
|
|
2665
|
-
|
|
2724
|
+
await fetch(API_URL + '/api/v1/audit-logs', {
|
|
2666
2725
|
method: 'POST',
|
|
2667
2726
|
headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
|
|
2668
2727
|
body: JSON.stringify(logEntry),
|
|
@@ -2671,7 +2730,13 @@ if (!REFRESH_MODE) { try { input += readFileSync(0, 'utf-8'); } catch {} }
|
|
|
2671
2730
|
} catch {}
|
|
2672
2731
|
}
|
|
2673
2732
|
writeDenyFlag(toolName);
|
|
2674
|
-
|
|
2733
|
+
// The verdict is emitted BEFORE the hook self-update, never after.
|
|
2734
|
+
// maybeSelfUpdate() fetches three hooks at up to 5s each, and the
|
|
2735
|
+
// whole process carries an 8s backstop that force-exits with
|
|
2736
|
+
// `process.exitCode || 0` — i.e. ALLOW. So a slow network on the update
|
|
2737
|
+
// path turned a decided DENY into a permitted call. Measured: 8043ms,
|
|
2738
|
+
// exit 0, on a call DLP had already refused. Updating is a background
|
|
2739
|
+
// nicety; it still runs on every allow, which is nearly every call.
|
|
2675
2740
|
blockTool(reason);
|
|
2676
2741
|
}
|
|
2677
2742
|
} catch {}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solongate/proxy",
|
|
3
|
-
"version": "0.83.
|
|
3
|
+
"version": "0.83.9",
|
|
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": {
|