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