#!/usr/bin/env node // reference-config-generator.mjs — Generate Grade A security configuration // Runs posture-scanner, identifies gaps, generates settings/CLAUDE.md/.gitignore // to close them. Supports --dry-run (default) and --apply (writes files with backup). // // Standalone CLI: node scanners/reference-config-generator.mjs [path] [--apply] [--dry-run] // Library: import { generate } from './reference-config-generator.mjs' // // Zero external dependencies — Node.js builtins only. import { readFile, writeFile, mkdir, access, copyFile } from 'node:fs/promises'; import { join, resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { scan } from './posture-scanner.mjs'; import { resetCounter } from './lib/output.mjs'; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const __dirname = dirname(fileURLToPath(import.meta.url)); const TEMPLATES_DIR = resolve(__dirname, '..', 'templates', 'reference-config'); // Categories where we can generate config fixes const FIXABLE_CATEGORIES = new Map([ [1, 'settings'], // Deny-First Configuration [2, 'gitignore'], // Secrets Protection (gitignore portion) [3, 'gitignore'], // Path Guarding (gitignore portion) [6, 'settings'], // Sandbox Configuration [10, 'claudemd'], // Cognitive State Security (CLAUDE.md guardrails) ]); // --------------------------------------------------------------------------- // File helpers // --------------------------------------------------------------------------- async function readJson(filePath) { try { return JSON.parse(await readFile(filePath, 'utf-8')); } catch { return null; } } async function readText(filePath) { try { return await readFile(filePath, 'utf-8'); } catch { return null; } } async function fileExists(filePath) { try { await access(filePath); return true; } catch { return false; } } async function ensureDir(dirPath) { await mkdir(dirPath, { recursive: true }); } // --------------------------------------------------------------------------- // Project type detection // --------------------------------------------------------------------------- /** * Detect project type: plugin, monorepo, or standalone. * @param {string} projectRoot * @returns {Promise<'plugin' | 'monorepo' | 'standalone'>} */ async function detectProjectType(projectRoot) { // Plugin: has .claude-plugin/plugin.json or plugin.json if (await fileExists(join(projectRoot, '.claude-plugin', 'plugin.json'))) return 'plugin'; if (await fileExists(join(projectRoot, 'plugin.json'))) return 'plugin'; // Monorepo: package.json with workspaces, or lerna.json, or pnpm-workspace.yaml const pkg = await readJson(join(projectRoot, 'package.json')); if (pkg?.workspaces) return 'monorepo'; if (await fileExists(join(projectRoot, 'lerna.json'))) return 'monorepo'; if (await fileExists(join(projectRoot, 'pnpm-workspace.yaml'))) return 'monorepo'; return 'standalone'; } // --------------------------------------------------------------------------- // Recommendation builders // --------------------------------------------------------------------------- /** * Build settings.json recommendation. * If existing settings have deny-first, returns 'none'. Otherwise creates or merges. */ async function buildSettingsRec(projectRoot, categories) { const settingsPath = join(projectRoot, '.claude', 'settings.json'); const existing = await readJson(settingsPath); const template = await readJson(join(TEMPLATES_DIR, 'settings-deny-first.json')); // Check if deny-first already set const cat1 = categories.find(c => c.id === 1); const cat6 = categories.find(c => c.id === 6); const needsDenyFirst = cat1 && cat1.status !== 'PASS'; const needsSandboxFix = cat6 && cat6.status !== 'PASS' && cat6.status !== 'N_A'; if (!needsDenyFirst && !needsSandboxFix) { return { category: 'Deny-First + Sandbox', file: '.claude/settings.json', action: 'none', content: '' }; } if (!existing) { // Create fresh from template return { category: 'Deny-First + Sandbox', file: '.claude/settings.json', action: 'create', content: JSON.stringify(template, null, 2), }; } // Merge: preserve existing keys, add deny-first if missing const merged = { ...existing }; if (needsDenyFirst) { if (!merged.permissions) merged.permissions = {}; if (merged.permissions.defaultPermissionLevel !== 'deny' && merged.permissions.defaultPermissionLevel !== 'deny-all') { merged.permissions.defaultPermissionLevel = 'deny'; } if (!merged.permissions.allow || merged.permissions.allow.includes('*')) { merged.permissions.allow = template.permissions.allow; } } if (needsSandboxFix) { if (merged.skipDangerousModePermissionPrompt === true) { merged.skipDangerousModePermissionPrompt = false; } if (merged.dangerouslyAllowArbitraryPaths === true) { delete merged.dangerouslyAllowArbitraryPaths; } } return { category: 'Deny-First + Sandbox', file: '.claude/settings.json', action: 'merge', content: JSON.stringify(merged, null, 2), }; } /** * Build CLAUDE.md recommendation. * Appends security section if not already present. */ async function buildClaudeMdRec(projectRoot, categories) { const claudeMdPath = join(projectRoot, 'CLAUDE.md'); const existing = await readText(claudeMdPath); const template = await readText(join(TEMPLATES_DIR, 'claude-md-security-section.md')); // Check categories that benefit from CLAUDE.md guardrails const cat1 = categories.find(c => c.id === 1); const cat7 = categories.find(c => c.id === 7); const cat10 = categories.find(c => c.id === 10); // If already has security boundaries, skip if (existing && /security\s+boundar/i.test(existing)) { return { category: 'CLAUDE.md Security', file: 'CLAUDE.md', action: 'none', content: '' }; } const needsSection = (cat1 && cat1.status !== 'PASS') || (cat7 && cat7.status !== 'PASS') || (cat10 && cat10.status !== 'PASS') || !existing; if (!needsSection) { return { category: 'CLAUDE.md Security', file: 'CLAUDE.md', action: 'none', content: '' }; } if (!existing) { return { category: 'CLAUDE.md Security', file: 'CLAUDE.md', action: 'create', content: `# Project\n\n${template}`, }; } return { category: 'CLAUDE.md Security', file: 'CLAUDE.md', action: 'append', content: `\n${template}`, }; } /** * Build .gitignore recommendation. * Adds missing security patterns. */ async function buildGitignoreRec(projectRoot, categories) { const gitignorePath = join(projectRoot, '.gitignore'); const existing = await readText(gitignorePath); const template = await readText(join(TEMPLATES_DIR, 'gitignore-security.txt')); const templateLines = template.trim().split('\n').filter(l => l.trim() && !l.startsWith('#')); const cat2 = categories.find(c => c.id === 2); const cat9 = categories.find(c => c.id === 9); if (!existing) { const needsGitignore = (cat2 && cat2.status !== 'PASS') || (cat9 && cat9.status !== 'PASS'); if (!needsGitignore) { return { category: 'Secrets + Session', file: '.gitignore', action: 'none', content: '' }; } return { category: 'Secrets + Session', file: '.gitignore', action: 'create', content: template, }; } // Find missing lines const missingLines = templateLines.filter(line => !existing.includes(line.trim())); if (missingLines.length === 0) { return { category: 'Secrets + Session', file: '.gitignore', action: 'none', content: '' }; } return { category: 'Secrets + Session', file: '.gitignore', action: 'append', content: '\n# Security additions (llm-security harden)\n' + missingLines.join('\n') + '\n', }; } // --------------------------------------------------------------------------- // Apply logic // --------------------------------------------------------------------------- /** * Apply recommendations to the filesystem. * @param {string} projectRoot * @param {object[]} recommendations * @returns {Promise} backupPath or null */ async function applyRecommendations(projectRoot, recommendations) { const actionable = recommendations.filter(r => r.action !== 'none'); if (actionable.length === 0) return null; // Create backup of files we'll modify const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); const backupDir = join(projectRoot, `.security-harden-backup-${ts}`); let backedUp = false; for (const rec of actionable) { const filePath = join(projectRoot, rec.file); if (await fileExists(filePath)) { if (!backedUp) { await ensureDir(backupDir); backedUp = true; } const backupFile = join(backupDir, rec.file.replace(/\//g, '__')); await copyFile(filePath, backupFile); } } // Apply each recommendation for (const rec of actionable) { const filePath = join(projectRoot, rec.file); switch (rec.action) { case 'create': { await ensureDir(dirname(filePath)); await writeFile(filePath, rec.content, 'utf-8'); break; } case 'merge': { // For settings.json: write the merged content await ensureDir(dirname(filePath)); await writeFile(filePath, rec.content, 'utf-8'); break; } case 'append': { const existing = await readText(filePath) || ''; await writeFile(filePath, existing + rec.content, 'utf-8'); break; } } } return backedUp ? backupDir : null; } // --------------------------------------------------------------------------- // Main generate function // --------------------------------------------------------------------------- /** * Generate reference configuration for a project. * @param {string} targetPath - Absolute path to project root * @param {object} [options] * @param {boolean} [options.apply=false] - Write files to disk * @param {boolean} [options.dryRun=true] - Show what would change (default) * @returns {Promise} Generation result */ export async function generate(targetPath, options = {}) { const apply = options.apply === true; const startMs = Date.now(); const projectRoot = resolve(targetPath); // Step 1: Detect project type const projectType = await detectProjectType(projectRoot); // Step 2: Run posture scan resetCounter(); const postureResult = await scan(projectRoot); const categories = postureResult.categories; // Step 3: Build recommendations const recommendations = []; recommendations.push(await buildSettingsRec(projectRoot, categories)); recommendations.push(await buildClaudeMdRec(projectRoot, categories)); recommendations.push(await buildGitignoreRec(projectRoot, categories)); // Step 4: Apply if requested let backupPath = null; let applied = false; if (apply) { backupPath = await applyRecommendations(projectRoot, recommendations); applied = true; } const durationMs = Date.now() - startMs; return { status: 'ok', target: projectRoot, projectType, timestamp: new Date().toISOString(), duration_ms: durationMs, posture: { grade: postureResult.scoring.grade, pass_rate: postureResult.scoring.pass_rate, pass: postureResult.scoring.pass, partial: postureResult.scoring.partial, fail: postureResult.scoring.fail, }, recommendations, applied, backupPath, summary: { total: recommendations.length, actionable: recommendations.filter(r => r.action !== 'none').length, creates: recommendations.filter(r => r.action === 'create').length, merges: recommendations.filter(r => r.action === 'merge').length, appends: recommendations.filter(r => r.action === 'append').length, }, }; } // --------------------------------------------------------------------------- // CLI entry point // --------------------------------------------------------------------------- const isMain = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); if (isMain) { const args = process.argv.slice(2); const applyFlag = args.includes('--apply'); const pathArg = args.find(a => !a.startsWith('--')) || process.cwd(); const absTarget = resolve(pathArg); try { const result = await generate(absTarget, { apply: applyFlag }); process.stdout.write(JSON.stringify(result, null, 2) + '\n'); process.exit(0); } catch (err) { process.stderr.write(`Error: ${err.message}\n`); process.exit(2); } }