@wrongstack/plugins 0.308.0 → 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.
@@ -208,6 +208,12 @@ export declare const OFFICIAL_PLUGIN_AUDIT_ENTRIES: readonly [{
208
208
  readonly summary: 'Scans tool output (fetched pages, files) for prompt-injection patterns and warns the model that content is data, not instructions';
209
209
  readonly defaultState: 'active';
210
210
  readonly canDisable: true;
211
+ }, {
212
+ readonly name: 'prompt-firewall';
213
+ readonly risk: 'high';
214
+ readonly summary: 'Scans the provider wire for credential leaks before context reaches the LLM API (wrapProviderRunner); redact/warn/block. Opt-in; redact by default.';
215
+ readonly defaultState: 'inactive';
216
+ readonly canDisable: true;
211
217
  }, {
212
218
  readonly name: 'llm-cache';
213
219
  readonly risk: 'medium';
@@ -226,12 +232,6 @@ export declare const OFFICIAL_PLUGIN_AUDIT_ENTRIES: readonly [{
226
232
  readonly summary: 'Collects session work (commits, edited files, diff) and drafts a pull-request description';
227
233
  readonly defaultState: 'inactive';
228
234
  readonly canDisable: true;
229
- }, {
230
- readonly name: 'prompt-firewall';
231
- readonly risk: 'high';
232
- readonly summary: 'Scans the provider wire for credential leaks before context reaches the LLM API (wrapProviderRunner); redact/warn/block. Opt-in; redact by default.';
233
- readonly defaultState: 'inactive';
234
- readonly canDisable: true;
235
235
  }, {
236
236
  readonly name: 'auto-escalate';
237
237
  readonly risk: 'medium';
package/dist/audit.js CHANGED
@@ -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,4 +1,4 @@
1
1
  import type { Plugin } from '@wrongstack/core/types';
2
- export declare const OFFICIAL_PLUGIN_SPECIFIERS: readonly ['@wrongstack/plugins/agent-handoff', '@wrongstack/plugins/cost-tracker', '@wrongstack/plugins/file-watcher', '@wrongstack/plugins/git-autocommit', '@wrongstack/plugins/auto-doc', '@wrongstack/plugins/shell-check', '@wrongstack/plugins/cron', '@wrongstack/plugins/template-engine', '@wrongstack/plugins/semver-bump', '@wrongstack/plugins/secret-scanner', '@wrongstack/plugins/todo-tracker', '@wrongstack/plugins/token-budget', '@wrongstack/plugins/lint-gate', '@wrongstack/plugins/branch-guard', '@wrongstack/plugins/diff-summary', '@wrongstack/plugins/commit-validator', '@wrongstack/plugins/format-on-save', '@wrongstack/plugins/test-runner-gate', '@wrongstack/plugins/import-organizer', '@wrongstack/plugins/knowledge-graph', '@wrongstack/plugins/todo-listener', '@wrongstack/plugins/session-recap', '@wrongstack/plugins/spec-linker', '@wrongstack/plugins/loop-breaker', '@wrongstack/plugins/gitignore-guard', '@wrongstack/plugins/path-guard', '@wrongstack/plugins/process-guard', '@wrongstack/plugins/context-pins', '@wrongstack/plugins/checkpoint', '@wrongstack/plugins/error-lens', '@wrongstack/plugins/dep-guard', '@wrongstack/plugins/config-validator', '@wrongstack/plugins/notify-hub', '@wrongstack/plugins/changelog-writer', '@wrongstack/plugins/injection-shield', '@wrongstack/plugins/llm-cache', '@wrongstack/plugins/model-router', '@wrongstack/plugins/pr-drafter', '@wrongstack/plugins/prompt-firewall', '@wrongstack/plugins/auto-escalate', '@wrongstack/plugins/test-coverage-gate', '@wrongstack/plugins/type-gate', '@wrongstack/plugins/token-throttle', '@wrongstack/plugins/plugin-stack-observer', '@wrongstack/plugins/dependency-vulnerability-gate', '@wrongstack/plugins/migration-planner', '@wrongstack/plugins/semantic-search-indexer', '@wrongstack/plugins/auto-i18n-extractor', '@wrongstack/plugins/doc-sync-guard', '@wrongstack/plugins/api-compatibility-gate', '@wrongstack/plugins/performance-regression-gate', '@wrongstack/plugins/test-flake-detector', '@wrongstack/plugins/schema-evolution-guard', '@wrongstack/plugins/license-audit-gate', '@wrongstack/plugins/accessibility-auditor', '@wrongstack/plugins/security-hotspot-scanner', '@wrongstack/plugins/dead-code-detector', '@wrongstack/plugins/duplicate-code-detector', '@wrongstack/plugins/code-metrics', '@wrongstack/plugins/refactor-suggester', '@wrongstack/plugins/test-generator', '@wrongstack/plugins/release-notes-generator', '@wrongstack/plugins/smart-rename', '@wrongstack/plugins/feature-flag-tracker', '@wrongstack/plugins/interface-contract-guard'];
2
+ export declare const OFFICIAL_PLUGIN_SPECIFIERS: readonly ['@wrongstack/plugins/agent-handoff', '@wrongstack/plugins/cost-tracker', '@wrongstack/plugins/file-watcher', '@wrongstack/plugins/git-autocommit', '@wrongstack/plugins/auto-doc', '@wrongstack/plugins/shell-check', '@wrongstack/plugins/cron', '@wrongstack/plugins/template-engine', '@wrongstack/plugins/semver-bump', '@wrongstack/plugins/secret-scanner', '@wrongstack/plugins/todo-tracker', '@wrongstack/plugins/token-budget', '@wrongstack/plugins/lint-gate', '@wrongstack/plugins/branch-guard', '@wrongstack/plugins/diff-summary', '@wrongstack/plugins/commit-validator', '@wrongstack/plugins/format-on-save', '@wrongstack/plugins/test-runner-gate', '@wrongstack/plugins/import-organizer', '@wrongstack/plugins/knowledge-graph', '@wrongstack/plugins/todo-listener', '@wrongstack/plugins/session-recap', '@wrongstack/plugins/spec-linker', '@wrongstack/plugins/loop-breaker', '@wrongstack/plugins/gitignore-guard', '@wrongstack/plugins/path-guard', '@wrongstack/plugins/process-guard', '@wrongstack/plugins/context-pins', '@wrongstack/plugins/checkpoint', '@wrongstack/plugins/error-lens', '@wrongstack/plugins/dep-guard', '@wrongstack/plugins/config-validator', '@wrongstack/plugins/notify-hub', '@wrongstack/plugins/changelog-writer', '@wrongstack/plugins/injection-shield', '@wrongstack/plugins/prompt-firewall', '@wrongstack/plugins/llm-cache', '@wrongstack/plugins/model-router', '@wrongstack/plugins/pr-drafter', '@wrongstack/plugins/auto-escalate', '@wrongstack/plugins/test-coverage-gate', '@wrongstack/plugins/type-gate', '@wrongstack/plugins/token-throttle', '@wrongstack/plugins/plugin-stack-observer', '@wrongstack/plugins/dependency-vulnerability-gate', '@wrongstack/plugins/migration-planner', '@wrongstack/plugins/semantic-search-indexer', '@wrongstack/plugins/auto-i18n-extractor', '@wrongstack/plugins/doc-sync-guard', '@wrongstack/plugins/api-compatibility-gate', '@wrongstack/plugins/performance-regression-gate', '@wrongstack/plugins/test-flake-detector', '@wrongstack/plugins/schema-evolution-guard', '@wrongstack/plugins/license-audit-gate', '@wrongstack/plugins/accessibility-auditor', '@wrongstack/plugins/security-hotspot-scanner', '@wrongstack/plugins/dead-code-detector', '@wrongstack/plugins/duplicate-code-detector', '@wrongstack/plugins/code-metrics', '@wrongstack/plugins/refactor-suggester', '@wrongstack/plugins/test-generator', '@wrongstack/plugins/release-notes-generator', '@wrongstack/plugins/smart-rename', '@wrongstack/plugins/feature-flag-tracker', '@wrongstack/plugins/interface-contract-guard'];
3
3
  export declare const OFFICIAL_PLUGIN_FACTORIES: readonly (() => Promise<Plugin>)[];
4
4
  //# sourceMappingURL=index.d.ts.map
package/dist/factories.js CHANGED
@@ -35,10 +35,10 @@ var OFFICIAL_PLUGIN_SPECIFIERS = [
35
35
  "@wrongstack/plugins/notify-hub",
36
36
  "@wrongstack/plugins/changelog-writer",
37
37
  "@wrongstack/plugins/injection-shield",
38
+ "@wrongstack/plugins/prompt-firewall",
38
39
  "@wrongstack/plugins/llm-cache",
39
40
  "@wrongstack/plugins/model-router",
40
41
  "@wrongstack/plugins/pr-drafter",
41
- "@wrongstack/plugins/prompt-firewall",
42
42
  "@wrongstack/plugins/auto-escalate",
43
43
  "@wrongstack/plugins/test-coverage-gate",
44
44
  "@wrongstack/plugins/type-gate",
@@ -37,6 +37,9 @@ function cachePut(key, value) {
37
37
  binCache.set(key, { value, cachedAt: Date.now() });
38
38
  return value;
39
39
  }
40
+ function clearLocalBinCache() {
41
+ binCache.clear();
42
+ }
40
43
  function resolveNodeBin(packageName, binName, cwd, extraArgs = []) {
41
44
  const key = `${packageName}|${binName}|${cwd}`;
42
45
  const cached = binCache.get(key);
@@ -450,6 +453,7 @@ ${result.stderr.trim()}`
450
453
  });
451
454
  },
452
455
  teardown(api) {
456
+ clearLocalBinCache();
453
457
  if (state.hookUnregister) {
454
458
  try {
455
459
  state.hookUnregister();
package/dist/index.js CHANGED
@@ -10298,6 +10298,7 @@ ${result.stderr.trim()}`
10298
10298
  });
10299
10299
  },
10300
10300
  teardown(api) {
10301
+ clearLocalBinCache();
10301
10302
  if (state25.hookUnregister) {
10302
10303
  try {
10303
10304
  state25.hookUnregister();
@@ -11951,6 +11952,12 @@ var plugin34 = {
11951
11952
  description: "Caches identical provider requests and short-circuits the provider call on a hit (wrapProviderRunner). Opt-in; deterministic-only by default.",
11952
11953
  apiVersion: "^0.1.10",
11953
11954
  capabilities: { tools: true },
11955
+ // Wrap-stack contract (issue #362): ExtensionRegistry composes wrappers
11956
+ // first-registered = outermost, and both dependsOn and optionalDeps load
11957
+ // dependencies first. Declaring prompt-firewall here guarantees the
11958
+ // firewall wraps OUTSIDE this cache even if the manifest order changes —
11959
+ // otherwise a cache hit short-circuits `inner` and bypasses redaction.
11960
+ optionalDeps: ["prompt-firewall"],
11954
11961
  defaultConfig: { ...DEFAULTS28 },
11955
11962
  configSchema: {
11956
11963
  type: "object",
@@ -16226,6 +16233,9 @@ var plugin42 = {
16226
16233
  };
16227
16234
  var process_guard_default = plugin42;
16228
16235
 
16236
+ // src/prompt-firewall/index.ts
16237
+ import { performance as performance2 } from "node:perf_hooks";
16238
+
16229
16239
  // src/runtime/credential-patterns.ts
16230
16240
  var CREDENTIAL_PATTERNS = [
16231
16241
  // LLM provider keys
@@ -16397,32 +16407,156 @@ var PATTERNS2 = [
16397
16407
  })),
16398
16408
  ...EXTRA_PATTERNS
16399
16409
  ];
16400
- function detectSecrets(text, allow) {
16401
- const counts = /* @__PURE__ */ new Map();
16402
- for (const p of PATTERNS2) {
16403
- p.re.lastIndex = 0;
16404
- let m = p.re.exec(text);
16410
+ var PATTERN_BUDGET_MS = 250;
16411
+ var GUARD_PROBE_LENGTH = 1e5;
16412
+ var GUARD_PROBE_OVERLAP = 4096;
16413
+ var SCAN_PASS_BUDGET_MS = 250;
16414
+ function createScanDeadline() {
16415
+ return { deadline: performance2.now() + SCAN_PASS_BUDGET_MS, tripped: /* @__PURE__ */ new Set() };
16416
+ }
16417
+ var SCAN_WINDOW_STRIDE = GUARD_PROBE_LENGTH - GUARD_PROBE_OVERLAP;
16418
+ var SCAN_WINDOW_TAIL = 65536;
16419
+ var SCAN_WINDOW_LIMIT = GUARD_PROBE_LENGTH + SCAN_WINDOW_TAIL;
16420
+ var SCAN_MAX_GROWTH = 16;
16421
+ function growMatch(re, p, text, absStart, deadline) {
16422
+ let size = GUARD_PROBE_LENGTH;
16423
+ for (let grown = 0; grown < SCAN_MAX_GROWTH; grown++) {
16424
+ if (deadline && performance2.now() > deadline.deadline) {
16425
+ deadline.tripped.add(p.kind);
16426
+ return null;
16427
+ }
16428
+ const extEnd = Math.min(text.length, absStart + size);
16429
+ const ext = text.slice(absStart, extEnd);
16430
+ re.lastIndex = 0;
16431
+ const em = re.exec(ext);
16432
+ if (!em || em.index !== 0 || em[0].length === 0) return null;
16433
+ const end = absStart + em[0].length;
16434
+ if (end < extEnd || extEnd === text.length) {
16435
+ return { start: absStart, end, matched: em[0] };
16436
+ }
16437
+ size *= 2;
16438
+ }
16439
+ return null;
16440
+ }
16441
+ function* execWindowed(p, text, deadline) {
16442
+ const re = new RegExp(p.re.source, p.re.flags);
16443
+ let acceptLo = 0;
16444
+ let highWater = 0;
16445
+ for (let window = 0; acceptLo < text.length; window++) {
16446
+ const sliceStart = window === 0 ? 0 : acceptLo - GUARD_PROBE_OVERLAP;
16447
+ const sliceEnd = Math.min(text.length, sliceStart + SCAN_WINDOW_LIMIT);
16448
+ const slice = text.slice(sliceStart, sliceEnd);
16449
+ const acceptHi = Math.min(acceptLo + SCAN_WINDOW_STRIDE, text.length);
16450
+ re.lastIndex = 0;
16451
+ let m = re.exec(slice);
16405
16452
  while (m !== null) {
16453
+ if (deadline && performance2.now() > deadline.deadline) {
16454
+ deadline.tripped.add(p.kind);
16455
+ return;
16456
+ }
16406
16457
  const matched = m[0];
16407
- if (!allow.some((a) => a.test(matched))) {
16408
- counts.set(p.kind, (counts.get(p.kind) ?? 0) + 1);
16458
+ if (matched.length === 0) {
16459
+ re.lastIndex += 1;
16460
+ m = re.exec(slice);
16461
+ continue;
16462
+ }
16463
+ const absStart = sliceStart + m.index;
16464
+ const absEnd = absStart + matched.length;
16465
+ if (absStart >= acceptLo && absStart < acceptHi) {
16466
+ let final = { start: absStart, end: absEnd, matched };
16467
+ if (absEnd === sliceEnd && sliceEnd < text.length) {
16468
+ final = growMatch(re, p, text, absStart, deadline) ?? final;
16469
+ re.lastIndex = m.index + final.matched.length;
16470
+ }
16471
+ if (final.start >= highWater) {
16472
+ highWater = Math.max(highWater, final.end);
16473
+ yield final;
16474
+ }
16409
16475
  }
16410
- m = p.re.exec(text);
16476
+ m = re.exec(slice);
16477
+ }
16478
+ acceptLo += SCAN_WINDOW_STRIDE;
16479
+ }
16480
+ }
16481
+ function countMatches(p, text, allow, counts, deadline) {
16482
+ if (deadline && performance2.now() > deadline.deadline) {
16483
+ deadline.tripped.add(p.kind);
16484
+ return;
16485
+ }
16486
+ for (const m of execWindowed(p, text, deadline)) {
16487
+ if (!allow.some((a) => a.test(m.matched))) {
16488
+ counts.set(p.kind, (counts.get(p.kind) ?? 0) + 1);
16411
16489
  }
16412
16490
  }
16413
- return [...counts.entries()].map(([kind, count]) => ({ kind, count }));
16414
16491
  }
16415
- function redactSecrets(text, allow) {
16492
+ function replacePattern(p, text, allow, redactions, deadline) {
16493
+ if (deadline && performance2.now() > deadline.deadline) {
16494
+ deadline.tripped.add(p.kind);
16495
+ return text;
16496
+ }
16497
+ let out = "";
16498
+ let copied = 0;
16499
+ for (const m of execWindowed(p, text, deadline)) {
16500
+ out += text.slice(copied, m.start);
16501
+ if (allow.some((a) => a.test(m.matched))) {
16502
+ out += m.matched;
16503
+ } else {
16504
+ redactions.n += 1;
16505
+ out += `[REDACTED:${p.kind}]`;
16506
+ }
16507
+ copied = m.end;
16508
+ }
16509
+ return out + text.slice(copied);
16510
+ }
16511
+ function redactSecrets(text, allow, deadline) {
16512
+ const redactions = { n: 0 };
16416
16513
  let out = text;
16417
- let redactions = 0;
16418
- for (const p of PATTERNS2) {
16419
- out = out.replace(new RegExp(p.re.source, p.re.flags), (match) => {
16420
- if (allow.some((a) => a.test(match))) return match;
16421
- redactions += 1;
16422
- return `[REDACTED:${p.kind}]`;
16514
+ for (const p of PATTERNS2) out = replacePattern(p, out, allow, redactions, deadline);
16515
+ return { text: out, redactions: redactions.n };
16516
+ }
16517
+ var DISTINCT_PATTERN_KINDS = new Set(PATTERNS2.map((p) => p.kind)).size;
16518
+ async function probeTimedOutPatterns(text) {
16519
+ const timedOut = /* @__PURE__ */ new Set();
16520
+ if (text.length === 0) return timedOut;
16521
+ const probeWindow = (offset) => {
16522
+ const window = text.slice(offset, offset + GUARD_PROBE_LENGTH);
16523
+ const combined = new RegExp(PATTERNS2.map((p) => `(${p.re.source})`).join("|"), "gi");
16524
+ return withReDoSGuard(combined, window, PATTERN_BUDGET_MS).then(async (combinedResult) => {
16525
+ if (!combinedResult.timedOut) return;
16526
+ for (const p of PATTERNS2) {
16527
+ if (timedOut.has(p.kind)) continue;
16528
+ const result = await withReDoSGuard(p.re, window, PATTERN_BUDGET_MS);
16529
+ if (result.timedOut) timedOut.add(p.kind);
16530
+ }
16423
16531
  });
16532
+ };
16533
+ const stride = GUARD_PROBE_LENGTH - GUARD_PROBE_OVERLAP;
16534
+ for (let offset = 0; offset < text.length; offset += stride) {
16535
+ if (timedOut.size >= DISTINCT_PATTERN_KINDS) break;
16536
+ await probeWindow(offset);
16537
+ }
16538
+ return timedOut;
16539
+ }
16540
+ async function detectSecretsGuarded(text, allow) {
16541
+ const timedOut = await probeTimedOutPatterns(text);
16542
+ const deadline = createScanDeadline();
16543
+ const counts = /* @__PURE__ */ new Map();
16544
+ for (const p of PATTERNS2) {
16545
+ if (!timedOut.has(p.kind)) countMatches(p, text, allow, counts, deadline);
16424
16546
  }
16425
- return { text: out, redactions };
16547
+ const allSkipped = /* @__PURE__ */ new Set([...timedOut, ...deadline.tripped]);
16548
+ return {
16549
+ detections: [...counts.entries()].map(([kind, count]) => ({ kind, count })),
16550
+ skipped: [...allSkipped].sort().map((kind) => ({ kind, reason: "redos-timeout" }))
16551
+ };
16552
+ }
16553
+ function redactSecretsGuarded(text, allow, skip, deadline) {
16554
+ const redactions = { n: 0 };
16555
+ let out = text;
16556
+ for (const p of PATTERNS2) {
16557
+ if (!skip.has(p.kind)) out = replacePattern(p, out, allow, redactions, deadline);
16558
+ }
16559
+ return { text: out, redactions: redactions.n };
16426
16560
  }
16427
16561
  function collectText(request) {
16428
16562
  const parts = [];
@@ -16436,17 +16570,27 @@ function collectText(request) {
16436
16570
  walk(request["messages"]);
16437
16571
  return parts.join("\n");
16438
16572
  }
16439
- function redactDeep(value, allow, counter) {
16573
+ var RESPONSE_SCAN_BUDGET = 1e6;
16574
+ function redactDeep(value, allow, counter, skip, budget, deadline) {
16440
16575
  if (typeof value === "string") {
16441
- const { text, redactions } = redactSecrets(value, allow);
16576
+ if (budget) {
16577
+ if (value.length > budget.remaining) {
16578
+ budget.remaining = 0;
16579
+ budget.truncated = true;
16580
+ return value;
16581
+ }
16582
+ budget.remaining -= value.length;
16583
+ }
16584
+ const { text, redactions } = skip ? redactSecretsGuarded(value, allow, skip, deadline) : redactSecrets(value, allow, deadline);
16442
16585
  counter.n += redactions;
16443
16586
  return text;
16444
16587
  }
16445
- if (Array.isArray(value)) return value.map((v) => redactDeep(v, allow, counter));
16588
+ if (Array.isArray(value))
16589
+ return value.map((v) => redactDeep(v, allow, counter, skip, budget, deadline));
16446
16590
  if (value && typeof value === "object") {
16447
16591
  const out = {};
16448
16592
  for (const [k, v] of Object.entries(value)) {
16449
- out[k] = redactDeep(v, allow, counter);
16593
+ out[k] = redactDeep(v, allow, counter, skip, budget, deadline);
16450
16594
  }
16451
16595
  return out;
16452
16596
  }
@@ -16482,16 +16626,33 @@ var state40 = {
16482
16626
  responseRedactions: 0,
16483
16627
  blocked: 0,
16484
16628
  timeoutCount: 0,
16629
+ skippedPatterns: [],
16630
+ responseTruncated: false,
16485
16631
  byKind: /* @__PURE__ */ new Map(),
16486
16632
  lastDetection: null,
16487
16633
  extensionUnregister: null
16488
16634
  };
16635
+ function surfaceScanTrips(api, tripped) {
16636
+ for (const kind of tripped) {
16637
+ if (!state40.skippedPatterns.includes(kind)) state40.skippedPatterns.push(kind);
16638
+ }
16639
+ state40.timeoutCount += 1;
16640
+ api.metrics.counter("redos_skips", 1);
16641
+ api.log.warn("prompt-firewall: scan-pass budget exceeded \u2014 patterns skipped mid-pass (issue #370)", {
16642
+ skipped: [...tripped]
16643
+ });
16644
+ }
16489
16645
  var plugin43 = {
16490
16646
  name: "prompt-firewall",
16491
16647
  version: "0.1.0",
16492
16648
  description: "Scans the provider wire for credential leaks before context reaches the LLM API (wrapProviderRunner); redact/warn/block. Opt-in; redact by default.",
16493
16649
  apiVersion: "^0.1.10",
16494
16650
  capabilities: { tools: true },
16651
+ // Wrap-stack contract (issue #362): ExtensionRegistry composes wrappers
16652
+ // first-registered = outermost. The manifest lists this plugin before
16653
+ // llm-cache, and llm-cache declares this plugin in optionalDeps, so the
16654
+ // firewall is the outer wrap: every request is scanned/redacted before
16655
+ // llm-cache can fingerprint or cache it.
16495
16656
  defaultConfig: { enabled: false, mode: "redact", scanResponse: true, allow: [] },
16496
16657
  configSchema: {
16497
16658
  type: "object",
@@ -16527,6 +16688,8 @@ var plugin43 = {
16527
16688
  state40.responseRedactions = 0;
16528
16689
  state40.blocked = 0;
16529
16690
  state40.timeoutCount = 0;
16691
+ state40.skippedPatterns = [];
16692
+ state40.responseTruncated = false;
16530
16693
  state40.byKind.clear();
16531
16694
  state40.lastDetection = null;
16532
16695
  if (state40.extensionUnregister) {
@@ -16550,15 +16713,16 @@ var plugin43 = {
16550
16713
  const req = request ?? {};
16551
16714
  state40.invocations += 1;
16552
16715
  const requestText = collectText(req);
16553
- for (const extra of EXTRA_PATTERNS) {
16554
- const guarded = await withReDoSGuard(extra.re, requestText, 250, {
16555
- onTimeout: () => {
16556
- state40.timeoutCount += 1;
16557
- }
16716
+ const { detections, skipped } = await detectSecretsGuarded(requestText, cfg.allow);
16717
+ state40.skippedPatterns = skipped.map((s) => s.kind);
16718
+ if (skipped.length > 0) {
16719
+ state40.timeoutCount += 1;
16720
+ api.log.warn("prompt-firewall: ReDoS budget exceeded, patterns skipped", {
16721
+ skipped: state40.skippedPatterns
16558
16722
  });
16559
- if (guarded.timedOut) break;
16723
+ api.metrics.counter("redos_skips", 1);
16560
16724
  }
16561
- const detections = detectSecrets(requestText, cfg.allow);
16725
+ const skipSet = new Set(state40.skippedPatterns);
16562
16726
  if (detections.length > 0) {
16563
16727
  state40.requestsWithSecrets += 1;
16564
16728
  for (const d of detections) {
@@ -16577,27 +16741,39 @@ var plugin43 = {
16577
16741
  }
16578
16742
  if (cfg.mode === "redact") {
16579
16743
  const counter = { n: 0 };
16580
- const redactedReq = redactDeep(req, cfg.allow, counter);
16744
+ const deadline = createScanDeadline();
16745
+ const redactedReq = redactDeep(req, cfg.allow, counter, skipSet, void 0, deadline);
16581
16746
  state40.requestRedactions += counter.n;
16582
16747
  api.metrics.counter("request_redactions", counter.n);
16748
+ if (deadline.tripped.size > 0) surfaceScanTrips(api, deadline.tripped);
16583
16749
  const response2 = await inner(_ctx, redactedReq);
16584
- return cfg.scanResponse ? redactResponse(response2, cfg.allow) : response2;
16750
+ return cfg.scanResponse ? redactResponse(response2, cfg.allow, skipSet) : response2;
16585
16751
  }
16586
16752
  }
16587
16753
  const response = await inner(_ctx, request);
16588
16754
  if (cfg.mode === "redact" && cfg.scanResponse) {
16589
- return redactResponse(response, cfg.allow);
16755
+ return redactResponse(response, cfg.allow, skipSet);
16590
16756
  }
16591
16757
  return response;
16592
16758
  }
16593
16759
  });
16594
16760
  }
16595
- function redactResponse(response, allow) {
16761
+ function redactResponse(response, allow, skip) {
16596
16762
  if (!response || typeof response !== "object") return response;
16597
16763
  const counter = { n: 0 };
16598
16764
  const content = response.content;
16599
16765
  if (content === void 0) return response;
16600
- const redacted = redactDeep(content, allow, counter);
16766
+ const budget = { remaining: RESPONSE_SCAN_BUDGET, truncated: false };
16767
+ const deadline = createScanDeadline();
16768
+ const redacted = redactDeep(content, allow, counter, skip, budget, deadline);
16769
+ if (deadline.tripped.size > 0) surfaceScanTrips(api, deadline.tripped);
16770
+ if (budget.truncated) {
16771
+ state40.responseTruncated = true;
16772
+ api.log.warn(
16773
+ "prompt-firewall: response scan budget exhausted \u2014 part of the response was returned unredacted"
16774
+ );
16775
+ api.metrics.counter("response_scan_truncated", 1);
16776
+ }
16601
16777
  if (counter.n > 0) {
16602
16778
  state40.responseRedactions += counter.n;
16603
16779
  api.metrics.counter("response_redactions", counter.n);
@@ -16623,6 +16799,8 @@ var plugin43 = {
16623
16799
  mode: cfg.mode,
16624
16800
  scanResponse: cfg.scanResponse,
16625
16801
  patterns: PATTERNS2.map((p) => p.kind),
16802
+ skippedPatterns: state40.skippedPatterns,
16803
+ responseTruncated: state40.responseTruncated,
16626
16804
  counters: {
16627
16805
  invocations: state40.invocations,
16628
16806
  requestsWithSecrets: state40.requestsWithSecrets,
@@ -16656,13 +16834,17 @@ var plugin43 = {
16656
16834
  requestsWithSecrets: state40.requestsWithSecrets,
16657
16835
  requestRedactions: state40.requestRedactions,
16658
16836
  responseRedactions: state40.responseRedactions,
16659
- blocked: state40.blocked
16837
+ blocked: state40.blocked,
16838
+ timeoutCount: state40.timeoutCount
16660
16839
  };
16661
16840
  state40.invocations = 0;
16662
16841
  state40.requestsWithSecrets = 0;
16663
16842
  state40.requestRedactions = 0;
16664
16843
  state40.responseRedactions = 0;
16665
16844
  state40.blocked = 0;
16845
+ state40.timeoutCount = 0;
16846
+ state40.skippedPatterns = [];
16847
+ state40.responseTruncated = false;
16666
16848
  state40.byKind.clear();
16667
16849
  state40.lastDetection = null;
16668
16850
  api.log.info("prompt-firewall: teardown complete", { final });
@@ -20629,10 +20811,10 @@ var OFFICIAL_PLUGIN_NAMES = [
20629
20811
  "notify-hub",
20630
20812
  "changelog-writer",
20631
20813
  "injection-shield",
20814
+ "prompt-firewall",
20632
20815
  "llm-cache",
20633
20816
  "model-router",
20634
20817
  "pr-drafter",
20635
- "prompt-firewall",
20636
20818
  "auto-escalate",
20637
20819
  "test-coverage-gate",
20638
20820
  "type-gate",
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",
@@ -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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wrongstack/plugins",
3
- "version": "0.308.0",
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.308.0",
307
- "@wrongstack/core": "0.308.0"
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",