Fase 4 Items 2+3 (CC 2.1.114→181 gap-review). New orchestrated scanner `skill-listing-scanner.mjs` (prefix SKL) flags every active skill whose description exceeds the verified 1,536-char listing cap (CC 2.1.105, changelog L1502). Past the cap, Claude Code silently truncates the description the model reads to route skill invocation — dropping the trigger phrases at the tail. HOME-scoped over all user + plugin skills via enumerateSkills (COL is the model). - CA-SKL-001 (medium): description > 1,536 chars. Remediation folds in Item 2(b) — recommends disableBundledSkills + skillOverrides + trimming (designvalg A: no standalone GAP-check, which would fire for nearly everyone). - Designvalg B: v1 ships the verified cap ONLY. The aggregate 2%-of-context listing budget is deferred — it needs a context-window assumption that would turn a verified fact into a guess (would carry a CALIBRATION_NOTE if added). - Choice C: recognize the skillOverrides settings key (CC 2.1.129) in KNOWN_KEYS. Left OUT of TYPE_CHECKS — the value is a per-skill object (off/user-invocable-only/name-only), not a string; a 'string' check (as the plan sketched) would create a NEW false positive. Verify-first deviation. Registration: scan-orchestrator (13th scanner), humanizer (SKL → 'Wasted tokens' + static/_default translations), scoring SCANNER_AREA_MAP (→ Token Efficiency; no 11th area), README badge 12→13, CLAUDE.md (finding-id + test-count), docs/scanner-internals.md, gap-matrix + plan status notes. Snapshots reseeded hermetically (SEED_SNAPSHOT/UPDATE_SNAPSHOT): SKL entry with 0 findings in empty HOME, scanners_ok 11→12, claudeMdEstimatedTokens bump from the CLAUDE.md edits flowing through the cascade. Contamination grep clean. Suite 868/868 (856 baseline + 11 SKL + 1 skillOverrides). RED→GREEN logged per cycle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ter3E2JSi1Khgmuf2kady8
255 lines
10 KiB
JavaScript
255 lines
10 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 } from './lib/string-utils.mjs';
|
|
|
|
const SCANNER = 'SET';
|
|
|
|
/** Known top-level settings.json keys (as of CC 2.1.181 / 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',
|
|
]);
|
|
|
|
/** 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;
|
|
|
|
/**
|
|
* 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
|
|
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,
|
|
}));
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
}));
|
|
}
|
|
|
|
// 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);
|
|
}
|