agentshield-sdk 7.2.1 → 7.3.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.
@@ -0,0 +1,762 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Agent Shield -- Agent Behavioral DNA (v7.5)
5
+ *
6
+ * Creates a comprehensive fingerprint of what "normal" looks like for a
7
+ * specific agent deployment, then detects compromise by comparing current
8
+ * behavior against the learned baseline.
9
+ *
10
+ * Goes beyond z-score anomaly detection (behavior-profiling.js) by tracking
11
+ * multi-dimensional feature vectors, categorical distributions, cross-feature
12
+ * correlations, and producing a portable DNA fingerprint.
13
+ *
14
+ * All processing runs locally -- no data ever leaves your environment.
15
+ */
16
+
17
+ // =========================================================================
18
+ // STATISTICAL HELPERS
19
+ // =========================================================================
20
+
21
+ /**
22
+ * Calculate mean of a numeric array.
23
+ * @param {number[]} arr
24
+ * @returns {number}
25
+ */
26
+ function mean(arr) {
27
+ return arr.length > 0 ? arr.reduce((a, b) => a + b, 0) / arr.length : 0;
28
+ }
29
+
30
+ /**
31
+ * Calculate sample standard deviation.
32
+ * @param {number[]} arr
33
+ * @returns {number}
34
+ */
35
+ function stdDev(arr) {
36
+ if (arr.length < 2) return 0;
37
+ const m = mean(arr);
38
+ const squaredDiffs = arr.map(x => (x - m) ** 2);
39
+ return Math.sqrt(squaredDiffs.reduce((a, b) => a + b, 0) / (arr.length - 1));
40
+ }
41
+
42
+ /**
43
+ * Calculate the z-score for a value given mean and standard deviation.
44
+ * @param {number} value
45
+ * @param {number} m
46
+ * @param {number} sd
47
+ * @returns {number}
48
+ */
49
+ function zScore(value, m, sd) {
50
+ if (sd === 0) return value === m ? 0 : Infinity;
51
+ return (value - m) / sd;
52
+ }
53
+
54
+ /**
55
+ * Calculate Pearson correlation between two arrays.
56
+ * @param {number[]} xs
57
+ * @param {number[]} ys
58
+ * @returns {number}
59
+ */
60
+ function pearsonCorrelation(xs, ys) {
61
+ const n = Math.min(xs.length, ys.length);
62
+ if (n < 3) return 0;
63
+ const mx = mean(xs.slice(0, n));
64
+ const my = mean(ys.slice(0, n));
65
+ let num = 0;
66
+ let dx2 = 0;
67
+ let dy2 = 0;
68
+ for (let i = 0; i < n; i++) {
69
+ const dx = xs[i] - mx;
70
+ const dy = ys[i] - my;
71
+ num += dx * dy;
72
+ dx2 += dx * dx;
73
+ dy2 += dy * dy;
74
+ }
75
+ const denom = Math.sqrt(dx2 * dy2);
76
+ return denom === 0 ? 0 : num / denom;
77
+ }
78
+
79
+ /**
80
+ * Shannon entropy of a frequency distribution (object with counts).
81
+ * @param {Object<string, number>} dist
82
+ * @returns {number}
83
+ */
84
+ function shannonEntropy(dist) {
85
+ const total = Object.values(dist).reduce((a, b) => a + b, 0);
86
+ if (total === 0) return 0;
87
+ let h = 0;
88
+ for (const count of Object.values(dist)) {
89
+ if (count === 0) continue;
90
+ const p = count / total;
91
+ h -= p * Math.log2(p);
92
+ }
93
+ return h;
94
+ }
95
+
96
+ // =========================================================================
97
+ // DEFAULT FEATURES
98
+ // =========================================================================
99
+
100
+ /**
101
+ * Default numeric features tracked by BehavioralDNA.
102
+ * @type {string[]}
103
+ */
104
+ const DEFAULT_NUMERIC_FEATURES = [
105
+ 'responseLength',
106
+ 'responseTimeMs',
107
+ 'toolCount',
108
+ 'threatScore',
109
+ 'sentimentScore'
110
+ ];
111
+
112
+ /**
113
+ * Default categorical features tracked by BehavioralDNA.
114
+ * @type {string[]}
115
+ */
116
+ const DEFAULT_CATEGORICAL_FEATURES = [
117
+ 'topicCategory',
118
+ 'languageUsed'
119
+ ];
120
+
121
+ // =========================================================================
122
+ // BEHAVIORAL DNA
123
+ // =========================================================================
124
+
125
+ /**
126
+ * Comprehensive behavioral fingerprint for an agent deployment.
127
+ * Learns what "normal" looks like, then detects deviations.
128
+ */
129
+ class BehavioralDNA {
130
+ /**
131
+ * @param {object} [options]
132
+ * @param {number} [options.learningPeriod=50] - Observations before detection activates.
133
+ * @param {number} [options.anomalyThreshold=2.5] - Std deviations for anomaly detection.
134
+ * @param {string[]} [options.features] - Numeric features to track.
135
+ * @param {string[]} [options.categoricalFeatures] - Categorical features to track.
136
+ * @param {number} [options.windowSize=500] - Max observations to retain per feature.
137
+ * @param {boolean} [options.trackCorrelations=true] - Track cross-feature correlations.
138
+ */
139
+ constructor(options = {}) {
140
+ this.learningPeriod = options.learningPeriod || 50;
141
+ this.anomalyThreshold = options.anomalyThreshold || 2.5;
142
+ this.windowSize = options.windowSize || 500;
143
+ this.trackCorrelations = options.trackCorrelations !== false;
144
+
145
+ this._numericFeatures = options.features || DEFAULT_NUMERIC_FEATURES.slice();
146
+ this._categoricalFeatures = options.categoricalFeatures || DEFAULT_CATEGORICAL_FEATURES.slice();
147
+
148
+ this._numericData = {};
149
+ for (const f of this._numericFeatures) {
150
+ this._numericData[f] = [];
151
+ }
152
+
153
+ this._categoricalData = {};
154
+ for (const f of this._categoricalFeatures) {
155
+ this._categoricalData[f] = {};
156
+ }
157
+
158
+ this._toolDistribution = {};
159
+ this._observationCount = 0;
160
+ this._createdAt = Date.now();
161
+ this._lastObservation = null;
162
+
163
+ console.log(
164
+ '[Agent Shield] BehavioralDNA initialized (learningPeriod: %d, threshold: %s)',
165
+ this.learningPeriod,
166
+ this.anomalyThreshold
167
+ );
168
+ }
169
+
170
+ /**
171
+ * Record an observation of agent behavior.
172
+ * @param {object} observation
173
+ * @param {string[]} [observation.toolsCalled] - Tools used in this interaction.
174
+ * @param {number} [observation.responseLength] - Length of agent response.
175
+ * @param {number} [observation.responseTimeMs] - Time taken to respond in ms.
176
+ * @param {number} [observation.threatScore] - Threat score from scanning (0-1).
177
+ * @param {string} [observation.topicCategory] - Detected topic category.
178
+ * @param {number} [observation.sentimentScore] - Sentiment score (-1 to 1).
179
+ * @param {string} [observation.languageUsed] - Language of response.
180
+ */
181
+ observe(observation) {
182
+ if (!observation || typeof observation !== 'object') return;
183
+
184
+ this._observationCount++;
185
+ this._lastObservation = Date.now();
186
+
187
+ // Record numeric features
188
+ for (const f of this._numericFeatures) {
189
+ if (f === 'toolCount' && observation.toolsCalled !== undefined) {
190
+ this._pushNumeric(f, Array.isArray(observation.toolsCalled) ? observation.toolsCalled.length : 0);
191
+ } else if (observation[f] !== undefined && typeof observation[f] === 'number') {
192
+ this._pushNumeric(f, observation[f]);
193
+ }
194
+ }
195
+
196
+ // Record categorical features
197
+ for (const f of this._categoricalFeatures) {
198
+ if (observation[f] !== undefined && observation[f] !== null) {
199
+ const val = String(observation[f]);
200
+ if (!this._categoricalData[f]) this._categoricalData[f] = {};
201
+ this._categoricalData[f][val] = (this._categoricalData[f][val] || 0) + 1;
202
+ }
203
+ }
204
+
205
+ // Record tool distribution
206
+ if (Array.isArray(observation.toolsCalled)) {
207
+ for (const tool of observation.toolsCalled) {
208
+ this._toolDistribution[tool] = (this._toolDistribution[tool] || 0) + 1;
209
+ }
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Returns true if still in the learning period.
215
+ * @returns {boolean}
216
+ */
217
+ isLearning() {
218
+ return this._observationCount < this.learningPeriod;
219
+ }
220
+
221
+ /**
222
+ * Returns the learned baseline statistics.
223
+ * @returns {object} Baseline with mean, stdDev, min, max, samples for each numeric feature,
224
+ * plus categorical distributions and tool usage.
225
+ */
226
+ getBaseline() {
227
+ const numeric = {};
228
+ for (const f of this._numericFeatures) {
229
+ const values = this._numericData[f] || [];
230
+ if (values.length > 0) {
231
+ numeric[f] = {
232
+ mean: _round(mean(values)),
233
+ stdDev: _round(stdDev(values)),
234
+ min: Math.min(...values),
235
+ max: Math.max(...values),
236
+ samples: values.length
237
+ };
238
+ }
239
+ }
240
+
241
+ const categorical = {};
242
+ for (const f of this._categoricalFeatures) {
243
+ const dist = this._categoricalData[f] || {};
244
+ const total = Object.values(dist).reduce((a, b) => a + b, 0);
245
+ if (total > 0) {
246
+ const normalized = {};
247
+ for (const [key, count] of Object.entries(dist)) {
248
+ normalized[key] = _round(count / total);
249
+ }
250
+ categorical[f] = {
251
+ distribution: normalized,
252
+ entropy: _round(shannonEntropy(dist)),
253
+ totalSamples: total
254
+ };
255
+ }
256
+ }
257
+
258
+ const toolTotal = Object.values(this._toolDistribution).reduce((a, b) => a + b, 0);
259
+ const toolNormalized = {};
260
+ if (toolTotal > 0) {
261
+ for (const [tool, count] of Object.entries(this._toolDistribution)) {
262
+ toolNormalized[tool] = _round(count / toolTotal);
263
+ }
264
+ }
265
+
266
+ const correlations = {};
267
+ if (this.trackCorrelations) {
268
+ const featureNames = this._numericFeatures.filter(f => (this._numericData[f] || []).length >= 3);
269
+ for (let i = 0; i < featureNames.length; i++) {
270
+ for (let j = i + 1; j < featureNames.length; j++) {
271
+ const key = featureNames[i] + ':' + featureNames[j];
272
+ correlations[key] = _round(
273
+ pearsonCorrelation(this._numericData[featureNames[i]], this._numericData[featureNames[j]])
274
+ );
275
+ }
276
+ }
277
+ }
278
+
279
+ return {
280
+ numeric,
281
+ categorical,
282
+ toolDistribution: toolNormalized,
283
+ correlations,
284
+ observationCount: this._observationCount,
285
+ isLearning: this.isLearning()
286
+ };
287
+ }
288
+
289
+ /**
290
+ * Compare an observation against the learned baseline.
291
+ * Only works after the learning period is complete.
292
+ * @param {object} observation - Same shape as observe().
293
+ * @returns {object} { anomaly, score, deviations, explanation }
294
+ */
295
+ detect(observation) {
296
+ if (this.isLearning()) {
297
+ return {
298
+ anomaly: false,
299
+ score: 0,
300
+ deviations: [],
301
+ explanation: 'Still in learning period (' + this._observationCount + '/' + this.learningPeriod + ' observations).'
302
+ };
303
+ }
304
+
305
+ const deviations = [];
306
+
307
+ // Check numeric features
308
+ for (const f of this._numericFeatures) {
309
+ let value;
310
+ if (f === 'toolCount' && observation.toolsCalled !== undefined) {
311
+ value = Array.isArray(observation.toolsCalled) ? observation.toolsCalled.length : 0;
312
+ } else {
313
+ value = observation[f];
314
+ }
315
+ if (value === undefined || typeof value !== 'number') continue;
316
+
317
+ const values = this._numericData[f] || [];
318
+ if (values.length < 2) continue;
319
+
320
+ const m = mean(values);
321
+ const sd = stdDev(values);
322
+ const z = zScore(value, m, sd);
323
+
324
+ if (Math.abs(z) > this.anomalyThreshold) {
325
+ deviations.push({
326
+ feature: f,
327
+ type: 'numeric',
328
+ value,
329
+ expected: { mean: _round(m), stdDev: _round(sd) },
330
+ zScore: _round(z),
331
+ direction: z > 0 ? 'above' : 'below',
332
+ severity: _deviationSeverity(Math.abs(z), this.anomalyThreshold)
333
+ });
334
+ }
335
+ }
336
+
337
+ // Check categorical features for novel or rare values
338
+ for (const f of this._categoricalFeatures) {
339
+ if (observation[f] === undefined || observation[f] === null) continue;
340
+ const val = String(observation[f]);
341
+ const dist = this._categoricalData[f] || {};
342
+ const total = Object.values(dist).reduce((a, b) => a + b, 0);
343
+
344
+ if (total === 0) continue;
345
+
346
+ const count = dist[val] || 0;
347
+ const freq = count / total;
348
+
349
+ if (count === 0) {
350
+ // Completely new category value never seen during learning
351
+ deviations.push({
352
+ feature: f,
353
+ type: 'categorical',
354
+ value: val,
355
+ expected: Object.keys(dist),
356
+ frequency: 0,
357
+ severity: 'high',
358
+ reason: 'Never-before-seen value'
359
+ });
360
+ } else if (freq < 0.01 && total > 20) {
361
+ // Extremely rare value
362
+ deviations.push({
363
+ feature: f,
364
+ type: 'categorical',
365
+ value: val,
366
+ frequency: _round(freq),
367
+ severity: 'medium',
368
+ reason: 'Extremely rare value (seen in <1% of observations)'
369
+ });
370
+ }
371
+ }
372
+
373
+ // Check tool usage for novel tools
374
+ if (Array.isArray(observation.toolsCalled)) {
375
+ const totalTools = Object.values(this._toolDistribution).reduce((a, b) => a + b, 0);
376
+ for (const tool of observation.toolsCalled) {
377
+ if (totalTools > 0 && !this._toolDistribution[tool]) {
378
+ deviations.push({
379
+ feature: 'toolsCalled',
380
+ type: 'tool',
381
+ value: tool,
382
+ severity: 'high',
383
+ reason: 'Tool never seen in baseline'
384
+ });
385
+ }
386
+ }
387
+ }
388
+
389
+ // Compute composite anomaly score (0 to 1)
390
+ const score = _compositeScore(deviations, this.anomalyThreshold);
391
+
392
+ // Build explanation
393
+ let explanation = '';
394
+ if (deviations.length === 0) {
395
+ explanation = 'Observation is within normal behavioral parameters.';
396
+ } else {
397
+ const parts = deviations.map(d => {
398
+ if (d.type === 'numeric') {
399
+ return d.feature + ' is ' + d.direction + ' normal (z=' + d.zScore + ')';
400
+ }
401
+ if (d.type === 'categorical') {
402
+ return d.feature + '="' + d.value + '" - ' + d.reason;
403
+ }
404
+ return d.feature + '="' + d.value + '" - ' + d.reason;
405
+ });
406
+ explanation = 'Anomalous behavior detected: ' + parts.join('; ') + '.';
407
+ }
408
+
409
+ return {
410
+ anomaly: deviations.length > 0,
411
+ score: _round(score),
412
+ deviations,
413
+ explanation
414
+ };
415
+ }
416
+
417
+ /**
418
+ * Return the full behavioral DNA fingerprint as a portable JSON-safe object.
419
+ * Can be saved and loaded later with loadFingerprint().
420
+ * @returns {object}
421
+ */
422
+ getFingerprint() {
423
+ return {
424
+ version: 1,
425
+ createdAt: this._createdAt,
426
+ lastObservation: this._lastObservation,
427
+ observationCount: this._observationCount,
428
+ config: {
429
+ learningPeriod: this.learningPeriod,
430
+ anomalyThreshold: this.anomalyThreshold,
431
+ windowSize: this.windowSize,
432
+ trackCorrelations: this.trackCorrelations,
433
+ numericFeatures: this._numericFeatures.slice(),
434
+ categoricalFeatures: this._categoricalFeatures.slice()
435
+ },
436
+ numericData: _deepCopyNumeric(this._numericData),
437
+ categoricalData: _deepCopyCategorical(this._categoricalData),
438
+ toolDistribution: { ...this._toolDistribution },
439
+ baseline: this.getBaseline()
440
+ };
441
+ }
442
+
443
+ /**
444
+ * Load a previously saved fingerprint, restoring full state.
445
+ * @param {object} data - Fingerprint object from getFingerprint().
446
+ */
447
+ loadFingerprint(data) {
448
+ if (!data || typeof data !== 'object') {
449
+ throw new Error('[Agent Shield] Invalid fingerprint data');
450
+ }
451
+ if (data.version !== 1) {
452
+ throw new Error('[Agent Shield] Unsupported fingerprint version: ' + data.version);
453
+ }
454
+
455
+ this._createdAt = data.createdAt || Date.now();
456
+ this._lastObservation = data.lastObservation || null;
457
+ this._observationCount = data.observationCount || 0;
458
+
459
+ if (data.config) {
460
+ this.learningPeriod = data.config.learningPeriod || this.learningPeriod;
461
+ this.anomalyThreshold = data.config.anomalyThreshold || this.anomalyThreshold;
462
+ this.windowSize = data.config.windowSize || this.windowSize;
463
+ this.trackCorrelations = data.config.trackCorrelations !== false;
464
+ this._numericFeatures = data.config.numericFeatures || this._numericFeatures;
465
+ this._categoricalFeatures = data.config.categoricalFeatures || this._categoricalFeatures;
466
+ }
467
+
468
+ this._numericData = {};
469
+ for (const f of this._numericFeatures) {
470
+ this._numericData[f] = (data.numericData && Array.isArray(data.numericData[f]))
471
+ ? data.numericData[f].slice()
472
+ : [];
473
+ }
474
+
475
+ this._categoricalData = {};
476
+ for (const f of this._categoricalFeatures) {
477
+ this._categoricalData[f] = (data.categoricalData && data.categoricalData[f])
478
+ ? { ...data.categoricalData[f] }
479
+ : {};
480
+ }
481
+
482
+ this._toolDistribution = data.toolDistribution ? { ...data.toolDistribution } : {};
483
+
484
+ console.log(
485
+ '[Agent Shield] BehavioralDNA fingerprint loaded (%d observations)',
486
+ this._observationCount
487
+ );
488
+ }
489
+
490
+ /**
491
+ * Clear all learned data and reset to initial state.
492
+ */
493
+ reset() {
494
+ for (const f of this._numericFeatures) {
495
+ this._numericData[f] = [];
496
+ }
497
+ for (const f of this._categoricalFeatures) {
498
+ this._categoricalData[f] = {};
499
+ }
500
+ this._toolDistribution = {};
501
+ this._observationCount = 0;
502
+ this._createdAt = Date.now();
503
+ this._lastObservation = null;
504
+ }
505
+
506
+ /** @private */
507
+ _pushNumeric(feature, value) {
508
+ if (!this._numericData[feature]) this._numericData[feature] = [];
509
+ this._numericData[feature].push(value);
510
+ if (this._numericData[feature].length > this.windowSize) {
511
+ this._numericData[feature].shift();
512
+ }
513
+ }
514
+ }
515
+
516
+ // =========================================================================
517
+ // AGENT PROFILER
518
+ // =========================================================================
519
+
520
+ /**
521
+ * Manages BehavioralDNA profiles for multiple agents.
522
+ */
523
+ class AgentProfiler {
524
+ /**
525
+ * @param {object} [defaultOptions] - Default BehavioralDNA options for new profiles.
526
+ */
527
+ constructor(defaultOptions = {}) {
528
+ this._defaultOptions = defaultOptions;
529
+ this._profiles = new Map();
530
+ }
531
+
532
+ /**
533
+ * Create a new BehavioralDNA profile for an agent.
534
+ * @param {string} agentId - Unique identifier for the agent.
535
+ * @param {object} [options] - Override default options for this agent.
536
+ * @returns {BehavioralDNA}
537
+ */
538
+ createProfile(agentId, options = {}) {
539
+ const merged = { ...this._defaultOptions, ...options };
540
+ const dna = new BehavioralDNA(merged);
541
+ this._profiles.set(agentId, dna);
542
+ console.log('[Agent Shield] AgentProfiler: created profile for "%s"', agentId);
543
+ return dna;
544
+ }
545
+
546
+ /**
547
+ * Get an existing profile for an agent.
548
+ * @param {string} agentId
549
+ * @returns {BehavioralDNA|null}
550
+ */
551
+ getProfile(agentId) {
552
+ return this._profiles.get(agentId) || null;
553
+ }
554
+
555
+ /**
556
+ * Check an observation against an agent's DNA.
557
+ * If the agent has no profile yet, one is created automatically.
558
+ * The observation is also recorded (observe) after detection.
559
+ * @param {object} observation - Observation object.
560
+ * @param {string} agentId - Agent identifier.
561
+ * @returns {object} Detection result from BehavioralDNA.detect().
562
+ */
563
+ checkAll(observation, agentId) {
564
+ let dna = this._profiles.get(agentId);
565
+ if (!dna) {
566
+ dna = this.createProfile(agentId);
567
+ }
568
+ const result = dna.detect(observation);
569
+ dna.observe(observation);
570
+ return result;
571
+ }
572
+
573
+ /**
574
+ * Get a summary report of all profiled agents.
575
+ * @returns {object} { agents: Array, totalAgents, learningCount, anomalousCount }
576
+ */
577
+ getReport() {
578
+ const agents = [];
579
+ let learningCount = 0;
580
+ let anomalousCount = 0;
581
+
582
+ for (const [agentId, dna] of this._profiles) {
583
+ const baseline = dna.getBaseline();
584
+ const isLearning = dna.isLearning();
585
+ if (isLearning) learningCount++;
586
+
587
+ agents.push({
588
+ agentId,
589
+ observationCount: baseline.observationCount,
590
+ isLearning,
591
+ numericFeatures: Object.keys(baseline.numeric),
592
+ categoricalFeatures: Object.keys(baseline.categorical),
593
+ toolCount: Object.keys(baseline.toolDistribution).length
594
+ });
595
+ }
596
+
597
+ return {
598
+ agents,
599
+ totalAgents: this._profiles.size,
600
+ learningCount,
601
+ anomalousCount
602
+ };
603
+ }
604
+ }
605
+
606
+ // =========================================================================
607
+ // FEATURE EXTRACTORS
608
+ // =========================================================================
609
+
610
+ /**
611
+ * Convert a scan result and metadata into a BehavioralDNA observation.
612
+ * @param {object} scanResult - Result from AgentShield.scan() or scanText().
613
+ * @param {object} [metadata] - Additional context about the interaction.
614
+ * @param {string[]} [metadata.toolsCalled] - Tools used in this interaction.
615
+ * @param {number} [metadata.responseTimeMs] - Response time in ms.
616
+ * @param {string} [metadata.topicCategory] - Topic category.
617
+ * @param {number} [metadata.sentimentScore] - Sentiment score (-1 to 1).
618
+ * @param {string} [metadata.languageUsed] - Language of the response.
619
+ * @param {string} [metadata.responseText] - Full response text (used for length).
620
+ * @returns {object} Observation suitable for BehavioralDNA.observe() or .detect().
621
+ */
622
+ function extractFeatures(scanResult, metadata = {}) {
623
+ const observation = {};
624
+
625
+ // Extract from scan result
626
+ if (scanResult) {
627
+ if (typeof scanResult === 'object') {
628
+ // Threat score: use overall score if present, otherwise derive from threats
629
+ if (typeof scanResult.score === 'number') {
630
+ observation.threatScore = scanResult.score;
631
+ } else if (typeof scanResult.threatScore === 'number') {
632
+ observation.threatScore = scanResult.threatScore;
633
+ } else if (Array.isArray(scanResult.threats) && scanResult.threats.length > 0) {
634
+ // Derive a score from number of threats (normalized to 0-1)
635
+ observation.threatScore = Math.min(scanResult.threats.length / 10, 1);
636
+ } else {
637
+ observation.threatScore = 0;
638
+ }
639
+ }
640
+ }
641
+
642
+ // Extract from metadata
643
+ if (Array.isArray(metadata.toolsCalled)) {
644
+ observation.toolsCalled = metadata.toolsCalled;
645
+ }
646
+
647
+ if (typeof metadata.responseTimeMs === 'number') {
648
+ observation.responseTimeMs = metadata.responseTimeMs;
649
+ }
650
+
651
+ if (typeof metadata.topicCategory === 'string') {
652
+ observation.topicCategory = metadata.topicCategory;
653
+ }
654
+
655
+ if (typeof metadata.sentimentScore === 'number') {
656
+ observation.sentimentScore = metadata.sentimentScore;
657
+ }
658
+
659
+ if (typeof metadata.languageUsed === 'string') {
660
+ observation.languageUsed = metadata.languageUsed;
661
+ }
662
+
663
+ // Response length from metadata
664
+ if (typeof metadata.responseText === 'string') {
665
+ observation.responseLength = metadata.responseText.length;
666
+ } else if (typeof metadata.responseLength === 'number') {
667
+ observation.responseLength = metadata.responseLength;
668
+ }
669
+
670
+ return observation;
671
+ }
672
+
673
+ // =========================================================================
674
+ // PRIVATE HELPERS
675
+ // =========================================================================
676
+
677
+ /**
678
+ * Round a number to 4 decimal places.
679
+ * @param {number} n
680
+ * @returns {number}
681
+ */
682
+ function _round(n) {
683
+ return Math.round(n * 10000) / 10000;
684
+ }
685
+
686
+ /**
687
+ * Determine severity based on how far a z-score exceeds the threshold.
688
+ * @param {number} absZ
689
+ * @param {number} threshold
690
+ * @returns {string}
691
+ */
692
+ function _deviationSeverity(absZ, threshold) {
693
+ if (absZ > threshold * 3) return 'critical';
694
+ if (absZ > threshold * 2) return 'high';
695
+ if (absZ > threshold * 1.5) return 'medium';
696
+ return 'low';
697
+ }
698
+
699
+ /**
700
+ * Compute a composite anomaly score (0 to 1) from deviations.
701
+ * @param {object[]} deviations
702
+ * @param {number} threshold
703
+ * @returns {number}
704
+ */
705
+ function _compositeScore(deviations, threshold) {
706
+ if (deviations.length === 0) return 0;
707
+
708
+ const severityWeights = { critical: 1.0, high: 0.7, medium: 0.4, low: 0.2 };
709
+ let totalWeight = 0;
710
+
711
+ for (const d of deviations) {
712
+ const w = severityWeights[d.severity] || 0.3;
713
+ totalWeight += w;
714
+ }
715
+
716
+ // Normalize: cap at 1.0
717
+ return Math.min(totalWeight / 3, 1);
718
+ }
719
+
720
+ /**
721
+ * Deep copy numeric data object.
722
+ * @param {Object<string, number[]>} data
723
+ * @returns {Object<string, number[]>}
724
+ */
725
+ function _deepCopyNumeric(data) {
726
+ const copy = {};
727
+ for (const [key, arr] of Object.entries(data)) {
728
+ copy[key] = arr.slice();
729
+ }
730
+ return copy;
731
+ }
732
+
733
+ /**
734
+ * Deep copy categorical data object.
735
+ * @param {Object<string, Object<string, number>>} data
736
+ * @returns {Object<string, Object<string, number>>}
737
+ */
738
+ function _deepCopyCategorical(data) {
739
+ const copy = {};
740
+ for (const [key, dist] of Object.entries(data)) {
741
+ copy[key] = { ...dist };
742
+ }
743
+ return copy;
744
+ }
745
+
746
+ // =========================================================================
747
+ // EXPORTS
748
+ // =========================================================================
749
+
750
+ module.exports = {
751
+ BehavioralDNA,
752
+ AgentProfiler,
753
+ extractFeatures,
754
+ DEFAULT_NUMERIC_FEATURES,
755
+ DEFAULT_CATEGORICAL_FEATURES,
756
+ // Expose helpers for testing
757
+ mean,
758
+ stdDev,
759
+ zScore,
760
+ pearsonCorrelation,
761
+ shannonEntropy
762
+ };