@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.
package/dist/lint-gate.js CHANGED
@@ -221,9 +221,10 @@ async function detectLinter(requested, cwd) {
221
221
  }
222
222
  async function lintContent(content, filePath, linter, timeoutMs, cwd, signal) {
223
223
  const ext = filePath.includes(".") ? filePath.slice(filePath.lastIndexOf(".")) : ".ts";
224
- const tmpDir = await mkdtemp(join(tmpdir(), "lint-gate-"));
225
- const tmpFile = join(tmpDir, `input${ext}`);
224
+ let tmpDir;
226
225
  try {
226
+ tmpDir = await mkdtemp(join(tmpdir(), "lint-gate-"));
227
+ const tmpFile = join(tmpDir, `input${ext}`);
227
228
  await writeFile(tmpFile, content, "utf-8");
228
229
  const fullArgs = [...linter.args, tmpFile];
229
230
  const result = await runCommand(linter.cmd, fullArgs, timeoutMs, cwd, signal);
@@ -234,14 +235,15 @@ async function lintContent(content, filePath, linter, timeoutMs, cwd, signal) {
234
235
  if (signal.aborted) throw signal.reason;
235
236
  return null;
236
237
  } finally {
237
- await rm(tmpDir, { recursive: true, force: true }).catch(() => void 0);
238
+ if (tmpDir) await rm(tmpDir, { recursive: true, force: true }).catch(() => void 0);
238
239
  }
239
240
  }
240
241
  async function lintAndFix(content, filePath, linter, timeoutMs, cwd, signal) {
241
242
  const ext = filePath.includes(".") ? filePath.slice(filePath.lastIndexOf(".")) : ".ts";
242
- const tmpDir = await mkdtemp(join(tmpdir(), "lint-gate-fix-"));
243
- const tmpFile = join(tmpDir, `input${ext}`);
243
+ let tmpDir;
244
244
  try {
245
+ tmpDir = await mkdtemp(join(tmpdir(), "lint-gate-fix-"));
246
+ const tmpFile = join(tmpDir, `input${ext}`);
245
247
  await writeFile(tmpFile, content, "utf-8");
246
248
  const fixArgs = linter.name === "biome" ? [linter.args[0], "check", "--write", tmpFile] : [linter.args[0], "--fix", tmpFile];
247
249
  await runCommand(linter.cmd, fixArgs, timeoutMs, cwd, signal);
@@ -251,7 +253,7 @@ async function lintAndFix(content, filePath, linter, timeoutMs, cwd, signal) {
251
253
  if (signal.aborted) throw signal.reason;
252
254
  return content;
253
255
  } finally {
254
- await rm(tmpDir, { recursive: true, force: true }).catch(() => void 0);
256
+ if (tmpDir) await rm(tmpDir, { recursive: true, force: true }).catch(() => void 0);
255
257
  }
256
258
  }
257
259
  function parseLinterOutput(stdout, linterName) {
@@ -505,9 +507,9 @@ ${summary}${truncated}`
505
507
  name: "lint-gate",
506
508
  stage: "mutate",
507
509
  timeoutMs: Math.max(1e3, cfg.timeoutMs + 1e3),
508
- // Formatter/linter availability must not create approval or denial
509
- // loops in YOLO mode. Explicit lint findings still block in block mode.
510
- failurePolicy: "open"
510
+ // Fail closed: a linter crash or timeout must not let unlinted
511
+ // content through in block mode (issue #363).
512
+ failurePolicy: "closed"
511
513
  });
512
514
  api.tools.register({
513
515
  name: "lint_gate_status",
@@ -579,5 +581,6 @@ ${summary}${truncated}`
579
581
  };
580
582
  var lint_gate_default = plugin;
581
583
  export {
582
- lint_gate_default as default
584
+ lint_gate_default as default,
585
+ resolveLocalLinter
583
586
  };
@@ -2,6 +2,20 @@ export interface WriteTarget {
2
2
  path: string;
3
3
  kind: 'file' | 'scope' | 'deletion-scope';
4
4
  }
5
+ /** Default wall-clock budget per glob match (ms). Must clear worker spin-up (~20ms). */
6
+ export declare const GLOB_REDOS_BUDGET_MS = 250;
7
+ export interface GuardedMatchOptions {
8
+ /** Invoked when a match times out — wired to the plugin's redosTimeouts counter. */
9
+ onTimeout?: (info: {
10
+ regex: RegExp;
11
+ input: string;
12
+ budgetMs: number;
13
+ elapsedMs: number;
14
+ }) => void;
15
+ /** Per-regex wall-clock budget. Default GLOB_REDOS_BUDGET_MS. */
16
+ budgetMs?: number;
17
+ }
18
+ export declare function matchesAnyGuarded(path: string, patterns: RegExp[], options?: GuardedMatchOptions): Promise<boolean>;
5
19
  /**
6
20
  * Compile a glob pattern to a RegExp. Supports `**` (any depth),
7
21
  * `*` (within one segment), and `?` (single char). Matching is done
@@ -27,6 +41,15 @@ export declare function staticPrefix(pattern: string): string;
27
41
  export declare function hasPartialSegmentWildcard(pattern: string): boolean;
28
42
  export declare function globWitness(pattern: string): string;
29
43
  export declare function scopesMayOverlap(left: string, right: string): boolean;
44
+ export declare function targetIntersectsScope(target: WriteTarget, patternTexts: string[]): boolean;
30
45
  export declare function targetIntersectsPatterns(target: WriteTarget, patternTexts: string[], patterns: RegExp[]): boolean;
46
+ /**
47
+ * ReDoS-guarded variant of `targetIntersectsPatterns` (issue #365 / audit
48
+ * T-02): the direct path match runs through `matchesAnyGuarded` (worker
49
+ * watchdog, fail-closed on timeout); scope-overlap logic stays synchronous
50
+ * because it matches against constructed candidate paths, not the raw
51
+ * hostile input.
52
+ */
53
+ export declare function targetIntersectsPatternsGuarded(target: WriteTarget, patternTexts: string[], patterns: RegExp[], options?: GuardedMatchOptions): Promise<boolean>;
31
54
  export declare function targetFullyAllowed(target: WriteTarget, allowTexts: string[], allowRes: RegExp[]): boolean;
32
55
  //# sourceMappingURL=glob.d.ts.map
@@ -7,6 +7,8 @@
7
7
  import type { Plugin } from '@wrongstack/core/types';
8
8
  export { compilePathGlob } from './glob.js';
9
9
  export { destructiveTargets } from './shell-targets.js';
10
+ /** True when `path` is a project-local symlink whose real target escaped. */
11
+ export declare function isSymlinkEscape(path: string, cwd?: string): boolean;
10
12
  declare const plugin: Plugin;
11
13
  export default plugin;
12
14
  //# sourceMappingURL=index.d.ts.map
@@ -1,4 +1,104 @@
1
+ // src/runtime/redos-guard.ts
2
+ import { Worker } from "node:worker_threads";
3
+ function withReDoSGuard(re, input, budgetMs = 50, options = {}) {
4
+ const opts = { budgetMs, ...options };
5
+ const start = Date.now();
6
+ const workerSource = buildWorkerSource(re.source, input, re.flags);
7
+ const worker = new Worker(workerSource, {
8
+ eval: true,
9
+ name: `redos-guard:${re.source.slice(0, 32)}`
10
+ });
11
+ return new Promise((resolve3) => {
12
+ let settled = false;
13
+ const onMessage = (msg) => {
14
+ if (settled) return;
15
+ settled = true;
16
+ clearTimeout(timer);
17
+ worker.terminate().catch(() => {
18
+ });
19
+ if (!msg.ok) {
20
+ resolve3({ timedOut: true, match: null });
21
+ return;
22
+ }
23
+ resolve3({ timedOut: false, match: msg.match });
24
+ };
25
+ const onError = () => {
26
+ if (settled) return;
27
+ settled = true;
28
+ clearTimeout(timer);
29
+ worker.terminate().catch(() => {
30
+ });
31
+ resolve3({ timedOut: true, match: null });
32
+ };
33
+ const timer = setTimeout(() => {
34
+ if (settled) return;
35
+ settled = true;
36
+ const elapsedMs = Date.now() - start;
37
+ worker.terminate().catch(() => {
38
+ });
39
+ try {
40
+ opts.onTimeout?.({
41
+ regex: re,
42
+ input,
43
+ budgetMs: opts.budgetMs,
44
+ elapsedMs
45
+ });
46
+ } catch {
47
+ }
48
+ resolve3({ timedOut: true, match: null });
49
+ }, opts.budgetMs);
50
+ timer.unref?.();
51
+ worker.on("message", onMessage);
52
+ worker.on("error", onError);
53
+ });
54
+ }
55
+ function buildWorkerSource(source, input, flags) {
56
+ const S = JSON.stringify(source);
57
+ const I = JSON.stringify(input);
58
+ const F = JSON.stringify(flags);
59
+ return `
60
+ const { parentPort } = require('node:worker_threads');
61
+ const source = ${S};
62
+ const input = ${I};
63
+ const flags = ${F};
64
+ try {
65
+ const re = new RegExp(source, flags);
66
+ const match = re.exec(input);
67
+ // parentPort.postMessage, NOT bare postMessage: with eval:true
68
+ // workers this Node version does not expose the bare postMessage
69
+ // global \u2014 the worker throws ReferenceError at startup and the
70
+ // host misreads it as a timeout (positive-path regression).
71
+ parentPort.postMessage({ ok: true, match });
72
+ } catch (err) {
73
+ parentPort.postMessage({ ok: false, error: err && err.message ? err.message : String(err) });
74
+ }
75
+ `;
76
+ }
77
+
1
78
  // src/path-guard/glob.ts
79
+ var GLOB_REDOS_BUDGET_MS = 250;
80
+ function mergeGuardedRegex(patterns) {
81
+ const flags = /* @__PURE__ */ new Set();
82
+ for (const p of patterns) {
83
+ for (const f of p.flags) {
84
+ if (f !== "g" && f !== "y") flags.add(f);
85
+ }
86
+ }
87
+ const uniqueFlags = [...flags].join("");
88
+ return new RegExp(patterns.map((p) => p.source).join("|"), uniqueFlags);
89
+ }
90
+ async function matchesAnyGuarded(path, patterns, options = {}) {
91
+ const normalized = normalizePath(path);
92
+ if (patterns.length === 0) return false;
93
+ const result = await withReDoSGuard(
94
+ mergeGuardedRegex(patterns),
95
+ normalized,
96
+ options.budgetMs ?? GLOB_REDOS_BUDGET_MS,
97
+ options.onTimeout ? { onTimeout: options.onTimeout } : {}
98
+ );
99
+ if (result.timedOut) return true;
100
+ return result.match !== null;
101
+ }
2
102
  function compilePathGlob(pattern) {
3
103
  const normalized = pattern.replace(/\\/g, "/");
4
104
  let source = "";
@@ -89,8 +189,8 @@ function isRootPathScope(path) {
89
189
  function isDirectoryAmbiguousPath(path) {
90
190
  const normalized = normalizePath(path).replace(/\/$/, "");
91
191
  if (isRootPathScope(normalized)) return true;
92
- const basename = normalized.slice(normalized.lastIndexOf("/") + 1);
93
- return path.endsWith("/") || basename.length > 0 && !basename.includes(".");
192
+ const basename2 = normalized.slice(normalized.lastIndexOf("/") + 1);
193
+ return path.endsWith("/") || basename2.length > 0 && !basename2.includes(".");
94
194
  }
95
195
  function hasConfiguredProtectedDescendant(path, patterns) {
96
196
  const normalized = normalizePath(path).replace(/\/$/, "").toLowerCase();
@@ -133,8 +233,7 @@ function scopesMayOverlap(left, right) {
133
233
  }
134
234
  return hasPartialSegmentWildcard(left) && rightPrefix.startsWith(leftPrefix) || hasPartialSegmentWildcard(right) && leftPrefix.startsWith(rightPrefix);
135
235
  }
136
- function targetIntersectsPatterns(target, patternTexts, patterns) {
137
- if (matchesAny(target.path, patterns)) return true;
236
+ function targetIntersectsScope(target, patternTexts) {
138
237
  if (target.kind === "file") return false;
139
238
  const normalized = normalizePath(target.path).replace(/\/$/, "");
140
239
  if (!isUnresolvedPathScope(normalized)) {
@@ -146,6 +245,14 @@ function targetIntersectsPatterns(target, patternTexts, patterns) {
146
245
  }
147
246
  return patternTexts.some((pattern) => scopesMayOverlap(normalized, normalizePath(pattern)));
148
247
  }
248
+ function targetIntersectsPatterns(target, patternTexts, patterns) {
249
+ if (matchesAny(target.path, patterns)) return true;
250
+ return targetIntersectsScope(target, patternTexts);
251
+ }
252
+ async function targetIntersectsPatternsGuarded(target, patternTexts, patterns, options = {}) {
253
+ if (await matchesAnyGuarded(target.path, patterns, options)) return true;
254
+ return targetIntersectsScope(target, patternTexts);
255
+ }
149
256
  function targetFullyAllowed(target, allowTexts, allowRes) {
150
257
  if (target.kind === "file") return matchesAny(target.path, allowRes);
151
258
  const normalized = normalizePath(target.path).replace(/\/$/, "");
@@ -462,7 +569,7 @@ function commandRecursivelyDeletes(command) {
462
569
  )) {
463
570
  return true;
464
571
  }
465
- const destructive = /(?:^|[;&|\r\n]\s*|\{\s*|\(\s*|\bxargs(?:\s+-[^\s]+)*\s+)(rm|rmdir|del)\s+((?:"[^"]*"|'[^']*'|\\.|\{[^}]*\}|\([^()]*\)|[^;&|\r\n}])+)/gi;
572
+ const destructive = /(?:^|[;&|\r\n]\s*|\{\s*|\(\s*|\bxargs(?:\s+-[^\s]+)*\s+)(rm|rmdir|del|rd|Remove-Item)\s+((?:"[^"]*"|'[^']*'|\\.|\{[^}]*\}|\([^()]*\)|[^;&|\r\n}])+)/gi;
466
573
  let match = destructive.exec(stripped);
467
574
  while (match !== null) {
468
575
  const tool = match[1]?.toLowerCase();
@@ -475,6 +582,8 @@ function commandRecursivelyDeletes(command) {
475
582
  if (token === "--") break;
476
583
  if (tool === "rm") {
477
584
  if (token === "--recursive" || /^-[^-]*[rR]/.test(token)) recursive = true;
585
+ } else if (tool === "remove-item") {
586
+ if (/^-Recurse$/i.test(token) || /^-r$/i.test(token)) recursive = true;
478
587
  } else if (/^\/[a-z]*s[a-z]*$/i.test(token)) {
479
588
  recursive = true;
480
589
  }
@@ -723,7 +832,7 @@ function destructiveTargetsAtDepth(command, depth) {
723
832
  }
724
833
  targets.push(...destructiveTargetsAtDepth(executableBody, depth + 1));
725
834
  }
726
- const destructive = /(?:^|[;&|\r\n]\s*|\{\s*|(?<![$(])\(\s*|\bxargs(?:\s+-[^\s]+)*\s+)(?:sudo\s+)?(rm|rmdir|del|unlink|truncate|shred|mv)\s+((?:"[^"]*"|'[^']*'|\\.|\{[^}]*\}|\([^()]*\)|[^;&|\r\n}])+)/gi;
835
+ const destructive = /(?:^|[;&|\r\n]\s*|\{\s*|(?<![$(])\(\s*|\bxargs(?:\s+-[^\s]+)*\s+)(?:sudo\s+)?(rm|rmdir|del|rd|Remove-Item|unlink|truncate|shred|mv)\s+((?:"[^"]*"|'[^']*'|\\.|\{[^}]*\}|\([^()]*\)|[^;&|\r\n}])+)/gi;
727
836
  let m = destructive.exec(normalizedCommand);
728
837
  while (m !== null) {
729
838
  if (!tokenIsQuoted(m, m[1] ?? "")) targets.push(...shellArgs(m[2] ?? ""));
@@ -1211,8 +1320,52 @@ function operationLabel(toolName) {
1211
1320
  }
1212
1321
 
1213
1322
  // src/path-guard/index.ts
1323
+ import { realpathSync } from "node:fs";
1324
+ import { resolve as resolve2 } from "node:path";
1325
+
1326
+ // src/runtime/index.ts
1327
+ import { basename, extname, isAbsolute, relative, resolve } from "node:path";
1328
+
1329
+ // src/runtime/local-bin.ts
1330
+ import { buildWin32CmdShimInvocation, resolveWin32Command } from "@wrongstack/tools/win32";
1331
+
1332
+ // src/runtime/index.ts
1333
+ var MAX_BUFFER_BYTES = 16 * 1024 * 1024;
1334
+ function hasLeadingDash(arg) {
1335
+ return arg.length > 0 && arg.startsWith("-");
1336
+ }
1337
+ function withinProjectPath(projectRoot, candidate) {
1338
+ if (candidate.length === 0 || candidate.length > 4096) return false;
1339
+ if (hasLeadingDash(candidate)) return false;
1340
+ const resolved = isAbsolute(candidate) ? resolve(candidate) : resolve(projectRoot, candidate);
1341
+ const rel = relative(projectRoot, resolved);
1342
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
1343
+ }
1344
+ function withinProject(p) {
1345
+ const cwd = process.cwd();
1346
+ return withinProjectPath(cwd, p) || relative(cwd, p) === ".";
1347
+ }
1348
+
1349
+ // src/path-guard/index.ts
1350
+ function isSymlinkEscape(path, cwd) {
1351
+ if (!withinProject(path)) return false;
1352
+ try {
1353
+ const abs = resolve2(cwd ?? process.cwd(), path);
1354
+ const real = realpathSync(abs);
1355
+ return !withinProject(real);
1356
+ } catch {
1357
+ return false;
1358
+ }
1359
+ }
1214
1360
  function createState() {
1215
- return { invocations: 0, blocks: 0, warns: 0, lastBlock: null, hookUnregister: null };
1361
+ return {
1362
+ invocations: 0,
1363
+ blocks: 0,
1364
+ warns: 0,
1365
+ redosTimeouts: 0,
1366
+ lastBlock: null,
1367
+ hookUnregister: null
1368
+ };
1216
1369
  }
1217
1370
  var states = /* @__PURE__ */ new WeakMap();
1218
1371
  var latestState = createState();
@@ -1307,7 +1460,11 @@ var plugin = {
1307
1460
  additionalContext: `path-guard (warn mode): ${subject} and this ${operation} would modify it. Double-check this is intentional.`
1308
1461
  };
1309
1462
  };
1310
- const hook = (input) => {
1463
+ const bumpRedos = () => {
1464
+ state.redosTimeouts += 1;
1465
+ api.metrics.counter("redos_timeouts");
1466
+ };
1467
+ const hook = async (input) => {
1311
1468
  if (!cfg.enabled) return;
1312
1469
  state.invocations += 1;
1313
1470
  const toolName = input.toolName ?? "";
@@ -1328,21 +1485,27 @@ var plugin = {
1328
1485
  const effectiveCwd = effectiveToolCwd(ti["cwd"], input.cwd);
1329
1486
  const recursivelyDeletes = commandRecursivelyDeletes(commandForInspection);
1330
1487
  const deletesImplicitScope = commandDeletesImplicitScope(commandForInspection);
1331
- for (const path of shellTargets) {
1332
- const target = {
1333
- path: relativeToInvocationCwd(resolveTargetPath(path, effectiveCwd), input.cwd),
1334
- kind: isRootPathScope(path) && deletesImplicitScope || isUnresolvedPathScope(path) || recursivelyDeletes && (isDirectoryAmbiguousPath(path) || hasConfiguredProtectedDescendant(path, cfg.protect)) ? "deletion-scope" : "file"
1335
- };
1336
- if (targetFullyAllowed(target, cfg.allow, allowRes)) continue;
1337
- const protectedShellTarget = targetIntersectsPatterns(target, cfg.protect, protectRes) || matchesAny(`${target.path.replace(/\/$/, "")}/.path-guard-probe`, protectRes);
1338
- if (protectedShellTarget) {
1339
- return verdict(
1340
- target.path,
1341
- toolName,
1342
- "destructive shell command",
1343
- target.kind !== "file"
1344
- );
1345
- }
1488
+ const shellHits = await Promise.all(
1489
+ shellTargets.map(async (path) => {
1490
+ const target = {
1491
+ path: relativeToInvocationCwd(resolveTargetPath(path, effectiveCwd), input.cwd),
1492
+ kind: isRootPathScope(path) && deletesImplicitScope || isUnresolvedPathScope(path) || recursivelyDeletes && (isDirectoryAmbiguousPath(path) || hasConfiguredProtectedDescendant(path, cfg.protect)) ? "deletion-scope" : "file"
1493
+ };
1494
+ if (targetFullyAllowed(target, cfg.allow, allowRes)) return null;
1495
+ const protectedShellTarget = await targetIntersectsPatternsGuarded(target, cfg.protect, protectRes, { onTimeout: bumpRedos }) || await matchesAnyGuarded(`${target.path.replace(/\/$/, "")}/.path-guard-probe`, protectRes, {
1496
+ onTimeout: bumpRedos
1497
+ });
1498
+ return protectedShellTarget ? target : null;
1499
+ })
1500
+ );
1501
+ const firstProtected = shellHits.find((t) => t !== null);
1502
+ if (firstProtected) {
1503
+ return verdict(
1504
+ firstProtected.path,
1505
+ toolName,
1506
+ "destructive shell command",
1507
+ firstProtected.kind !== "file"
1508
+ );
1346
1509
  }
1347
1510
  const writes = writesToDisk({ ...input, toolInput: ti });
1348
1511
  const hasStructuredTarget = [...PATH_FIELDS, ...PATH_LIST_FIELDS].some((field) => ti[field] !== void 0) || typeof ti["patch"] === "string";
@@ -1353,11 +1516,21 @@ var plugin = {
1353
1516
  }
1354
1517
  if (!writesToDisk({ ...input, toolInput: ti }) || isReadOnlyInvocation(toolName, ti)) return;
1355
1518
  const targets = pathsFromToolInput(ti, toolName, input.cwd);
1356
- for (const target of targets) {
1357
- if (targetFullyAllowed(target, cfg.allow, allowRes)) continue;
1358
- if (targetIntersectsPatterns(target, cfg.protect, protectRes)) {
1359
- return verdict(target.path, toolName, operationLabel(toolName), target.kind !== "file");
1360
- }
1519
+ const writeHits = await Promise.all(
1520
+ targets.map(async (target) => {
1521
+ if (target.kind === "file" && isSymlinkEscape(target.path, input.cwd)) return target;
1522
+ if (targetFullyAllowed(target, cfg.allow, allowRes)) return null;
1523
+ return targetIntersectsPatterns(target, cfg.protect, protectRes) ? target : null;
1524
+ })
1525
+ );
1526
+ const firstProtectedWrite = writeHits.find((t) => t !== null);
1527
+ if (firstProtectedWrite) {
1528
+ return verdict(
1529
+ firstProtectedWrite.path,
1530
+ toolName,
1531
+ operationLabel(toolName),
1532
+ firstProtectedWrite.kind !== "file"
1533
+ );
1361
1534
  }
1362
1535
  return;
1363
1536
  };
@@ -1384,7 +1557,8 @@ var plugin = {
1384
1557
  counters: {
1385
1558
  invocations: state.invocations,
1386
1559
  blocks: state.blocks,
1387
- warns: state.warns
1560
+ warns: state.warns,
1561
+ redosTimeouts: state.redosTimeouts
1388
1562
  },
1389
1563
  lastBlock: state.lastBlock
1390
1564
  };
@@ -1407,10 +1581,16 @@ var plugin = {
1407
1581
  }
1408
1582
  state.hookUnregister = null;
1409
1583
  }
1410
- const final = { invocations: state.invocations, blocks: state.blocks, warns: state.warns };
1584
+ const final = {
1585
+ invocations: state.invocations,
1586
+ blocks: state.blocks,
1587
+ warns: state.warns,
1588
+ redosTimeouts: state.redosTimeouts
1589
+ };
1411
1590
  state.invocations = 0;
1412
1591
  state.blocks = 0;
1413
1592
  state.warns = 0;
1593
+ state.redosTimeouts = 0;
1414
1594
  state.lastBlock = null;
1415
1595
  states.delete(api);
1416
1596
  api.log.info("path-guard: teardown complete", { final });
@@ -1423,7 +1603,8 @@ var plugin = {
1423
1603
  counters: {
1424
1604
  invocations: state.invocations,
1425
1605
  blocks: state.blocks,
1426
- warns: state.warns
1606
+ warns: state.warns,
1607
+ redosTimeouts: state.redosTimeouts
1427
1608
  }
1428
1609
  };
1429
1610
  }
@@ -1432,5 +1613,6 @@ var path_guard_default = plugin;
1432
1613
  export {
1433
1614
  compilePathGlob,
1434
1615
  path_guard_default as default,
1435
- destructiveTargets
1616
+ destructiveTargets,
1617
+ isSymlinkEscape
1436
1618
  };
@@ -39,6 +39,13 @@
39
39
  * @public
40
40
  */
41
41
  import type { Plugin } from '@wrongstack/core/types';
42
+ /**
43
+ * Legacy `kind` names for the shapes this plugin already reported, so the
44
+ * canonical `type` ids from the shared table keep their existing spelling
45
+ * in logs, metrics and the status tool. Anything not listed here is
46
+ * reported under its canonical id.
47
+ */
48
+ export declare const KIND_ALIASES: Readonly<Record<string, string>>;
42
49
  export interface Detection {
43
50
  kind: string;
44
51
  count: number;
@@ -130,6 +130,83 @@ function cloneCredentialPatterns() {
130
130
  }));
131
131
  }
132
132
 
133
+ // src/runtime/redos-guard.ts
134
+ import { Worker } from "node:worker_threads";
135
+ function withReDoSGuard(re, input, budgetMs = 50, options = {}) {
136
+ const opts = { budgetMs, ...options };
137
+ const start = Date.now();
138
+ const workerSource = buildWorkerSource(re.source, input, re.flags);
139
+ const worker = new Worker(workerSource, {
140
+ eval: true,
141
+ name: `redos-guard:${re.source.slice(0, 32)}`
142
+ });
143
+ return new Promise((resolve) => {
144
+ let settled = false;
145
+ const onMessage = (msg) => {
146
+ if (settled) return;
147
+ settled = true;
148
+ clearTimeout(timer);
149
+ worker.terminate().catch(() => {
150
+ });
151
+ if (!msg.ok) {
152
+ resolve({ timedOut: true, match: null });
153
+ return;
154
+ }
155
+ resolve({ timedOut: false, match: msg.match });
156
+ };
157
+ const onError = () => {
158
+ if (settled) return;
159
+ settled = true;
160
+ clearTimeout(timer);
161
+ worker.terminate().catch(() => {
162
+ });
163
+ resolve({ timedOut: true, match: null });
164
+ };
165
+ const timer = setTimeout(() => {
166
+ if (settled) return;
167
+ settled = true;
168
+ const elapsedMs = Date.now() - start;
169
+ worker.terminate().catch(() => {
170
+ });
171
+ try {
172
+ opts.onTimeout?.({
173
+ regex: re,
174
+ input,
175
+ budgetMs: opts.budgetMs,
176
+ elapsedMs
177
+ });
178
+ } catch {
179
+ }
180
+ resolve({ timedOut: true, match: null });
181
+ }, opts.budgetMs);
182
+ timer.unref?.();
183
+ worker.on("message", onMessage);
184
+ worker.on("error", onError);
185
+ });
186
+ }
187
+ function buildWorkerSource(source, input, flags) {
188
+ const S = JSON.stringify(source);
189
+ const I = JSON.stringify(input);
190
+ const F = JSON.stringify(flags);
191
+ return `
192
+ const { parentPort } = require('node:worker_threads');
193
+ const source = ${S};
194
+ const input = ${I};
195
+ const flags = ${F};
196
+ try {
197
+ const re = new RegExp(source, flags);
198
+ const match = re.exec(input);
199
+ // parentPort.postMessage, NOT bare postMessage: with eval:true
200
+ // workers this Node version does not expose the bare postMessage
201
+ // global \u2014 the worker throws ReferenceError at startup and the
202
+ // host misreads it as a timeout (positive-path regression).
203
+ parentPort.postMessage({ ok: true, match });
204
+ } catch (err) {
205
+ parentPort.postMessage({ ok: false, error: err && err.message ? err.message : String(err) });
206
+ }
207
+ `;
208
+ }
209
+
133
210
  // src/prompt-firewall/index.ts
134
211
  var KIND_ALIASES = {
135
212
  aws_access_key: "aws-access-key",
@@ -253,6 +330,7 @@ var state = {
253
330
  requestRedactions: 0,
254
331
  responseRedactions: 0,
255
332
  blocked: 0,
333
+ timeoutCount: 0,
256
334
  byKind: /* @__PURE__ */ new Map(),
257
335
  lastDetection: null,
258
336
  extensionUnregister: null
@@ -297,6 +375,7 @@ var plugin = {
297
375
  state.requestRedactions = 0;
298
376
  state.responseRedactions = 0;
299
377
  state.blocked = 0;
378
+ state.timeoutCount = 0;
300
379
  state.byKind.clear();
301
380
  state.lastDetection = null;
302
381
  if (state.extensionUnregister) {
@@ -319,7 +398,16 @@ var plugin = {
319
398
  async wrapProviderRunner(_ctx, request, inner) {
320
399
  const req = request ?? {};
321
400
  state.invocations += 1;
322
- const detections = detectSecrets(collectText(req), cfg.allow);
401
+ const requestText = collectText(req);
402
+ for (const extra of EXTRA_PATTERNS) {
403
+ const guarded = await withReDoSGuard(extra.re, requestText, 250, {
404
+ onTimeout: () => {
405
+ state.timeoutCount += 1;
406
+ }
407
+ });
408
+ if (guarded.timedOut) break;
409
+ }
410
+ const detections = detectSecrets(requestText, cfg.allow);
323
411
  if (detections.length > 0) {
324
412
  state.requestsWithSecrets += 1;
325
413
  for (const d of detections) {
@@ -389,7 +477,8 @@ var plugin = {
389
477
  requestsWithSecrets: state.requestsWithSecrets,
390
478
  requestRedactions: state.requestRedactions,
391
479
  responseRedactions: state.responseRedactions,
392
- blocked: state.blocked
480
+ blocked: state.blocked,
481
+ timeoutCount: state.timeoutCount
393
482
  },
394
483
  byKind: Object.fromEntries(state.byKind),
395
484
  lastDetection: state.lastDetection
@@ -443,6 +532,7 @@ var plugin = {
443
532
  };
444
533
  var prompt_firewall_default = plugin;
445
534
  export {
535
+ KIND_ALIASES,
446
536
  prompt_firewall_default as default,
447
537
  detectSecrets,
448
538
  readConfig,
@@ -0,0 +1,62 @@
1
+ /**
2
+ * H1 idempotent state — single-slot plugin state with a registry of
3
+ * releasable handles that survives `setup()` reload cycles.
4
+ *
5
+ * The "H1 audit pattern" (per SAGE memory T-03) is documented across
6
+ * the plugin suite: a plugin's module-scope `state` object holds
7
+ * counters plus a `hookUnregister` (or `extensionUnregister`) slot;
8
+ * on reload, the slot MUST be released before a new one is stored.
9
+ * Every plugin implements this inline with subtle variations:
10
+ *
11
+ * - some use `releaseHandle(state.hookUnregister)` (`accessibility-auditor`)
12
+ * - some use `try { state.hookUnregister(); } catch {}` (`config-validator`)
13
+ * - some use a single inline `if (state.hookUnregister) { … }` block
14
+ *
15
+ * The drift cost: in 4 plugins the prior handle was leaked on reload
16
+ * because the inline `if` check raced with the new registration.
17
+ * This helper centralises the contract.
18
+ *
19
+ * Contract:
20
+ * `createH1State<T>(initial)` returns
21
+ * {
22
+ * state: T, // the user's mutable state
23
+ * register: (key, unregister) => void,
24
+ * release: (key) => void,
25
+ * releaseAll: () => void,
26
+ * }
27
+ *
28
+ * - `register(key, unregister)` releases any prior handle at `key`
29
+ * before storing the new one.
30
+ * - `release(key)` is a no-op if no handle is registered.
31
+ * - `releaseAll()` releases every registered handle and clears the map.
32
+ * - A throwing unregister function is swallowed (best-effort), matching
33
+ * the existing `releaseHandle` semantics at `runtime/handles.ts`.
34
+ *
35
+ * The state object itself is NOT reset by `releaseAll` — counter
36
+ * reset is the plugin's responsibility (it knows the semantics of its
37
+ * counters). This helper owns the handle lifecycle only.
38
+ */
39
+ export type Unregister = () => void;
40
+ export interface H1State<T> {
41
+ /** The plugin's mutable state. Owned by the caller; never reset by this helper. */
42
+ state: T;
43
+ /**
44
+ * Register an unregister function under `key`. Any prior handle at
45
+ * `key` is released first. Throwing unregister functions are
46
+ * swallowed.
47
+ */
48
+ register: (key: string, unregister: Unregister | null | undefined) => void;
49
+ /**
50
+ * Release the handle at `key` (if any). Idempotent. Throwing
51
+ * unregister functions are swallowed.
52
+ */
53
+ release: (key: string) => void;
54
+ /** Release every registered handle. Idempotent. */
55
+ releaseAll: () => void;
56
+ /** Number of currently registered handles. Observability for health()/status tools. */
57
+ size: () => number;
58
+ /** List the registered keys. Order is insertion order; useful for diagnostics. */
59
+ keys: () => string[];
60
+ }
61
+ export declare function createH1State<T>(initial: T): H1State<T>;
62
+ //# sourceMappingURL=h1-state.d.ts.map
@@ -32,6 +32,9 @@ export { parseLlmJsonObject, runOptionalPluginCouncil, runOptionalPluginLlm, str
32
32
  export { BoundedMap, BoundedSet, type BoundedMapOptions } from './bounded-map.js';
33
33
  export { UNSERIALIZABLE, safeJsonStringify } from './safe-json.js';
34
34
  export { releaseHandle, releaseHandles, type Unregister } from './handles.js';
35
+ export { withReDoSGuard, guardedMatcher, type ReDoSResult, type ReDoSOptions, } from './redos-guard.js';
36
+ export { safePath, isInsideProject, type SafePathOptions, } from './sandbox.js';
37
+ export { createH1State, type H1State, } from './h1-state.js';
35
38
  export { clearLocalBinCache, findOnPath, resolveExecInvocation, resolveFirstNodeBin, resolveNodeBin, resolveWin32Command, type ExecInvocation, type ResolvedNodeBin, } from './local-bin.js';
36
39
  export type LanguageId = 'typescript' | 'javascript' | 'python' | 'go' | 'rust' | 'shell' | 'ruby' | 'java' | 'kotlin' | 'dotnet' | 'generic';
37
40
  export type PackageManagerId = 'npm' | 'pnpm' | 'yarn' | 'bun' | 'pip' | 'poetry' | 'go' | 'cargo' | 'gem' | 'maven' | 'gradle' | 'dotnet' | 'none';