@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.
@@ -1,260 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * PrivacyScrubber ZTDS Python Bridge Daemon
4
- *
5
- * Provides a low-latency, zero-network-egress stdio NDJSON RPC daemon
6
- * for Python, FastAPI, LangChain, and LlamaIndex environments.
7
- *
8
- * Communication protocol:
9
- * - Request (stdin): Single-line JSON {"id": number|string, "action": string, ...}\n
10
- * - Response (stdout): Single-line JSON {"id": number|string, "result": {...}}\n
11
- * or {"id": number|string, "error": {"message": string, ...}}\n
12
- *
13
- * Zero external network egress. 100% in-memory RAM execution.
14
- */
15
-
16
- 'use strict';
17
-
18
- const readline = require('readline');
19
- const path = require('path');
20
-
21
- // Resolve @privacyscrubber/sdk across local monorepo, parent dirs, cwd, and node_modules
22
- let sdk;
23
- const candidatePaths = [
24
- process.env.PRIVACYSCRUBBER_SDK_PATH,
25
- path.resolve(process.cwd(), 'npm-sdk/index.cjs'),
26
- path.resolve(process.cwd(), 'node_modules/@privacyscrubber/sdk'),
27
- path.resolve(__dirname, '../../../index.cjs'),
28
- path.resolve(__dirname, '../../index.cjs'),
29
- path.resolve(__dirname, '../index.cjs'),
30
- path.resolve(__dirname, './index.cjs'),
31
- '@privacyscrubber/sdk'
32
- ];
33
-
34
- for (const p of candidatePaths) {
35
- if (!p) continue;
36
- try {
37
- sdk = require(p);
38
- break;
39
- } catch (e) {
40
- // try next candidate
41
- }
42
- }
43
-
44
- if (!sdk) {
45
- try {
46
- const resolved = require.resolve('@privacyscrubber/sdk', { paths: [process.cwd(), __dirname] });
47
- sdk = require(resolved);
48
- } catch (e2) {}
49
- }
50
-
51
- if (!sdk) {
52
- process.stderr.write('Fatal: Could not load @privacyscrubber/sdk or index.cjs\n');
53
- process.exit(1);
54
- }
55
-
56
- // Parse initial CLI options
57
- const args = process.argv.slice(2);
58
- let initialProfile = process.env.PRIVACYSCRUBBER_PROFILE || 'General';
59
- let licenseKey = process.env.PRIVACYSCRUBBER_KEY || process.env.PRIVACYSCRUBBER_LICENSE_KEY || '';
60
-
61
- for (let i = 0; i < args.length; i++) {
62
- if (args[i] === '--profile' && args[i + 1]) {
63
- initialProfile = args[++i];
64
- } else if (args[i] === '--license-key' && args[i + 1]) {
65
- licenseKey = args[++i];
66
- }
67
- }
68
-
69
- // Instantiate long-lived engine in local RAM
70
- const engine = new sdk.PrivacyScrubberEngine({
71
- licenseKey,
72
- defaultProfile: initialProfile,
73
- generateReceipt: true
74
- });
75
-
76
- function sendResponse(id, result, error = null) {
77
- const payload = { id };
78
- if (error) {
79
- payload.error = {
80
- message: error.message || String(error),
81
- name: error.name || 'Error',
82
- tier: error.tier,
83
- limit: error.limit,
84
- profile: error.profile
85
- };
86
- } else {
87
- payload.result = result;
88
- }
89
- process.stdout.write(JSON.stringify(payload) + '\n');
90
- }
91
-
92
- // Setup readline interface on stdin
93
- const rl = readline.createInterface({
94
- input: process.stdin,
95
- terminal: false
96
- });
97
-
98
- rl.on('line', (line) => {
99
- const trimmed = line.trim();
100
- if (!trimmed) return;
101
-
102
- let req;
103
- try {
104
- req = JSON.parse(trimmed);
105
- } catch (parseErr) {
106
- sendResponse(null, null, new Error(`Invalid JSON request: ${parseErr.message}`));
107
- return;
108
- }
109
-
110
- const { id, action } = req;
111
-
112
- try {
113
- switch (action) {
114
- case 'ping': {
115
- sendResponse(id, {
116
- pong: true,
117
- engine: 'PrivacyScrubber ZTDS',
118
- version: '2.4.4',
119
- uptime: process.uptime()
120
- });
121
- break;
122
- }
123
-
124
- case 'sanitize': {
125
- const text = req.text || '';
126
- const options = {
127
- profile: req.profile || engine.defaultProfile,
128
- detectSecrets: req.detect_secrets ?? req.detectSecrets ?? false,
129
- licenseKey: req.license_key || req.licenseKey || engine.licenseKey,
130
- persistSession: req.persist_session ?? req.persistSession ?? true,
131
- generateReceipt: req.generate_receipt ?? req.generateReceipt ?? true,
132
- customRules: req.custom_rules || req.customRules || [],
133
- ignoreList: req.ignore_list || req.ignoreList || null,
134
- throwOnLimit: req.throw_on_limit ?? req.throwOnLimit ?? true
135
- };
136
-
137
- const res = engine.sanitize(text, options);
138
- sendResponse(id, {
139
- sanitized: res.scrubbedText,
140
- scrubbed_text: res.scrubbedText,
141
- token_map: res.tokenMap,
142
- matches: res.matches,
143
- count: res.count,
144
- execution_ms: res.executionMs,
145
- telemetry: res.telemetry,
146
- audit_receipt: res.auditReceipt,
147
- license: res.license
148
- });
149
- break;
150
- }
151
-
152
- case 'restore': {
153
- const text = req.text || '';
154
- const tokenMap = req.token_map || req.tokenMap || null;
155
- const res = engine.restore(text, tokenMap);
156
- sendResponse(id, {
157
- restored_text: res.restoredText,
158
- restored_count: res.restoredCount
159
- });
160
- break;
161
- }
162
-
163
- case 'sanitize_object': {
164
- const data = req.data ?? {};
165
- const persistSession = req.persist_session ?? req.persistSession ?? true;
166
- let mergedIgnore = null;
167
- const reqIgnore = req.ignore_list || req.ignoreList || null;
168
- if (reqIgnore || engine.ignoreList.size > 0) {
169
- mergedIgnore = new Set(engine.ignoreList);
170
- if (reqIgnore) {
171
- for (const item of reqIgnore) mergedIgnore.add(item);
172
- }
173
- }
174
- const options = {
175
- profile: req.profile || engine.defaultProfile,
176
- detectSecrets: req.detect_secrets ?? req.detectSecrets ?? false,
177
- licenseKey: req.license_key || req.licenseKey || engine.licenseKey,
178
- generateReceipt: req.generate_receipt ?? req.generateReceipt ?? true,
179
- customRules: req.custom_rules || req.customRules || [],
180
- ignoreList: mergedIgnore,
181
- existingSessionMap: persistSession ? engine.sessionMap : (req.existing_session_map || req.existingSessionMap || {}),
182
- throwOnLimit: req.throw_on_limit ?? req.throwOnLimit ?? true
183
- };
184
-
185
- const res = sdk.sanitizeObject(data, options);
186
- if (persistSession && res.tokenMap) {
187
- Object.assign(engine.sessionMap, res.tokenMap);
188
- const keys = Object.keys(engine.sessionMap);
189
- if (keys.length > engine.maxSessionTokens) {
190
- const excess = keys.length - engine.maxSessionTokens;
191
- for (let i = 0; i < excess; i++) {
192
- delete engine.sessionMap[keys[i]];
193
- }
194
- }
195
- }
196
- sendResponse(id, {
197
- sanitized: res.sanitized,
198
- token_map: res.tokenMap,
199
- count: res.count,
200
- execution_ms: res.executionMs,
201
- telemetry: res.telemetry,
202
- audit_receipt: res.auditReceipt
203
- });
204
- break;
205
- }
206
-
207
- case 'restore_object': {
208
- const data = req.data ?? {};
209
- const tokenMap = req.token_map || req.tokenMap || engine.sessionMap;
210
- const restored = sdk.restoreObject(data, tokenMap);
211
- sendResponse(id, {
212
- restored
213
- });
214
- break;
215
- }
216
-
217
- case 'reset_session': {
218
- engine.resetSession();
219
- sendResponse(id, { success: true });
220
- break;
221
- }
222
-
223
- case 'mark_false_positive': {
224
- const token = req.token;
225
- if (!token) {
226
- throw new Error('Missing token argument for mark_false_positive');
227
- }
228
- const fpRes = engine.markFalsePositive(token);
229
- sendResponse(id, fpRes);
230
- break;
231
- }
232
-
233
- case 'status': {
234
- const tier = engine.license?.tier || 'FREE';
235
- sendResponse(id, {
236
- profile: engine.defaultProfile,
237
- license_tier: tier,
238
- is_pro: Boolean(engine.license?.valid && ['SDK', 'OEM', 'ENTERPRISE', 'PRO', 'TEAMS'].includes(tier)),
239
- session_tokens_count: Object.keys(engine.sessionMap).length,
240
- ignore_list_size: engine.ignoreList.size,
241
- memory_usage: process.memoryUsage()
242
- });
243
- break;
244
- }
245
-
246
- default:
247
- sendResponse(id, null, new Error(`Unknown action: ${action}`));
248
- break;
249
- }
250
- } catch (err) {
251
- sendResponse(id, null, err);
252
- }
253
- });
254
-
255
- rl.on('close', () => {
256
- process.exit(0);
257
- });
258
-
259
- process.on('SIGINT', () => process.exit(0));
260
- process.on('SIGTERM', () => process.exit(0));
@@ -1,20 +0,0 @@
1
- """
2
- PrivacyScrubber ZTDS Adapter for LangChain
3
- Zero-Trust Data Sanitization & Local PII Shield for LangChain Pipelines.
4
- """
5
-
6
- from __future__ import annotations
7
-
8
- from privacyscrubber.langchain import (
9
- ZTDSCallbackHandler,
10
- ZTDSDocumentTransformer,
11
- create_ztds_lcel_runnables,
12
- LANGCHAIN_INSTALLED,
13
- )
14
-
15
- __all__ = [
16
- "ZTDSCallbackHandler",
17
- "ZTDSDocumentTransformer",
18
- "create_ztds_lcel_runnables",
19
- "LANGCHAIN_INSTALLED",
20
- ]
@@ -1,36 +0,0 @@
1
- """
2
- PrivacyScrubber ZTDS Client for Python
3
- Zero-Trust Data Sanitization & Local PII Shield for LLM / AI Pipelines.
4
-
5
- 100% in-memory client-side execution. Zero cloud egress. Sub-millisecond latency.
6
- RFC v1.0 standard-compliant Zero-Trust Data Sanitization engine.
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- from privacyscrubber import (
12
- PrivacyScrubberClient,
13
- PrivacyScrubber,
14
- AsyncPrivacyScrubberClient,
15
- AsyncPrivacyScrubber,
16
- SanitizeResult,
17
- RestoreResult,
18
- ObjectSanitizeResult,
19
- PrivacyScrubberError,
20
- PrivacyScrubberLicenseError,
21
- __version__,
22
- )
23
- from privacyscrubber.client import _find_daemon_path
24
-
25
- __all__ = [
26
- "PrivacyScrubberClient",
27
- "PrivacyScrubber",
28
- "AsyncPrivacyScrubberClient",
29
- "AsyncPrivacyScrubber",
30
- "SanitizeResult",
31
- "RestoreResult",
32
- "ObjectSanitizeResult",
33
- "PrivacyScrubberError",
34
- "PrivacyScrubberLicenseError",
35
- "__version__",
36
- ]
@@ -1,320 +0,0 @@
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.4", ver_out)
316
-
317
-
318
- if __name__ == "__main__":
319
- unittest.main()
320
-