来源与授权
本文来自 Claude-Code-Source-Study 原文,固定导入版本为 31b6e07a82d4。Copyright (c) 2026 Yao,依据 MIT License 提供。
本站仅按“github-public-noncommercial-2026-07-11”用于匿名、免费、非商业学习,并调整章节编排、站内链接、重复标题与阅读标记;不代表原作者对本站的合作、认证或背书。如权利方要求删除,我们会立即下架;授权或删除请求请通过本站首页底部公开联系方式联系。
本章是《深入 Claude Code 源码》系列第 3 章。我们将剖析 Settings 系统如何从 5 个正式配置源(加 1 个 Plugin 基底层)中读取、验证、合并配置,以及如何在运行时监听变更并热更新 —— 一个面向企业级部署的多层配置合并架构。除了文件层的合并管线之外,Claude Code 还有两条独立运行的组织级服务管线 ——
services/policyLimits/与services/settingsSync/—— 它们不参与设置合并,但同样在「企业管控」与「跨设备一致性」两个维度上塑造了用户最终看到的运行时配置面貌。
为什么需要多层配置?
一个 CLI 工具的配置需求看似简单 —— 用户写一个 JSON 文件就行了。但 Claude Code 面对的现实远比这复杂:
- 个人偏好 —— 用户想全局设置自己偏好的模型、权限规则
- 团队共享 —— 项目组要把 MCP 服务器、Hook 脚本提交到 Git 仓库共享
- 本地覆盖 —— 个人本地调试需要覆盖项目设置,但不能提交到 Git
- 企业管控 —— 安全团队需要强制启用沙箱、禁用危险权限,且用户不能覆盖
- 远程策略 —— 企业管理员通过 API 远程下发配置,无需触碰每台机器
- 平台差异 —— macOS 用 plist + MDM、Windows 用注册表、Linux 用文件
这些需求层层叠加,任何单一配置文件都无法满足。Claude Code 的 Settings 系统通过多层配置源 + 优先级合并 + 变更检测热更新的架构,优雅地解决了这个问题。
一、配置源全景:5 + 1 层优先级
Settings 系统的核心设计是一条明确的优先级链。配置从多个来源读取,按照优先级从低到高逐层合并,高优先级覆盖低优先级:
正式的配置源类型定义在 utils/settings/constants.ts 中,只有 5 个 SettingSource:
// utils/settings/constants.ts:7-22
export const SETTING_SOURCES = [
'userSettings', // 用户全局
'projectSettings', // 项目共享
'localSettings', // 本地覆盖(gitignored)
'flagSettings', // CLI --settings 参数
'policySettings', // 企业管控(最高优先级)
] as const
数组的顺序就是合并顺序 —— 后面的覆盖前面的。policySettings 排在最后,意味着企业管控策略拥有最终决定权。
此外还有一个非正式的第 0 层 —— Plugin Settings。它不是 SettingSource 类型成员,而是通过 getPluginSettingsBase() 在 loadSettingsFromDisk() 中作为最低优先级基底注入(settingsCache.ts:61-80)。Plugin 只包含白名单内的字段(如 agent 配置),所有正式的 file-based sources 都会覆盖它。
1.1 各层配置源的文件位置与用途
| 层级 | 配置源 | 文件位置 | 典型用途 |
|---|---|---|---|
| 基底 | Plugin(非 SettingSource) | 内存注入 | 插件提供的默认 Agent 配置等 |
| 1 | User | ~/.claude/settings.json | 个人全局偏好(模型、权限) |
| 2 | Project | $PROJECT/.claude/settings.json | 团队共享配置(Hook、MCP) |
| 3 | Local | $PROJECT/.claude/settings.local.json | 本地覆盖(自动加入 .gitignore) |
| 4 | Flag | --settings CLI 参数 | SDK / IDE 注入的临时配置 |
| 5(最高) | Policy | 多种来源(见下文) | 企业安全管控 |
其中 Policy Settings 最为特殊 —— 它本身就是一个内部有优先级的多源系统,采用 first-source-wins 策略。
1.2 Policy Settings 的内部优先级
Policy Settings 不像其他层级那样简单地从一个文件读取。它有自己的4 层子优先级链,使用 "first source wins"(第一个有内容的来源胜出)策略:
// utils/settings/settings.ts:322-345
function getSettingsForSourceUncached(source: SettingSource): SettingsJson | null {
if (source === 'policySettings') {
// 1. Remote API(最高优先级)
const remoteSettings = getRemoteManagedSettingsSyncFromCache()
if (remoteSettings && Object.keys(remoteSettings).length > 0) {
return remoteSettings
}
// 2. Admin-only MDM (HKLM/plist)
const mdmResult = getMdmSettings()
if (Object.keys(mdmResult.settings).length > 0) {
return mdmResult.settings
}
// 3. File-based managed settings
const { settings: fileSettings } = loadManagedFileSettings()
if (fileSettings) {
return fileSettings
}
// 4. HKCU registry(最低 — 用户可写)
const hkcu = getHkcuSettings()
if (Object.keys(hkcu.settings).length > 0) {
return hkcu.settings
}
return null
}
// ...
}
注意这与外层的合并策略不同:外层是所有层级逐层合并(merge),Policy 内部是第一个胜出(first-source-wins)。设计意图很明确 —— 如果企业通过 Remote API 下发了策略,就不应该再考虑本地 managed-settings.json 的内容,避免策略冲突。
本章路线:第一至第五节 讲 5 个
SettingSource的合并管线;第六节(PolicyLimits)与 第七节(SettingsSync)讲两条与 settings 平行的组织级旁路服务——它们不进loadSettingsFromDisk()合并管线(参见utils/settings/constants.ts:7-22中SETTING_SOURCES只有 5 个),但同样影响最终运行时配置。第八至第十一节 回到合并体系本身,覆盖变更检测、安全设计、MDM 轮询与可迁移模式。
二、核心合并算法:loadSettingsFromDisk()
整个 Settings 系统最核心的函数是 loadSettingsFromDisk(),它负责按优先级顺序读取并合并所有配置源:
// utils/settings/settings.ts:645-796
function loadSettingsFromDisk(): SettingsWithErrors {
// 防止递归调用(某些验证函数可能间接触发 settings 读取)
if (isLoadingSettings) {
return { settings: {}, errors: [] }
}
isLoadingSettings = true
try {
// 从 Plugin Settings 开始(最低优先级基底)
const pluginSettings = getPluginSettingsBase()
let mergedSettings: SettingsJson = {}
if (pluginSettings) {
mergedSettings = mergeWith(mergedSettings, pluginSettings, settingsMergeCustomizer)
}
const allErrors: ValidationError[] = []
const seenFiles = new Set<string>()
// 按优先级顺序逐层合并
for (const source of getEnabledSettingSources()) {
if (source === 'policySettings') {
// Policy 使用 first-source-wins(特殊逻辑)
// ...
continue
}
const filePath = getSettingsFilePathForSource(source)
if (filePath) {
const resolvedPath = resolve(filePath)
// 去重:同一文件不会被加载两次
if (!seenFiles.has(resolvedPath)) {
seenFiles.add(resolvedPath)
const { settings, errors } = parseSettingsFile(filePath)
if (settings) {
mergedSettings = mergeWith(mergedSettings, settings, settingsMergeCustomizer)
}
}
}
}
return { settings: mergedSettings, errors: allErrors }
} finally {
isLoadingSettings = false
}
}
这里有几个精巧的设计值得注意:
防递归守卫:isLoadingSettings 标志位防止递归。某些验证逻辑(如权限校验)可能间接触发 getSettings(),如果不做守卫就会无限递归。
文件去重:seenFiles 集合确保同一个物理文件不会被加载两次。这在实际中主要防御的是通过 --setting-sources CLI 参数控制后、某些源恰好解析到相同的 resolved path 的边缘情况(例如符号链接导致两个逻辑路径指向同一物理文件)。
数组合并策略:合并时使用自定义的 settingsMergeCustomizer:
// utils/settings/settings.ts:538-547
export function settingsMergeCustomizer(objValue: unknown, srcValue: unknown): unknown {
if (Array.isArray(objValue) && Array.isArray(srcValue)) {
return mergeArrays(objValue, srcValue) // 去重拼接
}
return undefined // 其他值让 lodash 默认处理(深度 merge 对象,覆盖标量)
}
数组是拼接去重(而非替换),这个决策对权限系统至关重要 —— 多层的 allow 和 deny 规则会合并在一起,而不是高层完全覆盖低层。
三、配置文件解析与验证
3.1 Zod Schema 验证
每个配置文件在加载后,都要通过 SettingsSchema 进行 Zod 运行时验证:
// utils/settings/settings.ts:201-231
function parseSettingsFileUncached(path: string): {
settings: SettingsJson | null
errors: ValidationError[]
} {
try {
const content = readFileSync(resolvedPath)
if (content.trim() === '') {
return { settings: {}, errors: [] }
}
const data = safeParseJSON(content, false)
// 在 schema 验证前先过滤无效的权限规则
const ruleWarnings = filterInvalidPermissionRules(data, path)
const result = SettingsSchema().safeParse(data)
if (!result.success) {
const errors = formatZodError(result.error, path)
return { settings: null, errors: [...ruleWarnings, ...errors] }
}
return { settings: result.data, errors: ruleWarnings }
} catch (error) {
handleFileSystemError(error, path)
return { settings: null, errors: [] }
}
}
这里有一个关键的容错设计:filterInvalidPermissionRules() 在 schema 验证之前运行,将无效的权限规则单独过滤掉。这样一条坏规则不会导致整个配置文件被拒绝 —— 其他有效的设置仍然生效,只是会产生警告。
3.2 SettingsSchema 的向后兼容设计
SettingsSchema 定义在 utils/settings/types.ts 中,使用 lazySchema() 延迟构造(与工具系统一致的模式,见第 10 章)。它的设计严格遵循向后兼容原则:
// utils/settings/types.ts:210-241(注释节选)
// ✅ 允许的变更:
// - 添加新的可选字段(始终使用 .optional())
// - 添加新的 enum 值(保留现有值)
// - 使验证更宽松
// ❌ 应避免的破坏性变更:
// - 删除字段(改为标记 deprecated)
// - 删除 enum 值
// - 将可选字段改为必需
// - 使类型更严格
Schema 覆盖了丰富的配置项 —— 从基础的 model、env 到复杂的 permissions(权限规则)、hooks(生命周期钩子)、sandbox(沙箱配置)、allowedMcpServers(MCP 服务器白名单)等超过 40 个配置字段。每个字段都有 .describe() 注释,这些注释会被导出为 JSON Schema 供编辑器提供自动补全。
一个特别有趣的容错设计是 strictPluginOnlyCustomization 字段:
// utils/settings/types.ts:519-540
strictPluginOnlyCustomization: z
.preprocess(
// 前向兼容:过滤掉未知的 surface 名称,这样未来新增的枚举值
// (如 'commands')不会导致旧客户端 safeParse 失败,
// 进而导致整个 managed-settings 文件被丢弃
v => Array.isArray(v)
? v.filter(x => (CUSTOMIZATION_SURFACES as readonly string[]).includes(x))
: v,
z.union([z.boolean(), z.array(z.enum(CUSTOMIZATION_SURFACES))]),
)
.optional()
// 非数组无效值通过 preprocess 后仍然不合法,.catch 将其降级为 undefined
// 而不是让整个 managed-settings 文件验证失败
.catch(undefined)
这体现了一个重要原则:配置解析应该 degrade gracefully,而不是 fail catastrophically。一个未知字段值不应该让整个企业策略文件失效。
3.3 三级缓存体系
频繁读取配置文件会带来性能问题。Settings 系统使用三级缓存来避免重复 I/O:
// utils/settings/settingsCache.ts
// 第 1 级:最终合并结果缓存
let sessionSettingsCache: SettingsWithErrors | null = null
// 第 2 级:单个配置源缓存
const perSourceCache = new Map<SettingSource, SettingsJson | null>()
// 第 3 级:文件解析缓存(去重同一文件被多路径引用时的重复解析)
const parseFileCache = new Map<string, ParsedSettings>()
export function resetSettingsCache(): void {
sessionSettingsCache = null
perSourceCache.clear()
parseFileCache.clear()
}
三级缓存覆盖了不同粒度:
- parseFileCache 避免同一个文件被重复解析(
parseSettingsFile()和getSettingsForSource()可能从不同路径命中同一文件) - perSourceCache 避免单源重复计算(如
policySettings的 4 层子源查找) - sessionSettingsCache 避免整体合并的重复计算
所有缓存通过 resetSettingsCache() 统一失效,保证一致性。
四、MDM 集成:跨平台的企业设备管理
Claude Code 支持通过操作系统原生的设备管理机制下发配置,这是面向大型企业部署的关键特性。
4.1 三模块分层架构
MDM 的实现被拆分为三个模块,每个模块有明确的职责和 import 约束:
mdm/
├── constants.ts — 零重量 import(只有 os),共享常量和路径构建器
├── rawRead.ts — 最小 import(child_process + fs),子进程 I/O
└── settings.ts — 解析、缓存、first-source-wins 逻辑
为什么要这样拆? 因为 rawRead.ts 在 main.tsx 模块求值阶段就被调用(第 2 章提到的侧效果前置),此时不能引入任何重量级模块。MDM 读取分为两个阶段:
阶段一:预启动子进程(main.tsx:3-4,模块求值期)
// utils/settings/mdm/rawRead.ts:120-123
export function startMdmRawRead(): void {
if (rawReadPromise) return
rawReadPromise = fireRawRead() // 立即启动 plutil/reg query 子进程
}
startMdmRawRead() 在 main.tsx 顶层调用,利用后续 ~135ms 的 import 求值时间并行完成子进程 I/O。此时只产生一个 Promise,不做任何解析。
阶段二:等待并消费结果(main.tsx:914,preAction hook 中)
// utils/settings/mdm/settings.ts:67-98
export function startMdmSettingsLoad(): void {
mdmLoadPromise = (async () => {
// 使用阶段一的 Promise(如果已启动),否则重新 fire
const rawPromise = getMdmRawReadPromise() ?? fireRawRead()
const { mdm, hkcu } = consumeRawReadResult(await rawPromise) // 解析 + 写缓存
mdmCache = mdm
hkcuCache = hkcu
})()
}
ensureMdmSettingsLoaded() 在首次 settings 读取前被 await —— 如果阶段一的子进程已经完成,这里几乎零等待。
4.2 跨平台读取策略
// utils/settings/mdm/rawRead.ts:55-113
export function fireRawRead(): Promise<RawReadResult> {
return (async (): Promise<RawReadResult> => {
if (process.platform === 'darwin') {
// macOS: 多路径 plist 并行读取(plutil 转换为 JSON)
const plistPaths = getMacOSPlistPaths()
const allResults = await Promise.all(
plistPaths.map(async ({ path, label }) => {
// 快速路径:文件不存在就跳过(省 ~5ms 的 plutil 启动时间)
if (!existsSync(path)) {
return { stdout: '', label, ok: false }
}
const { stdout, code } = await execFilePromise(PLUTIL_PATH, [
...PLUTIL_ARGS_PREFIX, path,
])
return { stdout, label, ok: code === 0 && !!stdout }
}),
)
// First source wins(数组按优先级排序)
const winner = allResults.find(r => r.ok)
return { plistStdouts: winner ? [{ stdout: winner.stdout, label: winner.label }] : [] }
}
if (process.platform === 'win32') {
// Windows: HKLM 和 HKCU 并行读取
const [hklm, hkcu] = await Promise.all([
execFilePromise('reg', ['query', WINDOWS_REGISTRY_KEY_PATH_HKLM, '/v', 'Settings']),
execFilePromise('reg', ['query', WINDOWS_REGISTRY_KEY_PATH_HKCU, '/v', 'Settings']),
])
return { hklmStdout: hklm.code === 0 ? hklm.stdout : null, hkcuStdout: hkcu.code === 0 ? hkcu.stdout : null }
}
// Linux: 无 MDM(使用 /etc/claude-code/managed-settings.json)
return { plistStdouts: null, hklmStdout: null, hkcuStdout: null }
})()
}
macOS 的 plist 路径有明确的优先级(定义在 constants.ts):
// utils/settings/mdm/constants.ts:45-81
export function getMacOSPlistPaths(): Array<{ path: string; label: string }> {
const paths = []
// 1. 最高优先级:每用户 Managed Preferences
paths.push({
path: `/Library/Managed Preferences/${username}/com.anthropic.claudecode.plist`,
label: 'per-user managed preferences',
})
// 2. 设备级 Managed Preferences
paths.push({
path: `/Library/Managed Preferences/com.anthropic.claudecode.plist`,
label: 'device-level managed preferences',
})
// 3. 仅限内部构建:用户可写的 Preferences(用于本地 MDM 测试)
if (process.env.USER_TYPE === 'ant') {
paths.push({
path: join(homedir(), 'Library', 'Preferences', 'com.anthropic.claudecode.plist'),
label: 'user preferences (ant-only)',
})
}
return paths
}
Windows 的注册表路径放在 SOFTWARE\Policies 下而不是 SOFTWARE 下 —— 源码注释解释了原因:SOFTWARE 在 WOW64 下会被重定向(32 位进程读到的是 WOW6432Node 下的值),而 SOFTWARE\Policies 是共享键,不受重定向影响。
4.3 Drop-in 目录模式
除了单一的 managed-settings.json,系统还支持 managed-settings.d/ 目录,允许多个独立的策略片段:
// utils/settings/settings.ts:63-121
export function loadManagedFileSettings(): { settings: SettingsJson | null; errors: ValidationError[] } {
let merged: SettingsJson = {}
// 1. 先加载 managed-settings.json 作为基底(最低优先级)
const { settings } = parseSettingsFile(getManagedSettingsFilePath())
if (settings && Object.keys(settings).length > 0) {
merged = mergeWith(merged, settings, settingsMergeCustomizer)
}
// 2. 扫描 managed-settings.d/ 下的 .json 文件
// 按文件名字母序排列,后面的覆盖前面的
const dropInDir = getManagedSettingsDropInDir()
const entries = fs.readdirSync(dropInDir)
.filter(d => d.isFile() && d.name.endsWith('.json') && !d.name.startsWith('.'))
.map(d => d.name)
.sort()
for (const name of entries) {
const { settings } = parseSettingsFile(join(dropInDir, name))
if (settings && Object.keys(settings).length > 0) {
merged = mergeWith(merged, settings, settingsMergeCustomizer)
}
}
return { settings: found ? merged : null, errors }
}
这是对 Linux systemd/sudoers drop-in 模式的借鉴:
- 基础文件提供默认值
- 不同团队可以独立地提交策略片段(如
10-otel.json、20-security.json) - 不需要协调对同一个文件的编辑
五、远程策略:Remote Managed Settings
Policy Settings 的最高优先级来源是 Remote API —— 企业管理员通过 Anthropic API 下发配置,无需物理访问每台设备。
5.1 资格检查与 Fail-Open 设计
isRemoteManagedSettingsEligible() 决定当前用户是否应该向 API 查询远程策略。它的判断逻辑分为前置排除和三路放行两个阶段:
// services/remoteManagedSettings/syncCache.ts:49-112
export function isRemoteManagedSettingsEligible(): boolean {
// ── 前置排除 ──
// 3P provider / 自定义 base URL → 不查询
if (getAPIProvider() !== 'firstParty') return false
if (!isFirstPartyAnthropicBaseUrl()) return false
// Cowork VM → 不适用(server-managed settings 不适合 VM 场景)
if (process.env.CLAUDE_CODE_ENTRYPOINT === 'local-agent') return false
// ── 路径 1:外部注入 OAuth token(subscriptionType === null)──
// CCD/CCR 通过环境变量注入的 token 没有 subscriptionType 元数据,
// 直接放行 —— 让 API 决定是否返回空设置(false positive 成本仅一次网络往返)
const tokens = getClaudeAIOAuthTokens()
if (tokens?.accessToken && tokens.subscriptionType === null) return true
// ── 路径 2:OAuth Enterprise 或 Team 用户 ──
// 除了 Enterprise,Team 订阅也有资格;还要求 scope 包含 CLAUDE_AI_INFERENCE_SCOPE
if (
tokens?.accessToken &&
tokens.scopes?.includes(CLAUDE_AI_INFERENCE_SCOPE) &&
(tokens.subscriptionType === 'enterprise' || tokens.subscriptionType === 'team')
) return true
// ── 路径 3:Console API Key 用户 ──
// 跳过 apiKeyHelper 以避免循环依赖
const { key: apiKey } = getAnthropicApiKeyWithSource({
skipRetrievingKeyFromApiKeyHelper: true,
})
if (apiKey) return true
return false
}
这里有一个值得注意的设计选择:对于 subscriptionType === null(外部注入 token,缺少元数据)的情况,系统宁可多发一次 API 请求也不漏掉有资格的用户。API 对无策略的 org 返回 204/404 空响应,settings.ts 的合并逻辑会正常 fallthrough 到 MDM/file —— 误判的成本极低(一次往返),但漏判会导致企业策略完全不生效。
远程设置的核心设计原则是 fail-open:获取失败时不阻塞启动,继续使用本地缓存或者不应用远程策略。
5.2 双层缓存 + ETag 优化
远程设置使用文件缓存 + 内存缓存的双层机制:
- 文件缓存:
~/.claude/remote-settings.json,跨 session 持久化 - 内存缓存:session 级别的
sessionCache,避免重复读文件 - HTTP ETag:通过
If-None-Match头和 SHA-256 checksum 实现增量更新,304 响应表示无变化
// services/remoteManagedSettings/index.ts:131-137
export function computeChecksumFromSettings(settings: SettingsJson): string {
const sorted = sortKeysDeep(settings)
// 无空格分隔符,匹配 Python 的 json.dumps(separators=(",", ":"))
const normalized = jsonStringify(sorted)
const hash = createHash('sha256').update(normalized).digest('hex')
return `sha256:${hash}`
}
注意 checksum 的计算要求与服务端 Python 实现完全一致(sort_keys=True, separators=(",", ":")),这是跨语言协作中容易出错的细节。
5.3 安全确认:新策略落地前的校验
在将新获取的远程设置应用到 session 之前,系统会执行一层安全检查:
// services/remoteManagedSettings/index.ts:456-468
if (hasContent) {
// 检查新设置是否包含危险变更(如权限降级、沙箱关闭等)
const securityResult = await checkManagedSettingsSecurity(cachedSettings, newSettings)
if (!handleSecurityCheckResult(securityResult)) {
// 用户拒绝 → 不应用新设置,保留缓存的旧版本
logForDebugging('Remote settings: User rejected new settings, using cached settings')
return cachedSettings
}
setSessionCache(newSettings)
await saveSettings(newSettings)
return newSettings
}
checkManagedSettingsSecurity() 对比新旧设置中的安全相关字段(权限规则、沙箱配置等),如果检测到敏感变更,会通过 handleSecurityCheckResult() 提示用户确认。这确保了即使企业管理员远程推送了策略变更,用户也不会在不知情的情况下被降低安全等级。
5.4 后台轮询
初始加载后,还有 1 小时间隔的后台轮询来捕获 mid-session 的策略变更:
// services/remoteManagedSettings/index.ts:612-628
export function startBackgroundPolling(): void {
pollingIntervalId = setInterval(() => {
void pollRemoteSettings()
}, POLLING_INTERVAL_MS) // 60 * 60 * 1000 = 1 小时
pollingIntervalId.unref() // 不阻止进程退出
}
六、PolicyLimits:与 Settings 平行的组织级开关
读到这里,你已经看到 Settings 系统通过 Policy 层把"企业管控"塞进了同一条优先级链。但还有一类管控不适合走 Settings —— 它不是"应该用哪个模型"或"允许哪些权限",而是"这个组织的用户能不能用某个产品特性"。services/policyLimits/ 就是为这类组织级开关单独构建的一条服务。
它和 Remote Managed Settings 像一对孪生兄弟:同样从 Anthropic API 拉取、同样缓存到 ~/.claude/policy-limits.json、同样 1 小时间隔后台轮询、同样 fail-open。但它的责任面不一样 —— Settings 决定行为细节,PolicyLimits 决定特性可见性。
5.5.1 资格判定:和 Remote Settings 同源但要求更严
// services/policyLimits/index.ts:8-13(文件头注释)
// Eligibility:
// - Console users (API key): All eligible
// - OAuth users (Claude.ai): Only Team and Enterprise/C4E subscribers are eligible
// - API fails open (non-blocking) - if fetch fails, continues without restrictions
// - API returns empty restrictions for users without policy limits
资格逻辑与 Remote Managed Settings 高度对齐 —— 都要求 1P Anthropic baseURL、都接受 OAuth Enterprise/Team 或 Console API Key —— 但 PolicyLimits 不接受 subscriptionType === null 的"外部注入 token"分支。原因很现实:PolicyLimits 是"硬关掉某个 UI 入口"的决策,宁可漏判(让某个 token 不受限)也不能误关(让一个普通 token 因元数据缺失被强制收紧)。
5.5.2 默认值与 essential-traffic 反例
isPolicyAllowed(policy) 是所有特性 gating 的统一入口。它的默认值在两种情况下截然不同:
// 简化自 services/policyLimits/index.ts
const ESSENTIAL_TRAFFIC_DENY_ON_MISS = new Set(['allow_product_feedback'])
export function isPolicyAllowed(policy: string): boolean {
// 已有显式策略 → 用显式值
const restrictions = getSessionCache()
if (restrictions && policy in restrictions) {
return restrictions[policy].allowed
}
// 没有策略 → 看用户是不是 essential-traffic-only 模式
if (isEssentialTrafficOnly() && ESSENTIAL_TRAFFIC_DENY_ON_MISS.has(policy)) {
return false // 反例:缺失即拒绝
}
return true // 默认放行(fail-open)
}
为什么 allow_product_feedback 要反着来?因为它涉及"把用户的交互数据发回 Anthropic"——对一个明确开启了"只发必要流量"的企业用户,缓存里没拉到策略不等于"管理员允许",更可能是"还没拉到",此时唯一安全的选择是不发。这是 fail-open 总原则下被显式拣出来的反例集合。
5.5.3 缓存文件的 0o600 权限
PolicyLimits 写 ~/.claude/policy-limits.json 时显式指定 mode 0o600(只允许文件所有者读写)。这一点和普通 Settings 文件不同 —— 后者依赖目录默认权限,而 PolicyLimits 缓存里可能包含组织级别的特性开关,对同机器其他用户暴露是不必要的信息泄露。
5.5.4 一个 30 秒超时的 loading promise
// services/policyLimits/index.ts:94-114
export function initializePolicyLimitsLoadingPromise(): void {
if (loadingCompletePromise) return
if (isPolicyLimitsEligible()) {
loadingCompletePromise = new Promise(resolve => {
loadingCompleteResolve = resolve
setTimeout(() => {
if (loadingCompleteResolve) {
loadingCompleteResolve() // 兜底防死锁
loadingCompleteResolve = null
}
}, LOADING_PROMISE_TIMEOUT_MS) // 30 秒
})
}
}
任何想 "等 PolicyLimits 就绪" 的调用方都可以 await waitForPolicyLimitsLoaded(),但即使 loadPolicyLimits() 因为某种原因从未被调用,promise 也会在 30 秒后自行 resolve —— 保证 fail-open 链条不会因为初始化路径上的漏接而把整个启动锁死。这是面对"远程依赖 + 启动 critical path"时一个常见但容易忘记的兜底动作。
七、SettingsSync:跨设备一致性的双向通道
如果说 PolicyLimits 是"自上而下下发",services/settingsSync/ 就是"自机器之间互相搬运"。它解决的痛点是:用户在笔记本上配的偏好(模型、Hook、Permission、CLAUDE.md 记忆),怎么在另一台机器(甚至是托管在云端的 Claude Code Remote)上自然出现。
后端 API 编号 anthropic#218817,文件头里直接给出来 —— 这是排查问题时极其有用的引线,比让读者去搜 backend 文档要省事得多。
5.6.1 两个方向、两组门禁
// services/settingsSync/index.ts:60-74(上传路径门禁)
if (
!feature('UPLOAD_USER_SETTINGS') ||
!getFeatureValue_CACHED_MAY_BE_STALE('tengu_enable_settings_sync_push', false) ||
!getIsInteractive() ||
!isUsingOAuth()
) {
// 跳过
return
}
上传方向(CLI → Server)的四道闸:bundle feature flag、GrowthBook 实时 flag、交互模式(getIsInteractive())、OAuth 登录。为什么要交互?因为 SDK / CCR 这类非交互场景下用户没有"我正在调整偏好"的语义,把后台 Agent 的临时配置上传到 server 会污染用户的真实偏好集。
下载方向(Server → CCR)则反过来 —— feature('DOWNLOAD_USER_SETTINGS') + tengu_strap_foyer flag,只在 CCR 启动并准备安装插件之前触发一次。
5.6.2 增量上传:lodash pickBy 之美
// services/settingsSync/index.ts:84-90
const projectId = await getRepoRemoteHash()
const localEntries = await buildEntriesFromLocalFiles(projectId)
const remoteEntries = result.isEmpty ? {} : result.data!.content.entries
const changedEntries = pickBy(
localEntries,
(value, key) => remoteEntries[key] !== value,
)
这里没有 diff 算法、没有 mtime 比较 —— 只是把本地 4 个文件(~/.claude/settings.json、~/.claude/CLAUDE.md、项目级 settings.local.json、项目级 CLAUDE.local.md)的内容做成 { path: content } 字典,和远程拉回来的字典逐 key 比一遍内容相等性,只上传变了的那几条。lodash pickBy 在这里既是过滤器也是 diff 引擎,简单到无可挑剔。
projectId = await getRepoRemoteHash() 决定项目级文件用什么 key —— 同一个 Git remote 的 worktree 视为同一项目,自然支持"在 A 机器克隆,在 B 机器接着用"的场景。
5.6.3 500KB 单文件上限与 MD5 完整性校验
const MAX_FILE_SIZE_BYTES = 500 * 1024 // 500 KB per file (matches backend limit)
注释里那句 "matches backend limit" 是关键 —— 这个阈值不是 CLI 一时兴起,而是和服务端硬协定。读到 600KB 的 CLAUDE.md 时上传会跳过这一条但不阻塞其余条目,符合 fail-open 总原则。完整性靠 UserSyncDataSchema 里的 MD5 checksum 字段做端到端校验。
5.6.4 落地时的内部写入标记
下载完成后,要把远程内容写回本地文件。这一刻会触发 第 6.2 节 描述的 chokidar 文件监听器 —— 如果不加标记,系统会把"自己刚写下去"误判为"用户外部改动"并触发一次完整的 settings 重载,造成无意义的抖动。
// applyRemoteEntriesToLocal 简化伪代码
markInternalWrite(targetPath) // 5 秒窗口内的本次写入将被吞掉
await writeFile(targetPath, content)
resetSettingsCache() // 主动失效,让下一次读拿到新值
clearMemoryFileCaches() // CLAUDE.md 类内存文件另有缓存
markInternalWrite / consumeInternalWrite 这对原语在 第 6.2 节 详细介绍,这里只点出 SettingsSync 是它的第二位重要消费者 —— 第一位是 UI 触发的权限规则保存。两个完全不同的写入入口共享同一个"我自己写的,别回响"机制,是一处值得称道的 API 复用。
5.6.5 一个 /reload-plugins 触发的快速重下
// services/settingsSync/index.ts:152-154
export function redownloadUserSettings(): Promise<boolean> {
downloadPromise = doDownloadUserSettings(0) // 0 = maxRetries
return downloadPromise
}
redownloadUserSettings() 本身不收参数,内部强制调用 doDownloadUserSettings(0),把 maxRetries 压到 0 —— 启动路径走的是 DEFAULT_MAX_RETRIES,会自动重试一两轮;/reload-plugins 是用户主动敲的命令,只做一次尝试,失败就 fail-open(源码注释原话:No retries: user-initiated command, one attempt + fail-open),用户自己再敲一次就行。这是把"调用语义"通过同一个底层函数的不同入参显式表达出来的一个干净例子 —— 启动有韧性、用户命令有响应感,避免了为两个场景各写一份下载逻辑。
八、变更检测与热更新
Settings 系统不是"启动时读一次就完了"—— 它支持运行时的配置文件变更检测和热更新。
6.1 文件监听架构
changeDetector.ts 使用 chokidar 库监听配置文件变更:
// utils/settings/changeDetector.ts:84-146
export async function initialize(): Promise<void> {
// 启动 MDM 轮询(独立于文件系统监听)
startMdmPoll()
const { dirs, settingsFiles, dropInDir } = await getWatchTargets()
watcher = chokidar.watch(dirs, {
persistent: true,
ignoreInitial: true,
depth: 0, // 只监听直接子级
awaitWriteFinish: {
stabilityThreshold: 1000, // 等待写入稳定
pollInterval: 500,
},
ignored: (path, stats) => {
// 只监听已知的配置文件路径
if (settingsFiles.has(normalized)) return false
// 也接受 drop-in 目录中的 .json 文件
if (dropInDir && normalized.startsWith(dropInDir) && normalized.endsWith('.json')) {
return false
}
return true // 忽略其他文件
},
})
watcher.on('change', handleChange)
watcher.on('unlink', handleDelete)
watcher.on('add', handleAdd)
}
6.2 内部写入过滤
当 Claude Code 自身修改配置文件时(比如用户通过 UI 添加权限规则),不应该触发变更通知。系统通过一个时间戳 Map 来过滤:
// utils/settings/internalWrites.ts
const timestamps = new Map<string, number>()
export function markInternalWrite(path: string): void {
timestamps.set(path, Date.now())
}
export function consumeInternalWrite(path: string, windowMs: number): boolean {
const ts = timestamps.get(path)
if (ts !== undefined && Date.now() - ts < windowMs) {
timestamps.delete(path) // 消费后删除,不影响下次真正的外部变更
return true
}
return false
}
在 updateSettingsForSource() 中写文件前,先调用 markInternalWrite(filePath),然后 handleChange() 中 5 秒内的变更会被识别为内部写入并跳过。
6.3 删除-重建模式的优雅处理
配置文件经常被 "删除然后重新创建"(auto-updater 或另一个 session 启动时),如果把删除事件直接当作配置清空处理会导致闪烁。系统引入了一个优雅的 grace period 机制:
// utils/settings/changeDetector.ts:62-64
const DELETION_GRACE_MS =
FILE_STABILITY_THRESHOLD_MS + FILE_STABILITY_POLL_INTERVAL_MS + 200
// = 1000 + 500 + 200 = 1700ms
function handleDelete(path: string): void {
// 不立即处理删除,设置 grace timer
const timer = setTimeout(() => {
pendingDeletions.delete(path)
fanOut(source) // 超时后才真正处理
}, DELETION_GRACE_MS, path, source)
pendingDeletions.set(path, timer)
}
function handleAdd(path: string): void {
// 文件被重新创建了 → 取消 pending 的删除
const pendingTimer = pendingDeletions.get(path)
if (pendingTimer) {
clearTimeout(pendingTimer)
pendingDeletions.delete(path)
}
handleChange(path) // 当作普通变更处理
}
如果在 1700ms 内文件被重新创建(add 事件触发),删除事件被取消 —— 只有 add(作为 change)被处理。
6.4 集中式缓存刷新
变更检测到后,通知通过 fanOut() 统一分发:
// utils/settings/changeDetector.ts:437-439
function fanOut(source: SettingSource): void {
resetSettingsCache() // 先刷新缓存
settingsChanged.emit(source) // 再通知所有监听者
}
源码注释特别提到了一个性能教训:之前缓存重置分散在每个监听者中,导致 N 个监听者 = N 次磁盘重读。集中到 fanOut() 后,只有第一个监听者触发一次磁盘读取,后续监听者命中缓存。
6.5 热更新应用
变更通知最终通过 applySettingsChange() 作用到 AppState:
// utils/settings/applySettingsChange.ts:33-92
export function applySettingsChange(
source: SettingSource,
setAppState: (f: (prev: AppState) => AppState) => void,
): void {
const newSettings = getInitialSettings()
const updatedRules = loadAllPermissionRulesFromDisk()
updateHooksConfigSnapshot()
setAppState(prev => {
let newContext = syncPermissionRulesFromDisk(prev.toolPermissionContext, updatedRules)
// 重新检查 bypass mode 是否被禁用
if (newContext.isBypassPermissionsModeAvailable && isBypassPermissionsModeDisabled()) {
newContext = createDisabledBypassPermissionsContext(newContext)
}
return {
...prev,
settings: newSettings,
toolPermissionContext: newContext,
}
})
}
一次配置变更会级联触发:设置刷新 → 权限规则重新加载 → Hooks 快照更新 → AppState 更新 → React UI 重渲染。
九、安全设计:防止恶意项目配置
Settings 系统中有多处安全防线,防止恶意项目通过配置文件获取过高权限:
7.1 Project Settings 的信任限制
某些敏感设置刻意排除 projectSettings,只信任用户自己的设置:
// utils/settings/settings.ts:882-889
export function hasSkipDangerousModePermissionPrompt(): boolean {
return !!(
getSettingsForSource('userSettings')?.skipDangerousModePermissionPrompt ||
getSettingsForSource('localSettings')?.skipDangerousModePermissionPrompt ||
getSettingsForSource('flagSettings')?.skipDangerousModePermissionPrompt ||
getSettingsForSource('policySettings')?.skipDangerousModePermissionPrompt
// 注意:没有 projectSettings!
)
}
注释解释了原因:projectSettings is intentionally excluded — a malicious project could otherwise auto-bypass the dialog (RCE risk). 一个恶意项目的 .claude/settings.json 可以被提交到 Git 仓库,如果允许它跳过权限确认对话框,就构成远程代码执行(RCE)风险。
7.2 Local Settings 自动 Gitignore
当写入 settings.local.json 时,自动将其加入 .gitignore:
// utils/settings/settings.ts:508-514
if (source === 'localSettings') {
void addFileGlobRuleToGitignore(
getRelativeSettingsFilePathForSource('localSettings'),
getOriginalCwd(),
)
}
这防止了包含本地调试配置(可能含有 API Key 路径等敏感信息)的文件被意外提交。
7.3 可编辑源的类型约束
通过 TypeScript 类型系统限制哪些配置源可以被程序修改:
// utils/settings/constants.ts:182-185
export type EditableSettingSource = Exclude<
SettingSource,
'policySettings' | 'flagSettings'
>
policySettings 和 flagSettings 在类型层面就被排除在可编辑范围之外。updateSettingsForSource() 函数在运行时也会检查这一约束。
十、MDM 轮询:注册表和 plist 的变更检测
文件系统的变更可以用 chokidar 监听,但 macOS plist 和 Windows 注册表的变更无法通过文件系统事件捕获。系统通过 30 分钟间隔的轮询来解决:
// utils/settings/changeDetector.ts:381-418
function startMdmPoll(): void {
// 拍快照
const initial = getMdmSettings()
const initialHkcu = getHkcuSettings()
lastMdmSnapshot = jsonStringify({ mdm: initial.settings, hkcu: initialHkcu.settings })
mdmPollTimer = setInterval(() => {
void (async () => {
const { mdm: current, hkcu: currentHkcu } = await refreshMdmSettings()
const currentSnapshot = jsonStringify({ mdm: current.settings, hkcu: currentHkcu.settings })
if (currentSnapshot !== lastMdmSnapshot) {
lastMdmSnapshot = currentSnapshot
setMdmSettingsCache(current, currentHkcu)
fanOut('policySettings')
}
})()
}, MDM_POLL_INTERVAL_MS) // 30 分钟
mdmPollTimer.unref() // 不阻止进程退出
}
原理很简单:将整个 MDM 设置 JSON 序列化为字符串做快照比较。虽然不够精细,但完全可靠且实现简单。
十一、可迁移的设计模式
模式 1:多层配置合并 + 类型安全
用一个有序数组定义配置源优先级,配合 lodash mergeWith 自定义合并策略。数组用 as const + SettingSource 类型保护,合并逻辑集中在一个函数中。
关键决策:数组是拼接去重还是替换?标量是覆盖还是追加?不同的场景需要不同的策略。Claude Code 选择了"数组拼接去重 + 标量覆盖" —— 权限规则叠加,模型配置覆盖。
适用场景:任何需要多层配置的应用 —— CLI 工具、SDK、企业级 SaaS 产品。
模式 2:变更检测的内部写入过滤
用时间戳 Map 标记"自己的写入",在变更回调中消费标记来过滤回声。关键设计:标记消费后删除(一次性),有时间窗口约束(5 秒),防止过期标记误伤真正的外部变更。
适用场景:任何 "监听文件变更但需要忽略自身写入" 的场景 —— 配置热重载、文件同步工具、IDE 插件。
模式 3:Fail-Open 降级策略
远程配置获取失败时,优先使用本地文件缓存的旧版本,而不是阻塞启动或完全放弃策略。这是 "cache-first + eventual consistency" 模式在配置管理中的应用。
适用场景:任何依赖远程服务的配置加载 —— feature flag 系统、A/B 测试配置、远程策略引擎。
下一章预告
第 4 章:配置迁移即代码 — 把每一次破坏性配置改动写成一段小函数
我们将剖析 migrations/ 目录下 11 个迁移文件与 main.tsx 的 runMigrations() 调度,看 Claude Code 如何把「产品在用户身后偷偷换零件」这件事写成一组幂等、可独立 review、由版本号守护串行执行的小函数。
全部内容请关注 https://github.com/luyao618/Claude-Code-Source-Study (求一颗免费的小星星)