shroud-privacy 2.0.0 → 2.0.2

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  Privacy obfuscation plugin for [OpenClaw](https://openclaw.ai). Detects sensitive data (PII, network infrastructure, credentials) and replaces it with deterministic fake values before anything reaches the LLM. Tool calls still work because Shroud deobfuscates on the way back.
4
4
 
5
- > **Open-source Community Edition** — free to use under MIT license. [Enterprise Edition](#enterprise-edition) available with additional features for teams.
5
+ > **Open-source Community Edition** — free to use under Apache 2.0 license. [Enterprise Edition](#enterprise-edition) available with additional features for teams.
6
6
 
7
7
  ## What it does
8
8
 
@@ -32,33 +32,50 @@ 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
35
+ ### From source (development)
36
36
 
37
37
  ```bash
38
- python agent.py plugin install shroud-privacy
38
+ git clone https://github.com/walterkeating-stack/shroud.git
39
+ cd shroud
40
+ npm install && npm run build
41
+ bash deploy-local.sh # → OpenClaw (~/.openclaw/extensions/)
39
42
  ```
40
43
 
41
- Configure in `~/.ncg/ncg.json` under `plugins.entries."shroud-privacy".config`.
44
+ ## Updating
42
45
 
43
- ### From source (development)
46
+ OpenClaw doesn't have a `plugins update` command yet, so updating requires removing the old install first. A helper script is included:
44
47
 
45
48
  ```bash
46
- git clone https://github.com/walterkeating-stack/shroud.git
47
- cd shroud
48
- npm install && npm run build
49
+ # Update to latest version (preserves your config)
50
+ bash scripts/update-openclaw-plugin.sh
49
51
 
50
- bash deploy-local.sh # OpenClaw (~/.openclaw/extensions/)
51
- bash deploy-ncg.sh # → NCG (~/.ncg/extensions/)
52
+ # Update to a specific version
53
+ bash scripts/update-openclaw-plugin.sh 2.0.1
52
54
  ```
53
55
 
54
- ## Configure
56
+ The script saves your plugin config from `openclaw.json`, removes the old extension, reinstalls from npm, restores your config, and restarts the gateway.
55
57
 
56
- Both OpenClaw and NCG store Shroud config in the same structure — only the file path differs:
58
+ ### Manual update
57
59
 
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` |
60
+ If you prefer to do it manually:
61
+
62
+ ```bash
63
+ # 1. Remove old plugin files
64
+ rm -rf ~/.openclaw/extensions/shroud-privacy
65
+
66
+ # 2. Reinstall (this resets your plugin config to defaults)
67
+ openclaw plugins install shroud-privacy
68
+
69
+ # 3. Re-apply your config in ~/.openclaw/openclaw.json
70
+ # (under plugins.entries."shroud-privacy".config)
71
+
72
+ # 4. Restart
73
+ openclaw gateway restart
74
+ ```
75
+
76
+ ## Configure
77
+
78
+ Edit `~/.openclaw/openclaw.json` under `plugins.entries."shroud-privacy".config`:
62
79
 
63
80
  ```jsonc
64
81
  "shroud-privacy": {
@@ -80,8 +97,7 @@ Both OpenClaw and NCG store Shroud config in the same structure — only the fil
80
97
  Restart the gateway after config changes:
81
98
 
82
99
  ```bash
83
- openclaw gateway restart # OpenClaw
84
- sudo systemctl restart ncg-gateway.service # NCG
100
+ openclaw gateway restart
85
101
  ```
86
102
 
87
103
  ### Safe defaults
@@ -91,7 +107,6 @@ Out of the box, Shroud:
91
107
  - Detects all entity categories at confidence >= 0.0
92
108
  - Logs audit lines (counts + categories) but **not** proof hashes or fake samples
93
109
  - Never logs raw values, real→fake mappings, or original text
94
- - All enterprise features are opt-in and disabled by default
95
110
 
96
111
  To enable proof hashes and fake samples for deeper audit:
97
112
 
@@ -127,56 +142,12 @@ To enable proof hashes and fake samples for deeper audit:
127
142
  | `logMappings` | boolean | `false` | Log mapping table (debug only) |
128
143
  | `customPatterns` | array | `[]` | User-defined regex detection patterns |
129
144
  | `detectorOverrides` | object | `{}` | Override built-in rules: disable or change confidence per rule name |
130
-
131
- ### Enterprise settings
132
-
133
- | Key | Type | Default | Description |
134
- |-----|------|---------|-------------|
135
- | `tenantId` | string | `""` | Multi-tenant isolation: tenant ID for HMAC keying |
136
- | `lockedCategories` | string[] | `[]` | Compliance mode: categories that MUST be detected |
137
145
  | `maxToolDepth` | number | `10` | Max nested tool call depth before warning |
138
- | `exposureWindow` | number | `60000` | Sliding window (ms) for exposure rate tracking |
139
- | `exposureThresholds` | object | `{}` | Per-category max detections per window |
140
- | `exposureGlobalThreshold` | number | `100` | Global max detections per window |
141
- | `policyFile` | string | `""` | Path to external JSON policy file (allowlist/denylist with glob/regex) |
142
146
  | `redactionLevel` | `"full"` \| `"masked"` \| `"stats"` | `"full"` | Output mode: fake values, partial masking, or category placeholders |
143
- | `sharedStorePath` | string | `""` | File path for cross-agent shared mapping store |
144
- | `sharedStoreTtlMs` | number | `5000` | Cache TTL for shared store reads (ms) |
145
- | `provenanceTagging` | boolean | `false` | Embed `«shroud:category:hash»` markers in output |
146
- | `sessionHandoff` | boolean | `false` | Enable session export/import tools |
147
-
148
- ### Key rotation settings
149
-
150
- | Key | Type | Default | Description |
151
- |-----|------|---------|-------------|
152
- | `keys` | array | `[]` | Versioned keys: `[{version, key, createdAt?, expiresAt?, retired?}]` |
153
- | `activeKeyVersion` | number | `0` | Which key version to use (0 = highest non-expired) |
154
-
155
- ### SIEM integration settings
156
-
157
- | Key | Type | Default | Description |
158
- |-----|------|---------|-------------|
159
- | `siemWebhooks` | array | `[]` | Webhook endpoints: `[{url, authHeader?, headers?, eventTypes?}]` |
160
- | `siemBatchSize` | number | `100` | Max events before auto-flush |
161
- | `siemFlushIntervalMs` | number | `30000` | Flush interval (ms) |
162
- | `siemMaxRetries` | number | `3` | Max retry attempts per flush |
163
- | `siemRetryBackoffMs` | number | `1000` | Initial retry backoff (doubles each retry) |
164
- | `siemEventFormat` | `"json"` \| `"cef"` | `"json"` | Output format for SIEM events |
147
+ | `dryRun` | boolean | `false` | Detect entities but don't replace (testing mode) |
148
+ | `maxStoreMappings` | number | `0` | Max mapping store size with LRU eviction (0 = unlimited) |
165
149
 
166
- ### Hot-reload, session isolation, and monitoring settings
167
-
168
- | Key | Type | Default | Description |
169
- |-----|------|---------|-------------|
170
- | `hotReload` | boolean | `false` | Watch config files and reload detection rules on change |
171
- | `customPatternsFile` | string | `""` | Path to custom patterns JSON file to watch |
172
- | `hotReloadDebounceMs` | number | `1000` | Debounce interval for file change events |
173
- | `sessionIsolation` | boolean | `false` | Per-session isolated stores and mapping engines |
174
- | `monitorEnabled` | boolean | `false` | Active monitoring and alerting pipeline |
175
- | `monitorRateWindowMs` | number | `60000` | Rolling window for rate baseline |
176
- | `monitorSpikeMultiplier` | number | `3.0` | Alert when rate exceeds baseline × multiplier |
177
- | `monitorMaxAlerts` | number | `500` | Max alerts to keep in memory |
178
-
179
- > **Env var overrides:** `SHROUD_SECRET_KEY`, `SHROUD_PERSISTENT_SALT`, `SHROUD_TENANT_ID`, `SHROUD_SHARED_STORE`, `SHROUD_SIEM_WEBHOOK_URL`, `SHROUD_SIEM_WEBHOOK_AUTH`, and `SHROUD_KEYS` (JSON array) override their respective config keys (priority: env var > plugin config > default).
150
+ > **Env var overrides:** `SHROUD_SECRET_KEY` and `SHROUD_PERSISTENT_SALT` override their respective config keys (priority: env var > plugin config > default).
180
151
 
181
152
  ### Detector overrides
182
153
 
@@ -196,7 +167,7 @@ Rules not listed keep their defaults. Overrides apply to both direct regex detec
196
167
 
197
168
  Shroud tracks per-rule match counts for the lifetime of the process. Counters appear in three places:
198
169
 
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.
170
+ - **`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
171
  - **Audit log lines** — `byRule=regex:email:3,regex:ipv4:2,...` alongside the existing `byCat` field.
201
172
  - **`getStats()`** — the `ruleHits` object in the stats response, useful for programmatic access.
202
173
 
@@ -270,12 +241,6 @@ With proof hashes enabled:
270
241
  [shroud][audit] OBFUSCATE req=a3f1bc9e02d4e7f1 | entities=4 | touched=2/5 | blocks=2 | chars=1200->1218 (delta=+18) | modified=YES | byCat=email:1,ip_address:2,hostname:1 | proof_in=8a3c1f0e2b4d proof_out=f7d2a1c9e084 | fakes=[jsmith@corp.net|100.64.0.12|SW-LAB-01]
271
242
  ```
272
243
 
273
- With compliance locking:
274
-
275
- ```
276
- [shroud][audit] OBFUSCATE req=... | ... | COMPLIANCE_WARN=missing:[credit_card]
277
- ```
278
-
279
244
  ### Audit field reference
280
245
 
281
246
  | Field | Meaning |
@@ -292,7 +257,6 @@ With compliance locking:
292
257
  | `proof_in` | Truncated salted SHA-256 of input text |
293
258
  | `proof_out` | Truncated salted SHA-256 of output text |
294
259
  | `fakes` | Sample of fake replacement values (never real values) |
295
- | `COMPLIANCE_WARN` | Missing locked categories (if compliance mode enabled) |
296
260
 
297
261
  ### Note on log duplication
298
262
 
@@ -302,7 +266,7 @@ OpenClaw logs each plugin message twice (once under the plugin subsystem logger,
302
266
 
303
267
  ```bash
304
268
  npm install
305
- npm test # run vitest (303 tests)
269
+ npm test # run vitest (210 tests)
306
270
  npm run build # compile TypeScript
307
271
  npm run lint # type-check without emitting
308
272
  ```
@@ -312,10 +276,7 @@ npm run lint # type-check without emitting
312
276
  ```bash
313
277
  npm run build
314
278
  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
279
+ openclaw gateway restart
319
280
  ```
320
281
 
321
282
  ## Release workflow
@@ -334,9 +295,7 @@ git push && git push --tags
334
295
 
335
296
  Then create a GitHub Release from the tag (attach the changelog entry as notes).
336
297
 
337
- ### npm publish (not published yet — maintainers only)
338
-
339
- This package is **not published to npm**. The `package.json` is pre-configured so publishing is a single command when the time comes. Do not publish without maintainer approval.
298
+ ### npm publish (maintainers only)
340
299
 
341
300
  ```bash
342
301
  # Pre-flight (always run before publishing)
@@ -366,4 +325,4 @@ The repo includes `.github/workflows/ci.yml` which runs lint + test + build on e
366
325
 
367
326
  ## License
368
327
 
369
- [MIT](LICENSE)
328
+ [Apache 2.0](LICENSE)
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "shroud-privacy",
3
3
  "name": "Shroud",
4
- "version": "2.0.0",
4
+ "version": "2.0.2",
5
5
  "description": "Privacy obfuscation with deterministic fake values and deobfuscation — PII never reaches the LLM, tool calls still work",
6
6
  "configSchema": {
7
7
  "type": "object",
@@ -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.2",
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));