/** * 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, levenshtein } from './lib/string-utils.mjs'; const SCANNER = 'SET'; /** Known top-level settings.json keys (as of CC 2.1.193 / June 2026) */ const KNOWN_KEYS = new Set([ 'additionalDirectories', 'agent', 'allowAllClaudeAiMcps', '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', 'disableBundledSkills', 'disableDeepLinkRegistration', 'disabledMcpjsonServers', 'effortLevel', 'enableAllProjectMcpServers', 'enabledMcpjsonServers', 'enabledPlugins', 'enforceAvailableModels', 'env', 'extraKnownMarketplaces', 'fallbackModel', 'fastModePerSessionOptIn', 'feedbackSurveyRate', 'fileSuggestion', 'footerLinksRegexes', 'forceLoginMethod', 'forceLoginOrgUUID', 'hooks', 'httpHookAllowedEnvVars', 'includeCoAuthoredBy', 'includeGitInstructions', 'language', 'model', 'modelOverrides', 'otelHeadersHelper', 'outputStyle', 'parentSettingsBehavior', 'permissions', 'plansDirectory', 'pluginSuggestionMarketplaces', 'pluginTrustMessage', 'prefersReducedMotion', 'requiredMaximumVersion', 'requiredMinimumVersion', 'respectGitignore', 'sandbox', 'showClearContextOnPlanAccept', 'showThinkingSummaries', 'skillOverrides', 'spinnerTipsEnabled', 'spinnerTipsOverride', 'spinnerVerbs', 'statusLine', 'strictKnownMarketplaces', 'useAutoModeDuringPlan', 'voiceEnabled', 'wheelScrollAccelerationEnabled', 'worktree', '$schema', // CC 2.1.193 binary-verified (M-BUG-10): present as quoted string literals in the binary 'agentPushNotifEnabled', 'remoteControlAtStartup', 'skipAutoPermissionPrompt', 'skipDangerousModePermissionPrompt', 'skipWorkflowUsageWarning', 'tui', ]); /** 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'], ['disableBundledSkills', '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'], ['wheelScrollAccelerationEnabled', 'boolean'], ]); /** Valid effortLevel values (CC 2.1.154 added 'xhigh' as the Opus-4.8 top tier) */ const VALID_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'xhigh', 'max']); /** v5 M6: warn when additionalDirectories grows beyond this — each entry adds * a project root to walks/discovery, inflating per-turn cost and confusing scope. */ const ADDITIONAL_DIRS_THRESHOLD = 2; /** M-BUG-10: the CC settings schema is passthrough — it forwards unrecognized * keys unchanged rather than rejecting them, so an arbitrary unknown key is * valid/forward-compatible, not an error. The only real risk is a TYPO of a * real key (the intended setting silently does nothing), so an unknown key is * flagged ONLY when it closely matches a known key: edit distance within * TYPO_MAX_DISTANCE and both keys at least TYPO_MIN_LEN chars (short keys are * too noisy for reliable edit-distance matching). */ const TYPO_MAX_DISTANCE = 2; const TYPO_MIN_LEN = 4; /** The only valid sub-keys of `autoMode`, each a prose-rule string array * (the literal "$defaults" is a valid entry). Verified against * code.claude.com/docs/en/auto-mode-config. */ const AUTO_MODE_SUBKEYS = new Set(['environment', 'allow', 'soft_deny', 'hard_deny']); /** * 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 — typo gate (M-BUG-10). The CC settings schema is // passthrough, so an unrecognized key is NOT an error; only a typo of a real // key is (the intended setting silently does nothing). Flag a key only when // it closely matches a known key; an unknown key far from every known key is // treated as valid/forward-compatible and emitted nothing. for (const key of Object.keys(parsed)) { if (KNOWN_KEYS.has(key)) continue; let nearest = null; let best = Infinity; for (const known of KNOWN_KEYS) { if (Math.min(key.length, known.length) < TYPO_MIN_LEN) continue; const d = levenshtein(key, known); if (d <= TYPO_MAX_DISTANCE && d < best) { best = d; nearest = known; } } if (nearest) { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.low, title: 'Possible typo in settings key', description: `${file.relPath}: "${key}" is not a recognized settings.json key, but it closely matches "${nearest}". Claude Code forwards unrecognized keys unchanged (it does not reject them), so if "${key}" is a typo of "${nearest}" the intended setting silently has no effect.`, file: file.absPath, evidence: key, recommendation: `Did you mean "${nearest}"? Fix the spelling, or keep "${key}" if it is intentional (e.g. a newer settings key this audit does not recognize yet).`, 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, })); } } // additionalDirectories threshold (v5 M6) if (Array.isArray(parsed.additionalDirectories) && parsed.additionalDirectories.length > ADDITIONAL_DIRS_THRESHOLD) { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.low, title: 'Many additionalDirectories entries', description: `${file.relPath}: additionalDirectories has ${parsed.additionalDirectories.length} ` + `entries (>${ADDITIONAL_DIRS_THRESHOLD}). Each entry expands Claude's read scope ` + 'across additional project roots, inflating discovery cost and risking unintended access.', file: file.absPath, evidence: parsed.additionalDirectories.slice(0, 5).map(d => `"${d}"`).join(', '), recommendation: 'Trim to the minimum set needed. Prefer launching Claude from the relevant root ' + 'rather than chaining many directories.', autoFixable: false, })); } // autoMode validation (auto-mode classifier config). // Structure: must be an object whose only keys are the four documented // prose-rule arrays (each an array of strings; "$defaults" is a valid entry). // Dead-config: Claude Code does NOT read autoMode from SHARED project settings // (.claude/settings.json) — "a checked-in repo cannot inject its own allow // rules" — so it is only honored in user/local/managed scopes. if (parsed.autoMode !== undefined) { const am = parsed.autoMode; if (typeof am !== 'object' || am === null || Array.isArray(am)) { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.medium, title: 'autoMode must be an object', description: `${file.relPath}: "autoMode" must be an object with environment/allow/soft_deny/hard_deny arrays, got ${Array.isArray(am) ? 'array' : typeof am}.`, file: file.absPath, evidence: `autoMode: ${JSON.stringify(am)}`, recommendation: 'Set autoMode to an object, e.g. { "environment": ["$defaults"] }.', autoFixable: false, details: { key: 'autoMode', problem: 'not-an-object' }, })); } else { for (const subKey of Object.keys(am)) { if (!AUTO_MODE_SUBKEYS.has(subKey)) { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.medium, title: `autoMode has an unknown sub-key: ${subKey}`, description: `${file.relPath}: "autoMode.${subKey}" is not a recognized sub-key. Valid keys are environment, allow, soft_deny, hard_deny. It is silently ignored — a typo of a real key (e.g. "hard_denies") means those rules never apply.`, file: file.absPath, evidence: `autoMode.${subKey}`, recommendation: 'Use only environment, allow, soft_deny, hard_deny. Check for typos.', autoFixable: false, details: { key: 'autoMode', subKey, problem: 'unknown-subkey' }, })); continue; } const val = am[subKey]; const isStringArray = Array.isArray(val) && val.every(e => typeof e === 'string'); if (!isStringArray) { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.medium, title: `autoMode.${subKey} must be an array of strings`, description: `${file.relPath}: "autoMode.${subKey}" must be an array of prose-rule strings (the literal "$defaults" is allowed), got ${Array.isArray(val) ? 'an array with a non-string entry' : typeof val}.`, file: file.absPath, evidence: `autoMode.${subKey}: ${JSON.stringify(val)}`, recommendation: `Set "autoMode.${subKey}" to an array of strings, e.g. ["$defaults"].`, autoFixable: false, details: { key: 'autoMode', subKey, problem: 'not-string-array' }, })); } } } // Dead-config: shared project settings (.claude/settings.json) is not read. if (file.scope === 'project') { findings.push(finding({ scanner: SCANNER, severity: SEVERITY.low, title: 'autoMode in shared project settings is ignored by Claude Code', description: `${file.relPath}: Claude Code does not read "autoMode" from shared project settings (.claude/settings.json), so a checked-in repo cannot inject its own rules. This autoMode block has no effect where it is.`, file: file.absPath, evidence: 'autoMode in shared .claude/settings.json (project scope)', recommendation: 'Move autoMode to user settings (~/.claude/settings.json), local settings (.claude/settings.local.json), or managed settings.', autoFixable: false, details: { key: 'autoMode', problem: 'shared-project-scope' }, })); } } // 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); }