shroud-privacy 2.5.4 → 2.5.6

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
@@ -13,12 +13,15 @@
13
13
  <a href="#install">Install</a> &middot;
14
14
  <a href="#why-shroud">Why Shroud</a> &middot;
15
15
  <a href="#configure">Configure</a> &middot;
16
+ <a href="docs/integrations.md">Integrations</a> &middot;
16
17
  <a href="#agent-privacy-protocol-app">APP Protocol</a> &middot;
17
18
  <a href="CHANGELOG.md">Changelog</a>
18
19
  </p>
19
20
 
20
21
  > Apache 2.0 &middot; Zero runtime dependencies &middot; Anthropic + OpenAI + Google supported &middot; Prompt-caching friendly &middot; Works with [OpenClaw](https://openclaw.ai), [Hermes Agent](https://github.com/nousresearch/hermes-agent), or any agent via [APP](#agent-privacy-protocol-app)
21
22
 
23
+ **Detailed integration reference:** [`docs/integrations.md`](docs/integrations.md)
24
+
22
25
  ---
23
26
 
24
27
  ## Why Shroud
@@ -78,14 +81,14 @@ Shroud does not guarantee compliance — regex-based detection has limitations (
78
81
 
79
82
  > **How it works:** Shroud intercepts ALL outbound LLM API calls (Anthropic, OpenAI, Google, any provider) at the `fetch` level and obfuscates detected entities in every message — including assistant history and Slack `<mailto:>` markup — before it leaves the process. On the response side, SSE streaming is deobfuscated per content block with buffered flushing. Every delivery path (Slack, WhatsApp, TUI, Telegram, Discord, Signal, web) gets real text automatically. Zero host patches required.
80
83
 
81
- > **Requires OpenClaw 2026.3.22 or later.**
84
+ > **Requires OpenClaw 2026.3.24 or later.**
82
85
 
83
86
  ### OpenClaw support policy
84
87
 
85
88
  - **Formal minimum supported version:** `2026.3.24` (from `openclaw.plugin.json` `minOpenClawVersion`).
86
89
  - **Release validation matrix (this release):**
87
90
  - **Baseline:** `2026.3.28` (includes WhatsApp E2E path)
88
- - **Latest-at-release:** `2026.4.9` (full 192-scenario E2E pass)
91
+ - **Latest-at-release:** `2026.4.14` (Slack E2E pass)
89
92
  - **Latest caveat:** on OpenClaw builds where WhatsApp provisioning via `channels add` is unsupported, latest-focused compat runs skip WhatsApp E2E and validate Slack E2E.
90
93
  - **Source of truth for current matrix:** `docs/ci-current-state.md` and `CHANGELOG.md`.
91
94
 
@@ -93,10 +96,10 @@ Shroud does not guarantee compliance — regex-based detection has limitations (
93
96
 
94
97
  ## Install
95
98
 
96
- ### OpenClaw (2026.3.22+)
99
+ ### OpenClaw (2026.3.24+)
97
100
 
98
101
  ```bash
99
- openclaw --version # ensure 2026.3.22+
102
+ openclaw --version # ensure 2026.3.24+
100
103
  openclaw plugins install shroud-privacy
101
104
  ```
102
105
 
@@ -138,7 +141,20 @@ Add to your project's `.mcp.json` or `~/.claude/.mcp.json`:
138
141
  }
139
142
  ```
140
143
 
141
- That's it — the MCP server auto-starts the privacy engine. Claude gains six tools: `shroud_obfuscate`, `shroud_deobfuscate`, `shroud_status`, `shroud_scan_tool`, `shroud_configure`, and `shroud_reset`.
144
+ That's it — the MCP server auto-starts a dedicated APP daemon on `/tmp/shroud-claude-mcp.sock` and writes Claude MCP session state under `OPENCLAW_STATE_DIR` (or `~/.openclaw`) as `shroud-claude-mcp-sessions.json`. Claude gains six tools: `shroud_obfuscate`, `shroud_deobfuscate`, `shroud_status`, `shroud_scan_tool`, `shroud_configure`, and `shroud_reset`.
145
+
146
+ If you want automatic tool-boundary obfuscation/deobfuscation instead of explicit MCP tools, use the shipped Claude hooks bridge in `clients/claude-code/shroud-bridge.mjs` together with `clients/claude-code/hooks.json`.
147
+
148
+ ### Codex
149
+
150
+ ```bash
151
+ npm install shroud-privacy
152
+ codex mcp add shroud -- node node_modules/shroud-privacy/clients/codex/shroud-mcp.mjs
153
+ ```
154
+
155
+ Codex uses the same six MCP tools as Claude, but on a separate APP daemon and socket: `/tmp/shroud-codex-mcp.sock`. Its APP session file defaults to `shroud-codex-mcp-sessions.json` under `OPENCLAW_STATE_DIR` or `~/.openclaw`.
156
+
157
+ The Codex MCP wrapper also auto-starts `clients/codex/shroud-bridge.mjs` on the host. That bridge watches Codex's local `~/.codex/history.jsonl` and writes `shroud-codex-cli-sessions.json` into the shared OpenClaw state dir, so Codex call/session counters stay current even when Codex is not actively invoking Shroud MCP tools. Set `SHROUD_CODEX_BRIDGE=0` only if you explicitly want to disable that behavior.
142
158
 
143
159
  ### Any agent (via APP)
144
160
 
@@ -165,6 +181,8 @@ with ShroudClient() as shroud:
165
181
 
166
182
  Copy `clients/python/shroud_client.py` into your project, or import it directly from the npm install path. Requires Node.js on the PATH.
167
183
 
184
+ For direct APP clients such as NCG, call `identify` first if you want per-agent session counters, then `obfuscate` / `deobfuscate`, and optionally wrap tool execution with `tool_call` / `tool_result` for telemetry.
185
+
168
186
  **Any language:**
169
187
 
170
188
  Spawn the APP server and talk JSON-RPC over stdin/stdout:
package/app-server.mjs CHANGED
@@ -13,14 +13,17 @@
13
13
  * SHROUD_PLUGIN_CONFIG JSON config for the engine
14
14
  * SHROUD_STORE_FILE Persistent store file path
15
15
  * SHROUD_STATS_FILE Stats dump file path
16
+ * SHROUD_APP_EVENTS_FILE JSONL file for APP-side event export
17
+ * SHROUD_APP_SESSIONS_FILE JSON file for APP-side agent session state
16
18
  */
17
19
 
18
20
  import { createHash, randomBytes } from "node:crypto";
19
21
  import { createInterface } from "node:readline";
20
22
  import { pathToFileURL } from "node:url";
21
23
  import { resolve, dirname } from "node:path";
22
- import { writeFileSync, readFileSync } from "node:fs";
24
+ import { writeFileSync, readFileSync, unlinkSync } from "node:fs";
23
25
  import { fileURLToPath } from "node:url";
26
+ import { createServer as createNetServer } from "node:net";
24
27
 
25
28
  const __dirname = dirname(fileURLToPath(import.meta.url));
26
29
  const shroudDist = process.argv[2] || resolve(__dirname, "dist");
@@ -61,6 +64,8 @@ let obfuscator = new Obfuscator(config);
61
64
 
62
65
  const STATS_FILE = process.env.SHROUD_STATS_FILE || "/tmp/shroud-stats.json";
63
66
  const STORE_FILE = process.env.SHROUD_STORE_FILE || "";
67
+ const APP_EVENTS_FILE = process.env.SHROUD_APP_EVENTS_FILE || "/tmp/shroud-app-events.jsonl";
68
+ const APP_SESSIONS_FILE = process.env.SHROUD_APP_SESSIONS_FILE || "/tmp/shroud-app-sessions.json";
64
69
 
65
70
  // ---------------------------------------------------------------------------
66
71
  // Audit chain
@@ -116,6 +121,20 @@ function resolvePartition(params) {
116
121
  const startTime = Date.now();
117
122
  let requestCount = 0;
118
123
  let totalProcessingMs = 0;
124
+ const toolSequence = [];
125
+ const privacy = {
126
+ obfuscationCalls: 0,
127
+ deobfuscationCalls: 0,
128
+ entitiesObfuscated: 0,
129
+ replacementsDeobfuscated: 0,
130
+ categoryCounts: {},
131
+ };
132
+
133
+ let agentIdentified = false;
134
+ let agentLabel = null;
135
+ let agentBuildId = null;
136
+ let agentVersion = null;
137
+ let agentChannel = null;
119
138
 
120
139
  // ---------------------------------------------------------------------------
121
140
  // Stats dump helper
@@ -130,6 +149,36 @@ function dumpStats() {
130
149
  } catch { /* best-effort */ }
131
150
  }
132
151
 
152
+ function dumpSessionFile() {
153
+ if (!agentIdentified) return;
154
+ try {
155
+ const session = {
156
+ agentLabel,
157
+ agentBuildId,
158
+ agentVersion,
159
+ channel: agentChannel,
160
+ source: "app-server",
161
+ pid: process.pid,
162
+ requestCount,
163
+ uptimeMs: Date.now() - startTime,
164
+ securityEvents: 0,
165
+ storeSize: getObfuscator().getStats().storeMappings ?? 0,
166
+ classification: {
167
+ role: "APP Agent",
168
+ confidencePct: 100,
169
+ confidence: "high",
170
+ colour: "#06b6d4",
171
+ signals: ["app-server"],
172
+ },
173
+ toolSequence: toolSequence.slice(-20),
174
+ privacy,
175
+ eventsFile: APP_EVENTS_FILE,
176
+ updatedAt: new Date().toISOString(),
177
+ };
178
+ writeFileSync(APP_SESSIONS_FILE, JSON.stringify(session, null, 2) + "\n");
179
+ } catch { /* best-effort */ }
180
+ }
181
+
133
182
  // ---------------------------------------------------------------------------
134
183
  // JSON-RPC helpers
135
184
  // ---------------------------------------------------------------------------
@@ -153,6 +202,37 @@ function jsonResult(id, result) {
153
202
  // APP method handlers
154
203
  // ---------------------------------------------------------------------------
155
204
 
205
+ function handleIdentify(id, params) {
206
+ if (!params || typeof params.agent !== "string" || !params.agent.trim()) {
207
+ return jsonError(id, ERR_BAD_PARAMS, 'Missing required param: agent (string)');
208
+ }
209
+ if (typeof params.version !== "string" || !params.version.trim()) {
210
+ return jsonError(id, ERR_BAD_PARAMS, 'Missing required param: version (string)');
211
+ }
212
+
213
+ agentLabel = params.agent.trim();
214
+ agentVersion = params.version.trim();
215
+ agentChannel = typeof params.channel === "string" && params.channel.trim() ? params.channel.trim() : "app";
216
+ agentBuildId = createHash("sha256")
217
+ .update(agentLabel + ":" + agentVersion)
218
+ .digest("hex")
219
+ .slice(0, 16);
220
+ agentIdentified = true;
221
+
222
+ process.stderr.write(
223
+ `[app-server] Agent identified: ${agentLabel} v${agentVersion} (${agentChannel}) buildId=${agentBuildId}\n`
224
+ );
225
+
226
+ dumpSessionFile();
227
+
228
+ return jsonResult(id, {
229
+ ok: true,
230
+ agent: agentLabel,
231
+ buildId: agentBuildId,
232
+ security: false,
233
+ });
234
+ }
235
+
156
236
  function handleObfuscate(id, params) {
157
237
  if (!params || typeof params.text !== "string") {
158
238
  return jsonError(id, ERR_BAD_PARAMS, "Missing required param: text");
@@ -193,6 +273,12 @@ function handleObfuscate(id, params) {
193
273
  audit.fakesSample = Object.values(out.mappingsUsed || {}).slice(0, maxFakes);
194
274
  result.audit = audit;
195
275
 
276
+ privacy.obfuscationCalls++;
277
+ privacy.entitiesObfuscated += out.entities.length;
278
+ for (const [cat, count] of Object.entries(categories)) {
279
+ privacy.categoryCounts[cat] = (privacy.categoryCounts[cat] || 0) + count;
280
+ }
281
+
196
282
  dumpStats();
197
283
  return jsonResult(id, result);
198
284
  }
@@ -231,6 +317,11 @@ function handleDeobfuscate(id, params) {
231
317
  };
232
318
  result.audit = audit;
233
319
 
320
+ if ((deobResult.replacementCount || 0) > 0) {
321
+ privacy.deobfuscationCalls++;
322
+ privacy.replacementsDeobfuscated += deobResult.replacementCount || 0;
323
+ }
324
+
234
325
  dumpStats();
235
326
  return jsonResult(id, result);
236
327
  }
@@ -381,8 +472,53 @@ function handleConfigure(id, params) {
381
472
  return jsonResult(id, { ok: true, appliedKeys });
382
473
  }
383
474
 
475
+ function handleSecurity(id) {
476
+ return jsonResult(id, {
477
+ enabled: false,
478
+ mode: "off",
479
+ events: 0,
480
+ byThreatClass: {},
481
+ agent: agentIdentified ? {
482
+ label: agentLabel,
483
+ buildId: agentBuildId,
484
+ version: agentVersion,
485
+ channel: agentChannel,
486
+ requestCount,
487
+ } : null,
488
+ recentEvents: [],
489
+ });
490
+ }
491
+
492
+ function handleToolCall(id, params) {
493
+ if (!params || typeof params.tool !== "string" || !params.tool.trim()) {
494
+ return jsonError(id, ERR_BAD_PARAMS, "Missing required param: tool (string)");
495
+ }
496
+
497
+ toolSequence.push(params.tool.trim());
498
+
499
+ return jsonResult(id, {
500
+ blocked: false,
501
+ reason: null,
502
+ sequenceLength: toolSequence.length,
503
+ events: [],
504
+ securityEnabled: false,
505
+ });
506
+ }
507
+
508
+ function handleToolResult(id, params) {
509
+ if (!params || typeof params.tool !== "string" || !params.tool.trim()) {
510
+ return jsonError(id, ERR_BAD_PARAMS, "Missing required param: tool (string)");
511
+ }
512
+
513
+ return jsonResult(id, {
514
+ ok: true,
515
+ recorded: true,
516
+ });
517
+ }
518
+
384
519
  function handleShutdown(id) {
385
520
  dumpStats();
521
+ dumpSessionFile();
386
522
  const response = jsonResult(id, { ok: true, flushed: true });
387
523
  process.stdout.write(response + "\n");
388
524
  process.exit(0);
@@ -413,32 +549,37 @@ function handleSetPartition(id, params) {
413
549
  // ---------------------------------------------------------------------------
414
550
 
415
551
  const METHODS = {
552
+ identify: handleIdentify,
416
553
  obfuscate: handleObfuscate,
417
554
  deobfuscate: handleDeobfuscate,
418
555
  batch: handleBatch,
419
556
  reset: handleReset,
420
557
  stats: handleStats,
421
558
  health: handleHealth,
559
+ security: handleSecurity,
560
+ tool_call: handleToolCall,
561
+ tool_result: handleToolResult,
422
562
  configure: handleConfigure,
423
563
  shutdown: handleShutdown,
424
564
  setPartition: handleSetPartition,
425
565
  };
426
566
 
427
- function dispatch(line) {
567
+ function dispatch(line, writeFn) {
428
568
  if (!line.trim()) return;
569
+ const write = writeFn || ((s) => process.stdout.write(s));
429
570
 
430
571
  let req;
431
572
  try {
432
573
  req = JSON.parse(line);
433
574
  } catch (e) {
434
- process.stdout.write(jsonError(null, ERR_PARSE, `Parse error: ${e.message}`) + "\n");
575
+ write(jsonError(null, ERR_PARSE, `Parse error: ${e.message}`) + "\n");
435
576
  return;
436
577
  }
437
578
 
438
579
  const { id, method, params } = req;
439
580
 
440
581
  if (id === undefined || id === null || !method) {
441
- process.stdout.write(
582
+ write(
442
583
  jsonError(id ?? null, ERR_INVALID_REQ, "Missing required field: id and method") + "\n"
443
584
  );
444
585
  return;
@@ -446,7 +587,7 @@ function dispatch(line) {
446
587
 
447
588
  const handler = METHODS[method];
448
589
  if (!handler) {
449
- process.stdout.write(
590
+ write(
450
591
  jsonError(id, ERR_NO_METHOD, `Method not found: ${method}`) + "\n"
451
592
  );
452
593
  return;
@@ -459,10 +600,11 @@ function dispatch(line) {
459
600
  const response = handler(id, params);
460
601
  // shutdown writes its own response and exits
461
602
  if (method !== "shutdown") {
462
- process.stdout.write(response + "\n");
603
+ write(response + "\n");
604
+ dumpSessionFile();
463
605
  }
464
606
  } catch (e) {
465
- process.stdout.write(
607
+ write(
466
608
  jsonError(id, ERR_ENGINE, `Engine error: ${e.message}`) + "\n"
467
609
  );
468
610
  }
@@ -507,28 +649,103 @@ const handshake = {
507
649
  engine: "shroud",
508
650
  version: engineVersion,
509
651
  capabilities: [
652
+ "identify",
510
653
  "obfuscate",
511
654
  "deobfuscate",
512
655
  "batch",
513
656
  "stats",
514
657
  "health",
515
658
  "configure",
659
+ "security",
660
+ "tool_call",
661
+ "tool_result",
516
662
  "audit",
517
663
  "partitions",
518
664
  ],
665
+ security: null,
519
666
  };
520
667
 
521
668
  process.stderr.write(`[app-server] Starting APP server v${engineVersion}\n`);
522
- process.stdout.write(JSON.stringify(handshake) + "\n");
523
669
 
524
670
  // ---------------------------------------------------------------------------
525
- // Main loop
671
+ // Socket listener mode (--listen <path>)
526
672
  // ---------------------------------------------------------------------------
527
673
 
528
- const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
529
- rl.on("line", dispatch);
530
- rl.on("close", () => {
531
- clearInterval(heartbeatInterval);
532
- dumpStats();
533
- process.exit(0);
534
- });
674
+ const listenFlag = process.argv.indexOf("--listen");
675
+ const SOCKET_PATH = listenFlag !== -1 ? (process.argv[listenFlag + 1] || "/tmp/shroud-app.sock") : null;
676
+
677
+ if (SOCKET_PATH) {
678
+ // Remove stale socket file
679
+ try { unlinkSync(SOCKET_PATH); } catch {}
680
+
681
+ let socketClients = 0;
682
+ const socketServer = createNetServer((conn) => {
683
+ socketClients++;
684
+ const clientId = socketClients;
685
+ process.stderr.write(`[app-server] Socket client #${clientId} connected\n`);
686
+
687
+ // Send handshake to this client
688
+ conn.write(JSON.stringify(handshake) + "\n");
689
+
690
+ let connected = true;
691
+ const connRl = createInterface({ input: conn, crlfDelay: Infinity });
692
+ const connWrite = (s) => { if (connected) try { conn.write(s); } catch {} };
693
+
694
+ connRl.on("line", (line) => dispatch(line, connWrite));
695
+ connRl.on("error", () => {});
696
+ conn.on("error", () => { connected = false; });
697
+ conn.on("close", () => {
698
+ connected = false;
699
+ process.stderr.write(`[app-server] Socket client #${clientId} disconnected\n`);
700
+ });
701
+ });
702
+
703
+ socketServer.listen(SOCKET_PATH, () => {
704
+ process.stderr.write(`[app-server] Listening on socket: ${SOCKET_PATH}\n`);
705
+ });
706
+
707
+ socketServer.on("error", (err) => {
708
+ process.stderr.write(`[app-server] Socket error: ${err.message}\n`);
709
+ process.exit(1);
710
+ });
711
+
712
+ // Also still serve stdin for backwards compat
713
+ process.stdout.write(JSON.stringify(handshake) + "\n");
714
+ const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
715
+ rl.on("line", (line) => dispatch(line));
716
+ rl.on("close", () => {
717
+ try { unlinkSync(SOCKET_PATH); } catch {}
718
+ socketServer.close();
719
+ clearInterval(heartbeatInterval);
720
+ dumpStats();
721
+ dumpSessionFile();
722
+ process.exit(0);
723
+ });
724
+
725
+ // Cleanup socket on exit
726
+ for (const sig of ["SIGINT", "SIGTERM"]) {
727
+ process.on(sig, () => {
728
+ try { unlinkSync(SOCKET_PATH); } catch {}
729
+ socketServer.close();
730
+ clearInterval(heartbeatInterval);
731
+ dumpStats();
732
+ dumpSessionFile();
733
+ process.exit(0);
734
+ });
735
+ }
736
+ } else {
737
+ // ---------------------------------------------------------------------------
738
+ // Default: stdin/stdout mode
739
+ // ---------------------------------------------------------------------------
740
+
741
+ process.stdout.write(JSON.stringify(handshake) + "\n");
742
+
743
+ const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
744
+ rl.on("line", (line) => dispatch(line));
745
+ rl.on("close", () => {
746
+ clearInterval(heartbeatInterval);
747
+ dumpStats();
748
+ dumpSessionFile();
749
+ process.exit(0);
750
+ });
751
+ }
@@ -0,0 +1,32 @@
1
+ {
2
+ "$schema": "https://docs.anthropic.com/schemas/claude-code-hooks.json",
3
+ "_comment": "Add this 'hooks' block to your .claude/settings.json or .claude/settings.local.json",
4
+ "hooks": {
5
+ "PreToolUse": [
6
+ {
7
+ "matcher": "Read|Bash|Grep|Glob|Write|Edit|NotebookEdit|WebFetch|WebSearch",
8
+ "hooks": [
9
+ {
10
+ "type": "http",
11
+ "url": "http://127.0.0.1:17380/pre-tool-use",
12
+ "timeout": 5000,
13
+ "statusMessage": "Shroud: scanning tool call"
14
+ }
15
+ ]
16
+ }
17
+ ],
18
+ "PostToolUse": [
19
+ {
20
+ "matcher": "Read|Bash|Grep|Glob|WebFetch|WebSearch",
21
+ "hooks": [
22
+ {
23
+ "type": "http",
24
+ "url": "http://127.0.0.1:17380/post-tool-use",
25
+ "timeout": 5000,
26
+ "statusMessage": "Shroud: obfuscating output"
27
+ }
28
+ ]
29
+ }
30
+ ]
31
+ }
32
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "_comment": "Copy this into your project's .mcp.json or ~/.claude/.mcp.json",
3
+ "mcpServers": {
4
+ "shroud": {
5
+ "command": "node",
6
+ "args": ["node_modules/shroud-privacy/clients/claude-code/shroud-mcp.mjs"]
7
+ }
8
+ }
9
+ }