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.
@@ -0,0 +1,246 @@
1
+ 'use strict';
2
+
3
+ const apiClientModule = require('./api-client');
4
+ const threatIntel = require('./threat-intel');
5
+ const processManager = require('./process-manager');
6
+ const logger = require('./logger');
7
+
8
+ const MAX_OFFLINE_QUEUE = 100;
9
+ const HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
10
+ const RECONNECT_INTERVAL_MS = 60 * 1000; // 1 minute
11
+
12
+ // Module state
13
+ let apiClient = null;
14
+ let envId = null;
15
+ let isOnline = false;
16
+ let offlineQueue = [];
17
+ let heartbeatTimer = null;
18
+
19
+ /**
20
+ * Connect to the ClawSecure API and fetch tier config.
21
+ * @returns {Promise<{ tier: string, envId: string|null, online: boolean }>}
22
+ */
23
+ async function connect() {
24
+ const token = processManager.getToken();
25
+ const baseUrl = processManager.getApiUrl();
26
+
27
+ if (!token) {
28
+ logger.warn('No API token configured. Run "clawsecure setup" to connect your account.');
29
+ logger.warn('Running in offline mode. Local monitoring is active.');
30
+ isOnline = false;
31
+ return { tier: detectTierFallback(), envId: null, online: false };
32
+ }
33
+
34
+ apiClient = apiClientModule.create({ baseUrl, token });
35
+ logger.info(`Connecting to ClawSecure API at ${baseUrl}...`);
36
+
37
+ const config = await apiClient.fetchConfig();
38
+ if (config) {
39
+ envId = config.envId || null;
40
+ isOnline = true;
41
+ logger.success('API connected');
42
+ return { tier: config.tier || 'shield', envId, online: true };
43
+ }
44
+
45
+ logger.warn('API unreachable. Running in offline mode. Data will sync when connection is restored.');
46
+ isOnline = false;
47
+ scheduleReconnect();
48
+ return { tier: detectTierFallback(), envId: null, online: false };
49
+ }
50
+
51
+ /**
52
+ * Fallback tier detection via environment variable.
53
+ * @returns {string}
54
+ */
55
+ function detectTierFallback() {
56
+ const envTier = process.env.CLAWSECURE_TIER;
57
+ if (envTier && envTier.toLowerCase() === 'sentinel') return 'sentinel';
58
+ return 'shield';
59
+ }
60
+
61
+ /**
62
+ * Send environment sync data to the API, or queue if offline.
63
+ * @param {object} payload
64
+ */
65
+ function syncEnvironment(payload) {
66
+ if (isOnline && apiClient && envId) {
67
+ apiClient.syncEnvironment(envId, payload).catch((err) => {
68
+ logger.debug(`Sync failed, queueing: ${err.message}`);
69
+ queueOffline({ type: 'sync', payload });
70
+ });
71
+ } else {
72
+ queueOffline({ type: 'sync', payload });
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Send tool call data to the API, or queue if offline.
78
+ * @param {Array<object>} toolCalls
79
+ */
80
+ function sendToolCalls(toolCalls) {
81
+ if (isOnline && apiClient && envId) {
82
+ apiClient.sendToolCalls(envId, toolCalls).catch((err) => {
83
+ logger.debug(`Tool call send failed, queueing: ${err.message}`);
84
+ queueOffline({ type: 'toolcalls', payload: toolCalls });
85
+ });
86
+ } else {
87
+ queueOffline({ type: 'toolcalls', payload: toolCalls });
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Send initial environment state to the API.
93
+ * @param {Array<object>} components
94
+ * @param {Map<string, string>} snapshot
95
+ * @returns {Promise<boolean>}
96
+ */
97
+ async function sendInitialSync(components, snapshot) {
98
+ if (!isOnline || !apiClient || !envId) return false;
99
+
100
+ const syncPayload = {
101
+ components: components.map((c) => ({
102
+ name: c.name, type: c.type, source: c.source,
103
+ enabled: c.enabled, hash: c.hash || null
104
+ })),
105
+ snapshot: Object.fromEntries(snapshot)
106
+ };
107
+ const synced = await apiClient.syncEnvironment(envId, syncPayload);
108
+ if (synced) logger.success('Initial environment sync complete');
109
+ return synced;
110
+ }
111
+
112
+ /**
113
+ * Load threat intelligence from the API.
114
+ * @returns {Promise<boolean>}
115
+ */
116
+ async function loadThreats() {
117
+ if (!isOnline || !apiClient) return false;
118
+ const loaded = await threatIntel.load(apiClient);
119
+ if (loaded) threatIntel.startAutoRefresh();
120
+ return loaded;
121
+ }
122
+
123
+ /**
124
+ * Add an event to the offline queue.
125
+ * @param {object} event
126
+ */
127
+ function queueOffline(event) {
128
+ if (offlineQueue.length >= MAX_OFFLINE_QUEUE) {
129
+ offlineQueue.shift();
130
+ logger.debug('Offline queue full, dropping oldest event');
131
+ }
132
+ offlineQueue.push(event);
133
+ }
134
+
135
+ /**
136
+ * Flush the offline queue to the API.
137
+ */
138
+ async function flushOfflineQueue() {
139
+ if (offlineQueue.length === 0) return;
140
+ logger.info(`Flushing ${offlineQueue.length} queued events...`);
141
+
142
+ const queue = offlineQueue.slice();
143
+ offlineQueue = [];
144
+
145
+ for (const event of queue) {
146
+ try {
147
+ if (event.type === 'sync') {
148
+ await apiClient.syncEnvironment(envId, event.payload);
149
+ } else if (event.type === 'toolcalls') {
150
+ await apiClient.sendToolCalls(envId, event.payload);
151
+ }
152
+ } catch (err) {
153
+ logger.debug(`Failed to flush event: ${err.message}`);
154
+ offlineQueue.push(event);
155
+ break;
156
+ }
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Schedule periodic reconnection attempts.
162
+ */
163
+ function scheduleReconnect() {
164
+ const timer = setInterval(async () => {
165
+ if (isOnline) {
166
+ clearInterval(timer);
167
+ return;
168
+ }
169
+ logger.debug('Attempting API reconnection...');
170
+ const token = processManager.getToken();
171
+ if (!token) return;
172
+
173
+ const baseUrl = processManager.getApiUrl();
174
+ if (!apiClient) {
175
+ apiClient = apiClientModule.create({ baseUrl, token });
176
+ }
177
+
178
+ const config = await apiClient.fetchConfig();
179
+ if (config) {
180
+ envId = config.envId || null;
181
+ isOnline = true;
182
+ clearInterval(timer);
183
+ logger.success('API reconnected');
184
+ await threatIntel.load(apiClient);
185
+ threatIntel.startAutoRefresh();
186
+ await flushOfflineQueue();
187
+ }
188
+ }, RECONNECT_INTERVAL_MS);
189
+
190
+ if (timer.unref) timer.unref();
191
+ }
192
+
193
+ /**
194
+ * Start the heartbeat interval.
195
+ * @param {function} getStatus Callback that returns current daemon status
196
+ */
197
+ function startHeartbeat(getStatus) {
198
+ if (heartbeatTimer) return;
199
+ heartbeatTimer = setInterval(() => {
200
+ if (isOnline && apiClient && envId) {
201
+ const status = getStatus ? getStatus() : {};
202
+ apiClient.syncEnvironment(envId, {
203
+ heartbeat: true,
204
+ ...status
205
+ }).catch((err) => {
206
+ logger.debug(`Heartbeat failed: ${err.message}`);
207
+ });
208
+ }
209
+ }, HEARTBEAT_INTERVAL_MS);
210
+
211
+ if (heartbeatTimer.unref) heartbeatTimer.unref();
212
+ }
213
+
214
+ /**
215
+ * Clean up all sync resources.
216
+ */
217
+ function cleanup() {
218
+ if (heartbeatTimer) {
219
+ clearInterval(heartbeatTimer);
220
+ heartbeatTimer = null;
221
+ }
222
+ threatIntel.stopAutoRefresh();
223
+ apiClient = null;
224
+ envId = null;
225
+ isOnline = false;
226
+ offlineQueue = [];
227
+ }
228
+
229
+ /**
230
+ * Get current connection state.
231
+ * @returns {{ online: boolean, queueSize: number }}
232
+ */
233
+ function getState() {
234
+ return { online: isOnline, queueSize: offlineQueue.length };
235
+ }
236
+
237
+ module.exports = {
238
+ connect,
239
+ syncEnvironment,
240
+ sendToolCalls,
241
+ sendInitialSync,
242
+ loadThreats,
243
+ startHeartbeat,
244
+ cleanup,
245
+ getState
246
+ };
@@ -0,0 +1,180 @@
1
+ 'use strict';
2
+
3
+ const logger = require('./logger');
4
+
5
+ const REFRESH_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
6
+
7
+ let cachedThreats = null;
8
+ let refreshTimer = null;
9
+ let apiClientRef = null;
10
+
11
+ /**
12
+ * Load threat intelligence data from the API.
13
+ * @param {object} apiClient API client instance
14
+ * @returns {Promise<boolean>} Whether load succeeded
15
+ */
16
+ async function load(apiClient) {
17
+ apiClientRef = apiClient;
18
+
19
+ const data = await apiClient.fetchThreats();
20
+ if (data) {
21
+ cachedThreats = normalizeThreatData(data);
22
+ logger.success(
23
+ `Threat intelligence loaded: ${cachedThreats.components.size} known threats, ` +
24
+ `${cachedThreats.hashes.size} known malicious hashes`
25
+ );
26
+ return true;
27
+ }
28
+
29
+ logger.warn('Could not load threat intelligence. Will retry on next refresh.');
30
+ cachedThreats = emptyThreatCache();
31
+ return false;
32
+ }
33
+
34
+ /**
35
+ * Start periodic threat intelligence refresh.
36
+ */
37
+ function startAutoRefresh() {
38
+ if (refreshTimer) return;
39
+ refreshTimer = setInterval(async () => {
40
+ await refresh();
41
+ }, REFRESH_INTERVAL_MS);
42
+
43
+ // Unref so it doesn't keep the process alive
44
+ if (refreshTimer.unref) refreshTimer.unref();
45
+ logger.debug('Threat intelligence auto-refresh enabled (every 6 hours)');
46
+ }
47
+
48
+ /**
49
+ * Refresh threat intelligence from the API.
50
+ * @returns {Promise<boolean>}
51
+ */
52
+ async function refresh() {
53
+ if (!apiClientRef) {
54
+ logger.debug('No API client available for threat refresh');
55
+ return false;
56
+ }
57
+
58
+ logger.info('Refreshing threat intelligence...');
59
+ const data = await apiClientRef.fetchThreats();
60
+ if (data) {
61
+ cachedThreats = normalizeThreatData(data);
62
+ logger.success('Threat intelligence refreshed');
63
+ return true;
64
+ }
65
+
66
+ logger.warn('Threat intelligence refresh failed, using cached data');
67
+ return false;
68
+ }
69
+
70
+ /**
71
+ * Stop auto-refresh timer.
72
+ */
73
+ function stopAutoRefresh() {
74
+ if (refreshTimer) {
75
+ clearInterval(refreshTimer);
76
+ refreshTimer = null;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Check if a component is a known threat.
82
+ * @param {string} componentName
83
+ * @param {string|null} hash SHA-256 hash
84
+ * @returns {{ isThreat: boolean, reason: string|null }}
85
+ */
86
+ function isKnownThreat(componentName, hash) {
87
+ if (!cachedThreats) {
88
+ return { isThreat: false, reason: null };
89
+ }
90
+
91
+ // Check by name
92
+ const nameKey = componentName.toLowerCase();
93
+ if (cachedThreats.components.has(nameKey)) {
94
+ return {
95
+ isThreat: true,
96
+ reason: cachedThreats.components.get(nameKey)
97
+ };
98
+ }
99
+
100
+ // Check by hash
101
+ if (hash && cachedThreats.hashes.has(hash)) {
102
+ return {
103
+ isThreat: true,
104
+ reason: cachedThreats.hashes.get(hash)
105
+ };
106
+ }
107
+
108
+ return { isThreat: false, reason: null };
109
+ }
110
+
111
+ /**
112
+ * Get the current cached threat data.
113
+ * @returns {object|null}
114
+ */
115
+ function getThreats() {
116
+ return cachedThreats;
117
+ }
118
+
119
+ /**
120
+ * Normalize raw threat API response into lookup-friendly structure.
121
+ * @param {object} data Raw API response
122
+ * @returns {object} Normalized cache
123
+ */
124
+ function normalizeThreatData(data) {
125
+ const cache = emptyThreatCache();
126
+
127
+ // Known bad components (by name)
128
+ if (Array.isArray(data.components)) {
129
+ for (const item of data.components) {
130
+ if (item.name) {
131
+ cache.components.set(item.name.toLowerCase(), item.reason || 'Known threat');
132
+ }
133
+ }
134
+ }
135
+
136
+ // Known malicious hashes
137
+ if (Array.isArray(data.hashes)) {
138
+ for (const item of data.hashes) {
139
+ if (item.hash) {
140
+ cache.hashes.set(item.hash, item.reason || 'Malicious hash');
141
+ }
142
+ }
143
+ }
144
+
145
+ // Metadata
146
+ cache.updatedAt = data.updatedAt || new Date().toISOString();
147
+
148
+ return cache;
149
+ }
150
+
151
+ /**
152
+ * Create an empty threat cache.
153
+ * @returns {object}
154
+ */
155
+ function emptyThreatCache() {
156
+ return {
157
+ components: new Map(),
158
+ hashes: new Map(),
159
+ updatedAt: null
160
+ };
161
+ }
162
+
163
+ /**
164
+ * Reset all state (for testing/cleanup).
165
+ */
166
+ function reset() {
167
+ stopAutoRefresh();
168
+ cachedThreats = null;
169
+ apiClientRef = null;
170
+ }
171
+
172
+ module.exports = {
173
+ load,
174
+ startAutoRefresh,
175
+ stopAutoRefresh,
176
+ refresh,
177
+ isKnownThreat,
178
+ getThreats,
179
+ reset
180
+ };
package/src/watcher.js ADDED
@@ -0,0 +1,155 @@
1
+ 'use strict';
2
+
3
+ const chokidar = require('chokidar');
4
+ const path = require('path');
5
+ const logger = require('./logger');
6
+
7
+ const DEFAULT_DEBOUNCE_MS = 500;
8
+
9
+ /**
10
+ * Creates a file watcher on the given directories.
11
+ * Debounces rapid changes into batched callbacks.
12
+ *
13
+ * @param {string[]} dirs Directories to watch
14
+ * @param {object} [options]
15
+ * @param {number} [options.debounceMs=500] Debounce interval in ms
16
+ * @param {boolean} [options.ignoreInitial=true] Skip initial add events
17
+ * @returns {object} Watcher controller with onFileChange(), close()
18
+ */
19
+ function createWatcher(dirs, options) {
20
+ const opts = Object.assign({ debounceMs: DEFAULT_DEBOUNCE_MS, ignoreInitial: true }, options);
21
+
22
+ // Filter to only directories that exist
23
+ const validDirs = dirs.filter((dir) => {
24
+ try {
25
+ require('fs').accessSync(dir);
26
+ return true;
27
+ } catch (e) {
28
+ logger.debug(`Skipping non-existent watch dir: ${dir}`);
29
+ return false;
30
+ }
31
+ });
32
+
33
+ if (validDirs.length === 0) {
34
+ logger.warn('No valid directories to watch.');
35
+ }
36
+
37
+ logger.info(`Watching ${validDirs.length} director${validDirs.length === 1 ? 'y' : 'ies'} for changes`);
38
+ for (const dir of validDirs) {
39
+ logger.debug(` Watching: ${dir}`);
40
+ }
41
+
42
+ const watcher = chokidar.watch(validDirs, {
43
+ persistent: true,
44
+ ignoreInitial: opts.ignoreInitial,
45
+ followSymlinks: false,
46
+ depth: 5,
47
+ ignored: [
48
+ '**/node_modules/**',
49
+ '**/.git/**',
50
+ '**/.DS_Store'
51
+ ],
52
+ awaitWriteFinish: {
53
+ stabilityThreshold: 200,
54
+ pollInterval: 50
55
+ }
56
+ });
57
+
58
+ let changeBuffer = [];
59
+ let debounceTimer = null;
60
+ let changeCallback = null;
61
+
62
+ /**
63
+ * Internal handler for all file events.
64
+ * Buffers events and flushes after debounce interval.
65
+ */
66
+ function handleEvent(eventType, filePath) {
67
+ logger.debug(`File ${eventType}: ${filePath}`);
68
+
69
+ changeBuffer.push({
70
+ type: eventType,
71
+ path: filePath,
72
+ dir: path.dirname(filePath),
73
+ timestamp: Date.now()
74
+ });
75
+
76
+ // Reset debounce timer
77
+ if (debounceTimer) clearTimeout(debounceTimer);
78
+ debounceTimer = setTimeout(flushChanges, opts.debounceMs);
79
+ }
80
+
81
+ /**
82
+ * Flush buffered changes to the registered callback.
83
+ */
84
+ function flushChanges() {
85
+ if (changeBuffer.length === 0) return;
86
+ if (!changeCallback) return;
87
+
88
+ const events = changeBuffer.slice();
89
+ changeBuffer = [];
90
+ debounceTimer = null;
91
+
92
+ // Deduplicate: keep latest event per file path
93
+ const seen = new Map();
94
+ for (const evt of events) {
95
+ seen.set(evt.path, evt);
96
+ }
97
+ const deduped = Array.from(seen.values());
98
+
99
+ logger.info(`Detected ${deduped.length} file change${deduped.length === 1 ? '' : 's'}`);
100
+ try {
101
+ changeCallback(deduped);
102
+ } catch (err) {
103
+ logger.error(`Error in change handler: ${err.message}`);
104
+ }
105
+ }
106
+
107
+ // Register chokidar events
108
+ watcher.on('add', (fp) => handleEvent('add', fp));
109
+ watcher.on('change', (fp) => handleEvent('change', fp));
110
+ watcher.on('unlink', (fp) => handleEvent('unlink', fp));
111
+ watcher.on('error', (err) => logger.error(`Watcher error: ${err.message}`));
112
+
113
+ watcher.on('ready', () => {
114
+ logger.success('File watcher ready');
115
+ });
116
+
117
+ return {
118
+ /**
119
+ * Register a callback for batched file changes.
120
+ * @param {function(Array<{type: string, path: string, dir: string, timestamp: number}>)} callback
121
+ */
122
+ onFileChange(callback) {
123
+ changeCallback = callback;
124
+ },
125
+
126
+ /**
127
+ * Gracefully close the watcher and flush pending changes.
128
+ * @returns {Promise<void>}
129
+ */
130
+ async close() {
131
+ if (debounceTimer) {
132
+ clearTimeout(debounceTimer);
133
+ debounceTimer = null;
134
+ }
135
+ // Flush any remaining buffered changes
136
+ if (changeBuffer.length > 0 && changeCallback) {
137
+ flushChanges();
138
+ }
139
+ await watcher.close();
140
+ logger.debug('File watcher closed');
141
+ },
142
+
143
+ /**
144
+ * Get the list of watched directories.
145
+ * @returns {object}
146
+ */
147
+ getWatched() {
148
+ return watcher.getWatched();
149
+ }
150
+ };
151
+ }
152
+
153
+ module.exports = {
154
+ createWatcher
155
+ };