@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 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 = 24;
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): when ON, logs are kept LOCAL ONLY — we append
752
- // this entry to the user's chosen file and do NOT send it to the cloud.
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
- fetchDone = true;
763
- maybeExit();
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',
@@ -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 = 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 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 {
@@ -8401,13 +8428,12 @@ if (!REFRESH_MODE) {
8401
8428
  } catch {
8402
8429
  }
8403
8430
  try {
8404
- if (!localLogsOnly(_sec))
8405
- await fetch(API_URL + "/api/v1/audit-logs", {
8406
- method: "POST",
8407
- headers: { "Content-Type": "application/json", ...AUTH_HEADERS },
8408
- body: JSON.stringify(_logEntry),
8409
- signal: AbortSignal.timeout(3e3)
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 && cached.policy)
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
- if (!localLogsOnly(securityCfg))
8523
- await fetch(API_URL + "/api/v1/audit-logs", {
8524
- method: "POST",
8525
- headers: { "Content-Type": "application/json", ...AUTH_HEADERS },
8526
- body: JSON.stringify({
8527
- tool: toolName,
8528
- arguments: args,
8529
- decision: "DENY",
8530
- reason: "ghost path (hidden from agent)",
8531
- permission: guessPermission(toolName),
8532
- source: `${AGENT_TYPE}-guard`,
8533
- agent_id: AGENT_TYPE,
8534
- agent_name: AGENT_NAME,
8535
- session_id: call.sessionId,
8536
- evaluation_time_ms: Date.now() - _evalStart
8537
- }),
8538
- signal: AbortSignal.timeout(3e3)
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
- if (!localLogsOnly(securityCfg))
8625
- await fetch(API_URL + "/api/v1/audit-logs", {
8626
- method: "POST",
8627
- headers: { "Content-Type": "application/json", ...AUTH_HEADERS },
8628
- body: JSON.stringify(logEntry),
8629
- signal: AbortSignal.timeout(3e3)
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 = 72;
35
+ const HOOK_VERSION = 74;
36
36
 
37
- // True when local log storage is ON. In that mode logs are kept LOCAL ONLY and
38
- // nothing is sent to the cloud audit log.
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
- // The cloud POST is skipped whenever localLogsOnly() says this device is in
129
- // local-only mode and that answer can come from the persisted marker alone,
130
- // i.e. WITHOUT a resolved config (empty/cold policy cache, or a refresh that
131
- // failed while the key was being rotated). In that state this function used
132
- // to return early: no cloud write, no local write, entry gone. A denial must
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; // cloud is handling it nothing to do
137
+ if (!localLogsOnly(security)) return; // local logging is offcloud 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 file = join(resolve(homedir(), '.solongate'), '.ratelimit-' + agentKey + '.json');
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
- let stamps = [];
1754
- if (existsSync(file)) {
1755
- try { stamps = JSON.parse(readFileSync(file, 'utf-8')); } catch { stamps = []; }
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
- 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).
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 >= limit) return { window: w.label, limit };
1811
+ if (count > limit) return { window: w.label, limit };
1767
1812
  }
1768
1813
  }
1769
- stamps.push(now);
1770
- try { writeFileSync(file, JSON.stringify(stamps)); } catch {}
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
- if (!localLogsOnly(_sec)) await fetch(API_URL + '/api/v1/audit-logs', {
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
- if (cached && cached.policy) staleCache = cached;
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
- if (!localLogsOnly(securityCfg)) await fetch(API_URL + '/api/v1/audit-logs', {
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
- await maybeSelfUpdate();
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) { await maybeSelfUpdate(); rewriteTool({ command: 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
- await maybeSelfUpdate();
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
- if (!localLogsOnly(securityCfg)) await fetch(API_URL + '/api/v1/audit-logs', {
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
- await maybeSelfUpdate();
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.7",
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": {