青雲的博客
拆开 Codex 第三部:模型输出怎样改变机器 第 16 章

模型返回的 Shell 参数,为什么还不是一条进程

从 FunctionCall.arguments 的 JSON 字符串出发,区分 classic 与 Unified Exec 的 typed payload、handler command、执行 envelope、exec policy 投影和 approval cache identity。

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

"{\"cmd\":\"rg -n 'TODO' src\",\"workdir\":\"/workspace/project\",\"tty\":false}"

这是一段真实 ResponseItem::FunctionCall.arguments 形态:外层值是 String,字符串内部才是一份 JSON object。cmdworkdirtty 看起来已经足够运行命令,但此时 Codex 还没有选定 environment,没有把脚本文本放进具体 shell 的 argv,没有构造 runtime-owned env,也没有形成 policy segments、approval cache key 或最终 launch argv。

协议注释直接说明 Responses API 返回的是“包含 JSON 的字符串”。ToolRouter::build_tool_call 只保留这个 raw string,把它装进 ToolPayload::Function { arguments };真正的 typed decode 发生在 handler 内部,由 serde_json::from_str 完成。第一层协议解码得到 ResponseItem,第二层才把 arguments 字符串解成某种工具参数。

先确认 live route:shell config 值不等于 payload

模型配置里能看到 DefaultLocalUnifiedExecDisabledShellCommand。这些值参与 model/tool selection:在当前 feature 组合下,DefaultLocal 会被映到 ShellCommand,Unified Exec 也可能因 feature 或平台条件回落。它们没有定义三种 classic wire payload。

固定版本协议仍保留 ResponseItem::LocalShellCallLocalShellAction::Exec,旧 action 甚至带 command: Vec<String>working_directoryenv。但当前 ToolRouter::build_tool_call 的 match arm 只执行 FunctionCall、client tool search 和 custom tool call;LocalShellCall 落入 _ => Ok(None)。因此旧协议里的 env 不能拿来证明 live 模型可向当前 shell handler 传环境变量。

live shell 调用都从 FunctionCall 进入 registry。工具名决定 raw string 最后解成哪一种 typed payload:shell_command 走 classic handler,exec_command 走 Unified Exec handler。

两套 typed payload,控制面并不相同

classic 的 ShellCommandToolCallParamscommand: String 为中心,另带可选 workdirlogintimeout_ms、sandbox/additional permissions、prefix_rulejustification。handler 先从 selected environment 取 base cwd,再解析 workdir;然后选 environment shell 或 session user shell,调用 Shell::derive_exec_args(command, use_login_shell)。到这里,脚本文本才成为 Vec<String>,例如 Unix shell 通常形成 [/bin/zsh, -lc, <script>] 这一类 handler argv。

classic payload 没有 tty,也没有向模型暴露可调的输出预算。timeout_ms 确实限制命令运行;缺省值先变成 ExecExpiration::DefaultTimeout,等待时落到固定的 10000 ms。

Unified Exec 的 typed payload 是另一组控制面:

字段handler 含义不能推出什么
cmd交给 selected shell 的脚本文本还不是 handler argv
shell / login选择 shell 与 login 语义;受配置和 environment 限制不能绕过远端报告的 shell 类型
tty是否分配 pseudo-terminal(PTY,伪终端),缺省 false不决定命令权限
yield_time_ms首轮调用等待输出多久,缺省 10000 ms不是进程 runtime timeout
max_output_tokens返回模型前的结果格式化预算;serde 缺省先是 None,格式化阶段再解析为 10000 token不限制子进程实际输出量或寿命
permission fieldsrequested sandbox/additional permissions、理由与 prefix hint不保证以原值进入 attempt

同一个 JSON 字符串还会被独立解成 ExecCommandEnvironmentArgs { environment_id, workdir }。handler 先用 environment_id 选择 step snapshot 中的 environment,再以该 environment 的 cwd 为 base 解析 workdir;之后才用相同 raw JSON 解 ExecCommandArgs。这两次 typed projection 服务不同所有者,不能画成后一次覆盖前一次。

远端 environment 强制使用 UnifiedExecShellMode::Direct。若模型传了 shell,handler 只校验它与远端报告的 shell type 一致,随后清掉 requested shell,再由远端 native shell 派生 command。local zsh-fork mode 则有自己的限制;这些都是 handler command 形成前的 mode 选择。

handler command 先分流,ordinary shell 再形成三份材料

参数 decode 的共同出口可以叫 handler command:已得到 Vec<String> argv 和 shell type,并且 selected environment、effective command cwd 也已知。classic 与 Unified 都先尝试 intercept_apply_patch;识别成功就回到第 15 章的 patch runtime 并提前返回。只有 ordinary shell path 才继续形成下面三份材料。它们共享同一个 ordinary command 输入,却各自服务不同机制。

flowchart TB
  accTitle: Shell 参数到三份执行材料
  accDescr: FunctionCall 的 raw JSON string 经 typed decode 形成 handler command,先尝试 apply_patch interception;识别成功回第十五章并提前离开 shell 分支,不进入 ordinary shell 的 policy projection、shell approval-cache identity 或 shell launch path,ordinary shell 才并列形成三份材料
  RAW["FunctionCall.arguments<br/>raw JSON String"] --> PAYLOAD["ToolPayload::Function<br/>same raw String"]
  PAYLOAD --> TYPED{"tool name selects typed decode"}
  TYPED -->|shell_command| CLASSIC["ShellCommandToolCallParams"]
  TYPED -->|exec_command| UNIFIED["ExecCommandArgs<br/>plus EnvironmentArgs"]
  CLASSIC --> HANDLER["handler command<br/>Vec argv + shell type"]
  UNIFIED --> HANDLER
  HANDLER --> INTERCEPT{"intercept_apply_patch?"}
  INTERCEPT -->|recognized| PATCH["apply_patch runtime / early return<br/>Chapter 15; leave ordinary shell path"]
  INTERCEPT -->|ordinary shell| ORDINARY["ordinary shell path<br/>same handler command"]
  ORDINARY --> ENVELOPE["A execution envelope<br/>argv cwd env mode tty budgets<br/>requested and effective permissions"]
  ORDINARY --> POLICY["B policy projection<br/>segments origin complex flag"]
  ORDINARY --> APPROVAL["C approval identity<br/>canonical command plus context"]
  ENVELOPE -. "Chapter 17 decisions" .-> ATTEMPT["approval and sandbox attempt"]
  POLICY -. "policy input" .-> ATTEMPT
  APPROVAL -. "cache lookup" .-> ATTEMPT
  ATTEMPT -. "Chapter 18" .-> LAUNCH["runtime rewrite and launch argv"]

把 canonical command 先送进 policy、再把 policy 结果送进 spawn,会得到一条看似顺滑却不符合源码的链。exec policy 读取实际 handler argv;approval identity 也从同一 handler argv 独立 canonicalize;execution envelope 保留实际运行需要的字段。canonical result 不会写回另外两份材料。

A:execution envelope 保存实际执行上下文

classic 的 ExecParams/ShellRequest 与 Unified 的 ExecCommandRequest/UnifiedExecRequest 类型不同,但可以按同一组问题阅读:

  • handler argv 是什么,shell type/mode 是什么;
  • 命令实际在哪个 cwd 执行;
  • runtime 构造了什么 env;
  • Unified 是否分配 tty、首轮等多久、怎样格式化返回;
  • 模型请求了什么 permissions,经 sticky/preapproved merge 后 effective permissions 是什么;
  • selected environment 的 identity 和原生 cwd 是什么。

Unified 这里有两个 cwd。cwd 是命令实际工作目录,可以由模型的 workdir 改变;sandbox_cwd 始终保留 selected environment 的原生 cwd/root 语义,作为后续 sandbox policy 的 anchor。workdir 解析成功不代表它能替换 environment root。classic 也允许 process cwd 变化,但其 runtime 与 orchestrator 仍从 turn/environment context 取得 sandbox 输入;不能只看 process cwd 推断 policy root。

permission 字段也要保存 requested/effective 两列。handler 会把当前调用的 requested sandbox/additional permissions 与 turn 已 sticky、已 preapproved 的授权合并,再 normalize。后续 exec policy 甚至会在 preapproved 时按 UseDefault 评估命令,而 runtime request 保存合并后的 effective 值。第 17 章需要同时看到原始请求和有效结果,不能假设 requested permissions 原样传到底。

env 由 runtime 构造,live model args 没有 env 字段

基础环境来自 ShellEnvironmentPolicy。算法先按 inherit 选择父进程环境,再应用默认 exclude、配置 exclude、显式 set 与 include-only。Core 随后注入 CODEX_THREAD_ID 和 active permission profile;Unified 再加入自己的 noninteractive/locale 固定值。进入 runtime 后,network proxy 准备、package path、zsh-fork path、shell snapshot replay 等还可能调整 env 与 PATH

这条链由配置、Session/Turn 与 runtime 共同拥有。classic 与 Unified 的 live model args 都没有任意 env map。旧 LocalShellCall::Exec.env 留在协议兼容层,不在当前 router 的执行 arm 上。

handler argv 仍不是 launch argv

policy 与 approval 都观察 handler argv,但 runtime spawn 之前还有一段机械改写:

  1. local shell snapshot 可能包住 shell script,并重放经过约束的环境;remote environment 跳过 snapshot;
  2. runtime-owned package/zsh paths 可能 prepend 到 PATH
  3. elevated Windows sandbox 可能禁用 PowerShell profile;PowerShell script 还会加 UTF-8 前缀;
  4. sandbox transform 可能把原 program/args 包进 Seatbelt、Linux helper 或 Windows launcher,并派生 arg0
  5. 最终才从 launch request 拆出 program 与 args,交给 PTY、pipe 或远端 exec backend。

因此 handler argv 适合 policy 与 approval 输入,launch argv 才是平台实际启动边界。本章只标出两者之间存在改写,不判断选哪种 sandbox,也不展开 PTY/session 的存活期。

显示 metadata 只有四类,不参与执行

codex_shell_command::parse_command 产出 ParsedCommand::{Read, ListFiles, Search, Unknown},用途是给用户提供 lossy、人类可读的 metadata。它会折叠连续重复项;只要任一局部结果是 Unknown,整个 command 就回退成一条完整 Unknown

这个 parser 不产生执行 argv,不决定 executable,也不负责 exec policy 匹配。它能把 rg TODO src 显示成 Search,不代表 policy 一定允许;它把 git status 显示成 Unknown,也不等于 policy 自动拒绝。执行、显示与 policy 是三套消费者。

B:exec policy 投影拆 segments,不改 handler argv

commands_for_exec_policy(actual handler argv) 先识别 bash/zsh/sh wrapper,再用 tree-sitter-bash 尝试严格的 word-only lowering。只有所有 command 都由静态 words 构成,并且连接符局限于 &&||;|,才得到一组可逐段匹配的 Vec<Vec<String>>。redirect、subshell、command substitution、变量扩展与 control flow 都会让严格 lowering 失败。Windows 另有 PowerShell AST 分支,并将 command_origin 标成 PowerShell,以便使用对应 heuristics。

严格 lowering 成功时:

["/bin/bash", "-lc", "git status && just test"]
  -> [["git", "status"], ["just", "test"]]
  -> command_origin = Generic
  -> used_complex_parsing = false

unsupported 或 empty 并不会在 projection 层自动变成 deny。普通 fallback 保留原 wrapper argv,交给 policy 与 unmatched-command heuristics 继续判断:

["/bin/bash", "-lc", "for f in *; do echo $f; done"]
  -> [["/bin/bash", "-lc", "for f in *; do echo $f; done"]]

heredoc 只提取 single literal-command prefix

heredoc 是一个受限例外。严格 parser 会因 redirect 失败;parse_shell_lc_single_command_prefix 随后只在脚本有 heredoc、没有额外 file redirect、且整个 tree 恰好只有一个 command node 时,提取该 command 的静态 words。fixture 中:

bash -lc "python3 <<'PY'\nprint('hello')\nPY"
  -> [["python3"]]
  -> used_complex_parsing = true
  -> command_origin = Generic

这份 prefix 允许既有 rule 继续匹配 executable。heredoc body 没有成为安全审计材料;它仍可能包含任意输入数据。used_complex_parsing=true 还会关闭自动生成 exec-policy amendment,避免把一次局部 prefix 观察扩写成持久规则。

policy manager 对 segments 做 rule/heuristics evaluation 后,才产出 Forbidden、NeedsApproval 或 Skip。是否 bypass sandbox、是否可写 amendment 是第 17 章的控制决策;本章只固定其输入由 actual handler argv 投影而来。

C:canonicalization 只生成 approval cache identity

canonicalize_command_for_approval(handler argv) 的注释把用途限定为 approval-cache matching。它减少 wrapper 路径差异,但不会改真实 command:

  • 单条 word-only shell script 解包成 inner argv:["/bin/bash", "-lc", "git status"] -> ["git", "status"]
  • bash/zsh/sh 的复合或复杂 script 变成 sentinel、flag 与原样 script:["/bin/bash", "-lc", "git status && just test"] -> ["__codex_shell_script__", "-lc", "git status && just test"]
  • PowerShell wrapper 使用独立 __codex_powershell_script__ sentinel 并保留 script。
  • 其他 argv 原样 clone。

classic approval key 由 environment_id + canonical command + cwd + sandbox permissions + additional permissions 组成。Unified 还把 tty 放进 key,cwd 类型也保留为 PathUri。同一 canonical command 在不同 environment、cwd、sandbox request 或 tty 下不会自动共享 identity。

canonicalization 不修改 handler/launch argv,不参与 prefix policy matching,也不决定 permission、sandbox 或进程。它只是 cache key 的一个字段。把 sentinel 当成 executable,或者用 canonical result 替代 commands_for_exec_policy,都会把两种投影的语义混在一起。

第 15 章留下的 apply_patch 早退分支

第 15 章已经证明 shell 不能一概视为无文件副作用。classic 与 Unified 都在普通 command lifecycle 前调用 intercept_apply_patch(handler argv, cwd, ...)。识别成功时,控制权进入 apply_patch runtime,tracker 收到 effect-aware delta,handler 随即 return;后面的 shell policy/launch 路径不会继续执行。

识别失败才走普通 shell。普通命令当然可以写文件、删目录或启动其他系统,但 shell runtime 没有 AppliedPatchDelta 这类 committed-effect ledger,开始/结束 CommandExecution 时也不给 TurnDiffTracker。所以“没有 TurnDiff”只说明缺少可追踪的 committed text delta,不能反推磁盘未改变。

固定实验:heredoc executable prefix 怎样命中 rule

固定 checkout 的 HEAD 是 5d1fbf26c43abc65a203928b2e31561cb039e06d,tag 是 rust-v0.144.6,工作树保持 clean。它的 workspace manifest 已声明 0.144.6,同 commit 的 Cargo.lock 里 132 个本地 workspace package 仍是 0.0.0;直接在这里执行 just test --locked 会在编译前拒绝更新 lockfile。

archive 和 lock 校准不在本章复制第二遍。本章代码块会加载本仓库的 scripts/codex-handbook-part3.sh,在自己的 shell 中调用 prepare_codex_part3 并注册 cleanup trap;没有博客 checkout 时,把该 helper 文件保存到任意路径,再通过 CODEX_HANDBOOK_PART3_HELPER 指向它。metadata 必须给出 132 个 0.144.6 workspace package;校准前 lockfile 的 132 个 0.0.0、无 source/checksum 条目必须与 workspace name set 完全相同;lock diff 也只能包含 132 组 version replacement。固定 checkout 的前后状态都必须为空,编译输出只能写入 $ARCHIVE_DIR/target

复现前提与第三部共享准备相同:支持 pipefail 的 bash 或 zsh,以及可用的 gitjustcargocargo-nextestjqawktarcmp

本章只增加一个精确 selector。代码块先加载本仓库的 scripts/codex-handbook-part3.sh,在自己的 shell 中调用 prepare_codex_part3 并注册 cleanup trap;没有博客 checkout 时,把该 helper 文件保存到任意路径,再通过 CODEX_HANDBOOK_PART3_HELPER 指向它。下面的断言会拒绝缺失的 archive、错误的 target 位置和不一致的 132 项 name set。run_checked_test 还要求 nextest 实际运行并通过 1 项测试,不能把 zero-match 或 fixture 提前返回当成通过:

SOURCE_ROOT="${SOURCE_ROOT:-/tmp/codex-handbook-final-rust-v0.144.6}"
SOURCE_DIR="$SOURCE_ROOT/codex-rs"
CODEX_HANDBOOK_PART3_HELPER="${CODEX_HANDBOOK_PART3_HELPER:-$PWD/scripts/codex-handbook-part3.sh}"
test -f "$CODEX_HANDBOOK_PART3_HELPER"
source "$CODEX_HANDBOOK_PART3_HELPER"
prepare_codex_part3
trap 'cleanup_codex_part3 "$?"' EXIT
ARCHIVE_CODEX_RS="$ARCHIVE_DIR/codex-rs"
export CARGO_TARGET_DIR="$ARCHIVE_DIR/target"

test -f "$SOURCE_DIR/Cargo.toml"
test "$CARGO_TARGET_DIR" = "$ARCHIVE_DIR/target"
cmp -s "$ARCHIVE_CODEX_RS/workspace.names" "$ARCHIVE_CODEX_RS/lock.names"
cd "$ARCHIVE_CODEX_RS"

run_checked_test exec-policy 1 \
  just test --locked -p codex-core --lib --no-capture \
  -E 'test(=exec_policy::tests::evaluates_heredoc_script_against_prefix_rules)'

关键输出:

PASS [...] exec_policy::tests::evaluates_heredoc_script_against_prefix_rules
Summary [...] 1 test run: 1 passed

fixture 没有打印环境短路信息。nextest 摘要里的 filtered skipped 数量不是这项判断的依据;共享 runner 检查的是选中测试的运行数、通过数和未捕获的 Skipping test... 输出。fixture 构造 bash -lc "python3 <<'PY'\nprint('hello')\nPY",加载显式 prefix_rule(pattern=["python3"], decision="allow"),approval policy 是 OnRequest、permission profile 是 read-only、sandbox request 是 UseDefault。最终断言严格等于:

ExecApprovalRequirement::Skip {
    bypass_sandbox: true,
    proposed_execpolicy_amendment: None,
}

这条通过只证明:single heredoc literal-command prefix 能让 python3 命中显式 allow rule,并在这组输入下得到 Skip { bypass_sandbox: true }。它没有审计 body 安全,没有检查 approval cache,没有启动真实进程,也没有覆盖 sandbox implementation、shell snapshot、PTY、远端 environment 或 Windows PowerShell。

交给第 17 章的三栏材料

第 17 章接手时,不能只拿一条“normalized command”。完整交接按三栏保存:

A. handler command / execution envelopeB. policy projectionC. approval identity
actual handler Vec<String> argv、effective command cwd、runtime-built env、environment id、shell type/mode、Unified tty/yield/output budgetcommands_for_exec_policy(actual argv) 得到的 segments、command_originused_complex_parsing,以及原始 prefix hintcanonicalize_command_for_approval(actual argv) 加 environment id、cwd、sandbox/additional permissions;Unified 额外加 tty
requested sandbox/additional permissions 与 sticky/preapproved merge 后的 effective permissions 都要保留;sandbox_cwd 单独保存 environment 原生 cwd/rootunsupported/empty 时保留原 wrapper argv;heredoc prefix 只证明 executable rule inputcache identity 不回写 argv,也不替 policy 或 permission 做决定

第 17 章:审批、权限与沙箱会使用这三栏输入,解释 exec policy evaluation、requested/effective permissions、approval cache 与 sandbox attempt 怎样组合。那一章的结果才决定是否询问、拒绝、跳过或选择某种 sandbox。

第 18 章:Unified Exec 与进程会话再接 execution envelope 和具体 attempt,追 shell snapshot、PowerShell 适配、sandbox wrapper 之后的 launch argv,以及 spawn、PTY/pipe、首轮 yield、timeout、session id、write_stdin 与 teardown。模型交来的 JSON 在本章完成了可审计的三份投影;进程仍在下一道边界之外。