@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.
Files changed (45) hide show
  1. package/README.md +85 -27
  2. package/benchmark.js +347 -0
  3. package/browser.js +2 -2
  4. package/cli.js +1 -1
  5. package/examples/python-bridge/MANIFEST.in +5 -0
  6. package/examples/python-bridge/README.md +265 -0
  7. package/examples/python-bridge/bridge_daemon.cjs +241 -0
  8. package/examples/python-bridge/dist/privacyscrubber-2.3.9-py3-none-any.whl +0 -0
  9. package/examples/python-bridge/dist/privacyscrubber-2.3.9.tar.gz +0 -0
  10. package/examples/python-bridge/fastapi_example.py +206 -0
  11. package/examples/python-bridge/langchain_adapter.py +20 -0
  12. package/examples/python-bridge/langchain_example.py +126 -0
  13. package/examples/python-bridge/llamaindex_example.py +103 -0
  14. package/examples/python-bridge/privacyscrubber/__init__.py +42 -0
  15. package/examples/python-bridge/privacyscrubber/bridge_daemon.cjs +241 -0
  16. package/examples/python-bridge/privacyscrubber/cli.py +138 -0
  17. package/examples/python-bridge/privacyscrubber/client.py +542 -0
  18. package/examples/python-bridge/privacyscrubber/exceptions.py +26 -0
  19. package/examples/python-bridge/privacyscrubber/langchain.py +161 -0
  20. package/examples/python-bridge/privacyscrubber/types.py +52 -0
  21. package/examples/python-bridge/privacyscrubber.egg-info/PKG-INFO +311 -0
  22. package/examples/python-bridge/privacyscrubber.egg-info/SOURCES.txt +18 -0
  23. package/examples/python-bridge/privacyscrubber.egg-info/dependency_links.txt +1 -0
  24. package/examples/python-bridge/privacyscrubber.egg-info/entry_points.txt +3 -0
  25. package/examples/python-bridge/privacyscrubber.egg-info/requires.txt +18 -0
  26. package/examples/python-bridge/privacyscrubber.egg-info/top_level.txt +1 -0
  27. package/examples/python-bridge/privacyscrubber.py +36 -0
  28. package/examples/python-bridge/pyproject.toml +62 -0
  29. package/examples/python-bridge/setup.py +66 -0
  30. package/examples/python-bridge/test_python_bridge.py +320 -0
  31. package/examples/vercel-ai-stream.js +195 -0
  32. package/fastmcp.cjs +271 -1
  33. package/fastmcp.d.ts +18 -0
  34. package/fastmcp.js +4 -0
  35. package/index.cjs +89 -36
  36. package/index.js +89 -36
  37. package/package.json +15 -3
  38. package/ps-license-manager.js +1 -1
  39. package/ps-pii-engine.cjs +1 -1
  40. package/scrubber-core.cjs +1 -1
  41. package/vercel-ai.cjs +400 -0
  42. package/vercel-ai.d.ts +156 -0
  43. package/vercel-ai.js +36 -0
  44. package/vision.cjs +13 -5
  45. package/vision.js +13 -5
@@ -0,0 +1,542 @@
1
+ """
2
+ PrivacyScrubber ZTDS Client Implementation
3
+ Synchronous and Asynchronous sidecar IPC over stdio NDJSON.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import json
10
+ import os
11
+ import subprocess
12
+ import threading
13
+ from typing import Any, Dict, List, Optional, Union
14
+
15
+ from privacyscrubber.exceptions import PrivacyScrubberError, PrivacyScrubberLicenseError
16
+ from privacyscrubber.types import SanitizeResult, RestoreResult, ObjectSanitizeResult
17
+
18
+
19
+ def _find_daemon_path(explicit_path: Optional[str] = None) -> str:
20
+ """Resolves the absolute path to bridge_daemon.cjs across package and filesystem."""
21
+ if explicit_path and os.path.isfile(explicit_path):
22
+ return os.path.abspath(explicit_path)
23
+
24
+ env_path = os.environ.get("PRIVACYSCRUBBER_BRIDGE_DAEMON")
25
+ if env_path and os.path.isfile(env_path):
26
+ return os.path.abspath(env_path)
27
+
28
+ here = os.path.dirname(os.path.abspath(__file__))
29
+ candidates = [
30
+ # 1. Directly inside packaged directory
31
+ os.path.join(here, "bridge_daemon.cjs"),
32
+ # 2. Parent directory (for dev repository layouts)
33
+ os.path.join(here, "..", "bridge_daemon.cjs"),
34
+ # 3. Monorepo / relative to npm-sdk
35
+ os.path.join(here, "..", "..", "python-bridge", "bridge_daemon.cjs"),
36
+ os.path.join(here, "..", "..", "examples", "python-bridge", "bridge_daemon.cjs"),
37
+ # 4. Standard node_modules locations
38
+ os.path.join(here, "node_modules", "@privacyscrubber", "sdk", "examples", "python-bridge", "bridge_daemon.cjs"),
39
+ os.path.join(os.getcwd(), "node_modules", "@privacyscrubber", "sdk", "examples", "python-bridge", "bridge_daemon.cjs"),
40
+ os.path.join(os.getcwd(), "npm-sdk", "examples", "python-bridge", "bridge_daemon.cjs"),
41
+ ]
42
+
43
+ for cand in candidates:
44
+ if os.path.isfile(cand):
45
+ return os.path.abspath(cand)
46
+
47
+ raise FileNotFoundError(
48
+ "Could not locate bridge_daemon.cjs. "
49
+ "Please provide daemon_path or set PRIVACYSCRUBBER_BRIDGE_DAEMON environment variable."
50
+ )
51
+
52
+
53
+ class PrivacyScrubberClient:
54
+ """
55
+ Synchronous PrivacyScrubber ZTDS client.
56
+ Manages a persistent, low-overhead Node.js sidecar daemon over stdio NDJSON.
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ node_path: str = "node",
62
+ daemon_path: Optional[str] = None,
63
+ license_key: Optional[str] = None,
64
+ default_profile: str = "General",
65
+ detect_secrets: bool = False,
66
+ auto_start: bool = True
67
+ ) -> None:
68
+ self.node_path = os.environ.get("PRIVACYSCRUBBER_NODE_PATH", node_path)
69
+ self.daemon_path = _find_daemon_path(daemon_path)
70
+ self.license_key = license_key or os.environ.get("PRIVACYSCRUBBER_KEY") or os.environ.get("PRIVACYSCRUBBER_LICENSE_KEY") or ""
71
+ self.default_profile = default_profile
72
+ self.detect_secrets = detect_secrets
73
+
74
+ self._process: Optional[subprocess.Popen] = None
75
+ self._lock = threading.Lock()
76
+ self._req_id = 0
77
+
78
+ if auto_start:
79
+ self.start()
80
+
81
+ def start(self) -> None:
82
+ """Starts the persistent sidecar process."""
83
+ with self._lock:
84
+ if self._process and self._process.poll() is None:
85
+ return
86
+
87
+ cmd = [
88
+ self.node_path,
89
+ self.daemon_path,
90
+ "--profile", self.default_profile
91
+ ]
92
+ if self.license_key:
93
+ cmd.extend(["--license-key", self.license_key])
94
+
95
+ self._process = subprocess.Popen(
96
+ cmd,
97
+ stdin=subprocess.PIPE,
98
+ stdout=subprocess.PIPE,
99
+ stderr=subprocess.PIPE,
100
+ text=True,
101
+ bufsize=1
102
+ )
103
+
104
+ def close(self) -> None:
105
+ """Gracefully shuts down the sidecar process."""
106
+ with self._lock:
107
+ if not self._process:
108
+ return
109
+ try:
110
+ if self._process.poll() is None:
111
+ if self._process.stdin:
112
+ self._process.stdin.close()
113
+ self._process.terminate()
114
+ self._process.wait(timeout=2.0)
115
+ except Exception:
116
+ try:
117
+ self._process.kill()
118
+ except Exception:
119
+ pass
120
+ finally:
121
+ try:
122
+ if self._process.stdout:
123
+ self._process.stdout.close()
124
+ except Exception:
125
+ pass
126
+ try:
127
+ if self._process.stderr:
128
+ self._process.stderr.close()
129
+ except Exception:
130
+ pass
131
+ self._process = None
132
+
133
+ def __enter__(self) -> PrivacyScrubberClient:
134
+ self.start()
135
+ return self
136
+
137
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
138
+ self.close()
139
+
140
+ def _send_request(self, action: str, **kwargs: Any) -> Dict[str, Any]:
141
+ """Sends a JSON-RPC request to the sidecar and awaits single-line response."""
142
+ with self._lock:
143
+ if not self._process or self._process.poll() is not None:
144
+ self.start()
145
+
146
+ self._req_id += 1
147
+ req_id = self._req_id
148
+
149
+ payload = {
150
+ "id": req_id,
151
+ "action": action,
152
+ **kwargs
153
+ }
154
+
155
+ req_line = json.dumps(payload) + "\n"
156
+ try:
157
+ assert self._process.stdin is not None
158
+ assert self._process.stdout is not None
159
+ self._process.stdin.write(req_line)
160
+ self._process.stdin.flush()
161
+
162
+ resp_line = self._process.stdout.readline()
163
+ if not resp_line:
164
+ stderr_output = self._process.stderr.read() if self._process.stderr else ""
165
+ raise PrivacyScrubberError(f"Sidecar terminated unexpectedly: {stderr_output.strip()}")
166
+
167
+ res = json.loads(resp_line.strip())
168
+ except Exception as e:
169
+ if not isinstance(e, PrivacyScrubberError):
170
+ raise PrivacyScrubberError(f"RPC communication failure: {str(e)}") from e
171
+ raise
172
+
173
+ if "error" in res and res["error"]:
174
+ err = res["error"]
175
+ msg = err.get("message", "Unknown error in PrivacyScrubber engine")
176
+ if err.get("name") == "PrivacyScrubberLicenseError":
177
+ raise PrivacyScrubberLicenseError(
178
+ msg,
179
+ tier=err.get("tier", "FREE"),
180
+ limit=err.get("limit", 15000),
181
+ profile=err.get("profile", "General")
182
+ )
183
+ raise PrivacyScrubberError(msg)
184
+
185
+ return res.get("result", {})
186
+
187
+ def ping(self) -> Dict[str, Any]:
188
+ """Verifies sidecar health, engine version, and process uptime."""
189
+ return self._send_request("ping")
190
+
191
+ def status(self) -> Dict[str, Any]:
192
+ """Queries engine status, memory footprint, and session size."""
193
+ return self._send_request("status")
194
+
195
+ def sanitize(
196
+ self,
197
+ text: str,
198
+ profile: Optional[str] = None,
199
+ detect_secrets: Optional[bool] = None,
200
+ license_key: Optional[str] = None,
201
+ persist_session: bool = True,
202
+ generate_receipt: bool = True,
203
+ ignore_list: Optional[List[str]] = None,
204
+ custom_rules: Optional[List[Dict[str, Any]]] = None,
205
+ throw_on_limit: bool = True
206
+ ) -> SanitizeResult:
207
+ """
208
+ Sanitizes text, replacing sensitive PII with cryptographic surrogate tokens.
209
+ Operates 100% in local process RAM with sub-millisecond latency.
210
+ """
211
+ res = self._send_request(
212
+ "sanitize",
213
+ text=text,
214
+ profile=profile or self.default_profile,
215
+ detect_secrets=self.detect_secrets if detect_secrets is None else detect_secrets,
216
+ license_key=license_key or self.license_key,
217
+ persist_session=persist_session,
218
+ generate_receipt=generate_receipt,
219
+ ignore_list=ignore_list,
220
+ custom_rules=custom_rules,
221
+ throw_on_limit=throw_on_limit
222
+ )
223
+
224
+ return SanitizeResult(
225
+ sanitized=res.get("sanitized", ""),
226
+ token_map=res.get("token_map", {}),
227
+ count=res.get("count", 0),
228
+ execution_ms=res.get("execution_ms", 0.0),
229
+ telemetry=res.get("telemetry"),
230
+ audit_receipt=res.get("audit_receipt"),
231
+ license=res.get("license"),
232
+ matches=res.get("matches")
233
+ )
234
+
235
+ def restore(
236
+ self,
237
+ text: str,
238
+ token_map: Optional[Dict[str, str]] = None
239
+ ) -> RestoreResult:
240
+ """
241
+ Replaces surrogate tokens in AI responses with their original values.
242
+ Uses in-memory engine session or the explicitly provided token_map.
243
+ """
244
+ res = self._send_request(
245
+ "restore",
246
+ text=text,
247
+ token_map=token_map
248
+ )
249
+ return RestoreResult(
250
+ restored_text=res.get("restored_text", ""),
251
+ restored_count=res.get("restored_count", 0)
252
+ )
253
+
254
+ def sanitize_object(
255
+ self,
256
+ data: Any,
257
+ profile: Optional[str] = None,
258
+ detect_secrets: Optional[bool] = None,
259
+ license_key: Optional[str] = None,
260
+ persist_session: bool = True,
261
+ throw_on_limit: bool = True
262
+ ) -> ObjectSanitizeResult:
263
+ """
264
+ Recursively sanitizes dictionary or list data structures.
265
+ Preserves original keys and non-string values intact.
266
+ """
267
+ res = self._send_request(
268
+ "sanitize_object",
269
+ data=data,
270
+ profile=profile or self.default_profile,
271
+ detect_secrets=self.detect_secrets if detect_secrets is None else detect_secrets,
272
+ license_key=license_key or self.license_key,
273
+ persist_session=persist_session,
274
+ throw_on_limit=throw_on_limit
275
+ )
276
+ return ObjectSanitizeResult(
277
+ sanitized=res.get("sanitized"),
278
+ token_map=res.get("token_map", {}),
279
+ count=res.get("count", 0),
280
+ execution_ms=res.get("execution_ms", 0.0),
281
+ telemetry=res.get("telemetry"),
282
+ audit_receipt=res.get("audit_receipt")
283
+ )
284
+
285
+ def restore_object(
286
+ self,
287
+ data: Any,
288
+ token_map: Optional[Dict[str, str]] = None
289
+ ) -> Any:
290
+ """Recursively restores tokenized dictionary or list structures."""
291
+ res = self._send_request(
292
+ "restore_object",
293
+ data=data,
294
+ token_map=token_map
295
+ )
296
+ return res.get("restored")
297
+
298
+ def reset_session(self) -> bool:
299
+ """Wipes the persistent in-memory session token map."""
300
+ res = self._send_request("reset_session")
301
+ return bool(res.get("success", False))
302
+
303
+ def mark_false_positive(self, token: str) -> Dict[str, Any]:
304
+ """
305
+ Excludes the plaintext value associated with a token from future sanitization.
306
+ """
307
+ return self._send_request("mark_false_positive", token=token)
308
+
309
+
310
+ class AsyncPrivacyScrubberClient:
311
+ """
312
+ Asynchronous PrivacyScrubber ZTDS client for FastAPI and asyncio runtimes.
313
+ """
314
+
315
+ def __init__(
316
+ self,
317
+ node_path: str = "node",
318
+ daemon_path: Optional[str] = None,
319
+ license_key: Optional[str] = None,
320
+ default_profile: str = "General",
321
+ detect_secrets: bool = False
322
+ ) -> None:
323
+ self.node_path = os.environ.get("PRIVACYSCRUBBER_NODE_PATH", node_path)
324
+ self.daemon_path = _find_daemon_path(daemon_path)
325
+ self.license_key = license_key or os.environ.get("PRIVACYSCRUBBER_KEY") or os.environ.get("PRIVACYSCRUBBER_LICENSE_KEY") or ""
326
+ self.default_profile = default_profile
327
+ self.detect_secrets = detect_secrets
328
+
329
+ self._process: Optional[asyncio.subprocess.Process] = None
330
+ self._lock = asyncio.Lock()
331
+ self._req_id = 0
332
+
333
+ async def start(self) -> None:
334
+ """Starts the persistent asynchronous sidecar process."""
335
+ async with self._lock:
336
+ if self._process and self._process.returncode is None:
337
+ return
338
+
339
+ cmd = [
340
+ self.node_path,
341
+ self.daemon_path,
342
+ "--profile", self.default_profile
343
+ ]
344
+ if self.license_key:
345
+ cmd.extend(["--license-key", self.license_key])
346
+
347
+ self._process = await asyncio.create_subprocess_exec(
348
+ *cmd,
349
+ stdin=asyncio.subprocess.PIPE,
350
+ stdout=asyncio.subprocess.PIPE,
351
+ stderr=asyncio.subprocess.PIPE
352
+ )
353
+
354
+ async def close(self) -> None:
355
+ """Gracefully closes the async sidecar process."""
356
+ async with self._lock:
357
+ if not self._process:
358
+ return
359
+ try:
360
+ if self._process.returncode is None:
361
+ if self._process.stdin:
362
+ self._process.stdin.close()
363
+ await self._process.stdin.wait_closed()
364
+ self._process.terminate()
365
+ await asyncio.wait_for(self._process.wait(), timeout=2.0)
366
+ except Exception:
367
+ try:
368
+ self._process.kill()
369
+ except Exception:
370
+ pass
371
+ finally:
372
+ self._process = None
373
+
374
+ async def __aenter__(self) -> AsyncPrivacyScrubberClient:
375
+ await self.start()
376
+ return self
377
+
378
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
379
+ await self.close()
380
+
381
+ async def _send_request(self, action: str, **kwargs: Any) -> Dict[str, Any]:
382
+ """Sends an async JSON-RPC request to the sidecar."""
383
+ async with self._lock:
384
+ if not self._process or self._process.returncode is not None:
385
+ cmd = [
386
+ self.node_path,
387
+ self.daemon_path,
388
+ "--profile", self.default_profile
389
+ ]
390
+ if self.license_key:
391
+ cmd.extend(["--license-key", self.license_key])
392
+ self._process = await asyncio.create_subprocess_exec(
393
+ *cmd,
394
+ stdin=asyncio.subprocess.PIPE,
395
+ stdout=asyncio.subprocess.PIPE,
396
+ stderr=asyncio.subprocess.PIPE
397
+ )
398
+
399
+ self._req_id += 1
400
+ req_id = self._req_id
401
+
402
+ payload = {
403
+ "id": req_id,
404
+ "action": action,
405
+ **kwargs
406
+ }
407
+
408
+ req_line = (json.dumps(payload) + "\n").encode("utf-8")
409
+ assert self._process.stdin is not None
410
+ assert self._process.stdout is not None
411
+
412
+ self._process.stdin.write(req_line)
413
+ await self._process.stdin.drain()
414
+
415
+ resp_bytes = await self._process.stdout.readline()
416
+ if not resp_bytes:
417
+ stderr_bytes = await self._process.stderr.read() if self._process.stderr else b""
418
+ raise PrivacyScrubberError(f"Async sidecar terminated: {stderr_bytes.decode('utf-8').strip()}")
419
+
420
+ res = json.loads(resp_bytes.decode("utf-8").strip())
421
+
422
+ if "error" in res and res["error"]:
423
+ err = res["error"]
424
+ msg = err.get("message", "Unknown error in PrivacyScrubber engine")
425
+ if err.get("name") == "PrivacyScrubberLicenseError":
426
+ raise PrivacyScrubberLicenseError(
427
+ msg,
428
+ tier=err.get("tier", "FREE"),
429
+ limit=err.get("limit", 15000),
430
+ profile=err.get("profile", "General")
431
+ )
432
+ raise PrivacyScrubberError(msg)
433
+
434
+ return res.get("result", {})
435
+
436
+ async def ping(self) -> Dict[str, Any]:
437
+ return await self._send_request("ping")
438
+
439
+ async def status(self) -> Dict[str, Any]:
440
+ return await self._send_request("status")
441
+
442
+ async def sanitize(
443
+ self,
444
+ text: str,
445
+ profile: Optional[str] = None,
446
+ detect_secrets: Optional[bool] = None,
447
+ license_key: Optional[str] = None,
448
+ persist_session: bool = True,
449
+ generate_receipt: bool = True,
450
+ ignore_list: Optional[List[str]] = None,
451
+ custom_rules: Optional[List[Dict[str, Any]]] = None,
452
+ throw_on_limit: bool = True
453
+ ) -> SanitizeResult:
454
+ res = await self._send_request(
455
+ "sanitize",
456
+ text=text,
457
+ profile=profile or self.default_profile,
458
+ detect_secrets=self.detect_secrets if detect_secrets is None else detect_secrets,
459
+ license_key=license_key or self.license_key,
460
+ persist_session=persist_session,
461
+ generate_receipt=generate_receipt,
462
+ ignore_list=ignore_list,
463
+ custom_rules=custom_rules,
464
+ throw_on_limit=throw_on_limit
465
+ )
466
+
467
+ return SanitizeResult(
468
+ sanitized=res.get("sanitized", ""),
469
+ token_map=res.get("token_map", {}),
470
+ count=res.get("count", 0),
471
+ execution_ms=res.get("execution_ms", 0.0),
472
+ telemetry=res.get("telemetry"),
473
+ audit_receipt=res.get("audit_receipt"),
474
+ license=res.get("license"),
475
+ matches=res.get("matches")
476
+ )
477
+
478
+ async def restore(
479
+ self,
480
+ text: str,
481
+ token_map: Optional[Dict[str, str]] = None
482
+ ) -> RestoreResult:
483
+ res = await self._send_request(
484
+ "restore",
485
+ text=text,
486
+ token_map=token_map
487
+ )
488
+ return RestoreResult(
489
+ restored_text=res.get("restored_text", ""),
490
+ restored_count=res.get("restored_count", 0)
491
+ )
492
+
493
+ async def sanitize_object(
494
+ self,
495
+ data: Any,
496
+ profile: Optional[str] = None,
497
+ detect_secrets: Optional[bool] = None,
498
+ license_key: Optional[str] = None,
499
+ persist_session: bool = True,
500
+ throw_on_limit: bool = True
501
+ ) -> ObjectSanitizeResult:
502
+ res = await self._send_request(
503
+ "sanitize_object",
504
+ data=data,
505
+ profile=profile or self.default_profile,
506
+ detect_secrets=self.detect_secrets if detect_secrets is None else detect_secrets,
507
+ license_key=license_key or self.license_key,
508
+ persist_session=persist_session,
509
+ throw_on_limit=throw_on_limit
510
+ )
511
+ return ObjectSanitizeResult(
512
+ sanitized=res.get("sanitized"),
513
+ token_map=res.get("token_map", {}),
514
+ count=res.get("count", 0),
515
+ execution_ms=res.get("execution_ms", 0.0),
516
+ telemetry=res.get("telemetry"),
517
+ audit_receipt=res.get("audit_receipt")
518
+ )
519
+
520
+ async def restore_object(
521
+ self,
522
+ data: Any,
523
+ token_map: Optional[Dict[str, str]] = None
524
+ ) -> Any:
525
+ res = await self._send_request(
526
+ "restore_object",
527
+ data=data,
528
+ token_map=token_map
529
+ )
530
+ return res.get("restored")
531
+
532
+ async def reset_session(self) -> bool:
533
+ res = await self._send_request("reset_session")
534
+ return bool(res.get("success", False))
535
+
536
+ async def mark_false_positive(self, token: str) -> Dict[str, Any]:
537
+ return await self._send_request("mark_false_positive", token=token)
538
+
539
+
540
+ # Aliases
541
+ PrivacyScrubber = PrivacyScrubberClient
542
+ AsyncPrivacyScrubber = AsyncPrivacyScrubberClient
@@ -0,0 +1,26 @@
1
+ """
2
+ PrivacyScrubber Exception Hierarchy
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+
8
+ class PrivacyScrubberError(Exception):
9
+ """Base exception for all PrivacyScrubber errors."""
10
+ pass
11
+
12
+
13
+ class PrivacyScrubberLicenseError(PrivacyScrubberError):
14
+ """Raised when Free tier quotas or license gates are exceeded."""
15
+ def __init__(
16
+ self,
17
+ message: str,
18
+ tier: str = "FREE",
19
+ limit: int = 15000,
20
+ profile: str = "General"
21
+ ) -> None:
22
+ super().__init__(message)
23
+ self.tier = tier
24
+ self.limit = limit
25
+ self.profile = profile
26
+ self.upgrade_url = "https://privacyscrubber.com/pricing?tier=sdk&src=python_sdk"