青雲的博客
拆开 Codex 第五部分 如何改造而不破坏系统 第 10 章

改审批策略之前,先把影响面追完

从 AskForApproval::Granular 的真实协议、配置和运行时分支出发,推演 network_approval 能力位需要怎样保持兼容并守住安全边界。

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

上一章的 GuardianWarning 是一次 transient notification。客户端如果当时没有收到,重启后也不会补发。审批策略的性质不同:它在请求发生前就决定一条路径能不能进入 hook、Guardian 或用户界面,还会出现在配置、wire payload、turn context 和生成 schema 中。

这类改动最容易被低估成“给 struct 加一个 bool”。Rust 编译器能帮你找到一部分 struct literal,却找不到旧 JSON 应该默认成什么,也不会提醒模型 prompt 仍在描述旧能力,更不会替你决定已经 session-approved 的 host 要不要撤销。

这一章先还原 rust-v0.144.6 中真实存在的 AskForApproval::Granular,再推演一个固定 tag 中不存在的字段:network_approval。推演的目标不是提交补丁,而是把语义、兼容边界、影响文件和测试合同写到足够明确,下一步实现时不需要猜。

可复现实验:先锁住现有 Granular 基线

我在固定 tag 的 detached checkout 中运行了十组命令:

just test -p codex-protocol \
  granular_approval_config_defaults_missing_optional_flags_to_false

just test -p codex-app-server-protocol \
  ask_for_approval_granular_round_trips_request_permissions_flag
just test -p codex-app-server-protocol \
  ask_for_approval_granular_defaults_missing_optional_flags_to_false
just test -p codex-app-server-protocol \
  ask_for_approval_granular_is_marked_experimental

just test -p codex-config deserialize_allowed_approval_policies
just test -p codex-app-server-protocol \
  config_requirements_granular_allowed_approval_policy_is_marked_experimental

just test -p codex-core only_never_policy_disables_network_approval_flow
just test -p codex-app-server --test all \
  thread_start_granular_approval_policy_requires_experimental_api_capability

just test -p codex-core config_schema_matches_fixture
just test -p codex-app-server-protocol --test schema_fixtures

前九组各选中一条测试,最后一组 schema fixtures 选中两条。结果合计 11 passed, 0 failed

这 11 条是现有行为基线,不是 network_approval 的实现证明。core 与 app-server 的 default 测试只确认 skill_approvalrequest_permissions 缺省为 false;round-trip 只覆盖现有字段;requirements 解析测试只用了字符串策略;network runtime 测试也没有构造 Granular。这些测试下一步都需要扩充,不能因为名字里出现了 granular 或 network 就当作新合同已经存在。

固定 tag 中的 Granular 到底控制什么

core protocol 把审批策略建模成四种值:UnlessTrustedOnRequestGranular(...)NeverGranularApprovalConfig 有五个能力位:

字段当前语义缺省行为
sandbox_approvalshell 的 sandbox escape、inline additional permissions 与 require_escalated 是否可询问必填
rulesexecpolicy prompt rule 是否可询问必填
skill_approvalskill script execution 是否可询问缺省 false
request_permissions内置 request_permissions 工具是否可询问缺省 false
mcp_elicitationsMCP elicitation 是否可询问必填

这里的 bool 不是“是否自动批准”。true 只让该类别有资格进入审批流,后面仍可能被 hook、Guardian 或用户拒绝;false 则在对应 runtime 分支直接拒绝,不弹出请求。

这已经说明新增字段不能只改一个 match。AskForApproval 派生了 Serde、JsonSchema 和 TypeScript;它也是 hash/equality 的一部分,会作为完整值进入 managed constraints。任何默认值错误,都会同时改变配置解析、旧 rollout 恢复和 requirements 比较。

同一个策略至少有两份公开表示

ConfigToml.approval_policy 直接使用 core AskForApproval。所以配置入口不需要再定义一份 enum,但生成的 core/config.schema.json 会把字段必填性与默认值公开给编辑器和配置校验器。

app-server-protocol 另有一份 v2 AskForApproval。它把 Granular 写成 struct-like enum variant,保留自己的 Serde/JsonSchema/TS 派生,并通过 to_coreFrom<CoreAskForApproval> 手工复制每一个字段。该 variant 还标着 askForApproval.granular experimental capability。

这个标记会递归穿过所有携带 approval policy 的公开输入与投影。固定 tag 的协议测试覆盖 thread/startthread/resumethread/forkturn/startthread/settings/update,以及 config/readconfigRequirements/read 对应类型。app-server 的 live capability test 只选了 thread/start 做进程级验证;新增字段后,其他入口的 struct literal、递归 marker 和 schema 仍要一起更新,不能用这一条 live test 代替。

flowchart TD
  accTitle: Granular approval policy 的解析、投影、执行与恢复影响面
  accDescr: config TOML 和 app-server request 分别进入 core AskForApproval;完整策略受 requirements 约束,写入 turn context,并同时影响 runtime network decision、模型权限提示、TUI 状态与生成 schema。

  A["config.toml\ncore AskForApproval"] --> C["Constrained<AskForApproval>"]
  B["app-server v2 AskForApproval\nexperimental conversion"] --> C
  R["managed requirements\n完整值 equality"] --> C
  C --> D["TurnContext\n当前 runtime policy"]
  D --> E["network approval decision"]
  D --> F["permissions prompt"]
  D --> G["SessionConfigured / TUI projection"]
  D --> H["TurnContext rollout baseline\nresume / fork"]
  A --> S["config JSON schema"]
  B --> T["app-server JSON / TS schema"]

Requirements 比较的是整个值

managed allowed_approval_policies 不是“允许 granular 这个 variant 就行”。Constrained 最终用 policies.contains(candidate) 比较完整 AskForApproval 值。

新增 network_approval 后,下面两份策略应当被视为不同值:

Granular { network_approval: true,  ... }
Granular { network_approval: false, ... }

这使默认值变得更敏感。旧 requirements payload 省略新字段时,如果被解析成 false,原本允许网络询问的管理策略会在升级后静默收紧;若反过来把一个原本应收紧的新 payload 误解析成 true,又会放宽安全边界。兼容默认必须在设计阶段定死,并由旧载荷测试证明。

命令行又是另一条边界。-a/--ask-for-approval 只接受 untrustedon-requestnever 三个无负载值,无法表达 Granular 对象。这个提案可以继续只通过 config 或 -c 传入;如果产品要求一级 CLI,就要设计对象解析或单独 flag,不能把 granular 塞进现有 value enum 后丢掉六个字段。

本章提案:只控制 allowlist miss 的审批资格

下面这个字段是设计草案,rust-v0.144.6 中不存在:

#[serde(default = "default_true")]
pub network_approval: bool;

还需要新增并测试 default_true(),core protocol 与 app-server v2 的两份表示都要采用相同缺省。生成的 config JSON schema 应显示 default: true 且不把字段列入 required;新版 TypeScript 可以要求调用者显式填写,但服务端必须继续接受旧客户端省略字段的 payload。

语义只覆盖一个场景:受管网络代理发现目标 host 不在 allowed_domains 时,是否允许这次 miss 进入审批流。

策略新 allowlist miss其他网络行为
Never继续直接拒绝与固定 tag 相同
Granular { network_approval: false }在 hook、Guardian、用户 UI 前直接拒绝已允许 domain 继续工作;不改变 sandbox 网络能力
Granular { network_approval: true }保持当前询问路径仍需通过 hook、Guardian 或用户决策
OnRequest / UnlessTrusted保持当前询问路径与固定 tag 相同

它不是“禁网”。allowlist 已命中的请求不会进入这段逻辑;PermissionProfile 是否提供受管网络、proxy 如何执行规则,也不由这个 bool 重写。

它也不接管另外两类扩权:

  • request_permissions 工具主动请求 network 权限,仍由现有 request_permissions 字段控制;
  • shell inline additional permissions 与 require_escalated,仍由 sandbox_approval 控制。

把三者合成一个 network 开关看似简单,实际会改变已经发布的 Granular 分类法。

拒绝检查应该落在哪里

固定 tag 的 allows_network_approval_flow 只有一个判断:策略不是 Never 就返回 true。handle_inline_policy_request 的关键顺序是:

解析 request attribution / environment
-> 命中 session_denied_hosts 则拒绝
-> 命中 session_approved_hosts 则允许
-> 合并同 host 的 pending request
-> 检查 Managed permission profile
-> 检查 AskForApproval 是否允许 network approval flow
-> permission request hooks
-> Guardian 或用户 approval UI
-> 缓存 ApprovedForSession / deny,或持久化 policy amendment

最小改动只扩展 allows_network_approval_flowNeverGranular(false) 返回 false,其余返回 true。这样直接拒绝仍在 hook、Guardian 和 UI 之前,现有 outcome 记录与 pending cleanup 也继续复用。

这个位置还固定了一个刻意保留的边界:session_approved_hosts 查询发生在 policy check 之前。把字段从 true 改成 false,不会自动撤销当前 session 已批准的 host;已经持久化进 network policy 的 allow rule 也会在更早的 allowlist 判断中生效,不会再次形成 miss。

如果产品需要“切换为 false 立即撤销一切已批准网络访问”,那是另一项功能:要清 session cache、回滚 policy amendment,并处理正在等待的 request。不能借这个字段的名字悄悄附带完成。

模型提示必须和 runtime 一起变

Granular 的 permissions prompt 会列出允许询问和自动拒绝的类别。固定 tag 只列五个现有字段,对 managed allowlist miss 的审批资格保持沉默。若 runtime 已因 network_approval: false 直接拒绝,prompt 仍无法表达这个差异,模型就可能反复走一条必败路径。

新增字段后,granular_instructions 的 categories 必须加入 `network_approval`,并测试 true/false 分别出现在正确分组。文字还要说明它只控制 managed allowlist miss,避免与 request_permissions 混淆。

TUI 不需要新请求类型,但仍有显示边界

network miss 当前复用 command approval,请求中带 network_approval_context。TUI overlay 已据此把标题改成 “Do you want to approve network access to …?”。network_approval: false 时,这个 request 根本不应到达 overlay,因此最小实现不需要新增一种 UI event。

但 TUI 的状态面只对整个 policy 调 to_string(),Granular 最终显示为 granular,看不到任何子字段;permissions menu 也只选择预设 profile/approval policy,不编辑 Granular object。若用户需要在界面里理解或修改 network_approval,必须另做子字段详情与编辑流程。仅支持配置文件时,则要明确这是当前 UI 边界,并测试 false 时不会弹网络审批。

持久化兼容不能只测 config.toml

当前 turn 的 TurnContextItem 会把完整 approval_policy 写入 rollout,供 resume/fork 重建最新 durable baseline。ThreadSettingsApplied 也保存设置变更。旧 rollout 中的 Granular object 没有新字段,因此 core Serde default 必须同样覆盖恢复路径。

app-server 的 SessionConfiguredEvent 和 TUI thread session state 又会投影当前策略。新增字段虽然不要求新 notification method,但现有 payload、缓存和 fork config 都会随完整 enum 变化。只测试 config TOML 能解析,无法证明一条旧 thread 还能 resume。

完整改动清单

必改内容新增或扩展的测试
core protocol字段、default_true、accessor、所有 struct literalold JSON 缺字段为 true;显式 false 保真
config schema重新生成 core/config.schema.jsonproperty default true、非 required、fixture 一致
managed requirements完整值继续区分 true/false旧 requirements payload 兼容;两种候选约束
CLI最小方案保持 config/-c,不伪造无负载 granularhelp/value parser 仍拒绝无法表达的对象
app-server protocolv2 字段、default、双向转换、experimental 标记true/false round-trip、old payload、capability gate
network runtimeallows_network_approval_flow 读取新 accessorfalse 直接拒绝;true 当前路径;approved host 不撤销
prompt增加精确定义的 Granular 类别true/false 分组,不与 request_permissions 混淆
TUIoverlay 无新增类型;决定是否展示/编辑子字段false 不出现 request;true 仍显示 network target
rollout/resumeTurnContextItem 缺字段仍解析为 trueold rollout resume、false round-trip、fork 保真
generated artifactsconfig 与 app-server JSON/TS 全部重生成config_schema_matches_fixture、schema fixtures

逐文件审计清单

上表用于理解职责,不能替代文件清单。固定 tag 中,最先需要做语义修改或显式确认“无需修改”的入口是:

codex-rs/protocol/src/protocol.rs
codex-rs/config/src/config_toml.rs
codex-rs/config/src/config_requirements.rs
codex-rs/core/src/tools/network_approval.rs
codex-rs/core/src/tools/network_approval_tests.rs
codex-rs/prompts/src/permissions_instructions.rs
codex-rs/prompts/src/permissions_instructions_tests.rs
codex-rs/utils/cli/src/approval_mode_cli_arg.rs
codex-rs/app-server-protocol/src/protocol/v2/shared.rs
codex-rs/app-server-protocol/src/protocol/v2/tests.rs
codex-rs/app-server/tests/suite/v2/experimental_api.rs
codex-rs/tui/src/bottom_pane/approval_overlay.rs
codex-rs/tui/src/chatwidget/status_surfaces.rs
codex-rs/tui/src/chatwidget/permissions_menu.rs
codex-rs/tui/src/app/thread_session_state.rs

其中 CLI 和现有 overlay 在最小方案里可以不改行为,但必须留下审计结论:CLI 继续不表达带载荷的 Granular;overlay 继续复用 network command approval,false 时不会收到请求。

新增字段还会让所有 Granular struct literal 和 match 成为编译或语义检查点。完整集合保留在这里,正文不逐个展开职责。

展开 31 个非生成 Rust 文件
codex-rs/protocol/src/protocol.rs
codex-rs/app-server-protocol/src/protocol/v2/shared.rs
codex-rs/app-server-protocol/src/protocol/v2/tests.rs
codex-rs/app-server/tests/suite/v2/experimental_api.rs
codex-rs/codex-mcp/src/connection_manager_tests.rs
codex-rs/codex-mcp/src/elicitation.rs
codex-rs/codex-mcp/src/mcp/mod_tests.rs
codex-rs/core/src/exec_policy.rs
codex-rs/core/src/exec_policy_tests.rs
codex-rs/core/src/guardian/review.rs
codex-rs/core/src/guardian/tests.rs
codex-rs/core/src/hook_runtime.rs
codex-rs/core/src/mcp_tool_call.rs
codex-rs/core/src/mcp_tool_call_tests.rs
codex-rs/core/src/safety.rs
codex-rs/core/src/safety_tests.rs
codex-rs/core/src/session/mod.rs
codex-rs/core/src/session/tests.rs
codex-rs/core/src/tools/handlers/mod.rs
codex-rs/core/src/tools/runtimes/apply_patch.rs
codex-rs/core/src/tools/runtimes/apply_patch_tests.rs
codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs
codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs
codex-rs/core/src/tools/sandboxing.rs
codex-rs/core/src/tools/sandboxing_tests.rs
codex-rs/core/tests/suite/approvals.rs
codex-rs/core/tests/suite/exec_policy.rs
codex-rs/core/tests/suite/request_permissions.rs
codex-rs/core/tests/suite/skill_approval.rs
codex-rs/prompts/src/permissions_instructions.rs
codex-rs/prompts/src/permissions_instructions_tests.rs

这不表示 31 个文件都要新增 network 分支。很多测试只是必须补上 network_approval 字段,并重新确认原类别行为没有变化;exec_policy、MCP、skill、apply-patch 与 sandboxing 尤其不能因为补默认值就被误接到新语义。

app-server generator 当前会把 AskForApproval 扩散到 26 个 JSON/TypeScript 产物。更新后应由生成命令决定实际 diff,再逐个确认没有遗漏或手工编辑。

展开 26 个 app-server 生成物与 core config schema
codex-rs/app-server-protocol/schema/json/ClientRequest.json
codex-rs/app-server-protocol/schema/json/ServerNotification.json
codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json
codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json
codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json
codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json
codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json
codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json
codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json
codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json
codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json
codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json
codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json
codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json
codex-rs/app-server-protocol/schema/typescript/v2/AskForApproval.ts
codex-rs/app-server-protocol/schema/typescript/v2/Config.ts
codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts
codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts
codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts
codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts
codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts
codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettings.ts
codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts
codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts
codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts
codex-rs/app-server-protocol/schema/typescript/v2/index.ts

core 配置生成物另有:

codex-rs/core/config.schema.json

必须扩展的测试族

文件列全以后,再按合同分组,避免只修编译:

  1. core protocol:旧 Granular JSON 缺字段默认 true、显式 true/false、accessor;
  2. app-server protocol:旧 payload、双向 conversion、TypeScript/JSON shape;
  3. recursive experimental:config、config requirements、thread start/resume/fork、turn start、thread settings update;
  4. live capability:至少保留 thread/start 的进程级拒绝,确认新增字段没有绕过 experimentalApi
  5. managed requirements:Granular(true/false) 完整值约束和旧对象默认;
  6. network runtime:直接拒绝、current allow path、session-approved host、policy amendment、pending request;
  7. downstream isolation:false 时 hook、Guardian、user sender 都未调用;
  8. adjacent categories:execpolicy、sandbox、skill、request_permissions、MCP、apply-patch 行为不变;
  9. prompt/TUI:类别分组、false 无 overlay、true 显示 network target、状态面边界;
  10. rollout:旧 TurnContext resume、新 false round-trip、fork 保真;
  11. generators:config schema fixture、app-server JSON 与 TypeScript fixtures。

生成文件要通过仓库命令更新:

just write-config-schema
just write-app-server-schema --experimental

手改某个 JSON 文件不算完成。源类型、转换、fixtures 和生成器输出必须在同一提交里收敛。

源码依据

本章对固定 tag 的描述只覆盖现有五字段 Granular 与当前 network approval flow。network_approvaldefault_true 和所有预期测试都是明确推演,不能从源码链接中误读为仓库已经实现。

现场运行的 11 条测试证明当前默认值、转换、experimental marker 与 thread/start capability gate、requirements 的字符串解析和 marker、network helper,以及 schema fixtures 在固定 tag 通过。它们没有验证 Granular 完整值约束、旧 requirements 对象,也没有编译本章提案;真正实现后,旧测试要扩展,新测试也要先失败一次,才能证明影响面没有停留在清单上。

失败边界

  • core 默认 true、app-server 默认 false:config 可以恢复,旧客户端 payload 却被静默收紧。
  • 只改 core enum:v2 conversion 丢字段,client 看见的策略与 runtime 不同。
  • schema 标成 required:服务端可能接受旧 payload,生成客户端却无法表达兼容形状。
  • 把字段解释为禁网:allowlisted domain、PermissionProfile 和 request_permissions 的职责被意外覆盖。
  • policy check 放在 hook 之后:false 仍触发外部 hook 或 Guardian,违反直接拒绝语义。
  • 切换 false 时清空 approved host:超出提案边界,并可能让 in-flight request 出现竞态。
  • prompt 不更新:模型持续发起 runtime 必拒的网络询问。
  • TUI 只显示 granular:用户无法从状态面判断 network approval 是否关闭。
  • 只跑 unit test:旧 rollout、managed requirements 和 generated schema 仍可能在发布后失败。

动手改一个地方

不要先补 UI。先在 scratch branch 只完成“默认值 + runtime 直接拒绝 + 协议保真”这条最窄纵切:

  1. 给 core 与 app-server 两份类型增加 network_approval,旧 payload 缺省为 true;
  2. 扩展双向 conversion 与所有 struct literal;
  3. allows_network_approval_flow 在 Granular(false) 时返回 false;
  4. 更新 granular_instructions 及 true/false 分类测试,让模型看到相同能力边界;
  5. 添加 spy,证明 false 路径没有调用 permission hook、Guardian 或 user approval sender;
  6. 预置一个 session_approved_hosts 条目,证明它仍按既有边界允许;
  7. 序列化旧 TurnContextItem fixture 并 resume,证明缺字段恢复为 true;
  8. 重生成 config/app-server schema,跑完本章列出的 11 条基线和新增测试。

这条纵切已经包含发布前必需的 prompt 合同,但不增加 TUI 子字段编辑入口。后者可以等核心语义稳定后再决定。若第一步就同时做菜单、CLI 和新 request type,失败时很难判断是安全语义错了,还是某个投影漏字段。

这一章建立了什么

这个提案最终锁定五个决定:旧载荷缺字段时默认为 truefalse 在 hook、Guardian 和用户审批之前直接拒绝;当前 session 已批准的 host 与已落盘 policy amendment 不被撤销;core、app-server、rollout resume 与生成 schema 必须保留同一个值;模型 prompt 也要暴露同一类别。少掉任何一项,network_approval 就会在重启、跨协议或真实执行时变成另一种策略。