@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/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
  };
package/dist/llm-cache.js CHANGED
@@ -82,6 +82,12 @@ var plugin = {
82
82
  description: "Caches identical provider requests and short-circuits the provider call on a hit (wrapProviderRunner). Opt-in; deterministic-only by default.",
83
83
  apiVersion: "^0.1.10",
84
84
  capabilities: { tools: true },
85
+ // Wrap-stack contract (issue #362): ExtensionRegistry composes wrappers
86
+ // first-registered = outermost, and both dependsOn and optionalDeps load
87
+ // dependencies first. Declaring prompt-firewall here guarantees the
88
+ // firewall wraps OUTSIDE this cache even if the manifest order changes —
89
+ // otherwise a cache hit short-circuits `inner` and bypasses redaction.
90
+ optionalDeps: ["prompt-firewall"],
85
91
  defaultConfig: { ...DEFAULTS },
86
92
  configSchema: {
87
93
  type: "object",
package/dist/manifest.js CHANGED
@@ -35,10 +35,10 @@ var OFFICIAL_PLUGIN_NAMES = [
35
35
  "notify-hub",
36
36
  "changelog-writer",
37
37
  "injection-shield",
38
+ "prompt-firewall",
38
39
  "llm-cache",
39
40
  "model-router",
40
41
  "pr-drafter",
41
- "prompt-firewall",
42
42
  "auto-escalate",
43
43
  "test-coverage-gate",
44
44
  "type-gate",
@@ -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
  };
@@ -245,6 +245,13 @@ var OFFICIAL_PLUGIN_AUDIT_ENTRIES = [
245
245
  defaultState: "active",
246
246
  canDisable: true
247
247
  },
248
+ {
249
+ name: "prompt-firewall",
250
+ risk: "high",
251
+ summary: "Scans the provider wire for credential leaks before context reaches the LLM API (wrapProviderRunner); redact/warn/block. Opt-in; redact by default.",
252
+ defaultState: "inactive",
253
+ canDisable: true
254
+ },
248
255
  {
249
256
  name: "llm-cache",
250
257
  risk: "medium",
@@ -266,13 +273,6 @@ var OFFICIAL_PLUGIN_AUDIT_ENTRIES = [
266
273
  defaultState: "inactive",
267
274
  canDisable: true
268
275
  },
269
- {
270
- name: "prompt-firewall",
271
- risk: "high",
272
- summary: "Scans the provider wire for credential leaks before context reaches the LLM API (wrapProviderRunner); redact/warn/block. Opt-in; redact by default.",
273
- defaultState: "inactive",
274
- canDisable: true
275
- },
276
276
  {
277
277
  name: "auto-escalate",
278
278
  risk: "medium",
@@ -1,52 +1,61 @@
1
+ import type { Plugin } from '@wrongstack/core/types';
1
2
  /**
2
- * prompt-firewall plugin inspects and redacts secrets on the provider
3
- * wire, before context leaves for the LLM API and as it returns.
4
- *
5
- * Distinct from `secret-scanner` (which guards the TOOL boundary): this
6
- * sits on `AgentExtension.wrapProviderRunner`, so it sees the FULL
7
- * request that is about to be sent to a third-party LLM provider —
8
- * regardless of how a secret entered the conversation. It scans the
9
- * outgoing request's system + message text for high-confidence
10
- * credential patterns and, depending on `mode`:
11
- *
12
- * - `warn` — logs + counts + emits a `prompt-firewall:leak`
13
- * event; the request goes through unchanged
14
- * - `redact` (default) — replaces each match with `[REDACTED:<kind>]` in a CLONE
15
- * of the request before sending, and also redacts secrets echoed back
16
- * in the response
17
- * - `block` — throws before the request is sent (the agent's error
18
- * path surfaces it), so the secret never reaches the provider
19
- *
20
- * Safety posture: opt-in — loads inert until
21
- * `config.extensions['prompt-firewall'].enabled = true`. `redact` is the
22
- * default so secrets are automatically stripped; switch to `warn` only
23
- * for detection-mode diagnostics without data loss.
24
- *
25
- * Config (`config.extensions['prompt-firewall']`):
26
- *
27
- * ```jsonc
28
- * {
29
- * "enabled": false,
30
- * "mode": "redact", // "warn" | "redact" | "block"
31
- * "scanResponse": true, // redact secrets echoed back (redact mode)
32
- * "allow": [] // regex source strings to exempt (false positives)
33
- * }
34
- * ```
35
- *
36
- * Tools:
37
- * - `prompt_firewall_status` — mode, pattern names, detection counters
38
- *
39
- * @public
3
+ * Legacy `kind` names for the shapes this plugin already reported, so the
4
+ * canonical `type` ids from the shared table keep their existing spelling
5
+ * in logs, metrics and the status tool. Anything not listed here is
6
+ * reported under its canonical id.
40
7
  */
41
- import type { Plugin } from '@wrongstack/core/types';
8
+ export declare const KIND_ALIASES: Readonly<Record<string, string>>;
42
9
  export interface Detection {
43
10
  kind: string;
44
11
  count: number;
45
12
  }
13
+ /**
14
+ * A pattern excluded from a scan pass. `redos-timeout` = the pattern blew
15
+ * its wall-clock budget on the guarded probe and was skipped (fail-open
16
+ * with a visible counter — this surface trades blocking for availability;
17
+ * `secret-scanner` is the fail-closed tool-side gate).
18
+ */
19
+ export interface ScanSkip {
20
+ kind: string;
21
+ reason: 'redos-timeout';
22
+ }
23
+ /**
24
+ * Mutable deadline shared across one scan pass. `tripped` collects the
25
+ * kinds that blew the budget mid-pass; the caller merges them into the
26
+ * visible skip surface (status + counters + warn log).
27
+ */
28
+ interface ScanDeadline {
29
+ deadline: number;
30
+ tripped: Set<string>;
31
+ }
32
+ /**
33
+ * Hard bound on any single synchronous regex input in a scan pass. The
34
+ * structural guarantee of #371: even a growth-loop re-measure never hands
35
+ * a regex the full unbounded leaf.
36
+ */
37
+ export declare const SCAN_WINDOW_LIMIT: number;
46
38
  /** Detect secret matches in text. Returns per-kind counts (no values). */
47
39
  export declare function detectSecrets(text: string, allow: RegExp[]): Detection[];
48
40
  /** Redact secret matches in text, replacing each with `[REDACTED:<kind>]`. */
49
- export declare function redactSecrets(text: string, allow: RegExp[]): {
41
+ export declare function redactSecrets(text: string, allow: RegExp[], deadline?: ScanDeadline): {
42
+ text: string;
43
+ redactions: number;
44
+ };
45
+ /**
46
+ * Detect secret matches, skipping patterns that blow the ReDoS budget on
47
+ * the guarded probe. The skip set is returned so callers can surface it
48
+ * (status counters / logs) instead of silently trusting the result.
49
+ */
50
+ export declare function detectSecretsGuarded(text: string, allow: RegExp[]): Promise<{
51
+ detections: Detection[];
52
+ skipped: ScanSkip[];
53
+ }>;
54
+ /**
55
+ * Redact secret matches, skipping the given timed-out patterns (reuse the
56
+ * set from `detectSecretsGuarded` so a request pays the probe once).
57
+ */
58
+ export declare function redactSecretsGuarded(text: string, allow: RegExp[], skip: ReadonlySet<string>, deadline?: ScanDeadline): {
50
59
  text: string;
51
60
  redactions: number;
52
61
  };