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 +42 -83
- package/openclaw.plugin.json +1 -6
- package/package.json +2 -5
- package/ncg_adapter.py +0 -530
- package/shroud_bridge.mjs +0 -225
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
|
|
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
|
-
###
|
|
35
|
+
### From source (development)
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
|
-
|
|
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
|
-
|
|
44
|
+
## Updating
|
|
42
45
|
|
|
43
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
npm install && npm run build
|
|
49
|
+
# Update to latest version (preserves your config)
|
|
50
|
+
bash scripts/update-openclaw-plugin.sh
|
|
49
51
|
|
|
50
|
-
|
|
51
|
-
bash
|
|
52
|
+
# Update to a specific version
|
|
53
|
+
bash scripts/update-openclaw-plugin.sh 2.0.1
|
|
52
54
|
```
|
|
53
55
|
|
|
54
|
-
|
|
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
|
-
|
|
58
|
+
### Manual update
|
|
57
59
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
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
|
-
| `
|
|
144
|
-
| `
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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
|
-
[
|
|
328
|
+
[Apache 2.0](LICENSE)
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "shroud-privacy",
|
|
3
3
|
"name": "Shroud",
|
|
4
|
-
"version": "2.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.
|
|
4
|
-
"description": "Privacy obfuscation plugin for OpenClaw and
|
|
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));
|