# Settings System Deep Dive

The settings system is the central hub for all behavioral configuration in Claude Code—from personal preferences to enterprise remote control. Five layers of sources (with the enterprise policy layer containing four sub-sources) are stacked through carefully designed merge semantics. It is far more than "reading a JSON file": behind the scenes there are systemd-style drop-in directories, strict Zod v4 validation, backward-compatibility promises, and a full suite of enterprise locking capabilities. This chapter parses the merge semantics of the five layers, the defensive design of the Schema, and the four-tier substructure of enterprise policy.

> **Source locations**: `src/utils/settings/` (multiple files), `src/services/settingsSync/`

> **On the Zod version**: Claude Code 2.1.88's `package.json` declares a dependency of `"zod": "^4.3.6"`, and `package-lock.json` confirms resolution to `zod@4.3.6`. Zod v4 was still a relatively new version line in mid-2025 (some sub-dependencies such as `@anthropic-ai/mcpb` were still pinned to Zod 3.x). Claude Code's decision to adopt v4 in production is itself a notable engineering choice—gaining better performance and type inference, but also taking on the potential risks of a newer version.

> 💡 **Plain English**: The priority of the settings system resembles a **legal system**—personal will (`userSettings`) < company internal rules (`projectSettings`) < department regulations (`localSettings`) < special directives (`flagSettings`) < national law (`policySettings`). Higher-level rules can override lower-level ones, and certain enterprise policy fields (such as `allowManagedHooksOnly`) are like "mandatory statutes" that lower levels cannot bypass. But this analogy is imprecise in one important respect: for array-type fields (such as permission rule lists), cross-layer merging uses "append" rather than "replace"—more like each level of law contributing its own clauses, which are then compiled into a complete code (see Section 2.1 for merge semantics).

### 🌍 Industry Context: Configuration Management in Developer Tools

Multi-layer configuration merging is a mature pattern in software engineering. The following comparison focuses on the tools most familiar to developers—IDEs and AI coding assistants:

**IDE configuration systems (the most direct reference frame)**:

- **VS Code / Cursor**: Adopt the classic three-layer model (default → user → workspace). VS Code's `settings.json` merging is an industry standard. The **Settings Profiles** feature introduced in 2023–2024 allows users to maintain multiple complete configurations and switch between them with one click—this is more thorough than Claude Code's Cowork mode (which only swaps the `userSettings` layer). VS Code's **Settings Sync** cloud synchronization is also more mature than Claude Code's remote managed settings API (supporting incremental sync and conflict resolution). Cursor inherits this system. Both require MDM-pushed configuration files for enterprise control and lack a built-in policy layer.
- **JetBrains IDEs**: Have IDE-level → Project-level → Module-level settings, plus **Team Settings** (shared via version-controlled `.idea` directories) and **JetBrains Gateway** remote configuration management. For enterprise Java/Kotlin developers—an important potential user base for Claude Code—JetBrains' configuration management experience is a core reference frame. Its `.idea` directory sharing model has similar design intent to Claude Code's `.claude/settings.json`.

**AI coding tools**:

- **GitHub Copilot**: Simple configuration (organization policy + user settings two layers). Enterprise admins control feature toggles via the GitHub Organization Settings page; there is no file-level policy merge mechanism.
- **Aider**: Uses a YAML configuration file (`.aider.conf.yml`), supporting global and project two layers, with no enterprise control capability.
- **CodeX (OpenAI)**: Extremely minimal configuration, controlled mainly via environment variables and command-line arguments, with no multi-layer merging. But its open-source skill library (with native Figma/Linear integrations) represents an alternative "capability expansion over configuration management" design philosophy.

**Industry references for configuration patterns**:

- **ESLint flat config**: ESLint once had complex cascading configuration (`.eslintrc` searched and merged up the directory tree), but after community reflection migrated to flat config in v9 (single file, explicit imports), on the grounds that cascading configuration "makes it impossible to predict which rules will actually take effect." This lesson is relevant for Claude Code's five-layer design.
- **Docker Compose override**: The two-file pattern of `docker-compose.yml` + `docker-compose.override.yml` is almost isomorphic to Claude Code's `settings.json` + `settings.local.json`.
- **systemd drop-in directories**: Claude Code's `managed-settings.d/` mechanism directly borrows from systemd's drop-in configuration pattern (`/etc/systemd/system/xxx.service.d/*.conf`). `/etc/sudoers.d/` and Kubernetes Kustomize overlays follow the same pattern. This is a standard practice validated by the Linux operations ecosystem; it is not an innovation by Claude Code, but choosing it was the correct engineering decision.

Among AI coding tools, Claude Code has the deepest configuration stack. But "deepest" does not equal "best"—VS Code's Settings Profiles + Sync may be more mature at the UX level, and JetBrains' Team Settings is more complete for project sharing. Claude Code's unique advantage lies in its **enterprise policy enforcement capability** (MDM + remote policy + `allowManaged*Only` locks), which VS Code/JetBrains lack.

---

## Overview

Claude Code's settings system is a **5-layer stack + 4 sub-layer enterprise policy** configuration architecture—from personal global preferences to enterprise remote control. Five layers of sources (with the enterprise policy layer containing four sub-sources that merge into a single external-facing source) are merged layer by layer via lodash `mergeWith`. What looks like "reading a JSON file" hides systemd-style drop-in directories, Zod v4 (`zod@4.3.6`, see `package.json:114`) strict validation, backward-compatibility safeguards, and a full suite of enterprise locking capabilities (locking MCP, locking Hooks, locking permission rules, locking Marketplace).

---

> **[Chart placeholder 3.9-A]**: Settings merge priority diagram — the full override chain of 5 layers + 4 sub-layers inside `policySettings`

> **[Chart placeholder 3.9-B]**: Enterprise lock matrix — cross-effects of `strictPluginOnlyCustomization` × `allowManaged*Only`

---

### Is this complexity worth it? — Actual distribution of layer usage

Before diving into technical details, it is worth answering a key question: **how many of the 5+4 layers are actually used in production?**

**For individual developers** (estimated majority of the user base): **2 layers** are actually used—`userSettings` (global preferences) + `projectSettings` (project-level configuration). `localSettings` is only used when local overrides are needed without committing to git (e.g., a personal `apiKeyHelper` path). `flagSettings` and `policySettings` do not exist for individual users.

**For small teams** (10–50 people): **2–3 layers** are actually used—`userSettings` + `projectSettings` + occasional `localSettings`. Teams share project configuration by committing `.claude/settings.json` to version control, and individual members use `settings.local.json` to override personal preferences. The enterprise policy layer is almost never used.

**For large enterprises** (500+ people, with security and compliance requirements): **3–5 layers** are used—`userSettings` + `projectSettings` + `policySettings` (usually only one sub-source, remote or MDM). The theoretical extreme of enabling all four policy sub-sources (remote + MDM + file + registry) is rare in practice—most enterprises choose one push channel (remote API or MDM) and stick with it.

**Conclusion**: In the 5+4 layer design, roughly 2–3 layers cover 90% of use cases. The remaining layers are prepared for the "long tail" of enterprise market needs—their existence does not increase complexity for ordinary users (completely hidden from the outside), but provides the necessary infrastructure for Anthropic to enter the enterprise market. This is more of a "strategic investment" than "over-engineering," provided these extra layers do not introduce maintenance burden and bug risk—Sections 6's analysis of the first-source-wins object-level granularity limitation and the crude cache invalidation strategy suggests this premise is not fully satisfied.

---

## 1. The Five Layers and Their Priority

### 1.1 Source Definitions

`constants.ts:7-22` defines 5 sources, **later merges override earlier merges**:

| Priority | Source | File Path | Editable? |
|----------|--------|-----------|-----------|
| 1 (lowest) | `userSettings` | `~/.claude/settings.json` | ✅ |
| 2 | `projectSettings` | `.claude/settings.json` | ✅ |
| 3 | `localSettings` | `.claude/settings.local.json` (gitignored) | ✅ |
| 4 | `flagSettings` | `--settings <path>` + inline SDK settings | ❌ |
| 5 (highest) | `policySettings` | Enterprise policy (4 sub-sources) | ❌ |

`EditableSettingSource` (`constants.ts:182-185`) excludes `policySettings` and `flagSettings`—these two sources are **read-only** from the user's code perspective. `updateSettingsForSource()` no-ops for them.

### 1.2 The Four-Tier Substructure of Enterprise Policy

`policySettings` internally uses a **first-source-wins** strategy (`settings.ts:322-345`)—it stops at the first non-empty source found:

| Priority | Sub-source | Mechanism |
|----------|------------|-----------|
| Highest | `remote` | Remote managed settings API (`getRemoteManagedSettingsSyncFromCache()`) |
| High | `MDM` (HKLM/plist) | System-level MDM (macOS Property List / Windows HKLM registry) |
| Medium | `file` | `managed-settings.json` + `managed-settings.d/*.json` |
| Lowest | `HKCU` | Windows user registry |

### 1.3 Drop-in Directory Mechanism

`loadManagedFileSettings()` (`settings.ts:74-121`) implements a **systemd/sudoers-style** drop-in mechanism:

```
managed-settings.json        ← base configuration (lowest)
managed-settings.d/
  ├── 10-otel.json           ← maintained by observability team
  ├── 20-security.json       ← maintained by security team
  └── 30-compliance.json     ← maintained by compliance team
```

- Files are sorted alphabetically and merged in sequence (later files override earlier files)
- Only files with a `.json` suffix are processed; hidden files starting with `.` are skipped
- Symbolic links are also accepted (`d.isFile() || d.isSymbolicLink()`)
- Different teams can maintain policy fragments independently without coordinating edits to a single file

### 1.4 Cowork Mode

`getUserSettingsFilePath()` (`settings.ts:264-272`): When the `--cowork` flag or the `CLAUDE_CODE_USE_COWORK_PLUGINS` environment variable is enabled, `userSettings` reads from `cowork_settings.json` instead. This lets users maintain two independent sets of global settings: "work" and "personal."

### 1.5 Plugin Settings Base

`loadSettingsFromDisk()` (`settings.ts:660-668`) merges `getPluginSettingsBase()` as the bottommost layer before all file-based sources—plugins can provide default settings, but they can be overridden by any file source.

## 2. Merge Semantics — The Most Critical Design Decision in This Chapter

The most engineering-valuable design in Claude Code's settings system is its **dual merge semantics**: cross-source merging and same-source writing use completely different array handling rules. This seemingly simple distinction hides the classic, thorniest problem in configuration system design.

### 2.1 Cross-Source Merging: Append Semantics

`settingsMergeCustomizer()` (`settings.ts:538-547`) is used by `lodash mergeWith`:

- **Arrays**: **Concatenate and deduplicate** (`uniq([...target, ...source])`)—not replace, but append
- **Objects**: Deep recursive merge
- **Other types**: Later source overrides earlier source

**Why choose append?** It allows different layers to "incrementally contribute" rules. For example: a user configures 2 global MCP servers in `userSettings`, and a project adds 3 project-specific MCP servers in `projectSettings`; after merging there are 5—this matches intuition. If replacement semantics were used, project settings would override the user's global list, forcing the user to repeat global configuration in every project.

### 2.2 Same-Source Writing: Replace Semantics

The internal merge inside `updateSettingsForSource()` (`settings.ts:473-495`) has **different** semantics:

- **`undefined`**: Means delete—`delete object[key]`
- **Arrays**: **Fully replace** (no append)—"the caller is responsible for computing the final state"
- **Objects**: Default lodash merge

**Why choose replace?** It makes editing a single file "what you see is what you get." When a user edits `~/.claude/settings.json` to change a permission list from 5 rules to 3, they expect the 3 rules in the file to be the final state—not appended to the original 5 to become 8.

### 2.3 The Essence of Dual Semantics: The Tension Between Declarative and Imperative

The essence of these two rule sets is the classic tension between "declarative" and "imperative" semantics in configuration:

- **Cross-source** is declarative: "each layer declares what it needs, and the system is responsible for merging"
- **Same-source** is imperative: "the file content is the final state"

This design decision forms an interesting contrast with the experience of the Kubernetes ecosystem. Kubernetes' strategic merge patch faces the same problem—how to merge lists? The K8s solution is to introduce a `patchMergeKey` annotation, merging by a specific field rather than full replacement. The Helm community has debated this endlessly. Claude Code chose a simpler solution (append everything across sources), avoiding the complexity of patch keys at the cost of sacrificing some flexibility.

**A practical limitation**: If a user wants to **remove** an MCP server added by `projectSettings` inside `localSettings`, they cannot—because cross-source merging only appends, and there is no "negation" mechanism (no exclusion syntax like `!server-name`). The user's only choice is to modify `projectSettings` itself (if they have permission). This is an inherent limitation of append semantics.

> 💡 **Plain English**: Cross-source merging is like various departments of a company jointly writing a security policy—the administration department contributes access-control rules, IT contributes network rules, and legal contributes compliance rules, which are compiled into a complete policy manual (append). Same-source writing is like a single department revising its own rules—the new version directly replaces the old one; it is not appended after it.

### 2.4 Anti-Mutation Guard

`parseSettingsFile()` (`settings.ts:178-199`) returns a **clone** of the cached entry:

```typescript
// Clone so callers (e.g. mergeWith in getSettingsForSourceUncached,
// updateSettingsForSource) can't mutate the cached entry.
return {
  settings: cached.settings ? clone(cached.settings) : null,
  errors: cached.errors,
}
```

The comment explains why: `mergeWith` mutates nested references in the target object. If a write fails (before `resetSettingsCache()`), unpersisted state would leak into the cache.

## 3. Schema and Validation

### 3.1 Zod v4 Schema

`SettingsSchema` (`types.ts:255+`) is a massive Zod schema defining roughly **78 top-level setting fields** (about 13 gated by feature flags, such as `voiceEnabled`, `assistant`, `autoMode`, etc., appearing only when the corresponding feature is enabled; the remaining ~65 are visible to all users). Key fields include:

| Category | Field | Description |
|----------|-------|-------------|
| Auth | `apiKeyHelper`, `awsCredentialExport`, `gcpAuthRefresh` | Various authentication script paths |
| Models | `model`, `availableModels`, `modelOverrides`, `advisorModel` | Model selection and enterprise allowlists |
| Permissions | `permissions.allow/deny/ask`, `permissions.defaultMode` | Permission rules (three modes) |
| MCP | `enableAllProjectMcpServers`, `allowedMcpServers`, `deniedMcpServers` | MCP allowlist/blocklist |
| Hooks | `hooks`, `disableAllHooks`, `allowManagedHooksOnly` | Hook configuration and enterprise control |
| Sandbox | `sandbox` | Sandbox policy (references `SandboxSettingsSchema`) |
| Plugins | `enabledPlugins`, `strictPluginOnlyCustomization` | Plugin on/off and enterprise locking |
| Marketplace | `extraKnownMarketplaces`, `strictKnownMarketplaces`, `blockedMarketplaces` | Marketplace source control |
| UI | `statusLine`, `spinnerVerbs`, `spinnerTipsOverride`, `syntaxHighlightingDisabled` | Interface customization |
| Remote | `remote.defaultEnvironmentId` | Bridge default environment |

### 3.2 Backward-Compatibility Promise

The comment at `types.ts:210-240` is a **backward-compatibility contract**:

```
✅ Allowed: adding optional fields, adding enum values, relaxing validation
❌ Forbidden: removing fields (mark as deprecated instead), removing enum values, tightening types
```

A test file path is also attached: `test/utils/settings/backward-compatibility.test.ts`.

### 3.3 Graceful Degradation

> 📚 **Course Connection (Software Engineering)**: The following code demonstrates the "safe degradation" principle in **Defensive Programming**—when input does not match expectations, the system should degrade to a safe state rather than crashing entirely. This is consistent with the ACID transaction rollback in database courses and the "fail-safe" design philosophy in distributed systems. `.catch(undefined)` acts as a bottom-line "safe default," ensuring the system can still run normally when facing unknown input.

`strictPluginOnlyCustomization` (`types.ts:517-548`) shows good practice in defensive schema design:

```typescript
z.preprocess(
  // Forward compatibility: drop unknown surface names
  // ["skills", "commands"] on old clients → ["skills"]
  // Degrade to "lock less," never degrade to "unlock all"
  v => Array.isArray(v)
    ? v.filter(x => CUSTOMIZATION_SURFACES.includes(x))
    : v,
  z.union([z.boolean(), z.array(z.enum(CUSTOMIZATION_SURFACES))]),
)
.catch(undefined)  // non-array non-boolean → undefined (degrade to "no lock")
```

The design philosophy in the comment: **the degradation direction can only be "lock less," never "break everything."** This principle in security engineering is called "fail-open" (fail safe by allowing access)—in contrast to "fail-closed" (fail safe by blocking access).

**Enterprise security perspective**: This fail-open design is worth vigilance in enterprise scenarios. Consider: an enterprise admin configures `strictPluginOnlyCustomization` in `managed-settings.json`, intending to lock all non-plugin customization channels. If the admin accidentally writes the string `"skills"` instead of the array `["skills"]`, `.catch(undefined)` silently degrades it to `undefined` (i.e., nothing is locked)—**a configuration typo bypasses the entire enterprise security policy, and the admin may be completely unaware**.

Claude Code partially mitigates this risk via the Doctor check in `doctorDiagnostic.ts:320-358`—Doctor detects invalid `strictPluginOnlyCustomization` values in the raw file and emits a warning. But Doctor requires the user to actively run it; it is not an automatic security barrier.

From a design trade-off perspective:
- **The case for fail-open** (Claude Code's choice): configuration parsing failures should not block normal user usage; a single field's format error should not cause the entire `managed-settings.json` to be rejected (which would be worse)
- **The case for fail-closed** (what an enterprise security admin might expect): failures of security-policy fields should be handled in the strictest mode—better to lock incorrectly than to unlock incorrectly; admins should validate formats via schema validation tools before deploying configurations
- **Possible improvement direction**: use fail-closed semantics exclusively for security-related fields (`strict*`, `allowManaged*Only`), and fail-open for UI fields (`spinnerVerbs`, `outputStyle`)—but this would increase schema complexity

### 3.4 Permission Rule Fault Tolerance

`filterInvalidPermissionRules()` (`validation.ts:224-265`) filters invalid permission rules before schema validation:

> One bad rule should not "poison" the entire settings file

This is defensive design—even if an admin writes one invalid permission rule, the others still take effect.

### 3.5 Edit Tool Integration

`validateSettingsFileContent()` (`validation.ts:179-217`) is called by the Edit tool—when Claude modifies a settings file, it first validates whether the modified content conforms to the schema. If not, it returns the full JSON Schema so Claude can understand the error and correct it.

## 4. Enterprise Locking Capabilities

### 4.1 Four `allowManaged*Only` Switches

| Switch | Effect |
|--------|--------|
| `allowManagedHooksOnly` | Only run Hooks from enterprise policy; ignore user/project/local Hooks |
| `allowManagedPermissionRulesOnly` | Only respect permission rules (allow/deny/ask) from enterprise policy |
| `allowManagedMcpServersOnly` | MCP allowlist reads only from enterprise policy (but users can still deny) |
| `strictPluginOnlyCustomization` | Specified surfaces (skills/agents/hooks/mcp) can only be customized via plugins |

### 4.2 strictPluginOnlyCustomization

`CUSTOMIZATION_SURFACES = ['skills', 'agents', 'hooks', 'mcp']` (`types.ts:248-253`)

When set to `true` or a list of surfaces:
- **Blocks**: `~/.claude/skills/`, `.claude/hooks/`, `.mcp.json`, hooks in settings.json
- **Does not block**: enterprise policy sources, plugin-provided customization

Combined with `strictKnownMarketplaces`, this achieves **end-to-end control**—plugins are constrained by the Marketplace allowlist, and all other customization channels are blocked by `strictPluginOnlyCustomization`.

### 4.3 Company Announcements

`companyAnnouncements: z.array(z.string())`—enterprise admins can display announcements at startup (randomly chosen when there are multiple). This is a lightweight but clever management feature.

## 5. Caching and Performance

### 5.1 Three-Level Cache

1. **File parse cache** (`getCachedParsedFile`): each file path is parsed only once
2. **Source settings cache** (`getCachedSettingsForSource`): merged results per source are cached
3. **Session settings cache** (`getSessionSettingsCache`): final merged results are cached at the session level

`resetSettingsCache()` clears all three levels at once.

### 5.2 Anti-Recursion Guard

`loadSettingsFromDisk()` (`settings.ts:639-649`) has an `isLoadingSettings` flag—preventing infinite recursion if the settings loading process (e.g., reading MDM) triggers another code path that reads settings.

### 5.3 Startup Profiling

The settings loading process embeds `profileCheckpoint('loadSettingsFromDisk_start')`—meaning the cost of settings loading is visible in startup performance profiles.

## 6. Design Trade-offs and Assessment

**Strengths**:
1. The 5-layer + 4-sub-layer architecture covers everything from individual developers to large enterprises, with complexity completely hidden from ordinary users—most users only touch 2 layers and do not know the others exist
2. Drop-in directories let multiple management teams maintain policies independently—a standard operations pattern validated by systemd/sudoers.d
3. The `filterInvalidPermissionRules` "no poisoning" principle—one bad rule does not invalidate the entire file
4. The Schema's backward-compatibility contract (`types.ts:210-240`) + test coverage reduce upgrade breakage risk
5. The dual merge semantics of cross-source "append" vs same-source "replace" make a pragmatic trade-off on the classic hard problem of configuration system design (see Section 2 analysis)

**Costs and Risks**:
1. **Cross-source merging has no "negation" mechanism**: a user cannot remove an array element appended by a higher-priority layer from a lower-priority layer. For example, an MCP server declared in `projectSettings` cannot be excluded in `localSettings`—only append, no subtract
2. **5 layers make "where did this setting come from" a debugging nightmare**. There is currently no `git blame`-like tool to trace the source layer of an effective setting (`claude doctor` only does format validation)
3. **`localSettings`'s gitignore protection is fire-and-forget** (`void addFileGlobRuleToGitignore`); users receive no warning on write failure. This is not just a UX issue—`settings.local.json` may contain sensitive information (such as `apiKeyHelper` script paths, custom auth configurations). If the gitignore write fails, this information may be tracked by git and pushed to a remote. In security-sensitive scenarios, this is a potential credential leak path
4. **`policySettings`'s first-source-wins is at the object level** (`settings.ts:322-345`)—if the remote policy returns *any* non-empty settings, *all* policies from MDM/file/HKCU are completely ignored. This means a division of labor like "remote policy manages MCP allowlist + MDM manages permission rules" is impossible—as soon as the remote policy returns even a single field, all other fields from MDM become invalid. For large enterprises where the security team (managing remote policy) and the IT operations team (managing MDM) operate independently, this is a substantive limitation
5. **Cache invalidation strategy is crude**: the three-level cache only has `resetSettingsCache()` for a full clear, with no file watcher, no incremental update, and no TTL. After an enterprise admin updates an MDM policy, the user must restart Claude Code for it to take effect. For a system that emphasizes "enterprise control," this falls short of expectations
6. **The existence of the `getSettings_DEPRECATED` alias indicates the naming refactor is incomplete**
7. **`strictPluginOnlyCustomization`'s fail-open design carries risk in enterprise security scenarios** (see Section 3.3 analysis)

---

*Quality self-check:*
- [x] Coverage: 5 layers + 4 sub-layers + merge semantics + Schema + enterprise locking + caching + complexity analysis
- [x] Fidelity: all line number references are from actual reading of constants.ts, settings.ts, types.ts, validation.ts; Zod v4 version verified via package.json/package-lock.json
- [x] Depth: distinguishes cross-source vs same-source dual merge semantics and analyzes design motivations; analyzes fail-open enterprise security impact
- [x] Criticality: assesses actual usage distribution of 5+4 layer complexity; points out first-source-wins object-level granularity limitation, crude cache invalidation, and gitignore security risk
- [x] Competitive comparison: contrasts VS Code Settings Profiles/Sync, JetBrains Team Settings, and ESLint flat config configuration management experience
