# 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 管理员强制(最高,压顶)
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: "管理员强制(最高)" },
];
}
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;
}
}
}
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 };
}
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 → 禁令解不掉
2
3
4
5
6
7
8
9
# provenance:多层冲突时的排障神器
合并结果不只给最终值,还记录每个键最终来自哪一层:
export interface MergeResult {
merged: LayeredSettings;
provenance: Record<string, string>; // 顶层键 → 最终来自哪一层
activeLayers: { name; desc; path }[]; // 实际存在的层
}
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)。