The 5-layer cascade, SettingsSchema, change detection, MDM, and remote managed settings
Claude Code runs in radically different contexts: a solo developer's laptop, a shared team repository, a CI agent, and an enterprise fleet managed by an IT department. A single flat config file cannot serve all of these at once.
The solution is a priority cascade: five independently-writable sources are merged in order, lower-to-higher priority. Each layer can add to or override anything below it. The result is a single merged SettingsJson object the rest of the codebase reads.
userSettings, projectSettings, and localSettings are writable via updateSettingsForSource().
The canonical source of truth is constants.ts, which defines the SETTING_SOURCES array in merge order:
export const SETTING_SOURCES = [ 'userSettings', // lowest priority 'projectSettings', 'localSettings', 'flagSettings', 'policySettings', // highest priority ] as const
Later entries override earlier ones. The merge happens in loadSettingsFromDisk() via lodash mergeWith with a custom array-deduplication strategy.
Global developer preferences. Writable by the user. Shared across all projects.
Committed to the repo. Shared with the whole team. Version-controlled.
Auto-gitignored. Personal per-project overrides that stay off-repo.
CLI flag. Merges a file and any inline SDK settings. Not file-watched.
IT/MDM enforced. First-source-wins among four sub-sources.
// settings.ts — simplified for clarity function loadSettingsFromDisk(): SettingsWithErrors { let mergedSettings: SettingsJson = {} // Plugin settings are the lowest-priority base layer const pluginSettings = getPluginSettingsBase() if (pluginSettings) { mergedSettings = mergeWith(mergedSettings, pluginSettings, settingsMergeCustomizer) } for (const source of getEnabledSettingSources()) { if (source === 'policySettings') { // First-source-wins among: remote → MDM → file → HKCU const policySettings = resolvePolicySettings() if (policySettings) { mergedSettings = mergeWith(mergedSettings, policySettings, settingsMergeCustomizer) } continue } const { settings } = parseSettingsFile(getSettingsFilePathForSource(source)) if (settings) { mergedSettings = mergeWith(mergedSettings, settings, settingsMergeCustomizer) } } return { settings: mergedSettings, errors: allErrors } }
SettingsSchema in types.ts is a Zod v4 schema that defines every valid key. It is used to validate every settings source before merging. Invalid files surface ValidationError[] but do not crash Claude Code — the rest of the valid configuration still loads.
| Key | Type | Purpose |
|---|---|---|
permissions | object | allow / deny / ask arrays, defaultMode, disableBypassPermissionsMode |
hooks | object | PreToolUse, PostToolUse, Notification, SessionStart, Stop, etc. |
env | record<string,string> | Environment variables injected into every session |
model | string | Override the default Claude model |
availableModels | string[] | Enterprise allowlist of selectable models |
allowedMcpServers | object[] | Enterprise MCP server allowlist (by name, command, or URL) |
deniedMcpServers | object[] | Enterprise MCP server denylist (denylist beats allowlist) |
apiKeyHelper | string | Script path that emits authentication values |
cleanupPeriodDays | number | Transcript retention in days (0 = disable persistence) |
strictPluginOnlyCustomization | bool | string[] | Lock skills/agents/hooks/mcp to plugin-only delivery |
allowManagedHooksOnly | boolean | When set in policy, only managed hooks run |
allowManagedPermissionRulesOnly | boolean | When set in policy, only managed permission rules apply |
attribution | object | Customize commit/PR attribution text |
sandbox | object | Sandbox configuration (enabled, network, filesystem, …) |
worktree | object | symlinkDirectories, sparsePaths for --worktree flag |
The codebase comments declare strict rules to avoid breaking users' existing config files:
.passthrough() on the permissions object preserves unknown fields in the filefilterInvalidPermissionRules() strips individual bad rules before Zod validation, so one bad rule doesn't null out the entire settings file{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"model": "claude-opus-4-5",
"env": {
"NODE_ENV": "development",
"LOG_LEVEL": "debug"
},
"permissions": {
"defaultMode": "acceptEdits",
"allow": ["Bash(git *)", "Read(**)"],
"deny": ["Bash(rm -rf *)"]
},
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "echo pre-bash" }]
}]
},
"cleanupPeriodDays": 7
}
Claude Code uses lodash mergeWith throughout the settings pipeline, with a custom settingsMergeCustomizer:
// settings.ts export function settingsMergeCustomizer( objValue: unknown, srcValue: unknown, ): unknown { if (Array.isArray(objValue) && Array.isArray(srcValue)) { return uniq([...objValue, ...srcValue]) // deduplicated concatenation } return undefined // let lodash handle objects / scalars }
updateSettingsForSource() — pass undefined as a value. The customizer detects srcValue === undefined and calls delete object[key].updateSettingsForSource(), arrays are replaced (not merged) — the caller is responsible for computing the desired final array state before passing it in.
Claude Code reads settings files synchronously on the critical startup path. Three caching layers prevent redundant disk I/O and Zod re-parsing:
| Cache | Keyed by | Holds | Invalidated by |
|---|---|---|---|
sessionSettingsCache |
(singleton) | Fully merged SettingsWithErrors |
resetSettingsCache() |
perSourceCache |
SettingSource |
Per-source SettingsJson | null |
resetSettingsCache() |
parseFileCache |
File path string | Parsed { settings, errors } |
resetSettingsCache() |
All three caches are cleared atomically by resetSettingsCache(). This single call is the "fan-out" step in the change detector: one cache reset means one disk reload regardless of how many React components or hooks are subscribed.
parseSettingsFile() always clones before returning from the file cache. This prevents callers from inadvertently mutating the cache entry (e.g., mergeWith mutates its first argument in place).
Settings files can change while Claude Code is running. The settingsChangeDetector (in changeDetector.ts) watches files with chokidar and polls MDM registries/plists every 30 minutes.
const FILE_STABILITY_THRESHOLD_MS = 1000 // wait for write to stabilize const FILE_STABILITY_POLL_INTERVAL_MS = 500 // chokidar awaitWriteFinish const INTERNAL_WRITE_WINDOW_MS = 5000 // suppress own writes const MDM_POLL_INTERVAL_MS = 30 * 60 * 1000 // 30 min MDM registry poll const DELETION_GRACE_MS = 1700 // absorb delete-and-recreate
When Claude Code itself writes a settings file (e.g., saving a permission rule), it calls markInternalWrite(filePath) before writing. When chokidar fires the change event, consumeInternalWrite(path, 5000) detects that the change originated internally and silently skips the reload. This prevents a reload cascade every time Claude Code updates its own settings.
// updateSettingsForSource() before file write: markInternalWrite(filePath) writeFileSyncAndFlush_DEPRECATED(filePath, jsonStringify(updatedSettings, null, 2)) resetSettingsCache()
Auto-updaters and some editors delete a file then recreate it atomically. Naively, this would trigger a "settings removed" notification followed immediately by a "settings added" notification. The change detector uses a 1700ms grace period: when a file is deleted, it waits before processing the deletion. If an add or change event arrives within the grace window, the deletion is cancelled and treated as a normal change instead.
Before the current architecture, each subscriber called resetSettingsCache() defensively on receipt of a change notification. With N subscribers this caused N cache clears and N disk reloads per change. The fix was to centralize the reset in a single fanOut() function:
function fanOut(source: SettingSource): void { resetSettingsCache() // one clear settingsChanged.emit(source) // N subscribers; first one pays the disk miss // subsequent ones hit the repopulated cache }
The policySettings layer has four internal sub-sources. The first one that has non-empty content wins — the others are ignored for this session.
| Platform | Base path | Drop-in dir |
|---|---|---|
| macOS | /Library/Application Support/ClaudeCode/managed-settings.json |
/Library/Application Support/ClaudeCode/managed-settings.d/ |
| Windows | C:\Program Files\ClaudeCode\managed-settings.json |
C:\Program Files\ClaudeCode\managed-settings.d\ |
| Linux | /etc/claude-code/managed-settings.json |
/etc/claude-code/managed-settings.d/ |
The managed-settings.d/ directory enables multiple teams to deliver independent policy fragments without editing a single shared file. Files are sorted alphabetically and merged in order — later files override earlier ones. This follows the systemd/sudoers convention.
// settings.ts — loadManagedFileSettings() // 1. Load managed-settings.json as base (lowest precedence) const { settings } = parseSettingsFile(getManagedSettingsFilePath()) // 2. Load and sort drop-in files const entries = fs.readdirSync(dropInDir) .filter(d => d.isFile() && d.name.endsWith('.json')) .map(d => d.name) .sort() // alphabetical: 10-otel.json, 20-security.json … for (const name of entries) { merged = mergeWith(merged, parseSettingsFile(join(dropInDir, name)).settings, ...) }
Reading the macOS plist (plutil -convert json) and Windows registry (reg query) requires spawning subprocesses. These are fired as early as possible during startup (before module initialization completes) so the subprocess runs in parallel with module loading. By the time ensureMdmSettingsLoaded() is awaited, the result is typically already available.
// mdm/settings.ts export function startMdmSettingsLoad(): void { mdmLoadPromise = (async () => { const rawPromise = getMdmRawReadPromise() ?? fireRawRead() const { mdm, hkcu } = consumeRawReadResult(await rawPromise) mdmCache = mdm hkcuCache = hkcu })() }
For Enterprise/Team subscribers and all Console (API key) users, Claude Code fetches settings from the Anthropic API at startup. This is the highest-priority sub-source within policySettings.
local-agent entrypoint): ineligibleTo avoid re-downloading unchanged settings on every startup, Claude Code computes a SHA-256 checksum of the cached settings and sends it as an If-None-Match header. The server returns 304 Not Modified if nothing changed. The checksum algorithm matches the server's Python implementation exactly:
// Must match Python: json.dumps(settings, sort_keys=True, separators=(",", ":")) export function computeChecksumFromSettings(settings: SettingsJson): string { const sorted = sortKeysDeep(settings) // recursive key sort const normalized = jsonStringify(sorted) // no spaces after separators const hash = createHash('sha256').update(normalized).digest('hex') return `sha256:${hash}` }
401/403) do not retry204/404 → no settings configured → delete stale cache fileWhen remote settings arrive with content that differs from the cache, checkManagedSettingsSecurity() runs before applying them. If the incoming settings are considered dangerous (e.g., new hooks, changed permission rules), the user is prompted to approve. Rejecting leaves the previous cached settings in place.
After the initial load, a background interval polls the API every 60 minutes (POLLING_INTERVAL_MS = 60 * 60 * 1000). On each poll it checks whether settings changed (JSON string comparison) and calls settingsChangeDetector.notifyChange('policySettings') only if they did. The interval is created with .unref() so it does not prevent process exit.
Settings sync (services/settingsSync/) is a separate mechanism from remote managed settings. It syncs a user's own settings across environments — primarily between the interactive CLI and Claude Code in Repositories (CCR) headless mode.
| Direction | Trigger | What is synced |
|---|---|---|
| Upload (CLI → cloud) | Interactive CLI startup (preAction) |
user settings.json, user CLAUDE.md, local settings.local.json, project CLAUDE.local.md |
| Download (cloud → CCR) | CCR headless startup (before plugin install) | Same four files, keyed by file path + git remote hash |
export const SYNC_KEYS = { USER_SETTINGS: '~/.claude/settings.json', USER_MEMORY: '~/.claude/CLAUDE.md', projectSettings: (projectId: string) => `projects/${projectId}/.claude/settings.local.json`, projectMemory: (projectId: string) => `projects/${projectId}/CLAUDE.local.md`, }
The project-specific keys are scoped by a SHA of the git remote URL, so settings for github.com/org/repo-A never overwrite settings for github.com/org/repo-B.
MAX_FILE_SIZE_BYTES). Files exceeding this are silently skipped. The limit is enforced on both upload and download paths.
Several settings keys intentionally exclude projectSettings from their trust domain. A malicious repo could otherwise inject dangerous settings automatically.
| Setting / Check | Sources trusted | Why projectSettings is excluded |
|---|---|---|
skipDangerousModePermissionPrompt |
user, local, flag, policy | A repo could auto-bypass the danger-mode dialog (RCE risk) |
skipAutoPermissionPrompt |
user, local, flag, policy | Same — auto-mode opt-in must be user-driven |
useAutoModeDuringPlan |
user, local, flag, policy | Same — plan-mode semantics are safety-critical |
autoMode classifier config |
user, local, flag, policy | Injecting allow/deny rules via a repo would be an RCE vector |
true in managed settings, only the allow/deny/ask rules from managed settings are respected. All user, project, local, and CLI argument permission rules are silently ignored. This is the enterprise control for preventing employees from granting themselves additional permissions.
resetSettingsCache() invalidates all three and is called atomically before notifying subscribers.skipDangerousModePermissionPrompt, auto-mode opt-in, classifier rules) intentionally exclude projectSettings to prevent malicious repositories from escalating their own privileges.userSettings and projectSettings. Which value wins in the final merged result?managed-settings.json file on disk. Which one supplies the policy settings?.claude/settings.json sets skipDangerousModePermissionPrompt: true. Will Claude Code respect this?permissions.allow: ["Read(**)", "Bash(git *)"] in userSettings and permissions.allow: ["Bash(git *)", "Write(src/)"] in projectSettings. What is the merged allow list?If-None-Match header. What value is sent in that header?managed-settings.d/ drop-in directory merge, and in what order are the files applied?