clawsecure 1.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.
package/src/daemon.js ADDED
@@ -0,0 +1,452 @@
1
+ 'use strict';
2
+
3
+ const chalk = require('chalk');
4
+ const path = require('path');
5
+ const configParser = require('./config-parser');
6
+ const componentScanner = require('./component-scanner');
7
+ const sessionParser = require('./session-parser');
8
+ const { stripToolCallData } = require('./metadata-stripper');
9
+ const { createWatcher } = require('./watcher');
10
+ const syncManager = require('./sync-manager');
11
+ const processManager = require('./process-manager');
12
+ const logger = require('./logger');
13
+ const { installSkill } = require('./skill-installer');
14
+
15
+ // Module-level state for the running daemon
16
+ let activeWatcher = null;
17
+ let currentSnapshot = new Map();
18
+ let sessionOffsets = new Map();
19
+ let currentTier = 'shield';
20
+ let totalToolCalls = 0;
21
+ let cachedInventory = null;
22
+ let cachedSkillDirs = null;
23
+ let cachedOpenclawDir = null;
24
+ let isRunning = false;
25
+
26
+ const PRIVACY_STATEMENT = 'Your API keys and credentials never leave your machine.';
27
+
28
+ /**
29
+ * Start the ClawSecure daemon.
30
+ * Phase 1: reads config, prints component inventory.
31
+ * Phase 2+: starts file watcher, connects to API.
32
+ * @param {{ profile?: string | null }} opts
33
+ */
34
+ async function start(opts) {
35
+ console.log('');
36
+ console.log(chalk.bold.blue(' ClawSecure') + ' - AI-Powered Runtime Monitoring');
37
+ console.log(chalk.green(` ${PRIVACY_STATEMENT}`));
38
+ console.log('');
39
+
40
+ // Check for existing daemon instance
41
+ const existing = processManager.checkExisting();
42
+ if (existing.running) {
43
+ logger.error(`ClawSecure daemon is already running (PID ${existing.pid}). Use "clawsecure stop" first.`);
44
+ return;
45
+ }
46
+
47
+ // Check and install/update Claw security skill
48
+ await installSkill();
49
+
50
+ // Resolve and parse config
51
+ const configPath = configParser.findConfigPath(opts);
52
+ logger.info(`Reading OpenClaw config: ${configPath}`);
53
+
54
+ let config;
55
+ try {
56
+ config = configParser.parseConfig(configPath);
57
+ } catch (err) {
58
+ logger.error(err.message);
59
+ return;
60
+ }
61
+
62
+ logger.success('Config parsed successfully');
63
+
64
+ // Extract component inventory
65
+ const inventory = configParser.extractComponents(config);
66
+ const totalCount = configParser.countComponents(inventory);
67
+ const skillDirs = configParser.extractSkillDirs(config);
68
+
69
+ // Print inventory summary
70
+ console.log('');
71
+ console.log(chalk.bold(' Environment Inventory'));
72
+ console.log(` ${'─'.repeat(40)}`);
73
+ printCategory('Skills', inventory.skills);
74
+ printCategory('MCP Servers', inventory.mcpServers);
75
+ printCategory('CLI Tools', inventory.cliTools);
76
+ printCategory('Agents', inventory.agents);
77
+ printCategory('Plugins', inventory.plugins);
78
+ printCategory('Repos', inventory.repos);
79
+ console.log(` ${'─'.repeat(40)}`);
80
+ console.log(` ${chalk.bold('Total components:')} ${totalCount}`);
81
+ console.log('');
82
+
83
+ // Run initial full scan
84
+ logger.info('Running initial environment scan...');
85
+ const scanResult = componentScanner.scanAll(inventory, skillDirs);
86
+ currentSnapshot = scanResult.snapshot;
87
+
88
+ // Print discovered on-disk skills
89
+ const diskSkills = scanResult.components.filter((c) => c.type === 'skill' && c.hash);
90
+ if (diskSkills.length > 0) {
91
+ console.log(chalk.bold(' On-Disk Skills Discovered'));
92
+ console.log(` ${'─'.repeat(40)}`);
93
+ for (const skill of diskSkills) {
94
+ const hashShort = skill.hash ? skill.hash.substring(0, 12) : 'no hash';
95
+ console.log(` ${chalk.green('●')} ${chalk.white(skill.name)} ${chalk.gray(`[${hashShort}]`)}`);
96
+ }
97
+ console.log(` ${'─'.repeat(40)}`);
98
+ console.log('');
99
+ }
100
+
101
+ // Build list of directories to watch
102
+ const openclawDir = configParser.getOpenClawDir();
103
+ const watchDirs = [openclawDir, ...skillDirs];
104
+
105
+ // Cache for use in change handlers
106
+ cachedInventory = inventory;
107
+ cachedSkillDirs = skillDirs;
108
+ cachedOpenclawDir = openclawDir;
109
+
110
+ // Connect to ClawSecure API
111
+ const apiResult = await syncManager.connect();
112
+ currentTier = apiResult.tier;
113
+ const isOnline = apiResult.online;
114
+
115
+ // Print tier
116
+ console.log(` ${chalk.bold('Tier:')} ${currentTier === 'sentinel' ? chalk.magenta('Sentinel') : chalk.cyan('Shield')}`);
117
+ console.log(` ${chalk.bold('API:')} ${isOnline ? chalk.green('connected') : chalk.yellow('offline mode')}`);
118
+ console.log('');
119
+
120
+ // Sentinel tier: scan session logs for tool call activity
121
+ if (currentTier === 'sentinel') {
122
+ logger.info('Sentinel tier active: scanning session logs...');
123
+ const sessionResult = sessionParser.scanAllSessions(openclawDir, sessionOffsets);
124
+ sessionOffsets = sessionResult.offsets;
125
+ totalToolCalls = sessionResult.stats.toolCalls;
126
+
127
+ // Strip and log tool call summary
128
+ if (sessionResult.toolCalls.length > 0) {
129
+ const safeToolCalls = stripToolCallData(sessionResult.toolCalls);
130
+ printToolCallSummary(safeToolCalls);
131
+ }
132
+
133
+ // Add session directories to watch list
134
+ const sessionDirs = sessionParser.getSessionDirs(openclawDir);
135
+ for (const dir of sessionDirs) {
136
+ if (!watchDirs.includes(dir)) {
137
+ watchDirs.push(dir);
138
+ }
139
+ }
140
+ } else {
141
+ logger.debug('Shield tier: session log parsing disabled');
142
+ }
143
+
144
+ // Start file watcher
145
+ activeWatcher = createWatcher(watchDirs);
146
+ activeWatcher.onFileChange((events) => {
147
+ handleFileChanges(events);
148
+ });
149
+
150
+ isRunning = true;
151
+ processManager.writePid();
152
+
153
+ // Register graceful shutdown handlers
154
+ const shutdown = async () => {
155
+ logger.info('Shutting down ClawSecure...');
156
+ await cleanup();
157
+ process.exit(0);
158
+ };
159
+ process.on('SIGINT', shutdown);
160
+ process.on('SIGTERM', shutdown);
161
+
162
+ // Pull threat intelligence
163
+ await syncManager.loadThreats();
164
+
165
+ // Send initial environment sync to API
166
+ await syncManager.sendInitialSync(scanResult.components, currentSnapshot);
167
+
168
+ // Start heartbeat
169
+ syncManager.startHeartbeat(() => ({
170
+ filesTracked: currentSnapshot.size,
171
+ tier: currentTier,
172
+ toolCalls: totalToolCalls
173
+ }));
174
+
175
+ logger.success('ClawSecure daemon is running. Press Ctrl+C to stop.');
176
+ logger.info(`Monitoring ${currentSnapshot.size} files across ${watchDirs.length} directories`);
177
+ }
178
+
179
+ /**
180
+ * Print a category of components.
181
+ * @param {string} label Category label
182
+ * @param {Array<object>} items Component list
183
+ */
184
+ function printCategory(label, items) {
185
+ const count = items.length;
186
+ const color = count > 0 ? chalk.white : chalk.gray;
187
+ console.log(` ${color(label + ':')} ${count}`);
188
+
189
+ if (count > 0) {
190
+ for (const item of items) {
191
+ const status = item.enabled ? chalk.green('●') : chalk.gray('○');
192
+ const name = chalk.white(item.name);
193
+ const source = item.source && item.source !== 'local' && item.source !== 'unknown'
194
+ ? chalk.gray(` (${item.source})`)
195
+ : '';
196
+ console.log(` ${status} ${name}${source}`);
197
+ }
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Handle batched file change events from the watcher.
203
+ * Routes to environment scan or session log parse based on file type.
204
+ * @param {Array<object>} events Debounced change events
205
+ */
206
+ function handleFileChanges(events) {
207
+ const configFile = path.join(cachedOpenclawDir, 'openclaw.json');
208
+
209
+ // Separate session log events from environment events
210
+ const sessionEvents = events.filter((e) => e.path.endsWith('.jsonl'));
211
+ const envEvents = events.filter((e) => !e.path.endsWith('.jsonl'));
212
+
213
+ // Handle environment changes (config, skills, etc.)
214
+ if (envEvents.length > 0) {
215
+ const configChanged = envEvents.some((e) => e.path === configFile);
216
+ if (configChanged) {
217
+ logger.info('OpenClaw config changed, re-reading...');
218
+ try {
219
+ const newConfig = configParser.parseConfig(configFile);
220
+ cachedInventory = configParser.extractComponents(newConfig);
221
+ cachedSkillDirs = configParser.extractSkillDirs(newConfig);
222
+ const scanResult = componentScanner.scanAll(cachedInventory, cachedSkillDirs);
223
+ const delta = componentScanner.computeDelta(currentSnapshot, scanResult.snapshot);
224
+ currentSnapshot = scanResult.snapshot;
225
+ logDelta(delta);
226
+ } catch (err) {
227
+ logger.error(`Failed to re-read config: ${err.message}`);
228
+ }
229
+ } else {
230
+ const scanResult = componentScanner.scanAll(cachedInventory, cachedSkillDirs);
231
+ const delta = componentScanner.computeDelta(currentSnapshot, scanResult.snapshot);
232
+ currentSnapshot = scanResult.snapshot;
233
+ logDelta(delta);
234
+ }
235
+ }
236
+
237
+ // Handle session log changes (Sentinel tier only)
238
+ if (sessionEvents.length > 0 && currentTier === 'sentinel') {
239
+ handleSessionLogChanges(sessionEvents);
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Handle session log file changes (Sentinel tier).
245
+ * Re-parses changed session files from last known offset.
246
+ * @param {Array<object>} events Session log change events
247
+ */
248
+ function handleSessionLogChanges(events) {
249
+ let newCalls = [];
250
+
251
+ for (const evt of events) {
252
+ if (evt.type === 'unlink') continue; // Session file deleted, skip
253
+
254
+ const prevOffset = sessionOffsets.get(evt.path) || 0;
255
+ const result = sessionParser.parseSessionFile(evt.path, prevOffset);
256
+ sessionOffsets.set(evt.path, result.newOffset);
257
+
258
+ if (result.toolCalls.length > 0) {
259
+ // Extract agentId and sessionId from file path
260
+ // Pattern: .../agents/<agentId>/sessions/<sessionId>.jsonl
261
+ const parts = evt.path.split(path.sep);
262
+ const sessionsIdx = parts.lastIndexOf('sessions');
263
+ const agentId = sessionsIdx > 0 ? parts[sessionsIdx - 1] : 'unknown';
264
+ const sessionId = path.basename(evt.path, '.jsonl');
265
+
266
+ for (const tc of result.toolCalls) {
267
+ newCalls.push({
268
+ toolName: tc.toolName,
269
+ timestamp: tc.timestamp,
270
+ agentId: agentId,
271
+ sessionId: sessionId
272
+ });
273
+ }
274
+ }
275
+ }
276
+
277
+ if (newCalls.length === 0) return;
278
+
279
+ // Strip to safe fields only
280
+ const safeCalls = stripToolCallData(newCalls);
281
+ totalToolCalls += safeCalls.length;
282
+
283
+ logger.info(
284
+ `${chalk.magenta('Sentinel')} ${safeCalls.length} new tool call${safeCalls.length === 1 ? '' : 's'} detected`
285
+ );
286
+ for (const tc of safeCalls) {
287
+ logger.debug(` Tool: ${tc.toolName} | Agent: ${tc.agentId} | ${tc.timestamp || 'no timestamp'}`);
288
+ }
289
+
290
+ // Send tool calls to API
291
+ syncManager.sendToolCalls(safeCalls);
292
+ }
293
+
294
+ /**
295
+ * Log a file delta summary.
296
+ * @param {{ added: string[], changed: string[], removed: string[] }} delta
297
+ */
298
+ function logDelta(delta) {
299
+ const total = delta.added.length + delta.changed.length + delta.removed.length;
300
+ if (total === 0) {
301
+ logger.debug('No meaningful changes detected');
302
+ return;
303
+ }
304
+
305
+ if (delta.added.length > 0) {
306
+ logger.info(`${chalk.green('+')} ${delta.added.length} file${delta.added.length === 1 ? '' : 's'} added`);
307
+ for (const f of delta.added) logger.debug(` + ${f}`);
308
+ }
309
+ if (delta.changed.length > 0) {
310
+ logger.info(`${chalk.yellow('~')} ${delta.changed.length} file${delta.changed.length === 1 ? '' : 's'} changed`);
311
+ for (const f of delta.changed) logger.debug(` ~ ${f}`);
312
+ }
313
+ if (delta.removed.length > 0) {
314
+ logger.info(`${chalk.red('-')} ${delta.removed.length} file${delta.removed.length === 1 ? '' : 's'} removed`);
315
+ for (const f of delta.removed) logger.debug(` - ${f}`);
316
+ }
317
+
318
+ // Send delta to API
319
+ syncManager.syncEnvironment({ delta });
320
+ }
321
+
322
+ /**
323
+ * Print a summary of discovered tool calls.
324
+ * @param {Array<object>} toolCalls Stripped tool call data
325
+ */
326
+ function printToolCallSummary(toolCalls) {
327
+ const counts = new Map();
328
+ for (const tc of toolCalls) {
329
+ const current = counts.get(tc.toolName) || 0;
330
+ counts.set(tc.toolName, current + 1);
331
+ }
332
+
333
+ console.log(chalk.bold(' Tool Call Activity'));
334
+ console.log(` ${'─'.repeat(40)}`);
335
+
336
+ const sorted = Array.from(counts.entries()).sort((a, b) => b[1] - a[1]);
337
+ const displayMax = 10;
338
+ const shown = sorted.slice(0, displayMax);
339
+
340
+ for (const [name, count] of shown) {
341
+ console.log(` ${chalk.magenta(count.toString().padStart(4))}x ${chalk.white(name)}`);
342
+ }
343
+ if (sorted.length > displayMax) {
344
+ console.log(chalk.gray(` ... and ${sorted.length - displayMax} more tools`));
345
+ }
346
+
347
+ console.log(` ${'─'.repeat(40)}`);
348
+ console.log(` ${chalk.bold('Total tool calls:')} ${toolCalls.length}`);
349
+ console.log('');
350
+ }
351
+
352
+ /**
353
+ * Gracefully clean up all resources.
354
+ */
355
+ async function cleanup() {
356
+ syncManager.cleanup();
357
+ if (activeWatcher) {
358
+ await activeWatcher.close();
359
+ activeWatcher = null;
360
+ }
361
+ processManager.removePid();
362
+ isRunning = false;
363
+ currentSnapshot = new Map();
364
+ sessionOffsets = new Map();
365
+ totalToolCalls = 0;
366
+ cachedInventory = null;
367
+ cachedSkillDirs = null;
368
+ cachedOpenclawDir = null;
369
+ logger.success('ClawSecure stopped');
370
+ }
371
+
372
+ /**
373
+ * Stop the ClawSecure daemon.
374
+ */
375
+ async function stop() {
376
+ // Check if a daemon is running via PID file (for CLI "clawsecure stop")
377
+ const existing = processManager.checkExisting();
378
+ if (existing.running && existing.pid !== process.pid) {
379
+ logger.info(`Stopping ClawSecure daemon (PID ${existing.pid})...`);
380
+ const stopped = processManager.stopProcess(existing.pid);
381
+ if (stopped) {
382
+ // Wait briefly for process to exit
383
+ await new Promise((r) => setTimeout(r, 1000));
384
+ processManager.removePid();
385
+ logger.success('ClawSecure daemon stopped');
386
+ }
387
+ return;
388
+ }
389
+
390
+ if (!isRunning && !activeWatcher) {
391
+ logger.info('ClawSecure daemon is not currently running.');
392
+ return;
393
+ }
394
+ await cleanup();
395
+ }
396
+
397
+ /**
398
+ * Show daemon status and environment summary.
399
+ */
400
+ async function status() {
401
+ const configPath = configParser.findConfigPath({});
402
+ const existing = processManager.checkExisting();
403
+ const token = processManager.getToken();
404
+
405
+ console.log('');
406
+ console.log(chalk.bold(' ClawSecure Status'));
407
+ console.log(` ${'─'.repeat(40)}`);
408
+ console.log(` Config: ${configPath}`);
409
+ console.log(` Token: ${token ? chalk.green('configured') : chalk.yellow('not set (run "clawsecure setup")')}`);
410
+ console.log(` Daemon: ${existing.running ? chalk.green(`running (PID ${existing.pid})`) : chalk.yellow('not running')}`);
411
+
412
+ if (existing.running) {
413
+ const syncState = syncManager.getState();
414
+ console.log(` Files tracked: ${currentSnapshot.size}`);
415
+ console.log(` Offline queue: ${syncState.queueSize}`);
416
+ }
417
+
418
+ try {
419
+ const config = configParser.parseConfig(configPath);
420
+ const inv = configParser.extractComponents(config);
421
+ const totalCount = configParser.countComponents(inv);
422
+ console.log(` Components: ${totalCount} discovered`);
423
+ } catch (err) {
424
+ console.log(` Components: ${chalk.gray('config not readable')}`);
425
+ }
426
+
427
+ console.log(` ${'─'.repeat(40)}`);
428
+ console.log('');
429
+ }
430
+
431
+ /**
432
+ * Set up the daemon token interactively.
433
+ * @param {string} token
434
+ */
435
+ async function setup(token) {
436
+ if (!token || !token.trim()) {
437
+ logger.error('Token is required. Usage: clawsecure setup <token>');
438
+ return;
439
+ }
440
+ const saved = processManager.saveToken(token.trim());
441
+ if (saved) {
442
+ logger.success('Token saved to ~/.clawsecure/config.json (permissions: owner-only)');
443
+ logger.info('You can now run "clawsecure start" to begin monitoring.');
444
+ }
445
+ }
446
+
447
+ module.exports = {
448
+ start,
449
+ stop,
450
+ status,
451
+ setup
452
+ };
package/src/logger.js ADDED
@@ -0,0 +1,60 @@
1
+ 'use strict';
2
+
3
+ const chalk = require('chalk');
4
+
5
+ let config = {
6
+ verbose: false,
7
+ quiet: false
8
+ };
9
+
10
+ /**
11
+ * Configure logger verbosity.
12
+ * @param {{ verbose?: boolean, quiet?: boolean }} opts
13
+ */
14
+ function configure(opts) {
15
+ if (opts.verbose !== undefined) config.verbose = opts.verbose;
16
+ if (opts.quiet !== undefined) config.quiet = opts.quiet;
17
+ }
18
+
19
+ /**
20
+ * Format a log message with timestamp.
21
+ * @param {string} level
22
+ * @param {string} msg
23
+ * @returns {string}
24
+ */
25
+ function format(level, msg) {
26
+ const ts = new Date().toISOString();
27
+ return `[${ts}] [${level}] ${msg}`;
28
+ }
29
+
30
+ function info(msg) {
31
+ if (config.quiet) return;
32
+ console.log(format(chalk.blue('INFO'), msg));
33
+ }
34
+
35
+ function warn(msg) {
36
+ console.warn(format(chalk.yellow('WARN'), msg));
37
+ }
38
+
39
+ function error(msg) {
40
+ console.error(format(chalk.red('ERROR'), msg));
41
+ }
42
+
43
+ function debug(msg) {
44
+ if (!config.verbose) return;
45
+ console.log(format(chalk.gray('DEBUG'), msg));
46
+ }
47
+
48
+ function success(msg) {
49
+ if (config.quiet) return;
50
+ console.log(format(chalk.green('OK'), msg));
51
+ }
52
+
53
+ module.exports = {
54
+ configure,
55
+ info,
56
+ warn,
57
+ error,
58
+ debug,
59
+ success
60
+ };
@@ -0,0 +1,181 @@
1
+ 'use strict';
2
+
3
+ const logger = require('./logger');
4
+
5
+ /**
6
+ * Patterns that identify sensitive keys.
7
+ * Any key matching these (case-insensitive) is stripped before transmission.
8
+ * Exported for transparency and testability.
9
+ */
10
+ const SENSITIVE_PATTERNS = [
11
+ 'apikey',
12
+ 'api_key',
13
+ 'apikeys',
14
+ 'token',
15
+ 'secret',
16
+ 'password',
17
+ 'passwd',
18
+ 'credential',
19
+ 'credentials',
20
+ 'oauth',
21
+ 'oauthsecret',
22
+ 'oauthtoken',
23
+ 'connectionstring',
24
+ 'connection_string',
25
+ 'private_key',
26
+ 'privatekey',
27
+ 'accesskey',
28
+ 'access_key',
29
+ 'secretkey',
30
+ 'secret_key',
31
+ 'auth_token',
32
+ 'authtoken',
33
+ 'bearer',
34
+ 'jwt',
35
+ 'cookie',
36
+ 'session_id',
37
+ 'sessionid',
38
+ 'encryption_key',
39
+ 'encryptionkey',
40
+ 'signing_key',
41
+ 'signingkey',
42
+ 'webhook_secret',
43
+ 'webhooksecret',
44
+ 'database_url',
45
+ 'databaseurl',
46
+ 'db_password',
47
+ 'dbpassword',
48
+ 'smtp_password',
49
+ 'smtppassword'
50
+ ];
51
+
52
+ // Pre-compile lowercase set for fast lookup
53
+ const SENSITIVE_SET = new Set(SENSITIVE_PATTERNS);
54
+
55
+ /**
56
+ * Check if a key name is sensitive.
57
+ * Uses exact match on lowercase and also checks if any sensitive
58
+ * pattern is a substring of the key.
59
+ * @param {string} key
60
+ * @returns {boolean}
61
+ */
62
+ function isSensitiveKey(key) {
63
+ const lower = key.toLowerCase().replace(/[-_.]/g, '');
64
+ // Exact match
65
+ if (SENSITIVE_SET.has(lower)) return true;
66
+ // Substring match for compound keys (e.g., "myApiKey", "githubToken")
67
+ for (const pattern of SENSITIVE_PATTERNS) {
68
+ if (lower.includes(pattern)) return true;
69
+ }
70
+ return false;
71
+ }
72
+
73
+ /**
74
+ * Check if a value looks like it could be a credential or secret.
75
+ * Catches env var references and common secret patterns.
76
+ * @param {*} value
77
+ * @returns {boolean}
78
+ */
79
+ function isSensitiveValue(value) {
80
+ if (typeof value !== 'string') return false;
81
+ // Environment variable references (${VAR} or $VAR)
82
+ if (/^\$\{?.+\}?$/.test(value)) return true;
83
+ // Common token/key patterns (long hex, base64, bearer tokens)
84
+ if (/^(sk-|pk-|ghp_|gho_|github_pat_|xox[bpas]-|Bearer\s)/.test(value)) return true;
85
+ return false;
86
+ }
87
+
88
+ /**
89
+ * Deep-clone an object and strip all sensitive fields.
90
+ * This is the PRIMARY security gate. It runs BEFORE every API call.
91
+ *
92
+ * NEVER sends: API keys, tokens, OAuth secrets, credentials,
93
+ * source code, file contents, database connection strings,
94
+ * raw config values, personal messages, PII.
95
+ *
96
+ * @param {*} obj Input object (will not be mutated)
97
+ * @returns {*} Clean deep copy with sensitive fields removed
98
+ */
99
+ function stripSensitiveFields(obj) {
100
+ if (obj === null || obj === undefined) return obj;
101
+ if (typeof obj !== 'object') return obj;
102
+
103
+ if (Array.isArray(obj)) {
104
+ return obj.map((item) => stripSensitiveFields(item));
105
+ }
106
+
107
+ const clean = {};
108
+ let strippedCount = 0;
109
+
110
+ for (const [key, value] of Object.entries(obj)) {
111
+ // Strip sensitive keys entirely
112
+ if (isSensitiveKey(key)) {
113
+ strippedCount++;
114
+ continue;
115
+ }
116
+
117
+ // Strip env var references in values (replace with "[REDACTED]")
118
+ if (isSensitiveValue(value)) {
119
+ clean[key] = '[REDACTED]';
120
+ strippedCount++;
121
+ continue;
122
+ }
123
+
124
+ // Recurse into nested objects
125
+ if (typeof value === 'object' && value !== null) {
126
+ clean[key] = stripSensitiveFields(value);
127
+ } else {
128
+ clean[key] = value;
129
+ }
130
+ }
131
+
132
+ if (strippedCount > 0) {
133
+ logger.debug(`Stripped ${strippedCount} sensitive field(s)`);
134
+ }
135
+
136
+ return clean;
137
+ }
138
+
139
+ /**
140
+ * Strip tool call data down to only safe transmission fields.
141
+ * This is the security gate for Sentinel session log data.
142
+ *
143
+ * ONLY passes through: toolName, timestamp, agentId, sessionId
144
+ * NEVER passes through: conversation content, arguments, results,
145
+ * user messages, assistant responses, file contents, or any other field.
146
+ *
147
+ * @param {Array<object>} toolCalls Raw extracted tool calls
148
+ * @returns {Array<{ toolName: string, timestamp: string, agentId: string, sessionId: string }>}
149
+ */
150
+ function stripToolCallData(toolCalls) {
151
+ if (!Array.isArray(toolCalls)) return [];
152
+
153
+ const stripped = [];
154
+ for (const tc of toolCalls) {
155
+ if (!tc || typeof tc !== 'object') continue;
156
+ if (!tc.toolName) continue;
157
+
158
+ stripped.push({
159
+ toolName: String(tc.toolName),
160
+ timestamp: tc.timestamp ? String(tc.timestamp) : null,
161
+ agentId: tc.agentId ? String(tc.agentId) : null,
162
+ sessionId: tc.sessionId ? String(tc.sessionId) : null
163
+ });
164
+ }
165
+
166
+ if (toolCalls.length !== stripped.length) {
167
+ logger.debug(
168
+ `Stripped ${toolCalls.length - stripped.length} invalid tool call entries`
169
+ );
170
+ }
171
+
172
+ return stripped;
173
+ }
174
+
175
+ module.exports = {
176
+ SENSITIVE_PATTERNS,
177
+ isSensitiveKey,
178
+ isSensitiveValue,
179
+ stripSensitiveFields,
180
+ stripToolCallData
181
+ };