agentshield-sdk 7.3.0 → 7.4.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 (43) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +36 -7
  3. package/package.json +7 -3
  4. package/src/agent-protocol.js +4 -0
  5. package/src/allowlist.js +605 -603
  6. package/src/audit-streaming.js +486 -469
  7. package/src/audit.js +1 -1
  8. package/src/behavior-profiling.js +299 -289
  9. package/src/behavioral-dna.js +4 -9
  10. package/src/canary.js +273 -271
  11. package/src/compliance.js +619 -617
  12. package/src/confidence-tuning.js +328 -324
  13. package/src/context-scoring.js +362 -360
  14. package/src/cost-optimizer.js +1024 -1024
  15. package/src/detector-core.js +186 -0
  16. package/src/distributed.js +5 -1
  17. package/src/embedding.js +310 -307
  18. package/src/herd-immunity.js +12 -12
  19. package/src/honeypot.js +332 -328
  20. package/src/integrations.js +1 -2
  21. package/src/intent-firewall.js +14 -14
  22. package/src/llm-redteam.js +678 -670
  23. package/src/main.js +10 -0
  24. package/src/middleware.js +5 -2
  25. package/src/model-fingerprint.js +1059 -1042
  26. package/src/multi-agent-trust.js +459 -453
  27. package/src/multi-agent.js +1 -1
  28. package/src/normalizer.js +734 -0
  29. package/src/pii.js +4 -0
  30. package/src/policy-dsl.js +775 -775
  31. package/src/presets.js +409 -409
  32. package/src/production.js +22 -9
  33. package/src/redteam.js +475 -475
  34. package/src/response-handler.js +436 -429
  35. package/src/scanners.js +358 -357
  36. package/src/self-healing.js +368 -363
  37. package/src/semantic.js +339 -339
  38. package/src/shield-score.js +250 -250
  39. package/src/sso-saml.js +8 -4
  40. package/src/testing.js +24 -2
  41. package/src/tool-guard.js +412 -412
  42. package/src/watermark.js +242 -235
  43. package/src/worker-scanner.js +608 -601
@@ -1,601 +1,608 @@
1
- 'use strict';
2
-
3
- /**
4
- * Agent Shield — Worker Scanner
5
- *
6
- * Async scanning for non-blocking operation. Uses setImmediate/setTimeout to
7
- * yield to the event loop between scans, preventing long-running scans from
8
- * blocking the main thread.
9
- *
10
- * NOTE: This implementation uses async wrappers around the synchronous scanner
11
- * with event loop yielding. In production environments requiring true parallel
12
- * CPU-bound scanning, you can swap in Node.js worker_threads by replacing the
13
- * _runInWorker method with actual Worker thread dispatch.
14
- *
15
- * All detection runs locally — no data ever leaves your environment.
16
- */
17
-
18
- const { scanText } = require('./detector-core');
19
-
20
- // =========================================================================
21
- // HELPERS
22
- // =========================================================================
23
-
24
- /**
25
- * Yield to the event loop. Uses setImmediate when available, falls back to setTimeout.
26
- * @returns {Promise<void>}
27
- */
28
- const yieldToEventLoop = () => new Promise(resolve => {
29
- if (typeof setImmediate === 'function') {
30
- setImmediate(resolve);
31
- } else {
32
- setTimeout(resolve, 0);
33
- }
34
- });
35
-
36
- /**
37
- * Create a deferred promise with external resolve/reject.
38
- * @returns {{ promise: Promise, resolve: Function, reject: Function }}
39
- */
40
- function createDeferred() {
41
- let resolve, reject;
42
- const promise = new Promise((res, rej) => {
43
- resolve = res;
44
- reject = rej;
45
- });
46
- return { promise, resolve, reject };
47
- }
48
-
49
- // =========================================================================
50
- // WORKER SCANNER
51
- // =========================================================================
52
-
53
- /**
54
- * Async scanner that runs scans without blocking the event loop.
55
- * Manages a virtual "pool" with concurrency control and timeout support.
56
- */
57
- class WorkerScanner {
58
- /**
59
- * @param {object} [options]
60
- * @param {number} [options.poolSize=2] - Maximum concurrent scans.
61
- * @param {number} [options.timeout=5000] - Per-scan timeout in milliseconds.
62
- */
63
- constructor(options = {}) {
64
- this.poolSize = options.poolSize || 2;
65
- this.timeout = options.timeout || 5000;
66
-
67
- this._activeWorkers = 0;
68
- this._completedJobs = 0;
69
- this._errorCount = 0;
70
- this._queue = [];
71
- this._terminated = false;
72
-
73
- console.log('[Agent Shield] WorkerScanner initialized (poolSize: %d, timeout: %dms)', this.poolSize, this.timeout);
74
- }
75
-
76
- /**
77
- * Scan text asynchronously without blocking the event loop.
78
- * @param {string} text - The text to scan.
79
- * @param {object} [options] - Scan options passed to scanText.
80
- * @returns {Promise<object>} Scan result from detector-core.
81
- */
82
- async scan(text, options = {}) {
83
- if (this._terminated) {
84
- throw new Error('WorkerScanner has been terminated.');
85
- }
86
-
87
- // Wait for an available slot
88
- while (this._activeWorkers >= this.poolSize) {
89
- await yieldToEventLoop();
90
- }
91
-
92
- return this._runScan(text, options);
93
- }
94
-
95
- /**
96
- * Scan multiple texts in parallel using the worker pool.
97
- * @param {string[]} texts - Array of texts to scan.
98
- * @param {object} [options] - Scan options passed to scanText.
99
- * @returns {Promise<object[]>} Array of scan results.
100
- */
101
- async scanBatch(texts, options = {}) {
102
- if (this._terminated) {
103
- throw new Error('WorkerScanner has been terminated.');
104
- }
105
-
106
- if (!Array.isArray(texts) || texts.length === 0) {
107
- return [];
108
- }
109
-
110
- // Launch all scans, concurrency is managed inside _runScan
111
- const promises = texts.map(text => this.scan(text, options));
112
- return Promise.all(promises);
113
- }
114
-
115
- /**
116
- * Get pool statistics.
117
- * @returns {object} Stats: { activeWorkers, queuedJobs, completed, errors, poolSize, terminated }.
118
- */
119
- getStats() {
120
- return {
121
- activeWorkers: this._activeWorkers,
122
- queuedJobs: this._queue.length,
123
- completed: this._completedJobs,
124
- errors: this._errorCount,
125
- poolSize: this.poolSize,
126
- terminated: this._terminated
127
- };
128
- }
129
-
130
- /**
131
- * Shut down the worker pool. Pending scans will be rejected.
132
- */
133
- terminate() {
134
- this._terminated = true;
135
-
136
- // Reject any queued jobs
137
- for (const job of this._queue) {
138
- job.reject(new Error('WorkerScanner terminated.'));
139
- }
140
- this._queue = [];
141
-
142
- console.log('[Agent Shield] WorkerScanner terminated (completed: %d, errors: %d)', this._completedJobs, this._errorCount);
143
- }
144
-
145
- /**
146
- * Run a single scan with timeout and event loop yielding.
147
- * @param {string} text
148
- * @param {object} options
149
- * @returns {Promise<object>}
150
- * @private
151
- */
152
- async _runScan(text, options) {
153
- this._activeWorkers++;
154
-
155
- try {
156
- // Yield to the event loop before starting CPU work
157
- await yieldToEventLoop();
158
-
159
- const result = await this._withTimeout(() => {
160
- return scanText(text, options);
161
- }, this.timeout);
162
-
163
- this._completedJobs++;
164
-
165
- // Yield after completing CPU work
166
- await yieldToEventLoop();
167
-
168
- return result;
169
- } catch (err) {
170
- this._errorCount++;
171
- throw err;
172
- } finally {
173
- this._activeWorkers--;
174
- }
175
- }
176
-
177
- /**
178
- * Run a function with a timeout.
179
- * @param {Function} fn - Synchronous function to run.
180
- * @param {number} timeoutMs - Timeout in milliseconds.
181
- * @returns {Promise<*>} Result of the function.
182
- * @private
183
- */
184
- _withTimeout(fn, timeoutMs) {
185
- return new Promise((resolve, reject) => {
186
- const timer = setTimeout(() => {
187
- reject(new Error(`Scan timed out after ${timeoutMs}ms`));
188
- }, timeoutMs);
189
-
190
- try {
191
- const result = fn();
192
- clearTimeout(timer);
193
- resolve(result);
194
- } catch (err) {
195
- clearTimeout(timer);
196
- reject(err);
197
- }
198
- });
199
- }
200
- }
201
-
202
- // =========================================================================
203
- // SCAN QUEUE
204
- // =========================================================================
205
-
206
- /**
207
- * Priority queue for managing scan jobs with concurrency control,
208
- * pause/resume, and drain support.
209
- */
210
- class ScanQueue {
211
- /**
212
- * @param {object} [options]
213
- * @param {number} [options.concurrency=4] - Maximum concurrent scans.
214
- * @param {number} [options.maxQueue=10000] - Maximum queued items.
215
- */
216
- constructor(options = {}) {
217
- this.concurrency = options.concurrency || 4;
218
- this.maxQueue = options.maxQueue || 10000;
219
-
220
- this._queue = [];
221
- this._activeCount = 0;
222
- this._paused = false;
223
- this._totalEnqueued = 0;
224
- this._totalProcessed = 0;
225
- this._totalErrors = 0;
226
- this._latencySum = 0;
227
- this._drainCallbacks = [];
228
-
229
- console.log('[Agent Shield] ScanQueue initialized (concurrency: %d, maxQueue: %d)', this.concurrency, this.maxQueue);
230
- }
231
-
232
- /**
233
- * Add a scan job to the queue.
234
- * @param {string} text - The text to scan.
235
- * @param {object} [options] - Scan options passed to scanText.
236
- * @param {number} [priority=0] - Priority (higher = processed first).
237
- * @returns {Promise<object>} Promise that resolves with the scan result.
238
- */
239
- async enqueue(text, options = {}, priority = 0) {
240
- if (this._queue.length >= this.maxQueue) {
241
- throw new Error(`ScanQueue is full (${this.maxQueue} items). Rejecting new scan.`);
242
- }
243
-
244
- const deferred = createDeferred();
245
- const job = {
246
- text,
247
- options,
248
- priority,
249
- enqueuedAt: Date.now(),
250
- deferred
251
- };
252
-
253
- this._queue.push(job);
254
- this._totalEnqueued++;
255
-
256
- // Sort by priority (descending) — highest priority first
257
- this._queue.sort((a, b) => b.priority - a.priority);
258
-
259
- // Try to process
260
- this._processNext();
261
-
262
- return deferred.promise;
263
- }
264
-
265
- /**
266
- * Pause queue processing. In-flight scans will complete, but no new scans start.
267
- */
268
- pause() {
269
- this._paused = true;
270
- console.log('[Agent Shield] ScanQueue paused');
271
- }
272
-
273
- /**
274
- * Resume queue processing.
275
- */
276
- resume() {
277
- this._paused = false;
278
- console.log('[Agent Shield] ScanQueue resumed');
279
- this._processNext();
280
- }
281
-
282
- /**
283
- * Wait for all pending and in-flight jobs to complete.
284
- * @returns {Promise<void>}
285
- */
286
- drain() {
287
- if (this._queue.length === 0 && this._activeCount === 0) {
288
- return Promise.resolve();
289
- }
290
-
291
- return new Promise(resolve => {
292
- this._drainCallbacks.push(resolve);
293
- });
294
- }
295
-
296
- /**
297
- * Get queue statistics.
298
- * @returns {object} Stats: { depth, active, processed, errors, avgLatencyMs, paused }.
299
- */
300
- getStats() {
301
- const avgLatencyMs = this._totalProcessed > 0
302
- ? Math.round(this._latencySum / this._totalProcessed)
303
- : 0;
304
-
305
- return {
306
- depth: this._queue.length,
307
- active: this._activeCount,
308
- processed: this._totalProcessed,
309
- errors: this._totalErrors,
310
- avgLatencyMs,
311
- paused: this._paused,
312
- totalEnqueued: this._totalEnqueued,
313
- maxQueue: this.maxQueue,
314
- concurrency: this.concurrency
315
- };
316
- }
317
-
318
- /**
319
- * Process the next job in the queue if concurrency allows.
320
- * @private
321
- */
322
- _processNext() {
323
- if (this._paused) return;
324
- if (this._activeCount >= this.concurrency) return;
325
- if (this._queue.length === 0) {
326
- this._checkDrain();
327
- return;
328
- }
329
-
330
- const job = this._queue.shift();
331
- this._activeCount++;
332
-
333
- // Use setImmediate/setTimeout to avoid blocking the event loop
334
- const run = async () => {
335
- const startTime = Date.now();
336
-
337
- try {
338
- // Yield before CPU work
339
- await yieldToEventLoop();
340
-
341
- const result = scanText(job.text, job.options);
342
- const latency = Date.now() - job.enqueuedAt;
343
-
344
- this._latencySum += latency;
345
- this._totalProcessed++;
346
-
347
- job.deferred.resolve(result);
348
- } catch (err) {
349
- this._totalErrors++;
350
- job.deferred.reject(err);
351
- } finally {
352
- this._activeCount--;
353
-
354
- // Yield after CPU work, then try next
355
- await yieldToEventLoop();
356
- this._processNext();
357
- }
358
- };
359
-
360
- run();
361
- }
362
-
363
- /**
364
- * Check if the queue has drained and notify any waiting callbacks.
365
- * @private
366
- */
367
- _checkDrain() {
368
- if (this._queue.length === 0 && this._activeCount === 0) {
369
- const callbacks = this._drainCallbacks;
370
- this._drainCallbacks = [];
371
- for (const cb of callbacks) {
372
- cb();
373
- }
374
- }
375
- }
376
- }
377
-
378
- // =========================================================================
379
- // THREADED WORKER SCANNER (opt-in, uses worker_threads)
380
- // =========================================================================
381
-
382
- /**
383
- * Worker scanner using real Node.js worker_threads for true parallel scanning.
384
- * Falls back to WorkerScanner (async yield) if worker_threads is unavailable.
385
- *
386
- * Usage:
387
- * const scanner = new ThreadedWorkerScanner({ poolSize: 4 });
388
- * const result = await scanner.scan('some text');
389
- * scanner.terminate();
390
- *
391
- * NOTE: This uses worker_threads which requires Node.js >= 12. The zero-dep
392
- * constraint is maintained since worker_threads is a Node.js built-in.
393
- */
394
- class ThreadedWorkerScanner {
395
- /**
396
- * @param {object} [options]
397
- * @param {number} [options.poolSize=2] - Number of worker threads.
398
- * @param {number} [options.timeout=5000] - Per-scan timeout in ms.
399
- */
400
- constructor(options = {}) {
401
- this.poolSize = options.poolSize || 2;
402
- this.timeout = options.timeout || 5000;
403
- this._workers = [];
404
- this._queue = [];
405
- this._completedJobs = 0;
406
- this._errorCount = 0;
407
- this._terminated = false;
408
- this._workerThreadsAvailable = false;
409
-
410
- try {
411
- this._workerThreadsModule = require('worker_threads');
412
- if (this._workerThreadsModule.isMainThread) {
413
- this._workerThreadsAvailable = true;
414
- this._initWorkers();
415
- }
416
- } catch (e) {
417
- console.log('[Agent Shield] worker_threads not available, falling back to async mode.');
418
- }
419
-
420
- if (!this._workerThreadsAvailable) {
421
- this._fallback = new WorkerScanner({
422
- poolSize: this.poolSize,
423
- timeout: this.timeout
424
- });
425
- }
426
-
427
- console.log('[Agent Shield] ThreadedWorkerScanner initialized (poolSize: %d, threaded: %s)', this.poolSize, this._workerThreadsAvailable);
428
- }
429
-
430
- /**
431
- * Initialize the worker thread pool.
432
- * @private
433
- */
434
- _initWorkers() {
435
- const { Worker } = this._workerThreadsModule;
436
- const workerScript = `
437
- const { parentPort } = require('worker_threads');
438
- const { scanText } = require('${require('path').resolve(__dirname, 'detector-core.js').replace(/\\/g, '\\\\')}');
439
-
440
- parentPort.on('message', (msg) => {
441
- try {
442
- const result = scanText(msg.text, msg.options || {});
443
- parentPort.postMessage({ id: msg.id, result, error: null });
444
- } catch (err) {
445
- parentPort.postMessage({ id: msg.id, result: null, error: err.message });
446
- }
447
- });
448
- `;
449
-
450
- for (let i = 0; i < this.poolSize; i++) {
451
- const worker = new Worker(workerScript, { eval: true });
452
- worker._busy = false;
453
- worker._currentJob = null;
454
-
455
- worker.on('message', (msg) => {
456
- const job = worker._currentJob;
457
- worker._busy = false;
458
- worker._currentJob = null;
459
-
460
- if (job) {
461
- clearTimeout(job.timer);
462
- if (msg.error) {
463
- this._errorCount++;
464
- job.reject(new Error(msg.error));
465
- } else {
466
- this._completedJobs++;
467
- job.resolve(msg.result);
468
- }
469
- }
470
-
471
- this._processQueue();
472
- });
473
-
474
- worker.on('error', (err) => {
475
- const job = worker._currentJob;
476
- worker._busy = false;
477
- worker._currentJob = null;
478
- this._errorCount++;
479
-
480
- if (job) {
481
- clearTimeout(job.timer);
482
- job.reject(err);
483
- }
484
-
485
- this._processQueue();
486
- });
487
-
488
- this._workers.push(worker);
489
- }
490
- }
491
-
492
- /**
493
- * Scan text using a worker thread (or fallback).
494
- * @param {string} text - The text to scan.
495
- * @param {object} [options] - Scan options passed to scanText.
496
- * @returns {Promise<object>} Scan result.
497
- */
498
- async scan(text, options = {}) {
499
- if (this._terminated) {
500
- throw new Error('ThreadedWorkerScanner has been terminated.');
501
- }
502
-
503
- if (!this._workerThreadsAvailable) {
504
- return this._fallback.scan(text, options);
505
- }
506
-
507
- return new Promise((resolve, reject) => {
508
- const id = ++this._completedJobs + this._errorCount + this._queue.length;
509
- const job = { id, text, options, resolve, reject, timer: null };
510
-
511
- job.timer = setTimeout(() => {
512
- reject(new Error(`Scan timed out after ${this.timeout}ms`));
513
- }, this.timeout);
514
-
515
- this._queue.push(job);
516
- this._processQueue();
517
- });
518
- }
519
-
520
- /**
521
- * Scan multiple texts in parallel.
522
- * @param {string[]} texts - Array of texts to scan.
523
- * @param {object} [options] - Scan options.
524
- * @returns {Promise<object[]>} Array of scan results.
525
- */
526
- async scanBatch(texts, options = {}) {
527
- if (!Array.isArray(texts) || texts.length === 0) return [];
528
- return Promise.all(texts.map(text => this.scan(text, options)));
529
- }
530
-
531
- /**
532
- * Process queued jobs by dispatching to idle workers.
533
- * @private
534
- */
535
- _processQueue() {
536
- if (this._queue.length === 0) return;
537
-
538
- for (const worker of this._workers) {
539
- if (!worker._busy && this._queue.length > 0) {
540
- const job = this._queue.shift();
541
- worker._busy = true;
542
- worker._currentJob = job;
543
- worker.postMessage({ id: job.id, text: job.text, options: job.options });
544
- }
545
- }
546
- }
547
-
548
- /**
549
- * Get pool statistics.
550
- * @returns {object}
551
- */
552
- getStats() {
553
- if (!this._workerThreadsAvailable && this._fallback) {
554
- return { ...this._fallback.getStats(), threaded: false };
555
- }
556
-
557
- return {
558
- activeWorkers: this._workers.filter(w => w._busy).length,
559
- queuedJobs: this._queue.length,
560
- completed: this._completedJobs,
561
- errors: this._errorCount,
562
- poolSize: this.poolSize,
563
- terminated: this._terminated,
564
- threaded: true
565
- };
566
- }
567
-
568
- /**
569
- * Shut down all worker threads.
570
- */
571
- terminate() {
572
- this._terminated = true;
573
-
574
- if (this._workerThreadsAvailable) {
575
- for (const worker of this._workers) {
576
- if (worker._currentJob) {
577
- clearTimeout(worker._currentJob.timer);
578
- worker._currentJob.reject(new Error('ThreadedWorkerScanner terminated.'));
579
- }
580
- worker.terminate();
581
- }
582
- this._workers = [];
583
- } else if (this._fallback) {
584
- this._fallback.terminate();
585
- }
586
-
587
- for (const job of this._queue) {
588
- clearTimeout(job.timer);
589
- job.reject(new Error('ThreadedWorkerScanner terminated.'));
590
- }
591
- this._queue = [];
592
-
593
- console.log('[Agent Shield] ThreadedWorkerScanner terminated (completed: %d, errors: %d)', this._completedJobs, this._errorCount);
594
- }
595
- }
596
-
597
- // =========================================================================
598
- // EXPORTS
599
- // =========================================================================
600
-
601
- module.exports = { WorkerScanner, ScanQueue, ThreadedWorkerScanner };
1
+ 'use strict';
2
+
3
+ /**
4
+ * Agent Shield — Worker Scanner
5
+ *
6
+ * Async scanning for non-blocking operation. Uses setImmediate/setTimeout to
7
+ * yield to the event loop between scans, preventing long-running scans from
8
+ * blocking the main thread.
9
+ *
10
+ * NOTE: This implementation uses async wrappers around the synchronous scanner
11
+ * with event loop yielding. In production environments requiring true parallel
12
+ * CPU-bound scanning, you can swap in Node.js worker_threads by replacing the
13
+ * _runInWorker method with actual Worker thread dispatch.
14
+ *
15
+ * All detection runs locally — no data ever leaves your environment.
16
+ */
17
+
18
+ const { scanText } = require('./detector-core');
19
+
20
+ // =========================================================================
21
+ // HELPERS
22
+ // =========================================================================
23
+
24
+ /**
25
+ * Yield to the event loop. Uses setImmediate when available, falls back to setTimeout.
26
+ * @returns {Promise<void>}
27
+ */
28
+ const yieldToEventLoop = () => new Promise(resolve => {
29
+ if (typeof setImmediate === 'function') {
30
+ setImmediate(resolve);
31
+ } else {
32
+ setTimeout(resolve, 0);
33
+ }
34
+ });
35
+
36
+ /**
37
+ * Create a deferred promise with external resolve/reject.
38
+ * @returns {{ promise: Promise, resolve: Function, reject: Function }}
39
+ */
40
+ function createDeferred() {
41
+ let resolve, reject;
42
+ const promise = new Promise((res, rej) => {
43
+ resolve = res;
44
+ reject = rej;
45
+ });
46
+ return { promise, resolve, reject };
47
+ }
48
+
49
+ // =========================================================================
50
+ // WORKER SCANNER
51
+ // =========================================================================
52
+
53
+ /**
54
+ * Async scanner that runs scans without blocking the event loop.
55
+ * Manages a virtual "pool" with concurrency control and timeout support.
56
+ */
57
+ class WorkerScanner {
58
+ /**
59
+ * @param {object} [options]
60
+ * @param {number} [options.poolSize=2] - Maximum concurrent scans.
61
+ * @param {number} [options.timeout=5000] - Per-scan timeout in milliseconds.
62
+ */
63
+ constructor(options = {}) {
64
+ this.poolSize = options.poolSize || 2;
65
+ this.timeout = options.timeout || 5000;
66
+
67
+ this._activeWorkers = 0;
68
+ this._completedJobs = 0;
69
+ this._errorCount = 0;
70
+ this._queue = [];
71
+ this._terminated = false;
72
+
73
+ console.log('[Agent Shield] WorkerScanner initialized (poolSize: %d, timeout: %dms)', this.poolSize, this.timeout);
74
+ }
75
+
76
+ /**
77
+ * Scan text asynchronously without blocking the event loop.
78
+ * @param {string} text - The text to scan.
79
+ * @param {object} [options] - Scan options passed to scanText.
80
+ * @returns {Promise<object>} Scan result from detector-core.
81
+ */
82
+ async scan(text, options = {}) {
83
+ if (this._terminated) {
84
+ throw new Error('WorkerScanner has been terminated.');
85
+ }
86
+
87
+ // Wait for an available slot using a notification pattern instead of spinning
88
+ if (this._activeWorkers >= this.poolSize) {
89
+ await new Promise(resolve => {
90
+ this._queue.push(resolve);
91
+ });
92
+ }
93
+
94
+ return this._runScan(text, options);
95
+ }
96
+
97
+ /**
98
+ * Scan multiple texts in parallel using the worker pool.
99
+ * @param {string[]} texts - Array of texts to scan.
100
+ * @param {object} [options] - Scan options passed to scanText.
101
+ * @returns {Promise<object[]>} Array of scan results.
102
+ */
103
+ async scanBatch(texts, options = {}) {
104
+ if (this._terminated) {
105
+ throw new Error('WorkerScanner has been terminated.');
106
+ }
107
+
108
+ if (!Array.isArray(texts) || texts.length === 0) {
109
+ return [];
110
+ }
111
+
112
+ // Launch all scans, concurrency is managed inside _runScan
113
+ const promises = texts.map(text => this.scan(text, options));
114
+ return Promise.all(promises);
115
+ }
116
+
117
+ /**
118
+ * Get pool statistics.
119
+ * @returns {object} Stats: { activeWorkers, queuedJobs, completed, errors, poolSize, terminated }.
120
+ */
121
+ getStats() {
122
+ return {
123
+ activeWorkers: this._activeWorkers,
124
+ queuedJobs: this._queue.length,
125
+ completed: this._completedJobs,
126
+ errors: this._errorCount,
127
+ poolSize: this.poolSize,
128
+ terminated: this._terminated
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Shut down the worker pool. Pending scans will be rejected.
134
+ */
135
+ terminate() {
136
+ this._terminated = true;
137
+
138
+ // Reject any queued jobs
139
+ for (const job of this._queue) {
140
+ job.reject(new Error('WorkerScanner terminated.'));
141
+ }
142
+ this._queue = [];
143
+
144
+ console.log('[Agent Shield] WorkerScanner terminated (completed: %d, errors: %d)', this._completedJobs, this._errorCount);
145
+ }
146
+
147
+ /**
148
+ * Run a single scan with timeout and event loop yielding.
149
+ * @param {string} text
150
+ * @param {object} options
151
+ * @returns {Promise<object>}
152
+ * @private
153
+ */
154
+ async _runScan(text, options) {
155
+ this._activeWorkers++;
156
+
157
+ try {
158
+ // Yield to the event loop before starting CPU work
159
+ await yieldToEventLoop();
160
+
161
+ const result = await this._withTimeout(() => {
162
+ return scanText(text, options);
163
+ }, this.timeout);
164
+
165
+ this._completedJobs++;
166
+
167
+ // Yield after completing CPU work
168
+ await yieldToEventLoop();
169
+
170
+ return result;
171
+ } catch (err) {
172
+ this._errorCount++;
173
+ throw err;
174
+ } finally {
175
+ this._activeWorkers--;
176
+ // Notify a waiting scan that a slot is available
177
+ if (this._queue.length > 0) {
178
+ const next = this._queue.shift();
179
+ next();
180
+ }
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Run a function with a timeout.
186
+ * @param {Function} fn - Synchronous function to run.
187
+ * @param {number} timeoutMs - Timeout in milliseconds.
188
+ * @returns {Promise<*>} Result of the function.
189
+ * @private
190
+ */
191
+ _withTimeout(fn, timeoutMs) {
192
+ return new Promise((resolve, reject) => {
193
+ const timer = setTimeout(() => {
194
+ reject(new Error(`Scan timed out after ${timeoutMs}ms`));
195
+ }, timeoutMs);
196
+
197
+ try {
198
+ const result = fn();
199
+ clearTimeout(timer);
200
+ resolve(result);
201
+ } catch (err) {
202
+ clearTimeout(timer);
203
+ reject(err);
204
+ }
205
+ });
206
+ }
207
+ }
208
+
209
+ // =========================================================================
210
+ // SCAN QUEUE
211
+ // =========================================================================
212
+
213
+ /**
214
+ * Priority queue for managing scan jobs with concurrency control,
215
+ * pause/resume, and drain support.
216
+ */
217
+ class ScanQueue {
218
+ /**
219
+ * @param {object} [options]
220
+ * @param {number} [options.concurrency=4] - Maximum concurrent scans.
221
+ * @param {number} [options.maxQueue=10000] - Maximum queued items.
222
+ */
223
+ constructor(options = {}) {
224
+ this.concurrency = options.concurrency || 4;
225
+ this.maxQueue = options.maxQueue || 10000;
226
+
227
+ this._queue = [];
228
+ this._activeCount = 0;
229
+ this._paused = false;
230
+ this._totalEnqueued = 0;
231
+ this._totalProcessed = 0;
232
+ this._totalErrors = 0;
233
+ this._latencySum = 0;
234
+ this._drainCallbacks = [];
235
+
236
+ console.log('[Agent Shield] ScanQueue initialized (concurrency: %d, maxQueue: %d)', this.concurrency, this.maxQueue);
237
+ }
238
+
239
+ /**
240
+ * Add a scan job to the queue.
241
+ * @param {string} text - The text to scan.
242
+ * @param {object} [options] - Scan options passed to scanText.
243
+ * @param {number} [priority=0] - Priority (higher = processed first).
244
+ * @returns {Promise<object>} Promise that resolves with the scan result.
245
+ */
246
+ async enqueue(text, options = {}, priority = 0) {
247
+ if (this._queue.length >= this.maxQueue) {
248
+ throw new Error(`ScanQueue is full (${this.maxQueue} items). Rejecting new scan.`);
249
+ }
250
+
251
+ const deferred = createDeferred();
252
+ const job = {
253
+ text,
254
+ options,
255
+ priority,
256
+ enqueuedAt: Date.now(),
257
+ deferred
258
+ };
259
+
260
+ this._queue.push(job);
261
+ this._totalEnqueued++;
262
+
263
+ // Sort by priority (descending) — highest priority first
264
+ this._queue.sort((a, b) => b.priority - a.priority);
265
+
266
+ // Try to process
267
+ this._processNext();
268
+
269
+ return deferred.promise;
270
+ }
271
+
272
+ /**
273
+ * Pause queue processing. In-flight scans will complete, but no new scans start.
274
+ */
275
+ pause() {
276
+ this._paused = true;
277
+ console.log('[Agent Shield] ScanQueue paused');
278
+ }
279
+
280
+ /**
281
+ * Resume queue processing.
282
+ */
283
+ resume() {
284
+ this._paused = false;
285
+ console.log('[Agent Shield] ScanQueue resumed');
286
+ this._processNext();
287
+ }
288
+
289
+ /**
290
+ * Wait for all pending and in-flight jobs to complete.
291
+ * @returns {Promise<void>}
292
+ */
293
+ drain() {
294
+ if (this._queue.length === 0 && this._activeCount === 0) {
295
+ return Promise.resolve();
296
+ }
297
+
298
+ return new Promise(resolve => {
299
+ this._drainCallbacks.push(resolve);
300
+ });
301
+ }
302
+
303
+ /**
304
+ * Get queue statistics.
305
+ * @returns {object} Stats: { depth, active, processed, errors, avgLatencyMs, paused }.
306
+ */
307
+ getStats() {
308
+ const avgLatencyMs = this._totalProcessed > 0
309
+ ? Math.round(this._latencySum / this._totalProcessed)
310
+ : 0;
311
+
312
+ return {
313
+ depth: this._queue.length,
314
+ active: this._activeCount,
315
+ processed: this._totalProcessed,
316
+ errors: this._totalErrors,
317
+ avgLatencyMs,
318
+ paused: this._paused,
319
+ totalEnqueued: this._totalEnqueued,
320
+ maxQueue: this.maxQueue,
321
+ concurrency: this.concurrency
322
+ };
323
+ }
324
+
325
+ /**
326
+ * Process the next job in the queue if concurrency allows.
327
+ * @private
328
+ */
329
+ _processNext() {
330
+ if (this._paused) return;
331
+ if (this._activeCount >= this.concurrency) return;
332
+ if (this._queue.length === 0) {
333
+ this._checkDrain();
334
+ return;
335
+ }
336
+
337
+ const job = this._queue.shift();
338
+ this._activeCount++;
339
+
340
+ // Use setImmediate/setTimeout to avoid blocking the event loop
341
+ const run = async () => {
342
+ const startTime = Date.now();
343
+
344
+ try {
345
+ // Yield before CPU work
346
+ await yieldToEventLoop();
347
+
348
+ const result = scanText(job.text, job.options);
349
+ const latency = Date.now() - job.enqueuedAt;
350
+
351
+ this._latencySum += latency;
352
+ this._totalProcessed++;
353
+
354
+ job.deferred.resolve(result);
355
+ } catch (err) {
356
+ this._totalErrors++;
357
+ job.deferred.reject(err);
358
+ } finally {
359
+ this._activeCount--;
360
+
361
+ // Yield after CPU work, then try next
362
+ await yieldToEventLoop();
363
+ this._processNext();
364
+ }
365
+ };
366
+
367
+ run();
368
+ }
369
+
370
+ /**
371
+ * Check if the queue has drained and notify any waiting callbacks.
372
+ * @private
373
+ */
374
+ _checkDrain() {
375
+ if (this._queue.length === 0 && this._activeCount === 0) {
376
+ const callbacks = this._drainCallbacks;
377
+ this._drainCallbacks = [];
378
+ for (const cb of callbacks) {
379
+ cb();
380
+ }
381
+ }
382
+ }
383
+ }
384
+
385
+ // =========================================================================
386
+ // THREADED WORKER SCANNER (opt-in, uses worker_threads)
387
+ // =========================================================================
388
+
389
+ /**
390
+ * Worker scanner using real Node.js worker_threads for true parallel scanning.
391
+ * Falls back to WorkerScanner (async yield) if worker_threads is unavailable.
392
+ *
393
+ * Usage:
394
+ * const scanner = new ThreadedWorkerScanner({ poolSize: 4 });
395
+ * const result = await scanner.scan('some text');
396
+ * scanner.terminate();
397
+ *
398
+ * NOTE: This uses worker_threads which requires Node.js >= 12. The zero-dep
399
+ * constraint is maintained since worker_threads is a Node.js built-in.
400
+ */
401
+ class ThreadedWorkerScanner {
402
+ /**
403
+ * @param {object} [options]
404
+ * @param {number} [options.poolSize=2] - Number of worker threads.
405
+ * @param {number} [options.timeout=5000] - Per-scan timeout in ms.
406
+ */
407
+ constructor(options = {}) {
408
+ this.poolSize = options.poolSize || 2;
409
+ this.timeout = options.timeout || 5000;
410
+ this._workers = [];
411
+ this._queue = [];
412
+ this._completedJobs = 0;
413
+ this._errorCount = 0;
414
+ this._terminated = false;
415
+ this._workerThreadsAvailable = false;
416
+
417
+ try {
418
+ this._workerThreadsModule = require('worker_threads');
419
+ if (this._workerThreadsModule.isMainThread) {
420
+ this._workerThreadsAvailable = true;
421
+ this._initWorkers();
422
+ }
423
+ } catch (e) {
424
+ console.log('[Agent Shield] worker_threads not available, falling back to async mode.');
425
+ }
426
+
427
+ if (!this._workerThreadsAvailable) {
428
+ this._fallback = new WorkerScanner({
429
+ poolSize: this.poolSize,
430
+ timeout: this.timeout
431
+ });
432
+ }
433
+
434
+ console.log('[Agent Shield] ThreadedWorkerScanner initialized (poolSize: %d, threaded: %s)', this.poolSize, this._workerThreadsAvailable);
435
+ }
436
+
437
+ /**
438
+ * Initialize the worker thread pool.
439
+ * @private
440
+ */
441
+ _initWorkers() {
442
+ const { Worker } = this._workerThreadsModule;
443
+ const workerScript = `
444
+ const { parentPort } = require('worker_threads');
445
+ const { scanText } = require('${require('path').resolve(__dirname, 'detector-core.js').replace(/\\/g, '\\\\')}');
446
+
447
+ parentPort.on('message', (msg) => {
448
+ try {
449
+ const result = scanText(msg.text, msg.options || {});
450
+ parentPort.postMessage({ id: msg.id, result, error: null });
451
+ } catch (err) {
452
+ parentPort.postMessage({ id: msg.id, result: null, error: err.message });
453
+ }
454
+ });
455
+ `;
456
+
457
+ for (let i = 0; i < this.poolSize; i++) {
458
+ const worker = new Worker(workerScript, { eval: true });
459
+ worker._busy = false;
460
+ worker._currentJob = null;
461
+
462
+ worker.on('message', (msg) => {
463
+ const job = worker._currentJob;
464
+ worker._busy = false;
465
+ worker._currentJob = null;
466
+
467
+ if (job) {
468
+ clearTimeout(job.timer);
469
+ if (msg.error) {
470
+ this._errorCount++;
471
+ job.reject(new Error(msg.error));
472
+ } else {
473
+ this._completedJobs++;
474
+ job.resolve(msg.result);
475
+ }
476
+ }
477
+
478
+ this._processQueue();
479
+ });
480
+
481
+ worker.on('error', (err) => {
482
+ const job = worker._currentJob;
483
+ worker._busy = false;
484
+ worker._currentJob = null;
485
+ this._errorCount++;
486
+
487
+ if (job) {
488
+ clearTimeout(job.timer);
489
+ job.reject(err);
490
+ }
491
+
492
+ this._processQueue();
493
+ });
494
+
495
+ this._workers.push(worker);
496
+ }
497
+ }
498
+
499
+ /**
500
+ * Scan text using a worker thread (or fallback).
501
+ * @param {string} text - The text to scan.
502
+ * @param {object} [options] - Scan options passed to scanText.
503
+ * @returns {Promise<object>} Scan result.
504
+ */
505
+ async scan(text, options = {}) {
506
+ if (this._terminated) {
507
+ throw new Error('ThreadedWorkerScanner has been terminated.');
508
+ }
509
+
510
+ if (!this._workerThreadsAvailable) {
511
+ return this._fallback.scan(text, options);
512
+ }
513
+
514
+ return new Promise((resolve, reject) => {
515
+ const id = this._completedJobs + this._errorCount + this._queue.length + 1;
516
+ const job = { id, text, options, resolve, reject, timer: null };
517
+
518
+ job.timer = setTimeout(() => {
519
+ reject(new Error(`Scan timed out after ${this.timeout}ms`));
520
+ }, this.timeout);
521
+
522
+ this._queue.push(job);
523
+ this._processQueue();
524
+ });
525
+ }
526
+
527
+ /**
528
+ * Scan multiple texts in parallel.
529
+ * @param {string[]} texts - Array of texts to scan.
530
+ * @param {object} [options] - Scan options.
531
+ * @returns {Promise<object[]>} Array of scan results.
532
+ */
533
+ async scanBatch(texts, options = {}) {
534
+ if (!Array.isArray(texts) || texts.length === 0) return [];
535
+ return Promise.all(texts.map(text => this.scan(text, options)));
536
+ }
537
+
538
+ /**
539
+ * Process queued jobs by dispatching to idle workers.
540
+ * @private
541
+ */
542
+ _processQueue() {
543
+ if (this._queue.length === 0) return;
544
+
545
+ for (const worker of this._workers) {
546
+ if (!worker._busy && this._queue.length > 0) {
547
+ const job = this._queue.shift();
548
+ worker._busy = true;
549
+ worker._currentJob = job;
550
+ worker.postMessage({ id: job.id, text: job.text, options: job.options });
551
+ }
552
+ }
553
+ }
554
+
555
+ /**
556
+ * Get pool statistics.
557
+ * @returns {object}
558
+ */
559
+ getStats() {
560
+ if (!this._workerThreadsAvailable && this._fallback) {
561
+ return { ...this._fallback.getStats(), threaded: false };
562
+ }
563
+
564
+ return {
565
+ activeWorkers: this._workers.filter(w => w._busy).length,
566
+ queuedJobs: this._queue.length,
567
+ completed: this._completedJobs,
568
+ errors: this._errorCount,
569
+ poolSize: this.poolSize,
570
+ terminated: this._terminated,
571
+ threaded: true
572
+ };
573
+ }
574
+
575
+ /**
576
+ * Shut down all worker threads.
577
+ */
578
+ terminate() {
579
+ this._terminated = true;
580
+
581
+ if (this._workerThreadsAvailable) {
582
+ for (const worker of this._workers) {
583
+ if (worker._currentJob) {
584
+ clearTimeout(worker._currentJob.timer);
585
+ worker._currentJob.reject(new Error('ThreadedWorkerScanner terminated.'));
586
+ }
587
+ worker.terminate();
588
+ }
589
+ this._workers = [];
590
+ } else if (this._fallback) {
591
+ this._fallback.terminate();
592
+ }
593
+
594
+ for (const job of this._queue) {
595
+ clearTimeout(job.timer);
596
+ job.reject(new Error('ThreadedWorkerScanner terminated.'));
597
+ }
598
+ this._queue = [];
599
+
600
+ console.log('[Agent Shield] ThreadedWorkerScanner terminated (completed: %d, errors: %d)', this._completedJobs, this._errorCount);
601
+ }
602
+ }
603
+
604
+ // =========================================================================
605
+ // EXPORTS
606
+ // =========================================================================
607
+
608
+ module.exports = { WorkerScanner, ScanQueue, ThreadedWorkerScanner };