@wrongstack/plugins 0.307.1 → 0.308.1

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/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
@@ -55,10 +55,10 @@ var OFFICIAL_PLUGIN_NAMES = [
55
55
  "notify-hub",
56
56
  "changelog-writer",
57
57
  "injection-shield",
58
+ "prompt-firewall",
58
59
  "llm-cache",
59
60
  "model-router",
60
61
  "pr-drafter",
61
- "prompt-firewall",
62
62
  "auto-escalate",
63
63
  "test-coverage-gate",
64
64
  "type-gate",
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.1",
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Ü",
@@ -168,6 +168,10 @@
168
168
  "types": "./dist/injection-shield.d.ts",
169
169
  "import": "./dist/injection-shield.js"
170
170
  },
171
+ "./prompt-firewall": {
172
+ "types": "./dist/prompt-firewall.d.ts",
173
+ "import": "./dist/prompt-firewall.js"
174
+ },
171
175
  "./llm-cache": {
172
176
  "types": "./dist/llm-cache.d.ts",
173
177
  "import": "./dist/llm-cache.js"
@@ -180,10 +184,6 @@
180
184
  "types": "./dist/pr-drafter.d.ts",
181
185
  "import": "./dist/pr-drafter.js"
182
186
  },
183
- "./prompt-firewall": {
184
- "types": "./dist/prompt-firewall.d.ts",
185
- "import": "./dist/prompt-firewall.js"
186
- },
187
187
  "./auto-escalate": {
188
188
  "types": "./dist/auto-escalate.d.ts",
189
189
  "import": "./dist/auto-escalate.js"
@@ -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/core": "0.308.1",
307
+ "@wrongstack/tools": "0.308.1"
308
308
  },
309
309
  "scripts": {
310
310
  "build": "node ../../scripts/build-package.mjs",