雨天小六

读懂 Codex(六):Agent 的手——工具、命令、权限与沙箱

· 更新于 2026-07-31 · 专栏:读懂 Codex

#Codex#Agent Runtime#Tool Calling#Sandbox#软件架构

第五章停在一项已经完成的模型输出上:

FunctionCall(
    call_id="call_17",
    name="exec_command",
    arguments={"cmd": "npm test"}
)

这还不是一次命令执行。它只是模型提出的结构化意图。真正改变文件、启动进程或访问网络之前,Codex 还必须回答四个问题:

  1. 模型调用的工具,是否就是这个 Step 曾经展示给它的那一个;
  2. 工具名称和参数应交给哪个执行器;
  3. 这次操作是否需要审批,最终允许接触哪些资源;
  4. 执行结果怎样成为下一次模型采样可以读取的事实。

这四个问题共同构成工具执行链。它不是一个巨大的 if name == ...,也不是“用户允许后直接运行命令”。Codex 把工具描述、路由、生命周期、命令策略、审批、权限和沙箱分成不同层,再用同一份 Step 快照把它们接起来。

工具有给模型看的一面,也有给 Runtime 用的一面

模型不会直接持有 Rust 函数。它看到的是 ToolSpec:工具名称、说明和参数格式。当前实现支持几种不同形状:

ToolSpec模型怎样提交调用典型用途
Function按 JSON Schema 生成参数exec_commandwrite_stdin
Freeform生成一段自定义语法文本apply_patch
Namespace在命名空间内选择具体工具MCP、扩展工具和多工具分组
ToolSearch搜索暂未直接展示的工具Deferred Tool 发现
WebSearch由 Responses 服务执行Hosted Tool

Runtime 需要的是另一面:哪个名字由谁处理,能否并行,怎样执行。ToolExecutor 把两面绑在同一个接口里:

tool_name()                    精确路由名称
spec()                         模型可见定义
exposure()                     在什么界面暴露
supports_parallel_tool_calls() 是否允许并行
handle(invocation)             真正的执行入口

这个绑定很重要。如果工具规格和执行函数由两套互不相关的注册表维护,规格更新而执行器没更新时,模型可能生成 Runtime 无法解释的参数;执行器换了语义而规格仍旧时,副作用会比模型理解的更大。当前源码在 codex-rs/tools/src/tool_executor.rs::ToolExecutor 的注释中直接把“规格与可执行 Runtime 保持绑定”写成接口目标。

不是每个已经注册的工具都要立刻塞进 Prompt。ToolExposure 规定了四种暴露方式:

暴露方式初始工具列表可被本地 Registry 调度Code Mode 中的含义
Direct可作为嵌套工具
Deferred先经 Tool Search 发现
DirectModelOnly只保留普通模型调用入口
Hidden仅供内部兼容或间接调度

还有一类 Hosted Tool 只有模型可见规格,没有本地 Handler,因为副作用由 Responses 服务完成。由此可见,“模型看得见”和“客户端注册了”是两个集合,不能用一张工具名称列表代替。

Deferred Tool 解决的是 Prompt 体积问题。工具数量较多时,Runtime 可以只展示 tool_search,把带搜索元数据的工具留在 Registry 中;模型找到所需工具后再调用它。Hidden Tool 则不允许模型发现,只保留内部调度能力。两者虽然都不在初始列表中,安全含义完全不同。

同一个 Step 同时生成规格和执行表

第四章介绍过 StepContext:它冻结一次模型采样实际使用的环境、MCP Binding、能力发现结果和 AGENTS.md。第六章最关键的字段是 tool_router。源码注释把它定义为:

为这一次采样请求展示并执行的最终工具计划。

建立 Step 时,capture_step_context_with_required_mcp_servers 先刷新已选环境的就绪状态,再捕获 MCP 和扩展能力,最后调用 built_tools。工具规划器从同一批 ToolExecutor 生成两个结果:

ToolRouter
├── model_visible_specs: Vec<ToolSpec>
└── registry: ToolRegistry
    └── ToolName → CoreToolRuntime

build_prompt 从这个 Router 读取 model_visible_specsToolCallRuntime 则保存同一个 Router 和整个 Arc<StepContext>,以后用其中的 Registry 执行调用。即使工具 Future 稍后才运行,它也不会偷偷改用下一次采样刚生成的工具表。

Codex 从同一份 StepContext 生成模型可见工具规格和 ToolRegistry,再把完整工具调用执行并回灌 History 的流程
图 6-1:一个 Step 的工具计划同时产生 Prompt 中的规格和 Runtime 中的执行表。工具调用保留原 StepContext,结果进入 History 后,下一次采样才重新捕获工具与环境。

这条约束解决了一个真实的时序问题。假设一次 Turn 中,远程环境在第一轮采样时仍未就绪,第二轮才变成可用:

Step A:只展示本地 exec_command
Step B:展示带 environment_id 的多环境 exec_command

Step A 返回的调用必须按 Step A 的参数合同解析,不能因为执行发生得晚,就拿 Step B 的环境和 Schema 解释它。下一次采样可以采用新的计划,已经发出的调用不能跨快照漂移。

完整 ResponseItem 才进入工具路由

工具执行从 OutputItemDone 开始,而不是从参数 Delta 开始。handle_output_item_done 收到完整 ResponseItem 后调用 ToolRouter::build_tool_call,当前客户端执行路径主要处理三种输入:

  • FunctionCall 变成 JSON 参数形状的 ToolPayload::Function
  • CustomToolCall 变成自由文本形状的 ToolPayload::Custom
  • execution == "client"ToolSearchCall 变成 ToolPayload::ToolSearch

Hosted Tool Search 不进入本地调度。工具名也不是一个扁平字符串:Namespace 和 Name 一起组成规范的 ToolName。因此 mcp__calendar / create_event 不会与本地同名函数碰撞。

解析成功后,Runtime 先把模型生成的 Tool Call Item 写入 History 和 Rollout,再创建工具 Future,并把 needs_follow_up 设为真。这里的顺序保证即使用户随后取消 Turn,历史里仍保留“模型曾经请求了什么”,不会只剩一个来历不明的工具结果。

工具 Future 经过 ToolRegistry 时还有几道结构检查:

  1. Registry 按完整 ToolName 查找 Handler;
  2. 未注册的名称变成模型可读的“unsupported call”结果;
  3. Handler 检查 Payload 形状是否匹配;
  4. 名称存在但 Function/Custom 形状不兼容,被视为 Runtime 内部契约错误;
  5. 检查通过后才发送 Tool Start 生命周期事件。

“工具不存在”和“程序把错误 Payload 交给了已存在工具”性质不同。前者可能是模型使用了过期或错误的工具名,返回结果后模型还能改正;后者意味着规格、解析器和 Registry 之间出现了程序错误,不能假装成普通命令失败。

Hooks 包围执行,但执行后的阻断不能回滚副作用

Registry 不会一查到 Handler 就立刻调用。它先运行 PreToolUse Hook。Hook 可以:

  • 继续使用原参数;
  • 返回新输入,让 Handler 重建 ToolInvocation
  • 阻断调用,并把原因反馈给模型。

Handler 成功后,Registry 再运行 PostToolUse Hook。Post Hook 可以补充上下文、把反馈文字替换成模型可见结果,或阻断结果继续返回。不过此时真实操作已经发生。源码在 registry.rs 中专门提醒:PostToolUse 阻断的是结果,不是已经完成的工具执行。

例如 apply_patch 已经修改文件后,Post Hook 决定不把原结果交给模型,并不会自动恢复旧文件。需要事务语义的工具必须由自己的 Runtime 提供提交、补偿或回滚机制,不能把生命周期 Hook 当成数据库事务。

多个工具可以并行执行,但结果仍按调用顺序回灌

一次模型响应可以完成多个 Tool Call。Codex 把对应 Future 放进 FuturesOrdered,并通过一个共享 RwLock 控制执行:

  • 声明支持并行的工具取得读锁,可以与其他并行工具同时运行;
  • 不支持并行的工具取得写锁,会等待已有并行调用结束,并阻止其他调用进入;
  • 即使实际完成时间不同,FuturesOrdered 仍按模型发出调用的顺序交付结果。

因此需要区分“执行顺序”和“History 顺序”。两个只读搜索可能并行,第二个先完成,但结果仍按调用次序与各自的 call_id 写入历史。这样既缩短等待,又给下一次采样一个稳定转录。

当前流程还有一个容易误读的细节:OutputItemDone 只是把 Future 排入队列。采样事件循环结束后,drain_in_flight 才轮询并等待这些 Future。工具不会在参数还没完成时抢跑,也不会把执行输出插进同一个尚未 Completed 的模型响应。

取消同样沿这层传播。每次调用获得子 CancellationToken。普通 Handler 可被直接中止;声明需要 Runtime 清理的 Handler 会先得到机会终止进程或释放资源。最终 Runtime 生成带失败语义的 Aborted Tool Output,让下一次模型采样知道操作没有正常完成,而不是让调用在 History 中永久悬空。

exec_command 不只是“一次 shell 调用”

命令工具最容易把工具路由、安全策略和进程生命周期同时暴露出来。当前 Unified Exec 组合通常向模型展示:

  • exec_command:启动命令,等待一小段时间并返回当前输出;
  • write_stdin:向仍存活的会话写入字符,或空写入以继续轮询。

旧的 shell_command 可以继续注册为 Hidden 兼容入口,但具体暴露仍受模型能力、Feature 和 Tool Mode 影响,不能把某一套工具表写成所有会话的固定常量。

exec_command 的 Handler 先从原 Step 的环境快照选择目标环境。没有 environment_id 时使用主环境;指定 ID 时只允许选择该 Step 中已经就绪的环境。工作目录相对环境自身的 cwd 解析,远程环境还使用它报告的 Shell 类型,而不是假定远端与 Codex 宿主机相同。

接下来命令被交给 Unified Exec Process Manager。短命令在初始等待窗口内结束时,结果直接带回退出码;仍在运行时,Manager 会先把进程存入 Session 级 Process Store,再返回 process_id

exec_command
  ├── 已退出 → output + exit_code
  └── 仍运行 → output snapshot + process_id

                                  └── write_stdin / 空轮询

write_stdin 使用工具参数名 session_id 接收这个进程 ID。对同一终端的读写会取得会话级交互锁,避免两个轮询同时抽走同一个输出缓冲区;不同终端仍可并行。空字符表示只等待新输出,非空字符写入 TTY。非 TTY 进程不能任意续写,只保留有限的中断处理。

write_stdin 也不会再次运行命令级 PreToolUse Hook,因为它只是已经批准并启动的命令的传输通道。当一次轮询观察到原命令最终结束时,PostToolUse 仍使用原 exec_command 的调用 ID 和命令内容,保持生命周期配对。

这套设计的代价是状态明显增加:Process Store、输出缓冲、交互锁、超时、终止、网络审批和清理都要跨多次工具调用保持一致。收益则是编译、测试服务器和交互式程序不必被伪装成一个超长的同步函数调用。

命令策略先判断“是否应该执行”,沙箱再约束“执行时能碰什么”

命令到达进程创建之前,ExecPolicyManager 会把 Shell 命令尽量拆成可判断的命令片段,然后同时参考显式 Exec Policy、危险命令启发式、已知安全命令和当前审批模式。结果被规整为三类:

结果含义
Skip不需要弹出审批;是否绕过沙箱由额外字段和权限策略决定
NeedsApproval需要 Hook、自动 Reviewer 或用户给出决定
Forbidden当前策略不允许执行,也不能通过询问来改变

例如审批模式为 Never 时,Runtime 不会询问用户,但这不代表所有命令都被允许:危险命令可以直接变成 Forbidden,普通命令则可以依赖受限沙箱运行。OnRequest 配合受限文件系统时,普通且未请求升级的命令通常直接进入沙箱;只有请求越过边界或命中策略时才需要审批。

显式 Allow 规则也有严格条件。只有解析出的每个命令片段都被 Exec Policy 明确允许,Skip 才可以携带 bypass_sandbox=true。模型给出的 prefix_rule 只是一个建议:Runtime 会拒绝过宽或不能覆盖整条命令的前缀,不能靠 ["bash"] 这类泛化规则把任意脚本变成可信命令。

这里至少有四个不能合并的概念:

回答的问题典型数据或组件
命令策略这条命令应允许、询问还是禁止Exec Policy、危险/安全启发式
审批流程这一次具体动作由谁决定Permission Hook、Guardian、User
权限配置如果运行,允许读写和联网到哪里PermissionProfile、附加权限
沙箱执行怎样在操作系统或目标环境强制落实边界SandboxManager、Exec Server

用户批准一次命令,是审批流程的结果,不会自动删除 Permission Profile;沙箱是执行机制,也不负责决定什么时候应该弹窗。

ToolOrchestrator 把审批、沙箱和升级组织成两次有界尝试

Shell、Unified Exec 和 Apply Patch 都把高风险执行交给 ToolOrchestrator。其正常顺序如下:

  1. 用当前 Workspace Roots 物化 Permission Profile;
  2. 计算 SkipNeedsApprovalForbidden
  3. 需要决定时,先运行 PermissionRequest Hook,再路由到 Guardian 或用户;
  4. 根据文件、网络策略和工具偏好选择首个 Sandbox Attempt;
  5. 执行并收集成功、普通错误或 Sandbox Denied;
  6. 只有工具允许升级且策略允许时,才审批并执行第二次尝试。
Codex ToolOrchestrator 从命令策略和审批到平台沙箱首轮执行、沙箱拒绝判断及有界升级重试的时序
图 6-2:审批是决策,Permission Profile 是资源边界,Sandbox 是强制执行。首次沙箱拒绝只有在工具和策略都允许时才进入一次升级尝试。

SandboxManager 在当前平台选择具体实现:macOS 使用 Seatbelt,Linux 路径在类型中名为 LinuxSeccomp 并由 Codex Linux Sandbox 根据权限配置构造启动参数,Windows 可使用 Restricted Token;不需要或无法选择平台沙箱时为 None

本地执行会把命令转换成平台对应的启动请求。远程执行不能让 Codex 宿主机用自己的路径和 Sandbox Wrapper 包住另一台机器的命令,因此 env_for_exec_server 保留原生命令,把 Permission Profile、Workspace Roots、cwd 和沙箱请求作为上下文发给 Exec Server,由目标环境落实边界。

Sandbox Denied 也不等于“自动去掉沙箱再跑一次”。Orchestrator 先检查:

  • 该工具是否允许失败升级;
  • 当前审批模式是否允许无沙箱审批;
  • 拒绝是否来自可解释的网络策略;
  • 文件策略是否含有 denied-read 限制;
  • 首轮批准能否覆盖权限更大的第二轮。

denied-read 尤其关键。这类限制只能由文件系统沙箱执行。如果为了升级直接无沙箱重跑,原本禁止读取的路径反而全部暴露。因此只要存在 denied-read,Runtime 就认为无沙箱执行不能表示当前权限策略;即使命令规则允许或调用明确请求升级,也必须保留沙箱和拒读边界。

严格自动审查下,首轮的 Guardian 批准只覆盖沙箱内执行,改成无沙箱的第二轮需要新审核。其他模式可以在动作已经获得适当批准且没有网络升级时复用决定。无论哪种情况,当前 Orchestrator 的升级都是一次有界的第二次尝试,不会无限循环“失败—放宽—再失败”。

apply_patch 是结构化文件操作,不是把文本丢给 Shell

apply_patch 使用 Freeform Tool,因为补丁语法比嵌套 JSON 更适合模型生成:

*** Begin Patch
*** Update File: src/app.py
@@
-old_value = 1
+new_value = 2
*** End Patch

Handler 首先解析语法,再针对同一个 Step 选中的文件系统验证补丁。验证会确认目标文件、上下文和移动操作等信息,之后才评估权限并委托 Runtime 执行。直接工具调用和 exec_command 中识别出的 apply_patch 命令最终都会进入这条专用路径,后者不会真的启动一个 Shell 子进程来修改文件。

补丁审批按原子目标建立 Key:

ApplyPatchApprovalKey = environment_id + path

一次补丁可能有多个 Key;移动文件还会同时加入源路径和目标路径。只有所有 Key 都已经获得 Session 级批准,后续补丁才可跳过询问。若用户选择“本会话允许”,Runtime 分别缓存每个路径,因此以后只修改其中一个已批准文件也能命中缓存。

在 Workspace 内本来可写的目录不需要额外权限。目标父目录不在当前可写范围时,Handler 才构造附加写权限请求。真正应用补丁时,ApplyPatchRuntime 仍使用 Orchestrator 选定的 Sandbox Attempt 和环境文件系统;远程文件也通过同一抽象处理。

这种实现比 echo ... > file 复杂,但它知道“将修改哪些路径”,可以提前展示 Diff、按路径审批、追踪已提交 Delta,并在部分失败时把已经发生的变化交给生命周期事件。通用 Shell 无法可靠提供这些语义。

工具结果不是日志,它是下一次采样的输入

Handler 返回的是实现 ToolOutput 的类型,而不是随意向标准输出打印一段文字。不同工具会生成对应的 ResponseInputItem

调用形状回灌形状
Function ToolFunctionCallOutput
Freeform Custom ToolCustomToolCallOutput
Client Tool SearchToolSearchOutput

结果保留原 call_id,模型因此能把输出与此前的调用配对。普通可恢复错误也会转成失败结果,例如不支持的工具、审批拒绝或参数错误;只有破坏 Runtime 契约的 Fatal Error 才终止整个采样链。

命令输出在进入模型上下文前还会受双重限制:Process Manager 先限制采集缓冲,ToolOutput 再按模型截断策略生成上下文文本。返回值可以携带原始 Token 估计、被省略的字节数、Chunk ID、退出码和仍存活的进程 ID。遥测预览也有独立上限,不能把“日志里只看到一小段”误判为 Handler 只产生了这一小段。

采样结束后,drain_in_flight 把每个结果转换成 ResponseItem,按调用顺序写入 History。外层 run_turn 看到 needs_follow_up=true 后,不是复用旧 Step,而是重新捕获 StepContext,再用已经包含 Tool Call 和 Tool Result 的 History 发起下一次采样。

用接近 Python 的伪代码表示,主循环是:

async def run_agent_step(session: Session) -> bool:
    step = await session.capture_step_context()
    prompt = build_prompt(
        history=session.history.for_prompt(),
        tools=step.tool_router.model_visible_specs,
    )

    ordered_calls: list[Awaitable[ToolResult]] = []
    needs_follow_up = False

    async for event in model.stream(prompt):
        if isinstance(event, OutputItemDone):
            await session.history.append(event.item)
            call = step.tool_router.try_parse(event.item)
            if call is not None:
                ordered_calls.append(
                    step.tool_runtime.execute(call, session.cancel_token)
                )
                needs_follow_up = True

    for result in await_in_call_order(ordered_calls):
        await session.history.append(result.to_response_item())

    return needs_follow_up

这段伪代码省略了界面事件、Hooks、网络审批和错误分类,但保留了三个不变量:使用同一 Step 的规格和 Registry,调用项先于结果进入 History,工具结果只在下一次模型采样中生效。

调试工具链要先判断失败发生在哪一层

“命令没执行”不是一个足够精确的故障描述。可以按下面的信号定位:

现象优先检查
模型始终不调用某工具它是否为 Deferred、Hidden,或被 Code Mode/Feature Gate 隐藏
模型生成了工具名但提示 unsupported调用是否来自旧 Step,Namespace 是否匹配 Registry
参数看似正确却触发 FatalToolPayload 是 Function 还是 Custom,Handler 的 matches_kind 是否一致
调用在执行前被挡住PreToolUse Hook、Exec Policy、审批模式与 Reviewer 决定
用户批准后仍被拒绝Permission Profile 是否仍限制路径或网络,Sandbox 是否正确落实
沙箱拒绝后没有无沙箱重试工具是否允许升级,审批策略和 denied-read 是否允许绕过
命令返回 process_id 但无退出码进程仍存活,应通过 write_stdin 继续轮询或输入
第二个并行工具先完成却后出现执行并行,但 FuturesOrdered 按调用顺序交付
Patch 移动目标没有获批审批 Key 是否同时包含源路径与目标路径
模型只看到部分命令输出区分采集上限、模型上下文截断和遥测预览

观察日志时还应把 tool_namenamespacecall_id、Step/Turn ID、环境 ID、审批来源、Sandbox Type、退出码和原始输出大小放在同一条 Trace 中。只记录 Shell 文本,无法解释它为什么走到了某个远程环境,也无法区分“策略拒绝”和“操作系统拒绝”。

Agent 的行动能力来自受控闭环

到这里,第五章留下的 Tool Call 已经走完整条链:

模型可见规格
→ 完整 Tool Call
→ 同 Step 路由
→ Hook 与 Handler
→ 命令策略和审批
→ Permission Profile 与 Sandbox
→ 本地或远程副作用
→ 类型化 Tool Result
→ History
→ 下一次采样

这条链的核心不是工具数量,而是四个边界始终可核对:规格与执行器来自同一 Step,副作用发生前有明确决策,批准和强制权限没有混为一谈,结果与原调用一起进入历史。

下一章会沿另一种工具继续向外扩展:当工具不再只操作文件和进程,而是创建子 Agent、发送消息和等待其他任务时,Codex 怎样保持父子关系、上下文继承、并发上限与终止语义。

延伸阅读

第六章细节导航

第六章的总览只负责建立地图。下面 82 个细节单元按真实执行链展开;每节都包含源码索引、机制伪代码、失败分支和两张可复现技术图。

工具协议与规划

Shell 与进程工具

Apply Patch

图像、计划和交互工具

Tool Search、Code Mode 与扩展工具

MCP 与插件工具

安全策略与系统沙箱

评论


← 返回文章列表