# Step 61: 多层设置系统合并

一句话导读:前面每步的配置都各自为政(规则写死 DEFAULT_DENY、沙箱写死构造函数)。本步给它们一个正经的家——五层配置按优先级深合并,而且不同字段用不同合并语义:标量覆盖、规则累加、安全字段压顶,让「管理员的禁令下层解不掉」天然成立。


# 一、这一步做了什么(What)

新增 utils/settings/layers.ts,核心是 loadLayered():从五个来源读配置,按优先级从低到高深合并成一份,并记录每个键的出处(provenance)。

五层,优先级从低到高:

user       ~/.claude/settings.json           个人全局(对所有项目生效)
project    <cwd>/.claude/settings.json        团队共享(提交进仓库)
local      <cwd>/.claude/settings.local.json  个人本地(gitignore,不提交)
cli        --set 命令行传入                    本次启动临时覆盖
enterprise <cwd>/managed-settings.json         管理员强制(最高,压顶)
1
2
3
4
5

关键不在「后层覆盖前层」这么简单——而在三种字段三种合并语义:标量覆盖、permissions.allow/deny 累加、sandbox 字段覆盖。合并结果还带 provenance(每个键来自哪层)和 activeLayers(实际存在的层)。


# 二、面试官视角:为什么要做?(Why)

面试题:配置合并不就是几个对象 Object.assign 后者盖前者吗?为什么值得单独一步,还要区分字段类型?

因为一刀切的深合并会丢掉配置系统里最关键的一条语义:管理员的禁令必须不可解除。

想象只用「后层覆盖前层」处理所有字段。管理员在 enterprise 层写 deny: ["Bash(curl:*)"],用户在 local 层写 deny: ["Read(*.log)"]。若 permissions 整体被后层覆盖,作为最高层的 enterprise 会把用户那条合规 deny 整个抹掉——总有一方的规则被无辜清空。

正确的语义是:规则应该累加——每一层都能往上叠 deny,谁也别抹谁。而累加 + step57 的 deny > allow 一结合,就自动得到那条黄金性质:enterprise 追加的 deny,任何下层都无法解除(下层能加 allow,但 deny 优先,allow 盖不过 deny)。

字段类型 该用的语义 若一刀切覆盖会怎样
标量(model/permMode) 覆盖(单值,高优先级说了算) ✔ 正好合适
permissions.allow/deny 累加 ✘ 某一层的规则被整体抹掉
sandbox 覆盖(方便 enterprise 压顶开启) ✔ 合适

一句话:配置合并不是数据操作,是策略表达——字段的语义决定合并的语义,用错语义就丢掉关键策略。


# 三、原理:它是怎么工作的(How)

# 层的定义:一张「名字 → 路径 → 描述」表,按优先级排列

export function layerSpecs(cwd = process.cwd()): LayerSpec[] {
  return [
    { name: "user",       path: join(homedir(), ".claude", "settings.json"),   desc: "个人全局" },
    { name: "project",    path: join(cwd, ".claude", "settings.json"),          desc: "团队共享(提交)" },
    { name: "local",      path: join(cwd, ".claude", "settings.local.json"),    desc: "个人本地(gitignore)" },
    // cli 层由启动参数注入,不是文件
    { name: "enterprise", path: join(cwd, "managed-settings.json"),             desc: "管理员强制(最高)" },
  ];
}
1
2
3
4
5
6
7
8
9

数组顺序就是优先级从低到高,合并时按顺序叠加、后者优先。

# 合并核心:mergeOne 按字段类型分派语义

这是整步的题眼——同一个循环里,三种字段走三条路:

function mergeOne(acc, prov, layer, layerName) {
  for (const [k, v] of Object.entries(layer)) {
    if (v === undefined) continue;
    if (k.startsWith("_")) continue;              // 跳过 _comment 注释键
    if (k === "permissions") {
      // 规则【累加】——每层都能往上叠
      acc.permissions ??= { allow: [], deny: [] };
      acc.permissions.allow = [...(acc.permissions.allow ?? []), ...(v.allow ?? [])];
      acc.permissions.deny  = [...(acc.permissions.deny  ?? []), ...(v.deny  ?? [])];
      if (v.allow?.length || v.deny?.length)
        prov["permissions"] = (prov["permissions"] ? prov["permissions"] + "+" : "") + layerName;
    } else if (k === "sandbox") {
      acc.sandbox = { ...(acc.sandbox ?? {}), ...v }; // 字段覆盖
      prov["sandbox"] = layerName;
    } else {
      acc[k] = v;                                    // 标量覆盖
      prov[k] = layerName;
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

注意 permissions 的 provenance 是累进的——project+enterprise 而非单一层,因为它的值是多层叠出来的,出处也得记全。

# cli 层的插入位置:在 local 之上、enterprise 之下

cli 不是文件,loadLayered 在遍历到 enterprise 之前手动插它,正好落在优先级 local < cli < enterprise 的位置:

export function loadLayered(cwd = process.cwd(), cliOverride?: LayeredSettings): MergeResult {
  const merged = {}, provenance = {}, activeLayers = [];
  for (const spec of layerSpecs(cwd)) {
    if (spec.name === "enterprise" && cliOverride) {  // enterprise 前插 cli
      mergeOne(merged, provenance, cliOverride, "cli");
      activeLayers.push({ name: "cli", desc: "命令行 --set", path: "(启动参数)" });
    }
    const data = readLayer(spec.path);
    if (!data) continue;                               // 该层文件不存在 → 跳过
    activeLayers.push({ name: spec.name, desc: spec.desc, path: spec.path });
    mergeOne(merged, provenance, data, spec.name);
  }
  return { merged, provenance, activeLayers };
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 数据流:一次 deny 如何向上收敛

user       permissions.deny: []
project    permissions.deny: ["Read(*.log)"]        ← 团队规则
local      permissions.deny: []
enterprise permissions.deny: ["Bash(curl:*)"]       ← 管理员强制
   ↓ mergeOne 累加
merged.permissions.deny = ["Read(*.log)", "Bash(curl:*)"]
provenance["permissions"] = "project+enterprise"
   ↓ 交给 step57 PermissionRules(deny 优先)
用户即使在 local 加 allow Bash(curl:*),也盖不过 enterprise 的 deny → 禁令解不掉
1
2
3
4
5
6
7
8
9

# provenance:多层冲突时的排障神器

合并结果不只给最终值,还记录每个键最终来自哪一层:

export interface MergeResult {
  merged: LayeredSettings;
  provenance: Record<string, string>;   // 顶层键 → 最终来自哪一层
  activeLayers: { name; desc; path }[]; // 实际存在的层
}
1
2
3
4
5

/settings 能展示「model 来自 local、deny 来自 project+enterprise」——三个文件都设了 model,到底哪个生效?看 provenance 一眼就知道,不用逐层翻文件。


# 四、深入追问(面试常见 follow-up)

Q:为什么 enterprise 层放最后(最高优先级),而不是最先?把它放最先当默认值不好吗? A:语义完全相反。放最先当默认值,意味着任何下层都能覆盖它——那它就不是「强制」而是「建议」了。管理员要的是压顶、不可覆盖:标量字段它最后写、盖掉一切;deny 规则它累加进来、配合 deny 优先谁也解不掉。「管理员强制」这个语义要求它必须在合并链的最末端。真实版的 managed/policy 层同理最高优先级。

Q:cli 层为什么插在 local 和 enterprise 之间,而不是干脆最高? A:因为命令行 --set 是「用户本次启动的临时意图」,它该盖过用户自己的持久配置(user/project/local),这很自然;但它不该越过管理员的强制策略——否则用户随手一个 --set permMode=accept 就绕过了 enterprise 的安全设定。所以 cli 高于 local、低于 enterprise,恰好卡在「比我自己的配置强、比管理员的强制弱」的位置。

Q:如果某层文件是坏 JSON,整个加载会崩吗? A:不会。readLayer 里 try { JSON.parse } catch { return null },坏文件当作「该层不表态」跳过(if (!data) continue),其余层照常合并。这是配置系统该有的健壮性——一个人本地的 settings.local.json 手滑写错,不能让整个程序起不来。

Q:permissions 累加会不会产生一堆重复/矛盾规则?比如 user 和 project 都 deny 了同一条。 A:会有重复,但无害。step57 的 evaluate 是 find——只要有任一条 deny 命中就返回 deny,重复的 deny 不改变结果。矛盾(一层 allow、一层 deny 同一目标)则由 deny 优先规则天然裁决,不需要去重。累加的代价只是规则数组长一点,换来的是「每层的意图都被保留、谁也不抹谁」,这笔交易划算。真要展示时靠 provenance 说清来源即可。


# 五、踩坑 / 设计权衡

  • sandbox 选覆盖而非累加:沙箱是「一组开关+根目录」,累加没意义(两个 writableRoots 数组拼一起反而放宽了边界)。覆盖让 enterprise 能干净地强制 enabled: true 压顶。字段语义决定合并语义在这里再次体现。
  • 跳过 _ 前缀键:允许配置文件里写 _comment 之类注释键而不污染合并结果——JSON 没有注释,这是常见变通。
  • 权衡:provenance 只记顶层键。没有深到 permissions.deny[3] 来自哪层 那么细,因为对排障来说「这组规则由 project+enterprise 叠出来」已经够用,再细就过度设计了。

# 六、与真实源码的对照

我们的实现 Claude Code 源码
loadLayered() 五层深合并 utils/settings/ 的多源设置合并
user/project/local/cli/enterprise 真实版的 user/project/local/managed(policy) 等层
permissions 累加 + deny 压顶 真实版 allow/deny 跨源合并、managed 优先
mergeOne 按字段分派语义 真实版对不同键有不同 merge 策略
provenance 出处记录 真实版按 source 追踪每条设置来源

# 七、一句话总结

多层设置 = 用「不同字段不同合并语义」把策略写进配置系统:五层按优先级深合并,标量覆盖、规则累加、安全字段压顶——规则累加叠加 step57 的 deny 优先,天然得到「enterprise 禁令下层不可解除」;再用 provenance 记住每个值的出处,让多层配置 debug 得动。合并不是数据操作,是策略表达。

# 下一节预告

至此安全与底层(权限 → 分类器 → 沙箱 → 分层设置)基本成形。接下来是全书最硬核的工程段——Phase 7 手写终端 UI(自己实现一个 Ink:终端 diff 渲染 + React reconciler)。