mcp-rubber-duck 1.8.0 → 1.9.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.
Files changed (118) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +158 -1
  3. package/audit-ci.json +2 -1
  4. package/dist/config/config.d.ts +2 -0
  5. package/dist/config/config.d.ts.map +1 -1
  6. package/dist/config/config.js +144 -1
  7. package/dist/config/config.js.map +1 -1
  8. package/dist/config/types.d.ts +1084 -2
  9. package/dist/config/types.d.ts.map +1 -1
  10. package/dist/config/types.js +59 -0
  11. package/dist/config/types.js.map +1 -1
  12. package/dist/guardrails/context.d.ts +10 -0
  13. package/dist/guardrails/context.d.ts.map +1 -0
  14. package/dist/guardrails/context.js +35 -0
  15. package/dist/guardrails/context.js.map +1 -0
  16. package/dist/guardrails/errors.d.ts +26 -0
  17. package/dist/guardrails/errors.d.ts.map +1 -0
  18. package/dist/guardrails/errors.js +42 -0
  19. package/dist/guardrails/errors.js.map +1 -0
  20. package/dist/guardrails/index.d.ts +6 -0
  21. package/dist/guardrails/index.d.ts.map +1 -0
  22. package/dist/guardrails/index.js +11 -0
  23. package/dist/guardrails/index.js.map +1 -0
  24. package/dist/guardrails/plugins/base-plugin.d.ts +35 -0
  25. package/dist/guardrails/plugins/base-plugin.d.ts.map +1 -0
  26. package/dist/guardrails/plugins/base-plugin.js +70 -0
  27. package/dist/guardrails/plugins/base-plugin.js.map +1 -0
  28. package/dist/guardrails/plugins/index.d.ts +6 -0
  29. package/dist/guardrails/plugins/index.d.ts.map +1 -0
  30. package/dist/guardrails/plugins/index.js +6 -0
  31. package/dist/guardrails/plugins/index.js.map +1 -0
  32. package/dist/guardrails/plugins/pattern-blocker.d.ts +27 -0
  33. package/dist/guardrails/plugins/pattern-blocker.d.ts.map +1 -0
  34. package/dist/guardrails/plugins/pattern-blocker.js +140 -0
  35. package/dist/guardrails/plugins/pattern-blocker.js.map +1 -0
  36. package/dist/guardrails/plugins/pii-redactor/detectors.d.ts +40 -0
  37. package/dist/guardrails/plugins/pii-redactor/detectors.d.ts.map +1 -0
  38. package/dist/guardrails/plugins/pii-redactor/detectors.js +134 -0
  39. package/dist/guardrails/plugins/pii-redactor/detectors.js.map +1 -0
  40. package/dist/guardrails/plugins/pii-redactor/index.d.ts +28 -0
  41. package/dist/guardrails/plugins/pii-redactor/index.d.ts.map +1 -0
  42. package/dist/guardrails/plugins/pii-redactor/index.js +157 -0
  43. package/dist/guardrails/plugins/pii-redactor/index.js.map +1 -0
  44. package/dist/guardrails/plugins/pii-redactor/pseudonymizer.d.ts +33 -0
  45. package/dist/guardrails/plugins/pii-redactor/pseudonymizer.d.ts.map +1 -0
  46. package/dist/guardrails/plugins/pii-redactor/pseudonymizer.js +70 -0
  47. package/dist/guardrails/plugins/pii-redactor/pseudonymizer.js.map +1 -0
  48. package/dist/guardrails/plugins/rate-limiter.d.ts +28 -0
  49. package/dist/guardrails/plugins/rate-limiter.d.ts.map +1 -0
  50. package/dist/guardrails/plugins/rate-limiter.js +91 -0
  51. package/dist/guardrails/plugins/rate-limiter.js.map +1 -0
  52. package/dist/guardrails/plugins/token-limiter.d.ts +30 -0
  53. package/dist/guardrails/plugins/token-limiter.d.ts.map +1 -0
  54. package/dist/guardrails/plugins/token-limiter.js +98 -0
  55. package/dist/guardrails/plugins/token-limiter.js.map +1 -0
  56. package/dist/guardrails/service.d.ts +38 -0
  57. package/dist/guardrails/service.d.ts.map +1 -0
  58. package/dist/guardrails/service.js +183 -0
  59. package/dist/guardrails/service.js.map +1 -0
  60. package/dist/guardrails/types.d.ts +96 -0
  61. package/dist/guardrails/types.d.ts.map +1 -0
  62. package/dist/guardrails/types.js +2 -0
  63. package/dist/guardrails/types.js.map +1 -0
  64. package/dist/providers/duck-provider-enhanced.d.ts +2 -1
  65. package/dist/providers/duck-provider-enhanced.d.ts.map +1 -1
  66. package/dist/providers/duck-provider-enhanced.js +55 -6
  67. package/dist/providers/duck-provider-enhanced.js.map +1 -1
  68. package/dist/providers/enhanced-manager.d.ts +2 -1
  69. package/dist/providers/enhanced-manager.d.ts.map +1 -1
  70. package/dist/providers/enhanced-manager.js +3 -3
  71. package/dist/providers/enhanced-manager.js.map +1 -1
  72. package/dist/providers/manager.d.ts +3 -1
  73. package/dist/providers/manager.d.ts.map +1 -1
  74. package/dist/providers/manager.js +4 -2
  75. package/dist/providers/manager.js.map +1 -1
  76. package/dist/providers/provider.d.ts +3 -1
  77. package/dist/providers/provider.d.ts.map +1 -1
  78. package/dist/providers/provider.js +43 -3
  79. package/dist/providers/provider.js.map +1 -1
  80. package/dist/server.d.ts +1 -0
  81. package/dist/server.d.ts.map +1 -1
  82. package/dist/server.js +28 -6
  83. package/dist/server.js.map +1 -1
  84. package/dist/services/function-bridge.d.ts +3 -1
  85. package/dist/services/function-bridge.d.ts.map +1 -1
  86. package/dist/services/function-bridge.js +40 -1
  87. package/dist/services/function-bridge.js.map +1 -1
  88. package/package.json +1 -1
  89. package/src/config/config.ts +187 -1
  90. package/src/config/types.ts +73 -0
  91. package/src/guardrails/context.ts +37 -0
  92. package/src/guardrails/errors.ts +46 -0
  93. package/src/guardrails/index.ts +20 -0
  94. package/src/guardrails/plugins/base-plugin.ts +103 -0
  95. package/src/guardrails/plugins/index.ts +5 -0
  96. package/src/guardrails/plugins/pattern-blocker.ts +190 -0
  97. package/src/guardrails/plugins/pii-redactor/detectors.ts +200 -0
  98. package/src/guardrails/plugins/pii-redactor/index.ts +203 -0
  99. package/src/guardrails/plugins/pii-redactor/pseudonymizer.ts +91 -0
  100. package/src/guardrails/plugins/rate-limiter.ts +142 -0
  101. package/src/guardrails/plugins/token-limiter.ts +155 -0
  102. package/src/guardrails/service.ts +209 -0
  103. package/src/guardrails/types.ts +120 -0
  104. package/src/providers/duck-provider-enhanced.ts +76 -7
  105. package/src/providers/enhanced-manager.ts +5 -3
  106. package/src/providers/manager.ts +6 -3
  107. package/src/providers/provider.ts +57 -6
  108. package/src/server.ts +32 -6
  109. package/src/services/function-bridge.ts +53 -2
  110. package/tests/guardrails/config.test.ts +267 -0
  111. package/tests/guardrails/errors.test.ts +109 -0
  112. package/tests/guardrails/plugins/pattern-blocker.test.ts +309 -0
  113. package/tests/guardrails/plugins/pii-redactor.test.ts +1004 -0
  114. package/tests/guardrails/plugins/rate-limiter.test.ts +310 -0
  115. package/tests/guardrails/plugins/token-limiter.test.ts +216 -0
  116. package/tests/guardrails/service.test.ts +911 -0
  117. package/tests/mcp-bridge.test.ts +248 -0
  118. package/tests/providers.test.ts +739 -0
@@ -0,0 +1,190 @@
1
+ import { BaseGuardrailPlugin } from './base-plugin.js';
2
+ import { GuardrailPhase, GuardrailContext, GuardrailResult } from '../types.js';
3
+ import { PatternBlockerConfig } from '../../config/types.js';
4
+
5
+ interface PatternMatch {
6
+ pattern: string;
7
+ isRegex: boolean;
8
+ matchedText: string;
9
+ position: number;
10
+ }
11
+
12
+ /**
13
+ * Pattern blocker plugin - blocks or warns on specific patterns
14
+ */
15
+ export class PatternBlockerPlugin extends BaseGuardrailPlugin {
16
+ name = 'pattern_blocker';
17
+ phases: GuardrailPhase[] = ['pre_request', 'pre_tool_input'];
18
+
19
+ private blockedPatterns: string[] = [];
20
+ private blockedPatternsRegex: RegExp[] = [];
21
+ private caseSensitive: boolean = false;
22
+ private actionOnMatch: 'block' | 'warn' | 'redact' = 'block';
23
+
24
+ async initialize(config: Record<string, unknown>): Promise<void> {
25
+ await super.initialize(config);
26
+
27
+ const typedConfig = config as Partial<PatternBlockerConfig>;
28
+ this.blockedPatterns = typedConfig.blocked_patterns ?? [];
29
+ this.caseSensitive = typedConfig.case_sensitive ?? false;
30
+ this.actionOnMatch = typedConfig.action_on_match ?? 'block';
31
+ this.priority = typedConfig.priority ?? 30;
32
+
33
+ // Compile regex patterns
34
+ this.blockedPatternsRegex = [];
35
+ for (const pattern of typedConfig.blocked_patterns_regex ?? []) {
36
+ try {
37
+ const flags = this.caseSensitive ? 'g' : 'gi';
38
+ this.blockedPatternsRegex.push(new RegExp(pattern, flags));
39
+ } catch (error) {
40
+ // Invalid regex, skip it
41
+ }
42
+ }
43
+ }
44
+
45
+ execute(phase: GuardrailPhase, context: GuardrailContext): Promise<GuardrailResult> {
46
+ if (!this.phases.includes(phase)) {
47
+ return Promise.resolve(this.allow(context));
48
+ }
49
+
50
+ // Get text to check based on phase
51
+ let textToCheck: string;
52
+ let fieldName: string;
53
+
54
+ if (phase === 'pre_request') {
55
+ textToCheck = context.prompt || '';
56
+ fieldName = 'prompt';
57
+ } else if (phase === 'pre_tool_input') {
58
+ textToCheck = JSON.stringify(context.toolArgs || {});
59
+ fieldName = 'toolArgs';
60
+ } else {
61
+ return Promise.resolve(this.allow(context));
62
+ }
63
+
64
+ // Find matches
65
+ const matches = this.findMatches(textToCheck);
66
+
67
+ if (matches.length === 0) {
68
+ return Promise.resolve(this.allow(context));
69
+ }
70
+
71
+ // Handle matches based on action
72
+ const matchSummary = matches.map((m) => m.pattern).join(', ');
73
+
74
+ if (this.actionOnMatch === 'block') {
75
+ this.addViolation(
76
+ context,
77
+ phase,
78
+ 'blocked_pattern',
79
+ 'error',
80
+ `Blocked patterns found: ${matchSummary}`,
81
+ { matches: matches.map((m) => ({ pattern: m.pattern, position: m.position })) }
82
+ );
83
+ return Promise.resolve(this.block(context, `Blocked pattern detected: ${matchSummary}`));
84
+ }
85
+
86
+ if (this.actionOnMatch === 'warn') {
87
+ this.addViolation(
88
+ context,
89
+ phase,
90
+ 'blocked_pattern_warning',
91
+ 'warning',
92
+ `Suspicious patterns found: ${matchSummary}`,
93
+ { matches: matches.map((m) => ({ pattern: m.pattern, position: m.position })) }
94
+ );
95
+ return Promise.resolve(this.allow(context));
96
+ }
97
+
98
+ if (this.actionOnMatch === 'redact') {
99
+ // Redact matches from text
100
+ let redactedText = textToCheck;
101
+ for (const match of matches) {
102
+ redactedText = redactedText.replace(
103
+ match.matchedText,
104
+ '[REDACTED]'
105
+ );
106
+ }
107
+
108
+ this.addModification(
109
+ context,
110
+ phase,
111
+ fieldName,
112
+ `Redacted ${matches.length} blocked patterns`,
113
+ textToCheck,
114
+ redactedText
115
+ );
116
+
117
+ // Update context
118
+ if (phase === 'pre_request') {
119
+ context.prompt = redactedText;
120
+ // Also update last message if present (create new object to avoid mutating original)
121
+ if (context.messages.length > 0) {
122
+ const lastIndex = context.messages.length - 1;
123
+ context.messages[lastIndex] = {
124
+ ...context.messages[lastIndex],
125
+ content: redactedText,
126
+ };
127
+ }
128
+ } else if (phase === 'pre_tool_input') {
129
+ try {
130
+ context.toolArgs = JSON.parse(redactedText) as Record<string, unknown>;
131
+ } catch {
132
+ // If parse fails, leave as is
133
+ }
134
+ }
135
+
136
+ return Promise.resolve(this.modify(context));
137
+ }
138
+
139
+ return Promise.resolve(this.allow(context));
140
+ }
141
+
142
+ /**
143
+ * Find all pattern matches in text
144
+ */
145
+ private findMatches(text: string): PatternMatch[] {
146
+ const matches: PatternMatch[] = [];
147
+ const searchText = this.caseSensitive ? text : text.toLowerCase();
148
+
149
+ // Check simple string patterns
150
+ for (const pattern of this.blockedPatterns) {
151
+ const searchPattern = this.caseSensitive ? pattern : pattern.toLowerCase();
152
+ let position = searchText.indexOf(searchPattern);
153
+ while (position !== -1) {
154
+ matches.push({
155
+ pattern,
156
+ isRegex: false,
157
+ matchedText: text.substring(position, position + pattern.length),
158
+ position,
159
+ });
160
+ position = searchText.indexOf(searchPattern, position + 1);
161
+ }
162
+ }
163
+
164
+ // Check regex patterns
165
+ for (const regex of this.blockedPatternsRegex) {
166
+ regex.lastIndex = 0; // Reset regex state
167
+ let match;
168
+ while ((match = regex.exec(text)) !== null) {
169
+ matches.push({
170
+ pattern: regex.source,
171
+ isRegex: true,
172
+ matchedText: match[0],
173
+ position: match.index,
174
+ });
175
+ }
176
+ }
177
+
178
+ return matches;
179
+ }
180
+
181
+ /**
182
+ * Get configured patterns (for testing)
183
+ */
184
+ getPatterns(): { simple: string[]; regex: string[] } {
185
+ return {
186
+ simple: [...this.blockedPatterns],
187
+ regex: this.blockedPatternsRegex.map((r) => r.source),
188
+ };
189
+ }
190
+ }
@@ -0,0 +1,200 @@
1
+ export type PIIType =
2
+ | 'email'
3
+ | 'phone'
4
+ | 'ssn'
5
+ | 'api_key'
6
+ | 'credit_card'
7
+ | 'ip_address'
8
+ | 'custom';
9
+
10
+ export interface PIIDetection {
11
+ type: PIIType;
12
+ value: string;
13
+ startIndex: number;
14
+ endIndex: number;
15
+ confidence: number;
16
+ }
17
+
18
+ export interface PIIDetectorConfig {
19
+ detectEmails: boolean;
20
+ detectPhones: boolean;
21
+ detectSSN: boolean;
22
+ detectAPIKeys: boolean;
23
+ detectCreditCards: boolean;
24
+ detectIPAddresses: boolean;
25
+ customPatterns: Array<{ name: string; pattern: string; placeholder: string }>;
26
+ allowlist: string[];
27
+ allowlistDomains: string[];
28
+ }
29
+
30
+ /**
31
+ * PII detector using regex patterns
32
+ */
33
+ export class PIIDetector {
34
+ private patterns: Map<PIIType, RegExp> = new Map();
35
+ private allowlist: Set<string>;
36
+ private allowlistDomains: Set<string>;
37
+ private customPatterns: Array<{ name: string; regex: RegExp; placeholder: string }> = [];
38
+
39
+ constructor(config: PIIDetectorConfig) {
40
+ this.allowlist = new Set(config.allowlist.map((a) => a.toLowerCase()));
41
+ this.allowlistDomains = new Set(config.allowlistDomains.map((d) => d.toLowerCase()));
42
+
43
+ // Initialize built-in patterns
44
+ if (config.detectEmails) {
45
+ this.patterns.set(
46
+ 'email',
47
+ /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g
48
+ );
49
+ }
50
+
51
+ if (config.detectPhones) {
52
+ // Handles multiple phone formats: US, international, with/without country code
53
+ this.patterns.set(
54
+ 'phone',
55
+ /\b(?:\+?1[-.\s]?)?\(?[0-9]{3}\)?[-.\s]?[0-9]{3}[-.\s]?[0-9]{4}\b/g
56
+ );
57
+ }
58
+
59
+ if (config.detectSSN) {
60
+ // US SSN format: XXX-XX-XXXX or XXXXXXXXX
61
+ this.patterns.set(
62
+ 'ssn',
63
+ /\b[0-9]{3}[-\s]?[0-9]{2}[-\s]?[0-9]{4}\b/g
64
+ );
65
+ }
66
+
67
+ if (config.detectAPIKeys) {
68
+ // Common API key patterns
69
+ this.patterns.set(
70
+ 'api_key',
71
+ /\b(sk-[a-zA-Z0-9]{20,}|gsk_[a-zA-Z0-9]{20,}|api[_-]?key[_-]?[a-zA-Z0-9]{16,})\b/gi
72
+ );
73
+ }
74
+
75
+ if (config.detectCreditCards) {
76
+ // Credit card patterns (Visa, Mastercard, Amex, Discover)
77
+ // Simplified - doesn't validate Luhn, just matches format
78
+ this.patterns.set(
79
+ 'credit_card',
80
+ /\b(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|3[47][0-9]{13}|6(?:011|5[0-9]{2})[0-9]{12})\b/g
81
+ );
82
+ }
83
+
84
+ if (config.detectIPAddresses) {
85
+ // IPv4 addresses
86
+ this.patterns.set(
87
+ 'ip_address',
88
+ /\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b/g
89
+ );
90
+ }
91
+
92
+ // Custom patterns
93
+ for (const custom of config.customPatterns) {
94
+ try {
95
+ this.customPatterns.push({
96
+ name: custom.name,
97
+ regex: new RegExp(custom.pattern, 'g'),
98
+ placeholder: custom.placeholder,
99
+ });
100
+ } catch {
101
+ // Invalid regex, skip it
102
+ }
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Detect PII in text
108
+ */
109
+ detect(text: string): PIIDetection[] {
110
+ const detections: PIIDetection[] = [];
111
+
112
+ // Check built-in patterns
113
+ for (const [type, pattern] of this.patterns) {
114
+ pattern.lastIndex = 0; // Reset regex state
115
+ let match;
116
+ while ((match = pattern.exec(text)) !== null) {
117
+ const value = match[0];
118
+
119
+ // Check allowlist
120
+ if (this.isAllowlisted(value, type)) {
121
+ continue;
122
+ }
123
+
124
+ detections.push({
125
+ type,
126
+ value,
127
+ startIndex: match.index,
128
+ endIndex: match.index + value.length,
129
+ confidence: this.calculateConfidence(type, value),
130
+ });
131
+ }
132
+ }
133
+
134
+ // Check custom patterns
135
+ for (const custom of this.customPatterns) {
136
+ custom.regex.lastIndex = 0;
137
+ let match;
138
+ while ((match = custom.regex.exec(text)) !== null) {
139
+ const value = match[0];
140
+
141
+ if (this.allowlist.has(value.toLowerCase())) {
142
+ continue;
143
+ }
144
+
145
+ detections.push({
146
+ type: 'custom',
147
+ value,
148
+ startIndex: match.index,
149
+ endIndex: match.index + value.length,
150
+ confidence: 0.9,
151
+ });
152
+ }
153
+ }
154
+
155
+ // Sort by position (for consistent pseudonymization)
156
+ return detections.sort((a, b) => a.startIndex - b.startIndex);
157
+ }
158
+
159
+ private isAllowlisted(value: string, type: PIIType): boolean {
160
+ const lowerValue = value.toLowerCase();
161
+
162
+ if (this.allowlist.has(lowerValue)) {
163
+ return true;
164
+ }
165
+
166
+ // For emails, check domain allowlist
167
+ if (type === 'email') {
168
+ const domain = lowerValue.split('@')[1];
169
+ if (domain && this.allowlistDomains.has(domain)) {
170
+ return true;
171
+ }
172
+ }
173
+
174
+ return false;
175
+ }
176
+
177
+ private calculateConfidence(type: PIIType, value: string): number {
178
+ // Simple confidence scoring based on format strictness
179
+ switch (type) {
180
+ case 'ssn':
181
+ return 0.95; // High confidence for strict format
182
+ case 'credit_card':
183
+ return 0.95; // High confidence for strict format
184
+ case 'email':
185
+ return 0.9;
186
+ case 'phone':
187
+ return 0.85;
188
+ case 'api_key':
189
+ // Higher confidence for longer keys or known prefixes
190
+ if (value.startsWith('sk-') || value.startsWith('gsk_')) {
191
+ return 0.95;
192
+ }
193
+ return 0.7; // Lower confidence due to possible false positives
194
+ case 'ip_address':
195
+ return 0.8;
196
+ default:
197
+ return 0.8;
198
+ }
199
+ }
200
+ }
@@ -0,0 +1,203 @@
1
+ import { BaseGuardrailPlugin } from '../base-plugin.js';
2
+ import { GuardrailPhase, GuardrailContext, GuardrailResult } from '../../types.js';
3
+ import { PIIRedactorConfig } from '../../../config/types.js';
4
+ import { PIIDetector, PIIDetectorConfig } from './detectors.js';
5
+ import { Pseudonymizer } from './pseudonymizer.js';
6
+ import { logger } from '../../../utils/logger.js';
7
+
8
+ /**
9
+ * PII Redactor plugin - detects and redacts sensitive data
10
+ */
11
+ export class PIIRedactorPlugin extends BaseGuardrailPlugin {
12
+ name = 'pii_redactor';
13
+ phases: GuardrailPhase[] = ['pre_request', 'post_response', 'pre_tool_input', 'post_tool_output'];
14
+
15
+ private detector!: PIIDetector;
16
+ private pseudonymizer!: Pseudonymizer;
17
+ private restoreOnResponse: boolean = false;
18
+ private logDetections: boolean = true;
19
+
20
+ async initialize(config: Record<string, unknown>): Promise<void> {
21
+ await super.initialize(config);
22
+
23
+ const typedConfig = config as Partial<PIIRedactorConfig>;
24
+
25
+ const detectorConfig: PIIDetectorConfig = {
26
+ detectEmails: typedConfig.detect_emails ?? true,
27
+ detectPhones: typedConfig.detect_phones ?? true,
28
+ detectSSN: typedConfig.detect_ssn ?? true,
29
+ detectAPIKeys: typedConfig.detect_api_keys ?? true,
30
+ detectCreditCards: typedConfig.detect_credit_cards ?? true,
31
+ detectIPAddresses: typedConfig.detect_ip_addresses ?? false,
32
+ customPatterns: typedConfig.custom_patterns ?? [],
33
+ allowlist: typedConfig.allowlist ?? [],
34
+ allowlistDomains: typedConfig.allowlist_domains ?? [],
35
+ };
36
+
37
+ this.detector = new PIIDetector(detectorConfig);
38
+ this.pseudonymizer = new Pseudonymizer();
39
+ this.restoreOnResponse = typedConfig.restore_on_response ?? false;
40
+ this.logDetections = typedConfig.log_detections ?? true;
41
+ this.priority = typedConfig.priority ?? 25;
42
+ }
43
+
44
+ async execute(phase: GuardrailPhase, context: GuardrailContext): Promise<GuardrailResult> {
45
+ switch (phase) {
46
+ case 'pre_request':
47
+ case 'pre_tool_input':
48
+ return this.redactPII(context, phase);
49
+
50
+ case 'post_response':
51
+ case 'post_tool_output':
52
+ if (this.restoreOnResponse) {
53
+ return this.restorePII(context, phase);
54
+ }
55
+ return this.allow(context);
56
+
57
+ default:
58
+ return this.allow(context);
59
+ }
60
+ }
61
+
62
+ private redactPII(
63
+ context: GuardrailContext,
64
+ phase: GuardrailPhase
65
+ ): Promise<GuardrailResult> {
66
+ let textToScan: string;
67
+ let field: string;
68
+
69
+ if (phase === 'pre_request') {
70
+ textToScan = context.prompt || '';
71
+ field = 'prompt';
72
+ } else {
73
+ textToScan = JSON.stringify(context.toolArgs || {});
74
+ field = 'toolArgs';
75
+ }
76
+
77
+ if (!textToScan) {
78
+ return Promise.resolve(this.allow(context));
79
+ }
80
+
81
+ const detections = this.detector.detect(textToScan);
82
+
83
+ if (detections.length === 0) {
84
+ return Promise.resolve(this.allow(context));
85
+ }
86
+
87
+ // Log detections
88
+ if (this.logDetections) {
89
+ logger.info(`PII detected in ${field}:`, {
90
+ requestId: context.requestId,
91
+ types: [...new Set(detections.map((d) => d.type))],
92
+ count: detections.length,
93
+ });
94
+ }
95
+
96
+ // Pseudonymize
97
+ const { text: redactedText, mappings } = this.pseudonymizer.pseudonymize(
98
+ textToScan,
99
+ detections
100
+ );
101
+
102
+ // Store mappings for potential restoration
103
+ context.metadata.set('pii_mappings', mappings);
104
+
105
+ // Record modification
106
+ this.addModification(
107
+ context,
108
+ phase,
109
+ field,
110
+ `Redacted ${detections.length} PII items: ${[...new Set(detections.map((d) => d.type))].join(', ')}`,
111
+ undefined, // Don't store original (contains PII)
112
+ undefined // Don't store new (would expose placeholder patterns)
113
+ );
114
+
115
+ // Apply modification
116
+ if (phase === 'pre_request') {
117
+ context.prompt = redactedText;
118
+ // Also update the last message if present
119
+ if (context.messages.length > 0) {
120
+ const lastIndex = context.messages.length - 1;
121
+ context.messages[lastIndex] = {
122
+ ...context.messages[lastIndex],
123
+ content: redactedText,
124
+ };
125
+ }
126
+ } else {
127
+ try {
128
+ context.toolArgs = JSON.parse(redactedText) as Record<string, unknown>;
129
+ } catch {
130
+ // If parse fails, store as string
131
+ context.toolArgs = { _redacted: redactedText };
132
+ }
133
+ }
134
+
135
+ return Promise.resolve(this.modify(context));
136
+ }
137
+
138
+ private restorePII(
139
+ context: GuardrailContext,
140
+ phase: GuardrailPhase
141
+ ): Promise<GuardrailResult> {
142
+ const mappings = context.metadata.get('pii_mappings') as Map<string, string> | undefined;
143
+
144
+ if (!mappings || mappings.size === 0) {
145
+ return Promise.resolve(this.allow(context));
146
+ }
147
+
148
+ let textToRestore: string;
149
+
150
+ if (phase === 'post_response') {
151
+ textToRestore = context.response || '';
152
+ } else {
153
+ textToRestore =
154
+ typeof context.toolResult === 'string'
155
+ ? context.toolResult
156
+ : JSON.stringify(context.toolResult);
157
+ }
158
+
159
+ if (!textToRestore) {
160
+ return Promise.resolve(this.allow(context));
161
+ }
162
+
163
+ const restoredText = this.pseudonymizer.restore(textToRestore, mappings);
164
+
165
+ // Only modify if something changed
166
+ if (restoredText === textToRestore) {
167
+ return Promise.resolve(this.allow(context));
168
+ }
169
+
170
+ this.addModification(
171
+ context,
172
+ phase,
173
+ phase === 'post_response' ? 'response' : 'toolResult',
174
+ `Restored ${mappings.size} PII placeholders`
175
+ );
176
+
177
+ if (phase === 'post_response') {
178
+ context.response = restoredText;
179
+ } else {
180
+ try {
181
+ context.toolResult = JSON.parse(restoredText) as unknown;
182
+ } catch {
183
+ context.toolResult = restoredText;
184
+ }
185
+ }
186
+
187
+ return Promise.resolve(this.modify(context));
188
+ }
189
+
190
+ /**
191
+ * Get detector for testing
192
+ */
193
+ getDetector(): PIIDetector {
194
+ return this.detector;
195
+ }
196
+
197
+ /**
198
+ * Get pseudonymizer for testing
199
+ */
200
+ getPseudonymizer(): Pseudonymizer {
201
+ return this.pseudonymizer;
202
+ }
203
+ }
@@ -0,0 +1,91 @@
1
+ import { PIIDetection, PIIType } from './detectors.js';
2
+
3
+ /**
4
+ * Pseudonymizer - replaces PII with numbered placeholders
5
+ * and supports optional restoration
6
+ */
7
+ export class Pseudonymizer {
8
+ private counters: Map<PIIType, number> = new Map();
9
+
10
+ /**
11
+ * Pseudonymize text by replacing PII with placeholders
12
+ * Returns the modified text and a mapping for restoration
13
+ */
14
+ pseudonymize(
15
+ text: string,
16
+ detections: PIIDetection[]
17
+ ): { text: string; mappings: Map<string, string> } {
18
+ const mappings = new Map<string, string>();
19
+ let result = text;
20
+ let offset = 0;
21
+
22
+ // Reset counters for consistent numbering
23
+ this.counters.clear();
24
+
25
+ for (const detection of detections) {
26
+ const placeholder = this.generatePlaceholder(detection.type);
27
+ mappings.set(placeholder, detection.value);
28
+
29
+ // Replace in text (accounting for previous replacements)
30
+ const start = detection.startIndex + offset;
31
+ const end = detection.endIndex + offset;
32
+ result = result.substring(0, start) + placeholder + result.substring(end);
33
+
34
+ // Adjust offset for next replacement
35
+ offset += placeholder.length - detection.value.length;
36
+ }
37
+
38
+ return { text: result, mappings };
39
+ }
40
+
41
+ /**
42
+ * Restore placeholders in text with original values
43
+ */
44
+ restore(text: string, mappings: Map<string, string>): string {
45
+ let result = text;
46
+
47
+ for (const [placeholder, original] of mappings) {
48
+ // Use global replace to handle multiple occurrences
49
+ result = result.replace(
50
+ new RegExp(this.escapeRegex(placeholder), 'g'),
51
+ original
52
+ );
53
+ }
54
+
55
+ return result;
56
+ }
57
+
58
+ /**
59
+ * Generate a placeholder for a PII type
60
+ */
61
+ private generatePlaceholder(type: PIIType): string {
62
+ const count = (this.counters.get(type) || 0) + 1;
63
+ this.counters.set(type, count);
64
+
65
+ const typeLabels: Record<PIIType, string> = {
66
+ email: 'EMAIL',
67
+ phone: 'PHONE',
68
+ ssn: 'SSN',
69
+ api_key: 'API_KEY',
70
+ credit_card: 'CARD',
71
+ ip_address: 'IP',
72
+ custom: 'REDACTED',
73
+ };
74
+
75
+ return `[${typeLabels[type]}_${count}]`;
76
+ }
77
+
78
+ /**
79
+ * Escape special regex characters in a string
80
+ */
81
+ private escapeRegex(str: string): string {
82
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
83
+ }
84
+
85
+ /**
86
+ * Reset counters (for testing)
87
+ */
88
+ reset(): void {
89
+ this.counters.clear();
90
+ }
91
+ }