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/LICENSE +21 -0
- package/README.md +127 -0
- package/bin/clawsecure.js +84 -0
- package/package.json +48 -0
- package/skill/.clawsecure-version +1 -0
- package/skill/HEARTBEAT.md +18 -0
- package/skill/README.md +146 -0
- package/skill/SKILL.md +83 -0
- package/skill/references/commands.md +40 -0
- package/skill/references/config-audit-checklist.md +81 -0
- package/skill/references/mcp-risk-classifications.md +43 -0
- package/skill/references/onboarding.md +48 -0
- package/skill/references/response-templates.md +102 -0
- package/skill/references/secure-install-guide.md +91 -0
- package/src/api-client.js +227 -0
- package/src/component-scanner.js +238 -0
- package/src/config-parser.js +352 -0
- package/src/daemon.js +452 -0
- package/src/logger.js +60 -0
- package/src/metadata-stripper.js +181 -0
- package/src/process-manager.js +220 -0
- package/src/session-parser.js +241 -0
- package/src/skill-installer.js +199 -0
- package/src/sync-manager.js +246 -0
- package/src/threat-intel.js +180 -0
- package/src/watcher.js +155 -0
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
|
+
};
|