agentshield-sdk 7.1.0 → 7.2.1
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 +28 -0
- package/README.md +48 -13
- package/package.json +5 -3
- package/src/circuit-breaker.js +321 -321
- package/src/detector-core.js +3 -3
- package/src/distributed.js +402 -359
- package/src/fuzzer.js +764 -764
- package/src/index.js +23 -7
- package/src/ipia-detector.js +821 -0
- package/src/main.js +20 -2
- package/src/mcp-security-runtime.js +30 -5
- package/src/mcp-server.js +12 -8
- package/src/middleware.js +303 -208
- package/src/multi-agent.js +421 -404
- package/src/pii.js +401 -390
- package/src/stream-scanner.js +34 -4
- package/src/testing.js +505 -505
- package/src/utils.js +199 -83
- package/types/index.d.ts +443 -0
package/src/circuit-breaker.js
CHANGED
|
@@ -1,321 +1,321 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Circuit Breaker (#31), Shadow Mode (#33), and Rate Limiting (#5)
|
|
5
|
-
*
|
|
6
|
-
* - Circuit Breaker: Auto-shuts down an agent after too many threats in a time window.
|
|
7
|
-
* - Shadow Mode: Detection-only mode — logs everything, blocks nothing.
|
|
8
|
-
* - Rate Limiting: Tracks input patterns and flags anomalous spikes.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Circuit breaker states.
|
|
13
|
-
*/
|
|
14
|
-
const STATE = {
|
|
15
|
-
CLOSED: 'closed', // Normal operation
|
|
16
|
-
OPEN: 'open', // Tripped — all requests blocked
|
|
17
|
-
HALF_OPEN: 'half_open' // Testing if safe to resume
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
class CircuitBreaker {
|
|
21
|
-
/**
|
|
22
|
-
* @param {object} [options]
|
|
23
|
-
* @param {number} [options.threshold=5] - Number of threats to trip the breaker.
|
|
24
|
-
* @param {number} [options.windowMs=60000] - Time window in ms (default: 1 minute).
|
|
25
|
-
* @param {number} [options.cooldownMs=300000] - Cooldown before half-open (default: 5 minutes).
|
|
26
|
-
* @param {Function} [options.onTrip] - Callback when breaker trips.
|
|
27
|
-
* @param {Function} [options.onReset] - Callback when breaker resets.
|
|
28
|
-
*/
|
|
29
|
-
constructor(options = {}) {
|
|
30
|
-
this.threshold = options.threshold
|
|
31
|
-
this.windowMs = options.windowMs
|
|
32
|
-
this.cooldownMs = options.cooldownMs
|
|
33
|
-
this.onTrip = options.onTrip || null;
|
|
34
|
-
this.onReset = options.onReset || null;
|
|
35
|
-
|
|
36
|
-
this.state = STATE.CLOSED;
|
|
37
|
-
this.threatTimestamps = [];
|
|
38
|
-
this.trippedAt = null;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Records a threat event. Trips the breaker if threshold is exceeded.
|
|
43
|
-
* @param {number} [count=1] - Number of threats to record.
|
|
44
|
-
*/
|
|
45
|
-
recordThreat(count = 1) {
|
|
46
|
-
const now = Date.now();
|
|
47
|
-
for (let i = 0; i < count; i++) {
|
|
48
|
-
this.threatTimestamps.push(now);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// Prune old timestamps outside the window
|
|
52
|
-
const cutoff = now - this.windowMs;
|
|
53
|
-
this.threatTimestamps = this.threatTimestamps.filter(t => t > cutoff);
|
|
54
|
-
|
|
55
|
-
if (this.state === STATE.CLOSED && this.threatTimestamps.length >= this.threshold) {
|
|
56
|
-
this._trip();
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Checks if the breaker allows a request through.
|
|
62
|
-
* @returns {object} { allowed: boolean, state: string, reason?: string }
|
|
63
|
-
*/
|
|
64
|
-
check() {
|
|
65
|
-
if (this.state === STATE.CLOSED) {
|
|
66
|
-
return { allowed: true, state: this.state };
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
if (this.state === STATE.OPEN) {
|
|
70
|
-
const elapsed = Date.now() - this.trippedAt;
|
|
71
|
-
if (elapsed >= this.cooldownMs) {
|
|
72
|
-
this.state = STATE.HALF_OPEN;
|
|
73
|
-
return { allowed: true, state: this.state, reason: 'Testing after cooldown' };
|
|
74
|
-
}
|
|
75
|
-
const remainingMs = this.cooldownMs - elapsed;
|
|
76
|
-
return {
|
|
77
|
-
allowed: false,
|
|
78
|
-
state: this.state,
|
|
79
|
-
reason: `Circuit breaker tripped. Resumes in ${Math.ceil(remainingMs / 1000)}s.`
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// HALF_OPEN: allow one request to test
|
|
84
|
-
return { allowed: true, state: this.state, reason: 'Half-open test request' };
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Reports the result of a half-open test request.
|
|
89
|
-
* @param {boolean} safe - Whether the test request was safe.
|
|
90
|
-
*/
|
|
91
|
-
reportTestResult(safe) {
|
|
92
|
-
if (this.state !== STATE.HALF_OPEN) return;
|
|
93
|
-
|
|
94
|
-
if (safe) {
|
|
95
|
-
this._reset();
|
|
96
|
-
} else {
|
|
97
|
-
this._trip();
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/** @private */
|
|
102
|
-
_trip() {
|
|
103
|
-
this.state = STATE.OPEN;
|
|
104
|
-
this.trippedAt = Date.now();
|
|
105
|
-
if (this.onTrip) {
|
|
106
|
-
try {
|
|
107
|
-
this.onTrip({
|
|
108
|
-
state: STATE.OPEN,
|
|
109
|
-
threatCount: this.threatTimestamps.length,
|
|
110
|
-
timestamp: this.trippedAt
|
|
111
|
-
});
|
|
112
|
-
} catch (err) {
|
|
113
|
-
console.error('[Agent Shield] onTrip callback error:', err.message);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** @private */
|
|
119
|
-
_reset() {
|
|
120
|
-
this.state = STATE.CLOSED;
|
|
121
|
-
this.threatTimestamps = [];
|
|
122
|
-
this.trippedAt = null;
|
|
123
|
-
if (this.onReset) {
|
|
124
|
-
try {
|
|
125
|
-
this.onReset({ state: STATE.CLOSED, timestamp: Date.now() });
|
|
126
|
-
} catch (err) {
|
|
127
|
-
console.error('[Agent Shield] onReset callback error:', err.message);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Manually reset the breaker.
|
|
134
|
-
*/
|
|
135
|
-
reset() {
|
|
136
|
-
this._reset();
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Returns current breaker status.
|
|
141
|
-
* @returns {object}
|
|
142
|
-
*/
|
|
143
|
-
getStatus() {
|
|
144
|
-
return {
|
|
145
|
-
state: this.state,
|
|
146
|
-
recentThreats: this.threatTimestamps.length,
|
|
147
|
-
threshold: this.threshold,
|
|
148
|
-
trippedAt: this.trippedAt
|
|
149
|
-
};
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// =========================================================================
|
|
154
|
-
// SHADOW MODE
|
|
155
|
-
// =========================================================================
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Wraps an AgentShield instance in shadow mode.
|
|
159
|
-
* Logs all detections but never blocks. Perfect for evaluation.
|
|
160
|
-
*
|
|
161
|
-
* @param {object} shield - An AgentShield instance.
|
|
162
|
-
* @param {object} [options]
|
|
163
|
-
* @param {Function} [options.logger] - Custom log function. Defaults to console.log.
|
|
164
|
-
* @returns {object} - A shadow-mode wrapped shield with the same API.
|
|
165
|
-
*/
|
|
166
|
-
const shadowMode = (shield, options = {}) => {
|
|
167
|
-
const logger = options.logger || console.log;
|
|
168
|
-
const log = [];
|
|
169
|
-
|
|
170
|
-
const wrap = (methodName, original) => {
|
|
171
|
-
return function (...args) {
|
|
172
|
-
const result = original.apply(shield, args);
|
|
173
|
-
|
|
174
|
-
// If it's a promise (async methods like from middleware), handle accordingly
|
|
175
|
-
if (result && typeof result.then === 'function') {
|
|
176
|
-
return result.then(res => {
|
|
177
|
-
const entry = { method: methodName, result: res, timestamp: Date.now() };
|
|
178
|
-
log.push(entry);
|
|
179
|
-
if (log.length > 1000) log.shift();
|
|
180
|
-
if (res.threats && res.threats.length > 0) {
|
|
181
|
-
try { logger(`[Agent Shield Shadow] ${methodName}: ${res.threats.length} threat(s) detected (not blocked)`, res.threats.map(t => t.description)); } catch (e) {
|
|
182
|
-
}
|
|
183
|
-
// Never block in shadow mode
|
|
184
|
-
if ('blocked' in res) res.blocked = false;
|
|
185
|
-
return res;
|
|
186
|
-
});
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
const entry = { method: methodName, result, timestamp: Date.now() };
|
|
190
|
-
log.push(entry);
|
|
191
|
-
if (log.length > 1000) log.shift();
|
|
192
|
-
|
|
193
|
-
if (result.threats && result.threats.length > 0) {
|
|
194
|
-
try { logger(`[Agent Shield Shadow] ${methodName}: ${result.threats.length} threat(s) detected (not blocked)`, result.threats.map(t => t.description)); } catch (e) {
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// Never block in shadow mode
|
|
198
|
-
if ('blocked' in result) result.blocked = false;
|
|
199
|
-
return result;
|
|
200
|
-
};
|
|
201
|
-
};
|
|
202
|
-
|
|
203
|
-
return {
|
|
204
|
-
scan: wrap('scan', shield.scan),
|
|
205
|
-
scanInput: wrap('scanInput', shield.scanInput),
|
|
206
|
-
scanOutput: wrap('scanOutput', shield.scanOutput),
|
|
207
|
-
scanToolCall: wrap('scanToolCall', shield.scanToolCall),
|
|
208
|
-
scanBatch: wrap('scanBatch', shield.scanBatch),
|
|
209
|
-
getStats: () => shield.getStats(),
|
|
210
|
-
getLog: () => [...log],
|
|
211
|
-
isShadowMode: true
|
|
212
|
-
};
|
|
213
|
-
};
|
|
214
|
-
|
|
215
|
-
// =========================================================================
|
|
216
|
-
// RATE LIMITER
|
|
217
|
-
// =========================================================================
|
|
218
|
-
|
|
219
|
-
class RateLimiter {
|
|
220
|
-
/**
|
|
221
|
-
* @param {object} [options]
|
|
222
|
-
* @param {number} [options.maxRequests=100] - Max requests per window.
|
|
223
|
-
* @param {number} [options.windowMs=60000] - Window size in ms.
|
|
224
|
-
* @param {number} [options.maxThreatsPerWindow=10] - Max threats before flagging anomaly.
|
|
225
|
-
* @param {Function} [options.onLimit] - Callback when rate limit hit.
|
|
226
|
-
* @param {Function} [options.onAnomaly] - Callback when anomaly detected.
|
|
227
|
-
*/
|
|
228
|
-
constructor(options = {}) {
|
|
229
|
-
this.maxRequests = options.maxRequests
|
|
230
|
-
this.windowMs = options.windowMs
|
|
231
|
-
this.maxThreatsPerWindow = options.maxThreatsPerWindow
|
|
232
|
-
this.onLimit = options.onLimit || null;
|
|
233
|
-
this.onAnomaly = options.onAnomaly || null;
|
|
234
|
-
|
|
235
|
-
this.requestTimestamps = [];
|
|
236
|
-
this.threatTimestamps = [];
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/**
|
|
240
|
-
* Records a request. Returns whether it's allowed.
|
|
241
|
-
* @returns {object} { allowed: boolean, remaining: number, reason?: string }
|
|
242
|
-
*/
|
|
243
|
-
recordRequest() {
|
|
244
|
-
const now = Date.now();
|
|
245
|
-
const cutoff = now - this.windowMs;
|
|
246
|
-
|
|
247
|
-
this.requestTimestamps = this.requestTimestamps.filter(t => t > cutoff);
|
|
248
|
-
this.requestTimestamps.push(now);
|
|
249
|
-
|
|
250
|
-
if (this.requestTimestamps.length > this.maxRequests) {
|
|
251
|
-
if (this.onLimit) {
|
|
252
|
-
try {
|
|
253
|
-
this.onLimit({ count: this.requestTimestamps.length, windowMs: this.windowMs });
|
|
254
|
-
} catch (err) {
|
|
255
|
-
console.error('[Agent Shield] onLimit callback error:', err.message);
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
return {
|
|
259
|
-
allowed: false,
|
|
260
|
-
remaining: 0,
|
|
261
|
-
reason: `Rate limit exceeded: ${this.requestTimestamps.length}/${this.maxRequests} requests in ${this.windowMs / 1000}s`
|
|
262
|
-
};
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
return {
|
|
266
|
-
allowed: true,
|
|
267
|
-
remaining: this.maxRequests - this.requestTimestamps.length
|
|
268
|
-
};
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/**
|
|
272
|
-
* Records threat detections. Flags anomalies if spike detected.
|
|
273
|
-
* @param {number} [count=1] - Number of threats.
|
|
274
|
-
* @returns {object} { anomaly: boolean, threatCount: number }
|
|
275
|
-
*/
|
|
276
|
-
recordThreat(count = 1) {
|
|
277
|
-
const now = Date.now();
|
|
278
|
-
const cutoff = now - this.windowMs;
|
|
279
|
-
|
|
280
|
-
for (let i = 0; i < count; i++) {
|
|
281
|
-
this.threatTimestamps.push(now);
|
|
282
|
-
}
|
|
283
|
-
this.threatTimestamps = this.threatTimestamps.filter(t => t > cutoff);
|
|
284
|
-
|
|
285
|
-
const isAnomaly = this.threatTimestamps.length >= this.maxThreatsPerWindow;
|
|
286
|
-
if (isAnomaly && this.onAnomaly) {
|
|
287
|
-
try {
|
|
288
|
-
this.onAnomaly({ threatCount: this.threatTimestamps.length, windowMs: this.windowMs });
|
|
289
|
-
} catch (err) {
|
|
290
|
-
console.error('[Agent Shield] onAnomaly callback error:', err.message);
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
return {
|
|
295
|
-
anomaly: isAnomaly,
|
|
296
|
-
threatCount: this.threatTimestamps.length
|
|
297
|
-
};
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/**
|
|
301
|
-
* Returns current rate limiter status.
|
|
302
|
-
* @returns {object}
|
|
303
|
-
*/
|
|
304
|
-
getStatus() {
|
|
305
|
-
const now = Date.now();
|
|
306
|
-
const cutoff = now - this.windowMs;
|
|
307
|
-
return {
|
|
308
|
-
requests: this.requestTimestamps.filter(t => t > cutoff).length,
|
|
309
|
-
maxRequests: this.maxRequests,
|
|
310
|
-
threats: this.threatTimestamps.filter(t => t > cutoff).length,
|
|
311
|
-
maxThreatsPerWindow: this.maxThreatsPerWindow
|
|
312
|
-
};
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
reset() {
|
|
316
|
-
this.requestTimestamps = [];
|
|
317
|
-
this.threatTimestamps = [];
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
module.exports = { CircuitBreaker, shadowMode, RateLimiter, STATE };
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Circuit Breaker (#31), Shadow Mode (#33), and Rate Limiting (#5)
|
|
5
|
+
*
|
|
6
|
+
* - Circuit Breaker: Auto-shuts down an agent after too many threats in a time window.
|
|
7
|
+
* - Shadow Mode: Detection-only mode — logs everything, blocks nothing.
|
|
8
|
+
* - Rate Limiting: Tracks input patterns and flags anomalous spikes.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Circuit breaker states.
|
|
13
|
+
*/
|
|
14
|
+
const STATE = {
|
|
15
|
+
CLOSED: 'closed', // Normal operation
|
|
16
|
+
OPEN: 'open', // Tripped — all requests blocked
|
|
17
|
+
HALF_OPEN: 'half_open' // Testing if safe to resume
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
class CircuitBreaker {
|
|
21
|
+
/**
|
|
22
|
+
* @param {object} [options]
|
|
23
|
+
* @param {number} [options.threshold=5] - Number of threats to trip the breaker.
|
|
24
|
+
* @param {number} [options.windowMs=60000] - Time window in ms (default: 1 minute).
|
|
25
|
+
* @param {number} [options.cooldownMs=300000] - Cooldown before half-open (default: 5 minutes).
|
|
26
|
+
* @param {Function} [options.onTrip] - Callback when breaker trips.
|
|
27
|
+
* @param {Function} [options.onReset] - Callback when breaker resets.
|
|
28
|
+
*/
|
|
29
|
+
constructor(options = {}) {
|
|
30
|
+
this.threshold = (options.threshold != null && options.threshold > 0) ? options.threshold : 5;
|
|
31
|
+
this.windowMs = (options.windowMs != null && options.windowMs > 0) ? options.windowMs : 60000;
|
|
32
|
+
this.cooldownMs = (options.cooldownMs != null && options.cooldownMs > 0) ? options.cooldownMs : 300000;
|
|
33
|
+
this.onTrip = options.onTrip || null;
|
|
34
|
+
this.onReset = options.onReset || null;
|
|
35
|
+
|
|
36
|
+
this.state = STATE.CLOSED;
|
|
37
|
+
this.threatTimestamps = [];
|
|
38
|
+
this.trippedAt = null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Records a threat event. Trips the breaker if threshold is exceeded.
|
|
43
|
+
* @param {number} [count=1] - Number of threats to record.
|
|
44
|
+
*/
|
|
45
|
+
recordThreat(count = 1) {
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
for (let i = 0; i < count; i++) {
|
|
48
|
+
this.threatTimestamps.push(now);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Prune old timestamps outside the window
|
|
52
|
+
const cutoff = now - this.windowMs;
|
|
53
|
+
this.threatTimestamps = this.threatTimestamps.filter(t => t > cutoff);
|
|
54
|
+
|
|
55
|
+
if (this.state === STATE.CLOSED && this.threatTimestamps.length >= this.threshold) {
|
|
56
|
+
this._trip();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Checks if the breaker allows a request through.
|
|
62
|
+
* @returns {object} { allowed: boolean, state: string, reason?: string }
|
|
63
|
+
*/
|
|
64
|
+
check() {
|
|
65
|
+
if (this.state === STATE.CLOSED) {
|
|
66
|
+
return { allowed: true, state: this.state };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (this.state === STATE.OPEN) {
|
|
70
|
+
const elapsed = Date.now() - this.trippedAt;
|
|
71
|
+
if (elapsed >= this.cooldownMs) {
|
|
72
|
+
this.state = STATE.HALF_OPEN;
|
|
73
|
+
return { allowed: true, state: this.state, reason: 'Testing after cooldown' };
|
|
74
|
+
}
|
|
75
|
+
const remainingMs = this.cooldownMs - elapsed;
|
|
76
|
+
return {
|
|
77
|
+
allowed: false,
|
|
78
|
+
state: this.state,
|
|
79
|
+
reason: `Circuit breaker tripped. Resumes in ${Math.ceil(remainingMs / 1000)}s.`
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// HALF_OPEN: allow one request to test
|
|
84
|
+
return { allowed: true, state: this.state, reason: 'Half-open test request' };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Reports the result of a half-open test request.
|
|
89
|
+
* @param {boolean} safe - Whether the test request was safe.
|
|
90
|
+
*/
|
|
91
|
+
reportTestResult(safe) {
|
|
92
|
+
if (this.state !== STATE.HALF_OPEN) return;
|
|
93
|
+
|
|
94
|
+
if (safe) {
|
|
95
|
+
this._reset();
|
|
96
|
+
} else {
|
|
97
|
+
this._trip();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** @private */
|
|
102
|
+
_trip() {
|
|
103
|
+
this.state = STATE.OPEN;
|
|
104
|
+
this.trippedAt = Date.now();
|
|
105
|
+
if (this.onTrip) {
|
|
106
|
+
try {
|
|
107
|
+
this.onTrip({
|
|
108
|
+
state: STATE.OPEN,
|
|
109
|
+
threatCount: this.threatTimestamps.length,
|
|
110
|
+
timestamp: this.trippedAt
|
|
111
|
+
});
|
|
112
|
+
} catch (err) {
|
|
113
|
+
console.error('[Agent Shield] onTrip callback error:', err.message);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** @private */
|
|
119
|
+
_reset() {
|
|
120
|
+
this.state = STATE.CLOSED;
|
|
121
|
+
this.threatTimestamps = [];
|
|
122
|
+
this.trippedAt = null;
|
|
123
|
+
if (this.onReset) {
|
|
124
|
+
try {
|
|
125
|
+
this.onReset({ state: STATE.CLOSED, timestamp: Date.now() });
|
|
126
|
+
} catch (err) {
|
|
127
|
+
console.error('[Agent Shield] onReset callback error:', err.message);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Manually reset the breaker.
|
|
134
|
+
*/
|
|
135
|
+
reset() {
|
|
136
|
+
this._reset();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Returns current breaker status.
|
|
141
|
+
* @returns {object}
|
|
142
|
+
*/
|
|
143
|
+
getStatus() {
|
|
144
|
+
return {
|
|
145
|
+
state: this.state,
|
|
146
|
+
recentThreats: this.threatTimestamps.length,
|
|
147
|
+
threshold: this.threshold,
|
|
148
|
+
trippedAt: this.trippedAt
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// =========================================================================
|
|
154
|
+
// SHADOW MODE
|
|
155
|
+
// =========================================================================
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Wraps an AgentShield instance in shadow mode.
|
|
159
|
+
* Logs all detections but never blocks. Perfect for evaluation.
|
|
160
|
+
*
|
|
161
|
+
* @param {object} shield - An AgentShield instance.
|
|
162
|
+
* @param {object} [options]
|
|
163
|
+
* @param {Function} [options.logger] - Custom log function. Defaults to console.log.
|
|
164
|
+
* @returns {object} - A shadow-mode wrapped shield with the same API.
|
|
165
|
+
*/
|
|
166
|
+
const shadowMode = (shield, options = {}) => {
|
|
167
|
+
const logger = options.logger || console.log;
|
|
168
|
+
const log = [];
|
|
169
|
+
|
|
170
|
+
const wrap = (methodName, original) => {
|
|
171
|
+
return function (...args) {
|
|
172
|
+
const result = original.apply(shield, args);
|
|
173
|
+
|
|
174
|
+
// If it's a promise (async methods like from middleware), handle accordingly
|
|
175
|
+
if (result && typeof result.then === 'function') {
|
|
176
|
+
return result.then(res => {
|
|
177
|
+
const entry = { method: methodName, result: res, timestamp: Date.now() };
|
|
178
|
+
log.push(entry);
|
|
179
|
+
if (log.length > 1000) log.shift();
|
|
180
|
+
if (res.threats && res.threats.length > 0) {
|
|
181
|
+
try { logger(`[Agent Shield Shadow] ${methodName}: ${res.threats.length} threat(s) detected (not blocked)`, res.threats.map(t => t.description)); } catch (e) { console.error('[Agent Shield] Shadow mode logger error:', e.message); }
|
|
182
|
+
}
|
|
183
|
+
// Never block in shadow mode
|
|
184
|
+
if ('blocked' in res) res.blocked = false;
|
|
185
|
+
return res;
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const entry = { method: methodName, result, timestamp: Date.now() };
|
|
190
|
+
log.push(entry);
|
|
191
|
+
if (log.length > 1000) log.shift();
|
|
192
|
+
|
|
193
|
+
if (result.threats && result.threats.length > 0) {
|
|
194
|
+
try { logger(`[Agent Shield Shadow] ${methodName}: ${result.threats.length} threat(s) detected (not blocked)`, result.threats.map(t => t.description)); } catch (e) { console.error('[Agent Shield] Shadow mode logger error:', e.message); }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Never block in shadow mode
|
|
198
|
+
if ('blocked' in result) result.blocked = false;
|
|
199
|
+
return result;
|
|
200
|
+
};
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
scan: wrap('scan', shield.scan),
|
|
205
|
+
scanInput: wrap('scanInput', shield.scanInput),
|
|
206
|
+
scanOutput: wrap('scanOutput', shield.scanOutput),
|
|
207
|
+
scanToolCall: wrap('scanToolCall', shield.scanToolCall),
|
|
208
|
+
scanBatch: wrap('scanBatch', shield.scanBatch),
|
|
209
|
+
getStats: () => shield.getStats(),
|
|
210
|
+
getLog: () => [...log],
|
|
211
|
+
isShadowMode: true
|
|
212
|
+
};
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
// =========================================================================
|
|
216
|
+
// RATE LIMITER
|
|
217
|
+
// =========================================================================
|
|
218
|
+
|
|
219
|
+
class RateLimiter {
|
|
220
|
+
/**
|
|
221
|
+
* @param {object} [options]
|
|
222
|
+
* @param {number} [options.maxRequests=100] - Max requests per window.
|
|
223
|
+
* @param {number} [options.windowMs=60000] - Window size in ms.
|
|
224
|
+
* @param {number} [options.maxThreatsPerWindow=10] - Max threats before flagging anomaly.
|
|
225
|
+
* @param {Function} [options.onLimit] - Callback when rate limit hit.
|
|
226
|
+
* @param {Function} [options.onAnomaly] - Callback when anomaly detected.
|
|
227
|
+
*/
|
|
228
|
+
constructor(options = {}) {
|
|
229
|
+
this.maxRequests = (options.maxRequests != null && options.maxRequests > 0) ? options.maxRequests : 100;
|
|
230
|
+
this.windowMs = (options.windowMs != null && options.windowMs > 0) ? options.windowMs : 60000;
|
|
231
|
+
this.maxThreatsPerWindow = (options.maxThreatsPerWindow != null && options.maxThreatsPerWindow > 0) ? options.maxThreatsPerWindow : 10;
|
|
232
|
+
this.onLimit = options.onLimit || null;
|
|
233
|
+
this.onAnomaly = options.onAnomaly || null;
|
|
234
|
+
|
|
235
|
+
this.requestTimestamps = [];
|
|
236
|
+
this.threatTimestamps = [];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Records a request. Returns whether it's allowed.
|
|
241
|
+
* @returns {object} { allowed: boolean, remaining: number, reason?: string }
|
|
242
|
+
*/
|
|
243
|
+
recordRequest() {
|
|
244
|
+
const now = Date.now();
|
|
245
|
+
const cutoff = now - this.windowMs;
|
|
246
|
+
|
|
247
|
+
this.requestTimestamps = this.requestTimestamps.filter(t => t > cutoff);
|
|
248
|
+
this.requestTimestamps.push(now);
|
|
249
|
+
|
|
250
|
+
if (this.requestTimestamps.length > this.maxRequests) {
|
|
251
|
+
if (this.onLimit) {
|
|
252
|
+
try {
|
|
253
|
+
this.onLimit({ count: this.requestTimestamps.length, windowMs: this.windowMs });
|
|
254
|
+
} catch (err) {
|
|
255
|
+
console.error('[Agent Shield] onLimit callback error:', err.message);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
allowed: false,
|
|
260
|
+
remaining: 0,
|
|
261
|
+
reason: `Rate limit exceeded: ${this.requestTimestamps.length}/${this.maxRequests} requests in ${this.windowMs / 1000}s`
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
allowed: true,
|
|
267
|
+
remaining: this.maxRequests - this.requestTimestamps.length
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Records threat detections. Flags anomalies if spike detected.
|
|
273
|
+
* @param {number} [count=1] - Number of threats.
|
|
274
|
+
* @returns {object} { anomaly: boolean, threatCount: number }
|
|
275
|
+
*/
|
|
276
|
+
recordThreat(count = 1) {
|
|
277
|
+
const now = Date.now();
|
|
278
|
+
const cutoff = now - this.windowMs;
|
|
279
|
+
|
|
280
|
+
for (let i = 0; i < count; i++) {
|
|
281
|
+
this.threatTimestamps.push(now);
|
|
282
|
+
}
|
|
283
|
+
this.threatTimestamps = this.threatTimestamps.filter(t => t > cutoff);
|
|
284
|
+
|
|
285
|
+
const isAnomaly = this.threatTimestamps.length >= this.maxThreatsPerWindow;
|
|
286
|
+
if (isAnomaly && this.onAnomaly) {
|
|
287
|
+
try {
|
|
288
|
+
this.onAnomaly({ threatCount: this.threatTimestamps.length, windowMs: this.windowMs });
|
|
289
|
+
} catch (err) {
|
|
290
|
+
console.error('[Agent Shield] onAnomaly callback error:', err.message);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
anomaly: isAnomaly,
|
|
296
|
+
threatCount: this.threatTimestamps.length
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Returns current rate limiter status.
|
|
302
|
+
* @returns {object}
|
|
303
|
+
*/
|
|
304
|
+
getStatus() {
|
|
305
|
+
const now = Date.now();
|
|
306
|
+
const cutoff = now - this.windowMs;
|
|
307
|
+
return {
|
|
308
|
+
requests: this.requestTimestamps.filter(t => t > cutoff).length,
|
|
309
|
+
maxRequests: this.maxRequests,
|
|
310
|
+
threats: this.threatTimestamps.filter(t => t > cutoff).length,
|
|
311
|
+
maxThreatsPerWindow: this.maxThreatsPerWindow
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
reset() {
|
|
316
|
+
this.requestTimestamps = [];
|
|
317
|
+
this.threatTimestamps = [];
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
module.exports = { CircuitBreaker, shadowMode, RateLimiter, STATE };
|
package/src/detector-core.js
CHANGED
|
@@ -130,7 +130,7 @@ const INJECTION_PATTERNS = [
|
|
|
130
130
|
|
|
131
131
|
// --- Role Hijacking ---
|
|
132
132
|
{
|
|
133
|
-
regex: /you\s+are\s+now\s+(?:a|an|the)\s
|
|
133
|
+
regex: /you\s+are\s+now\s+(?:(?:a|an|the)\s+)?(?:unrestricted|unfiltered|uncensored|evil|hacker|jailbroken|different|new\s+(?:ai|assistant|entity|agent|persona)|my\s+(?:personal|private|new)|free\s+(?:from|of)|without\s+(?:restrictions|limits|rules|filters))/i,
|
|
134
134
|
severity: 'high',
|
|
135
135
|
category: 'role_hijack',
|
|
136
136
|
description: 'Text tries to change what an AI assistant thinks it is.',
|
|
@@ -840,7 +840,7 @@ const INJECTION_PATTERNS = [
|
|
|
840
840
|
detail: 'System file access: attempts to read sensitive OS-level files.'
|
|
841
841
|
},
|
|
842
842
|
{
|
|
843
|
-
regex: /list\s+(?:all\s+)?(?:available\s+)?(?:API\s*keys?|tokens?|passwords?|credentials?|secrets?)(?:\s
|
|
843
|
+
regex: /list\s+(?:all\s+)?(?:available\s+)?(?:API\s*keys?|tokens?|passwords?|credentials?|secrets?)(?:\s*,\s*\w[\w\s]*){0,5}(?:\s+(?:you\s+)?(?:have\s+)?(?:access\s+to)?)?/i,
|
|
844
844
|
severity: 'critical',
|
|
845
845
|
category: 'data_exfiltration',
|
|
846
846
|
description: 'Text tries to enumerate all credentials and secrets the agent can access.',
|
|
@@ -1925,7 +1925,7 @@ const scanText = (text, options = {}) => {
|
|
|
1925
1925
|
const maxSize = options.maxInputSize || MAX_INPUT_SIZE;
|
|
1926
1926
|
const startTime = now();
|
|
1927
1927
|
|
|
1928
|
-
if (typeof text !== 'string' || text.length
|
|
1928
|
+
if (typeof text !== 'string' || text.length === 0 || text.trim().length === 0) {
|
|
1929
1929
|
return {
|
|
1930
1930
|
status: 'safe',
|
|
1931
1931
|
threats: [],
|