@devilsdev/rag-guardrails 0.0.0-stage → 3.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ali Kahwaji
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,19 @@
1
- # Temporary Holding Version
1
+ # @devilsdev/rag-guardrails
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Guardrails for RAG: prompt-injection detection and pre-retrieval, retrieval and post-generation guards.
4
+
5
+ Part of [@devilsdev/rag-pipeline-utils](https://www.npmjs.com/package/@devilsdev/rag-pipeline-utils), split into packages so you install only what you use. The objects are the same ones the full package exports; versions move in lockstep.
6
+
7
+ ```bash
8
+ npm install @devilsdev/rag-guardrails
9
+ ```
10
+
11
+ Exports: `PreRetrievalGuard`, `detectInjection`, `RetrievalGuard`, `PostGenerationGuard`, `GuardrailsPipeline`.
12
+
13
+ Documentation: https://devilsdev.github.io/rag-pipeline-utils/
14
+
15
+ The other packages:
16
+
17
+ - [`@devilsdev/rag-core`](https://www.npmjs.com/package/@devilsdev/rag-core)
18
+ - [`@devilsdev/rag-connectors`](https://www.npmjs.com/package/@devilsdev/rag-connectors)
19
+ - [`@devilsdev/rag-eval`](https://www.npmjs.com/package/@devilsdev/rag-eval)
package/dist/index.cjs ADDED
@@ -0,0 +1,850 @@
1
+ var __getOwnPropNames = Object.getOwnPropertyNames;
2
+ var __commonJS = (cb, mod2) => function __require() {
3
+ return mod2 || (0, cb[__getOwnPropNames(cb)[0]])((mod2 = { exports: {} }).exports, mod2), mod2.exports;
4
+ };
5
+
6
+ // src/security/validator.js
7
+ var require_validator = __commonJS({
8
+ "src/security/validator.js"(exports2, module2) {
9
+ "use strict";
10
+ function validateInput(input, opts = {}) {
11
+ const maxLength = opts.maxLength || 2e4;
12
+ const inputStr = String(input);
13
+ const reasons = [];
14
+ if (inputStr.length > maxLength) {
15
+ reasons.push(`Input exceeds maximum length of ${maxLength} characters`);
16
+ }
17
+ const disallowedTokens = ["```", "${", "</script", "--", ";", "||", "&&"];
18
+ const lowerInput = inputStr.toLowerCase();
19
+ for (const token of disallowedTokens) {
20
+ if (lowerInput.includes(token.toLowerCase())) {
21
+ reasons.push(`Input contains disallowed token: ${token}`);
22
+ }
23
+ }
24
+ const valid = reasons.length === 0;
25
+ const sanitized = inputStr.substring(0, maxLength);
26
+ return {
27
+ valid,
28
+ reasons,
29
+ sanitized
30
+ };
31
+ }
32
+ module2.exports = { validateInput };
33
+ }
34
+ });
35
+
36
+ // scoped:@devilsdev/rag-core|evaluate/scoring.js
37
+ var require_scoring = __commonJS({
38
+ "scoped:@devilsdev/rag-core|evaluate/scoring.js"(exports2, module2) {
39
+ module2.exports = require("@devilsdev/rag-core")[Symbol.for("@devilsdev/rag-core/internal")]("evaluate/scoring.js");
40
+ }
41
+ });
42
+
43
+ // src/guardrails/injection-detector.js
44
+ var require_injection_detector = __commonJS({
45
+ "src/guardrails/injection-detector.js"(exports2, module2) {
46
+ "use strict";
47
+ function charClass(items) {
48
+ const hex = (cp) => "\\u" + cp.toString(16).padStart(4, "0");
49
+ return items.map((it) => Array.isArray(it) ? `${hex(it[0])}-${hex(it[1])}` : hex(it)).join("");
50
+ }
51
+ var INVISIBLE_RE = new RegExp(
52
+ `[${charClass([
53
+ 173,
54
+ // soft hyphen
55
+ 847,
56
+ // combining grapheme joiner
57
+ 1564,
58
+ // arabic letter mark
59
+ 4447,
60
+ 4448,
61
+ // hangul fillers
62
+ 6068,
63
+ 6069,
64
+ [6155, 6159],
65
+ // mongolian variation selectors / vowel separator
66
+ [8203, 8207],
67
+ // zero-width space/joiners, LRM/RLM
68
+ [8234, 8238],
69
+ // bidi embeddings/overrides
70
+ [8288, 8303],
71
+ // word joiner, invisible operators, bidi isolates
72
+ 12644,
73
+ // hangul filler
74
+ [65024, 65039],
75
+ // variation selectors
76
+ 65279,
77
+ // BOM / zero-width no-break space
78
+ 65440
79
+ // halfwidth hangul filler
80
+ ])}]`,
81
+ "g"
82
+ );
83
+ var CONTROL_RE = new RegExp(
84
+ `[${charClass([[0, 8], 11, 12, [14, 31], [127, 159]])}]`,
85
+ "g"
86
+ );
87
+ var HOMOGLYPH_CODEPOINTS = [
88
+ // Cyrillic
89
+ [1072, "a"],
90
+ [1074, "b"],
91
+ [1077, "e"],
92
+ [1105, "e"],
93
+ [1082, "k"],
94
+ [1084, "m"],
95
+ [1085, "h"],
96
+ [1086, "o"],
97
+ [1088, "p"],
98
+ [1089, "c"],
99
+ [1090, "t"],
100
+ [1091, "y"],
101
+ [1093, "x"],
102
+ [1109, "s"],
103
+ [1110, "i"],
104
+ [1111, "i"],
105
+ [1112, "j"],
106
+ [1281, "d"],
107
+ [1307, "q"],
108
+ [1309, "w"],
109
+ [1231, "l"],
110
+ [609, "g"],
111
+ // Greek
112
+ [945, "a"],
113
+ [946, "b"],
114
+ [949, "e"],
115
+ [951, "n"],
116
+ [953, "i"],
117
+ [954, "k"],
118
+ [957, "v"],
119
+ [959, "o"],
120
+ [961, "p"],
121
+ [964, "t"],
122
+ [965, "u"],
123
+ [967, "x"],
124
+ [969, "w"],
125
+ [1010, "c"]
126
+ ];
127
+ var HOMOGLYPHS = Object.fromEntries(
128
+ HOMOGLYPH_CODEPOINTS.map(([cp, letter]) => [
129
+ String.fromCodePoint(cp),
130
+ letter
131
+ ])
132
+ );
133
+ var LEET = {
134
+ 0: "o",
135
+ 1: "i",
136
+ 3: "e",
137
+ 4: "a",
138
+ 5: "s",
139
+ 7: "t",
140
+ 8: "b",
141
+ "@": "a",
142
+ $: "s",
143
+ "!": "i",
144
+ "|": "l"
145
+ };
146
+ function normalizeText(text) {
147
+ return String(text).normalize("NFKC").replace(INVISIBLE_RE, "").replace(CONTROL_RE, " ").normalize("NFD").replace(new RegExp("\\p{Mn}+", "gu"), "").normalize("NFC").replace(/[^\S\n]+/g, " ").replace(/ ?\n\s*/g, "\n").trim();
148
+ }
149
+ function foldText(text) {
150
+ const lower = normalizeText(text).toLowerCase();
151
+ let out = "";
152
+ for (const ch of lower) {
153
+ out += HOMOGLYPHS[ch] || LEET[ch] || ch;
154
+ }
155
+ return out.replace(
156
+ /\b(?:[a-z][.\-_* ]){3,}[a-z]\b/g,
157
+ (m) => m.replace(/[.\-_* ]/g, "")
158
+ );
159
+ }
160
+ var DEFAULT_RULES = [
161
+ {
162
+ id: "ignore-instructions",
163
+ pattern: /\b(?:ignore|disregard|forget|skip|override|bypass)\s+(?:all\s+|any\s+|the\s+|your\s+|my\s+|of\s+)*(?:previous|prior|above|preceding|earlier|former|original|system|initial)\s+(?:instructions?|prompts?|directions?|directives?|rules|guidelines|context|messages?)\b/
164
+ },
165
+ {
166
+ id: "forget-everything",
167
+ pattern: /\bforget\s+(?:everything|all)\s+(?:you(?:'ve|\s+have|\s+were|\s+know|\s+learned)|(?:previous|prior|above|before|that\s+came))/
168
+ },
169
+ {
170
+ id: "role-override",
171
+ pattern: /\byou\s+are\s+now\s+(?:an?\s+|my\s+|the\s+)?(?:[a-z'-]+\s+){0,2}(?:ai|assistant|bot|chatbot|model|llm|dan|hacker|unrestricted|unfiltered|uncensored|jailbroken|evil|persona|character)\b|\byou\s+are\s+now\s+(?:in\s+)?(?:developer|dan|god|admin|jailbreak|unrestricted)\s+mode\b|\byou\s+are\s+no\s+longer\s+(?:an?\s+)?(?:ai|assistant|bound|restricted|limited|chatgpt|claude)\b|\byou\s+are\s+dan\b/
172
+ },
173
+ {
174
+ // "act as" only in imperative position: start of text/sentence, or after
175
+ // please/now/you must/will/should. "Redis acts as a cache" is not flagged.
176
+ id: "act-as",
177
+ pattern: /(?:^|[.!?:;\n]\s*|\b(?:please|now|you\s+(?:must|will|should|shall|can|are\s+to)|i\s+want\s+you\s+to)\s+)act\s+as\s+(?:if\s+you\b|an?\s+|my\s+|the\s+|though\b)/
178
+ },
179
+ {
180
+ id: "pretend",
181
+ pattern: /\bpretend\s+(?:that\s+)?(?:you\s+are|you're|youre|you\s+have\s+no|to\s+be\s+(?:an?\s+)?(?:\w+\s+)?(?:ai|assistant|model|bot|chatbot|dan|unrestricted|unfiltered|uncensored|evil|hacker|jailbroken))\b/
182
+ },
183
+ {
184
+ // Chat-template role markers: "system:" as a line prefix, [system],
185
+ // <|im_start|>system, [INST], <<SYS>>, "### system".
186
+ id: "role-marker",
187
+ pattern: /(?:^|\n)\s*(?:system|assistant|developer)\s*:|\[\s*(?:system|inst)\s*\]|<\|?\s*(?:im_start|system)\s*\|?>|<<\s*sys\s*>>|#{2,}\s*(?:system|instruction)s?\b/
188
+ },
189
+ {
190
+ id: "dan",
191
+ pattern: /\bdan\s+(?:mode|prompt|jailbreak)\b|\bdo\s+anything\s+now\b/
192
+ },
193
+ {
194
+ id: "bypass-restrictions",
195
+ pattern: /\b(?:bypass|circumvent|evade)\s+(?:all\s+|any\s+|your\s+|the\s+|of\s+)*(?:ai\s+|safety\s+|content\s+|ethical\s+)?(?:restrictions|guardrails|safeguards|safety\s+(?:rules|measures|guidelines|filters?)|content\s+(?:polic(?:y|ies)|filters?)|moderation)\b|\b(?:disable|ignore|remove|turn\s+off|drop)\s+(?:all\s+(?:of\s+)?)?your\s+(?:safety\s+|content\s+|ethical\s+)?(?:restrictions|filters?|guardrails|safeguards|safety|rules|guidelines|programming)\b/
196
+ },
197
+ {
198
+ id: "override-safety",
199
+ pattern: /\boverride\s+(?:your\s+|the\s+|all\s+)?(?:safety|content\s+(?:filters?|polic(?:y|ies))|system\s+(?:prompt|instructions?))\b/
200
+ },
201
+ {
202
+ id: "jailbreak",
203
+ pattern: /\bjailbr(?:ea|o)k(?:ing|en)?\s+(?:the\s+|this\s+|your\s+|an?\s+)?(?:model|ai|assistant|llm|chatbot|gpt|claude|system|prompt|you)\b|\bjailbreak\s+(?:mode|prompt)\b|\byou\s+are\s+jailbroken\b/
204
+ },
205
+ {
206
+ id: "prompt-exfiltration",
207
+ pattern: /\b(?:reveal|show|print|repeat|output|leak|display|tell\s+me)\s+(?:me\s+)?(?:your|the)\s+(?:system\s+prompt|hidden\s+(?:prompt|instructions)|initial\s+(?:prompt|instructions)|original\s+(?:prompt|instructions)|instructions\s+above)\b/
208
+ }
209
+ ];
210
+ function toStatelessRegExp(pattern) {
211
+ if (pattern instanceof RegExp) {
212
+ if (!pattern.global && !pattern.sticky) return pattern;
213
+ return new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, ""));
214
+ }
215
+ return new RegExp(String(pattern), "i");
216
+ }
217
+ function detectInjection(text, opts = {}) {
218
+ const normalized = normalizeText(text == null ? "" : text);
219
+ const lower = normalized.toLowerCase();
220
+ const folded = foldText(normalized);
221
+ const matches = [];
222
+ if (opts.useDefaults !== false) {
223
+ for (const rule of DEFAULT_RULES) {
224
+ const m = rule.pattern.exec(lower) || rule.pattern.exec(folded);
225
+ if (m) {
226
+ matches.push({
227
+ id: rule.id,
228
+ pattern: String(rule.pattern),
229
+ match: m[0].trim()
230
+ });
231
+ }
232
+ }
233
+ }
234
+ for (const raw of opts.patterns || []) {
235
+ const re = toStatelessRegExp(raw);
236
+ const m = re.exec(normalized) || re.exec(folded);
237
+ if (m) {
238
+ matches.push({ id: "custom", pattern: String(raw), match: m[0].trim() });
239
+ }
240
+ }
241
+ return { detected: matches.length > 0, matches, normalized };
242
+ }
243
+ module2.exports = {
244
+ detectInjection,
245
+ normalizeText,
246
+ foldText,
247
+ DEFAULT_RULES
248
+ };
249
+ }
250
+ });
251
+
252
+ // src/guardrails/pre-retrieval-guard.js
253
+ var require_pre_retrieval_guard = __commonJS({
254
+ "src/guardrails/pre-retrieval-guard.js"(exports2, module2) {
255
+ "use strict";
256
+ var { EventEmitter } = require("events");
257
+ var { validateInput } = require_validator();
258
+ var { tokenize } = require_scoring();
259
+ var { detectInjection, DEFAULT_RULES } = require_injection_detector();
260
+ var DEFAULT_CONFIG = {
261
+ maxQueryLength: 1e4,
262
+ enableInjectionDetection: true,
263
+ enableTopicFiltering: false,
264
+ blockedTopics: [],
265
+ allowedTopics: [],
266
+ // Built-in rules (see ./injection-detector). They match against normalised
267
+ // text (NFKC, zero-width/control characters removed, homoglyph and
268
+ // leetspeak folding). options.injectionPatterns adds extra patterns.
269
+ injectionPatterns: DEFAULT_RULES.map((rule) => rule.pattern)
270
+ };
271
+ var PreRetrievalGuard = class extends EventEmitter {
272
+ /**
273
+ * @param {object} [options] - Override default configuration
274
+ * @param {number} [options.maxQueryLength=10000] - Maximum allowed query length
275
+ * @param {boolean} [options.enableInjectionDetection=true] - Whether to check for prompt injection
276
+ * @param {boolean} [options.enableTopicFiltering=false] - Whether to enforce topic constraints
277
+ * @param {string[]} [options.blockedTopics=[]] - Topics to reject
278
+ * @param {string[]} [options.allowedTopics=[]] - If non-empty, only these topics are permitted
279
+ * @param {RegExp[]} [options.injectionPatterns] - Additional injection patterns (merged with defaults)
280
+ */
281
+ constructor(options = {}) {
282
+ super();
283
+ this.config = { ...DEFAULT_CONFIG, ...options };
284
+ this._customPatterns = options.injectionPatterns ? [...options.injectionPatterns] : [];
285
+ if (options.injectionPatterns) {
286
+ this.config.injectionPatterns = [
287
+ ...DEFAULT_CONFIG.injectionPatterns,
288
+ ...options.injectionPatterns
289
+ ];
290
+ }
291
+ }
292
+ /**
293
+ * Check a query for safety issues, injection attempts, and topic violations.
294
+ *
295
+ * @param {string} query - The user query to validate
296
+ * @returns {{
297
+ * safe: boolean,
298
+ * issues: string[],
299
+ * sanitizedQuery: string,
300
+ * injection: { detected: boolean, matches: Array<{id: string, pattern: string, match: string}> }
301
+ * }}
302
+ * `sanitizedQuery` is the query truncated to maxQueryLength; it is never
303
+ * rewritten. Injection findings are reported in `issues` and `injection`
304
+ * and make `safe` false; blocking is up to the caller (GuardrailsPipeline
305
+ * blocks in strict mode).
306
+ * @fires PreRetrievalGuard#checked
307
+ */
308
+ check(query) {
309
+ const issues = [];
310
+ if (query == null || typeof query !== "string" || query.trim().length === 0) {
311
+ issues.push("Query must be a non-empty string");
312
+ const result2 = {
313
+ safe: false,
314
+ issues,
315
+ sanitizedQuery: "",
316
+ injection: { detected: false, matches: [] }
317
+ };
318
+ this.emit("checked", result2);
319
+ return result2;
320
+ }
321
+ const validation = validateInput(query, {
322
+ maxLength: this.config.maxQueryLength
323
+ });
324
+ if (!validation.valid) {
325
+ for (const reason of validation.reasons) {
326
+ issues.push(reason);
327
+ }
328
+ }
329
+ const sanitizedQuery = validation.sanitized;
330
+ let injection = { detected: false, matches: [] };
331
+ if (this.config.enableInjectionDetection) {
332
+ const found = detectInjection(sanitizedQuery, {
333
+ patterns: this._customPatterns
334
+ });
335
+ injection = { detected: found.detected, matches: found.matches };
336
+ for (const m of found.matches) {
337
+ issues.push(
338
+ `Potential prompt injection detected: ${m.id} (${JSON.stringify(m.match)})`
339
+ );
340
+ }
341
+ }
342
+ if (this.config.enableTopicFiltering) {
343
+ const tokens = tokenize(sanitizedQuery);
344
+ if (this.config.blockedTopics.length > 0) {
345
+ for (const topic of this.config.blockedTopics) {
346
+ const topicLower = topic.toLowerCase();
347
+ if (tokens.includes(topicLower)) {
348
+ issues.push(`Query contains blocked topic: ${topic}`);
349
+ }
350
+ }
351
+ }
352
+ if (this.config.allowedTopics.length > 0) {
353
+ const allowedLower = this.config.allowedTopics.map(
354
+ (t) => t.toLowerCase()
355
+ );
356
+ const hasAllowedTopic = tokens.some(
357
+ (token) => allowedLower.includes(token)
358
+ );
359
+ if (!hasAllowedTopic) {
360
+ issues.push("Query does not match any allowed topics");
361
+ }
362
+ }
363
+ }
364
+ const result = {
365
+ safe: issues.length === 0,
366
+ issues,
367
+ sanitizedQuery,
368
+ injection
369
+ };
370
+ this.emit("checked", result);
371
+ return result;
372
+ }
373
+ };
374
+ module2.exports = { PreRetrievalGuard, DEFAULT_CONFIG };
375
+ }
376
+ });
377
+
378
+ // src/guardrails/retrieval-guard.js
379
+ var require_retrieval_guard = __commonJS({
380
+ "src/guardrails/retrieval-guard.js"(exports2, module2) {
381
+ "use strict";
382
+ var { EventEmitter } = require("events");
383
+ var { detectInjection } = require_injection_detector();
384
+ var DEFAULT_CONFIG = {
385
+ minRelevanceScore: 0,
386
+ maxAgeDays: 0,
387
+ // 0 = disabled
388
+ enforceACL: false,
389
+ // Opt-in scan of retrieved document text for prompt injection (indirect
390
+ // injection). 'remove' drops flagged documents, 'flag' keeps them and
391
+ // marks them with `injection` details.
392
+ detectInjection: false,
393
+ injectionAction: "remove",
394
+ injectionPatterns: []
395
+ };
396
+ var RetrievalGuard = class extends EventEmitter {
397
+ /**
398
+ * @param {object} [options] - Override default configuration
399
+ * @param {number} [options.minRelevanceScore=0] - Minimum relevance score threshold
400
+ * @param {number} [options.maxAgeDays=0] - Maximum document age in days (0 = disabled)
401
+ * @param {boolean} [options.enforceACL=false] - Whether to enforce access control
402
+ * @param {boolean} [options.detectInjection=false] - Scan document text for prompt injection
403
+ * @param {'remove'|'flag'} [options.injectionAction='remove'] - Remove flagged documents or keep and mark them
404
+ * @param {RegExp[]} [options.injectionPatterns=[]] - Extra injection patterns for the scan
405
+ */
406
+ constructor(options = {}) {
407
+ super();
408
+ this.config = { ...DEFAULT_CONFIG, ...options };
409
+ }
410
+ /**
411
+ * Filter retrieval results based on configured guards.
412
+ *
413
+ * @param {Array<object>} results - Array of retrieval results, each with .score and optional .metadata
414
+ * @param {object} [context={}] - Request context
415
+ * @param {string[]} [context.userPermissions] - User's permission roles for ACL filtering
416
+ * @returns {{
417
+ * results: Array<object>,
418
+ * removed: Array<object>,
419
+ * metadata: { reasons: Object<string, number> }
420
+ * }}
421
+ * @fires RetrievalGuard#filtered
422
+ */
423
+ filter(results, context = {}) {
424
+ if (!Array.isArray(results)) {
425
+ const output2 = { results: [], removed: [], metadata: { reasons: {} } };
426
+ this.emit("filtered", output2);
427
+ return output2;
428
+ }
429
+ const kept = [];
430
+ const removed = [];
431
+ const reasons = {};
432
+ const now = Date.now();
433
+ const maxAgeMs = this.config.maxAgeDays > 0 ? this.config.maxAgeDays * 24 * 60 * 60 * 1e3 : 0;
434
+ for (const result of results) {
435
+ let dominated = false;
436
+ const doc = result.metadata || result.doc || result;
437
+ if (this.config.minRelevanceScore > 0) {
438
+ const score = result.score != null ? result.score : 0;
439
+ if (score < this.config.minRelevanceScore) {
440
+ dominated = true;
441
+ reasons.relevance = (reasons.relevance || 0) + 1;
442
+ }
443
+ }
444
+ if (!dominated && maxAgeMs > 0) {
445
+ const timestamp = doc.updatedAt || doc.timestamp;
446
+ if (timestamp) {
447
+ const docTime = new Date(timestamp).getTime();
448
+ if (!isNaN(docTime) && now - docTime > maxAgeMs) {
449
+ dominated = true;
450
+ reasons.freshness = (reasons.freshness || 0) + 1;
451
+ }
452
+ }
453
+ }
454
+ if (!dominated && this.config.enforceACL) {
455
+ const userPermissions = context.userPermissions || [];
456
+ const restriction = result.requiredRole ?? result.acl ?? doc.requiredRole ?? doc.acl;
457
+ const requiredRoles = restriction ? [].concat(restriction) : null;
458
+ if (requiredRoles && requiredRoles.length > 0) {
459
+ const hasAccess = requiredRoles.some(
460
+ (role) => userPermissions.includes(role)
461
+ );
462
+ if (!hasAccess) {
463
+ dominated = true;
464
+ reasons.acl = (reasons.acl || 0) + 1;
465
+ }
466
+ }
467
+ }
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
+ }
484
+ }
485
+ if (dominated) {
486
+ removed.push(result);
487
+ } else {
488
+ kept.push(result);
489
+ }
490
+ }
491
+ const output = {
492
+ results: kept,
493
+ removed,
494
+ metadata: { reasons }
495
+ };
496
+ this.emit("filtered", { original: results, filtered: kept, removed });
497
+ return output;
498
+ }
499
+ };
500
+ function documentText(result) {
501
+ if (typeof result === "string") return result;
502
+ if (!result || typeof result !== "object") return "";
503
+ const doc = result.doc && typeof result.doc === "object" ? result.doc : {};
504
+ return String(
505
+ result.content || result.text || result.pageContent || doc.content || doc.text || ""
506
+ );
507
+ }
508
+ module2.exports = { RetrievalGuard, DEFAULT_CONFIG };
509
+ }
510
+ });
511
+
512
+ // scoped:@devilsdev/rag-core|citation/citation-tracker.js
513
+ var require_citation_tracker = __commonJS({
514
+ "scoped:@devilsdev/rag-core|citation/citation-tracker.js"(exports2, module2) {
515
+ module2.exports = require("@devilsdev/rag-core")[Symbol.for("@devilsdev/rag-core/internal")]("citation/citation-tracker.js");
516
+ }
517
+ });
518
+
519
+ // src/guardrails/post-generation-guard.js
520
+ var require_post_generation_guard = __commonJS({
521
+ "src/guardrails/post-generation-guard.js"(exports2, module2) {
522
+ "use strict";
523
+ var { EventEmitter } = require("events");
524
+ var DEFAULT_CONFIG = {
525
+ enablePIIDetection: true,
526
+ enableGroundednessCheck: true,
527
+ groundednessThreshold: 0.3,
528
+ maxResponseLength: 5e4,
529
+ 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
+ }
536
+ };
537
+ var PostGenerationGuard = class extends EventEmitter {
538
+ /**
539
+ * @param {object} [options] - Override default configuration
540
+ * @param {boolean} [options.enablePIIDetection=true] - Whether to detect PII in output
541
+ * @param {boolean} [options.enableGroundednessCheck=true] - Whether to check groundedness
542
+ * @param {number} [options.groundednessThreshold=0.3] - Minimum groundedness score
543
+ * @param {number} [options.maxResponseLength=50000] - Maximum allowed response length
544
+ * @param {number} [options.minResponseLength=0] - Minimum response length
545
+ * @param {object} [options.piiPatterns] - Custom PII regex patterns (merged with defaults)
546
+ */
547
+ constructor(options = {}) {
548
+ super();
549
+ this.config = { ...DEFAULT_CONFIG, ...options };
550
+ if (options.piiPatterns) {
551
+ this.config.piiPatterns = {
552
+ ...DEFAULT_CONFIG.piiPatterns,
553
+ ...options.piiPatterns
554
+ };
555
+ }
556
+ }
557
+ /**
558
+ * Check generated output for safety issues, PII, and groundedness.
559
+ *
560
+ * @param {string} output - The LLM-generated output text
561
+ * @param {Array<object>} [retrievedDocs=[]] - Source documents for groundedness checking
562
+ * @returns {{
563
+ * safe: boolean,
564
+ * issues: string[],
565
+ * sanitizedOutput: string,
566
+ * groundednessScore?: number
567
+ * }}
568
+ * @fires PostGenerationGuard#checked
569
+ */
570
+ check(output, retrievedDocs = []) {
571
+ const issues = [];
572
+ 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;
577
+ }
578
+ if (this.config.maxResponseLength > 0 && output.length > this.config.maxResponseLength) {
579
+ issues.push(
580
+ `Output exceeds maximum length of ${this.config.maxResponseLength} characters`
581
+ );
582
+ }
583
+ if (this.config.minResponseLength > 0 && output.length < this.config.minResponseLength) {
584
+ issues.push(
585
+ `Output is shorter than minimum length of ${this.config.minResponseLength} characters`
586
+ );
587
+ }
588
+ const piiMatches = {};
589
+ if (this.config.enablePIIDetection) {
590
+ for (const [name, pattern] of Object.entries(this.config.piiPatterns)) {
591
+ pattern.lastIndex = 0;
592
+ const matches = output.match(pattern);
593
+ if (matches && matches.length > 0) {
594
+ piiMatches[name] = matches;
595
+ issues.push(
596
+ `PII detected (${name}): ${matches.length} occurrence(s)`
597
+ );
598
+ }
599
+ }
600
+ }
601
+ let groundednessScore;
602
+ if (this.config.enableGroundednessCheck && Array.isArray(retrievedDocs) && retrievedDocs.length > 0) {
603
+ try {
604
+ const { CitationTracker } = require_citation_tracker();
605
+ const tracker = new CitationTracker();
606
+ const tracking = tracker.track(output, retrievedDocs);
607
+ groundednessScore = tracking.groundednessScore;
608
+ if (groundednessScore < this.config.groundednessThreshold) {
609
+ issues.push(
610
+ `Groundedness score (${groundednessScore.toFixed(2)}) is below threshold (${this.config.groundednessThreshold})`
611
+ );
612
+ }
613
+ } catch (err) {
614
+ issues.push(`Groundedness check failed: ${err.message}`);
615
+ }
616
+ }
617
+ let sanitizedOutput = output;
618
+ if (Object.keys(piiMatches).length > 0) {
619
+ for (const [, matches] of Object.entries(piiMatches)) {
620
+ for (const match of matches) {
621
+ sanitizedOutput = sanitizedOutput.split(match).join("[REDACTED]");
622
+ }
623
+ }
624
+ }
625
+ if (this.config.maxResponseLength > 0 && sanitizedOutput.length > this.config.maxResponseLength) {
626
+ sanitizedOutput = sanitizedOutput.substring(
627
+ 0,
628
+ this.config.maxResponseLength
629
+ );
630
+ }
631
+ const result = {
632
+ safe: issues.length === 0,
633
+ issues,
634
+ sanitizedOutput
635
+ };
636
+ if (groundednessScore !== void 0) {
637
+ result.groundednessScore = groundednessScore;
638
+ }
639
+ this.emit("checked", result);
640
+ return result;
641
+ }
642
+ /**
643
+ * Replace PII matches in arbitrary text with [REDACTED], using the same
644
+ * patterns as check(). Used to scrub text derived from the answer, such as
645
+ * citation sentences.
646
+ *
647
+ * @param {string} text
648
+ * @returns {string}
649
+ */
650
+ redact(text) {
651
+ if (typeof text !== "string" || !this.config.enablePIIDetection) {
652
+ return text;
653
+ }
654
+ let redacted = text;
655
+ for (const pattern of Object.values(this.config.piiPatterns)) {
656
+ redacted = redacted.replace(pattern, "[REDACTED]");
657
+ }
658
+ return redacted;
659
+ }
660
+ };
661
+ module2.exports = { PostGenerationGuard, DEFAULT_CONFIG };
662
+ }
663
+ });
664
+
665
+ // src/guardrails/guardrails-pipeline.js
666
+ var require_guardrails_pipeline = __commonJS({
667
+ "src/guardrails/guardrails-pipeline.js"(exports2, module2) {
668
+ "use strict";
669
+ var { EventEmitter } = require("events");
670
+ var { PreRetrievalGuard } = require_pre_retrieval_guard();
671
+ var { RetrievalGuard } = require_retrieval_guard();
672
+ var { PostGenerationGuard } = require_post_generation_guard();
673
+ var DEFAULT_CONFIG = {
674
+ preRetrieval: {},
675
+ // PreRetrievalGuard options, or false to disable
676
+ retrieval: {},
677
+ // RetrievalGuard options, or false to disable
678
+ postGeneration: {},
679
+ // PostGenerationGuard options, or false to disable
680
+ strict: false
681
+ // If true, block on any guard failure; if false, warn and continue
682
+ };
683
+ function summarizeFilter(filterOutput) {
684
+ return {
685
+ ...filterOutput,
686
+ removed: filterOutput.removed.map((doc) => ({ id: doc.id }))
687
+ };
688
+ }
689
+ var GuardrailsPipeline = class extends EventEmitter {
690
+ /**
691
+ * @param {object} pipeline - The underlying RAG pipeline (must have a .run() method)
692
+ * @param {object} [options] - Override default configuration
693
+ * @param {object|false} [options.preRetrieval={}] - PreRetrievalGuard options, or false to disable
694
+ * @param {object|false} [options.retrieval={}] - RetrievalGuard options, or false to disable
695
+ * @param {object|false} [options.postGeneration={}] - PostGenerationGuard options, or false to disable
696
+ * @param {boolean} [options.strict=false] - Whether to block on any guard failure
697
+ */
698
+ constructor(pipeline, options = {}) {
699
+ super();
700
+ if (!pipeline || typeof pipeline.run !== "function") {
701
+ throw new Error(
702
+ "GuardrailsPipeline requires a pipeline with a .run() method"
703
+ );
704
+ }
705
+ this.pipeline = pipeline;
706
+ this.config = { ...DEFAULT_CONFIG, ...options };
707
+ this.preGuard = this.config.preRetrieval !== false ? new PreRetrievalGuard(this.config.preRetrieval || {}) : null;
708
+ this.retrievalGuard = this.config.retrieval !== false ? new RetrievalGuard(this.config.retrieval || {}) : null;
709
+ this.postGuard = this.config.postGeneration !== false ? new PostGenerationGuard(this.config.postGeneration || {}) : null;
710
+ }
711
+ /**
712
+ * Run the full guarded pipeline.
713
+ *
714
+ * @param {object} input - Pipeline input
715
+ * @param {string} input.query - The user query
716
+ * @param {Float32Array} [input.queryVector] - Optional precomputed query vector
717
+ * @param {object} [input.options] - Additional pipeline options
718
+ * @param {object} [input.context] - Request context (e.g. userPermissions for ACL)
719
+ * @returns {Promise<object>} Pipeline result with guardrails metadata attached
720
+ */
721
+ async run(input) {
722
+ const guardrails = {
723
+ preRetrieval: null,
724
+ retrieval: null,
725
+ postGeneration: null
726
+ };
727
+ let query = input.query;
728
+ if (this.preGuard) {
729
+ guardrails.preRetrieval = this.preGuard.check(query);
730
+ this.emit("preRetrieval", guardrails.preRetrieval);
731
+ if (!guardrails.preRetrieval.safe && this.config.strict) {
732
+ return {
733
+ success: false,
734
+ error: "Pre-retrieval guard blocked query",
735
+ guardrails
736
+ };
737
+ }
738
+ query = guardrails.preRetrieval.sanitizedQuery;
739
+ }
740
+ let preGenerationFilter = null;
741
+ const filterOptions = this.retrievalGuard ? {
742
+ filterResults: (docs) => {
743
+ preGenerationFilter = this.retrievalGuard.filter(
744
+ docs,
745
+ input.context
746
+ );
747
+ return preGenerationFilter.results;
748
+ }
749
+ } : {};
750
+ const result = await this.pipeline.run({
751
+ ...input,
752
+ query,
753
+ options: { ...input.options, citations: true, ...filterOptions }
754
+ });
755
+ if (this.retrievalGuard && preGenerationFilter) {
756
+ guardrails.retrieval = summarizeFilter(preGenerationFilter);
757
+ this.emit("retrieval", guardrails.retrieval);
758
+ } else if (this.retrievalGuard && result.results) {
759
+ guardrails.retrieval = summarizeFilter(
760
+ this.retrievalGuard.filter(result.results, input.context)
761
+ );
762
+ this.emit("retrieval", guardrails.retrieval);
763
+ result.results = guardrails.retrieval.results;
764
+ if (guardrails.retrieval.metadata.reasons.acl) {
765
+ return {
766
+ success: false,
767
+ error: "Retrieval guard removed access-controlled documents after generation; answer withheld",
768
+ guardrails
769
+ };
770
+ }
771
+ }
772
+ if (this.postGuard && result.answer) {
773
+ guardrails.postGeneration = this.postGuard.check(
774
+ result.answer,
775
+ result.results
776
+ );
777
+ this.emit("postGeneration", guardrails.postGeneration);
778
+ if (!guardrails.postGeneration.safe && this.config.strict) {
779
+ return {
780
+ success: false,
781
+ error: "Post-generation guard flagged output",
782
+ guardrails
783
+ };
784
+ }
785
+ result.answer = guardrails.postGeneration.sanitizedOutput;
786
+ this._redactDeep(result.citations);
787
+ this._redactDeep(result.evaluation);
788
+ }
789
+ result.guardrails = guardrails;
790
+ return result;
791
+ }
792
+ /**
793
+ * Citations and evaluation details copy sentences from the raw answer, so
794
+ * scrub every string in them with the same PII patterns as the answer.
795
+ *
796
+ * @param {*} value - Object, array or string to redact in place
797
+ * @returns {*} The redacted value
798
+ * @private
799
+ */
800
+ _redactDeep(value) {
801
+ if (typeof value === "string") return this.postGuard.redact(value);
802
+ if (value && typeof value === "object") {
803
+ for (const key of Object.keys(value)) {
804
+ value[key] = this._redactDeep(value[key]);
805
+ }
806
+ }
807
+ return value;
808
+ }
809
+ };
810
+ module2.exports = { GuardrailsPipeline, DEFAULT_CONFIG };
811
+ }
812
+ });
813
+
814
+ // src/packages/guardrails.js
815
+ var require_guardrails = __commonJS({
816
+ "src/packages/guardrails.js"(exports2, module2) {
817
+ "use strict";
818
+ var { PreRetrievalGuard } = require_pre_retrieval_guard();
819
+ var { detectInjection } = require_injection_detector();
820
+ var { RetrievalGuard } = require_retrieval_guard();
821
+ var { PostGenerationGuard } = require_post_generation_guard();
822
+ var { GuardrailsPipeline } = require_guardrails_pipeline();
823
+ module2.exports = {
824
+ PreRetrievalGuard,
825
+ detectInjection,
826
+ RetrievalGuard,
827
+ PostGenerationGuard,
828
+ GuardrailsPipeline
829
+ };
830
+ }
831
+ });
832
+
833
+ // src/package-entry.js
834
+ var mod = require_guardrails();
835
+ var modules = {
836
+ "guardrails/guardrails-pipeline.js": () => require_guardrails_pipeline(),
837
+ "guardrails/injection-detector.js": () => require_injection_detector(),
838
+ "guardrails/post-generation-guard.js": () => require_post_generation_guard(),
839
+ "guardrails/pre-retrieval-guard.js": () => require_pre_retrieval_guard(),
840
+ "guardrails/retrieval-guard.js": () => require_retrieval_guard(),
841
+ "packages/guardrails.js": () => require_guardrails(),
842
+ "security/validator.js": () => require_validator()
843
+ };
844
+ Object.defineProperty(mod, Symbol.for("@devilsdev/rag-guardrails/internal"), {
845
+ value: (file) => {
846
+ if (!modules[file]) throw new Error("@devilsdev/rag-guardrails has no module " + file);
847
+ return modules[file]();
848
+ }
849
+ });
850
+ module.exports = mod;
@@ -0,0 +1 @@
1
+ export { PreRetrievalGuard, detectInjection, RetrievalGuard, PostGenerationGuard, GuardrailsPipeline } from "@devilsdev/rag-core/all-types";
package/dist/index.mjs ADDED
@@ -0,0 +1,5 @@
1
+ import { createRequire } from 'module';
2
+ const require = createRequire(import.meta.url);
3
+ const mod = require('./index.cjs');
4
+ export const { PreRetrievalGuard, detectInjection, RetrievalGuard, PostGenerationGuard, GuardrailsPipeline } = mod;
5
+ export default mod;
package/package.json CHANGED
@@ -1,6 +1,46 @@
1
1
  {
2
2
  "name": "@devilsdev/rag-guardrails",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "3.2.0",
4
+ "description": "Guardrails for RAG: prompt-injection detection and pre-retrieval, retrieval and post-generation guards.",
5
+ "keywords": [
6
+ "rag",
7
+ "guardrails",
8
+ "prompt-injection",
9
+ "llm-security"
10
+ ],
11
+ "license": "MIT",
12
+ "author": "Ali Kahwaji",
13
+ "homepage": "https://devilsdev.github.io/rag-pipeline-utils/",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/DevilsDev/rag-pipeline-utils.git"
17
+ },
18
+ "bugs": {
19
+ "url": "https://github.com/DevilsDev/rag-pipeline-utils/issues"
20
+ },
21
+ "engines": {
22
+ "node": ">=22"
23
+ },
24
+ "main": "./dist/index.cjs",
25
+ "types": "./dist/index.d.ts",
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "require": "./dist/index.cjs",
30
+ "import": "./dist/index.mjs"
31
+ },
32
+ "./package.json": "./package.json"
33
+ },
34
+ "files": [
35
+ "dist/",
36
+ "README.md",
37
+ "LICENSE"
38
+ ],
39
+ "sideEffects": false,
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "dependencies": {
44
+ "@devilsdev/rag-core": "3.2.0"
45
+ }
46
+ }