feat: initial open marketplace with llm-security, config-audit, ultraplan-local
This commit is contained in:
commit
f93d6abdae
380 changed files with 65935 additions and 0 deletions
308
plugins/config-audit/scanners/lib/file-discovery.mjs
Normal file
308
plugins/config-audit/scanners/lib/file-discovery.mjs
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
/**
|
||||
* Config file discovery for config-audit.
|
||||
* Finds CLAUDE.md, settings.json, hooks.json, .mcp.json, rules/, plugin.json, etc.
|
||||
* Zero external dependencies.
|
||||
*/
|
||||
|
||||
import { readdir, stat, readFile } from 'node:fs/promises';
|
||||
import { join, resolve, relative, extname, basename, dirname, sep } from 'node:path';
|
||||
|
||||
const SKIP_DIRS = new Set([
|
||||
'node_modules', '.git', 'dist', 'build', 'coverage', '__pycache__',
|
||||
'.next', '.nuxt', '.output', '.cache', '.turbo', '.parcel-cache',
|
||||
'vendor', 'venv', '.venv', '.tox',
|
||||
]);
|
||||
|
||||
/** Config file patterns to discover */
|
||||
const CONFIG_PATTERNS = {
|
||||
claudeMd: /^CLAUDE\.md$|^CLAUDE\.local\.md$/i,
|
||||
settingsJson: /^settings\.json$|^settings\.local\.json$/,
|
||||
mcpJson: /^\.mcp\.json$/,
|
||||
pluginJson: /^plugin\.json$/,
|
||||
hooksJson: /^hooks\.json$/,
|
||||
rulesDir: /^rules$/,
|
||||
agentsMd: /\.md$/,
|
||||
commandsMd: /\.md$/,
|
||||
skillsMd: /^SKILL\.md$/i,
|
||||
keybindings: /^keybindings\.json$/,
|
||||
claudeJson: /^\.claude\.json$/,
|
||||
};
|
||||
|
||||
/**
|
||||
* Discover all Claude Code config files under a target path.
|
||||
* @param {string} targetPath
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.maxFiles=500] - max files to return
|
||||
* @param {boolean} [opts.includeGlobal=false] - also scan ~/.claude/
|
||||
* @returns {Promise<{ files: ConfigFile[], skipped: number }>}
|
||||
*
|
||||
* @typedef {{ absPath: string, relPath: string, type: string, scope: string, size: number }} ConfigFile
|
||||
*/
|
||||
export async function discoverConfigFiles(targetPath, opts = {}) {
|
||||
const maxFiles = opts.maxFiles || 2000;
|
||||
const maxDepth = opts.maxDepth || 10;
|
||||
const files = [];
|
||||
const skippedRef = { count: 0 };
|
||||
|
||||
await walkForConfig(targetPath, targetPath, files, skippedRef, maxFiles, undefined, maxDepth);
|
||||
|
||||
if (opts.includeGlobal) {
|
||||
const home = process.env.HOME || process.env.USERPROFILE || '';
|
||||
const claudeDir = join(home, '.claude');
|
||||
try {
|
||||
await stat(claudeDir);
|
||||
await walkForConfig(claudeDir, claudeDir, files, skippedRef, maxFiles, 'user', maxDepth);
|
||||
} catch { /* .claude dir doesn't exist */ }
|
||||
|
||||
// ~/.claude.json
|
||||
const claudeJson = join(home, '.claude.json');
|
||||
try {
|
||||
const s = await stat(claudeJson);
|
||||
files.push({
|
||||
absPath: claudeJson,
|
||||
relPath: '.claude.json',
|
||||
type: 'claude-json',
|
||||
scope: 'user',
|
||||
size: s.size,
|
||||
});
|
||||
} catch { /* doesn't exist */ }
|
||||
}
|
||||
|
||||
return { files, skipped: skippedRef.count };
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk directory tree looking for config files.
|
||||
*/
|
||||
async function walkForConfig(dir, basePath, files, skippedRef, maxFiles, forceScope, maxDepth) {
|
||||
if (files.length >= maxFiles) return;
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (files.length >= maxFiles) break;
|
||||
const fullPath = join(dir, entry.name);
|
||||
const rel = relative(basePath, fullPath);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (SKIP_DIRS.has(entry.name)) {
|
||||
skippedRef.count++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for .claude directory (contains settings, rules, etc.)
|
||||
if (entry.name === '.claude' || entry.name === '.claude-plugin') {
|
||||
await walkForConfig(fullPath, basePath, files, skippedRef, maxFiles, forceScope, maxDepth);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for rules/ inside .claude
|
||||
if (entry.name === 'rules' && dirname(rel).includes('.claude')) {
|
||||
await walkRulesDir(fullPath, basePath, files, maxFiles, forceScope || classifyScope(rel, basePath));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for agents/, commands/, skills/, hooks/ dirs
|
||||
if (['agents', 'commands', 'skills', 'hooks'].includes(entry.name)) {
|
||||
await walkForConfig(fullPath, basePath, files, skippedRef, maxFiles, forceScope, maxDepth);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Recurse into subdirectories (configurable depth limit)
|
||||
const depth = rel.split(sep).length;
|
||||
if (depth < maxDepth) {
|
||||
await walkForConfig(fullPath, basePath, files, skippedRef, maxFiles, forceScope, maxDepth);
|
||||
}
|
||||
} else if (entry.isFile()) {
|
||||
const fileType = classifyFile(entry.name, rel);
|
||||
if (fileType) {
|
||||
let s;
|
||||
try {
|
||||
s = await stat(fullPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
files.push({
|
||||
absPath: fullPath,
|
||||
relPath: rel,
|
||||
type: fileType,
|
||||
scope: forceScope || classifyScope(rel, basePath),
|
||||
size: s.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a rules directory and collect all files (including non-.md for validation).
|
||||
*/
|
||||
async function walkRulesDir(dir, basePath, files, maxFiles, scope) {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (files.length >= maxFiles) break;
|
||||
const fullPath = join(dir, entry.name);
|
||||
if (entry.isFile()) {
|
||||
let s;
|
||||
try {
|
||||
s = await stat(fullPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
files.push({
|
||||
absPath: fullPath,
|
||||
relPath: relative(basePath, fullPath),
|
||||
type: 'rule',
|
||||
scope,
|
||||
size: s.size,
|
||||
});
|
||||
} else if (entry.isDirectory()) {
|
||||
await walkRulesDir(fullPath, basePath, files, maxFiles, scope);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a file by name and path.
|
||||
* @returns {string | null}
|
||||
*/
|
||||
function classifyFile(name, relPath) {
|
||||
if (CONFIG_PATTERNS.claudeMd.test(name)) return 'claude-md';
|
||||
if (name === 'settings.json' || name === 'settings.local.json') {
|
||||
if (relPath.includes('.claude')) return 'settings-json';
|
||||
}
|
||||
if (name === '.mcp.json') return 'mcp-json';
|
||||
if (name === 'plugin.json' && relPath.includes('.claude-plugin')) return 'plugin-json';
|
||||
if (name === 'hooks.json' && relPath.includes('hooks')) return 'hooks-json';
|
||||
if (name === 'keybindings.json') return 'keybindings-json';
|
||||
if (name === '.claude.json') return 'claude-json';
|
||||
|
||||
// Agent/command/skill markdown files
|
||||
if (name.endsWith('.md') && relPath.includes(`agents${sep}`)) return 'agent-md';
|
||||
if (name.endsWith('.md') && relPath.includes(`commands${sep}`)) return 'command-md';
|
||||
if (/^SKILL\.md$/i.test(name)) return 'skill-md';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the scope of a config file.
|
||||
* @returns {'managed' | 'user' | 'project' | 'local' | 'plugin'}
|
||||
*/
|
||||
function classifyScope(relPath, basePath) {
|
||||
if (relPath.includes('managed-settings')) return 'managed';
|
||||
if (basePath.includes(`.claude${sep}plugins`)) return 'plugin';
|
||||
if (relPath.includes('.local.')) return 'local';
|
||||
const home = process.env.HOME || process.env.USERPROFILE || '';
|
||||
if (basePath.startsWith(join(home, '.claude'))) return 'user';
|
||||
return 'project';
|
||||
}
|
||||
|
||||
/** Common developer directory names under $HOME */
|
||||
const DEV_DIRS = ['repos', 'projects', 'src', 'code', 'dev', 'work', 'Sites', 'Developer'];
|
||||
|
||||
/**
|
||||
* Discover all root paths for a full-machine scan.
|
||||
* Only returns paths that actually exist on the filesystem.
|
||||
* @returns {Promise<Array<{ path: string, maxDepth: number }>>}
|
||||
*/
|
||||
export async function discoverFullMachinePaths() {
|
||||
const home = process.env.HOME || process.env.USERPROFILE || '';
|
||||
const candidates = [
|
||||
// ~/.claude — deepest (plugins can be 6+ levels deep)
|
||||
{ path: join(home, '.claude'), maxDepth: 10 },
|
||||
// Managed system paths
|
||||
{ path: '/Library/Application Support/ClaudeCode', maxDepth: 5 },
|
||||
{ path: '/etc/claude-code', maxDepth: 5 },
|
||||
// Common developer directories
|
||||
...DEV_DIRS.map(d => ({ path: join(home, d), maxDepth: 5 })),
|
||||
];
|
||||
|
||||
const existing = [];
|
||||
for (const c of candidates) {
|
||||
try {
|
||||
const s = await stat(c.path);
|
||||
if (s.isDirectory()) existing.push(c);
|
||||
} catch { /* not present */ }
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover config files across multiple root paths.
|
||||
* Calls discoverConfigFiles() per root with correct basePath (preserves scope/relPath).
|
||||
* Deduplicates files by absPath — first occurrence wins.
|
||||
* @param {Array<{ path: string, maxDepth: number }>} roots
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.maxFiles=2000] - global max across all roots
|
||||
* @returns {Promise<{ files: ConfigFile[], skipped: number }>}
|
||||
*/
|
||||
export async function discoverConfigFilesMulti(roots, opts = {}) {
|
||||
const maxFiles = opts.maxFiles || 2000;
|
||||
const seen = new Set();
|
||||
const allFiles = [];
|
||||
let totalSkipped = 0;
|
||||
|
||||
for (const root of roots) {
|
||||
if (allFiles.length >= maxFiles) break;
|
||||
|
||||
const result = await discoverConfigFiles(root.path, {
|
||||
maxFiles: maxFiles - allFiles.length,
|
||||
maxDepth: root.maxDepth,
|
||||
});
|
||||
|
||||
totalSkipped += result.skipped;
|
||||
|
||||
for (const f of result.files) {
|
||||
if (!seen.has(f.absPath)) {
|
||||
seen.add(f.absPath);
|
||||
allFiles.push(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle ~/.claude.json separately (single file, not a directory)
|
||||
const home = process.env.HOME || process.env.USERPROFILE || '';
|
||||
const claudeJson = join(home, '.claude.json');
|
||||
if (allFiles.length < maxFiles && !seen.has(claudeJson)) {
|
||||
try {
|
||||
const s = await stat(claudeJson);
|
||||
allFiles.push({
|
||||
absPath: claudeJson,
|
||||
relPath: '.claude.json',
|
||||
type: 'claude-json',
|
||||
scope: 'user',
|
||||
size: s.size,
|
||||
});
|
||||
} catch { /* doesn't exist */ }
|
||||
}
|
||||
|
||||
return { files: allFiles, skipped: totalSkipped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a file as UTF-8 text. Returns null on error or if binary.
|
||||
* @param {string} absPath
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
export async function readTextFile(absPath) {
|
||||
try {
|
||||
const content = await readFile(absPath, 'utf-8');
|
||||
// Check for binary (null bytes in first 8KB)
|
||||
const sample = content.slice(0, 8192);
|
||||
if (sample.includes('\0')) return null;
|
||||
return content;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue