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.
- package/CHANGELOG.md +15 -0
- package/README.md +274 -2
- package/audit-ci.json +2 -1
- package/dist/config/config.d.ts +2 -0
- package/dist/config/config.d.ts.map +1 -1
- package/dist/config/config.js +144 -1
- package/dist/config/config.js.map +1 -1
- package/dist/config/types.d.ts +1084 -2
- package/dist/config/types.d.ts.map +1 -1
- package/dist/config/types.js +59 -0
- package/dist/config/types.js.map +1 -1
- package/dist/guardrails/context.d.ts +10 -0
- package/dist/guardrails/context.d.ts.map +1 -0
- package/dist/guardrails/context.js +35 -0
- package/dist/guardrails/context.js.map +1 -0
- package/dist/guardrails/errors.d.ts +26 -0
- package/dist/guardrails/errors.d.ts.map +1 -0
- package/dist/guardrails/errors.js +42 -0
- package/dist/guardrails/errors.js.map +1 -0
- package/dist/guardrails/index.d.ts +6 -0
- package/dist/guardrails/index.d.ts.map +1 -0
- package/dist/guardrails/index.js +11 -0
- package/dist/guardrails/index.js.map +1 -0
- package/dist/guardrails/plugins/base-plugin.d.ts +35 -0
- package/dist/guardrails/plugins/base-plugin.d.ts.map +1 -0
- package/dist/guardrails/plugins/base-plugin.js +70 -0
- package/dist/guardrails/plugins/base-plugin.js.map +1 -0
- package/dist/guardrails/plugins/index.d.ts +6 -0
- package/dist/guardrails/plugins/index.d.ts.map +1 -0
- package/dist/guardrails/plugins/index.js +6 -0
- package/dist/guardrails/plugins/index.js.map +1 -0
- package/dist/guardrails/plugins/pattern-blocker.d.ts +27 -0
- package/dist/guardrails/plugins/pattern-blocker.d.ts.map +1 -0
- package/dist/guardrails/plugins/pattern-blocker.js +140 -0
- package/dist/guardrails/plugins/pattern-blocker.js.map +1 -0
- package/dist/guardrails/plugins/pii-redactor/detectors.d.ts +40 -0
- package/dist/guardrails/plugins/pii-redactor/detectors.d.ts.map +1 -0
- package/dist/guardrails/plugins/pii-redactor/detectors.js +134 -0
- package/dist/guardrails/plugins/pii-redactor/detectors.js.map +1 -0
- package/dist/guardrails/plugins/pii-redactor/index.d.ts +28 -0
- package/dist/guardrails/plugins/pii-redactor/index.d.ts.map +1 -0
- package/dist/guardrails/plugins/pii-redactor/index.js +157 -0
- package/dist/guardrails/plugins/pii-redactor/index.js.map +1 -0
- package/dist/guardrails/plugins/pii-redactor/pseudonymizer.d.ts +33 -0
- package/dist/guardrails/plugins/pii-redactor/pseudonymizer.d.ts.map +1 -0
- package/dist/guardrails/plugins/pii-redactor/pseudonymizer.js +70 -0
- package/dist/guardrails/plugins/pii-redactor/pseudonymizer.js.map +1 -0
- package/dist/guardrails/plugins/rate-limiter.d.ts +28 -0
- package/dist/guardrails/plugins/rate-limiter.d.ts.map +1 -0
- package/dist/guardrails/plugins/rate-limiter.js +91 -0
- package/dist/guardrails/plugins/rate-limiter.js.map +1 -0
- package/dist/guardrails/plugins/token-limiter.d.ts +30 -0
- package/dist/guardrails/plugins/token-limiter.d.ts.map +1 -0
- package/dist/guardrails/plugins/token-limiter.js +98 -0
- package/dist/guardrails/plugins/token-limiter.js.map +1 -0
- package/dist/guardrails/service.d.ts +38 -0
- package/dist/guardrails/service.d.ts.map +1 -0
- package/dist/guardrails/service.js +183 -0
- package/dist/guardrails/service.js.map +1 -0
- package/dist/guardrails/types.d.ts +96 -0
- package/dist/guardrails/types.d.ts.map +1 -0
- package/dist/guardrails/types.js +2 -0
- package/dist/guardrails/types.js.map +1 -0
- package/dist/prompts/architecture.d.ts +6 -0
- package/dist/prompts/architecture.d.ts.map +1 -0
- package/dist/prompts/architecture.js +103 -0
- package/dist/prompts/architecture.js.map +1 -0
- package/dist/prompts/assumptions.d.ts +6 -0
- package/dist/prompts/assumptions.d.ts.map +1 -0
- package/dist/prompts/assumptions.js +72 -0
- package/dist/prompts/assumptions.js.map +1 -0
- package/dist/prompts/blindspots.d.ts +6 -0
- package/dist/prompts/blindspots.d.ts.map +1 -0
- package/dist/prompts/blindspots.js +71 -0
- package/dist/prompts/blindspots.js.map +1 -0
- package/dist/prompts/diverge-converge.d.ts +6 -0
- package/dist/prompts/diverge-converge.d.ts.map +1 -0
- package/dist/prompts/diverge-converge.js +85 -0
- package/dist/prompts/diverge-converge.js.map +1 -0
- package/dist/prompts/index.d.ts +22 -0
- package/dist/prompts/index.d.ts.map +1 -0
- package/dist/prompts/index.js +57 -0
- package/dist/prompts/index.js.map +1 -0
- package/dist/prompts/perspectives.d.ts +7 -0
- package/dist/prompts/perspectives.d.ts.map +1 -0
- package/dist/prompts/perspectives.js +65 -0
- package/dist/prompts/perspectives.js.map +1 -0
- package/dist/prompts/red-team.d.ts +6 -0
- package/dist/prompts/red-team.d.ts.map +1 -0
- package/dist/prompts/red-team.js +83 -0
- package/dist/prompts/red-team.js.map +1 -0
- package/dist/prompts/reframe.d.ts +6 -0
- package/dist/prompts/reframe.d.ts.map +1 -0
- package/dist/prompts/reframe.js +71 -0
- package/dist/prompts/reframe.js.map +1 -0
- package/dist/prompts/tradeoffs.d.ts +6 -0
- package/dist/prompts/tradeoffs.d.ts.map +1 -0
- package/dist/prompts/tradeoffs.js +87 -0
- package/dist/prompts/tradeoffs.js.map +1 -0
- package/dist/prompts/types.d.ts +14 -0
- package/dist/prompts/types.d.ts.map +1 -0
- package/dist/prompts/types.js +2 -0
- package/dist/prompts/types.js.map +1 -0
- package/dist/providers/duck-provider-enhanced.d.ts +2 -1
- package/dist/providers/duck-provider-enhanced.d.ts.map +1 -1
- package/dist/providers/duck-provider-enhanced.js +55 -6
- package/dist/providers/duck-provider-enhanced.js.map +1 -1
- package/dist/providers/enhanced-manager.d.ts +2 -1
- package/dist/providers/enhanced-manager.d.ts.map +1 -1
- package/dist/providers/enhanced-manager.js +3 -3
- package/dist/providers/enhanced-manager.js.map +1 -1
- package/dist/providers/manager.d.ts +3 -1
- package/dist/providers/manager.d.ts.map +1 -1
- package/dist/providers/manager.js +4 -2
- package/dist/providers/manager.js.map +1 -1
- package/dist/providers/provider.d.ts +3 -1
- package/dist/providers/provider.d.ts.map +1 -1
- package/dist/providers/provider.js +43 -3
- package/dist/providers/provider.js.map +1 -1
- package/dist/server.d.ts +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +48 -7
- package/dist/server.js.map +1 -1
- package/dist/services/function-bridge.d.ts +3 -1
- package/dist/services/function-bridge.d.ts.map +1 -1
- package/dist/services/function-bridge.js +40 -1
- package/dist/services/function-bridge.js.map +1 -1
- package/package.json +1 -1
- package/src/config/config.ts +187 -1
- package/src/config/types.ts +73 -0
- package/src/guardrails/context.ts +37 -0
- package/src/guardrails/errors.ts +46 -0
- package/src/guardrails/index.ts +20 -0
- package/src/guardrails/plugins/base-plugin.ts +103 -0
- package/src/guardrails/plugins/index.ts +5 -0
- package/src/guardrails/plugins/pattern-blocker.ts +190 -0
- package/src/guardrails/plugins/pii-redactor/detectors.ts +200 -0
- package/src/guardrails/plugins/pii-redactor/index.ts +203 -0
- package/src/guardrails/plugins/pii-redactor/pseudonymizer.ts +91 -0
- package/src/guardrails/plugins/rate-limiter.ts +142 -0
- package/src/guardrails/plugins/token-limiter.ts +155 -0
- package/src/guardrails/service.ts +209 -0
- package/src/guardrails/types.ts +120 -0
- package/src/prompts/architecture.ts +111 -0
- package/src/prompts/assumptions.ts +80 -0
- package/src/prompts/blindspots.ts +79 -0
- package/src/prompts/diverge-converge.ts +92 -0
- package/src/prompts/index.ts +63 -0
- package/src/prompts/perspectives.ts +73 -0
- package/src/prompts/red-team.ts +91 -0
- package/src/prompts/reframe.ts +78 -0
- package/src/prompts/tradeoffs.ts +95 -0
- package/src/prompts/types.ts +14 -0
- package/src/providers/duck-provider-enhanced.ts +76 -7
- package/src/providers/enhanced-manager.ts +5 -3
- package/src/providers/manager.ts +6 -3
- package/src/providers/provider.ts +57 -6
- package/src/server.ts +55 -6
- package/src/services/function-bridge.ts +53 -2
- package/tests/guardrails/config.test.ts +267 -0
- package/tests/guardrails/errors.test.ts +109 -0
- package/tests/guardrails/plugins/pattern-blocker.test.ts +309 -0
- package/tests/guardrails/plugins/pii-redactor.test.ts +1004 -0
- package/tests/guardrails/plugins/rate-limiter.test.ts +310 -0
- package/tests/guardrails/plugins/token-limiter.test.ts +216 -0
- package/tests/guardrails/service.test.ts +911 -0
- package/tests/mcp-bridge.test.ts +248 -0
- package/tests/prompts.test.ts +314 -0
- 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
|
+
}
|