@wrongstack/plugins 0.307.1 → 0.308.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.
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Redos guard — run a regex inside a `node:worker_threads` worker so
3
+ * the host can actually terminate it on a wall-clock budget.
4
+ *
5
+ * Why a worker thread?
6
+ * A `setTimeout`-based watchdog cannot interrupt a synchronous
7
+ * CPU-bound regex in Node.js's single-threaded event loop. The
8
+ * `setImmediate`/`setTimeout` race fires whichever wins the next
9
+ * event-loop tick; if the regex blocks the loop synchronously for
10
+ * 7 seconds, the timer has long since fired and the regex still
11
+ * returns a result — only after the loop is unblocked does the
12
+ * `setImmediate` callback resume and resolve `{ timedOut: false }`.
13
+ *
14
+ * The only honest fix is to run the regex in a separate thread that
15
+ * the host can `worker.terminate()`. `node:worker_threads` gives us
16
+ * that, and the per-thread cost is amortized by the runtime helper
17
+ * itself (the host doesn't pay for the thread except when it
18
+ * invokes `withReDoSGuard`).
19
+ *
20
+ * Why is this here, not inside each plugin?
21
+ * Three plugins (`secret-scanner`, `prompt-firewall`, `path-guard`)
22
+ * need the same contract. Three copies would drift; one copy is
23
+ * auditable and testable.
24
+ *
25
+ * Contract:
26
+ * `withReDoSGuard(re, input, ms)` returns:
27
+ * { timedOut: false, match: RegExpExecArray | null } on normal completion
28
+ * { timedOut: true, match: null } on timeout
29
+ */
30
+ export interface ReDoSResult {
31
+ /** True when the regex did not complete within the wall-clock budget. */
32
+ timedOut: boolean;
33
+ /** The match result (groups, indices) when `timedOut === false`; null otherwise. */
34
+ match: RegExpExecArray | null;
35
+ }
36
+ export interface ReDoSOptions {
37
+ /** Wall-clock budget in ms. Default 50. */
38
+ budgetMs?: number;
39
+ /**
40
+ * Optional hook invoked exactly once when the budget is exceeded.
41
+ * Called synchronously after the regex is terminated. Default: no-op.
42
+ */
43
+ onTimeout?: (info: {
44
+ regex: RegExp;
45
+ input: string;
46
+ budgetMs: number;
47
+ elapsedMs: number;
48
+ }) => void;
49
+ }
50
+ /**
51
+ * Run `re.exec(input)` inside a worker thread with a wall-clock
52
+ * watchdog. The worker is terminated when the budget elapses; the
53
+ * regex cannot keep running.
54
+ *
55
+ * Returns a Promise; resolved with `{ timedOut, match }`.
56
+ */
57
+ export declare function withReDoSGuard(re: RegExp, input: string, budgetMs?: number, options?: ReDoSOptions): Promise<ReDoSResult>;
58
+ /**
59
+ * Convenience: build a guarded matcher.
60
+ *
61
+ * ```ts
62
+ * const matchCredential = guardedMatcher(/AKIA[0-9A-Z]{16}/g, 25);
63
+ * const r = await matchCredential(line);
64
+ * if (r.timedOut) counters.redosTimeouts++;
65
+ * else if (r.match) report(r.match);
66
+ * ```
67
+ */
68
+ export declare function guardedMatcher(re: RegExp, budgetMs?: number, onTimeout?: ReDoSOptions['onTimeout']): (input: string) => Promise<ReDoSResult>;
69
+ //# sourceMappingURL=redos-guard.d.ts.map
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Sandbox — canonical project-path validation with symlink resolution.
3
+ *
4
+ * Why a new helper when `runtime/index.ts` already exports
5
+ * `withinProject` and `sanitizeRunnerPath`?
6
+ *
7
+ * - `withinProject` does NOT resolve symlinks: a path like
8
+ * `project/link-to-/etc/passwd` reports as inside the project
9
+ * even though the resolved target is outside.
10
+ * - `sanitizeRunnerPath` is for runner argv (linter binaries),
11
+ * not user-supplied tool input. The two paths share the
12
+ * leading-dash + length checks but `sanitizeRunnerPath`
13
+ * rejects a `cwd` for which it cannot resolve; user-input
14
+ * sandbox needs canonicalization instead.
15
+ *
16
+ * The previous design (per SAGE memory T-03) relied on three
17
+ * duplicate `withinProject` copies in `runtime/index.ts`,
18
+ * `file-watcher/index.ts`, and `path-guard/glob.ts`. They drifted
19
+ * on edge cases. This helper is the single replacement.
20
+ *
21
+ * Contract:
22
+ * `safePath(input, projectRoot?)` returns the canonical absolute
23
+ * path inside the project on success, or `null` on rejection.
24
+ *
25
+ * Rejection cases:
26
+ * - empty string
27
+ * - length > 4096 bytes (matches `runtime.withinProject`)
28
+ * - leading-dash (option smuggling)
29
+ * - cannot be resolved (path doesn't exist or EPERM)
30
+ * - resolved path escapes the project root
31
+ * - symlink target is outside the project
32
+ */
33
+ export interface SafePathOptions {
34
+ /**
35
+ * Project root for the sandbox. Defaults to `process.cwd()`. Pass
36
+ * the session cwd from the plugin host when available so that
37
+ * tools running in a subdirectory are scoped to that directory.
38
+ */
39
+ projectRoot?: string;
40
+ /**
41
+ * If true (default), follow symlinks via `realpathSync`. Set false
42
+ * for plugins that need to record the literal path the user wrote
43
+ * (e.g. checkpoint capture, git diff).
44
+ */
45
+ followSymlinks?: boolean;
46
+ }
47
+ /**
48
+ * Resolve `input` to an absolute path inside the project root.
49
+ *
50
+ * Returns `null` for empty/oversized/leading-dash inputs and for
51
+ * paths whose real target escapes the project.
52
+ */
53
+ export declare function safePath(input: string, options?: SafePathOptions): string | null;
54
+ /**
55
+ * Boolean convenience for callers that don't need the canonical path.
56
+ * Equivalent to `safePath(input, options) !== null`.
57
+ */
58
+ export declare function isInsideProject(input: string, options?: SafePathOptions): boolean;
59
+ //# sourceMappingURL=sandbox.d.ts.map
package/dist/runtime.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // src/runtime/index.ts
2
2
  import { execFile } from "node:child_process";
3
3
  import { existsSync, readdirSync, statSync } from "node:fs";
4
- import { basename, extname as extname2, isAbsolute as isAbsolute2, relative as relative2, resolve as resolve2 } from "node:path";
4
+ import { basename, extname as extname2, isAbsolute as isAbsolute3, relative as relative3, resolve as resolve3 } from "node:path";
5
5
 
6
6
  // src/runtime/llm.ts
7
7
  function stripOuterMarkdownFence(text) {
@@ -347,6 +347,162 @@ function releaseHandles(state, keys) {
347
347
  }
348
348
  }
349
349
 
350
+ // src/runtime/redos-guard.ts
351
+ import { Worker } from "node:worker_threads";
352
+ function withReDoSGuard(re, input, budgetMs = 50, options = {}) {
353
+ const opts = { budgetMs, ...options };
354
+ const start = Date.now();
355
+ const workerSource = buildWorkerSource(re.source, input, re.flags);
356
+ const worker = new Worker(workerSource, {
357
+ eval: true,
358
+ name: `redos-guard:${re.source.slice(0, 32)}`
359
+ });
360
+ return new Promise((resolve4) => {
361
+ let settled = false;
362
+ const onMessage = (msg) => {
363
+ if (settled) return;
364
+ settled = true;
365
+ clearTimeout(timer);
366
+ worker.terminate().catch(() => {
367
+ });
368
+ if (!msg.ok) {
369
+ resolve4({ timedOut: true, match: null });
370
+ return;
371
+ }
372
+ resolve4({ timedOut: false, match: msg.match });
373
+ };
374
+ const onError = () => {
375
+ if (settled) return;
376
+ settled = true;
377
+ clearTimeout(timer);
378
+ worker.terminate().catch(() => {
379
+ });
380
+ resolve4({ timedOut: true, match: null });
381
+ };
382
+ const timer = setTimeout(() => {
383
+ if (settled) return;
384
+ settled = true;
385
+ const elapsedMs = Date.now() - start;
386
+ worker.terminate().catch(() => {
387
+ });
388
+ try {
389
+ opts.onTimeout?.({
390
+ regex: re,
391
+ input,
392
+ budgetMs: opts.budgetMs,
393
+ elapsedMs
394
+ });
395
+ } catch {
396
+ }
397
+ resolve4({ timedOut: true, match: null });
398
+ }, opts.budgetMs);
399
+ timer.unref?.();
400
+ worker.on("message", onMessage);
401
+ worker.on("error", onError);
402
+ });
403
+ }
404
+ function buildWorkerSource(source, input, flags) {
405
+ const S = JSON.stringify(source);
406
+ const I = JSON.stringify(input);
407
+ const F = JSON.stringify(flags);
408
+ return `
409
+ const { parentPort } = require('node:worker_threads');
410
+ const source = ${S};
411
+ const input = ${I};
412
+ const flags = ${F};
413
+ try {
414
+ const re = new RegExp(source, flags);
415
+ const match = re.exec(input);
416
+ // parentPort.postMessage, NOT bare postMessage: with eval:true
417
+ // workers this Node version does not expose the bare postMessage
418
+ // global \u2014 the worker throws ReferenceError at startup and the
419
+ // host misreads it as a timeout (positive-path regression).
420
+ parentPort.postMessage({ ok: true, match });
421
+ } catch (err) {
422
+ parentPort.postMessage({ ok: false, error: err && err.message ? err.message : String(err) });
423
+ }
424
+ `;
425
+ }
426
+ function guardedMatcher(re, budgetMs = 50, onTimeout) {
427
+ return (input) => withReDoSGuard(re, input, budgetMs, onTimeout ? { onTimeout } : {});
428
+ }
429
+
430
+ // src/runtime/sandbox.ts
431
+ import { realpathSync } from "node:fs";
432
+ import { isAbsolute as isAbsolute2, relative as relative2, resolve as resolve2 } from "node:path";
433
+ var MAX_PATH_BYTES = 4096;
434
+ function safePath(input, options = {}) {
435
+ if (typeof input !== "string") return null;
436
+ if (input.length === 0 || input.length > MAX_PATH_BYTES) return null;
437
+ if (input.startsWith("-")) return null;
438
+ const projectRoot = resolve2(options.projectRoot ?? process.cwd());
439
+ const lexical = isAbsolute2(input) ? resolve2(input) : resolve2(projectRoot, input);
440
+ if (!withinLexical(projectRoot, lexical)) return null;
441
+ if (options.followSymlinks !== false) {
442
+ let real;
443
+ try {
444
+ real = realpathSync(lexical);
445
+ } catch {
446
+ return null;
447
+ }
448
+ if (!withinLexical(projectRoot, real)) return null;
449
+ return real;
450
+ }
451
+ return lexical;
452
+ }
453
+ function withinLexical(projectRoot, candidate) {
454
+ const rel = relative2(projectRoot, candidate);
455
+ if (rel === "" || rel === ".") return true;
456
+ if (rel.startsWith("..")) return false;
457
+ if (isAbsolute2(rel)) return false;
458
+ return true;
459
+ }
460
+ function isInsideProject(input, options = {}) {
461
+ return safePath(input, options) !== null;
462
+ }
463
+
464
+ // src/runtime/h1-state.ts
465
+ function createH1State(initial) {
466
+ const handles = /* @__PURE__ */ new Map();
467
+ const safeRelease = (unregister) => {
468
+ try {
469
+ unregister();
470
+ } catch {
471
+ }
472
+ };
473
+ return {
474
+ state: initial,
475
+ register(key, unregister) {
476
+ const prior = handles.get(key);
477
+ if (prior) {
478
+ safeRelease(prior);
479
+ handles.delete(key);
480
+ }
481
+ if (unregister) {
482
+ handles.set(key, unregister);
483
+ }
484
+ },
485
+ release(key) {
486
+ const prior = handles.get(key);
487
+ if (!prior) return;
488
+ handles.delete(key);
489
+ safeRelease(prior);
490
+ },
491
+ releaseAll() {
492
+ for (const unregister of handles.values()) {
493
+ safeRelease(unregister);
494
+ }
495
+ handles.clear();
496
+ },
497
+ size() {
498
+ return handles.size;
499
+ },
500
+ keys() {
501
+ return [...handles.keys()];
502
+ }
503
+ };
504
+ }
505
+
350
506
  // src/runtime/index.ts
351
507
  var META_CHARS = /["'`;&|<>\r\n]/;
352
508
  var MAX_BUFFER_BYTES = 16 * 1024 * 1024;
@@ -361,9 +517,9 @@ function safeSplit(command) {
361
517
  function withinProjectPath(projectRoot, candidate) {
362
518
  if (candidate.length === 0 || candidate.length > 4096) return false;
363
519
  if (hasLeadingDash(candidate)) return false;
364
- const resolved = isAbsolute2(candidate) ? resolve2(candidate) : resolve2(projectRoot, candidate);
365
- const rel = relative2(projectRoot, resolved);
366
- return rel === "" || !rel.startsWith("..") && !isAbsolute2(rel);
520
+ const resolved = isAbsolute3(candidate) ? resolve3(candidate) : resolve3(projectRoot, candidate);
521
+ const rel = relative3(projectRoot, resolved);
522
+ return rel === "" || !rel.startsWith("..") && !isAbsolute3(rel);
367
523
  }
368
524
  function everyFlagAllowed(allowed, args) {
369
525
  for (const arg of args) {
@@ -375,14 +531,14 @@ function everyFlagAllowed(allowed, args) {
375
531
  }
376
532
  function sanitizeRunnerPath(value, options = {}) {
377
533
  if (!value || hasLeadingDash(value)) return null;
378
- const projectRoot = resolve2(options.projectRoot ?? process.cwd());
534
+ const projectRoot = resolve3(options.projectRoot ?? process.cwd());
379
535
  if (!withinProjectPath(projectRoot, value)) return null;
380
- return isAbsolute2(value) ? resolve2(value) : resolve2(projectRoot, value);
536
+ return isAbsolute3(value) ? resolve3(value) : resolve3(projectRoot, value);
381
537
  }
382
538
  function resolveRunnerCommand(runtime, command, options = {}) {
383
539
  const tokens = safeSplit(command);
384
540
  if (!tokens || tokens.length === 0) return null;
385
- const projectRoot = resolve2(options.projectRoot ?? process.cwd());
541
+ const projectRoot = resolve3(options.projectRoot ?? process.cwd());
386
542
  const launcher = runtime.packageManager;
387
543
  const [head, second, ...rest] = tokens;
388
544
  if (!head) return null;
@@ -413,7 +569,7 @@ function resolveRunnerCommand(runtime, command, options = {}) {
413
569
  return { cmd: head, args: [second, exe, ...tail], display };
414
570
  }
415
571
  }
416
- if (isAbsolute2(head)) {
572
+ if (isAbsolute3(head)) {
417
573
  if (!withinProjectPath(projectRoot, head)) return null;
418
574
  const base = basename(head);
419
575
  if (base !== runtime.executable && base !== launcher) return null;
@@ -434,7 +590,7 @@ function runRunnerCommand(argv, options) {
434
590
  });
435
591
  }
436
592
  return new Promise((resolvePromise) => {
437
- const projectRoot = resolve2(options.projectRoot ?? process.cwd());
593
+ const projectRoot = resolve3(options.projectRoot ?? process.cwd());
438
594
  const trimmedCwd = options.cwd.trim();
439
595
  if (!withinProjectPath(projectRoot, trimmedCwd)) {
440
596
  resolvePromise({
@@ -448,6 +604,7 @@ function runRunnerCommand(argv, options) {
448
604
  }
449
605
  let timedOut = false;
450
606
  let spawnErrored = false;
607
+ const start = Date.now();
451
608
  const stdoutChunks = [];
452
609
  const stderrChunks = [];
453
610
  let stdoutBytes = 0;
@@ -477,6 +634,15 @@ function runRunnerCommand(argv, options) {
477
634
  timeout: options.timeoutMs,
478
635
  signal: options.signal,
479
636
  maxBuffer: MAX_BUFFER_BYTES,
637
+ // execFile defaults `encoding` to 'utf8', which makes the
638
+ // stdout/stderr `data` events emit *strings*. The chunk arrays
639
+ // below are typed Buffer[] and every consumer runs them through
640
+ // Buffer.concat(...).toString('utf8'), which throws
641
+ // ERR_INVALID_ARG_TYPE on any non-empty string output. Pin the
642
+ // streams to buffers so the declared contract holds (regression:
643
+ // runRunnerCommand crashed on any child that actually wrote
644
+ // output; only the maxBuffer fixture exercised this path).
645
+ encoding: "buffer",
480
646
  windowsHide: true,
481
647
  shell: false,
482
648
  ...invocation.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}
@@ -505,11 +671,31 @@ function runRunnerCommand(argv, options) {
505
671
  }
506
672
  if (err) {
507
673
  const anyErr = err;
674
+ if ((anyErr.killed === true || anyErr.signal === "SIGTERM") && // maxBuffer overflow must NOT be misreported as a timeout
675
+ // — it's a real failure (the child wrote too much) and
676
+ // downstream callers (type-gate/index.ts:227) return
677
+ // null on timedOut=true, which would silently swallow
678
+ // maxBuffer overflow into a confusing empty-output
679
+ // result. Skip the timeout resolve when the err shape
680
+ // names maxBuffer explicitly.
681
+ !/maxBuffer length exceeded/i.test(anyErr.message ?? "") && // And only count it as a timeout if the wall clock has
682
+ // actually elapsed past the budget. External SIGTERMs and
683
+ // races against the exit handler don't satisfy this.
684
+ Date.now() - start >= options.timeoutMs) {
685
+ resolvePromise({
686
+ code: null,
687
+ stdout: Buffer.concat(stdoutChunks).toString("utf-8"),
688
+ stderr: Buffer.concat(stderrChunks).toString("utf-8"),
689
+ timedOut: true,
690
+ spawnError: false
691
+ });
692
+ return;
693
+ }
508
694
  const code = typeof anyErr.code === "number" ? anyErr.code : 1;
509
695
  resolvePromise({
510
696
  code,
511
- stdout: Buffer.concat(stdoutChunks).toString("utf8"),
512
- stderr: Buffer.concat(stderrChunks).toString("utf8"),
697
+ stdout: Buffer.concat(stdoutChunks).toString("utf-8"),
698
+ stderr: Buffer.concat(stderrChunks).toString("utf-8"),
513
699
  timedOut: false,
514
700
  spawnError: false
515
701
  });
@@ -525,7 +711,7 @@ function runRunnerCommand(argv, options) {
525
711
  }
526
712
  );
527
713
  child.on("exit", (_code, signal) => {
528
- if (signal !== null) {
714
+ if (signal !== null && Date.now() - start >= options.timeoutMs) {
529
715
  timedOut = true;
530
716
  }
531
717
  });
@@ -555,14 +741,14 @@ async function probeRunner(runtime, probeArg = "--version", options) {
555
741
  }
556
742
  function withinProject(p) {
557
743
  const cwd = process.cwd();
558
- return withinProjectPath(cwd, p) || relative2(cwd, p) === ".";
744
+ return withinProjectPath(cwd, p) || relative3(cwd, p) === ".";
559
745
  }
560
746
  function locateRunnerEntry(runtime, projectRoot) {
561
- const root = resolve2(projectRoot);
747
+ const root = resolve3(projectRoot);
562
748
  const candidates = [
563
- resolve2(root, "node_modules", ".bin", runtime.executable),
564
- resolve2(root, "node_modules", ".bin", `${runtime.executable}.cmd`),
565
- resolve2(root, "node_modules", ".bin", `${runtime.executable}.ps1`)
749
+ resolve3(root, "node_modules", ".bin", runtime.executable),
750
+ resolve3(root, "node_modules", ".bin", `${runtime.executable}.cmd`),
751
+ resolve3(root, "node_modules", ".bin", `${runtime.executable}.ps1`)
566
752
  ];
567
753
  for (const c of candidates) {
568
754
  if (existsSync(c)) return c;
@@ -592,7 +778,7 @@ function collectSourceFiles(root, opts) {
592
778
  entries.sort();
593
779
  for (const entry of entries) {
594
780
  if (excludeSet.has(entry)) continue;
595
- const full = resolve2(dir, entry);
781
+ const full = resolve3(dir, entry);
596
782
  let st;
597
783
  try {
598
784
  st = statSync(full);
@@ -635,7 +821,7 @@ async function collectSourceFilesAsync(root, opts) {
635
821
  entries.sort((a, b) => a.name.localeCompare(b.name));
636
822
  for (const entry of entries) {
637
823
  if (excludeSet.has(entry.name)) continue;
638
- const full = resolve2(dir, entry.name);
824
+ const full = resolve3(dir, entry.name);
639
825
  if (entry.isDirectory()) {
640
826
  await walk(full, depth + 1);
641
827
  } else if (entry.isFile() && matchesExtension(full, opts.extensions)) {
@@ -656,7 +842,10 @@ export {
656
842
  clearLocalBinCache,
657
843
  collectSourceFiles,
658
844
  collectSourceFilesAsync,
845
+ createH1State,
659
846
  findOnPath,
847
+ guardedMatcher,
848
+ isInsideProject,
660
849
  locateRunnerEntry,
661
850
  matchesExtension,
662
851
  parseLlmJsonObject,
@@ -672,7 +861,9 @@ export {
672
861
  runOptionalPluginLlm,
673
862
  runRunnerCommand,
674
863
  safeJsonStringify,
864
+ safePath,
675
865
  sanitizeRunnerPath,
676
866
  stripOuterMarkdownFence,
867
+ withReDoSGuard,
677
868
  withinProject
678
869
  };
@@ -188,6 +188,7 @@ function createState() {
188
188
  allowCount: 0,
189
189
  /** PostToolUse: secrets detected in tool output. */
190
190
  leakCount: 0,
191
+ timeoutCount: 0,
191
192
  /** Most recent PreToolUse block — surfaced by `secret_scanner_status`. */
192
193
  lastBlock: null,
193
194
  /** Most recent PostToolUse leak — surfaced by `secret_scanner_status`. */
@@ -356,7 +357,19 @@ function buildHook(cfg, log, runtime) {
356
357
  const { state } = runtime;
357
358
  if (!cfg.enabled) return;
358
359
  const toolName = input.toolName ?? "unknown";
359
- const matched = scanInput(input.toolInput);
360
+ let matched;
361
+ try {
362
+ matched = scanInput(input.toolInput);
363
+ } catch (err) {
364
+ if (String(err).includes("ReDoS")) {
365
+ state.timeoutCount += 1;
366
+ return {
367
+ decision: "block",
368
+ reason: "secret-scanner: ReDoS timeout \u2014 regex scan exceeded the wall-clock budget. Fail-closed: treated as a block."
369
+ };
370
+ }
371
+ throw err;
372
+ }
360
373
  if (!matched) return;
361
374
  const summary = matched.join(", ");
362
375
  const when = (/* @__PURE__ */ new Date()).toISOString();
@@ -534,7 +547,8 @@ var plugin = {
534
547
  block: state.blockCount,
535
548
  redact: state.redactCount,
536
549
  allow: state.allowCount,
537
- leak: state.leakCount
550
+ leak: state.leakCount,
551
+ timeoutCount: state.timeoutCount
538
552
  },
539
553
  lastBlock: state.lastBlock,
540
554
  lastLeak: state.lastLeak
package/dist/type-gate.js CHANGED
@@ -177,6 +177,7 @@ function runRunnerCommand(argv, options) {
177
177
  }
178
178
  let timedOut = false;
179
179
  let spawnErrored = false;
180
+ const start = Date.now();
180
181
  const stdoutChunks = [];
181
182
  const stderrChunks = [];
182
183
  let stdoutBytes = 0;
@@ -206,6 +207,15 @@ function runRunnerCommand(argv, options) {
206
207
  timeout: options.timeoutMs,
207
208
  signal: options.signal,
208
209
  maxBuffer: MAX_BUFFER_BYTES,
210
+ // execFile defaults `encoding` to 'utf8', which makes the
211
+ // stdout/stderr `data` events emit *strings*. The chunk arrays
212
+ // below are typed Buffer[] and every consumer runs them through
213
+ // Buffer.concat(...).toString('utf8'), which throws
214
+ // ERR_INVALID_ARG_TYPE on any non-empty string output. Pin the
215
+ // streams to buffers so the declared contract holds (regression:
216
+ // runRunnerCommand crashed on any child that actually wrote
217
+ // output; only the maxBuffer fixture exercised this path).
218
+ encoding: "buffer",
209
219
  windowsHide: true,
210
220
  shell: false,
211
221
  ...invocation.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}
@@ -234,11 +244,31 @@ function runRunnerCommand(argv, options) {
234
244
  }
235
245
  if (err) {
236
246
  const anyErr = err;
247
+ if ((anyErr.killed === true || anyErr.signal === "SIGTERM") && // maxBuffer overflow must NOT be misreported as a timeout
248
+ // — it's a real failure (the child wrote too much) and
249
+ // downstream callers (type-gate/index.ts:227) return
250
+ // null on timedOut=true, which would silently swallow
251
+ // maxBuffer overflow into a confusing empty-output
252
+ // result. Skip the timeout resolve when the err shape
253
+ // names maxBuffer explicitly.
254
+ !/maxBuffer length exceeded/i.test(anyErr.message ?? "") && // And only count it as a timeout if the wall clock has
255
+ // actually elapsed past the budget. External SIGTERMs and
256
+ // races against the exit handler don't satisfy this.
257
+ Date.now() - start >= options.timeoutMs) {
258
+ resolvePromise({
259
+ code: null,
260
+ stdout: Buffer.concat(stdoutChunks).toString("utf-8"),
261
+ stderr: Buffer.concat(stderrChunks).toString("utf-8"),
262
+ timedOut: true,
263
+ spawnError: false
264
+ });
265
+ return;
266
+ }
237
267
  const code = typeof anyErr.code === "number" ? anyErr.code : 1;
238
268
  resolvePromise({
239
269
  code,
240
- stdout: Buffer.concat(stdoutChunks).toString("utf8"),
241
- stderr: Buffer.concat(stderrChunks).toString("utf8"),
270
+ stdout: Buffer.concat(stdoutChunks).toString("utf-8"),
271
+ stderr: Buffer.concat(stderrChunks).toString("utf-8"),
242
272
  timedOut: false,
243
273
  spawnError: false
244
274
  });
@@ -254,7 +284,7 @@ function runRunnerCommand(argv, options) {
254
284
  }
255
285
  );
256
286
  child.on("exit", (_code, signal) => {
257
- if (signal !== null) {
287
+ if (signal !== null && Date.now() - start >= options.timeoutMs) {
258
288
  timedOut = true;
259
289
  }
260
290
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wrongstack/plugins",
3
- "version": "0.307.1",
3
+ "version": "0.308.0",
4
4
  "description": "Official WrongStack collection of focused plugins for code quality, security, observability, planning, and agent coordination",
5
5
  "license": "MIT",
6
6
  "author": "ECOSTACK TECHNOLOGY OÜ",
@@ -303,8 +303,8 @@
303
303
  "vitest": "^4.1.10"
304
304
  },
305
305
  "dependencies": {
306
- "@wrongstack/tools": "0.307.1",
307
- "@wrongstack/core": "0.307.1"
306
+ "@wrongstack/tools": "0.308.0",
307
+ "@wrongstack/core": "0.308.0"
308
308
  },
309
309
  "scripts": {
310
310
  "build": "node ../../scripts/build-package.mjs",