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.
Files changed (51) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/LICENSE +21 -21
  3. package/README.md +26 -60
  4. package/bin/agentshield-audit +51 -0
  5. package/package.json +7 -10
  6. package/src/adaptive.js +330 -330
  7. package/src/alert-tuning.js +480 -480
  8. package/src/audit-streaming.js +1 -1
  9. package/src/badges.js +196 -196
  10. package/src/behavioral-dna.js +12 -0
  11. package/src/canary.js +2 -3
  12. package/src/certification.js +563 -563
  13. package/src/circuit-breaker.js +2 -2
  14. package/src/confused-deputy.js +4 -0
  15. package/src/conversation.js +494 -494
  16. package/src/cross-turn.js +3 -17
  17. package/src/ctf.js +462 -462
  18. package/src/detector-core.js +71 -152
  19. package/src/document-scanner.js +795 -795
  20. package/src/drift-monitor.js +344 -0
  21. package/src/encoding.js +429 -429
  22. package/src/enterprise.js +405 -405
  23. package/src/flight-recorder.js +2 -0
  24. package/src/i18n-patterns.js +523 -523
  25. package/src/index.js +19 -0
  26. package/src/main.js +61 -41
  27. package/src/mcp-guard.js +974 -0
  28. package/src/micro-model.js +762 -0
  29. package/src/ml-detector.js +316 -0
  30. package/src/model-finetuning.js +884 -884
  31. package/src/multimodal.js +296 -296
  32. package/src/nist-mapping.js +2 -2
  33. package/src/observability.js +330 -330
  34. package/src/openclaw.js +450 -450
  35. package/src/otel.js +544 -544
  36. package/src/owasp-2025.js +1 -1
  37. package/src/owasp-agentic.js +420 -0
  38. package/src/plugin-marketplace.js +628 -628
  39. package/src/plugin-system.js +349 -349
  40. package/src/policy-extended.js +635 -635
  41. package/src/policy.js +443 -443
  42. package/src/prompt-leakage.js +2 -2
  43. package/src/real-attack-datasets.js +2 -2
  44. package/src/redteam-cli.js +439 -0
  45. package/src/supply-chain-scanner.js +691 -0
  46. package/src/testing.js +5 -1
  47. package/src/threat-encyclopedia.js +629 -629
  48. package/src/threat-intel-network.js +1017 -1017
  49. package/src/token-analysis.js +467 -467
  50. package/src/tool-output-validator.js +354 -354
  51. package/src/watermark.js +1 -2
@@ -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
+ };