@privacyscrubber/sdk 2.4.4 → 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/README.md +50 -1
- package/browser.js +399 -19
- package/cli.js +119 -11
- package/examples/python-bridge/README.md +3 -3
- package/examples/python-bridge/fastapi_example.py +1 -1
- package/examples/python-bridge/langchain_example.py +1 -1
- package/examples/python-bridge/privacyscrubber/__init__.py +1 -1
- package/examples/python-bridge/privacyscrubber/bridge_daemon.cjs +2 -2
- package/examples/python-bridge/privacyscrubber/cli.py +1 -1
- package/examples/python-bridge/pyproject.toml +1 -1
- package/examples/python-bridge/setup.py +1 -1
- package/index.cjs +1194 -86
- package/index.d.ts +147 -2
- package/index.js +1194 -86
- package/package.json +13 -3
- package/ps-license-manager.js +1 -1
- package/ps-pii-engine.cjs +1 -1
- package/sarif.cjs +14 -2
- package/scrubber-core.cjs +1 -1
- package/vercel-ai.cjs +123 -34
- package/vercel-ai.d.ts +7 -0
- package/benchmark.js +0 -355
- package/examples/python-bridge/bridge_daemon.cjs +0 -260
- package/examples/python-bridge/langchain_adapter.py +0 -20
- package/examples/python-bridge/privacyscrubber.py +0 -36
- package/examples/python-bridge/test_python_bridge.py +0 -320
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
|
*/
|
|
@@ -540,8 +550,17 @@ function sanitize(text, options = {}) {
|
|
|
540
550
|
wasTruncated = true;
|
|
541
551
|
}
|
|
542
552
|
|
|
553
|
+
// Filter user-supplied custom rules against catastrophic backtracking (PS-VDP-2026-10-002 parity)
|
|
554
|
+
const nestedQuantifierRegex = /\([^)]*(\+|\*|\{\d+,?\d*\})\)[+*]|\([^)]*(\+|\*|\{\d+,?\d*\})\)\{\d+,?\d*\}|\([^)]*\|[^)]*\)(?:[+*]|\{\d+,?\d*\})/;
|
|
555
|
+
const safeCustomRules = (Array.isArray(customRules) ? customRules : []).filter(r => {
|
|
556
|
+
const pat = typeof r === 'string' ? r : (r && (r.pattern || (r.regex && r.regex.source)));
|
|
557
|
+
if (!pat || typeof pat !== 'string' || pat.length > 500) return false;
|
|
558
|
+
if (nestedQuantifierRegex.test(pat)) return false;
|
|
559
|
+
return true;
|
|
560
|
+
});
|
|
561
|
+
|
|
543
562
|
// Auto-inject DevOps Secrets rules when in Dev profile or when detectSecrets is requested
|
|
544
|
-
const activeRules = [...
|
|
563
|
+
const activeRules = [...safeCustomRules];
|
|
545
564
|
if (profile.toLowerCase() === 'dev' || detectSecrets === true) {
|
|
546
565
|
DEVOPS_SECRETS_DETECTOR.forEach(rule => {
|
|
547
566
|
activeRules.push({
|
|
@@ -713,6 +732,21 @@ function sanitizeObject(obj, options = {}) {
|
|
|
713
732
|
|
|
714
733
|
const mergedSessionMap = { ...(options.existingSessionMap || {}) };
|
|
715
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
|
+
}
|
|
716
750
|
|
|
717
751
|
function traverseAndSanitize(val) {
|
|
718
752
|
if (val === null || val === undefined) return val;
|
|
@@ -727,20 +761,42 @@ function sanitizeObject(obj, options = {}) {
|
|
|
727
761
|
totalCount += res.count;
|
|
728
762
|
return res.scrubbedText;
|
|
729
763
|
}
|
|
764
|
+
if (typeof val !== 'object') return val;
|
|
765
|
+
if (isNonPlainObject(val)) return val;
|
|
766
|
+
if (seen.has(val)) return seen.get(val);
|
|
767
|
+
|
|
730
768
|
if (Array.isArray(val)) {
|
|
731
|
-
|
|
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;
|
|
732
775
|
}
|
|
733
|
-
if (val instanceof
|
|
734
|
-
|
|
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;
|
|
735
783
|
}
|
|
736
|
-
if (
|
|
737
|
-
const
|
|
738
|
-
|
|
739
|
-
|
|
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));
|
|
740
789
|
}
|
|
741
|
-
return
|
|
790
|
+
return outSet;
|
|
742
791
|
}
|
|
743
|
-
|
|
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);
|
|
798
|
+
}
|
|
799
|
+
return out;
|
|
744
800
|
}
|
|
745
801
|
|
|
746
802
|
const sanitizedResult = traverseAndSanitize(obj);
|
|
@@ -770,23 +826,62 @@ function sanitizeObject(obj, options = {}) {
|
|
|
770
826
|
* @param {Record<string, string>} tokenMap - Token map returned from sanitize() or sanitizeObject().
|
|
771
827
|
* @returns {any} - Restored object with original values.
|
|
772
828
|
*/
|
|
773
|
-
function restoreObject(obj, tokenMap = {}) {
|
|
829
|
+
function restoreObject(obj, tokenMap = {}, _seen = null) {
|
|
774
830
|
if (obj === null || obj === undefined) return obj;
|
|
775
831
|
if (typeof obj === 'string') {
|
|
776
832
|
const res = restore(obj, tokenMap);
|
|
777
833
|
return res.restoredText;
|
|
778
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
|
+
|
|
779
853
|
if (Array.isArray(obj)) {
|
|
780
|
-
|
|
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;
|
|
781
860
|
}
|
|
782
|
-
if (
|
|
783
|
-
const
|
|
784
|
-
|
|
785
|
-
|
|
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));
|
|
786
866
|
}
|
|
787
|
-
return
|
|
867
|
+
return outMap;
|
|
868
|
+
}
|
|
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;
|
|
788
876
|
}
|
|
789
|
-
|
|
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;
|
|
790
885
|
}
|
|
791
886
|
|
|
792
887
|
/**
|
|
@@ -829,12 +924,16 @@ class PrivacyScrubberEngine {
|
|
|
829
924
|
.forEach(v => mergedIgnoreList.add(v));
|
|
830
925
|
}
|
|
831
926
|
|
|
927
|
+
const effectiveExistingMap = options.persistSession !== false
|
|
928
|
+
? { ...this.sessionMap, ...(options.existingSessionMap || {}) }
|
|
929
|
+
: (options.existingSessionMap || {});
|
|
930
|
+
|
|
832
931
|
const mergedOptions = {
|
|
833
932
|
profile: options.profile || this.defaultProfile,
|
|
834
933
|
licenseKey: options.licenseKey || this.licenseKey,
|
|
835
934
|
customRules: [...this.customRules, ...(options.customRules || [])],
|
|
836
935
|
tokenLabelMap: { ...this.tokenLabelMap, ...(options.tokenLabelMap || {}) },
|
|
837
|
-
existingSessionMap:
|
|
936
|
+
existingSessionMap: effectiveExistingMap,
|
|
838
937
|
generateReceipt: options.generateReceipt !== undefined ? options.generateReceipt : this.generateReceipt,
|
|
839
938
|
throwOnLimit: options.throwOnLimit !== undefined ? options.throwOnLimit : true,
|
|
840
939
|
detectSecrets: options.detectSecrets !== undefined ? options.detectSecrets : false,
|
|
@@ -934,68 +1033,229 @@ class PrivacyScrubberEngine {
|
|
|
934
1033
|
* @returns {object} Wrapped OpenAI client.
|
|
935
1034
|
*/
|
|
936
1035
|
function wrapOpenAI(openaiClient, options = {}) {
|
|
937
|
-
|
|
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) {
|
|
938
1039
|
throw new Error('[PrivacyScrubber] Invalid OpenAI client instance passed to wrapOpenAI.');
|
|
939
1040
|
}
|
|
940
1041
|
|
|
941
1042
|
const engine = new PrivacyScrubberEngine(options);
|
|
942
|
-
const originalCreate = openaiClient.chat.completions.create.bind(openaiClient.chat.completions);
|
|
943
1043
|
|
|
944
|
-
|
|
945
|
-
if (
|
|
946
|
-
|
|
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;
|
|
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
|
+
});
|
|
947
1059
|
}
|
|
1060
|
+
return content;
|
|
1061
|
+
}
|
|
948
1062
|
|
|
949
|
-
|
|
950
|
-
const
|
|
951
|
-
|
|
952
|
-
|
|
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 });
|
|
953
1120
|
Object.assign(sessionTokenMap, res.tokenMap);
|
|
954
|
-
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
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 });
|
|
959
1132
|
Object.assign(sessionTokenMap, res.tokenMap);
|
|
960
|
-
return
|
|
1133
|
+
return res.scrubbedText;
|
|
1134
|
+
}
|
|
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
|
+
};
|
|
961
1147
|
}
|
|
962
|
-
return
|
|
1148
|
+
return item;
|
|
963
1149
|
});
|
|
964
|
-
return { ...msg, content: sanitizedChunks };
|
|
965
1150
|
}
|
|
966
|
-
return msg;
|
|
967
|
-
});
|
|
968
1151
|
|
|
969
|
-
|
|
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
|
+
|
|
970
1239
|
const response = await originalCreate(modifiedParams, requestOptions);
|
|
971
1240
|
|
|
972
|
-
// Streaming support: transparently de-tokenize async iterable chunks
|
|
973
1241
|
if (params.stream) {
|
|
974
1242
|
const detok = new StreamingDetokenizer(sessionTokenMap);
|
|
975
1243
|
return detok.wrapStream(response);
|
|
976
1244
|
}
|
|
977
1245
|
|
|
978
|
-
if (response && Array.isArray(response.
|
|
979
|
-
response.
|
|
980
|
-
if (
|
|
981
|
-
|
|
982
|
-
choice.message.content = engine.restore(choice.message.content, sessionTokenMap).restoredText;
|
|
983
|
-
} else if (Array.isArray(choice.message.content)) {
|
|
984
|
-
choice.message.content = choice.message.content.map(chunk => {
|
|
985
|
-
if (chunk && chunk.type === 'text' && typeof chunk.text === 'string') {
|
|
986
|
-
return { ...chunk, text: engine.restore(chunk.text, sessionTokenMap).restoredText };
|
|
987
|
-
}
|
|
988
|
-
return chunk;
|
|
989
|
-
});
|
|
990
|
-
}
|
|
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 };
|
|
991
1250
|
}
|
|
1251
|
+
return block;
|
|
992
1252
|
});
|
|
993
1253
|
}
|
|
994
1254
|
|
|
995
1255
|
return response;
|
|
996
1256
|
};
|
|
997
1257
|
|
|
998
|
-
return
|
|
1258
|
+
return anthropicClient;
|
|
999
1259
|
}
|
|
1000
1260
|
|
|
1001
1261
|
/**
|
|
@@ -1014,25 +1274,43 @@ function wrapAiModel(model, options = {}) {
|
|
|
1014
1274
|
throw new Error('[PrivacyScrubber] Invalid model instance passed to wrapAiModel.');
|
|
1015
1275
|
}
|
|
1016
1276
|
|
|
1017
|
-
const engine = new PrivacyScrubberEngine(options);
|
|
1277
|
+
const engine = options.engine || new PrivacyScrubberEngine(options);
|
|
1278
|
+
const sharedSessionMap = {};
|
|
1018
1279
|
const wrapped = Object.create(model);
|
|
1019
1280
|
|
|
1020
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
|
+
}
|
|
1021
1287
|
if (!Array.isArray(prompt)) return prompt;
|
|
1022
1288
|
return prompt.map(msg => {
|
|
1023
1289
|
if (!msg) return msg;
|
|
1024
1290
|
if (typeof msg.content === 'string') {
|
|
1025
|
-
const res = engine.sanitize(msg.content, { existingSessionMap: sessionMap });
|
|
1291
|
+
const res = engine.sanitize(msg.content, { existingSessionMap: sessionMap, persistSession: false });
|
|
1026
1292
|
Object.assign(sessionMap, res.tokenMap);
|
|
1027
1293
|
return { ...msg, content: res.scrubbedText };
|
|
1028
1294
|
}
|
|
1029
1295
|
if (Array.isArray(msg.content)) {
|
|
1030
1296
|
const newContent = msg.content.map(part => {
|
|
1031
|
-
if (part && part.type === 'text' && typeof part.text === 'string') {
|
|
1032
|
-
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 });
|
|
1033
1299
|
Object.assign(sessionMap, res.tokenMap);
|
|
1034
1300
|
return { ...part, text: res.scrubbedText };
|
|
1035
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
|
+
}
|
|
1036
1314
|
return part;
|
|
1037
1315
|
});
|
|
1038
1316
|
return { ...msg, content: newContent };
|
|
@@ -1041,16 +1319,41 @@ function wrapAiModel(model, options = {}) {
|
|
|
1041
1319
|
});
|
|
1042
1320
|
}
|
|
1043
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
|
+
|
|
1044
1333
|
if (typeof model.doGenerate === 'function') {
|
|
1045
1334
|
wrapped.doGenerate = async function(params) {
|
|
1046
|
-
const sessionTokenMap =
|
|
1335
|
+
const sessionTokenMap = createScopedTokenMap(params?.prompt);
|
|
1047
1336
|
const sanitizedParams = {
|
|
1048
1337
|
...params,
|
|
1049
1338
|
prompt: sanitizePromptParts(params.prompt, sessionTokenMap)
|
|
1050
1339
|
};
|
|
1340
|
+
Object.assign(sharedSessionMap, sessionTokenMap);
|
|
1051
1341
|
const result = await model.doGenerate(sanitizedParams);
|
|
1052
|
-
if (result
|
|
1053
|
-
|
|
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
|
+
}
|
|
1054
1357
|
}
|
|
1055
1358
|
return result;
|
|
1056
1359
|
};
|
|
@@ -1058,43 +1361,87 @@ function wrapAiModel(model, options = {}) {
|
|
|
1058
1361
|
|
|
1059
1362
|
if (typeof model.doStream === 'function') {
|
|
1060
1363
|
wrapped.doStream = async function(params) {
|
|
1061
|
-
const sessionTokenMap =
|
|
1364
|
+
const sessionTokenMap = createScopedTokenMap(params?.prompt);
|
|
1062
1365
|
const sanitizedParams = {
|
|
1063
1366
|
...params,
|
|
1064
1367
|
prompt: sanitizePromptParts(params.prompt, sessionTokenMap)
|
|
1065
1368
|
};
|
|
1369
|
+
Object.assign(sharedSessionMap, sessionTokenMap);
|
|
1066
1370
|
const result = await model.doStream(sanitizedParams);
|
|
1067
1371
|
if (result && result.stream) {
|
|
1068
1372
|
const detok = new StreamingDetokenizer(sessionTokenMap);
|
|
1069
1373
|
const originalStream = result.stream;
|
|
1374
|
+
let usesV2Delta = false;
|
|
1070
1375
|
|
|
1071
1376
|
if (typeof originalStream.pipeThrough === 'function' && typeof TransformStream !== 'undefined') {
|
|
1072
1377
|
result.stream = originalStream.pipeThrough(new TransformStream({
|
|
1073
1378
|
transform(chunk, controller) {
|
|
1074
|
-
if (chunk && chunk.type === 'text-delta'
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
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
|
+
}
|
|
1079
1399
|
}
|
|
1400
|
+
controller.enqueue(chunk);
|
|
1080
1401
|
},
|
|
1081
1402
|
flush(controller) {
|
|
1082
1403
|
const rem = detok.flush();
|
|
1083
|
-
if (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
|
+
}
|
|
1084
1411
|
}
|
|
1085
1412
|
}));
|
|
1086
1413
|
} else if (typeof originalStream[Symbol.asyncIterator] === 'function') {
|
|
1087
1414
|
async function* wrappedAsyncIterable() {
|
|
1088
1415
|
for await (const chunk of originalStream) {
|
|
1089
|
-
if (chunk && chunk.type === 'text-delta'
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
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
|
+
}
|
|
1094
1436
|
}
|
|
1437
|
+
yield chunk;
|
|
1095
1438
|
}
|
|
1096
1439
|
const rem = detok.flush();
|
|
1097
|
-
if (rem)
|
|
1440
|
+
if (rem) {
|
|
1441
|
+
yield usesV2Delta
|
|
1442
|
+
? { type: 'text-delta', delta: rem, textDelta: rem }
|
|
1443
|
+
: { type: 'text-delta', textDelta: rem, delta: rem };
|
|
1444
|
+
}
|
|
1098
1445
|
}
|
|
1099
1446
|
result.stream = wrappedAsyncIterable();
|
|
1100
1447
|
}
|
|
@@ -1108,6 +1455,631 @@ function wrapAiModel(model, options = {}) {
|
|
|
1108
1455
|
|
|
1109
1456
|
const wrapAiSdk = wrapAiModel;
|
|
1110
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
|
+
|
|
1111
2083
|
/**
|
|
1112
2084
|
* Framework middleware helper for LangChain / LlamaIndex pipelines.
|
|
1113
2085
|
*/
|
|
@@ -1747,9 +2719,11 @@ class StreamingDetokenizer {
|
|
|
1747
2719
|
*/
|
|
1748
2720
|
async* wrapStream(asyncIterable) {
|
|
1749
2721
|
let lastOpenAIChunk = null;
|
|
2722
|
+
let lastResponsesDeltaChunk = null;
|
|
2723
|
+
let lastAnthropicDeltaChunk = null;
|
|
1750
2724
|
for await (const chunk of asyncIterable) {
|
|
1751
2725
|
if (chunk === null || chunk === undefined) continue;
|
|
1752
|
-
// Handle OpenAI chunk object
|
|
2726
|
+
// Handle OpenAI chat.completions chunk object
|
|
1753
2727
|
if (chunk.choices && Array.isArray(chunk.choices) && chunk.choices[0]?.delta?.content !== undefined) {
|
|
1754
2728
|
lastOpenAIChunk = chunk;
|
|
1755
2729
|
const delta = chunk.choices[0].delta.content;
|
|
@@ -1767,6 +2741,55 @@ class StreamingDetokenizer {
|
|
|
1767
2741
|
continue;
|
|
1768
2742
|
}
|
|
1769
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
|
+
|
|
1770
2793
|
let text = '';
|
|
1771
2794
|
if (typeof chunk === 'string') {
|
|
1772
2795
|
text = chunk;
|
|
@@ -1791,6 +2814,10 @@ class StreamingDetokenizer {
|
|
|
1791
2814
|
idx === 0 ? { ...c, delta: { ...c.delta, content: finalChunk } } : c
|
|
1792
2815
|
);
|
|
1793
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 } };
|
|
1794
2821
|
} else {
|
|
1795
2822
|
yield finalChunk;
|
|
1796
2823
|
}
|
|
@@ -1952,7 +2979,7 @@ function formatCefEvent(eventData = {}) {
|
|
|
1952
2979
|
const {
|
|
1953
2980
|
vendor = 'PrivacyScrubber',
|
|
1954
2981
|
product = 'ZTDS',
|
|
1955
|
-
version =
|
|
2982
|
+
version = SDK_VERSION,
|
|
1956
2983
|
eventClassId = 'PII_REDACTION',
|
|
1957
2984
|
eventName = 'Zero-Trust PII Sanitization Executed',
|
|
1958
2985
|
riskLevel = 'LOW EXPOSURE',
|
|
@@ -2021,7 +3048,7 @@ function formatSyslogEvent(eventData = {}) {
|
|
|
2021
3048
|
function formatJsonlEvent(eventData = {}) {
|
|
2022
3049
|
const {
|
|
2023
3050
|
timestamp = new Date().toISOString(),
|
|
2024
|
-
version =
|
|
3051
|
+
version = SDK_VERSION,
|
|
2025
3052
|
protocol = 'ZTDS',
|
|
2026
3053
|
event = 'sanitization',
|
|
2027
3054
|
riskLevel = 'LOW EXPOSURE',
|
|
@@ -2069,12 +3096,22 @@ function createAuditLogger(options = {}) {
|
|
|
2069
3096
|
const buffer = [];
|
|
2070
3097
|
|
|
2071
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
|
+
}
|
|
2072
3109
|
try {
|
|
2073
|
-
const dir = path.dirname(
|
|
3110
|
+
const dir = path.dirname(resolvedDest);
|
|
2074
3111
|
if (!fs.existsSync(dir)) {
|
|
2075
3112
|
fs.mkdirSync(dir, { recursive: true });
|
|
2076
3113
|
}
|
|
2077
|
-
fileHandle =
|
|
3114
|
+
fileHandle = resolvedDest;
|
|
2078
3115
|
} catch {}
|
|
2079
3116
|
} else if (destination && typeof destination.write === 'function') {
|
|
2080
3117
|
streamHandle = destination;
|
|
@@ -2104,6 +3141,12 @@ function createAuditLogger(options = {}) {
|
|
|
2104
3141
|
buffer.push(line);
|
|
2105
3142
|
if (fileHandle) {
|
|
2106
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
|
+
}
|
|
2107
3150
|
fs.appendFileSync(fileHandle, line + '\n', 'utf8');
|
|
2108
3151
|
} catch (e) {
|
|
2109
3152
|
console.error('[PrivacyScrubber AuditLogger] File write error:', e);
|
|
@@ -2407,11 +3450,41 @@ function applyPatch(filePath, content, options = {}) {
|
|
|
2407
3450
|
const {
|
|
2408
3451
|
tokenMap = null,
|
|
2409
3452
|
engine = null,
|
|
2410
|
-
createBackup = true
|
|
3453
|
+
createBackup = true,
|
|
3454
|
+
cwd = process.cwd(),
|
|
3455
|
+
allowOutsideWorkspace = false
|
|
2411
3456
|
} = options;
|
|
2412
3457
|
|
|
3458
|
+
if (filePath.includes('\0')) {
|
|
3459
|
+
throw new Error('[applyPatch] Security policy violation — null byte in filePath blocked.');
|
|
3460
|
+
}
|
|
3461
|
+
|
|
2413
3462
|
const activeTokenMap = tokenMap || (engine && engine.sessionMap) || {};
|
|
2414
|
-
const
|
|
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
|
+
|
|
2415
3488
|
const restored = unscrub(content, activeTokenMap);
|
|
2416
3489
|
|
|
2417
3490
|
let backupCreated = false;
|
|
@@ -2465,6 +3538,32 @@ function createGuardedTools(engineOrOptions = {}, toolConfig = {}) {
|
|
|
2465
3538
|
|
|
2466
3539
|
const baseCwd = toolConfig.cwd || process.cwd();
|
|
2467
3540
|
const defaultProfile = toolConfig.profile || engine.defaultProfile || 'Dev';
|
|
3541
|
+
const allowOutsideWorkspace = toolConfig.allowOutsideWorkspace === true;
|
|
3542
|
+
|
|
3543
|
+
function resolveSafeToolPath(inputPath, toolName, paramName = 'filePath') {
|
|
3544
|
+
const workspaceRoot = fs.existsSync(baseCwd) ? fs.realpathSync(baseCwd) : path.resolve(baseCwd);
|
|
3545
|
+
const resolvedPath = path.resolve(workspaceRoot, inputPath);
|
|
3546
|
+
if (allowOutsideWorkspace) return resolvedPath;
|
|
3547
|
+
|
|
3548
|
+
const relToRoot = path.relative(workspaceRoot, resolvedPath);
|
|
3549
|
+
if (relToRoot.startsWith('..') || path.isAbsolute(relToRoot)) {
|
|
3550
|
+
throw new Error(`[${toolName}] Security policy violation — target '${paramName}' resolves outside the active workspace directory (path traversal blocked).`);
|
|
3551
|
+
}
|
|
3552
|
+
let checkCursor = resolvedPath;
|
|
3553
|
+
while (!fs.existsSync(checkCursor)) {
|
|
3554
|
+
const parentCursor = path.dirname(checkCursor);
|
|
3555
|
+
if (parentCursor === checkCursor) break;
|
|
3556
|
+
checkCursor = parentCursor;
|
|
3557
|
+
}
|
|
3558
|
+
if (fs.existsSync(checkCursor)) {
|
|
3559
|
+
const realCheck = fs.realpathSync(checkCursor);
|
|
3560
|
+
const relReal = path.relative(workspaceRoot, realCheck);
|
|
3561
|
+
if (relReal.startsWith('..') || path.isAbsolute(relReal)) {
|
|
3562
|
+
throw new Error(`[${toolName}] Security policy violation — target path symlink escapes the active workspace directory.`);
|
|
3563
|
+
}
|
|
3564
|
+
}
|
|
3565
|
+
return resolvedPath;
|
|
3566
|
+
}
|
|
2468
3567
|
|
|
2469
3568
|
const tools = {
|
|
2470
3569
|
guardExec: {
|
|
@@ -2484,7 +3583,7 @@ function createGuardedTools(engineOrOptions = {}, toolConfig = {}) {
|
|
|
2484
3583
|
if (!command || typeof command !== 'string') {
|
|
2485
3584
|
throw new Error('[guard_exec] Missing or invalid parameter: command must be a string.');
|
|
2486
3585
|
}
|
|
2487
|
-
const execCwd = cwd ?
|
|
3586
|
+
const execCwd = cwd ? resolveSafeToolPath(cwd, 'guard_exec', 'cwd') : baseCwd;
|
|
2488
3587
|
let stdout = '';
|
|
2489
3588
|
let stderr = '';
|
|
2490
3589
|
let exitCode = 0;
|
|
@@ -2548,7 +3647,7 @@ function createGuardedTools(engineOrOptions = {}, toolConfig = {}) {
|
|
|
2548
3647
|
if (!filePath || typeof filePath !== 'string') {
|
|
2549
3648
|
throw new Error('[guard_read_file] Missing or invalid parameter: filePath must be a string.');
|
|
2550
3649
|
}
|
|
2551
|
-
const resolved =
|
|
3650
|
+
const resolved = resolveSafeToolPath(filePath, 'guard_read_file', 'filePath');
|
|
2552
3651
|
if (!fs.existsSync(resolved)) {
|
|
2553
3652
|
throw new Error(`[guard_read_file] File not found: ${resolved}`);
|
|
2554
3653
|
}
|
|
@@ -2639,9 +3738,12 @@ function createGuardedTools(engineOrOptions = {}, toolConfig = {}) {
|
|
|
2639
3738
|
required: ['filePath', 'content']
|
|
2640
3739
|
},
|
|
2641
3740
|
execute: async ({ filePath, content, createBackup = true } = {}) => {
|
|
2642
|
-
const
|
|
3741
|
+
const safeFilePath = resolveSafeToolPath(filePath, 'guard_apply_patch', 'filePath');
|
|
3742
|
+
const patchResult = applyPatch(safeFilePath, content, {
|
|
2643
3743
|
tokenMap: engine.sessionMap,
|
|
2644
|
-
createBackup
|
|
3744
|
+
createBackup,
|
|
3745
|
+
cwd: baseCwd,
|
|
3746
|
+
allowOutsideWorkspace
|
|
2645
3747
|
});
|
|
2646
3748
|
|
|
2647
3749
|
const responseText = `[Zero-Trust Agentic Guard: Patch Applied]\n` +
|
|
@@ -2670,7 +3772,7 @@ function createGuardedTools(engineOrOptions = {}, toolConfig = {}) {
|
|
|
2670
3772
|
required: []
|
|
2671
3773
|
},
|
|
2672
3774
|
execute: async ({ staged = false, cwd, profile = defaultProfile } = {}) => {
|
|
2673
|
-
const repoCwd = cwd ?
|
|
3775
|
+
const repoCwd = cwd ? resolveSafeToolPath(cwd, 'guard_git_diff', 'cwd') : baseCwd;
|
|
2674
3776
|
const gitCmd = staged ? 'git diff --cached' : 'git diff';
|
|
2675
3777
|
let diffOutput = '';
|
|
2676
3778
|
|
|
@@ -2752,6 +3854,7 @@ const {
|
|
|
2752
3854
|
} = require('./vision.cjs');
|
|
2753
3855
|
|
|
2754
3856
|
const _defaultExport = {
|
|
3857
|
+
SDK_VERSION,
|
|
2755
3858
|
sanitize,
|
|
2756
3859
|
sanitizeObject,
|
|
2757
3860
|
scrub,
|
|
@@ -2778,8 +3881,13 @@ const _defaultExport = {
|
|
|
2778
3881
|
PrivacyScrubberEngine,
|
|
2779
3882
|
PrivacyScrubberLicenseError,
|
|
2780
3883
|
wrapOpenAI,
|
|
3884
|
+
wrapAnthropic,
|
|
3885
|
+
wrapGemini,
|
|
3886
|
+
wrapGoogleGenAI,
|
|
2781
3887
|
wrapAiModel,
|
|
2782
3888
|
wrapAiSdk,
|
|
3889
|
+
createPrivacyFetch,
|
|
3890
|
+
createOpenAIAgentGuardrails,
|
|
2783
3891
|
createLangChainTransform,
|
|
2784
3892
|
createDocumentTransformer,
|
|
2785
3893
|
createLangChainDocumentTransformer,
|