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