@wrongstack/tools 0.5.6 → 0.6.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/builtin.js CHANGED
@@ -232,6 +232,323 @@ function parseAuditOutput(json, exitCode) {
232
232
  }
233
233
  }
234
234
 
235
+ // src/circuit-breaker.ts
236
+ var DEFAULT_MAX_CONSECUTIVE_FAILURES = 5;
237
+ var DEFAULT_SLOW_CALL_THRESHOLD_MS = 6e4;
238
+ var DEFAULT_MAX_SLOW_CALLS = 3;
239
+ var DEFAULT_WINDOW_MS = 6e4;
240
+ var DEFAULT_MAX_CALLS_PER_WINDOW = 30;
241
+ var DEFAULT_COOLDOWN_MS = 3e4;
242
+ var CircuitBreaker = class {
243
+ maxConsecutiveFailures;
244
+ slowCallThresholdMs;
245
+ maxSlowCalls;
246
+ windowMs;
247
+ maxCallsPerWindow;
248
+ cooldownMs;
249
+ state = "closed";
250
+ consecutiveFailures = 0;
251
+ window = [];
252
+ lastFailureAt = null;
253
+ lastSlowAt = null;
254
+ /** Timestamp when the breaker was opened (for cooldown calculation). */
255
+ openedAt = null;
256
+ /** Timestamp when the last call ran (for half-open gate). */
257
+ lastCallAt = null;
258
+ constructor(config = {}) {
259
+ this.maxConsecutiveFailures = config.maxConsecutiveFailures ?? DEFAULT_MAX_CONSECUTIVE_FAILURES;
260
+ this.slowCallThresholdMs = config.slowCallThresholdMs ?? DEFAULT_SLOW_CALL_THRESHOLD_MS;
261
+ this.maxSlowCalls = config.maxSlowCalls ?? DEFAULT_MAX_SLOW_CALLS;
262
+ this.windowMs = config.windowMs ?? DEFAULT_WINDOW_MS;
263
+ this.maxCallsPerWindow = config.maxCallsPerWindow ?? DEFAULT_MAX_CALLS_PER_WINDOW;
264
+ this.cooldownMs = config.cooldownMs ?? DEFAULT_COOLDOWN_MS;
265
+ }
266
+ /**
267
+ * Returns true if the circuit allows a new call to proceed.
268
+ * When false, callers should abort the tool call and return a
269
+ * circuit-breaker error instead of spawning a process.
270
+ */
271
+ get canProceed() {
272
+ this._checkStateTransition();
273
+ return this.state !== "open";
274
+ }
275
+ /**
276
+ * Snapshot of the current breaker state for observability (`/kill`).
277
+ */
278
+ snapshot() {
279
+ this._checkStateTransition();
280
+ const now = Date.now();
281
+ let cooldownRemaining = null;
282
+ if (this.openedAt !== null && this.state === "open") {
283
+ const elapsed = now - this.openedAt;
284
+ cooldownRemaining = Math.max(0, this.cooldownMs - elapsed);
285
+ }
286
+ return {
287
+ state: this.state,
288
+ consecutiveFailures: this.consecutiveFailures,
289
+ slowCallsInWindow: this.window.filter((c) => c.slow).length,
290
+ callsInWindow: this.window.length,
291
+ windowMs: this.windowMs,
292
+ cooldownRemainingMs: cooldownRemaining,
293
+ lastFailureAt: this.lastFailureAt,
294
+ lastSlowAt: this.lastSlowAt
295
+ };
296
+ }
297
+ /**
298
+ * Call this BEFORE spawning a bash/exec process.
299
+ * Returns true if the call is allowed; false if the breaker is open.
300
+ * When false, callers MUST NOT spawn a process.
301
+ */
302
+ beforeCall() {
303
+ this._checkStateTransition();
304
+ if (this.state === "open") return false;
305
+ return true;
306
+ }
307
+ /**
308
+ * Call this AFTER a bash/exec process finishes (success or failure).
309
+ * `durationMs` is the wall-clock time the process ran.
310
+ * `failed` is true when the process returned a non-zero exit code or
311
+ * threw an exception before spawning.
312
+ */
313
+ afterCall(durationMs, failed) {
314
+ const now = Date.now();
315
+ this.lastCallAt = now;
316
+ if (this.state === "half-open") {
317
+ if (failed) {
318
+ this._trip();
319
+ return;
320
+ }
321
+ this._reset();
322
+ return;
323
+ }
324
+ this._pruneWindow(now);
325
+ const slow = durationMs >= this.slowCallThresholdMs;
326
+ this.window.push({ at: now, failed, slow });
327
+ if (failed) {
328
+ this.consecutiveFailures++;
329
+ this.lastFailureAt = now;
330
+ if (this.consecutiveFailures >= this.maxConsecutiveFailures) {
331
+ this._trip();
332
+ }
333
+ return;
334
+ }
335
+ this.consecutiveFailures = 0;
336
+ if (slow) {
337
+ this.lastSlowAt = now;
338
+ const slowCount = this.window.filter((c) => c.slow).length;
339
+ if (slowCount >= this.maxSlowCalls) {
340
+ this._trip();
341
+ }
342
+ }
343
+ const callCount = this.window.length;
344
+ if (callCount >= this.maxCallsPerWindow) {
345
+ this._trip();
346
+ }
347
+ }
348
+ /** Force the breaker open. Used by /kill force and Ctrl+C. */
349
+ forceOpen() {
350
+ this._trip();
351
+ }
352
+ /** Force a reset to closed. Used by tests and /kill reset. */
353
+ forceReset() {
354
+ this._reset();
355
+ }
356
+ _trip() {
357
+ if (this.state === "open") return;
358
+ this.state = "open";
359
+ this.openedAt = Date.now();
360
+ }
361
+ _reset() {
362
+ this.state = "closed";
363
+ this.consecutiveFailures = 0;
364
+ this.window = [];
365
+ this.openedAt = null;
366
+ }
367
+ /** Transition from open → half-open when cooldown elapses. */
368
+ _checkStateTransition() {
369
+ if (this.state !== "open" || this.openedAt === null) return;
370
+ const elapsed = Date.now() - this.openedAt;
371
+ if (elapsed >= this.cooldownMs) {
372
+ this.state = "half-open";
373
+ this.openedAt = null;
374
+ }
375
+ }
376
+ _pruneWindow(now) {
377
+ const cutoff = now - this.windowMs;
378
+ this.window = this.window.filter((c) => c.at >= cutoff);
379
+ }
380
+ };
381
+
382
+ // src/process-registry.ts
383
+ var DEFAULT_GRACE_MS = 2e3;
384
+ var ProcessRegistryImpl = class {
385
+ processes = /* @__PURE__ */ new Map();
386
+ breaker;
387
+ constructor(breakerConfig) {
388
+ this.breaker = new CircuitBreaker(breakerConfig);
389
+ }
390
+ register(info) {
391
+ this.processes.set(info.pid, { ...info, killed: false });
392
+ }
393
+ /** Unregister a process by PID. Called on 'close' / 'exit' events. */
394
+ unregister(pid) {
395
+ this.processes.delete(pid);
396
+ }
397
+ /** Get a single process by PID. */
398
+ get(pid) {
399
+ return this.processes.get(pid);
400
+ }
401
+ /** Get all tracked processes. */
402
+ list() {
403
+ return Array.from(this.processes.values());
404
+ }
405
+ /** Get processes filtered by name (e.g. 'bash', 'exec'). */
406
+ byName(name) {
407
+ return this.list().filter((p) => p.name === name);
408
+ }
409
+ /** Get processes filtered by session. */
410
+ bySession(sessionId) {
411
+ return this.list().filter((p) => p.sessionId === sessionId);
412
+ }
413
+ /** Count of active (non-killed) processes. */
414
+ get activeCount() {
415
+ let n = 0;
416
+ for (const p of this.processes.values()) {
417
+ if (!p.killed) n++;
418
+ }
419
+ return n;
420
+ }
421
+ /**
422
+ * Combined stats for observability — used by /ps and the TUI status bar.
423
+ */
424
+ stats() {
425
+ return {
426
+ activeCount: this.activeCount,
427
+ totalCount: this.processes.size,
428
+ breaker: this.breaker.snapshot()
429
+ };
430
+ }
431
+ /**
432
+ * Returns true if the circuit allows a new bash/exec call to proceed.
433
+ * When false, callers MUST NOT spawn a process.
434
+ */
435
+ get canProceed() {
436
+ return this.breaker.canProceed;
437
+ }
438
+ /**
439
+ * Called before spawning a process. Returns true if allowed; false if
440
+ * the circuit breaker is open.
441
+ */
442
+ beforeCall() {
443
+ return this.breaker.beforeCall();
444
+ }
445
+ /**
446
+ * Called after a process finishes. `durationMs` is wall-clock time;
447
+ * `failed` is true for non-zero exit codes.
448
+ */
449
+ afterCall(durationMs, failed) {
450
+ this.breaker.afterCall(durationMs, failed);
451
+ }
452
+ /** Force-open the circuit breaker (Ctrl+C, /kill force). */
453
+ forceBreakerOpen() {
454
+ this.breaker.forceOpen();
455
+ }
456
+ /** Force-reset the circuit breaker to closed (/kill reset). */
457
+ forceBreakerReset() {
458
+ this.breaker.forceReset();
459
+ }
460
+ /** Kill a single process by PID.
461
+ *
462
+ * On POSIX: sends SIGTERM to the *process group* (-pid) so that
463
+ * runaway grandchild processes (`sleep 9999 & disown`) are also killed.
464
+ * After `graceMs` a SIGKILL is sent if the process hasn't exited.
465
+ *
466
+ * On Windows: `child.kill()` maps to TerminateProcess — process groups
467
+ * are not meaningfully supported. A second `force=true` call sends
468
+ * SIGKILL (which maps to TerminateProcess again — the distinction is
469
+ * in the exit code, not the signal).
470
+ *
471
+ * Returns true if the process was found and kill was attempted.
472
+ */
473
+ kill(pid, opts = {}) {
474
+ const p = this.processes.get(pid);
475
+ if (!p) return false;
476
+ if (p.killed) return true;
477
+ const { force = false, graceMs = DEFAULT_GRACE_MS } = opts;
478
+ const isWin = os.platform() === "win32";
479
+ if (isWin) {
480
+ try {
481
+ p.child.kill(force ? "SIGKILL" : "SIGTERM");
482
+ } catch {
483
+ }
484
+ p.killed = true;
485
+ return true;
486
+ }
487
+ try {
488
+ if (force) {
489
+ try {
490
+ process.kill(-pid, "SIGKILL");
491
+ } catch {
492
+ p.child.kill("SIGKILL");
493
+ }
494
+ } else {
495
+ try {
496
+ process.kill(-pid, "SIGTERM");
497
+ } catch {
498
+ p.child.kill("SIGTERM");
499
+ }
500
+ const timer = setTimeout(() => {
501
+ if (this.processes.has(pid) && !p.child.killed) {
502
+ try {
503
+ process.kill(-pid, "SIGKILL");
504
+ } catch {
505
+ try {
506
+ p.child.kill("SIGKILL");
507
+ } catch {
508
+ }
509
+ }
510
+ }
511
+ }, graceMs);
512
+ timer.unref?.();
513
+ }
514
+ } catch {
515
+ }
516
+ p.killed = true;
517
+ return true;
518
+ }
519
+ /**
520
+ * Kill all tracked processes.
521
+ * Returns the PIDs that were kill()ed.
522
+ */
523
+ killAll(opts = {}) {
524
+ const pids = Array.from(this.processes.keys());
525
+ const killed = [];
526
+ for (const pid of pids) {
527
+ if (this.kill(pid, opts)) killed.push(pid);
528
+ }
529
+ return killed;
530
+ }
531
+ /**
532
+ * Kill all processes for a specific session.
533
+ * Returns the PIDs that were kill()ed.
534
+ */
535
+ killSession(sessionId, opts = {}) {
536
+ const pids = this.bySession(sessionId).map((p) => p.pid);
537
+ const killed = [];
538
+ for (const pid of pids) {
539
+ if (this.kill(pid, opts)) killed.push(pid);
540
+ }
541
+ return killed;
542
+ }
543
+ };
544
+ var _registry;
545
+ function getProcessRegistry() {
546
+ if (!_registry) {
547
+ _registry = new ProcessRegistryImpl();
548
+ }
549
+ return _registry;
550
+ }
551
+
235
552
  // src/bash.ts
236
553
  var MAX_OUTPUT = 32768;
237
554
  var DEFAULT_TIMEOUT = 3e4;
@@ -270,12 +587,27 @@ var bashTool = {
270
587
  },
271
588
  async *executeStream(input, ctx, opts) {
272
589
  if (!input?.command) throw new Error("bash: command is required");
590
+ const registry = getProcessRegistry();
591
+ if (!registry.beforeCall()) {
592
+ yield {
593
+ type: "final",
594
+ output: {
595
+ output: "",
596
+ exit_code: 1,
597
+ timed_out: false,
598
+ pid: null,
599
+ error: "bash: circuit breaker open \u2014 too many consecutive failures or slow calls. Use /kill to inspect or /kill reset to recover."
600
+ }
601
+ };
602
+ return;
603
+ }
273
604
  const timeoutMs = Math.max(1, Math.min(input.timeout_ms ?? DEFAULT_TIMEOUT, 6e5));
274
605
  const isWin = os.platform() === "win32";
275
606
  const shell = isWin ? process.env["COMSPEC"] ?? "cmd.exe" : process.env["SHELL"] ?? "/bin/bash";
276
607
  const args = isWin ? ["/c", input.command] : ["-c", input.command];
277
608
  const env = buildChildEnv(ctx.session?.id);
278
609
  const detached = isWin ? !!input.background : true;
610
+ const startedAt = Date.now();
279
611
  if (input.background) {
280
612
  let buf2 = "";
281
613
  let truncated = false;
@@ -286,7 +618,18 @@ var bashTool = {
286
618
  detached: true,
287
619
  signal: opts.signal
288
620
  });
289
- const pid = child2.pid;
621
+ const pid2 = child2.pid;
622
+ if (typeof pid2 === "number") {
623
+ registry.register({
624
+ pid: pid2,
625
+ name: "bash",
626
+ command: input.command,
627
+ startedAt: Date.now(),
628
+ sessionId: ctx.session?.id,
629
+ child: child2
630
+ });
631
+ child2.on("close", () => registry.unregister(pid2));
632
+ }
290
633
  child2.stdout?.on("data", (chunk) => {
291
634
  if (!truncated) {
292
635
  const remain = MAX_OUTPUT - buf2.length;
@@ -306,15 +649,16 @@ var bashTool = {
306
649
  }
307
650
  });
308
651
  child2.on("close", () => {
652
+ registry.afterCall(Date.now() - startedAt, false);
309
653
  });
310
- if (typeof pid === "number") child2.unref();
654
+ if (typeof pid2 === "number") child2.unref();
311
655
  yield {
312
656
  type: "final",
313
657
  output: {
314
658
  output: truncated ? buf2.slice(0, MAX_OUTPUT) + "\u2026[truncated]" : buf2,
315
659
  exit_code: null,
316
660
  timed_out: false,
317
- pid
661
+ pid: pid2
318
662
  }
319
663
  };
320
664
  return;
@@ -326,6 +670,17 @@ var bashTool = {
326
670
  detached,
327
671
  signal: opts.signal
328
672
  });
673
+ const pid = child.pid;
674
+ if (typeof pid === "number") {
675
+ registry.register({
676
+ pid,
677
+ name: "bash",
678
+ command: input.command,
679
+ startedAt: Date.now(),
680
+ sessionId: ctx.session?.id,
681
+ child
682
+ });
683
+ }
329
684
  let buf = "";
330
685
  let pending = "";
331
686
  let timedOut = false;
@@ -408,10 +763,13 @@ var bashTool = {
408
763
  });
409
764
  child.on("error", (err) => {
410
765
  for (const t of timers) clearTimeout(t);
766
+ registry.afterCall(Date.now() - startedAt, true);
411
767
  push({ kind: "error", err });
412
768
  });
413
769
  child.on("close", (code) => {
414
770
  for (const t of timers) clearTimeout(t);
771
+ if (typeof pid === "number") registry.unregister(pid);
772
+ registry.afterCall(Date.now() - startedAt, code !== 0 && code !== null);
415
773
  push({ kind: "end", code });
416
774
  });
417
775
  try {
@@ -989,14 +1347,21 @@ var BLOCKED_ARG_PATTERNS = {
989
1347
  // go run could execute arbitrary .go files; -ldflags could inject build-time code
990
1348
  go: [/^-ldflags$/],
991
1349
  // bun --preload is similar to node --require
992
- bun: [/^--preload$/],
1350
+ bun: [/^--preload$/, /^run$/, /^bunx$/, /^create$/, /^init$/],
993
1351
  // docker build/run can create containers with host access;
994
1352
  // only allow read-only commands (ps, images, version)
995
1353
  docker: [/^build$/, /^run$/, /^exec$/, /^push$/, /^pull$/],
996
1354
  // find -exec/-ok/-execdir execute arbitrary commands
997
1355
  find: [/^-exec$/, /^-exec;$/, /^-ok$/, /^-ok;$/, /^-execdir$/, /^-execdir;$/, /^-exec=/, /^-ok=/, /^-execdir=/],
998
1356
  // rm -rf / is catastrophic — block root and home targets
999
- rm: [/^\/$/, /^\/\*$/, /^~$/]
1357
+ rm: [/^\/$/, /^\/\*$/, /^~$/],
1358
+ // npm run/exec/create/pack/publish can execute arbitrary scripts or publish malware
1359
+ npm: [/^run$/, /^exec$/, /^create$/, /^init$/, /^pack$/, /^publish$/, /^deploy$/],
1360
+ // pnpm run/dlx/exec/create can execute arbitrary scripts
1361
+ pnpm: [/^run$/, /^dlx$/, /^exec$/, /^create$/, /^init$/, /^pack$/, /^publish$/, /^deploy$/],
1362
+ // npx should only be used for --version; any package name is a vector for
1363
+ // malicious package execution (typosquatting, dependency confusion)
1364
+ npx: [/^[^\s]+$/]
1000
1365
  };
1001
1366
  function validateArgs(cmd, args) {
1002
1367
  const blocked = BLOCKED_ARG_PATTERNS[cmd];
@@ -1029,6 +1394,18 @@ var execTool = {
1029
1394
  required: ["command"]
1030
1395
  },
1031
1396
  async execute(input, ctx, opts) {
1397
+ const registry = getProcessRegistry();
1398
+ if (!registry.canProceed) {
1399
+ return {
1400
+ command: input.command,
1401
+ args: input.args ?? [],
1402
+ stdout: "",
1403
+ stderr: "Circuit breaker is open \u2014 too many consecutive failures. Use /kill reset to recover.",
1404
+ exitCode: 1,
1405
+ truncated: false,
1406
+ allowed: false
1407
+ };
1408
+ }
1032
1409
  const cmd = input.command.trim();
1033
1410
  if (!cmd)
1034
1411
  return {
@@ -1088,15 +1465,23 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId) {
1088
1465
  let stdout = "";
1089
1466
  let stderr = "";
1090
1467
  let killed = false;
1468
+ const startedAt = Date.now();
1091
1469
  const child = spawn(cmd, args, {
1092
1470
  cwd,
1093
1471
  signal,
1094
1472
  env: buildChildEnv(sessionId),
1095
1473
  stdio: ["ignore", "pipe", "pipe"]
1096
1474
  });
1475
+ const registry = getProcessRegistry();
1476
+ const pid = child.pid;
1477
+ if (typeof pid === "number") {
1478
+ const fullCommand = `${cmd} ${args.join(" ")}`;
1479
+ registry.register({ pid, name: "exec", command: fullCommand, startedAt: Date.now(), sessionId, child });
1480
+ }
1097
1481
  const timer = setTimeout(() => {
1098
1482
  killed = true;
1099
- child.kill("SIGTERM");
1483
+ if (typeof pid === "number") registry.kill(pid);
1484
+ else child.kill("SIGTERM");
1100
1485
  }, timeout);
1101
1486
  child.stdout?.on("data", (chunk) => {
1102
1487
  if (stdout.length < MAX_OUTPUT2) stdout += chunk.toString();
@@ -1106,18 +1491,24 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId) {
1106
1491
  });
1107
1492
  child.on("close", (code) => {
1108
1493
  clearTimeout(timer);
1494
+ if (typeof pid === "number") registry.unregister(pid);
1495
+ const durationMs = Date.now() - startedAt;
1496
+ const exitCode = killed ? 124 : code ?? 1;
1497
+ registry.afterCall(durationMs, exitCode !== 0);
1109
1498
  resolve5({
1110
1499
  command: cmd,
1111
1500
  args,
1112
1501
  stdout: stdout.slice(0, MAX_OUTPUT2),
1113
1502
  stderr: stderr.slice(0, MAX_OUTPUT2),
1114
- exitCode: killed ? 124 : code ?? 1,
1503
+ exitCode,
1115
1504
  truncated: stdout.length >= MAX_OUTPUT2 || stderr.length >= MAX_OUTPUT2,
1116
1505
  allowed: true
1117
1506
  });
1118
1507
  });
1119
1508
  child.on("error", (err) => {
1120
1509
  clearTimeout(timer);
1510
+ if (typeof pid === "number") registry.unregister(pid);
1511
+ registry.afterCall(Date.now() - startedAt, true);
1121
1512
  resolve5({
1122
1513
  command: cmd,
1123
1514
  args,
@@ -1761,12 +2152,17 @@ async function readGitignore(dir) {
1761
2152
  }
1762
2153
 
1763
2154
  // src/_regex.ts
1764
- var MAX_PATTERN_LEN = 512;
2155
+ var MAX_PATTERN_LEN = 256;
1765
2156
  var DANGEROUS_PATTERNS = [
1766
- /(\([^)]*[+*][^)]*\))[+*]/,
1767
2157
  // (a+)+, (.*)+, etc — nested quantifier on a group with internal quantifier
1768
- /(\(\?:[^)]*[+*][^)]*\))[+*]/
1769
- // same, with non-capturing group
2158
+ /(\([^)]*[+*][^)]*\))[+*]/,
2159
+ /(\(\?:[^)]*[+*][^)]*\))[+*]/,
2160
+ // Adjacent quantifiers: a++ a*+
2161
+ /[+*]{2,}/,
2162
+ // Quantifier on alternation with length 2+
2163
+ /\([^|)]+\|[^)]+\)[+*][+*]/,
2164
+ // Greedy quantifier inside lookahead/lookbehind — (?!.*a+)
2165
+ /[\(\[][^)\]]*[+*][^)\]]*[\)\]][^)]*\?\??/
1770
2166
  ];
1771
2167
  function compileUserRegex(pattern, flags) {
1772
2168
  if (typeof pattern !== "string") {
@@ -3344,7 +3740,7 @@ async function handleBuiltIn(name, templateFiles, cwd, ctx, dryRun, vars) {
3344
3740
  const fullPath = target;
3345
3741
  if (!dryRun) {
3346
3742
  await fs9.mkdir(path.dirname(fullPath), { recursive: true });
3347
- await fs9.writeFile(fullPath, substituteVars(content, name, vars), "utf8");
3743
+ await atomicWrite(fullPath, substituteVars(content, name, vars));
3348
3744
  }
3349
3745
  files.push(resolvedPath);
3350
3746
  filesCreated++;