@solongate/proxy 0.81.3 → 0.81.5

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,12 +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
- // Defer the exit by one loop tick (setImmediate). Calling process.exit() in the
535
- // same tick the audit-log fetch settled aborts on Windows + Node 24 with
536
- // `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\win\async.c`
537
- // a threadpool worker is still mid-uv_async_send when exit() closes the loop's
538
- // async handle. One tick lets libuv drain that completion; we then still hard-exit.
539
- const maybeExit = () => { if (flushed && fetchDone) { process.exitCode = 0; setImmediate(() => { try { process.exit(0); } catch {} }); } };
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; } };
540
545
  if (typeof EMITTED_PAYLOAD === 'string') {
541
546
  try { process.stdout.write(EMITTED_PAYLOAD, () => { flushed = true; maybeExit(); }); }
542
547
  catch { flushed = true; }
@@ -586,11 +591,11 @@ try { input += readFileSync(0, 'utf-8'); } catch {}
586
591
  signal: AbortSignal.timeout(5000),
587
592
  }).catch(() => {}).finally(() => { fetchDone = true; maybeExit(); });
588
593
  }
589
- // Hard backstop: exit even if the write callback or fetch never settles. By 3s
590
- // the threadpool has long drained, so a direct exit here is safe.
591
- setTimeout(() => { try { process.exit(process.exitCode || 0); } catch {} }, 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 {}
592
598
  } catch {
593
- // Defer one tick in case an error was thrown while a fetch was still settling.
594
- try { setImmediate(() => { try { process.exit(0); } catch {} }); } catch { try { process.exit(0); } catch {} }
599
+ process.exitCode = 0;
595
600
  }
596
601
  })();
@@ -6757,21 +6757,17 @@ async function refreshPolicyCache() {
6757
6757
  }
6758
6758
  }
6759
6759
  if (REFRESH_MODE) {
6760
- refreshPolicyCache().finally(() => {
6761
- process.exitCode = 0;
6762
- try {
6763
- setImmediate(() => {
6764
- try {
6765
- process.exit(0);
6766
- } catch {
6767
- }
6768
- });
6769
- } catch {
6760
+ try {
6761
+ setTimeout(() => {
6770
6762
  try {
6771
- process.exit(0);
6763
+ process.exit(process.exitCode || 0);
6772
6764
  } catch {
6773
6765
  }
6774
- }
6766
+ }, 8e3).unref();
6767
+ } catch {
6768
+ }
6769
+ refreshPolicyCache().finally(() => {
6770
+ process.exitCode = 0;
6775
6771
  });
6776
6772
  }
6777
6773
  var SG_DONE = Symbol("sg-done");
@@ -6780,19 +6776,6 @@ function sgFinish(code) {
6780
6776
  if (!_sgDone) {
6781
6777
  _sgDone = true;
6782
6778
  process.exitCode = code;
6783
- try {
6784
- setImmediate(() => {
6785
- try {
6786
- process.exit(process.exitCode || 0);
6787
- } catch {
6788
- }
6789
- });
6790
- } catch {
6791
- try {
6792
- process.exit(code);
6793
- } catch {
6794
- }
6795
- }
6796
6779
  }
6797
6780
  throw SG_DONE;
6798
6781
  }
@@ -6804,8 +6787,14 @@ function blockTool(reason) {
6804
6787
  }));
6805
6788
  sgFinish(0);
6806
6789
  } else {
6807
- process.stderr.write(reason);
6808
- sgFinish(2);
6790
+ process.stdout.write(JSON.stringify({
6791
+ hookSpecificOutput: {
6792
+ hookEventName: "PreToolUse",
6793
+ permissionDecision: "deny",
6794
+ permissionDecisionReason: reason
6795
+ }
6796
+ }));
6797
+ sgFinish(0);
6809
6798
  }
6810
6799
  }
6811
6800
  function allowTool() {
@@ -7698,6 +7687,15 @@ if (!REFRESH_MODE) {
7698
7687
  }
7699
7688
  }
7700
7689
  (async () => {
7690
+ try {
7691
+ setTimeout(() => {
7692
+ try {
7693
+ process.exit(process.exitCode || 0);
7694
+ } catch {
7695
+ }
7696
+ }, 8e3).unref();
7697
+ } catch {
7698
+ }
7701
7699
  try {
7702
7700
  if (REFRESH_MODE)
7703
7701
  return;
@@ -7853,8 +7851,8 @@ if (!REFRESH_MODE) {
7853
7851
  process.stdout.write(JSON.stringify({ decision: "deny", reason: ghostHit }));
7854
7852
  sgFinish(0);
7855
7853
  }
7856
- process.stderr.write(ghostHit);
7857
- sgFinish(2);
7854
+ process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: ghostHit } }));
7855
+ sgFinish(0);
7858
7856
  }
7859
7857
  if (AGENT_TYPE !== "gemini-cli" && toolName === "Bash") {
7860
7858
  const rw = ghostListingRewrite(args, securityCfg.ghost);
package/hooks/guard.mjs CHANGED
@@ -281,7 +281,7 @@ 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.exitCode = 0; try { setImmediate(() => { try { process.exit(0); } catch {} }); } catch { try { process.exit(0); } catch {} } }); }
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:
@@ -301,18 +301,20 @@ if (REFRESH_MODE) { refreshPolicyCache().finally(() => { process.exitCode = 0; t
301
301
  // the threadpool has drained, so exit() is safe.
302
302
  const SG_DONE = Symbol('sg-done');
303
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.
304
316
  function sgFinish(code) {
305
- if (!_sgDone) {
306
- _sgDone = true;
307
- process.exitCode = code;
308
- // Defer the real exit by ONE loop tick. process.exit() called synchronously in
309
- // the same tick a fetch just settled aborts on Windows: a threadpool worker is
310
- // still mid-uv_async_send when exit() closes the loop's async handle. setImmediate
311
- // lets libuv drain that completion first, then we exit — ~0ms, and it still hard-
312
- // exits (killing undici keep-alive sockets) instead of waiting on them.
313
- try { setImmediate(() => { try { process.exit(process.exitCode || 0); } catch {} }); }
314
- catch { try { process.exit(code); } catch {} }
315
- }
317
+ if (!_sgDone) { _sgDone = true; process.exitCode = code; }
316
318
  throw SG_DONE;
317
319
  }
318
320
 
@@ -324,9 +326,20 @@ function blockTool(reason) {
324
326
  }));
325
327
  sgFinish(0);
326
328
  } else {
327
- // Claude Code — exit code 2
328
- process.stderr.write(reason);
329
- sgFinish(2);
329
+ // Claude Code — signal the block via the JSON PreToolUse protocol on stdout,
330
+ // NOT exit code 2. On Windows the hook runs through PowerShell, which does not
331
+ // reliably propagate a native process's exit code back to Claude Code, so an
332
+ // `exit 2` block was being seen as a "non-blocking status code" and the tool ran
333
+ // anyway. Claude only processes this JSON on exit 0 (JSON is ignored if you exit
334
+ // 2), so we MUST exit 0 here. This is shell-independent and works everywhere.
335
+ process.stdout.write(JSON.stringify({
336
+ hookSpecificOutput: {
337
+ hookEventName: 'PreToolUse',
338
+ permissionDecision: 'deny',
339
+ permissionDecisionReason: reason,
340
+ },
341
+ }));
342
+ sgFinish(0);
330
343
  }
331
344
  }
332
345
 
@@ -1440,6 +1453,10 @@ function readReferencedFiles(args, cwd) {
1440
1453
  // inside a stream callback, so the loop tears down cleanly.
1441
1454
  if (!REFRESH_MODE) { try { input += readFileSync(0, 'utf-8'); } catch {} }
1442
1455
  ;(async () => {
1456
+ // Safety backstop ONLY: the normal path exits by natural drain (~1ms after work
1457
+ // finishes). If the loop somehow fails to drain, force-exit — 8s is well past every
1458
+ // fetch timeout (≤3s) so the threadpool is idle and exit() can't hit the abort.
1459
+ try { setTimeout(() => { try { process.exit(process.exitCode || 0); } catch {} }, 8000).unref(); } catch {}
1443
1460
  try {
1444
1461
  // Background-refresh invocation has no tool call to evaluate — it already ran
1445
1462
  // refreshPolicyCache() at startup; do nothing on the (empty) stdin.
@@ -1634,8 +1651,10 @@ if (!REFRESH_MODE) { try { input += readFileSync(0, 'utf-8'); } catch {} }
1634
1651
  } catch {}
1635
1652
  await maybeSelfUpdate();
1636
1653
  if (AGENT_TYPE === 'gemini-cli') { process.stdout.write(JSON.stringify({ decision: 'deny', reason: ghostHit })); sgFinish(0); }
1637
- process.stderr.write(ghostHit);
1638
- sgFinish(2);
1654
+ // Claude Code: JSON deny on stdout + exit 0 (see blockTool — exit codes are
1655
+ // not reliable through PowerShell on Windows; JSON is ignored on exit 2).
1656
+ process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: ghostHit } }));
1657
+ sgFinish(0);
1639
1658
  }
1640
1659
  // No direct hit: if this is a listing command, rewrite it so hidden
1641
1660
  // entries are filtered out of its output (Claude Code only).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solongate/proxy",
3
- "version": "0.81.3",
3
+ "version": "0.81.5",
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": {