shroud-privacy 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/LICENSE +190 -0
  2. package/NOTICE +7 -0
  3. package/README.md +369 -0
  4. package/dist/audit.d.ts +46 -0
  5. package/dist/audit.js +127 -0
  6. package/dist/canary.d.ts +31 -0
  7. package/dist/canary.js +73 -0
  8. package/dist/config.d.ts +27 -0
  9. package/dist/config.js +123 -0
  10. package/dist/detectors/base.d.ts +8 -0
  11. package/dist/detectors/base.js +2 -0
  12. package/dist/detectors/code.d.ts +25 -0
  13. package/dist/detectors/code.js +144 -0
  14. package/dist/detectors/context.d.ts +31 -0
  15. package/dist/detectors/context.js +357 -0
  16. package/dist/detectors/patterns.d.ts +15 -0
  17. package/dist/detectors/patterns.js +58 -0
  18. package/dist/detectors/regex.d.ts +28 -0
  19. package/dist/detectors/regex.js +955 -0
  20. package/dist/generators/base.d.ts +6 -0
  21. package/dist/generators/base.js +2 -0
  22. package/dist/generators/codes.d.ts +20 -0
  23. package/dist/generators/codes.js +231 -0
  24. package/dist/generators/names.d.ts +29 -0
  25. package/dist/generators/names.js +194 -0
  26. package/dist/generators/network.d.ts +86 -0
  27. package/dist/generators/network.js +477 -0
  28. package/dist/hooks.d.ts +27 -0
  29. package/dist/hooks.js +457 -0
  30. package/dist/index.d.ts +12 -0
  31. package/dist/index.js +58 -0
  32. package/dist/mapping.d.ts +33 -0
  33. package/dist/mapping.js +72 -0
  34. package/dist/obfuscator.d.ts +78 -0
  35. package/dist/obfuscator.js +603 -0
  36. package/dist/redaction.d.ts +26 -0
  37. package/dist/redaction.js +76 -0
  38. package/dist/store.d.ts +40 -0
  39. package/dist/store.js +79 -0
  40. package/dist/types.d.ts +101 -0
  41. package/dist/types.js +35 -0
  42. package/ncg_adapter.py +530 -0
  43. package/openclaw.plugin.json +72 -0
  44. package/package.json +56 -0
  45. package/shroud_bridge.mjs +225 -0
package/dist/hooks.js ADDED
@@ -0,0 +1,457 @@
1
+ /**
2
+ * OpenClaw lifecycle hooks for the Shroud privacy plugin.
3
+ *
4
+ * Registers 5 hooks:
5
+ * 1. before_prompt_build (async) -- obfuscate user prompt via prependContext
6
+ * 2. before_llm_send (async) -- obfuscate LLM input messages + return transformResponse for deobfuscation
7
+ * 3. before_tool_call (async) -- deobfuscate tool params (+ depth tracking)
8
+ * 4. tool_result_persist (SYNC) -- obfuscate tool result message
9
+ * 5. message_sending (async) -- deobfuscate outbound message content (fallback)
10
+ */
11
+ import { createHash, randomBytes } from "node:crypto";
12
+ import { writeFileSync } from "node:fs";
13
+ import { BUILTIN_PATTERNS } from "./detectors/regex.js";
14
+ const STATS_FILE = process.env.SHROUD_STATS_FILE || "/tmp/shroud-stats.json";
15
+ function dumpStatsFile(obfuscator) {
16
+ try {
17
+ const stats = obfuscator.getStats();
18
+ stats.updatedAt = new Date().toISOString();
19
+ stats.source = "openclaw";
20
+ stats.pid = process.pid;
21
+ writeFileSync(STATS_FILE, JSON.stringify(stats, null, 2) + "\n");
22
+ }
23
+ catch {
24
+ // best-effort
25
+ }
26
+ }
27
+ // ---------------------------------------------------------------------------
28
+ // Hashing utilities (audit proof hashes)
29
+ // ---------------------------------------------------------------------------
30
+ function sha256Hex(text) {
31
+ return createHash("sha256").update(text).digest("hex");
32
+ }
33
+ function safeHash(text, salt) {
34
+ return sha256Hex(salt + text);
35
+ }
36
+ function truncateHash(hash, n) {
37
+ return hash.slice(0, n);
38
+ }
39
+ // ---------------------------------------------------------------------------
40
+ // String walking helper
41
+ // ---------------------------------------------------------------------------
42
+ /**
43
+ * Deep-walk an unknown message structure and obfuscate/deobfuscate all
44
+ * string leaves. OpenClaw message payloads can be a plain string, an
45
+ * array of content blocks, or a nested object — this handles all three.
46
+ */
47
+ function walkStrings(value, fn) {
48
+ if (typeof value === "string")
49
+ return fn(value);
50
+ if (Array.isArray(value)) {
51
+ return value.map((item) => {
52
+ if (typeof item === "object" && item !== null && "text" in item && typeof item.text === "string") {
53
+ return { ...item, text: fn(item.text) };
54
+ }
55
+ return walkStrings(item, fn);
56
+ });
57
+ }
58
+ if (typeof value === "object" && value !== null) {
59
+ const out = {};
60
+ for (const [k, v] of Object.entries(value)) {
61
+ if (typeof v === "string") {
62
+ out[k] = fn(v);
63
+ }
64
+ else {
65
+ out[k] = v; // don't recurse arbitrarily into unknown objects
66
+ }
67
+ }
68
+ return out;
69
+ }
70
+ return value;
71
+ }
72
+ // ---------------------------------------------------------------------------
73
+ // obfuscateMessages (original, no stats)
74
+ // ---------------------------------------------------------------------------
75
+ function obfuscateMessages(messages, obfuscator) {
76
+ return messages.map((msg) => {
77
+ if (!msg || typeof msg !== "object")
78
+ return msg;
79
+ if (typeof msg.content === "string") {
80
+ const result = obfuscator.obfuscate(msg.content);
81
+ if (result.entities.length === 0)
82
+ return msg;
83
+ return { ...msg, content: result.obfuscated };
84
+ }
85
+ if (Array.isArray(msg.content)) {
86
+ const newContent = msg.content.map((block) => {
87
+ if (block && typeof block === "object" && typeof block.text === "string") {
88
+ const result = obfuscator.obfuscate(block.text);
89
+ if (result.entities.length === 0)
90
+ return block;
91
+ return { ...block, text: result.obfuscated };
92
+ }
93
+ return block;
94
+ });
95
+ return { ...msg, content: newContent };
96
+ }
97
+ return msg;
98
+ });
99
+ }
100
+ // ---------------------------------------------------------------------------
101
+ // obfuscateMessagesWithStats (audit-aware)
102
+ // ---------------------------------------------------------------------------
103
+ function obfuscateMessagesWithStats(messages, obfuscator, config) {
104
+ const stats = {
105
+ totalEntities: 0,
106
+ byCategory: {},
107
+ byRule: {},
108
+ messagesTouched: 0,
109
+ blocksTouched: 0,
110
+ inputChars: 0,
111
+ outputChars: 0,
112
+ fakesSample: [],
113
+ };
114
+ const maxFakes = config.auditMaxFakesSample;
115
+ const obfuscatedMessages = messages.map((msg) => {
116
+ if (!msg || typeof msg !== "object")
117
+ return msg;
118
+ if (typeof msg.content === "string") {
119
+ const result = obfuscator.obfuscate(msg.content);
120
+ stats.inputChars += msg.content.length;
121
+ stats.outputChars += result.obfuscated.length;
122
+ if (result.entities.length > 0) {
123
+ stats.messagesTouched++;
124
+ stats.blocksTouched++;
125
+ accumulateStats(stats, result, maxFakes);
126
+ }
127
+ if (result.entities.length === 0)
128
+ return msg;
129
+ return { ...msg, content: result.obfuscated };
130
+ }
131
+ if (Array.isArray(msg.content)) {
132
+ let msgTouched = false;
133
+ const newContent = msg.content.map((block) => {
134
+ if (block && typeof block === "object" && typeof block.text === "string") {
135
+ const result = obfuscator.obfuscate(block.text);
136
+ stats.inputChars += block.text.length;
137
+ stats.outputChars += result.obfuscated.length;
138
+ if (result.entities.length > 0) {
139
+ msgTouched = true;
140
+ stats.blocksTouched++;
141
+ accumulateStats(stats, result, maxFakes);
142
+ }
143
+ if (result.entities.length === 0)
144
+ return block;
145
+ return { ...block, text: result.obfuscated };
146
+ }
147
+ return block;
148
+ });
149
+ if (msgTouched)
150
+ stats.messagesTouched++;
151
+ return { ...msg, content: newContent };
152
+ }
153
+ return msg;
154
+ });
155
+ return { messages: obfuscatedMessages, stats };
156
+ }
157
+ function accumulateStats(stats, result, maxFakes) {
158
+ for (const entity of result.entities) {
159
+ stats.totalEntities++;
160
+ stats.byCategory[entity.category] = (stats.byCategory[entity.category] || 0) + 1;
161
+ stats.byRule[entity.detector] = (stats.byRule[entity.detector] || 0) + 1;
162
+ }
163
+ // Collect fake values only (never real values)
164
+ if (maxFakes > 0 && stats.fakesSample.length < maxFakes) {
165
+ for (const fake of Object.values(result.mappingsUsed)) {
166
+ if (stats.fakesSample.length >= maxFakes)
167
+ break;
168
+ stats.fakesSample.push(fake);
169
+ }
170
+ }
171
+ }
172
+ // ---------------------------------------------------------------------------
173
+ // Audit log emitters
174
+ // ---------------------------------------------------------------------------
175
+ function emitAuditLog(logger, config, requestId, stats, totalMessages, concatenatedOriginal, concatenatedObfuscated) {
176
+ const byCatStr = Object.entries(stats.byCategory)
177
+ .map(([k, v]) => `${k}:${v}`)
178
+ .join(",");
179
+ const byRuleStr = Object.entries(stats.byRule)
180
+ .map(([k, v]) => `${k}:${v}`)
181
+ .join(",");
182
+ const modified = stats.inputChars !== stats.outputChars || stats.totalEntities > 0;
183
+ const charDelta = stats.outputChars - stats.inputChars;
184
+ // Always compute proof hashes when proofs enabled
185
+ let proofHashIn = "";
186
+ let proofHashOut = "";
187
+ if (config.auditIncludeProofHashes) {
188
+ proofHashIn = truncateHash(safeHash(concatenatedOriginal, config.auditHashSalt), config.auditHashTruncate);
189
+ proofHashOut = truncateHash(safeHash(concatenatedObfuscated, config.auditHashSalt), config.auditHashTruncate);
190
+ }
191
+ if (config.auditLogFormat === "json") {
192
+ const obj = {
193
+ event: "shroud.audit.before_llm_send",
194
+ req: requestId,
195
+ ts: new Date().toISOString(),
196
+ modified,
197
+ totalEntities: stats.totalEntities,
198
+ messagesTouched: stats.messagesTouched,
199
+ blocksTouched: stats.blocksTouched,
200
+ inputChars: stats.inputChars,
201
+ outputChars: stats.outputChars,
202
+ charDelta,
203
+ byCategory: stats.byCategory,
204
+ byRule: stats.byRule,
205
+ };
206
+ if (config.auditIncludeProofHashes) {
207
+ obj.proofIn = proofHashIn;
208
+ obj.proofOut = proofHashOut;
209
+ }
210
+ if (config.auditMaxFakesSample > 0 && stats.fakesSample.length > 0) {
211
+ obj.fakesSample = stats.fakesSample;
212
+ }
213
+ logger?.info(JSON.stringify(obj));
214
+ }
215
+ else {
216
+ const parts = [
217
+ `[shroud][audit] OBFUSCATE req=${requestId}`,
218
+ `entities=${stats.totalEntities}`,
219
+ `touched=${stats.messagesTouched}/${totalMessages}`,
220
+ `blocks=${stats.blocksTouched}`,
221
+ `chars=${stats.inputChars}->${stats.outputChars} (delta=${charDelta >= 0 ? "+" : ""}${charDelta})`,
222
+ `modified=${modified ? "YES" : "NO"}`,
223
+ `byCat=${byCatStr || "none"}`,
224
+ `byRule=${byRuleStr || "none"}`,
225
+ ];
226
+ if (config.auditIncludeProofHashes) {
227
+ parts.push(`proof_in=${proofHashIn} proof_out=${proofHashOut}`);
228
+ }
229
+ if (config.auditMaxFakesSample > 0 && stats.fakesSample.length > 0) {
230
+ parts.push(`fakes=[${stats.fakesSample.join("|")}]`);
231
+ }
232
+ logger?.info(parts.join(" | "));
233
+ }
234
+ }
235
+ function emitDeobfuscationAuditLog(logger, config, requestId, replacementCount) {
236
+ if (config.auditLogFormat === "json") {
237
+ logger?.info(JSON.stringify({
238
+ event: "shroud.audit.deobfuscation",
239
+ req: requestId,
240
+ ts: new Date().toISOString(),
241
+ modified: replacementCount > 0,
242
+ deobfuscations: replacementCount,
243
+ }));
244
+ }
245
+ else {
246
+ logger?.info(`[shroud][audit] DEOBFUSCATE req=${requestId} | replacements=${replacementCount} | modified=${replacementCount > 0 ? "YES" : "NO"}`);
247
+ }
248
+ }
249
+ // ---------------------------------------------------------------------------
250
+ // Hook registration
251
+ // ---------------------------------------------------------------------------
252
+ export function registerHooks(api, obfuscator) {
253
+ const config = obfuscator.config;
254
+ const auditActive = config.auditEnabled || config.verboseLogging;
255
+ // -----------------------------------------------------------------------
256
+ // 1. before_prompt_build (async): obfuscate user prompt
257
+ // -----------------------------------------------------------------------
258
+ api.on("before_prompt_build", async (event) => {
259
+ const prompt = event?.prompt;
260
+ if (typeof prompt !== "string" || !prompt)
261
+ return;
262
+ const result = obfuscator.obfuscate(prompt);
263
+ if (result.entities.length === 0)
264
+ return;
265
+ dumpStatsFile(obfuscator);
266
+ api.logger?.info(`[shroud] before_prompt_build: obfuscated ${result.entities.length} entities`);
267
+ return {
268
+ prependContext: [
269
+ "--- SHROUD PRIVACY LAYER ---",
270
+ "The following user message has been privacy-filtered.",
271
+ "Use ONLY the sanitized version below. Do NOT reference the original values.",
272
+ "",
273
+ result.obfuscated,
274
+ "--- END SHROUD ---",
275
+ ].join("\n"),
276
+ };
277
+ });
278
+ // -----------------------------------------------------------------------
279
+ // 2. before_llm_send (async): obfuscate LLM input + provide response deobfuscator
280
+ // -----------------------------------------------------------------------
281
+ api.on("before_llm_send", async (event) => {
282
+ if (!Array.isArray(event?.messages))
283
+ return;
284
+ // Reset tool depth at the start of each LLM turn — tool calls from the
285
+ // previous turn are complete, so the counter should not carry over.
286
+ if (obfuscator.toolDepth > 0) {
287
+ obfuscator.resetToolDepth();
288
+ }
289
+ const totalMessages = event.messages.length;
290
+ let obfuscatedMessages;
291
+ let requestId = "";
292
+ if (auditActive) {
293
+ requestId = randomBytes(8).toString("hex");
294
+ const { messages: msgs, stats } = obfuscateMessagesWithStats(event.messages, obfuscator, config);
295
+ obfuscatedMessages = msgs;
296
+ // Build concatenated texts for proof hashes
297
+ let concatenatedOriginal = "";
298
+ let concatenatedObfuscated = "";
299
+ if (config.auditIncludeProofHashes) {
300
+ for (let i = 0; i < event.messages.length; i++) {
301
+ const orig = event.messages[i];
302
+ const obf = obfuscatedMessages[i];
303
+ if (typeof orig?.content === "string") {
304
+ concatenatedOriginal += orig.content;
305
+ concatenatedObfuscated += (obf?.content ?? "");
306
+ }
307
+ else if (Array.isArray(orig?.content)) {
308
+ for (let j = 0; j < orig.content.length; j++) {
309
+ const origBlock = orig.content[j];
310
+ const obfBlock = obf?.content?.[j];
311
+ if (typeof origBlock?.text === "string") {
312
+ concatenatedOriginal += origBlock.text;
313
+ concatenatedObfuscated += (obfBlock?.text ?? "");
314
+ }
315
+ }
316
+ }
317
+ }
318
+ }
319
+ try {
320
+ emitAuditLog(api.logger, config, requestId, stats, totalMessages, concatenatedOriginal, concatenatedObfuscated);
321
+ }
322
+ catch {
323
+ // Logging is best-effort
324
+ }
325
+ }
326
+ else {
327
+ obfuscatedMessages = obfuscateMessages(event.messages, obfuscator);
328
+ }
329
+ dumpStatsFile(obfuscator);
330
+ api.logger?.info("[shroud] before_llm_send: obfuscated messages + installed transformResponse");
331
+ const capturedReqId = requestId;
332
+ return {
333
+ messages: obfuscatedMessages,
334
+ transformResponse: (text) => {
335
+ if (auditActive) {
336
+ try {
337
+ const { text: deobfuscated, replacementCount } = obfuscator.deobfuscateWithStats(text);
338
+ if (replacementCount > 0) {
339
+ try {
340
+ emitDeobfuscationAuditLog(api.logger, config, capturedReqId, replacementCount);
341
+ }
342
+ catch {
343
+ // best-effort
344
+ }
345
+ }
346
+ dumpStatsFile(obfuscator);
347
+ return deobfuscated;
348
+ }
349
+ catch {
350
+ const result = obfuscator.deobfuscate(text);
351
+ dumpStatsFile(obfuscator);
352
+ return result;
353
+ }
354
+ }
355
+ const result = obfuscator.deobfuscate(text);
356
+ dumpStatsFile(obfuscator);
357
+ return result;
358
+ },
359
+ };
360
+ });
361
+ // -----------------------------------------------------------------------
362
+ // 3. before_tool_call (async): deobfuscate tool params + track depth
363
+ // -----------------------------------------------------------------------
364
+ api.on("before_tool_call", async (event) => {
365
+ if (!event?.params || typeof event.params !== "object")
366
+ return;
367
+ // Tool chain depth tracking
368
+ const depth = obfuscator.enterToolCall();
369
+ if (depth > config.maxToolDepth) {
370
+ api.logger?.warn(`[shroud][depth] Tool chain depth ${depth} exceeds max ${config.maxToolDepth} — possible infinite recursion`);
371
+ }
372
+ if (depth > 1) {
373
+ api.logger?.info(`[shroud][depth] Nested tool call at depth ${depth}: ${event.toolName ?? "?"}`);
374
+ }
375
+ const serialized = JSON.stringify(event.params);
376
+ const deobfuscated = obfuscator.deobfuscate(serialized);
377
+ if (serialized === deobfuscated)
378
+ return;
379
+ api.logger?.info(`[shroud] before_tool_call(${event.toolName ?? "?"}): deobfuscated params`);
380
+ try {
381
+ return { params: JSON.parse(deobfuscated) };
382
+ }
383
+ catch {
384
+ api.logger?.warn("[shroud] Failed to parse deobfuscated tool params");
385
+ }
386
+ });
387
+ // -----------------------------------------------------------------------
388
+ // 4. tool_result_persist (SYNC): obfuscate tool result message
389
+ // -----------------------------------------------------------------------
390
+ api.on("tool_result_persist", (event) => {
391
+ if (!event?.message)
392
+ return;
393
+ // Exit tool depth
394
+ obfuscator.exitToolCall();
395
+ const obfuscated = walkStrings(event.message, (s) => {
396
+ const result = obfuscator.obfuscate(s);
397
+ return result.obfuscated;
398
+ });
399
+ dumpStatsFile(obfuscator);
400
+ return { message: obfuscated };
401
+ });
402
+ // -----------------------------------------------------------------------
403
+ // 5. message_sending (async): deobfuscate outbound message content
404
+ // -----------------------------------------------------------------------
405
+ api.on("message_sending", async (event) => {
406
+ if (typeof event?.content !== "string")
407
+ return;
408
+ const deobfuscated = obfuscator.deobfuscate(event.content);
409
+ if (deobfuscated === event.content)
410
+ return;
411
+ api.logger?.info("[shroud] message_sending: deobfuscated outbound message");
412
+ dumpStatsFile(obfuscator);
413
+ return { content: deobfuscated };
414
+ });
415
+ // -----------------------------------------------------------------------
416
+ // Tool: shroud-stats — rulebase view with hit counters
417
+ // -----------------------------------------------------------------------
418
+ api.registerTool({
419
+ name: "shroud-stats",
420
+ description: "Show Shroud privacy plugin status: active rules, per-rule hit counts, store size, and config summary.",
421
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
422
+ handler: async () => {
423
+ const stats = obfuscator.config;
424
+ const overrides = stats.detectorOverrides;
425
+ const obStats = obfuscator.getStats();
426
+ const rules = BUILTIN_PATTERNS.map((p) => {
427
+ const ov = overrides[p.name];
428
+ const enabled = ov?.enabled !== false;
429
+ const confidence = ov?.confidence ?? p.confidence;
430
+ const hits = obStats.ruleHits[`regex:${p.name}`] ?? 0;
431
+ return { name: p.name, category: p.category, enabled, confidence, hits };
432
+ });
433
+ rules.sort((a, b) => b.hits - a.hits);
434
+ const maxName = Math.max(...rules.map((r) => r.name.length), 4);
435
+ const maxCat = Math.max(...rules.map((r) => r.category.length), 8);
436
+ const header = `${"Rule".padEnd(maxName)} ${"Category".padEnd(maxCat)} Status Conf Hits`;
437
+ const sep = "─".repeat(header.length);
438
+ const rows = rules.map((r) => {
439
+ const status = r.enabled ? "active" : "DISABLED";
440
+ const bar = r.hits > 0 ? " " + "█".repeat(Math.min(Math.ceil(Math.log2(r.hits + 1)), 16)) : "";
441
+ return `${r.name.padEnd(maxName)} ${r.category.padEnd(maxCat)} ${status.padEnd(8)} ${r.confidence.toFixed(2).padStart(4)} ${String(r.hits).padStart(5)}${bar}`;
442
+ });
443
+ const lines = [
444
+ `Shroud Rule Hits (since gateway start)`,
445
+ sep,
446
+ header,
447
+ sep,
448
+ ...rows,
449
+ sep,
450
+ `Store: ${obStats.storeMappings} active mappings`,
451
+ `Audit: ${stats.auditEnabled || stats.verboseLogging ? "enabled" : "disabled"}`,
452
+ `Redaction: ${stats.redactionLevel}`,
453
+ ];
454
+ return { content: [{ type: "text", text: lines.join("\n") }] };
455
+ },
456
+ });
457
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Shroud -- OpenClaw privacy plugin.
3
+ *
4
+ * Automatically obfuscates sensitive data before it reaches the LLM
5
+ * and deobfuscates responses before they reach the user.
6
+ */
7
+ declare const _default: {
8
+ id: string;
9
+ name: string;
10
+ register(api: any): void;
11
+ };
12
+ export default _default;
package/dist/index.js ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Shroud -- OpenClaw privacy plugin.
3
+ *
4
+ * Automatically obfuscates sensitive data before it reaches the LLM
5
+ * and deobfuscates responses before they reach the user.
6
+ */
7
+ import { resolveConfig } from "./config.js";
8
+ import { Obfuscator } from "./obfuscator.js";
9
+ import { registerHooks } from "./hooks.js";
10
+ export default {
11
+ id: "shroud-privacy",
12
+ name: "Shroud",
13
+ register(api) {
14
+ const config = resolveConfig(api.pluginConfig);
15
+ const obfuscator = new Obfuscator(config);
16
+ registerHooks(api, obfuscator);
17
+ // Register shroud_status tool
18
+ api.registerTool({
19
+ name: "shroud_status",
20
+ description: "Show Shroud privacy stats: entity counts, session info, audit status",
21
+ inputSchema: {
22
+ type: "object",
23
+ properties: {},
24
+ additionalProperties: false,
25
+ },
26
+ handler: async () => ({
27
+ content: [
28
+ {
29
+ type: "text",
30
+ text: JSON.stringify(obfuscator.getStats(), null, 2),
31
+ },
32
+ ],
33
+ }),
34
+ });
35
+ // Register shroud_reset tool
36
+ api.registerTool({
37
+ name: "shroud_reset",
38
+ description: "Clear all Shroud mappings and start a fresh privacy session",
39
+ inputSchema: {
40
+ type: "object",
41
+ properties: {},
42
+ additionalProperties: false,
43
+ },
44
+ handler: async () => {
45
+ obfuscator.reset();
46
+ return {
47
+ content: [
48
+ {
49
+ type: "text",
50
+ text: "Shroud session reset. All mappings cleared.",
51
+ },
52
+ ],
53
+ };
54
+ },
55
+ });
56
+ api.logger?.info("[shroud] Plugin loaded — native TypeScript, no proxy required.");
57
+ },
58
+ };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Deterministic mapping engine using HMAC + salt for irreversible obfuscation.
3
+ *
4
+ * Uses HMAC-SHA256(secretKey, salt + tenantId + value) to produce a seed, then
5
+ * indexes into the appropriate generator's fake value pool. The salt adds
6
+ * randomness so that the same real value produces different fake values across
7
+ * sessions (unless the same salt is reused), preventing inference attacks.
8
+ *
9
+ * When tenantId is set, it's incorporated into the HMAC message, ensuring the
10
+ * same value produces different fakes for different tenants.
11
+ */
12
+ import { Category } from "./types.js";
13
+ import type { BaseGenerator } from "./generators/base.js";
14
+ import { SubnetMapper } from "./generators/network.js";
15
+ export declare class MappingEngine {
16
+ private readonly _secretKey;
17
+ private readonly _salt;
18
+ private readonly _tenantId;
19
+ private readonly _generators;
20
+ constructor(secretKey: string, salt?: string, subnetMapper?: SubnetMapper, tenantId?: string);
21
+ /** Return the current salt (for persistence/restore). */
22
+ get salt(): string;
23
+ private _registerDefaults;
24
+ /** Register a custom generator for a category. */
25
+ registerGenerator(category: Category, generator: BaseGenerator): void;
26
+ /**
27
+ * Compute a deterministic seed from a real value using HMAC + salt + tenantId.
28
+ * Reads first 6 bytes as unsigned int (stays in safe integer range).
29
+ */
30
+ computeSeed(value: string): number;
31
+ /** Map a real value to a fake value, preserving format of original. */
32
+ mapValue(value: string, category: Category): string;
33
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Deterministic mapping engine using HMAC + salt for irreversible obfuscation.
3
+ *
4
+ * Uses HMAC-SHA256(secretKey, salt + tenantId + value) to produce a seed, then
5
+ * indexes into the appropriate generator's fake value pool. The salt adds
6
+ * randomness so that the same real value produces different fake values across
7
+ * sessions (unless the same salt is reused), preventing inference attacks.
8
+ *
9
+ * When tenantId is set, it's incorporated into the HMAC message, ensuring the
10
+ * same value produces different fakes for different tenants.
11
+ */
12
+ import { createHmac, randomBytes } from "node:crypto";
13
+ import { NameGenerator } from "./generators/names.js";
14
+ import { NetworkGenerator, SubnetMapper } from "./generators/network.js";
15
+ import { CodeGenerator } from "./generators/codes.js";
16
+ export class MappingEngine {
17
+ _secretKey;
18
+ _salt;
19
+ _tenantId;
20
+ _generators = new Map();
21
+ constructor(secretKey, salt, subnetMapper, tenantId) {
22
+ this._secretKey = Buffer.from(secretKey, "utf-8");
23
+ this._salt = Buffer.from(salt ?? randomBytes(16).toString("hex"), "utf-8");
24
+ this._tenantId = tenantId ?? "";
25
+ this._registerDefaults(subnetMapper);
26
+ }
27
+ /** Return the current salt (for persistence/restore). */
28
+ get salt() {
29
+ return this._salt.toString("utf-8");
30
+ }
31
+ _registerDefaults(subnetMapper) {
32
+ const generators = [
33
+ new NameGenerator(),
34
+ new NetworkGenerator(subnetMapper ?? new SubnetMapper()),
35
+ new CodeGenerator(),
36
+ ];
37
+ for (const gen of generators) {
38
+ for (const cat of gen.categories) {
39
+ this._generators.set(cat, gen);
40
+ }
41
+ }
42
+ }
43
+ /** Register a custom generator for a category. */
44
+ registerGenerator(category, generator) {
45
+ this._generators.set(category, generator);
46
+ }
47
+ /**
48
+ * Compute a deterministic seed from a real value using HMAC + salt + tenantId.
49
+ * Reads first 6 bytes as unsigned int (stays in safe integer range).
50
+ */
51
+ computeSeed(value) {
52
+ const parts = [this._salt];
53
+ if (this._tenantId) {
54
+ parts.push(Buffer.from(this._tenantId, "utf-8"));
55
+ }
56
+ parts.push(Buffer.from(value, "utf-8"));
57
+ const msg = Buffer.concat(parts);
58
+ const digest = createHmac("sha256", this._secretKey).update(msg).digest();
59
+ return digest.readUIntBE(0, 6);
60
+ }
61
+ /** Map a real value to a fake value, preserving format of original. */
62
+ mapValue(value, category) {
63
+ const gen = this._generators.get(category);
64
+ if (gen === undefined) {
65
+ // Fallback: hash-based opaque token
66
+ const seed = this.computeSeed(value);
67
+ return `[REDACTED-${category}-${String(seed % 10000).padStart(4, "0")}]`;
68
+ }
69
+ const seed = this.computeSeed(value);
70
+ return gen.generate(category, seed, value);
71
+ }
72
+ }