第二十八章:配置治理为什么要拆成几层
拆企业治理、只读策略、用户漫游和远端受管配置这几条并行配置面。
1. 为什么这一块必须单独拆
Claude Code 里和“配置治理”有关的代码,不是一套配置文件读写这么简单。
至少有三条完全不同的线同时存在:
- Settings Cascade 本地和远端配置怎么合并,最后变成一份有效 settings。
- Managed / Policy 企业或管理员怎么把只读策略压进运行时,影响权限、MCP、hooks、插件、登录、远端能力。
- Settings Sync 用户自己的设置和 memory 怎么在不同环境之间漫游。
这三条线表面上都像“settings”,但设计目标完全不一样:
policySettings是 管理员注入policyLimits是 服务端功能闸门settingsSync是 用户个人漫游
如果不把它们拆开,后面做 C# 版时非常容易抄歪:
- 把用户同步数据当成企业策略
- 把组织级 feature gate 塞进本地 settings 合并树
- 把 managed settings 误做成可编辑 source
- 把远端策略加载做成启动期阻塞点
相关源码锚点:
src/utils/settings/settings.tssrc/utils/settings/types.tssrc/utils/settings/constants.tssrc/utils/settings/changeDetector.tssrc/utils/settings/applySettingsChange.tssrc/utils/settings/mdm/settings.tssrc/utils/managedEnv.tssrc/utils/permissions/permissionsLoader.tssrc/services/remoteManagedSettings/*src/services/policyLimits/*src/services/settingsSync/*src/main.tsxsrc/entrypoints/init.ts
2. 先说结论
我对这一块的判断是:
Claude Code 的配置治理不是一条 settings 链,而是四层叠起来的运行时治理面。
- 本地设置层
user / project / local / flag / plugin base - 受管策略层
policySettings只读、最高信任、能影响多个子系统 - 服务端功能闸门层
policyLimits不进 settings 树,直接决定某些能力能不能开 - 用户漫游层
settingsSync把用户自己的 settings / memory 在环境间搬来搬去
这里面最值钱的设计,不是“配置从哪里读”,而是下面这些边界:
policySettings和policyLimits是两种不同治理面。- managed settings 是“只读策略注入”,不是普通 source。
- settings sync 是“个人状态同步”,不是企业管理。
- 企业策略加载默认 fail-open,但某些高风险点会单独加防线。
- 热更新不是 watcher 直接改内存,而是统一走 cache reset + listener fan-out。
3. 总体结构图
flowchart TD
A["本地文件 / 进程参数 / 插件"] --> B["settings.ts<br/>本地 settings 合并"]
C["remoteManagedSettings API"] --> D["policySettings"]
E["MDM / HKLM / plist / managed-settings.json"] --> D
B --> F["effective settings"]
D --> F
G["policyLimits API"] --> H["isPolicyAllowed()<br/>功能闸门"]
I["settingsSync API"] --> J["用户文件漫游<br/>settings.local.json / CLAUDE.md"]
J --> B
K["changeDetector + notifyChange"] --> L["applySettingsChange"]
L --> M["AppState / PermissionContext / Hooks / Env"]
F --> M
H --> M
这张图最关键的一点是:
Claude Code 没把所有治理都塞进一份 settings.json。
它明确分成了:
- 合并型配置
- 只读型策略
- 服务端布尔闸门
- 用户数据同步
4. 本地 Settings Cascade:这是配置树,不是策略树
源码锚点:
src/utils/settings/constants.tssrc/utils/settings/settings.tssrc/utils/settings/settingsCache.ts
最基础的设置来源顺序在 SETTING_SOURCES 里定义得很清楚:
userSettingsprojectSettingslocalSettingsflagSettingspolicySettings
还有一个经常被忽略的底座:
pluginSettingsBase
它是最低优先级 base layer,后面的文件型 source 会盖上去。
4.1 普通 source 用“深合并”,policy source 不是
普通 settings source 的合并方式是:
- 对象走深合并
- 数组走“拼接 + 去重”
这是 settingsMergeCustomizer() 干的。
但 policySettings 很特殊,它不是“多个政策来源字段级合并”,而是:
先选一个赢家,再把赢家整份并进总 settings。
这就是文档里一直写的:
first source wins
5. policySettings 的真正语义:受管策略注入层
源码锚点:
src/utils/settings/settings.tssrc/utils/settings/mdm/settings.tssrc/utils/settings/managedPath.tssrc/services/remoteManagedSettings/syncCacheState.ts
policySettings 不是一个普通文件来源,而是一个“聚合后的受管策略 source”。
它内部又有自己的优先级:
- remote managed settings
- MDM / HKLM / macOS plist
managed-settings.json+managed-settings.d/*.json- HKCU
注意这里最重要的点:
这四层不是字段级 merge,而是整份 source 选最高优先级赢家。
也就是说:
- 一旦 remote managed settings 有内容
- 本地 MDM / 文件 / HKCU 全部不再参与
policySettings的最终值
5.1 但文件型 managed settings 自己内部又会 merge
这个地方很容易看错。
policySettings 整体是 first-source-wins,但文件型 managed settings 内部又有一层自己的 merge 规则:
- 先读
managed-settings.json - 再读
managed-settings.d/*.json - drop-in 按文件名排序
- 后面的 drop-in 盖前面的
也就是说它同时用了两种模型:
- 跨 managed 来源 first-source-wins
- 文件型 managed 来源内部 base + drop-ins merge
这个设计很成熟,因为它同时满足了两种诉求:
- 企业可以定义“哪一类来源最可信”
- 同一台机器上的管理员团队又能拆成多个独立 policy fragment
5.2 policySettings 是只读 source
settings.ts 里 updateSettingsForSource() 明确把 policySettings 和 flagSettings 排除在可编辑 source 之外。
这意味着:
- 用户可以改 user/project/local
- 管理策略只能来自外部治理面
- 运行时不会把 managed settings 当成本地可写配置口
这个边界很关键。
6. Remote Managed Settings:企业策略的远端注入层
源码锚点:
src/services/remoteManagedSettings/index.tssrc/services/remoteManagedSettings/syncCache.tssrc/services/remoteManagedSettings/syncCacheState.tssrc/services/remoteManagedSettings/securityCheck.tsx
这一层干的不是“同步用户设置”,而是:
把企业管理员在服务端配置的 managed settings 拉下来,投进 policySettings。
6.1 它有很强的 eligibility 和性能意识
remote managed settings 不是所有人都打 API。
源码里会先判断:
- 是否 first-party provider
- 是否 first-party base URL
- 是否是支持这套策略的订阅形态
- 是否是特殊 entrypoint,比如
local-agent
而且这套 eligibility 还专门拆成了 syncCache.ts / syncCacheState.ts,就是为了避开 settings.ts -> auth.ts -> settings.ts 这种启动环依赖。
这说明作者对启动图谱是非常敏感的。
6.2 它不是盲拉,而是 checksum + 缓存校验
remoteManagedSettings 会:
- 对本地缓存 settings 做稳定排序
- 算
sha256 - 用它做
If-None-Match
服务端回:
304继续用本地缓存204/404视为“当前没有 remote managed settings”200返回完整 settings
这个细节很值钱,因为它不是简单“本地有缓存就读缓存”,而是:
本地缓存 + 内容校验 + 幂等刷新
6.3 它默认 fail-open,但会吃 stale cache
如果网络失败:
- 有旧缓存,就继续用旧缓存
- 没缓存,就继续启动,但不带 remote managed settings
这就是一个很典型的企业客户端策略:
- 不让治理 API 把本地 CLI 整体拖死
- 但尽量维持上一次已知策略
6.4 它不是只在启动拉一次,还会后台轮询
remoteManagedSettings 会启动每小时一次的后台 poll。
如果内容变化了,就会:
- 更新 session cache / 持久化文件
- 触发
settingsChangeDetector.notifyChange('policySettings')
后面的 permission、hooks、AppState、env 等更新,就统一走这条热更新链路。
6.5 它对“危险策略变更”加了额外检查
这一块很值得学。
不是所有 remote managed settings 更新都会静默生效。
如果新 settings 里包含危险配置,并且和旧配置相比发生了危险变化,交互模式下会弹出一个阻塞对话框让用户确认。
也就是说:
remote managed settings 虽然是管理员推下来的,但 Claude Code 仍然会对高风险配置变更再加一层本地确认。
这说明它不是无脑“企业策略最大”,而是在危险面上加了用户可见防线。
7. policyLimits:服务端功能闸门,不进 settings 树
源码锚点:
src/services/policyLimits/index.tssrc/services/policyLimits/types.ts
这是这一块最容易和 managed settings 混淆的地方。
policyLimits 不是 settings。
它是另一条服务:
- 独立 endpoint
- 独立缓存文件
- 独立 polling
- 独立
isPolicyAllowed(policy)查询接口
返回的数据形态也不一样:
- 只返回被限制的 policy
- 缺席的 key 就表示 allowed
7.1 它管的是功能开关,不是参数配置
典型用法是:
allow_remote_sessionsallow_remote_controlallow_product_feedback
这些都不是“配置某个子系统的参数”,而是:
这个能力到底能不能开。
这就是为什么它不该并进 settings 树。
7.2 它的默认哲学也是 fail-open,但有例外
大多数情况下:
- 没拉到 policy limits
- 没缓存
- 不认识这个 policy key
都会按 allowed 处理。
但它也有一个非常有意思的例外:
- 在
essential-traffic-only模式下 - 某些策略会在 cache miss 时 fail-closed
当前代码里给出的例子是:
allow_product_feedback
这说明 Claude Code 的 fail-open 不是教条,而是:
默认 fail-open,碰到合规/高敏感流量再按点收紧。
8. settingsSync:这是用户漫游,不是企业策略
源码锚点:
src/services/settingsSync/index.tssrc/services/settingsSync/types.tssrc/cli/print.ts
这一块也特别容易被误读成“远端 settings”。
但它和 remote managed settings 完全不是一回事。
settingsSync 管的是:
用户自己的设置和 memory 文件,在不同 Claude Code 环境之间漫游。
8.1 它同步的是文件内容,不是结构化 settings patch
API 结构是个很简单的平面 entries: Record<string, string>。
同步键值长这样:
~/.claude/settings.json~/.claude/CLAUDE.mdprojects/<projectId>/.claude/settings.local.jsonprojects/<projectId>/CLAUDE.local.md
这说明它同步的是:
- 用户全局 settings
- 用户全局 memory
- 项目本地 settings
- 项目本地 memory
而不是完整的 source tree,也不是策略字段级 patch。
8.2 它非常明确地区分 upload 和 download 场景
Interactive CLI:
- 主要是 本地上传到远端
- 增量 diff,只传有变化的 entry
CCR / remote / headless:
- 主要是 从远端下载到本地
- 而且会尽量在插件安装前完成
这个设计很值钱,因为它没有假设所有宿主都该做双向同步。
8.3 它同步的是 user/local,不是 project/policy
这一点非常关键。
代码里同步的是:
userSettingslocalSettingsUsermemoryLocalmemory
没有把这些东西同步进去:
projectSettingspolicySettingsflagSettings
这背后的设计思路非常清楚:
- checked-in 的
projectSettings应该跟仓库走,不该跟账户漫游走 policySettings是管理员控制面,不是用户个人数据flagSettings是本次启动参数,不是持久状态
8.4 它会主动避开 watcher 回声
settingsSync 落本地文件时会先 markInternalWrite(...),然后写文件。
changeDetector 看到这个文件事件时会把它当内部写回声吞掉,避免形成:
- settings sync 写文件
- watcher 触发
- 又当外部变化处理
- 再次抖动
这个细节很工程化,也很值得在 C# 版照抄。
9. Claude Code 在这块做了哪些额外处理
如果只看功能名,这一层像“配置系统 + 同步系统”。
但源码里实际多做了不少成熟 runtime 才会做的事情。
9.1 启动顺序被仔细编排过
init.ts 里会先初始化 remote managed settings 和 policy limits 的 loading promise。
main.tsx preAction 再:
- 先等早起的 MDM / keychain 预取
- 再跑
init() - 再异步触发
loadRemoteManagedSettings() - 再异步触发
loadPolicyLimits() - 最后 fire-and-forget 做
settingsSyncupload
这说明它在把:
- 本地可同步拿到的策略
- 远端可晚到的策略
- 用户漫游
分成不同优先级执行。
9.2 env 应用有 trust 边界
managedEnv.ts 非常值得学。
在 trust 之前:
userSettingsflagSettingspolicySettings
这几个高信任来源的 env 可以完整应用。
但 projectSettings / localSettings:
- 只能先应用安全白名单 env
- 全量 env 要等 trust 之后
也就是说:
Claude Code 把“配置来源信任级别”直接做进了 env 应用管线。
9.3 变更传播是统一通道,不是各改各的
配置变化最终都走:
settingsChangeDetectorfanOut()resetSettingsCache()applySettingsChange()
applySettingsChange() 再统一刷新:
- merged settings
- permission rules
- hooks snapshot
- AppState
- effortLevel 等派生状态
这样不会出现每个子系统自己监听文件、自己做半套热更的碎片化局面。
9.4 headless 和 TUI 都有热更新
TUI 路径里有 React 树和 settings hook。
但 headless 模式没有,所以 print.ts 会显式订阅 settingsChangeDetector,再调用 applySettingsChange()。
这个点很值钱,因为它说明:
热更新不是 UI 特性,而是 runtime 特性。
10. 这套治理层到底能控制什么
只看 types.ts 里暴露出来的策略面,就已经很明显了。
10.1 权限和审批
allowManagedPermissionRulesOnlypermissions.*skipDangerousModePermissionPromptskipAutoPermissionPrompt
其中 allowManagedPermissionRulesOnly 甚至会直接让:
- 非 managed permission rule 失效
- “always allow” 选项隐藏
- 新 permission rule 不再允许落盘
10.2 Hooks 和执行面
allowManagedHooksOnlyallowedHttpHookUrlshttpHookAllowedEnvVars
10.3 MCP 和插件生态
allowedMcpServersdeniedMcpServersallowManagedMcpServersOnlystrictPluginOnlyCustomizationstrictKnownMarketplacesblockedMarketplaceschannelsEnabledallowedChannelPlugins
这里面我最喜欢的一个细节是:
allowManagedMcpServersOnly 只把 allowlist 收口到 managed settings,
但 denylist 仍然允许用户自己加。
换句话说:
- 管理员决定“哪些能被允许”
- 用户仍然保留“我自己不要这个”的否决权
这个边界拿捏得很好。
10.4 身份和组织边界
forceLoginMethodforceLoginOrgUUIDsshConfigsremote.defaultEnvironmentId
其中 forceLoginOrgUUID 还会在启动后主动验证当前 token 的 org。
而且这个验证是 fail-closed 的:
- 如果配了 required org
- 但当前拿不到 profile 来确认 org
那就直接判校验失败。
这说明 Claude Code 在“组织归属”这件事上是认真做了强约束的。
11. 对 C# 版最有价值的抽象
如果把这块翻成 C#,我建议不要只做一个 SettingsService。
更合适的拆法是:
11.1 配置合并层
ISettingsSourceSettingsCascadeManagedPolicySourceResolverSettingsCacheSettingsChangeBus
11.2 远端策略层
IRemoteManagedSettingsClientManagedSettingsEligibilityManagedSettingsCacheStoreManagedSettingsSecurityGuardManagedSettingsPoller
11.3 功能闸门层
IPolicyLimitsClientPolicyGateStorePolicyGateEvaluator
11.4 用户漫游层
IUserSettingsSyncClientSettingsSyncEntryMapperSettingsSyncUploaderSettingsSyncDownloader
11.5 应用层联动
SettingsRuntimeApplierPermissionRulesReloaderHookConfigReloaderManagedEnvironmentApplier
这里最值得照抄的原则是:
把“配置值”“管理员策略”“服务端功能开关”“用户漫游数据”拆成四个不同对象模型。
不要全塞进一个 Settings 类,不然后面一定会互相污染。
12. 最后一句判断
如果只把这块看成 settings 读写,Claude Code 在这部分并不惊艳。
但如果按 runtime 视角看,它其实已经搭出了一套很完整的治理分层:
- 本地配置怎么合并
- 管理策略怎么注入
- 服务端怎么硬关某些能力
- 用户自己的状态怎么跨环境漫游
- 热更新怎么统一传播
- trust 边界怎么卡住危险 env
对做 C# 版来说,这份源码最值钱的地方不是某个字段名,而是这个分层本身。
只要这层分得对,后面不管你宿主是 CLI、桌面端、IDE 插件,还是多会话服务,治理逻辑都不会塌。