@contextfort-ai/openclaw-secure 0.1.9 → 0.1.12

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.
@@ -72,56 +72,89 @@ module.exports = function createSkillsGuard({ readFileSync, httpsRequest, baseDi
72
72
  const entries = fs.readdirSync(dir, { withFileTypes: true });
73
73
  for (const e of entries) {
74
74
  if (e.isDirectory()) {
75
- skills.push({ skillPath: path.join(dir, e.name), skillName: e.name });
75
+ skills.push({ skillPath: path.join(dir, e.name), skillName: e.name, type: 'skill' });
76
76
  }
77
77
  }
78
78
  // If there are loose files directly in the skill dir (e.g. SKILL.md), treat as a skill
79
79
  const hasLooseFiles = entries.some(e => e.isFile());
80
80
  if (hasLooseFiles) {
81
- skills.push({ skillPath: dir, skillName: path.basename(dir) });
81
+ skills.push({ skillPath: dir, skillName: path.basename(dir), type: 'skill' });
82
82
  }
83
83
  } catch {}
84
84
  return skills;
85
85
  }
86
86
 
87
+ function getPluginEntries() {
88
+ const pluginsDir = path.join(HOME, '.claude', 'plugins');
89
+ const entries = [];
90
+ try {
91
+ const dirents = fs.readdirSync(pluginsDir, { withFileTypes: true });
92
+ for (const e of dirents) {
93
+ if (e.isDirectory() && !e.name.startsWith('.')) {
94
+ entries.push({
95
+ skillPath: path.join(pluginsDir, e.name),
96
+ skillName: e.name,
97
+ type: 'plugin',
98
+ });
99
+ }
100
+ }
101
+ } catch {}
102
+ return entries;
103
+ }
104
+
87
105
  function readSkillFiles(skillPath) {
88
106
  const files = [];
89
107
  const binaryFiles = [];
108
+ const skippedFiles = []; // { file, reason }
90
109
  const MAX_FILE_SIZE = 1 * 1024 * 1024; // 1MB
91
110
  const MAX_TOTAL_SIZE = 5 * 1024 * 1024; // 5MB
92
111
  let totalSize = 0;
112
+ let totalSizeExceeded = false;
93
113
 
94
114
  function walk(dirPath, base) {
95
115
  let entries;
96
116
  try { entries = fs.readdirSync(dirPath, { withFileTypes: true }); } catch { return; }
97
117
  for (const e of entries) {
98
- if (e.name.startsWith('.') || e.name === 'node_modules') continue; // skip hidden and node_modules
118
+ if (e.name.startsWith('.') || e.name === 'node_modules') continue;
99
119
  const full = path.join(dirPath, e.name);
120
+ const rel = path.join(base, e.name);
100
121
  if (e.isDirectory()) {
101
- walk(full, path.join(base, e.name));
122
+ walk(full, rel);
102
123
  } else if (e.isFile()) {
103
124
  try {
104
125
  const stat = fs.statSync(full);
105
- if (stat.size > MAX_FILE_SIZE || stat.size === 0) continue;
106
- if (totalSize + stat.size > MAX_TOTAL_SIZE) continue;
126
+ if (stat.size === 0) {
127
+ skippedFiles.push({ file: rel, reason: 'empty file (0 bytes)' });
128
+ continue;
129
+ }
130
+ if (stat.size > MAX_FILE_SIZE) {
131
+ skippedFiles.push({ file: rel, reason: `exceeds 1MB limit (${(stat.size / 1024 / 1024).toFixed(1)}MB)` });
132
+ continue;
133
+ }
134
+ if (totalSize + stat.size > MAX_TOTAL_SIZE) {
135
+ if (!totalSizeExceeded) totalSizeExceeded = true;
136
+ skippedFiles.push({ file: rel, reason: `total size would exceed 5MB limit (already ${(totalSize / 1024 / 1024).toFixed(1)}MB)` });
137
+ continue;
138
+ }
107
139
  // Check for binaries: read first 512 bytes and check for null bytes
108
140
  const buf = Buffer.alloc(Math.min(512, stat.size));
109
141
  const fd = fs.openSync(full, 'r');
110
142
  try { fs.readSync(fd, buf, 0, buf.length, 0); } finally { fs.closeSync(fd); }
111
143
  if (buf.includes(0)) {
112
- binaryFiles.push(path.join(base, e.name));
144
+ binaryFiles.push(rel);
145
+ skippedFiles.push({ file: rel, reason: 'binary file (contains null bytes)' });
113
146
  continue;
114
147
  }
115
148
 
116
149
  const content = readFileSync(full, 'utf8');
117
150
  totalSize += stat.size;
118
- files.push({ relative_path: path.join(base, e.name), content });
151
+ files.push({ relative_path: rel, content });
119
152
  } catch {}
120
153
  }
121
154
  }
122
155
  }
123
156
  walk(skillPath, '');
124
- return { files, binaryFiles };
157
+ return { files, binaryFiles, skippedFiles, totalSize };
125
158
  }
126
159
 
127
160
  function hashSkillFiles(files) {
@@ -165,16 +198,21 @@ module.exports = function createSkillsGuard({ readFileSync, httpsRequest, baseDi
165
198
  } catch {}
166
199
  }
167
200
 
168
- function scanSkillAsync(skillPath, files, hash, installId) {
201
+ function scanSkillAsync(skillPath, files, hash, installId, type, skippedFiles) {
169
202
  if (pendingScans.has(skillPath)) return;
170
203
  pendingScans.add(skillPath);
171
- track('skill_scan_started', { skill_name: path.basename(skillPath), file_count: files.length });
204
+ track('skill_scan_started', { skill_name: path.basename(skillPath), file_count: files.length, type: type || 'skill' });
205
+ if (localLogger) {
206
+ try { localLogger.logLocal({ event: 'guard_check', guard: 'skill', decision: 'scanning', reason: `Scanning ${type || 'skill'}: ${path.basename(skillPath)} (${files.length} files)`, detail: { skill_name: path.basename(skillPath), skill_path: skillPath, file_count: files.length, file_names: files.map(f => f.relative_path), file_contents: files.map(f => ({ path: f.relative_path, content: f.content.slice(0, 2000) })), type: type || 'skill' } }); } catch {}
207
+ }
172
208
 
173
209
  const payload = JSON.stringify({
174
210
  install_id: installId,
175
211
  skill_path: skillPath,
176
212
  skill_name: path.basename(skillPath),
177
213
  files: files,
214
+ type: type || 'skill',
215
+ skipped_files: skippedFiles && skippedFiles.length > 0 ? skippedFiles : undefined,
178
216
  });
179
217
 
180
218
  // Log what we're sending to Supabase (omit file contents for privacy)
@@ -214,7 +252,16 @@ module.exports = function createSkillsGuard({ readFileSync, httpsRequest, baseDi
214
252
  } else {
215
253
  flaggedSkills.delete(skillPath);
216
254
  }
217
- track('skill_scan_result', { skill_name: path.basename(skillPath), suspicious: !!result.suspicious, status_code: 200 });
255
+ track('skill_scan_result', { skill_name: path.basename(skillPath), suspicious: !!result.suspicious, status_code: 200, type: type || 'skill' });
256
+ if (localLogger) {
257
+ try {
258
+ if (result.suspicious) {
259
+ localLogger.logLocal({ event: 'guard_check', guard: 'skill', decision: 'scan_flagged', reason: result.reason || 'Suspicious skill detected', detail: { skill_name: path.basename(skillPath), skill_path: skillPath, file_count: files.length, file_names: files.map(f => f.relative_path), file_contents: files.map(f => ({ path: f.relative_path, content: f.content.slice(0, 2000) })), type: type || 'skill' } });
260
+ } else {
261
+ localLogger.logLocal({ event: 'guard_check', guard: 'skill', decision: 'scan_clean', reason: `Scan clean: ${path.basename(skillPath)}`, detail: { skill_name: path.basename(skillPath), skill_path: skillPath, file_count: files.length, file_names: files.map(f => f.relative_path), type: type || 'skill' } });
262
+ }
263
+ } catch {}
264
+ }
218
265
  saveScanCache();
219
266
  } catch {}
220
267
  } else {
@@ -232,14 +279,18 @@ module.exports = function createSkillsGuard({ readFileSync, httpsRequest, baseDi
232
279
  }
233
280
  }
234
281
 
235
- function logSkillRemoved(skillPath, installId) {
236
- track('skill_removed', { skill_name: path.basename(skillPath) });
282
+ function logSkillRemoved(skillPath, installId, type) {
283
+ track('skill_removed', { skill_name: path.basename(skillPath), type: type || 'skill' });
284
+ if (localLogger) {
285
+ try { localLogger.logLocal({ event: 'guard_check', guard: 'skill', decision: 'removed', reason: `${(type || 'skill')} removed: ${path.basename(skillPath)}`, detail: { skill_name: path.basename(skillPath), skill_path: skillPath, type: type || 'skill' } }); } catch {}
286
+ }
237
287
  const payload = JSON.stringify({
238
288
  install_id: installId,
239
289
  skill_path: skillPath,
240
290
  skill_name: path.basename(skillPath),
241
291
  files: [],
242
292
  removed: true,
293
+ type: type || 'skill',
243
294
  });
244
295
 
245
296
  const url = new URL(SKILL_SCAN_API);
@@ -263,8 +314,8 @@ module.exports = function createSkillsGuard({ readFileSync, httpsRequest, baseDi
263
314
  } catch {}
264
315
  }
265
316
 
266
- function scanSkillIfChanged(skillPath) {
267
- const { files, binaryFiles } = readSkillFiles(skillPath);
317
+ function scanSkillIfChanged(skillPath, type) {
318
+ const { files, binaryFiles, skippedFiles, totalSize } = readSkillFiles(skillPath);
268
319
  if (files.length === 0 && binaryFiles.length === 0) {
269
320
  // Skill was deleted or is empty — remove from flagged
270
321
  flaggedSkills.delete(skillPath);
@@ -273,25 +324,38 @@ module.exports = function createSkillsGuard({ readFileSync, httpsRequest, baseDi
273
324
  return;
274
325
  }
275
326
 
327
+ // Log skipped files if any
328
+ if (skippedFiles.length > 0 && localLogger) {
329
+ try { localLogger.logLocal({ event: 'guard_check', guard: 'skill', decision: 'files_skipped', reason: `${skippedFiles.length} file(s) skipped in ${type || 'skill'}: ${path.basename(skillPath)}`, detail: { skill_name: path.basename(skillPath), skill_path: skillPath, type: type || 'skill', skipped_files: skippedFiles, total_size_bytes: totalSize, scanned_file_count: files.length } }); } catch {}
330
+ }
331
+
276
332
  // Flag immediately if binary files found — legitimate skills should not contain binaries
277
333
  if (binaryFiles.length > 0) {
278
334
  flaggedSkills.set(skillPath, {
279
335
  suspicious: true,
280
- reason: `Skill contains binary files (${binaryFiles.join(', ')}). Legitimate skills should only contain text files. Please delete these binary files or remove this skill.`,
336
+ reason: `${(type || 'skill')} contains binary files (${binaryFiles.join(', ')}). Please delete these binary files or remove this ${type || 'skill'}.`,
281
337
  });
282
- track('skill_binary_detected', { skill_name: path.basename(skillPath), binary_count: binaryFiles.length });
338
+ track('skill_binary_detected', { skill_name: path.basename(skillPath), binary_count: binaryFiles.length, type: type || 'skill' });
339
+ if (localLogger) {
340
+ try { localLogger.logLocal({ event: 'guard_check', guard: 'skill', decision: 'binary_detected', reason: `Binary files found in ${type || 'skill'}: ${path.basename(skillPath)}`, detail: { skill_name: path.basename(skillPath), skill_path: skillPath, binary_files: binaryFiles, type: type || 'skill' } }); } catch {}
341
+ }
283
342
  saveScanCache();
284
343
  return;
285
344
  }
286
345
 
287
346
  const hash = hashSkillFiles(files);
347
+ const isNew = !skillContentHashes.has(skillPath);
288
348
  if (skillContentHashes.get(skillPath) === hash) return; // unchanged
289
349
 
350
+ if (!isNew && localLogger) {
351
+ try { localLogger.logLocal({ event: 'guard_check', guard: 'skill', decision: 'modified', reason: `${(type || 'skill')} modified: ${path.basename(skillPath)}`, detail: { skill_name: path.basename(skillPath), skill_path: skillPath, file_count: files.length, file_names: files.map(f => f.relative_path), type: type || 'skill' } }); } catch {}
352
+ }
353
+
290
354
  const installId = getInstallId();
291
- scanSkillAsync(skillPath, files, hash, installId);
355
+ scanSkillAsync(skillPath, files, hash, installId, type, skippedFiles);
292
356
  }
293
357
 
294
- function watchSkillDirectory(dir) {
358
+ function watchSkillDirectory(dir, type) {
295
359
  const debounceTimers = new Map();
296
360
  try {
297
361
  const watcher = fs.watch(dir, { recursive: true }, (eventType, filename) => {
@@ -300,19 +364,19 @@ module.exports = function createSkillsGuard({ readFileSync, httpsRequest, baseDi
300
364
  if (debounceTimers.has(skillDir)) clearTimeout(debounceTimers.get(skillDir));
301
365
  debounceTimers.set(skillDir, setTimeout(() => {
302
366
  debounceTimers.delete(skillDir);
303
- // Re-discover skills and scan changed ones
304
- const skills = collectSkillEntries(dir);
305
- const currentPaths = new Set(skills.map(s => s.skillPath));
306
- for (const { skillPath } of skills) {
307
- scanSkillIfChanged(skillPath);
367
+ // Re-discover entries and scan changed ones
368
+ const entries = type === 'plugin' ? getPluginEntries() : collectSkillEntries(dir);
369
+ const currentPaths = new Set(entries.map(s => s.skillPath));
370
+ for (const { skillPath, type: entryType } of entries) {
371
+ scanSkillIfChanged(skillPath, entryType || type);
308
372
  }
309
- // Detect deleted skills: any known skill in this dir that no longer exists
373
+ // Detect deleted entries: any known path in this dir that no longer exists
310
374
  for (const knownPath of skillContentHashes.keys()) {
311
375
  if (knownPath.startsWith(dir) && !currentPaths.has(knownPath)) {
312
376
  flaggedSkills.delete(knownPath);
313
377
  skillContentHashes.delete(knownPath);
314
378
  const installId = getInstallId();
315
- logSkillRemoved(knownPath, installId);
379
+ logSkillRemoved(knownPath, installId, type);
316
380
  saveScanCache();
317
381
  }
318
382
  }
@@ -365,14 +429,85 @@ module.exports = function createSkillsGuard({ readFileSync, httpsRequest, baseDi
365
429
  for (const dir of dirs) {
366
430
  const skills = collectSkillEntries(dir);
367
431
  totalSkills += skills.length;
368
- for (const { skillPath } of skills) {
369
- scanSkillIfChanged(skillPath);
432
+ for (const { skillPath, type } of skills) {
433
+ scanSkillIfChanged(skillPath, type);
370
434
  }
371
435
  watchSkillDirectory(dir);
372
436
  }
437
+
438
+ // Scan plugins (entire plugin directories, not just skills/)
439
+ const pluginEntries = getPluginEntries();
440
+ totalSkills += pluginEntries.length;
441
+ for (const { skillPath, type } of pluginEntries) {
442
+ scanSkillIfChanged(skillPath, type);
443
+ }
444
+ // Watch the plugins directory itself for new/changed plugins
445
+ const pluginsDir = path.join(HOME, '.claude', 'plugins');
446
+ try { if (fs.statSync(pluginsDir).isDirectory()) watchSkillDirectory(pluginsDir, 'plugin'); } catch {}
447
+
373
448
  // Always register session so install_id → user_id mapping exists in Supabase
374
449
  registerSession(installId, totalSkills);
375
- track('skill_scanner_init', { skill_dir_count: dirs.length, total_skills: totalSkills });
450
+ track('skill_scanner_init', { skill_dir_count: dirs.length, total_skills: totalSkills, plugin_count: pluginEntries.length });
451
+ if (localLogger) {
452
+ try { localLogger.logLocal({ event: 'guard_check', guard: 'skill', decision: 'init', reason: `Skill scanner initialized: ${totalSkills} skills/plugins in ${dirs.length} directories`, detail: { skill_dir_count: dirs.length, total_skills: totalSkills, plugin_count: pluginEntries.length, directories: dirs } }); } catch {}
453
+ // Log each skill individually so dashboard can show per-skill status
454
+ for (const dir of dirs) {
455
+ const skills = collectSkillEntries(dir);
456
+ for (const { skillPath, skillName, type } of skills) {
457
+ try {
458
+ const { files, binaryFiles, skippedFiles, totalSize } = readSkillFiles(skillPath);
459
+ const cached = skillContentHashes.has(skillPath);
460
+ const flagged = flaggedSkills.get(skillPath);
461
+ localLogger.logLocal({
462
+ event: 'guard_check', guard: 'skill', decision: 'init_skill',
463
+ reason: `Skill discovered: ${skillName}`,
464
+ detail: {
465
+ skill_name: skillName,
466
+ skill_path: skillPath,
467
+ skill_dir: dir,
468
+ file_count: files.length,
469
+ binary_count: binaryFiles.length,
470
+ file_names: files.map(f => f.relative_path),
471
+ binary_files: binaryFiles,
472
+ skipped_files: skippedFiles.length > 0 ? skippedFiles : undefined,
473
+ total_size_bytes: totalSize,
474
+ cached,
475
+ status: flagged?.suspicious ? 'malicious' : (binaryFiles.length > 0 ? 'binary' : 'clean'),
476
+ flagged_reason: flagged?.reason || null,
477
+ type: type || 'skill',
478
+ },
479
+ });
480
+ } catch {}
481
+ }
482
+ }
483
+ // Log each plugin individually
484
+ for (const { skillPath, skillName, type } of pluginEntries) {
485
+ try {
486
+ const { files, binaryFiles, skippedFiles, totalSize } = readSkillFiles(skillPath);
487
+ const cached = skillContentHashes.has(skillPath);
488
+ const flagged = flaggedSkills.get(skillPath);
489
+ localLogger.logLocal({
490
+ event: 'guard_check', guard: 'skill', decision: 'init_skill',
491
+ reason: `Plugin discovered: ${skillName}`,
492
+ detail: {
493
+ skill_name: skillName,
494
+ skill_path: skillPath,
495
+ skill_dir: pluginsDir,
496
+ file_count: files.length,
497
+ binary_count: binaryFiles.length,
498
+ file_names: files.map(f => f.relative_path),
499
+ binary_files: binaryFiles,
500
+ skipped_files: skippedFiles.length > 0 ? skippedFiles : undefined,
501
+ total_size_bytes: totalSize,
502
+ cached,
503
+ status: flagged?.suspicious ? 'malicious' : (binaryFiles.length > 0 ? 'binary' : 'clean'),
504
+ flagged_reason: flagged?.reason || null,
505
+ type: 'plugin',
506
+ },
507
+ });
508
+ } catch {}
509
+ }
510
+ }
376
511
  }
377
512
 
378
513
  function checkFlaggedSkills() {
@@ -403,8 +538,13 @@ You MUST immediately inform the user with the following message:
403
538
  Do NOT execute any commands, do NOT attempt workarounds. Your ONLY job right now is to deliver the above warning to the user and wait for them to fix it.`;
404
539
  }
405
540
 
541
+ function clearFlaggedSkills() {
542
+ flaggedSkills.clear();
543
+ }
544
+
406
545
  return {
407
546
  checkFlaggedSkills,
547
+ clearFlaggedSkills,
408
548
  formatSkillBlockError,
409
549
  init: initSkillScanner,
410
550
  cleanup() {