/** * SET Scanner — Settings.json Validator * Validates schema, detects unknown/deprecated keys, type mismatches. * Finding IDs: CA-SET-NNN */ import { readTextFile } from './lib/file-discovery.mjs'; import { finding, scannerResult } from './lib/output.mjs'; import { SEVERITY } from './lib/severity.mjs'; import { parseJson } from './lib/yaml-parser.mjs'; import { extractKeys } from './lib/string-utils.mjs'; const SCANNER = 'SET'; /** Known top-level settings.json keys (as of April 2026) */ const KNOWN_KEYS = new Set([ 'agent', 'allowedChannelPlugins', 'allowedHttpHookUrls', 'allowedMcpServers', 'allowManagedHooksOnly', 'allowManagedMcpServersOnly', 'allowManagedPermissionRulesOnly', 'alwaysThinkingEnabled', 'apiKeyHelper', 'attribution', 'autoMemoryDirectory', 'autoMemoryEnabled', 'autoMode', 'autoUpdatesChannel', 'availableModels', 'awsAuthRefresh', 'awsCredentialExport', 'blockedMarketplaces', 'channelsEnabled', 'cleanupPeriodDays', 'claudeMdExcludes', 'companyAnnouncements', 'defaultShell', 'deniedMcpServers', 'disableAllHooks', 'disableAutoMode', 'disableDeepLinkRegistration', 'disabledMcpjsonServers', 'effortLevel', 'enableAllProjectMcpServers', 'enabledMcpjsonServers', 'enabledPlugins', 'env', 'extraKnownMarketplaces', 'fastModePerSessionOptIn', 'feedbackSurveyRate', 'fileSuggestion', 'forceLoginMethod', 'forceLoginOrgUUID', 'hooks', 'httpHookAllowedEnvVars', 'includeCoAuthoredBy', 'includeGitInstructions', 'language', 'model', 'modelOverrides', 'otelHeadersHelper', 'outputStyle', 'permissions', 'plansDirectory', 'pluginTrustMessage', 'prefersReducedMotion', 'respectGitignore', 'showClearContextOnPlanAccept', 'showThinkingSummaries', 'spinnerTipsEnabled', 'spinnerTipsOverride', 'spinnerVerbs', 'statusLine', 'strictKnownMarketplaces', 'useAutoModeDuringPlan', 'voiceEnabled', 'worktree', '$schema', ]); /** Deprecated keys with migration info */ const DEPRECATED_KEYS = new Map([ ['includeCoAuthoredBy', 'Use "attribution" instead'], ]); /** Keys that require specific types */ const TYPE_CHECKS = new Map([ ['alwaysThinkingEnabled', 'boolean'], ['autoMemoryEnabled', 'boolean'], ['channelsEnabled', 'boolean'], ['cleanupPeriodDays', 'number'], ['disableAllHooks', 'boolean'], ['effortLevel', 'string'], ['enableAllProjectMcpServers', 'boolean'], ['fastModePerSessionOptIn', 'boolean'], ['feedbackSurveyRate', 'number'], ['includeGitInstructions', 'boolean'], ['language', 'string'], ['model', 'string'], ['outputStyle', 'string'], ['prefersReducedMotion', 'boolean'], ['respectGitignore', 'boolean'], ['showThinkingSummaries', 'boolean'], ['spinnerTipsEnabled', 'boolean'], ['voiceEnabled', 'boolean'], ]); /** Valid effortLevel values */ const VALID_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'max']); /** * Scan all settings.json files discovered. * @param {string} targetPath * @param {{ files: import('./lib/file-discovery.mjs').ConfigFile[] }} discovery * @returns {Promise} */ export async function scan(targetPath, discovery) { const start = Date.now(); const settingsFiles = discovery.files.filter(f => f.type === 'settings-json'); const findings = []; let filesScanned = 0; if (settingsFiles.length === 0) { return scannerResult(SCANNER, 'skipped', [], 0, Date.now() - start); } for (const file of settingsFiles) { const content = await readTextFile(file.absPath); if (!content) continue; filesScanned++; const parsed = parseJson(content); if (parsed === null) { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.critical, title: 'Invalid JSON in settings file', description: `${file.relPath} contains invalid JSON and will be ignored by Claude Code.`, file: file.absPath, recommendation: 'Fix JSON syntax errors. Use a JSON validator.', autoFixable: false, })); continue; } // Check for unknown keys for (const key of Object.keys(parsed)) { if (!KNOWN_KEYS.has(key)) { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.medium, title: 'Unknown settings key', description: `${file.relPath}: "${key}" is not a recognized settings.json key. It will be silently ignored.`, file: file.absPath, evidence: key, recommendation: 'Check spelling. See https://json.schemastore.org/claude-code-settings.json for valid keys.', autoFixable: false, })); } } // Check for deprecated keys for (const [key, migration] of DEPRECATED_KEYS) { if (parsed[key] !== undefined) { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.medium, title: 'Deprecated settings key', description: `${file.relPath}: "${key}" is deprecated. ${migration}`, file: file.absPath, evidence: `${key}: ${JSON.stringify(parsed[key])}`, recommendation: migration, autoFixable: true, })); } } // Type validation for (const [key, expectedType] of TYPE_CHECKS) { if (parsed[key] !== undefined && typeof parsed[key] !== expectedType) { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.high, title: 'Type mismatch in settings', description: `${file.relPath}: "${key}" should be ${expectedType}, got ${typeof parsed[key]}.`, file: file.absPath, evidence: `${key}: ${JSON.stringify(parsed[key])} (${typeof parsed[key]})`, recommendation: `Change "${key}" to a ${expectedType} value.`, autoFixable: true, })); } } // effortLevel value check if (parsed.effortLevel && !VALID_EFFORT_LEVELS.has(parsed.effortLevel)) { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.medium, title: 'Invalid effortLevel value', description: `${file.relPath}: effortLevel "${parsed.effortLevel}" is not valid.`, file: file.absPath, evidence: `effortLevel: "${parsed.effortLevel}"`, recommendation: `Use one of: ${[...VALID_EFFORT_LEVELS].join(', ')}`, autoFixable: true, })); } // Missing $schema hint if (!parsed.$schema) { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.info, title: 'Missing $schema reference', description: `${file.relPath} lacks a $schema reference. Adding one enables autocomplete in VS Code/Cursor.`, file: file.absPath, recommendation: 'Add: "$schema": "https://json.schemastore.org/claude-code-settings.json"', autoFixable: true, })); } // Permissions checks if (parsed.permissions) { const perms = parsed.permissions; if (!perms.deny || (Array.isArray(perms.deny) && perms.deny.length === 0)) { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.medium, title: 'No deny rules configured', description: `${file.relPath}: No permission deny rules. Claude can access all files including .env and secrets.`, file: file.absPath, recommendation: 'Add deny rules for sensitive files: "deny": ["Read(./.env)", "Read(./secrets/**)"]', autoFixable: false, })); } if (!perms.allow || (Array.isArray(perms.allow) && perms.allow.length === 0)) { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.low, title: 'No allow rules configured', description: `${file.relPath}: No permission allow rules. This means frequent permission prompts for common operations.`, file: file.absPath, recommendation: 'Add allow rules for common tools: "allow": ["Bash(npm run *)", "Read(src/**)"]', autoFixable: false, })); } } // hooks checks (basic — detailed in hook-validator) if (parsed.hooks) { if (Array.isArray(parsed.hooks)) { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.critical, title: 'Hooks configured as array instead of object', description: `${file.relPath}: "hooks" must be an object with event keys, not an array. All hooks will be ignored.`, file: file.absPath, evidence: '"hooks": [...]', recommendation: 'Change to object format: "hooks": { "PreToolUse": [...] }', autoFixable: true, })); } } } return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start); }