学习工作台文章阅读
工作台/文章/第 3 章:配置体系与企业 MDM — 多层配置的合并之道
返回文章列表
2026年7月11日15 分钟

第 3 章配置体系与企业 MDM多层配置的合并之道

墨圆
墨圆团队发布于 2026年7月11日

来源与授权

本文来自 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 面对的现实远比这复杂:

  1. 个人偏好 —— 用户想全局设置自己偏好的模型、权限规则
  2. 团队共享 —— 项目组要把 MCP 服务器、Hook 脚本提交到 Git 仓库共享
  3. 本地覆盖 —— 个人本地调试需要覆盖项目设置,但不能提交到 Git
  4. 企业管控 —— 安全团队需要强制启用沙箱、禁用危险权限,且用户不能覆盖
  5. 远程策略 —— 企业管理员通过 API 远程下发配置,无需触碰每台机器
  6. 平台差异 —— macOS 用 plist + MDM、Windows 用注册表、Linux 用文件

这些需求层层叠加,任何单一配置文件都无法满足。Claude Code 的 Settings 系统通过多层配置源 + 优先级合并 + 变更检测热更新的架构,优雅地解决了这个问题。


一、配置源全景:5 + 1 层优先级

Settings 系统的核心设计是一条明确的优先级链。配置从多个来源读取,按照优先级从低到高逐层合并,高优先级覆盖低优先级:

流程图
图表进入视野后渲染

正式的配置源类型定义在 utils/settings/constants.ts 中,只有 5 个 SettingSource

TYPESCRIPT
// 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 配置等
1User~/.claude/settings.json个人全局偏好(模型、权限)
2Project$PROJECT/.claude/settings.json团队共享配置(Hook、MCP)
3Local$PROJECT/.claude/settings.local.json本地覆盖(自动加入 .gitignore
4Flag--settings CLI 参数SDK / IDE 注入的临时配置
5(最高)Policy多种来源(见下文)企业安全管控

其中 Policy Settings 最为特殊 —— 它本身就是一个内部有优先级的多源系统,采用 first-source-wins 策略。

1.2 Policy Settings 的内部优先级

Policy Settings 不像其他层级那样简单地从一个文件读取。它有自己的4 层子优先级链,使用 "first source wins"(第一个有内容的来源胜出)策略:

TYPESCRIPT
// 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-22SETTING_SOURCES 只有 5 个),但同样影响最终运行时配置。第八至第十一节 回到合并体系本身,覆盖变更检测、安全设计、MDM 轮询与可迁移模式。


二、核心合并算法:loadSettingsFromDisk()

整个 Settings 系统最核心的函数是 loadSettingsFromDisk(),它负责按优先级顺序读取并合并所有配置源:

TYPESCRIPT
// 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

TYPESCRIPT
// 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 对象,覆盖标量)
}

数组是拼接去重(而非替换),这个决策对权限系统至关重要 —— 多层的 allowdeny 规则会合并在一起,而不是高层完全覆盖低层。


三、配置文件解析与验证

3.1 Zod Schema 验证

每个配置文件在加载后,都要通过 SettingsSchema 进行 Zod 运行时验证:

TYPESCRIPT
// 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 章)。它的设计严格遵循向后兼容原则:

TYPESCRIPT
// utils/settings/types.ts:210-241(注释节选)
// ✅ 允许的变更:
// - 添加新的可选字段(始终使用 .optional())
// - 添加新的 enum 值(保留现有值)
// - 使验证更宽松

// ❌ 应避免的破坏性变更:
// - 删除字段(改为标记 deprecated)
// - 删除 enum 值
// - 将可选字段改为必需
// - 使类型更严格

Schema 覆盖了丰富的配置项 —— 从基础的 modelenv 到复杂的 permissions(权限规则)、hooks(生命周期钩子)、sandbox(沙箱配置)、allowedMcpServers(MCP 服务器白名单)等超过 40 个配置字段。每个字段都有 .describe() 注释,这些注释会被导出为 JSON Schema 供编辑器提供自动补全。

一个特别有趣的容错设计是 strictPluginOnlyCustomization 字段:

TYPESCRIPT
// 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:

TYPESCRIPT
// 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.tsmain.tsx 模块求值阶段就被调用(第 2 章提到的侧效果前置),此时不能引入任何重量级模块。MDM 读取分为两个阶段

阶段一:预启动子进程main.tsx:3-4,模块求值期)

TYPESCRIPT
// 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:914preAction hook 中)

TYPESCRIPT
// 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 跨平台读取策略

TYPESCRIPT
// 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):

TYPESCRIPT
// 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/ 目录,允许多个独立的策略片段:

TYPESCRIPT
// 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.json20-security.json
  • 不需要协调对同一个文件的编辑

五、远程策略:Remote Managed Settings

Policy Settings 的最高优先级来源是 Remote API —— 企业管理员通过 Anthropic API 下发配置,无需物理访问每台设备。

5.1 资格检查与 Fail-Open 设计

isRemoteManagedSettingsEligible() 决定当前用户是否应该向 API 查询远程策略。它的判断逻辑分为前置排除三路放行两个阶段:

TYPESCRIPT
// 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 优化

远程设置使用文件缓存 + 内存缓存的双层机制:

  1. 文件缓存~/.claude/remote-settings.json,跨 session 持久化
  2. 内存缓存:session 级别的 sessionCache,避免重复读文件
  3. HTTP ETag:通过 If-None-Match 头和 SHA-256 checksum 实现增量更新,304 响应表示无变化
TYPESCRIPT
// 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 之前,系统会执行一层安全检查:

TYPESCRIPT
// 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 的策略变更:

TYPESCRIPT
// 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 同源但要求更严

TYPESCRIPT
// 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 的统一入口。它的默认值在两种情况下截然不同:

TYPESCRIPT
// 简化自 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

TYPESCRIPT
// 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 两个方向、两组门禁

TYPESCRIPT
// 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 之美

TYPESCRIPT
// 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 完整性校验

TYPESCRIPT
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 重载,造成无意义的抖动。

TYPESCRIPT
// applyRemoteEntriesToLocal 简化伪代码
markInternalWrite(targetPath)           // 5 秒窗口内的本次写入将被吞掉
await writeFile(targetPath, content)
resetSettingsCache()                    // 主动失效,让下一次读拿到新值
clearMemoryFileCaches()                 // CLAUDE.md 类内存文件另有缓存

markInternalWrite / consumeInternalWrite 这对原语在 第 6.2 节 详细介绍,这里只点出 SettingsSync 是它的第二位重要消费者 —— 第一位是 UI 触发的权限规则保存。两个完全不同的写入入口共享同一个"我自己写的,别回响"机制,是一处值得称道的 API 复用。

5.6.5 一个 /reload-plugins 触发的快速重下

TYPESCRIPT
// 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 库监听配置文件变更:

TYPESCRIPT
// 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 来过滤:

TYPESCRIPT
// 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 机制:

TYPESCRIPT
// 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() 统一分发:

TYPESCRIPT
// utils/settings/changeDetector.ts:437-439
function fanOut(source: SettingSource): void {
  resetSettingsCache()     // 先刷新缓存
  settingsChanged.emit(source)  // 再通知所有监听者
}

源码注释特别提到了一个性能教训:之前缓存重置分散在每个监听者中,导致 N 个监听者 = N 次磁盘重读。集中到 fanOut() 后,只有第一个监听者触发一次磁盘读取,后续监听者命中缓存。

6.5 热更新应用

变更通知最终通过 applySettingsChange() 作用到 AppState:

TYPESCRIPT
// 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,只信任用户自己的设置:

TYPESCRIPT
// 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

TYPESCRIPT
// utils/settings/settings.ts:508-514
if (source === 'localSettings') {
  void addFileGlobRuleToGitignore(
    getRelativeSettingsFilePathForSource('localSettings'),
    getOriginalCwd(),
  )
}

这防止了包含本地调试配置(可能含有 API Key 路径等敏感信息)的文件被意外提交。

7.3 可编辑源的类型约束

通过 TypeScript 类型系统限制哪些配置源可以被程序修改:

TYPESCRIPT
// utils/settings/constants.ts:182-185
export type EditableSettingSource = Exclude<
  SettingSource,
  'policySettings' | 'flagSettings'
>

policySettingsflagSettings 在类型层面就被排除在可编辑范围之外。updateSettingsForSource() 函数在运行时也会检查这一约束。


十、MDM 轮询:注册表和 plist 的变更检测

文件系统的变更可以用 chokidar 监听,但 macOS plist 和 Windows 注册表的变更无法通过文件系统事件捕获。系统通过 30 分钟间隔的轮询来解决:

TYPESCRIPT
// 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 (求一颗免费的小星星)