@devilsdev/rag-guardrails 3.4.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,7 +8,7 @@ Part of [@devilsdev/rag-pipeline-utils](https://www.npmjs.com/package/@devilsdev
8
8
  npm install @devilsdev/rag-guardrails
9
9
  ```
10
10
 
11
- Exports: `PreRetrievalGuard`, `detectInjection`, `RetrievalGuard`, `PostGenerationGuard`, `GuardrailsPipeline`.
11
+ Exports: `PreRetrievalGuard`, `detectInjection`, `patternInjectionDetector`, `patternPIIDetector`, `detectedText`, `evaluateDetectors`, `detectorVerdict`, `renderDetectorReport`, `RetrievalGuard`, `PostGenerationGuard`, `GuardrailsPipeline`.
12
12
 
13
13
  Documentation: https://devilsdev.github.io/rag-pipeline-utils/
14
14
 
package/dist/index.cjs CHANGED
@@ -249,6 +249,156 @@ var require_injection_detector = __commonJS({
249
249
  }
250
250
  });
251
251
 
252
+ // src/guardrails/detectors.js
253
+ var require_detectors = __commonJS({
254
+ "src/guardrails/detectors.js"(exports2, module2) {
255
+ "use strict";
256
+ var { detectInjection } = require_injection_detector();
257
+ var KINDS = ["injection", "pii"];
258
+ var DEFAULT_PII_PATTERNS = {
259
+ email: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g,
260
+ phone: /\b\d{3}[-.]?\d{3}[-.]?\d{4}\b/g,
261
+ ssn: /\b\d{3}-\d{2}-\d{4}\b/g,
262
+ creditCard: /\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b/g
263
+ };
264
+ function patternInjectionDetector({ patterns = [] } = {}) {
265
+ return {
266
+ name: "pattern-injection",
267
+ kind: "injection",
268
+ detect(text) {
269
+ const found = detectInjection(String(text ?? ""), { patterns });
270
+ return {
271
+ flagged: found.detected,
272
+ labels: [...new Set(found.matches.map((m) => m.id))],
273
+ matches: found.matches.map((m) => ({ label: m.id, text: m.match }))
274
+ };
275
+ }
276
+ };
277
+ }
278
+ function patternPIIDetector({ patterns } = {}) {
279
+ const all = { ...DEFAULT_PII_PATTERNS, ...patterns };
280
+ return {
281
+ name: "pattern-pii",
282
+ kind: "pii",
283
+ detect(text) {
284
+ const matches = [];
285
+ for (const [label, pattern] of Object.entries(all)) {
286
+ const re = new RegExp(
287
+ pattern.source,
288
+ pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`
289
+ );
290
+ for (const m of String(text ?? "").matchAll(re)) {
291
+ if (m[0]) {
292
+ matches.push({
293
+ label,
294
+ text: m[0],
295
+ start: m.index,
296
+ end: m.index + m[0].length
297
+ });
298
+ }
299
+ }
300
+ }
301
+ return {
302
+ flagged: matches.length > 0,
303
+ labels: [...new Set(matches.map((m) => m.label))],
304
+ matches
305
+ };
306
+ }
307
+ };
308
+ }
309
+ function resolveDetectors(guard, kind, options = {}) {
310
+ const { detectors = [], builtIn = true } = options;
311
+ if (!Array.isArray(detectors)) {
312
+ throw new TypeError(`${guard}: detectors must be an array`);
313
+ }
314
+ for (const d of detectors) {
315
+ if (!d || typeof d !== "object" || typeof d.detect !== "function" || typeof d.name !== "string" || !KINDS.includes(d.kind)) {
316
+ throw new TypeError(
317
+ `${guard}: a detector is { name, kind: 'injection' | 'pii', detect(text, context) }`
318
+ );
319
+ }
320
+ if (d.kind !== kind) {
321
+ throw new TypeError(
322
+ `${guard} runs ${kind} detectors; "${d.name}" is a ${d.kind} detector`
323
+ );
324
+ }
325
+ }
326
+ if (builtIn === false && detectors.length === 0) {
327
+ throw new TypeError(
328
+ `${guard}: builtIn: false needs at least one ${kind} detector, or the check is off`
329
+ );
330
+ }
331
+ const onDetectorError = options.onDetectorError ?? "block";
332
+ if (onDetectorError !== "block" && onDetectorError !== "skip") {
333
+ throw new TypeError(`${guard}: onDetectorError must be 'block' or 'skip'`);
334
+ }
335
+ return detectors;
336
+ }
337
+ function normalise(detector, raw) {
338
+ const r = raw && typeof raw === "object" ? raw : { flagged: Boolean(raw) };
339
+ const matches = Array.isArray(r.matches) ? r.matches.filter((m) => m && typeof m === "object").map((m) => ({ ...m, text: m.text == null ? "" : String(m.text) })) : [];
340
+ return {
341
+ detector: detector.name,
342
+ kind: detector.kind,
343
+ flagged: Boolean(r.flagged),
344
+ ...typeof r.score === "number" && { score: r.score },
345
+ ...Array.isArray(r.labels) && { labels: r.labels.map(String) },
346
+ matches
347
+ };
348
+ }
349
+ function failed(detector, error) {
350
+ return {
351
+ detector: detector.name,
352
+ kind: detector.kind,
353
+ flagged: false,
354
+ matches: [],
355
+ error: String(error && error.message || error)
356
+ };
357
+ }
358
+ function runDetectorsSync(detectors, text, context, { guard, asyncMethod }) {
359
+ return detectors.map((detector) => {
360
+ let raw;
361
+ try {
362
+ raw = detector.detect(text, context);
363
+ } catch (error) {
364
+ return failed(detector, error);
365
+ }
366
+ if (raw && typeof raw.then === "function") {
367
+ Promise.resolve(raw).catch(() => {
368
+ });
369
+ throw new TypeError(
370
+ `${guard}: detector "${detector.name}" is async; use ${asyncMethod}()`
371
+ );
372
+ }
373
+ return normalise(detector, raw);
374
+ });
375
+ }
376
+ function runDetectorsAsync(detectors, text, context) {
377
+ return Promise.all(
378
+ detectors.map(async (detector) => {
379
+ try {
380
+ return normalise(detector, await detector.detect(text, context));
381
+ } catch (error) {
382
+ return failed(detector, error);
383
+ }
384
+ })
385
+ );
386
+ }
387
+ function detectedText(result) {
388
+ return result && Array.isArray(result.matchedText) && result.matchedText || [];
389
+ }
390
+ module2.exports = {
391
+ DEFAULT_PII_PATTERNS,
392
+ patternInjectionDetector,
393
+ patternPIIDetector,
394
+ resolveDetectors,
395
+ runDetectorsSync,
396
+ runDetectorsAsync,
397
+ detectedText
398
+ };
399
+ }
400
+ });
401
+
252
402
  // src/guardrails/pre-retrieval-guard.js
253
403
  var require_pre_retrieval_guard = __commonJS({
254
404
  "src/guardrails/pre-retrieval-guard.js"(exports2, module2) {
@@ -257,6 +407,11 @@ var require_pre_retrieval_guard = __commonJS({
257
407
  var { validateInput } = require_validator();
258
408
  var { tokenize } = require_scoring();
259
409
  var { detectInjection, DEFAULT_RULES } = require_injection_detector();
410
+ var {
411
+ resolveDetectors,
412
+ runDetectorsSync,
413
+ runDetectorsAsync
414
+ } = require_detectors();
260
415
  var DEFAULT_CONFIG = {
261
416
  maxQueryLength: 1e4,
262
417
  enableInjectionDetection: true,
@@ -277,9 +432,17 @@ var require_pre_retrieval_guard = __commonJS({
277
432
  * @param {string[]} [options.blockedTopics=[]] - Topics to reject
278
433
  * @param {string[]} [options.allowedTopics=[]] - If non-empty, only these topics are permitted
279
434
  * @param {RegExp[]} [options.injectionPatterns] - Additional injection patterns (merged with defaults)
435
+ * @param {Array<object>} [options.detectors=[]] - Injection detectors to run on the query as well (see ./detectors); async ones need checkAsync()
436
+ * @param {boolean} [options.builtIn=true] - false: use only `detectors`, not the built-in patterns
437
+ * @param {'block'|'skip'} [options.onDetectorError='block'] - A detector that throws makes the query unsafe ('block') or is ignored ('skip')
280
438
  */
281
439
  constructor(options = {}) {
282
440
  super();
441
+ this._detectors = resolveDetectors(
442
+ "PreRetrievalGuard",
443
+ "injection",
444
+ options
445
+ );
283
446
  this.config = { ...DEFAULT_CONFIG, ...options };
284
447
  this._customPatterns = options.injectionPatterns ? [...options.injectionPatterns] : [];
285
448
  if (options.injectionPatterns) {
@@ -306,17 +469,54 @@ var require_pre_retrieval_guard = __commonJS({
306
469
  * @fires PreRetrievalGuard#checked
307
470
  */
308
471
  check(query) {
472
+ const state = this._prepare(query);
473
+ if (state.result) return this._emit(state.result);
474
+ const detections = this._runDetectors(state) ? runDetectorsSync(this._detectors, state.sanitizedQuery, CONTEXT, {
475
+ guard: "PreRetrievalGuard",
476
+ asyncMethod: "checkAsync"
477
+ }) : [];
478
+ return this._finish(state, detections);
479
+ }
480
+ /**
481
+ * check() for async detectors, such as a classifier model or a service.
482
+ * Detectors run concurrently.
483
+ *
484
+ * @param {string} query
485
+ * @param {object} [options]
486
+ * @param {AbortSignal} [options.signal] - Passed to each detector in its context
487
+ * @returns {Promise<ReturnType<PreRetrievalGuard['check']>>}
488
+ */
489
+ async checkAsync(query, { signal } = {}) {
490
+ const state = this._prepare(query);
491
+ if (state.result) return this._emit(state.result);
492
+ const detections = this._runDetectors(state) ? await runDetectorsAsync(this._detectors, state.sanitizedQuery, {
493
+ ...CONTEXT,
494
+ ...signal && { signal }
495
+ }) : [];
496
+ return this._finish(state, detections);
497
+ }
498
+ /** @private */
499
+ _runDetectors() {
500
+ return this.config.enableInjectionDetection && this._detectors.length > 0;
501
+ }
502
+ /** @private */
503
+ _emit(result) {
504
+ this.emit("checked", result);
505
+ return result;
506
+ }
507
+ /** @private Validation and the built-in checks, before any detector runs. */
508
+ _prepare(query) {
309
509
  const issues = [];
310
510
  if (query == null || typeof query !== "string" || query.trim().length === 0) {
311
511
  issues.push("Query must be a non-empty string");
312
- const result2 = {
313
- safe: false,
314
- issues,
315
- sanitizedQuery: "",
316
- injection: { detected: false, matches: [] }
512
+ return {
513
+ result: {
514
+ safe: false,
515
+ issues,
516
+ sanitizedQuery: "",
517
+ injection: { detected: false, matches: [] }
518
+ }
317
519
  };
318
- this.emit("checked", result2);
319
- return result2;
320
520
  }
321
521
  const validation = validateInput(query, {
322
522
  maxLength: this.config.maxQueryLength
@@ -328,7 +528,7 @@ var require_pre_retrieval_guard = __commonJS({
328
528
  }
329
529
  const sanitizedQuery = validation.sanitized;
330
530
  let injection = { detected: false, matches: [] };
331
- if (this.config.enableInjectionDetection) {
531
+ if (this.config.enableInjectionDetection && this.config.builtIn !== false) {
332
532
  const found = detectInjection(sanitizedQuery, {
333
533
  patterns: this._customPatterns
334
534
  });
@@ -361,26 +561,496 @@ var require_pre_retrieval_guard = __commonJS({
361
561
  }
362
562
  }
363
563
  }
564
+ return { issues, sanitizedQuery, injection };
565
+ }
566
+ /** @private Merge the detectors' findings and emit the result. */
567
+ _finish({ issues, sanitizedQuery, injection }, detections) {
568
+ const skip = this.config.onDetectorError === "skip";
569
+ for (const d of detections) {
570
+ if (d.error) {
571
+ if (!skip) {
572
+ issues.push(`Injection detector "${d.detector}" failed: ${d.error}`);
573
+ }
574
+ } else if (d.flagged) {
575
+ injection = { ...injection, detected: true };
576
+ const labels = d.labels && d.labels.length ? `: ${d.labels.join(", ")}` : "";
577
+ issues.push(
578
+ `Potential prompt injection detected by ${d.detector}${labels}`
579
+ );
580
+ }
581
+ }
364
582
  const result = {
365
583
  safe: issues.length === 0,
366
584
  issues,
367
585
  sanitizedQuery,
368
586
  injection
369
587
  };
370
- this.emit("checked", result);
371
- return result;
588
+ if (this._detectors.length > 0) result.detections = detections;
589
+ return this._emit(result);
372
590
  }
373
591
  };
592
+ var CONTEXT = Object.freeze({ stage: "query" });
374
593
  module2.exports = { PreRetrievalGuard, DEFAULT_CONFIG };
375
594
  }
376
595
  });
377
596
 
597
+ // src/guardrails/evaluate-detectors.js
598
+ var require_evaluate_detectors = __commonJS({
599
+ "src/guardrails/evaluate-detectors.js"(exports2, module2) {
600
+ "use strict";
601
+ var REPORT_STYLE = `
602
+ :root{--bg:#fff;--fg:#1b1f24;--muted:#57606a;--line:#d0d7de;--ok:#1a7f37;--bad:#cf222e;--chip:#f6f8fa}
603
+ @media (prefers-color-scheme:dark){:root{--bg:#0d1117;--fg:#e6edf3;--muted:#8d96a0;--line:#30363d;--ok:#3fb950;--bad:#f85149;--chip:#161b22}}
604
+ *{box-sizing:border-box}body{margin:0;padding:24px 16px;background:var(--bg);color:var(--fg);font:15px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}
605
+ main{max-width:1100px;margin:0 auto}h1{font-size:22px;margin:0 0 4px}h2{font-size:17px;margin:28px 0 8px}
606
+ .muted{color:var(--muted)}.status{display:inline-block;padding:2px 10px;border-radius:999px;font-weight:600;font-size:13px}
607
+ .pass{color:var(--ok);border:1px solid var(--ok)}.fail{color:var(--bad);border:1px solid var(--bad)}
608
+ ul.reasons{margin:8px 0;padding-left:20px}.scroll{overflow-x:auto}
609
+ table{border-collapse:collapse;width:100%;font-size:14px}th,td{border-bottom:1px solid var(--line);padding:6px 8px;text-align:left;vertical-align:top}
610
+ th{color:var(--muted);font-weight:600}td.num,th.num{text-align:right;font-variant-numeric:tabular-nums}
611
+ code{background:var(--chip);padding:1px 5px;border-radius:4px;font-size:13px}`;
612
+ var esc = (text) => String(text).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
613
+ var KINDS = ["injection", "pii"];
614
+ var MAX_IDS = 50;
615
+ var Z = 1.96;
616
+ function validateDetectorCases(cases) {
617
+ if (!Array.isArray(cases) || cases.length === 0) {
618
+ throw new TypeError("cases must be a non-empty array");
619
+ }
620
+ const ids = /* @__PURE__ */ new Set();
621
+ return cases.map((c, i) => {
622
+ const where = `case ${i + 1}${c && c.id != null ? ` ("${c.id}")` : ""}`;
623
+ if (!c || typeof c !== "object") {
624
+ throw new TypeError(`${where} is not an object`);
625
+ }
626
+ if (!KINDS.includes(c.kind)) {
627
+ throw new TypeError(`${where}: kind must be "injection" or "pii"`);
628
+ }
629
+ if (typeof c.text !== "string") {
630
+ throw new TypeError(`${where}: text must be a string`);
631
+ }
632
+ if (c.spans !== void 0) {
633
+ if (c.kind !== "pii") {
634
+ throw new TypeError(`${where}: only pii cases take spans`);
635
+ }
636
+ if (!Array.isArray(c.spans) || !c.spans.every(
637
+ (s) => s && Number.isInteger(s.start) && Number.isInteger(s.end) && s.start >= 0 && s.end > s.start && s.end <= c.text.length
638
+ )) {
639
+ throw new TypeError(
640
+ `${where}: spans must be [{ label, start, end }] inside the text`
641
+ );
642
+ }
643
+ } else if (typeof c.flagged !== "boolean") {
644
+ throw new TypeError(
645
+ `${where}: needs flagged: true | false${c.kind === "pii" ? ", or spans" : ""}`
646
+ );
647
+ }
648
+ if (c.tags !== void 0 && !Array.isArray(c.tags)) {
649
+ throw new TypeError(`${where}: tags must be an array of strings`);
650
+ }
651
+ const id = c.id != null ? String(c.id) : String(i + 1);
652
+ if (ids.has(id)) throw new TypeError(`${where}: duplicate id "${id}"`);
653
+ ids.add(id);
654
+ return { ...c, id, tags: (c.tags || []).map(String) };
655
+ });
656
+ }
657
+ function wilson(k, n) {
658
+ if (n === 0) return null;
659
+ const p = k / n;
660
+ const denom = 1 + Z * Z / n;
661
+ const centre = (p + Z * Z / (2 * n)) / denom;
662
+ const half = Z * Math.sqrt(p * (1 - p) / n + Z * Z / (4 * n * n)) / denom;
663
+ return [round(Math.max(0, centre - half)), round(Math.min(1, centre + half))];
664
+ }
665
+ var round = (x) => Number(x.toFixed(4));
666
+ var ratio = (k, n) => n === 0 ? null : round(k / n);
667
+ function confusionMetrics({ tp, fp, fn, tn }) {
668
+ const precision = ratio(tp, tp + fp);
669
+ const recall = ratio(tp, tp + fn);
670
+ const f1 = ratio(2 * tp, 2 * tp + fp + fn);
671
+ return {
672
+ cases: tp + fp + fn + tn,
673
+ tp,
674
+ fp,
675
+ fn,
676
+ tn,
677
+ precision,
678
+ recall,
679
+ f1,
680
+ accuracy: ratio(tp + tn, tp + fp + fn + tn),
681
+ precisionInterval: wilson(tp, tp + fp),
682
+ recallInterval: wilson(tp, tp + fn)
683
+ };
684
+ }
685
+ var overlaps = (a, b) => a.start < b.end && b.start < a.end;
686
+ function locate(matches, text) {
687
+ const used = /* @__PURE__ */ new Set();
688
+ const out = [];
689
+ for (const m of matches) {
690
+ if (Number.isInteger(m.start) && Number.isInteger(m.end)) {
691
+ out.push(m);
692
+ continue;
693
+ }
694
+ if (!m.text) continue;
695
+ let from = 0;
696
+ let at = text.indexOf(m.text, from);
697
+ while (at !== -1 && used.has(at)) {
698
+ from = at + 1;
699
+ at = text.indexOf(m.text, from);
700
+ }
701
+ if (at === -1) continue;
702
+ used.add(at);
703
+ out.push({ ...m, start: at, end: at + m.text.length });
704
+ }
705
+ return out;
706
+ }
707
+ async function mapLimit(items, limit, fn) {
708
+ const results = new Array(items.length);
709
+ let next = 0;
710
+ const workers = Array.from(
711
+ { length: Math.min(limit, items.length) },
712
+ async () => {
713
+ while (next < items.length) {
714
+ const i = next++;
715
+ results[i] = await fn(items[i], i);
716
+ }
717
+ }
718
+ );
719
+ await Promise.all(workers);
720
+ return results;
721
+ }
722
+ async function runOne(detector, c, signal) {
723
+ if (signal && signal.aborted) throw signal.reason || new Error("Aborted");
724
+ const stage = c.stage || (c.kind === "pii" ? "output" : "query");
725
+ try {
726
+ const raw = await detector.detect(c.text, { stage, signal });
727
+ const r = raw && typeof raw === "object" ? raw : { flagged: Boolean(raw) };
728
+ return {
729
+ flagged: Boolean(r.flagged),
730
+ matches: Array.isArray(r.matches) ? r.matches.filter((m) => m && typeof m === "object") : []
731
+ };
732
+ } catch (error) {
733
+ if (signal && signal.aborted) throw error;
734
+ return { error: String(error && error.message || error) };
735
+ }
736
+ }
737
+ function scoreDetector(detector, cases, outcomes, covered) {
738
+ const counts = { tp: 0, fp: 0, fn: 0, tn: 0 };
739
+ const byTag = {};
740
+ const missed = [];
741
+ const falseAlarms = [];
742
+ const failed = [];
743
+ const spanTotals = { predicted: 0, matched: 0 };
744
+ const byLabel = {};
745
+ const byDetectorLabel = {};
746
+ let spanCases = 0;
747
+ cases.forEach((c, i) => {
748
+ const o = outcomes[i];
749
+ if (o.error) {
750
+ failed.push({ id: c.id, error: o.error });
751
+ return;
752
+ }
753
+ const expected = c.spans ? c.spans.length > 0 : c.flagged;
754
+ const key = expected ? o.flagged ? "tp" : "fn" : o.flagged ? "fp" : "tn";
755
+ counts[key]++;
756
+ for (const tag of c.tags) {
757
+ byTag[tag] = byTag[tag] || { tp: 0, fp: 0, fn: 0, tn: 0 };
758
+ byTag[tag][key]++;
759
+ }
760
+ if (key === "fn") missed.push(c.id);
761
+ if (key === "fp") falseAlarms.push(c.id);
762
+ if (c.spans) {
763
+ spanCases++;
764
+ const found = locate(o.matches, c.text);
765
+ for (const m of found) {
766
+ const hit = c.spans.some((s) => overlaps(s, m));
767
+ const label = m.label == null ? "(none)" : String(m.label);
768
+ byDetectorLabel[label] = byDetectorLabel[label] || {
769
+ predicted: 0,
770
+ matched: 0
771
+ };
772
+ byDetectorLabel[label].predicted++;
773
+ spanTotals.predicted++;
774
+ if (hit) {
775
+ byDetectorLabel[label].matched++;
776
+ spanTotals.matched++;
777
+ }
778
+ }
779
+ for (const s of c.spans) {
780
+ const label = s.label == null ? "(none)" : String(s.label);
781
+ byLabel[label] = byLabel[label] || { gold: 0, found: 0 };
782
+ byLabel[label].gold++;
783
+ if (found.some((m) => overlaps(s, m))) byLabel[label].found++;
784
+ }
785
+ }
786
+ });
787
+ const result = {
788
+ kind: detector.kind,
789
+ ...confusionMetrics(counts),
790
+ errors: failed.length,
791
+ byTag: Object.fromEntries(
792
+ Object.entries(byTag).sort(([a], [b]) => a.localeCompare(b)).map(([tag, t]) => [tag, confusionMetrics(t)])
793
+ ),
794
+ missed: missed.slice(0, MAX_IDS),
795
+ falseAlarms: falseAlarms.slice(0, MAX_IDS),
796
+ failed: failed.slice(0, MAX_IDS)
797
+ };
798
+ if (spanCases > 0) {
799
+ const inScope = Object.entries(byLabel).filter(
800
+ ([label]) => !covered || covered.includes(label)
801
+ );
802
+ const gold = inScope.reduce((n, [, l]) => n + l.gold, 0);
803
+ const foundSpans = inScope.reduce((n, [, l]) => n + l.found, 0);
804
+ result.spans = {
805
+ cases: spanCases,
806
+ predicted: spanTotals.predicted,
807
+ matched: spanTotals.matched,
808
+ precision: ratio(spanTotals.matched, spanTotals.predicted),
809
+ precisionInterval: wilson(spanTotals.matched, spanTotals.predicted),
810
+ ...covered && { coveredLabels: covered },
811
+ gold,
812
+ found: foundSpans,
813
+ recall: ratio(foundSpans, gold),
814
+ recallInterval: wilson(foundSpans, gold),
815
+ byLabel: Object.fromEntries(
816
+ Object.entries(byLabel).sort(([, a], [, b]) => b.gold - a.gold).map(([label, l]) => [
817
+ label,
818
+ {
819
+ gold: l.gold,
820
+ found: l.found,
821
+ recall: ratio(l.found, l.gold),
822
+ covered: covered ? covered.includes(label) : true
823
+ }
824
+ ])
825
+ ),
826
+ byDetectorLabel: Object.fromEntries(
827
+ Object.entries(byDetectorLabel).sort(([, a], [, b]) => b.predicted - a.predicted).map(([label, l]) => [
828
+ label,
829
+ { ...l, precision: ratio(l.matched, l.predicted) }
830
+ ])
831
+ )
832
+ };
833
+ }
834
+ return result;
835
+ }
836
+ async function evaluateDetectors(cases, detectors, options = {}) {
837
+ const { concurrency = 4, coveredLabels, signal } = options;
838
+ const checked = validateDetectorCases(cases);
839
+ if (!Array.isArray(detectors) || detectors.length === 0) {
840
+ throw new TypeError("detectors must be a non-empty array");
841
+ }
842
+ const names = /* @__PURE__ */ new Set();
843
+ for (const d of detectors) {
844
+ if (!d || typeof d.detect !== "function" || typeof d.name !== "string" || !KINDS.includes(d.kind)) {
845
+ throw new TypeError(
846
+ "a detector is { name, kind: 'injection' | 'pii', detect(text, context) }"
847
+ );
848
+ }
849
+ if (names.has(d.name)) {
850
+ throw new TypeError(`two detectors are named "${d.name}"`);
851
+ }
852
+ names.add(d.name);
853
+ }
854
+ if (!Number.isInteger(concurrency) || concurrency < 1) {
855
+ throw new TypeError("concurrency must be a positive integer");
856
+ }
857
+ const report = {
858
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
859
+ cases: {
860
+ injection: checked.filter((c) => c.kind === "injection").length,
861
+ pii: checked.filter((c) => c.kind === "pii").length
862
+ },
863
+ detectors: {}
864
+ };
865
+ for (const detector of detectors) {
866
+ const mine = checked.filter((c) => c.kind === detector.kind);
867
+ if (mine.length === 0) continue;
868
+ const started = Date.now();
869
+ const outcomes = await mapLimit(
870
+ mine,
871
+ concurrency,
872
+ (c) => runOne(detector, c, signal)
873
+ );
874
+ const covered = Array.isArray(coveredLabels) ? coveredLabels : coveredLabels && coveredLabels[detector.name];
875
+ report.detectors[detector.name] = {
876
+ ...scoreDetector(detector, mine, outcomes, covered),
877
+ msPerCase: round((Date.now() - started) / mine.length)
878
+ };
879
+ }
880
+ return report;
881
+ }
882
+ function detectorVerdict(report, min = {}) {
883
+ const reasons = [];
884
+ for (const [d, m] of Object.entries(report.detectors)) {
885
+ if (m.errors > 0) {
886
+ reasons.push(`${d}: ${m.errors} case(s) failed with an error`);
887
+ }
888
+ }
889
+ for (const [key, minimum] of Object.entries(min)) {
890
+ const dot = key.indexOf(".");
891
+ const name = key.slice(0, dot);
892
+ const path = key.slice(dot + 1).split(".");
893
+ const entry = report.detectors[name];
894
+ const actual = entry ? path.reduce((v, k) => v == null ? v : v[k], entry) : void 0;
895
+ if (typeof actual !== "number") {
896
+ reasons.push(`${key} was not measured (minimum ${minimum})`);
897
+ } else if (actual < minimum) {
898
+ reasons.push(
899
+ `${key} ${actual.toFixed(3)} is below the minimum ${minimum}`
900
+ );
901
+ }
902
+ }
903
+ return { passed: reasons.length === 0, reasons };
904
+ }
905
+ var fixed = (v) => typeof v === "number" ? v.toFixed(3) : "-";
906
+ var interval = (i) => i ? `${i[0].toFixed(2)}\u2013${i[1].toFixed(2)}` : "-";
907
+ var mdCell = (text) => String(text).replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
908
+ function tables(report) {
909
+ const out = [];
910
+ const main = {
911
+ title: "Detectors",
912
+ header: [
913
+ "Detector",
914
+ "Kind",
915
+ "Cases",
916
+ "TP",
917
+ "FP",
918
+ "FN",
919
+ "TN",
920
+ "Precision",
921
+ "Recall",
922
+ "F1",
923
+ "Recall 95% CI",
924
+ "Errors"
925
+ ],
926
+ rows: Object.entries(report.detectors).map(([name, m]) => [
927
+ name,
928
+ m.kind,
929
+ m.cases,
930
+ m.tp,
931
+ m.fp,
932
+ m.fn,
933
+ m.tn,
934
+ fixed(m.precision),
935
+ fixed(m.recall),
936
+ fixed(m.f1),
937
+ interval(m.recallInterval),
938
+ m.errors
939
+ ])
940
+ };
941
+ out.push(main);
942
+ for (const [name, m] of Object.entries(report.detectors)) {
943
+ const tags = Object.entries(m.byTag);
944
+ if (tags.length > 0) {
945
+ out.push({
946
+ title: `${name} by tag`,
947
+ header: ["Tag", "Cases", "TP", "FP", "FN", "TN", "Precision", "Recall"],
948
+ rows: tags.map(([tag, t]) => [
949
+ tag,
950
+ t.cases,
951
+ t.tp,
952
+ t.fp,
953
+ t.fn,
954
+ t.tn,
955
+ fixed(t.precision),
956
+ fixed(t.recall)
957
+ ])
958
+ });
959
+ }
960
+ if (m.spans) {
961
+ out.push({
962
+ title: `${name} spans: ${m.spans.predicted} matches, precision ${fixed(m.spans.precision)}; recall ${fixed(m.spans.recall)} on ${m.spans.gold} ${m.spans.coveredLabels ? "covered " : ""}spans`,
963
+ header: ["Labelled as", "Spans", "Found", "Recall", "Covered"],
964
+ rows: Object.entries(m.spans.byLabel).map(([label, l]) => [
965
+ label,
966
+ l.gold,
967
+ l.found,
968
+ fixed(l.recall),
969
+ l.covered ? "yes" : "no"
970
+ ])
971
+ });
972
+ }
973
+ }
974
+ return out;
975
+ }
976
+ function renderDetectorReport(report, options = {}) {
977
+ const { format = "markdown", title = "Guardrail detectors", min } = options;
978
+ const verdict = detectorVerdict(report, min);
979
+ const summary = `${report.cases.injection} injection case(s), ${report.cases.pii} PII case(s).`;
980
+ const sections = tables(report);
981
+ if (format === "markdown") {
982
+ const out = [
983
+ `### ${title}: ${verdict.passed ? "\u2705 Passed" : "\u274C Failed"}`,
984
+ "",
985
+ summary,
986
+ ""
987
+ ];
988
+ for (const r of verdict.reasons) out.push(`- ${r}`);
989
+ if (verdict.reasons.length) out.push("");
990
+ for (const s of sections) {
991
+ out.push(`**${mdCell(s.title)}**`, "");
992
+ out.push(`| ${s.header.join(" | ")} |`);
993
+ out.push(
994
+ `| ${s.header.map((_, i) => i === 0 ? "---" : "---:").join(" | ")} |`
995
+ );
996
+ for (const row of s.rows) out.push(`| ${row.map(mdCell).join(" | ")} |`);
997
+ out.push("");
998
+ }
999
+ return out.join("\n");
1000
+ }
1001
+ if (format === "html") {
1002
+ const parts = [
1003
+ '<!doctype html><html lang="en"><head><meta charset="utf-8">',
1004
+ '<meta name="viewport" content="width=device-width,initial-scale=1">',
1005
+ `<title>${esc(title)}</title><style>${REPORT_STYLE}</style></head><body><main>`,
1006
+ `<h1>${esc(title)}</h1>`,
1007
+ `<p><span class="status ${verdict.passed ? "pass" : "fail"}">${verdict.passed ? "Passed" : "Failed"}</span> <span class="muted">${esc(summary)} \xB7 ${esc(report.createdAt || "")}</span></p>`
1008
+ ];
1009
+ if (verdict.reasons.length) {
1010
+ parts.push(
1011
+ `<ul class="reasons">${verdict.reasons.map((r) => `<li>${esc(r)}</li>`).join("")}</ul>`
1012
+ );
1013
+ }
1014
+ for (const s of sections) {
1015
+ parts.push(
1016
+ `<h2>${esc(s.title)}</h2><div class="scroll"><table><thead><tr>`
1017
+ );
1018
+ parts.push(
1019
+ s.header.map((h, i) => `<th${i === 0 ? "" : ' class="num"'}>${esc(h)}</th>`).join("")
1020
+ );
1021
+ parts.push("</tr></thead><tbody>");
1022
+ for (const row of s.rows) {
1023
+ parts.push(
1024
+ `<tr>${row.map((v, i) => `<td${i === 0 ? "" : ' class="num"'}>${i === 0 ? `<code>${esc(v)}</code>` : esc(v)}</td>`).join("")}</tr>`
1025
+ );
1026
+ }
1027
+ parts.push("</tbody></table></div>");
1028
+ }
1029
+ parts.push("</main></body></html>");
1030
+ return parts.join("");
1031
+ }
1032
+ throw new TypeError(`format must be "markdown" or "html", not "${format}"`);
1033
+ }
1034
+ module2.exports = {
1035
+ evaluateDetectors,
1036
+ validateDetectorCases,
1037
+ detectorVerdict,
1038
+ renderDetectorReport
1039
+ };
1040
+ }
1041
+ });
1042
+
378
1043
  // src/guardrails/retrieval-guard.js
379
1044
  var require_retrieval_guard = __commonJS({
380
1045
  "src/guardrails/retrieval-guard.js"(exports2, module2) {
381
1046
  "use strict";
382
1047
  var { EventEmitter } = require("events");
383
1048
  var { detectInjection } = require_injection_detector();
1049
+ var {
1050
+ resolveDetectors,
1051
+ runDetectorsSync,
1052
+ runDetectorsAsync
1053
+ } = require_detectors();
384
1054
  var DEFAULT_CONFIG = {
385
1055
  minRelevanceScore: 0,
386
1056
  maxAgeDays: 0,
@@ -402,10 +1072,19 @@ var require_retrieval_guard = __commonJS({
402
1072
  * @param {boolean} [options.detectInjection=false] - Scan document text for prompt injection
403
1073
  * @param {'remove'|'flag'} [options.injectionAction='remove'] - Remove flagged documents or keep and mark them
404
1074
  * @param {RegExp[]} [options.injectionPatterns=[]] - Extra injection patterns for the scan
1075
+ * @param {Array<object>} [options.detectors=[]] - Injection detectors to run on each document's text too, when detectInjection is on (see ./detectors); async ones need filterAsync(), which calls them once per document, concurrently
1076
+ * @param {boolean} [options.builtIn=true] - false: use only `detectors` for the injection scan
1077
+ * @param {'block'|'skip'} [options.onDetectorError='block'] - A detector that throws counts as a finding ('block': the document is removed or flagged) or is ignored ('skip')
405
1078
  */
406
1079
  constructor(options = {}) {
407
1080
  super();
408
1081
  this.config = { ...DEFAULT_CONFIG, ...options };
1082
+ this._detectors = resolveDetectors("RetrievalGuard", "injection", options);
1083
+ if (this._detectors.length > 0 && this.config.detectInjection !== true) {
1084
+ throw new TypeError(
1085
+ "RetrievalGuard: detectors run in the injection scan; set detectInjection: true"
1086
+ );
1087
+ }
409
1088
  }
410
1089
  /**
411
1090
  * Filter retrieval results based on configured guards.
@@ -421,17 +1100,59 @@ var require_retrieval_guard = __commonJS({
421
1100
  * @fires RetrievalGuard#filtered
422
1101
  */
423
1102
  filter(results, context = {}) {
1103
+ const state = this._screen(results, context);
1104
+ if (state.output) return state.output;
1105
+ const detections = state.scan.map(
1106
+ (i) => this._detectors.length > 0 ? runDetectorsSync(
1107
+ this._detectors,
1108
+ documentText(state.results[i]),
1109
+ { stage: "document", document: state.results[i] },
1110
+ { guard: "RetrievalGuard", asyncMethod: "filterAsync" }
1111
+ ) : []
1112
+ );
1113
+ return this._finish(state, detections);
1114
+ }
1115
+ /**
1116
+ * filter() for async detectors, such as a classifier model or a service. Each
1117
+ * document that reaches the injection scan is checked by every detector,
1118
+ * all concurrently.
1119
+ *
1120
+ * @param {Array<object>} results
1121
+ * @param {object} [context={}]
1122
+ * @param {object} [options]
1123
+ * @param {AbortSignal} [options.signal] - Passed to each detector in its context
1124
+ * @returns {Promise<ReturnType<RetrievalGuard['filter']>>}
1125
+ */
1126
+ async filterAsync(results, context = {}, { signal } = {}) {
1127
+ const state = this._screen(results, context);
1128
+ if (state.output) return state.output;
1129
+ const detections = await Promise.all(
1130
+ state.scan.map(
1131
+ (i) => this._detectors.length > 0 ? runDetectorsAsync(this._detectors, documentText(state.results[i]), {
1132
+ stage: "document",
1133
+ document: state.results[i],
1134
+ ...signal && { signal }
1135
+ }) : []
1136
+ )
1137
+ );
1138
+ return this._finish(state, detections);
1139
+ }
1140
+ /**
1141
+ * @private Relevance, freshness and ACL, then which documents go on to
1142
+ * the injection scan.
1143
+ */
1144
+ _screen(results, context) {
424
1145
  if (!Array.isArray(results)) {
425
- const output2 = { results: [], removed: [], metadata: { reasons: {} } };
426
- this.emit("filtered", output2);
427
- return output2;
1146
+ const output = { results: [], removed: [], metadata: { reasons: {} } };
1147
+ this.emit("filtered", output);
1148
+ return { output };
428
1149
  }
429
- const kept = [];
430
- const removed = [];
431
1150
  const reasons = {};
1151
+ const dropped = new Array(results.length).fill(false);
1152
+ const scan = [];
432
1153
  const now = Date.now();
433
1154
  const maxAgeMs = this.config.maxAgeDays > 0 ? this.config.maxAgeDays * 24 * 60 * 60 * 1e3 : 0;
434
- for (const result of results) {
1155
+ results.forEach((result, i) => {
435
1156
  let dominated = false;
436
1157
  const doc = result.metadata || result.doc || result;
437
1158
  if (this.config.minRelevanceScore > 0) {
@@ -465,29 +1186,50 @@ var require_retrieval_guard = __commonJS({
465
1186
  }
466
1187
  }
467
1188
  }
468
- if (!dominated && this.config.detectInjection) {
469
- const text = documentText(result);
470
- const found = detectInjection(text, {
471
- patterns: this.config.injectionPatterns
472
- });
473
- if (found.detected) {
474
- reasons.injection = (reasons.injection || 0) + 1;
475
- if (this.config.injectionAction === "flag") {
476
- kept.push({
477
- ...result,
478
- injection: { detected: true, matches: found.matches }
479
- });
480
- continue;
481
- }
482
- dominated = true;
483
- }
1189
+ dropped[i] = dominated;
1190
+ if (!dominated && this.config.detectInjection) scan.push(i);
1191
+ });
1192
+ return { results, reasons, dropped, scan };
1193
+ }
1194
+ /** @private 4. Indirect prompt injection in document text (opt-in). */
1195
+ _finish({ results, reasons, dropped, scan }, detections) {
1196
+ const findings = /* @__PURE__ */ new Map();
1197
+ const skip = this.config.onDetectorError === "skip";
1198
+ scan.forEach((i, n) => {
1199
+ const matches = this.config.builtIn === false ? [] : detectInjection(documentText(results[i]), {
1200
+ patterns: this.config.injectionPatterns
1201
+ }).matches;
1202
+ const found = detections[n] || [];
1203
+ const flagged = found.some((d) => d.flagged || d.error && !skip);
1204
+ if (matches.length > 0 || flagged) {
1205
+ findings.set(i, { matches, detections: found });
484
1206
  }
485
- if (dominated) {
1207
+ });
1208
+ const kept = [];
1209
+ const removed = [];
1210
+ results.forEach((result, i) => {
1211
+ const finding = findings.get(i);
1212
+ if (finding) {
1213
+ reasons.injection = (reasons.injection || 0) + 1;
1214
+ if (this.config.injectionAction === "flag") {
1215
+ kept.push({
1216
+ ...result,
1217
+ injection: {
1218
+ detected: true,
1219
+ matches: finding.matches,
1220
+ ...this._detectors.length > 0 && {
1221
+ detections: finding.detections
1222
+ }
1223
+ }
1224
+ });
1225
+ return;
1226
+ }
486
1227
  removed.push(result);
487
- } else {
488
- kept.push(result);
1228
+ return;
489
1229
  }
490
- }
1230
+ if (dropped[i]) removed.push(result);
1231
+ else kept.push(result);
1232
+ });
491
1233
  const output = {
492
1234
  results: kept,
493
1235
  removed,
@@ -521,18 +1263,19 @@ var require_post_generation_guard = __commonJS({
521
1263
  "src/guardrails/post-generation-guard.js"(exports2, module2) {
522
1264
  "use strict";
523
1265
  var { EventEmitter } = require("events");
1266
+ var {
1267
+ DEFAULT_PII_PATTERNS,
1268
+ resolveDetectors,
1269
+ runDetectorsSync,
1270
+ runDetectorsAsync
1271
+ } = require_detectors();
524
1272
  var DEFAULT_CONFIG = {
525
1273
  enablePIIDetection: true,
526
1274
  enableGroundednessCheck: true,
527
1275
  groundednessThreshold: 0.3,
528
1276
  maxResponseLength: 5e4,
529
1277
  minResponseLength: 0,
530
- piiPatterns: {
531
- email: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g,
532
- phone: /\b\d{3}[-.]?\d{3}[-.]?\d{4}\b/g,
533
- ssn: /\b\d{3}-\d{2}-\d{4}\b/g,
534
- creditCard: /\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b/g
535
- }
1278
+ piiPatterns: DEFAULT_PII_PATTERNS
536
1279
  };
537
1280
  var PostGenerationGuard = class extends EventEmitter {
538
1281
  /**
@@ -543,9 +1286,13 @@ var require_post_generation_guard = __commonJS({
543
1286
  * @param {number} [options.maxResponseLength=50000] - Maximum allowed response length
544
1287
  * @param {number} [options.minResponseLength=0] - Minimum response length
545
1288
  * @param {object} [options.piiPatterns] - Custom PII regex patterns (merged with defaults)
1289
+ * @param {Array<object>} [options.detectors=[]] - PII detectors to run on the output too (see ./detectors); their matches are redacted. Async ones need checkAsync()
1290
+ * @param {boolean} [options.builtIn=true] - false: use only `detectors`, not the built-in PII patterns
1291
+ * @param {'block'|'skip'} [options.onDetectorError='block'] - A detector that throws makes the output unsafe ('block'; what it would have found can't be redacted) or is ignored ('skip')
546
1292
  */
547
1293
  constructor(options = {}) {
548
1294
  super();
1295
+ this._detectors = resolveDetectors("PostGenerationGuard", "pii", options);
549
1296
  this.config = { ...DEFAULT_CONFIG, ...options };
550
1297
  if (options.piiPatterns) {
551
1298
  this.config.piiPatterns = {
@@ -568,13 +1315,52 @@ var require_post_generation_guard = __commonJS({
568
1315
  * @fires PostGenerationGuard#checked
569
1316
  */
570
1317
  check(output, retrievedDocs = []) {
571
- const issues = [];
572
1318
  if (output == null || typeof output !== "string") {
573
- issues.push("Output must be a string");
574
- const result2 = { safe: false, issues, sanitizedOutput: "" };
575
- this.emit("checked", result2);
576
- return result2;
1319
+ return this._invalid();
577
1320
  }
1321
+ const detections = this._runDetectors() ? runDetectorsSync(this._detectors, output, CONTEXT, {
1322
+ guard: "PostGenerationGuard",
1323
+ asyncMethod: "checkAsync"
1324
+ }) : [];
1325
+ return this._check(output, retrievedDocs, detections);
1326
+ }
1327
+ /**
1328
+ * check() for async detectors, such as a model or a PII service.
1329
+ * Detectors run concurrently.
1330
+ *
1331
+ * @param {string} output
1332
+ * @param {Array<object>} [retrievedDocs=[]]
1333
+ * @param {object} [options]
1334
+ * @param {AbortSignal} [options.signal] - Passed to each detector in its context
1335
+ * @returns {Promise<ReturnType<PostGenerationGuard['check']>>}
1336
+ */
1337
+ async checkAsync(output, retrievedDocs = [], { signal } = {}) {
1338
+ if (output == null || typeof output !== "string") {
1339
+ return this._invalid();
1340
+ }
1341
+ const detections = this._runDetectors() ? await runDetectorsAsync(this._detectors, output, {
1342
+ ...CONTEXT,
1343
+ ...signal && { signal }
1344
+ }) : [];
1345
+ return this._check(output, retrievedDocs, detections);
1346
+ }
1347
+ /** @private */
1348
+ _runDetectors() {
1349
+ return this.config.enablePIIDetection && this._detectors.length > 0;
1350
+ }
1351
+ /** @private 1. The output must be a string. */
1352
+ _invalid() {
1353
+ const result = {
1354
+ safe: false,
1355
+ issues: ["Output must be a string"],
1356
+ sanitizedOutput: ""
1357
+ };
1358
+ this.emit("checked", result);
1359
+ return result;
1360
+ }
1361
+ /** @private */
1362
+ _check(output, retrievedDocs, detections) {
1363
+ const issues = [];
578
1364
  if (this.config.maxResponseLength > 0 && output.length > this.config.maxResponseLength) {
579
1365
  issues.push(
580
1366
  `Output exceeds maximum length of ${this.config.maxResponseLength} characters`
@@ -586,7 +1372,7 @@ var require_post_generation_guard = __commonJS({
586
1372
  );
587
1373
  }
588
1374
  const piiMatches = {};
589
- if (this.config.enablePIIDetection) {
1375
+ if (this.config.enablePIIDetection && this.config.builtIn !== false) {
590
1376
  for (const [name, pattern] of Object.entries(this.config.piiPatterns)) {
591
1377
  pattern.lastIndex = 0;
592
1378
  const matches = output.match(pattern);
@@ -598,6 +1384,22 @@ var require_post_generation_guard = __commonJS({
598
1384
  }
599
1385
  }
600
1386
  }
1387
+ const skip = this.config.onDetectorError === "skip";
1388
+ const detected = [];
1389
+ for (const d of detections) {
1390
+ if (d.error) {
1391
+ if (!skip) {
1392
+ issues.push(`PII detector "${d.detector}" failed: ${d.error}`);
1393
+ }
1394
+ } else if (d.flagged) {
1395
+ const texts = d.matches.map((m) => m.text).filter(Boolean);
1396
+ detected.push(...texts);
1397
+ const labels = d.labels && d.labels.length ? ` (${d.labels.join(", ")})` : "";
1398
+ issues.push(
1399
+ `PII detected by ${d.detector}${labels}: ${texts.length} occurrence(s)`
1400
+ );
1401
+ }
1402
+ }
601
1403
  let groundednessScore;
602
1404
  if (this.config.enableGroundednessCheck && Array.isArray(retrievedDocs) && retrievedDocs.length > 0) {
603
1405
  try {
@@ -622,6 +1424,7 @@ var require_post_generation_guard = __commonJS({
622
1424
  }
623
1425
  }
624
1426
  }
1427
+ sanitizedOutput = replaceAll(sanitizedOutput, detected);
625
1428
  if (this.config.maxResponseLength > 0 && sanitizedOutput.length > this.config.maxResponseLength) {
626
1429
  sanitizedOutput = sanitizedOutput.substring(
627
1430
  0,
@@ -636,28 +1439,52 @@ var require_post_generation_guard = __commonJS({
636
1439
  if (groundednessScore !== void 0) {
637
1440
  result.groundednessScore = groundednessScore;
638
1441
  }
1442
+ if (this._detectors.length > 0) {
1443
+ result.detections = detections.map(({ matches, ...d }) => ({
1444
+ ...d,
1445
+ matches: matches.map(({ text, ...m }) => m)
1446
+ }));
1447
+ Object.defineProperty(result, "matchedText", {
1448
+ value: [...new Set(detected)],
1449
+ enumerable: false
1450
+ });
1451
+ }
639
1452
  this.emit("checked", result);
640
1453
  return result;
641
1454
  }
642
1455
  /**
643
1456
  * Replace PII matches in arbitrary text with [REDACTED], using the same
644
1457
  * patterns as check(). Used to scrub text derived from the answer, such as
645
- * citation sentences.
1458
+ * citation sentences. Detectors don't run here (they may be async):
1459
+ * pass the strings a check found, `detectedText(result)`, as `matches`
1460
+ * so they are removed too.
646
1461
  *
647
1462
  * @param {string} text
1463
+ * @param {string[]} [matches=[]] - Exact strings to redact as well
648
1464
  * @returns {string}
649
1465
  */
650
- redact(text) {
1466
+ redact(text, matches = []) {
651
1467
  if (typeof text !== "string" || !this.config.enablePIIDetection) {
652
1468
  return text;
653
1469
  }
654
1470
  let redacted = text;
655
- for (const pattern of Object.values(this.config.piiPatterns)) {
656
- redacted = redacted.replace(pattern, "[REDACTED]");
1471
+ if (this.config.builtIn !== false) {
1472
+ for (const pattern of Object.values(this.config.piiPatterns)) {
1473
+ redacted = redacted.replace(pattern, "[REDACTED]");
1474
+ }
657
1475
  }
658
- return redacted;
1476
+ return replaceAll(redacted, matches);
659
1477
  }
660
1478
  };
1479
+ var CONTEXT = Object.freeze({ stage: "output" });
1480
+ function replaceAll(text, strings) {
1481
+ const unique = [...new Set(strings.filter(Boolean))].sort(
1482
+ (a, b) => b.length - a.length
1483
+ );
1484
+ let out = text;
1485
+ for (const s of unique) out = out.split(s).join("[REDACTED]");
1486
+ return out;
1487
+ }
661
1488
  module2.exports = { PostGenerationGuard, DEFAULT_CONFIG };
662
1489
  }
663
1490
  });
@@ -670,6 +1497,7 @@ var require_guardrails_pipeline = __commonJS({
670
1497
  var { PreRetrievalGuard } = require_pre_retrieval_guard();
671
1498
  var { RetrievalGuard } = require_retrieval_guard();
672
1499
  var { PostGenerationGuard } = require_post_generation_guard();
1500
+ var { detectedText } = require_detectors();
673
1501
  var DEFAULT_CONFIG = {
674
1502
  preRetrieval: {},
675
1503
  // PreRetrievalGuard options, or false to disable
@@ -686,6 +1514,12 @@ var require_guardrails_pipeline = __commonJS({
686
1514
  removed: filterOutput.removed.map((doc) => ({ id: doc.id }))
687
1515
  };
688
1516
  }
1517
+ function withReasons(message, check) {
1518
+ const issues = check && check.issues || [];
1519
+ if (issues.length === 0) return message;
1520
+ const text = issues.map(String).join("; ");
1521
+ return `${message}: ${text.length > 300 ? `${text.slice(0, 297)}...` : text}`;
1522
+ }
689
1523
  var GuardrailsPipeline = class extends EventEmitter {
690
1524
  /**
691
1525
  * @param {object} pipeline - The underlying RAG pipeline (must have a .run() method)
@@ -725,13 +1559,21 @@ var require_guardrails_pipeline = __commonJS({
725
1559
  postGeneration: null
726
1560
  };
727
1561
  let query = input.query;
1562
+ const signal = input.options && input.options.signal;
1563
+ const callOptions = signal ? { signal } : {};
728
1564
  if (this.preGuard) {
729
- guardrails.preRetrieval = this.preGuard.check(query);
1565
+ guardrails.preRetrieval = await this.preGuard.checkAsync(
1566
+ query,
1567
+ callOptions
1568
+ );
730
1569
  this.emit("preRetrieval", guardrails.preRetrieval);
731
1570
  if (!guardrails.preRetrieval.safe && this.config.strict) {
732
1571
  return {
733
1572
  success: false,
734
- error: "Pre-retrieval guard blocked query",
1573
+ error: withReasons(
1574
+ "Pre-retrieval guard blocked query",
1575
+ guardrails.preRetrieval
1576
+ ),
735
1577
  guardrails
736
1578
  };
737
1579
  }
@@ -739,10 +1581,11 @@ var require_guardrails_pipeline = __commonJS({
739
1581
  }
740
1582
  let preGenerationFilter = null;
741
1583
  const filterOptions = this.retrievalGuard ? {
742
- filterResults: (docs) => {
743
- preGenerationFilter = this.retrievalGuard.filter(
1584
+ filterResults: async (docs) => {
1585
+ preGenerationFilter = await this.retrievalGuard.filterAsync(
744
1586
  docs,
745
- input.context
1587
+ input.context,
1588
+ callOptions
746
1589
  );
747
1590
  return preGenerationFilter.results;
748
1591
  }
@@ -757,7 +1600,11 @@ var require_guardrails_pipeline = __commonJS({
757
1600
  this.emit("retrieval", guardrails.retrieval);
758
1601
  } else if (this.retrievalGuard && result.results) {
759
1602
  guardrails.retrieval = summarizeFilter(
760
- this.retrievalGuard.filter(result.results, input.context)
1603
+ await this.retrievalGuard.filterAsync(
1604
+ result.results,
1605
+ input.context,
1606
+ callOptions
1607
+ )
761
1608
  );
762
1609
  this.emit("retrieval", guardrails.retrieval);
763
1610
  result.results = guardrails.retrieval.results;
@@ -770,21 +1617,26 @@ var require_guardrails_pipeline = __commonJS({
770
1617
  }
771
1618
  }
772
1619
  if (this.postGuard && result.answer) {
773
- guardrails.postGeneration = this.postGuard.check(
1620
+ guardrails.postGeneration = await this.postGuard.checkAsync(
774
1621
  result.answer,
775
- result.results
1622
+ result.results,
1623
+ callOptions
776
1624
  );
777
1625
  this.emit("postGeneration", guardrails.postGeneration);
778
1626
  if (!guardrails.postGeneration.safe && this.config.strict) {
779
1627
  return {
780
1628
  success: false,
781
- error: "Post-generation guard flagged output",
1629
+ error: withReasons(
1630
+ "Post-generation guard flagged output",
1631
+ guardrails.postGeneration
1632
+ ),
782
1633
  guardrails
783
1634
  };
784
1635
  }
785
1636
  result.answer = guardrails.postGeneration.sanitizedOutput;
786
- this._redactDeep(result.citations);
787
- this._redactDeep(result.evaluation);
1637
+ const found = detectedText(guardrails.postGeneration);
1638
+ this._redactDeep(result.citations, found);
1639
+ this._redactDeep(result.evaluation, found);
788
1640
  }
789
1641
  result.guardrails = guardrails;
790
1642
  return result;
@@ -794,14 +1646,15 @@ var require_guardrails_pipeline = __commonJS({
794
1646
  * scrub every string in them with the same PII patterns as the answer.
795
1647
  *
796
1648
  * @param {*} value - Object, array or string to redact in place
1649
+ * @param {string[]} [matches=[]] - Strings found by PII detectors, redacted too
797
1650
  * @returns {*} The redacted value
798
1651
  * @private
799
1652
  */
800
- _redactDeep(value) {
801
- if (typeof value === "string") return this.postGuard.redact(value);
1653
+ _redactDeep(value, matches = []) {
1654
+ if (typeof value === "string") return this.postGuard.redact(value, matches);
802
1655
  if (value && typeof value === "object") {
803
1656
  for (const key of Object.keys(value)) {
804
- value[key] = this._redactDeep(value[key]);
1657
+ value[key] = this._redactDeep(value[key], matches);
805
1658
  }
806
1659
  }
807
1660
  return value;
@@ -817,12 +1670,28 @@ var require_guardrails = __commonJS({
817
1670
  "use strict";
818
1671
  var { PreRetrievalGuard } = require_pre_retrieval_guard();
819
1672
  var { detectInjection } = require_injection_detector();
1673
+ var {
1674
+ patternInjectionDetector,
1675
+ patternPIIDetector,
1676
+ detectedText
1677
+ } = require_detectors();
1678
+ var {
1679
+ evaluateDetectors,
1680
+ detectorVerdict,
1681
+ renderDetectorReport
1682
+ } = require_evaluate_detectors();
820
1683
  var { RetrievalGuard } = require_retrieval_guard();
821
1684
  var { PostGenerationGuard } = require_post_generation_guard();
822
1685
  var { GuardrailsPipeline } = require_guardrails_pipeline();
823
1686
  module2.exports = {
824
1687
  PreRetrievalGuard,
825
1688
  detectInjection,
1689
+ patternInjectionDetector,
1690
+ patternPIIDetector,
1691
+ detectedText,
1692
+ evaluateDetectors,
1693
+ detectorVerdict,
1694
+ renderDetectorReport,
826
1695
  RetrievalGuard,
827
1696
  PostGenerationGuard,
828
1697
  GuardrailsPipeline
@@ -833,6 +1702,8 @@ var require_guardrails = __commonJS({
833
1702
  // src/package-entry.js
834
1703
  var mod = require_guardrails();
835
1704
  var modules = {
1705
+ "guardrails/detectors.js": () => require_detectors(),
1706
+ "guardrails/evaluate-detectors.js": () => require_evaluate_detectors(),
836
1707
  "guardrails/guardrails-pipeline.js": () => require_guardrails_pipeline(),
837
1708
  "guardrails/injection-detector.js": () => require_injection_detector(),
838
1709
  "guardrails/post-generation-guard.js": () => require_post_generation_guard(),
package/dist/index.d.ts CHANGED
@@ -1 +1 @@
1
- export { PreRetrievalGuard, detectInjection, RetrievalGuard, PostGenerationGuard, GuardrailsPipeline } from "@devilsdev/rag-core/all-types";
1
+ export { PreRetrievalGuard, detectInjection, patternInjectionDetector, patternPIIDetector, detectedText, evaluateDetectors, detectorVerdict, renderDetectorReport, RetrievalGuard, PostGenerationGuard, GuardrailsPipeline } from "@devilsdev/rag-core/all-types";
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createRequire } from 'module';
2
2
  const require = createRequire(import.meta.url);
3
3
  const mod = require('./index.cjs');
4
- export const { PreRetrievalGuard, detectInjection, RetrievalGuard, PostGenerationGuard, GuardrailsPipeline } = mod;
4
+ export const { PreRetrievalGuard, detectInjection, patternInjectionDetector, patternPIIDetector, detectedText, evaluateDetectors, detectorVerdict, renderDetectorReport, RetrievalGuard, PostGenerationGuard, GuardrailsPipeline } = mod;
5
5
  export default mod;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devilsdev/rag-guardrails",
3
- "version": "3.4.0",
3
+ "version": "3.5.0",
4
4
  "description": "Guardrails for RAG: prompt-injection detection and pre-retrieval, retrieval and post-generation guards.",
5
5
  "keywords": [
6
6
  "rag",
@@ -41,6 +41,6 @@
41
41
  "access": "public"
42
42
  },
43
43
  "dependencies": {
44
- "@devilsdev/rag-core": "3.4.0"
44
+ "@devilsdev/rag-core": "3.5.0"
45
45
  }
46
46
  }