shroud-privacy 2.0.0 → 2.0.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.
package/README.md CHANGED
@@ -32,33 +32,18 @@ openclaw plugins install shroud-privacy
32
32
 
33
33
  That's it. Configure in `~/.openclaw/openclaw.json` under `plugins.entries."shroud-privacy".config`.
34
34
 
35
- ### NCG Agent
36
-
37
- ```bash
38
- python agent.py plugin install shroud-privacy
39
- ```
40
-
41
- Configure in `~/.ncg/ncg.json` under `plugins.entries."shroud-privacy".config`.
42
-
43
35
  ### From source (development)
44
36
 
45
37
  ```bash
46
38
  git clone https://github.com/walterkeating-stack/shroud.git
47
39
  cd shroud
48
40
  npm install && npm run build
49
-
50
41
  bash deploy-local.sh # → OpenClaw (~/.openclaw/extensions/)
51
- bash deploy-ncg.sh # → NCG (~/.ncg/extensions/)
52
42
  ```
53
43
 
54
44
  ## Configure
55
45
 
56
- Both OpenClaw and NCG store Shroud config in the same structure — only the file path differs:
57
-
58
- | Platform | Config file | Config path |
59
- |----------|-------------|-------------|
60
- | OpenClaw | `~/.openclaw/openclaw.json` | `plugins.entries."shroud-privacy".config` |
61
- | NCG | `~/.ncg/ncg.json` | `plugins.entries."shroud-privacy".config` |
46
+ Edit `~/.openclaw/openclaw.json` under `plugins.entries."shroud-privacy".config`:
62
47
 
63
48
  ```jsonc
64
49
  "shroud-privacy": {
@@ -80,8 +65,7 @@ Both OpenClaw and NCG store Shroud config in the same structure — only the fil
80
65
  Restart the gateway after config changes:
81
66
 
82
67
  ```bash
83
- openclaw gateway restart # OpenClaw
84
- sudo systemctl restart ncg-gateway.service # NCG
68
+ openclaw gateway restart
85
69
  ```
86
70
 
87
71
  ### Safe defaults
@@ -196,7 +180,7 @@ Rules not listed keep their defaults. Overrides apply to both direct regex detec
196
180
 
197
181
  Shroud tracks per-rule match counts for the lifetime of the process. Counters appear in three places:
198
182
 
199
- - **`shroud-stats` CLI** — run `node scripts/shroud-stats.mjs` to see all rules with status, confidence, and hit counts. Shows live cumulative stats from the running gateway (NCG or OpenClaw) via `/tmp/shroud-stats.json`. Use `--test "text with PII"` to test detection against sample input.
183
+ - **`shroud-stats` CLI** — run `node scripts/shroud-stats.mjs` to see all rules with status, confidence, and hit counts. Shows live cumulative stats from the running OpenClaw gateway via `/tmp/shroud-stats.json`. Use `--test "text with PII"` to test detection against sample input.
200
184
  - **Audit log lines** — `byRule=regex:email:3,regex:ipv4:2,...` alongside the existing `byCat` field.
201
185
  - **`getStats()`** — the `ruleHits` object in the stats response, useful for programmatic access.
202
186
 
@@ -312,10 +296,7 @@ npm run lint # type-check without emitting
312
296
  ```bash
313
297
  npm run build
314
298
  bash deploy-local.sh # → OpenClaw (~/.openclaw/extensions/shroud-privacy/)
315
- bash deploy-ncg.sh # → NCG (~/.ncg/extensions/shroud-privacy/)
316
-
317
- openclaw gateway restart # restart OpenClaw
318
- sudo systemctl restart ncg-gateway.service # restart NCG
299
+ openclaw gateway restart
319
300
  ```
320
301
 
321
302
  ## Release workflow
@@ -61,11 +61,6 @@
61
61
  "canaryEnabled": { "label": "Enable Canary Tokens", "help": "Inject tracking tokens to detect PII leakage" },
62
62
  "auditEnabled": { "label": "Enable Audit Log", "help": "Tamper-evident obfuscation event tracking" }
63
63
  },
64
- "ncg": {
65
- "adapter": "ncg_adapter.py",
66
- "adapterClass": "ShroudPlugin",
67
- "bridge": "shroud_bridge.mjs"
68
- },
69
64
  "compatibility": {
70
65
  "minOpenClawVersion": "2026.3.0"
71
66
  }
package/package.json CHANGED
@@ -1,15 +1,13 @@
1
1
  {
2
2
  "name": "shroud-privacy",
3
- "version": "2.0.0",
4
- "description": "Privacy obfuscation plugin for OpenClaw and NCG deterministic fake values with deobfuscation",
3
+ "version": "2.0.1",
4
+ "description": "Privacy obfuscation plugin for OpenClaw — detects sensitive data and replaces with deterministic fake values",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
8
8
  "files": [
9
9
  "dist/",
10
10
  "openclaw.plugin.json",
11
- "ncg_adapter.py",
12
- "shroud_bridge.mjs",
13
11
  "LICENSE",
14
12
  "NOTICE"
15
13
  ],
@@ -23,7 +21,6 @@
23
21
  "keywords": [
24
22
  "openclaw",
25
23
  "openclaw-plugin",
26
- "ncg",
27
24
  "privacy",
28
25
  "pii",
29
26
  "obfuscation",
package/ncg_adapter.py DELETED
@@ -1,530 +0,0 @@
1
- """Shroud plugin adapter for NCG Agent.
2
-
3
- Bridges NCG's Python agent to the Shroud TypeScript obfuscation engine
4
- (running as a Node.js subprocess) via JSON-RPC over stdin/stdout.
5
-
6
- This mirrors how OpenClaw integrates with Shroud:
7
- - Obfuscate text before sending to the LLM
8
- - Deobfuscate text received from the LLM
9
- - Deobfuscate tool parameters before execution
10
- - Obfuscate tool results before persisting to history
11
-
12
- Usage (via PluginManager):
13
- plugin = ShroudPlugin.from_config(plugin_dir, config)
14
- plugin.start()
15
- safe = plugin.sanitize(real_text) # before LLM
16
- real = plugin.desanitize(safe_text) # after LLM
17
- plugin.stop()
18
-
19
- Legacy usage (direct, for testing):
20
- plugin = ShroudPlugin.from_config() # loads config/shroud.yaml
21
- plugin.start()
22
-
23
- The plugin can be activated/deactivated at runtime, and its config
24
- can be hot-reloaded without restarting the agent.
25
- """
26
-
27
- import json
28
- import logging
29
- import os
30
- import re
31
- import subprocess
32
- import threading
33
- import time
34
- from pathlib import Path
35
-
36
- from plugins.base import NCGPlugin
37
-
38
- log = logging.getLogger("ncg.shroud")
39
-
40
- # Legacy defaults — used only for backward-compat direct instantiation
41
- _DEFAULT_SHROUD_PATH = str(Path(__file__).resolve().parent / "dist")
42
- _LEGACY_BRIDGE_SCRIPT = Path(__file__).parent / "shroud_bridge.mjs"
43
- _LEGACY_CONFIG_FILE = Path(__file__).resolve().parent.parent / "config" / "shroud.yaml"
44
-
45
- # CGNAT range used by Shroud for fake IPv4 addresses
46
- _CGNAT_RE = re.compile(r'\b100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.\d{1,3}\.\d{1,3}\b')
47
- # ULA range used by Shroud for fake IPv6 addresses (fd00::/8)
48
- _ULA_RE = re.compile(r'\bfd00:[0-9a-fA-F:]{2,39}\b')
49
-
50
-
51
- def _fmt_audit_obfuscate(audit):
52
- """Format obfuscation audit data as a single human-readable line.
53
-
54
- Proves: text was modified, what categories were found, char delta,
55
- truncated proof hashes (no raw values), fake samples, chain hash.
56
- """
57
- cats = ",".join(f"{k}:{v}" for k, v in audit.get("byCategory", {}).items()) or "none"
58
- fakes = "|".join(audit.get("fakesSample", []))
59
- parts = [
60
- f"[shroud][audit] OBFUSCATE req={audit.get('req', '?')}",
61
- f"entities={audit.get('totalEntities', 0)}",
62
- f"chars={audit.get('inputChars', 0)}->{audit.get('outputChars', 0)} (delta={audit.get('charDelta', 0):+d})",
63
- f"modified={'YES' if audit.get('modified') else 'NO'}",
64
- f"byCat={cats}",
65
- f"proof_in={audit.get('proofIn', '?')} proof_out={audit.get('proofOut', '?')}",
66
- f"chain={audit.get('chainHash', '?')}",
67
- ]
68
- if fakes:
69
- parts.append(f"fakes=[{fakes}]")
70
- return " | ".join(parts)
71
-
72
-
73
- def _fmt_audit_deobfuscate(audit, req_id=None):
74
- """Format deobfuscation audit data as a single human-readable line."""
75
- parts = [
76
- f"[shroud][audit] DEOBFUSCATE",
77
- ]
78
- if req_id:
79
- parts[0] += f" req={req_id}"
80
- parts += [
81
- f"replacements={audit.get('replacementCount', 0)}",
82
- f"chars={audit.get('inputChars', 0)}->{audit.get('outputChars', 0)}",
83
- f"modified={'YES' if audit.get('modified') else 'NO'}",
84
- f"proof_in={audit.get('proofIn', '?')} proof_out={audit.get('proofOut', '?')}",
85
- f"chain={audit.get('chainHash', '?')}",
86
- ]
87
- return " | ".join(parts)
88
-
89
-
90
- class ShroudPlugin(NCGPlugin):
91
- """Python adapter for the Shroud obfuscation engine.
92
-
93
- Manages a long-lived Node.js child process that runs the shroud
94
- bridge script. Communication is newline-delimited JSON over
95
- stdin/stdout (same pattern OpenClaw uses for plugin IPC).
96
- """
97
-
98
- def __init__(self, *, enabled=True, shroud_path=None, bridge_script=None,
99
- config=None, plugin_dir=None):
100
- self.enabled = enabled
101
- self.plugin_dir = plugin_dir
102
- self.shroud_path = shroud_path or _DEFAULT_SHROUD_PATH
103
- self.bridge_script = bridge_script or _LEGACY_BRIDGE_SCRIPT
104
- self.config = config or {}
105
- self._proc = None
106
- self._lock = threading.Lock()
107
- self._req_id = 0
108
- self._started = False
109
- self._version = None
110
- self._audit_logger = None # set by agent for file-based audit log
111
- self._last_obf_req = None # track request ID for deobfuscation correlation
112
-
113
- # ── Factory ─────────────────────────────────────────────────────
114
-
115
- @classmethod
116
- def from_config(cls, plugin_dir: Path = None, config: dict = None,
117
- enabled: bool = True):
118
- """Create a ShroudPlugin from a plugin directory and config dict.
119
-
120
- When called by PluginManager:
121
- ShroudPlugin.from_config(plugin_dir=..., config={...}, enabled=True)
122
-
123
- Legacy usage (direct, loads config/shroud.yaml):
124
- ShroudPlugin.from_config()
125
- """
126
- if plugin_dir is not None:
127
- # PluginManager path: derive paths from plugin_dir
128
- shroud_path = str(plugin_dir / "dist")
129
- bridge_script = plugin_dir / "shroud_bridge.mjs"
130
- return cls(enabled=enabled, shroud_path=shroud_path,
131
- bridge_script=bridge_script, config=config or {},
132
- plugin_dir=plugin_dir)
133
-
134
- # Legacy path: read from config/shroud.yaml
135
- path = _LEGACY_CONFIG_FILE
136
- file_config = {}
137
- shroud_path = _DEFAULT_SHROUD_PATH
138
-
139
- if path.exists():
140
- try:
141
- import yaml
142
- raw = yaml.safe_load(path.read_text()) or {}
143
- shroud_path = raw.get("shroud_path", shroud_path)
144
- if shroud_path.startswith("$"):
145
- shroud_path = os.path.expandvars(shroud_path)
146
- file_config = raw.get("plugin_config", {})
147
- if raw.get("enabled") is False:
148
- enabled = False
149
- log.info("[shroud] Config loaded from %s", path)
150
- except Exception as e:
151
- log.warning("[shroud] Failed to load config %s: %s", path, e)
152
- else:
153
- log.info("[shroud] No config at %s — using defaults", path)
154
-
155
- return cls(enabled=enabled, shroud_path=shroud_path, config=file_config)
156
-
157
- def set_audit_logger(self, audit_logger):
158
- """Attach the agent's audit file logger so shroud events appear in session logs."""
159
- self._audit_logger = audit_logger
160
-
161
- # ── Lifecycle ───────────────────────────────────────────────────
162
-
163
- def start(self):
164
- """Spawn the Node.js bridge subprocess."""
165
- if not self.enabled:
166
- log.info("[shroud] Plugin disabled — skipping start")
167
- return False
168
-
169
- if self._started:
170
- log.warning("[shroud] Already started")
171
- return True
172
-
173
- # Verify shroud dist exists
174
- dist = Path(self.shroud_path)
175
- if not dist.exists() or not (dist / "obfuscator.js").exists():
176
- log.error("[shroud] Shroud dist not found at %s", self.shroud_path)
177
- self.enabled = False
178
- return False
179
-
180
- # Verify bridge script exists
181
- if not self.bridge_script.exists():
182
- log.error("[shroud] Bridge script not found at %s", self.bridge_script)
183
- self.enabled = False
184
- return False
185
-
186
- env = os.environ.copy()
187
- if self.config:
188
- env["SHROUD_PLUGIN_CONFIG"] = json.dumps(self.config)
189
-
190
- try:
191
- self._proc = subprocess.Popen(
192
- ["node", str(self.bridge_script), self.shroud_path],
193
- stdin=subprocess.PIPE,
194
- stdout=subprocess.PIPE,
195
- stderr=subprocess.PIPE,
196
- env=env,
197
- text=True,
198
- bufsize=1, # line-buffered
199
- )
200
-
201
- # Start stderr reader thread for bridge logs
202
- self._stderr_thread = threading.Thread(
203
- target=self._read_stderr, daemon=True, name="shroud-stderr"
204
- )
205
- self._stderr_thread.start()
206
-
207
- # Wait for ready signal
208
- ready_line = self._proc.stdout.readline()
209
- if not ready_line:
210
- raise RuntimeError("Bridge process died immediately")
211
-
212
- ready = json.loads(ready_line)
213
- if not ready.get("ready"):
214
- raise RuntimeError(f"Unexpected ready signal: {ready}")
215
-
216
- self._version = ready.get("version", "?")
217
- self._started = True
218
- log.info("[shroud] Plugin started (v%s, pid=%d)",
219
- self._version, self._proc.pid)
220
- return True
221
-
222
- except Exception as e:
223
- log.error("[shroud] Failed to start: %s", e)
224
- self._cleanup()
225
- self.enabled = False
226
- return False
227
-
228
- def stop(self):
229
- """Shut down the bridge subprocess."""
230
- if self._proc:
231
- log.info("[shroud] Stopping plugin (pid=%d)", self._proc.pid)
232
- self._cleanup()
233
- self._started = False
234
- log.info("[shroud] Plugin stopped")
235
-
236
- def _cleanup(self):
237
- if self._proc:
238
- try:
239
- self._proc.stdin.close()
240
- except Exception:
241
- pass
242
- try:
243
- self._proc.terminate()
244
- self._proc.wait(timeout=5)
245
- except Exception:
246
- try:
247
- self._proc.kill()
248
- except Exception:
249
- pass
250
- self._proc = None
251
-
252
- def _restart(self):
253
- """Attempt to restart the bridge subprocess after a crash.
254
-
255
- NOTE: mappings from the previous bridge process are lost.
256
- This is a best-effort recovery — new obfuscations will create fresh mappings.
257
- """
258
- self._cleanup()
259
- self._started = False
260
- try:
261
- return self.start()
262
- except Exception as e:
263
- log.error("[shroud] Restart failed: %s", e)
264
- return False
265
-
266
- def _read_stderr(self):
267
- """Read bridge stderr and forward to Python logging."""
268
- try:
269
- for line in self._proc.stderr:
270
- line = line.rstrip()
271
- if line:
272
- log.debug("[shroud-bridge] %s", line)
273
- except Exception:
274
- pass
275
-
276
- # ── JSON-RPC communication ──────────────────────────────────────
277
-
278
- def _call(self, method, params=None):
279
- """Send a JSON-RPC request and return the result."""
280
- if not self._started or not self._proc or self._proc.poll() is not None:
281
- if self._started:
282
- log.error("[shroud] Bridge process died (exit=%s) — attempting restart",
283
- self._proc.returncode if self._proc else "?")
284
- self._started = False
285
- # Auto-restart instead of disabling
286
- if self._restart():
287
- log.info("[shroud] Bridge restarted successfully")
288
- else:
289
- log.error("[shroud] Bridge restart failed — disabling")
290
- self.enabled = False
291
- return None
292
- else:
293
- return None
294
-
295
- with self._lock:
296
- self._req_id += 1
297
- req_id = self._req_id
298
- req = {"id": req_id, "method": method}
299
- if params:
300
- req["params"] = params
301
-
302
- try:
303
- start_t = time.monotonic()
304
- self._proc.stdin.write(json.dumps(req) + "\n")
305
- self._proc.stdin.flush()
306
-
307
- resp_line = self._proc.stdout.readline()
308
- elapsed_ms = (time.monotonic() - start_t) * 1000
309
-
310
- if not resp_line:
311
- log.error("[shroud] Bridge EOF on method=%s", method)
312
- self._started = False
313
- self.enabled = False
314
- return None
315
-
316
- resp = json.loads(resp_line)
317
-
318
- if resp.get("error"):
319
- log.warning("[shroud] %s error: %s", method, resp["error"])
320
- return None
321
-
322
- if elapsed_ms > 100:
323
- log.debug("[shroud] %s took %.1fms", method, elapsed_ms)
324
-
325
- return resp.get("result")
326
-
327
- except (BrokenPipeError, OSError) as e:
328
- log.error("[shroud] Bridge pipe error on %s: %s — attempting restart", method, e)
329
- self._started = False
330
- if self._restart():
331
- log.warning("[shroud] Bridge restarted after pipe error (mappings lost)")
332
- else:
333
- self.enabled = False
334
- return None
335
- except json.JSONDecodeError as e:
336
- log.error("[shroud] Bad JSON from bridge on %s: %s", method, e)
337
- return None
338
-
339
- # ── Audit logging ───────────────────────────────────────────────
340
-
341
- def _log_audit(self, message):
342
- """Write audit line to both console logger and agent audit file."""
343
- log.info("%s", message)
344
- if self._audit_logger:
345
- try:
346
- self._audit_logger.info("%s", message)
347
- except Exception:
348
- pass # best-effort
349
-
350
- # ── Public API (matches NCG Sanitizer interface) ────────────────
351
-
352
- def sanitize(self, text):
353
- """Obfuscate real values → fake values (before sending to LLM).
354
-
355
- Drop-in replacement for Sanitizer.sanitize().
356
- """
357
- if not self.enabled or not text:
358
- return text
359
-
360
- result = self._call("obfuscate", {"text": text})
361
- if result is None:
362
- return text # fallback: pass-through on error
363
-
364
- entity_count = result.get("entityCount", 0)
365
- if entity_count > 0:
366
- cats = result.get("categories", {})
367
- log.debug("[shroud] obfuscate: %d entities %s", entity_count, cats)
368
-
369
- # Audit logging
370
- audit = result.get("audit")
371
- if audit:
372
- self._last_obf_req = audit.get("req")
373
- self._log_audit(_fmt_audit_obfuscate(audit))
374
-
375
- return result.get("obfuscated", text)
376
-
377
- def desanitize(self, text):
378
- """Deobfuscate fake values → real values (after receiving from LLM).
379
-
380
- Drop-in replacement for Sanitizer.desanitize().
381
- """
382
- if not self.enabled or not text:
383
- return text
384
-
385
- result = self._call("deobfuscate", {"text": text})
386
- if result is None:
387
- log.warning("[shroud] desanitize: bridge returned None — fakes may leak through! "
388
- "text_len=%d", len(text))
389
- return text # fallback: pass-through on error
390
-
391
- deobfuscated = result.get("text", text)
392
- replacement_count = result.get("replacementCount", 0)
393
- store_size = result.get("storeSize", -1)
394
-
395
- # Audit logging
396
- audit = result.get("audit")
397
- if audit:
398
- self._log_audit(_fmt_audit_deobfuscate(audit, self._last_obf_req))
399
-
400
- # Residual fake detection: scan for CGNAT IPv4 and ULA IPv6 that survived deobfuscation
401
- residual_v4 = _CGNAT_RE.findall(deobfuscated)
402
- residual_v6 = _ULA_RE.findall(deobfuscated)
403
- residual = residual_v4 + residual_v6
404
- if residual:
405
- unique_residual = set(residual)
406
- label = "CGNAT/ULA" if residual_v6 else "CGNAT"
407
- log.warning(
408
- "[shroud] RESIDUAL FAKES DETECTED after deobfuscation: %d occurrences "
409
- "(%d unique) of %s IPs in output | store_size=%d | replacements=%d | "
410
- "residual_ips=%s",
411
- len(residual), len(unique_residual), label, store_size, replacement_count,
412
- ",".join(sorted(unique_residual)[:10]),
413
- )
414
- if self._audit_logger:
415
- self._audit_logger.warning(
416
- "[shroud][LEAK] Residual %s IPs: %s (store=%d, replacements=%d)",
417
- label, ",".join(sorted(unique_residual)[:10]), store_size, replacement_count,
418
- )
419
-
420
- if store_size == 0 and replacement_count == 0 and len(text) > 100:
421
- log.warning("[shroud] desanitize: store is EMPTY — no mappings available for "
422
- "deobfuscation (bridge may have restarted)")
423
-
424
- return deobfuscated
425
-
426
- def reset(self):
427
- """Clear all mappings and start a fresh session."""
428
- result = self._call("reset")
429
- if result and result.get("ok"):
430
- log.info("[shroud] Session reset — all mappings cleared")
431
- self._last_obf_req = None
432
- return result
433
-
434
- def get_stats(self):
435
- """Return obfuscation statistics."""
436
- result = self._call("getStats")
437
- if result is None:
438
- return {"enabled": self.enabled, "error": "bridge not running"}
439
- result["enabled"] = self.enabled
440
- result["bridge_pid"] = self._proc.pid if self._proc else None
441
- result["version"] = self._version
442
- return result
443
-
444
- # ── Runtime control ─────────────────────────────────────────────
445
-
446
- def activate(self):
447
- """Enable the plugin at runtime."""
448
- if self.enabled and self._started:
449
- log.info("[shroud] Already active")
450
- return True
451
- self.enabled = True
452
- ok = self.start()
453
- if ok:
454
- log.info("[shroud] Activated")
455
- return ok
456
-
457
- def deactivate(self):
458
- """Disable the plugin at runtime (pass-through mode)."""
459
- self.enabled = False
460
- self.stop()
461
- log.info("[shroud] Deactivated — obfuscation disabled")
462
-
463
- def update_config(self, new_config):
464
- """Hot-reload shroud config without restarting the agent."""
465
- result = self._call("reconfigure", {"config": new_config})
466
- if result and result.get("ok"):
467
- self.config = new_config
468
- log.info("[shroud] Config updated and reloaded")
469
- return True
470
- log.warning("[shroud] Config update failed")
471
- return False
472
-
473
- def reload_from_file(self):
474
- """Re-read config file and hot-reload."""
475
- config_file = _LEGACY_CONFIG_FILE
476
- if config_file.exists():
477
- try:
478
- import yaml
479
- raw = yaml.safe_load(config_file.read_text()) or {}
480
- new_config = raw.get("plugin_config", {})
481
- return self.update_config(new_config)
482
- except Exception as e:
483
- log.error("[shroud] Failed to reload config: %s", e)
484
- return False
485
- log.warning("[shroud] Config file not found: %s", config_file)
486
- return False
487
-
488
- # ── Tool registration (NCGPlugin interface) ──────────────────────
489
-
490
- def get_tool_definitions(self) -> list[dict]:
491
- """Return shroud tool definitions for the agent."""
492
- return [
493
- {"name": "shroud_status",
494
- "description": "Show Shroud privacy plugin stats: entity counts, session info, audit status, version.",
495
- "input_schema": {"type": "object", "properties": {}}},
496
- {"name": "shroud_reset",
497
- "description": "Clear all Shroud obfuscation mappings and start a fresh privacy session.",
498
- "input_schema": {"type": "object", "properties": {}}},
499
- {"name": "shroud_activate",
500
- "description": "Enable the Shroud privacy plugin (starts obfuscation of data sent to LLM).",
501
- "input_schema": {"type": "object", "properties": {}}},
502
- {"name": "shroud_deactivate",
503
- "description": "Disable the Shroud privacy plugin (data sent to LLM without obfuscation).",
504
- "input_schema": {"type": "object", "properties": {}}},
505
- ]
506
-
507
- def get_tool_handlers(self) -> dict:
508
- """Return {tool_name: handler_fn} for shroud tools."""
509
- return {
510
- "shroud_status": lambda _: self.get_stats(),
511
- "shroud_reset": lambda _: (
512
- {"ok": True, "message": "Shroud session reset. All mappings cleared."}
513
- if self.reset() else {"error": "Reset failed"}),
514
- "shroud_activate": lambda _: (
515
- {"ok": True, "message": "Shroud activated"} if self.activate()
516
- else {"ok": False, "message": "Activation failed"}),
517
- "shroud_deactivate": lambda _: (
518
- self.deactivate() or
519
- {"ok": True, "message": "Shroud deactivated — obfuscation disabled"}),
520
- }
521
-
522
- @property
523
- def is_running(self):
524
- """Check if the bridge subprocess is alive."""
525
- return (self._started and self._proc is not None
526
- and self._proc.poll() is None)
527
-
528
- def __repr__(self):
529
- state = "active" if self.is_running else ("disabled" if not self.enabled else "stopped")
530
- return f"<ShroudPlugin state={state} version={self._version}>"
package/shroud_bridge.mjs DELETED
@@ -1,225 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Shroud Bridge — JSON-RPC server over stdin/stdout.
4
- *
5
- * Loads the shroud Obfuscator from a configurable dist path and exposes
6
- * obfuscate / deobfuscate / reset / getStats / configure via newline-
7
- * delimited JSON messages.
8
- *
9
- * Protocol:
10
- * → {"id":1,"method":"obfuscate","params":{"text":"..."}}
11
- * ← {"id":1,"result":{"obfuscated":"...","entityCount":3,"audit":{...}}}
12
- *
13
- * → {"id":2,"method":"deobfuscate","params":{"text":"..."}}
14
- * ← {"id":2,"result":{"text":"...","replacementCount":2,"audit":{...}}}
15
- *
16
- * → {"id":3,"method":"reset"}
17
- * ← {"id":3,"result":{"ok":true}}
18
- *
19
- * → {"id":4,"method":"getStats"}
20
- * ← {"id":4,"result":{...}}
21
- *
22
- * → {"id":5,"method":"ping"}
23
- * ← {"id":5,"result":{"ok":true,"version":"1.3.0"}}
24
- */
25
-
26
- import { createHash, randomBytes } from "node:crypto";
27
- import { createInterface } from "node:readline";
28
- import { pathToFileURL } from "node:url";
29
- import { resolve } from "node:path";
30
- import { writeFileSync } from "node:fs";
31
-
32
- // Shroud dist path passed as first CLI arg (default: ./dist relative to this script)
33
- import { dirname } from "node:path";
34
- import { fileURLToPath } from "node:url";
35
- const __dirname = dirname(fileURLToPath(import.meta.url));
36
- const shroudDist = process.argv[2] || resolve(__dirname, "dist");
37
-
38
- // Dynamically import the Obfuscator and config resolver
39
- const { Obfuscator } = await import(
40
- pathToFileURL(resolve(shroudDist, "obfuscator.js")).href
41
- );
42
- const { resolveConfig } = await import(
43
- pathToFileURL(resolve(shroudDist, "config.js")).href
44
- );
45
-
46
- // Read plugin config from env var (JSON) or use defaults
47
- let pluginConfig = {};
48
- if (process.env.SHROUD_PLUGIN_CONFIG) {
49
- try {
50
- pluginConfig = JSON.parse(process.env.SHROUD_PLUGIN_CONFIG);
51
- } catch (e) {
52
- process.stderr.write(`[shroud-bridge] Bad SHROUD_PLUGIN_CONFIG: ${e.message}\n`);
53
- }
54
- }
55
-
56
- const config = resolveConfig(pluginConfig);
57
- let obfuscator = new Obfuscator(config);
58
-
59
- // Hash chain: each audit entry includes hash of previous entry for tamper evidence
60
- let chainHash = "0000000000000000";
61
-
62
- function advanceChain(data) {
63
- const payload = chainHash + JSON.stringify(data);
64
- chainHash = createHash("sha256").update(payload).digest("hex").slice(0, 16);
65
- return chainHash;
66
- }
67
-
68
- function proofHash(text) {
69
- const salt = config.auditHashSalt || "";
70
- const truncate = config.auditHashTruncate || 12;
71
- return createHash("sha256")
72
- .update(salt + text)
73
- .digest("hex")
74
- .slice(0, truncate);
75
- }
76
-
77
- const STATS_FILE = process.env.SHROUD_STATS_FILE || "/tmp/shroud-stats.json";
78
-
79
- function dumpStats() {
80
- try {
81
- const stats = obfuscator.getStats();
82
- stats.updatedAt = new Date().toISOString();
83
- stats.pid = process.pid;
84
- writeFileSync(STATS_FILE, JSON.stringify(stats, null, 2) + "\n");
85
- } catch {
86
- // best-effort
87
- }
88
- }
89
-
90
- process.stderr.write("[shroud-bridge] Ready.\n");
91
-
92
- // Signal readiness to parent process
93
- process.stdout.write(JSON.stringify({ ready: true, version: "1.3.0" }) + "\n");
94
-
95
- const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
96
-
97
- rl.on("line", (line) => {
98
- if (!line.trim()) return;
99
- let req;
100
- try {
101
- req = JSON.parse(line);
102
- } catch (e) {
103
- process.stdout.write(
104
- JSON.stringify({ id: null, error: `Parse error: ${e.message}` }) + "\n"
105
- );
106
- return;
107
- }
108
-
109
- const { id, method, params } = req;
110
- let result;
111
- try {
112
- switch (method) {
113
- case "ping":
114
- result = { ok: true, version: "1.3.0" };
115
- break;
116
-
117
- case "obfuscate": {
118
- const text = params?.text ?? "";
119
- const out = obfuscator.obfuscate(text);
120
- const categories = {};
121
- for (const e of out.entities) {
122
- categories[e.category] = (categories[e.category] || 0) + 1;
123
- }
124
- result = {
125
- obfuscated: out.obfuscated,
126
- entityCount: out.entities.length,
127
- categories,
128
- };
129
-
130
- // Always include audit data in response — Python side decides what to log
131
- if (config.auditEnabled || config.verboseLogging) {
132
- const reqId = randomBytes(8).toString("hex");
133
- const audit = {
134
- req: reqId,
135
- totalEntities: out.entities.length,
136
- inputChars: text.length,
137
- outputChars: out.obfuscated.length,
138
- charDelta: out.obfuscated.length - text.length,
139
- byCategory: categories,
140
- modified: out.obfuscated !== text,
141
- proofIn: proofHash(text),
142
- proofOut: proofHash(out.obfuscated),
143
- };
144
-
145
- // Fake samples (only fake values, never real)
146
- const maxFakes = config.auditMaxFakesSample || 3;
147
- audit.fakesSample = Object.values(out.mappingsUsed).slice(0, maxFakes);
148
-
149
- // Advance tamper-evident hash chain
150
- audit.chainHash = advanceChain(audit);
151
-
152
- result.audit = audit;
153
- }
154
- dumpStats();
155
- break;
156
- }
157
-
158
- case "deobfuscate": {
159
- const text = params?.text ?? "";
160
- const deobResult = obfuscator.deobfuscateWithStats
161
- ? obfuscator.deobfuscateWithStats(text)
162
- : { text: obfuscator.deobfuscate(text), replacementCount: 0 };
163
-
164
- // Always include store size for diagnostics
165
- const stats = obfuscator.getStats();
166
- result = {
167
- text: deobResult.text,
168
- replacementCount: deobResult.replacementCount,
169
- storeSize: stats.storeMappings ?? 0,
170
- };
171
-
172
- if (config.auditEnabled || config.verboseLogging) {
173
- const audit = {
174
- replacementCount: deobResult.replacementCount,
175
- storeSize: stats.storeMappings ?? 0,
176
- inputChars: text.length,
177
- outputChars: deobResult.text.length,
178
- modified: deobResult.text !== text,
179
- proofIn: proofHash(text),
180
- proofOut: proofHash(deobResult.text),
181
- };
182
- // Correlate with request via chain
183
- audit.chainHash = advanceChain(audit);
184
- result.audit = audit;
185
- }
186
- dumpStats();
187
- break;
188
- }
189
-
190
- case "reset":
191
- obfuscator.reset();
192
- chainHash = "0000000000000000";
193
- dumpStats();
194
- result = { ok: true };
195
- break;
196
-
197
- case "getStats":
198
- result = obfuscator.getStats();
199
- result.chainHash = chainHash;
200
- break;
201
-
202
- case "reconfigure": {
203
- // Hot-reload config without restarting the process
204
- const newConfig = resolveConfig(params?.config ?? {});
205
- obfuscator = new Obfuscator(newConfig);
206
- result = { ok: true, config: newConfig };
207
- break;
208
- }
209
-
210
- default:
211
- result = undefined;
212
- process.stdout.write(
213
- JSON.stringify({ id, error: `Unknown method: ${method}` }) + "\n"
214
- );
215
- return;
216
- }
217
- process.stdout.write(JSON.stringify({ id, result }) + "\n");
218
- } catch (e) {
219
- process.stdout.write(
220
- JSON.stringify({ id, error: `${method} failed: ${e.message}` }) + "\n"
221
- );
222
- }
223
- });
224
-
225
- rl.on("close", () => process.exit(0));