Settings Precedence:不是简单就近覆盖,而是一条 Deep-Merge、Policy 抢占与 Trusted-Env 分流的装配链
一句话讲清楚
Claude Code 里的 Settings 并非像传统工具那样“找到离当前仓库最近的 settings.json 然后照单全收”。它的核心逻辑是将多层来源实时装配成一份运行时快照。
这套装配链通常包含五个层级:内置插件的低优先级默认配置、用户级(User)、项目级(Project)、本地级(Local)三层文件设置、通过 --settings 等 Flag 注入的会话设置,以及最高优先级的托管策略(Policy Settings)。
官方文档中描述的优先级表只是表象。在源码实现层面,这套系统具有三个关键特性:首先,不同来源之间并非简单的整份替换,而是深度合并(Deep Merge);其次,数组类型的配置(如权限列表)不是覆盖关系,而是拼接并去重;最后,托管策略层并非多来源合并,而是采取“首位胜出”的抢占机制。这种设计确保了企业级管控(Managed Policy)能够绝对覆盖个人或项目的偏好,同时又保留了配置项之间的精细叠加能力。
实现机制
在 claude-code-opensource/src/utils/settings/constants.ts 中,系统严格定义了配置来源的优先级顺序。标准来源从低到高依次为:userSettings、projectSettings、localSettings、flagSettings 和 policySettings。
一个关键的实现细节在 getEnabledSettingSources() 函数中:即使开发者尝试使用 --setting-sources 限制只读取某些配置层,flagSettings 和 policySettings 也会被系统强制保留。这意味着用户可以屏蔽仓库级的配置,但无法绕过命令行注入或企业强制下发的策略。
真正的装配逻辑位于 claude-code-opensource/src/utils/settings/settings.ts 的 loadSettingsFromDisk()。它以插件默认配置为基底,按顺序层层叠加。合并规则由 settingsMergeCustomizer() 定义:标量字段直接覆盖,而对象则递归合并,数组则进行拼接去重。这就是为什么 permissions.allow 或 sandbox.filesystem.allowWrite 等数组字段会跨 Scope 累积,而不是被后续层级抹除。
托管策略层(policySettings)的处理则更为特殊。在 getSettingsForSource('policySettings') 中,它遵循“抢占链”逻辑,按以下顺序尝试获取第一个有效的非空来源:
- 服务器端管理的配置(Server-managed settings)
- 操作系统级管理工具(MDM / macOS plist / Windows HKLM)
- 文件系统中的托管配置文件(
managed-settings.json及其.d目录) - 当前用户的注册表设置(Windows HKCU)
一旦某个来源被命中,后续来源将被忽略。这种设计体现了典型的“企业级管控优先”思路。此外,/config 命令并不对应独立的存储,它只是修改 userSettings、projectSettings 或 localSettings 的 UI 入口,底层依然依赖 updateSettingsForSource() 完成落盘。
在安全性方面,claude-code-opensource/src/utils/managedEnv.ts 实现了一个重要的分层:并非所有合并后的设置都直接受信任。在用户建立 Trust 关系之前,系统只会完整应用来自 userSettings、flagSettings 和 policySettings 的环境变量。而来自 projectSettings 和 localSettings 的环境变量必须经过严格的白名单过滤,以防止恶意仓库通过配置 ANTHROPIC_BASE_URL 或代理设置来实施劫持攻击。
限制与陷阱
首先,托管策略(Policy)虽然优先级最高,但它对数组字段的影响依然是“追加并去重”。如果你想通过 Policy 彻底清空低层定义的权限数组,目前的 Deep Merge 逻辑可能会带来非预期的结果。
其次,托管层级内部是“非此即彼”的抢占关系。如果系统中同时存在 MDM 配置和本地 managed-settings.json,MDM 将获得绝对控制权,后者将完全失效。
第三,--setting-sources 并非万能的隔离开关。它无法切断命令行 Flag 和企业策略的注入,这在进行安全排查或调试纯净环境时需要特别注意。
第四,环境变量的加载存在“信任边界”。在未信任的项目中,只有来自安全源(如全局配置)的敏感变量才会生效,这解释了为什么在某些新克隆的项目中,自定义的 Base URL 或代理可能会“意外失效”。
最后,虽然系统支持通过 changeDetector.ts 热更新大部分设置,但凡是涉及进程环境初始化或外部连接建链的配置,往往需要重启会话才能完全生效。
延伸阅读
如果你关注企业管控的实现细节,下一步应阅读 Server-managed Settings 的工作原理。
如果你关心安全防御机制,可以深入分析 managedEnv.ts 是如何划定配置信任边界的。
如果你正在开发需要感知配置变动的插件,建议查看 changeDetector.ts 的监听机制以及各子系统如何订阅配置更新。
源码锚点
claude-code-opensource/src/utils/settings/constants.ts: 定义配置层级顺序与强制保留逻辑。
📄 src/utils/settings/constants.ts — 定义配置层级顺序与强制保留逻辑。
export const SETTING_SOURCES = [
// User settings (global)
'userSettings',
// Project settings (shared per-directory)
'projectSettings',
// Local settings (gitignored)
'localSettings',
// Flag settings (from --settings flag)
'flagSettings',
// Policy settings (managed-settings.json or remote settings from API)
'policySettings',
] as const
export type SettingSource = (typeof SETTING_SOURCES)[number]
export function getSettingSourceName(source: SettingSource): string {
switch (source) {
case 'userSettings':
return 'user'
case 'projectSettings':
return 'project'
case 'localSettings':
return 'project, gitignored'
case 'flagSettings':
return 'cli flag'
case 'policySettings':claude-code-opensource/src/utils/settings/settings.ts: 核心装配逻辑、Deep Merge 实现及 Policy 抢占流程。
📄 src/utils/settings/settings.ts — 核心装配逻辑、Deep Merge 实现及 Policy 抢占流程。
merged = mergeWith(merged, settings, settingsMergeCustomizer)
found = true
}
const dropInDir = getManagedSettingsDropInDir()
try {claude-code-opensource/src/utils/managedEnv.ts: 环境配置的信任过滤逻辑。
📄 src/utils/managedEnv.ts — 环境配置的信任过滤逻辑。
function withoutSSHTunnelVars(
env: Record<string, string> | undefined,
): Record<string, string> {
if (!env || !process.env.ANTHROPIC_UNIX_SOCKET) return env || {}
const {
ANTHROPIC_UNIX_SOCKET: _1,
ANTHROPIC_BASE_URL: _2,
ANTHROPIC_API_KEY: _3,
ANTHROPIC_AUTH_TOKEN: _4,
CLAUDE_CODE_OAUTH_TOKEN: _5,
...rest
} = env
return rest
}claude-code-opensource/src/utils/settings/changeDetector.ts: 配置文件与 MDM 状态的实时监听器。
📄 src/utils/settings/changeDetector.ts — 配置文件与 MDM 状态的实时监听器。
* Poll interval for MDM settings (registry/plist) changes.
* These can't be watched via filesystem events, so we poll periodically.
*/
const MDM_POLL_INTERVAL_MS = 30 * 60 * 1000 // 30 minutes
/**
* Grace period in milliseconds before processing a settings file deletion.
* Handles the common delete-and-recreate pattern during auto-updates or when
* another session starts up. If an `add` or `change` event fires within this
* window (file was recreated), the deletion is cancelled and treated as a change.
*
* Must exceed chokidar's awaitWriteFinish delay (stabilityThreshold + pollInterval)
* so the grace window outlasts the write stability check on the recreated file.
*/
const DELETION_GRACE_MS =
FILE_STABILITY_THRESHOLD_MS + FILE_STABILITY_POLL_INTERVAL_MS + 200
let watcher: FSWatcher | null = null
let mdmPollTimer: ReturnType<typeof setInterval> | null = null
let lastMdmSnapshot: string | null = null
let initialized = false
let disposed = false
const pendingDeletions = new Map<string, ReturnType<typeof setTimeout>>()
const settingsChanged = createSignal<[source: SettingSource]>()
// Test overrides for timing constants
let testOverrides: {
stabilityThreshold?: number
pollInterval?: number
mdmPollInterval?: numberclaude-code-opensource/src/utils/status.tsx:/status命令如何提取并展示当前生效的配置溯源。
📄 src/utils/status.tsx — `/status` 命令如何提取并展示当前生效的配置溯源。
export type Property = {
label?: string;
value: React.ReactNode | Array<string>;
};