@privacyscrubber/mcp-server 2.3.9 → 2.4.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/index.js CHANGED
@@ -26,6 +26,7 @@ import { fileURLToPath } from 'url';
26
26
  import crypto from 'crypto';
27
27
  import os from 'os';
28
28
  import { execSync } from 'child_process';
29
+ import { Transform } from 'stream';
29
30
 
30
31
  const require = createRequire(import.meta.url);
31
32
  const __filename = fileURLToPath(import.meta.url);
@@ -63,6 +64,54 @@ if (!LicenseManager || typeof LicenseManager.validate !== 'function') {
63
64
  // Initialize core engine
64
65
  PrivacyScrubberCore.init();
65
66
 
67
+ let piiEngine;
68
+ try {
69
+ piiEngine = require('./ps-pii-engine.cjs');
70
+ } catch (e) {
71
+ try {
72
+ piiEngine = require('./ps-pii-engine.js');
73
+ } catch (err) {}
74
+ }
75
+
76
+ export const CANONICAL_INDUSTRY_PROFILES = [
77
+ 'General', 'Dev', 'Medical', 'Pharma', 'Biotech', 'Telecom', 'Legal', 'Compliance', 'CCPA',
78
+ 'Finance', 'Bizops', 'Sales', 'WealthMgmt', 'Insurance', 'Accounting', 'Underwriting',
79
+ 'Automotive', 'Energy', 'Hospitality', 'HR', 'Security', 'Marketing', 'Support',
80
+ 'RealEstate', 'Agents', 'Academic', 'Creative', 'Tech', 'Personal', 'Engineering'
81
+ ];
82
+
83
+ export function resolveRequestedProfile(args, defaultProfile = 'General') {
84
+ if (!args || typeof args !== 'object') return defaultProfile;
85
+ const sec = typeof args.sector === 'string' ? args.sector.trim() : '';
86
+ const prof = typeof args.profile === 'string' ? args.profile.trim() : '';
87
+
88
+ let candidate = defaultProfile;
89
+ if (sec && (!prof || prof.toLowerCase() === 'general')) {
90
+ candidate = sec;
91
+ } else if (prof) {
92
+ candidate = prof;
93
+ } else if (sec) {
94
+ candidate = sec;
95
+ }
96
+
97
+ // Canonical normalization via PROFILE_ALIAS_MAP if available
98
+ if (candidate && candidate.toLowerCase() !== 'general' && piiEngine && piiEngine.PROFILE_ALIAS_MAP) {
99
+ const mapped = piiEngine.PROFILE_ALIAS_MAP[candidate.toLowerCase()];
100
+ if (mapped) {
101
+ if (candidate.toLowerCase() === mapped.toLowerCase()) {
102
+ return candidate;
103
+ }
104
+ return mapped.charAt(0).toUpperCase() + mapped.slice(1);
105
+ }
106
+ }
107
+
108
+ return candidate;
109
+ }
110
+
111
+ const PROFILE_PARAM_DESCRIPTION = "The detection profile to use. Available: 'General' (Free, 15,000 chars), or 30 specialized industry profiles (5,000 chars Free trial quota; unlimited on PRO/TEAMS/SDK): 'Dev' (Engineering/Code), 'Medical', 'Pharma', 'Biotech', 'Telecom', 'Legal', 'Compliance', 'CCPA', 'Finance', 'Bizops', 'Sales', 'WealthMgmt', 'Insurance', 'Accounting', 'Underwriting', 'Automotive', 'Energy', 'Hospitality', 'HR', 'Security', 'Marketing', 'Support', 'RealEstate', 'Agents', 'Academic', 'Creative', 'Tech', 'Personal', 'Engineering'. Defaults to 'General'.";
112
+
113
+ const SECTOR_PARAM_DESCRIPTION = "Optional alias for 'profile'. The industry sector or compliance vertical to apply (e.g. 'medical', 'finance', 'legal', 'dev', 'hr', 'accounting', 'security', 'compliance', 'underwriting', 'telecom').";
114
+
66
115
  // Volatile in-memory token map per session (multi-task agent isolation)
67
116
  const agentSessions = new Map();
68
117
  const MAX_CONCURRENT_SESSIONS = 200;
@@ -75,9 +124,13 @@ function getSession(sessionId = 'default') {
75
124
  if (!session) {
76
125
  if (agentSessions.size >= MAX_CONCURRENT_SESSIONS) {
77
126
  const oldestKey = agentSessions.keys().next().value;
127
+ const oldestSession = agentSessions.get(oldestKey);
128
+ if (oldestSession && oldestSession.streamDetokenizer) {
129
+ oldestSession.streamDetokenizer.reset();
130
+ }
78
131
  agentSessions.delete(oldestKey);
79
132
  }
80
- session = { map: {}, ignoreList: new Set(), createdAt: now, lastActive: now };
133
+ session = { map: {}, ignoreList: new Set(), streamDetokenizer: null, createdAt: now, lastActive: now };
81
134
  agentSessions.set(sid, session);
82
135
  }
83
136
  session.lastActive = now;
@@ -96,6 +149,282 @@ function getSessionIgnoreList(sessionId = 'default') {
96
149
  const sessionMap = getSessionMap('default');
97
150
  const sessionIgnoreList = getSessionIgnoreList('default');
98
151
 
152
+ function escapeRegex(s) {
153
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
154
+ }
155
+
156
+ /**
157
+ * Builds a fast streaming token replacement function for MCP.
158
+ * Preserves all whitespace, indentation, newlines, and code blocks.
159
+ * Handles descending key ordering, case-insensitivity, alternative delimiters (<...>, {{...}}, __...__),
160
+ * and possessive suffixes ('s).
161
+ */
162
+ export function buildStreamReplacer(tokenMap = {}, options = {}) {
163
+ if (!tokenMap || typeof tokenMap !== 'object' || Object.keys(tokenMap).length === 0) {
164
+ return (text) => text || '';
165
+ }
166
+
167
+ const onTokenRestored = typeof options?.onTokenRestored === 'function' ? options.onTokenRestored : null;
168
+ const lookup = new Map();
169
+ const patterns = [];
170
+ const sortedKeys = Object.keys(tokenMap).sort((a, b) => b.length - a.length);
171
+
172
+ for (const k of sortedKeys) {
173
+ const val = tokenMap[k];
174
+ const inner = k.replace(/^(?:\[|<|\{\{?|__|【|[|〔|「|『)|(?:\]|>|\}\}?|__|】|]|〕|」|』)$/g, '');
175
+ const match = inner.match(/^([A-Za-z_0-9]+?)[-_ ]?(\d+)$/);
176
+
177
+ if (match) {
178
+ const label = escapeRegex(match[1]);
179
+ const idx = match[2];
180
+ const corePat = '[*`_~\\s]*' + label + '[*`_~]*[-_\\s]*0*' + idx + '[*`_~.,;:!?\\s]*';
181
+ const patternStr = '(?:\\[' + corePat + '\\]|<' + corePat + '>|\\{\\{' + corePat + '\\}\\}|\\{' + corePat + '\\}|__' + corePat + '__|[【[〔「『]' + corePat + '[】]〕」』]|%5B' + corePat + '%5D|&#(?:91|091|x5[bB]);' + corePat + '&#(?:93|093|x5[dD]);|&(?:lbrack|lsqb);' + corePat + '&(?:rbrack|rsqb);|\\\\u005[bB]' + corePat + '\\\\u005[dD]|\\b' + label + '[-_\\s]+0*' + idx + '\\b)';
182
+ patterns.push(patternStr);
183
+ const normKey = (match[1] + '_' + parseInt(idx, 10)).toUpperCase();
184
+ lookup.set(normKey, val);
185
+ lookup.set(k, val);
186
+ lookup.set(k.toUpperCase(), val);
187
+ } else {
188
+ const safe = escapeRegex(k);
189
+ patterns.push(safe);
190
+ lookup.set(k, val);
191
+ lookup.set(k.toUpperCase(), val);
192
+ }
193
+ }
194
+
195
+ const unifiedRegex = new RegExp('(?:' + patterns.join('|') + ')(\'s|’s|s)?', 'gi');
196
+
197
+ return function replaceTokens(text) {
198
+ if (!text) return '';
199
+ unifiedRegex.lastIndex = 0;
200
+ return text.replace(unifiedRegex, (match, suffix) => {
201
+ const isPossessive = suffix && /('s|’s)$/i.test(suffix);
202
+ const coreMatch = suffix ? match.slice(0, match.length - suffix.length) : match;
203
+ let restored = null;
204
+
205
+ if (lookup.has(coreMatch)) {
206
+ restored = lookup.get(coreMatch);
207
+ } else if (lookup.has(coreMatch.toUpperCase())) {
208
+ restored = lookup.get(coreMatch.toUpperCase());
209
+ } else {
210
+ const inner = coreMatch
211
+ .replace(/^(?:%5B|&#(?:91|091|x5[bB]);|&(?:lbrack|lsqb);|\\u005[bB]|\[|<|\{\{?|__|【|[|〔|「|『)/i, '')
212
+ .replace(/(?:%5D|&#(?:93|093|x5[dD]);|&(?:rbrack|rsqb);|\\u005[dD]|\]|>|\}\}?|__|】|]|〕|」|』)$/i, '')
213
+ .replace(/[*`_~.,;:!?\s]+/g, ' ')
214
+ .trim();
215
+ const m = inner.match(/^([A-Za-z_0-9]+?)[-_ ]?(\d+)$/);
216
+ if (m) {
217
+ const normKey = (m[1] + '_' + parseInt(m[2], 10)).toUpperCase();
218
+ if (lookup.has(normKey)) {
219
+ restored = lookup.get(normKey);
220
+ }
221
+ }
222
+ }
223
+
224
+ if (restored !== null) {
225
+ if (onTokenRestored) {
226
+ try { onTokenRestored({ token: match, originalValue: restored }); } catch (_) {}
227
+ }
228
+ return restored + (isPossessive ? "'s" : '');
229
+ }
230
+
231
+ if (lookup.has(match)) {
232
+ restored = lookup.get(match);
233
+ if (onTokenRestored) {
234
+ try { onTokenRestored({ token: match, originalValue: restored }); } catch (_) {}
235
+ }
236
+ return restored;
237
+ }
238
+
239
+ return match;
240
+ });
241
+ };
242
+ }
243
+
244
+ /**
245
+ * Streaming Token Detokenizer with sliding window buffer for LLM chunk boundary splits.
246
+ * Buffers partial token candidates at chunk boundaries across stdio and Server-Sent Events,
247
+ * preventing character loss and token corruption.
248
+ */
249
+ export class StreamingDetokenizer {
250
+ constructor(tokenMap = {}, options = {}) {
251
+ this.tokenMap = tokenMap && typeof tokenMap === 'object' ? tokenMap : {};
252
+ this.options = options || {};
253
+ this.replacer = buildStreamReplacer(this.tokenMap, this.options);
254
+ this.pendingBuffer = '';
255
+ this.hasTokens = Object.keys(this.tokenMap).length > 0;
256
+ this._lastTokenCount = Object.keys(this.tokenMap).length;
257
+ this.decoder = new TextDecoder('utf-8', { fatal: false });
258
+ }
259
+
260
+ get buffer() {
261
+ return this.pendingBuffer;
262
+ }
263
+
264
+ set buffer(val) {
265
+ this.pendingBuffer = typeof val === 'string' ? val : '';
266
+ }
267
+
268
+ updateTokenMap(newMap) {
269
+ if (newMap && typeof newMap === 'object') {
270
+ this.tokenMap = newMap;
271
+ this.replacer = buildStreamReplacer(this.tokenMap, this.options);
272
+ this.hasTokens = Object.keys(this.tokenMap).length > 0;
273
+ this._lastTokenCount = Object.keys(this.tokenMap).length;
274
+ }
275
+ }
276
+
277
+ reset() {
278
+ this.pendingBuffer = '';
279
+ }
280
+
281
+ feed(chunk) {
282
+ if (chunk === undefined || chunk === null) return '';
283
+ let text = '';
284
+ if (typeof chunk === 'string') {
285
+ text = chunk;
286
+ } else if (chunk instanceof Uint8Array || (typeof Buffer !== 'undefined' && Buffer.isBuffer(chunk))) {
287
+ text = this.decoder.decode(chunk, { stream: true });
288
+ } else {
289
+ text = String(chunk);
290
+ }
291
+
292
+ if (!text) return '';
293
+
294
+ const currentTokenCount = Object.keys(this.tokenMap).length;
295
+ if (currentTokenCount !== this._lastTokenCount) {
296
+ this.replacer = buildStreamReplacer(this.tokenMap, this.options);
297
+ this.hasTokens = currentTokenCount > 0;
298
+ this._lastTokenCount = currentTokenCount;
299
+ }
300
+
301
+ if (!this.hasTokens) return text;
302
+
303
+ this.pendingBuffer += text;
304
+
305
+ const tailLen = 160;
306
+ const tail = this.pendingBuffer.length > tailLen ? this.pendingBuffer.slice(-tailLen) : this.pendingBuffer;
307
+ const offset = this.pendingBuffer.length > tailLen ? this.pendingBuffer.length - tailLen : 0;
308
+ const m = tail.match(/(?:(?:\[|【|[|〔|「|『|<|\{\{?|__?|%5[Bb]|\\u005[Bb]|&(?:amp;)?(?:#0*91;|#[xX]0*5[Bb];|lbrack;))[A-Za-z0-9_\-А-Яа-яЁё *`~.\\]{0,128}(?:%5?|&(?:a(?:m(?:p(?:;)?)?)?)?(?:#(?:0*9?3?|[xX]0*5?[Dd]?)?|r(?:b(?:r(?:a(?:c(?:k)?)?)?)?)?)?)?|%[0-9A-Fa-f]?|\\(?:u(?:0(?:0(?:5)?)?)?)?|&(?:a(?:m(?:p(?:;)?)?)?)?(?:#(?:0*9?1?|[xX]0*5?)?|l(?:b(?:r(?:a(?:c(?:k)?)?)?)?)?)?)$/);
309
+
310
+ if (m) {
311
+ const candidate = m[0];
312
+ const isClosedUnderscoreOrUnicode = (candidate.length >= 4 && candidate.startsWith('__') && candidate.endsWith('__')) || /\\u005[Dd]/.test(candidate);
313
+ if (isClosedUnderscoreOrUnicode && this.replacer(candidate) !== candidate) {
314
+ const toEmit = this.pendingBuffer;
315
+ this.pendingBuffer = '';
316
+ return this.replacer(toEmit);
317
+ }
318
+ const splitIdx = offset + m.index;
319
+ const safeToEmit = this.pendingBuffer.slice(0, splitIdx);
320
+ this.pendingBuffer = this.pendingBuffer.slice(splitIdx);
321
+ return safeToEmit ? this.replacer(safeToEmit) : '';
322
+ }
323
+
324
+ const toEmit = this.pendingBuffer;
325
+ this.pendingBuffer = '';
326
+ return this.replacer(toEmit);
327
+ }
328
+
329
+ process(chunk) {
330
+ return this.feed(chunk);
331
+ }
332
+
333
+ flush() {
334
+ let text = '';
335
+ try {
336
+ text = this.decoder.decode(new Uint8Array(), { stream: false });
337
+ } catch (_) {}
338
+
339
+ if (text) {
340
+ this.pendingBuffer += text;
341
+ }
342
+
343
+ if (!this.pendingBuffer) return '';
344
+ const remaining = this.pendingBuffer;
345
+ this.pendingBuffer = '';
346
+ return this.replacer(remaining);
347
+ }
348
+
349
+ toNodeTransform() {
350
+ const self = this;
351
+ const transformStream = new Transform({
352
+ transform(chunk, encoding, callback) {
353
+ try {
354
+ const out = self.feed(chunk);
355
+ if (out) this.push(out);
356
+ callback();
357
+ } catch (err) {
358
+ callback(err);
359
+ }
360
+ },
361
+ flush(callback) {
362
+ try {
363
+ const out = self.flush();
364
+ if (out) this.push(out);
365
+ callback();
366
+ } catch (err) {
367
+ callback(err);
368
+ }
369
+ },
370
+ destroy(err, callback) {
371
+ self.reset();
372
+ callback(err);
373
+ }
374
+ });
375
+ transformStream.detokenizer = self;
376
+ return transformStream;
377
+ }
378
+
379
+ async* wrapStream(asyncIterable) {
380
+ for await (const chunk of asyncIterable) {
381
+ if (chunk === null || chunk === undefined) continue;
382
+ if (chunk.choices && Array.isArray(chunk.choices) && chunk.choices[0]?.delta?.content !== undefined) {
383
+ const delta = chunk.choices[0].delta.content;
384
+ if (typeof delta === 'string') {
385
+ const restored = this.feed(delta);
386
+ if (restored) {
387
+ chunk.choices[0].delta.content = restored;
388
+ yield chunk;
389
+ }
390
+ } else {
391
+ yield chunk;
392
+ }
393
+ continue;
394
+ }
395
+
396
+ let text = '';
397
+ if (typeof chunk === 'string') {
398
+ text = chunk;
399
+ } else if (typeof chunk?.text === 'string') {
400
+ text = chunk.text;
401
+ } else if (typeof chunk?.content === 'string') {
402
+ text = chunk.content;
403
+ } else if (chunk instanceof Uint8Array || (typeof Buffer !== 'undefined' && Buffer.isBuffer(chunk))) {
404
+ text = chunk;
405
+ } else {
406
+ text = String(chunk);
407
+ }
408
+
409
+ const emitted = this.feed(text);
410
+ if (emitted) yield emitted;
411
+ }
412
+
413
+ const finalChunk = this.flush();
414
+ if (finalChunk) yield finalChunk;
415
+ }
416
+ }
417
+
418
+ export function getStreamDetokenizer(sessionId = 'default') {
419
+ const session = getSession(sessionId);
420
+ if (!session.streamDetokenizer) {
421
+ session.streamDetokenizer = new StreamingDetokenizer(session.map);
422
+ } else {
423
+ session.streamDetokenizer.updateTokenMap(session.map);
424
+ }
425
+ return session.streamDetokenizer;
426
+ }
427
+
99
428
  function reverseTokens(text, map) {
100
429
  if (!text || typeof text !== 'string' || !map || Object.keys(map).length === 0) return text;
101
430
  if (PrivacyScrubberCore && typeof PrivacyScrubberCore.unscrubText === 'function') {
@@ -130,18 +459,35 @@ function mcpLog(msg) {
130
459
 
131
460
  // Secrets detection patterns — defined once here, shared by detectSecrets() and performSanitization()
132
461
  const DEVOPS_SECRETS_DETECTOR = [
133
- { name: 'AWS Credentials', regex: /\b(?:AKIA|ASIA|AGPA|AIDA|AROA|AIPA)[A-Z0-9]{16}\b/g },
134
- { name: 'JSON Web Token (JWT)', regex: /\beyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\b/g },
135
- { name: 'API Token/Key (GitHub/Slack/NPM)', regex: /\b(?:ghp|gho|ghu|ghs|ghr|glpat|npm|xox[baprs])[-_][A-Za-z0-9_]{10,}\b/g },
136
- { name: 'Stripe API Key', regex: /\b(?:[rs]k)_(?:test|live)_[a-zA-Z0-9]{24,}\b/g },
137
- { name: 'OpenAI Project API Key', regex: /\b(?:sk|pk)-(?:proj-)?[a-zA-Z0-9_-]{16,}\b/gi },
138
- { name: 'Database Connection URI', regex: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp|mssql):\/\/[^\s"']+/gi },
139
- { name: 'Embedded Credentials URI', regex: /\b(?:https?|s?ftp):\/\/[a-zA-Z0-9_.%+-]+:[^\s/@:"']+@[a-zA-Z0-9_.-]+(?::\d+)?(?:\/[^\s"']*)?/gi },
140
- { name: 'URL Query Parameter Secret', regex: /(?<=[?&](?:api_key|apikey|access_token|client_secret|auth_token|token|secret|password|private_key)=)[^\s"'\x26#)>]{8,}/gi },
141
- { name: 'Bearer / Auth Header Token', regex: /(?<=\b(?:Bearer|Token)\s+)[a-zA-Z0-9_\-\.~+/=]{16,}\b/gi },
142
- { name: 'Database/API Secret', regex: /\b(DB|POSTGRES|REDIS|MYSQL|AWS|SECRET|PASSWORD|TOKEN|API|KEY)[A-Z0-9_]*\s*[:=]\s*[^ \t\r\n"']{8,}\b/gi },
143
- { name: 'Private Cryptographic Key (PEM Block)', regex: /-----BEGIN (?:RSA |EC |PGP |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY(?: BLOCK)?-----[\s\S]{10,8192}?-----END (?:RSA |EC |PGP |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY(?: BLOCK)?-----/g },
144
- { name: 'Private Cryptographic Key (Header)', regex: /-----BEGIN (?:RSA |EC |PGP |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY(?: BLOCK)?-----/g }
462
+ { name: 'AWS Credentials', regex: /\b(?:AKIA|ASIA|AGPA|AIDA|AROA|AIPA)[A-Z0-9]{12,28}\b/g, label: 'AWS_KEY' },
463
+ { name: 'Google Cloud / Gemini API Key', regex: /\bAIza[0-9A-Za-z_-]{35}\b/g, label: 'API_TOKEN' },
464
+ { name: 'Anthropic API Key', regex: /\bsk-ant-(?:api\d{2}|admin\d{2}|sid\d{2})-[a-zA-Z0-9_-]{16,128}\b/g, label: 'API_TOKEN' },
465
+ { name: 'HuggingFace / Perplexity API Key', regex: /\b(?:hf_[a-zA-Z0-9]{20,64}|pplx-[a-zA-Z0-9]{24,64})\b/g, label: 'API_TOKEN' },
466
+ { name: 'SendGrid API Key', regex: /\bSG\.[a-zA-Z0-9_-]{16,32}\.[a-zA-Z0-9_-]{32,64}\b/g, label: 'API_TOKEN' },
467
+ { name: 'Postman API Key', regex: /\bPMAK-[a-fA-F0-9]{24}-[a-fA-F0-9]{34}\b/g, label: 'API_TOKEN' },
468
+ { name: 'GitHub Fine-Grained PAT', regex: /\bgithub_pat_[a-zA-Z0-9]{20,30}_[a-zA-Z0-9]{40,80}\b/g, label: 'API_TOKEN' },
469
+ { name: 'Slack Enterprise / Refresh Token', regex: /\bxoxe\.xox[baprs]-\d-[a-zA-Z0-9-]{10,}\b|\bxox[eapbrs]-[a-zA-Z0-9-]{10,}\b/g, label: 'API_TOKEN' },
470
+ { name: 'Telegram Bot API Token', regex: /\b\d{8,10}:AA[A-Za-z0-9_-]{33}\b/g, label: 'API_TOKEN' },
471
+ { name: 'Age Encryption Secret Key', regex: /\bAGE-SECRET-KEY-1[A-Z0-9]{58}\b/g, label: 'SECRET' },
472
+ { name: 'Modern Cloud & AI Platform Key', regex: /\b(?:sbp_[a-fA-F0-9]{40}|dop_v1_[a-fA-F0-9]{64}|doo_v1_[a-fA-F0-9]{64}|dp\.(?:st|pt|ct|sa)\.[a-zA-Z0-9_-]{20,}|lin_api_[a-zA-Z0-9]{32,64}|ntn_[a-zA-Z0-9]{32,64}|secret_[a-zA-Z0-9]{32,64}|gsk_[a-zA-Z0-9]{32,64}|r8_[a-zA-Z0-9]{24,64}|tvly-[a-zA-Z0-9_-]{20,64}|fc-[a-fA-F0-9]{32}|dapi[a-fA-F0-9]{32}(?:-\d)?|re_[a-zA-Z0-9_]{20,64}|napi_[a-zA-Z0-9]{32,64}|[a-zA-Z0-9]{14}\.atlasv1\.[a-zA-Z0-9]{50,80}|pypi-[A-Za-z0-9_-]{16,128}|whsec_[A-Za-z0-9+/=_-]{24,128}|shpat_[a-fA-F0-9]{32}|shpss_[a-fA-F0-9]{32}|shpca_[a-fA-F0-9]{32}|shppa_[a-fA-F0-9]{32}|hvs\.[A-Za-z0-9_-]{24,128}|hvb\.[A-Za-z0-9_-]{24,128}|glsa_[A-Za-z0-9]{32}_[A-Fa-f0-9]{8}|glc_[A-Za-z0-9+/=_-]{20,128}|pscale_tkn_[A-Za-z0-9_.-]{20,64}|pscale_pw_[A-Za-z0-9_.-]{20,64}|pat[A-Za-z0-9]{14}\.[a-fA-F0-9]{64}|pat-(?:na1|eu1)-[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}|sq0atp-[A-Za-z0-9_-]{22,64}|sq0csp-[A-Za-z0-9_-]{32,64}|NRAK-[A-Z0-9]{27}|v1\.0-[a-fA-F0-9]{24}-[a-fA-F0-9]{16,160}|ATATT3xFfGF0[A-Za-z0-9+/=_-]{24,128}|tskey-(?:auth|api|client)-[A-Za-z0-9_-]{20,96}|sntry[su]_[A-Za-z0-9+/=_-]{32,160}|(?:pk|sk)\.eyJ1[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{16,}|key-[a-f0-9]{32}|[a-f0-9]{32}-us\d{1,2})\b/g, label: 'API_TOKEN' },
473
+ { name: 'Twilio Account SID / API Key', regex: /\b(?:AC|SK)[a-fA-F0-9]{32}\b/g, label: 'API_TOKEN' },
474
+ { name: 'Bcrypt / Argon2 Password Hash', regex: /(?<![A-Za-z0-9])(?:\$2[aby]\$\d{2}\$[./A-Za-z0-9=]{20,60}|\$argon2(?:id|i|d)\$[^\s"']{15,120})/g, label: 'SECRET' },
475
+ { name: 'JSON Web Token (JWT)', regex: /\beyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\b/g, label: 'JWT_TOKEN' },
476
+ { name: 'API Token/Key (GitHub/Slack/NPM)', regex: /\b(?:ghp|gho|ghu|ghs|ghr|glpat|npm|xox[baprs])[-_][A-Za-z0-9_-]{10,}\b/g, label: 'API_TOKEN' },
477
+ { name: 'Stripe API Key', regex: /\b(?:[rs]k)_(?:test|live)_[a-zA-Z0-9]{10,}\b/g, label: 'STRIPE_KEY' },
478
+ { name: 'OpenAI Project API Key', regex: /\b(?:sk|pk)-(?:proj-)?[a-zA-Z0-9_-]{16,}\b/gi, label: 'OPENAI_KEY' },
479
+ { name: 'Slack / Discord Incoming Webhook URL', regex: /\bhttps:\/\/(?:hooks\.slack\.com\/services\/T[A-Z0-9_]+\/B[A-Z0-9_]+\/[A-Za-z0-9_-]+|(?:ptb\.|canary\.)?discord(?:app)?\.com\/api\/webhooks\/\d{17,20}\/[A-Za-z0-9_-]{60,70})\b/gi, label: 'SECRET' },
480
+ { name: 'Azure Connection String / SAS Token', regex: /(?:DefaultEndpointsProtocol=https?;AccountName=[^;\s"']+;AccountKey=[A-Za-z0-9+/=]{20,}(?:;EndpointSuffix=[^;\s"']+)?|Endpoint=sb:\/\/[^;\s"']+;SharedAccessKeyName=[^;\s"']+;SharedAccessKey=[A-Za-z0-9+/=]{16,}|\bSharedAccessSignature=sr=[^\s"']+&sig=[A-Za-z0-9%/+=]{16,}|\bAccountKey=[A-Za-z0-9+/=]{40,88}==)/gi, label: 'SECRET' },
481
+ { name: 'Database Connection URI', regex: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp|mssql):\/\/[^\s"']+/gi, label: 'DB_URI' },
482
+ { name: 'Embedded Credentials URI', regex: /\b(?:https?|hxxps?|s?ftp|fxp|wss?):\/\/[a-zA-Z0-9_.%+-]+:[^\s/@:"']+@[a-zA-Z0-9_.-]+(?:\[\.\][a-zA-Z0-9_.-]+|\(\.\)[a-zA-Z0-9_.-]+)*(?::\d+)?(?:\/[^\s"']*)?/gi, label: 'SECRET' },
483
+ { name: 'URL Query Parameter Secret', regex: /(?<=[?&](?:api_key|apikey|access_token|client_secret|auth_token|token|secret|password|private_key)=)[^\s"'\x26#)>]{8,}/gi, label: 'SECRET' },
484
+ { name: 'CLI Credential Flag / Curl User Auth', regex: /(?:\bcurl\b[^\r\n]*?\s(?:-u|--user)\s+["']?[A-Za-z0-9_.+-]+:[^\s"']+|\b(?:--password|--api-key|--apikey|--access-token|--auth-token|--client-secret|--secret-key|--private-key)(?:=|\s+)["']?[^\s"']{6,}["']?)/gi, label: 'SECRET' },
485
+ { name: 'HTTP API Key Header', regex: /\b(?:X-API-Key|X-Amz-Security-Token|X-Functions-Key|X-Shopify-Access-Token|X-Auth-Token|Api-Key)\s*:\s*["']?[A-Za-z0-9_.\-~+/=]{12,}["']?/gi, label: 'SECRET' },
486
+ { name: 'Docker Registry Auth / K8s Secret Base64', regex: /(?<=["']?(?:auth|identitytoken|registrytoken|\.dockerconfigjson)["']?\s*:\s*["'])[A-Za-z0-9+/=]{16,}(?=["'])/gi, label: 'SECRET' },
487
+ { name: 'Bearer / Auth Header Token', regex: /(?<=\b(?:Bearer|Token)\s+)[a-zA-Z0-9_\-\.~+/=]{16,}\b/gi, label: 'SECRET' },
488
+ { name: 'Database/API Secret', regex: /\b(DB|POSTGRES|REDIS|MYSQL|AWS|SECRET|PASSWORD|TOKEN|API|KEY)[A-Z0-9_]*\s*[:=]\s*[^ \t\r\n"']{8,}/gi, label: 'SECRET' },
489
+ { name: 'Private Cryptographic Key (PEM Block)', regex: /-----BEGIN (?:RSA |EC |PGP |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY(?: BLOCK)?-----[\s\S]{10,8192}?-----END (?:RSA |EC |PGP |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY(?: BLOCK)?-----/g, label: 'SECRET' },
490
+ { name: 'Private Cryptographic Key (Header)', regex: /-----BEGIN (?:RSA |EC |PGP |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY(?: BLOCK)?-----/g, label: 'SECRET' }
145
491
  ];
146
492
 
147
493
  function checkLicenseStatus() {
@@ -151,7 +497,9 @@ function checkLicenseStatus() {
151
497
  const result = LicenseManager.validate(key);
152
498
 
153
499
  if (!result.valid) {
154
- mcpLog(`DEBUG: License invalid because: ${result.reason || "Unknown"}`);
500
+ if (process.env.DEBUG_PRIVACYSCRUBBER) {
501
+ mcpLog(`[License] Key validation notice: ${result.reason || "Unknown"}`);
502
+ }
155
503
  return { isPro: false, type: null, error: result.reason || "Invalid license format or signature." };
156
504
  }
157
505
 
@@ -264,8 +612,8 @@ function buildCisoAuditTelemetry(currentTokenMap = {}) {
264
612
  frameworksSet.add('CCPA/CPRA');
265
613
  }
266
614
  if (types.includes('ID') || types.includes('SSN') || types.includes('PASSPORT') || types.includes('NATIONAL_ID')) {
267
- frameworksSet.add('SOC 2 Type II');
268
- frameworksSet.add('ISO 27001 (A.8.11)');
615
+ frameworksSet.add('SOC 2 controls');
616
+ frameworksSet.add('ISO 27001 A.8.11 controls');
269
617
  }
270
618
  if (types.some(t => ['CREDIT_CARD', 'BANK', 'IBAN', 'FINANCIAL', 'CARD', 'STRIPE_KEY', 'TAX_ID', 'EIN', 'W2', '1099', 'SALARY', 'WAGE'].includes(t))) {
271
619
  frameworksSet.add('PCI DSS v4.0');
@@ -285,7 +633,7 @@ function buildCisoAuditTelemetry(currentTokenMap = {}) {
285
633
  }
286
634
 
287
635
  const ipHoldings = {
288
- standard: 'IETF draft-sibiryakov-ztds-protocol-00',
636
+ standard: 'IETF draft-sibiryakov-ztds-protocol-01',
289
637
  standardUrl: 'https://datatracker.ietf.org/doc/draft-sibiryakov-ztds-protocol/',
290
638
  patent: "U.S. & Int'l Patents Pending (Paris Convention Priority)",
291
639
  trademark: 'ZTDS™ Reg. #182655957 (ILPO Cl 9 & 42)'
@@ -295,27 +643,27 @@ function buildCisoAuditTelemetry(currentTokenMap = {}) {
295
643
  {
296
644
  statute: 'EU AI Act (Art. 50)',
297
645
  scope: 'AI Transparency & Data Minimization',
298
- rule: '0-byte external network transmission; prevents unauthorized ingestion into frontier models.'
646
+ rule: 'Sanitization runs locally by design; reduces the content sent to external AI models. Informational mapping, not a compliance determination.'
299
647
  },
300
648
  {
301
649
  statute: 'GDPR (Art. 4, 25, 28, 32)',
302
650
  scope: 'Pseudonymization & Processor Liability',
303
- rule: 'Volatile client-side tokenization eliminates GDPR Article 28 data processor liability.'
651
+ rule: 'Client-side pseudonymization is a technical measure supporting Art. 25 and 32. It does not by itself remove controller or processor obligations.'
304
652
  },
305
653
  {
306
654
  statute: 'HIPAA 45 CFR § 164.514(b)',
307
655
  scope: 'Safe Harbor De-Identification',
308
- rule: 'Deterministic removal of all 18 PHI identifiers prior to AI analysis.'
656
+ rule: 'Pattern-based masking of direct identifiers supports Safe Harbor de-identification workflows. Review output for residual identifiers before sharing.'
309
657
  },
310
658
  {
311
659
  statute: 'IRC § 7216 & IRS Pub. 1075',
312
660
  scope: 'Tax Return Information Confidentiality',
313
- rule: 'Strict criminal penalty safeguard against unauthorized disclosure of tax records to cloud LLMs.'
661
+ rule: 'Masking identifiers before AI use can reduce exposure of tax return information. Informational mapping, not legal advice.'
314
662
  },
315
663
  {
316
664
  statute: 'FRE 502',
317
665
  scope: 'Attorney-Client Privilege Preservation',
318
- rule: 'Automated in-memory redaction prevents inadvertent waiver of legal privilege in AI workflows.'
666
+ rule: 'In-memory redaction can reduce disclosure of privileged details in AI workflows. Privilege determinations remain a legal judgment.'
319
667
  }
320
668
  ];
321
669
 
@@ -934,7 +1282,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
934
1282
  },
935
1283
  profile: {
936
1284
  type: "string",
937
- description: "The detection profile to use (e.g. 'General', 'Dev', 'Medical', 'Finance', 'Legal'). Defaults to 'General'."
1285
+ description: PROFILE_PARAM_DESCRIPTION
1286
+ },
1287
+ sector: {
1288
+ type: "string",
1289
+ description: SECTOR_PARAM_DESCRIPTION
938
1290
  },
939
1291
  format: {
940
1292
  type: "string",
@@ -947,7 +1299,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
947
1299
  },
948
1300
  {
949
1301
  name: "sanitize_text",
950
- description: "STEP 1: Call this first. You MUST NOT process raw user data before calling this. Locally scrubs PII, secrets, and credentials (like API keys, passwords, emails, phones, names) from code, logs, or text. Replaces them with safe placeholders (e.g., [EMAIL_1], [API_KEY_1]). Keep your data secure before passing it to any LLM. (For in-code backend services or RAG vector pipelines outside of MCP, use '@privacyscrubber/sdk': npm i @privacyscrubber/sdk)",
1302
+ description: "STEP 1: Call this first. You MUST NOT process raw user data before calling this. Locally scrubs PII, secrets, and credentials (like API keys, passwords, emails, phones, names) from code, logs, or text. Replaces them with safe placeholders (e.g., [EMAIL_1], [API_KEY_1]). Keep your data secure before passing it to any LLM. Supports General and all 30 specialized industry profiles in RAM session map. (For in-code backend services or RAG vector pipelines outside of MCP, use '@privacyscrubber/sdk': npm i @privacyscrubber/sdk)",
951
1303
  inputSchema: {
952
1304
  type: "object",
953
1305
  properties: {
@@ -957,7 +1309,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
957
1309
  },
958
1310
  profile: {
959
1311
  type: "string",
960
- description: "The detection profile to use. Available: 'General' (Free), or PRO profiles: 'Dev' (Engineering/Code), 'Medical', 'Pharma', 'Biotech', 'Telecom', 'Legal', 'Compliance', 'CCPA', 'Finance', 'Bizops', 'Sales', 'WealthMgmt', 'Insurance', 'Accounting', 'Underwriting', 'Automotive', 'Energy', 'Hospitality', 'HR', 'Security', 'Marketing', 'Support', 'RealEstate', 'Agents', 'Academic', 'Creative', 'Tech', 'Personal'. Defaults to 'General'."
1312
+ description: PROFILE_PARAM_DESCRIPTION
1313
+ },
1314
+ sector: {
1315
+ type: "string",
1316
+ description: SECTOR_PARAM_DESCRIPTION
961
1317
  },
962
1318
  compact: {
963
1319
  type: "boolean",
@@ -978,7 +1334,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
978
1334
  },
979
1335
  {
980
1336
  name: "scrub_text",
981
- description: "Alias for 'sanitize_text'. Locally scrubs PII and secrets before LLM ingestion.",
1337
+ description: "Alias for 'sanitize_text'. Locally scrubs PII, secrets, and credentials from prompts or text before LLM ingestion. Supports General and all 30 specialized industry profiles in RAM session map.",
982
1338
  inputSchema: {
983
1339
  type: "object",
984
1340
  properties: {
@@ -988,7 +1344,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
988
1344
  },
989
1345
  profile: {
990
1346
  type: "string",
991
- description: "The detection profile to use. Defaults to 'General'."
1347
+ description: PROFILE_PARAM_DESCRIPTION
1348
+ },
1349
+ sector: {
1350
+ type: "string",
1351
+ description: SECTOR_PARAM_DESCRIPTION
992
1352
  },
993
1353
  compact: {
994
1354
  type: "boolean",
@@ -1009,13 +1369,21 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1009
1369
  },
1010
1370
  {
1011
1371
  name: "reveal_text",
1012
- description: "STEP 3: Call this last. You MUST pass your final generated response through this tool to restore tokens (e.g., [EMAIL_1]) back with the original private data from the local volatile RAM-only session map before showing it to the user.",
1372
+ description: "STEP 3: Call this last. You MUST pass your final generated response through this tool to restore tokens (e.g., [EMAIL_1]) back with the original private data from the local volatile RAM-only session map before showing it to the user. Supports both standard batch mode and real-time streaming mode (stream: true).",
1013
1373
  inputSchema: {
1014
1374
  type: "object",
1015
1375
  properties: {
1016
1376
  text: {
1017
1377
  type: "string",
1018
- description: "The AI generated response containing placeholders to restore."
1378
+ description: "The AI generated response (or streaming chunk) containing placeholders to restore."
1379
+ },
1380
+ stream: {
1381
+ type: "boolean",
1382
+ description: "Optional. When true, uses the sliding-window StreamingDetokenizer to prevent token splitting across chunk boundaries."
1383
+ },
1384
+ flush: {
1385
+ type: "boolean",
1386
+ description: "Optional. When true in streaming mode, flushes any remaining buffered text at EOF."
1019
1387
  },
1020
1388
  session_id: {
1021
1389
  type: "string",
@@ -1025,6 +1393,32 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1025
1393
  required: ["text"]
1026
1394
  }
1027
1395
  },
1396
+ {
1397
+ name: "stream_reveal_text",
1398
+ description: "STEP 3 (Streaming): Real-time streaming token detokenizer for LLM Server-Sent Events (SSE) and stdio chunk pipelines. Buffers split token candidates across chunk boundaries (e.g. '[EM' + 'AIL_1]') so no characters or tokens are corrupted. Pass 'flush: true' on the final chunk.",
1399
+ inputSchema: {
1400
+ type: "object",
1401
+ properties: {
1402
+ chunk: {
1403
+ type: "string",
1404
+ description: "The incoming text chunk from the LLM stream or stdout."
1405
+ },
1406
+ flush: {
1407
+ type: "boolean",
1408
+ description: "Optional. Set to true on the final chunk or at EOF to flush remaining buffer. Defaults to false."
1409
+ },
1410
+ reset: {
1411
+ type: "boolean",
1412
+ description: "Optional. Set to true to reset internal streaming buffer for this session. Defaults to false."
1413
+ },
1414
+ session_id: {
1415
+ type: "string",
1416
+ description: "Optional session ID matching the sanitize_text call for multi-agent isolation. Defaults to 'default'."
1417
+ }
1418
+ },
1419
+ required: ["chunk"]
1420
+ }
1421
+ },
1028
1422
  {
1029
1423
  name: "sanitize_file",
1030
1424
  description: "Reads a local file, sanitizes its contents using the selected profile, and outputs the safe version for AI analysis. Securely keeps original identifiers in memory.",
@@ -1037,7 +1431,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1037
1431
  },
1038
1432
  profile: {
1039
1433
  type: "string",
1040
- description: "The detection profile to use. Defaults to 'General'."
1434
+ description: PROFILE_PARAM_DESCRIPTION
1435
+ },
1436
+ sector: {
1437
+ type: "string",
1438
+ description: SECTOR_PARAM_DESCRIPTION
1041
1439
  },
1042
1440
  compact: {
1043
1441
  type: "boolean",
@@ -1063,7 +1461,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1063
1461
  },
1064
1462
  profile: {
1065
1463
  type: "string",
1066
- description: "The detection profile to use. Defaults to 'General'."
1464
+ description: PROFILE_PARAM_DESCRIPTION
1465
+ },
1466
+ sector: {
1467
+ type: "string",
1468
+ description: SECTOR_PARAM_DESCRIPTION
1067
1469
  },
1068
1470
  compact: {
1069
1471
  type: "boolean",
@@ -1089,7 +1491,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1089
1491
  },
1090
1492
  profile: {
1091
1493
  type: "string",
1092
- description: "The detection profile to use. Defaults to 'General'."
1494
+ description: PROFILE_PARAM_DESCRIPTION
1495
+ },
1496
+ sector: {
1497
+ type: "string",
1498
+ description: SECTOR_PARAM_DESCRIPTION
1093
1499
  },
1094
1500
  extensions: {
1095
1501
  type: "array",
@@ -1116,7 +1522,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1116
1522
  },
1117
1523
  profile: {
1118
1524
  type: "string",
1119
- description: "The detection profile to use. Defaults to 'General'."
1525
+ description: PROFILE_PARAM_DESCRIPTION
1526
+ },
1527
+ sector: {
1528
+ type: "string",
1529
+ description: SECTOR_PARAM_DESCRIPTION
1120
1530
  },
1121
1531
  dry_run: {
1122
1532
  type: "boolean",
@@ -1157,6 +1567,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1157
1567
  department: {
1158
1568
  type: "string",
1159
1569
  description: "Optional department or auditor ID (e.g. 'SecOps / Engineering')."
1570
+ },
1571
+ session_id: {
1572
+ type: "string",
1573
+ description: "Optional session ID for multi-agent isolation. Defaults to 'default'."
1160
1574
  }
1161
1575
  },
1162
1576
  required: []
@@ -1205,7 +1619,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1205
1619
  },
1206
1620
  profile: {
1207
1621
  type: "string",
1208
- description: "Detection profile. Available: 'General', 'Dev' (recommended for code/secrets), 'Security', 'Finance', etc. Defaults to 'Dev'."
1622
+ description: "Detection profile. Available: 'Dev' (default for code/secrets), 'General', or any of the 30 specialized industry profiles (e.g. 'Security', 'Finance', 'Medical'). Defaults to 'Dev'."
1623
+ },
1624
+ sector: {
1625
+ type: "string",
1626
+ description: SECTOR_PARAM_DESCRIPTION
1209
1627
  },
1210
1628
  timeout_ms: {
1211
1629
  type: "number",
@@ -1227,7 +1645,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1227
1645
  },
1228
1646
  profile: {
1229
1647
  type: "string",
1230
- description: "Detection profile. Defaults to 'Dev'."
1648
+ description: "Detection profile. Available: 'Dev' (default), 'General', or any of the 30 specialized industry profiles. Defaults to 'Dev'."
1649
+ },
1650
+ sector: {
1651
+ type: "string",
1652
+ description: SECTOR_PARAM_DESCRIPTION
1231
1653
  },
1232
1654
  max_lines: {
1233
1655
  type: "number",
@@ -1253,7 +1675,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1253
1675
  },
1254
1676
  profile: {
1255
1677
  type: "string",
1256
- description: "Detection profile. Defaults to 'Dev'."
1678
+ description: "Detection profile. Available: 'Dev' (default), 'General', or any of the 30 specialized industry profiles. Defaults to 'Dev'."
1679
+ },
1680
+ sector: {
1681
+ type: "string",
1682
+ description: SECTOR_PARAM_DESCRIPTION
1257
1683
  },
1258
1684
  session_id: {
1259
1685
  type: "string",
@@ -1382,7 +1808,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1382
1808
  },
1383
1809
  profile: {
1384
1810
  type: "string",
1385
- description: "The detection profile to use. Defaults to 'General'."
1811
+ description: PROFILE_PARAM_DESCRIPTION
1812
+ },
1813
+ sector: {
1814
+ type: "string",
1815
+ description: SECTOR_PARAM_DESCRIPTION
1386
1816
  },
1387
1817
  session_id: {
1388
1818
  type: "string",
@@ -1619,14 +2049,14 @@ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
1619
2049
  "biotech": "Biotech & Genomics profile. Scrubs accessions, patient phenotypes, HGVS variants, DNA/RNA samples, and flowcell IDs.",
1620
2050
  "telecom": "Call Centers & Telephony profile. Scrubs CTI interaction IDs, Amazon Connect/Genesys sessions, agent extensions, ANI/DNIS, IVR DTMF, and audio transcript diarization headers."
1621
2051
  };
1622
- const desc = descriptions[profileName.toLowerCase()] || `The '${profileName}' profile is a PRO-tier detection ruleset tuned for specific industry compliance. It detects and sanitizes domain-specific identifiers.`;
2052
+ const desc = descriptions[profileName.toLowerCase()] || `The '${profileName}' profile is a specialized detection ruleset tuned for specific industry compliance. It detects and sanitizes domain-specific identifiers.`;
1623
2053
 
1624
2054
  return {
1625
2055
  contents: [
1626
2056
  {
1627
2057
  uri,
1628
2058
  mimeType: "text/markdown",
1629
- text: `# Profile: ${profileName}\n\n**Description:** ${desc}\n\n*Note: To use this profile, pass \`"profile": "${profileName}"\` to the \`sanitize_text\` or \`sanitize_file\` tools. Advanced profiles require a PRO license.*`
2059
+ text: `# Profile: ${profileName}\n\n**Description:** ${desc}\n\n*Note: To use this profile, pass \`"profile": "${profileName}"\` or \`"sector": "${profileName}"\` to tools like \`scrub_text\`, \`sanitize_text\`, or \`sanitize_file\`. Advanced profiles include a 5,000 character Free trial quota; PRO/TEAMS tiers grant unlimited characters.*`
1630
2060
  }
1631
2061
  ]
1632
2062
  };
@@ -1641,7 +2071,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1641
2071
 
1642
2072
  try {
1643
2073
  if (name === "audit_context") {
1644
- const { text, profile = "General", format = "detailed" } = args || {};
2074
+ const { text, format = "detailed" } = args || {};
1645
2075
 
1646
2076
  if (text === undefined || text === null) {
1647
2077
  return {
@@ -1656,7 +2086,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1656
2086
  };
1657
2087
  }
1658
2088
 
1659
- const targetProfile = (profile || "General").trim();
2089
+ const targetProfile = resolveRequestedProfile(args, "General");
1660
2090
  const customRules = loadCustomRules();
1661
2091
  const normalizedProfile = targetProfile.toLowerCase();
1662
2092
  const license = checkLicenseStatus();
@@ -1707,7 +2137,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1707
2137
  }
1708
2138
 
1709
2139
  if (name === "sanitize_text" || name === "scrub_text") {
1710
- const { text, profile = "General", ignore_list, compact = false, session_id = "default" } = args || {};
2140
+ const { text, ignore_list, compact = false, session_id = "default" } = args || {};
1711
2141
  const currentSession = getSessionMap(session_id);
1712
2142
  const currentIgnoreList = getSessionIgnoreList(session_id);
1713
2143
 
@@ -1727,7 +2157,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1727
2157
  content: [{ type: "text", text: `Error: Parameter 'text' must be a string, got '${typeof text}'. Stringify objects before passing.` }]
1728
2158
  };
1729
2159
  }
1730
- const targetProfile = (profile || "General").trim();
2160
+ const targetProfile = resolveRequestedProfile(args, "General");
1731
2161
 
1732
2162
  // Check tier gating for advanced profiles
1733
2163
  const isAdvanced = targetProfile.toLowerCase() !== "general";
@@ -1772,7 +2202,38 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1772
2202
  }
1773
2203
 
1774
2204
  if (name === "reveal_text") {
1775
- const { text, session_id = "default" } = args || {};
2205
+ const { text, stream = false, flush = false, session_id = "default" } = args || {};
2206
+
2207
+ if (stream) {
2208
+ if (text === undefined || text === null) {
2209
+ if (flush) {
2210
+ const detok = getStreamDetokenizer(session_id);
2211
+ const flushedText = detok.flush();
2212
+ return {
2213
+ content: [{ type: "text", text: flushedText }]
2214
+ };
2215
+ }
2216
+ return {
2217
+ isError: true,
2218
+ content: [{ type: "text", text: "Error: Missing required parameter 'text'. Provide the stream chunk to restore." }]
2219
+ };
2220
+ }
2221
+ if (typeof text !== "string") {
2222
+ return {
2223
+ isError: true,
2224
+ content: [{ type: "text", text: `Error: Parameter 'text' must be a string, got '${typeof text}'.` }]
2225
+ };
2226
+ }
2227
+ const detok = getStreamDetokenizer(session_id);
2228
+ let emitted = detok.feed(text);
2229
+ if (flush) {
2230
+ emitted += detok.flush();
2231
+ }
2232
+ return {
2233
+ content: [{ type: "text", text: emitted }]
2234
+ };
2235
+ }
2236
+
1776
2237
  if (text === undefined || text === null) {
1777
2238
  return {
1778
2239
  isError: true,
@@ -1812,6 +2273,43 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1812
2273
  };
1813
2274
  }
1814
2275
 
2276
+ if (name === "stream_reveal_text") {
2277
+ const { chunk, flush = false, reset = false, session_id = "default" } = args || {};
2278
+ if (chunk === undefined || chunk === null) {
2279
+ if (flush) {
2280
+ const detok = getStreamDetokenizer(session_id);
2281
+ const flushedText = detok.flush();
2282
+ return {
2283
+ content: [{ type: "text", text: flushedText }]
2284
+ };
2285
+ }
2286
+ return {
2287
+ isError: true,
2288
+ content: [{ type: "text", text: "Error: Missing required parameter 'chunk'. Provide the text chunk to process." }]
2289
+ };
2290
+ }
2291
+ if (typeof chunk !== "string") {
2292
+ return {
2293
+ isError: true,
2294
+ content: [{ type: "text", text: `Error: Parameter 'chunk' must be a string, got '${typeof chunk}'.` }]
2295
+ };
2296
+ }
2297
+
2298
+ const detok = getStreamDetokenizer(session_id);
2299
+ if (reset) {
2300
+ detok.reset();
2301
+ }
2302
+
2303
+ let emitted = detok.feed(chunk);
2304
+ if (flush) {
2305
+ emitted += detok.flush();
2306
+ }
2307
+
2308
+ return {
2309
+ content: [{ type: "text", text: emitted }]
2310
+ };
2311
+ }
2312
+
1815
2313
  if (name === "mark_false_positive") {
1816
2314
  const { token, session_id = "default" } = args || {};
1817
2315
  if (!token || typeof token !== 'string') {
@@ -1860,7 +2358,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1860
2358
  ]
1861
2359
  };
1862
2360
  }
1863
- const { profile = "General", compact = false, session_id = "default" } = args || {};
2361
+ const targetProfile = resolveRequestedProfile(args, "General");
2362
+ const { compact = false, session_id = "default" } = args || {};
1864
2363
  const targetSession = getSessionMap(session_id);
1865
2364
  const targetIgnoreList = getSessionIgnoreList(session_id);
1866
2365
  const filePath = rawPath;
@@ -1908,9 +2407,111 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1908
2407
  if (resolvedPath.toLowerCase().endsWith(".docx")) {
1909
2408
  try {
1910
2409
  const mammoth = require('mammoth');
1911
- const result = await mammoth.extractRawText({ path: resolvedPath });
1912
- const content = result.value;
1913
- const targetProfile = profile.trim();
2410
+ const zlib = require('zlib');
2411
+ const docxBuf = fs.readFileSync(resolvedPath);
2412
+ const result = await mammoth.extractRawText({ buffer: docxBuf });
2413
+ let extraXmlText = "";
2414
+ try {
2415
+ const zipEntries = {};
2416
+ let eocdOffset = -1;
2417
+ for (let i = docxBuf.length - 22; i >= Math.max(0, docxBuf.length - 65557); i--) {
2418
+ if (docxBuf.readUInt32LE(i) === 0x06054b50) {
2419
+ eocdOffset = i;
2420
+ break;
2421
+ }
2422
+ }
2423
+ if (eocdOffset !== -1) {
2424
+ const cdEntries = docxBuf.readUInt16LE(eocdOffset + 10);
2425
+ let pos = docxBuf.readUInt32LE(eocdOffset + 16);
2426
+ for (let e = 0; e < cdEntries && pos + 46 <= docxBuf.length; e++) {
2427
+ if (docxBuf.readUInt32LE(pos) !== 0x02014b50) break;
2428
+ const method = docxBuf.readUInt16LE(pos + 10);
2429
+ const compSize = docxBuf.readUInt32LE(pos + 20);
2430
+ const nameLen = docxBuf.readUInt16LE(pos + 28);
2431
+ const extraLen = docxBuf.readUInt16LE(pos + 30);
2432
+ const commentLen = docxBuf.readUInt16LE(pos + 32);
2433
+ const localOffset = docxBuf.readUInt32LE(pos + 42);
2434
+ const entryName = docxBuf.toString('utf8', pos + 46, pos + 46 + nameLen);
2435
+ if (localOffset + 30 <= docxBuf.length && docxBuf.readUInt32LE(localOffset) === 0x04034b50) {
2436
+ const lNameLen = docxBuf.readUInt16LE(localOffset + 26);
2437
+ const lExtraLen = docxBuf.readUInt16LE(localOffset + 28);
2438
+ const dataStart = localOffset + 30 + lNameLen + lExtraLen;
2439
+ const compData = docxBuf.subarray(dataStart, dataStart + compSize);
2440
+ if (method === 0) {
2441
+ zipEntries[entryName] = compData.toString('utf8');
2442
+ } else if (method === 8) {
2443
+ zipEntries[entryName] = zlib.inflateRawSync(compData).toString('utf8');
2444
+ }
2445
+ }
2446
+ pos += 46 + nameLen + extraLen + commentLen;
2447
+ }
2448
+ }
2449
+ const xmlTargets = Object.keys(zipEntries).filter((n) =>
2450
+ /^(?:word\/(?:header\d*|footer\d*|footnotes|endnotes|comments|commentsExtended|people)\.xml|docProps\/(?:core|app|custom)\.xml)$/i.test(n)
2451
+ );
2452
+ const docAuthors = new Set();
2453
+ for (const xmlPath of xmlTargets) {
2454
+ const rawXml = zipEntries[xmlPath];
2455
+ const authorRx = /\b(?:w:author|w15:author|w15:userId)="([^"]+)"/gi;
2456
+ let am;
2457
+ while ((am = authorRx.exec(rawXml)) !== null) {
2458
+ if (am[1] && am[1].trim()) docAuthors.add(am[1].trim());
2459
+ }
2460
+ const stripped = rawXml
2461
+ .replace(/<\/w:p>/gi, "\n")
2462
+ .replace(/<w:tab\b[^>]*\/?>/gi, "\t")
2463
+ .replace(/<[^>]+>/g, " ")
2464
+ .replace(/&amp;/g, "&")
2465
+ .replace(/&lt;/g, "<")
2466
+ .replace(/&gt;/g, ">")
2467
+ .replace(/&quot;/g, '"')
2468
+ .replace(/&apos;/g, "'")
2469
+ .replace(/[ \t]+/g, " ")
2470
+ .trim();
2471
+ if (stripped) extraXmlText += `\n${stripped}\n`;
2472
+ }
2473
+ if (zipEntries["word/document.xml"]) {
2474
+ const docXml = zipEntries["word/document.xml"];
2475
+ const authorRx = /\bw:author="([^"]+)"/gi;
2476
+ let am;
2477
+ while ((am = authorRx.exec(docXml)) !== null) {
2478
+ if (am[1] && am[1].trim()) docAuthors.add(am[1].trim());
2479
+ }
2480
+ const delAndInstrRx = /<w:(?:delText|instrText)\b[^>]*>([\s\S]*?)<\/w:(?:delText|instrText)>/gi;
2481
+ let dm;
2482
+ const hiddenChunks = [];
2483
+ while ((dm = delAndInstrRx.exec(docXml)) !== null) {
2484
+ const val = dm[1]
2485
+ .replace(/<[^>]+>/g, " ")
2486
+ .replace(/&amp;/g, "&")
2487
+ .replace(/&lt;/g, "<")
2488
+ .replace(/&gt;/g, ">")
2489
+ .replace(/&quot;/g, '"')
2490
+ .replace(/&apos;/g, "'")
2491
+ .trim();
2492
+ if (val) hiddenChunks.push(val);
2493
+ }
2494
+ if (hiddenChunks.length > 0) {
2495
+ extraXmlText += `\n[Tracked Changes / Field Text]: ${hiddenChunks.join(" | ")}\n`;
2496
+ }
2497
+ }
2498
+ if (docAuthors.size > 0) {
2499
+ extraXmlText += `\n[Document Reviewers]: ${Array.from(docAuthors).join(", ")}\n`;
2500
+ }
2501
+ const relsTargets = Object.keys(zipEntries).filter((n) => /^word\/_rels\/.*\.rels$/i.test(n));
2502
+ for (const relPath of relsTargets) {
2503
+ const relXml = zipEntries[relPath];
2504
+ const targetRegex = /Target="([^"]+)"/gi;
2505
+ let tm;
2506
+ while ((tm = targetRegex.exec(relXml)) !== null) {
2507
+ const val = tm[1];
2508
+ if (/^(?:mailto:|tel:|https?:\/\/)/i.test(val) && !/schemas\.openxmlformats\.org|schemas\.microsoft\.com|w3\.org/i.test(val)) {
2509
+ extraXmlText += `\n[Document Link]: ${val}\n`;
2510
+ }
2511
+ }
2512
+ }
2513
+ } catch (_) {}
2514
+ const content = (result.value || '') + (extraXmlText ? '\n' + extraXmlText : '');
1914
2515
  const isAdvanced = targetProfile.toLowerCase() !== "general";
1915
2516
  const license = checkLicenseStatus();
1916
2517
  let finalProfile = targetProfile;
@@ -1973,7 +2574,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1973
2574
  const pdf = require('pdf-parse');
1974
2575
  const data = await pdf(buffer);
1975
2576
  const content = data.text;
1976
- const targetProfile = profile.trim();
1977
2577
  let finalProfile = targetProfile;
1978
2578
 
1979
2579
  const sanitized = performSanitization(content, finalProfile, targetIgnoreList, targetSession);
@@ -2000,13 +2600,46 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2000
2600
  const XLSX = require('xlsx');
2001
2601
  const workbook = XLSX.read(buffer, { type: 'buffer' });
2002
2602
  let content = '';
2603
+ if (workbook.Props && typeof workbook.Props === 'object') {
2604
+ const propFields = ['Author', 'LastAuthor', 'Company', 'Manager', 'Title', 'Subject', 'Comments'];
2605
+ for (const pf of propFields) {
2606
+ if (workbook.Props[pf] && String(workbook.Props[pf]).trim()) {
2607
+ content += `[Workbook ${pf}]: ${String(workbook.Props[pf]).trim()}\n`;
2608
+ }
2609
+ }
2610
+ }
2611
+ if (workbook.CustProps && typeof workbook.CustProps === 'object') {
2612
+ for (const [ck, cv] of Object.entries(workbook.CustProps)) {
2613
+ if (cv !== undefined && cv !== null && String(cv).trim()) {
2614
+ content += `[Custom Property ${ck}]: ${String(cv).trim()}\n`;
2615
+ }
2616
+ }
2617
+ }
2003
2618
  workbook.SheetNames.forEach(sheetName => {
2004
2619
  const worksheet = workbook.Sheets[sheetName];
2005
2620
  content += `--- Sheet: ${sheetName} ---\n`;
2006
2621
  content += XLSX.utils.sheet_to_csv(worksheet) + '\n';
2622
+ if (worksheet && typeof worksheet === 'object') {
2623
+ for (const cellKey of Object.keys(worksheet)) {
2624
+ if (cellKey.startsWith('!')) continue;
2625
+ const cell = worksheet[cellKey];
2626
+ if (!cell || typeof cell !== 'object') continue;
2627
+ if (cell.l && cell.l.Target) {
2628
+ content += `[Hyperlink ${cellKey}]: ${cell.l.Target}\n`;
2629
+ }
2630
+ if (Array.isArray(cell.c)) {
2631
+ for (const comm of cell.c) {
2632
+ const author = comm.a ? `${comm.a}: ` : '';
2633
+ const cText = (comm.t || '').trim();
2634
+ if (cText) {
2635
+ content += `[Comment ${cellKey}] ${author}${cText}\n`;
2636
+ }
2637
+ }
2638
+ }
2639
+ }
2640
+ }
2007
2641
  });
2008
2642
 
2009
- const targetProfile = profile.trim();
2010
2643
  let finalProfile = targetProfile;
2011
2644
 
2012
2645
  const sanitized = performSanitization(content, finalProfile, targetIgnoreList, targetSession);
@@ -2051,14 +2684,63 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2051
2684
  };
2052
2685
  }
2053
2686
 
2054
- const content = buffer.toString("utf8");
2055
- const targetProfile = profile.trim();
2687
+ let content = buffer.toString("utf8");
2688
+ const ext = path.extname(resolvedPath).toLowerCase();
2689
+ if (ext === ".eml") {
2690
+ const decodeQpUtf8 = (str) =>
2691
+ str
2692
+ .replace(/=\r?\n/g, "")
2693
+ .replace(/(?:=[0-9A-Fa-f]{2})+/g, (seq) => {
2694
+ try {
2695
+ const bytes = Buffer.alloc(seq.length / 3);
2696
+ for (let i = 0; i < bytes.length; i++) {
2697
+ bytes[i] = parseInt(seq.slice(i * 3 + 1, i * 3 + 3), 16);
2698
+ }
2699
+ return bytes.toString("utf8");
2700
+ } catch (_) {
2701
+ return seq;
2702
+ }
2703
+ });
2704
+ content = content.replace(/=\?([^?]+)\?([BQbq])\?([^?]+)\?=/g, (full, charset, enc, payload) => {
2705
+ try {
2706
+ if (enc.toUpperCase() === "B") {
2707
+ return Buffer.from(payload.replace(/\s+/g, ""), "base64").toString("utf8");
2708
+ }
2709
+ return decodeQpUtf8(payload.replace(/_/g, " "));
2710
+ } catch (_) {
2711
+ return full;
2712
+ }
2713
+ });
2714
+ content = content.replace(
2715
+ /(Content-Transfer-Encoding\s*:\s*base64\b[\s\S]*?\r?\n\r?\n)([A-Za-z0-9+/=\r\n]+)(?=\r?\n--|\s*$)/gi,
2716
+ (full, hdr, b64Block) => {
2717
+ try {
2718
+ const cleanB64 = b64Block.replace(/[\r\n\s]+/g, "");
2719
+ if (cleanB64.length < 8) return full;
2720
+ return hdr + Buffer.from(cleanB64, "base64").toString("utf8");
2721
+ } catch (_) {
2722
+ return full;
2723
+ }
2724
+ }
2725
+ );
2726
+ content = decodeQpUtf8(content);
2727
+ } else if (ext === ".rtf") {
2728
+ content = content
2729
+ .replace(/\\par\b|\\line\b/gi, "\n")
2730
+ .replace(/\\'([0-9a-fA-F]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
2731
+ .replace(/\\u(-?\d+)\??/g, (_, code) => {
2732
+ const n = parseInt(code, 10);
2733
+ return String.fromCharCode(n < 0 ? n + 65536 : n);
2734
+ })
2735
+ .replace(/\\[a-zA-Z]+(?:-?\d+)?[ ]?/g, " ")
2736
+ .replace(/[{}]/g, " ")
2737
+ .replace(/[ \t]+/g, " ");
2738
+ }
2056
2739
  const isAdvanced = targetProfile.toLowerCase() !== "general";
2057
2740
  const license = checkLicenseStatus();
2058
2741
  let finalProfile = targetProfile;
2059
2742
  let isAutoElevated = false;
2060
2743
 
2061
- const ext = path.extname(resolvedPath).toLowerCase();
2062
2744
  const CODE_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx', '.py', '.go', '.rs', '.java', '.rb', '.php', '.cs', '.sh', '.bash', '.zsh', '.env', '.yaml', '.yml', '.toml', '.sql']);
2063
2745
  const DEV_HEURISTIC_REGEX = /(?:AKIA[0-9A-Z]{16}|ghp_[a-zA-Z0-9]{36}|sk-[a-zA-Z0-9]{20,}|-----BEGIN (?:RSA )?PRIVATE KEY-----|Bearer\s+eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+|(?:postgres|postgresql|mysql|mongodb|redis):\/\/[^\s"'>]+|export\s+(?:AWS_|DATABASE_|API_KEY|SECRET)|const\s+\w+\s*=\s*require\(|import\s+.*\s+from\s+['"])/i;
2064
2746
 
@@ -2095,7 +2777,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2095
2777
  }
2096
2778
 
2097
2779
  if (name === "redact_file") {
2098
- const { file_path, profile, dry_run, no_backup } = args;
2780
+ const { file_path, dry_run, no_backup } = args;
2099
2781
  if (!file_path || !fs.existsSync(file_path)) {
2100
2782
  return { isError: true, content: [{ type: "text", text: `Error: File not found at path: ${file_path}` }] };
2101
2783
  }
@@ -2110,10 +2792,55 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2110
2792
  if (buffer.indexOf(0) !== -1) {
2111
2793
  return { isError: true, content: [{ type: "text", text: `Error: Cannot redact binary file.` }] };
2112
2794
  }
2113
- const content = buffer.toString('utf8');
2795
+ let content = buffer.toString('utf8');
2796
+ const ext = path.extname(file_path).toLowerCase();
2797
+ if (ext === ".eml") {
2798
+ const decodeQpUtf8 = (str) =>
2799
+ str.replace(/=\r?\n/g, "").replace(/(?:=[0-9A-Fa-f]{2})+/g, (seq) => {
2800
+ try {
2801
+ const bytes = Buffer.alloc(seq.length / 3);
2802
+ for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(seq.slice(i * 3 + 1, i * 3 + 3), 16);
2803
+ return bytes.toString("utf8");
2804
+ } catch (_) { return seq; }
2805
+ });
2806
+ content = content.replace(/=\?([^?]+)\?([BQbq])\?([^?]+)\?=/g, (full, charset, enc, payload) => {
2807
+ try {
2808
+ if (enc.toUpperCase() === "B") return Buffer.from(payload.replace(/\s+/g, ""), "base64").toString("utf8");
2809
+ return decodeQpUtf8(payload.replace(/_/g, " "));
2810
+ } catch (_) { return full; }
2811
+ });
2812
+ content = content.replace(/(Content-Transfer-Encoding\s*:\s*base64\b[\s\S]*?\r?\n\r?\n)([A-Za-z0-9+/=\r\n]+)(?=\r?\n--|\s*$)/gi, (full, hdr, b64Block) => {
2813
+ try {
2814
+ const cleanB64 = b64Block.replace(/[\r\n\s]+/g, "");
2815
+ if (cleanB64.length < 8) return full;
2816
+ return hdr + Buffer.from(cleanB64, "base64").toString("utf8");
2817
+ } catch (_) { return full; }
2818
+ });
2819
+ content = decodeQpUtf8(content);
2820
+ } else if (ext === ".rtf") {
2821
+ content = content
2822
+ .replace(/\\par\b|\\line\b/gi, "\n")
2823
+ .replace(/\\'([0-9a-fA-F]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
2824
+ .replace(/\\u(-?\d+)\??/g, (_, code) => {
2825
+ const n = parseInt(code, 10);
2826
+ return String.fromCharCode(n < 0 ? n + 65536 : n);
2827
+ })
2828
+ .replace(/\\[a-zA-Z]+(?:-?\d+)?[ ]?/g, " ")
2829
+ .replace(/[{}]/g, " ")
2830
+ .replace(/[ \t]+/g, " ");
2831
+ }
2114
2832
 
2115
2833
  const customRules = loadCustomRules();
2116
- const normalizedProfile = (profile || "general").trim().toLowerCase();
2834
+ const activeRules = [...customRules];
2835
+ DEVOPS_SECRETS_DETECTOR.forEach(rule => {
2836
+ activeRules.push({
2837
+ name: rule.name,
2838
+ pattern: rule.regex.source,
2839
+ regex: new RegExp(rule.regex.source, rule.regex.flags),
2840
+ label: rule.label || 'SECRET'
2841
+ });
2842
+ });
2843
+ const normalizedProfile = resolveRequestedProfile(args, "general").toLowerCase();
2117
2844
  const license = checkLicenseStatus();
2118
2845
 
2119
2846
  const limitStatus = checkFreeTierLimit(license.isPro);
@@ -2125,7 +2852,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2125
2852
  }
2126
2853
  const localSessionMap = {};
2127
2854
 
2128
- const result = PrivacyScrubberCore.scrubText(content, customRules, {}, normalizedProfile, localSessionMap, license.isPro);
2855
+ const result = PrivacyScrubberCore.scrubText(content, activeRules, {}, normalizedProfile, localSessionMap, license.isPro);
2129
2856
 
2130
2857
  let isSecretLeak = false;
2131
2858
  if (!license.isPro && result.tokenMap) {
@@ -2240,8 +2967,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2240
2967
  const tierIcon = license.isPro ? '✅' : '🔓';
2241
2968
  const profileList = license.isPro
2242
2969
  ? 'All 30 specialized profiles active (General, Dev, Medical, Legal, Finance, HR…)'
2243
- : 'General only — PRO unlocks all 30 specialized industry profiles';
2244
- const sizeLimit = license.isPro ? 'Unlimited' : '15,000 characters per request';
2970
+ : 'All 30 profiles active (5k quota) · PRO unlocks unlimited';
2971
+ const sizeLimit = license.isPro ? 'Unlimited' : '15,000 char (General) / 5,000 char (Industry)';
2245
2972
 
2246
2973
  const configPath = resolveConfigPath();
2247
2974
  let rulesStatus = '';
@@ -2323,7 +3050,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2323
3050
  }
2324
3051
 
2325
3052
  if (name === "audit_directory_for_pii") {
2326
- const { directory_path, profile, extensions, ignore_node_modules } = args;
3053
+ const { directory_path, extensions, ignore_node_modules } = args;
2327
3054
  if (!directory_path || !fs.existsSync(directory_path)) {
2328
3055
  return { isError: true, content: [{ type: "text", text: `Error: Directory not found at path: ${directory_path}` }] };
2329
3056
  }
@@ -2352,7 +3079,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2352
3079
  let reportLines = [`## Security Audit Report: ${directory_path}`];
2353
3080
  let filesWithIssues = 0;
2354
3081
  const customRules = loadCustomRules();
2355
- const normalizedProfile = (profile || "general").trim().toLowerCase();
3082
+ const activeRules = [...customRules];
3083
+ DEVOPS_SECRETS_DETECTOR.forEach(rule => {
3084
+ activeRules.push({
3085
+ name: rule.name,
3086
+ pattern: rule.regex.source,
3087
+ regex: new RegExp(rule.regex.source, rule.regex.flags),
3088
+ label: rule.label || 'SECRET'
3089
+ });
3090
+ });
3091
+ const normalizedProfile = resolveRequestedProfile(args, "general").toLowerCase();
2356
3092
  const license = checkLicenseStatus();
2357
3093
 
2358
3094
  const limitStatus = checkFreeTierLimit(license.isPro);
@@ -2374,7 +3110,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2374
3110
  const content = buffer.toString('utf8');
2375
3111
 
2376
3112
  const localSessionMap = {};
2377
- const result = PrivacyScrubberCore.scrubText(content, customRules, {}, normalizedProfile, localSessionMap, license.isPro);
3113
+ const result = PrivacyScrubberCore.scrubText(content, activeRules, {}, normalizedProfile, localSessionMap, license.isPro);
2378
3114
 
2379
3115
  Object.assign(totalTokensForDirectory, localSessionMap);
2380
3116
 
@@ -2422,17 +3158,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2422
3158
  }
2423
3159
 
2424
3160
  if (name === "generate_compliance_report") {
2425
- const { format = "markdown", company_name = "PrivacyScrubber Client", department = "SecOps / Compliance" } = args || {};
2426
- const telemetry = buildCisoAuditTelemetry(sessionMap);
3161
+ const { format = "markdown", company_name = "PrivacyScrubber Client", department = "SecOps / Compliance", session_id = "default" } = args || {};
3162
+ const targetSession = getSessionMap(session_id);
3163
+ const telemetry = buildCisoAuditTelemetry(targetSession);
2427
3164
 
2428
- const receipt = buildPsr2Receipt(sessionMap);
3165
+ const receipt = buildPsr2Receipt(targetSession);
2429
3166
  return {
2430
3167
  content: [{ type: "text", text: formatPsr2Report({ format, receipt, telemetry, company_name, department, utmSource: 'mcp_compliance_report', utmMedium: format === 'json' ? 'json' : 'markdown' }) }]
2431
3168
  };
2432
3169
  }
2433
3170
 
2434
3171
  if (name === "guard_exec") {
2435
- const { command, cwd, profile = "Dev", timeout_ms = 15000, session_id = "default" } = args || {};
3172
+ const { command, cwd, timeout_ms = 15000, session_id = "default" } = args || {};
2436
3173
  if (!command || typeof command !== "string") {
2437
3174
  return {
2438
3175
  isError: true,
@@ -2472,7 +3209,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2472
3209
  exitCode = typeof err.status === 'number' ? err.status : 1;
2473
3210
  }
2474
3211
 
2475
- const targetProfile = (profile || "Dev").trim();
3212
+ const targetProfile = resolveRequestedProfile(args, "Dev");
2476
3213
  const isAdvanced = targetProfile.toLowerCase() !== "general";
2477
3214
  const charLimit = (isAdvanced && !license.isPro) ? 5000 : 15000;
2478
3215
 
@@ -2502,7 +3239,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2502
3239
  }
2503
3240
 
2504
3241
  if (name === "guard_read_file") {
2505
- const { file_path, profile = "Dev", max_lines = 500, session_id = "default" } = args || {};
3242
+ const { file_path, max_lines = 500, session_id = "default" } = args || {};
2506
3243
  if (!file_path || typeof file_path !== "string") {
2507
3244
  return {
2508
3245
  isError: true,
@@ -2523,6 +3260,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2523
3260
  content: [{ type: "text", text: `Error: Path is a directory. Use audit_directory_for_pii to scan directories.` }]
2524
3261
  };
2525
3262
  }
3263
+ if (stat.size > 10 * 1024 * 1024) {
3264
+ return {
3265
+ isError: true,
3266
+ content: [{ type: "text", text: `Error: File is too large (${(stat.size / 1024 / 1024).toFixed(2)}MB). Maximum allowed size is 10MB.` }]
3267
+ };
3268
+ }
3269
+ const rawBuffer = fs.readFileSync(resolvedPath);
3270
+ const checkLen = Math.min(rawBuffer.length, 8000);
3271
+ for (let i = 0; i < checkLen; i++) {
3272
+ if (rawBuffer[i] === 0) {
3273
+ return {
3274
+ isError: true,
3275
+ content: [{ type: "text", text: `Error: Binary file format detected. Use sanitize_file for DOCX/PDF/XLSX documents.` }]
3276
+ };
3277
+ }
3278
+ }
2526
3279
  const license = checkLicenseStatus();
2527
3280
  const limitStatus = checkFreeTierLimit(license.isPro);
2528
3281
  if (limitStatus.blocked) {
@@ -2532,12 +3285,48 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2532
3285
  };
2533
3286
  }
2534
3287
 
2535
- let rawContent = fs.readFileSync(resolvedPath, 'utf8');
3288
+ let rawContent = rawBuffer.toString('utf8');
3289
+ const ext = path.extname(resolvedPath).toLowerCase();
3290
+ if (ext === '.eml') {
3291
+ const decodeQpUtf8 = (str) =>
3292
+ str.replace(/=\r?\n/g, '').replace(/(?:=[0-9A-Fa-f]{2})+/g, (seq) => {
3293
+ try {
3294
+ const bytes = Buffer.alloc(seq.length / 3);
3295
+ for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(seq.slice(i * 3 + 1, i * 3 + 3), 16);
3296
+ return bytes.toString('utf8');
3297
+ } catch (_) { return seq; }
3298
+ });
3299
+ rawContent = rawContent.replace(/=\?([^?]+)\?([BQbq])\?([^?]+)\?=/g, (full, charset, enc, payload) => {
3300
+ try {
3301
+ if (enc.toUpperCase() === 'B') return Buffer.from(payload.replace(/\s+/g, ''), 'base64').toString('utf8');
3302
+ return decodeQpUtf8(payload.replace(/_/g, ' '));
3303
+ } catch (_) { return full; }
3304
+ });
3305
+ rawContent = rawContent.replace(/(Content-Transfer-Encoding\s*:\s*base64\b[\s\S]*?\r?\n\r?\n)([A-Za-z0-9+/=\r\n]+)(?=\r?\n--|\s*$)/gi, (full, hdr, b64Block) => {
3306
+ try {
3307
+ const cleanB64 = b64Block.replace(/[\r\n\s]+/g, '');
3308
+ if (cleanB64.length < 8) return full;
3309
+ return hdr + Buffer.from(cleanB64, 'base64').toString('utf8');
3310
+ } catch (_) { return full; }
3311
+ });
3312
+ rawContent = decodeQpUtf8(rawContent);
3313
+ } else if (ext === '.rtf') {
3314
+ rawContent = rawContent
3315
+ .replace(/\\par\b|\\line\b/gi, '\n')
3316
+ .replace(/\\'([0-9a-fA-F]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
3317
+ .replace(/\\u(-?\d+)\??/g, (_, code) => {
3318
+ const n = parseInt(code, 10);
3319
+ return String.fromCharCode(n < 0 ? n + 65536 : n);
3320
+ })
3321
+ .replace(/\\[a-zA-Z]+(?:-?\d+)?[ ]?/g, ' ')
3322
+ .replace(/[{}]/g, ' ')
3323
+ .replace(/[ \t]+/g, ' ');
3324
+ }
2536
3325
  const lines = rawContent.split(/\r?\n/);
2537
3326
  const capped = lines.slice(0, max_lines).join('\n');
2538
3327
  const wasLineTruncated = lines.length > max_lines;
2539
3328
 
2540
- const targetProfile = (profile || "Dev").trim();
3329
+ const targetProfile = resolveRequestedProfile(args, "Dev");
2541
3330
  const isAdvanced = targetProfile.toLowerCase() !== "general";
2542
3331
  const charLimit = (isAdvanced && !license.isPro) ? 5000 : 15000;
2543
3332
 
@@ -2563,7 +3352,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2563
3352
  }
2564
3353
 
2565
3354
  if (name === "guard_git_diff") {
2566
- const { staged = false, cwd, profile = "Dev", session_id = "default" } = args || {};
3355
+ const { staged = false, cwd, session_id = "default" } = args || {};
2567
3356
  const targetSession = getSessionMap(session_id);
2568
3357
  const targetIgnoreList = getSessionIgnoreList(session_id);
2569
3358
  const repoCwd = cwd ? path.resolve(cwd) : process.cwd();
@@ -2597,7 +3386,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2597
3386
  };
2598
3387
  }
2599
3388
 
2600
- const targetProfile = (profile || "Dev").trim();
3389
+ const targetProfile = resolveRequestedProfile(args, "Dev");
2601
3390
  const isAdvanced = targetProfile.toLowerCase() !== "general";
2602
3391
  const charLimit = (isAdvanced && !license.isPro) ? 5000 : 15000;
2603
3392
 
@@ -2806,6 +3595,7 @@ Before reading sensitive files, running terminal commands that may print credent
2806
3595
  }
2807
3596
  const tokenEntries = Object.entries(session.map);
2808
3597
  const telemetry = buildCisoAuditTelemetry(session.map);
3598
+ const streamBufferLen = session.streamDetokenizer ? session.streamDetokenizer.buffer.length : 0;
2809
3599
  return {
2810
3600
  content: [{
2811
3601
  type: "text",
@@ -2813,6 +3603,7 @@ Before reading sensitive files, running terminal commands that may print credent
2813
3603
  `Created: ${new Date(session.createdAt).toISOString()}\n` +
2814
3604
  `Last Active: ${new Date(session.lastActive).toISOString()}\n` +
2815
3605
  `Total Tokens Stored in RAM: ${tokenEntries.length}\n` +
3606
+ `Stream Buffer: ${streamBufferLen} bytes pending\n` +
2816
3607
  `Entity Types: ${Object.keys(telemetry.entities).join(', ') || 'None'}\n` +
2817
3608
  `Risk Level: ${telemetry.riskLevel}\n` +
2818
3609
  `Status: Volatile in-memory mapping active (0 bytes disk/network).`
@@ -2825,7 +3616,8 @@ Before reading sensitive files, running terminal commands that may print credent
2825
3616
  for (const [sid, sess] of agentSessions.entries()) {
2826
3617
  const count = Object.keys(sess.map).length;
2827
3618
  totalTokens += count;
2828
- sessionList.push({ id: sid, tokens: count, lastActive: new Date(sess.lastActive).toISOString() });
3619
+ const streamBufferLen = sess.streamDetokenizer ? sess.streamDetokenizer.buffer.length : 0;
3620
+ sessionList.push({ id: sid, tokens: count, streamBufferBytes: streamBufferLen, lastActive: new Date(sess.lastActive).toISOString() });
2829
3621
  }
2830
3622
 
2831
3623
  return {
@@ -2836,7 +3628,7 @@ Before reading sensitive files, running terminal commands that may print credent
2836
3628
  `Total Tokens in Memory: ${totalTokens}\n` +
2837
3629
  `Process Heap Used: ${(memUsage.heapUsed / (1024 * 1024)).toFixed(2)} MB\n` +
2838
3630
  `Uptime: ${Math.floor(process.uptime())} seconds\n` +
2839
- `Active Sessions: ${sessionList.map(s => `${s.id} (${s.tokens} tokens)`).join(', ') || 'None'}`
3631
+ `Active Sessions: ${sessionList.map(s => `${s.id} (${s.tokens} tokens, ${s.streamBufferBytes} buf)`).join(', ') || 'None'}`
2840
3632
  }]
2841
3633
  };
2842
3634
  }
@@ -2845,6 +3637,11 @@ Before reading sensitive files, running terminal commands that may print credent
2845
3637
  const { session_id, all = false, reset_all = false } = args || {};
2846
3638
  if (all || reset_all) {
2847
3639
  const count = agentSessions.size;
3640
+ for (const sess of agentSessions.values()) {
3641
+ if (sess.streamDetokenizer) {
3642
+ sess.streamDetokenizer.reset();
3643
+ }
3644
+ }
2848
3645
  agentSessions.clear();
2849
3646
  getSessionMap('default');
2850
3647
  sessionIgnoreList.clear();
@@ -2857,6 +3654,10 @@ Before reading sensitive files, running terminal commands that may print credent
2857
3654
  }
2858
3655
 
2859
3656
  const sid = (session_id || 'default').toString().trim();
3657
+ const sess = agentSessions.get(sid);
3658
+ if (sess && sess.streamDetokenizer) {
3659
+ sess.streamDetokenizer.reset();
3660
+ }
2860
3661
  const existed = agentSessions.delete(sid);
2861
3662
  if (sid === 'default') {
2862
3663
  getSessionMap('default');
@@ -2872,7 +3673,7 @@ Before reading sensitive files, running terminal commands that may print credent
2872
3673
  }
2873
3674
 
2874
3675
  if (name === "guard_rag_chunk") {
2875
- const { chunks, profile = "General", session_id = "default" } = args || {};
3676
+ const { chunks, session_id = "default" } = args || {};
2876
3677
  if (!Array.isArray(chunks)) {
2877
3678
  return {
2878
3679
  isError: true,
@@ -2880,6 +3681,7 @@ Before reading sensitive files, running terminal commands that may print credent
2880
3681
  };
2881
3682
  }
2882
3683
 
3684
+ const targetProfile = resolveRequestedProfile(args, "General");
2883
3685
  const targetSession = getSessionMap(session_id);
2884
3686
  const targetIgnoreList = getSessionIgnoreList(session_id);
2885
3687
  let totalTokensRedacted = 0;
@@ -2888,13 +3690,13 @@ Before reading sensitive files, running terminal commands that may print credent
2888
3690
  for (let i = 0; i < chunks.length; i++) {
2889
3691
  const item = chunks[i];
2890
3692
  if (typeof item === 'string') {
2891
- const res = performSanitization(item, profile, targetIgnoreList, targetSession);
3693
+ const res = performSanitization(item, targetProfile, targetIgnoreList, targetSession);
2892
3694
  totalTokensRedacted += Object.keys(res.newTokens || {}).length;
2893
3695
  sanitizedChunks.push(res.scrubbedText);
2894
3696
  } else if (item && typeof item === 'object') {
2895
3697
  const copy = { ...item };
2896
3698
  const rawText = copy.text || copy.pageContent || copy.document || '';
2897
- const res = performSanitization(rawText, profile, targetIgnoreList, targetSession);
3699
+ const res = performSanitization(rawText, targetProfile, targetIgnoreList, targetSession);
2898
3700
  totalTokensRedacted += Object.keys(res.newTokens || {}).length;
2899
3701
 
2900
3702
  if (copy.text !== undefined) copy.text = res.scrubbedText;
@@ -3183,7 +3985,7 @@ function detectSecrets(text) {
3183
3985
  return detected;
3184
3986
  }
3185
3987
 
3186
- function performSanitization(text, profile, ignoreList = null, customSessionMap = null) {
3988
+ export function performSanitization(text, profile, ignoreList = null, customSessionMap = null) {
3187
3989
  const customRules = loadCustomRules();
3188
3990
  const normalizedProfile = (profile || "general").trim().toLowerCase();
3189
3991
  const license = checkLicenseStatus();
@@ -3211,7 +4013,7 @@ function performSanitization(text, profile, ignoreList = null, customSessionMap
3211
4013
  return { scrubbedText: result.scrubbedText, newTokens };
3212
4014
  }
3213
4015
 
3214
- function truncateIfFree(text, isPro, charLimit = 15000) {
4016
+ export function truncateIfFree(text, isPro, charLimit = 15000) {
3215
4017
  if (!isPro && text.length > charLimit) {
3216
4018
  mcpLog(`${colors.yellowBold}[PrivacyScrubber] Input truncated to ${charLimit.toLocaleString()} characters (Free Tier Limit).${colors.reset}\n${colors.cyan}[Upgrade] Set PRIVACYSCRUBBER_KEY to your PRO/TEAMS/SDK license key for unlimited throughput: https://privacyscrubber.com/pricing?tier=teams&utm_source=mcp&utm_medium=truncation&utm_campaign=teams_upgrade${colors.reset}\n`);
3217
4019
  const upsellNotice = `\n\n[PrivacyScrubber Free Tier: Payload truncated to ${charLimit.toLocaleString()} chars. Upgrade to PRO ($15/mo or $110 Lifetime) or Developer SDK ($299/mo) for unlimited payload processing: https://privacyscrubber.com/pricing?tier=teams&utm_source=mcp&utm_medium=truncation&utm_campaign=teams_upgrade]`;