模型返回的 Shell 参数,为什么还不是一条进程
从 FunctionCall.arguments 的 JSON 字符串出发,区分 classic 与 Unified Exec 的 typed payload、handler command、执行 envelope、exec policy 投影和 approval cache identity。
"{\"cmd\":\"rg -n 'TODO' src\",\"workdir\":\"/workspace/project\",\"tty\":false}"
这是一段真实 ResponseItem::FunctionCall.arguments 形态:外层值是 String,字符串内部才是一份 JSON object。cmd、workdir 和 tty 看起来已经足够运行命令,但此时 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
模型配置里能看到 Default、Local、UnifiedExec、Disabled、ShellCommand。这些值参与 model/tool selection:在当前 feature 组合下,Default 与 Local 会被映到 ShellCommand,Unified Exec 也可能因 feature 或平台条件回落。它们没有定义三种 classic wire payload。
固定版本协议仍保留 ResponseItem::LocalShellCall 和 LocalShellAction::Exec,旧 action 甚至带 command: Vec<String>、working_directory 与 env。但当前 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 的 ShellCommandToolCallParams 以 command: String 为中心,另带可选 workdir、login、timeout_ms、sandbox/additional permissions、prefix_rule 与 justification。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 fields | requested 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 之前还有一段机械改写:
- local shell snapshot 可能包住 shell script,并重放经过约束的环境;remote environment 跳过 snapshot;
- runtime-owned package/zsh paths 可能 prepend 到
PATH; - elevated Windows sandbox 可能禁用 PowerShell profile;PowerShell script 还会加 UTF-8 前缀;
- sandbox transform 可能把原 program/args 包进 Seatbelt、Linux helper 或 Windows launcher,并派生
arg0; - 最终才从 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,以及可用的 git、just、cargo、cargo-nextest、jq、awk、tar 与 cmp。
本章只增加一个精确 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 envelope | B. policy projection | C. approval identity |
|---|---|---|
actual handler Vec<String> argv、effective command cwd、runtime-built env、environment id、shell type/mode、Unified tty/yield/output budget | commands_for_exec_policy(actual argv) 得到的 segments、command_origin、used_complex_parsing,以及原始 prefix hint | canonicalize_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/root | unsupported/empty 时保留原 wrapper argv;heredoc prefix 只证明 executable rule input | cache 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 在本章完成了可审计的三份投影;进程仍在下一道边界之外。