@privacyscrubber/sdk 2.4.5 → 2.4.6

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
@@ -34,6 +34,16 @@ if (scrubberPkg && typeof scrubberPkg.init === 'function') {
34
34
  scrubberPkg.init();
35
35
  }
36
36
 
37
+ export const SDK_VERSION = (() => {
38
+ try {
39
+ const pkgJson = require('./package.json');
40
+ if (pkgJson && typeof pkgJson.version === 'string') {
41
+ return pkgJson.version;
42
+ }
43
+ } catch (_) {}
44
+ return '2.4.6';
45
+ })();
46
+
37
47
  /**
38
48
  * Built-in patterns for DevOps & Cloud Secrets detection.
39
49
  */
@@ -724,6 +734,21 @@ export function sanitizeObject(obj, options = {}) {
724
734
 
725
735
  const mergedSessionMap = { ...(options.existingSessionMap || {}) };
726
736
  let totalCount = 0;
737
+ const seen = new WeakMap();
738
+
739
+ function isNonPlainObject(val) {
740
+ return (
741
+ val instanceof Date ||
742
+ val instanceof RegExp ||
743
+ val instanceof Error ||
744
+ val instanceof Promise ||
745
+ val instanceof WeakMap ||
746
+ val instanceof WeakSet ||
747
+ (typeof URL !== 'undefined' && val instanceof URL) ||
748
+ ArrayBuffer.isView(val) ||
749
+ (typeof ArrayBuffer !== 'undefined' && val instanceof ArrayBuffer)
750
+ );
751
+ }
727
752
 
728
753
  function traverseAndSanitize(val) {
729
754
  if (val === null || val === undefined) return val;
@@ -738,20 +763,42 @@ export function sanitizeObject(obj, options = {}) {
738
763
  totalCount += res.count;
739
764
  return res.scrubbedText;
740
765
  }
766
+ if (typeof val !== 'object') return val;
767
+ if (isNonPlainObject(val)) return val;
768
+ if (seen.has(val)) return seen.get(val);
769
+
741
770
  if (Array.isArray(val)) {
742
- return val.map(item => traverseAndSanitize(item));
771
+ const outArr = [];
772
+ seen.set(val, outArr);
773
+ for (let i = 0; i < val.length; i++) {
774
+ outArr.push(traverseAndSanitize(val[i]));
775
+ }
776
+ return outArr;
743
777
  }
744
- if (val instanceof Date || val instanceof RegExp) {
745
- return val;
778
+ if (val instanceof Map) {
779
+ const outMap = new Map();
780
+ seen.set(val, outMap);
781
+ for (const [k, v] of val.entries()) {
782
+ outMap.set(k, traverseAndSanitize(v));
783
+ }
784
+ return outMap;
746
785
  }
747
- if (typeof val === 'object') {
748
- const out = {};
749
- for (const [k, v] of Object.entries(val)) {
750
- out[k] = traverseAndSanitize(v);
786
+ if (val instanceof Set) {
787
+ const outSet = new Set();
788
+ seen.set(val, outSet);
789
+ for (const item of val.values()) {
790
+ outSet.add(traverseAndSanitize(item));
751
791
  }
752
- return out;
792
+ return outSet;
793
+ }
794
+
795
+ const out = {};
796
+ seen.set(val, out);
797
+ for (const [k, v] of Object.entries(val)) {
798
+ if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
799
+ out[k] = traverseAndSanitize(v);
753
800
  }
754
- return val;
801
+ return out;
755
802
  }
756
803
 
757
804
  const sanitizedResult = traverseAndSanitize(obj);
@@ -781,23 +828,62 @@ export function sanitizeObject(obj, options = {}) {
781
828
  * @param {Record<string, string>} tokenMap - Token map returned from sanitize() or sanitizeObject().
782
829
  * @returns {any} - Restored object with original values.
783
830
  */
784
- export function restoreObject(obj, tokenMap = {}) {
831
+ export function restoreObject(obj, tokenMap = {}, _seen = null) {
785
832
  if (obj === null || obj === undefined) return obj;
786
833
  if (typeof obj === 'string') {
787
834
  const res = restore(obj, tokenMap);
788
835
  return res.restoredText;
789
836
  }
837
+ if (typeof obj !== 'object') return obj;
838
+ if (
839
+ obj instanceof Date ||
840
+ obj instanceof RegExp ||
841
+ obj instanceof Error ||
842
+ obj instanceof Promise ||
843
+ obj instanceof WeakMap ||
844
+ obj instanceof WeakSet ||
845
+ (typeof URL !== 'undefined' && obj instanceof URL) ||
846
+ ArrayBuffer.isView(obj) ||
847
+ (typeof ArrayBuffer !== 'undefined' && obj instanceof ArrayBuffer)
848
+ ) {
849
+ return obj;
850
+ }
851
+
852
+ const seen = _seen || new WeakMap();
853
+ if (seen.has(obj)) return seen.get(obj);
854
+
790
855
  if (Array.isArray(obj)) {
791
- return obj.map(item => restoreObject(item, tokenMap));
856
+ const outArr = [];
857
+ seen.set(obj, outArr);
858
+ for (let i = 0; i < obj.length; i++) {
859
+ outArr.push(restoreObject(obj[i], tokenMap, seen));
860
+ }
861
+ return outArr;
792
862
  }
793
- if (typeof obj === 'object' && !(obj instanceof Date) && !(obj instanceof RegExp)) {
794
- const out = {};
795
- for (const [k, v] of Object.entries(obj)) {
796
- out[k] = restoreObject(v, tokenMap);
863
+ if (obj instanceof Map) {
864
+ const outMap = new Map();
865
+ seen.set(obj, outMap);
866
+ for (const [k, v] of obj.entries()) {
867
+ outMap.set(k, restoreObject(v, tokenMap, seen));
797
868
  }
798
- return out;
869
+ return outMap;
799
870
  }
800
- return obj;
871
+ if (obj instanceof Set) {
872
+ const outSet = new Set();
873
+ seen.set(obj, outSet);
874
+ for (const item of obj.values()) {
875
+ outSet.add(restoreObject(item, tokenMap, seen));
876
+ }
877
+ return outSet;
878
+ }
879
+
880
+ const out = {};
881
+ seen.set(obj, out);
882
+ for (const [k, v] of Object.entries(obj)) {
883
+ if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
884
+ out[k] = restoreObject(v, tokenMap, seen);
885
+ }
886
+ return out;
801
887
  }
802
888
 
803
889
  /**
@@ -840,12 +926,16 @@ export class PrivacyScrubberEngine {
840
926
  .forEach(v => mergedIgnoreList.add(v));
841
927
  }
842
928
 
929
+ const effectiveExistingMap = options.persistSession !== false
930
+ ? { ...this.sessionMap, ...(options.existingSessionMap || {}) }
931
+ : (options.existingSessionMap || {});
932
+
843
933
  const mergedOptions = {
844
934
  profile: options.profile || this.defaultProfile,
845
935
  licenseKey: options.licenseKey || this.licenseKey,
846
936
  customRules: [...this.customRules, ...(options.customRules || [])],
847
937
  tokenLabelMap: { ...this.tokenLabelMap, ...(options.tokenLabelMap || {}) },
848
- existingSessionMap: options.persistSession !== false ? this.sessionMap : (options.existingSessionMap || {}),
938
+ existingSessionMap: effectiveExistingMap,
849
939
  generateReceipt: options.generateReceipt !== undefined ? options.generateReceipt : this.generateReceipt,
850
940
  throwOnLimit: options.throwOnLimit !== undefined ? options.throwOnLimit : true,
851
941
  detectSecrets: options.detectSecrets !== undefined ? options.detectSecrets : false,
@@ -945,68 +1035,229 @@ export class PrivacyScrubberEngine {
945
1035
  * @returns {object} Wrapped OpenAI client.
946
1036
  */
947
1037
  export function wrapOpenAI(openaiClient, options = {}) {
948
- if (!openaiClient || !openaiClient.chat || !openaiClient.chat.completions) {
1038
+ const hasChatCompletions = !!(openaiClient && openaiClient.chat && openaiClient.chat.completions && typeof openaiClient.chat.completions.create === 'function');
1039
+ const hasResponses = !!(openaiClient && openaiClient.responses && typeof openaiClient.responses.create === 'function');
1040
+ if (!hasChatCompletions && !hasResponses) {
949
1041
  throw new Error('[PrivacyScrubber] Invalid OpenAI client instance passed to wrapOpenAI.');
950
1042
  }
951
1043
 
952
1044
  const engine = new PrivacyScrubberEngine(options);
953
- const originalCreate = openaiClient.chat.completions.create.bind(openaiClient.chat.completions);
954
1045
 
955
- openaiClient.chat.completions.create = async function(params, requestOptions) {
956
- if (!params || !Array.isArray(params.messages)) {
957
- return originalCreate(params, requestOptions);
1046
+ function sanitizeMessageContent(content, sessionTokenMap) {
1047
+ if (typeof content === 'string') {
1048
+ const res = engine.sanitize(content, { existingSessionMap: sessionTokenMap });
1049
+ Object.assign(sessionTokenMap, res.tokenMap);
1050
+ return res.scrubbedText;
958
1051
  }
1052
+ if (Array.isArray(content)) {
1053
+ return content.map(chunk => {
1054
+ if (chunk && (chunk.type === 'text' || chunk.type === 'input_text') && typeof chunk.text === 'string') {
1055
+ const res = engine.sanitize(chunk.text, { existingSessionMap: sessionTokenMap });
1056
+ Object.assign(sessionTokenMap, res.tokenMap);
1057
+ return { ...chunk, text: res.scrubbedText };
1058
+ }
1059
+ return chunk;
1060
+ });
1061
+ }
1062
+ return content;
1063
+ }
959
1064
 
960
- const sessionTokenMap = {};
961
- const sanitizedMessages = params.messages.map(msg => {
962
- if (typeof msg.content === 'string') {
963
- const res = engine.sanitize(msg.content, { existingSessionMap: sessionTokenMap });
1065
+ if (hasChatCompletions) {
1066
+ const originalCreate = openaiClient.chat.completions.create.bind(openaiClient.chat.completions);
1067
+
1068
+ openaiClient.chat.completions.create = async function(params, requestOptions) {
1069
+ if (!params || !Array.isArray(params.messages)) {
1070
+ return originalCreate(params, requestOptions);
1071
+ }
1072
+
1073
+ const sessionTokenMap = options.persistSession === false ? {} : { ...engine.sessionMap };
1074
+ const sanitizedMessages = params.messages.map(msg => ({
1075
+ ...msg,
1076
+ content: sanitizeMessageContent(msg.content, sessionTokenMap)
1077
+ }));
1078
+
1079
+ const modifiedParams = { ...params, messages: sanitizedMessages };
1080
+ const response = await originalCreate(modifiedParams, requestOptions);
1081
+
1082
+ // Streaming support: transparently de-tokenize async iterable chunks
1083
+ if (params.stream) {
1084
+ const detok = new StreamingDetokenizer(sessionTokenMap);
1085
+ return detok.wrapStream(response);
1086
+ }
1087
+
1088
+ if (response && Array.isArray(response.choices)) {
1089
+ response.choices.forEach(choice => {
1090
+ if (choice.message) {
1091
+ if (typeof choice.message.content === 'string') {
1092
+ choice.message.content = engine.restore(choice.message.content, sessionTokenMap).restoredText;
1093
+ } else if (Array.isArray(choice.message.content)) {
1094
+ choice.message.content = choice.message.content.map(chunk => {
1095
+ if (chunk && chunk.type === 'text' && typeof chunk.text === 'string') {
1096
+ return { ...chunk, text: engine.restore(chunk.text, sessionTokenMap).restoredText };
1097
+ }
1098
+ return chunk;
1099
+ });
1100
+ }
1101
+ }
1102
+ });
1103
+ }
1104
+
1105
+ return response;
1106
+ };
1107
+ }
1108
+
1109
+ if (hasResponses) {
1110
+ const originalResponsesCreate = openaiClient.responses.create.bind(openaiClient.responses);
1111
+
1112
+ openaiClient.responses.create = async function(params, requestOptions) {
1113
+ if (!params || typeof params !== 'object') {
1114
+ return originalResponsesCreate(params, requestOptions);
1115
+ }
1116
+
1117
+ const sessionTokenMap = options.persistSession === false ? {} : { ...engine.sessionMap };
1118
+ const modifiedParams = { ...params };
1119
+
1120
+ if (typeof modifiedParams.instructions === 'string') {
1121
+ const res = engine.sanitize(modifiedParams.instructions, { existingSessionMap: sessionTokenMap });
964
1122
  Object.assign(sessionTokenMap, res.tokenMap);
965
- return { ...msg, content: res.scrubbedText };
966
- } else if (Array.isArray(msg.content)) {
967
- const sanitizedChunks = msg.content.map(chunk => {
968
- if (chunk && chunk.type === 'text' && typeof chunk.text === 'string') {
969
- const res = engine.sanitize(chunk.text, { existingSessionMap: sessionTokenMap });
1123
+ modifiedParams.instructions = res.scrubbedText;
1124
+ }
1125
+
1126
+ if (typeof modifiedParams.input === 'string') {
1127
+ const res = engine.sanitize(modifiedParams.input, { existingSessionMap: sessionTokenMap });
1128
+ Object.assign(sessionTokenMap, res.tokenMap);
1129
+ modifiedParams.input = res.scrubbedText;
1130
+ } else if (Array.isArray(modifiedParams.input)) {
1131
+ modifiedParams.input = modifiedParams.input.map(item => {
1132
+ if (typeof item === 'string') {
1133
+ const res = engine.sanitize(item, { existingSessionMap: sessionTokenMap });
970
1134
  Object.assign(sessionTokenMap, res.tokenMap);
971
- return { ...chunk, text: res.scrubbedText };
1135
+ return res.scrubbedText;
972
1136
  }
973
- return chunk;
1137
+ if (item && typeof item === 'object') {
1138
+ return {
1139
+ ...item,
1140
+ ...(item.content !== undefined && { content: sanitizeMessageContent(item.content, sessionTokenMap) }),
1141
+ ...(typeof item.text === 'string' && {
1142
+ text: (() => {
1143
+ const res = engine.sanitize(item.text, { existingSessionMap: sessionTokenMap });
1144
+ Object.assign(sessionTokenMap, res.tokenMap);
1145
+ return res.scrubbedText;
1146
+ })()
1147
+ })
1148
+ };
1149
+ }
1150
+ return item;
974
1151
  });
975
- return { ...msg, content: sanitizedChunks };
976
1152
  }
977
- return msg;
978
- });
979
1153
 
980
- const modifiedParams = { ...params, messages: sanitizedMessages };
1154
+ const response = await originalResponsesCreate(modifiedParams, requestOptions);
1155
+
1156
+ if (params.stream) {
1157
+ const detok = new StreamingDetokenizer(sessionTokenMap);
1158
+ return detok.wrapStream(response);
1159
+ }
1160
+
1161
+ if (response && typeof response === 'object') {
1162
+ if (typeof response.output_text === 'string') {
1163
+ try {
1164
+ response.output_text = engine.restore(response.output_text, sessionTokenMap).restoredText;
1165
+ } catch (_) {}
1166
+ }
1167
+ if (Array.isArray(response.output)) {
1168
+ response.output.forEach(outItem => {
1169
+ if (outItem && Array.isArray(outItem.content)) {
1170
+ outItem.content.forEach(block => {
1171
+ if (block && typeof block.text === 'string') {
1172
+ block.text = engine.restore(block.text, sessionTokenMap).restoredText;
1173
+ }
1174
+ });
1175
+ }
1176
+ });
1177
+ }
1178
+ }
1179
+
1180
+ return response;
1181
+ };
1182
+ }
1183
+
1184
+ return openaiClient;
1185
+ }
1186
+
1187
+ /**
1188
+ * Transparent Zero-Trust wrapper for Anthropic SDK (@anthropic-ai/sdk).
1189
+ * Automatically scrubs system prompts and user/assistant messages in local RAM
1190
+ * before sending to Anthropic, and restores masked tokens in both synchronous
1191
+ * and streaming responses.
1192
+ *
1193
+ * @param {object} anthropicClient - Instance of Anthropic SDK (e.g. new Anthropic()).
1194
+ * @param {object} [options={}] - PrivacyScrubber configuration.
1195
+ * @returns {object} Wrapped Anthropic client.
1196
+ */
1197
+ export function wrapAnthropic(anthropicClient, options = {}) {
1198
+ if (!anthropicClient || !anthropicClient.messages || typeof anthropicClient.messages.create !== 'function') {
1199
+ throw new Error('[PrivacyScrubber] Invalid Anthropic client instance passed to wrapAnthropic.');
1200
+ }
1201
+
1202
+ const engine = new PrivacyScrubberEngine(options);
1203
+ const originalCreate = anthropicClient.messages.create.bind(anthropicClient.messages);
1204
+
1205
+ function sanitizeAnthropicContent(content, sessionTokenMap) {
1206
+ if (typeof content === 'string') {
1207
+ const res = engine.sanitize(content, { existingSessionMap: sessionTokenMap });
1208
+ Object.assign(sessionTokenMap, res.tokenMap);
1209
+ return res.scrubbedText;
1210
+ }
1211
+ if (Array.isArray(content)) {
1212
+ return content.map(block => {
1213
+ if (block && block.type === 'text' && typeof block.text === 'string') {
1214
+ const res = engine.sanitize(block.text, { existingSessionMap: sessionTokenMap });
1215
+ Object.assign(sessionTokenMap, res.tokenMap);
1216
+ return { ...block, text: res.scrubbedText };
1217
+ }
1218
+ return block;
1219
+ });
1220
+ }
1221
+ return content;
1222
+ }
1223
+
1224
+ anthropicClient.messages.create = async function(params, requestOptions) {
1225
+ if (!params || !Array.isArray(params.messages)) {
1226
+ return originalCreate(params, requestOptions);
1227
+ }
1228
+
1229
+ const sessionTokenMap = {};
1230
+ const modifiedParams = { ...params };
1231
+
1232
+ if (modifiedParams.system !== undefined) {
1233
+ modifiedParams.system = sanitizeAnthropicContent(modifiedParams.system, sessionTokenMap);
1234
+ }
1235
+
1236
+ modifiedParams.messages = params.messages.map(msg => ({
1237
+ ...msg,
1238
+ content: sanitizeAnthropicContent(msg.content, sessionTokenMap)
1239
+ }));
1240
+
981
1241
  const response = await originalCreate(modifiedParams, requestOptions);
982
1242
 
983
- // Streaming support: transparently de-tokenize async iterable chunks
984
1243
  if (params.stream) {
985
1244
  const detok = new StreamingDetokenizer(sessionTokenMap);
986
1245
  return detok.wrapStream(response);
987
1246
  }
988
1247
 
989
- if (response && Array.isArray(response.choices)) {
990
- response.choices.forEach(choice => {
991
- if (choice.message) {
992
- if (typeof choice.message.content === 'string') {
993
- choice.message.content = engine.restore(choice.message.content, sessionTokenMap).restoredText;
994
- } else if (Array.isArray(choice.message.content)) {
995
- choice.message.content = choice.message.content.map(chunk => {
996
- if (chunk && chunk.type === 'text' && typeof chunk.text === 'string') {
997
- return { ...chunk, text: engine.restore(chunk.text, sessionTokenMap).restoredText };
998
- }
999
- return chunk;
1000
- });
1001
- }
1248
+ if (response && Array.isArray(response.content)) {
1249
+ response.content = response.content.map(block => {
1250
+ if (block && block.type === 'text' && typeof block.text === 'string') {
1251
+ return { ...block, text: engine.restore(block.text, sessionTokenMap).restoredText };
1002
1252
  }
1253
+ return block;
1003
1254
  });
1004
1255
  }
1005
1256
 
1006
1257
  return response;
1007
1258
  };
1008
1259
 
1009
- return openaiClient;
1260
+ return anthropicClient;
1010
1261
  }
1011
1262
 
1012
1263
  /**
@@ -1025,25 +1276,43 @@ export function wrapAiModel(model, options = {}) {
1025
1276
  throw new Error('[PrivacyScrubber] Invalid model instance passed to wrapAiModel.');
1026
1277
  }
1027
1278
 
1028
- const engine = new PrivacyScrubberEngine(options);
1279
+ const engine = options.engine || new PrivacyScrubberEngine(options);
1280
+ const sharedSessionMap = {};
1029
1281
  const wrapped = Object.create(model);
1030
1282
 
1031
1283
  function sanitizePromptParts(prompt, sessionMap) {
1284
+ if (typeof prompt === 'string') {
1285
+ const res = engine.sanitize(prompt, { existingSessionMap: sessionMap, persistSession: false });
1286
+ Object.assign(sessionMap, res.tokenMap);
1287
+ return res.scrubbedText;
1288
+ }
1032
1289
  if (!Array.isArray(prompt)) return prompt;
1033
1290
  return prompt.map(msg => {
1034
1291
  if (!msg) return msg;
1035
1292
  if (typeof msg.content === 'string') {
1036
- const res = engine.sanitize(msg.content, { existingSessionMap: sessionMap });
1293
+ const res = engine.sanitize(msg.content, { existingSessionMap: sessionMap, persistSession: false });
1037
1294
  Object.assign(sessionMap, res.tokenMap);
1038
1295
  return { ...msg, content: res.scrubbedText };
1039
1296
  }
1040
1297
  if (Array.isArray(msg.content)) {
1041
1298
  const newContent = msg.content.map(part => {
1042
- if (part && part.type === 'text' && typeof part.text === 'string') {
1043
- const res = engine.sanitize(part.text, { existingSessionMap: sessionMap });
1299
+ if (part && (part.type === 'text' || part.type === 'reasoning') && typeof part.text === 'string') {
1300
+ const res = engine.sanitize(part.text, { existingSessionMap: sessionMap, persistSession: false });
1044
1301
  Object.assign(sessionMap, res.tokenMap);
1045
1302
  return { ...part, text: res.scrubbedText };
1046
1303
  }
1304
+ if (part && part.type === 'tool-result') {
1305
+ if (typeof part.result === 'string') {
1306
+ const res = engine.sanitize(part.result, { existingSessionMap: sessionMap, persistSession: false });
1307
+ Object.assign(sessionMap, res.tokenMap);
1308
+ return { ...part, result: res.scrubbedText };
1309
+ }
1310
+ if (part.output && typeof part.output.value === 'string') {
1311
+ const res = engine.sanitize(part.output.value, { existingSessionMap: sessionMap, persistSession: false });
1312
+ Object.assign(sessionMap, res.tokenMap);
1313
+ return { ...part, output: { ...part.output, value: res.scrubbedText } };
1314
+ }
1315
+ }
1047
1316
  return part;
1048
1317
  });
1049
1318
  return { ...msg, content: newContent };
@@ -1052,16 +1321,41 @@ export function wrapAiModel(model, options = {}) {
1052
1321
  });
1053
1322
  }
1054
1323
 
1324
+ function createScopedTokenMap(prompt) {
1325
+ const sessionTokenMap = options.persistSession ? { ...sharedSessionMap } : {};
1326
+ if (!options.persistSession && prompt && Object.keys(sharedSessionMap).length > 0) {
1327
+ const s = typeof prompt === 'string' ? prompt : JSON.stringify(prompt);
1328
+ for (const [tok, orig] of Object.entries(sharedSessionMap)) {
1329
+ if (s.includes(tok)) sessionTokenMap[tok] = orig;
1330
+ }
1331
+ }
1332
+ return sessionTokenMap;
1333
+ }
1334
+
1055
1335
  if (typeof model.doGenerate === 'function') {
1056
1336
  wrapped.doGenerate = async function(params) {
1057
- const sessionTokenMap = {};
1337
+ const sessionTokenMap = createScopedTokenMap(params?.prompt);
1058
1338
  const sanitizedParams = {
1059
1339
  ...params,
1060
1340
  prompt: sanitizePromptParts(params.prompt, sessionTokenMap)
1061
1341
  };
1342
+ Object.assign(sharedSessionMap, sessionTokenMap);
1062
1343
  const result = await model.doGenerate(sanitizedParams);
1063
- if (result && typeof result.text === 'string') {
1064
- result.text = engine.restore(result.text, sessionTokenMap).restoredText;
1344
+ if (result) {
1345
+ if (typeof result.text === 'string') {
1346
+ result.text = engine.restore(result.text, sessionTokenMap).restoredText;
1347
+ }
1348
+ if (typeof result.reasoning === 'string') {
1349
+ result.reasoning = engine.restore(result.reasoning, sessionTokenMap).restoredText;
1350
+ }
1351
+ if (Array.isArray(result.content)) {
1352
+ result.content = result.content.map(part => {
1353
+ if (part && (part.type === 'text' || part.type === 'reasoning') && typeof part.text === 'string') {
1354
+ return { ...part, text: engine.restore(part.text, sessionTokenMap).restoredText };
1355
+ }
1356
+ return part;
1357
+ });
1358
+ }
1065
1359
  }
1066
1360
  return result;
1067
1361
  };
@@ -1069,43 +1363,87 @@ export function wrapAiModel(model, options = {}) {
1069
1363
 
1070
1364
  if (typeof model.doStream === 'function') {
1071
1365
  wrapped.doStream = async function(params) {
1072
- const sessionTokenMap = {};
1366
+ const sessionTokenMap = createScopedTokenMap(params?.prompt);
1073
1367
  const sanitizedParams = {
1074
1368
  ...params,
1075
1369
  prompt: sanitizePromptParts(params.prompt, sessionTokenMap)
1076
1370
  };
1371
+ Object.assign(sharedSessionMap, sessionTokenMap);
1077
1372
  const result = await model.doStream(sanitizedParams);
1078
1373
  if (result && result.stream) {
1079
1374
  const detok = new StreamingDetokenizer(sessionTokenMap);
1080
1375
  const originalStream = result.stream;
1376
+ let usesV2Delta = false;
1081
1377
 
1082
1378
  if (typeof originalStream.pipeThrough === 'function' && typeof TransformStream !== 'undefined') {
1083
1379
  result.stream = originalStream.pipeThrough(new TransformStream({
1084
1380
  transform(chunk, controller) {
1085
- if (chunk && chunk.type === 'text-delta' && typeof chunk.textDelta === 'string') {
1086
- const restored = detok.feed(chunk.textDelta);
1087
- if (restored) controller.enqueue({ ...chunk, textDelta: restored });
1088
- } else {
1089
- controller.enqueue(chunk);
1381
+ if (chunk && (chunk.type === 'text-delta' || chunk.type === 'reasoning' || chunk.type === 'reasoning-delta')) {
1382
+ if (typeof chunk.textDelta === 'string') {
1383
+ const restored = detok.feed(chunk.textDelta);
1384
+ if (restored) {
1385
+ controller.enqueue({
1386
+ ...chunk,
1387
+ textDelta: restored,
1388
+ ...(typeof chunk.delta === 'string' ? { delta: restored } : {})
1389
+ });
1390
+ }
1391
+ return;
1392
+ }
1393
+ if (typeof chunk.delta === 'string') {
1394
+ usesV2Delta = true;
1395
+ const restored = detok.feed(chunk.delta);
1396
+ if (restored) {
1397
+ controller.enqueue({ ...chunk, delta: restored, textDelta: restored });
1398
+ }
1399
+ return;
1400
+ }
1090
1401
  }
1402
+ controller.enqueue(chunk);
1091
1403
  },
1092
1404
  flush(controller) {
1093
1405
  const rem = detok.flush();
1094
- if (rem) controller.enqueue({ type: 'text-delta', textDelta: rem });
1406
+ if (rem) {
1407
+ controller.enqueue(
1408
+ usesV2Delta
1409
+ ? { type: 'text-delta', delta: rem, textDelta: rem }
1410
+ : { type: 'text-delta', textDelta: rem, delta: rem }
1411
+ );
1412
+ }
1095
1413
  }
1096
1414
  }));
1097
1415
  } else if (typeof originalStream[Symbol.asyncIterator] === 'function') {
1098
1416
  async function* wrappedAsyncIterable() {
1099
1417
  for await (const chunk of originalStream) {
1100
- if (chunk && chunk.type === 'text-delta' && typeof chunk.textDelta === 'string') {
1101
- const restored = detok.feed(chunk.textDelta);
1102
- if (restored) yield { ...chunk, textDelta: restored };
1103
- } else {
1104
- yield chunk;
1418
+ if (chunk && (chunk.type === 'text-delta' || chunk.type === 'reasoning' || chunk.type === 'reasoning-delta')) {
1419
+ if (typeof chunk.textDelta === 'string') {
1420
+ const restored = detok.feed(chunk.textDelta);
1421
+ if (restored) {
1422
+ yield {
1423
+ ...chunk,
1424
+ textDelta: restored,
1425
+ ...(typeof chunk.delta === 'string' ? { delta: restored } : {})
1426
+ };
1427
+ }
1428
+ continue;
1429
+ }
1430
+ if (typeof chunk.delta === 'string') {
1431
+ usesV2Delta = true;
1432
+ const restored = detok.feed(chunk.delta);
1433
+ if (restored) {
1434
+ yield { ...chunk, delta: restored, textDelta: restored };
1435
+ }
1436
+ continue;
1437
+ }
1105
1438
  }
1439
+ yield chunk;
1106
1440
  }
1107
1441
  const rem = detok.flush();
1108
- if (rem) yield { type: 'text-delta', textDelta: rem };
1442
+ if (rem) {
1443
+ yield usesV2Delta
1444
+ ? { type: 'text-delta', delta: rem, textDelta: rem }
1445
+ : { type: 'text-delta', textDelta: rem, delta: rem };
1446
+ }
1109
1447
  }
1110
1448
  result.stream = wrappedAsyncIterable();
1111
1449
  }
@@ -1119,6 +1457,631 @@ export function wrapAiModel(model, options = {}) {
1119
1457
 
1120
1458
  export const wrapAiSdk = wrapAiModel;
1121
1459
 
1460
+ /**
1461
+ * Transparent Zero-Trust wrapper for Google Gemini SDKs:
1462
+ * - Official @google/genai SDK (GoogleGenAI instance with client.models.generateContent / generateContentStream)
1463
+ * - Legacy @google/generative-ai SDK (GenerativeModel or GoogleGenerativeAI instance)
1464
+ *
1465
+ * Automatically scrubs string, Part[], and Content[] prompts as well as systemInstruction in local RAM,
1466
+ * and restores masked tokens in both synchronous and streaming responses.
1467
+ *
1468
+ * @param {object} geminiClientOrModel - GoogleGenAI instance, GenerativeModel instance, or GoogleGenerativeAI client.
1469
+ * @param {object} [options={}] - PrivacyScrubber configuration options.
1470
+ * @returns {object} Wrapped Gemini client or model.
1471
+ */
1472
+ export function wrapGemini(geminiClientOrModel, options = {}) {
1473
+ if (!geminiClientOrModel || typeof geminiClientOrModel !== 'object') {
1474
+ throw new Error('[PrivacyScrubber] Invalid Gemini client or model passed to wrapGemini.');
1475
+ }
1476
+
1477
+ const engine = options.engine || new PrivacyScrubberEngine(options);
1478
+ const sharedSessionMap = {};
1479
+
1480
+ function sanitizeGeminiContentNode(node, sessionTokenMap) {
1481
+ if (node === null || node === undefined) return node;
1482
+ if (typeof node === 'string') {
1483
+ const res = engine.sanitize(node, { existingSessionMap: sessionTokenMap });
1484
+ Object.assign(sessionTokenMap, res.tokenMap);
1485
+ return res.scrubbedText;
1486
+ }
1487
+ if (Array.isArray(node)) {
1488
+ return node.map(item => sanitizeGeminiContentNode(item, sessionTokenMap));
1489
+ }
1490
+ if (typeof node === 'object') {
1491
+ const copy = { ...node };
1492
+ if (typeof copy.text === 'string') {
1493
+ const res = engine.sanitize(copy.text, { existingSessionMap: sessionTokenMap });
1494
+ Object.assign(sessionTokenMap, res.tokenMap);
1495
+ copy.text = res.scrubbedText;
1496
+ }
1497
+ if (Array.isArray(copy.parts)) {
1498
+ copy.parts = copy.parts.map(p => sanitizeGeminiContentNode(p, sessionTokenMap));
1499
+ }
1500
+ if (copy.contents !== undefined) {
1501
+ copy.contents = sanitizeGeminiContentNode(copy.contents, sessionTokenMap);
1502
+ }
1503
+ if (copy.systemInstruction !== undefined) {
1504
+ copy.systemInstruction = sanitizeGeminiContentNode(copy.systemInstruction, sessionTokenMap);
1505
+ }
1506
+ if (copy.config && typeof copy.config === 'object') {
1507
+ copy.config = { ...copy.config };
1508
+ if (copy.config.systemInstruction !== undefined) {
1509
+ copy.config.systemInstruction = sanitizeGeminiContentNode(copy.config.systemInstruction, sessionTokenMap);
1510
+ }
1511
+ }
1512
+ return copy;
1513
+ }
1514
+ return node;
1515
+ }
1516
+
1517
+ function restoreGeminiResponseObject(respObj, sessionTokenMap, detok = null) {
1518
+ if (!respObj || typeof respObj !== 'object') return respObj;
1519
+ const restoreFn = (str) => detok ? detok.feed(str) : engine.restore(str, sessionTokenMap).restoredText;
1520
+
1521
+ let combinedText = '';
1522
+ if (Array.isArray(respObj.candidates)) {
1523
+ respObj.candidates = respObj.candidates.map(cand => {
1524
+ if (!cand || typeof cand !== 'object') return cand;
1525
+ const candCopy = { ...cand };
1526
+ if (candCopy.content && Array.isArray(candCopy.content.parts)) {
1527
+ candCopy.content = {
1528
+ ...candCopy.content,
1529
+ parts: candCopy.content.parts.map(part => {
1530
+ if (part && typeof part.text === 'string') {
1531
+ const restoredText = restoreFn(part.text);
1532
+ combinedText += restoredText;
1533
+ return { ...part, text: restoredText };
1534
+ }
1535
+ return part;
1536
+ })
1537
+ };
1538
+ }
1539
+ return candCopy;
1540
+ });
1541
+ }
1542
+
1543
+ if (typeof respObj.text === 'function') {
1544
+ const origTextFn = respObj.text.bind(respObj);
1545
+ respObj.text = () => {
1546
+ if (combinedText) return combinedText;
1547
+ const raw = origTextFn();
1548
+ return typeof raw === 'string' ? restoreFn(raw) : raw;
1549
+ };
1550
+ } else if (typeof respObj.text === 'string') {
1551
+ const restored = combinedText || restoreFn(respObj.text);
1552
+ try {
1553
+ Object.defineProperty(respObj, 'text', {
1554
+ value: restored,
1555
+ writable: true,
1556
+ enumerable: true,
1557
+ configurable: true
1558
+ });
1559
+ } catch (_) {
1560
+ respObj.text = restored;
1561
+ }
1562
+ } else if (combinedText) {
1563
+ try {
1564
+ Object.defineProperty(respObj, 'text', {
1565
+ value: combinedText,
1566
+ writable: true,
1567
+ enumerable: true,
1568
+ configurable: true
1569
+ });
1570
+ } catch (_) {}
1571
+ }
1572
+
1573
+ if (respObj.response && typeof respObj.response === 'object') {
1574
+ restoreGeminiResponseObject(respObj.response, sessionTokenMap, detok);
1575
+ }
1576
+
1577
+ return respObj;
1578
+ }
1579
+
1580
+ function wrapGeminiStreamResult(streamResult, sessionTokenMap) {
1581
+ const detok = new StreamingDetokenizer(sessionTokenMap);
1582
+
1583
+ async function* wrapAsyncChunks(iterable) {
1584
+ let lastChunkTemplate = null;
1585
+ for await (const chunk of iterable) {
1586
+ if (!chunk) continue;
1587
+ lastChunkTemplate = chunk;
1588
+ if (typeof chunk === 'string') {
1589
+ const out = detok.feed(chunk);
1590
+ if (out) yield out;
1591
+ } else if (typeof chunk === 'object') {
1592
+ const cloned = Object.create(Object.getPrototypeOf(chunk));
1593
+ Object.assign(cloned, chunk);
1594
+ restoreGeminiResponseObject(cloned, sessionTokenMap, detok);
1595
+ yield cloned;
1596
+ } else {
1597
+ yield chunk;
1598
+ }
1599
+ }
1600
+ const tail = detok.flush();
1601
+ if (tail) {
1602
+ if (lastChunkTemplate && typeof lastChunkTemplate === 'object') {
1603
+ const finalChunk = {
1604
+ ...lastChunkTemplate,
1605
+ candidates: [{ content: { role: 'model', parts: [{ text: tail }] } }],
1606
+ text: tail
1607
+ };
1608
+ if (typeof lastChunkTemplate.text === 'function') {
1609
+ finalChunk.text = () => tail;
1610
+ }
1611
+ yield finalChunk;
1612
+ } else {
1613
+ yield tail;
1614
+ }
1615
+ }
1616
+ }
1617
+
1618
+ if (streamResult && typeof streamResult[Symbol.asyncIterator] === 'function') {
1619
+ return wrapAsyncChunks(streamResult);
1620
+ }
1621
+ if (streamResult && streamResult.stream && typeof streamResult.stream[Symbol.asyncIterator] === 'function') {
1622
+ const wrappedStream = wrapAsyncChunks(streamResult.stream);
1623
+ const wrappedResponse = streamResult.response && typeof streamResult.response.then === 'function'
1624
+ ? streamResult.response.then(r => restoreGeminiResponseObject(r, sessionTokenMap))
1625
+ : streamResult.response;
1626
+ return {
1627
+ ...streamResult,
1628
+ stream: wrappedStream,
1629
+ response: wrappedResponse
1630
+ };
1631
+ }
1632
+ return streamResult;
1633
+ }
1634
+
1635
+ function wrapTargetObject(target) {
1636
+ if (typeof target.generateContent === 'function') {
1637
+ const origGen = target.generateContent.bind(target);
1638
+ target.generateContent = async function(req, ...rest) {
1639
+ const sessionTokenMap = { ...sharedSessionMap };
1640
+ const sanitizedReq = sanitizeGeminiContentNode(req, sessionTokenMap);
1641
+ Object.assign(sharedSessionMap, sessionTokenMap);
1642
+ const res = await origGen(sanitizedReq, ...rest);
1643
+ return restoreGeminiResponseObject(res, sessionTokenMap);
1644
+ };
1645
+ }
1646
+ if (typeof target.generateContentStream === 'function') {
1647
+ const origStream = target.generateContentStream.bind(target);
1648
+ target.generateContentStream = async function(req, ...rest) {
1649
+ const sessionTokenMap = { ...sharedSessionMap };
1650
+ const sanitizedReq = sanitizeGeminiContentNode(req, sessionTokenMap);
1651
+ Object.assign(sharedSessionMap, sessionTokenMap);
1652
+ const streamRes = await origStream(sanitizedReq, ...rest);
1653
+ return wrapGeminiStreamResult(streamRes, sessionTokenMap);
1654
+ };
1655
+ }
1656
+ }
1657
+
1658
+ if (geminiClientOrModel.models && typeof geminiClientOrModel.models === 'object') {
1659
+ wrapTargetObject(geminiClientOrModel.models);
1660
+ }
1661
+ if (typeof geminiClientOrModel.generateContent === 'function' || typeof geminiClientOrModel.generateContentStream === 'function') {
1662
+ wrapTargetObject(geminiClientOrModel);
1663
+ }
1664
+ if (typeof geminiClientOrModel.getGenerativeModel === 'function') {
1665
+ const origGetModel = geminiClientOrModel.getGenerativeModel.bind(geminiClientOrModel);
1666
+ geminiClientOrModel.getGenerativeModel = function(modelParams, ...rest) {
1667
+ const sessionTokenMap = { ...sharedSessionMap };
1668
+ const sanitizedParams = sanitizeGeminiContentNode(modelParams, sessionTokenMap);
1669
+ Object.assign(sharedSessionMap, sessionTokenMap);
1670
+ const modelInstance = origGetModel(sanitizedParams, ...rest);
1671
+ if (modelInstance && typeof modelInstance === 'object') {
1672
+ wrapTargetObject(modelInstance);
1673
+ }
1674
+ return modelInstance;
1675
+ };
1676
+ }
1677
+
1678
+ geminiClientOrModel._ztdsEngine = engine;
1679
+ return geminiClientOrModel;
1680
+ }
1681
+
1682
+ export const wrapGoogleGenAI = wrapGemini;
1683
+
1684
+ /**
1685
+ * Creates a universal Zero-Trust HTTP/SSE `fetch` wrapper for any LLM provider or SDK
1686
+ * (OpenAI, Anthropic, OpenRouter, Groq, DeepSeek, Mistral, Together, Ollama, Cloudflare AI Gateway).
1687
+ *
1688
+ * Automatically sanitizes outbound JSON request bodies (`messages`, `prompt`, `input`, `contents`, `system`)
1689
+ * in local RAM before network transmission, and transparently de-tokenizes both JSON responses
1690
+ * and Server-Sent Events (`text/event-stream`) streams in real time.
1691
+ *
1692
+ * Usage:
1693
+ * const client = new OpenAI({ fetch: createPrivacyFetch({ profile: 'General' }) });
1694
+ *
1695
+ * @param {object} [options={}] - Configuration options.
1696
+ * @returns {Function} Drop-in `fetch(input, init)` replacement.
1697
+ */
1698
+ export function createPrivacyFetch(options = {}) {
1699
+ const engine = options.engine || new PrivacyScrubberEngine(options);
1700
+ const baseFetch = options.fetch || (typeof globalThis !== 'undefined' && globalThis.fetch);
1701
+ const sanitizeInputs = options.sanitizeInputs !== false;
1702
+ const sanitizeOutputs = options.sanitizeOutputs !== false;
1703
+
1704
+ const PROMPT_KEYS = new Set([
1705
+ 'messages', 'prompt', 'input', 'contents', 'system',
1706
+ 'systemInstruction', 'system_instruction', 'instructions', 'text', 'content'
1707
+ ]);
1708
+ const SAFE_SKIP_KEYS = new Set([
1709
+ 'model', 'role', 'type', 'id', 'object', 'finish_reason', 'stop_reason',
1710
+ 'encoding_format', 'response_format', 'tool_choice', 'stream'
1711
+ ]);
1712
+
1713
+ function sanitizeJsonNode(node, sessionTokenMap) {
1714
+ if (node === null || node === undefined) return node;
1715
+ if (typeof node === 'string') {
1716
+ if (!node.trim()) return node;
1717
+ const res = engine.sanitize(node, { existingSessionMap: sessionTokenMap });
1718
+ Object.assign(sessionTokenMap, res.tokenMap);
1719
+ return res.scrubbedText;
1720
+ }
1721
+ if (Array.isArray(node)) {
1722
+ return node.map(item => sanitizeJsonNode(item, sessionTokenMap));
1723
+ }
1724
+ if (typeof node === 'object') {
1725
+ const out = {};
1726
+ for (const [k, v] of Object.entries(node)) {
1727
+ if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
1728
+ if (SAFE_SKIP_KEYS.has(k)) {
1729
+ out[k] = v;
1730
+ } else if (PROMPT_KEYS.has(k) || typeof v === 'object') {
1731
+ out[k] = sanitizeJsonNode(v, sessionTokenMap);
1732
+ } else {
1733
+ out[k] = v;
1734
+ }
1735
+ }
1736
+ return out;
1737
+ }
1738
+ return node;
1739
+ }
1740
+
1741
+ function detokenizeSseJsonPayload(parsed, detok) {
1742
+ if (!parsed || typeof parsed !== 'object') return parsed;
1743
+ if (Array.isArray(parsed.choices)) {
1744
+ parsed.choices = parsed.choices.map(choice => {
1745
+ if (!choice || typeof choice !== 'object') return choice;
1746
+ const c = { ...choice };
1747
+ if (c.delta && typeof c.delta === 'object') {
1748
+ c.delta = { ...c.delta };
1749
+ if (typeof c.delta.content === 'string') {
1750
+ c.delta.content = detok.feed(c.delta.content);
1751
+ }
1752
+ if (typeof c.delta.reasoning_content === 'string') {
1753
+ c.delta.reasoning_content = detok.feed(c.delta.reasoning_content);
1754
+ }
1755
+ if (typeof c.delta.text === 'string') {
1756
+ c.delta.text = detok.feed(c.delta.text);
1757
+ }
1758
+ }
1759
+ if (typeof c.text === 'string') {
1760
+ c.text = detok.feed(c.text);
1761
+ }
1762
+ return c;
1763
+ });
1764
+ }
1765
+ if (parsed.delta && typeof parsed.delta === 'object') {
1766
+ parsed.delta = { ...parsed.delta };
1767
+ if (typeof parsed.delta.text === 'string') {
1768
+ parsed.delta.text = detok.feed(parsed.delta.text);
1769
+ }
1770
+ if (typeof parsed.delta.partial_json === 'string') {
1771
+ parsed.delta.partial_json = detok.feed(parsed.delta.partial_json);
1772
+ }
1773
+ } else if (typeof parsed.delta === 'string') {
1774
+ parsed.delta = detok.feed(parsed.delta);
1775
+ }
1776
+ if (Array.isArray(parsed.candidates)) {
1777
+ parsed.candidates = parsed.candidates.map(cand => {
1778
+ if (cand && cand.content && Array.isArray(cand.content.parts)) {
1779
+ return {
1780
+ ...cand,
1781
+ content: {
1782
+ ...cand.content,
1783
+ parts: cand.content.parts.map(p =>
1784
+ (p && typeof p.text === 'string') ? { ...p, text: detok.feed(p.text) } : p
1785
+ )
1786
+ }
1787
+ };
1788
+ }
1789
+ return cand;
1790
+ });
1791
+ }
1792
+ if (typeof parsed.response === 'string') {
1793
+ parsed.response = detok.feed(parsed.response);
1794
+ }
1795
+ return parsed;
1796
+ }
1797
+
1798
+ const privacyFetch = async function(input, init = {}) {
1799
+ if (typeof baseFetch !== 'function') {
1800
+ throw new Error('[PrivacyScrubber] No global fetch implementation found; pass { fetch } to createPrivacyFetch.');
1801
+ }
1802
+
1803
+ const sessionTokenMap = { ...engine.sessionMap };
1804
+ let modifiedInit = init ? { ...init } : {};
1805
+
1806
+ if (sanitizeInputs && modifiedInit && typeof modifiedInit.body === 'string') {
1807
+ const trimmed = modifiedInit.body.trim();
1808
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
1809
+ try {
1810
+ const parsed = JSON.parse(modifiedInit.body);
1811
+ const sanitizedBody = sanitizeJsonNode(parsed, sessionTokenMap);
1812
+ modifiedInit.body = JSON.stringify(sanitizedBody);
1813
+ if (modifiedInit.headers) {
1814
+ if (typeof Headers !== 'undefined' && modifiedInit.headers instanceof Headers) {
1815
+ const h = new Headers(modifiedInit.headers);
1816
+ h.delete('content-length');
1817
+ modifiedInit.headers = h;
1818
+ } else if (typeof modifiedInit.headers === 'object' && !Array.isArray(modifiedInit.headers)) {
1819
+ const h = { ...modifiedInit.headers };
1820
+ delete h['content-length'];
1821
+ delete h['Content-Length'];
1822
+ modifiedInit.headers = h;
1823
+ }
1824
+ }
1825
+ if (typeof options.onSanitized === 'function') {
1826
+ try {
1827
+ options.onSanitized({
1828
+ url: typeof input === 'string' ? input : (input && input.url) || '',
1829
+ tokenMap: sessionTokenMap,
1830
+ tokenCount: Object.keys(sessionTokenMap).length
1831
+ });
1832
+ } catch (_) {}
1833
+ }
1834
+ } catch (_) {}
1835
+ }
1836
+ }
1837
+
1838
+ const response = await baseFetch(input, modifiedInit);
1839
+ if (!sanitizeOutputs || !response || Object.keys(sessionTokenMap).length === 0) {
1840
+ return response;
1841
+ }
1842
+
1843
+ const contentType = (response.headers && typeof response.headers.get === 'function')
1844
+ ? (response.headers.get('content-type') || '').toLowerCase()
1845
+ : '';
1846
+
1847
+ if (contentType.includes('text/event-stream') && response.body && typeof response.body.pipeThrough === 'function' && typeof TransformStream !== 'undefined') {
1848
+ const detok = new StreamingDetokenizer(sessionTokenMap, options.streamingOptions);
1849
+ const decoder = new TextDecoder();
1850
+ const encoder = new TextEncoder();
1851
+ let lineBuffer = '';
1852
+
1853
+ const processSseLine = (line) => {
1854
+ if (!line.startsWith('data:')) return line;
1855
+ const dataPayload = line.slice(5).trim();
1856
+ if (!dataPayload || dataPayload === '[DONE]') {
1857
+ const rem = detok.flush();
1858
+ return rem ? `data: ${JSON.stringify({ choices: [{ index: 0, delta: { content: rem } }] })}\n\n${line}` : line;
1859
+ }
1860
+ if (dataPayload.startsWith('{')) {
1861
+ try {
1862
+ const parsed = JSON.parse(dataPayload);
1863
+ const restoredObj = detokenizeSseJsonPayload(parsed, detok);
1864
+ return `data: ${JSON.stringify(restoredObj)}`;
1865
+ } catch (_) {
1866
+ return `data: ${detok.feed(dataPayload)}`;
1867
+ }
1868
+ }
1869
+ return `data: ${detok.feed(dataPayload)}`;
1870
+ };
1871
+
1872
+ const transformedBody = response.body.pipeThrough(new TransformStream({
1873
+ transform(chunk, controller) {
1874
+ const str = typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true });
1875
+ lineBuffer += str;
1876
+ const lines = lineBuffer.split(/\r?\n/);
1877
+ lineBuffer = lines.pop() || '';
1878
+ if (lines.length > 0) {
1879
+ const outLines = lines.map(processSseLine).join('\n') + '\n';
1880
+ controller.enqueue(encoder.encode(outLines));
1881
+ }
1882
+ },
1883
+ flush(controller) {
1884
+ let finalOut = '';
1885
+ if (lineBuffer) {
1886
+ finalOut += processSseLine(lineBuffer) + '\n';
1887
+ lineBuffer = '';
1888
+ }
1889
+ const rem = detok.flush();
1890
+ if (rem) {
1891
+ finalOut += `data: ${JSON.stringify({ choices: [{ index: 0, delta: { content: rem } }] })}\n\n`;
1892
+ }
1893
+ if (finalOut) {
1894
+ controller.enqueue(encoder.encode(finalOut));
1895
+ }
1896
+ }
1897
+ }));
1898
+
1899
+ if (typeof Response !== 'undefined') {
1900
+ return new Response(transformedBody, {
1901
+ status: response.status,
1902
+ statusText: response.statusText,
1903
+ headers: response.headers
1904
+ });
1905
+ }
1906
+ return { ...response, body: transformedBody };
1907
+ }
1908
+
1909
+ if (contentType.includes('application/json') || typeof response.json === 'function') {
1910
+ try {
1911
+ const rawText = typeof response.text === 'function'
1912
+ ? await response.text()
1913
+ : JSON.stringify(await response.json());
1914
+ const parsed = JSON.parse(rawText);
1915
+ const restoredObj = restoreObject(parsed, sessionTokenMap);
1916
+ const restoredText = JSON.stringify(restoredObj);
1917
+ if (typeof Response !== 'undefined') {
1918
+ const headers = new Headers(response.headers || {});
1919
+ headers.delete('content-length');
1920
+ return new Response(restoredText, {
1921
+ status: response.status,
1922
+ statusText: response.statusText,
1923
+ headers
1924
+ });
1925
+ }
1926
+ return {
1927
+ ...response,
1928
+ text: async () => restoredText,
1929
+ json: async () => restoredObj
1930
+ };
1931
+ } catch (_) {
1932
+ return response;
1933
+ }
1934
+ }
1935
+
1936
+ return response;
1937
+ };
1938
+
1939
+ privacyFetch.engine = engine;
1940
+ return privacyFetch;
1941
+ }
1942
+
1943
+ /**
1944
+ * Creates OpenAI Agents SDK (@openai/agents) compatible InputGuardrail and OutputGuardrail objects.
1945
+ *
1946
+ * Supports two operating modes:
1947
+ * 1. Sanitization & Telemetry Mode (default): Scrubs PII/secrets into engine.sessionMap and attaches
1948
+ * CISO compliance telemetry to `outputInfo` while allowing execution (`tripwireTriggered: false`).
1949
+ * 2. Strict Tripwire Mode (`tripwireOnCritical: true` or `tripwireOnAnyPii: true`): Triggers the
1950
+ * OpenAI Agents SDK guardrail tripwire (`tripwireTriggered: true`) to halt agent execution when
1951
+ * critical secrets/PII or any PII are detected.
1952
+ *
1953
+ * @param {object} [options={}] - Configuration options.
1954
+ * @returns {object} `{ inputGuardrail, outputGuardrail, inputGuardrails, outputGuardrails, engine }`
1955
+ */
1956
+ export function createOpenAIAgentGuardrails(options = {}) {
1957
+ const engine = options.engine || new PrivacyScrubberEngine(options);
1958
+ const tripwireOnCritical = options.tripwireOnCritical === true;
1959
+ const tripwireOnAnyPii = options.tripwireOnAnyPii === true;
1960
+
1961
+ function extractAndScrubAgentInput(input, sessionTokenMap) {
1962
+ if (typeof input === 'string') {
1963
+ const res = engine.sanitize(input, { existingSessionMap: sessionTokenMap });
1964
+ Object.assign(sessionTokenMap, res.tokenMap);
1965
+ return { scrubbedInput: res.scrubbedText, count: res.count };
1966
+ }
1967
+ if (Array.isArray(input)) {
1968
+ let totalCount = 0;
1969
+ const scrubbedList = input.map(item => {
1970
+ if (typeof item === 'string') {
1971
+ const r = engine.sanitize(item, { existingSessionMap: sessionTokenMap });
1972
+ Object.assign(sessionTokenMap, r.tokenMap);
1973
+ totalCount += r.count;
1974
+ return r.scrubbedText;
1975
+ }
1976
+ if (item && typeof item === 'object') {
1977
+ const copy = { ...item };
1978
+ if (typeof copy.content === 'string') {
1979
+ const r = engine.sanitize(copy.content, { existingSessionMap: sessionTokenMap });
1980
+ Object.assign(sessionTokenMap, r.tokenMap);
1981
+ totalCount += r.count;
1982
+ copy.content = r.scrubbedText;
1983
+ if (options.mutateInPlace !== false) item.content = r.scrubbedText;
1984
+ } else if (Array.isArray(copy.content)) {
1985
+ copy.content = copy.content.map(part => {
1986
+ if (part && typeof part.text === 'string') {
1987
+ const r = engine.sanitize(part.text, { existingSessionMap: sessionTokenMap });
1988
+ Object.assign(sessionTokenMap, r.tokenMap);
1989
+ totalCount += r.count;
1990
+ if (options.mutateInPlace !== false) part.text = r.scrubbedText;
1991
+ return { ...part, text: r.scrubbedText };
1992
+ }
1993
+ return part;
1994
+ });
1995
+ }
1996
+ return copy;
1997
+ }
1998
+ return item;
1999
+ });
2000
+ return { scrubbedInput: scrubbedList, count: totalCount };
2001
+ }
2002
+ return { scrubbedInput: input, count: 0 };
2003
+ }
2004
+
2005
+ const inputGuardrail = {
2006
+ name: options.inputGuardrailName || 'PrivacyScrubber ZTDS Input Guardrail',
2007
+ async execute({ input, context } = {}) {
2008
+ const sessionTokenMap = { ...engine.sessionMap };
2009
+ const { scrubbedInput, count } = extractAndScrubAgentInput(input, sessionTokenMap);
2010
+ Object.assign(engine.sessionMap, sessionTokenMap);
2011
+
2012
+ if (context && typeof context === 'object') {
2013
+ context.ztdsTokenMap = engine.sessionMap;
2014
+ context.ztdsScrubbedInput = scrubbedInput;
2015
+ }
2016
+
2017
+ const telemetry = buildAuditTelemetry(sessionTokenMap);
2018
+ const isCritical = telemetry.riskLevel && telemetry.riskLevel.includes('CRITICAL');
2019
+ const tripwireTriggered = Boolean(
2020
+ (tripwireOnAnyPii && count > 0) ||
2021
+ (tripwireOnCritical && count > 0 && isCritical)
2022
+ );
2023
+
2024
+ return {
2025
+ tripwireTriggered,
2026
+ outputInfo: {
2027
+ scrubbedInput,
2028
+ tokenMap: { ...sessionTokenMap },
2029
+ tokenCount: count,
2030
+ riskLevel: telemetry.riskLevel,
2031
+ frameworks: telemetry.frameworksList,
2032
+ telemetry
2033
+ }
2034
+ };
2035
+ }
2036
+ };
2037
+
2038
+ const outputGuardrail = {
2039
+ name: options.outputGuardrailName || 'PrivacyScrubber ZTDS Output Guardrail',
2040
+ async execute({ agentOutput, context } = {}) {
2041
+ const activeMap = (context && context.ztdsTokenMap) || engine.sessionMap || {};
2042
+ let restoredOutput = agentOutput;
2043
+ let restoredCount = 0;
2044
+
2045
+ if (typeof agentOutput === 'string') {
2046
+ const res = engine.restore(agentOutput, activeMap);
2047
+ restoredOutput = res.restoredText;
2048
+ restoredCount = res.restoredCount;
2049
+ } else if (agentOutput && typeof agentOutput === 'object') {
2050
+ restoredOutput = restoreObject(agentOutput, activeMap);
2051
+ if (options.mutateInPlace !== false) {
2052
+ Object.assign(agentOutput, restoredOutput);
2053
+ }
2054
+ }
2055
+
2056
+ if (context && typeof context === 'object') {
2057
+ context.ztdsRestoredOutput = restoredOutput;
2058
+ }
2059
+
2060
+ let leakDetected = false;
2061
+ if (options.tripwireOnOutputLeak === true && typeof restoredOutput === 'string') {
2062
+ const check = engine.sanitize(restoredOutput, { persistSession: false });
2063
+ leakDetected = check.count > 0;
2064
+ }
2065
+
2066
+ return {
2067
+ tripwireTriggered: leakDetected,
2068
+ outputInfo: {
2069
+ restoredOutput,
2070
+ restoredCount
2071
+ }
2072
+ };
2073
+ }
2074
+ };
2075
+
2076
+ return {
2077
+ inputGuardrail,
2078
+ outputGuardrail,
2079
+ inputGuardrails: [inputGuardrail],
2080
+ outputGuardrails: [outputGuardrail],
2081
+ engine
2082
+ };
2083
+ }
2084
+
1122
2085
  /**
1123
2086
  * Framework middleware helper for LangChain / LlamaIndex pipelines.
1124
2087
  */
@@ -1758,9 +2721,11 @@ export class StreamingDetokenizer {
1758
2721
  */
1759
2722
  async* wrapStream(asyncIterable) {
1760
2723
  let lastOpenAIChunk = null;
2724
+ let lastResponsesDeltaChunk = null;
2725
+ let lastAnthropicDeltaChunk = null;
1761
2726
  for await (const chunk of asyncIterable) {
1762
2727
  if (chunk === null || chunk === undefined) continue;
1763
- // Handle OpenAI chunk object
2728
+ // Handle OpenAI chat.completions chunk object
1764
2729
  if (chunk.choices && Array.isArray(chunk.choices) && chunk.choices[0]?.delta?.content !== undefined) {
1765
2730
  lastOpenAIChunk = chunk;
1766
2731
  const delta = chunk.choices[0].delta.content;
@@ -1778,6 +2743,55 @@ export class StreamingDetokenizer {
1778
2743
  continue;
1779
2744
  }
1780
2745
 
2746
+ // Handle OpenAI Responses API streaming events (response.output_text.delta / done)
2747
+ if (chunk && typeof chunk === 'object' && typeof chunk.type === 'string' && chunk.type.startsWith('response.')) {
2748
+ if (chunk.type === 'response.output_text.delta' && typeof chunk.delta === 'string') {
2749
+ lastResponsesDeltaChunk = chunk;
2750
+ const restored = this.feed(chunk.delta);
2751
+ if (restored) {
2752
+ yield { ...chunk, delta: restored };
2753
+ }
2754
+ continue;
2755
+ }
2756
+ if (chunk.type === 'response.output_text.done' && typeof chunk.text === 'string') {
2757
+ const remaining = this.flush();
2758
+ if (remaining && lastResponsesDeltaChunk) {
2759
+ yield { ...lastResponsesDeltaChunk, delta: remaining };
2760
+ }
2761
+ yield { ...chunk, text: this.replacer(chunk.text) };
2762
+ continue;
2763
+ }
2764
+ yield chunk;
2765
+ continue;
2766
+ }
2767
+
2768
+ // Handle Anthropic messages.create({ stream: true }) events (content_block_delta / content_block_stop)
2769
+ if (chunk && typeof chunk === 'object' && typeof chunk.type === 'string' && (
2770
+ chunk.type === 'content_block_delta' ||
2771
+ chunk.type === 'content_block_start' ||
2772
+ chunk.type === 'content_block_stop' ||
2773
+ chunk.type === 'message_start' ||
2774
+ chunk.type === 'message_delta' ||
2775
+ chunk.type === 'message_stop'
2776
+ )) {
2777
+ if (chunk.type === 'content_block_delta' && chunk.delta && chunk.delta.type === 'text_delta' && typeof chunk.delta.text === 'string') {
2778
+ lastAnthropicDeltaChunk = chunk;
2779
+ const restored = this.feed(chunk.delta.text);
2780
+ if (restored) {
2781
+ yield { ...chunk, delta: { ...chunk.delta, text: restored } };
2782
+ }
2783
+ continue;
2784
+ }
2785
+ if ((chunk.type === 'content_block_stop' || chunk.type === 'message_stop') && this.pendingBuffer) {
2786
+ const remaining = this.flush();
2787
+ if (remaining && lastAnthropicDeltaChunk) {
2788
+ yield { ...lastAnthropicDeltaChunk, delta: { ...lastAnthropicDeltaChunk.delta, text: remaining } };
2789
+ }
2790
+ }
2791
+ yield chunk;
2792
+ continue;
2793
+ }
2794
+
1781
2795
  let text = '';
1782
2796
  if (typeof chunk === 'string') {
1783
2797
  text = chunk;
@@ -1802,6 +2816,10 @@ export class StreamingDetokenizer {
1802
2816
  idx === 0 ? { ...c, delta: { ...c.delta, content: finalChunk } } : c
1803
2817
  );
1804
2818
  yield { ...lastOpenAIChunk, choices: clonedChoices };
2819
+ } else if (lastResponsesDeltaChunk) {
2820
+ yield { ...lastResponsesDeltaChunk, delta: finalChunk };
2821
+ } else if (lastAnthropicDeltaChunk) {
2822
+ yield { ...lastAnthropicDeltaChunk, delta: { ...lastAnthropicDeltaChunk.delta, text: finalChunk } };
1805
2823
  } else {
1806
2824
  yield finalChunk;
1807
2825
  }
@@ -1963,7 +2981,7 @@ export function formatCefEvent(eventData = {}) {
1963
2981
  const {
1964
2982
  vendor = 'PrivacyScrubber',
1965
2983
  product = 'ZTDS',
1966
- version = '2.4.5',
2984
+ version = SDK_VERSION,
1967
2985
  eventClassId = 'PII_REDACTION',
1968
2986
  eventName = 'Zero-Trust PII Sanitization Executed',
1969
2987
  riskLevel = 'LOW EXPOSURE',
@@ -2032,7 +3050,7 @@ export function formatSyslogEvent(eventData = {}) {
2032
3050
  export function formatJsonlEvent(eventData = {}) {
2033
3051
  const {
2034
3052
  timestamp = new Date().toISOString(),
2035
- version = '2.4.5',
3053
+ version = SDK_VERSION,
2036
3054
  protocol = 'ZTDS',
2037
3055
  event = 'sanitization',
2038
3056
  riskLevel = 'LOW EXPOSURE',
@@ -2080,12 +3098,22 @@ export function createAuditLogger(options = {}) {
2080
3098
  const buffer = [];
2081
3099
 
2082
3100
  if (typeof destination === 'string' && destination.trim()) {
3101
+ if (destination.includes('\0')) {
3102
+ throw new Error('[PrivacyScrubber AuditLogger] Security violation: null byte in destination path.');
3103
+ }
3104
+ const resolvedDest = path.resolve(destination.trim());
3105
+ if (fs.existsSync(resolvedDest)) {
3106
+ const st = fs.lstatSync(resolvedDest);
3107
+ if (st.isSymbolicLink() || !st.isFile()) {
3108
+ throw new Error('[PrivacyScrubber AuditLogger] Security violation: destination must be a regular non-symlink file.');
3109
+ }
3110
+ }
2083
3111
  try {
2084
- const dir = path.dirname(destination);
3112
+ const dir = path.dirname(resolvedDest);
2085
3113
  if (!fs.existsSync(dir)) {
2086
3114
  fs.mkdirSync(dir, { recursive: true });
2087
3115
  }
2088
- fileHandle = destination;
3116
+ fileHandle = resolvedDest;
2089
3117
  } catch {}
2090
3118
  } else if (destination && typeof destination.write === 'function') {
2091
3119
  streamHandle = destination;
@@ -2115,6 +3143,12 @@ export function createAuditLogger(options = {}) {
2115
3143
  buffer.push(line);
2116
3144
  if (fileHandle) {
2117
3145
  try {
3146
+ if (fs.existsSync(fileHandle)) {
3147
+ const st = fs.lstatSync(fileHandle);
3148
+ if (st.isSymbolicLink() || !st.isFile()) {
3149
+ throw new Error('Target destination became a symlink or non-regular file.');
3150
+ }
3151
+ }
2118
3152
  fs.appendFileSync(fileHandle, line + '\n', 'utf8');
2119
3153
  } catch (e) {
2120
3154
  console.error('[PrivacyScrubber AuditLogger] File write error:', e);
@@ -2418,11 +3452,41 @@ export function applyPatch(filePath, content, options = {}) {
2418
3452
  const {
2419
3453
  tokenMap = null,
2420
3454
  engine = null,
2421
- createBackup = true
3455
+ createBackup = true,
3456
+ cwd = process.cwd(),
3457
+ allowOutsideWorkspace = false
2422
3458
  } = options;
2423
3459
 
3460
+ if (filePath.includes('\0')) {
3461
+ throw new Error('[applyPatch] Security policy violation — null byte in filePath blocked.');
3462
+ }
3463
+
2424
3464
  const activeTokenMap = tokenMap || (engine && engine.sessionMap) || {};
2425
- const resolvedPath = path.resolve(process.cwd(), filePath);
3465
+ const workspaceRoot = fs.existsSync(cwd) ? fs.realpathSync(cwd) : path.resolve(cwd);
3466
+ const tmpRoot = fs.existsSync(os.tmpdir()) ? fs.realpathSync(os.tmpdir()) : path.resolve(os.tmpdir());
3467
+ const resolvedPath = path.resolve(workspaceRoot, filePath);
3468
+
3469
+ let checkCursor = resolvedPath;
3470
+ while (!fs.existsSync(checkCursor)) {
3471
+ const parentCursor = path.dirname(checkCursor);
3472
+ if (parentCursor === checkCursor) break;
3473
+ checkCursor = parentCursor;
3474
+ }
3475
+ const realExistingCursor = fs.existsSync(checkCursor) ? fs.realpathSync(checkCursor) : checkCursor;
3476
+ const suffixFromCursor = path.relative(checkCursor, resolvedPath);
3477
+ const canonicalTarget = path.resolve(realExistingCursor, suffixFromCursor);
3478
+
3479
+ if (!allowOutsideWorkspace) {
3480
+ const hasTraversalSegments = /(?:^|[\\/])\.\.(?:[\\/]|$)/.test(String(filePath));
3481
+ const relToWorkspace = path.relative(workspaceRoot, canonicalTarget);
3482
+ const isInsideWorkspace = !relToWorkspace.startsWith('..') && !path.isAbsolute(relToWorkspace);
3483
+ const relToTmp = path.relative(tmpRoot, canonicalTarget);
3484
+ const isInsideTmp = path.isAbsolute(filePath) && !hasTraversalSegments && !relToTmp.startsWith('..') && !path.isAbsolute(relToTmp);
3485
+ if (!isInsideWorkspace && !isInsideTmp) {
3486
+ throw new Error(`[applyPatch] Security policy violation — target 'filePath' resolves outside the active workspace directory (path traversal blocked).`);
3487
+ }
3488
+ }
3489
+
2426
3490
  const restored = unscrub(content, activeTokenMap);
2427
3491
 
2428
3492
  let backupCreated = false;
@@ -2679,7 +3743,9 @@ export function createGuardedTools(engineOrOptions = {}, toolConfig = {}) {
2679
3743
  const safeFilePath = resolveSafeToolPath(filePath, 'guard_apply_patch', 'filePath');
2680
3744
  const patchResult = applyPatch(safeFilePath, content, {
2681
3745
  tokenMap: engine.sessionMap,
2682
- createBackup
3746
+ createBackup,
3747
+ cwd: baseCwd,
3748
+ allowOutsideWorkspace
2683
3749
  });
2684
3750
 
2685
3751
  const responseText = `[Zero-Trust Agentic Guard: Patch Applied]\n` +
@@ -2795,6 +3861,7 @@ import {
2795
3861
  } from './vision.js';
2796
3862
 
2797
3863
  export default {
3864
+ SDK_VERSION,
2798
3865
  sanitize,
2799
3866
  sanitizeObject,
2800
3867
  scrub,
@@ -2821,8 +3888,13 @@ export default {
2821
3888
  PrivacyScrubberEngine,
2822
3889
  PrivacyScrubberLicenseError,
2823
3890
  wrapOpenAI,
3891
+ wrapAnthropic,
3892
+ wrapGemini,
3893
+ wrapGoogleGenAI,
2824
3894
  wrapAiModel,
2825
3895
  wrapAiSdk,
3896
+ createPrivacyFetch,
3897
+ createOpenAIAgentGuardrails,
2826
3898
  createLangChainTransform,
2827
3899
  createDocumentTransformer,
2828
3900
  createLangChainDocumentTransformer,