// skill-registry.mjs — Local database of known skill fingerprints and risk profiles. // Fingerprints skills by SHA-256 of normalized content, stores scan results, // enables instant re-scan detection and pattern search. // Zero external dependencies. import { createHash } from 'node:crypto'; import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs'; import { join, resolve, relative, dirname, basename, extname } from 'node:path'; import { fileURLToPath } from 'node:url'; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const REGISTRY_VERSION = '1'; const MAX_FILE_SIZE = 256 * 1024; // 256KB — skills are markdown, not binaries const SCANNABLE_EXTENSIONS = new Set(['.md', '.mdx', '.json', '.mjs', '.js', '.ts', '.sh']); const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'coverage']); // Stale threshold — 7 days. If a cached scan is older than this, // we suggest re-scanning but still return the cached result. const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000; // --------------------------------------------------------------------------- // Plugin root resolution // --------------------------------------------------------------------------- const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const PLUGIN_ROOT = resolve(__dirname, '..', '..'); // --------------------------------------------------------------------------- // Content normalization — same skill should produce same fingerprint // regardless of trailing whitespace, line endings, or blank line count. // --------------------------------------------------------------------------- /** * Normalize content for fingerprinting. * - Normalize line endings to \n * - Trim trailing whitespace from each line * - Collapse multiple consecutive blank lines into one * - Trim leading/trailing blank lines * @param {string} content * @returns {string} */ export function normalizeContent(content) { return content .replace(/\r\n/g, '\n') .replace(/\r/g, '\n') .split('\n') .map(line => line.trimEnd()) .join('\n') .replace(/\n{3,}/g, '\n\n') .trim(); } // --------------------------------------------------------------------------- // File collection — gather all scannable files from a skill path // --------------------------------------------------------------------------- /** * Recursively collect files from a directory. * @param {string} dirPath - Absolute path to directory * @param {string} basePath - Base path for relative path calculation * @returns {{ relPath: string, content: string }[]} */ function collectFiles(dirPath, basePath) { const files = []; let entries; try { entries = readdirSync(dirPath, { withFileTypes: true }); } catch { return files; } for (const entry of entries) { const fullPath = join(dirPath, entry.name); if (entry.isDirectory()) { if (SKIP_DIRS.has(entry.name)) continue; files.push(...collectFiles(fullPath, basePath)); continue; } if (!entry.isFile()) continue; const ext = extname(entry.name).toLowerCase(); if (!SCANNABLE_EXTENSIONS.has(ext)) continue; try { const stat = statSync(fullPath); if (stat.size > MAX_FILE_SIZE) continue; const content = readFileSync(fullPath, 'utf8'); files.push({ relPath: relative(basePath, fullPath), content }); } catch { continue; } } return files; } // --------------------------------------------------------------------------- // Fingerprinting // --------------------------------------------------------------------------- /** * Generate a SHA-256 fingerprint for a skill. * * For a directory: collects all scannable files, sorts by relative path, * normalizes each, and hashes the concatenation. * * For a single file: normalizes and hashes it directly. * * @param {string} skillPath - Absolute or relative path to skill file or directory * @returns {{ fingerprint: string, files: string[], name: string }} */ export function fingerprintSkill(skillPath) { const absPath = resolve(skillPath); const hash = createHash('sha256'); let fileList = []; let name = basename(absPath); if (statSync(absPath).isDirectory()) { const collected = collectFiles(absPath, absPath); // Sort for determinism collected.sort((a, b) => a.relPath.localeCompare(b.relPath)); for (const { relPath, content } of collected) { fileList.push(relPath); // Hash includes the relative path so renames change the fingerprint hash.update(relPath + '\x00'); hash.update(normalizeContent(content) + '\x00'); } // Try to extract skill name from SKILL.md or plugin.json const skillMd = collected.find(f => f.relPath.toLowerCase().endsWith('skill.md') || f.relPath.toLowerCase().includes('/skill.md') ); if (skillMd) { const nameMatch = skillMd.content.match(/^#\s+(.+)/m); if (nameMatch) name = nameMatch[1].trim(); } const pluginJson = collected.find(f => f.relPath === 'plugin.json' || f.relPath.endsWith('/plugin.json')); if (pluginJson) { try { const parsed = JSON.parse(pluginJson.content); if (parsed.name) name = parsed.name; } catch { /* ignore parse errors */ } } } else { // Single file const content = readFileSync(absPath, 'utf8'); fileList.push(basename(absPath)); hash.update(normalizeContent(content)); // Try to extract name from frontmatter const nameMatch = content.match(/^name:\s*(.+)/m); if (nameMatch) name = nameMatch[1].trim().replace(/^["']|["']$/g, ''); } return { fingerprint: hash.digest('hex'), files: fileList, name, }; } // --------------------------------------------------------------------------- // Registry I/O // --------------------------------------------------------------------------- /** * Default registry file path. * @param {string} [pluginRoot] * @returns {string} */ export function registryPath(pluginRoot) { return join(pluginRoot || PLUGIN_ROOT, 'reports', 'skill-registry.json'); } /** * Seed registry file path (ships with plugin). * @param {string} [pluginRoot] * @returns {string} */ export function seedRegistryPath(pluginRoot) { return join(pluginRoot || PLUGIN_ROOT, 'knowledge', 'skill-registry.json'); } /** * Create an empty registry structure. * @returns {object} */ function emptyRegistry() { return { version: REGISTRY_VERSION, updated: new Date().toISOString(), entry_count: 0, entries: {}, }; } /** * Load registry from disk. Merges seed data if available. * Creates empty registry if file doesn't exist. * @param {string} [pluginRoot] * @returns {object} */ export function loadRegistry(pluginRoot) { const filePath = registryPath(pluginRoot); let registry; if (existsSync(filePath)) { try { registry = JSON.parse(readFileSync(filePath, 'utf8')); } catch { registry = emptyRegistry(); } } else { registry = emptyRegistry(); } // Merge seed data (seed entries never overwrite existing entries) const seedPath = seedRegistryPath(pluginRoot); if (existsSync(seedPath)) { try { const seeds = JSON.parse(readFileSync(seedPath, 'utf8')); for (const [fp, entry] of Object.entries(seeds.entries || {})) { if (!registry.entries[fp]) { registry.entries[fp] = { ...entry, source_type: 'seed' }; } } } catch { /* ignore seed parse errors */ } } // Ensure entry_count is accurate registry.entry_count = Object.keys(registry.entries).length; return registry; } /** * Save registry to disk. * @param {object} registry * @param {string} [pluginRoot] * @returns {string} Path to saved file */ export function saveRegistry(registry, pluginRoot) { const filePath = registryPath(pluginRoot); const dir = dirname(filePath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } registry.updated = new Date().toISOString(); registry.entry_count = Object.keys(registry.entries).length; writeFileSync(filePath, JSON.stringify(registry, null, 2) + '\n'); return filePath; } // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- /** * Check if a fingerprint exists in the registry. * @param {string} fingerprint * @param {string} [pluginRoot] * @returns {{ found: boolean, entry: object|null, stale: boolean }} */ export function checkRegistry(fingerprint, pluginRoot) { const registry = loadRegistry(pluginRoot); const entry = registry.entries[fingerprint] || null; if (!entry) { return { found: false, entry: null, stale: false }; } const lastScanned = new Date(entry.last_scanned).getTime(); const stale = (Date.now() - lastScanned) > STALE_THRESHOLD_MS; return { found: true, entry, stale }; } /** * Register a scan result for a skill. * @param {object} opts * @param {string} opts.skillPath - Path that was scanned * @param {string} opts.fingerprint - From fingerprintSkill() * @param {string} opts.name - Skill name * @param {string[]} opts.files - Files included in fingerprint * @param {string} opts.verdict - ALLOW|WARNING|BLOCK * @param {number} opts.risk_score - 0-100 * @param {object} opts.counts - { critical, high, medium, low, info } * @param {number} opts.files_scanned - Number of files scanned * @param {string[]} [opts.tags] - Optional tags * @param {string} [pluginRoot] * @returns {{ entry: object, path: string }} */ export function registerScan(opts, pluginRoot) { const registry = loadRegistry(pluginRoot); const existing = registry.entries[opts.fingerprint]; const entry = { name: opts.name, source: opts.skillPath, fingerprint: opts.fingerprint, first_seen: existing?.first_seen || new Date().toISOString(), last_scanned: new Date().toISOString(), scan_count: (existing?.scan_count || 0) + 1, verdict: opts.verdict, risk_score: opts.risk_score, counts: opts.counts, files_scanned: opts.files_scanned, files_in_fingerprint: opts.files, tags: opts.tags || existing?.tags || [], source_type: 'scanned', }; registry.entries[opts.fingerprint] = entry; const savedPath = saveRegistry(registry, pluginRoot); return { entry, path: savedPath }; } /** * Search the registry by name, source, or tag pattern. * @param {string} pattern - Search pattern (case-insensitive substring match) * @param {string} [pluginRoot] * @returns {object[]} Matching entries */ export function searchRegistry(pattern, pluginRoot) { const registry = loadRegistry(pluginRoot); const lower = pattern.toLowerCase(); const matches = []; for (const entry of Object.values(registry.entries)) { const searchable = [ entry.name || '', entry.source || '', ...(entry.tags || []), entry.fingerprint || '', ].join(' ').toLowerCase(); if (searchable.includes(lower)) { matches.push(entry); } } // Sort by last_scanned descending (most recent first) matches.sort((a, b) => { const aTime = new Date(b.last_scanned || 0).getTime(); const bTime = new Date(a.last_scanned || 0).getTime(); return aTime - bTime; }); return matches; } /** * Get registry statistics. * @param {string} [pluginRoot] * @returns {object} */ export function getStats(pluginRoot) { const registry = loadRegistry(pluginRoot); const entries = Object.values(registry.entries); const stats = { version: registry.version, updated: registry.updated, total_entries: entries.length, by_verdict: { ALLOW: 0, WARNING: 0, BLOCK: 0 }, by_source_type: { scanned: 0, seed: 0 }, total_scans: 0, stale_count: 0, avg_risk_score: 0, }; let riskSum = 0; const now = Date.now(); for (const entry of entries) { // By verdict const v = entry.verdict || 'ALLOW'; stats.by_verdict[v] = (stats.by_verdict[v] || 0) + 1; // By source type const st = entry.source_type || 'scanned'; stats.by_source_type[st] = (stats.by_source_type[st] || 0) + 1; // Scan count stats.total_scans += entry.scan_count || 0; // Risk score riskSum += entry.risk_score || 0; // Stale check const lastScanned = new Date(entry.last_scanned || 0).getTime(); if ((now - lastScanned) > STALE_THRESHOLD_MS) { stats.stale_count++; } } stats.avg_risk_score = entries.length > 0 ? Math.round(riskSum / entries.length) : 0; return stats; } /** * Remove an entry from the registry by fingerprint. * @param {string} fingerprint * @param {string} [pluginRoot] * @returns {boolean} true if entry was found and removed */ export function removeEntry(fingerprint, pluginRoot) { const registry = loadRegistry(pluginRoot); if (!registry.entries[fingerprint]) return false; delete registry.entries[fingerprint]; saveRegistry(registry, pluginRoot); return true; } /** * List all entries, optionally filtered by verdict. * @param {object} [opts] * @param {string} [opts.verdict] - Filter by verdict (ALLOW|WARNING|BLOCK) * @param {boolean} [opts.staleOnly] - Only return stale entries * @param {string} [pluginRoot] * @returns {object[]} */ export function listEntries(opts, pluginRoot) { const registry = loadRegistry(pluginRoot); let entries = Object.values(registry.entries); const now = Date.now(); if (opts?.verdict) { entries = entries.filter(e => e.verdict === opts.verdict); } if (opts?.staleOnly) { entries = entries.filter(e => { const lastScanned = new Date(e.last_scanned || 0).getTime(); return (now - lastScanned) > STALE_THRESHOLD_MS; }); } // Sort by last_scanned descending entries.sort((a, b) => new Date(b.last_scanned || 0).getTime() - new Date(a.last_scanned || 0).getTime() ); return entries; }