The Problem
Configuration management sounds simple -- just read a JSON file, right? But when you need to support all of the following scenarios, complexity grows exponentially:
- User global settings (
~/.claude/settings.json)
- Project shared settings (
.claude/settings.json, committed to git)
- Project local settings (
.claude/settings.local.json, gitignored)
- CLI flag overrides (
--settings parameter)
- Enterprise managed policies (MDM push or remote API)
- Remote managed settings (organization-level config fetched from API)
- Real-time hot reloading on config changes
- Priority-based merging across multiple sources
- Automatic migration from old formats to new ones
Claude Code's configuration system validates every layer with Zod schemas, merges 5 sources using deterministic priority rules, and handles historical compatibility through 11 migration functions. This article dives into the implementation of each layer.
Configuration Sources and Priority
src/utils/settings/constants.ts defines the source priorities:
1// src/utils/settings/constants.ts Lines 7-22
2export const SETTING_SOURCES = [
3 'userSettings', // User global settings
4 'projectSettings', // Project shared settings
5 'localSettings', // Project local settings (gitignored)
6 'flagSettings', // CLI --settings flag
7 'policySettings', // Enterprise managed policies
8] as const
The order determines priority -- later sources override earlier ones. This means:
- User settings form the base layer
- Project settings override user preferences (team conventions)
- Local settings override project settings (personal overrides)
- CLI flags override all file-based config (temporary overrides)
- Policy settings have the highest priority (enterprise enforcement)
Source Types
1// src/utils/settings/constants.ts Lines 24, 182-185
2export type SettingSource = (typeof SETTING_SOURCES)[number]
3
4export type EditableSettingSource = Exclude<
5 SettingSource,
6 'policySettings' | 'flagSettings'
7>
EditableSettingSource excludes policy and flag sources -- users cannot edit managed policies or config generated from CLI flags. Only userSettings, projectSettings, and localSettings can be modified through the /config command or by directly editing files.
Source Enable Control
1// src/utils/settings/constants.ts Lines 159-167
2export function getEnabledSettingSources(): SettingSource[] {
3 const allowed = getAllowedSettingSources()
4 // Policy and flag sources are always enabled
5 const result = new Set<SettingSource>(allowed)
6 result.add('policySettings')
7 result.add('flagSettings')
8 return Array.from(result)
9}
Even when sources are restricted via --setting-sources, policy and flag settings always remain active. This ensures enterprise managed policies cannot be bypassed.
Zod Schema Validation
src/utils/settings/types.ts defines the complete configuration schema.
Permissions Schema
1// src/utils/settings/types.ts Lines 42-85
2export const PermissionsSchema = lazySchema(() =>
3 z.object({
4 allow: z.array(PermissionRuleSchema()).optional()
5 .describe('List of permission rules for allowed operations'),
6 deny: z.array(PermissionRuleSchema()).optional()
7 .describe('List of permission rules for denied operations'),
8 ask: z.array(PermissionRuleSchema()).optional()
9 .describe('List of permission rules that should always prompt'),
10 defaultMode: z.enum(
11 feature('TRANSCRIPT_CLASSIFIER')
12 ? PERMISSION_MODES
13 : EXTERNAL_PERMISSION_MODES,
14 ).optional(),
15 disableBypassPermissionsMode: z.enum(['disable']).optional(),
16 ...(feature('TRANSCRIPT_CLASSIFIER')
17 ? { disableAutoMode: z.enum(['disable']).optional() }
18 : {}),
19 additionalDirectories: z.array(z.string()).optional(),
20 }).passthrough(),
21)
Note two key design decisions:
feature() compile-time conditionals -- The TRANSCRIPT_CLASSIFIER flag controls whether the auto mode schema is included. In external builds, the disableAutoMode field doesn't exist in the schema at all.
.passthrough() -- Allows unknown fields to pass validation, ensuring forward compatibility. Adding new fields in future versions won't cause errors in older versions.
Hook Schema
1// src/schemas/hooks.ts Lines 32-171 (core section)
2function buildHookSchemas() {
3 const BashCommandHookSchema = z.object({
4 type: z.literal('command'),
5 command: z.string(),
6 if: IfConditionSchema(),
7 shell: z.enum(SHELL_TYPES).optional(),
8 timeout: z.number().positive().optional(),
9 statusMessage: z.string().optional(),
10 once: z.boolean().optional(),
11 async: z.boolean().optional(),
12 asyncRewake: z.boolean().optional(),
13 })
14
15 const PromptHookSchema = z.object({
16 type: z.literal('prompt'),
17 prompt: z.string(),
18 if: IfConditionSchema(),
19 timeout: z.number().positive().optional(),
20 model: z.string().optional(),
21 statusMessage: z.string().optional(),
22 once: z.boolean().optional(),
23 })
24
25 const HttpHookSchema = z.object({
26 type: z.literal('http'),
27 url: z.string().url(),
28 if: IfConditionSchema(),
29 timeout: z.number().positive().optional(),
30 headers: z.record(z.string(), z.string()).optional(),
31 allowedEnvVars: z.array(z.string()).optional(),
32 statusMessage: z.string().optional(),
33 once: z.boolean().optional(),
34 })
35
36 const AgentHookSchema = z.object({
37 type: z.literal('agent'),
38 prompt: z.string(),
39 if: IfConditionSchema(),
40 timeout: z.number().positive().optional(),
41 model: z.string().optional(),
42 statusMessage: z.string().optional(),
43 once: z.boolean().optional(),
44 })
45
46 return { BashCommandHookSchema, PromptHookSchema, HttpHookSchema, AgentHookSchema }
47}
Hooks use Zod's discriminatedUnion, differentiating four types by the type field:
1// src/schemas/hooks.ts Lines 176-189
2export const HookCommandSchema = lazySchema(() => {
3 const { BashCommandHookSchema, PromptHookSchema, AgentHookSchema, HttpHookSchema }
4 = buildHookSchemas()
5 return z.discriminatedUnion('type', [
6 BashCommandHookSchema,
7 PromptHookSchema,
8 AgentHookSchema,
9 HttpHookSchema,
10 ])
11})
The lazySchema Pattern
Notice that all schemas are wrapped with lazySchema. This is a lazy evaluation wrapper -- schemas are only constructed on first invocation, avoiding expensive Zod type building during module loading. For CLI startup speed, this is an important optimization.
Environment Variables Schema
1// src/utils/settings/types.ts Lines 35-37
2export const EnvironmentVariablesSchema = lazySchema(() =>
3 z.record(z.string(), z.coerce.string()),
4)
z.coerce.string() means that even if values are numbers or booleans, they'll be coerced to strings. This aligns with environment variable semantics -- all environment variables are fundamentally strings.
Configuration File Loading and Merging
src/utils/settings/settings.ts implements configuration reading and merging.
File Managed Settings
1// src/utils/settings/settings.ts Lines 74-100 (loadManagedFileSettings)
2export function loadManagedFileSettings(): {
3 settings: SettingsJson | null
4 errors: ValidationError[]
5} {
6 const errors: ValidationError[] = []
7 let merged: SettingsJson = {}
8 let found = false
9
10 // 1. Load base file
11 const { settings, errors: baseErrors } = parseSettingsFile(
12 getManagedSettingsFilePath()
13 )
14 errors.push(...baseErrors)
15 if (settings && Object.keys(settings).length > 0) {
16 merged = mergeWith(merged, settings, settingsMergeCustomizer)
17 found = true
18 }
19
20 // 2. Load drop-in directory
21 const dropInDir = getManagedSettingsDropInDir()
22 try {
23 const entries = getFsImplementation()
24 .readdirSync(dropInDir)
25 .filter(d =>
26 (d.isFile() || d.isSymbolicLink()) &&
27 d.name.endsWith('.json') &&
28 !d.name.startsWith('.')
29 )
30 // Sorted alphabetically -- later files have higher priority
31 // ...
32 }
33}
Managed settings support two forms:
- Single file --
managed-settings.json, serving as the base
- Drop-in directory --
managed-settings.d/*.json, merged in alphabetical order
This design borrows from systemd's drop-in convention: different teams can independently deploy policy fragments (e.g., 10-otel.json, 20-security.json) without coordinating edits to the same file.
MDM (Mobile Device Management) Integration
1// From settings.ts Lines 36-37, MDM imports
2import { getHkcuSettings, getMdmSettings } from './mdm/settings.js'
Claude Code also supports OS-level MDM configuration distribution:
- macOS -- Distributed via MDM profiles to
/Library/Managed Preferences/
- Windows -- Via HKCU registry keys
These all fall under the policySettings source and are merged with file managed settings.
Multi-Source Merging
Configuration merging uses lodash's mergeWith with a custom merge strategy:
The key distinction in the merge strategy:
- Permission rule arrays (
allow, deny, ask) -- Concatenated. A project's allow rules are appended after the user's allow rules, not replaced.
- Other arrays -- Replaced. For example,
additionalDirectories from a later source completely overrides the earlier one.
- Objects -- Deep merged. Nested fields are overridden individually.
Settings Cache
1// From settings.ts Lines 40-46, cache imports
2import {
3 getCachedParsedFile,
4 getCachedSettingsForSource,
5 getSessionSettingsCache,
6 resetSettingsCache,
7 setCachedParsedFile,
8 setCachedSettingsForSource,
9 setSessionSettingsCache,
10} from './settingsCache.js'
Configuration reading uses multi-level caching:
- File parse cache -- Each file path is parsed only once
- Source cache -- Settings for each source are computed only once
- Session cache -- The final merged result is computed only once
When any source file changes, the cache is selectively invalidated and rebuilt.
Settings Change Detection
From settings.ts line 27, the import:
1import { settingsChangeDetector } from '../../utils/settings/changeDetector.js'
changeDetector uses filesystem watchers (e.g., fs.watch) to detect settings file changes. When a change is detected:
- The modified file is re-parsed
- Affected cache layers are invalidated
- The
useSettingsChange callback is triggered
- AppState is updated via
store.setState
onChangeAppState handles side effects (such as clearing auth caches)
Versioned Migrations
The src/migrations/ directory contains 11 migration functions that handle the historical evolution of configuration formats.
Migration List
1migrateAutoUpdatesToSettings.ts -- Auto-update preference migration to settings.json
2migrateBypassPermissionsAcceptedToSettings.ts -- Permission bypass setting migration
3migrateEnableAllProjectMcpServersToSettings.ts -- MCP server enable setting migration
4migrateFennecToOpus.ts -- Fennec model alias migration to Opus
5migrateLegacyOpusToCurrent.ts -- Legacy Opus name migration
6migrateOpusToOpus1m.ts -- Opus -> Opus[1m] migration
7migrateReplBridgeEnabledToRemoteControlAtStartup.ts -- Bridge setting migration
8migrateSonnet1mToSonnet45.ts -- Sonnet 1m -> Sonnet 4.5 migration
9migrateSonnet45ToSonnet46.ts -- Sonnet 4.5 -> Sonnet 4.6 migration
10resetAutoModeOptInForDefaultOffer.ts -- Auto mode opt-in reset
11resetProToOpusDefault.ts -- Pro user default model reset
Migration Example: Auto Updates
1// src/migrations/migrateAutoUpdatesToSettings.ts Lines 13-61
2export function migrateAutoUpdatesToSettings(): void {
3 const globalConfig = getGlobalConfig()
4
5 // Only migrate when the user explicitly disabled auto-updates
6 if (
7 globalConfig.autoUpdates !== false ||
8 globalConfig.autoUpdatesProtectedForNative === true
9 ) {
10 return
11 }
12
13 try {
14 const userSettings = getSettingsForSource('userSettings') || {}
15
16 // Migrate to env variable
17 updateSettingsForSource('userSettings', {
18 ...userSettings,
19 env: {
20 ...userSettings.env,
21 DISABLE_AUTOUPDATER: '1',
22 },
23 })
24
25 logEvent('tengu_migrate_autoupdates_to_settings', {
26 was_user_preference: true,
27 already_had_env_var: !!userSettings.env?.DISABLE_AUTOUPDATER,
28 })
29
30 // Take effect immediately
31 process.env.DISABLE_AUTOUPDATER = '1'
32
33 // Remove from old config
34 saveGlobalConfig(current => {
35 const { autoUpdates: _, autoUpdatesProtectedForNative: __, ...rest } = current
36 return rest
37 })
38 } catch (error) {
39 logError(new Error(`Failed to migrate auto-updates: ${error}`))
40 }
41}
Key characteristics of migrations:
- Idempotent -- Running multiple times produces no side effects
- Conditional execution -- Only triggered when the old format is detected
- Atomic -- Writes the new config first, then deletes the old one
- Observable -- Migration events are recorded via
logEvent
Migration Example: Model Aliases
1// src/migrations/migrateFennecToOpus.ts Lines 18-45
2export function migrateFennecToOpus(): void {
3 // Internal users only
4 if (process.env.USER_TYPE !== 'ant') return
5
6 const settings = getSettingsForSource('userSettings')
7 const model = settings?.model
8
9 if (typeof model === 'string') {
10 if (model.startsWith('fennec-latest[1m]')) {
11 updateSettingsForSource('userSettings', { model: 'opus[1m]' })
12 } else if (model.startsWith('fennec-latest')) {
13 updateSettingsForSource('userSettings', { model: 'opus' })
14 } else if (
15 model.startsWith('fennec-fast-latest') ||
16 model.startsWith('opus-4-5-fast')
17 ) {
18 updateSettingsForSource('userSettings', {
19 model: 'opus[1m]',
20 fastMode: true,
21 })
22 }
23 }
24}
This migration demonstrates two important decisions:
- Only migrate userSettings -- Never touches project/local/policy settings. A source code comment explains why: "Reading merged settings would cause infinite re-runs + silent global promotion."
- Model remapping --
fennec-fast-latest maps to opus[1m] + fastMode: true, preserving the user's performance preference.
Migration Execution Timing
Migrations execute during the preAction phase in main.tsx:
1// profileCheckpoint calls in main.tsx showing order
2profileCheckpoint('preAction_after_mdm') // Line 915
3profileCheckpoint('preAction_after_init') // Line 917
4profileCheckpoint('preAction_after_sinks') // Line 935
5profileCheckpoint('preAction_after_migrations') // Line 951 <- Migrations complete here
6profileCheckpoint('preAction_after_remote_settings') // Line 959
Migrations execute after sink initialization but before remote settings are loaded -- this means migrations can use telemetry (to record migration events) but don't depend on remote settings.
Remote Managed Settings
src/services/remoteManagedSettings/index.ts implements fetching enterprise-level managed settings from an API.
1// src/services/remoteManagedSettings/index.ts Lines 1-13
2/**
3 * Remote Managed Settings Service
4 *
5 * Manages fetching, caching, and validation of remote-managed settings
6 * for enterprise customers. Uses checksum-based validation to minimize
7 * network traffic and provides graceful degradation on failures.
8 *
9 * Eligibility:
10 * - Console users (API key): All eligible
11 * - OAuth users (Claude.ai): Only Enterprise/C4E and Team subscribers
12 * - API fails open (non-blocking)
13 * - API returns empty settings for users without managed settings
14 */
Checksum Caching
Remote configuration uses a checksum mechanism to reduce network traffic. Similar to HTTP ETags, but based on SHA256 content hashing:
- First fetch -> store config + compute checksum
- Subsequent fetches -> include checksum, server compares
- If unchanged -> 304 Not Modified, use cache
- If changed -> 200 + new config
Security Checks
1// src/services/remoteManagedSettings/index.ts Lines 38-39
2import {
3 checkManagedSettingsSecurity,
4 handleSecurityCheckResult,
5} from './securityCheck.jsx'
Remote configuration must pass security checks before being applied. securityCheck.tsx ensures remote config cannot introduce dangerous operations -- for example, remote config should not be able to set arbitrary env variables or modify permission deny rules.
Background Polling
1// src/services/remoteManagedSettings/index.ts Lines 54-55
2const POLLING_INTERVAL_MS = 60 * 60 * 1000 // 1 hour
Remote config is polled once per hour. The loading process is non-blocking -- if the API is unreachable, it continues running with the cache or without remote config.
Policy Limits
src/services/policyLimits/index.ts is another enterprise configuration layer -- organization-level feature restrictions.
1// src/services/policyLimits/index.ts Lines 510-526
2export function isPolicyAllowed(policy: string): boolean {
3 const restrictions = getRestrictionsFromCache()
4 if (!restrictions) {
5 // Safe degradation in HIPAA mode
6 if (isEssentialTrafficOnly() && ESSENTIAL_TRAFFIC_DENY_ON_MISS.has(policy)) {
7 return false
8 }
9 return true // fail open
10 }
11 const restriction = restrictions[policy]
12 if (!restriction) return true // Unknown policy = allow
13 return restriction.allowed
14}
Design principles for policy limits:
- Fail open -- Allow by default. Network failures should not prevent users from using the CLI.
- HIPAA exception -- For
essential-traffic-only mode, specific policies (like allow_product_feedback) default to deny when the cache is unavailable.
1// src/services/policyLimits/index.ts Line 502
2const ESSENTIAL_TRAFFIC_DENY_ON_MISS = new Set(['allow_product_feedback'])
Cache Architecture
Three-level cache:
- Session cache -- In-memory
sessionCache, fastest
- Disk cache --
~/.claude/policy-limits.json, survives process restarts
- Network fetch -- API request with retry and exponential backoff
ETag Caching
1// src/services/policyLimits/index.ts Lines 132-159
2function computeChecksum(
3 restrictions: PolicyLimitsResponse['restrictions'],
4): string {
5 const sorted = sortKeysDeep(restrictions)
6 const normalized = jsonStringify(sorted)
7 const hash = createHash('sha256').update(normalized).digest('hex')
8 return `sha256:${hash}`
9}
The checksum is computed from normalized JSON -- all keys are recursively sorted first, then serialized, then hashed. This ensures that even if the server returns fields in a different order, the checksum will be identical as long as the content is the same.
Authentication Support
1// src/services/policyLimits/index.ts Lines 227-262
2function getAuthHeaders(): { headers: Record<string, string>; error?: string } {
3 // Try API key first (Console users)
4 try {
5 const { key: apiKey } = getAnthropicApiKeyWithSource({
6 skipRetrievingKeyFromApiKeyHelper: true,
7 })
8 if (apiKey) {
9 return { headers: { 'x-api-key': apiKey } }
10 }
11 } catch { /* Fall through to OAuth */ }
12
13 // Fall back to OAuth tokens (Claude.ai users)
14 const oauthTokens = getClaudeAIOAuthTokens()
15 if (oauthTokens?.accessToken) {
16 return {
17 headers: {
18 Authorization: `Bearer ${oauthTokens.accessToken}`,
19 'anthropic-beta': OAUTH_BETA_HEADER,
20 },
21 }
22 }
23
24 return { headers: {}, error: 'No authentication available' }
25}
The policy limits API supports two authentication methods:
- API key -- Console users use the
x-api-key header directly
- OAuth -- Claude.ai users use Bearer tokens
skipRetrievingKeyFromApiKeyHelper: true avoids triggering the API key helper's execution -- on high-frequency paths like policy limit checks, launching an external process to retrieve a key is undesirable.
Initialization Loading Promise
1// src/services/policyLimits/index.ts Lines 94-114
2export function initializePolicyLimitsLoadingPromise(): void {
3 if (loadingCompletePromise) return
4
5 if (isPolicyLimitsEligible()) {
6 loadingCompletePromise = new Promise(resolve => {
7 loadingCompleteResolve = resolve
8
9 // Deadlock prevention timeout
10 setTimeout(() => {
11 if (loadingCompleteResolve) {
12 loadingCompleteResolve()
13 loadingCompleteResolve = null
14 }
15 }, LOADING_PROMISE_TIMEOUT_MS) // 30 seconds
16 })
17 }
18}
Both remote managed settings and policy limits use the same Promise pattern:
- Create the Promise early during initialization
- Other systems can
await waitForPolicyLimitsToLoad() to wait for loading to complete
- A 30-second timeout prevents deadlocks -- if
loadPolicyLimits() is never called (e.g., in Agent SDK tests), the Promise resolves automatically
Configuration Validation and Error Handling
Configuration file parsing is not a simple JSON.parse. Every file undergoes full Zod schema validation:
- JSON parsing -- The file may contain invalid JSON
- Schema validation -- Field type, format, and range checks
- Permission rule filtering -- Invalid permission rules are filtered out rather than rejecting the entire file
- Error collection -- All validation errors are collected without interrupting loading
The core principle of this design is resilience -- a malformed configuration file should not prevent the CLI from starting. Invalid rules are skipped while valid rules continue to take effect.
Source Display Names
1// src/utils/settings/constants.ts Lines 26-93
2export function getSettingSourceName(source: SettingSource): string {
3 switch (source) {
4 case 'userSettings': return 'user'
5 case 'projectSettings': return 'project'
6 case 'localSettings': return 'project, gitignored'
7 case 'flagSettings': return 'cli flag'
8 case 'policySettings': return 'managed'
9 }
10}
11
12export function getSourceDisplayName(
13 source: SettingSource | 'plugin' | 'built-in',
14): string {
15 switch (source) {
16 case 'userSettings': return 'User'
17 case 'projectSettings': return 'Project'
18 case 'localSettings': return 'Local'
19 case 'flagSettings': return 'Flag'
20 case 'policySettings': return 'Managed'
21 case 'plugin': return 'Plugin'
22 case 'built-in': return 'Built-in'
23 }
24}
Multiple display name formats are provided -- short names (for UI labels), descriptive names (for inline text), and capitalized names (for context/skill UI). This ensures configuration sources can be clearly identified across different UI scenarios.
Summary
Claude Code's configuration system is an engineering masterpiece:
- 5-source priority merging -- user < project < local < flag < policy, with policies that cannot be bypassed
- Zod schema validation -- Compile-time feature flags control schema shape,
lazySchema for lazy construction
- Drop-in directories -- Borrowing from the systemd convention, allowing multiple teams to independently deploy policy fragments
- 11 versioned migrations -- Idempotent, conditionally executed, atomically updated, and observable
- Remote managed settings -- Checksum caching, ETag optimization, fail-open strategy
- Policy limits -- Three-level cache, HIPAA safe degradation, dual authentication support
- Configuration hot reload -- File watch -> cache invalidation -> Store update -> side effect execution