@solongate/proxy 0.81.2 → 0.81.4

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/hooks/audit.mjs CHANGED
@@ -399,6 +399,10 @@ let input = '';
399
399
  // EOF avoids creating that handle, so the exits below tear down cleanly.
400
400
  try { input += readFileSync(0, 'utf-8'); } catch {}
401
401
  ;(async () => {
402
+ // Safety backstop ONLY: the normal path exits by natural drain. Force-exit if the
403
+ // loop fails to drain — 8s > the 5s fetch timeout, so the threadpool is idle and
404
+ // exit() can't hit the Windows UV_HANDLE_CLOSING abort.
405
+ try { setTimeout(() => { try { process.exit(process.exitCode || 0); } catch {} }, 8000).unref(); } catch {}
402
406
  try {
403
407
  const data = JSON.parse(input);
404
408
  let EMITTED_PAYLOAD = null;
@@ -531,7 +535,13 @@ try { input += readFileSync(0, 'utf-8'); } catch {}
531
535
  // on the write's flush callback (and on the fire-and-forget audit POST).
532
536
  let flushed = (typeof EMITTED_PAYLOAD !== 'string');
533
537
  let fetchDone = false;
534
- const maybeExit = () => { if (flushed && fetchDone) process.exit(0); };
538
+ // Set the exit code and let the event loop drain naturally — do NOT call
539
+ // process.exit(). On Windows + Node 24, process.exit() in the same tick the
540
+ // audit-log fetch settled aborts with `Assertion failed: !(handle->flags &
541
+ // UV_HANDLE_CLOSING), file src\win\async.c` (a threadpool DNS worker is still
542
+ // mid-uv_async_send when exit() force-closes the loop's async handle). undici
543
+ // unrefs idle sockets, so Node exits on its own ~1ms after the fetch settles.
544
+ const maybeExit = () => { if (flushed && fetchDone) { process.exitCode = 0; } };
535
545
  if (typeof EMITTED_PAYLOAD === 'string') {
536
546
  try { process.stdout.write(EMITTED_PAYLOAD, () => { flushed = true; maybeExit(); }); }
537
547
  catch { flushed = true; }
@@ -581,9 +591,11 @@ try { input += readFileSync(0, 'utf-8'); } catch {}
581
591
  signal: AbortSignal.timeout(5000),
582
592
  }).catch(() => {}).finally(() => { fetchDone = true; maybeExit(); });
583
593
  }
584
- // Hard backstop: exit even if the write callback or fetch never settles.
585
- setTimeout(() => process.exit(0), 3000);
594
+ // Safety backstop ONLY (unref'd, 8s > the 5s fetch timeout): the normal path
595
+ // exits by natural drain ~1ms after the fetch settles. This fires only if the
596
+ // loop somehow fails to drain — by 8s the threadpool is idle, so exit() is safe.
597
+ try { setTimeout(() => { try { process.exit(process.exitCode || 0); } catch {} }, 8000).unref(); } catch {}
586
598
  } catch {
587
- process.exit(0);
599
+ process.exitCode = 0;
588
600
  }
589
601
  })();
@@ -6757,7 +6757,27 @@ async function refreshPolicyCache() {
6757
6757
  }
6758
6758
  }
6759
6759
  if (REFRESH_MODE) {
6760
- refreshPolicyCache().finally(() => process.exit(0));
6760
+ try {
6761
+ setTimeout(() => {
6762
+ try {
6763
+ process.exit(process.exitCode || 0);
6764
+ } catch {
6765
+ }
6766
+ }, 8e3).unref();
6767
+ } catch {
6768
+ }
6769
+ refreshPolicyCache().finally(() => {
6770
+ process.exitCode = 0;
6771
+ });
6772
+ }
6773
+ var SG_DONE = Symbol("sg-done");
6774
+ var _sgDone = false;
6775
+ function sgFinish(code) {
6776
+ if (!_sgDone) {
6777
+ _sgDone = true;
6778
+ process.exitCode = code;
6779
+ }
6780
+ throw SG_DONE;
6761
6781
  }
6762
6782
  function blockTool(reason) {
6763
6783
  if (AGENT_TYPE === "gemini-cli") {
@@ -6765,23 +6785,23 @@ function blockTool(reason) {
6765
6785
  decision: "deny",
6766
6786
  reason: `[SolonGate] ${reason}`
6767
6787
  }));
6768
- process.exit(0);
6788
+ sgFinish(0);
6769
6789
  } else {
6770
6790
  process.stderr.write(reason);
6771
- process.exit(2);
6791
+ sgFinish(2);
6772
6792
  }
6773
6793
  }
6774
6794
  function allowTool() {
6775
6795
  if (AGENT_TYPE === "gemini-cli") {
6776
6796
  process.stdout.write(JSON.stringify({ decision: "allow" }));
6777
6797
  }
6778
- process.exit(0);
6798
+ sgFinish(0);
6779
6799
  }
6780
6800
  function rewriteTool(updatedInput) {
6781
6801
  process.stdout.write(JSON.stringify({
6782
6802
  hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput }
6783
6803
  }));
6784
- process.exit(0);
6804
+ sgFinish(0);
6785
6805
  }
6786
6806
  function writeDenyFlag(toolName) {
6787
6807
  try {
@@ -7661,246 +7681,261 @@ if (!REFRESH_MODE) {
7661
7681
  }
7662
7682
  }
7663
7683
  (async () => {
7664
- if (REFRESH_MODE)
7665
- return;
7666
- if (process.env.SOLONGATE_DEBUG) {
7667
- }
7668
- if (!API_KEY) {
7669
- allowTool();
7670
- return;
7671
- }
7672
- const _evalStart = Date.now();
7673
7684
  try {
7674
- const raw = JSON.parse(input);
7675
- if (process.env.SOLONGATE_DEBUG) {
7685
+ setTimeout(() => {
7676
7686
  try {
7677
- const { appendFileSync: afs, mkdirSync: mds } = await import("node:fs");
7678
- mds(resolve(".solongate"), { recursive: true });
7679
- const debugLine = JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), hook: "guard", argv: process.argv.slice(2), tool_name: raw.tool_name || raw.toolName || raw.command, agent_id: AGENT_ID }) + "\n";
7680
- afs(resolve(".solongate", ".debug-guard-log"), debugLine);
7687
+ process.exit(process.exitCode || 0);
7681
7688
  } catch {
7682
7689
  }
7690
+ }, 8e3).unref();
7691
+ } catch {
7692
+ }
7693
+ try {
7694
+ if (REFRESH_MODE)
7695
+ return;
7696
+ if (process.env.SOLONGATE_DEBUG) {
7683
7697
  }
7684
- let mappedToolName = raw.tool_name || raw.toolName || "";
7685
- let mappedToolInput = raw.tool_input || raw.toolInput || raw.params || {};
7686
- const data = {
7687
- ...raw,
7688
- tool_name: mappedToolName,
7689
- tool_input: mappedToolInput,
7690
- tool_response: raw.tool_response || raw.toolResponse || {},
7691
- cwd: raw.cwd || process.cwd(),
7692
- session_id: raw.session_id || raw.sessionId || raw.conversation_id || ""
7693
- };
7694
- const args = data.tool_input;
7695
- const toolName = data.tool_name || "";
7696
- const hookCwd = data.cwd || process.cwd();
7697
- let policy;
7698
- let selfProtectEnabled = true;
7699
- let securityCfg = null;
7700
- const agentKey = (AGENT_ID || "default").replace(/[^a-zA-Z0-9_-]/g, "_");
7701
- const policyCacheFile = join(resolve(homedir(), ".solongate"), ".policy-cache-" + agentKey + ".json");
7702
- const POLICY_TTL_MS = 1e4;
7698
+ if (!API_KEY) {
7699
+ allowTool();
7700
+ return;
7701
+ }
7702
+ const _evalStart = Date.now();
7703
7703
  try {
7704
- let dashboardPolicy = null;
7705
- let staleCache = null;
7706
- let refreshDue = true;
7704
+ const raw = JSON.parse(input);
7705
+ if (process.env.SOLONGATE_DEBUG) {
7706
+ try {
7707
+ const { appendFileSync: afs, mkdirSync: mds } = await import("node:fs");
7708
+ mds(resolve(".solongate"), { recursive: true });
7709
+ const debugLine = JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), hook: "guard", argv: process.argv.slice(2), tool_name: raw.tool_name || raw.toolName || raw.command, agent_id: AGENT_ID }) + "\n";
7710
+ afs(resolve(".solongate", ".debug-guard-log"), debugLine);
7711
+ } catch {
7712
+ }
7713
+ }
7714
+ let mappedToolName = raw.tool_name || raw.toolName || "";
7715
+ let mappedToolInput = raw.tool_input || raw.toolInput || raw.params || {};
7716
+ const data = {
7717
+ ...raw,
7718
+ tool_name: mappedToolName,
7719
+ tool_input: mappedToolInput,
7720
+ tool_response: raw.tool_response || raw.toolResponse || {},
7721
+ cwd: raw.cwd || process.cwd(),
7722
+ session_id: raw.session_id || raw.sessionId || raw.conversation_id || ""
7723
+ };
7724
+ const args = data.tool_input;
7725
+ const toolName = data.tool_name || "";
7726
+ const hookCwd = data.cwd || process.cwd();
7727
+ let policy;
7728
+ let selfProtectEnabled = true;
7729
+ let securityCfg = null;
7730
+ const agentKey = (AGENT_ID || "default").replace(/[^a-zA-Z0-9_-]/g, "_");
7731
+ const policyCacheFile = join(resolve(homedir(), ".solongate"), ".policy-cache-" + agentKey + ".json");
7732
+ const POLICY_TTL_MS = 1e4;
7707
7733
  try {
7708
- if (existsSync(policyCacheFile)) {
7709
- const cached = JSON.parse(readFileSync(policyCacheFile, "utf-8"));
7710
- if (cached && cached.policy)
7711
- staleCache = cached;
7712
- if (cached && cached._ts && Date.now() - cached._ts < POLICY_TTL_MS) {
7713
- refreshDue = false;
7714
- if (cached.policy)
7715
- dashboardPolicy = cached.policy;
7716
- if (typeof cached.selfProtect === "boolean")
7717
- selfProtectEnabled = cached.selfProtect;
7718
- if (cached.security !== void 0)
7719
- securityCfg = cached.security;
7720
- if (cached.hookVersions)
7721
- CLOUD_HOOK_VERSIONS = cached.hookVersions;
7734
+ let dashboardPolicy = null;
7735
+ let staleCache = null;
7736
+ let refreshDue = true;
7737
+ try {
7738
+ if (existsSync(policyCacheFile)) {
7739
+ const cached = JSON.parse(readFileSync(policyCacheFile, "utf-8"));
7740
+ if (cached && cached.policy)
7741
+ staleCache = cached;
7742
+ if (cached && cached._ts && Date.now() - cached._ts < POLICY_TTL_MS) {
7743
+ refreshDue = false;
7744
+ if (cached.policy)
7745
+ dashboardPolicy = cached.policy;
7746
+ if (typeof cached.selfProtect === "boolean")
7747
+ selfProtectEnabled = cached.selfProtect;
7748
+ if (cached.security !== void 0)
7749
+ securityCfg = cached.security;
7750
+ if (cached.hookVersions)
7751
+ CLOUD_HOOK_VERSIONS = cached.hookVersions;
7752
+ }
7722
7753
  }
7754
+ } catch {
7723
7755
  }
7724
- } catch {
7725
- }
7726
- if (refreshDue) {
7727
- if (!dashboardPolicy && staleCache) {
7728
- dashboardPolicy = staleCache.policy;
7729
- if (typeof staleCache.selfProtect === "boolean")
7730
- selfProtectEnabled = staleCache.selfProtect;
7731
- if (staleCache.security !== void 0)
7732
- securityCfg = staleCache.security;
7733
- if (staleCache.hookVersions)
7734
- CLOUD_HOOK_VERSIONS = staleCache.hookVersions;
7735
- }
7736
- if (API_KEY) {
7737
- try {
7738
- const lock = join(resolve(homedir(), ".solongate"), ".policy-refresh-" + agentKey + ".lock");
7739
- const due = !existsSync(lock) || Date.now() - statSync(lock).mtimeMs > 3e3;
7740
- if (due) {
7756
+ if (refreshDue) {
7757
+ if (!dashboardPolicy && staleCache) {
7758
+ dashboardPolicy = staleCache.policy;
7759
+ if (typeof staleCache.selfProtect === "boolean")
7760
+ selfProtectEnabled = staleCache.selfProtect;
7761
+ if (staleCache.security !== void 0)
7762
+ securityCfg = staleCache.security;
7763
+ if (staleCache.hookVersions)
7764
+ CLOUD_HOOK_VERSIONS = staleCache.hookVersions;
7765
+ }
7766
+ if (API_KEY) {
7767
+ try {
7768
+ const lock = join(resolve(homedir(), ".solongate"), ".policy-refresh-" + agentKey + ".lock");
7769
+ const due = !existsSync(lock) || Date.now() - statSync(lock).mtimeMs > 3e3;
7770
+ if (due) {
7771
+ try {
7772
+ writeFileSync(lock, String(Date.now()));
7773
+ } catch {
7774
+ }
7775
+ spawn(process.execPath, [process.argv[1], AGENT_TYPE, AGENT_NAME, "--sg-refresh-policy"], { detached: true, stdio: "ignore", env: process.env }).unref();
7776
+ }
7777
+ } catch {
7778
+ }
7779
+ }
7780
+ }
7781
+ if (process.env.SOLONGATE_DEBUG) {
7782
+ }
7783
+ if (dashboardPolicy) {
7784
+ policy = dashboardPolicy;
7785
+ } else {
7786
+ const candidates = [
7787
+ join(resolve(homedir(), ".solongate"), "policy.json"),
7788
+ resolve(hookCwd, "policy.json")
7789
+ ];
7790
+ for (const p of candidates) {
7791
+ if (existsSync(p)) {
7741
7792
  try {
7742
- writeFileSync(lock, String(Date.now()));
7793
+ policy = JSON.parse(readFileSync(p, "utf-8"));
7794
+ break;
7743
7795
  } catch {
7744
7796
  }
7745
- spawn(process.execPath, [process.argv[1], AGENT_TYPE, AGENT_NAME, "--sg-refresh-policy"], { detached: true, stdio: "ignore", env: process.env }).unref();
7746
7797
  }
7747
- } catch {
7748
7798
  }
7749
7799
  }
7800
+ } catch {
7750
7801
  }
7751
7802
  if (process.env.SOLONGATE_DEBUG) {
7752
7803
  }
7753
- if (dashboardPolicy) {
7754
- policy = dashboardPolicy;
7755
- } else {
7756
- const candidates = [
7757
- join(resolve(homedir(), ".solongate"), "policy.json"),
7758
- resolve(hookCwd, "policy.json")
7759
- ];
7760
- for (const p of candidates) {
7761
- if (existsSync(p)) {
7762
- try {
7763
- policy = JSON.parse(readFileSync(p, "utf-8"));
7764
- break;
7765
- } catch {
7766
- }
7767
- }
7804
+ {
7805
+ const scope = policy && Array.isArray(policy.agents) && policy.agents.length > 0 ? policy.agents : ["*"];
7806
+ if (!scope.includes("*") && !scope.includes(AGENT_TYPE)) {
7807
+ allowTool();
7808
+ return;
7768
7809
  }
7769
7810
  }
7770
- } catch {
7771
- }
7772
- if (process.env.SOLONGATE_DEBUG) {
7773
- }
7774
- {
7775
- const scope = policy && Array.isArray(policy.agents) && policy.agents.length > 0 ? policy.agents : ["*"];
7776
- if (!scope.includes("*") && !scope.includes(AGENT_TYPE)) {
7777
- allowTool();
7778
- return;
7779
- }
7780
- }
7781
- if (process.env.SOLONGATE_DEBUG) {
7782
- }
7783
- let reason = selfProtectEnabled ? tamperCheck(toolName, args) : null;
7784
- if (!reason && securityCfg && securityCfg.ghost) {
7785
- const ghostHit = ghostBlock(toolName, args, securityCfg.ghost);
7786
- if (ghostHit) {
7787
- try {
7788
- writeDenyFlag(toolName);
7789
- } catch {
7790
- }
7791
- 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: data.session_id || "", evaluation_time_ms: Date.now() - _evalStart });
7792
- try {
7793
- if (!localLogsOnly(securityCfg))
7794
- await fetch(API_URL + "/api/v1/audit-logs", {
7795
- method: "POST",
7796
- headers: { "Content-Type": "application/json", ...AUTH_HEADERS },
7797
- body: JSON.stringify({
7798
- tool: toolName,
7799
- arguments: args,
7800
- decision: "DENY",
7801
- reason: "ghost path (hidden from agent)",
7802
- permission: guessPermission(toolName),
7803
- source: `${AGENT_TYPE}-guard`,
7804
- agent_id: AGENT_TYPE,
7805
- agent_name: AGENT_NAME,
7806
- session_id: data.session_id || "",
7807
- evaluation_time_ms: Date.now() - _evalStart
7808
- }),
7809
- signal: AbortSignal.timeout(3e3)
7810
- });
7811
- } catch {
7812
- }
7813
- await maybeSelfUpdate();
7814
- if (AGENT_TYPE === "gemini-cli") {
7815
- process.stdout.write(JSON.stringify({ decision: "deny", reason: ghostHit }));
7816
- process.exit(0);
7817
- }
7818
- process.stderr.write(ghostHit);
7819
- process.exit(2);
7811
+ if (process.env.SOLONGATE_DEBUG) {
7820
7812
  }
7821
- if (AGENT_TYPE !== "gemini-cli" && toolName === "Bash") {
7822
- const rw = ghostListingRewrite(args, securityCfg.ghost);
7823
- if (rw) {
7813
+ let reason = selfProtectEnabled ? tamperCheck(toolName, args) : null;
7814
+ if (!reason && securityCfg && securityCfg.ghost) {
7815
+ const ghostHit = ghostBlock(toolName, args, securityCfg.ghost);
7816
+ if (ghostHit) {
7817
+ try {
7818
+ writeDenyFlag(toolName);
7819
+ } catch {
7820
+ }
7821
+ 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: data.session_id || "", evaluation_time_ms: Date.now() - _evalStart });
7822
+ try {
7823
+ if (!localLogsOnly(securityCfg))
7824
+ await fetch(API_URL + "/api/v1/audit-logs", {
7825
+ method: "POST",
7826
+ headers: { "Content-Type": "application/json", ...AUTH_HEADERS },
7827
+ body: JSON.stringify({
7828
+ tool: toolName,
7829
+ arguments: args,
7830
+ decision: "DENY",
7831
+ reason: "ghost path (hidden from agent)",
7832
+ permission: guessPermission(toolName),
7833
+ source: `${AGENT_TYPE}-guard`,
7834
+ agent_id: AGENT_TYPE,
7835
+ agent_name: AGENT_NAME,
7836
+ session_id: data.session_id || "",
7837
+ evaluation_time_ms: Date.now() - _evalStart
7838
+ }),
7839
+ signal: AbortSignal.timeout(3e3)
7840
+ });
7841
+ } catch {
7842
+ }
7824
7843
  await maybeSelfUpdate();
7825
- rewriteTool({ command: rw });
7844
+ if (AGENT_TYPE === "gemini-cli") {
7845
+ process.stdout.write(JSON.stringify({ decision: "deny", reason: ghostHit }));
7846
+ sgFinish(0);
7847
+ }
7848
+ process.stderr.write(ghostHit);
7849
+ sgFinish(2);
7850
+ }
7851
+ if (AGENT_TYPE !== "gemini-cli" && toolName === "Bash") {
7852
+ const rw = ghostListingRewrite(args, securityCfg.ghost);
7853
+ if (rw) {
7854
+ await maybeSelfUpdate();
7855
+ rewriteTool({ command: rw });
7856
+ }
7826
7857
  }
7827
7858
  }
7828
- }
7829
- if (!reason)
7830
- reason = securityLayerCheck(toolName, args, securityCfg, agentKey);
7831
- if (process.env.SOLONGATE_DEBUG) {
7832
- }
7833
- let opaRoute = "white";
7834
- if (reason) {
7835
- opaRoute = "black";
7836
- } else if (policy && policy.rules) {
7837
- const opaResult = await evaluateWithOpa(policy, args, toolName, hookCwd);
7838
- if (opaResult === void 0) {
7839
- const legacy = evaluate(policy, args, toolName);
7840
- if (typeof legacy === "string") {
7841
- reason = legacy;
7859
+ if (!reason)
7860
+ reason = securityLayerCheck(toolName, args, securityCfg, agentKey);
7861
+ if (process.env.SOLONGATE_DEBUG) {
7862
+ }
7863
+ let opaRoute = "white";
7864
+ if (reason) {
7865
+ opaRoute = "black";
7866
+ } else if (policy && policy.rules) {
7867
+ const opaResult = await evaluateWithOpa(policy, args, toolName, hookCwd);
7868
+ if (opaResult === void 0) {
7869
+ const legacy = evaluate(policy, args, toolName);
7870
+ if (typeof legacy === "string") {
7871
+ reason = legacy;
7872
+ opaRoute = "black";
7873
+ } else {
7874
+ opaRoute = "white";
7875
+ }
7876
+ } else if (typeof opaResult === "string") {
7877
+ reason = opaResult;
7842
7878
  opaRoute = "black";
7843
7879
  } else {
7844
7880
  opaRoute = "white";
7845
7881
  }
7846
- } else if (typeof opaResult === "string") {
7847
- reason = opaResult;
7848
- opaRoute = "black";
7849
- } else {
7850
- opaRoute = "white";
7851
7882
  }
7852
- }
7853
- process.stderr.write(`[SolonGate ROUTE] ${opaRoute.toUpperCase()} (${reason ? "block" : "allow"})
7883
+ process.stderr.write(`[SolonGate ROUTE] ${opaRoute.toUpperCase()} (${reason ? "block" : "allow"})
7854
7884
  `);
7855
- try {
7856
- const _fd = resolve(".solongate");
7857
- mkdirSync(_fd, { recursive: true });
7858
- const _rec = { ms: Date.now() - _evalStart, ts: Date.now(), tool: toolName, session: data.session_id || "" };
7859
- writeFileSync(join(_fd, ".last-eval"), JSON.stringify(_rec));
7860
7885
  try {
7861
- const ring = join(_fd, ".eval-ring.jsonl");
7862
- appendFileSync(ring, JSON.stringify(_rec) + "\n");
7886
+ const _fd = resolve(".solongate");
7887
+ mkdirSync(_fd, { recursive: true });
7888
+ const _rec = { ms: Date.now() - _evalStart, ts: Date.now(), tool: toolName, session: data.session_id || "" };
7889
+ writeFileSync(join(_fd, ".last-eval"), JSON.stringify(_rec));
7863
7890
  try {
7864
- if (statSync(ring).size > 16384)
7865
- writeFileSync(ring, readFileSync(ring, "utf-8").split("\n").filter(Boolean).slice(-50).join("\n") + "\n");
7891
+ const ring = join(_fd, ".eval-ring.jsonl");
7892
+ appendFileSync(ring, JSON.stringify(_rec) + "\n");
7893
+ try {
7894
+ if (statSync(ring).size > 16384)
7895
+ writeFileSync(ring, readFileSync(ring, "utf-8").split("\n").filter(Boolean).slice(-50).join("\n") + "\n");
7896
+ } catch {
7897
+ }
7866
7898
  } catch {
7867
7899
  }
7868
7900
  } catch {
7869
7901
  }
7870
- } catch {
7871
- }
7872
- if (reason) {
7873
- if (true) {
7874
- try {
7875
- const logEntry = {
7876
- tool: toolName,
7877
- arguments: args,
7878
- decision: "DENY",
7879
- reason,
7880
- permission: guessPermission(toolName),
7881
- source: `${AGENT_TYPE}-guard`,
7882
- agent_id: AGENT_TYPE,
7883
- agent_name: AGENT_NAME,
7884
- session_id: data.session_id || "",
7885
- evaluation_time_ms: Date.now() - _evalStart
7886
- };
7887
- writeLocalLog(securityCfg, { ts: (/* @__PURE__ */ new Date()).toISOString(), ...logEntry });
7888
- if (!localLogsOnly(securityCfg))
7889
- await fetch(API_URL + "/api/v1/audit-logs", {
7890
- method: "POST",
7891
- headers: { "Content-Type": "application/json", ...AUTH_HEADERS },
7892
- body: JSON.stringify(logEntry),
7893
- signal: AbortSignal.timeout(3e3)
7894
- });
7895
- } catch {
7902
+ if (reason) {
7903
+ if (true) {
7904
+ try {
7905
+ const logEntry = {
7906
+ tool: toolName,
7907
+ arguments: args,
7908
+ decision: "DENY",
7909
+ reason,
7910
+ permission: guessPermission(toolName),
7911
+ source: `${AGENT_TYPE}-guard`,
7912
+ agent_id: AGENT_TYPE,
7913
+ agent_name: AGENT_NAME,
7914
+ session_id: data.session_id || "",
7915
+ evaluation_time_ms: Date.now() - _evalStart
7916
+ };
7917
+ writeLocalLog(securityCfg, { ts: (/* @__PURE__ */ new Date()).toISOString(), ...logEntry });
7918
+ if (!localLogsOnly(securityCfg))
7919
+ await fetch(API_URL + "/api/v1/audit-logs", {
7920
+ method: "POST",
7921
+ headers: { "Content-Type": "application/json", ...AUTH_HEADERS },
7922
+ body: JSON.stringify(logEntry),
7923
+ signal: AbortSignal.timeout(3e3)
7924
+ });
7925
+ } catch {
7926
+ }
7896
7927
  }
7928
+ writeDenyFlag(toolName);
7929
+ await maybeSelfUpdate();
7930
+ blockTool(reason);
7897
7931
  }
7898
- writeDenyFlag(toolName);
7899
- await maybeSelfUpdate();
7900
- blockTool(reason);
7932
+ } catch {
7933
+ }
7934
+ await maybeSelfUpdate();
7935
+ allowTool();
7936
+ } catch (e) {
7937
+ if (e !== SG_DONE) {
7938
+ process.exitCode = process.exitCode || 0;
7901
7939
  }
7902
- } catch {
7903
7940
  }
7904
- await maybeSelfUpdate();
7905
- allowTool();
7906
7941
  })();
package/hooks/guard.mjs CHANGED
@@ -281,24 +281,54 @@ async function refreshPolicyCache() {
281
281
  try { writeFileSync(cacheFile, JSON.stringify({ _ts: Date.now(), policy, selfProtect, security, hookVersions })); } catch {}
282
282
  } catch {}
283
283
  }
284
- if (REFRESH_MODE) { refreshPolicyCache().finally(() => process.exit(0)); }
284
+ if (REFRESH_MODE) { try { setTimeout(() => { try { process.exit(process.exitCode || 0); } catch {} }, 8000).unref(); } catch {} refreshPolicyCache().finally(() => { process.exitCode = 0; }); }
285
285
 
286
286
  // ── Per-tool block/allow output ──
287
287
  // Response format depends on the agent:
288
288
  // Claude Code: exit 2 + stderr = BLOCK, exit 0 = ALLOW
289
289
  // Gemini CLI: {"decision": "deny/allow", "reason": "..."}
290
290
 
291
+ // Terminate the hook WITHOUT forcing process.exit(). On Windows + Node 24, calling
292
+ // process.exit() right after a fetch() (the cloud audit-log POST) aborts with
293
+ // `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\win\async.c`:
294
+ // the fetch's DNS/socket teardown is still settling in libuv's threadpool and
295
+ // exit() double-closes the loop's async handle. The abort replaces exit code 2, so
296
+ // Claude Code sees a non-blocking hook failure and runs the tool anyway — the guard
297
+ // computes DENY but never blocks. Instead set process.exitCode and let the event
298
+ // loop drain and exit on its own. A latch preserves the FIRST code (so a later
299
+ // allowTool() reached on fall-through can't overwrite a block), SG_DONE unwinds the
300
+ // stack, and an unref'd backstop force-exits only if a handle is stuck — by then
301
+ // the threadpool has drained, so exit() is safe.
302
+ const SG_DONE = Symbol('sg-done');
303
+ let _sgDone = false;
304
+ // Terminate WITHOUT ever calling process.exit() on the hot path. On Windows + Node
305
+ // 24, process.exit() called in (or right after) the same tick a cloud fetch()
306
+ // settled aborts with `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING),
307
+ // file src\win\async.c`: a libuv threadpool worker (DNS) is still mid-uv_async_send
308
+ // when exit() force-closes the loop's async handle. The abort replaces exit code 2,
309
+ // so Claude Code sees a non-blocking hook failure and runs the tool anyway (guard
310
+ // logs DENY, never blocks). Instead we just set process.exitCode and let the event
311
+ // loop drain — undici unrefs idle sockets, so Node exits on its own within ~1ms of
312
+ // the work finishing, cleanly (no forced teardown → no abort). A latch preserves
313
+ // the FIRST code so a later allowTool() on fall-through can't overwrite a block; an
314
+ // unref'd backstop armed at the top of the handler is the only place exit() may run,
315
+ // and only long after every fetch has settled.
316
+ function sgFinish(code) {
317
+ if (!_sgDone) { _sgDone = true; process.exitCode = code; }
318
+ throw SG_DONE;
319
+ }
320
+
291
321
  function blockTool(reason) {
292
322
  if (AGENT_TYPE === 'gemini-cli') {
293
323
  process.stdout.write(JSON.stringify({
294
324
  decision: 'deny',
295
325
  reason: `[SolonGate] ${reason}`,
296
326
  }));
297
- process.exit(0);
327
+ sgFinish(0);
298
328
  } else {
299
329
  // Claude Code — exit code 2
300
330
  process.stderr.write(reason);
301
- process.exit(2);
331
+ sgFinish(2);
302
332
  }
303
333
  }
304
334
 
@@ -306,7 +336,7 @@ function allowTool() {
306
336
  if (AGENT_TYPE === 'gemini-cli') {
307
337
  process.stdout.write(JSON.stringify({ decision: 'allow' }));
308
338
  }
309
- process.exit(0);
339
+ sgFinish(0);
310
340
  }
311
341
 
312
342
  // Allow the tool but REPLACE its input (Claude Code `updatedInput`). Used by the
@@ -316,7 +346,7 @@ function rewriteTool(updatedInput) {
316
346
  process.stdout.write(JSON.stringify({
317
347
  hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', updatedInput },
318
348
  }));
319
- process.exit(0);
349
+ sgFinish(0);
320
350
  }
321
351
 
322
352
  // Write flag file so stop.mjs knows a tool call (DENY) happened and doesn't log extra ALLOW
@@ -1412,6 +1442,11 @@ function readReferencedFiles(args, cwd) {
1412
1442
  // inside a stream callback, so the loop tears down cleanly.
1413
1443
  if (!REFRESH_MODE) { try { input += readFileSync(0, 'utf-8'); } catch {} }
1414
1444
  ;(async () => {
1445
+ // Safety backstop ONLY: the normal path exits by natural drain (~1ms after work
1446
+ // finishes). If the loop somehow fails to drain, force-exit — 8s is well past every
1447
+ // fetch timeout (≤3s) so the threadpool is idle and exit() can't hit the abort.
1448
+ try { setTimeout(() => { try { process.exit(process.exitCode || 0); } catch {} }, 8000).unref(); } catch {}
1449
+ try {
1415
1450
  // Background-refresh invocation has no tool call to evaluate — it already ran
1416
1451
  // refreshPolicyCache() at startup; do nothing on the (empty) stdin.
1417
1452
  if (REFRESH_MODE) return;
@@ -1604,9 +1639,9 @@ if (!REFRESH_MODE) { try { input += readFileSync(0, 'utf-8'); } catch {} }
1604
1639
  });
1605
1640
  } catch {}
1606
1641
  await maybeSelfUpdate();
1607
- if (AGENT_TYPE === 'gemini-cli') { process.stdout.write(JSON.stringify({ decision: 'deny', reason: ghostHit })); process.exit(0); }
1642
+ if (AGENT_TYPE === 'gemini-cli') { process.stdout.write(JSON.stringify({ decision: 'deny', reason: ghostHit })); sgFinish(0); }
1608
1643
  process.stderr.write(ghostHit);
1609
- process.exit(2);
1644
+ sgFinish(2);
1610
1645
  }
1611
1646
  // No direct hit: if this is a listing command, rewrite it so hidden
1612
1647
  // entries are filtered out of its output (Claude Code only).
@@ -1717,4 +1752,9 @@ if (!REFRESH_MODE) { try { input += readFileSync(0, 'utf-8'); } catch {} }
1717
1752
  } catch {}
1718
1753
  await maybeSelfUpdate();
1719
1754
  allowTool();
1755
+ } catch (e) {
1756
+ // SG_DONE is the normal terminator (exitCode already set); anything else is an
1757
+ // unexpected error — fail OPEN (exit 0) so a hook bug never wedges the agent.
1758
+ if (e !== SG_DONE) { process.exitCode = process.exitCode || 0; }
1759
+ }
1720
1760
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solongate/proxy",
3
- "version": "0.81.2",
3
+ "version": "0.81.4",
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": {