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.
- package/LICENSE +190 -0
- package/NOTICE +7 -0
- package/README.md +369 -0
- package/dist/audit.d.ts +46 -0
- package/dist/audit.js +127 -0
- package/dist/canary.d.ts +31 -0
- package/dist/canary.js +73 -0
- package/dist/config.d.ts +27 -0
- package/dist/config.js +123 -0
- package/dist/detectors/base.d.ts +8 -0
- package/dist/detectors/base.js +2 -0
- package/dist/detectors/code.d.ts +25 -0
- package/dist/detectors/code.js +144 -0
- package/dist/detectors/context.d.ts +31 -0
- package/dist/detectors/context.js +357 -0
- package/dist/detectors/patterns.d.ts +15 -0
- package/dist/detectors/patterns.js +58 -0
- package/dist/detectors/regex.d.ts +28 -0
- package/dist/detectors/regex.js +955 -0
- package/dist/generators/base.d.ts +6 -0
- package/dist/generators/base.js +2 -0
- package/dist/generators/codes.d.ts +20 -0
- package/dist/generators/codes.js +231 -0
- package/dist/generators/names.d.ts +29 -0
- package/dist/generators/names.js +194 -0
- package/dist/generators/network.d.ts +86 -0
- package/dist/generators/network.js +477 -0
- package/dist/hooks.d.ts +27 -0
- package/dist/hooks.js +457 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +58 -0
- package/dist/mapping.d.ts +33 -0
- package/dist/mapping.js +72 -0
- package/dist/obfuscator.d.ts +78 -0
- package/dist/obfuscator.js +603 -0
- package/dist/redaction.d.ts +26 -0
- package/dist/redaction.js +76 -0
- package/dist/store.d.ts +40 -0
- package/dist/store.js +79 -0
- package/dist/types.d.ts +101 -0
- package/dist/types.js +35 -0
- package/ncg_adapter.py +530 -0
- package/openclaw.plugin.json +72 -0
- package/package.json +56 -0
- 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
|
+
}
|