第四部:外部能力怎样进入同一套治理
这一部把外部能力拆成六条不同的进入路径:network 先过确定性策略再问 reviewer,Skill 主路径把声明或正文放进 prompt,显式 MCP 依赖另走受控安装分支;MCP、Dynamic Tools、Code Mode 与 Plugin 也各有自己的连接、执行和加载边界。

展开阅读路线与实验入口
读这一部之前,需要知道什么
第三部把模型调用追到了工具路由、审批、沙箱和进程生命周期。这个基础仍然有用,但到了外部能力,先沿着“规格—审批—执行”画一条总线会产生误导。网络请求由代理策略判断目标;Skill 主路径进入 prompt,显式声明的 MCP 依赖另有受控安装分支;MCP(Model Context Protocol,模型上下文协议)由 Codex 主动连接 server;Dynamic Tools 把调用交回宿主;Code Mode 在内部组织嵌套调用;Plugin 先把若干资源解析成一个加载结果。它们碰到的 owner、失败面和可观察结果并不相同。
本部继续使用 declaration、discovery、exposure、approval、execution、projection 六个诊断词。它们帮助回答“能力声明在哪里”“模型能否看见”“谁能执行”“结果投到哪里”,但不是公共 pipeline;不能要求六条进入路径依次经过六个阶段。比如 Skill 正文可以进入 prompt,却没有工具 execution;显式 MCP dependency 安装只更新配置、认证或 runtime,也不等于执行 Skill 正文;显式 network deny 在 policy boundary 已终止,也不会产生 approval context。
这一部负责讲清什么
先把六章放进同一张表。表里的 boundary 是每章首先要守住的责任界面,不是从左到右的调用顺序。
| 章节 | 进入边界 | 本章固定的问题 | 明确不等同于 |
|---|---|---|---|
| 第 19 章 Network | policy boundary | 实际 host 怎样经过 attribution、deterministic policy,再决定是否有资格交给 reviewer | 一次 shell 审批自动放行所有网络目标 |
| 第 20 章 Skills | prompt boundary | metadata catalog 与显式读取的正文怎样进入 context;MCP dependency 怎样受控安装 | Skill 自动注册为可执行工具 |
| 第 21 章 MCP | client boundary | McpRuntimeSnapshot 怎样持有 config、manager 与环境绑定,MCP tool 怎样被发现和调用 | Codex 在这条链上充当 MCP server |
| 第 22 章 Dynamic Tools | host boundary | DynamicToolSpec 怎样声明能力,call_id 怎样把执行请求与宿主响应配对 | Core 内部已经持有 executor callback |
| 第 23 章 Code Mode | orchestration boundary | cell 内的嵌套调用怎样经过 delegate,再回到现有工具路由 | JavaScript runtime 绕过审批与 tool contract |
| 第 24 章 Plugin | package-loading boundary | manifest 资源怎样绑定到同一权威来源,再形成 effective roots、MCP、apps 与 hooks | Plugin 自身是一种统一 executor |
flowchart TB
accTitle: 外部能力进入 Codex 的六种边界
accDescr: network、Skill、MCP、Dynamic Tools、Code Mode 与 Plugin 分别在策略、prompt、client、宿主、编排和包加载边界取得能力;它们共享治理词汇,但不是一条统一执行流水线
NETWORK["Network target"]
SKILL["Skill declaration or body"]
MCP["MCP server tools"]
DYNAMIC["Dynamic tool declaration"]
CODE["Code Mode nested call"]
PLUGIN["Plugin manifest resources"]
POLICY["policy boundary"]
PROMPT["prompt boundary"]
CLIENT["client boundary"]
HOST["host boundary"]
ORCHESTRATION["orchestration boundary"]
LOADER["package-loading boundary"]
GOVERNANCE["shared governance questions"]
NETWORK --> POLICY
SKILL --> PROMPT
MCP --> CLIENT
DYNAMIC --> HOST
CODE --> ORCHESTRATION
PLUGIN --> LOADER
GOVERNANCE -.-> POLICY
GOVERNANCE -.-> PROMPT
GOVERNANCE -.-> CLIENT
GOVERNANCE -.-> HOST
GOVERNANCE -.-> ORCHESTRATION
GOVERNANCE -.-> LOADER
图中没有 POLICY --> PROMPT,也没有从 client 继续串到 host、orchestration 或 loader。共享 governance 只表示每条路径都要追问 authority、permission、cancellation、error 与 visibility;虚线不声称它们由同一个 governor 处理。
第 19 章先从网络访问为什么另有一条决策链开始,第 24 章在一个 Plugin 怎样变成一组可安装能力收束这组六条边界。中间四章按能力进入点切换观察位置,而不是接力传递同一个对象。
六章共用哪套实验
这一部不构造一个同时启动 network proxy、Skill、MCP、宿主动态工具、Code Mode 和 Plugin 的大 fixture。六条命名测试不是一条端到端 fixture;它们分别验证一个局部边界,并共用相同的证据纪律:固定 tag 与 commit、从 disposable archive 运行、使用完整测试名、确认 nextest 实际执行目标测试,再检查 test-level skip 与平台门禁。
| 边界 | 命名测试 | 这条锚点实际证明什么 |
|---|---|---|
| policy | denied_network_policy_message_for_denylist_block_is_explicit | 显式 deny 会生成不可从当前 prompt 审批覆盖的清晰消息 |
| prompt | user_turn_includes_skill_instructions | 显式 Skill 输入使被选中的正文进入一次 user turn |
| client | stdio_server_round_trip | 构建好的 stdio server fixture 可被 MCP client 发现并完成一次工具往返 |
| host | dynamic_tool_call_round_trip_sends_content_items_to_model | 宿主响应的 text / image content items 会回到后续模型请求 |
| orchestration | code_mode_get_context_remaining_returns_structured_result | Code Mode 通过嵌套工具调用取得结构化 tokens_left 结果 |
| package-loading | environment_descriptor_binds_every_manifest_resource | environment descriptor 会重写 manifest 中每类资源的定位方式 |
MCP 锚点多一个 fixture 前提:stdio_server_round_trip 会查找已经构建的 test_stdio_server。共享脚本先在 archive 内执行一次 cargo build --locked -p codex-rmcp-client --bin test_stdio_server,再把命名测试交给仓库要求的 just test;just recipe 负责测试栈和 nextest profile。fixture 不存在、filter 零匹配、测试被环境门禁提前 return,或者摘要没有明确显示 1 test run / 1 passed,都不能记成绿色结果。
哪些机制暂时不讲
本部只追踪能力怎样进入当前 runtime 以及局部结果怎样返回。rollout、history、SQLite、resume、compaction 和 persistence 的通用 owner、写入时机与恢复语义延后到第 25 章:同一段对话,为什么同时存在三种状态及后续章节。第 22 章只会为 DynamicToolSpec 说明 SessionMeta 上的恢复入口和 validation 边界,不展开通用 resume 机制。这里出现 McpRuntimeSnapshot、pending dynamic call 或 code-mode cell,也不表示这些对象都已经成为 durable state。
app-server 和 TUI 的 UI projection 也延后到第 30 章:app-server 协议之后。第 22 章会指出宿主请求与响应的协议边界,第 23 章会指出嵌套调用的 event/result 边界,但不会把某个 client 的进度 item、通知顺序或界面状态当成 Core 的通用执行语义。
另外,本部不把 Plugin availability 当成所有资源可用的证明。加载结果仍可能带 error、disabled skill path 或经过 policy 过滤;MCP server 可被发现也不等于每个工具都已 exposure;network reviewer 存在也不等于显式 deny 可以被覆盖。各章在自己的边界处停止外推。
读完这一部,你应该能做什么
面对“外部能力明明配置了,为什么模型看不到或执行不了”,先判断它从哪条边界进入:
- network 从实际目标和 policy reason 开始,确认请求是否有
NetworkApprovalContext,再看 reviewer; - Skill 分开 discovery metadata 与 explicit body injection,确认缺的是目录 exposure 还是本轮 mention;
- MCP 从 client snapshot、manager、tool exposure 与
tools/call逐层定位,不把 server 进程存活当成调用成功; - Dynamic Tools 找
call_id、pending waiter 与宿主 response,确认执行权是否仍在外部 host; - Code Mode 找 cell、delegate、nested dispatch 和结果回送,继续沿原工具的治理边界检查;
- Plugin 从资源定位、active state 与 load outcome 开始,分别检查 skills、MCP、apps 和 hooks。
到这里可以比较六类能力的 declaration、discovery、exposure、approval、execution 与 projection,却不会把比较维度误画成共同生命周期。下一部会换一个问题:一次行为真正留下了哪些 conversation item 或 metadata,以及它们分别落在 History、rollout 还是 SQLite。能力已经加载,不能直接推出这些记录已经存在。
源码工作台
核心目录
| 目录 / 文件 | 本部只用它回答什么 |
|---|---|
codex-rs/core/src/network_policy_decision.rs | 哪类 proxy decision 能生成 approval context,显式 deny 怎样变成人类可读消息 |
codex-rs/core-skills/src/injection.rs | 显式 mention 怎样读取正文并形成 SkillInjections,读取失败怎样成为 warning |
codex-rs/core/src/session/mcp_runtime.rs | 单次请求的 MCP config、manager、runtime context 与 environment ids 由谁共同持有 |
codex-rs/protocol/src/dynamic_tools.rs | 宿主声明与 call/response wire 分别包含哪些字段,哪里没有 executor callback |
codex-rs/core/src/tools/code_mode/delegate.rs | cell dispatch gate、nested invocation、notification 与 cancellation 怎样交给 delegate |
codex-rs/plugin/src/load_outcome.rs | active plugin 怎样形成 capability summary、effective skill roots 与加载结果 |
这些路径提供六个切面,不提供一个总入口。章节还会向各自的 loader、router、handler 和集成测试展开,但先用这里的对象确定 owner,能避免一开始就被同名的 tool、server、host 与 plugin wrapper 带偏。
先抓住六种进入边界
| 边界 | 关键控制对象 | 先问的问题 |
|---|---|---|
| policy | NetworkPolicyDecisionPayload、NetworkApprovalContext | policy 已经 deny,还是产生了可以交给 reviewer 的 ask? |
| prompt | SkillMetadata、SkillInjections | 当前拿到的是发现阶段 metadata,还是本轮显式读取的正文? |
| client | McpRuntimeSnapshot、McpConnectionManager | config、环境绑定与连接 manager 是否来自同一请求快照? |
| host | DynamicToolSpec、DynamicToolCallRequest | Core 只有 declaration,还是已经收到外部宿主按 call_id 返回的 response? |
| orchestration | CodeModeDispatchBroker、CodeModeSessionDelegate | nested call 是否等到 cell 可 dispatch,并沿 cancellation token 返回? |
| package-loading | LoadedPlugin、PluginLoadOutcome | plugin 是否 active,哪些 resource 经过 policy 后真正进入 effective view? |
network 的 network_approval_context_from_payload 只接受 decider 给出的 ask,并要求 protocol 与非空 host;denied_network_policy_message 则只处理 deny。两条函数分开,意味着“可申请审批”和“解释不可审批拒绝”从这里已经是两种结果。
Skill injection 也没有 execution 分支。build_skill_injections 为每个已选 metadata 选择对应 filesystem、读取 SKILL.md,成功时加入带 name/path/contents 的 item,失败时只加入 warning。
MCP 请求快照把 McpConfig、plugin availability、McpConnectionManager、McpRuntimeContext 与 available environment ids 放在同一对象里。它是 client 侧请求状态的 owner,不是 server tool 的实现。
Dynamic Tools 的 protocol 类型同样划清 ownership。DynamicToolSpec 只带 function/namespace declaration;request 用 call_id、turn_id、tool 和 arguments 寻址一次宿主调用;response 才带 content_items 与 success。
Code Mode 的 broker 等 cell gate,发出 InvokeTool 或 Notify,再同时等待 response 与 cancellation。这里证明的是 orchestration ownership;被调用工具的审批、执行和输出 contract 仍属于原有 tool path。
Plugin loader 最终保留的不只是名称。LoadedPlugin 带 root、enabled/error、skill roots、MCP servers、apps、hook sources 与 warnings;只有 active 且确实含对外能力的 plugin 才生成 capability summary,PluginLoadOutcome 再持有加载列表与派生摘要。
实验准备与六个验证锚点
固定 checkout 只用来确认身份并导出源码。第四部也要在自己的新 shell 中运行;不要复用前一部的 ARCHIVE_DIR 或 EXIT trap。这个 tag 的 workspace manifest 已是 0.144.6,Cargo.lock 中却仍有 132 个 local package 写着 0.0.0;直接运行 Cargo 会改 lockfile。下面的唯一脚本先创建 disposable git archive,在副本中离线校准 lockfile,并验证改动只有 132 组 0.0.0 -> 0.144.6。构建产物也被强制写进临时目录,固定源码在六条测试前后都必须保持 clean。
run_checked_test 同时检查完整测试名、nextest summary、pass count 和 skip 文本。六个锚点各跑一个精确测试;MCP 的 test_stdio_server 也先在副本中用校准后的 lockfile 构建。脚本依赖 Bash、Git、just、Cargo、cargo-nextest、jq、awk、tar、cmp 与 grep。
完整复现脚本:隔离 checkout、校准 Cargo.lock、验证六个局部锚点
set -euo pipefail
SOURCE_ROOT="${SOURCE_ROOT:-${CODEX_SOURCE_DIR:-/tmp/codex-handbook-final-rust-v0.144.6}}"
SOURCE_DIR="$SOURCE_ROOT/codex-rs"
COMMIT=5d1fbf26c43abc65a203928b2e31561cb039e06d
EXPECTED_LOCAL_PACKAGES=132
export ARCHIVE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/codex-part4.XXXXXX")"
export ARCHIVE_CODEX_RS="$ARCHIVE_DIR/codex-rs"
export CARGO_TARGET_DIR="$ARCHIVE_DIR/target"
export CARGO_TERM_COLOR=never
trap 'rm -rf "$ARCHIVE_DIR"' EXIT
test "$(git -C "$SOURCE_ROOT" rev-parse HEAD)" = "$COMMIT"
test "$(git -C "$SOURCE_ROOT" describe --tags --exact-match HEAD)" = rust-v0.144.6
test -f "$SOURCE_DIR/Cargo.toml"
source_status="$(git -C "$SOURCE_ROOT" status --porcelain)"
test -z "$source_status"
test -z "${CODEX_SANDBOX_NETWORK_DISABLED+x}"
test -z "${CODEX_SANDBOX+x}"
git -C "$SOURCE_ROOT" archive "$COMMIT" | tar -x -C "$ARCHIVE_DIR"
cd "$ARCHIVE_CODEX_RS"
cp Cargo.lock Cargo.lock.before
cargo update --workspace --offline
cargo metadata --locked --format-version 1 --no-deps >workspace-metadata.json
jq -e --argjson expected "$EXPECTED_LOCAL_PACKAGES" '
(.packages | length) == $expected and
(.workspace_members | length) == $expected and
all(.packages[]; .version == "0.144.6")
' workspace-metadata.json >/dev/null
jq -r '.packages[].name' workspace-metadata.json | LC_ALL=C sort >workspace.names
awk -v expected="$EXPECTED_LOCAL_PACKAGES" '
BEGIN { RS = "\\[\\[package\\]\\]"; count = 0; bad = 0 }
/version = "0.0.0"/ {
count++
if ($0 ~ /source = / || $0 ~ /checksum = /) bad++
name = ""
fields = split($0, field, "\n")
for (i = 1; i <= fields; i++) {
if (field[i] ~ /^name = "/) {
name = field[i]
sub(/^name = "/, "", name)
sub(/"$/, "", name)
}
}
if (name == "") bad++
else print name
}
END { exit !(count == expected && bad == 0) }
' Cargo.lock.before | LC_ALL=C sort >lock.names
cmp -s workspace.names lock.names
diff_code=0
diff -U0 Cargo.lock.before Cargo.lock >Cargo.lock.diff || diff_code=$?
test "$diff_code" -eq 1
awk -v expected="$EXPECTED_LOCAL_PACKAGES" '
/^--- / || /^\+\+\+ / || /^@@ / { next }
/^-version = "0.0.0"$/ { removed++; next }
/^\+version = "0.144.6"$/ { added++; next }
/^[+-]/ { unexpected++ }
END { exit !(removed == expected && added == expected && unexpected == 0) }
' Cargo.lock.diff
cp Cargo.lock Cargo.lock.calibrated
run_checked_test() {
label=$1
expected=$2
test_name=$3
shift 3
log="$ARCHIVE_DIR/$label.log"
if ! "$@" >"$log" 2>&1; then
sed -n '1,240p' "$log"
return 1
fi
if ! grep -F "$test_name" "$log" >/dev/null; then
sed -n '1,240p' "$log"
return 1
fi
if ! grep -Eq "Summary( \\[[^]]+\\])?:?[[:space:]]+${expected} tests? run:[[:space:]]+${expected} passed" "$log"; then
sed -n '1,240p' "$log"
return 1
fi
if grep -Eqi 'Skipping test|skip_if_' "$log"; then
sed -n '1,240p' "$log"
return 1
fi
sed -n '1,240p' "$log"
}
cargo build --locked -p codex-rmcp-client --bin test_stdio_server
run_checked_test network 1 \
network_policy_decision::tests::denied_network_policy_message_for_denylist_block_is_explicit \
just test --locked -p codex-core --lib --no-capture \
-E 'test(=network_policy_decision::tests::denied_network_policy_message_for_denylist_block_is_explicit)'
run_checked_test skill 1 suite::skills::user_turn_includes_skill_instructions \
just --set rust_min_stack 16777216 test --locked -p codex-core --test all --no-capture \
-E 'test(=suite::skills::user_turn_includes_skill_instructions)'
run_checked_test mcp 1 suite::rmcp_client::stdio_server_round_trip \
just --set rust_min_stack 16777216 test --locked -p codex-core --test all --no-capture \
-E 'test(=suite::rmcp_client::stdio_server_round_trip)'
run_checked_test dynamic-tools 1 \
suite::v2::dynamic_tools::dynamic_tool_call_round_trip_sends_content_items_to_model \
just test --locked -p codex-app-server --test all --no-capture \
-E 'test(=suite::v2::dynamic_tools::dynamic_tool_call_round_trip_sends_content_items_to_model)'
run_checked_test code-mode 1 \
suite::code_mode::code_mode_get_context_remaining_returns_structured_result \
just --set rust_min_stack 16777216 test --locked -p codex-core --test all --no-capture \
-E 'test(=suite::code_mode::code_mode_get_context_remaining_returns_structured_result)'
run_checked_test plugin 1 \
provider::tests::environment_descriptor_binds_every_manifest_resource \
just test --locked -p codex-plugin --lib --no-capture \
-E 'test(=provider::tests::environment_descriptor_binds_every_manifest_resource)'
cmp -s Cargo.lock.calibrated Cargo.lock
source_status="$(git -C "$SOURCE_ROOT" status --porcelain)"
test -z "$source_status"这些测试的 source range 用来限定断言强度。network 锚点只检查 denylist 消息;Skill 锚点含 Windows path 与 network skip;MCP 锚点需要单独构建 stdio fixture;Dynamic Tools 与 Code Mode 依赖各自 mock transport;Plugin 锚点是纯 loader unit test。它们不是六个平台都可无条件复现的同一种集成测试。
Feature 成熟度
Feature 的 Stage 仍然只描述 feature catalog 的发布状态,不证明某条能力在当前 session 已声明、exposure、获批或执行。固定版本里 CodeMode 是 UnderDevelopment 且默认关闭,CodeModeHost 是 Stable 且默认开启;这两个相邻 flag 已足以说明 orchestration engine 与 host wiring 不能用一个“Code Mode 已开启”概括。
NetworkProxy 是默认关闭的 Experimental feature;Apps 与 Plugins 标为 Stable 且默认开启,部分 MCP naming 和 app path flag 却仍是 UnderDevelopment 或 Removed。Stage 与 default 只能描述 catalog,不能替代每条路径的 config、policy、host support 与 runtime evidence。
平台、fixture 与宿主限制
| 边界 | 固定版本里的限制 | 不能外推的结论 |
|---|---|---|
| network | proxy、sandbox 与 private-address 行为依赖平台和实际连接目标 | reviewer 存在就能覆盖 deterministic deny |
| Skill | 命名测试对 target Windows path 有 skip,并依赖 mock Responses server | 所有 filesystem 对同一路径给出相同结果 |
| MCP | stdio round trip 需要先构建 test_stdio_server,本机还需要 RUST_MIN_STACK=16777216 | fixture 缺失是 runtime 的最终失败;增大栈证明业务逻辑有缺陷 |
| Dynamic Tools | app-server test 需要外部宿主按 request id / call id 返回数据 | Core 持有宿主 executor,或任意 URL image 都可进入模型 |
| Code Mode | 受 feature、嵌入 runtime、cell gate、nested tool 与 cancellation 共同约束 | JavaScript 执行环境自动拥有所有工具和权限 |
| Plugin | resource locator 可以是 local 或 environment,active/error/policy 仍会裁剪结果 | manifest 声明的每个资源最终都对模型可见 |
Skill、MCP 和 Code Mode 的 stack 设置只是在这台机器上排除 harness 噪声;最终 1 passed 只证明各自固定 fixture 下的命名路径,不证明所有 server、transport 或宿主环境都成立。先确认测试确实运行,再把结果限制在 source setup 允许的范围内。
工作台到这里停止。先进入第 19 章逐项验证 policy boundary;完成第 24 章后,第四部也随之结束。第 25 章从一次真实写入重新起步,第 30 章再把 Core 状态放到 client protocol 边界观察。
网络访问为什么另有一条决策链
从 execution attribution 追到 network proxy 的 deny、private-network、allowlist,再进入 NetworkApprovalService、permission hook、Guardian 或用户审批,解释哪些拒绝可以申请放行,哪些拒绝根本不会生成审批上下文。
第 18 章停在一个仍然存活的进程上。它已经离开当前 tool future,却还可能通过 session 的 ProcessStore 被继续寻址。现在假设这个进程执行 curl:真正到达代理的不是“某条命令准备联网”这一高层意图,而是一条带 host、port、protocol 的 HTTP 或 SOCKS 请求。
这也是网络访问另开一条决策链的原因。shell 审批回答的是命令能否继续执行;network proxy 必须对实际目标再判断一次。命令获批不代表任意域名随之获批,Guardian 的 allow 也不能覆盖代理更早做出的显式 deny。
固定版本的顺序可以先压成一句话:
execution attribution
-> explicit deny
-> conditional local/private guard
-> allowlist
-> only a `NotAllowed` result (normally an allowlist miss) may call the decider
-> approval service
-> permission hook
-> Guardian or user reviewer
这条链里最重要的边界不在最后的 approve / deny 按钮,而在“什么请求有资格走到 reviewer”。
先找回这次访问属于哪次执行
managed proxy 是 session 级共享对象,网络连接却来自某次具体执行。begin_network_approval 为调用登记 registration_id、turn、command、environment 与 cancellation token;Linux seccomp 路径还会生成另一个 attribution token,建立 execution-scoped proxy。registration id 是 core 的 execution id,attribution token 只是共享 proxy ingress 的内存查表键,两者不能混成一个身份。
trusted Linux bridge 会在应用协议之前写入一个短 frame。BindConnectionAttribution 读出 token,向 NetworkProxyState 查 execution-scoped state;未知 token 或已绑定 environment 不一致会在 ingress 处返回 PermissionDenied。成功时,scoped state 被放进这条 TCP stream 的 extensions,后续 HTTP / SOCKS handler 才能从同一连接拿到 environment 与 execution id。
这里有一个容易写过头的地方:首字节不是 attribution frame 的 magic 时,读取函数返回 None,代理继续使用 base state。源码证明的是“受信 bridge 可以给连接补上 execution attribution”,不是“所有代理连接都强制携带 token”,更不是 token 本身提供了密码学认证。
到了 core,NetworkApprovalService 再用 request 上的 execution id 查 active call,并核对 environment。这里必须区分“没有 owner”与“没有 attribution”。
带 execution_id 的请求如果找不到 active call,或 environment 不匹配,整个 attribution 才是 None。只带 environment_id 时,即使 active calls 有多个或一个也没有,service 仍返回带 environment 的 attribution,只把 owner_call 留为 None;后续仍可用当前 turn context 和合成的 network-access 命令继续 cache、gate 与 reviewer。
两个 id 都没有时,唯一 active call 才能用来推断 owner。多个并发调用无法安全归属,整个 attribution 返回 None;没有 active call 则返回一份无 owner 的空 attribution,再尝试由 turn context 补 environment。attribution 在这里负责把网络结果尽量交还正确 owner,不负责放宽 policy。
三道固定策略先于任何 reviewer
proxy 将 host 规范化后按固定顺序判断。这个顺序直接写在 NetworkProxyState::host_blocked 里:
| 次序 | 判断 | 失败 reason | 能否进入 decider |
|---|---|---|---|
| 1 | explicit denylist | Denied | 不能 |
| 2 | local / private network guard | NotAllowedLocal(有例外) | 不能 |
| 3 | configured allowlist | NotAllowed(通常是 miss) | 有 decider 时可以,无 decider 时拒绝 |
denylist 永远先于 allowlist。即使 allowlist 是全局 *,命中 deny 的 host 也先返回。
local/private gate 只在 allow_local_binding 关闭时运行。精确写入 allowlist 的 local、loopback 或 IP literal,可以通过这一次 host_blocked 检查;*、子域通配和其他 wildcard 不算精确例外。hostname 一旦解析到 non-public IP,即使已经命中 allowlist,也仍会被挡住。
通过这层例外还不等于连接已经建立。direct connector 会按最终 socket address 再拒绝 non-public IP;man-in-the-middle(MITM,中间人代理)的 inner HTTPS 请求也会在 CONNECT 之后重跑 local/private 检查。对可解析 host 来说,allowlist 未配置或未命中通常产生 NotAllowed;格式无法解析的 host 在本 tag 也复用这个 reason。因此,严格说 decider 的入口是 reason,而不只是 allowlist miss。
源码里的“private”实际比 RFC1918 更宽。IPv4 还覆盖 loopback、link-local、unspecified、multicast、broadcast、CGNAT、TEST-NET、benchmark 与 reserved ranges;IPv6 覆盖 mapped IPv4、loopback、unspecified、multicast、unique-local 与 link-local。因此正文里把这层只叫“内网 IP 检查”会漏掉它的 fail-closed 范围。
evaluate_host_policy 按 reason 只对 HostBlockReason::NotAllowed 调用动态 NetworkPolicyDecider;在这个固定 tag 中,正常的已解析 host 上它通常代表 allowlist miss,但 Host::parse 失败也会复用同一个 reason。Denied、NotAllowedLocal 等其余 block reason 直接变成 source = BaselinePolicy、decision = Deny。所以后面的 service cache、hook、Guardian 或用户 reviewer 都不能覆盖显式 deny 或 private-network guard;“可覆盖 allowlist miss”是常见路径,不是对所有 NotAllowed 输入的语法保证。
flowchart TB
accTitle: 网络目标从 attribution 到 reviewer 的决策链
accDescr: 请求先尝试绑定 execution,再依次检查显式 deny、条件性的 local 或 private 限制与 allowlist;显式 deny 和未通过 private guard 的请求直接终止,只有 NotAllowed 通常为 allowlist miss 才可进入 NetworkApprovalService,并在 hook 后路由到 Guardian 或用户 reviewer
REQUEST["HTTP / SOCKS request"] --> ATTR["execution attribution"]
ATTR --> DENY{"explicit deny?"}
DENY -->|yes| HARD_DENY["BaselinePolicy / Deny"]
DENY -->|no| PRIVATE{"local or non-public blocked after literal exception?"}
PRIVATE -->|yes| PRIVATE_DENY["BaselinePolicy / Deny"]
PRIVATE -->|no| ALLOWLIST{"allowlist match?"}
ALLOWLIST -->|yes| ALLOW["Allow"]
ALLOWLIST -->|no| DECIDER{"decider installed?"}
DECIDER -->|no| MISS_DENY["BaselinePolicy / Deny"]
DECIDER -->|yes| SERVICE["NetworkApprovalService"]
SERVICE --> GATES{"attribution, cache and gates"}
GATES -->|fail| DECIDER_DENY["Decider / Deny"]
GATES -->|continue| HOOK["permission-request hook"]
HOOK -->|allow or deny| RESULT["NetworkDecision"]
HOOK -->|no decision| ROUTE{"review route"}
ROUTE -->|auto-review| GUARDIAN["Guardian"]
ROUTE -->|manual| USER["user reviewer"]
GUARDIAN --> RESULT
USER --> RESULT
图中的 private 分支表示 host_blocked 在处理完精确 local-literal 例外后仍返回 NotAllowedLocal,不是所有 local literal 的无条件硬拒绝;而且它只描述 host policy,direct upstream connector 和 MITM inner request 还会按连接时的 non-public address 再检查一次。
这张图只画 network target policy。普通命令可能在首次 sandbox attempt 前已经经过一次 exec approval;那次批准不会删除图里的前三道网络检查。
Ask 是一张窄门票
proxy 的类型有一点反直觉:NetworkDecision 只有 Allow 和 Deny { ... } 两个外层 variant,动态 decider 想申请审批时会返回 NetworkDecision::Deny { decision: Ask, source: Decider }。这表示当前请求仍被阻断,但阻断携带了一张“可以申请”的票;它不等于拒绝已经被推翻。
core 对这张票做了更严格的投影。NetworkPolicyDecisionPayload 必须同时满足 decision == Ask 与 source == Decider,随后还必须有 protocol、非空 host,才能产生 NetworkApprovalContext。任何 Deny,包括 Deny + Decider,都会得到 None。
orchestrator 收到 managed-network sandbox denial 后先尝试提取这个 context。只要 payload 存在而 context 不存在,它就把原始 SandboxErr::Denied 返回,不构造 retry approval。显式 deny 因此在 Guardian 之前已经结束;Guardian 没有拿到 request,自然无从把 deny 翻成 allow。
core 还为这条边界提供了明确文案:denylist block 会告诉模型,domain 被 policy 显式拒绝,cannot be approved from this prompt。这不是 Guardian 的风险判断,而是 deterministic policy 的终态说明。
NetworkApprovalService 拥有什么
NetworkApprovalService 是 session 里的协调者,不是最终 enforcement engine。它持有四组状态:active call 与 outcome、相同 host 正在等待的 approval、session-approved hosts、session-denied hosts。host cache key 由 environment、lowercased host、protocol 与 port 组成;因此一次 https approval 不会顺手批准同 host 的任意 protocol、port 或 environment。
service 处理 NotAllowed(通常是 allowlist miss)时的真实顺序是:
- 用 execution / environment 找 owner;只有整体 attribution 缺失才拒绝,带 environment 但
owner_call=None的请求仍可继续。 - 先查 session deny cache,再查 allow cache。
- 用同一个
HostApprovalKey合并并发等待者,只有第一个 pending request owner 创建 reviewer request;这不要求owner_call存在。 - 缺 active turn、缺 environment、Permission Profile 不是
Managed,或 approval policy 为Never时 fail closed。 - permission-request hook 有决定就直接采用;没有决定才路由 Guardian 或用户。
hook 也属于 reviewer 之前的 governor。Allow 产生本次放行,Deny 记录 policy outcome 并唤醒相同 host 的等待者。只有 hook 没有返回决定时,service 才根据 routes_approval_to_guardian 选择自动 reviewer;否则发送普通 command approval event 给用户。
review 结果再被折回三个内部状态:allow once、allow for session、deny。ApprovedForSession 才写 session allow cache;deny amendment 才写 session deny cache。普通 denial 不会永久污染后续 session 请求,重复请求仍可能重新走 reviewer。
Guardian 是通用 reviewer
GuardianApprovalRequest 同时覆盖 shell、Unified Exec、Unix execve、apply patch、network access、MCP tool call 与 request permissions。network 只是其中一个 variant,携带 target、host、protocol、port,以及可选的触发命令。Guardian 因而是这套审批系统的一般 reviewer,不是 network policy 的 owner。
只有 OnRequest 或 Granular 且 approvals_reviewer == AutoReview 时,allowed approval prompt 才改道 Guardian。源码注释也保留了前置边界:更早的控制层仍可能 block action。Guardian review 对 timeout、review session error、prompt build error 与 parse error fail closed;取消会记录为 Aborted 并返回 ReviewDecision::Abort,而 NetworkApprovalService 把 Abort 与 Denied 一样收口为 pending deny。正常的 Allow / Deny assessment 才映射成普通 ReviewDecision::Approved 或 Denied。
不要给固定版本补一个不存在的配置
第 17 章已经列过 GranularApprovalConfig 的 baseline。固定 tag 只有五个 gate:sandbox_approval、rules、skill_approval、request_permissions、mcp_elicitations。其中没有 network_approval。把下面这种 proposal 写成当前可用配置,会制造一条源码中不存在的控制面:
# 仅为未落地提案示意;rust-v0.144.6 不接受这个字段。
[approval_policy.granular]
network_approval = true
当前网络审批是否能进入动态链,由已经落地的组合决定:session 是否配置 managed network requirements、proxy 是否安装 decider、Permission Profile 是否为 Managed、approval policy 是否不是 Never。start_proxy 的 enable_network_approval_flow 只是 core 调用的内部 bool 参数;它不是 serde 字段,也不是用户可写的 network_approval 配置。
policy amendment 已落地,但入口仍受 context 限制
proposed_network_policy_amendments 与虚构的 granular config 字段不是一回事。用户审批 event 可以为当前 NetworkApprovalContext.host 生成 allow / deny amendment 候选。收到 amendment 后,session 先验证 amendment host 与刚刚批准的 host 规范化后完全一致,再更新运行中 proxy 的 allowlist 或 denylist,最后追加 exec-policy network rule。
这条持久化链仍然改变不了显式 deny 的顺序。amendment proposals 依赖一个已经存在的 NetworkApprovalContext;explicit deny 不产生 context,所以当前 prompt 连 amendment 入口都到不了。即使 session cache 曾批准过同一个 host,proxy 也只在 host_blocked 返回 NotAllowed 时询问 decider,后来加入的 baseline deny 仍会先挡住请求。
另一个失败边界发生在持久化阶段:service 对 amendment persistence error 发 warning,但当次 resolved decision 仍按 allow-for-session 或 deny 收口。运行中 proxy update 与磁盘 exec-policy append 不是原子事务,不能把“本次 reviewer 已做决定”写成“持久规则一定完整落盘”。
指定实验:确认 deny 不是零测试假绿
命令只在第四部源码工作台创建并校准后的 disposable archive 副本中运行。固定 checkout 只负责确认 tag、commit 和导出源码;进入临时副本的 codex-rs 目录后执行:
: "${ARCHIVE_CODEX_RS:?先执行第四部导读的 archive 准备脚本}"
cd "$ARCHIVE_CODEX_RS"
just test --locked -p codex-core --lib denied_network_policy_message_for_denylist_block_is_explicit
2026-07-21 的真实结果中,codex_core library harness 找到并运行了目标测试:
PASS ... codex-core::lib network_policy_decision::tests::denied_network_policy_message_for_denylist_block_is_explicit
Summary ... 1 test run: 1 passed
后续 binary / integration harness 如果被 name filter 排除,nextest 会显示没有命中的测试,不构成这次实验的成功证据。门禁是同时看到完整测试名、PASS 与 library target 的 1 passed;如果目标 harness 也是 zero-match,或者测试带 runtime skip,这次实验就失败。
这条测试只证明 denylist 文案映射,不证明完整代理、Guardian 与 UI 的端到端行为。显式 deny 不进入 Guardian 的证据仍来自三段可穷尽的控制流:host_blocked 先返回 Denied,evaluate_host_policy 不为它调用 decider,core 也只把 Ask + Decider 转成 approval context。
用 owner / executor / governor 收口
网络链可以落在下面这张 matrix 里。owner 保存长期或 session 状态,executor 协调或执行当前动作,governor 决定它能否继续;同一个组件偶尔承担两列职责,但不能因此抹掉接口。
| 对象 | owner | executor | governor | 交给第 20 章的接口 |
|---|---|---|---|---|
| execution attribution | service active calls + proxy token map | trusted bridge + BindConnectionAttribution | token lookup、environment match;unknown/mismatched execution 或无标识且多 active calls 时 fail-closed | 外部能力执行时,谁把调用重新绑定到 turn / environment? |
| network target request | scoped NetworkProxyState | HTTP / SOCKS proxy | explicit deny → 条件性 private guard → allowlist;连接层再检查 | 能力声明的 network 需要怎样投影成真实 target policy? |
| approval candidate | pending host approval + session cache | NetworkApprovalService 协调并派发 | permission hook、Guardian 或用户 reviewer;Ask + Decider context 与 policy gates | capability 被发现后,什么证据让它有资格请求审批? |
| durable amendment | runtime proxy policy + exec-policy file | persist_network_policy_amendment | exact approved host、managed constraints | 能力带来的长期授权由谁保存,又如何限制作用域? |
持久化 write failure 不属于 governor 的批准条件,而是 failure boundary:service 会发 warning,但当次 resolved decision 仍按 reviewer 的 allow-for-session 或 deny 收口;运行中 proxy 更新与磁盘 exec-policy append 也不是原子事务。
第 20 章不会继续扩展 network proxy,而是换一个入口追踪 Skills:能力怎样被发现、筛选并注入当前 session,再把边界交给 SkillsService、mention collector 和 context fragment 这些实际 owner。见第 20 章:Skills 的发现与注入。