@wrongstack/plugins 0.308.0 → 0.308.2

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/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",
@@ -562,14 +562,19 @@ function stripLauncherAtBoundary(command, launcher, valueTaking) {
562
562
  }
563
563
  return `${command.slice(0, match.index)}${boundary}${after.slice(consumed).replace(/^\s+/, "")}`;
564
564
  }
565
+ var XARGS_OPTIONS = String.raw`(?:\s+(?:(?:-[InLsPjeE]|--(?:arg-file|replace|max-args|max-lines|max-chars|max-procs))\s+[^\s]+|-[^\s]+))*`;
566
+ var COMMAND_BOUNDARY = String.raw`(?:^|[;&|\r\n]\s*|\{\s*|(?<![$(])\(\s*|\bxargs${XARGS_OPTIONS}\s+)`;
565
567
  function commandRecursivelyDeletes(command) {
566
568
  const stripped = stripTransparentLaunchers(maskNonExecutingHeredocBodies(command));
567
- if (/(?:^|[;&|\r\n]\s*|\$\(\s*|\(\s*|`\s*)find\b[^;&|)`]*(?:-delete\b|-exec(?:dir)?\s+(?:[^\s;&|]+[\\/])?(?:rm|rmdir)\b)/i.test(
569
+ if (/(?:^|[;&|\r\n]\s*|\{\s*|\$\(\s*|\(\s*|`\s*)find\b[^;&|)`]*(?:-delete\b|-exec(?:dir)?\s+(?:[^\s;&|]+[\\/])?(?:rm|rmdir)\b)/i.test(
568
570
  stripped
569
571
  )) {
570
572
  return true;
571
573
  }
572
- const destructive = /(?:^|[;&|\r\n]\s*|\{\s*|\(\s*|\bxargs(?:\s+-[^\s]+)*\s+)(rm|rmdir|del|rd|Remove-Item)\s+((?:"[^"]*"|'[^']*'|\\.|\{[^}]*\}|\([^()]*\)|[^;&|\r\n}])+)/gi;
574
+ const destructive = new RegExp(
575
+ String.raw`${COMMAND_BOUNDARY}(rm|rmdir|del|rd|Remove-Item)\s+((?:"[^"]*"|'[^']*'|\\.|\{[^}]*\}|\([^()]*\)|[^;&|\r\n}])+)`,
576
+ "gi"
577
+ );
573
578
  let match = destructive.exec(stripped);
574
579
  while (match !== null) {
575
580
  const tool = match[1]?.toLowerCase();
@@ -786,6 +791,27 @@ ${remainingCommand}`;
786
791
  if (heredoc !== null) maskBody(heredoc.bodyStart, lines.length, heredoc.quoted);
787
792
  return lines.join("");
788
793
  }
794
+ function stripSubshellClosers(raw) {
795
+ let result = raw.trimEnd();
796
+ while (result.endsWith(")")) {
797
+ if (quoteIsEscaped(result, result.length - 1)) break;
798
+ let quote = null;
799
+ let depth = 0;
800
+ for (let index = 0; index < result.length - 1; index += 1) {
801
+ const char = result[index];
802
+ if (isQuoteBoundary(result, index, quote)) {
803
+ quote = quote === char ? null : char === "'" ? "'" : '"';
804
+ continue;
805
+ }
806
+ if (quote !== null) continue;
807
+ if (char === "(" && !quoteIsEscaped(result, index)) depth += 1;
808
+ else if (char === ")" && !quoteIsEscaped(result, index)) depth -= 1;
809
+ }
810
+ if (depth > 0) break;
811
+ result = result.slice(0, -1).trimEnd();
812
+ }
813
+ return result;
814
+ }
789
815
  function destructiveTargets(command) {
790
816
  return destructiveTargetsAtDepth(command, 0);
791
817
  }
@@ -832,20 +858,26 @@ function destructiveTargetsAtDepth(command, depth) {
832
858
  }
833
859
  targets.push(...destructiveTargetsAtDepth(executableBody, depth + 1));
834
860
  }
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;
861
+ const destructive = new RegExp(
862
+ String.raw`${COMMAND_BOUNDARY}(?:sudo\s+)?(rm|rmdir|del|rd|Remove-Item|unlink|truncate|shred|mv)\s+((?:"[^"]*"|'[^']*'|\\.|\{[^}]*\}|\([^()]*\)|[^;&|\r\n}])+)`,
863
+ "gi"
864
+ );
836
865
  let m = destructive.exec(normalizedCommand);
837
866
  while (m !== null) {
838
867
  if (!tokenIsQuoted(m, m[1] ?? "")) targets.push(...shellArgs(m[2] ?? ""));
839
868
  m = destructive.exec(normalizedCommand);
840
869
  }
841
- const copy = /(?:^|[;&|\r\n]\s*)(?:sudo\s+)?(cp|install)\s+([^;&|\r\n]+)/gi;
870
+ const copy = new RegExp(
871
+ String.raw`${COMMAND_BOUNDARY}(?:sudo\s+)?(cp|install)\s+([^;&|\r\n]+)`,
872
+ "gi"
873
+ );
842
874
  let c = copy.exec(normalizedCommand);
843
875
  while (c !== null) {
844
876
  if (tokenIsQuoted(c, c[1] ?? "")) {
845
877
  c = copy.exec(normalizedCommand);
846
878
  continue;
847
879
  }
848
- const tokens = shellTokens(c[2] ?? "");
880
+ const tokens = shellTokens(stripSubshellClosers(c[2] ?? ""));
849
881
  let destination;
850
882
  for (let index = 0; index < tokens.length; index += 1) {
851
883
  const token = tokens[index];
@@ -862,23 +894,32 @@ function destructiveTargetsAtDepth(command, depth) {
862
894
  if (destination) targets.push(destination);
863
895
  c = copy.exec(normalizedCommand);
864
896
  }
865
- const tee = /(?:^|[;&|\r\n]\s*)(?:sudo\s+)?(tee)\s+([^;&|\r\n]+)/gi;
897
+ const tee = new RegExp(
898
+ String.raw`${COMMAND_BOUNDARY}(?:sudo\s+)?(tee)\s+([^;&|\r\n]+)`,
899
+ "gi"
900
+ );
866
901
  let t = tee.exec(normalizedCommand);
867
902
  while (t !== null) {
868
903
  if (!tokenIsQuoted(t, t[1] ?? "")) targets.push(...shellArgs(t[2] ?? ""));
869
904
  t = tee.exec(normalizedCommand);
870
905
  }
871
- const dd = /(?:^|[;&|\r\n]\s*)(?:sudo\s+)?(dd)\s+([^;&|\r\n]+)/gi;
906
+ const dd = new RegExp(
907
+ String.raw`${COMMAND_BOUNDARY}(?:sudo\s+)?(dd)\s+([^;&|\r\n]+)`,
908
+ "gi"
909
+ );
872
910
  let d = dd.exec(normalizedCommand);
873
911
  while (d !== null) {
874
912
  if (!tokenIsQuoted(d, d[1] ?? "")) {
875
913
  const outputMatch = /(?:^|\s)of=("[^"]*"|'[^']*'|[^\s]+)/i.exec(d[2] ?? "");
876
- const output = outputMatch?.[1]?.replace(/^['"]|['"]$/g, "");
914
+ const output = outputMatch?.[1] ? stripSubshellClosers(outputMatch[1]).replace(/^['"]|['"]$/g, "") : void 0;
877
915
  if (output) targets.push(output);
878
916
  }
879
917
  d = dd.exec(normalizedCommand);
880
918
  }
881
- const overwrite = /(?:^|[;&|\r\n]\s*)(?:sudo\s+)?(sed|ln)\s+([^;&|\r\n]+)/gi;
919
+ const overwrite = new RegExp(
920
+ String.raw`${COMMAND_BOUNDARY}(?:sudo\s+)?(sed|ln)\s+([^;&|\r\n]+)`,
921
+ "gi"
922
+ );
882
923
  let o = overwrite.exec(normalizedCommand);
883
924
  while (o !== null) {
884
925
  const rawArgs = o[2] ?? "";
@@ -896,7 +937,10 @@ function destructiveTargetsAtDepth(command, depth) {
896
937
  }
897
938
  o = overwrite.exec(normalizedCommand);
898
939
  }
899
- const xargsPipeline = /\b(?:echo|printf)\s+([^|]+)\|\s*xargs(?:\s+-[^\s]+)*\s+(?:sudo\s+)?(?:rm|rmdir|del|unlink|truncate|shred)\b/gi;
940
+ const xargsPipeline = new RegExp(
941
+ String.raw`\b(?:echo|printf)\s+([^|]+)\|\s*xargs${XARGS_OPTIONS}\s+(?:sudo\s+)?(?:rm|rmdir|del|unlink|truncate|shred)\b`,
942
+ "gi"
943
+ );
900
944
  let x = xargsPipeline.exec(normalizedCommand);
901
945
  while (x !== null) {
902
946
  if (!tokenIsQuoted(x, "xargs")) targets.push(...shellArgs(x[1] ?? ""));
@@ -1013,7 +1057,7 @@ function destructiveTargetsAtDepth(command, depth) {
1013
1057
  targets.push(gitTarget("."));
1014
1058
  }
1015
1059
  }
1016
- const findDelete = /(?:^|[;&|\r\n]\s*|\$\(\s*|\(\s*|`\s*)find\b([^;&|)`]*(?:\s-delete(?:\s|$)|\s-exec(?:dir)?\s+(?:[^\s;&|]+[\\/])?(?:rm|rmdir)\b)[^;&|)`]*)/gi;
1060
+ const findDelete = /(?:^|[;&|\r\n]\s*|\{\s*|\$\(\s*|\(\s*|`\s*)find\b([^;&|)`]*(?:\s-delete(?:[\s;})]|$)|\s-exec(?:dir)?\s+(?:[^\s;&|]+[\\/])?(?:rm|rmdir)\b)[^;&|)`]*)/gi;
1017
1061
  let f = findDelete.exec(normalizedCommand);
1018
1062
  while (f !== null) {
1019
1063
  const tokens = shellTokens(f[1] ?? "");
@@ -1471,10 +1515,19 @@ var plugin = {
1471
1515
  const ti = input.toolInput ?? {};
1472
1516
  const command = typeof ti["command"] === "string" ? ti["command"] : "";
1473
1517
  const commandArgs = Array.isArray(ti["args"]) ? ti["args"].filter((arg) => typeof arg === "string") : [];
1474
- const commandForInspection = [
1475
- command,
1476
- ...commandArgs.map((arg) => /^[\w./:@%+=,-]+$/.test(arg) ? arg : JSON.stringify(arg))
1477
- ].join(" ");
1518
+ const serializeArgForInspection = (arg) => {
1519
+ if (/^[\w./:@%+=,-]+$/.test(arg)) return arg;
1520
+ const assignment = /^([A-Za-z_]\w*)=(.+)$/.exec(arg);
1521
+ const key = assignment?.[1];
1522
+ const value = assignment?.[2];
1523
+ if (key !== void 0 && value !== void 0) {
1524
+ return `${key}="${value.replaceAll('"', "")}"`;
1525
+ }
1526
+ return JSON.stringify(arg);
1527
+ };
1528
+ const commandForInspection = [command, ...commandArgs.map(serializeArgForInspection)].join(
1529
+ " "
1530
+ );
1478
1531
  if (command && executesShell({
1479
1532
  toolName: input.toolName,
1480
1533
  toolInput: ti,
@@ -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,43 +1,3 @@
1
- /**
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
40
- */
41
1
  import type { Plugin } from '@wrongstack/core/types';
42
2
  /**
43
3
  * Legacy `kind` names for the shapes this plugin already reported, so the
@@ -50,10 +10,52 @@ export interface Detection {
50
10
  kind: string;
51
11
  count: number;
52
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;
53
38
  /** Detect secret matches in text. Returns per-kind counts (no values). */
54
39
  export declare function detectSecrets(text: string, allow: RegExp[]): Detection[];
55
40
  /** Redact secret matches in text, replacing each with `[REDACTED:<kind>]`. */
56
- 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): {
57
59
  text: string;
58
60
  redactions: number;
59
61
  };
@@ -1,3 +1,6 @@
1
+ // src/prompt-firewall/index.ts
2
+ import { performance } from "node:perf_hooks";
3
+
1
4
  // src/runtime/credential-patterns.ts
2
5
  var CREDENTIAL_PATTERNS = [
3
6
  // LLM provider keys
@@ -246,32 +249,161 @@ var PATTERNS = [
246
249
  })),
247
250
  ...EXTRA_PATTERNS
248
251
  ];
249
- function detectSecrets(text, allow) {
250
- const counts = /* @__PURE__ */ new Map();
251
- for (const p of PATTERNS) {
252
- p.re.lastIndex = 0;
253
- let m = p.re.exec(text);
252
+ var PATTERN_BUDGET_MS = 250;
253
+ var GUARD_PROBE_LENGTH = 1e5;
254
+ var GUARD_PROBE_OVERLAP = 4096;
255
+ var SCAN_PASS_BUDGET_MS = 250;
256
+ function createScanDeadline() {
257
+ return { deadline: performance.now() + SCAN_PASS_BUDGET_MS, tripped: /* @__PURE__ */ new Set() };
258
+ }
259
+ var SCAN_WINDOW_STRIDE = GUARD_PROBE_LENGTH - GUARD_PROBE_OVERLAP;
260
+ var SCAN_WINDOW_TAIL = 65536;
261
+ var SCAN_WINDOW_LIMIT = GUARD_PROBE_LENGTH + SCAN_WINDOW_TAIL;
262
+ var SCAN_MAX_GROWTH = 16;
263
+ function growMatch(re, p, text, absStart, deadline) {
264
+ let size = GUARD_PROBE_LENGTH;
265
+ for (let grown = 0; grown < SCAN_MAX_GROWTH; grown++) {
266
+ if (deadline && performance.now() > deadline.deadline) {
267
+ deadline.tripped.add(p.kind);
268
+ return null;
269
+ }
270
+ const extEnd = Math.min(text.length, absStart + size);
271
+ const ext = text.slice(absStart, extEnd);
272
+ re.lastIndex = 0;
273
+ const em = re.exec(ext);
274
+ if (!em || em.index !== 0 || em[0].length === 0) return null;
275
+ const end = absStart + em[0].length;
276
+ if (end < extEnd || extEnd === text.length) {
277
+ return { start: absStart, end, matched: em[0] };
278
+ }
279
+ size *= 2;
280
+ }
281
+ return null;
282
+ }
283
+ function* execWindowed(p, text, deadline) {
284
+ const re = new RegExp(p.re.source, p.re.flags);
285
+ let acceptLo = 0;
286
+ let highWater = 0;
287
+ for (let window = 0; acceptLo < text.length; window++) {
288
+ const sliceStart = window === 0 ? 0 : acceptLo - GUARD_PROBE_OVERLAP;
289
+ const sliceEnd = Math.min(text.length, sliceStart + SCAN_WINDOW_LIMIT);
290
+ const slice = text.slice(sliceStart, sliceEnd);
291
+ const acceptHi = Math.min(acceptLo + SCAN_WINDOW_STRIDE, text.length);
292
+ re.lastIndex = 0;
293
+ let m = re.exec(slice);
254
294
  while (m !== null) {
295
+ if (deadline && performance.now() > deadline.deadline) {
296
+ deadline.tripped.add(p.kind);
297
+ return;
298
+ }
255
299
  const matched = m[0];
256
- if (!allow.some((a) => a.test(matched))) {
257
- counts.set(p.kind, (counts.get(p.kind) ?? 0) + 1);
300
+ if (matched.length === 0) {
301
+ re.lastIndex += 1;
302
+ m = re.exec(slice);
303
+ continue;
258
304
  }
259
- m = p.re.exec(text);
305
+ const absStart = sliceStart + m.index;
306
+ const absEnd = absStart + matched.length;
307
+ if (absStart >= acceptLo && absStart < acceptHi) {
308
+ let final = { start: absStart, end: absEnd, matched };
309
+ if (absEnd === sliceEnd && sliceEnd < text.length) {
310
+ final = growMatch(re, p, text, absStart, deadline) ?? final;
311
+ re.lastIndex = m.index + final.matched.length;
312
+ }
313
+ if (final.start >= highWater) {
314
+ highWater = Math.max(highWater, final.end);
315
+ yield final;
316
+ }
317
+ }
318
+ m = re.exec(slice);
319
+ }
320
+ acceptLo += SCAN_WINDOW_STRIDE;
321
+ }
322
+ }
323
+ function countMatches(p, text, allow, counts, deadline) {
324
+ if (deadline && performance.now() > deadline.deadline) {
325
+ deadline.tripped.add(p.kind);
326
+ return;
327
+ }
328
+ for (const m of execWindowed(p, text, deadline)) {
329
+ if (!allow.some((a) => a.test(m.matched))) {
330
+ counts.set(p.kind, (counts.get(p.kind) ?? 0) + 1);
331
+ }
332
+ }
333
+ }
334
+ function replacePattern(p, text, allow, redactions, deadline) {
335
+ if (deadline && performance.now() > deadline.deadline) {
336
+ deadline.tripped.add(p.kind);
337
+ return text;
338
+ }
339
+ let out = "";
340
+ let copied = 0;
341
+ for (const m of execWindowed(p, text, deadline)) {
342
+ out += text.slice(copied, m.start);
343
+ if (allow.some((a) => a.test(m.matched))) {
344
+ out += m.matched;
345
+ } else {
346
+ redactions.n += 1;
347
+ out += `[REDACTED:${p.kind}]`;
260
348
  }
349
+ copied = m.end;
261
350
  }
351
+ return out + text.slice(copied);
352
+ }
353
+ function detectSecrets(text, allow) {
354
+ const counts = /* @__PURE__ */ new Map();
355
+ for (const p of PATTERNS) countMatches(p, text, allow, counts);
262
356
  return [...counts.entries()].map(([kind, count]) => ({ kind, count }));
263
357
  }
264
- function redactSecrets(text, allow) {
358
+ function redactSecrets(text, allow, deadline) {
359
+ const redactions = { n: 0 };
265
360
  let out = text;
266
- let redactions = 0;
267
- for (const p of PATTERNS) {
268
- out = out.replace(new RegExp(p.re.source, p.re.flags), (match) => {
269
- if (allow.some((a) => a.test(match))) return match;
270
- redactions += 1;
271
- return `[REDACTED:${p.kind}]`;
361
+ for (const p of PATTERNS) out = replacePattern(p, out, allow, redactions, deadline);
362
+ return { text: out, redactions: redactions.n };
363
+ }
364
+ var DISTINCT_PATTERN_KINDS = new Set(PATTERNS.map((p) => p.kind)).size;
365
+ async function probeTimedOutPatterns(text) {
366
+ const timedOut = /* @__PURE__ */ new Set();
367
+ if (text.length === 0) return timedOut;
368
+ const probeWindow = (offset) => {
369
+ const window = text.slice(offset, offset + GUARD_PROBE_LENGTH);
370
+ const combined = new RegExp(PATTERNS.map((p) => `(${p.re.source})`).join("|"), "gi");
371
+ return withReDoSGuard(combined, window, PATTERN_BUDGET_MS).then(async (combinedResult) => {
372
+ if (!combinedResult.timedOut) return;
373
+ for (const p of PATTERNS) {
374
+ if (timedOut.has(p.kind)) continue;
375
+ const result = await withReDoSGuard(p.re, window, PATTERN_BUDGET_MS);
376
+ if (result.timedOut) timedOut.add(p.kind);
377
+ }
272
378
  });
379
+ };
380
+ const stride = GUARD_PROBE_LENGTH - GUARD_PROBE_OVERLAP;
381
+ for (let offset = 0; offset < text.length; offset += stride) {
382
+ if (timedOut.size >= DISTINCT_PATTERN_KINDS) break;
383
+ await probeWindow(offset);
384
+ }
385
+ return timedOut;
386
+ }
387
+ async function detectSecretsGuarded(text, allow) {
388
+ const timedOut = await probeTimedOutPatterns(text);
389
+ const deadline = createScanDeadline();
390
+ const counts = /* @__PURE__ */ new Map();
391
+ for (const p of PATTERNS) {
392
+ if (!timedOut.has(p.kind)) countMatches(p, text, allow, counts, deadline);
393
+ }
394
+ const allSkipped = /* @__PURE__ */ new Set([...timedOut, ...deadline.tripped]);
395
+ return {
396
+ detections: [...counts.entries()].map(([kind, count]) => ({ kind, count })),
397
+ skipped: [...allSkipped].sort().map((kind) => ({ kind, reason: "redos-timeout" }))
398
+ };
399
+ }
400
+ function redactSecretsGuarded(text, allow, skip, deadline) {
401
+ const redactions = { n: 0 };
402
+ let out = text;
403
+ for (const p of PATTERNS) {
404
+ if (!skip.has(p.kind)) out = replacePattern(p, out, allow, redactions, deadline);
273
405
  }
274
- return { text: out, redactions };
406
+ return { text: out, redactions: redactions.n };
275
407
  }
276
408
  function collectText(request) {
277
409
  const parts = [];
@@ -285,17 +417,27 @@ function collectText(request) {
285
417
  walk(request["messages"]);
286
418
  return parts.join("\n");
287
419
  }
288
- function redactDeep(value, allow, counter) {
420
+ var RESPONSE_SCAN_BUDGET = 1e6;
421
+ function redactDeep(value, allow, counter, skip, budget, deadline) {
289
422
  if (typeof value === "string") {
290
- const { text, redactions } = redactSecrets(value, allow);
423
+ if (budget) {
424
+ if (value.length > budget.remaining) {
425
+ budget.remaining = 0;
426
+ budget.truncated = true;
427
+ return value;
428
+ }
429
+ budget.remaining -= value.length;
430
+ }
431
+ const { text, redactions } = skip ? redactSecretsGuarded(value, allow, skip, deadline) : redactSecrets(value, allow, deadline);
291
432
  counter.n += redactions;
292
433
  return text;
293
434
  }
294
- if (Array.isArray(value)) return value.map((v) => redactDeep(v, allow, counter));
435
+ if (Array.isArray(value))
436
+ return value.map((v) => redactDeep(v, allow, counter, skip, budget, deadline));
295
437
  if (value && typeof value === "object") {
296
438
  const out = {};
297
439
  for (const [k, v] of Object.entries(value)) {
298
- out[k] = redactDeep(v, allow, counter);
440
+ out[k] = redactDeep(v, allow, counter, skip, budget, deadline);
299
441
  }
300
442
  return out;
301
443
  }
@@ -331,16 +473,33 @@ var state = {
331
473
  responseRedactions: 0,
332
474
  blocked: 0,
333
475
  timeoutCount: 0,
476
+ skippedPatterns: [],
477
+ responseTruncated: false,
334
478
  byKind: /* @__PURE__ */ new Map(),
335
479
  lastDetection: null,
336
480
  extensionUnregister: null
337
481
  };
482
+ function surfaceScanTrips(api, tripped) {
483
+ for (const kind of tripped) {
484
+ if (!state.skippedPatterns.includes(kind)) state.skippedPatterns.push(kind);
485
+ }
486
+ state.timeoutCount += 1;
487
+ api.metrics.counter("redos_skips", 1);
488
+ api.log.warn("prompt-firewall: scan-pass budget exceeded \u2014 patterns skipped mid-pass (issue #370)", {
489
+ skipped: [...tripped]
490
+ });
491
+ }
338
492
  var plugin = {
339
493
  name: "prompt-firewall",
340
494
  version: "0.1.0",
341
495
  description: "Scans the provider wire for credential leaks before context reaches the LLM API (wrapProviderRunner); redact/warn/block. Opt-in; redact by default.",
342
496
  apiVersion: "^0.1.10",
343
497
  capabilities: { tools: true },
498
+ // Wrap-stack contract (issue #362): ExtensionRegistry composes wrappers
499
+ // first-registered = outermost. The manifest lists this plugin before
500
+ // llm-cache, and llm-cache declares this plugin in optionalDeps, so the
501
+ // firewall is the outer wrap: every request is scanned/redacted before
502
+ // llm-cache can fingerprint or cache it.
344
503
  defaultConfig: { enabled: false, mode: "redact", scanResponse: true, allow: [] },
345
504
  configSchema: {
346
505
  type: "object",
@@ -376,6 +535,8 @@ var plugin = {
376
535
  state.responseRedactions = 0;
377
536
  state.blocked = 0;
378
537
  state.timeoutCount = 0;
538
+ state.skippedPatterns = [];
539
+ state.responseTruncated = false;
379
540
  state.byKind.clear();
380
541
  state.lastDetection = null;
381
542
  if (state.extensionUnregister) {
@@ -399,15 +560,16 @@ var plugin = {
399
560
  const req = request ?? {};
400
561
  state.invocations += 1;
401
562
  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
- }
563
+ const { detections, skipped } = await detectSecretsGuarded(requestText, cfg.allow);
564
+ state.skippedPatterns = skipped.map((s) => s.kind);
565
+ if (skipped.length > 0) {
566
+ state.timeoutCount += 1;
567
+ api.log.warn("prompt-firewall: ReDoS budget exceeded, patterns skipped", {
568
+ skipped: state.skippedPatterns
407
569
  });
408
- if (guarded.timedOut) break;
570
+ api.metrics.counter("redos_skips", 1);
409
571
  }
410
- const detections = detectSecrets(requestText, cfg.allow);
572
+ const skipSet = new Set(state.skippedPatterns);
411
573
  if (detections.length > 0) {
412
574
  state.requestsWithSecrets += 1;
413
575
  for (const d of detections) {
@@ -426,27 +588,39 @@ var plugin = {
426
588
  }
427
589
  if (cfg.mode === "redact") {
428
590
  const counter = { n: 0 };
429
- const redactedReq = redactDeep(req, cfg.allow, counter);
591
+ const deadline = createScanDeadline();
592
+ const redactedReq = redactDeep(req, cfg.allow, counter, skipSet, void 0, deadline);
430
593
  state.requestRedactions += counter.n;
431
594
  api.metrics.counter("request_redactions", counter.n);
595
+ if (deadline.tripped.size > 0) surfaceScanTrips(api, deadline.tripped);
432
596
  const response2 = await inner(_ctx, redactedReq);
433
- return cfg.scanResponse ? redactResponse(response2, cfg.allow) : response2;
597
+ return cfg.scanResponse ? redactResponse(response2, cfg.allow, skipSet) : response2;
434
598
  }
435
599
  }
436
600
  const response = await inner(_ctx, request);
437
601
  if (cfg.mode === "redact" && cfg.scanResponse) {
438
- return redactResponse(response, cfg.allow);
602
+ return redactResponse(response, cfg.allow, skipSet);
439
603
  }
440
604
  return response;
441
605
  }
442
606
  });
443
607
  }
444
- function redactResponse(response, allow) {
608
+ function redactResponse(response, allow, skip) {
445
609
  if (!response || typeof response !== "object") return response;
446
610
  const counter = { n: 0 };
447
611
  const content = response.content;
448
612
  if (content === void 0) return response;
449
- const redacted = redactDeep(content, allow, counter);
613
+ const budget = { remaining: RESPONSE_SCAN_BUDGET, truncated: false };
614
+ const deadline = createScanDeadline();
615
+ const redacted = redactDeep(content, allow, counter, skip, budget, deadline);
616
+ if (deadline.tripped.size > 0) surfaceScanTrips(api, deadline.tripped);
617
+ if (budget.truncated) {
618
+ state.responseTruncated = true;
619
+ api.log.warn(
620
+ "prompt-firewall: response scan budget exhausted \u2014 part of the response was returned unredacted"
621
+ );
622
+ api.metrics.counter("response_scan_truncated", 1);
623
+ }
450
624
  if (counter.n > 0) {
451
625
  state.responseRedactions += counter.n;
452
626
  api.metrics.counter("response_redactions", counter.n);
@@ -472,6 +646,8 @@ var plugin = {
472
646
  mode: cfg.mode,
473
647
  scanResponse: cfg.scanResponse,
474
648
  patterns: PATTERNS.map((p) => p.kind),
649
+ skippedPatterns: state.skippedPatterns,
650
+ responseTruncated: state.responseTruncated,
475
651
  counters: {
476
652
  invocations: state.invocations,
477
653
  requestsWithSecrets: state.requestsWithSecrets,
@@ -505,13 +681,17 @@ var plugin = {
505
681
  requestsWithSecrets: state.requestsWithSecrets,
506
682
  requestRedactions: state.requestRedactions,
507
683
  responseRedactions: state.responseRedactions,
508
- blocked: state.blocked
684
+ blocked: state.blocked,
685
+ timeoutCount: state.timeoutCount
509
686
  };
510
687
  state.invocations = 0;
511
688
  state.requestsWithSecrets = 0;
512
689
  state.requestRedactions = 0;
513
690
  state.responseRedactions = 0;
514
691
  state.blocked = 0;
692
+ state.timeoutCount = 0;
693
+ state.skippedPatterns = [];
694
+ state.responseTruncated = false;
515
695
  state.byKind.clear();
516
696
  state.lastDetection = null;
517
697
  api.log.info("prompt-firewall: teardown complete", { final });
@@ -533,8 +713,11 @@ var plugin = {
533
713
  var prompt_firewall_default = plugin;
534
714
  export {
535
715
  KIND_ALIASES,
716
+ SCAN_WINDOW_LIMIT,
536
717
  prompt_firewall_default as default,
537
718
  detectSecrets,
719
+ detectSecretsGuarded,
538
720
  readConfig,
539
- redactSecrets
721
+ redactSecrets,
722
+ redactSecretsGuarded
540
723
  };
@@ -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",