shroud-privacy 2.2.20 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -233,18 +233,78 @@ Out of the box, Shroud:
233
233
 
234
234
  > **Env var overrides:** `SHROUD_SECRET_KEY` and `SHROUD_PERSISTENT_SALT` override their respective config keys (priority: env var > plugin config > default).
235
235
 
236
- ### Detector overrides
236
+ ### Detection rules as code (hot-reload)
237
237
 
238
- Disable or tune individual detection rules by name. Rule names match the built-in pattern names (e.g. `email`, `ipv4`, `phone_intl`, `cisco_enable_secret`). See `src/detectors/regex.ts` for the full list.
238
+ Shroud auto-generates a JSONC config file on first run containing every built-in detection rule:
239
+
240
+ ```
241
+ ~/.shroud/shroud.config.json
242
+ ```
243
+
244
+ The file is fully editable. Changes hot-reload within 2 seconds — no gateway restart needed.
245
+
246
+ **Priority:** env vars > config file > plugin config > defaults.
247
+
248
+ #### Override a built-in rule
249
+
250
+ Change the regex, confidence, or category of any rule:
251
+
252
+ ```jsonc
253
+ {
254
+ "rules": {
255
+ "email": { "pattern": "\\b[\\w.+-]+@[\\w-]+\\.[a-z]{2,}\\b", "confidence": 0.99 }
256
+ }
257
+ }
258
+ ```
259
+
260
+ #### Disable a rule
239
261
 
240
262
  ```jsonc
241
- "detectorOverrides": {
242
- "phone_intl": { "enabled": false }, // disable international phone detection
243
- "file_path_unix": { "confidence": 0.5 }, // lower confidence (filtered by minConfidence)
244
- "snmp_community": { "confidence": 1.0 } // boost to always match
263
+ {
264
+ "rules": {
265
+ "phone_us": { "enabled": false },
266
+ "gps_coordinate": { "enabled": false }
267
+ }
268
+ }
269
+ ```
270
+
271
+ #### Add a custom rule
272
+
273
+ ```jsonc
274
+ {
275
+ "rules": {
276
+ "internal_ticket": {
277
+ "pattern": "\\bTICK-\\d{6}\\b",
278
+ "category": "custom",
279
+ "confidence": 0.9
280
+ }
281
+ }
245
282
  }
246
283
  ```
247
284
 
285
+ #### Rule format
286
+
287
+ Each rule in the `rules` object supports:
288
+
289
+ | Field | Type | Description |
290
+ |-------|------|-------------|
291
+ | `pattern` | string | Regex pattern (required for new rules, optional for overrides) |
292
+ | `category` | string | Entity category: `email`, `ip_address`, `phone`, `hostname`, `network_credential`, `custom`, etc. |
293
+ | `confidence` | number | Detection confidence 0.0-1.0 (filtered by `minConfidence`) |
294
+ | `enabled` | boolean | Set to `false` to disable a rule |
295
+
296
+ #### Config manager features
297
+
298
+ | Feature | Detail |
299
+ |---------|--------|
300
+ | Format | JSONC (JSON with `//` and `/* */` comments) |
301
+ | Auto-create | Config file generated on first run with all built-in rules |
302
+ | Watch interval | 2 seconds |
303
+ | History depth | 50 versions (commit/rollback via dashboard API) |
304
+ | Restart-only fields | `secretKey`, `persistentSalt`, `dashboardEnabled`, `dashboardPort`, `maxStoreMappings` — logged as warnings, not applied until restart |
305
+
306
+ > **Legacy:** `detectorOverrides` and `customPatterns` in `openclaw.json` still work. The `rules` config is the preferred way — it replaces both.
307
+
248
308
  ---
249
309
 
250
310
  ## URL handling
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Config-as-code manager with hot-reload.
3
+ *
4
+ * Watches a JSONC config file (`~/.shroud/shroud.config.json` by default)
5
+ * and merges it with the base config from `resolveConfig(pluginConfig)`.
6
+ *
7
+ * Priority: env vars > config file > plugin config > defaults.
8
+ *
9
+ * Supports:
10
+ * - JSONC (JSON with // and /* comments)
11
+ * - Keyed field-change callbacks (only fires when watched fields change)
12
+ * - Generic reload callbacks
13
+ * - Commit/rollback with 50-version history
14
+ * - Dashboard read/write via setFields()
15
+ */
16
+ import type { ShroudConfig } from "./types.js";
17
+ import { type ConfigIssue } from "./config.js";
18
+ /** A versioned config snapshot. */
19
+ export interface ConfigCommit {
20
+ version: number;
21
+ timestamp: string;
22
+ description: string;
23
+ config: Partial<ShroudConfig>;
24
+ }
25
+ export declare class ConfigManager {
26
+ private _configPath;
27
+ private _historyPath;
28
+ private _base;
29
+ private _fileOverrides;
30
+ private _effective;
31
+ private _fieldListeners;
32
+ private _reloadListeners;
33
+ private _history;
34
+ private _version;
35
+ private _watching;
36
+ constructor(configPath: string, baseConfig: ShroudConfig);
37
+ /** Current effective config (merged). */
38
+ getEffective(): ShroudConfig;
39
+ /** Raw overrides from the config file. */
40
+ getFileOverrides(): Partial<ShroudConfig>;
41
+ /** Path to the config file. */
42
+ getConfigPath(): string;
43
+ /** Current version number (increments on each commit). */
44
+ getCurrentVersion(): number;
45
+ /**
46
+ * Register a callback that fires when any of the specified fields change.
47
+ * The callback is deduplicated: even if multiple watched fields change in
48
+ * one reload, the callback fires exactly once.
49
+ */
50
+ onFieldChange(fields: string[], callback: () => void): void;
51
+ /** Register a callback that fires on every successful reload. */
52
+ onReload(callback: (config: ShroudConfig) => void): void;
53
+ startWatching(): void;
54
+ stopWatching(): void;
55
+ /** Merge partial config into the file and trigger reload. */
56
+ setFields(partial: Partial<ShroudConfig>): {
57
+ changedFields: string[];
58
+ warnings: string[];
59
+ };
60
+ /** Validate a partial config without applying it. */
61
+ validate(partial: Partial<ShroudConfig>): ConfigIssue[];
62
+ commit(description: string): ConfigCommit;
63
+ rollback(version: number): ConfigCommit | null;
64
+ getHistory(): ConfigCommit[];
65
+ /** Generate default config file with all built-in detection rules. */
66
+ private _writeDefaultConfig;
67
+ private _reload;
68
+ private _loadFile;
69
+ private _saveFile;
70
+ private _merge;
71
+ private _diff;
72
+ private _loadHistory;
73
+ private _saveHistory;
74
+ }
75
+ /**
76
+ * Strip // and /* comments from JSONC text.
77
+ * Handles strings correctly (doesn't strip inside quoted values).
78
+ */
79
+ export declare function stripComments(jsonc: string): string;
@@ -0,0 +1,417 @@
1
+ /**
2
+ * Config-as-code manager with hot-reload.
3
+ *
4
+ * Watches a JSONC config file (`~/.shroud/shroud.config.json` by default)
5
+ * and merges it with the base config from `resolveConfig(pluginConfig)`.
6
+ *
7
+ * Priority: env vars > config file > plugin config > defaults.
8
+ *
9
+ * Supports:
10
+ * - JSONC (JSON with // and /* comments)
11
+ * - Keyed field-change callbacks (only fires when watched fields change)
12
+ * - Generic reload callbacks
13
+ * - Commit/rollback with 50-version history
14
+ * - Dashboard read/write via setFields()
15
+ */
16
+ import { readFileSync, writeFileSync, existsSync, watchFile, unwatchFile, mkdirSync } from "node:fs";
17
+ import { dirname } from "node:path";
18
+ import { validateConfig } from "./config.js";
19
+ import { BUILTIN_PATTERNS } from "./detectors/regex.js";
20
+ /** Fields that cannot be hot-reloaded — require gateway restart. */
21
+ const RESTART_ONLY = new Set([
22
+ "secretKey",
23
+ "persistentSalt",
24
+ "dashboardEnabled",
25
+ "dashboardPort",
26
+ "maxStoreMappings",
27
+ ]);
28
+ export class ConfigManager {
29
+ _configPath;
30
+ _historyPath;
31
+ _base;
32
+ _fileOverrides;
33
+ _effective;
34
+ _fieldListeners = [];
35
+ _reloadListeners = [];
36
+ _history = [];
37
+ _version = 0;
38
+ _watching = false;
39
+ constructor(configPath, baseConfig) {
40
+ this._configPath = configPath;
41
+ this._historyPath = configPath + ".history.json";
42
+ this._base = baseConfig;
43
+ // Auto-create config file with built-in rules if it doesn't exist
44
+ if (!existsSync(configPath)) {
45
+ this._writeDefaultConfig();
46
+ }
47
+ this._fileOverrides = this._loadFile();
48
+ this._effective = this._merge(this._base, this._fileOverrides);
49
+ this._loadHistory();
50
+ }
51
+ /** Current effective config (merged). */
52
+ getEffective() {
53
+ return this._effective;
54
+ }
55
+ /** Raw overrides from the config file. */
56
+ getFileOverrides() {
57
+ return { ...this._fileOverrides };
58
+ }
59
+ /** Path to the config file. */
60
+ getConfigPath() {
61
+ return this._configPath;
62
+ }
63
+ /** Current version number (increments on each commit). */
64
+ getCurrentVersion() {
65
+ return this._version;
66
+ }
67
+ // ── Subscriptions ──────────────────────────────────────
68
+ /**
69
+ * Register a callback that fires when any of the specified fields change.
70
+ * The callback is deduplicated: even if multiple watched fields change in
71
+ * one reload, the callback fires exactly once.
72
+ */
73
+ onFieldChange(fields, callback) {
74
+ this._fieldListeners.push({ fields: new Set(fields), callback });
75
+ }
76
+ /** Register a callback that fires on every successful reload. */
77
+ onReload(callback) {
78
+ this._reloadListeners.push(callback);
79
+ }
80
+ // ── File watching ──────────────────────────────────────
81
+ startWatching() {
82
+ if (this._watching)
83
+ return;
84
+ this._watching = true;
85
+ // Ensure parent directory exists so watchFile can stat the path
86
+ mkdirSync(dirname(this._configPath), { recursive: true });
87
+ watchFile(this._configPath, { interval: 2000 }, () => {
88
+ this._reload();
89
+ });
90
+ }
91
+ stopWatching() {
92
+ if (!this._watching)
93
+ return;
94
+ this._watching = false;
95
+ unwatchFile(this._configPath);
96
+ }
97
+ // ── Programmatic writes (dashboard) ────────────────────
98
+ /** Merge partial config into the file and trigger reload. */
99
+ setFields(partial) {
100
+ const warnings = [];
101
+ const cleaned = {};
102
+ for (const [key, value] of Object.entries(partial)) {
103
+ if (RESTART_ONLY.has(key)) {
104
+ warnings.push(`"${key}" requires gateway restart — skipped`);
105
+ continue;
106
+ }
107
+ cleaned[key] = value;
108
+ }
109
+ // Merge with existing file overrides
110
+ const merged = { ...this._fileOverrides, ...cleaned };
111
+ this._saveFile(merged);
112
+ const changedFields = this._reload();
113
+ return { changedFields, warnings };
114
+ }
115
+ /** Validate a partial config without applying it. */
116
+ validate(partial) {
117
+ const testConfig = this._merge(this._base, { ...this._fileOverrides, ...partial });
118
+ return validateConfig(testConfig);
119
+ }
120
+ // ── Commit / rollback ──────────────────────────────────
121
+ commit(description) {
122
+ this._version++;
123
+ const entry = {
124
+ version: this._version,
125
+ timestamp: new Date().toISOString(),
126
+ description,
127
+ config: { ...this._fileOverrides },
128
+ };
129
+ this._history.push(entry);
130
+ // Cap at 50
131
+ if (this._history.length > 50) {
132
+ this._history = this._history.slice(-50);
133
+ }
134
+ this._saveHistory();
135
+ return entry;
136
+ }
137
+ rollback(version) {
138
+ const entry = this._history.find(h => h.version === version);
139
+ if (!entry)
140
+ return null;
141
+ this._saveFile(entry.config);
142
+ this._reload();
143
+ return entry;
144
+ }
145
+ getHistory() {
146
+ return [...this._history];
147
+ }
148
+ // ── Internal ───────────────────────────────────────────
149
+ /** Generate default config file with all built-in detection rules. */
150
+ _writeDefaultConfig() {
151
+ const lines = [
152
+ "{",
153
+ " // Shroud config-as-code — auto-generated with built-in detection rules.",
154
+ " // Edit rules here. Changes hot-reload within 2 seconds (no restart needed).",
155
+ " // Priority: env vars > this file > plugin config > defaults.",
156
+ " //",
157
+ " // Rule format:",
158
+ ' // "rule_name": {',
159
+ ' // "pattern": "regex string", // override or define the detection regex',
160
+ ' // "category": "email", // entity category (email, ip_address, phone, etc.)',
161
+ ' // "confidence": 0.95, // detection confidence (0.0-1.0)',
162
+ ' // "enabled": false // set to false to disable a rule',
163
+ " // }",
164
+ " //",
165
+ ' "rules": {',
166
+ ];
167
+ for (let i = 0; i < BUILTIN_PATTERNS.length; i++) {
168
+ const p = BUILTIN_PATTERNS[i];
169
+ const comma = i < BUILTIN_PATTERNS.length - 1 ? "," : "";
170
+ // Convert RegExp to source string
171
+ lines.push(` "${p.name}": { "pattern": ${JSON.stringify(p.pattern.source)}, "category": "${p.category}", "confidence": ${p.confidence} }${comma}`);
172
+ }
173
+ lines.push(" }");
174
+ lines.push("}");
175
+ lines.push("");
176
+ try {
177
+ mkdirSync(dirname(this._configPath), { recursive: true });
178
+ writeFileSync(this._configPath, lines.join("\n"), "utf-8");
179
+ }
180
+ catch { /* non-fatal — config file is optional */ }
181
+ }
182
+ _reload() {
183
+ const oldEffective = this._effective;
184
+ try {
185
+ this._fileOverrides = this._loadFile();
186
+ this._effective = this._merge(this._base, this._fileOverrides);
187
+ }
188
+ catch {
189
+ // Corrupt file — keep previous config
190
+ return [];
191
+ }
192
+ const changed = this._diff(oldEffective, this._effective);
193
+ // Filter out restart-only fields — log warning but don't apply
194
+ const restartChanged = changed.filter(f => RESTART_ONLY.has(f));
195
+ if (restartChanged.length > 0) {
196
+ console.warn(`[shroud][config] Fields require restart (not applied): ${restartChanged.join(", ")}`);
197
+ // Revert restart-only fields to base values
198
+ for (const field of restartChanged) {
199
+ this._effective[field] =
200
+ oldEffective[field];
201
+ }
202
+ }
203
+ const effectiveChanged = changed.filter(f => !RESTART_ONLY.has(f));
204
+ if (effectiveChanged.length > 0) {
205
+ // Fire field-specific listeners
206
+ const firedCallbacks = new Set();
207
+ for (const listener of this._fieldListeners) {
208
+ if (effectiveChanged.some(f => listener.fields.has(f)) && !firedCallbacks.has(listener.callback)) {
209
+ firedCallbacks.add(listener.callback);
210
+ try {
211
+ listener.callback();
212
+ }
213
+ catch { /* non-fatal */ }
214
+ }
215
+ }
216
+ // Fire generic reload listeners
217
+ for (const cb of this._reloadListeners) {
218
+ try {
219
+ cb(this._effective);
220
+ }
221
+ catch { /* non-fatal */ }
222
+ }
223
+ }
224
+ return effectiveChanged;
225
+ }
226
+ _loadFile() {
227
+ if (!existsSync(this._configPath))
228
+ return {};
229
+ try {
230
+ const raw = readFileSync(this._configPath, "utf-8");
231
+ const stripped = stripComments(raw);
232
+ return JSON.parse(stripped);
233
+ }
234
+ catch {
235
+ return {};
236
+ }
237
+ }
238
+ _saveFile(config) {
239
+ try {
240
+ mkdirSync(dirname(this._configPath), { recursive: true });
241
+ writeFileSync(this._configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
242
+ }
243
+ catch (err) {
244
+ console.warn(`[shroud][config] Failed to write config file: ${err}`);
245
+ }
246
+ }
247
+ _merge(base, overlay) {
248
+ const result = { ...base };
249
+ for (const [key, value] of Object.entries(overlay)) {
250
+ if (value !== undefined) {
251
+ result[key] = value;
252
+ }
253
+ }
254
+ // Re-apply env var overrides (env always wins)
255
+ return applyEnvOverrides(result);
256
+ }
257
+ _diff(oldConfig, newConfig) {
258
+ const changed = [];
259
+ const allKeys = Array.from(new Set([
260
+ ...Object.keys(oldConfig),
261
+ ...Object.keys(newConfig),
262
+ ]));
263
+ for (const key of allKeys) {
264
+ const oldVal = oldConfig[key];
265
+ const newVal = newConfig[key];
266
+ if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
267
+ changed.push(key);
268
+ }
269
+ }
270
+ return changed;
271
+ }
272
+ _loadHistory() {
273
+ if (!existsSync(this._historyPath))
274
+ return;
275
+ try {
276
+ const raw = readFileSync(this._historyPath, "utf-8");
277
+ const parsed = JSON.parse(raw);
278
+ if (Array.isArray(parsed)) {
279
+ this._history = parsed;
280
+ const maxVersion = this._history.reduce((max, h) => Math.max(max, h.version), 0);
281
+ this._version = maxVersion;
282
+ }
283
+ }
284
+ catch { /* start fresh */ }
285
+ }
286
+ _saveHistory() {
287
+ try {
288
+ mkdirSync(dirname(this._historyPath), { recursive: true });
289
+ writeFileSync(this._historyPath, JSON.stringify(this._history, null, 2) + "\n", "utf-8");
290
+ }
291
+ catch { /* non-fatal */ }
292
+ }
293
+ }
294
+ /**
295
+ * Strip // and /* comments from JSONC text.
296
+ * Handles strings correctly (doesn't strip inside quoted values).
297
+ */
298
+ export function stripComments(jsonc) {
299
+ let result = "";
300
+ let i = 0;
301
+ const len = jsonc.length;
302
+ while (i < len) {
303
+ // String literal — copy verbatim
304
+ if (jsonc[i] === '"') {
305
+ const start = i;
306
+ i++; // opening quote
307
+ while (i < len && jsonc[i] !== '"') {
308
+ if (jsonc[i] === "\\")
309
+ i++; // skip escaped char
310
+ i++;
311
+ }
312
+ i++; // closing quote
313
+ result += jsonc.slice(start, i);
314
+ continue;
315
+ }
316
+ // Line comment
317
+ if (jsonc[i] === "/" && jsonc[i + 1] === "/") {
318
+ while (i < len && jsonc[i] !== "\n")
319
+ i++;
320
+ continue;
321
+ }
322
+ // Block comment
323
+ if (jsonc[i] === "/" && jsonc[i + 1] === "*") {
324
+ i += 2;
325
+ while (i < len && !(jsonc[i] === "*" && jsonc[i + 1] === "/"))
326
+ i++;
327
+ i += 2; // skip */
328
+ continue;
329
+ }
330
+ result += jsonc[i];
331
+ i++;
332
+ }
333
+ return result;
334
+ }
335
+ /**
336
+ * Re-apply environment variable overrides to a config.
337
+ * Env vars always win over file and plugin config.
338
+ */
339
+ function applyEnvOverrides(config) {
340
+ const result = { ...config };
341
+ const env = process.env;
342
+ // Boolean env overrides
343
+ const boolOverrides = [
344
+ ["SHROUD_CANARY_ENABLED", "canaryEnabled"],
345
+ ["SHROUD_HONEYPOT_ENABLED", "honeypotEnabled"],
346
+ ["SHROUD_PROFILING_ENABLED", "profilingEnabled"],
347
+ ["SHROUD_CANARY_SYSTEM", "canarySystemInjection"],
348
+ ["SHROUD_CANARY_BEHAVIOURAL", "canaryBehavioural"],
349
+ ["SHROUD_INJECTION_SCAN_RESPONSES", "injectionScanResponses"],
350
+ ["SHROUD_DRIFT_ENABLED", "driftEnabled"],
351
+ ["SHROUD_SHADOW_EXECUTION", "shadowExecutionEnabled"],
352
+ ["SHROUD_DASHBOARD", "dashboardEnabled"],
353
+ ["SHROUD_COHERENCE_ENABLED", "coherenceEnabled"],
354
+ ["SHROUD_VECTOR_STORE_ENABLED", "vectorStoreEnabled"],
355
+ ["SHROUD_CLUSTERING_ENABLED", "clusteringEnabled"],
356
+ ["SHROUD_URL_CORRELATION_ENABLED", "urlCorrelationEnabled"],
357
+ ["SHROUD_INTENT_CHAIN_ENABLED", "intentChainEnabled"],
358
+ ["SHROUD_TRANSFORMER_ENABLED", "transformerEnabled"],
359
+ ];
360
+ for (const [envKey, field] of boolOverrides) {
361
+ if (env[envKey] === "true")
362
+ result[field] = true;
363
+ else if (env[envKey] === "false")
364
+ result[field] = false;
365
+ }
366
+ // Enum env overrides
367
+ if (env.SHROUD_INJECTION_DETECTION === "flag" || env.SHROUD_INJECTION_DETECTION === "block" || env.SHROUD_INJECTION_DETECTION === "off") {
368
+ result.injectionDetection = env.SHROUD_INJECTION_DETECTION;
369
+ }
370
+ if (env.SHROUD_INJECTION_MIN_SEVERITY === "low" || env.SHROUD_INJECTION_MIN_SEVERITY === "medium" || env.SHROUD_INJECTION_MIN_SEVERITY === "high") {
371
+ result.injectionMinSeverity = env.SHROUD_INJECTION_MIN_SEVERITY;
372
+ }
373
+ if (env.SHROUD_PROFILING_MODE === "learning" || env.SHROUD_PROFILING_MODE === "active" || env.SHROUD_PROFILING_MODE === "strict") {
374
+ result.profilingMode = env.SHROUD_PROFILING_MODE;
375
+ }
376
+ // String env overrides
377
+ const stringOverrides = [
378
+ ["SHROUD_SECRET_KEY", "secretKey"],
379
+ ["SHROUD_PERSISTENT_SALT", "persistentSalt"],
380
+ ["SHROUD_PROFILING_DIR", "profilingProfileDir"],
381
+ ["SHROUD_SIGNATURES_URL", "signaturesUrl"],
382
+ ["SHROUD_SIGNATURES_FILE", "signaturesFile"],
383
+ ["SHROUD_SIEM_WEBHOOK_URL", "siemWebhookUrl"],
384
+ ["SHROUD_SIEM_WEBHOOK_AUTH", "siemWebhookAuth"],
385
+ ["SHROUD_SIEM_JSONL_PATH", "siemJsonlPath"],
386
+ ];
387
+ for (const [envKey, field] of stringOverrides) {
388
+ if (env[envKey])
389
+ result[field] = env[envKey];
390
+ }
391
+ // Numeric env overrides
392
+ const numOverrides = [
393
+ ["SHROUD_DRIFT_THRESHOLD", "driftThreshold"],
394
+ ["SHROUD_DRIFT_SUDDEN_TURN", "driftSuddenTurnDelta"],
395
+ ["SHROUD_COHERENCE_ZSCORE", "coherenceZScore"],
396
+ ["SHROUD_COHERENCE_RESULT_LIMIT", "coherenceResultLimit"],
397
+ ["SHROUD_TRANSFORMER_THRESHOLD", "transformerThreshold"],
398
+ ["SHROUD_TRANSFORMER_WINDOW", "transformerWindowSize"],
399
+ ["SHROUD_TRANSFORMER_MIN_SESSIONS", "transformerMinSessions"],
400
+ ["SHROUD_TRANSFORMER_TRAIN_INTERVAL", "transformerTrainInterval"],
401
+ ["SHROUD_TRANSFORMER_INTENT_ATTENTION_THRESHOLD", "transformerIntentAttentionThreshold"],
402
+ ["SHROUD_SIGNATURES_REFRESH", "signaturesRefreshSec"],
403
+ ["SHROUD_DASHBOARD_PORT", "dashboardPort"],
404
+ ["SHROUD_VECTOR_STORE_MAX", "vectorStoreMax"],
405
+ ["SHROUD_DELEGATION_DRIFT_THRESHOLD", "delegationDriftThreshold"],
406
+ ["SHROUD_SHADOW_TIMEOUT", "shadowExecutionTimeoutMs"],
407
+ ["SHROUD_HONEYPOT_RATE", "honeypotRate"],
408
+ ];
409
+ for (const [envKey, field] of numOverrides) {
410
+ if (env[envKey]) {
411
+ const parsed = parseFloat(env[envKey]);
412
+ if (!isNaN(parsed))
413
+ result[field] = parsed;
414
+ }
415
+ }
416
+ return result;
417
+ }
package/dist/config.js CHANGED
@@ -71,6 +71,9 @@ export function resolveConfig(pluginConfig) {
71
71
  detectorOverrides: raw.detectorOverrides != null && typeof raw.detectorOverrides === "object"
72
72
  ? raw.detectorOverrides
73
73
  : {},
74
+ rules: raw.rules != null && typeof raw.rules === "object"
75
+ ? raw.rules
76
+ : {},
74
77
  // Tool chain depth
75
78
  maxToolDepth: typeof raw.maxToolDepth === "number" ? raw.maxToolDepth : 10,
76
79
  // Redaction level
@@ -121,5 +124,23 @@ export function validateConfig(config) {
121
124
  if (Object.keys(config.detectorOverrides).length > 0) {
122
125
  issues.push({ severity: "info", field: "detectorOverrides", message: `${Object.keys(config.detectorOverrides).length} detector override(s) configured.` });
123
126
  }
127
+ // Rules as code
128
+ if (config.rules && Object.keys(config.rules).length > 0) {
129
+ const ruleCount = Object.keys(config.rules).length;
130
+ const disabled = Object.values(config.rules).filter(r => r.enabled === false).length;
131
+ const custom = Object.entries(config.rules).filter(([, r]) => r.pattern && r.enabled !== false).length;
132
+ issues.push({ severity: "info", field: "rules", message: `${ruleCount} rule(s) configured (${custom} custom/override, ${disabled} disabled).` });
133
+ // Validate regex patterns
134
+ for (const [name, rule] of Object.entries(config.rules)) {
135
+ if (rule.pattern) {
136
+ try {
137
+ new RegExp(rule.pattern);
138
+ }
139
+ catch {
140
+ issues.push({ severity: "error", field: "rules", message: `Rule "${name}" has invalid regex pattern.` });
141
+ }
142
+ }
143
+ }
144
+ }
124
145
  return issues;
125
146
  }
@@ -19,10 +19,17 @@ export type DetectorOverrides = Record<string, {
19
19
  enabled?: boolean;
20
20
  confidence?: number;
21
21
  }>;
22
+ /** Config-as-code rule definition (pattern as string, not RegExp). */
23
+ export type ConfigRule = {
24
+ enabled?: boolean;
25
+ pattern?: string;
26
+ category?: string;
27
+ confidence?: number;
28
+ };
22
29
  /** Detects sensitive entities using regex patterns. */
23
30
  export declare class RegexDetector implements BaseDetector {
24
31
  readonly name = "regex";
25
32
  private patterns;
26
- constructor(extraPatterns?: PatternDef[], overrides?: DetectorOverrides);
33
+ constructor(extraPatterns?: PatternDef[], overrides?: DetectorOverrides, configRules?: Record<string, ConfigRule>);
27
34
  detect(text: string): DetectedEntity[];
28
35
  }
@@ -1231,15 +1231,84 @@ class SpanTracker {
1231
1231
  spans.splice(lo, 0, [start, end]);
1232
1232
  }
1233
1233
  }
1234
+ /** Resolve a category string to a Category enum value. */
1235
+ function resolveCategory(name) {
1236
+ const upper = name.toUpperCase().replace(/[^A-Z_]/g, "_");
1237
+ if (upper in Category)
1238
+ return Category[upper];
1239
+ // Common aliases
1240
+ const aliases = {
1241
+ EMAIL: Category.EMAIL,
1242
+ IP: Category.IP_ADDRESS,
1243
+ IP_ADDRESS: Category.IP_ADDRESS,
1244
+ PHONE: Category.PHONE,
1245
+ CREDIT_CARD: Category.CREDIT_CARD,
1246
+ SSN: Category.SSN,
1247
+ API_KEY: Category.API_KEY,
1248
+ URL: Category.URL,
1249
+ FILE_PATH: Category.FILE_PATH,
1250
+ HOSTNAME: Category.HOSTNAME,
1251
+ MAC_ADDRESS: Category.MAC_ADDRESS,
1252
+ NETWORK_CREDENTIAL: Category.NETWORK_CREDENTIAL,
1253
+ CUSTOM: Category.CUSTOM,
1254
+ };
1255
+ return aliases[upper] ?? Category.CUSTOM;
1256
+ }
1234
1257
  /** Detects sensitive entities using regex patterns. */
1235
1258
  export class RegexDetector {
1236
1259
  name = "regex";
1237
1260
  patterns;
1238
- constructor(extraPatterns, overrides) {
1261
+ constructor(extraPatterns, overrides, configRules) {
1239
1262
  let patterns = [...BUILTIN_PATTERNS];
1240
1263
  if (extraPatterns) {
1241
1264
  patterns.push(...extraPatterns);
1242
1265
  }
1266
+ // Config-as-code rules: override built-in properties, disable, or add new
1267
+ if (configRules) {
1268
+ const builtinNames = new Set(patterns.map(p => p.name));
1269
+ // Override existing rules
1270
+ patterns = patterns.map((p) => {
1271
+ const rule = configRules[p.name];
1272
+ if (!rule)
1273
+ return p;
1274
+ const updated = { ...p };
1275
+ if (rule.pattern) {
1276
+ try {
1277
+ updated.pattern = new RegExp(rule.pattern, "g");
1278
+ }
1279
+ catch { /* invalid regex — keep built-in */ }
1280
+ }
1281
+ if (rule.confidence !== undefined)
1282
+ updated.confidence = rule.confidence;
1283
+ if (rule.category)
1284
+ updated.category = resolveCategory(rule.category);
1285
+ return updated;
1286
+ });
1287
+ // Disable rules
1288
+ patterns = patterns.filter((p) => {
1289
+ const rule = configRules[p.name];
1290
+ return rule?.enabled !== false;
1291
+ });
1292
+ // Add new rules (names not in built-ins)
1293
+ for (const [name, rule] of Object.entries(configRules)) {
1294
+ if (builtinNames.has(name))
1295
+ continue; // already handled above
1296
+ if (rule.enabled === false)
1297
+ continue;
1298
+ if (!rule.pattern)
1299
+ continue; // new rules must have a pattern
1300
+ try {
1301
+ patterns.push({
1302
+ name,
1303
+ pattern: new RegExp(rule.pattern, "g"),
1304
+ category: rule.category ? resolveCategory(rule.category) : Category.CUSTOM,
1305
+ confidence: rule.confidence ?? 0.9,
1306
+ });
1307
+ }
1308
+ catch { /* invalid regex — skip */ }
1309
+ }
1310
+ }
1311
+ // Legacy detectorOverrides (applied after config rules for backwards compat)
1243
1312
  if (overrides) {
1244
1313
  patterns = patterns.filter((p) => {
1245
1314
  const ov = overrides[p.name];
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ import { join, dirname } from "node:path";
10
10
  import { resolveConfig } from "./config.js";
11
11
  import { Obfuscator } from "./obfuscator.js";
12
12
  import { registerHooks } from "./hooks.js";
13
+ import { ConfigManager } from "./config-manager.js";
13
14
  // ---------------------------------------------------------------------------
14
15
  // Runtime prototype patch: wrap EventStream.prototype.push() with the
15
16
  // Shroud deobfuscation hook. No file reads, no file writes, no cache
@@ -115,6 +116,21 @@ export default {
115
116
  patchEventStreamPrototype(api.logger);
116
117
  const config = resolveConfig(api.pluginConfig);
117
118
  const obfuscator = new Obfuscator(config);
119
+ // Config-as-code: watch ~/.shroud/shroud.config.json for hot-reload.
120
+ // Defer startWatching so watchFile doesn't block plugin install (which
121
+ // loads the plugin to verify it, then expects the process to exit).
122
+ // Resolve config path: prefer OPENCLAW_STATE_DIR, then HOME/.shroud
123
+ const configDir = process.env.OPENCLAW_STATE_DIR
124
+ ? join(process.env.OPENCLAW_STATE_DIR, ".shroud")
125
+ : join(process.env.HOME || "/root", ".shroud");
126
+ const configPath = join(configDir, "shroud.config.json");
127
+ const configManager = new ConfigManager(configPath, config);
128
+ configManager.onReload((newConfig) => {
129
+ obfuscator.updateConfig(newConfig);
130
+ api.logger?.info("[shroud] Config hot-reloaded from " + configPath);
131
+ });
132
+ const watchTimer = setTimeout(() => configManager.startWatching(), 5000);
133
+ watchTimer.unref();
118
134
  registerHooks(api, obfuscator);
119
135
  // Register shroud_status tool
120
136
  api.registerTool({
@@ -7,7 +7,7 @@
7
7
  import { DetectedEntity, ObfuscationResult, ShroudConfig } from "./types.js";
8
8
  import { BaseDetector } from "./detectors/base.js";
9
9
  export declare class Obfuscator {
10
- readonly config: ShroudConfig;
10
+ config: ShroudConfig;
11
11
  private _store;
12
12
  private _subnetMapper;
13
13
  private _mapping;
@@ -26,6 +26,13 @@ export declare class Obfuscator {
26
26
  private _toolDepth;
27
27
  constructor(config: ShroudConfig);
28
28
  private _initDetectors;
29
+ /**
30
+ * Hot-swap config at runtime (called by ConfigManager on reload).
31
+ * Preserves mappings, stores, and stats — only swaps behaviour flags.
32
+ * Fields that require restart (secretKey, persistentSalt, etc.) are
33
+ * already filtered by ConfigManager before this is called.
34
+ */
35
+ updateConfig(newConfig: ShroudConfig): void;
29
36
  /** Add a custom detector at runtime. */
30
37
  addDetector(detector: BaseDetector): void;
31
38
  /** Track tool call depth. */
@@ -230,8 +230,9 @@ export class Obfuscator {
230
230
  }
231
231
  _initDetectors() {
232
232
  const overrides = this.config.detectorOverrides;
233
- // Always enable the regex detector (with optional overrides)
234
- const regexDetector = new RegexDetector(undefined, overrides);
233
+ const configRules = this.config.rules;
234
+ // Always enable the regex detector (with config rules + legacy overrides)
235
+ const regexDetector = new RegexDetector(undefined, overrides, configRules);
235
236
  // Wrap with ContextDetector for confidence boosting, proximity,
236
237
  // hostname propagation, learned entities, and frequency decay
237
238
  this._contextDetector = new ContextDetector(regexDetector);
@@ -243,6 +244,33 @@ export class Obfuscator {
243
244
  // Code-aware detector shares the same configured regex detector
244
245
  this._detectors.push(new CodeDetector(regexDetector));
245
246
  }
247
+ /**
248
+ * Hot-swap config at runtime (called by ConfigManager on reload).
249
+ * Preserves mappings, stores, and stats — only swaps behaviour flags.
250
+ * Fields that require restart (secretKey, persistentSalt, etc.) are
251
+ * already filtered by ConfigManager before this is called.
252
+ */
253
+ updateConfig(newConfig) {
254
+ this.config = newConfig;
255
+ // Rebuild detectors to pick up new overrides / custom patterns
256
+ this._detectors = [];
257
+ this._contextDetector = null;
258
+ this._initDetectors();
259
+ // Toggle canary
260
+ if (newConfig.canaryEnabled && !this._canary) {
261
+ this._canary = new CanaryInjector(newConfig.canaryPrefix, newConfig.secretKey);
262
+ }
263
+ else if (!newConfig.canaryEnabled) {
264
+ this._canary = null;
265
+ }
266
+ // Toggle audit
267
+ if (newConfig.auditEnabled && !this._audit) {
268
+ this._audit = new AuditLogger(newConfig.secretKey);
269
+ }
270
+ else if (!newConfig.auditEnabled) {
271
+ this._audit = null;
272
+ }
273
+ }
246
274
  /** Add a custom detector at runtime. */
247
275
  addDetector(detector) {
248
276
  this._detectors.push(detector);
package/dist/types.d.ts CHANGED
@@ -90,6 +90,19 @@ export interface ShroudConfig {
90
90
  enabled?: boolean;
91
91
  confidence?: number;
92
92
  }>;
93
+ /**
94
+ * Detection rules as code. Each key is a rule name.
95
+ * - Override built-in rules: change pattern, confidence, or category
96
+ * - Disable rules: { "enabled": false }
97
+ * - Add new rules: { "pattern": "regex string", "category": "email", "confidence": 0.9 }
98
+ * Built-in rules from BUILTIN_PATTERNS are the defaults; this merges on top.
99
+ */
100
+ rules: Record<string, {
101
+ enabled?: boolean;
102
+ pattern?: string;
103
+ category?: string;
104
+ confidence?: number;
105
+ }>;
93
106
  /** Tool chain depth awareness — max depth before warning. */
94
107
  maxToolDepth: number;
95
108
  /** Redaction levels — 'full' | 'masked' | 'stats'. */
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "shroud-privacy",
3
3
  "name": "Shroud",
4
- "version": "2.2.20",
4
+ "version": "2.3.0",
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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shroud-privacy",
3
- "version": "2.2.20",
3
+ "version": "2.3.0",
4
4
  "description": "Privacy and infrastructure protection for AI agents — detects sensitive data (PII, network topology, credentials, OT/SCADA) and replaces with deterministic fakes before anything reaches the LLM.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",