agentshield-sdk 8.0.0 → 10.0.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 +19 -0
- package/LICENSE +21 -21
- package/README.md +26 -60
- package/bin/agentshield-audit +51 -0
- package/package.json +7 -10
- package/src/adaptive.js +330 -330
- package/src/alert-tuning.js +480 -480
- package/src/audit-streaming.js +1 -1
- package/src/badges.js +196 -196
- package/src/behavioral-dna.js +12 -0
- package/src/canary.js +2 -3
- package/src/certification.js +563 -563
- package/src/circuit-breaker.js +2 -2
- package/src/confused-deputy.js +4 -0
- package/src/conversation.js +494 -494
- package/src/cross-turn.js +3 -17
- package/src/ctf.js +462 -462
- package/src/detector-core.js +71 -152
- package/src/document-scanner.js +795 -795
- package/src/drift-monitor.js +344 -0
- package/src/encoding.js +429 -429
- package/src/enterprise.js +405 -405
- package/src/flight-recorder.js +2 -0
- package/src/i18n-patterns.js +523 -523
- package/src/index.js +19 -0
- package/src/main.js +61 -41
- package/src/mcp-guard.js +974 -0
- package/src/micro-model.js +762 -0
- package/src/ml-detector.js +316 -0
- package/src/model-finetuning.js +884 -884
- package/src/multimodal.js +296 -296
- package/src/nist-mapping.js +2 -2
- package/src/observability.js +330 -330
- package/src/openclaw.js +450 -450
- package/src/otel.js +544 -544
- package/src/owasp-2025.js +1 -1
- package/src/owasp-agentic.js +420 -0
- package/src/plugin-marketplace.js +628 -628
- package/src/plugin-system.js +349 -349
- package/src/policy-extended.js +635 -635
- package/src/policy.js +443 -443
- package/src/prompt-leakage.js +2 -2
- package/src/real-attack-datasets.js +2 -2
- package/src/redteam-cli.js +439 -0
- package/src/supply-chain-scanner.js +691 -0
- package/src/testing.js +5 -1
- package/src/threat-encyclopedia.js +629 -629
- package/src/threat-intel-network.js +1017 -1017
- package/src/token-analysis.js +467 -467
- package/src/tool-output-validator.js +354 -354
- package/src/watermark.js +1 -2
package/src/mcp-guard.js
ADDED
|
@@ -0,0 +1,974 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Agent Shield — MCP Guard
|
|
5
|
+
*
|
|
6
|
+
* Drop-in MCP security middleware. Protects MCP connections with:
|
|
7
|
+
* - Server attestation: hash tool definitions on first connect, alert if they change
|
|
8
|
+
* - Input/output scanning via detector-core on all tool I/O
|
|
9
|
+
* - Cross-server isolation (prevent one server's tools from manipulating another's)
|
|
10
|
+
* - OAuth enforcement layer (reject unauthenticated connections)
|
|
11
|
+
* - Per-server rate limiting with circuit breaker
|
|
12
|
+
* - Behavioral baseline per tool (track normal usage, alert on deviation)
|
|
13
|
+
*
|
|
14
|
+
* All detection runs locally — no data ever leaves your environment.
|
|
15
|
+
*
|
|
16
|
+
* @module mcp-guard
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const crypto = require('crypto');
|
|
20
|
+
const { scanText } = require('./detector-core');
|
|
21
|
+
|
|
22
|
+
let MicroModel = null;
|
|
23
|
+
try { MicroModel = require('./micro-model').MicroModel; } catch { /* optional */ }
|
|
24
|
+
|
|
25
|
+
// =========================================================================
|
|
26
|
+
// CONSTANTS
|
|
27
|
+
// =========================================================================
|
|
28
|
+
|
|
29
|
+
/** Default rate limit: max tool calls per server per minute. */
|
|
30
|
+
const DEFAULT_RATE_LIMIT = 60;
|
|
31
|
+
|
|
32
|
+
/** Default circuit breaker threshold (threats before tripping). */
|
|
33
|
+
const DEFAULT_CB_THRESHOLD = 5;
|
|
34
|
+
|
|
35
|
+
/** Default circuit breaker cooldown in ms (5 minutes). */
|
|
36
|
+
const DEFAULT_CB_COOLDOWN_MS = 300000;
|
|
37
|
+
|
|
38
|
+
/** Default baseline window size (number of observations to keep). */
|
|
39
|
+
const DEFAULT_BASELINE_WINDOW = 100;
|
|
40
|
+
|
|
41
|
+
/** SSRF target patterns — private IPs, cloud metadata endpoints.
|
|
42
|
+
* Ref: CVE-2026-26118, 36.7% of MCP servers vulnerable. */
|
|
43
|
+
const SSRF_BLOCKLIST = [
|
|
44
|
+
/169\.254\.169\.254/, // AWS/Azure metadata
|
|
45
|
+
/metadata\.google\.internal/, // GCP metadata
|
|
46
|
+
/metadata\.aws\.internal/, // AWS metadata (alt)
|
|
47
|
+
/100\.100\.100\.200/, // Alibaba Cloud metadata
|
|
48
|
+
/(?:^|\/)(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3})/, // 10.x.x.x
|
|
49
|
+
/(?:^|\/)(?:172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3})/, // 172.16-31.x.x
|
|
50
|
+
/(?:^|\/)(?:192\.168\.\d{1,3}\.\d{1,3})/, // 192.168.x.x
|
|
51
|
+
/(?:^|\/)(?:127\.0\.0\.1|0\.0\.0\.0|localhost)/, // loopback
|
|
52
|
+
/(?:^|\/)(?:::1|0:0:0:0:0:0:0:1)/ // IPv6 loopback
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
/** Z-score threshold for behavioral anomaly. */
|
|
56
|
+
const DEFAULT_Z_THRESHOLD = 3.0;
|
|
57
|
+
|
|
58
|
+
// =========================================================================
|
|
59
|
+
// HELPERS
|
|
60
|
+
// =========================================================================
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* SHA-256 hash of a JSON-serializable value.
|
|
64
|
+
* @param {*} value
|
|
65
|
+
* @returns {string} Hex-encoded hash.
|
|
66
|
+
*/
|
|
67
|
+
function sha256(value) {
|
|
68
|
+
const str = typeof value === 'string' ? value : JSON.stringify(value);
|
|
69
|
+
return crypto.createHash('sha256').update(str).digest('hex');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Calculate mean of a numeric array.
|
|
74
|
+
* @param {number[]} arr
|
|
75
|
+
* @returns {number}
|
|
76
|
+
*/
|
|
77
|
+
function mean(arr) {
|
|
78
|
+
return arr.length > 0 ? arr.reduce((a, b) => a + b, 0) / arr.length : 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Calculate sample standard deviation.
|
|
83
|
+
* @param {number[]} arr
|
|
84
|
+
* @returns {number}
|
|
85
|
+
*/
|
|
86
|
+
function stdDev(arr) {
|
|
87
|
+
if (arr.length < 2) return 0;
|
|
88
|
+
const m = mean(arr);
|
|
89
|
+
return Math.sqrt(arr.reduce((s, x) => s + (x - m) ** 2, 0) / (arr.length - 1));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Calculate z-score for a value.
|
|
94
|
+
* @param {number} value
|
|
95
|
+
* @param {number} m - Mean.
|
|
96
|
+
* @param {number} sd - Standard deviation.
|
|
97
|
+
* @returns {number}
|
|
98
|
+
*/
|
|
99
|
+
function zScore(value, m, sd) {
|
|
100
|
+
if (sd === 0) return value === m ? 0 : Infinity;
|
|
101
|
+
return (value - m) / sd;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// =========================================================================
|
|
105
|
+
// ServerAttestation
|
|
106
|
+
// =========================================================================
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Tracks SHA-256 fingerprints of MCP server tool definitions.
|
|
110
|
+
* Detects the "rugpull" attack where tool definitions change between sessions
|
|
111
|
+
* (e.g. the Postmark-style attack).
|
|
112
|
+
*/
|
|
113
|
+
class ServerAttestation {
|
|
114
|
+
constructor() {
|
|
115
|
+
/** @type {Map<string, { hash: string, tools: object, attestedAt: number }>} */
|
|
116
|
+
this.registry = new Map();
|
|
117
|
+
/** @type {Array<object>} */
|
|
118
|
+
this.alerts = [];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Attest a server's tool definitions. On first call, records the hash.
|
|
123
|
+
* On subsequent calls, compares against the stored hash.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} serverId - Unique server identifier.
|
|
126
|
+
* @param {object} toolDefinitions - The server's tool definitions object.
|
|
127
|
+
* @returns {{ trusted: boolean, hash: string, changed: boolean, alert: object|null }}
|
|
128
|
+
*/
|
|
129
|
+
attest(serverId, toolDefinitions) {
|
|
130
|
+
const hash = sha256(toolDefinitions);
|
|
131
|
+
const existing = this.registry.get(serverId);
|
|
132
|
+
|
|
133
|
+
if (!existing) {
|
|
134
|
+
this.registry.set(serverId, {
|
|
135
|
+
hash,
|
|
136
|
+
tools: JSON.parse(JSON.stringify(toolDefinitions)),
|
|
137
|
+
attestedAt: Date.now()
|
|
138
|
+
});
|
|
139
|
+
return { trusted: true, hash, changed: false, alert: null };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (existing.hash === hash) {
|
|
143
|
+
return { trusted: true, hash, changed: false, alert: null };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Tool definitions changed — potential rugpull
|
|
147
|
+
const alert = {
|
|
148
|
+
type: 'tool_definition_change',
|
|
149
|
+
severity: 'critical',
|
|
150
|
+
serverId,
|
|
151
|
+
previousHash: existing.hash,
|
|
152
|
+
currentHash: hash,
|
|
153
|
+
timestamp: Date.now(),
|
|
154
|
+
description: `Server "${serverId}" tool definitions changed. Previous hash: ${existing.hash.substring(0, 12)}... Current: ${hash.substring(0, 12)}... Possible rugpull attack.`
|
|
155
|
+
};
|
|
156
|
+
this.alerts.push(alert);
|
|
157
|
+
|
|
158
|
+
return { trusted: false, hash, changed: true, alert };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Force-update a server's attestation (after manual review).
|
|
163
|
+
* @param {string} serverId
|
|
164
|
+
* @param {object} toolDefinitions
|
|
165
|
+
*/
|
|
166
|
+
update(serverId, toolDefinitions) {
|
|
167
|
+
const hash = sha256(toolDefinitions);
|
|
168
|
+
this.registry.set(serverId, {
|
|
169
|
+
hash,
|
|
170
|
+
tools: JSON.parse(JSON.stringify(toolDefinitions)),
|
|
171
|
+
attestedAt: Date.now()
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Get the stored attestation for a server.
|
|
177
|
+
* @param {string} serverId
|
|
178
|
+
* @returns {object|null}
|
|
179
|
+
*/
|
|
180
|
+
get(serverId) {
|
|
181
|
+
return this.registry.get(serverId) || null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Get all alerts.
|
|
186
|
+
* @returns {Array<object>}
|
|
187
|
+
*/
|
|
188
|
+
getAlerts() {
|
|
189
|
+
return [...this.alerts];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Clear alerts.
|
|
194
|
+
*/
|
|
195
|
+
clearAlerts() {
|
|
196
|
+
this.alerts = [];
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// =========================================================================
|
|
201
|
+
// CrossServerIsolation
|
|
202
|
+
// =========================================================================
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Prevents one MCP server's tools from accessing or manipulating
|
|
206
|
+
* another server's context, data, or tool calls.
|
|
207
|
+
*/
|
|
208
|
+
class CrossServerIsolation {
|
|
209
|
+
constructor() {
|
|
210
|
+
/** @type {Map<string, Set<string>>} serverId -> set of tool names */
|
|
211
|
+
this.serverTools = new Map();
|
|
212
|
+
/** @type {Map<string, string>} toolName -> serverId */
|
|
213
|
+
this.toolOwnership = new Map();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Register a server and its tools.
|
|
218
|
+
* @param {string} serverId
|
|
219
|
+
* @param {string[]} toolNames
|
|
220
|
+
*/
|
|
221
|
+
registerServer(serverId, toolNames) {
|
|
222
|
+
this.serverTools.set(serverId, new Set(toolNames));
|
|
223
|
+
for (const name of toolNames) {
|
|
224
|
+
this.toolOwnership.set(name, serverId);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Validate that a tool call from a given server context doesn't
|
|
230
|
+
* reference tools owned by another server.
|
|
231
|
+
*
|
|
232
|
+
* @param {string} callingServerId - The server context making the call.
|
|
233
|
+
* @param {string} toolName - The tool being called.
|
|
234
|
+
* @param {*} args - Tool arguments (scanned for cross-server references).
|
|
235
|
+
* @returns {{ allowed: boolean, violation: object|null }}
|
|
236
|
+
*/
|
|
237
|
+
validate(callingServerId, toolName, args) {
|
|
238
|
+
const owner = this.toolOwnership.get(toolName);
|
|
239
|
+
|
|
240
|
+
// Unknown tool — allow (not our concern)
|
|
241
|
+
if (!owner) {
|
|
242
|
+
return { allowed: true, violation: null };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Tool belongs to a different server
|
|
246
|
+
if (owner !== callingServerId) {
|
|
247
|
+
return {
|
|
248
|
+
allowed: false,
|
|
249
|
+
violation: {
|
|
250
|
+
type: 'cross_server_access',
|
|
251
|
+
severity: 'high',
|
|
252
|
+
callingServer: callingServerId,
|
|
253
|
+
toolOwner: owner,
|
|
254
|
+
toolName,
|
|
255
|
+
description: `Server "${callingServerId}" attempted to call tool "${toolName}" owned by server "${owner}".`
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Check args for references to other servers' tools
|
|
261
|
+
const argsStr = typeof args === 'string' ? args : JSON.stringify(args || {});
|
|
262
|
+
for (const [otherServer, tools] of this.serverTools) {
|
|
263
|
+
if (otherServer === callingServerId) continue;
|
|
264
|
+
for (const otherTool of tools) {
|
|
265
|
+
if (argsStr.includes(otherTool)) {
|
|
266
|
+
return {
|
|
267
|
+
allowed: false,
|
|
268
|
+
violation: {
|
|
269
|
+
type: 'cross_server_reference',
|
|
270
|
+
severity: 'medium',
|
|
271
|
+
callingServer: callingServerId,
|
|
272
|
+
referencedServer: otherServer,
|
|
273
|
+
referencedTool: otherTool,
|
|
274
|
+
toolName,
|
|
275
|
+
description: `Tool "${toolName}" arguments reference tool "${otherTool}" from server "${otherServer}".`
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return { allowed: true, violation: null };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Get the owning server for a tool.
|
|
287
|
+
* @param {string} toolName
|
|
288
|
+
* @returns {string|null}
|
|
289
|
+
*/
|
|
290
|
+
getOwner(toolName) {
|
|
291
|
+
return this.toolOwnership.get(toolName) || null;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// =========================================================================
|
|
296
|
+
// OAuthEnforcer
|
|
297
|
+
// =========================================================================
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Enforces OAuth authentication on MCP connections.
|
|
301
|
+
* Rejects unauthenticated or expired connections.
|
|
302
|
+
*/
|
|
303
|
+
class OAuthEnforcer {
|
|
304
|
+
/**
|
|
305
|
+
* @param {object} [options]
|
|
306
|
+
* @param {boolean} [options.required=true] - Whether OAuth is required.
|
|
307
|
+
* @param {string[]} [options.allowedIssuers=[]] - Allowed token issuers.
|
|
308
|
+
* @param {string[]} [options.requiredScopes=[]] - Required OAuth scopes.
|
|
309
|
+
* @param {number} [options.clockSkewMs=30000] - Allowed clock skew in ms.
|
|
310
|
+
*/
|
|
311
|
+
constructor(options = {}) {
|
|
312
|
+
this.required = options.required !== false;
|
|
313
|
+
this.allowedIssuers = new Set(options.allowedIssuers || []);
|
|
314
|
+
this.requiredScopes = options.requiredScopes || [];
|
|
315
|
+
this.clockSkewMs = options.clockSkewMs || 30000;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Validate an OAuth token object.
|
|
320
|
+
*
|
|
321
|
+
* @param {object|null} token - Token with { sub, iss, exp, scopes, ... }
|
|
322
|
+
* @returns {{ authenticated: boolean, reason: string|null }}
|
|
323
|
+
*/
|
|
324
|
+
validate(token) {
|
|
325
|
+
if (!token) {
|
|
326
|
+
if (!this.required) {
|
|
327
|
+
return { authenticated: true, reason: null };
|
|
328
|
+
}
|
|
329
|
+
return { authenticated: false, reason: 'No authentication token provided.' };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Check expiration
|
|
333
|
+
if (token.exp) {
|
|
334
|
+
const now = Date.now();
|
|
335
|
+
const expMs = typeof token.exp === 'number' && token.exp < 1e12
|
|
336
|
+
? token.exp * 1000 // Unix seconds -> ms
|
|
337
|
+
: token.exp;
|
|
338
|
+
if (now > expMs + this.clockSkewMs) {
|
|
339
|
+
return { authenticated: false, reason: 'Token has expired.' };
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Check issuer
|
|
344
|
+
if (this.allowedIssuers.size > 0 && token.iss) {
|
|
345
|
+
if (!this.allowedIssuers.has(token.iss)) {
|
|
346
|
+
return { authenticated: false, reason: `Issuer "${token.iss}" is not allowed.` };
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Check scopes
|
|
351
|
+
const tokenScopes = token.scopes || token.scope
|
|
352
|
+
? (Array.isArray(token.scopes) ? token.scopes : (token.scope || '').split(' '))
|
|
353
|
+
: [];
|
|
354
|
+
for (const required of this.requiredScopes) {
|
|
355
|
+
if (!tokenScopes.includes(required)) {
|
|
356
|
+
return { authenticated: false, reason: `Missing required scope: "${required}".` };
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return { authenticated: true, reason: null };
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// =========================================================================
|
|
365
|
+
// ToolBehaviorBaseline
|
|
366
|
+
// =========================================================================
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Tracks behavioral baselines per tool: call frequency, argument length,
|
|
370
|
+
* response time, error rate. Alerts when current behavior deviates.
|
|
371
|
+
*/
|
|
372
|
+
class ToolBehaviorBaseline {
|
|
373
|
+
/**
|
|
374
|
+
* @param {object} [options]
|
|
375
|
+
* @param {number} [options.windowSize=100] - Number of observations to keep.
|
|
376
|
+
* @param {number} [options.zThreshold=3.0] - Z-score threshold for anomaly.
|
|
377
|
+
*/
|
|
378
|
+
constructor(options = {}) {
|
|
379
|
+
this.windowSize = options.windowSize || DEFAULT_BASELINE_WINDOW;
|
|
380
|
+
this.zThreshold = options.zThreshold || DEFAULT_Z_THRESHOLD;
|
|
381
|
+
/** @type {Map<string, { argLengths: number[], responseTimes: number[], errorCount: number, callCount: number, callTimestamps: number[] }>} */
|
|
382
|
+
this.baselines = new Map();
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Record a tool call observation.
|
|
387
|
+
*
|
|
388
|
+
* @param {string} toolName
|
|
389
|
+
* @param {object} observation
|
|
390
|
+
* @param {number} [observation.argLength] - Length of serialized arguments.
|
|
391
|
+
* @param {number} [observation.responseTimeMs] - Response time in ms.
|
|
392
|
+
* @param {boolean} [observation.isError] - Whether the call resulted in an error.
|
|
393
|
+
* @returns {{ anomalies: Array<object> }}
|
|
394
|
+
*/
|
|
395
|
+
record(toolName, observation = {}) {
|
|
396
|
+
if (!this.baselines.has(toolName)) {
|
|
397
|
+
this.baselines.set(toolName, {
|
|
398
|
+
argLengths: [],
|
|
399
|
+
responseTimes: [],
|
|
400
|
+
errorCount: 0,
|
|
401
|
+
callCount: 0,
|
|
402
|
+
callTimestamps: []
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const baseline = this.baselines.get(toolName);
|
|
407
|
+
const anomalies = [];
|
|
408
|
+
|
|
409
|
+
baseline.callCount++;
|
|
410
|
+
baseline.callTimestamps.push(Date.now());
|
|
411
|
+
|
|
412
|
+
// Trim to window
|
|
413
|
+
if (baseline.callTimestamps.length > this.windowSize) {
|
|
414
|
+
baseline.callTimestamps = baseline.callTimestamps.slice(-this.windowSize);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Argument length
|
|
418
|
+
if (observation.argLength != null) {
|
|
419
|
+
const z = this._checkAnomaly(baseline.argLengths, observation.argLength);
|
|
420
|
+
if (z !== null) {
|
|
421
|
+
anomalies.push({
|
|
422
|
+
type: 'unusual_arg_length',
|
|
423
|
+
toolName,
|
|
424
|
+
severity: 'medium',
|
|
425
|
+
zScore: z,
|
|
426
|
+
value: observation.argLength,
|
|
427
|
+
mean: mean(baseline.argLengths),
|
|
428
|
+
description: `Tool "${toolName}" argument length (${observation.argLength}) deviates from baseline (z=${z.toFixed(2)}).`
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
baseline.argLengths.push(observation.argLength);
|
|
432
|
+
if (baseline.argLengths.length > this.windowSize) {
|
|
433
|
+
baseline.argLengths = baseline.argLengths.slice(-this.windowSize);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Response time
|
|
438
|
+
if (observation.responseTimeMs != null) {
|
|
439
|
+
const z = this._checkAnomaly(baseline.responseTimes, observation.responseTimeMs);
|
|
440
|
+
if (z !== null) {
|
|
441
|
+
anomalies.push({
|
|
442
|
+
type: 'unusual_response_time',
|
|
443
|
+
toolName,
|
|
444
|
+
severity: 'low',
|
|
445
|
+
zScore: z,
|
|
446
|
+
value: observation.responseTimeMs,
|
|
447
|
+
mean: mean(baseline.responseTimes),
|
|
448
|
+
description: `Tool "${toolName}" response time (${observation.responseTimeMs}ms) deviates from baseline (z=${z.toFixed(2)}).`
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
baseline.responseTimes.push(observation.responseTimeMs);
|
|
452
|
+
if (baseline.responseTimes.length > this.windowSize) {
|
|
453
|
+
baseline.responseTimes = baseline.responseTimes.slice(-this.windowSize);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Error
|
|
458
|
+
if (observation.isError) {
|
|
459
|
+
baseline.errorCount++;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return { anomalies };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Get the baseline stats for a tool.
|
|
467
|
+
* @param {string} toolName
|
|
468
|
+
* @returns {object|null}
|
|
469
|
+
*/
|
|
470
|
+
getBaseline(toolName) {
|
|
471
|
+
const b = this.baselines.get(toolName);
|
|
472
|
+
if (!b) return null;
|
|
473
|
+
return {
|
|
474
|
+
callCount: b.callCount,
|
|
475
|
+
errorCount: b.errorCount,
|
|
476
|
+
errorRate: b.callCount > 0 ? b.errorCount / b.callCount : 0,
|
|
477
|
+
avgArgLength: mean(b.argLengths),
|
|
478
|
+
avgResponseTime: mean(b.responseTimes),
|
|
479
|
+
stdArgLength: stdDev(b.argLengths),
|
|
480
|
+
stdResponseTime: stdDev(b.responseTimes)
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Check if a value is anomalous compared to historical data.
|
|
486
|
+
* @param {number[]} history
|
|
487
|
+
* @param {number} value
|
|
488
|
+
* @returns {number|null} Z-score if anomalous, null otherwise.
|
|
489
|
+
* @private
|
|
490
|
+
*/
|
|
491
|
+
_checkAnomaly(history, value) {
|
|
492
|
+
if (history.length < 5) return null; // Not enough data
|
|
493
|
+
const m = mean(history);
|
|
494
|
+
const sd = stdDev(history);
|
|
495
|
+
const z = zScore(value, m, sd);
|
|
496
|
+
return Math.abs(z) >= this.zThreshold ? z : null;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// =========================================================================
|
|
501
|
+
// MCPGuard — Main class
|
|
502
|
+
// =========================================================================
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Drop-in MCP security middleware. Wraps MCP server connections with
|
|
506
|
+
* attestation, scanning, isolation, auth, rate limiting, and behavioral
|
|
507
|
+
* baselines.
|
|
508
|
+
*/
|
|
509
|
+
class MCPGuard {
|
|
510
|
+
/**
|
|
511
|
+
* @param {object} [options]
|
|
512
|
+
* @param {boolean} [options.requireAuth=false] - Require OAuth tokens.
|
|
513
|
+
* @param {string[]} [options.allowedIssuers] - Allowed OAuth issuers.
|
|
514
|
+
* @param {string[]} [options.requiredScopes] - Required OAuth scopes.
|
|
515
|
+
* @param {number} [options.rateLimit=60] - Max calls per server per minute.
|
|
516
|
+
* @param {number} [options.cbThreshold=5] - Circuit breaker threat threshold.
|
|
517
|
+
* @param {number} [options.cbCooldownMs=300000] - Circuit breaker cooldown.
|
|
518
|
+
* @param {number} [options.baselineWindow=100] - Behavioral baseline window.
|
|
519
|
+
* @param {number} [options.zThreshold=3.0] - Z-score anomaly threshold.
|
|
520
|
+
* @param {Function} [options.onAlert] - Callback for alerts: (alert) => void.
|
|
521
|
+
* @param {Function} [options.scanner] - Custom scan function.
|
|
522
|
+
*/
|
|
523
|
+
constructor(options = {}) {
|
|
524
|
+
this.attestation = new ServerAttestation();
|
|
525
|
+
this.isolation = new CrossServerIsolation();
|
|
526
|
+
this.oauth = new OAuthEnforcer({
|
|
527
|
+
required: options.requireAuth || false,
|
|
528
|
+
allowedIssuers: options.allowedIssuers,
|
|
529
|
+
requiredScopes: options.requiredScopes
|
|
530
|
+
});
|
|
531
|
+
this.baselines = new ToolBehaviorBaseline({
|
|
532
|
+
windowSize: options.baselineWindow || DEFAULT_BASELINE_WINDOW,
|
|
533
|
+
zThreshold: options.zThreshold || DEFAULT_Z_THRESHOLD
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
this.rateLimit = options.rateLimit || DEFAULT_RATE_LIMIT;
|
|
537
|
+
this.cbThreshold = options.cbThreshold || DEFAULT_CB_THRESHOLD;
|
|
538
|
+
this.cbCooldownMs = options.cbCooldownMs || DEFAULT_CB_COOLDOWN_MS;
|
|
539
|
+
this.onAlert = options.onAlert || null;
|
|
540
|
+
this.scanner = options.scanner || ((text) => scanText(text));
|
|
541
|
+
this.microModel = options.enableMicroModel && MicroModel ? new MicroModel() : null;
|
|
542
|
+
|
|
543
|
+
/** @type {Map<string, { timestamps: number[], threatCount: number, trippedAt: number|null }>} */
|
|
544
|
+
this.serverState = new Map();
|
|
545
|
+
|
|
546
|
+
/** @type {Array<object>} */
|
|
547
|
+
this.auditLog = [];
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// -----------------------------------------------------------------------
|
|
551
|
+
// Server lifecycle
|
|
552
|
+
// -----------------------------------------------------------------------
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Register an MCP server connection. Attests tool definitions and
|
|
556
|
+
* sets up isolation boundaries.
|
|
557
|
+
*
|
|
558
|
+
* @param {string} serverId - Unique server identifier.
|
|
559
|
+
* @param {object} toolDefinitions - The server's tool definitions.
|
|
560
|
+
* @param {object} [authToken] - OAuth token for authentication.
|
|
561
|
+
* @returns {{ allowed: boolean, attestation: object, auth: object, threats: Array<object> }}
|
|
562
|
+
*/
|
|
563
|
+
registerServer(serverId, toolDefinitions, authToken) {
|
|
564
|
+
const threats = [];
|
|
565
|
+
|
|
566
|
+
// Auth check
|
|
567
|
+
const auth = this.oauth.validate(authToken || null);
|
|
568
|
+
if (!auth.authenticated) {
|
|
569
|
+
threats.push({
|
|
570
|
+
type: 'auth_failure',
|
|
571
|
+
severity: 'critical',
|
|
572
|
+
serverId,
|
|
573
|
+
description: auth.reason
|
|
574
|
+
});
|
|
575
|
+
this._log('register_blocked', serverId, { reason: auth.reason });
|
|
576
|
+
return { allowed: false, attestation: null, auth, threats };
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Attest tool definitions
|
|
580
|
+
const attestResult = this.attestation.attest(serverId, toolDefinitions);
|
|
581
|
+
if (!attestResult.trusted) {
|
|
582
|
+
threats.push(attestResult.alert);
|
|
583
|
+
this._emitAlert(attestResult.alert);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Register tools for isolation
|
|
587
|
+
const toolNames = this._extractToolNames(toolDefinitions);
|
|
588
|
+
this.isolation.registerServer(serverId, toolNames);
|
|
589
|
+
|
|
590
|
+
// Initialize rate limiter state
|
|
591
|
+
if (!this.serverState.has(serverId)) {
|
|
592
|
+
this.serverState.set(serverId, {
|
|
593
|
+
timestamps: [],
|
|
594
|
+
threatCount: 0,
|
|
595
|
+
trippedAt: null
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
this._log('server_registered', serverId, {
|
|
600
|
+
toolCount: toolNames.length,
|
|
601
|
+
hash: attestResult.hash,
|
|
602
|
+
changed: attestResult.changed
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
return {
|
|
606
|
+
allowed: threats.length === 0,
|
|
607
|
+
attestation: attestResult,
|
|
608
|
+
auth,
|
|
609
|
+
threats
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// -----------------------------------------------------------------------
|
|
614
|
+
// Tool call interception
|
|
615
|
+
// -----------------------------------------------------------------------
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Intercept and validate a tool call before execution.
|
|
619
|
+
*
|
|
620
|
+
* @param {string} serverId - The server context.
|
|
621
|
+
* @param {string} toolName - Tool being called.
|
|
622
|
+
* @param {*} args - Tool arguments.
|
|
623
|
+
* @returns {{ allowed: boolean, threats: Array<object>, anomalies: Array<object> }}
|
|
624
|
+
*/
|
|
625
|
+
interceptToolCall(serverId, toolName, args) {
|
|
626
|
+
const threats = [];
|
|
627
|
+
const anomalies = [];
|
|
628
|
+
|
|
629
|
+
// Circuit breaker check
|
|
630
|
+
const cbCheck = this._checkCircuitBreaker(serverId);
|
|
631
|
+
if (!cbCheck.allowed) {
|
|
632
|
+
threats.push({
|
|
633
|
+
type: 'circuit_breaker_open',
|
|
634
|
+
severity: 'critical',
|
|
635
|
+
serverId,
|
|
636
|
+
toolName,
|
|
637
|
+
description: `Circuit breaker is open for server "${serverId}". Too many threats detected.`
|
|
638
|
+
});
|
|
639
|
+
return { allowed: false, threats, anomalies };
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// Rate limit check
|
|
643
|
+
const rlCheck = this._checkRateLimit(serverId);
|
|
644
|
+
if (!rlCheck.allowed) {
|
|
645
|
+
threats.push({
|
|
646
|
+
type: 'rate_limit_exceeded',
|
|
647
|
+
severity: 'high',
|
|
648
|
+
serverId,
|
|
649
|
+
toolName,
|
|
650
|
+
description: `Server "${serverId}" exceeded rate limit of ${this.rateLimit} calls/minute.`
|
|
651
|
+
});
|
|
652
|
+
return { allowed: false, threats, anomalies };
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Cross-server isolation check
|
|
656
|
+
const isoCheck = this.isolation.validate(serverId, toolName, args);
|
|
657
|
+
if (!isoCheck.allowed) {
|
|
658
|
+
threats.push(isoCheck.violation);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Scan input
|
|
662
|
+
const argsStr = typeof args === 'string' ? args : JSON.stringify(args || {});
|
|
663
|
+
const scanResult = this.scanner(argsStr);
|
|
664
|
+
if (scanResult.threats && scanResult.threats.length > 0) {
|
|
665
|
+
for (const t of scanResult.threats) {
|
|
666
|
+
threats.push({
|
|
667
|
+
type: 'input_injection',
|
|
668
|
+
severity: t.severity || 'high',
|
|
669
|
+
serverId,
|
|
670
|
+
toolName,
|
|
671
|
+
category: t.category,
|
|
672
|
+
description: t.description || 'Threat detected in tool call arguments.'
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// SSRF firewall — block private IPs and cloud metadata endpoints
|
|
678
|
+
const urls = argsStr.match(/https?:\/\/[^\s"'}\]]+/gi) || [];
|
|
679
|
+
for (const url of urls) {
|
|
680
|
+
for (const pattern of SSRF_BLOCKLIST) {
|
|
681
|
+
if (pattern.test(url)) {
|
|
682
|
+
threats.push({
|
|
683
|
+
type: 'ssrf_blocked',
|
|
684
|
+
severity: 'critical',
|
|
685
|
+
serverId,
|
|
686
|
+
toolName,
|
|
687
|
+
description: `Blocked SSRF attempt targeting "${url.substring(0, 100)}". Private IPs and cloud metadata endpoints are not allowed (ref CVE-2026-26118).`
|
|
688
|
+
});
|
|
689
|
+
break;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Micro-model secondary scan
|
|
695
|
+
if (this.microModel) {
|
|
696
|
+
const modelResult = this.microModel.scan(argsStr);
|
|
697
|
+
if (modelResult.threats && modelResult.threats.length > 0) {
|
|
698
|
+
for (const t of modelResult.threats) {
|
|
699
|
+
threats.push({
|
|
700
|
+
type: 'micro_model_input',
|
|
701
|
+
severity: t.severity || 'high',
|
|
702
|
+
serverId,
|
|
703
|
+
toolName,
|
|
704
|
+
category: t.category,
|
|
705
|
+
confidence: t.confidence,
|
|
706
|
+
description: t.description || 'Micro-model detected threat in tool call arguments.'
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Record behavioral observation
|
|
713
|
+
const behaviorResult = this.baselines.record(toolName, {
|
|
714
|
+
argLength: argsStr.length
|
|
715
|
+
});
|
|
716
|
+
anomalies.push(...behaviorResult.anomalies);
|
|
717
|
+
|
|
718
|
+
// Update threat count for circuit breaker
|
|
719
|
+
if (threats.length > 0) {
|
|
720
|
+
this._recordThreats(serverId, threats.length);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
this._log('tool_call', serverId, { toolName, allowed: threats.length === 0, threatCount: threats.length });
|
|
724
|
+
|
|
725
|
+
return { allowed: threats.length === 0, threats, anomalies };
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Intercept and validate a tool's output after execution.
|
|
730
|
+
*
|
|
731
|
+
* @param {string} serverId - The server context.
|
|
732
|
+
* @param {string} toolName - Tool that produced the output.
|
|
733
|
+
* @param {*} output - The tool's output.
|
|
734
|
+
* @param {number} [responseTimeMs] - How long the tool took.
|
|
735
|
+
* @returns {{ safe: boolean, threats: Array<object>, anomalies: Array<object> }}
|
|
736
|
+
*/
|
|
737
|
+
interceptToolOutput(serverId, toolName, output, responseTimeMs) {
|
|
738
|
+
const threats = [];
|
|
739
|
+
const anomalies = [];
|
|
740
|
+
|
|
741
|
+
// Scan output
|
|
742
|
+
const outputStr = typeof output === 'string' ? output : JSON.stringify(output || {});
|
|
743
|
+
const scanResult = this.scanner(outputStr);
|
|
744
|
+
if (scanResult.threats && scanResult.threats.length > 0) {
|
|
745
|
+
for (const t of scanResult.threats) {
|
|
746
|
+
threats.push({
|
|
747
|
+
type: 'output_injection',
|
|
748
|
+
severity: t.severity || 'high',
|
|
749
|
+
serverId,
|
|
750
|
+
toolName,
|
|
751
|
+
category: t.category,
|
|
752
|
+
description: t.description || 'Threat detected in tool output.'
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Micro-model secondary scan on output
|
|
758
|
+
if (this.microModel) {
|
|
759
|
+
const modelResult = this.microModel.scan(outputStr);
|
|
760
|
+
if (modelResult.threats && modelResult.threats.length > 0) {
|
|
761
|
+
for (const t of modelResult.threats) {
|
|
762
|
+
threats.push({
|
|
763
|
+
type: 'micro_model_output',
|
|
764
|
+
severity: t.severity || 'high',
|
|
765
|
+
serverId,
|
|
766
|
+
toolName,
|
|
767
|
+
category: t.category,
|
|
768
|
+
confidence: t.confidence,
|
|
769
|
+
description: t.description || 'Micro-model detected threat in tool output.'
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// Record behavioral observation (only pass responseTimeMs if actually provided)
|
|
776
|
+
const behaviorObs = { isError: false };
|
|
777
|
+
if (responseTimeMs != null && responseTimeMs > 0) {
|
|
778
|
+
behaviorObs.responseTimeMs = responseTimeMs;
|
|
779
|
+
}
|
|
780
|
+
const behaviorResult = this.baselines.record(toolName, behaviorObs);
|
|
781
|
+
anomalies.push(...behaviorResult.anomalies);
|
|
782
|
+
|
|
783
|
+
if (threats.length > 0) {
|
|
784
|
+
this._recordThreats(serverId, threats.length);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
this._log('tool_output', serverId, { toolName, safe: threats.length === 0 });
|
|
788
|
+
|
|
789
|
+
return { safe: threats.length === 0, threats, anomalies };
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// -----------------------------------------------------------------------
|
|
793
|
+
// Reporting
|
|
794
|
+
// -----------------------------------------------------------------------
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Get a summary report of all server states.
|
|
798
|
+
* @returns {object}
|
|
799
|
+
*/
|
|
800
|
+
getReport() {
|
|
801
|
+
const servers = {};
|
|
802
|
+
for (const [serverId, state] of this.serverState) {
|
|
803
|
+
const attestation = this.attestation.get(serverId);
|
|
804
|
+
servers[serverId] = {
|
|
805
|
+
threatCount: state.threatCount,
|
|
806
|
+
circuitBreakerTripped: state.trippedAt !== null,
|
|
807
|
+
attestationHash: attestation ? attestation.hash.substring(0, 16) : null,
|
|
808
|
+
attestedAt: attestation ? attestation.attestedAt : null
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
return {
|
|
813
|
+
serverCount: this.serverState.size,
|
|
814
|
+
servers,
|
|
815
|
+
alerts: this.attestation.getAlerts(),
|
|
816
|
+
auditLogSize: this.auditLog.length
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Get the audit log.
|
|
822
|
+
* @returns {Array<object>}
|
|
823
|
+
*/
|
|
824
|
+
getAuditLog() {
|
|
825
|
+
return [...this.auditLog];
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* Reset the circuit breaker for a server (after manual review).
|
|
830
|
+
* @param {string} serverId
|
|
831
|
+
*/
|
|
832
|
+
resetCircuitBreaker(serverId) {
|
|
833
|
+
const state = this.serverState.get(serverId);
|
|
834
|
+
if (state) {
|
|
835
|
+
state.threatCount = 0;
|
|
836
|
+
state.trippedAt = null;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// -----------------------------------------------------------------------
|
|
841
|
+
// Private helpers
|
|
842
|
+
// -----------------------------------------------------------------------
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* Check if a server's circuit breaker is open.
|
|
846
|
+
* @param {string} serverId
|
|
847
|
+
* @returns {{ allowed: boolean }}
|
|
848
|
+
* @private
|
|
849
|
+
*/
|
|
850
|
+
_checkCircuitBreaker(serverId) {
|
|
851
|
+
const state = this.serverState.get(serverId);
|
|
852
|
+
if (!state) return { allowed: true };
|
|
853
|
+
|
|
854
|
+
if (state.trippedAt) {
|
|
855
|
+
const elapsed = Date.now() - state.trippedAt;
|
|
856
|
+
if (elapsed < this.cbCooldownMs) {
|
|
857
|
+
return { allowed: false };
|
|
858
|
+
}
|
|
859
|
+
// Cooldown elapsed — half-open, reset
|
|
860
|
+
state.trippedAt = null;
|
|
861
|
+
state.threatCount = 0;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
return { allowed: true };
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* Check rate limit for a server.
|
|
869
|
+
* @param {string} serverId
|
|
870
|
+
* @returns {{ allowed: boolean }}
|
|
871
|
+
* @private
|
|
872
|
+
*/
|
|
873
|
+
_checkRateLimit(serverId) {
|
|
874
|
+
const state = this.serverState.get(serverId);
|
|
875
|
+
if (!state) return { allowed: true };
|
|
876
|
+
|
|
877
|
+
const now = Date.now();
|
|
878
|
+
const oneMinuteAgo = now - 60000;
|
|
879
|
+
state.timestamps = state.timestamps.filter(t => t > oneMinuteAgo);
|
|
880
|
+
state.timestamps.push(now);
|
|
881
|
+
|
|
882
|
+
return { allowed: state.timestamps.length <= this.rateLimit };
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Record threat counts and potentially trip the circuit breaker.
|
|
887
|
+
* @param {string} serverId
|
|
888
|
+
* @param {number} count
|
|
889
|
+
* @private
|
|
890
|
+
*/
|
|
891
|
+
_recordThreats(serverId, count) {
|
|
892
|
+
const state = this.serverState.get(serverId);
|
|
893
|
+
if (!state) return;
|
|
894
|
+
|
|
895
|
+
state.threatCount += count;
|
|
896
|
+
if (state.threatCount >= this.cbThreshold && !state.trippedAt) {
|
|
897
|
+
state.trippedAt = Date.now();
|
|
898
|
+
const alert = {
|
|
899
|
+
type: 'circuit_breaker_tripped',
|
|
900
|
+
severity: 'critical',
|
|
901
|
+
serverId,
|
|
902
|
+
threatCount: state.threatCount,
|
|
903
|
+
timestamp: Date.now(),
|
|
904
|
+
description: `Circuit breaker tripped for server "${serverId}" after ${state.threatCount} threats.`
|
|
905
|
+
};
|
|
906
|
+
this._emitAlert(alert);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Extract tool names from various definition formats.
|
|
912
|
+
* @param {*} definitions
|
|
913
|
+
* @returns {string[]}
|
|
914
|
+
* @private
|
|
915
|
+
*/
|
|
916
|
+
_extractToolNames(definitions) {
|
|
917
|
+
if (Array.isArray(definitions)) {
|
|
918
|
+
return definitions.map(t => t.name || t.toolName || '').filter(Boolean);
|
|
919
|
+
}
|
|
920
|
+
if (definitions && typeof definitions === 'object') {
|
|
921
|
+
return Object.keys(definitions);
|
|
922
|
+
}
|
|
923
|
+
return [];
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* Emit an alert via the onAlert callback.
|
|
928
|
+
* @param {object} alert
|
|
929
|
+
* @private
|
|
930
|
+
*/
|
|
931
|
+
_emitAlert(alert) {
|
|
932
|
+
console.warn(`[Agent Shield] MCPGuard alert: ${alert.description}`);
|
|
933
|
+
if (this.onAlert) {
|
|
934
|
+
try { this.onAlert(alert); } catch { /* ignore callback errors */ }
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/**
|
|
939
|
+
* Log an event to the audit log.
|
|
940
|
+
* @param {string} action
|
|
941
|
+
* @param {string} serverId
|
|
942
|
+
* @param {object} details
|
|
943
|
+
* @private
|
|
944
|
+
*/
|
|
945
|
+
_log(action, serverId, details) {
|
|
946
|
+
this.auditLog.push({
|
|
947
|
+
timestamp: Date.now(),
|
|
948
|
+
action,
|
|
949
|
+
serverId,
|
|
950
|
+
...details
|
|
951
|
+
});
|
|
952
|
+
// Trim audit log to 10000 entries
|
|
953
|
+
if (this.auditLog.length > 10000) {
|
|
954
|
+
this.auditLog = this.auditLog.slice(-10000);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// =========================================================================
|
|
960
|
+
// EXPORTS
|
|
961
|
+
// =========================================================================
|
|
962
|
+
|
|
963
|
+
module.exports = {
|
|
964
|
+
MCPGuard,
|
|
965
|
+
ServerAttestation,
|
|
966
|
+
CrossServerIsolation,
|
|
967
|
+
OAuthEnforcer,
|
|
968
|
+
ToolBehaviorBaseline,
|
|
969
|
+
DEFAULT_RATE_LIMIT,
|
|
970
|
+
DEFAULT_CB_THRESHOLD,
|
|
971
|
+
DEFAULT_CB_COOLDOWN_MS,
|
|
972
|
+
DEFAULT_BASELINE_WINDOW,
|
|
973
|
+
DEFAULT_Z_THRESHOLD
|
|
974
|
+
};
|