shroud-privacy 2.0.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/LICENSE +190 -0
  2. package/NOTICE +7 -0
  3. package/README.md +369 -0
  4. package/dist/audit.d.ts +46 -0
  5. package/dist/audit.js +127 -0
  6. package/dist/canary.d.ts +31 -0
  7. package/dist/canary.js +73 -0
  8. package/dist/config.d.ts +27 -0
  9. package/dist/config.js +123 -0
  10. package/dist/detectors/base.d.ts +8 -0
  11. package/dist/detectors/base.js +2 -0
  12. package/dist/detectors/code.d.ts +25 -0
  13. package/dist/detectors/code.js +144 -0
  14. package/dist/detectors/context.d.ts +31 -0
  15. package/dist/detectors/context.js +357 -0
  16. package/dist/detectors/patterns.d.ts +15 -0
  17. package/dist/detectors/patterns.js +58 -0
  18. package/dist/detectors/regex.d.ts +28 -0
  19. package/dist/detectors/regex.js +955 -0
  20. package/dist/generators/base.d.ts +6 -0
  21. package/dist/generators/base.js +2 -0
  22. package/dist/generators/codes.d.ts +20 -0
  23. package/dist/generators/codes.js +231 -0
  24. package/dist/generators/names.d.ts +29 -0
  25. package/dist/generators/names.js +194 -0
  26. package/dist/generators/network.d.ts +86 -0
  27. package/dist/generators/network.js +477 -0
  28. package/dist/hooks.d.ts +27 -0
  29. package/dist/hooks.js +457 -0
  30. package/dist/index.d.ts +12 -0
  31. package/dist/index.js +58 -0
  32. package/dist/mapping.d.ts +33 -0
  33. package/dist/mapping.js +72 -0
  34. package/dist/obfuscator.d.ts +78 -0
  35. package/dist/obfuscator.js +603 -0
  36. package/dist/redaction.d.ts +26 -0
  37. package/dist/redaction.js +76 -0
  38. package/dist/store.d.ts +40 -0
  39. package/dist/store.js +79 -0
  40. package/dist/types.d.ts +101 -0
  41. package/dist/types.js +35 -0
  42. package/ncg_adapter.py +530 -0
  43. package/openclaw.plugin.json +72 -0
  44. package/package.json +56 -0
  45. package/shroud_bridge.mjs +225 -0
package/ncg_adapter.py ADDED
@@ -0,0 +1,530 @@
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}>"
@@ -0,0 +1,72 @@
1
+ {
2
+ "id": "shroud-privacy",
3
+ "name": "Shroud",
4
+ "version": "2.0.0",
5
+ "description": "Privacy obfuscation with deterministic fake values and deobfuscation — PII never reaches the LLM, tool calls still work",
6
+ "configSchema": {
7
+ "type": "object",
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "secretKey": { "type": "string", "description": "HMAC secret for deterministic mapping. Auto-generated if empty." },
11
+ "persistentSalt": { "type": "string", "description": "Fixed salt for cross-session consistency. Empty = random per session." },
12
+ "minConfidence": { "type": "number", "minimum": 0, "maximum": 1, "default": 0, "description": "Minimum detector confidence to obfuscate" },
13
+ "allowlist": { "type": "array", "items": { "type": "string" }, "default": [], "description": "Values to never obfuscate (supports * and ? wildcards)" },
14
+ "denylist": { "type": "array", "items": { "type": "string" }, "default": [], "description": "Values to always obfuscate" },
15
+ "canaryEnabled": { "type": "boolean", "default": false, "description": "Inject tracking tokens to detect data leakage" },
16
+ "canaryPrefix": { "type": "string", "default": "SHROUD-CANARY", "description": "Prefix for canary tokens" },
17
+ "auditEnabled": { "type": "boolean", "default": false, "description": "Track obfuscation events with tamper-evident chain hashing" },
18
+ "verboseLogging": { "type": "boolean", "default": false, "description": "Alias for auditEnabled — enable verbose audit lines" },
19
+ "auditLogFormat": { "type": "string", "enum": ["human", "json"], "default": "human", "description": "Audit log output format" },
20
+ "auditIncludeProofHashes": { "type": "boolean", "default": false, "description": "Include salted SHA-256 proof hashes in audit lines" },
21
+ "auditHashSalt": { "type": "string", "default": "", "description": "Salt for proof hashes" },
22
+ "auditHashTruncate": { "type": "integer", "default": 12, "minimum": 4, "maximum": 64, "description": "Truncate proof hashes to N hex chars" },
23
+ "auditMaxFakesSample": { "type": "integer", "default": 0, "minimum": 0, "maximum": 20, "description": "Include up to N fake replacement values in audit log (0 = disabled)" },
24
+ "logMappings": { "type": "boolean", "default": false, "description": "Log mapping table (debug only)" },
25
+ "detectorOverrides": {
26
+ "type": "object",
27
+ "additionalProperties": {
28
+ "type": "object",
29
+ "properties": {
30
+ "enabled": { "type": "boolean" },
31
+ "confidence": { "type": "number", "minimum": 0, "maximum": 1 }
32
+ }
33
+ },
34
+ "default": {},
35
+ "description": "Override built-in detector rules: disable or change confidence by rule name"
36
+ },
37
+ "customPatterns": {
38
+ "type": "array",
39
+ "items": {
40
+ "type": "object",
41
+ "properties": {
42
+ "name": { "type": "string" },
43
+ "pattern": { "type": "string" },
44
+ "category": { "type": "string" }
45
+ },
46
+ "required": ["name", "pattern"]
47
+ },
48
+ "default": [],
49
+ "description": "User-defined regex patterns for custom PII detection"
50
+ },
51
+ "maxToolDepth": { "type": "integer", "default": 10, "minimum": 1, "maximum": 100, "description": "Max nested tool call depth before warning" },
52
+ "redactionLevel": { "type": "string", "enum": ["full", "masked", "stats"], "default": "full", "description": "Output mode: full (fake values), masked (partial masking), stats (category placeholders)" },
53
+ "dryRun": { "type": "boolean", "default": false, "description": "Detect entities but don't replace — useful for testing detection rules" },
54
+ "maxStoreMappings": { "type": "integer", "default": 0, "minimum": 0, "description": "Max mapping store size; oldest entries evicted when exceeded. 0 = unlimited." }
55
+ }
56
+ },
57
+ "uiHints": {
58
+ "secretKey": { "label": "Secret Key", "help": "HMAC secret for deterministic mapping. Auto-generated if empty.", "sensitive": true },
59
+ "persistentSalt": { "label": "Persistent Salt", "help": "Fixed salt for cross-session mapping consistency." },
60
+ "minConfidence": { "label": "Min Confidence", "help": "Detection confidence threshold (0.0-1.0)" },
61
+ "canaryEnabled": { "label": "Enable Canary Tokens", "help": "Inject tracking tokens to detect PII leakage" },
62
+ "auditEnabled": { "label": "Enable Audit Log", "help": "Tamper-evident obfuscation event tracking" }
63
+ },
64
+ "ncg": {
65
+ "adapter": "ncg_adapter.py",
66
+ "adapterClass": "ShroudPlugin",
67
+ "bridge": "shroud_bridge.mjs"
68
+ },
69
+ "compatibility": {
70
+ "minOpenClawVersion": "2026.3.0"
71
+ }
72
+ }
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "shroud-privacy",
3
+ "version": "2.0.0",
4
+ "description": "Privacy obfuscation plugin for OpenClaw and NCG — deterministic fake values with deobfuscation",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "files": [
9
+ "dist/",
10
+ "openclaw.plugin.json",
11
+ "ncg_adapter.py",
12
+ "shroud_bridge.mjs",
13
+ "LICENSE",
14
+ "NOTICE"
15
+ ],
16
+ "scripts": {
17
+ "build": "tsc",
18
+ "test": "vitest run",
19
+ "test:watch": "vitest",
20
+ "lint": "tsc --noEmit",
21
+ "prepublishOnly": "npm run lint && npm test && npm run build"
22
+ },
23
+ "keywords": [
24
+ "openclaw",
25
+ "openclaw-plugin",
26
+ "ncg",
27
+ "privacy",
28
+ "pii",
29
+ "obfuscation",
30
+ "llm",
31
+ "redaction"
32
+ ],
33
+ "author": "walterkeating-stack",
34
+ "license": "Apache-2.0",
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "devDependencies": {
39
+ "typescript": "^5.4",
40
+ "vitest": "^2.0",
41
+ "@types/node": "^20"
42
+ },
43
+ "openclaw": {
44
+ "extensions": [
45
+ "./dist/index.js"
46
+ ]
47
+ },
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "git+https://github.com/walterkeating-stack/shroud.git"
51
+ },
52
+ "homepage": "https://github.com/walterkeating-stack/shroud#readme",
53
+ "bugs": {
54
+ "url": "https://github.com/walterkeating-stack/shroud/issues"
55
+ }
56
+ }