Claude Code 源码分析第 16 课 · 第 05

设置与配置

5 层级联、SettingsSchema、更改检测、MDM 和远程管理设置

1.概述:为什么要级联?

Claude Code 在完全不同的环境中运行:独立开发人员的笔记本电脑、共享团队存储库、CI 代理以及由 IT 部门管理的企业队列。 单个平面配置文件无法同时满足所有这些需求。

解决方案是一个 优先级联:五个可独立写入的源按优先级从低到高的顺序合并。 每个层都可以添加或覆盖其下面的任何内容。 结果是单个合并 SettingsJson object 代码库的其余部分读取。

关键不变量: 级联在运行时是只读的——在会话期间,任何源都不能修改另一个源的文件。 仅有的 userSettings, projectSettings, 和 localSettings 可通过写入 updateSettingsForSource().

2. 5层级联

真理的规范来源是 constants.ts,它定义了 SETTING_SOURCES 数组按合并顺序:

export const SETTING_SOURCES = [
  'userSettings',    // lowest priority
  'projectSettings',
  'localSettings',
  'flagSettings',
  'policySettings',  // highest priority
] as const

后面的条目覆盖前面的条目。 合并发生在 loadSettingsFromDisk() via lodash mergeWith 使用自定义阵列重复数据删除策略。

User
~/.claude/settings.json

全球开发者偏好。 用户可写。 在所有项目中共享。

Project
.claude/settings.json

致力于回购。 与整个团队共享。 版本控制。

Local
.claude/settings.local.json

自动忽略。 每个项目的个人覆盖不会在回购中发生。

Flag
--设置<路径>

CLI 标志。 合并文件 and 任何内联 SDK 设置。 未进行文件监视。

Policy
managed-settings.json

实施 IT/MDM。 第一个来源在四个子来源中胜出。

优先级堆栈 — 从低到高

1
userSettings ~/.claude/settings.json (或 cowork_settings.json) Editable
2
projectSettings .claude/settings.json 可编辑·版本控制
3
localSettings .claude/settings.local.json 可编辑·自动忽略
4
flagSettings --settings <文件> + 内联 SDK 对象 只读 · 不进行文件监视
5
policySettings 远程 → HKLM/plist → 托管设置.json → HKCU 只读·先源致胜
策略设置很特殊。 与其他四层(全部合并在一起)不同,策略层使用 first-source-wins 在内部 — 其四个子源(远程、MDM、文件、HKCU)中的一个获胜,并且只有该子源的设置应用于策略层。 看 第 7 课 条.

级联流程图

flowchart TD A["Plugin base (lowest)"] --> B[userSettings\n~/.claude/settings.json] B --> C[projectSettings\n.claude/settings.json] C --> D[localSettings\n.claude/settings.local.json] D --> E[flagSettings\n--settings flag + inline] E --> F[policySettings\nfirst-source-wins sub-cascade] F --> G["Merged SettingsJson\n(getInitialSettings)"] subgraph PolicySubCascade["policySettings — first-source-wins"] P1["① Remote API"] -->|"wins if non-empty"| P2["② MDM\n(HKLM / macOS plist)"] P2 -->|"wins if non-empty"| P3["③ managed-settings.json\n+ drop-in .d/ files"] P3 -->|"wins if non-empty"| P4["④ HKCU registry\n(Windows only)"] end style A fill:#0d1117,stroke:#2a2a4a,color:#847c72 style G fill:#0f3460,stroke:#2a2a4a,color:#e0e0f0 style PolicySubCascade fill:#200a0a,stroke:#f87171
来源:loadSettingsFromDisk() — 合并引擎
// settings.ts — simplified for clarity
function loadSettingsFromDisk(): SettingsWithErrors {
  let mergedSettings: SettingsJson = {}

  // Plugin settings are the lowest-priority base layer
  const pluginSettings = getPluginSettingsBase()
  if (pluginSettings) {
    mergedSettings = mergeWith(mergedSettings, pluginSettings, settingsMergeCustomizer)
  }

  for (const source of getEnabledSettingSources()) {
    if (source === 'policySettings') {
      // First-source-wins among: remote → MDM → file → HKCU
      const policySettings = resolvePolicySettings()
      if (policySettings) {
        mergedSettings = mergeWith(mergedSettings, policySettings, settingsMergeCustomizer)
      }
      continue
    }

    const { settings } = parseSettingsFile(getSettingsFilePathForSource(source))
    if (settings) {
      mergedSettings = mergeWith(mergedSettings, settings, settingsMergeCustomizer)
    }
  }

  return { settings: mergedSettings, errors: allErrors }
}

3.SettingsSchema:可以配置什么

SettingsSchema in types.ts 是一个 Zod v4 架构,定义了每个有效的密钥。 它用于验证 every 合并前设置源。 无效文件表面 ValidationError[] 但不要使 Claude Code 崩溃 — 其余的有效配置仍会加载。

顶级键(选定)

KeyTypePurpose
permissionsobject允许/拒绝/询问数组、defaultMode、disableBypassPermissionsMode
hooksobjectPreToolUse、PostToolUse、通知、SessionStart、Stop 等
env记录<字符串,字符串>注入每个会话的环境变量
modelstring覆盖默认的克劳德模型
availableModelsstring[]可选型号的企业白名单
allowedMcpServersobject[]企业 MCP 服务器白名单(按名称、命令或 URL)
deniedMcpServersobject[]企业 MCP 服务器拒绝列表(拒绝列表击败允许列表)
apiKeyHelperstring发出身份验证值的脚本路径
cleanupPeriodDaysnumber转录本保留天数(0 = 禁用持久性)
strictPluginOnlyCustomization布尔 | 细绳[]将技能/代理/挂钩/mcp 锁定为仅插件交付
allowManagedHooksOnlyboolean在策略中设置时,仅运行托管挂钩
allowManagedPermissionRulesOnlyboolean在策略中设置时,仅适用托管权限规则
attributionobject自定义提交/PR 归属文本
sandboxobject沙箱配置(启用、网络、文件系统……)
worktreeobject--worktree 标志的 symlinkDirectories、sparsePaths
向后兼容合约

代码库注释声明了严格的规则,以避免破坏用户现有的配置文件:

  • Allowed: 添加新的可选字段、新的枚举值,使验证更加宽松
  • Forbidden: 删除字段、删除枚举值、将可选字段设为必填、重命名键
  • The .passthrough() 权限对象上保留文件中的未知字段
  • filterInvalidPermissionRules() 废除个别不良规则 before Zod 验证,因此一条错误规则不会使整个设置文件无效

设置文件示例

{
  "$schema": "https://json.schemastore.org/claude-code-settings.json",

  "model": "claude-opus-4-5",

  "env": {
    "NODE_ENV": "development",
    "LOG_LEVEL": "debug"
  },

  "permissions": {
    "defaultMode": "acceptEdits",
    "allow": ["Bash(git *)", "Read(**)"],
    "deny":  ["Bash(rm -rf *)"]
  },

  "hooks": {
    "PreToolUse": [{
      "matcher": "Bash",
      "hooks": [{ "type": "command", "command": "echo pre-bash" }]
    }]
  },

  "cleanupPeriodDays": 7
}

4. 合并语义

Claude Code 使用 lodash mergeWith 在整个设置管道中,使用自定义 settingsMergeCustomizer:

// settings.ts
export function settingsMergeCustomizer(
  objValue: unknown,
  srcValue: unknown,
): unknown {
  if (Array.isArray(objValue) && Array.isArray(srcValue)) {
    return uniq([...objValue, ...srcValue])  // deduplicated concatenation
  }
  return undefined  // let lodash handle objects / scalars
}

规则一览

  • Objects ——深度融合。 较高层中的密钥会覆盖较低层中的相同密钥。
  • Arrays - 连接和重复数据删除。 所有启用的来源的权限规则都会累积。
  • Scalars — 较高层获胜(简单覆盖)。
  • 删除通过 updateSettingsForSource() - 经过 undefined 作为一个值。 定制器检测到 srcValue === undefined 并打电话 delete object[key].
flowchart LR U["userSettings\n{model: 'opus', env: {A:'1'}}"] P["projectSettings\n{env: {B:'2'}, permissions: {allow: ['Read']}}"] L["localSettings\n{permissions: {allow: ['Bash(git *)']}}"] M["Merged result\n{model:'opus', env:{A:'1',B:'2'},\npermissions:{allow:['Read','Bash(git *)']}}"] U --> M P --> M L --> M style M fill:#0f3460,stroke:#2a2a4a,color:#e0e0f0
数组合并与数组替换: 将源加载到级联时使用合并定制器。 什么时候 writing 设置通过 updateSettingsForSource(),数组是 replaced (不合并)——调用者负责在传入之前计算所需的最终数组状态。

5.三层缓存

Claude Code 在关键启动路径上同步读取设置文件。 三个缓存层可防止冗余磁盘 I/O 和 Zod 重新解析:

flowchart TD A["getInitialSettings()"] --> B{"sessionSettingsCache\nhit?"} B -- yes --> Z["Return cached SettingsJson"] B -- no --> C["loadSettingsFromDisk()"] C --> D{"perSourceCache\nhit per source?"} D -- yes --> E["Use cached per-source settings"] D -- no --> F{"parseFileCache\nhit per path?"} F -- yes --> G["Use cached parsed JSON"] F -- no --> H["readFileSync + Zod parse"] H --> F E --> I["mergeWith all sources"] G --> I I --> B style Z fill:#0a2a1e,stroke:#34d399,color:#34d399
Cache键入者Holds无效于
sessionSettingsCache (singleton) 完全合并 SettingsWithErrors resetSettingsCache()
perSourceCache SettingSource Per-source SettingsJson | null resetSettingsCache()
parseFileCache 文件路径字符串 Parsed { settings, errors } resetSettingsCache()

所有三个缓存均由原子方式清除 resetSettingsCache()。 这一单一调用是变更检测器中的“扇出”步骤:一次缓存重置意味着一次磁盘重新加载,无论订阅了多少个 React 组件或钩子。

Clone-on-read: parseSettingsFile() 始终在从文件缓存返回之前进行克隆。 这可以防止调用者无意中改变缓存条目(例如, mergeWith 改变它的第一个参数)。

6. 变化检测

当 Claude Code 运行时,设置文件可能会更改。 这 settingsChangeDetector (in changeDetector.ts) 监视文件 chokidar 并每 30 分钟轮询一次 MDM 注册表/plist。

文件观看架构

sequenceDiagram participant FS as Filesystem (chokidar) participant CD as changeDetector participant Hook as ConfigChange hook participant Cache as settingsCache participant Subs as Subscribers FS->>CD: change / unlink / add event CD->>CD: consumeInternalWrite? (skip if own write) CD->>Hook: executeConfigChangeHooks(source, path) Hook-->>CD: results (blocked or not) CD->>Cache: resetSettingsCache() CD->>Subs: settingsChanged.emit(source) Subs->>Cache: getSettingsWithErrors() [cache miss → disk reload]

关键常数

const FILE_STABILITY_THRESHOLD_MS = 1000   // wait for write to stabilize
const FILE_STABILITY_POLL_INTERVAL_MS = 500  // chokidar awaitWriteFinish
const INTERNAL_WRITE_WINDOW_MS = 5000        // suppress own writes
const MDM_POLL_INTERVAL_MS = 30 * 60 * 1000  // 30 min MDM registry poll
const DELETION_GRACE_MS = 1700              // absorb delete-and-recreate
内部写入——抑制自触发的重新加载循环

当 Claude Code 本身写入设置文件(例如,保存权限规则)时,它会调用 markInternalWrite(filePath) 在写作之前。 当乔基达尔发射 change 事件, consumeInternalWrite(path, 5000) 检测到更改源自内部并默默地跳过重新加载。 这可以防止每次 Claude Code 更新其自己的设置时重新加载级联。

// updateSettingsForSource() before file write:
markInternalWrite(filePath)
writeFileSyncAndFlush_DEPRECATED(filePath, jsonStringify(updatedSettings, null, 2))
resetSettingsCache()
删除并重新创建宽限期

自动更新程序和某些编辑器会删除文件,然后自动重新创建它。 天真地,这将触发“设置已删除”通知,然后立即触发“设置已添加”通知。 变化检测器使用 1700ms宽限期:当文件被删除时,它会在处理删除之前等待。 如果一个 add or change 事件在宽限窗口内到达,删除将被取消并被视为正常更改。

fanOut() — 单生产者模式

在当前架构之前,每个订阅者都调用 resetSettingsCache() 收到变更通知后采取防御措施。 对于 N 个订阅者,这会导致每次更改进行 N 次缓存清除和 N 次磁盘重新加载。 解决方案是将重置集中在一个单一的 fanOut() function:

function fanOut(source: SettingSource): void {
  resetSettingsCache()        // one clear
  settingsChanged.emit(source)  // N subscribers; first one pays the disk miss
                                // subsequent ones hit the repopulated cache
}

7. MDM 和基于文件的托管设置

The policySettings 层有四个内部子源。 第一个具有非空内容的获胜 - 其他的在本次会话中将被忽略。

政策子源优先级

1
Remote Anthropic API → /api/claude_code/settings 企业/团队+控制台用户
2
MDM plist/HKLM macOS:/Library/托管首选项/com.anthropic.claudecode.plist
Windows:HKLM\SOFTWARE\Policies\ClaudeCode
仅限管理员写入
3
File 托管设置.json + 托管设置.d/*.json 需要对 /etc/claude-code 或同等内容的更高写入访问权限
4
HKCU HKCU\SOFTWARE\Policies\ClaudeCode(仅 Windows) 用户可写——最低策略优先级

Managed-settings.json 的平台路径

Platform基本路径插入目录
macOS /Library/Application Support/ClaudeCode/managed-settings.json /Library/Application Support/ClaudeCode/managed-settings.d/
Windows C:\Program Files\ClaudeCode\managed-settings.json C:\Program Files\ClaudeCode\managed-settings.d\
Linux /etc/claude-code/managed-settings.json /etc/claude-code/managed-settings.d/

插入目录合并顺序

The managed-settings.d/ 目录使多个团队能够交付独立的策略片段,而无需编辑单个共享文件。 文件已排序 alphabetically 并按顺序合并——后面的文件覆盖前面的文件。 这遵循 systemd/sudoers 约定。

// settings.ts — loadManagedFileSettings()
// 1. Load managed-settings.json as base (lowest precedence)
const { settings } = parseSettingsFile(getManagedSettingsFilePath())

// 2. Load and sort drop-in files
const entries = fs.readdirSync(dropInDir)
  .filter(d => d.isFile() && d.name.endsWith('.json'))
  .map(d => d.name)
  .sort()  // alphabetical: 10-otel.json, 20-security.json …

for (const name of entries) {
  merged = mergeWith(merged, parseSettingsFile(join(dropInDir, name)).settings, ...)
}

MDM 原始读取 — 启动并行性

读取 macOS plist (plutil -convert json)和 Windows 注册表(reg query)需要生成子进程。 这些在启动期间(在模块初始化完成之前)尽早触发,因此子进程与模块加载并行运行。 到......的时候 ensureMdmSettingsLoaded() 等待时,结果通常已经可用。

// mdm/settings.ts
export function startMdmSettingsLoad(): void {
  mdmLoadPromise = (async () => {
    const rawPromise = getMdmRawReadPromise() ?? fireRawRead()
    const { mdm, hkcu } = consumeRawReadResult(await rawPromise)
    mdmCache = mdm
    hkcuCache = hkcu
  })()
}

8. 远程管理设置

对于企业/团队订阅者和所有控制台(API 密钥)用户,Claude Code 在启动时从 Anthropic API 获取设置。 这是 highest-priority 内的子源 policySettings.

Eligibility

  • 控制台用户(API 密钥): 总是有资格
  • OAuth 用户 — 企业或团队: eligible
  • OAuth users — 未知订阅类型 (外部注入代币,CI):符合条件 — API 为不符合条件的组织返回空设置,因此成本是额外的一次往返
  • 第三方API提供商 or 自定义基本 URL: ineligible
  • 联合办公(local-agent entrypoint): ineligible

获取生命周期

sequenceDiagram participant Init as CLI init participant RMS as RemoteManagedSettings participant API as Anthropic API participant FS as Disk cache participant CD as changeDetector Init->>RMS: loadRemoteManagedSettings() RMS->>FS: read cached settings (sync, getRemoteManagedSettingsSyncFromCache) FS-->>RMS: cached settings (if any) RMS->>Init: resolve loading promise (unblocks waiters immediately) RMS->>API: GET /api/claude_code/settings\nIf-None-Match: "sha256:..." API-->>RMS: 200 new settings / 304 not modified / 204 no content / 404 RMS->>RMS: security check (dangerous changes need user approval) RMS->>FS: save new settings (mode 0o600) RMS->>CD: notifyChange('policySettings') CD->>CD: resetSettingsCache() + fanOut Note over Init,CD: Background poll every 1 hour

基于 ETag 的缓存(校验和)

为了避免在每次启动时重新下载未更改的设置,Claude Code 计算缓存设置的 SHA-256 校验和并将其作为 If-None-Match 标头。 服务器返回 304 Not Modified 如果什么都没有改变。 校验和算法与服务器的 Python 实现完全匹配:

// Must match Python: json.dumps(settings, sort_keys=True, separators=(",", ":"))
export function computeChecksumFromSettings(settings: SettingsJson): string {
  const sorted = sortKeysDeep(settings)       // recursive key sort
  const normalized = jsonStringify(sorted)    // no spaces after separators
  const hash = createHash('sha256').update(normalized).digest('hex')
  return `sha256:${hash}`
}

故障开放设计

  • 如果获取失败(网络错误、超时、身份验证错误)并且缓存文件存在→使用陈旧的缓存
  • 如果获取失败且不存在缓存 → 无需远程设置即可继续
  • 验证错误(401/403) 做 not retry
  • 网络/超时错误使用指数退避重试最多 5 次
  • 204/404 → 未配置任何设置 → 删除过时的缓存文件

危险变更的安全检查

当远程设置到达时内容与缓存不同时, checkManagedSettingsSecurity() 在应用它们之前运行。 如果传入的设置被认为是危险的(例如,新的挂钩、更改的权限规则),则会提示用户批准。 拒绝会保留之前缓存的设置。

后台轮询

初始加载后,后台间隔轮询 API 每 60 分钟一班 (POLLING_INTERVAL_MS = 60 * 60 * 1000)。 在每次轮询中,它都会检查设置是否更改(JSON 字符串比较)并调用 settingsChangeDetector.notifyChange('policySettings') 只要他们这样做了。 间隔是用以下命令创建的 .unref() 所以它不会阻止进程退出。

9. 设置同步(CCR)

设置同步(services/settingsSync/) 是独立于远程管理设置的机制。 它同步一个 用户自己的 跨环境的设置 - 主要是在存储库 (CCR) 无头模式下的交互式 CLI 和 Claude Code 之间。

DirectionTrigger同步了什么
Upload (CLI → 云) 交互式 CLI 启动(preAction) 用户settings.json、用户CLAUDE.md、本地settings.local.json、项目CLAUDE.local.md
Download (云 → CCR) CCR 无头启动(插件安装之前) 相同的四个文件,由文件路径 + git 远程哈希键控

同步键

export const SYNC_KEYS = {
  USER_SETTINGS:  '~/.claude/settings.json',
  USER_MEMORY:    '~/.claude/CLAUDE.md',
  projectSettings: (projectId: string) =>
    `projects/${projectId}/.claude/settings.local.json`,
  projectMemory:  (projectId: string) =>
    `projects/${projectId}/CLAUDE.local.md`,
}

项目特定的密钥由 git 远程 URL 的 SHA 确定范围,因此设置 github.com/org/repo-A 切勿覆盖设置 github.com/org/repo-B.

增量上传: 上传路径首先获取远程副本,计算差异,然后仅发送值已更改的键。 即使您有很多项目,这也可以使 PUT 请求保持较小的规模。
尺寸限制: 每个文件的上限为 500 KB (MAX_FILE_SIZE_BYTES)。 超过此限制的文件将被静默跳过。 该限制对上传和下载路径均强制执行。

10. 安全限制

几个设置键有意排除 projectSettings 来自他们的信任域。 否则,恶意存储库可能会自动注入危险设置。

设置/检查来源可信为什么projectSettings被排除
skipDangerousModePermissionPrompt 用户、本地、标志、策略 存储库可以自动绕过危险模式对话框(RCE 风险)
skipAutoPermissionPrompt 用户、本地、标志、策略 相同 - 自动模式选择必须由用户驱动
useAutoModeDuringPlan 用户、本地、标志、策略 相同——计划模式语义是安全关键的
autoMode 分类器配置 用户、本地、标志、策略 通过存储库注入允许/拒绝规则将是一个 RCE 向量
allowManagedPermissionRulesOnly — 当设置为 true 在托管设置中, only 遵守托管设置中的允许/拒绝/询问规则。 所有用户、项目、本地和 CLI 参数权限规则都会被静默忽略。 这是企业控制,用于防止员工授予自己额外的权限。
allowManagedHooksOnly — 类似的钩子锁定。 只有在托管设置中定义的挂钩才会执行。 这可以防止用户安装窃取数据或绕过审核的挂钩。

11. 要点

  • 设置按顺序从 5 层合并: 用户→项目→本地→标志→策略。 后面的层会覆盖前面的层。 数组是跨层去重连接的。
  • policySettings 在内部使用first-source-wins:远程击败 MDM (plist/HKLM) 击败 Managed-settings.json 击败 HKCU。 仅应用第一个非空源。
  • The 三层缓存 (会话、每个源、每个文件)保持快速启动。 单个 resetSettingsCache() 使所有三个无效,并在通知订阅者之前以原子方式调用。
  • 变化检测 使用 chokidar 文件监视用户/项目/本地/策略文件以及 30 分钟的 MDM 轮询。 使用 5 秒窗口抑制内部写入,以防止重新加载循环。
  • 远程管理设置 是最高优先级的策略子源,在启动时使用 ETag 缓存获取,最多重试 5 次,每小时轮询一次。 系统总是无法打开 - 失败的提取会回退到缓存的文件或只是跳过远程设置。
  • 设置同步 与远程管理设置正交。 它同步 用户自己的 使用云键值存储在 CLI 和 CCR 之间设置文件(不是企业策略),范围由 git 远程哈希确定。
  • 几个安全敏感标志(skipDangerousModePermissionPrompt、自动模式选择加入、分类器规则)有意排除 projectSettings 以防止恶意存储库升级自己的权限。
  • The 向后兼容合约 SettingsSchema 中禁止删除字段或枚举值。 新字段必须是可选的。 在 Zod 验证之前,无效的个人权限规则将被删除,因此一条错误的规则无法使整个设置文件无效。

12. 测验

Q1. 两者都定义了一个设置 userSettings and projectSettings。 最终合并结果中哪个值获胜?

级联从低到高合并:SETTING_SOURCES 中后面的条目覆盖前面的条目。 projectSettings(索引 1)覆盖 userSettings(索引 0)。

Q2。 您有一个 macOS MDM plist 和一个 managed-settings.json 磁盘上的文件。 哪一个提供策略设置?

policySettings 是第一个源获胜:远程 → MDM (plist/HKLM) → Managed-settings.json → HKCU。 MDM plist(索引 2)胜过文件(索引 3)。

Q3。 当 Claude Code 自己写入设置文件(例如,保存权限规则)时会发生什么?

markInternalWrite(path)记录路径+时间戳。 handleChange() 中的 ConsumerInternalWrite() 在 INTERNAL_WRITE_WINDOW_MS (5000ms) 内检查这一点并返回 true,从而防止重新加载通知。

Q4。 一个项目的 .claude/settings.json sets skipDangerousModePermissionPrompt: true。 Claude Code 会尊重这一点吗?

hasSkipDangerousModePermissionPrompt() 检查userSettings、localSettings、flagSettings 和policySettings — projectSettings 被故意排除。 否则,恶意存储库可能会自动绕过危险模式对话框(RCE 风险)。

Q5. 你有 permissions.allow: ["Read(**)", "Bash(git *)"] 在用户设置和 permissions.allow: ["Bash(git *)", "Write(src/)"] 在项目设置中。 什么是合并的允许列表?

settingsMergeCustomizer 检测两个数组并返回 uniq([...objValue, ...srcValue])。 “Bash(git *)”出现在两者中,但在结果中进行了重复数据删除。

Q6. 远程管理的设置是通过 If-None-Match 标头。 该标头中发送的值是什么?

computeChecksumFromSettings() 递归地对键进行排序,序列化为没有空格的 JSON(匹配 Python 的 json.dumps 分隔符 =(",",":")),并返回 "sha256:" + sha256 十六进制摘要。 这必须与服务器端实现完全匹配。

Q7. 什么触发了 managed-settings.d/ 插入目录合并,以及文件应用的顺序是什么?

loadManagedFileSettings() 首先合并 Managed-settings.json (基础),然后按字母顺序对插入文件名进行排序,并将每个文件合并在顶部。 这符合 systemd/sudoers 约定:10-otel.json < 20-security.json,后面的名称获胜。