config-audit/scanners/settings-validator.mjs
Kjell Tore Guttormsen 3d6ddb273c fix(scanners): the fix engine downgraded a near-miss on xhigh to high (C2)
`fix-engine.mjs` carried its own copy of the valid `effortLevel` list, and
that copy had gone stale on `xhigh` (CC 2.1.154's top Opus tier) while
`settings-validator.mjs` had all five. The nearest-match "fix" therefore
corrected `xhig` — and `XHIGH` — to `high`: the tool silently changed the
tier the user asked for, in the one code path whose whole job is to write
the corrected value back to disk.

Fixed by sharing the validator's table instead of aligning the copy, so the
two cannot drift again. Guard asserts the blanket invariant — a one-character
near-miss on EVERY valid tier corrects back to that same tier — rather than
pinning the one level that happened to be missing; verified red against the
original stale array.

Suite 1488/0. Frozen v5.0.0 snapshots untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014P6Rh59Mtj4uYrdYMCYZJE
2026-08-09 21:29:00 +02:00

360 lines
16 KiB
JavaScript

/**
* 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).
* Exported because the fix engine's nearest-match needs the SAME list: a second
* copy there had gone stale on `xhigh` and quietly corrected near-misses on the
* top tier down to `high` (C2). One table, no drift. */
export 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<object>}
*/
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);
}