feat(set): validate autoMode structure + flag it in shared settings (CA-SET)

settings-validator now validates the autoMode block (auto-mode classifier
config). Structure (medium): autoMode must be an object whose only keys are
environment/allow/soft_deny/hard_deny, each a string array ("$defaults" is a
valid entry); flags not-an-object, unknown-subkey, not-string-array. Dead-config
(low): Claude Code does not read autoMode from shared project settings
(.claude/settings.json), so an autoMode block committed there has no effect —
keyed on file.scope === 'project'.

Both premises primary-source-verified (code.claude.com/docs/en/auto-mode-config).
The plan's "test per-file scope first" gate passed: ConfigFile already carries
scope. SET is in the orchestrator; SC-5 re-checked, byte-equal (snapshot fixture
has no autoMode). Fixtures force-added (.claude/ is gitignored).

Tests +5 (944->949). Scanner count unchanged (13). --json/--raw byte-stable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ter3E2JSi1Khgmuf2kady8
This commit is contained in:
Kjell Tore Guttormsen 2026-06-19 22:14:26 +02:00
commit 3633571c7e
8 changed files with 225 additions and 2 deletions

View file

@ -75,6 +75,11 @@ const VALID_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'xhigh', 'max']);
* a project root to walks/discovery, inflating per-turn cost and confusing scope. */
const ADDITIONAL_DIRS_THRESHOLD = 2;
/** 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
@ -234,6 +239,75 @@ export async function scan(targetPath, discovery) {
}));
}
// 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)) {