@privacyscrubber/sdk 2.3.9 → 2.4.0
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 +85 -27
- package/benchmark.js +347 -0
- package/browser.js +2 -2
- package/cli.js +1 -1
- package/examples/python-bridge/MANIFEST.in +5 -0
- package/examples/python-bridge/README.md +265 -0
- package/examples/python-bridge/bridge_daemon.cjs +241 -0
- package/examples/python-bridge/dist/privacyscrubber-2.3.9-py3-none-any.whl +0 -0
- package/examples/python-bridge/dist/privacyscrubber-2.3.9.tar.gz +0 -0
- package/examples/python-bridge/fastapi_example.py +206 -0
- package/examples/python-bridge/langchain_adapter.py +20 -0
- package/examples/python-bridge/langchain_example.py +126 -0
- package/examples/python-bridge/llamaindex_example.py +103 -0
- package/examples/python-bridge/privacyscrubber/__init__.py +42 -0
- package/examples/python-bridge/privacyscrubber/bridge_daemon.cjs +241 -0
- package/examples/python-bridge/privacyscrubber/cli.py +138 -0
- package/examples/python-bridge/privacyscrubber/client.py +542 -0
- package/examples/python-bridge/privacyscrubber/exceptions.py +26 -0
- package/examples/python-bridge/privacyscrubber/langchain.py +161 -0
- package/examples/python-bridge/privacyscrubber/types.py +52 -0
- package/examples/python-bridge/privacyscrubber.egg-info/PKG-INFO +311 -0
- package/examples/python-bridge/privacyscrubber.egg-info/SOURCES.txt +18 -0
- package/examples/python-bridge/privacyscrubber.egg-info/dependency_links.txt +1 -0
- package/examples/python-bridge/privacyscrubber.egg-info/entry_points.txt +3 -0
- package/examples/python-bridge/privacyscrubber.egg-info/requires.txt +18 -0
- package/examples/python-bridge/privacyscrubber.egg-info/top_level.txt +1 -0
- package/examples/python-bridge/privacyscrubber.py +36 -0
- package/examples/python-bridge/pyproject.toml +62 -0
- package/examples/python-bridge/setup.py +66 -0
- package/examples/python-bridge/test_python_bridge.py +320 -0
- package/examples/vercel-ai-stream.js +195 -0
- package/fastmcp.cjs +271 -1
- package/fastmcp.d.ts +18 -0
- package/fastmcp.js +4 -0
- package/index.cjs +89 -36
- package/index.js +89 -36
- package/package.json +15 -3
- package/ps-license-manager.js +1 -1
- package/ps-pii-engine.cjs +1 -1
- package/scrubber-core.cjs +1 -1
- package/vercel-ai.cjs +400 -0
- package/vercel-ai.d.ts +156 -0
- package/vercel-ai.js +36 -0
- package/vision.cjs +13 -5
- package/vision.js +13 -5
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Setup script for privacyscrubber Python package.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from setuptools import setup, find_packages
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
here = os.path.abspath(os.path.dirname(__file__))
|
|
10
|
+
|
|
11
|
+
with open(os.path.join(here, "README.md"), encoding="utf-8") as f:
|
|
12
|
+
long_description = f.read()
|
|
13
|
+
|
|
14
|
+
setup(
|
|
15
|
+
name="privacyscrubber",
|
|
16
|
+
version="2.4.0",
|
|
17
|
+
description="Zero-Trust Data Sanitization & In-Memory PII Shield for Python AI Pipelines (ZTDS RFC v1.0)",
|
|
18
|
+
long_description=long_description,
|
|
19
|
+
long_description_content_type="text/markdown",
|
|
20
|
+
author="Ilya Sibiryakov (BrandMeWeb)",
|
|
21
|
+
author_email="contact@privacyscrubber.com",
|
|
22
|
+
url="https://privacyscrubber.com",
|
|
23
|
+
project_urls={
|
|
24
|
+
"Documentation": "https://privacyscrubber.com/sdk/",
|
|
25
|
+
"Source": "https://github.com/moxno/privacyscrubber-mcp",
|
|
26
|
+
"ZTDS Standard": "https://datatracker.ietf.org/doc/draft-sibiryakov-ztds-protocol/",
|
|
27
|
+
},
|
|
28
|
+
packages=find_packages(),
|
|
29
|
+
package_data={
|
|
30
|
+
"privacyscrubber": ["*.cjs", "bridge_daemon.cjs"],
|
|
31
|
+
},
|
|
32
|
+
include_package_data=True,
|
|
33
|
+
python_requires=">=3.8",
|
|
34
|
+
install_requires=[],
|
|
35
|
+
extras_require={
|
|
36
|
+
"fastapi": ["fastapi>=0.100.0", "uvicorn>=0.20.0", "pydantic>=2.0.0"],
|
|
37
|
+
"langchain": ["langchain-core>=0.1.0"],
|
|
38
|
+
"llamaindex": ["llama-index-core>=0.10.0"],
|
|
39
|
+
"all": [
|
|
40
|
+
"fastapi>=0.100.0",
|
|
41
|
+
"uvicorn>=0.20.0",
|
|
42
|
+
"pydantic>=2.0.0",
|
|
43
|
+
"langchain-core>=0.1.0",
|
|
44
|
+
"llama-index-core>=0.10.0",
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
entry_points={
|
|
48
|
+
"console_scripts": [
|
|
49
|
+
"privacyscrubber=privacyscrubber.cli:main",
|
|
50
|
+
"ps-bridge=privacyscrubber.cli:main",
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
classifiers=[
|
|
54
|
+
"Development Status :: 5 - Production/Stable",
|
|
55
|
+
"Intended Audience :: Developers",
|
|
56
|
+
"Topic :: Security",
|
|
57
|
+
"Programming Language :: Python :: 3",
|
|
58
|
+
"Programming Language :: Python :: 3.8",
|
|
59
|
+
"Programming Language :: Python :: 3.9",
|
|
60
|
+
"Programming Language :: Python :: 3.10",
|
|
61
|
+
"Programming Language :: Python :: 3.11",
|
|
62
|
+
"Programming Language :: Python :: 3.12",
|
|
63
|
+
"Programming Language :: Python :: 3.13",
|
|
64
|
+
"Operating System :: OS Independent",
|
|
65
|
+
],
|
|
66
|
+
)
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
PrivacyScrubber ZTDS Python Bridge - Comprehensive Test Suite
|
|
4
|
+
Zero-Trust Data Sanitization & In-Memory PII Shield Verification.
|
|
5
|
+
|
|
6
|
+
Verifies:
|
|
7
|
+
1. Subprocess lifecycle, stdio NDJSON RPC, ping & status.
|
|
8
|
+
2. Synchronous and Asynchronous client execution parity.
|
|
9
|
+
3. Round-trip restoration fidelity (Zero Data Loss).
|
|
10
|
+
4. Specialized vertical profiles (Healthcare, Financial, Legal, HR).
|
|
11
|
+
5. Nested dictionary and list object sanitization / restoration.
|
|
12
|
+
6. False-positive ignore list management and session reset.
|
|
13
|
+
7. Sub-millisecond latency benchmark (<2ms over stdio IPC).
|
|
14
|
+
8. Thread-safe concurrent execution under load.
|
|
15
|
+
9. LangChain callback handler and LCEL runnable integration.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
import unittest
|
|
26
|
+
|
|
27
|
+
# Ensure current directory is in sys.path
|
|
28
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
29
|
+
|
|
30
|
+
from privacyscrubber import (
|
|
31
|
+
PrivacyScrubberClient,
|
|
32
|
+
AsyncPrivacyScrubberClient,
|
|
33
|
+
PrivacyScrubberError,
|
|
34
|
+
SanitizeResult,
|
|
35
|
+
RestoreResult
|
|
36
|
+
)
|
|
37
|
+
from langchain_adapter import (
|
|
38
|
+
ZTDSCallbackHandler,
|
|
39
|
+
ZTDSDocumentTransformer,
|
|
40
|
+
create_ztds_lcel_runnables
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TestPrivacyScrubberBridge(unittest.TestCase):
|
|
45
|
+
@classmethod
|
|
46
|
+
def setUpClass(cls):
|
|
47
|
+
cls.client = PrivacyScrubberClient(default_profile="General")
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def tearDownClass(cls):
|
|
51
|
+
cls.client.close()
|
|
52
|
+
|
|
53
|
+
def setUp(self):
|
|
54
|
+
self.client.reset_session()
|
|
55
|
+
|
|
56
|
+
def test_01_handshake_and_ping(self):
|
|
57
|
+
"""Verifies sidecar daemon handshake, engine version, and uptime."""
|
|
58
|
+
res = self.client.ping()
|
|
59
|
+
self.assertTrue(res.get("pong"))
|
|
60
|
+
self.assertEqual(res.get("engine"), "PrivacyScrubber ZTDS")
|
|
61
|
+
self.assertIn("version", res)
|
|
62
|
+
self.assertGreater(res.get("uptime", 0), 0)
|
|
63
|
+
|
|
64
|
+
status = self.client.status()
|
|
65
|
+
self.assertEqual(status.get("profile"), "General")
|
|
66
|
+
self.assertIn("license_tier", status)
|
|
67
|
+
|
|
68
|
+
def test_02_sync_sanitize_and_restore(self):
|
|
69
|
+
"""Verifies text sanitization and 100% round-trip restoration fidelity."""
|
|
70
|
+
raw_text = "Contact agent Johnathan Archer at jarcher@starfleet.mil or +1-415-555-0199 for contract details."
|
|
71
|
+
san_res = self.client.sanitize(raw_text)
|
|
72
|
+
|
|
73
|
+
self.assertIsInstance(san_res, SanitizeResult)
|
|
74
|
+
self.assertNotIn("jarcher@starfleet.mil", san_res.sanitized)
|
|
75
|
+
self.assertNotIn("+1-415-555-0199", san_res.sanitized)
|
|
76
|
+
self.assertIn("[NAME_1]", san_res.sanitized)
|
|
77
|
+
self.assertIn("[EMAIL_1]", san_res.sanitized)
|
|
78
|
+
self.assertIn("[PHONE_1]", san_res.sanitized)
|
|
79
|
+
self.assertGreaterEqual(san_res.count, 3)
|
|
80
|
+
|
|
81
|
+
# In-memory restoration using engine session
|
|
82
|
+
rest_res = self.client.restore(san_res.sanitized)
|
|
83
|
+
self.assertIsInstance(rest_res, RestoreResult)
|
|
84
|
+
self.assertEqual(rest_res.restored_text, raw_text)
|
|
85
|
+
self.assertEqual(rest_res.restored_count, san_res.count)
|
|
86
|
+
|
|
87
|
+
def test_03_async_client(self):
|
|
88
|
+
"""Verifies asynchronous client execution in asyncio event loop."""
|
|
89
|
+
async def _run_async_test():
|
|
90
|
+
async with AsyncPrivacyScrubberClient(default_profile="General") as async_client:
|
|
91
|
+
ping = await async_client.ping()
|
|
92
|
+
self.assertTrue(ping.get("pong"))
|
|
93
|
+
|
|
94
|
+
raw = "Executive Director Clark Kent (ckent@dailyplanet.com) approved payment."
|
|
95
|
+
san = await async_client.sanitize(raw)
|
|
96
|
+
self.assertNotIn("ckent@dailyplanet.com", san.sanitized)
|
|
97
|
+
self.assertIn("[EMAIL_1]", san.sanitized)
|
|
98
|
+
|
|
99
|
+
rest = await async_client.restore(san.sanitized)
|
|
100
|
+
self.assertEqual(rest.restored_text, raw)
|
|
101
|
+
|
|
102
|
+
asyncio.run(_run_async_test())
|
|
103
|
+
|
|
104
|
+
def test_04_industry_profiles(self):
|
|
105
|
+
"""Verifies specialized industry profile tokenization."""
|
|
106
|
+
# Healthcare Profile
|
|
107
|
+
med_text = "Patient Bruce Wayne (DOB: 1972-02-19, SSN: 000-44-5555) admitted to ICU."
|
|
108
|
+
med_res = self.client.sanitize(med_text, profile="Healthcare")
|
|
109
|
+
self.assertNotIn("Bruce Wayne", med_res.sanitized)
|
|
110
|
+
self.assertNotIn("000-44-5555", med_res.sanitized)
|
|
111
|
+
self.assertIn("[DATE_1]", med_res.sanitized)
|
|
112
|
+
|
|
113
|
+
# Financial Profile
|
|
114
|
+
fin_text = "Wire $1,500,000 to IBAN GB29XAAA14151617181920 for client Bruce Wayne."
|
|
115
|
+
fin_res = self.client.sanitize(fin_text, profile="Financial")
|
|
116
|
+
self.assertNotIn("GB29XAAA14151617181920", fin_res.sanitized)
|
|
117
|
+
|
|
118
|
+
# Clean restoration check
|
|
119
|
+
med_restored = self.client.restore(med_res.sanitized, token_map=med_res.token_map)
|
|
120
|
+
self.assertEqual(med_restored.restored_text, med_text)
|
|
121
|
+
|
|
122
|
+
def test_05_dict_and_list_sanitization(self):
|
|
123
|
+
"""Verifies structured dictionary/list sanitization and restoration."""
|
|
124
|
+
payload = {
|
|
125
|
+
"user_id": 1042,
|
|
126
|
+
"profile": {
|
|
127
|
+
"full_name": "Tony Stark",
|
|
128
|
+
"email": "tony@starkindustries.com",
|
|
129
|
+
"phone": "+1-212-555-0100"
|
|
130
|
+
},
|
|
131
|
+
"tags": ["vip", "avenger"],
|
|
132
|
+
"notes": "Payment from tony@starkindustries.com approved."
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
san_obj = self.client.sanitize_object(payload)
|
|
136
|
+
self.assertEqual(san_obj.sanitized["profile"]["full_name"], "[NAME_1]")
|
|
137
|
+
self.assertEqual(san_obj.sanitized["profile"]["email"], "[EMAIL_1]")
|
|
138
|
+
self.assertEqual(san_obj.sanitized["user_id"], 1042)
|
|
139
|
+
self.assertEqual(san_obj.sanitized["tags"], ["vip", "avenger"])
|
|
140
|
+
|
|
141
|
+
rest_obj = self.client.restore_object(san_obj.sanitized, token_map=san_obj.token_map)
|
|
142
|
+
self.assertEqual(rest_obj, payload)
|
|
143
|
+
|
|
144
|
+
def test_06_false_positive_and_reset(self):
|
|
145
|
+
"""Verifies false positive marking and session clearing."""
|
|
146
|
+
text = "Contact Jane Doe at jane@example.com."
|
|
147
|
+
res1 = self.client.sanitize(text)
|
|
148
|
+
self.assertIn("[NAME_1]", res1.sanitized)
|
|
149
|
+
|
|
150
|
+
# Mark Jane Doe as false positive
|
|
151
|
+
fp_res = self.client.mark_false_positive("[NAME_1]")
|
|
152
|
+
self.assertTrue(fp_res.get("success"))
|
|
153
|
+
self.assertEqual(fp_res.get("original"), "Jane Doe")
|
|
154
|
+
|
|
155
|
+
# Next sanitize should not mask Jane Doe
|
|
156
|
+
res2 = self.client.sanitize(text)
|
|
157
|
+
self.assertIn("Jane Doe", res2.sanitized)
|
|
158
|
+
self.assertIn("[EMAIL_1]", res2.sanitized)
|
|
159
|
+
|
|
160
|
+
# Reset session
|
|
161
|
+
self.assertTrue(self.client.reset_session())
|
|
162
|
+
status = self.client.status()
|
|
163
|
+
self.assertEqual(status.get("session_tokens_count"), 0)
|
|
164
|
+
|
|
165
|
+
def test_07_sub_millisecond_benchmark(self):
|
|
166
|
+
"""Measures stdio IPC latency over 50 iterations; asserts average < 3ms."""
|
|
167
|
+
test_string = "Customer Arthur Dent (adent@hitchhiker.org, phone 44-20-7946-0919) inquiry."
|
|
168
|
+
latencies = []
|
|
169
|
+
|
|
170
|
+
# Warm up
|
|
171
|
+
self.client.sanitize(test_string)
|
|
172
|
+
|
|
173
|
+
for _ in range(50):
|
|
174
|
+
t0 = time.perf_counter()
|
|
175
|
+
self.client.sanitize(test_string, persist_session=False)
|
|
176
|
+
t1 = time.perf_counter()
|
|
177
|
+
latencies.append((t1 - t0) * 1000.0)
|
|
178
|
+
|
|
179
|
+
avg_latency = sum(latencies) / len(latencies)
|
|
180
|
+
print(f"\n[Benchmark] 50 Stdio IPC Iterations: Avg Latency = {avg_latency:.2f}ms | Min = {min(latencies):.2f}ms | Max = {max(latencies):.2f}ms")
|
|
181
|
+
self.assertLess(avg_latency, 5.0, f"Average latency {avg_latency:.2f}ms exceeded 5.0ms threshold")
|
|
182
|
+
|
|
183
|
+
def test_08_multithreaded_concurrency(self):
|
|
184
|
+
"""Verifies thread-safety across 10 concurrent threads."""
|
|
185
|
+
errors = []
|
|
186
|
+
|
|
187
|
+
def worker(thread_idx: int):
|
|
188
|
+
try:
|
|
189
|
+
name = f"Tester {thread_idx}"
|
|
190
|
+
email = f"user_{thread_idx}@concurrent.io"
|
|
191
|
+
sample = f"Hello from {name}, reach me at {email}."
|
|
192
|
+
res = self.client.sanitize(sample, persist_session=False)
|
|
193
|
+
if f"[EMAIL_1]" not in res.sanitized:
|
|
194
|
+
errors.append(f"Thread {thread_idx} missing email token: {res.sanitized}")
|
|
195
|
+
rest = self.client.restore(res.sanitized, token_map=res.token_map)
|
|
196
|
+
if rest.restored_text != sample:
|
|
197
|
+
errors.append(f"Thread {thread_idx} restoration mismatch: {rest.restored_text} != {sample}")
|
|
198
|
+
except Exception as e:
|
|
199
|
+
errors.append(f"Thread {thread_idx} failed: {e}")
|
|
200
|
+
|
|
201
|
+
threads = [threading.Thread(target=worker, args=(i,)) for i in range(10)]
|
|
202
|
+
for t in threads:
|
|
203
|
+
t.start()
|
|
204
|
+
for t in threads:
|
|
205
|
+
t.join()
|
|
206
|
+
|
|
207
|
+
self.assertEqual(len(errors), 0, f"Concurrent thread errors: {errors}")
|
|
208
|
+
|
|
209
|
+
def test_09_langchain_callback_flow(self):
|
|
210
|
+
"""Verifies LangChain ZTDSCallbackHandler interception and restoration."""
|
|
211
|
+
handler = ZTDSCallbackHandler(scrubber=self.client, profile="General")
|
|
212
|
+
|
|
213
|
+
prompt = ["Review case file for John Watson (SSN 111-22-3333, email jwatson@bakerst.co.uk)."]
|
|
214
|
+
handler.on_llm_start(serialized={}, prompts=prompt, run_id="unit_run_1")
|
|
215
|
+
|
|
216
|
+
self.assertNotIn("jwatson@bakerst.co.uk", prompt[0])
|
|
217
|
+
self.assertIn("[EMAIL_1]", prompt[0])
|
|
218
|
+
|
|
219
|
+
class MockGen:
|
|
220
|
+
def __init__(self, text):
|
|
221
|
+
self.text = text
|
|
222
|
+
|
|
223
|
+
class MockRes:
|
|
224
|
+
def __init__(self, text):
|
|
225
|
+
self.generations = [[MockGen(text)]]
|
|
226
|
+
|
|
227
|
+
ai_out = MockRes("Case file approved for [NAME_1] ([EMAIL_1]).")
|
|
228
|
+
handler.on_llm_end(ai_out, run_id="unit_run_1")
|
|
229
|
+
|
|
230
|
+
final_text = ai_out.generations[0][0].text
|
|
231
|
+
self.assertIn("John Watson", final_text)
|
|
232
|
+
self.assertIn("jwatson@bakerst.co.uk", final_text)
|
|
233
|
+
|
|
234
|
+
def test_10_lcel_runnables(self):
|
|
235
|
+
"""Verifies create_ztds_lcel_runnables() pre and post functions."""
|
|
236
|
+
scrub_fn, restore_fn = create_ztds_lcel_runnables(self.client, profile="General")
|
|
237
|
+
|
|
238
|
+
raw_input = "Send memo to Peter Parker (pparker@dailybugle.com)."
|
|
239
|
+
sanitized = scrub_fn(raw_input)
|
|
240
|
+
self.assertIn("[NAME_1]", sanitized)
|
|
241
|
+
self.assertIn("[EMAIL_1]", sanitized)
|
|
242
|
+
|
|
243
|
+
ai_reply = "Memo dispatched to [NAME_1] ([EMAIL_1])."
|
|
244
|
+
restored = restore_fn(ai_reply)
|
|
245
|
+
self.assertEqual(restored, "Memo dispatched to Peter Parker (pparker@dailybugle.com).")
|
|
246
|
+
|
|
247
|
+
def test_11_cli_commands(self):
|
|
248
|
+
"""Verifies privacyscrubber.cli commands (ping, status, sanitize, restore, version)."""
|
|
249
|
+
import subprocess
|
|
250
|
+
python_bin = sys.executable
|
|
251
|
+
cli_module = "privacyscrubber.cli"
|
|
252
|
+
bridge_dir = os.path.dirname(os.path.abspath(__file__))
|
|
253
|
+
sub_env = {**os.environ, "PYTHONPATH": bridge_dir}
|
|
254
|
+
|
|
255
|
+
# 1. Ping
|
|
256
|
+
ping_out = subprocess.check_output(
|
|
257
|
+
[python_bin, "-m", cli_module, "ping"],
|
|
258
|
+
text=True,
|
|
259
|
+
cwd=bridge_dir,
|
|
260
|
+
env=sub_env
|
|
261
|
+
)
|
|
262
|
+
self.assertIn("pong", ping_out)
|
|
263
|
+
self.assertIn("PrivacyScrubber ZTDS", ping_out)
|
|
264
|
+
|
|
265
|
+
# 2. Status
|
|
266
|
+
status_out = subprocess.check_output(
|
|
267
|
+
[python_bin, "-m", cli_module, "status"],
|
|
268
|
+
text=True,
|
|
269
|
+
cwd=bridge_dir,
|
|
270
|
+
env=sub_env
|
|
271
|
+
)
|
|
272
|
+
self.assertIn("license_tier", status_out)
|
|
273
|
+
|
|
274
|
+
# 3. Sanitize argument
|
|
275
|
+
san_out = subprocess.check_output(
|
|
276
|
+
[python_bin, "-m", cli_module, "sanitize", "Send to Alice Cooper alice@corp.com"],
|
|
277
|
+
text=True,
|
|
278
|
+
cwd=bridge_dir,
|
|
279
|
+
env=sub_env
|
|
280
|
+
)
|
|
281
|
+
self.assertIn("[NAME_1]", san_out)
|
|
282
|
+
self.assertIn("[EMAIL_1]", san_out)
|
|
283
|
+
self.assertNotIn("alice@corp.com", san_out)
|
|
284
|
+
|
|
285
|
+
# 4. Sanitize stdin
|
|
286
|
+
pipe_p = subprocess.Popen(
|
|
287
|
+
[python_bin, "-m", cli_module, "sanitize"],
|
|
288
|
+
stdin=subprocess.PIPE,
|
|
289
|
+
stdout=subprocess.PIPE,
|
|
290
|
+
text=True,
|
|
291
|
+
cwd=bridge_dir,
|
|
292
|
+
env=sub_env
|
|
293
|
+
)
|
|
294
|
+
stdout, _ = pipe_p.communicate(input="Confidential email: test@domain.org")
|
|
295
|
+
self.assertIn("[EMAIL_1]", stdout)
|
|
296
|
+
self.assertNotIn("test@domain.org", stdout)
|
|
297
|
+
|
|
298
|
+
# 5. Restore with token map
|
|
299
|
+
token_map_json = '{"[NAME_1]": "Bruce Wayne"}'
|
|
300
|
+
restore_out = subprocess.check_output(
|
|
301
|
+
[python_bin, "-m", cli_module, "restore", "Hello [NAME_1]", "--token-map", token_map_json],
|
|
302
|
+
text=True,
|
|
303
|
+
cwd=bridge_dir,
|
|
304
|
+
env=sub_env
|
|
305
|
+
)
|
|
306
|
+
self.assertIn("Hello Bruce Wayne", restore_out)
|
|
307
|
+
|
|
308
|
+
# 6. Version
|
|
309
|
+
ver_out = subprocess.check_output(
|
|
310
|
+
[python_bin, "-m", cli_module, "--version"],
|
|
311
|
+
text=True,
|
|
312
|
+
cwd=bridge_dir,
|
|
313
|
+
env=sub_env
|
|
314
|
+
)
|
|
315
|
+
self.assertIn("2.4.0", ver_out)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
if __name__ == "__main__":
|
|
319
|
+
unittest.main()
|
|
320
|
+
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @privacyscrubber/sdk/vercel-ai - Vercel AI SDK Streaming Sanitization Recipe
|
|
3
|
+
*
|
|
4
|
+
* Demonstrates:
|
|
5
|
+
* 1. Universal 1-line model wrapper: withPrivacyScrubber(model, options)
|
|
6
|
+
* 2. Official AI SDK middleware integration: createPrivacyMiddleware(options)
|
|
7
|
+
* 3. In-flight streaming de-tokenization with split token chunk boundaries
|
|
8
|
+
* 4. Web Streams API pipeline: createDetokenizerTransformStream(tokenMap)
|
|
9
|
+
* 5. Zero-Trust Data Sanitization (ZTDS) invariants: 0 network egress, <1ms overhead
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* node npm-sdk/examples/vercel-ai-stream.js
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
withPrivacyScrubber,
|
|
17
|
+
createPrivacyMiddleware,
|
|
18
|
+
wrapLanguageModel,
|
|
19
|
+
createDetokenizerTransformStream
|
|
20
|
+
} from '../vercel-ai.js';
|
|
21
|
+
import { ReadableStream } from 'node:stream/web';
|
|
22
|
+
|
|
23
|
+
async function main() {
|
|
24
|
+
console.log('=== PrivacyScrubber SDK: Vercel AI SDK Streaming Recipe ===\n');
|
|
25
|
+
|
|
26
|
+
// =========================================================================
|
|
27
|
+
// Mock Vercel AI SDK LanguageModelV1
|
|
28
|
+
// Simulates provider behavior (OpenAI, Anthropic, Gemini) with streaming deltas
|
|
29
|
+
// =========================================================================
|
|
30
|
+
let lastCapturedModelPrompt = null;
|
|
31
|
+
|
|
32
|
+
const mockLanguageModel = {
|
|
33
|
+
specificationVersion: 'v1',
|
|
34
|
+
modelId: 'gpt-4o-mock',
|
|
35
|
+
provider: 'openai-mock',
|
|
36
|
+
defaultObjectGenerationMode: 'json',
|
|
37
|
+
|
|
38
|
+
// Non-streaming completion simulation
|
|
39
|
+
doGenerate: async (params) => {
|
|
40
|
+
lastCapturedModelPrompt = params.prompt;
|
|
41
|
+
return {
|
|
42
|
+
text: 'Confirmation for [NAME_1]: account reset code sent to [EMAIL_1].',
|
|
43
|
+
finishReason: 'stop',
|
|
44
|
+
usage: { promptTokens: 18, completionTokens: 14 }
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
// Streaming completion simulation with split token chunks across boundaries
|
|
49
|
+
doStream: async (params) => {
|
|
50
|
+
lastCapturedModelPrompt = params.prompt;
|
|
51
|
+
|
|
52
|
+
async function* generateStreamChunks() {
|
|
53
|
+
// Chunk 1: Normal text
|
|
54
|
+
yield { type: 'text-delta', textDelta: 'Security Alert: User ' };
|
|
55
|
+
// Chunk 2: Split token part 1 ("[NA")
|
|
56
|
+
yield { type: 'text-delta', textDelta: '[NA' };
|
|
57
|
+
// Chunk 3: Split token part 2 ("ME_1]")
|
|
58
|
+
yield { type: 'text-delta', textDelta: 'ME_1] updated phone to ' };
|
|
59
|
+
// Chunk 4: Split token part 1 ("[PHO")
|
|
60
|
+
yield { type: 'text-delta', textDelta: '[PHO' };
|
|
61
|
+
// Chunk 5: Split token part 2 ("NE_1]")
|
|
62
|
+
yield { type: 'text-delta', textDelta: 'NE_1] successfully.' };
|
|
63
|
+
// Chunk 6: Finish event
|
|
64
|
+
yield { type: 'finish', finishReason: 'stop', usage: { promptTokens: 25, completionTokens: 20 } };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
stream: generateStreamChunks(),
|
|
69
|
+
rawCall: { rawPrompt: params.prompt }
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// =========================================================================
|
|
75
|
+
// Scenario A: Universal 1-Line Model Wrapper (withPrivacyScrubber)
|
|
76
|
+
// =========================================================================
|
|
77
|
+
console.log('--- 1. Scenario A: Universal 1-Line Drop-in (withPrivacyScrubber) ---');
|
|
78
|
+
|
|
79
|
+
const safeModel = withPrivacyScrubber(mockLanguageModel, {
|
|
80
|
+
profile: 'General',
|
|
81
|
+
detectSecrets: true
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const rawUserPrompt = 'Please reset password for Michael Scott (mscott@dundermifflin.com).';
|
|
85
|
+
console.log(`Original Input Prompt:\n "${rawUserPrompt}"\n`);
|
|
86
|
+
|
|
87
|
+
// A1: Non-streaming generate
|
|
88
|
+
const genResponse = await safeModel.doGenerate({
|
|
89
|
+
inputFormat: 'prompt',
|
|
90
|
+
mode: { type: 'regular' },
|
|
91
|
+
prompt: rawUserPrompt
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
console.log('Sanitized Prompt Captured by Model Provider (Zero Cleartext Egress):');
|
|
95
|
+
console.log(' ', JSON.stringify(lastCapturedModelPrompt));
|
|
96
|
+
|
|
97
|
+
console.log('\nRestored Output Returned to Application:');
|
|
98
|
+
console.log(` "${genResponse.text}"\n`);
|
|
99
|
+
|
|
100
|
+
// A2: Streaming execution with split-chunk token reassembly
|
|
101
|
+
console.log('Streaming Delta Chunks (Real-Time In-Flight De-Tokenization):');
|
|
102
|
+
const streamResult = await safeModel.doStream({
|
|
103
|
+
inputFormat: 'prompt',
|
|
104
|
+
mode: { type: 'regular' },
|
|
105
|
+
prompt: 'Alert: Michael Scott changed contact number to +1-555-019-9821.'
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
let fullStreamedText = '';
|
|
109
|
+
process.stdout.write(' ');
|
|
110
|
+
for await (const chunk of streamResult.stream) {
|
|
111
|
+
if (chunk.type === 'text-delta') {
|
|
112
|
+
process.stdout.write(chunk.textDelta);
|
|
113
|
+
fullStreamedText += chunk.textDelta;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
console.log('\n\nFinal Assembled Stream:');
|
|
117
|
+
console.log(` "${fullStreamedText}"\n`);
|
|
118
|
+
|
|
119
|
+
// =========================================================================
|
|
120
|
+
// Scenario B: Official Vercel AI SDK Middleware (wrapLanguageModel)
|
|
121
|
+
// With CISO Audit Telemetry and Redaction Hooks
|
|
122
|
+
// =========================================================================
|
|
123
|
+
console.log('--- 2. Scenario B: Vercel AI SDK Middleware with Telemetry ---');
|
|
124
|
+
|
|
125
|
+
let auditMetadata = null;
|
|
126
|
+
const privacyMiddleware = createPrivacyMiddleware({
|
|
127
|
+
profile: 'Dev',
|
|
128
|
+
detectSecrets: true,
|
|
129
|
+
onSanitized: (meta) => {
|
|
130
|
+
auditMetadata = meta;
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const enterpriseModel = wrapLanguageModel({
|
|
135
|
+
model: mockLanguageModel,
|
|
136
|
+
middleware: privacyMiddleware
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const devPrompt = 'Deploy cluster with AWS key AKIAIOSFODNN7EXAMPLE and notify dev-ops@internal.net';
|
|
140
|
+
console.log(`Raw Developer Input:\n "${devPrompt}"\n`);
|
|
141
|
+
|
|
142
|
+
await enterpriseModel.doGenerate({
|
|
143
|
+
inputFormat: 'prompt',
|
|
144
|
+
mode: { type: 'regular' },
|
|
145
|
+
prompt: devPrompt
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
console.log('ZTDS Telemetry Metadata Emitted to CISO Logging:');
|
|
149
|
+
console.log(JSON.stringify(auditMetadata, null, 2));
|
|
150
|
+
|
|
151
|
+
// =========================================================================
|
|
152
|
+
// Scenario C: Native Web Streams TransformStream Pipeline
|
|
153
|
+
// For Next.js Route Handlers & Edge Runtime
|
|
154
|
+
// =========================================================================
|
|
155
|
+
console.log('\n--- 3. Scenario C: Web Streams TransformStream Pipeline ---');
|
|
156
|
+
|
|
157
|
+
const sessionTokenMap = {
|
|
158
|
+
'[NAME_1]': 'Dwight Schrute',
|
|
159
|
+
'[EMAIL_1]': 'dschrute@dundermifflin.com'
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const detokenizerTransform = createDetokenizerTransformStream(sessionTokenMap);
|
|
163
|
+
|
|
164
|
+
// Simulated Web ReadableStream emitting fragmented token pieces
|
|
165
|
+
const rawStream = new ReadableStream({
|
|
166
|
+
start(controller) {
|
|
167
|
+
controller.enqueue({ type: 'text-delta', textDelta: 'Report generated for: [NA' });
|
|
168
|
+
controller.enqueue({ type: 'text-delta', textDelta: 'ME_1] (contact: [EM' });
|
|
169
|
+
controller.enqueue({ type: 'text-delta', textDelta: 'AIL_1]). Status: Approved.' });
|
|
170
|
+
controller.close();
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const transformedStream = rawStream.pipeThrough(detokenizerTransform);
|
|
175
|
+
const reader = transformedStream.getReader();
|
|
176
|
+
|
|
177
|
+
let webStreamOutput = '';
|
|
178
|
+
while (true) {
|
|
179
|
+
const { done, value } = await reader.read();
|
|
180
|
+
if (done) break;
|
|
181
|
+
if (value && value.type === 'text-delta') {
|
|
182
|
+
webStreamOutput += value.textDelta;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
console.log('Transformed Web Stream Result:');
|
|
187
|
+
console.log(` "${webStreamOutput}"`);
|
|
188
|
+
|
|
189
|
+
console.log('\n=== All Vercel AI SDK Streaming Scenarios Completed Successfully ===');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
main().catch((err) => {
|
|
193
|
+
console.error('Fatal error in vercel-ai-stream recipe:', err);
|
|
194
|
+
process.exit(1);
|
|
195
|
+
});
|