青雲的博客
拆开 Codex 第三部分 Codex 如何在机器上行动 第 04 章

一条 Shell 命令如何穿过审批、策略与沙箱

跟踪 FunctionCall 如何经过 shell handler、execpolicy、ReviewDecision、SandboxManager 和进程 runtime。

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

模型返回 exec_command,界面弹出审批,用户点了允许,命令随后运行。这个过程很容易被压成一句话:Codex 先审批,再解除沙箱执行。

后半句不成立。审批和沙箱是两个不同问题。审批决定这次执行是否需要人或审查器给出 ReviewDecision;沙箱决定允许尝试以后,进程能读写哪些路径、能否访问网络,以及由哪个平台 backend 包裹。一个命令可以被批准后仍在 Seatbelt、Linux sandbox 或 Windows restricted token 中执行。require_escalated 也只是命令提出的权限请求,不是已经获得批准的事实。

这一章从模型给出的 FunctionCall 开始,一直走到进程输出、后台 session、write_stdin 和取消。重点不在列出所有策略枚举,而在分清每一道检查由谁负责。

本机采集:只验证发布二进制的 debug sandbox

下面记录的是:在本文这台 macOS darwin-arm64 环境读取一份有效 permission profile 后,使用官方 @openai/[email protected] 发布包得到的一次版本与 Seatbelt 行为采集。它是本机实测记录,不是跨环境或跨 profile 的固定输出。图片可点击查看原尺寸。

真实终端裁剪:完整版本命令输出 codex-cli 0.144.6,完整 Codex sandbox 命令输出 trace TRACE_OK、sandbox seatbelt 和 network_disabled 1

真实终端原始裁剪。环境:macOS darwin-arm64、官方 @openai/[email protected] 发布包、已读取的有效 permission profile、工作目录 /tmp。版本命令: npm exec —yes —package=@openai/[email protected] — codex —version;sandbox 命令:

npm exec —yes —package=@openai/[email protected] — codex sandbox — /bin/sh -c ‘printf “trace=%s\nsandbox=%s\nnetwork_disabled=%s\n” “TRACE_OK” “CODEXSANDBOX""CODEX_SANDBOX" "CODEX_SANDBOX_NETWORK_DISABLED”’

。源码基线是 rust-v0.144.6 / 5d1fbf26c43abc65a203928b2e31561cb039e06d 。在这份 profile 下的预期结果:版本命令输出 codex-cli 0.144.6;sandbox 命令输出 trace=TRACE_OKsandbox=seatbeltnetwork_disabled=1 。本次采集与预期一致。截图裁掉了终端标签栏和下方空白,没有改动命令与输出;它不是完整 approval 主链证明,也不替代命令、profile 与源码证据。

完整版本命令:

npm exec --yes --package=@openai/[email protected] -- codex --version

完整 sandbox 实验命令:

npm exec --yes --package=@openai/[email protected] -- codex sandbox -- /bin/sh -c 'printf "trace=%s\nsandbox=%s\nnetwork_disabled=%s\n" "TRACE_OK" "$CODEX_SANDBOX" "$CODEX_SANDBOX_NETWORK_DISABLED"'

本次采集得到的输出(仅代表本文这台 macOS 环境与这份有效 profile)为:

codex-cli 0.144.6
trace=TRACE_OK
sandbox=seatbelt
network_disabled=1

这个实验通过 codex sandbox 调试子命令读取有效 permission profile 并包裹给定命令。它证明的是:本文这台 macOS 环境中的这份发布二进制能进入 Seatbelt,子进程能看到对应 sandbox/network 环境标记,并完成本次 trace 采集。

它没有经过 Agent 的 FunctionCall、execpolicy 和审批 UI,因此不能证明完整 approval 主链;也不能外推 Linux 的 bubblewrap/seccomp、Windows restricted token 或 remote executor 行为。环境变量证明 wrapper 设置了运行标记,不等同于逐条验证所有 filesystem/network rule。

模型返回的还不是进程

模型返回的是一个结构化描述:tool name、arguments 和 call id。ToolRouter::build_tool_call 先把它转换成内部 ToolCall,registry 再按 ToolName 找到具体 runtime。

shell 在当前版本启用时有两组入口:

  • Unified Exec 模式向模型暴露 exec_commandwrite_stdin,同时把旧 shell_command 以 Hidden 方式留在 registry 中;
  • DefaultLocalShellCommand 分支暴露 classic shell_command
  • Disabled 分支不注册任何 shell 工具;当前 step 没有可用 environment 时,规划也会在选择 shell mode 前直接返回。

这不是两个名字指向同一个函数。exec_command 面向可持续的 process session,支持 yield、poll 和后续 stdin;classic shell_command 等一次命令在当前 tool call 中完成。

exec_command handler 解析 environment、cwd、shell、TTY、yield、permission request 等字段,再把命令字符串转换成实际 argv。Direct 模式调用当前 shell 的 derive_exec_args;本地 zsh-fork 模式构造 zsh -czsh -lc。远端 environment 必须使用远端报告的 shell 边界,不能直接拿 host shell 假设目标系统相同。

到这里仍没有 spawn。handler 只是把模型参数变成一份 runtime request,并把 session、turn、call id 和 environment 一起交给后面的 orchestrator/process manager。

approval policy 和 sandbox policy 回答不同问题

可以把两者先压成两个问题:

policy它回答什么典型结果
approval policy这条命令要不要询问、能否自动通过、是否直接禁止SkipNeedsApprovalForbidden
sandbox / permission policy真正执行时可访问哪些文件、网络和附加权限SandboxType、filesystem/network rules、effective permission profile

approval policy 的输入不只有用户配置。execpolicy 会解析命令,检查显式规则,并在未匹配时调用安全 heuristic。原始 decision 是 AllowPromptForbidden,core 再把它转换成执行要求:

Allow / safe fallback -> Skip
Prompt -> NeedsApproval
Forbidden -> Forbidden

Allow 也不能一概理解成“无沙箱全信任”。只有每个解析出的命令段都被显式 allow rule 覆盖,才可能生成 Skip { bypass_sandbox: true }。heuristic 认为命令可在受限环境中尝试时,通常是 Skip { bypass_sandbox: false }

这一区别解释了一个看似矛盾的配置:approval policy 可以是 Never,命令仍在 restricted sandbox 中自动运行。Never 表示不能弹审批,不等于 filesystem/network policy 自动消失。危险命令在某些组合下反而会直接 Forbidden

完整准入与执行链

下面的图刻意拆成两个 subgraph。第一部分决定“能不能尝试”,第二部分决定“如何尝试并怎样结束”。

flowchart TD
    accTitle: Shell 调用穿过审批、沙箱与进程执行
    accDescr: FunctionCall 经 ToolRegistry 和 shell handler 转成命令;execpolicy 决定跳过、询问或禁止,approval 与 SandboxManager 分别处理准入和权限;平台 runtime 启动进程、流式返回输出,并只在允许时处理 sandbox denial 升级。
    FC["ResponseItem::FunctionCall"] --> TR["ToolRouter / ToolRegistry"]
    TR --> H["exec_command 或 shell_command handler"]
    H --> L["命令转换: string -> argv + cwd + env"]

    subgraph Admission["Admission:策略与审批"]
        L --> EP["execpolicy rules + heuristic"]
        EP --> AR{"ExecApprovalRequirement"}
        AR -->|"Forbidden"| REJ["拒绝,返回模型"]
        AR -->|"NeedsApproval"| RV["等待 ReviewDecision"]
        RV -->|"Denied / Abort"| REJ
        RV -->|"Approved"| PASS["审批检查通过"]
        AR -->|"Skip"| PASS
    end

    subgraph Execution["Execution:沙箱与进程"]
        PASS --> SO["sandbox override 计算"]
        SO --> SM["SandboxManager 选择平台 backend"]
        SM --> TX["transform argv / permission profile"]
        TX --> SP["local / Windows / remote spawn"]
        SP --> OUT["stream / yield / completion"]
        OUT --> DENY{"识别 sandbox denial?"}
        DENY -->|"否"| DONE["ToolOutput"]
        DENY -->|"是且策略允许"| ESC["审批升级尝试"]
        ESC --> SM
        DENY -->|"不可升级"| FAIL["denial output"]
    end

图里最重要的是 PASS -> sandbox override。orchestrator 源码也直接用注释分成 1) Approval2) First attempt under the selected sandboxNeedsApproval 获批后只把 already_approved 设为真;随后还要独立计算 sandbox_override_for_first_attempt,再由 SandboxManager 判断是否需要沙箱、选择哪个 backend。

这里还要把 UseDefault 写准:它是 shell call 的 per-command 输入,含义是“不主动请求 sandbox override”。若 requirement 是 NeedsApproval,即使随后得到 Approved,这个组合本身也不会改写首轮沙箱,通常仍由 turn 的 filesystem/network policy、managed-network 状态和 host 能力计算 backend。

但不能把它写成“绝对沿用 active profile”。若显式 execpolicy allow 产生 Skip { bypass_sandbox: true },即使工具参数是 UseDefault,首轮仍可能绕过沙箱。唯一先行的硬限制是 denied-read:这类规则只能由 filesystem sandbox 执行,所以实现会拒绝 bypass,保留 NoOverride

require_escalated 为什么不等于已批准

sandbox_permissions: require_escalated 表示命令希望绕过默认沙箱。justification 在参数解析上是可选值;tool schema 提示模型只在 require_escalated 时使用它,并没有用条件 schema 把它变成必填项。runtime 在没有 retry denial reason 时把它作为用户看到的审批原因,并继续转交 permission-request hooks、guardian ApprovalAction 和 network approval trigger。

它的边界也很清楚:justification 不改变 execpolicy decision,更不会自己授予权限。execpolicy rule 内也可以有一份 rule justification,用来解释 PromptForbidden,那是策略作者写入规则的理由,不是模型随 tool call 传来的这个字段。真正需要经过 execpolicy、用户或审查器审批以及 permission profile 检查的是整条 elevated request。

请求进入 handler 和 execpolicy 后,至少可能得到三种结果:

  1. 当前 approval policy 允许询问,于是变成 NeedsApproval,等待用户或审查器决定;
  2. policy 不允许这种 override,直接拒绝;
  3. 显式 execpolicy rule 已经预授权,变成 Skip

即使审批检查已通过,sandbox override 还要检查 active filesystem policy。若存在 denied-read paths,实现不会为了 elevated request 直接取消沙箱,因为这些拒绝读取规则只有沙箱能执行。sandbox_permissions_preserving_denied_reads 会把请求回退为默认 sandboxed attempt。

因此,“模型传了 require_escalated,所以命令已提权”在三个层面都过早:它还没经过规则、没得到 review decision,也没通过 denied-read 保留检查。

SandboxManager 怎样选择平台实现

SandboxManager::should_sandbox 先回答“这份 policy 是否需要沙箱”,select_initial 再回答“当前 host 有什么实现”。这两个问题也不能合并:policy 需要沙箱,但平台没有 backend 时,选择结果可能是 None,这属于能力边界,不表示 policy 本来允许 unrestricted。

固定版本的本地平台分支大致是:

  • macOS:MacosSeatbelt,argv 被包装为 /usr/bin/sandbox-exec 加生成的 Seatbelt policy;
  • Linux:LinuxSeccomp 这个枚举进入 codex-linux-sandbox helper,默认实现还包括 bubblewrap filesystem view;Landlock 是 legacy fallback,不能只按枚举名写成“只有 seccomp”;
  • Windows:启用时使用 restricted token,并在 launch 阶段选择相应 Windows sandbox session;
  • remote environment:交给远端 exec backend,在执行端按它的平台和 sandbox context 再选择,不能一概说远端不受沙箱。

平台差异还会影响路径和 shell。remote cwd 可以是宿主机无法解析的远端路径;Windows sandbox launch 需要额外包装;本地 PTY 与远端 exec-server 的 stdin/终止协议也不同。源码里的统一 SandboxType 只是 orchestration 语言,不会抹掉这些实现差异。

spawn 之后,Unified Exec 和 classic shell 分开走

两条路径最实际的差异在生命周期。

行为Unified exec_commandclassic shell_command
首次返回等一个可配置 yield;进程仍活着时返回 session id等当前命令结束、超时或取消
后续交互write_stdin、空 poll、控制字符 interrupt没有后台 session id 和 write_stdin
输出后台读取 PTY/transport,持续发 delta;本地合并流通常标为 stdout分别读取 stdout/stderr,并保留 stream 区分
取消已存入 process store 后,tool future 取消不必然杀掉后台进程runtime 等待 teardown;Unix 先 TERM group,必要时 KILL
完成exit watcher 等进程退出,并留 trailing-output grace 后发结束事件当前 tool call 内汇总 exit、stdout/stderr、timeout

Unified process manager 在启动后创建 transcript 和事件 emitter,开始后台 streaming。首次 yield 前若进程还活着,会把它存进 ProcessStore,工具结果携带 session id。后续 write_stdin 可以写 TTY;非 TTY 普通输入被拒绝,只允许特定 interrupt 字节。空 write_stdin 更像长 poll,非空写则保持低延迟。

classic 路径通过 piped stdout/stderr 读取到结束。取消 token 会进入 exec wait;在 Unix 上先向 process group 发送 TERM,短暂等待后再 kill 剩余成员,非 Unix 则走对应平台的 child kill。它更接近“一次调用对应一个完整命令结果”。

只有进入 ProcessStore 的进程,才可能在 turn 取消后继续存活。spawn 失败、入库前取消、thread shutdown 或主动清理 terminal 都是例外。

sandbox denial 不是只看 exit code

一个 sandboxed 命令非零退出,可能只是程序自己的错误。Codex 的 is_likely_sandbox_denied 因此是 heuristic:

  • 没有 sandbox 或 exit code 为 0,直接不是 denial;
  • 在 stdout、stderr 和 aggregated output 中寻找 sandbox 相关关键词;
  • 某些常见 command-not-found/usage exit code 没有关键词时不推断 denial;
  • Linux 的特定 SIGSYS 组合可作为 denial 信号;
  • remote executor 还可以显式上报 sandbox_denied

这套判断的作用,是决定错误是否进入 orchestrator 的 escalation 分支。它不是内核级审计事实。文本碰巧包含关键词可能产生误判,没有关键词的拒绝也可能漏掉,所以正文和日志应写“likely sandbox denial”,不要写成“已证明 policy 拒绝”。

即使识别为 denial,也不是自动无沙箱重跑。orchestrator 还要检查 runtime 是否允许 escalation、approval policy、是否已有可复用批准、network approval,以及 denied-read 是否必须保留。只有这些条件都允许,才有第二次 attempt。

源码依据

本章的源码证据固定在 5d1fbf26c43abc65a203928b2e31561cb039e06d,覆盖工具入口、执行要求、首轮沙箱选择与进程生命周期四类决策。它们来自不同模块,不能用某一个 shell handler 代替整条执行链。

终端截图是完整命令与输出的真实终端原始裁剪,仍不能单独当作完整执行链证明,也不替代这些源码边界。执行依据是命令、有效 permission profile、本次采集文本与源码证据;截图只对应 darwin-arm64、官方 0.144.6 发布二进制和 codex sandbox debug path。若后续 tag、profile 或环境改变,需要重新采集并核验,不能只改图片标题。

失败边界

一条 Shell 调用可能在不同层失败,后续语义也不同:

  • tool name 或 payload 不匹配:registry 返回可交给模型修正的调用错误;
  • 命令解析失败:handler 尚未形成合法 runtime request;
  • execpolicy Forbidden:不会进入用户审批;
  • NeedsApproval 被拒绝:不会启动首轮进程;
  • sandbox transform 失败:policy 通过了,但当前平台无法构造执行请求;
  • 进程普通非零退出:是工具结果,不应自动当 sandbox denial;
  • likely sandbox denial:只有满足 escalation 条件才尝试第二轮;
  • turn 取消:classic 与已后台化 Unified process 的资源结果不同。

排障时先问失败发生在哪一道检查。仅凭“用户点了允许但命令没成功”,无法判断是审批状态没回传、sandbox transform 失败、进程自身退出,还是输出被 denial heuristic 归类。

动手改一个地方

可以为 shell orchestration 增加一条不包含原命令的决策 trace。建议在 ToolOrchestrator::run 里记录:

tool_name
turn_id
approval_requirement = skip | needs_approval | forbidden
reviewed = true | false
sandbox_override = none | bypass_first_attempt
initial_sandbox
attempt = first | escalated
outcome = success | rejected | sandbox_denied | process_error | cancelled

不要记录 cmd、cwd 全路径、justification、env 或 stdout/stderr。若需要关联同一调用,使用已有 call id 的受控 hash 或内部 trace id,不把用户命令复制到 telemetry。

测试应覆盖三条边界:

  1. NeedsApproval 得到 Approved,且权限选择是 UseDefault 后,首轮得到 NoOverride,不能把审批结果直接当成 sandbox bypass;
  2. 显式 allow 生成 Skip { bypass_sandbox: true } 且没有 denied-read 时,即便参数是 UseDefault,首轮也会 bypass;
  3. 有 denied-read restriction 时,无论显式 allow 还是 elevated request,都必须保留沙箱。

再加一个普通非零退出用例,确认不会因为 exit code 本身就标成 sandbox denial。这样 trace 才是在记录真实决策,不是在复述预期 policy。

这一章建立了什么

Shell 工具没有“批准后直接解除沙箱”的捷径。审批、permission profile、平台沙箱和进程结果,是四个必须分别验证的边界。

require_escalated 不是批准,sandbox denial 也不会自动触发无沙箱重试。只看其中一个状态,解释不了命令最终为什么运行、被拒绝或失败。

进程结果返回后,工具 future 产出的 ResponseInputItem 会被转换成 ResponseItem,再交给 record_conversation_items。Shell 执行链到这里完成最后一次控制权交接;这个入口怎样更新工作历史和 rollout,同一批持久数据又怎样在其他层形成查询投影,交给下一章继续追:同一段对话,为什么同时存在三种状态