@privacyscrubber/sdk 2.1.0 → 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +217 -2
- package/cli.js +130 -0
- package/index.d.ts +178 -0
- package/index.js +604 -11
- package/package.json +7 -1
- package/ps-license-manager.js +1 -1
- package/ps-pii-engine.cjs +1 -1
- package/scrubber-core.cjs +1 -1
package/index.js
CHANGED
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
|
|
11
11
|
import { createRequire } from 'module';
|
|
12
12
|
const require = createRequire(import.meta.url);
|
|
13
|
+
import fs from 'fs';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import { execSync } from 'child_process';
|
|
16
|
+
import { Transform } from 'stream';
|
|
13
17
|
|
|
14
18
|
import './polyfill.js';
|
|
15
19
|
import './ps-license-manager.js';
|
|
@@ -160,7 +164,8 @@ export function buildAuditTelemetry(currentTokenMap = {}) {
|
|
|
160
164
|
*/
|
|
161
165
|
export function formatAuditReceipt(telemetry) {
|
|
162
166
|
if (!telemetry || telemetry.totalCount === 0) {
|
|
163
|
-
return "> 🛡️ **PrivacyScrubber Audit Receipt**: CLEAN (ZERO PII DETECTED)\n"
|
|
167
|
+
return "> 🛡️ **PrivacyScrubber Audit Receipt**: CLEAN (ZERO PII DETECTED)\n" +
|
|
168
|
+
"> * ⭐ **Star on GitHub:** [moxno/privacyscrubber-mcp](https://github.com/moxno/privacyscrubber-mcp) | **SDK & Enterprise:** [privacyscrubber.com/pricing](https://privacyscrubber.com/pricing)\n";
|
|
164
169
|
}
|
|
165
170
|
const entitiesList = Object.entries(telemetry.entities)
|
|
166
171
|
.map(([type, count]) => `${count} ${type}`)
|
|
@@ -171,22 +176,42 @@ export function formatAuditReceipt(telemetry) {
|
|
|
171
176
|
return `> 🛡️ **PrivacyScrubber Audit Receipt**\n` +
|
|
172
177
|
`> * **Risk Level:** ${icon} ${telemetry.riskLevel}\n` +
|
|
173
178
|
`> * **Compliance Enforced:** ${telemetry.frameworksList.join(', ')}\n` +
|
|
174
|
-
`> * **Tokens Masked:** ${telemetry.totalCount} (${entitiesList})\n
|
|
179
|
+
`> * **Tokens Masked:** ${telemetry.totalCount} (${entitiesList})\n` +
|
|
180
|
+
`> * ⭐ **Star on GitHub:** [moxno/privacyscrubber-mcp](https://github.com/moxno/privacyscrubber-mcp) | **SDK & Enterprise:** [privacyscrubber.com/pricing](https://privacyscrubber.com/pricing)\n`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
let communityNoticeLogged = false;
|
|
184
|
+
|
|
185
|
+
function emitCommunityNoticeIfNeeded(isPro) {
|
|
186
|
+
if (communityNoticeLogged || isPro) return;
|
|
187
|
+
if (typeof process !== 'undefined' && process.env?.NODE_ENV === 'production') {
|
|
188
|
+
communityNoticeLogged = true;
|
|
189
|
+
console.info('[@privacyscrubber/sdk] Running Community Edition (Core PII).\nTo unlock 25 Enterprise Profiles & DevOps Secrets in production: https://privacyscrubber.com/pricing/?tier=sdk');
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Resets the community notice state for testing purposes.
|
|
195
|
+
* @private
|
|
196
|
+
*/
|
|
197
|
+
export function _resetCommunityNoticeForTesting() {
|
|
198
|
+
communityNoticeLogged = false;
|
|
175
199
|
}
|
|
176
200
|
|
|
177
201
|
/**
|
|
178
|
-
*
|
|
202
|
+
* Sanitizes input text, masking detected PII & secrets with reversible tokens.
|
|
179
203
|
*
|
|
180
|
-
* @param {string} text - Raw input text
|
|
181
|
-
* @param {object} [options={}] -
|
|
182
|
-
* @param {string} [options.profile='General'] -
|
|
204
|
+
* @param {string} text - Raw input text.
|
|
205
|
+
* @param {object} [options={}] - Sanitization options.
|
|
206
|
+
* @param {string} [options.profile='General'] - Compliance detection profile.
|
|
183
207
|
* @param {string} [options.licenseKey] - Optional license key. Falls back to process.env.PRIVACYSCRUBBER_KEY.
|
|
184
|
-
* @param {Array} [options.customRules=[]] - Custom regex rules
|
|
185
|
-
* @param {Record<string, string>} [options.tokenLabelMap={}] - Custom label
|
|
186
|
-
* @param {Record<string, string>} [options.existingSessionMap={}] -
|
|
187
|
-
* @param {boolean} [options.generateReceipt=true] -
|
|
208
|
+
* @param {Array<{name: string, pattern: string, regex: RegExp, label?: string}>} [options.customRules=[]] - Custom regex rules.
|
|
209
|
+
* @param {Record<string, string>} [options.tokenLabelMap={}] - Custom label mapping.
|
|
210
|
+
* @param {Record<string, string>} [options.existingSessionMap={}] - In-memory session map to preserve token IDs across turns.
|
|
211
|
+
* @param {boolean} [options.generateReceipt=true] - Generate a CISO Markdown audit receipt.
|
|
188
212
|
* @param {boolean} [options.throwOnLimit=true] - Throw PrivacyScrubberLicenseError when free tier limit is hit.
|
|
189
|
-
* @param {boolean} [options.detectSecrets=false] - Auto-
|
|
213
|
+
* @param {boolean} [options.detectSecrets=false] - Auto-detect cloud credentials and API keys.
|
|
214
|
+
* @param {Set<string>|Array<string>} [options.ignoreList=null] - Values to preserve in cleartext.
|
|
190
215
|
* @returns {{
|
|
191
216
|
* scrubbedText: string,
|
|
192
217
|
* tokenMap: Record<string, string>,
|
|
@@ -226,6 +251,8 @@ export function sanitize(text, options = {}) {
|
|
|
226
251
|
const isPro = licenseCheck.valid;
|
|
227
252
|
const tier = isPro ? licenseCheck.tier : 'FREE';
|
|
228
253
|
|
|
254
|
+
emitCommunityNoticeIfNeeded(isPro);
|
|
255
|
+
|
|
229
256
|
const isSpecialized = profile && profile.toLowerCase() !== 'general';
|
|
230
257
|
const limit = isPro ? Infinity : (isSpecialized ? 5000 : 15000);
|
|
231
258
|
|
|
@@ -302,6 +329,18 @@ export function sanitize(text, options = {}) {
|
|
|
302
329
|
*/
|
|
303
330
|
export const scrub = sanitize;
|
|
304
331
|
|
|
332
|
+
/**
|
|
333
|
+
* Ergonomic 1-liner: Sanitizes text and returns the scrubbed string directly.
|
|
334
|
+
* @param {string} text - Input text.
|
|
335
|
+
* @param {string} [profile='General'] - Target compliance profile.
|
|
336
|
+
* @returns {string} - Scrubbed string.
|
|
337
|
+
*/
|
|
338
|
+
export function clean(text, profile = 'General') {
|
|
339
|
+
if (!text || typeof text !== 'string') return '';
|
|
340
|
+
const { scrubbedText } = sanitize(text, { profile });
|
|
341
|
+
return scrubbedText;
|
|
342
|
+
}
|
|
343
|
+
|
|
305
344
|
/**
|
|
306
345
|
* Primary High-Level Function: Restore / Unscrub AI response back to original entities in-memory.
|
|
307
346
|
*
|
|
@@ -486,6 +525,198 @@ export function createLangChainTransform(options = {}) {
|
|
|
486
525
|
};
|
|
487
526
|
}
|
|
488
527
|
|
|
528
|
+
/**
|
|
529
|
+
* Creates a zero-latency streaming token restorer for LLM Server-Sent Events (SSE) and Web Streams.
|
|
530
|
+
* Solves the broken chunk boundary problem where tokens like [EMAIL_1] are split across multiple chunks.
|
|
531
|
+
*
|
|
532
|
+
* Works with:
|
|
533
|
+
* - Vercel AI SDK (streamText / createDataStreamResponse)
|
|
534
|
+
* - OpenAI streaming (chat.completions.create({ stream: true }))
|
|
535
|
+
* - LangChain streaming
|
|
536
|
+
* - Native Web Streams (ReadableStream.pipeThrough)
|
|
537
|
+
* - Async generators (for await ... of)
|
|
538
|
+
*
|
|
539
|
+
* @param {Record<string, string>} tokenMap - Volatile token mapping object.
|
|
540
|
+
* @param {object} [options] - Configuration options.
|
|
541
|
+
* @returns {TransformStream & { process: (chunk: string) => string, flush: () => string, wrapStream: (asyncIterable: AsyncIterable<string>) => AsyncGenerator<string> }}
|
|
542
|
+
*/
|
|
543
|
+
export function createStreamTokenRestorer(tokenMap = {}, options = {}) {
|
|
544
|
+
let pendingBuffer = '';
|
|
545
|
+
const map = tokenMap && typeof tokenMap === 'object' ? tokenMap : {};
|
|
546
|
+
|
|
547
|
+
function directRestore(text) {
|
|
548
|
+
if (!text) return '';
|
|
549
|
+
let result = text;
|
|
550
|
+
for (const token in map) {
|
|
551
|
+
if (result.includes(token)) {
|
|
552
|
+
result = result.split(token).join(map[token]);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return result;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function processChunk(chunk) {
|
|
559
|
+
if (!chunk || typeof chunk !== 'string') return '';
|
|
560
|
+
pendingBuffer += chunk;
|
|
561
|
+
|
|
562
|
+
// Check if there is an unclosed potential token bracket at the tail of the buffer.
|
|
563
|
+
// Token format: [TYPE_INDEX] or [CUSTOM_NAME] e.g. [EMAIL_1], [NAME_2], [SECRET_1]
|
|
564
|
+
const lastOpenIdx = pendingBuffer.lastIndexOf('[');
|
|
565
|
+
const lastCloseIdx = pendingBuffer.lastIndexOf(']');
|
|
566
|
+
|
|
567
|
+
if (lastOpenIdx !== -1 && lastOpenIdx > lastCloseIdx) {
|
|
568
|
+
const partial = pendingBuffer.slice(lastOpenIdx);
|
|
569
|
+
if (/^\[[A-Za-z0-9_]*$/.test(partial) && partial.length <= 64) {
|
|
570
|
+
const safeToEmit = pendingBuffer.slice(0, lastOpenIdx);
|
|
571
|
+
pendingBuffer = partial;
|
|
572
|
+
if (safeToEmit) {
|
|
573
|
+
return directRestore(safeToEmit);
|
|
574
|
+
}
|
|
575
|
+
return '';
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const textToRestore = pendingBuffer;
|
|
580
|
+
pendingBuffer = '';
|
|
581
|
+
return directRestore(textToRestore);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function flushBuffer() {
|
|
585
|
+
if (!pendingBuffer) return '';
|
|
586
|
+
const remaining = pendingBuffer;
|
|
587
|
+
pendingBuffer = '';
|
|
588
|
+
return directRestore(remaining);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
async function* wrapStream(asyncIterable) {
|
|
592
|
+
for await (const chunk of asyncIterable) {
|
|
593
|
+
const text = typeof chunk === 'string' ? chunk : (chunk?.text || chunk?.content || '');
|
|
594
|
+
const emitted = processChunk(text);
|
|
595
|
+
if (emitted) yield emitted;
|
|
596
|
+
}
|
|
597
|
+
const finalChunk = flushBuffer();
|
|
598
|
+
if (finalChunk) yield finalChunk;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
let stream;
|
|
602
|
+
if (typeof TransformStream !== 'undefined') {
|
|
603
|
+
stream = new TransformStream({
|
|
604
|
+
transform(chunk, controller) {
|
|
605
|
+
const text = typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk);
|
|
606
|
+
const out = processChunk(text);
|
|
607
|
+
if (out) controller.enqueue(out);
|
|
608
|
+
},
|
|
609
|
+
flush(controller) {
|
|
610
|
+
const out = flushBuffer();
|
|
611
|
+
if (out) controller.enqueue(out);
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
} else {
|
|
615
|
+
stream = {};
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
stream.process = processChunk;
|
|
619
|
+
stream.flush = flushBuffer;
|
|
620
|
+
stream.wrapStream = wrapStream;
|
|
621
|
+
|
|
622
|
+
return stream;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Wraps an async iterable LLM stream (e.g. from Vercel AI SDK textStream or OpenAI stream)
|
|
627
|
+
* and yields de-tokenized text chunks in real-time, seamlessly stitching split tokens across chunks.
|
|
628
|
+
*
|
|
629
|
+
* @param {AsyncIterable<string|object>} stream - The incoming LLM stream.
|
|
630
|
+
* @param {Record<string, string>} tokenMap - Volatile token map.
|
|
631
|
+
* @returns {AsyncGenerator<string>}
|
|
632
|
+
*/
|
|
633
|
+
export async function* wrapAiStream(stream, tokenMap = {}) {
|
|
634
|
+
const restorer = createStreamTokenRestorer(tokenMap);
|
|
635
|
+
yield* restorer.wrapStream(stream);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Middleware wrapper for Next.js App Router Route Handlers (POST/PUT).
|
|
640
|
+
* Automatically sanitizes incoming prompt payloads in RAM (<1ms) before the handler runs,
|
|
641
|
+
* and attaches sanitized text, tokenMap, and helper restoration functions.
|
|
642
|
+
*
|
|
643
|
+
* @param {Function} handler - The async Next.js route handler (req, context) => Response
|
|
644
|
+
* @param {object} [options] - Sanitization options (profile, detectSecrets, etc.)
|
|
645
|
+
* @returns {Function} Wrapped Next.js route handler
|
|
646
|
+
*/
|
|
647
|
+
export function withPrivacyScrubber(handler, options = {}) {
|
|
648
|
+
return async function privacyScrubberHandler(req, context = {}) {
|
|
649
|
+
if (!req || typeof req.json !== 'function') {
|
|
650
|
+
return handler(req, context);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
let rawBody;
|
|
654
|
+
try {
|
|
655
|
+
rawBody = await req.json();
|
|
656
|
+
} catch (_) {
|
|
657
|
+
return handler(req, context);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
let scrubbedBody = rawBody;
|
|
661
|
+
let tokenMap = {};
|
|
662
|
+
let telemetry = null;
|
|
663
|
+
|
|
664
|
+
if (typeof rawBody === 'string') {
|
|
665
|
+
const res = sanitize(rawBody, options);
|
|
666
|
+
scrubbedBody = res.scrubbedText;
|
|
667
|
+
tokenMap = res.tokenMap;
|
|
668
|
+
telemetry = res.telemetry;
|
|
669
|
+
} else if (rawBody && typeof rawBody === 'object') {
|
|
670
|
+
const serialized = JSON.stringify(rawBody);
|
|
671
|
+
const res = sanitize(serialized, options);
|
|
672
|
+
try {
|
|
673
|
+
scrubbedBody = JSON.parse(res.scrubbedText);
|
|
674
|
+
} catch (_) {
|
|
675
|
+
scrubbedBody = rawBody;
|
|
676
|
+
}
|
|
677
|
+
tokenMap = res.tokenMap;
|
|
678
|
+
telemetry = res.telemetry;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const psContext = {
|
|
682
|
+
...context,
|
|
683
|
+
rawBody,
|
|
684
|
+
scrubbedBody,
|
|
685
|
+
tokenMap,
|
|
686
|
+
telemetry,
|
|
687
|
+
restore: (text) => restore(text, tokenMap),
|
|
688
|
+
createStreamRestorer: () => createStreamTokenRestorer(tokenMap)
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
return handler(req, psContext);
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Express.js / Connect middleware for automatic zero-trust PII sanitization.
|
|
697
|
+
* Sanitizes req.body in local RAM and attaches req.psTokenMap and req.restore(aiResponse).
|
|
698
|
+
*
|
|
699
|
+
* @param {object} [options] - Sanitization options.
|
|
700
|
+
* @returns {Function} Express middleware (req, res, next)
|
|
701
|
+
*/
|
|
702
|
+
export function createExpressMiddleware(options = {}) {
|
|
703
|
+
return function privacyScrubberExpress(req, res, next) {
|
|
704
|
+
if (req && req.body && typeof req.body === 'object') {
|
|
705
|
+
const serialized = JSON.stringify(req.body);
|
|
706
|
+
const { scrubbedText, tokenMap, telemetry } = sanitize(serialized, options);
|
|
707
|
+
req.rawBody = req.body;
|
|
708
|
+
try {
|
|
709
|
+
req.body = JSON.parse(scrubbedText);
|
|
710
|
+
} catch (_) {}
|
|
711
|
+
req.psTokenMap = tokenMap;
|
|
712
|
+
req.psTelemetry = telemetry;
|
|
713
|
+
req.restore = (aiResponse) => restore(aiResponse, tokenMap);
|
|
714
|
+
req.createStreamRestorer = () => createStreamTokenRestorer(tokenMap);
|
|
715
|
+
}
|
|
716
|
+
if (typeof next === 'function') next();
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
|
|
489
720
|
/**
|
|
490
721
|
* Generates an official Zero-Trust Compliance Audit Report.
|
|
491
722
|
* @param {Record<string, string>} sessionMap - Map of token bindings.
|
|
@@ -557,6 +788,359 @@ ${telemetry.frameworksList.map(f => `- **${f}**`).join('\n')}
|
|
|
557
788
|
*Verify at: https://privacyscrubber.com/features/audit-receipt/*`;
|
|
558
789
|
}
|
|
559
790
|
|
|
791
|
+
/**
|
|
792
|
+
* Creates a Node.js Transform stream that sanitizes incoming text chunks line-by-line in volatile RAM.
|
|
793
|
+
* Zero external network egress. Zero memory bloat. Ideal for streaming server logs, CSVs, or HTTP responses.
|
|
794
|
+
*
|
|
795
|
+
* @param {object} [options={}] - Stream configuration or PrivacyScrubberEngine options.
|
|
796
|
+
* @param {PrivacyScrubberEngine} [options.engine] - Optional pre-configured PrivacyScrubberEngine instance.
|
|
797
|
+
* @param {string} [options.profile='General'] - Compliance detection profile.
|
|
798
|
+
* @param {string} [options.licenseKey] - Optional license key.
|
|
799
|
+
* @param {boolean} [options.detectSecrets=false] - Auto-detect cloud credentials and API keys.
|
|
800
|
+
* @param {Array} [options.customRules=[]] - Custom regex rules.
|
|
801
|
+
* @returns {Transform & { getSessionMap: () => Record<string, string>, getTelemetry: () => object, engine: PrivacyScrubberEngine }}
|
|
802
|
+
*/
|
|
803
|
+
export function createSanitizeStream(options = {}) {
|
|
804
|
+
const engine = options.engine || new PrivacyScrubberEngine(options);
|
|
805
|
+
let tail = '';
|
|
806
|
+
|
|
807
|
+
const stream = new Transform({
|
|
808
|
+
transform(chunk, encoding, callback) {
|
|
809
|
+
try {
|
|
810
|
+
const str = tail + (Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk));
|
|
811
|
+
const lines = str.split('\n');
|
|
812
|
+
tail = lines.pop() || '';
|
|
813
|
+
|
|
814
|
+
for (let i = 0; i < lines.length; i++) {
|
|
815
|
+
const sanitized = engine.sanitize(lines[i], { persistSession: true, generateReceipt: false });
|
|
816
|
+
this.push(sanitized.scrubbedText + '\n');
|
|
817
|
+
}
|
|
818
|
+
callback();
|
|
819
|
+
} catch (err) {
|
|
820
|
+
callback(err);
|
|
821
|
+
}
|
|
822
|
+
},
|
|
823
|
+
flush(callback) {
|
|
824
|
+
try {
|
|
825
|
+
if (tail.length > 0) {
|
|
826
|
+
const sanitized = engine.sanitize(tail, { persistSession: true, generateReceipt: false });
|
|
827
|
+
this.push(sanitized.scrubbedText);
|
|
828
|
+
tail = '';
|
|
829
|
+
}
|
|
830
|
+
callback();
|
|
831
|
+
} catch (err) {
|
|
832
|
+
callback(err);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
stream.engine = engine;
|
|
838
|
+
stream.getSessionMap = () => ({ ...engine.sessionMap });
|
|
839
|
+
stream.getTelemetry = () => buildAuditTelemetry(engine.sessionMap);
|
|
840
|
+
|
|
841
|
+
return stream;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* Restores masked tokens in AI-generated code or text locally in volatile RAM,
|
|
846
|
+
* and writes the authentic content directly to the destination file on disk.
|
|
847
|
+
*
|
|
848
|
+
* @param {string} filePath - Target file destination.
|
|
849
|
+
* @param {string} content - Code/text containing placeholder tokens (e.g. [API_KEY_1]).
|
|
850
|
+
* @param {object} [options={}] - Options: tokenMap, engine, createBackup (default: true).
|
|
851
|
+
* @returns {{ success: boolean, filePath: string, restoredCount: number, backupCreated: boolean, backupPath?: string }}
|
|
852
|
+
*/
|
|
853
|
+
export function applyPatch(filePath, content, options = {}) {
|
|
854
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
855
|
+
throw new Error('[PrivacyScrubber] Missing required parameter: filePath must be a string.');
|
|
856
|
+
}
|
|
857
|
+
if (content === undefined || content === null || typeof content !== 'string') {
|
|
858
|
+
throw new Error('[PrivacyScrubber] Missing required parameter: content must be a string.');
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
const {
|
|
862
|
+
tokenMap = null,
|
|
863
|
+
engine = null,
|
|
864
|
+
createBackup = true
|
|
865
|
+
} = options;
|
|
866
|
+
|
|
867
|
+
const activeTokenMap = tokenMap || (engine && engine.sessionMap) || {};
|
|
868
|
+
const resolvedPath = path.resolve(process.cwd(), filePath);
|
|
869
|
+
const restored = unscrub(content, activeTokenMap);
|
|
870
|
+
|
|
871
|
+
let backupCreated = false;
|
|
872
|
+
let backupPath;
|
|
873
|
+
|
|
874
|
+
if (createBackup && fs.existsSync(resolvedPath)) {
|
|
875
|
+
backupPath = `${resolvedPath}.bak`;
|
|
876
|
+
fs.copyFileSync(resolvedPath, backupPath);
|
|
877
|
+
backupCreated = true;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const parentDir = path.dirname(resolvedPath);
|
|
881
|
+
if (!fs.existsSync(parentDir)) {
|
|
882
|
+
fs.mkdirSync(parentDir, { recursive: true });
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
fs.writeFileSync(resolvedPath, restored.restoredText, 'utf8');
|
|
886
|
+
|
|
887
|
+
return {
|
|
888
|
+
success: true,
|
|
889
|
+
filePath: resolvedPath,
|
|
890
|
+
restoredCount: restored.restoredCount || 0,
|
|
891
|
+
backupCreated,
|
|
892
|
+
backupPath
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* Alias for applyPatch.
|
|
898
|
+
*/
|
|
899
|
+
export function restoreToFile(filePath, content, tokenMapOrOptions = {}, options = {}) {
|
|
900
|
+
if (tokenMapOrOptions && typeof tokenMapOrOptions === 'object' && !tokenMapOrOptions.sessionMap && !tokenMapOrOptions.tokenMap) {
|
|
901
|
+
return applyPatch(filePath, content, { ...options, tokenMap: tokenMapOrOptions });
|
|
902
|
+
}
|
|
903
|
+
return applyPatch(filePath, content, typeof tokenMapOrOptions === 'object' ? tokenMapOrOptions : options);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* Creates CISO-grade Zero-Trust Agentic Guard tools for AI agents & LLM frameworks.
|
|
908
|
+
* Compatible with Vercel AI SDK, OpenAI function calling, LangChain, and AutoGen.
|
|
909
|
+
* Intercepts terminal commands, file reads, and code writes, masking secrets into volatile RAM.
|
|
910
|
+
*
|
|
911
|
+
* @param {PrivacyScrubberEngine|object} [engineOrOptions={}] - PrivacyScrubberEngine or config options.
|
|
912
|
+
* @param {object} [toolConfig={}] - Default configuration: cwd, profile.
|
|
913
|
+
* @returns {object} Object containing guardExec, guardReadFile, guardApplyPatch, guardGitDiff, getDefinitions, and engine.
|
|
914
|
+
*/
|
|
915
|
+
export function createGuardedTools(engineOrOptions = {}, toolConfig = {}) {
|
|
916
|
+
const engine = (engineOrOptions instanceof PrivacyScrubberEngine)
|
|
917
|
+
? engineOrOptions
|
|
918
|
+
: new PrivacyScrubberEngine(engineOrOptions);
|
|
919
|
+
|
|
920
|
+
const baseCwd = toolConfig.cwd || process.cwd();
|
|
921
|
+
const defaultProfile = toolConfig.profile || engine.defaultProfile || 'Dev';
|
|
922
|
+
|
|
923
|
+
const tools = {
|
|
924
|
+
guardExec: {
|
|
925
|
+
name: 'guard_exec',
|
|
926
|
+
description: 'Executes a shell command locally, intercepts stdout/stderr line-by-line, sanitizes credentials and PII into RAM tokens, and returns clean text to model context.',
|
|
927
|
+
parameters: {
|
|
928
|
+
type: 'object',
|
|
929
|
+
properties: {
|
|
930
|
+
command: { type: 'string', description: 'Shell command to execute.' },
|
|
931
|
+
cwd: { type: 'string', description: 'Working directory.' },
|
|
932
|
+
profile: { type: 'string', description: 'Detection profile (default: Dev).' },
|
|
933
|
+
timeoutMs: { type: 'number', description: 'Execution timeout in milliseconds (default: 15000).' }
|
|
934
|
+
},
|
|
935
|
+
required: ['command']
|
|
936
|
+
},
|
|
937
|
+
execute: async ({ command, cwd, profile = defaultProfile, timeoutMs = 15000 } = {}) => {
|
|
938
|
+
if (!command || typeof command !== 'string') {
|
|
939
|
+
throw new Error('[guard_exec] Missing or invalid parameter: command must be a string.');
|
|
940
|
+
}
|
|
941
|
+
const execCwd = cwd ? path.resolve(baseCwd, cwd) : baseCwd;
|
|
942
|
+
let stdout = '';
|
|
943
|
+
let stderr = '';
|
|
944
|
+
let exitCode = 0;
|
|
945
|
+
|
|
946
|
+
try {
|
|
947
|
+
stdout = execSync(command, {
|
|
948
|
+
cwd: execCwd,
|
|
949
|
+
timeout: Math.min(Math.max(timeoutMs, 1000), 60000),
|
|
950
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
951
|
+
encoding: 'utf8',
|
|
952
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
953
|
+
});
|
|
954
|
+
} catch (err) {
|
|
955
|
+
stdout = err.stdout ? err.stdout.toString() : '';
|
|
956
|
+
stderr = err.stderr ? err.stderr.toString() : (err.message || '');
|
|
957
|
+
exitCode = typeof err.status === 'number' ? err.status : 1;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
const resCommand = engine.sanitize(command, { profile, detectSecrets: true, persistSession: true, generateReceipt: false });
|
|
961
|
+
const resStdout = engine.sanitize(stdout, { profile, detectSecrets: true, persistSession: true, generateReceipt: false });
|
|
962
|
+
const resStderr = engine.sanitize(stderr, { profile, detectSecrets: true, persistSession: true, generateReceipt: false });
|
|
963
|
+
|
|
964
|
+
const newTokens = { ...resCommand.tokenMap, ...resStdout.tokenMap, ...resStderr.tokenMap };
|
|
965
|
+
const telemetry = buildAuditTelemetry(newTokens);
|
|
966
|
+
const receipt = formatAuditReceipt(telemetry);
|
|
967
|
+
|
|
968
|
+
const responseText = `[Zero-Trust Agentic Guard: Exec]\n` +
|
|
969
|
+
`Command: ${resCommand.scrubbedText}\n` +
|
|
970
|
+
`Exit Code: ${exitCode}\n` +
|
|
971
|
+
`Tokens Redacted: ${Object.keys(newTokens).length}\n\n` +
|
|
972
|
+
(resStdout.scrubbedText ? `--- STDOUT ---\n${resStdout.scrubbedText}\n\n` : '') +
|
|
973
|
+
(resStderr.scrubbedText ? `--- STDERR ---\n${resStderr.scrubbedText}\n\n` : '') +
|
|
974
|
+
receipt;
|
|
975
|
+
|
|
976
|
+
return {
|
|
977
|
+
command: resCommand.scrubbedText,
|
|
978
|
+
stdout: resStdout.scrubbedText,
|
|
979
|
+
stderr: resStderr.scrubbedText,
|
|
980
|
+
exitCode,
|
|
981
|
+
tokenCount: Object.keys(newTokens).length,
|
|
982
|
+
telemetry,
|
|
983
|
+
receipt,
|
|
984
|
+
responseText
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
},
|
|
988
|
+
|
|
989
|
+
guardReadFile: {
|
|
990
|
+
name: 'guard_read_file',
|
|
991
|
+
description: 'Reads local file contents, tokenizing sensitive credentials and PII in volatile RAM before returning text to model context.',
|
|
992
|
+
parameters: {
|
|
993
|
+
type: 'object',
|
|
994
|
+
properties: {
|
|
995
|
+
filePath: { type: 'string', description: 'Path to target file.' },
|
|
996
|
+
maxLines: { type: 'number', description: 'Maximum lines to read (default: 500).' },
|
|
997
|
+
profile: { type: 'string', description: 'Detection profile (default: Dev).' }
|
|
998
|
+
},
|
|
999
|
+
required: ['filePath']
|
|
1000
|
+
},
|
|
1001
|
+
execute: async ({ filePath, maxLines = 500, profile = defaultProfile } = {}) => {
|
|
1002
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
1003
|
+
throw new Error('[guard_read_file] Missing or invalid parameter: filePath must be a string.');
|
|
1004
|
+
}
|
|
1005
|
+
const resolved = path.resolve(baseCwd, filePath);
|
|
1006
|
+
if (!fs.existsSync(resolved)) {
|
|
1007
|
+
throw new Error(`[guard_read_file] File not found: ${resolved}`);
|
|
1008
|
+
}
|
|
1009
|
+
const stat = fs.statSync(resolved);
|
|
1010
|
+
if (stat.isDirectory()) {
|
|
1011
|
+
throw new Error(`[guard_read_file] Path is a directory, not a file: ${resolved}`);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
const raw = fs.readFileSync(resolved, 'utf8');
|
|
1015
|
+
const lines = raw.split(/\r?\n/);
|
|
1016
|
+
const capped = lines.slice(0, maxLines).join('\n');
|
|
1017
|
+
const wasTruncated = lines.length > maxLines;
|
|
1018
|
+
|
|
1019
|
+
const res = engine.sanitize(capped, { profile, detectSecrets: true, persistSession: true, generateReceipt: false });
|
|
1020
|
+
const telemetry = buildAuditTelemetry(res.tokenMap);
|
|
1021
|
+
const receipt = formatAuditReceipt(telemetry);
|
|
1022
|
+
|
|
1023
|
+
const responseText = `[Zero-Trust Agentic Guard: File Read: ${path.basename(resolved)}]\n` +
|
|
1024
|
+
`Path: ${resolved}\n` +
|
|
1025
|
+
`Total Lines: ${lines.length}${wasTruncated ? ` (Capped to ${maxLines} lines)` : ''}\n` +
|
|
1026
|
+
`Tokens Redacted: ${res.count}\n\n` +
|
|
1027
|
+
`--- SANITIZED CONTENT ---\n` +
|
|
1028
|
+
res.scrubbedText + '\n\n' +
|
|
1029
|
+
receipt;
|
|
1030
|
+
|
|
1031
|
+
return {
|
|
1032
|
+
filePath: resolved,
|
|
1033
|
+
content: res.scrubbedText,
|
|
1034
|
+
totalLines: lines.length,
|
|
1035
|
+
wasTruncated,
|
|
1036
|
+
tokenCount: res.count,
|
|
1037
|
+
telemetry,
|
|
1038
|
+
receipt,
|
|
1039
|
+
responseText
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1042
|
+
},
|
|
1043
|
+
|
|
1044
|
+
guardApplyPatch: {
|
|
1045
|
+
name: 'guard_apply_patch',
|
|
1046
|
+
description: 'Restores authentic secrets locally from volatile RAM session map and writes real content directly to target file with automatic .bak backup.',
|
|
1047
|
+
parameters: {
|
|
1048
|
+
type: 'object',
|
|
1049
|
+
properties: {
|
|
1050
|
+
filePath: { type: 'string', description: 'Destination file path.' },
|
|
1051
|
+
content: { type: 'string', description: 'Content containing token placeholders (e.g. [API_KEY_1]).' },
|
|
1052
|
+
createBackup: { type: 'boolean', description: 'Whether to create .bak backup if file exists (default: true).' }
|
|
1053
|
+
},
|
|
1054
|
+
required: ['filePath', 'content']
|
|
1055
|
+
},
|
|
1056
|
+
execute: async ({ filePath, content, createBackup = true } = {}) => {
|
|
1057
|
+
const patchResult = applyPatch(filePath, content, {
|
|
1058
|
+
tokenMap: engine.sessionMap,
|
|
1059
|
+
createBackup
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
const responseText = `[Zero-Trust Agentic Guard: Patch Applied]\n` +
|
|
1063
|
+
`Destination: ${patchResult.filePath}\n` +
|
|
1064
|
+
`Tokens Restored Locally: ${patchResult.restoredCount}\n` +
|
|
1065
|
+
`Backup Created: ${patchResult.backupCreated ? `${patchResult.filePath}.bak` : 'No'}\n` +
|
|
1066
|
+
`Status: File written with 100% authentic local values. Remote LLM received zero secrets.`;
|
|
1067
|
+
|
|
1068
|
+
return {
|
|
1069
|
+
...patchResult,
|
|
1070
|
+
responseText
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
},
|
|
1074
|
+
|
|
1075
|
+
guardGitDiff: {
|
|
1076
|
+
name: 'guard_git_diff',
|
|
1077
|
+
description: 'Inspects staged or unstaged git diff changes with credentials and PII masked into volatile RAM tokens.',
|
|
1078
|
+
parameters: {
|
|
1079
|
+
type: 'object',
|
|
1080
|
+
properties: {
|
|
1081
|
+
staged: { type: 'boolean', description: 'Whether to review staged changes (--cached).' },
|
|
1082
|
+
cwd: { type: 'string', description: 'Repository directory.' },
|
|
1083
|
+
profile: { type: 'string', description: 'Detection profile (default: Dev).' }
|
|
1084
|
+
},
|
|
1085
|
+
required: []
|
|
1086
|
+
},
|
|
1087
|
+
execute: async ({ staged = false, cwd, profile = defaultProfile } = {}) => {
|
|
1088
|
+
const repoCwd = cwd ? path.resolve(baseCwd, cwd) : baseCwd;
|
|
1089
|
+
const gitCmd = staged ? 'git diff --cached' : 'git diff';
|
|
1090
|
+
let diffOutput = '';
|
|
1091
|
+
|
|
1092
|
+
try {
|
|
1093
|
+
diffOutput = execSync(gitCmd, { cwd: repoCwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
1094
|
+
} catch (err) {
|
|
1095
|
+
if (err.code === 'ENOBUFS' && err.stdout) {
|
|
1096
|
+
diffOutput = err.stdout.toString('utf8');
|
|
1097
|
+
} else {
|
|
1098
|
+
throw new Error(`[guard_git_diff] Git command failed: ${err.message}`);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
if (!diffOutput.trim()) {
|
|
1103
|
+
return {
|
|
1104
|
+
diff: '',
|
|
1105
|
+
isStaged: !!staged,
|
|
1106
|
+
tokenCount: 0,
|
|
1107
|
+
responseText: `[Zero-Trust Agentic Guard: Git Diff]\nNo changes detected (${staged ? 'staged' : 'unstaged'}).`
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
const res = engine.sanitize(diffOutput, { profile, detectSecrets: true, persistSession: true, generateReceipt: false });
|
|
1112
|
+
const telemetry = buildAuditTelemetry(res.tokenMap);
|
|
1113
|
+
const receipt = formatAuditReceipt(telemetry);
|
|
1114
|
+
|
|
1115
|
+
const responseText = `[Zero-Trust Agentic Guard: Git Diff (${staged ? 'Staged' : 'Unstaged'})]\n` +
|
|
1116
|
+
`Tokens Redacted: ${res.count}\n\n` +
|
|
1117
|
+
`--- SANITIZED DIFF ---\n` +
|
|
1118
|
+
res.scrubbedText + '\n\n' +
|
|
1119
|
+
receipt;
|
|
1120
|
+
|
|
1121
|
+
return {
|
|
1122
|
+
diff: res.scrubbedText,
|
|
1123
|
+
isStaged: !!staged,
|
|
1124
|
+
tokenCount: res.count,
|
|
1125
|
+
telemetry,
|
|
1126
|
+
receipt,
|
|
1127
|
+
responseText
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
};
|
|
1132
|
+
|
|
1133
|
+
tools.getDefinitions = () => [
|
|
1134
|
+
{ type: 'function', function: { name: tools.guardExec.name, description: tools.guardExec.description, parameters: tools.guardExec.parameters } },
|
|
1135
|
+
{ type: 'function', function: { name: tools.guardReadFile.name, description: tools.guardReadFile.description, parameters: tools.guardReadFile.parameters } },
|
|
1136
|
+
{ type: 'function', function: { name: tools.guardApplyPatch.name, description: tools.guardApplyPatch.description, parameters: tools.guardApplyPatch.parameters } },
|
|
1137
|
+
{ type: 'function', function: { name: tools.guardGitDiff.name, description: tools.guardGitDiff.description, parameters: tools.guardGitDiff.parameters } }
|
|
1138
|
+
];
|
|
1139
|
+
|
|
1140
|
+
tools.engine = engine;
|
|
1141
|
+
return tools;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
560
1144
|
// Low-level backwards-compatible exports
|
|
561
1145
|
export const scrubText = scrubberPkg.scrubText;
|
|
562
1146
|
export const unscrubText = scrubberPkg.unscrubText;
|
|
@@ -564,6 +1148,7 @@ export const unscrubText = scrubberPkg.unscrubText;
|
|
|
564
1148
|
export default {
|
|
565
1149
|
sanitize,
|
|
566
1150
|
scrub,
|
|
1151
|
+
clean,
|
|
567
1152
|
restore,
|
|
568
1153
|
unscrub,
|
|
569
1154
|
validateLicense,
|
|
@@ -575,6 +1160,14 @@ export default {
|
|
|
575
1160
|
PrivacyScrubberLicenseError,
|
|
576
1161
|
wrapOpenAI,
|
|
577
1162
|
createLangChainTransform,
|
|
1163
|
+
createStreamTokenRestorer,
|
|
1164
|
+
wrapAiStream,
|
|
1165
|
+
withPrivacyScrubber,
|
|
1166
|
+
createExpressMiddleware,
|
|
1167
|
+
createSanitizeStream,
|
|
1168
|
+
applyPatch,
|
|
1169
|
+
restoreToFile,
|
|
1170
|
+
createGuardedTools,
|
|
578
1171
|
scrubText,
|
|
579
1172
|
unscrubText,
|
|
580
1173
|
DEVOPS_SECRETS_DETECTOR
|