@wrongstack/tools 0.273.1 → 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.
Files changed (44) hide show
  1. package/dist/audit.js +57 -19
  2. package/dist/audit.js.map +1 -1
  3. package/dist/bash.js +166 -83
  4. package/dist/bash.js.map +1 -1
  5. package/dist/builtin.js +669 -189
  6. package/dist/builtin.js.map +1 -1
  7. package/dist/circuit-breaker.js +1 -0
  8. package/dist/circuit-breaker.js.map +1 -1
  9. package/dist/codebase-index/index.js +24 -16
  10. package/dist/codebase-index/index.js.map +1 -1
  11. package/dist/codebase-index/worker.js +24 -16
  12. package/dist/codebase-index/worker.js.map +1 -1
  13. package/dist/edit.js +1 -1
  14. package/dist/edit.js.map +1 -1
  15. package/dist/exec.js +136 -38
  16. package/dist/exec.js.map +1 -1
  17. package/dist/fetch.js +9 -1
  18. package/dist/fetch.js.map +1 -1
  19. package/dist/format.js +57 -19
  20. package/dist/format.js.map +1 -1
  21. package/dist/glob.js +61 -11
  22. package/dist/glob.js.map +1 -1
  23. package/dist/grep.js +92 -39
  24. package/dist/grep.js.map +1 -1
  25. package/dist/index.d.ts +37 -1
  26. package/dist/index.js +680 -200
  27. package/dist/index.js.map +1 -1
  28. package/dist/install.js +65 -19
  29. package/dist/install.js.map +1 -1
  30. package/dist/lint.js +57 -19
  31. package/dist/lint.js.map +1 -1
  32. package/dist/pack.js +667 -188
  33. package/dist/pack.js.map +1 -1
  34. package/dist/process-registry.d.ts +30 -0
  35. package/dist/process-registry.js +57 -19
  36. package/dist/process-registry.js.map +1 -1
  37. package/dist/search.js.map +1 -1
  38. package/dist/test.js +57 -19
  39. package/dist/test.js.map +1 -1
  40. package/dist/typecheck.js +57 -19
  41. package/dist/typecheck.js.map +1 -1
  42. package/dist/write.js +2 -2
  43. package/dist/write.js.map +1 -1
  44. package/package.json +2 -2
package/dist/pack.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { spawn, execFileSync } from 'node:child_process';
2
2
  import * as Core from '@wrongstack/core';
3
- import { buildChildEnv, detectNewlineStyle, normalizeToLf, toStyle, atomicWrite, unifiedDiff, isPrivateIPv4, isPrivateIPv6, assessCommitSafety, compileGlob, expectDefined, recordPackageAction, detectPackageEcosystem, mutatePlan, clearPlan, getPlanTemplate, addPlanItem, deriveTodosFromPlanItem, removePlanItem, setPlanItemStatus, mutateTasks, formatTaskList, formatPlan, toErrorMessage as toErrorMessage$2, computeTaskItemProgress, loadPlan, savePlan, loadTasks, saveTasks, wstackGlobalRoot, resolveWstackPaths, truncate } from '@wrongstack/core';
3
+ import { buildChildEnv, getDesignKitLoader, isDesignStack, loadActiveKit, applyTokenOverrides, setActiveKit, recordKitChoice, recordOverrides, setDesignOverrides, materializeTokens, runDesignVerify, detectNewlineStyle, normalizeToLf, toStyle, atomicWrite, unifiedDiff, isPrivateIPv4, isPrivateIPv6, assessCommitSafety, compileGlob, expectDefined, recordPackageAction, detectPackageEcosystem, mutatePlan, clearPlan, getPlanTemplate, addPlanItem, deriveTodosFromPlanItem, removePlanItem, setPlanItemStatus, mutateTasks, formatTaskList, formatPlan, toErrorMessage as toErrorMessage$2, computeTaskItemProgress, loadPlan, savePlan, loadTasks, saveTasks, wstackGlobalRoot, resolveWstackPaths, truncate } from '@wrongstack/core';
4
4
  import * as fs from 'node:fs';
5
5
  import { statSync, mkdirSync, createWriteStream } from 'node:fs';
6
6
  import * as fs2 from 'node:fs/promises';
@@ -61,7 +61,7 @@ function createOutputSpool(opts) {
61
61
  let filePath = null;
62
62
  let failed = false;
63
63
  let finalized = false;
64
- const open = () => {
64
+ const open2 = () => {
65
65
  if (stream || failed) return;
66
66
  try {
67
67
  const dir = toolOutputDir();
@@ -94,7 +94,7 @@ function createOutputSpool(opts) {
94
94
  return;
95
95
  }
96
96
  head += text;
97
- open();
97
+ open2();
98
98
  head = "";
99
99
  return;
100
100
  }
@@ -280,6 +280,7 @@ var CircuitBreaker = class {
280
280
  if (this.state === "open") return;
281
281
  this.state = "open";
282
282
  this.openedAt = Date.now();
283
+ this.window = [];
283
284
  try {
284
285
  this.onTrip?.();
285
286
  } catch {
@@ -319,7 +320,7 @@ var SENSITIVE_FLAG_PATTERNS = [
319
320
  /--(?: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,
320
321
  // -f "value" style short flags
321
322
  /(?<!\w)-t(?:\s+|\s*=\s*)[^\s,]+/,
322
- /(?<!\w)-p(?:ssword)?(?:\s+|\s*=\s*)[^\s,]+/gi,
323
+ /(?<!\w)-(?:p|password)(?:\s+|\s*=\s*)[^\s,]+/gi,
323
324
  // env var–style secrets: TOKEN=x, API_KEY=y, etc.
324
325
  /(?:TOKEN|API_KEY|API_SECRET|AUTH_TOKEN|GITHUB_TOKEN|GH_TOKEN|BEARER|JWT|OAUTH|CREDENTIAL|SECRET|PRIVATE_KEY|PASSWORD|PASSWD)\s*[=:]\s*[^\s,]+/gi,
325
326
  // Generic high-entropy look: base64 strings >32 chars or hex strings >32 digits — but only
@@ -380,12 +381,35 @@ var ProcessRegistryImpl = class {
380
381
  register(info) {
381
382
  this.processes.set(info.pid, { ...info, killed: false, protected: info.protected ?? false });
382
383
  }
384
+ _isSafeSignalPid(pid) {
385
+ return Number.isInteger(pid) && pid > 1 && pid !== process.pid && pid !== process.ppid;
386
+ }
387
+ _canSignalProcessGroup(p) {
388
+ return os2.platform() !== "win32" && p.processGroupLeader === true && this._isSafeSignalPid(p.pid) && typeof p.child.pid === "number" && p.child.pid === p.pid;
389
+ }
390
+ _killChildDirect(p, signal) {
391
+ try {
392
+ p.child.kill(signal);
393
+ } catch {
394
+ }
395
+ }
396
+ _killPosix(p, signal) {
397
+ if (this._canSignalProcessGroup(p)) {
398
+ try {
399
+ process.kill(-p.pid, signal);
400
+ return;
401
+ } catch {
402
+ }
403
+ }
404
+ this._killChildDirect(p, signal);
405
+ }
383
406
  /** Unregister a process by PID. Called on 'close' / 'exit' events. */
384
407
  unregister(pid) {
385
408
  this.processes.delete(pid);
386
409
  }
387
410
  /** Get a single process by PID. */
388
411
  get(pid) {
412
+ this._pruneStale(pid);
389
413
  return this.processes.get(pid);
390
414
  }
391
415
  /** Get all tracked processes. */
@@ -549,13 +573,14 @@ var ProcessRegistryImpl = class {
549
573
  * Returns true if the process was found and kill was attempted.
550
574
  */
551
575
  kill(pid, opts = {}) {
576
+ this._pruneStale(pid);
552
577
  const p = this.processes.get(pid);
553
578
  if (!p) return false;
554
579
  if (p.killed) return true;
555
580
  if (p.protected) return false;
556
581
  const { force = false, graceMs = DEFAULT_GRACE_MS } = opts;
557
- const isWin3 = os2.platform() === "win32";
558
- if (isWin3) {
582
+ const isWin4 = os2.platform() === "win32";
583
+ if (isWin4) {
559
584
  const liveRealChild = p.child.exitCode === null && typeof p.child.pid === "number";
560
585
  if (liveRealChild && killWin32Tree(pid)) {
561
586
  const fallback = setTimeout(() => {
@@ -578,27 +603,12 @@ var ProcessRegistryImpl = class {
578
603
  }
579
604
  try {
580
605
  if (force) {
581
- try {
582
- process.kill(-pid, "SIGKILL");
583
- } catch {
584
- p.child.kill("SIGKILL");
585
- }
606
+ this._killPosix(p, "SIGKILL");
586
607
  } else {
587
- try {
588
- process.kill(-pid, "SIGTERM");
589
- } catch {
590
- p.child.kill("SIGTERM");
591
- }
608
+ this._killPosix(p, "SIGTERM");
592
609
  const timer = setTimeout(() => {
593
610
  if (this.processes.has(pid) && !p.child.killed) {
594
- try {
595
- process.kill(-pid, "SIGKILL");
596
- } catch {
597
- try {
598
- p.child.kill("SIGKILL");
599
- } catch {
600
- }
601
- }
611
+ this._killPosix(p, "SIGKILL");
602
612
  }
603
613
  }, graceMs);
604
614
  timer.unref?.();
@@ -633,6 +643,34 @@ var ProcessRegistryImpl = class {
633
643
  }
634
644
  return killed;
635
645
  }
646
+ /**
647
+ * Check whether a tracked process entry is stale — the child has exited
648
+ * (exitCode !== null) AND it's been in the registry long enough that the
649
+ * OS may have reused the PID for a new, unrelated process.
650
+ *
651
+ * P3 #24 (before-release.md): on POSIX, PIDs are reused after process
652
+ * exit. If a tracked process exits but its 'close' event hasn't fired yet
653
+ * (or was missed), the registry still holds the entry. A new process
654
+ * gets the same PID, and PID-based lookups (get, kill, shouldBlockKill)
655
+ * may incorrectly protect or target the wrong process.
656
+ *
657
+ * The 60s threshold is conservative — the OS typically waits much longer
658
+ * before reusing a PID, but we want to clean up before that becomes a risk.
659
+ */
660
+ _isStaleEntry(entry) {
661
+ return entry.child.exitCode !== null && Date.now() - entry.startedAt > 6e4;
662
+ }
663
+ /**
664
+ * Remove a stale entry for a specific PID before any PID-based lookup.
665
+ * This prevents PID reuse from causing the registry to act on a dead
666
+ * process that has been replaced by a new one with the same PID.
667
+ */
668
+ _pruneStale(pid) {
669
+ const entry = this.processes.get(pid);
670
+ if (entry && this._isStaleEntry(entry)) {
671
+ this.processes.delete(pid);
672
+ }
673
+ }
636
674
  };
637
675
  var _registry;
638
676
  function getProcessRegistry() {
@@ -1506,17 +1544,18 @@ function getPersistentProcessRegistry() {
1506
1544
  }
1507
1545
 
1508
1546
  // src/bash-kill-guard.ts
1547
+ var isWin2 = os2.platform() === "win32";
1509
1548
  function extractKillCommand(command) {
1510
1549
  const normalized = command.replace(/\s+/g, " ").trim();
1511
1550
  const shellCMatch = normalized.match(
1512
- /^(?:\/\w+)?\/?(?:bin|usr)\/(?:ba)?sh\s+-[c]\s+['"](.+?)['"]$/
1551
+ /^(?:\S+(?:\s+\S+)?)?\s+-c\s+['"](.+?)['"]$/
1513
1552
  );
1514
1553
  if (shellCMatch?.[1]) {
1515
1554
  const inner = shellCMatch[1].trim();
1516
1555
  return isKillRelatedCommand(inner) ? inner : null;
1517
1556
  }
1518
1557
  const shellCUnquoted = normalized.match(
1519
- /^(?:\/\w+)?\/?(?:bin|usr)\/(?:ba)?sh\s+-[c]\s+(kill(?:\s+-[a-zA-Z]+)?(?:\s+\d+)+)$/
1558
+ /^(?:\S+(?:\s+\S+)?)?\s+-c\s+(kill(?:\s+-[a-zA-Z]+)?(?:\s+\d+)+)$/
1520
1559
  );
1521
1560
  if (shellCUnquoted?.[1]) {
1522
1561
  return shellCUnquoted[1];
@@ -1525,15 +1564,43 @@ function extractKillCommand(command) {
1525
1564
  }
1526
1565
  function isKillRelatedCommand(cmd) {
1527
1566
  const normalized = cmd.toLowerCase().replace(/\s+/g, " ").trim();
1567
+ if (isWin2) {
1568
+ if (/^taskkill\s/i.test(normalized)) return true;
1569
+ if (/^tskill\s/i.test(normalized)) return true;
1570
+ return false;
1571
+ }
1528
1572
  if (/^kill(\s|$)/.test(normalized)) return true;
1529
1573
  if (/^(pkill|killall|pgrep|skill)\s/.test(normalized)) return true;
1530
- if (/^taskkill\s/i.test(normalized)) return true;
1531
- if (/^tskill\s/i.test(normalized)) return true;
1532
1574
  if (/^\/proc\/\d+\/(?:kill|fd)/.test(normalized)) return true;
1533
1575
  return false;
1534
1576
  }
1535
1577
  function parseKillCommand(command) {
1536
1578
  const normalized = command.replace(/\s+/g, " ").trim();
1579
+ if (isWin2) {
1580
+ const taskkillMatch = normalized.match(/^taskkill\s+(?:\/[a-zA-Z]+\s+)*\/PID\s+(\d+)/i);
1581
+ if (taskkillMatch?.[1]) {
1582
+ const pidStr = taskkillMatch[1];
1583
+ return {
1584
+ pid: parseInt(pidStr, 10),
1585
+ signal: normalized.includes("/F") ? "FORCE" : "TERM",
1586
+ isGroupKill: false,
1587
+ isAllKill: false,
1588
+ originalCommand: command
1589
+ };
1590
+ }
1591
+ const tskillMatch = normalized.match(/^tskill\s+(\d+)/i);
1592
+ if (tskillMatch?.[1]) {
1593
+ const pidStr = tskillMatch[1];
1594
+ return {
1595
+ pid: parseInt(pidStr, 10),
1596
+ signal: "TERM",
1597
+ isGroupKill: false,
1598
+ isAllKill: false,
1599
+ originalCommand: command
1600
+ };
1601
+ }
1602
+ return null;
1603
+ }
1537
1604
  const simpleMatch = normalized.match(/^kill\s+(?:(-[a-zA-Z]+)\s+)?(\d+|-?\d+)$/);
1538
1605
  if (simpleMatch) {
1539
1606
  const signal = simpleMatch[1] ?? "-TERM";
@@ -1577,28 +1644,6 @@ function parseKillCommand(command) {
1577
1644
  if (pgrepMatch) {
1578
1645
  return null;
1579
1646
  }
1580
- const taskkillMatch = normalized.match(/^taskkill\s+(?:\/[a-zA-Z]+\s+)*\/PID\s+(\d+)/i);
1581
- if (taskkillMatch?.[1]) {
1582
- const pidStr = taskkillMatch[1];
1583
- return {
1584
- pid: parseInt(pidStr, 10),
1585
- signal: normalized.includes("/F") ? "FORCE" : "TERM",
1586
- isGroupKill: false,
1587
- isAllKill: false,
1588
- originalCommand: command
1589
- };
1590
- }
1591
- const tskillMatch = normalized.match(/^tskill\s+(\d+)/i);
1592
- if (tskillMatch?.[1]) {
1593
- const pidStr = tskillMatch[1];
1594
- return {
1595
- pid: parseInt(pidStr, 10),
1596
- signal: "TERM",
1597
- isGroupKill: false,
1598
- isAllKill: false,
1599
- originalCommand: command
1600
- };
1601
- }
1602
1647
  return null;
1603
1648
  }
1604
1649
  async function getProtectedEntries() {
@@ -1675,7 +1720,7 @@ async function checkAndBlockKillCommand(command) {
1675
1720
  }
1676
1721
  return { blocked: false };
1677
1722
  }
1678
- function pickShell(platform3, command, env) {
1723
+ function pickShell(platform4, command, env) {
1679
1724
  const override = env.get("WRONGSTACK_SHELL")?.trim().toLowerCase();
1680
1725
  if (override === "cmd" || override === "cmd.exe") return "cmd";
1681
1726
  if (override === "powershell" || override === "powershell.exe") return "powershell";
@@ -1729,7 +1774,7 @@ function looksLikePowerShellExtended(command) {
1729
1774
  }
1730
1775
  function wrapPowerShellScript(command) {
1731
1776
  const bootstrap = "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8;$ConfirmPreference='None';$WhatIfPreference=$false";
1732
- return "\uFEFF" + bootstrap + "\ntry {\n" + command + "\n} finally { exit $LASTEXITCODE }";
1777
+ return bootstrap + "\n$ErrorActionPreference='Stop'\n" + command + "\nif ($LASTEXITCODE -is [int]) { exit $LASTEXITCODE }";
1733
1778
  }
1734
1779
  var PS_VERB_RE = new RegExp(
1735
1780
  // Boundaries: start-of-string, whitespace, `;`, `&`, `|`, `(`, `{`, `,`.
@@ -1874,10 +1919,10 @@ var bashTool = {
1874
1919
  }));
1875
1920
  }
1876
1921
  const timeoutMs = Math.max(1, Math.min(input.timeout_ms ?? DEFAULT_TIMEOUT_MS, 6e5));
1877
- const isWin3 = os2.platform() === "win32";
1922
+ const isWin4 = os2.platform() === "win32";
1878
1923
  let plan;
1879
1924
  let winShellKind;
1880
- if (isWin3) {
1925
+ if (isWin4) {
1881
1926
  const shell2 = pickShell("win32", input.command, {
1882
1927
  get: (k) => process.env[k]
1883
1928
  });
@@ -1906,7 +1951,7 @@ var bashTool = {
1906
1951
  const shell = plan.bin;
1907
1952
  const args = plan.useStdin ? [...plan.argv] : [...plan.argv, input.command];
1908
1953
  const env = buildChildEnv(ctx.session?.id);
1909
- const detached = !isWin3;
1954
+ const detached = !isWin4;
1910
1955
  const startedAt = Date.now();
1911
1956
  if (input.background) {
1912
1957
  let buf2 = "";
@@ -1924,7 +1969,7 @@ var bashTool = {
1924
1969
  // apply: the child gets a hidden console that grandchildren inherit.
1925
1970
  // Windows children survive parent exit either way. POSIX keeps
1926
1971
  // detached for the process-group kill semantics.
1927
- detached: !isWin3,
1972
+ detached: !isWin4,
1928
1973
  windowsHide: true
1929
1974
  });
1930
1975
  if (plan.useStdin) {
@@ -1942,7 +1987,8 @@ var bashTool = {
1942
1987
  command: redactCommand(input.command),
1943
1988
  startedAt: Date.now(),
1944
1989
  sessionId: ctx.session?.id,
1945
- child: child2
1990
+ child: child2,
1991
+ processGroupLeader: detached && child2.pid === pid2
1946
1992
  });
1947
1993
  child2.on("close", () => registry.unregister(pid2));
1948
1994
  }
@@ -1983,6 +2029,14 @@ var bashTool = {
1983
2029
  pid: pid2
1984
2030
  }
1985
2031
  };
2032
+ ctx.recordSideEffect?.({
2033
+ toolUseId: `bash-bg-${Date.now()}`,
2034
+ toolName: "bash",
2035
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
2036
+ input: { command: redactCommand(input.command), background: true },
2037
+ outcome: `launched (pid ${pid2 ?? "unknown"})`,
2038
+ risk: "shell"
2039
+ });
1986
2040
  return;
1987
2041
  }
1988
2042
  const child = spawn(shell, args, {
@@ -1993,7 +2047,7 @@ var bashTool = {
1993
2047
  stdio: [plan.useStdin ? "pipe" : "ignore", "pipe", "pipe"],
1994
2048
  detached,
1995
2049
  windowsHide: true,
1996
- ...isWin3 ? {} : { signal: opts.signal }
2050
+ ...isWin4 ? {} : { signal: opts.signal }
1997
2051
  });
1998
2052
  if (plan.useStdin) {
1999
2053
  try {
@@ -2010,7 +2064,8 @@ var bashTool = {
2010
2064
  command: redactCommand(input.command),
2011
2065
  startedAt: Date.now(),
2012
2066
  sessionId: ctx.session?.id,
2013
- child
2067
+ child,
2068
+ processGroupLeader: detached && child.pid === pid
2014
2069
  });
2015
2070
  }
2016
2071
  let buf = "";
@@ -2019,7 +2074,7 @@ var bashTool = {
2019
2074
  const timers = [];
2020
2075
  const spool = createOutputSpool({ tool: "bash", thresholdBytes: MAX_OUTPUT });
2021
2076
  function killWithTimeout(child2, timeoutMs2) {
2022
- if (isWin3) {
2077
+ if (isWin4) {
2023
2078
  if (typeof child2.pid === "number" && child2.exitCode === null && killWin32Tree(child2.pid)) {
2024
2079
  const fallback = setTimeout(() => {
2025
2080
  if (child2.exitCode === null) {
@@ -2039,34 +2094,22 @@ var bashTool = {
2039
2094
  }
2040
2095
  return;
2041
2096
  }
2042
- try {
2043
- if (typeof child2.pid === "number") {
2044
- try {
2045
- process.kill(-child2.pid, "SIGTERM");
2046
- } catch {
2047
- child2.kill("SIGTERM");
2048
- }
2049
- } else {
2097
+ if (typeof child2.pid === "number") {
2098
+ registry.kill(child2.pid, { graceMs: timeoutMs2 });
2099
+ } else {
2100
+ try {
2050
2101
  child2.kill("SIGTERM");
2102
+ } catch {
2051
2103
  }
2052
- } catch {
2053
- }
2054
- const killTimer = setTimeout(() => {
2055
- try {
2056
- if (typeof child2.pid === "number") {
2057
- try {
2058
- process.kill(-child2.pid, "SIGKILL");
2059
- } catch {
2060
- child2.kill("SIGKILL");
2061
- }
2062
- } else {
2104
+ const killTimer = setTimeout(() => {
2105
+ try {
2063
2106
  child2.kill("SIGKILL");
2107
+ } catch {
2064
2108
  }
2065
- } catch {
2066
- }
2067
- }, timeoutMs2);
2068
- timers.push(killTimer);
2069
- killTimer.unref?.();
2109
+ }, timeoutMs2);
2110
+ timers.push(killTimer);
2111
+ killTimer.unref?.();
2112
+ }
2070
2113
  }
2071
2114
  const timer = setTimeout(() => {
2072
2115
  timedOut = true;
@@ -2075,7 +2118,7 @@ var bashTool = {
2075
2118
  timers.push(timer);
2076
2119
  timer.unref?.();
2077
2120
  const onAbort = () => killWithTimeout(child, 2e3);
2078
- if (isWin3) {
2121
+ if (isWin4) {
2079
2122
  if (opts.signal.aborted) onAbort();
2080
2123
  else opts.signal.addEventListener("abort", onAbort, { once: true });
2081
2124
  }
@@ -2163,6 +2206,14 @@ ${hint}` : ""),
2163
2206
  timed_out: timedOut
2164
2207
  }
2165
2208
  };
2209
+ ctx.recordSideEffect?.({
2210
+ toolUseId: `bash-${Date.now()}`,
2211
+ toolName: "bash",
2212
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
2213
+ input: { command: redactCommand(input.command) },
2214
+ outcome: timedOut ? `timed out (exit ${c.code})` : `exit ${c.code}`,
2215
+ risk: "shell"
2216
+ });
2166
2217
  return;
2167
2218
  }
2168
2219
  const now = Date.now();
@@ -2174,7 +2225,7 @@ ${hint}` : ""),
2174
2225
  } finally {
2175
2226
  for (const t of timers) clearTimeout(t);
2176
2227
  spool.finalize();
2177
- if (isWin3) opts.signal.removeEventListener("abort", onAbort);
2228
+ if (isWin4) opts.signal.removeEventListener("abort", onAbort);
2178
2229
  child.stdout?.off("data", onData);
2179
2230
  child.stderr?.off("data", onData);
2180
2231
  child.stdout?.destroy();
@@ -2184,6 +2235,38 @@ ${hint}` : ""),
2184
2235
  else killWithTimeout(child, 2e3);
2185
2236
  }
2186
2237
  }
2238
+ },
2239
+ /**
2240
+ * Tool-level teardown fired by `ToolExecutor.runToolCleanup()` when the
2241
+ * tool's run is aborted/timeout'd. The generator's `finally` block above
2242
+ * already force-kills the direct child, but that only runs if the
2243
+ * executor closes the async iterator (via `iter.return()`). When the
2244
+ * executor tears down without iterating — or a re-entrant abort races
2245
+ * with the generator — a bash-spawned process tree can survive in the
2246
+ * ProcessRegistry with `killed === false`, continuing to write files,
2247
+ * consume CPU, or hold inherited stdio pipes open for the rest of the
2248
+ * session.
2249
+ *
2250
+ * This is the defensive layer the executor calls via `tool.cleanup()`
2251
+ * (see `types/tool.ts`): kill every bash-owned process still tracked
2252
+ * for this session that hasn't exited yet. `registry.kill()` already
2253
+ * handles process-group / taskkill tree-kill and the SIGTERM→SIGKILL
2254
+ * grace window, so this just scopes the registry's existing kill path
2255
+ * to "this session's runaway bash children". Idempotent — a process
2256
+ * that already exited is skipped by `kill()` (it returns false), and a
2257
+ * `protected` infrastructure process (dev server the user intentionally
2258
+ * backgrounded) is left alone by design.
2259
+ */
2260
+ async cleanup(_input, ctx) {
2261
+ const registry = getProcessRegistry();
2262
+ const sessionId = ctx.session?.id;
2263
+ if (!sessionId) return;
2264
+ for (const entry of registry.bySession(sessionId)) {
2265
+ if (entry.name !== "bash") continue;
2266
+ if (entry.child.exitCode !== null) continue;
2267
+ if (entry.protected) continue;
2268
+ registry.kill(entry.pid, { force: true });
2269
+ }
2187
2270
  }
2188
2271
  };
2189
2272
 
@@ -2654,6 +2737,8 @@ var IndexStore = class {
2654
2737
  this.db.exec("CREATE INDEX IF NOT EXISTS idx_s_kind ON symbols(kind)");
2655
2738
  this.db.exec("CREATE INDEX IF NOT EXISTS idx_s_lang ON symbols(lang)");
2656
2739
  this.db.exec("CREATE INDEX IF NOT EXISTS idx_s_file ON symbols(file)");
2740
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_s_lang_kind ON symbols(lang, kind)");
2741
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_s_file_fk ON symbols(file_fk)");
2657
2742
  this.db.exec(`
2658
2743
  CREATE TABLE IF NOT EXISTS refs (
2659
2744
  id INTEGER PRIMARY KEY,
@@ -3566,9 +3651,9 @@ async function syncGoParse(filePath, content, lang) {
3566
3651
  }
3567
3652
  }
3568
3653
  async function parseSymbols3(opts) {
3569
- const { file, lang } = opts;
3654
+ const { file, content, lang } = opts;
3570
3655
  try {
3571
- return await syncPyParse(file, lang);
3656
+ return await syncPyParse(file, content, lang);
3572
3657
  } catch {
3573
3658
  return { file, lang, symbols: [], mtimeMs: Date.now() };
3574
3659
  }
@@ -3636,8 +3721,7 @@ syms = []
3636
3721
  errors = []
3637
3722
 
3638
3723
  try:
3639
- with open(sys.argv[1], "r", encoding="utf-8") as f:
3640
- source = f.read()
3724
+ source = sys.stdin.read()
3641
3725
  tree = ast.parse(source, filename=sys.argv[1])
3642
3726
  except Exception as e:
3643
3727
  errors.append(str(e))
@@ -3777,16 +3861,21 @@ visitor.visit(tree)
3777
3861
 
3778
3862
  print(json.dumps([s.to_dict() for s in syms]))
3779
3863
  `;
3780
- async function syncPyParse(filePath, lang) {
3864
+ var _cachedScriptPath = null;
3865
+ async function syncPyParse(filePath, content, lang) {
3781
3866
  try {
3782
- const tmpDir = path3.join(os2.tmpdir(), "ws-py-parse");
3783
- await fs2.mkdir(tmpDir, { recursive: true });
3784
- const scriptPath = path3.join(tmpDir, "parse.py");
3785
- await fs2.writeFile(scriptPath, PY_PARSE_SCRIPT, "utf8");
3786
- const proc = spawn("python", [scriptPath, filePath], {
3867
+ if (!_cachedScriptPath) {
3868
+ const tmpDir = path3.join(os2.tmpdir(), "ws-py-parse");
3869
+ await fs2.mkdir(tmpDir, { recursive: true });
3870
+ _cachedScriptPath = path3.join(tmpDir, "parse.py");
3871
+ await fs2.writeFile(_cachedScriptPath, PY_PARSE_SCRIPT, "utf8");
3872
+ }
3873
+ const proc = spawn("python", [_cachedScriptPath, filePath], {
3787
3874
  stdio: ["pipe", "pipe", "pipe"],
3788
3875
  windowsHide: true
3789
3876
  });
3877
+ proc.stdin?.write(content);
3878
+ proc.stdin?.end();
3790
3879
  let stdout = "";
3791
3880
  proc.stdout?.on("data", (chunk) => {
3792
3881
  stdout += chunk.toString();
@@ -4453,7 +4542,7 @@ async function parseFile(file, content, lang) {
4453
4542
  case "go":
4454
4543
  return parseSymbols2({ file, content, lang: "go" });
4455
4544
  case "py":
4456
- return parseSymbols3({ file, lang: "py" });
4545
+ return parseSymbols3({ file, content, lang: "py" });
4457
4546
  case "rs":
4458
4547
  return parseSymbols4({ file, content, lang: "rs" });
4459
4548
  case "json":
@@ -4484,10 +4573,12 @@ async function runIndexerWithStore(store, opts) {
4484
4573
  let symbolsIndexed = 0;
4485
4574
  const isGitIgnored = await loadGitignoreMatcher(projectRoot);
4486
4575
  let files;
4576
+ let discoveredFiles = null;
4487
4577
  if (opts.files && opts.files.length > 0) {
4488
4578
  files = opts.files.map((f) => path3.resolve(projectRoot, f)).filter((f) => !isGitIgnored(path3.relative(projectRoot, f).replace(/\\/g, "/"), false));
4489
4579
  } else {
4490
4580
  files = await findSourceFiles(projectRoot, ignore, isGitIgnored, signal);
4581
+ discoveredFiles = new Set(files);
4491
4582
  }
4492
4583
  if (langs && langs.length > 0) {
4493
4584
  const langSet = new Set(langs);
@@ -4605,11 +4696,11 @@ async function runIndexerWithStore(store, opts) {
4605
4696
  filesIndexed++;
4606
4697
  }
4607
4698
  }
4608
- for (const [file_] of existingMeta) {
4609
- try {
4610
- await fs2.stat(file_);
4611
- } catch {
4612
- store.deleteFile(file_);
4699
+ if (discoveredFiles) {
4700
+ for (const [file_] of existingMeta) {
4701
+ if (!discoveredFiles.has(file_)) {
4702
+ store.deleteFile(file_);
4703
+ }
4613
4704
  }
4614
4705
  }
4615
4706
  const durationMs = Date.now() - startMs;
@@ -5126,6 +5217,229 @@ var codebaseStatsTool = {
5126
5217
  };
5127
5218
  }
5128
5219
  };
5220
+ function normalizeOverrides(set) {
5221
+ const out = {};
5222
+ if (set && typeof set === "object") {
5223
+ for (const [k, v] of Object.entries(set)) {
5224
+ if (typeof v === "string") out[k] = v;
5225
+ }
5226
+ }
5227
+ return out;
5228
+ }
5229
+ var designTool = {
5230
+ name: "design",
5231
+ category: "Design",
5232
+ description: 'Browse, load, customize, and enforce curated frontend/mobile UI design kits. Use BEFORE writing UI code to commit to one coherent, modern, responsive, dark/light, accessible design. Actions: "list" (menu), "use" (load+pin a kit for a stack), "foundations" (baseline), "set" (override kit colors/tokens), "materialize" (write the tokens to a real theme file \u2014 CSS @theme/OKLCH or native), "verify" (scan UI files for off-palette colors).',
5233
+ usageHint: 'Flow: `design {action:"use", kit:"minimal-clarity", stack:"web"}` \u2192 optionally `design {action:"set", set:{primary:"oklch(62% 0.2 25)"}}` \u2192 `design {action:"materialize"}` to write tokens to disk \u2192 implement against them \u2192 `design {action:"verify"}`.',
5234
+ permission: "auto",
5235
+ mutating: false,
5236
+ capabilities: [],
5237
+ timeoutMs: 15e3,
5238
+ inputSchema: {
5239
+ type: "object",
5240
+ properties: {
5241
+ action: {
5242
+ type: "string",
5243
+ enum: ["list", "use", "foundations", "set", "materialize", "verify"],
5244
+ description: "list = menu; use = load+pin a kit; foundations = baseline; set = override colors/tokens; materialize = write tokens to a theme file; verify = scan UI for off-palette colors. Default: list."
5245
+ },
5246
+ kit: {
5247
+ type: "string",
5248
+ description: 'Kit id (required for "use"), e.g. "minimal-clarity", "neo-brutalist".'
5249
+ },
5250
+ stack: {
5251
+ type: "string",
5252
+ enum: ["web", "react-native", "flutter", "swiftui", "compose"],
5253
+ description: "Target stack \u2014 narrows guidance + materialize format. Default: web."
5254
+ },
5255
+ set: {
5256
+ type: "object",
5257
+ description: 'Token overrides for "set"/"use": { "primary": "oklch(\u2026)", "dark.bg": "#111" }. Bare key = both themes; "light."/"dark." prefix = that theme only. Empty value clears an override.',
5258
+ additionalProperties: { type: "string" }
5259
+ },
5260
+ out: {
5261
+ type: "string",
5262
+ description: "Materialize output path (project-relative). Defaults to a per-stack convention."
5263
+ },
5264
+ force: {
5265
+ type: "boolean",
5266
+ description: "Materialize: overwrite an existing file (default false \u2014 refuses to clobber)."
5267
+ },
5268
+ files: {
5269
+ type: "array",
5270
+ items: { type: "string" },
5271
+ description: "Verify: explicit project-relative files to scan. Default: a bounded UI-file walk."
5272
+ }
5273
+ },
5274
+ required: []
5275
+ },
5276
+ async execute(input, ctx) {
5277
+ const loader = getDesignKitLoader(ctx.projectRoot);
5278
+ const action = input.action ?? "list";
5279
+ const stack = input.stack && isDesignStack(input.stack) ? input.stack : void 0;
5280
+ if (action === "foundations") {
5281
+ const text = await loader.foundationsText(stack);
5282
+ return { action, stack, output: text || "No foundations document is installed." };
5283
+ }
5284
+ if (action === "use") {
5285
+ const kitId = input.kit?.trim();
5286
+ if (!kitId) {
5287
+ const menu2 = await loader.menuText();
5288
+ return { action, output: `No kit id provided.
5289
+
5290
+ ${menu2}` };
5291
+ }
5292
+ const manifest = await loader.find(kitId);
5293
+ if (!manifest) {
5294
+ const menu2 = await loader.menuText();
5295
+ return { action, kit: kitId, output: `Kit "${kitId}" not found.
5296
+
5297
+ ${menu2}` };
5298
+ }
5299
+ const resolvedStack = stack ?? manifest.stacks[0] ?? "web";
5300
+ const body = await loader.readBody(manifest.id, resolvedStack);
5301
+ const rawTokens = await loader.readTokens(manifest.id);
5302
+ const persisted = await loadActiveKit(ctx.projectRoot);
5303
+ const keepOverrides = persisted?.kit === manifest.id ? persisted.overrides ?? {} : {};
5304
+ const overrides = { ...keepOverrides, ...normalizeOverrides(input.set) };
5305
+ const tokens = rawTokens ? applyTokenOverrides(rawTokens, overrides) : rawTokens;
5306
+ setActiveKit(ctx, manifest.id, resolvedStack, overrides);
5307
+ await recordKitChoice(
5308
+ ctx.projectRoot,
5309
+ manifest.id,
5310
+ resolvedStack,
5311
+ "design-tool",
5312
+ (/* @__PURE__ */ new Date()).toISOString(),
5313
+ Object.keys(overrides).length ? overrides : void 0
5314
+ );
5315
+ const ovLine = Object.keys(overrides).length ? `
5316
+ Active color overrides: ${Object.entries(overrides).map(([k, v]) => `${k}=${v}`).join(", ")}
5317
+ ` : "";
5318
+ const header = `# Active design kit: ${manifest.name} (${manifest.id}) \u2014 stack: ${resolvedStack}
5319
+ ${manifest.aesthetic}
5320
+ ${ovLine}
5321
+ Implement the UI faithfully to this spec. Keep light/dark, responsive, and WCAG AA.
5322
+ `;
5323
+ const tokenBlock = tokens ? `
5324
+ ## Token snapshot (overrides applied)
5325
+ \`\`\`json
5326
+ ${JSON.stringify(tokens, null, 2)}
5327
+ \`\`\`
5328
+ Tip: run \`design {action:"materialize"}\` to write these tokens to a real theme file.
5329
+ ` : "";
5330
+ return {
5331
+ action,
5332
+ kit: manifest.id,
5333
+ stack: resolvedStack,
5334
+ output: `${header}${tokenBlock}
5335
+ ${body}`
5336
+ };
5337
+ }
5338
+ if (action === "set") {
5339
+ const patch = normalizeOverrides(input.set);
5340
+ if (Object.keys(patch).length === 0) {
5341
+ return { action, output: 'No overrides given. Pass set:{ "primary": "oklch(\u2026)" }.' };
5342
+ }
5343
+ const merged = await recordOverrides(ctx.projectRoot, patch, (/* @__PURE__ */ new Date()).toISOString());
5344
+ if (!merged) {
5345
+ return {
5346
+ action,
5347
+ output: 'No active kit. Pick one first: `design {action:"use", kit:"<id>"}`.'
5348
+ };
5349
+ }
5350
+ setDesignOverrides(ctx, merged);
5351
+ return {
5352
+ action,
5353
+ output: `Overrides updated. Active overrides: ${Object.entries(merged).map(([k, v]) => `${k}=${v}`).join(", ")}
5354
+ These win over kit tokens. Run \`design {action:"materialize"}\` to write them to a theme file.`
5355
+ };
5356
+ }
5357
+ if (action === "materialize") {
5358
+ const active = await loadActiveKit(ctx.projectRoot);
5359
+ if (!active) {
5360
+ return {
5361
+ action,
5362
+ output: 'No active kit. Pick one first: `design {action:"use", kit:"<id>"}`.'
5363
+ };
5364
+ }
5365
+ const resolvedStack = stack ?? (active.stack && isDesignStack(active.stack) ? active.stack : "web");
5366
+ const rawTokens = await loader.readTokens(active.kit);
5367
+ if (!rawTokens) {
5368
+ return { action, kit: active.kit, output: `Kit "${active.kit}" has no tokens.json.` };
5369
+ }
5370
+ const tokens = applyTokenOverrides(rawTokens, active.overrides);
5371
+ const result = materializeTokens({
5372
+ tokens,
5373
+ stack: resolvedStack,
5374
+ kitId: active.kit,
5375
+ outPath: input.out
5376
+ });
5377
+ const abs = path3.join(ctx.projectRoot, result.path);
5378
+ let exists = false;
5379
+ try {
5380
+ await fs2.access(abs);
5381
+ exists = true;
5382
+ } catch {
5383
+ }
5384
+ if (exists && !input.force) {
5385
+ return {
5386
+ action,
5387
+ kit: active.kit,
5388
+ stack: resolvedStack,
5389
+ path: result.path,
5390
+ output: `${result.path} already exists. Re-run with force:true to overwrite, or write this ${result.format} yourself:
5391
+
5392
+ \`\`\`
5393
+ ${result.content}
5394
+ \`\`\``
5395
+ };
5396
+ }
5397
+ await fs2.mkdir(path3.dirname(abs), { recursive: true });
5398
+ await fs2.writeFile(abs, result.content);
5399
+ return {
5400
+ action,
5401
+ kit: active.kit,
5402
+ stack: resolvedStack,
5403
+ path: result.path,
5404
+ output: `Wrote ${result.format} to ${result.path}. Import these tokens in your UI so the kit palette is the source of truth. ${exists ? "(overwrote existing file)" : ""}`
5405
+ };
5406
+ }
5407
+ if (action === "verify") {
5408
+ const active = await loadActiveKit(ctx.projectRoot);
5409
+ if (!active) {
5410
+ return {
5411
+ action,
5412
+ output: 'No active kit to verify against. Pick one: `design {action:"use", kit:"<id>"}`.'
5413
+ };
5414
+ }
5415
+ const rawTokens = await loader.readTokens(active.kit);
5416
+ if (!rawTokens) {
5417
+ return { action, kit: active.kit, output: `Kit "${active.kit}" has no tokens.json.` };
5418
+ }
5419
+ const tokens = applyTokenOverrides(rawTokens, active.overrides);
5420
+ const report = await runDesignVerify(ctx.projectRoot, tokens, input.files);
5421
+ const pct = Math.round(report.score * 100);
5422
+ const top = report.violations.slice(0, 25).map((v) => ` ${v.file}:${v.line} \u2014 ${v.reason}: ${v.snippet}`).join("\n");
5423
+ const summary = `Adherence: ${pct}% on-palette across ${report.filesScanned} file(s). ${report.violations.length} violation(s).` + (report.violations.length ? `
5424
+ ${top}${report.violations.length > 25 ? `
5425
+ \u2026and ${report.violations.length - 25} more` : ""}
5426
+
5427
+ Replace off-palette colors with kit tokens (or the materialized CSS vars / token utilities).` : "\nNo off-palette colors found \u2014 UI adheres to the kit palette.");
5428
+ return {
5429
+ action,
5430
+ kit: active.kit,
5431
+ output: summary,
5432
+ score: report.score,
5433
+ violations: report.violations.length
5434
+ };
5435
+ }
5436
+ const menu = await loader.menuText();
5437
+ return {
5438
+ action: "list",
5439
+ output: (menu || "No design kits are installed.") + '\n\nLoad one with `design {action:"use", kit:"<id>", stack:"<stack>"}`.'
5440
+ };
5441
+ }
5442
+ };
5129
5443
  var diffTool = {
5130
5444
  name: "diff",
5131
5445
  category: "Filesystem",
@@ -5500,7 +5814,7 @@ var editTool = {
5500
5814
  const newFile = toStyle(newFileLf, style);
5501
5815
  await atomicWrite(absPath, newFile, { mode: updated.mode & 511 });
5502
5816
  const written = await fs2.stat(absPath);
5503
- ctx.recordRead(absPath, written.mtimeMs);
5817
+ ctx.recordRead(absPath, written.mtimeMs, "write");
5504
5818
  ctx.session.recordFileChange({
5505
5819
  path: absPath,
5506
5820
  action: "modified",
@@ -5543,7 +5857,7 @@ function findSimilarity(haystack, needle) {
5543
5857
  }
5544
5858
  return line;
5545
5859
  }
5546
- var isWin2 = process.platform === "win32";
5860
+ var isWin3 = process.platform === "win32";
5547
5861
  var DEFAULT_ALLOWED_COMMANDS = /* @__PURE__ */ new Set([
5548
5862
  // JS / TS toolchain
5549
5863
  "node",
@@ -5553,12 +5867,25 @@ var DEFAULT_ALLOWED_COMMANDS = /* @__PURE__ */ new Set([
5553
5867
  "npx",
5554
5868
  "bun",
5555
5869
  "deno",
5870
+ "corepack",
5556
5871
  "tsc",
5872
+ "tsx",
5873
+ "ts-node",
5874
+ "vite",
5557
5875
  "vitest",
5558
5876
  "jest",
5559
5877
  "biome",
5560
5878
  "eslint",
5561
5879
  "prettier",
5880
+ "turbo",
5881
+ "nx",
5882
+ "webpack",
5883
+ "rollup",
5884
+ "parcel",
5885
+ "next",
5886
+ "astro",
5887
+ "playwright",
5888
+ "cypress",
5562
5889
  // version control
5563
5890
  "git",
5564
5891
  // Rust
@@ -5571,10 +5898,22 @@ var DEFAULT_ALLOWED_COMMANDS = /* @__PURE__ */ new Set([
5571
5898
  "python3",
5572
5899
  "pip",
5573
5900
  "pip3",
5901
+ "pytest",
5902
+ "ruff",
5903
+ "mypy",
5904
+ "uv",
5905
+ "uvx",
5906
+ "poetry",
5907
+ "hatch",
5908
+ "tox",
5574
5909
  // Ruby
5575
5910
  "ruby",
5576
5911
  "gem",
5577
5912
  "bundle",
5913
+ // PHP
5914
+ "php",
5915
+ "composer",
5916
+ "phpunit",
5578
5917
  // JVM
5579
5918
  "java",
5580
5919
  "javac",
@@ -5586,18 +5925,25 @@ var DEFAULT_ALLOWED_COMMANDS = /* @__PURE__ */ new Set([
5586
5925
  // C / C++ / native build
5587
5926
  "make",
5588
5927
  "cmake",
5589
- // containers / orchestration (read-only subcommands; see BLOCKED_ARG_PATTERNS)
5928
+ // containers / orchestration
5590
5929
  "docker",
5930
+ "podman",
5591
5931
  "kubectl",
5592
5932
  // common POSIX file/text utilities
5933
+ "pwd",
5593
5934
  "ls",
5594
5935
  "cat",
5595
5936
  "head",
5596
5937
  "tail",
5597
5938
  "wc",
5598
5939
  "grep",
5940
+ "rg",
5599
5941
  "find",
5600
5942
  "echo",
5943
+ "sort",
5944
+ "uniq",
5945
+ "sed",
5946
+ "awk",
5601
5947
  "mkdir",
5602
5948
  "cp",
5603
5949
  "mv",
@@ -5613,8 +5959,7 @@ var MAX_ARGS = 20;
5613
5959
  var MAX_OUTPUT2 = 2e5;
5614
5960
  var DEFAULT_TIMEOUT_MS2 = 3e4;
5615
5961
  var BLOCKED_ARG_PATTERNS = {
5616
- // python -c/--command executes arbitrary code; python -m runs modules
5617
- python: [/-c$/, /^--command$/, /^-m$/, /^--module$/],
5962
+ python: [],
5618
5963
  // git --exec=<cmd> runs arbitrary commands via upload-pack/receive-pack;
5619
5964
  // -C <dir> changes working directory, bypassing cwd sandbox;
5620
5965
  // -c/--config <k>=<v> injects config that runs commands
@@ -5630,15 +5975,10 @@ var BLOCKED_ARG_PATTERNS = {
5630
5975
  /^--config=/,
5631
5976
  /^--config-env=/
5632
5977
  ],
5633
- // node -r/--require preloads arbitrary modules; --eval executes code
5634
- node: [/^-r$/, /^--require$/, /^-e$/, /^--eval$/, /^--prof-process$/],
5635
- // go run could execute arbitrary .go files; -ldflags could inject build-time code
5636
- go: [/^-ldflags$/],
5637
- // bun --preload is similar to node --require
5638
- bun: [/^--preload$/, /^run$/, /^bunx$/, /^create$/, /^init$/],
5639
- // docker build/run can create containers with host access;
5640
- // only allow read-only commands (ps, images, version)
5641
- docker: [/^build$/, /^run$/, /^exec$/, /^push$/, /^pull$/],
5978
+ node: [],
5979
+ go: [],
5980
+ bun: [],
5981
+ docker: [],
5642
5982
  // find -exec/-ok/-execdir execute arbitrary commands
5643
5983
  find: [/^-exec$/, /^-exec;$/, /^-ok$/, /^-ok;$/, /^-execdir$/, /^-execdir;$/, /^-exec=/, /^-ok=/, /^-execdir=/],
5644
5984
  // rm -rf / is catastrophic — block absolute paths, home, dot-dirs,
@@ -5646,15 +5986,49 @@ var BLOCKED_ARG_PATTERNS = {
5646
5986
  // `rm -rf ./src/*` expands to project files; `rm -rf ../../` escapes upward;
5647
5987
  // `rm -rf /*` targets the filesystem root. All are blocked.
5648
5988
  rm: [/^\//, /^~\//, /^~$/, /^\.$/, /^\.\.$/, /\*$/, /\/$/, /\/\*$/, /\.\//],
5649
- // npm run/exec/create/pack/publish can execute arbitrary scripts or publish malware
5650
- npm: [/^run$/, /^exec$/, /^create$/, /^init$/, /^pack$/, /^publish$/, /^deploy$/],
5651
- // pnpm run/dlx/exec/create can execute arbitrary scripts
5652
- pnpm: [/^run$/, /^dlx$/, /^exec$/, /^create$/, /^init$/, /^pack$/, /^publish$/, /^deploy$/],
5653
- // npx should only be used for --version; any package name is a vector for
5654
- // malicious package execution (typosquatting, dependency confusion)
5655
- npx: [/^[^\s]+$/]
5989
+ // npm/pnpm subcommands are checked separately below. Matching every arg here
5990
+ // over-blocked normal dev flows such as `pnpm vitest run ...`.
5991
+ npm: [],
5992
+ pnpm: [],
5993
+ npx: []
5656
5994
  };
5995
+ var BLOCKED_SUBCOMMANDS = {
5996
+ docker: /* @__PURE__ */ new Set(["push"]),
5997
+ podman: /* @__PURE__ */ new Set(["push"]),
5998
+ npm: /* @__PURE__ */ new Set(["publish", "deploy"]),
5999
+ pnpm: /* @__PURE__ */ new Set(["publish", "deploy"]),
6000
+ yarn: /* @__PURE__ */ new Set(["publish"])
6001
+ };
6002
+ var BLOCKED_SUBCOMMAND_SEQUENCES = {
6003
+ yarn: [["npm", "publish"]]
6004
+ };
6005
+ function firstSubcommand(args) {
6006
+ for (const arg of args) {
6007
+ if (arg === "--") return null;
6008
+ if (!arg.startsWith("-")) return arg;
6009
+ }
6010
+ return null;
6011
+ }
6012
+ function subcommandArgs(args) {
6013
+ const out = [];
6014
+ for (const arg of args) {
6015
+ if (arg === "--") break;
6016
+ if (!arg.startsWith("-")) out.push(arg);
6017
+ }
6018
+ return out;
6019
+ }
5657
6020
  function validateArgs(cmd, args) {
6021
+ const blockedSubcommands = BLOCKED_SUBCOMMANDS[cmd];
6022
+ const subcommand = firstSubcommand(args);
6023
+ if (blockedSubcommands && subcommand && blockedSubcommands.has(subcommand)) {
6024
+ return `Blocked subcommand "${subcommand}" for command "${cmd}"`;
6025
+ }
6026
+ const blockedSequences = BLOCKED_SUBCOMMAND_SEQUENCES[cmd];
6027
+ if (blockedSequences) {
6028
+ const actual = subcommandArgs(args);
6029
+ const blocked2 = blockedSequences.find((seq) => seq.every((part, idx) => actual[idx] === part));
6030
+ if (blocked2) return `Blocked subcommand "${blocked2.join(" ")}" for command "${cmd}"`;
6031
+ }
5658
6032
  const blocked = BLOCKED_ARG_PATTERNS[cmd];
5659
6033
  if (!blocked) return null;
5660
6034
  for (const arg of args) {
@@ -5781,7 +6155,7 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId) {
5781
6155
  const startedAt = Date.now();
5782
6156
  const spool = createOutputSpool({ tool: `exec-${cmd}`, thresholdBytes: MAX_OUTPUT2 });
5783
6157
  const resolved = resolveWin32Command(cmd);
5784
- const needsShell = isWin2 && (resolved.endsWith(".cmd") || resolved.endsWith(".bat"));
6158
+ const needsShell = isWin3 && (resolved.endsWith(".cmd") || resolved.endsWith(".bat"));
5785
6159
  const spawnCmd = needsShell ? cmd : resolved;
5786
6160
  if (needsShell) assertSafeWin32ShellArgs(args);
5787
6161
  let child;
@@ -5791,7 +6165,7 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId) {
5791
6165
  env: buildChildEnv(sessionId),
5792
6166
  stdio: ["ignore", "pipe", "pipe"],
5793
6167
  windowsHide: true,
5794
- ...isWin2 ? {} : { signal },
6168
+ ...isWin3 ? {} : { signal },
5795
6169
  ...needsShell ? { shell: true, windowsVerbatimArguments: true } : {}
5796
6170
  });
5797
6171
  } catch (err) {
@@ -5811,7 +6185,7 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId) {
5811
6185
  const isAbort = err && err.code === "ABORT_ERR";
5812
6186
  const stderrText = isAbort ? `Aborted: ${err.message}` : err.message;
5813
6187
  clearTimeout(timer);
5814
- if (isWin2) signal.removeEventListener("abort", onAbort);
6188
+ if (isWin3) signal.removeEventListener("abort", onAbort);
5815
6189
  if (typeof pid === "number") registry.unregister(pid);
5816
6190
  registry.afterCall(Date.now() - startedAt, true);
5817
6191
  spool.finalize();
@@ -5841,7 +6215,7 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId) {
5841
6215
  if (typeof pid === "number") registry.kill(pid, { force: true });
5842
6216
  else child.kill("SIGTERM");
5843
6217
  };
5844
- if (isWin2) {
6218
+ if (isWin3) {
5845
6219
  if (signal.aborted) onAbort();
5846
6220
  else signal.addEventListener("abort", onAbort, { once: true });
5847
6221
  }
@@ -5857,7 +6231,7 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId) {
5857
6231
  });
5858
6232
  child.on("close", (code) => {
5859
6233
  clearTimeout(timer);
5860
- if (isWin2) signal.removeEventListener("abort", onAbort);
6234
+ if (isWin3) signal.removeEventListener("abort", onAbort);
5861
6235
  if (typeof pid === "number") registry.unregister(pid);
5862
6236
  const durationMs = Date.now() - startedAt;
5863
6237
  const exitCode = killed ? 124 : code ?? 1;
@@ -6021,7 +6395,7 @@ var fetchTool = {
6021
6395
  if (!final) throw new Error("fetch: stream ended without final event");
6022
6396
  return final;
6023
6397
  },
6024
- async *executeStream(input, _ctx, opts) {
6398
+ async *executeStream(input, ctx, opts) {
6025
6399
  if (!input?.url) throw new Error("fetch: url is required");
6026
6400
  const u = new URL(input.url);
6027
6401
  if (u.protocol !== "https:" && u.protocol !== "http:") {
@@ -6093,6 +6467,14 @@ var fetchTool = {
6093
6467
  url: res.url
6094
6468
  }
6095
6469
  };
6470
+ ctx.recordSideEffect?.({
6471
+ toolUseId: `fetch-${Date.now()}`,
6472
+ toolName: "fetch",
6473
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
6474
+ input: { url: input.url, format: input.format },
6475
+ outcome: `HTTP ${res.status} (${ct})`,
6476
+ risk: "network"
6477
+ });
6096
6478
  } finally {
6097
6479
  clearTimeout(timer);
6098
6480
  }
@@ -6530,7 +6912,27 @@ function runGit2(args, cwd, signal) {
6530
6912
  });
6531
6913
  });
6532
6914
  }
6915
+
6916
+ // src/_concurrency.ts
6917
+ async function mapWithConcurrency(items, limit, fn) {
6918
+ if (items.length === 0) return [];
6919
+ const effectiveLimit = Math.max(1, Math.min(limit | 0, items.length));
6920
+ const results = new Array(items.length);
6921
+ let nextIndex = 0;
6922
+ const worker2 = async () => {
6923
+ while (true) {
6924
+ const i = nextIndex++;
6925
+ if (i >= items.length) return;
6926
+ results[i] = await fn(items[i]);
6927
+ }
6928
+ };
6929
+ await Promise.all(Array.from({ length: effectiveLimit }, worker2));
6930
+ return results;
6931
+ }
6932
+
6933
+ // src/glob.ts
6533
6934
  var DEFAULT_IGNORE2 = ["node_modules", ".git", "dist", "build", ".next", "coverage", ".turbo"];
6935
+ var WALK_CONCURRENCY = 16;
6534
6936
  var globTool = {
6535
6937
  name: "glob",
6536
6938
  category: "Filesystem",
@@ -6568,6 +6970,22 @@ var globTool = {
6568
6970
  const re = compileGlob(input.pattern);
6569
6971
  const results = [];
6570
6972
  let truncated = false;
6973
+ const pushResult = async (full) => {
6974
+ if (truncated || results.length >= limit) {
6975
+ truncated = true;
6976
+ return;
6977
+ }
6978
+ try {
6979
+ const st = await fs2.stat(full);
6980
+ if (truncated || results.length >= limit) {
6981
+ truncated = true;
6982
+ return;
6983
+ }
6984
+ results.push({ rel: full, mtime: st.mtimeMs });
6985
+ if (results.length >= limit) truncated = true;
6986
+ } catch {
6987
+ }
6988
+ };
6571
6989
  const walk = async (dir, relPrefix) => {
6572
6990
  if (results.length >= limit) {
6573
6991
  truncated = true;
@@ -6579,6 +6997,8 @@ var globTool = {
6579
6997
  } catch {
6580
6998
  return;
6581
6999
  }
7000
+ const subdirs = [];
7001
+ const matchedFiles = [];
6582
7002
  for (const e of entries) {
6583
7003
  const name = e.name;
6584
7004
  if (DEFAULT_IGNORE2.includes(name)) continue;
@@ -6586,22 +7006,36 @@ var globTool = {
6586
7006
  const rel = relPrefix ? `${relPrefix}/${name}` : name;
6587
7007
  const full = path3.join(dir, name);
6588
7008
  if (e.isDirectory()) {
6589
- await walk(full, rel);
6590
- if (truncated) return;
7009
+ subdirs.push({ full, rel });
6591
7010
  } else if (e.isFile()) {
6592
- if (re.test(rel) || re.test(name)) {
6593
- try {
6594
- const st = await fs2.stat(full);
6595
- results.push({ rel: full, mtime: st.mtimeMs });
6596
- if (results.length >= limit) {
6597
- truncated = true;
6598
- return;
6599
- }
6600
- } catch {
7011
+ re.lastIndex = 0;
7012
+ const relMatch = re.test(rel);
7013
+ re.lastIndex = 0;
7014
+ const nameMatch = re.test(name);
7015
+ if (relMatch || nameMatch) {
7016
+ matchedFiles.push(full);
7017
+ }
7018
+ } else if (e.isSymbolicLink()) {
7019
+ try {
7020
+ const st = await fs2.stat(full);
7021
+ if (st.isDirectory()) {
7022
+ subdirs.push({ full, rel });
7023
+ } else if (st.isFile()) {
7024
+ re.lastIndex = 0;
7025
+ const relMatch = re.test(rel);
7026
+ re.lastIndex = 0;
7027
+ const nameMatch = re.test(name);
7028
+ if (relMatch || nameMatch) matchedFiles.push(full);
6601
7029
  }
7030
+ } catch {
6602
7031
  }
6603
7032
  }
7033
+ if (truncated) return;
6604
7034
  }
7035
+ await mapWithConcurrency(matchedFiles, WALK_CONCURRENCY, pushResult);
7036
+ if (truncated) return;
7037
+ const remainingSubdirs = truncated ? [] : subdirs;
7038
+ await mapWithConcurrency(remainingSubdirs, WALK_CONCURRENCY, ({ full, rel }) => walk(full, rel));
6605
7039
  };
6606
7040
  await walk(base, "");
6607
7041
  results.sort((a, b) => b.mtime - a.mtime);
@@ -6665,6 +7099,8 @@ function capSubject(line) {
6665
7099
  // src/grep.ts
6666
7100
  var DEFAULT_IGNORE3 = ["node_modules", ".git", "dist", "build", ".next", "coverage"];
6667
7101
  var NATIVE_SCAN_CONCURRENCY = 32;
7102
+ var NATIVE_READ_CHUNK_BYTES = 64 * 1024;
7103
+ var NATIVE_MAX_FILE_BYTES = 1e6;
6668
7104
  var grepTool = {
6669
7105
  name: "grep",
6670
7106
  category: "Search",
@@ -6889,7 +7325,8 @@ async function runNative(input, base, mode, limit, signal) {
6889
7325
  const re = compiled.regex;
6890
7326
  const globRe = input.glob ? compileGlob(input.glob) : null;
6891
7327
  const matches = [];
6892
- const fileMatches = /* @__PURE__ */ new Map();
7328
+ const countOnlyFirstHit = mode === "count" && limit === 1;
7329
+ const maxBytes = mode === "content" ? NATIVE_MAX_FILE_BYTES : Math.min(NATIVE_MAX_FILE_BYTES, 256 * 1024);
6893
7330
  let total = 0;
6894
7331
  let stopped = false;
6895
7332
  const scanFile = async (full, name) => {
@@ -6898,34 +7335,79 @@ async function runNative(input, base, mode, limit, signal) {
6898
7335
  if (globRe) globRe.lastIndex = 0;
6899
7336
  try {
6900
7337
  const stat11 = await fs2.stat(full);
6901
- if (stat11.size > 1e6 || stopped || signal.aborted) return;
6902
- const head = await fs2.readFile(full);
6903
- if (isBinaryBuffer(head) || stopped || signal.aborted) return;
6904
- const text = head.toString("utf8");
6905
- const lines = text.split(/\r?\n/);
6906
- let fileHits = 0;
6907
- for (let i = 0; i < lines.length; i++) {
6908
- if (stopped || signal.aborted) break;
6909
- const ln = capSubject(lines[i] ?? "");
6910
- re.lastIndex = 0;
6911
- if (re.test(ln)) {
6912
- fileHits++;
6913
- total++;
6914
- if (mode === "content" && matches.length < limit) {
6915
- matches.push(`${full}:${i + 1}:${ln}`);
7338
+ if (!stat11.isFile() || stat11.size > maxBytes || stopped || signal.aborted) return;
7339
+ const file = await fs2.open(full, "r");
7340
+ try {
7341
+ let bytesReadTotal = 0;
7342
+ let lineNumber = 0;
7343
+ let fileHits = 0;
7344
+ let leftover = "";
7345
+ let binaryChecked = false;
7346
+ const buffer = Buffer.allocUnsafe(Math.min(NATIVE_READ_CHUNK_BYTES, maxBytes));
7347
+ while (!stopped && !signal.aborted && bytesReadTotal < maxBytes) {
7348
+ const remaining = maxBytes - bytesReadTotal;
7349
+ const { bytesRead } = await file.read(buffer, 0, Math.min(buffer.length, remaining), null);
7350
+ if (bytesRead === 0) break;
7351
+ const chunk = buffer.subarray(0, bytesRead);
7352
+ if (!binaryChecked) {
7353
+ binaryChecked = true;
7354
+ if (isBinaryBuffer(chunk)) return;
6916
7355
  }
7356
+ bytesReadTotal += bytesRead;
7357
+ const text = leftover + chunk.toString("utf8");
7358
+ const lines = text.split(/\r?\n/);
7359
+ leftover = lines.pop() ?? "";
7360
+ for (const rawLine of lines) {
7361
+ if (stopped || signal.aborted) break;
7362
+ lineNumber++;
7363
+ const ln = capSubject(rawLine);
7364
+ re.lastIndex = 0;
7365
+ if (!re.test(ln)) continue;
7366
+ fileHits++;
7367
+ total++;
7368
+ if (mode === "content") {
7369
+ if (matches.length < limit) matches.push(`${full}:${lineNumber}:${ln}`);
7370
+ } else if (mode === "files_with_matches") {
7371
+ if (fileHits === 1 && matches.length < limit) matches.push(full);
7372
+ break;
7373
+ } else if (fileHits === 1 && matches.length < limit) {
7374
+ matches.push(`${full}:${countOnlyFirstHit ? 1 : 0}`);
7375
+ }
7376
+ if (countOnlyFirstHit || mode !== "content" && matches.length >= limit) {
7377
+ stopped = true;
7378
+ break;
7379
+ }
7380
+ }
7381
+ if (mode === "files_with_matches" && fileHits > 0) break;
6917
7382
  }
6918
- }
6919
- if (fileHits > 0) {
6920
- fileMatches.set(full, fileHits);
6921
- if (mode === "files_with_matches" && matches.length < limit) {
6922
- matches.push(full);
7383
+ if (!stopped && !signal.aborted && leftover.length > 0) {
7384
+ lineNumber++;
7385
+ const ln = capSubject(leftover);
7386
+ re.lastIndex = 0;
7387
+ if (re.test(ln)) {
7388
+ fileHits++;
7389
+ total++;
7390
+ if (mode === "content") {
7391
+ if (matches.length < limit) matches.push(`${full}:${lineNumber}:${ln}`);
7392
+ } else if (mode === "files_with_matches") {
7393
+ if (matches.length < limit) matches.push(full);
7394
+ } else if (matches.length < limit) {
7395
+ matches.push(`${full}:${countOnlyFirstHit ? 1 : fileHits}`);
7396
+ }
7397
+ }
6923
7398
  }
6924
- if (mode === "count" && matches.length < limit) {
6925
- matches.push(`${full}:${fileHits}`);
7399
+ if (fileHits > 0) {
7400
+ if (mode === "count") {
7401
+ const idx = matches.findIndex((entry) => entry.startsWith(`${full}:`));
7402
+ if (idx !== -1) matches[idx] = `${full}:${fileHits}`;
7403
+ }
7404
+ if (mode === "files_with_matches" && matches.length >= limit) stopped = true;
6926
7405
  }
7406
+ if (mode === "content" && matches.length >= limit) stopped = true;
7407
+ if (mode === "count" && matches.length >= limit && (countOnlyFirstHit || mode !== "count")) stopped = true;
7408
+ } finally {
7409
+ await file.close();
6927
7410
  }
6928
- if (matches.length >= limit) stopped = true;
6929
7411
  } catch {
6930
7412
  }
6931
7413
  };
@@ -6938,18 +7420,20 @@ async function runNative(input, base, mode, limit, signal) {
6938
7420
  return;
6939
7421
  }
6940
7422
  const files = [];
7423
+ const subdirs = [];
6941
7424
  for (const e of entries) {
6942
7425
  if (stopped) return;
6943
7426
  if (DEFAULT_IGNORE3.includes(e.name)) continue;
6944
7427
  if (e.isSymbolicLink()) continue;
6945
7428
  const full = path3.join(dir, e.name);
6946
7429
  if (e.isDirectory()) {
6947
- await walk(full);
7430
+ subdirs.push(full);
6948
7431
  } else if (e.isFile()) {
6949
7432
  files.push({ full, name: e.name });
6950
7433
  }
6951
7434
  }
6952
7435
  await mapWithConcurrency(files, NATIVE_SCAN_CONCURRENCY, ({ full, name }) => scanFile(full, name));
7436
+ await mapWithConcurrency(subdirs, Math.min(16, NATIVE_SCAN_CONCURRENCY), walk);
6953
7437
  };
6954
7438
  await walk(base);
6955
7439
  return {
@@ -6959,20 +7443,6 @@ async function runNative(input, base, mode, limit, signal) {
6959
7443
  used: "native"
6960
7444
  };
6961
7445
  }
6962
- async function mapWithConcurrency(items, concurrency, fn) {
6963
- if (items.length === 0) return;
6964
- let next = 0;
6965
- const workerCount = Math.min(Math.max(1, concurrency), items.length);
6966
- const workers = Array.from({ length: workerCount }, async () => {
6967
- for (; ; ) {
6968
- const idx = next++;
6969
- if (idx >= items.length) return;
6970
- const item = items[idx];
6971
- if (item !== void 0) await fn(item);
6972
- }
6973
- });
6974
- await Promise.all(workers);
6975
- }
6976
7446
  var installTool = {
6977
7447
  name: "install",
6978
7448
  category: "Package Management",
@@ -7097,6 +7567,14 @@ var installTool = {
7097
7567
  }
7098
7568
  }
7099
7569
  }
7570
+ ctx.recordSideEffect?.({
7571
+ toolUseId: `install-${Date.now()}`,
7572
+ toolName: "install",
7573
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
7574
+ input: { packages: pkgList, cwd, dry_run: Boolean(input.dry_run) },
7575
+ outcome: output.dry_run ? "dry run" : result.exitCode === 0 ? `installed ${pkgList.length || "all"} packages` : `failed (exit ${result.exitCode})`,
7576
+ risk: "package"
7577
+ });
7100
7578
  yield { type: "final", output };
7101
7579
  }
7102
7580
  };
@@ -7186,8 +7664,8 @@ var jsonTool = {
7186
7664
  };
7187
7665
  }
7188
7666
  };
7189
- function query(data, path21) {
7190
- const parts = path21.replace(/\[(\d+)\]/g, ".$1").split(".").filter(Boolean);
7667
+ function query(data, path22) {
7668
+ const parts = path22.replace(/\[(\d+)\]/g, ".$1").split(".").filter(Boolean);
7191
7669
  let current = data;
7192
7670
  for (const part of parts) {
7193
7671
  if (current === null || current === void 0) return void 0;
@@ -7464,7 +7942,7 @@ async function dockerLogs(service, lines, filterRe, cwd, signal, since) {
7464
7942
  }
7465
7943
  var DOCKER_LOGS_TIMEOUT_MS = 3e3;
7466
7944
  var MAX_TAIL_LINES = 1e5;
7467
- async function fileLogs(path21, lines, filterRe, stream) {
7945
+ async function fileLogs(path22, lines, filterRe, stream) {
7468
7946
  const { createInterface } = await import('node:readline');
7469
7947
  const { createReadStream } = await import('node:fs');
7470
7948
  const entries = [];
@@ -7473,7 +7951,7 @@ async function fileLogs(path21, lines, filterRe, stream) {
7473
7951
  let writeIdx = 0;
7474
7952
  let totalLines = 0;
7475
7953
  const rl = createInterface({
7476
- input: createReadStream(path21),
7954
+ input: createReadStream(path22),
7477
7955
  crlfDelay: Number.POSITIVE_INFINITY
7478
7956
  });
7479
7957
  for await (const line of rl) {
@@ -7494,7 +7972,7 @@ async function fileLogs(path21, lines, filterRe, stream) {
7494
7972
  if (parsed) entries.push(parsed);
7495
7973
  }
7496
7974
  return {
7497
- source: path21,
7975
+ source: path22,
7498
7976
  entries,
7499
7977
  total: entries.length,
7500
7978
  truncated: totalLines > effLines,
@@ -8017,13 +8495,13 @@ ${formatTaskList(taskFile.tasks)}`
8017
8495
  }
8018
8496
  };
8019
8497
  function mkResult(plan, ok, message, todos) {
8020
- const open = plan.items.filter((i) => i.status !== "done").length;
8498
+ const open2 = plan.items.filter((i) => i.status !== "done").length;
8021
8499
  const result = {
8022
8500
  ok,
8023
8501
  message,
8024
8502
  plan: formatPlan(plan),
8025
8503
  count: plan.items.length,
8026
- open
8504
+ open: open2
8027
8505
  };
8028
8506
  if (todos !== void 0) result.todos = todos;
8029
8507
  return result;
@@ -10059,7 +10537,7 @@ var writeTool = {
10059
10537
  if (existed) {
10060
10538
  if (!ctx.hasRead(absPath)) {
10061
10539
  prev = await fs2.readFile(absPath, "utf8");
10062
- ctx.recordRead(absPath, stat12.mtimeMs);
10540
+ ctx.recordRead(absPath, stat12.mtimeMs, "write");
10063
10541
  } else {
10064
10542
  prev = await fs2.readFile(absPath, "utf8");
10065
10543
  }
@@ -10073,7 +10551,7 @@ var writeTool = {
10073
10551
  const diff = existed ? unifiedDiff(prev, input.content, { fromFile: input.path, toFile: input.path }) : `+++ ${input.path}
10074
10552
  + (new file, ${input.content.split("\n").length} lines)`;
10075
10553
  const stat11 = await fs2.stat(absPath);
10076
- ctx.recordRead(absPath, stat11.mtimeMs);
10554
+ ctx.recordRead(absPath, stat11.mtimeMs, "write");
10077
10555
  ctx.session.recordFileChange({
10078
10556
  path: absPath,
10079
10557
  action: existed ? "modified" : "created",
@@ -10119,6 +10597,7 @@ var builtinTools = [
10119
10597
  logsTool,
10120
10598
  documentTool,
10121
10599
  scaffoldTool,
10600
+ designTool,
10122
10601
  toolSearchTool,
10123
10602
  toolUseTool,
10124
10603
  batchToolUseTool,