青雲的博客
拆开 Codex 第一部:先建立系统地图 第 04 章

配置能覆盖,Requirements 不能绕过

解释 ordinary candidate 如何与独立 Requirements stack 汇合校验,如何按 TOML 规则 compose、记录约束来源,并在运行时对不合规值执行 fallback。

源码版本
rust-v0.144.6
验证日期
Commit
5d1fbf26c43abc65a203928b2e31561cb039e06d

第 3 章结束在一个容易被误读的词上:effective_config() 看起来已经是“最终配置”,但它只产出 ordinary layers 合并后的 ordinary candidate。这个 candidate 还要经过另一条控制链。企业或设备侧声明的 Requirements 不作为普通 layer 追加到 ConfigLayerStack 顶部,而是先单独 compose 成约束,再检查 candidate 能否被接受。

这条区别只针对正常的 Config 构造路径。普通 config 和 CLI 可以改 candidate,但普通 config/CLI 路径不能绕过 Requirements,把一个不被允许的值直接送进 runtime。内部 debug/test 可以通过 LoaderOverrides 显式设置 ignore_managed_requirements,让 loader 跳过 Requirements 加载;这是有意保留的诊断例外,公开的 codex sandbox 子命令也会走到同一个 flag:codex sandbox -P <profile> 不加 --include-managed-config(默认关)时把它设为 true。但那条路径运行的是显式传入的原始命令、不是 agent loop(有 shell 权限的人本就能直接运行该命令),因此它是沙箱诊断入口,而非正常 agent 回合的绕过口;正常 config/CLI 的 agent 路径仍不能绕过 Requirements。

Requirements stack 不是 ordinary layer 的下一层

loader 在同一个函数里同时准备两类输入:一类是第 3 章已经讲过的 config layers,另一类是 requirements_layers: Vec<RequirementsLayerEntry>RequirementSource 只标识约束来自哪里;这个 enum 没有普通 config 那种数值 precedence。Requirements 的优先级由 Vec 的低到高 append 顺序表达,最后一层写入的 regular TOML 才有机会替换前面的值。

当前标准路径的顺序可以先写成一张来源表。这里的“高”只表示 Requirements compose 顺序,不是 ConfigLayerSource::precedence() 的另一套数字:

顺序(低到高)来源进入 Requirements 的形式
1system requirements.tomlSystemRequirementsToml
2cloud fragmentsenterprise-managed RequirementsLayerEntry
3legacy managed file从 legacy managed_config.toml 的三个字段回填 regular requirements
4legacy MDMLegacyManagedConfigTomlFromMdm 回填同一组约束
5macOS requirements_toml_base64 managed preferenceMdmManagedPreferences layer

loader 先读取 cloud bundle、macOS managed preference 和 system requirements.toml,然后在加载普通 config layers 之后,把 system、cloud、legacy、managed preference 依次 extendrequirements_layers。因此这里的源码顺序是“先收集、后按 Vec 组合”,不能从文件 I/O 完成时间倒推优先级。

cloud bundle 有一个反直觉的细节:后端 fragment 到达时是 high-first,但 Requirements stack 要 low-to-high 合并,所以转换成 RequirementsLayerEntry 后显式 reverse。如果只看到 bundle 的数组顺序,恰好会把云端层的 winner 读反。

legacy managed file 与 legacy MDM 不是同一层的两个别名。兼容代码分别读取两个对象,把 approval_policyapprovals_reviewersandbox_mode 三个旧字段转成 allowed_approval_policiesallowed_approvals_reviewersallowed_sandbox_modes。这里的 sandbox_mode 回填不是一对一:它会无条件把 read-only 放进 allowed_sandbox_modes(read-only 是 Codex 运行的前提),再按需追加原值,所以 legacy workspace-write 得到的是 [read-only, workspace-write] 而非只钉死 workspace-write。文件来源先入 Vec,MDM 来源随后入 Vec,所以 MDM 回填可以覆盖文件回填。

这里还有一条容易漏掉的双路径:第 3 章追踪的同一批 legacy objects 仍会作为 ordinary layer 进入 candidate;本章只追它们被 clone 后如何生成 Requirements layer。于是同一份 legacy policy 同时拥有 ordinary provenance 和 constraint provenance,两者不能合并成一份 metadata。

Requirements 是独立 stack,不是 ordinary layer。RequirementSource::composite 会去重,并按高优先级到低优先级保存来源;它描述的是“哪些 Requirements layer 对这个约束字段提供过输入”,不是普通 config 的 leaf origin。这个 source 之后会进入错误消息和 ConstrainedWithSource,因此来源身份需要从一开始就和 candidate 身份分开。

Compose 复用 TOML merge,但 allowlist 不求交集

Requirements stack 的 regular fields 先各自解析,再把每层的 regular TOML 低到高折叠。这个选择和普通 config 的递归 merge 保持一致,避免 Requirements 自己发明一套不兼容的树合并语义。源码注释只列出几个必须特殊处理的 domain fields;普通字段不需要再做一张百科式清单。

基层 / 高层类型行为约束含义
table + table递归高层只替换自己提供的 key,未出现的 sibling 保留
array整体替换高层数组取代低层数组,不逐项追加
scalar 或类型变化整体替换当前路径直接采用高层值

allowed_approval_policiesallowed_approvals_reviewersallowed_sandbox_modesallowed_web_search_modes 都属于 regular TOML 字段。高层数组整体替换低层数组,所以它们不求交集;高层可以收窄,也可以在自身声明有效的情况下放宽低层 allowlist。把“多层管理”理解成逐层求交集,会把一个明确允许的高层值误判成冲突。

merge_toml_values 的边界很小:只有两个当前节点都是 table 时才递归,其他类型都替换当前节点。Requirements 的 allowlist 因此继承“整体替换”语义;专用 merger 只用于确实需要顺序或集合语义的字段,例如高层优先的 rules/hooks 和 deny_read。这不改变 regular fields 的 replace-not-intersect 结论。

effective_config() 与约束来源各自回答不同问题

第 3 章的两个 API 仍然有用,但它们不替 Requirements 记账。effective_config() 返回 enabled ordinary layers 的合并结果,origins() 返回 ordinary 层对 scalar/list winner 的来源 metadata;两者都 不含 Requirements,也不回答 candidate 经过约束后会落到哪个 effective value。

观察对象能回答的问题不能替代的判断
effective_config()ordinary candidate 的树形值Requirements 是否允许该值
origins()ordinary scalar/list winner 的 layer metadata约束失败后的 fallback
Requirements provenance哪些高层 table contributor 参与 composeordinary candidate 的来源
ConstrainedWithSource约束来源与当前 requirement-compliant valuecandidate 来自哪一个 config layer

Requirements 的 table 顶层 contributor 是 contributor 列表,不是 leaf origin:多个 table layer 会按 high-to-low 组成 Composite,即使某个低层 leaf 最终没有 surviving value,它仍可能出现在顶层 contributor 中。这里有两个方向,不能混为一个:Vec 的 low-to-high 决定值替换;Composite 的 high-to-low 只记录/展示 contributor 顺序,不改变 winner。这里的 Composite 记录约束输入的来源组合,不应被解释成普通配置的逐叶覆盖链。

ConstrainedWithSource<T>source 是约束来源,不是 candidate 来源。它包住一个 Constrained<T>,字段可以是 approval policy、approvals reviewer、permission profile、web search 等最终要接受校验的运行时值。普通 config 的 path 和 Requirements 的 source 需要分别展示,否则诊断信息会让人误以为“这份 cloud policy 产生了这个 candidate”。

Constrained<T> 把冲突变成可观察的 fallback

Constrained<T> 可以把“当前值”和“fallback value”放在同一个约束对象里理解:初始化时的 value 是 requirement-compliant fallback,candidate 通过后才替换它。对象同时保存 validator 和 optional normalizer。set() 的顺序是 normalize,再 validate;validator 失败时不写入新值,所以旧的 fallback 仍然可读。

真正把它接到 Config 构造的是 apply_requirement_constrained_value():先尝试 set candidate;失败时读取当前 fallback,写入 startup warning,再尝试 set fallback。只有 fallback invariant(fallback 自己也无法通过 validator)失败,才返回 InvalidInput。因此普通 candidate 不合规通常是 fallback + warning,不应把所有冲突概括成启动硬失败。

四个 runtime 字段,加一条 Feature 路径

这一节只追最终 Config 构造里最容易混淆的四个跨平台 runtime 字段:approval policy、approvals reviewer、permission profile 和 web search。它们都经过 apply_requirement_constrained_value();Feature pin 则走 ManagedFeatures 自己的 normalize/validate 路径。ConfigRequirements 还有 Windows、residency、network 等其他域,下面不是完整字段目录。

观察对象resolved candidateconstraint 与 sourcefallback / effective value被拒绝时的边界
approval_policytyped override、ordinary config 或 trust/default 解析出的策略allowed_approval_policies 与对应 RequirementSource有约束时以 allowlist 第一项为 fallback;通过校验则保留 candidate显式/最终 candidate 在 helper 被拒时写 startup warning 后 fallback;未显式 default 可在 helper 前先回退
approvals_reviewertyped override、ordinary config 或默认 Userallowed_approvals_reviewers 与对应 RequirementSource有约束时以 allowlist 第一项为 fallback;例如只允许 guardian_subagent 时得到 AutoReview与 approval policy 相同:显式/最终 candidate 由 helper 处理,未显式 default 可提前回退
permission_profiletyped PermissionProfile override、选中的 profile 或 legacy 派生值allowed_sandbox_modes;validator 先把 profile 映射成 sandbox category默认 fallback 是 canonical PermissionProfile::read_only();通过校验则保留 resolved profilecandidate 被拒时 warning + read-only fallback;allowlist 不含 read-only 会更早在 Requirements 构造时报错
web_search_modeconfig 与 Feature 状态解析出的 WebSearchModeallowed_web_search_modes 与对应 RequirementSource按 Cached、Indexed、Live、Disabled 的顺序选可用 fallback;Disabled 会被加入 accepted sethelper 被拒时写 startup warning 后 fallback
Feature pinconfigured Features[features] pin 与对应 RequirementSourcenormalize_candidate 直接把 pinned state 写进 candidatenormalize 后再 validate;失败返回构造错误,不经过上述 fallback helper

PermissionProfile 这一行不能反过来理解成“profile 就是 danger-full-access(DFA)”。源码先调用 sandbox_mode_requirement_for_permission_profile 做分类:PermissionProfile::Disabled 映射成 DFA,Managed 若带 full-disk-write 也映射成 DFA;其余 profile 会按文件系统写权限或 external sandbox 映射到各自 category。allowed_sandbox_modes 约束的是这个分类结果,不是 PermissionProfile enum 本身。approval policy 又是另一条约束,不能因为 profile 回退成 read-only,就推断它会自动改成某个值。

PermissionProfile、approval policy 与 Feature Stage 不能互相代替

在 core 的最终 Config 构造里,approval policy、approvals reviewer、permission profile 和 web search 依次经过同一个 constrained-value helper。PermissionProfile 被 Requirements 限制后,代码还会根据原始 profile 的 sandbox category 检查一个特殊的 approval_policy = "never" 组合;这个分支是明确的安全硬错误,不能反推成“所有 fallback 都硬失败”。

approval policy 独立于 PermissionProfile 和 sandbox class;allowed_sandbox_modes 只表达能力类别,不能替代 approval constraint。

Stage 这组枚举只描述 Feature 的生命周期:UnderDevelopment、Experimental、Stable、Deprecated、Removed;它本身不 pin,只有 Requirements 的 [features] 数据才会在 normalize/validate 阶段 pin feature state。

managed feature resolver 会先读取 [features] Requirements,得到 pinned features,再 normalize candidate 并 validate pinned features。这里的 source 是 feature Requirements,不是 Stage 名称;Stage 只帮助解释 feature 处于什么生命周期。

把两个层次带进下面的实验即可:PermissionProfile 是 canonical runtime object,allowed_sandbox_modes 约束的是它映射出的 sandbox category;ignore_managed_requirements 是 debug/test 与 codex sandbox 子命令共享的 intentional exception,不改变正常 agent 路径不能绕过 Requirements 这一点。实验只验证 read-only fallback,不扩展到其他 Requirements 字段。

固定实验:Disabled 不能穿过 read-only Requirements

这次实验不手写一个“看起来合理”的配置,而是运行 disposable archive 中已经存在的测试。命令使用仓库的 just test recipe,不加 nextest 的 --exact

: "${ARCHIVE_CODEX_RS:?先执行第一部导读的 archive 准备脚本}"
cd "$ARCHIVE_CODEX_RS"
just test --locked -p codex-core permission_profile_override_falls_back_when_disallowed_by_requirements

在固定 tag 上,过滤器实际只运行一个 test:

PASS codex-core config::tests::permission_profile_override_falls_back_when_disallowed_by_requirements
Summary: 1 test run, 1 passed

fixture 通过 typed PermissionProfile::Disabled 作为 ConfigOverrides candidate。这个 profile 映射到 danger-full-access(DFA)class;cloud Requirements 只允许 allowed_sandbox_modes = ["read-only"]。构造完成后,canonical profile 回退为 PermissionProfile::read_only(),legacy sandbox 也回退为 read-only policy。两条结果同时成立,说明约束 enforcement 发生在最终 Config 构造,而不是 ordinary candidate 解析阶段。

这个实验只证明一个 candidate 被一个 cloud requirement 约束后的 fallback 结果。它不证明多层 precedence、warning 文本、所有字段或 hard-fail 分支;这些结论要分别回到 stack、helper 和特定错误分支的源码。尤其不能因为本例回退成功,就把所有 Requirements 冲突都写成同一种结果。

还要保留一条固定版本的边界:rust-v0.144.6ConfigRequirements 没有 network_approval 字段。若局部改造需要新增它,必须标成 proposed change,不能反过来当成当前版本的 runtime flow。

flowchart LR
    accTitle: Codex managed requirements enforcement boundaries
    accDescr: Typed overrides and ordinary config are resolved into runtime candidates. Four fields pass through the constrained-value helper with independently composed Requirements, while Feature pins use ManagedFeatures normalization and validation.
    OVERRIDE["typed ConfigOverrides"]
    ORDINARY["ordinary ConfigToml candidate"]
    RESOLVE["field-specific resolution<br/>override / config / default"]
    SYSTEM["Requirements sources"]
    REQ["compose constraints<br/>fallback + source"]
    HELPER["apply_requirement_constrained_value<br/>four runtime fields"]
    WARNING["fallback + startup warning"]
    FINAL["effective Config value"]
    FEATURE["configured Features"]
    PIN["feature pins + source"]
    MANAGED["ManagedFeatures<br/>normalize + validate"]
    OVERRIDE --> RESOLVE
    ORDINARY --> RESOLVE
    SYSTEM --> REQ
    RESOLVE --> HELPER
    REQ --> HELPER
    HELPER -->|allowed| FINAL
    HELPER -->|rejected| WARNING
    WARNING --> FINAL
    FEATURE --> MANAGED
    SYSTEM --> PIN
    PIN --> MANAGED
    MANAGED --> FINAL

图里把 typed ConfigOverrides 与 ordinary config 都放在字段解析之前,因为 helper 接收的是已经解析成具体类型的 runtime candidate,不直接读取 raw TOML。它也没有把 allowlist 画成交集,或把 Requirements 伪装成 effective_config() 的高层 ordinary layer。前者是 replace-not-intersect 的实际规则,后者会让 origins() 看起来能够解释一个它根本没有记录的 constraint source。

下一步:身份边界

到这里,配置值的来源、约束来源和最终值已经分开。下一章要处理另一种常见混淆:登录拿到的 credential 负责什么,Agent identity 又由哪一层生成。继续读《登录身份不等于 Agent 身份》