@solongate/proxy 0.83.8 → 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',
@@ -6536,7 +6536,7 @@ 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 = 73;
6539
+ var HOOK_VERSION = 74;
6540
6540
  function localLogsOnly(security) {
6541
6541
  if (security !== void 0) {
6542
6542
  const l = security && security.localLogs;
@@ -8428,13 +8428,12 @@ if (!REFRESH_MODE) {
8428
8428
  } catch {
8429
8429
  }
8430
8430
  try {
8431
- if (!localLogsOnly(_sec))
8432
- await fetch(API_URL + "/api/v1/audit-logs", {
8433
- method: "POST",
8434
- headers: { "Content-Type": "application/json", ...AUTH_HEADERS },
8435
- body: JSON.stringify(_logEntry),
8436
- signal: AbortSignal.timeout(3e3)
8437
- });
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
+ });
8438
8437
  } catch {
8439
8438
  }
8440
8439
  if (AGENT_TYPE !== "codex")
@@ -8461,7 +8460,7 @@ if (!REFRESH_MODE) {
8461
8460
  try {
8462
8461
  if (existsSync(policyCacheFile)) {
8463
8462
  const cached = JSON.parse(readFileSync(policyCacheFile, "utf-8"));
8464
- if (cached && cached.policy)
8463
+ if (cached)
8465
8464
  staleCache = cached;
8466
8465
  if (cached && cached._ts && Date.now() - cached._ts < POLICY_TTL_MS) {
8467
8466
  refreshDue = false;
@@ -8546,35 +8545,31 @@ if (!REFRESH_MODE) {
8546
8545
  }
8547
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 });
8548
8547
  try {
8549
- if (!localLogsOnly(securityCfg))
8550
- await fetch(API_URL + "/api/v1/audit-logs", {
8551
- method: "POST",
8552
- headers: { "Content-Type": "application/json", ...AUTH_HEADERS },
8553
- body: JSON.stringify({
8554
- tool: toolName,
8555
- arguments: args,
8556
- decision: "DENY",
8557
- reason: "ghost path (hidden from agent)",
8558
- permission: guessPermission(toolName),
8559
- source: `${AGENT_TYPE}-guard`,
8560
- agent_id: AGENT_TYPE,
8561
- agent_name: AGENT_NAME,
8562
- session_id: call.sessionId,
8563
- evaluation_time_ms: Date.now() - _evalStart
8564
- }),
8565
- signal: AbortSignal.timeout(3e3)
8566
- });
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
+ });
8567
8565
  } catch {
8568
8566
  }
8569
- await maybeSelfUpdate();
8570
8567
  blockTool(ghostHit, true);
8571
8568
  }
8572
8569
  if (toolName === "Bash" || toolName === "run_command" || guessPermission(toolName) === "EXECUTE") {
8573
8570
  const rw = ghostListingRewrite(args, securityCfg.ghost);
8574
- if (rw) {
8575
- await maybeSelfUpdate();
8571
+ if (rw)
8576
8572
  rewriteTool({ command: rw });
8577
- }
8578
8573
  }
8579
8574
  }
8580
8575
  if (!reason)
@@ -8608,7 +8603,6 @@ if (!REFRESH_MODE) {
8608
8603
  reason = "Security layer (DLP): reading a file that contains a secret is blocked. Blocked by SolonGate.";
8609
8604
  opaRoute = "black";
8610
8605
  } else if (plan && plan.rewrite) {
8611
- await maybeSelfUpdate();
8612
8606
  rewriteTool(plan.rewrite);
8613
8607
  }
8614
8608
  }
@@ -8648,18 +8642,16 @@ if (!REFRESH_MODE) {
8648
8642
  evaluation_time_ms: Date.now() - _evalStart
8649
8643
  };
8650
8644
  writeLocalLog(securityCfg, { ts: (/* @__PURE__ */ new Date()).toISOString(), ...logEntry });
8651
- if (!localLogsOnly(securityCfg))
8652
- await fetch(API_URL + "/api/v1/audit-logs", {
8653
- method: "POST",
8654
- headers: { "Content-Type": "application/json", ...AUTH_HEADERS },
8655
- body: JSON.stringify(logEntry),
8656
- signal: AbortSignal.timeout(3e3)
8657
- });
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
+ });
8658
8651
  } catch {
8659
8652
  }
8660
8653
  }
8661
8654
  writeDenyFlag(toolName);
8662
- await maybeSelfUpdate();
8663
8655
  blockTool(reason);
8664
8656
  }
8665
8657
  } catch {
package/hooks/guard.mjs CHANGED
@@ -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 = 73;
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');
@@ -2424,7 +2426,7 @@ if (!REFRESH_MODE) { try { input += readFileSync(0, 'utf-8'); } catch {} }
2424
2426
  };
2425
2427
  try { writeLocalLog(_sec, { ts: new Date().toISOString(), ..._logEntry }); } catch {}
2426
2428
  try {
2427
- if (!localLogsOnly(_sec)) await fetch(API_URL + '/api/v1/audit-logs', {
2429
+ await fetch(API_URL + '/api/v1/audit-logs', {
2428
2430
  method: 'POST',
2429
2431
  headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
2430
2432
  body: JSON.stringify(_logEntry),
@@ -2484,7 +2486,13 @@ if (!REFRESH_MODE) { try { input += readFileSync(0, 'utf-8'); } catch {} }
2484
2486
  try {
2485
2487
  if (existsSync(policyCacheFile)) {
2486
2488
  const cached = JSON.parse(readFileSync(policyCacheFile, 'utf-8'));
2487
- 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;
2488
2496
  if (cached && cached._ts && Date.now() - cached._ts < POLICY_TTL_MS) {
2489
2497
  refreshDue = false;
2490
2498
  if (cached.policy) dashboardPolicy = cached.policy;
@@ -2571,7 +2579,7 @@ if (!REFRESH_MODE) { try { input += readFileSync(0, 'utf-8'); } catch {} }
2571
2579
  try { writeDenyFlag(toolName); } catch {}
2572
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 });
2573
2581
  try {
2574
- if (!localLogsOnly(securityCfg)) await fetch(API_URL + '/api/v1/audit-logs', {
2582
+ await fetch(API_URL + '/api/v1/audit-logs', {
2575
2583
  method: 'POST',
2576
2584
  headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
2577
2585
  body: JSON.stringify({
@@ -2585,7 +2593,7 @@ if (!REFRESH_MODE) { try { input += readFileSync(0, 'utf-8'); } catch {} }
2585
2593
  signal: AbortSignal.timeout(3000),
2586
2594
  });
2587
2595
  } catch {}
2588
- await maybeSelfUpdate();
2596
+ // NOT maybeSelfUpdate() first: see the deny at the end of this flow.
2589
2597
  // Stealth deny: ghostHit is a bare "No such file or directory", and the
2590
2598
  // stealth flag keeps the adapter from adding any SolonGate branding, so
2591
2599
  // the path looks like it simply is not there on every client.
@@ -2597,7 +2605,7 @@ if (!REFRESH_MODE) { try { input += readFileSync(0, 'utf-8'); } catch {} }
2597
2605
  // PreToolUse `overwrite` replaces the command before it runs (hooks.md).
2598
2606
  if (toolName === 'Bash' || toolName === 'run_command' || guessPermission(toolName) === 'EXECUTE') {
2599
2607
  const rw = ghostListingRewrite(args, securityCfg.ghost);
2600
- if (rw) { await maybeSelfUpdate(); rewriteTool({ command: rw }); }
2608
+ if (rw) rewriteTool({ command: rw }); // verdict first, update later
2601
2609
  }
2602
2610
  }
2603
2611
  // Extra security layers run after tamper, before policy. Block reason wins
@@ -2667,8 +2675,7 @@ if (!REFRESH_MODE) { try { input += readFileSync(0, 'utf-8'); } catch {} }
2667
2675
  reason = 'Security layer (DLP): reading a file that contains a secret is blocked. Blocked by SolonGate.';
2668
2676
  opaRoute = 'black';
2669
2677
  } else if (plan && plan.rewrite) {
2670
- await maybeSelfUpdate();
2671
- rewriteTool(plan.rewrite);
2678
+ rewriteTool(plan.rewrite); // verdict first, update later
2672
2679
  }
2673
2680
  }
2674
2681
 
@@ -2714,7 +2721,7 @@ if (!REFRESH_MODE) { try { input += readFileSync(0, 'utf-8'); } catch {} }
2714
2721
  writeLocalLog(securityCfg, { ts: new Date().toISOString(), ...logEntry });
2715
2722
  // PI hook layer removed — piResult fields no longer attached.
2716
2723
  // Local-only mode: keep the log on the user's machine, skip the cloud.
2717
- if (!localLogsOnly(securityCfg)) await fetch(API_URL + '/api/v1/audit-logs', {
2724
+ await fetch(API_URL + '/api/v1/audit-logs', {
2718
2725
  method: 'POST',
2719
2726
  headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
2720
2727
  body: JSON.stringify(logEntry),
@@ -2723,7 +2730,13 @@ if (!REFRESH_MODE) { try { input += readFileSync(0, 'utf-8'); } catch {} }
2723
2730
  } catch {}
2724
2731
  }
2725
2732
  writeDenyFlag(toolName);
2726
- 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.
2727
2740
  blockTool(reason);
2728
2741
  }
2729
2742
  } catch {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solongate/proxy",
3
- "version": "0.83.8",
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": {