@wrongstack/tools 0.274.0 → 0.275.0

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/bash.js CHANGED
@@ -338,6 +338,7 @@ var CircuitBreaker = class {
338
338
  if (this.state === "open") return;
339
339
  this.state = "open";
340
340
  this.openedAt = Date.now();
341
+ this.window = [];
341
342
  try {
342
343
  this.onTrip?.();
343
344
  } catch {
@@ -377,7 +378,7 @@ var SENSITIVE_FLAG_PATTERNS = [
377
378
  /--(?:token|password|passwd|pwd|secret|api[-_]?key|api[-_]?secret|auth|credential|private[-_]?key|access[-_]?key|github[-_]?token|gh[-_]?token|bearer|jwt|oauth|pin|pincode|passphrase|access[-_]?token)(?:[=\s,][^\s]*)?/gi,
378
379
  // -f "value" style short flags
379
380
  /(?<!\w)-t(?:\s+|\s*=\s*)[^\s,]+/,
380
- /(?<!\w)-p(?:ssword)?(?:\s+|\s*=\s*)[^\s,]+/gi,
381
+ /(?<!\w)-(?:p|password)(?:\s+|\s*=\s*)[^\s,]+/gi,
381
382
  // env var–style secrets: TOKEN=x, API_KEY=y, etc.
382
383
  /(?:TOKEN|API_KEY|API_SECRET|AUTH_TOKEN|GITHUB_TOKEN|GH_TOKEN|BEARER|JWT|OAUTH|CREDENTIAL|SECRET|PRIVATE_KEY|PASSWORD|PASSWD)\s*[=:]\s*[^\s,]+/gi,
383
384
  // Generic high-entropy look: base64 strings >32 chars or hex strings >32 digits — but only
@@ -438,12 +439,35 @@ var ProcessRegistryImpl = class {
438
439
  register(info) {
439
440
  this.processes.set(info.pid, { ...info, killed: false, protected: info.protected ?? false });
440
441
  }
442
+ _isSafeSignalPid(pid) {
443
+ return Number.isInteger(pid) && pid > 1 && pid !== process.pid && pid !== process.ppid;
444
+ }
445
+ _canSignalProcessGroup(p) {
446
+ return os2.platform() !== "win32" && p.processGroupLeader === true && this._isSafeSignalPid(p.pid) && typeof p.child.pid === "number" && p.child.pid === p.pid;
447
+ }
448
+ _killChildDirect(p, signal) {
449
+ try {
450
+ p.child.kill(signal);
451
+ } catch {
452
+ }
453
+ }
454
+ _killPosix(p, signal) {
455
+ if (this._canSignalProcessGroup(p)) {
456
+ try {
457
+ process.kill(-p.pid, signal);
458
+ return;
459
+ } catch {
460
+ }
461
+ }
462
+ this._killChildDirect(p, signal);
463
+ }
441
464
  /** Unregister a process by PID. Called on 'close' / 'exit' events. */
442
465
  unregister(pid) {
443
466
  this.processes.delete(pid);
444
467
  }
445
468
  /** Get a single process by PID. */
446
469
  get(pid) {
470
+ this._pruneStale(pid);
447
471
  return this.processes.get(pid);
448
472
  }
449
473
  /** Get all tracked processes. */
@@ -607,13 +631,14 @@ var ProcessRegistryImpl = class {
607
631
  * Returns true if the process was found and kill was attempted.
608
632
  */
609
633
  kill(pid, opts = {}) {
634
+ this._pruneStale(pid);
610
635
  const p = this.processes.get(pid);
611
636
  if (!p) return false;
612
637
  if (p.killed) return true;
613
638
  if (p.protected) return false;
614
639
  const { force = false, graceMs = DEFAULT_GRACE_MS } = opts;
615
- const isWin = os2.platform() === "win32";
616
- if (isWin) {
640
+ const isWin2 = os2.platform() === "win32";
641
+ if (isWin2) {
617
642
  const liveRealChild = p.child.exitCode === null && typeof p.child.pid === "number";
618
643
  if (liveRealChild && killWin32Tree(pid)) {
619
644
  const fallback = setTimeout(() => {
@@ -636,27 +661,12 @@ var ProcessRegistryImpl = class {
636
661
  }
637
662
  try {
638
663
  if (force) {
639
- try {
640
- process.kill(-pid, "SIGKILL");
641
- } catch {
642
- p.child.kill("SIGKILL");
643
- }
664
+ this._killPosix(p, "SIGKILL");
644
665
  } else {
645
- try {
646
- process.kill(-pid, "SIGTERM");
647
- } catch {
648
- p.child.kill("SIGTERM");
649
- }
666
+ this._killPosix(p, "SIGTERM");
650
667
  const timer = setTimeout(() => {
651
668
  if (this.processes.has(pid) && !p.child.killed) {
652
- try {
653
- process.kill(-pid, "SIGKILL");
654
- } catch {
655
- try {
656
- p.child.kill("SIGKILL");
657
- } catch {
658
- }
659
- }
669
+ this._killPosix(p, "SIGKILL");
660
670
  }
661
671
  }, graceMs);
662
672
  timer.unref?.();
@@ -691,6 +701,34 @@ var ProcessRegistryImpl = class {
691
701
  }
692
702
  return killed;
693
703
  }
704
+ /**
705
+ * Check whether a tracked process entry is stale — the child has exited
706
+ * (exitCode !== null) AND it's been in the registry long enough that the
707
+ * OS may have reused the PID for a new, unrelated process.
708
+ *
709
+ * P3 #24 (before-release.md): on POSIX, PIDs are reused after process
710
+ * exit. If a tracked process exits but its 'close' event hasn't fired yet
711
+ * (or was missed), the registry still holds the entry. A new process
712
+ * gets the same PID, and PID-based lookups (get, kill, shouldBlockKill)
713
+ * may incorrectly protect or target the wrong process.
714
+ *
715
+ * The 60s threshold is conservative — the OS typically waits much longer
716
+ * before reusing a PID, but we want to clean up before that becomes a risk.
717
+ */
718
+ _isStaleEntry(entry) {
719
+ return entry.child.exitCode !== null && Date.now() - entry.startedAt > 6e4;
720
+ }
721
+ /**
722
+ * Remove a stale entry for a specific PID before any PID-based lookup.
723
+ * This prevents PID reuse from causing the registry to act on a dead
724
+ * process that has been replaced by a new one with the same PID.
725
+ */
726
+ _pruneStale(pid) {
727
+ const entry = this.processes.get(pid);
728
+ if (entry && this._isStaleEntry(entry)) {
729
+ this.processes.delete(pid);
730
+ }
731
+ }
694
732
  };
695
733
  var _registry;
696
734
  function getProcessRegistry() {
@@ -1106,17 +1144,18 @@ function getPersistentProcessRegistry() {
1106
1144
  }
1107
1145
 
1108
1146
  // src/bash-kill-guard.ts
1147
+ var isWin = os2.platform() === "win32";
1109
1148
  function extractKillCommand(command) {
1110
1149
  const normalized = command.replace(/\s+/g, " ").trim();
1111
1150
  const shellCMatch = normalized.match(
1112
- /^(?:\/\w+)?\/?(?:bin|usr)\/(?:ba)?sh\s+-[c]\s+['"](.+?)['"]$/
1151
+ /^(?:\S+(?:\s+\S+)?)?\s+-c\s+['"](.+?)['"]$/
1113
1152
  );
1114
1153
  if (shellCMatch?.[1]) {
1115
1154
  const inner = shellCMatch[1].trim();
1116
1155
  return isKillRelatedCommand(inner) ? inner : null;
1117
1156
  }
1118
1157
  const shellCUnquoted = normalized.match(
1119
- /^(?:\/\w+)?\/?(?:bin|usr)\/(?:ba)?sh\s+-[c]\s+(kill(?:\s+-[a-zA-Z]+)?(?:\s+\d+)+)$/
1158
+ /^(?:\S+(?:\s+\S+)?)?\s+-c\s+(kill(?:\s+-[a-zA-Z]+)?(?:\s+\d+)+)$/
1120
1159
  );
1121
1160
  if (shellCUnquoted?.[1]) {
1122
1161
  return shellCUnquoted[1];
@@ -1125,15 +1164,43 @@ function extractKillCommand(command) {
1125
1164
  }
1126
1165
  function isKillRelatedCommand(cmd) {
1127
1166
  const normalized = cmd.toLowerCase().replace(/\s+/g, " ").trim();
1167
+ if (isWin) {
1168
+ if (/^taskkill\s/i.test(normalized)) return true;
1169
+ if (/^tskill\s/i.test(normalized)) return true;
1170
+ return false;
1171
+ }
1128
1172
  if (/^kill(\s|$)/.test(normalized)) return true;
1129
1173
  if (/^(pkill|killall|pgrep|skill)\s/.test(normalized)) return true;
1130
- if (/^taskkill\s/i.test(normalized)) return true;
1131
- if (/^tskill\s/i.test(normalized)) return true;
1132
1174
  if (/^\/proc\/\d+\/(?:kill|fd)/.test(normalized)) return true;
1133
1175
  return false;
1134
1176
  }
1135
1177
  function parseKillCommand(command) {
1136
1178
  const normalized = command.replace(/\s+/g, " ").trim();
1179
+ if (isWin) {
1180
+ const taskkillMatch = normalized.match(/^taskkill\s+(?:\/[a-zA-Z]+\s+)*\/PID\s+(\d+)/i);
1181
+ if (taskkillMatch?.[1]) {
1182
+ const pidStr = taskkillMatch[1];
1183
+ return {
1184
+ pid: parseInt(pidStr, 10),
1185
+ signal: normalized.includes("/F") ? "FORCE" : "TERM",
1186
+ isGroupKill: false,
1187
+ isAllKill: false,
1188
+ originalCommand: command
1189
+ };
1190
+ }
1191
+ const tskillMatch = normalized.match(/^tskill\s+(\d+)/i);
1192
+ if (tskillMatch?.[1]) {
1193
+ const pidStr = tskillMatch[1];
1194
+ return {
1195
+ pid: parseInt(pidStr, 10),
1196
+ signal: "TERM",
1197
+ isGroupKill: false,
1198
+ isAllKill: false,
1199
+ originalCommand: command
1200
+ };
1201
+ }
1202
+ return null;
1203
+ }
1137
1204
  const simpleMatch = normalized.match(/^kill\s+(?:(-[a-zA-Z]+)\s+)?(\d+|-?\d+)$/);
1138
1205
  if (simpleMatch) {
1139
1206
  const signal = simpleMatch[1] ?? "-TERM";
@@ -1177,28 +1244,6 @@ function parseKillCommand(command) {
1177
1244
  if (pgrepMatch) {
1178
1245
  return null;
1179
1246
  }
1180
- const taskkillMatch = normalized.match(/^taskkill\s+(?:\/[a-zA-Z]+\s+)*\/PID\s+(\d+)/i);
1181
- if (taskkillMatch?.[1]) {
1182
- const pidStr = taskkillMatch[1];
1183
- return {
1184
- pid: parseInt(pidStr, 10),
1185
- signal: normalized.includes("/F") ? "FORCE" : "TERM",
1186
- isGroupKill: false,
1187
- isAllKill: false,
1188
- originalCommand: command
1189
- };
1190
- }
1191
- const tskillMatch = normalized.match(/^tskill\s+(\d+)/i);
1192
- if (tskillMatch?.[1]) {
1193
- const pidStr = tskillMatch[1];
1194
- return {
1195
- pid: parseInt(pidStr, 10),
1196
- signal: "TERM",
1197
- isGroupKill: false,
1198
- isAllKill: false,
1199
- originalCommand: command
1200
- };
1201
- }
1202
1247
  return null;
1203
1248
  }
1204
1249
  async function getProtectedEntries() {
@@ -1275,7 +1320,7 @@ async function checkAndBlockKillCommand(command) {
1275
1320
  }
1276
1321
  return { blocked: false };
1277
1322
  }
1278
- function pickShell(platform3, command, env) {
1323
+ function pickShell(platform4, command, env) {
1279
1324
  const override = env.get("WRONGSTACK_SHELL")?.trim().toLowerCase();
1280
1325
  if (override === "cmd" || override === "cmd.exe") return "cmd";
1281
1326
  if (override === "powershell" || override === "powershell.exe") return "powershell";
@@ -1329,7 +1374,7 @@ function looksLikePowerShellExtended(command) {
1329
1374
  }
1330
1375
  function wrapPowerShellScript(command) {
1331
1376
  const bootstrap = "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8;$ConfirmPreference='None';$WhatIfPreference=$false";
1332
- return "\uFEFF" + bootstrap + "\ntry {\n" + command + "\n} finally { exit $LASTEXITCODE }";
1377
+ return bootstrap + "\n$ErrorActionPreference='Stop'\n" + command + "\nif ($LASTEXITCODE -is [int]) { exit $LASTEXITCODE }";
1333
1378
  }
1334
1379
  var PS_VERB_RE = new RegExp(
1335
1380
  // Boundaries: start-of-string, whitespace, `;`, `&`, `|`, `(`, `{`, `,`.
@@ -1509,10 +1554,10 @@ var bashTool = {
1509
1554
  }));
1510
1555
  }
1511
1556
  const timeoutMs = Math.max(1, Math.min(input.timeout_ms ?? DEFAULT_TIMEOUT_MS, 6e5));
1512
- const isWin = os2.platform() === "win32";
1557
+ const isWin2 = os2.platform() === "win32";
1513
1558
  let plan;
1514
1559
  let winShellKind;
1515
- if (isWin) {
1560
+ if (isWin2) {
1516
1561
  const shell2 = pickShell("win32", input.command, {
1517
1562
  get: (k) => process.env[k]
1518
1563
  });
@@ -1541,7 +1586,7 @@ var bashTool = {
1541
1586
  const shell = plan.bin;
1542
1587
  const args = plan.useStdin ? [...plan.argv] : [...plan.argv, input.command];
1543
1588
  const env = buildChildEnv(ctx.session?.id);
1544
- const detached = !isWin;
1589
+ const detached = !isWin2;
1545
1590
  const startedAt = Date.now();
1546
1591
  if (input.background) {
1547
1592
  let buf2 = "";
@@ -1559,7 +1604,7 @@ var bashTool = {
1559
1604
  // apply: the child gets a hidden console that grandchildren inherit.
1560
1605
  // Windows children survive parent exit either way. POSIX keeps
1561
1606
  // detached for the process-group kill semantics.
1562
- detached: !isWin,
1607
+ detached: !isWin2,
1563
1608
  windowsHide: true
1564
1609
  });
1565
1610
  if (plan.useStdin) {
@@ -1577,7 +1622,8 @@ var bashTool = {
1577
1622
  command: redactCommand(input.command),
1578
1623
  startedAt: Date.now(),
1579
1624
  sessionId: ctx.session?.id,
1580
- child: child2
1625
+ child: child2,
1626
+ processGroupLeader: detached && child2.pid === pid2
1581
1627
  });
1582
1628
  child2.on("close", () => registry.unregister(pid2));
1583
1629
  }
@@ -1618,6 +1664,14 @@ var bashTool = {
1618
1664
  pid: pid2
1619
1665
  }
1620
1666
  };
1667
+ ctx.recordSideEffect?.({
1668
+ toolUseId: `bash-bg-${Date.now()}`,
1669
+ toolName: "bash",
1670
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
1671
+ input: { command: redactCommand(input.command), background: true },
1672
+ outcome: `launched (pid ${pid2 ?? "unknown"})`,
1673
+ risk: "shell"
1674
+ });
1621
1675
  return;
1622
1676
  }
1623
1677
  const child = spawn(shell, args, {
@@ -1628,7 +1682,7 @@ var bashTool = {
1628
1682
  stdio: [plan.useStdin ? "pipe" : "ignore", "pipe", "pipe"],
1629
1683
  detached,
1630
1684
  windowsHide: true,
1631
- ...isWin ? {} : { signal: opts.signal }
1685
+ ...isWin2 ? {} : { signal: opts.signal }
1632
1686
  });
1633
1687
  if (plan.useStdin) {
1634
1688
  try {
@@ -1645,7 +1699,8 @@ var bashTool = {
1645
1699
  command: redactCommand(input.command),
1646
1700
  startedAt: Date.now(),
1647
1701
  sessionId: ctx.session?.id,
1648
- child
1702
+ child,
1703
+ processGroupLeader: detached && child.pid === pid
1649
1704
  });
1650
1705
  }
1651
1706
  let buf = "";
@@ -1654,7 +1709,7 @@ var bashTool = {
1654
1709
  const timers = [];
1655
1710
  const spool = createOutputSpool({ tool: "bash", thresholdBytes: MAX_OUTPUT });
1656
1711
  function killWithTimeout(child2, timeoutMs2) {
1657
- if (isWin) {
1712
+ if (isWin2) {
1658
1713
  if (typeof child2.pid === "number" && child2.exitCode === null && killWin32Tree(child2.pid)) {
1659
1714
  const fallback = setTimeout(() => {
1660
1715
  if (child2.exitCode === null) {
@@ -1674,34 +1729,22 @@ var bashTool = {
1674
1729
  }
1675
1730
  return;
1676
1731
  }
1677
- try {
1678
- if (typeof child2.pid === "number") {
1679
- try {
1680
- process.kill(-child2.pid, "SIGTERM");
1681
- } catch {
1682
- child2.kill("SIGTERM");
1683
- }
1684
- } else {
1732
+ if (typeof child2.pid === "number") {
1733
+ registry.kill(child2.pid, { graceMs: timeoutMs2 });
1734
+ } else {
1735
+ try {
1685
1736
  child2.kill("SIGTERM");
1737
+ } catch {
1686
1738
  }
1687
- } catch {
1688
- }
1689
- const killTimer = setTimeout(() => {
1690
- try {
1691
- if (typeof child2.pid === "number") {
1692
- try {
1693
- process.kill(-child2.pid, "SIGKILL");
1694
- } catch {
1695
- child2.kill("SIGKILL");
1696
- }
1697
- } else {
1739
+ const killTimer = setTimeout(() => {
1740
+ try {
1698
1741
  child2.kill("SIGKILL");
1742
+ } catch {
1699
1743
  }
1700
- } catch {
1701
- }
1702
- }, timeoutMs2);
1703
- timers.push(killTimer);
1704
- killTimer.unref?.();
1744
+ }, timeoutMs2);
1745
+ timers.push(killTimer);
1746
+ killTimer.unref?.();
1747
+ }
1705
1748
  }
1706
1749
  const timer = setTimeout(() => {
1707
1750
  timedOut = true;
@@ -1710,7 +1753,7 @@ var bashTool = {
1710
1753
  timers.push(timer);
1711
1754
  timer.unref?.();
1712
1755
  const onAbort = () => killWithTimeout(child, 2e3);
1713
- if (isWin) {
1756
+ if (isWin2) {
1714
1757
  if (opts.signal.aborted) onAbort();
1715
1758
  else opts.signal.addEventListener("abort", onAbort, { once: true });
1716
1759
  }
@@ -1798,6 +1841,14 @@ ${hint}` : ""),
1798
1841
  timed_out: timedOut
1799
1842
  }
1800
1843
  };
1844
+ ctx.recordSideEffect?.({
1845
+ toolUseId: `bash-${Date.now()}`,
1846
+ toolName: "bash",
1847
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
1848
+ input: { command: redactCommand(input.command) },
1849
+ outcome: timedOut ? `timed out (exit ${c.code})` : `exit ${c.code}`,
1850
+ risk: "shell"
1851
+ });
1801
1852
  return;
1802
1853
  }
1803
1854
  const now = Date.now();
@@ -1809,7 +1860,7 @@ ${hint}` : ""),
1809
1860
  } finally {
1810
1861
  for (const t of timers) clearTimeout(t);
1811
1862
  spool.finalize();
1812
- if (isWin) opts.signal.removeEventListener("abort", onAbort);
1863
+ if (isWin2) opts.signal.removeEventListener("abort", onAbort);
1813
1864
  child.stdout?.off("data", onData);
1814
1865
  child.stderr?.off("data", onData);
1815
1866
  child.stdout?.destroy();
@@ -1819,6 +1870,38 @@ ${hint}` : ""),
1819
1870
  else killWithTimeout(child, 2e3);
1820
1871
  }
1821
1872
  }
1873
+ },
1874
+ /**
1875
+ * Tool-level teardown fired by `ToolExecutor.runToolCleanup()` when the
1876
+ * tool's run is aborted/timeout'd. The generator's `finally` block above
1877
+ * already force-kills the direct child, but that only runs if the
1878
+ * executor closes the async iterator (via `iter.return()`). When the
1879
+ * executor tears down without iterating — or a re-entrant abort races
1880
+ * with the generator — a bash-spawned process tree can survive in the
1881
+ * ProcessRegistry with `killed === false`, continuing to write files,
1882
+ * consume CPU, or hold inherited stdio pipes open for the rest of the
1883
+ * session.
1884
+ *
1885
+ * This is the defensive layer the executor calls via `tool.cleanup()`
1886
+ * (see `types/tool.ts`): kill every bash-owned process still tracked
1887
+ * for this session that hasn't exited yet. `registry.kill()` already
1888
+ * handles process-group / taskkill tree-kill and the SIGTERM→SIGKILL
1889
+ * grace window, so this just scopes the registry's existing kill path
1890
+ * to "this session's runaway bash children". Idempotent — a process
1891
+ * that already exited is skipped by `kill()` (it returns false), and a
1892
+ * `protected` infrastructure process (dev server the user intentionally
1893
+ * backgrounded) is left alone by design.
1894
+ */
1895
+ async cleanup(_input, ctx) {
1896
+ const registry = getProcessRegistry();
1897
+ const sessionId = ctx.session?.id;
1898
+ if (!sessionId) return;
1899
+ for (const entry of registry.bySession(sessionId)) {
1900
+ if (entry.name !== "bash") continue;
1901
+ if (entry.child.exitCode !== null) continue;
1902
+ if (entry.protected) continue;
1903
+ registry.kill(entry.pid, { force: true });
1904
+ }
1822
1905
  }
1823
1906
  };
1824
1907