mcp-rubber-duck 1.7.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 (169) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +274 -2
  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/prompts/architecture.d.ts +6 -0
  65. package/dist/prompts/architecture.d.ts.map +1 -0
  66. package/dist/prompts/architecture.js +103 -0
  67. package/dist/prompts/architecture.js.map +1 -0
  68. package/dist/prompts/assumptions.d.ts +6 -0
  69. package/dist/prompts/assumptions.d.ts.map +1 -0
  70. package/dist/prompts/assumptions.js +72 -0
  71. package/dist/prompts/assumptions.js.map +1 -0
  72. package/dist/prompts/blindspots.d.ts +6 -0
  73. package/dist/prompts/blindspots.d.ts.map +1 -0
  74. package/dist/prompts/blindspots.js +71 -0
  75. package/dist/prompts/blindspots.js.map +1 -0
  76. package/dist/prompts/diverge-converge.d.ts +6 -0
  77. package/dist/prompts/diverge-converge.d.ts.map +1 -0
  78. package/dist/prompts/diverge-converge.js +85 -0
  79. package/dist/prompts/diverge-converge.js.map +1 -0
  80. package/dist/prompts/index.d.ts +22 -0
  81. package/dist/prompts/index.d.ts.map +1 -0
  82. package/dist/prompts/index.js +57 -0
  83. package/dist/prompts/index.js.map +1 -0
  84. package/dist/prompts/perspectives.d.ts +7 -0
  85. package/dist/prompts/perspectives.d.ts.map +1 -0
  86. package/dist/prompts/perspectives.js +65 -0
  87. package/dist/prompts/perspectives.js.map +1 -0
  88. package/dist/prompts/red-team.d.ts +6 -0
  89. package/dist/prompts/red-team.d.ts.map +1 -0
  90. package/dist/prompts/red-team.js +83 -0
  91. package/dist/prompts/red-team.js.map +1 -0
  92. package/dist/prompts/reframe.d.ts +6 -0
  93. package/dist/prompts/reframe.d.ts.map +1 -0
  94. package/dist/prompts/reframe.js +71 -0
  95. package/dist/prompts/reframe.js.map +1 -0
  96. package/dist/prompts/tradeoffs.d.ts +6 -0
  97. package/dist/prompts/tradeoffs.d.ts.map +1 -0
  98. package/dist/prompts/tradeoffs.js +87 -0
  99. package/dist/prompts/tradeoffs.js.map +1 -0
  100. package/dist/prompts/types.d.ts +14 -0
  101. package/dist/prompts/types.d.ts.map +1 -0
  102. package/dist/prompts/types.js +2 -0
  103. package/dist/prompts/types.js.map +1 -0
  104. package/dist/providers/duck-provider-enhanced.d.ts +2 -1
  105. package/dist/providers/duck-provider-enhanced.d.ts.map +1 -1
  106. package/dist/providers/duck-provider-enhanced.js +55 -6
  107. package/dist/providers/duck-provider-enhanced.js.map +1 -1
  108. package/dist/providers/enhanced-manager.d.ts +2 -1
  109. package/dist/providers/enhanced-manager.d.ts.map +1 -1
  110. package/dist/providers/enhanced-manager.js +3 -3
  111. package/dist/providers/enhanced-manager.js.map +1 -1
  112. package/dist/providers/manager.d.ts +3 -1
  113. package/dist/providers/manager.d.ts.map +1 -1
  114. package/dist/providers/manager.js +4 -2
  115. package/dist/providers/manager.js.map +1 -1
  116. package/dist/providers/provider.d.ts +3 -1
  117. package/dist/providers/provider.d.ts.map +1 -1
  118. package/dist/providers/provider.js +43 -3
  119. package/dist/providers/provider.js.map +1 -1
  120. package/dist/server.d.ts +1 -0
  121. package/dist/server.d.ts.map +1 -1
  122. package/dist/server.js +48 -7
  123. package/dist/server.js.map +1 -1
  124. package/dist/services/function-bridge.d.ts +3 -1
  125. package/dist/services/function-bridge.d.ts.map +1 -1
  126. package/dist/services/function-bridge.js +40 -1
  127. package/dist/services/function-bridge.js.map +1 -1
  128. package/package.json +1 -1
  129. package/src/config/config.ts +187 -1
  130. package/src/config/types.ts +73 -0
  131. package/src/guardrails/context.ts +37 -0
  132. package/src/guardrails/errors.ts +46 -0
  133. package/src/guardrails/index.ts +20 -0
  134. package/src/guardrails/plugins/base-plugin.ts +103 -0
  135. package/src/guardrails/plugins/index.ts +5 -0
  136. package/src/guardrails/plugins/pattern-blocker.ts +190 -0
  137. package/src/guardrails/plugins/pii-redactor/detectors.ts +200 -0
  138. package/src/guardrails/plugins/pii-redactor/index.ts +203 -0
  139. package/src/guardrails/plugins/pii-redactor/pseudonymizer.ts +91 -0
  140. package/src/guardrails/plugins/rate-limiter.ts +142 -0
  141. package/src/guardrails/plugins/token-limiter.ts +155 -0
  142. package/src/guardrails/service.ts +209 -0
  143. package/src/guardrails/types.ts +120 -0
  144. package/src/prompts/architecture.ts +111 -0
  145. package/src/prompts/assumptions.ts +80 -0
  146. package/src/prompts/blindspots.ts +79 -0
  147. package/src/prompts/diverge-converge.ts +92 -0
  148. package/src/prompts/index.ts +63 -0
  149. package/src/prompts/perspectives.ts +73 -0
  150. package/src/prompts/red-team.ts +91 -0
  151. package/src/prompts/reframe.ts +78 -0
  152. package/src/prompts/tradeoffs.ts +95 -0
  153. package/src/prompts/types.ts +14 -0
  154. package/src/providers/duck-provider-enhanced.ts +76 -7
  155. package/src/providers/enhanced-manager.ts +5 -3
  156. package/src/providers/manager.ts +6 -3
  157. package/src/providers/provider.ts +57 -6
  158. package/src/server.ts +55 -6
  159. package/src/services/function-bridge.ts +53 -2
  160. package/tests/guardrails/config.test.ts +267 -0
  161. package/tests/guardrails/errors.test.ts +109 -0
  162. package/tests/guardrails/plugins/pattern-blocker.test.ts +309 -0
  163. package/tests/guardrails/plugins/pii-redactor.test.ts +1004 -0
  164. package/tests/guardrails/plugins/rate-limiter.test.ts +310 -0
  165. package/tests/guardrails/plugins/token-limiter.test.ts +216 -0
  166. package/tests/guardrails/service.test.ts +911 -0
  167. package/tests/mcp-bridge.test.ts +248 -0
  168. package/tests/prompts.test.ts +314 -0
  169. package/tests/providers.test.ts +739 -0
@@ -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
+ }
@@ -0,0 +1,142 @@
1
+ import { BaseGuardrailPlugin } from './base-plugin.js';
2
+ import { GuardrailPhase, GuardrailContext, GuardrailResult } from '../types.js';
3
+ import { RateLimiterConfig } from '../../config/types.js';
4
+
5
+ interface RequestRecord {
6
+ timestamp: number;
7
+ }
8
+
9
+ /**
10
+ * Rate limiter plugin - limits requests per minute/hour
11
+ */
12
+ export class RateLimiterPlugin extends BaseGuardrailPlugin {
13
+ name = 'rate_limiter';
14
+ phases: GuardrailPhase[] = ['pre_request'];
15
+
16
+ private requestsPerMinute: number = 60;
17
+ private requestsPerHour: number = 1000;
18
+ private perProvider: boolean = false;
19
+ private burstAllowance: number = 5;
20
+
21
+ // Request history: key is provider (or 'global'), value is array of timestamps
22
+ private requestHistory: Map<string, RequestRecord[]> = new Map();
23
+
24
+ async initialize(config: Record<string, unknown>): Promise<void> {
25
+ await super.initialize(config);
26
+
27
+ const typedConfig = config as Partial<RateLimiterConfig>;
28
+ this.requestsPerMinute = typedConfig.requests_per_minute ?? 60;
29
+ this.requestsPerHour = typedConfig.requests_per_hour ?? 1000;
30
+ this.perProvider = typedConfig.per_provider ?? false;
31
+ this.burstAllowance = typedConfig.burst_allowance ?? 5;
32
+ this.priority = typedConfig.priority ?? 10;
33
+ }
34
+
35
+ execute(phase: GuardrailPhase, context: GuardrailContext): Promise<GuardrailResult> {
36
+ if (phase !== 'pre_request') {
37
+ return Promise.resolve(this.allow(context));
38
+ }
39
+
40
+ const key = this.perProvider ? context.provider : 'global';
41
+ const now = Date.now();
42
+
43
+ // Get or create request history for this key
44
+ let history = this.requestHistory.get(key);
45
+ if (!history) {
46
+ history = [];
47
+ this.requestHistory.set(key, history);
48
+ }
49
+
50
+ // Clean up old entries (older than 1 hour)
51
+ const oneHourAgo = now - 60 * 60 * 1000;
52
+ history = history.filter((r) => r.timestamp > oneHourAgo);
53
+
54
+ // Remove empty keys to prevent unbounded Map growth with perProvider mode
55
+ if (history.length === 0) {
56
+ this.requestHistory.delete(key);
57
+ history = [];
58
+ } else {
59
+ this.requestHistory.set(key, history);
60
+ }
61
+
62
+ // Count requests in last minute and last hour
63
+ const oneMinuteAgo = now - 60 * 1000;
64
+ const requestsLastMinute = history.filter((r) => r.timestamp > oneMinuteAgo).length;
65
+ const requestsLastHour = history.length;
66
+
67
+ // Check rate limits (with burst allowance)
68
+ const effectiveMinuteLimit = this.requestsPerMinute + this.burstAllowance;
69
+ const effectiveHourLimit = this.requestsPerHour + this.burstAllowance;
70
+
71
+ if (requestsLastMinute >= effectiveMinuteLimit) {
72
+ this.addViolation(
73
+ context,
74
+ phase,
75
+ 'requests_per_minute',
76
+ 'error',
77
+ `Rate limit exceeded: ${requestsLastMinute} requests in the last minute (limit: ${this.requestsPerMinute})`,
78
+ { requestsLastMinute, limit: this.requestsPerMinute }
79
+ );
80
+ return Promise.resolve(this.block(
81
+ context,
82
+ `Rate limit exceeded: ${requestsLastMinute}/${this.requestsPerMinute} requests per minute`
83
+ ));
84
+ }
85
+
86
+ if (requestsLastHour >= effectiveHourLimit) {
87
+ this.addViolation(
88
+ context,
89
+ phase,
90
+ 'requests_per_hour',
91
+ 'error',
92
+ `Rate limit exceeded: ${requestsLastHour} requests in the last hour (limit: ${this.requestsPerHour})`,
93
+ { requestsLastHour, limit: this.requestsPerHour }
94
+ );
95
+ return Promise.resolve(this.block(
96
+ context,
97
+ `Rate limit exceeded: ${requestsLastHour}/${this.requestsPerHour} requests per hour`
98
+ ));
99
+ }
100
+
101
+ // Log warning if approaching limit
102
+ if (requestsLastMinute >= this.requestsPerMinute * 0.8) {
103
+ this.addViolation(
104
+ context,
105
+ phase,
106
+ 'requests_per_minute_warning',
107
+ 'warning',
108
+ `Approaching rate limit: ${requestsLastMinute}/${this.requestsPerMinute} requests per minute`,
109
+ { requestsLastMinute, limit: this.requestsPerMinute }
110
+ );
111
+ }
112
+
113
+ // Record this request
114
+ history.push({ timestamp: now });
115
+ // Ensure history is stored in Map (needed after empty cleanup)
116
+ this.requestHistory.set(key, history);
117
+
118
+ return Promise.resolve(this.allow(context));
119
+ }
120
+
121
+ /**
122
+ * Get current request counts (for testing/monitoring)
123
+ */
124
+ getRequestCounts(key: string = 'global'): { lastMinute: number; lastHour: number } {
125
+ const now = Date.now();
126
+ const history = this.requestHistory.get(key) || [];
127
+ const oneMinuteAgo = now - 60 * 1000;
128
+ const oneHourAgo = now - 60 * 60 * 1000;
129
+
130
+ return {
131
+ lastMinute: history.filter((r) => r.timestamp > oneMinuteAgo).length,
132
+ lastHour: history.filter((r) => r.timestamp > oneHourAgo).length,
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Reset request history (for testing)
138
+ */
139
+ reset(): void {
140
+ this.requestHistory.clear();
141
+ }
142
+ }
@@ -0,0 +1,155 @@
1
+ import { BaseGuardrailPlugin } from './base-plugin.js';
2
+ import { GuardrailPhase, GuardrailContext, GuardrailResult } from '../types.js';
3
+ import { TokenLimiterConfig } from '../../config/types.js';
4
+
5
+ /**
6
+ * Token limiter plugin - limits input/output token counts
7
+ */
8
+ export class TokenLimiterPlugin extends BaseGuardrailPlugin {
9
+ name = 'token_limiter';
10
+ phases: GuardrailPhase[] = ['pre_request', 'post_response'];
11
+
12
+ private maxInputTokens: number = 8192;
13
+ private maxOutputTokens: number | undefined;
14
+ private warnAtPercentage: number = 80;
15
+
16
+ async initialize(config: Record<string, unknown>): Promise<void> {
17
+ await super.initialize(config);
18
+
19
+ const typedConfig = config as Partial<TokenLimiterConfig>;
20
+ this.maxInputTokens = typedConfig.max_input_tokens ?? 8192;
21
+ this.maxOutputTokens = typedConfig.max_output_tokens;
22
+ this.warnAtPercentage = typedConfig.warn_at_percentage ?? 80;
23
+ this.priority = typedConfig.priority ?? 20;
24
+ }
25
+
26
+ execute(phase: GuardrailPhase, context: GuardrailContext): Promise<GuardrailResult> {
27
+ if (phase === 'pre_request') {
28
+ return this.checkInputTokens(context, phase);
29
+ } else if (phase === 'post_response') {
30
+ return this.checkOutputTokens(context, phase);
31
+ }
32
+ return Promise.resolve(this.allow(context));
33
+ }
34
+
35
+ private checkInputTokens(
36
+ context: GuardrailContext,
37
+ phase: GuardrailPhase
38
+ ): Promise<GuardrailResult> {
39
+ // Estimate token count from prompt
40
+ const prompt = context.prompt || '';
41
+ const estimatedTokens = this.estimateTokenCount(prompt);
42
+
43
+ // Also count messages if present
44
+ let totalTokens = estimatedTokens;
45
+ for (const msg of context.messages) {
46
+ totalTokens += this.estimateTokenCount(msg.content);
47
+ }
48
+
49
+ // Check if over limit
50
+ if (totalTokens > this.maxInputTokens) {
51
+ this.addViolation(
52
+ context,
53
+ phase,
54
+ 'max_input_tokens',
55
+ 'error',
56
+ `Token limit exceeded: estimated ${totalTokens} tokens (limit: ${this.maxInputTokens})`,
57
+ { estimatedTokens: totalTokens, limit: this.maxInputTokens }
58
+ );
59
+ return Promise.resolve(
60
+ this.block(context, `Token limit exceeded: ~${totalTokens}/${this.maxInputTokens} tokens`)
61
+ );
62
+ }
63
+
64
+ // Warn if approaching limit
65
+ const warnThreshold = this.maxInputTokens * (this.warnAtPercentage / 100);
66
+ if (totalTokens >= warnThreshold) {
67
+ this.addViolation(
68
+ context,
69
+ phase,
70
+ 'max_input_tokens_warning',
71
+ 'warning',
72
+ `Approaching token limit: estimated ${totalTokens}/${this.maxInputTokens} tokens (${Math.round((totalTokens / this.maxInputTokens) * 100)}%)`,
73
+ {
74
+ estimatedTokens: totalTokens,
75
+ limit: this.maxInputTokens,
76
+ percentage: Math.round((totalTokens / this.maxInputTokens) * 100),
77
+ }
78
+ );
79
+ }
80
+
81
+ return Promise.resolve(this.allow(context));
82
+ }
83
+
84
+ private checkOutputTokens(
85
+ context: GuardrailContext,
86
+ phase: GuardrailPhase
87
+ ): Promise<GuardrailResult> {
88
+ // Skip if no output limit configured
89
+ if (!this.maxOutputTokens) {
90
+ return Promise.resolve(this.allow(context));
91
+ }
92
+
93
+ const response = context.response || '';
94
+ const estimatedTokens = this.estimateTokenCount(response);
95
+
96
+ // Check if over limit
97
+ if (estimatedTokens > this.maxOutputTokens) {
98
+ this.addViolation(
99
+ context,
100
+ phase,
101
+ 'max_output_tokens',
102
+ 'error',
103
+ `Output token limit exceeded: estimated ${estimatedTokens} tokens (limit: ${this.maxOutputTokens})`,
104
+ { estimatedTokens, limit: this.maxOutputTokens }
105
+ );
106
+ return Promise.resolve(
107
+ this.block(
108
+ context,
109
+ `Output token limit exceeded: ~${estimatedTokens}/${this.maxOutputTokens} tokens`
110
+ )
111
+ );
112
+ }
113
+
114
+ // Warn if approaching limit
115
+ const warnThreshold = this.maxOutputTokens * (this.warnAtPercentage / 100);
116
+ if (estimatedTokens >= warnThreshold) {
117
+ this.addViolation(
118
+ context,
119
+ phase,
120
+ 'max_output_tokens_warning',
121
+ 'warning',
122
+ `Approaching output token limit: estimated ${estimatedTokens}/${this.maxOutputTokens} tokens (${Math.round((estimatedTokens / this.maxOutputTokens) * 100)}%)`,
123
+ {
124
+ estimatedTokens,
125
+ limit: this.maxOutputTokens,
126
+ percentage: Math.round((estimatedTokens / this.maxOutputTokens) * 100),
127
+ }
128
+ );
129
+ }
130
+
131
+ return Promise.resolve(this.allow(context));
132
+ }
133
+
134
+ /**
135
+ * Estimate token count from text
136
+ * Uses a simple heuristic: ~4 characters per token for English text
137
+ * This is a rough approximation - for accuracy, use tiktoken
138
+ */
139
+ estimateTokenCount(text: string): number {
140
+ if (!text) return 0;
141
+ // Rough approximation: 1 token ≈ 4 characters for English
142
+ // Add some overhead for special tokens
143
+ return Math.ceil(text.length / 4) + 4;
144
+ }
145
+
146
+ /**
147
+ * Get configured limits (for testing/monitoring)
148
+ */
149
+ getLimits(): { maxInputTokens: number; maxOutputTokens: number | undefined } {
150
+ return {
151
+ maxInputTokens: this.maxInputTokens,
152
+ maxOutputTokens: this.maxOutputTokens,
153
+ };
154
+ }
155
+ }