markdown.engineering

Lesson 16 — Settings & Configuration

The 5-layer cascade, SettingsSchema, change detection, MDM, and remote managed settings

1. Overview: why a cascade?

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.

Key invariant: The cascade is read-only at runtime — no source can modify another source's file during a session. Only userSettings, projectSettings, and localSettings are writable via updateSettingsForSource().

2. The 5-layer cascade

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.

User
~/.claude/settings.json

Global developer preferences. Writable by the user. Shared across all projects.

Project
.claude/settings.json

Committed to the repo. Shared with the whole team. Version-controlled.

Local
.claude/settings.local.json

Auto-gitignored. Personal per-project overrides that stay off-repo.

Flag
--settings <path>

CLI flag. Merges a file and any inline SDK settings. Not file-watched.

Policy
managed-settings.json

IT/MDM enforced. First-source-wins among four sub-sources.

Priority stack — low to high

1
userSettings ~/.claude/settings.json  (or cowork_settings.json) Editable
2
projectSettings .claude/settings.json Editable · version-controlled
3
localSettings .claude/settings.local.json Editable · auto-gitignored
4
flagSettings --settings <file> + inline SDK object Read-only · not file-watched
5
policySettings remote → HKLM/plist → managed-settings.json → HKCU Read-only · first-source-wins
policySettings is special. Unlike the other four layers (which all merge together), the policy layer uses first-source-wins internally — exactly one of its four sub-sources (remote, MDM, file, HKCU) wins, and only that sub-source's settings are applied at the policy layer. See Section 7.

Cascade flow diagram

flowchart TD A["Plugin base (lowest)"] --> B[userSettings\n~/.claude/settings.json] B --> C[projectSettings\n.claude/settings.json] C --> D[localSettings\n.claude/settings.local.json] D --> E[flagSettings\n--settings flag + inline] E --> F[policySettings\nfirst-source-wins sub-cascade] F --> G["Merged SettingsJson\n(getInitialSettings)"] subgraph PolicySubCascade["policySettings — first-source-wins"] P1["① Remote API"] -->|"wins if non-empty"| P2["② MDM\n(HKLM / macOS plist)"] P2 -->|"wins if non-empty"| P3["③ managed-settings.json\n+ drop-in .d/ files"] P3 -->|"wins if non-empty"| P4["④ HKCU registry\n(Windows only)"] end style A fill:#0d1117,stroke:#2a2a4a,color:#847c72 style G fill:#0f3460,stroke:#2a2a4a,color:#e0e0f0 style PolicySubCascade fill:#200a0a,stroke:#f87171
Source: loadSettingsFromDisk() — the merge engine
// 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 }
}

3. SettingsSchema: what can be configured

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.

Top-level keys (selected)

KeyTypePurpose
permissionsobjectallow / deny / ask arrays, defaultMode, disableBypassPermissionsMode
hooksobjectPreToolUse, PostToolUse, Notification, SessionStart, Stop, etc.
envrecord<string,string>Environment variables injected into every session
modelstringOverride the default Claude model
availableModelsstring[]Enterprise allowlist of selectable models
allowedMcpServersobject[]Enterprise MCP server allowlist (by name, command, or URL)
deniedMcpServersobject[]Enterprise MCP server denylist (denylist beats allowlist)
apiKeyHelperstringScript path that emits authentication values
cleanupPeriodDaysnumberTranscript retention in days (0 = disable persistence)
strictPluginOnlyCustomizationbool | string[]Lock skills/agents/hooks/mcp to plugin-only delivery
allowManagedHooksOnlybooleanWhen set in policy, only managed hooks run
allowManagedPermissionRulesOnlybooleanWhen set in policy, only managed permission rules apply
attributionobjectCustomize commit/PR attribution text
sandboxobjectSandbox configuration (enabled, network, filesystem, …)
worktreeobjectsymlinkDirectories, sparsePaths for --worktree flag
Backward-compatibility contract

The codebase comments declare strict rules to avoid breaking users' existing config files:

  • Allowed: adding new optional fields, new enum values, making validation more permissive
  • Forbidden: removing fields, removing enum values, making optional fields required, renaming keys
  • The .passthrough() on the permissions object preserves unknown fields in the file
  • filterInvalidPermissionRules() strips individual bad rules before Zod validation, so one bad rule doesn't null out the entire settings file

Example 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
}

4. Merge semantics

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
}

Rules at a glance

  • Objects — deep-merged. A key in a higher layer overrides the same key in a lower layer.
  • Arrays — concatenated and deduplicated. Permissions rules from all enabled sources accumulate.
  • Scalars — the higher layer wins (simple overwrite).
  • Deletion via updateSettingsForSource() — pass undefined as a value. The customizer detects srcValue === undefined and calls delete object[key].
flowchart LR U["userSettings\n{model: 'opus', env: {A:'1'}}"] P["projectSettings\n{env: {B:'2'}, permissions: {allow: ['Read']}}"] L["localSettings\n{permissions: {allow: ['Bash(git *)']}}"] M["Merged result\n{model:'opus', env:{A:'1',B:'2'},\npermissions:{allow:['Read','Bash(git *)']}}"] U --> M P --> M L --> M style M fill:#0f3460,stroke:#2a2a4a,color:#e0e0f0
Array merge vs. array replace: The merge customizer is used when loading sources into the cascade. When writing settings via updateSettingsForSource(), arrays are replaced (not merged) — the caller is responsible for computing the desired final array state before passing it in.

5. The 3-tier cache

Claude Code reads settings files synchronously on the critical startup path. Three caching layers prevent redundant disk I/O and Zod re-parsing:

flowchart TD A["getInitialSettings()"] --> B{"sessionSettingsCache\nhit?"} B -- yes --> Z["Return cached SettingsJson"] B -- no --> C["loadSettingsFromDisk()"] C --> D{"perSourceCache\nhit per source?"} D -- yes --> E["Use cached per-source settings"] D -- no --> F{"parseFileCache\nhit per path?"} F -- yes --> G["Use cached parsed JSON"] F -- no --> H["readFileSync + Zod parse"] H --> F E --> I["mergeWith all sources"] G --> I I --> B style Z fill:#0a2a1e,stroke:#34d399,color:#34d399
CacheKeyed byHoldsInvalidated 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.

Clone-on-read: 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).

6. Change detection

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.

File watching architecture

sequenceDiagram participant FS as Filesystem (chokidar) participant CD as changeDetector participant Hook as ConfigChange hook participant Cache as settingsCache participant Subs as Subscribers FS->>CD: change / unlink / add event CD->>CD: consumeInternalWrite? (skip if own write) CD->>Hook: executeConfigChangeHooks(source, path) Hook-->>CD: results (blocked or not) CD->>Cache: resetSettingsCache() CD->>Subs: settingsChanged.emit(source) Subs->>Cache: getSettingsWithErrors() [cache miss → disk reload]

Key constants

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
Internal writes — suppressing self-triggered reload loops

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()
Delete-and-recreate grace period

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.

fanOut() — the single-producer pattern

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
}

7. MDM & file-based managed settings

The policySettings layer has four internal sub-sources. The first one that has non-empty content wins — the others are ignored for this session.

Policy sub-source priority

1
Remote Anthropic API → /api/claude_code/settings Enterprise/Team + Console users
2
MDM plist/HKLM macOS: /Library/Managed Preferences/com.anthropic.claudecode.plist
Windows: HKLM\SOFTWARE\Policies\ClaudeCode
Admin-only write
3
File managed-settings.json + managed-settings.d/*.json Requires elevated write access to /etc/claude-code or equivalent
4
HKCU HKCU\SOFTWARE\Policies\ClaudeCode (Windows only) User-writable — lowest policy priority

Platform paths for managed-settings.json

PlatformBase pathDrop-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/

Drop-in directory merge order

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, ...)
}

MDM raw read — startup parallelism

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
  })()
}

8. Remote managed settings

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.

Eligibility

  • Console users (API key): always eligible
  • OAuth users — Enterprise or Team: eligible
  • OAuth users — unknown subscription type (externally injected tokens, CI): eligible — the API returns empty settings for ineligible orgs, so the cost is one extra round-trip
  • Third-party API provider or custom base URL: ineligible
  • Cowork (local-agent entrypoint): ineligible

Fetch lifecycle

sequenceDiagram participant Init as CLI init participant RMS as RemoteManagedSettings participant API as Anthropic API participant FS as Disk cache participant CD as changeDetector Init->>RMS: loadRemoteManagedSettings() RMS->>FS: read cached settings (sync, getRemoteManagedSettingsSyncFromCache) FS-->>RMS: cached settings (if any) RMS->>Init: resolve loading promise (unblocks waiters immediately) RMS->>API: GET /api/claude_code/settings\nIf-None-Match: "sha256:..." API-->>RMS: 200 new settings / 304 not modified / 204 no content / 404 RMS->>RMS: security check (dangerous changes need user approval) RMS->>FS: save new settings (mode 0o600) RMS->>CD: notifyChange('policySettings') CD->>CD: resetSettingsCache() + fanOut Note over Init,CD: Background poll every 1 hour

ETag-based caching (checksum)

To 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}`
}

Fail-open design

  • If the fetch fails (network error, timeout, auth error) and a cached file exists → use stale cache
  • If the fetch fails and no cache exists → proceed without remote settings
  • Auth errors (401/403) do not retry
  • Network/timeout errors retry up to 5 times with exponential backoff
  • 204/404 → no settings configured → delete stale cache file

Security check for dangerous changes

When 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.

Background polling

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.

9. Settings sync (CCR)

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.

DirectionTriggerWhat 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

Sync keys

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.

Incremental upload: The upload path fetches the remote copy first, computes a diff, and only sends keys whose values have changed. This keeps the PUT request small even if you have many projects.
Size limit: Each file is capped at 500 KB (MAX_FILE_SIZE_BYTES). Files exceeding this are silently skipped. The limit is enforced on both upload and download paths.

10. Security constraints

Several settings keys intentionally exclude projectSettings from their trust domain. A malicious repo could otherwise inject dangerous settings automatically.

Setting / CheckSources trustedWhy 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
allowManagedPermissionRulesOnly — when set to 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.
allowManagedHooksOnly — similar lockdown for hooks. Only hooks defined in managed settings will execute. This prevents users from installing hooks that exfiltrate data or bypass auditing.

11. Key Takeaways

  • Settings are merged from 5 layers in order: user → project → local → flag → policy. Later layers override earlier ones. Arrays are deduplicated-concatenated across layers.
  • policySettings uses first-source-wins internally: remote beats MDM (plist/HKLM) beats managed-settings.json beats HKCU. Only the first non-empty source is applied.
  • The 3-tier cache (session, per-source, per-file) keeps startup fast. A single resetSettingsCache() invalidates all three and is called atomically before notifying subscribers.
  • Change detection uses chokidar file watching for user/project/local/policy files plus a 30-minute MDM poll. Internal writes are suppressed with a 5-second window to prevent reload loops.
  • Remote managed settings are the highest-priority policy sub-source, fetched at startup with ETag caching, retried up to 5 times, and polled hourly. The system always fails open — a failed fetch falls back to the cached file or simply skips remote settings.
  • Settings sync is orthogonal to remote managed settings. It syncs the user's own settings files (not enterprise policy) between CLI and CCR using a cloud key-value store, scoped by git remote hash.
  • Several security-sensitive flags (skipDangerousModePermissionPrompt, auto-mode opt-in, classifier rules) intentionally exclude projectSettings to prevent malicious repositories from escalating their own privileges.
  • The backward-compatibility contract in SettingsSchema forbids removing fields or enum values. New fields must be optional. Invalid individual permission rules are stripped before Zod validation so one bad rule cannot null out the entire settings file.

12. Quiz

Q1. A setting is defined in both userSettings and projectSettings. Which value wins in the final merged result?

The cascade merges low-to-high: later entries in SETTING_SOURCES override earlier ones. projectSettings (index 1) overrides userSettings (index 0).

Q2. You have both a macOS MDM plist and a managed-settings.json file on disk. Which one supplies the policy settings?

policySettings is first-source-wins: remote → MDM (plist/HKLM) → managed-settings.json → HKCU. The MDM plist (index 2) wins over the file (index 3).

Q3. What happens when Claude Code writes a settings file itself (e.g., saving a permission rule)?

markInternalWrite(path) records the path + timestamp. consumeInternalWrite() in handleChange() checks this within INTERNAL_WRITE_WINDOW_MS (5000ms) and returns true, preventing the reload notification.

Q4. A project's .claude/settings.json sets skipDangerousModePermissionPrompt: true. Will Claude Code respect this?

hasSkipDangerousModePermissionPrompt() checks userSettings, localSettings, flagSettings, and policySettings — projectSettings is deliberately excluded. A malicious repo could otherwise auto-bypass the danger-mode dialog (RCE risk).

Q5. You have permissions.allow: ["Read(**)", "Bash(git *)"] in userSettings and permissions.allow: ["Bash(git *)", "Write(src/)"] in projectSettings. What is the merged allow list?

settingsMergeCustomizer detects two arrays and returns uniq([...objValue, ...srcValue]). "Bash(git *)" appears in both but is deduplicated in the result.

Q6. Remote managed settings are fetched with an If-None-Match header. What value is sent in that header?

computeChecksumFromSettings() recursively sorts keys, serializes to JSON without whitespace (matching Python's json.dumps separators=(",",":")), and returns "sha256:" + sha256 hex digest. This must match the server-side implementation exactly.

Q7. What triggers the managed-settings.d/ drop-in directory merge, and in what order are the files applied?

loadManagedFileSettings() first merges managed-settings.json (base), then sorts drop-in filenames alphabetically and merges each on top. This matches systemd/sudoers conventions: 10-otel.json < 20-security.json, later names win.