@privacyscrubber/sdk 2.3.9 → 2.4.1

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