跳到主要内容

手脚与护栏:工具、执行、沙箱、编辑、MCP/skills

30 秒导读: 前几章讲的是「怎么把模型的话说得漂亮」(harness 仿真、请求塑形、回合循环)。这一章讲模型的话怎么真的落到你的机器上。README 里那句「Runs commands inside native sandboxing on macOS, Linux, and Windows」就是本章的全部主题:一个动作从「模型吐出一段 JSON」到「一条命令在受限沙箱里真的跑起来、一个文件真的被改」,中间要过声明、路由、审批、护栏、沙箱、模糊匹配六道关。


1. 这层是什么(零基础也能懂)

一句话定义: 这是 agent 的手脚(能真的动手改机器)加护栏(动手前后有安全闸)

模型本身只会做一件事:输出文字。它说「我要运行 rm -rf build/」或「把 foo.py 第 12 行改成 x = 2」,这些都只是字符串。真正让字符串变成硬盘上的字节变化、进程表里的一个 PID,是这一层的活。

它要解决的核心难点,不是「调用模型」,而是这三件事叠在一起:

  • 把话翻译成动作 —— 模型说的工具名/参数,要能对上一个真实可执行的运行时。
  • 别把机器搞坏 —— 危险命令要拦、要问用户、要关进沙箱;不能让一次幻觉把家目录删了。
  • 容错落地 —— 模型给的「旧代码」几乎从不和磁盘上的真实内容一字不差(空格、引号、换行都可能差),补丁还得能打上去。

给谁用: 这一层是 codex-core 的内部机制,终端用户感知不到;但对读源码想搞懂「agent 如何安全执行」的工程师,这是最硬核、工程含量最高的一支。

五个子系统一览(本章逐个讲):

子系统干什么主要位置
工具体系声明工具、发现工具、把工具喂给模型、分发调用tools crate + core/src/tools/
命令执行把 argv 真的 spawn 出去,带超时/输出上限core/src/exec.rsexec_env.rs
执行策略命令跑之前判定:放行 / 问用户 / 禁止core/src/exec_policy.rs + execpolicy crate
原生沙箱三大平台各自的进程隔离(文件+网络)sandboxing crate + linux-sandbox crate
文件编辑把模型给的补丁模糊匹配后打到文件上core/src/apply_patch.rs + apply-patch crate
扩展面接外部工具(MCP)、技能(skills)、钩子(hooks)、复核(guardian)core/src/mcp*.rscore-skillshooksguardian

一句话直觉: 把这一层想成餐厅后厨的传菜+食品安全系统。模型是前台点单员(只会喊单),这一层是把单子翻译成后厨动作、检查有没有过敏原、把危险操作交给经理审批、最后真的把菜做出来的那套流程。

本节到此为止,不碰代码;下面开始进入机制。


2. 顶层全景(一次动作怎么落地)

怎么读这张图: 从上到下是一个工具调用的生命周期;左边是「声明期」(回合开始时,把工具目录准备好喂给模型),右边是「执行期」(模型真的调了某个工具之后发生什么)。命中护栏就可能停在审批那一步。

┌──────────────── 声明期(回合开始前) ────────────────┐
│ │
各来源工具 │ ToolExecutor::spec() ──► ToolSpec │
(shell/apply_patch/ │ │ │ │
mcp/skills/plugin) │ ▼ ▼ │
│ ToolExposure 分级 create_tools_json │
│ Direct/Deferred/Hidden (Responses API JSON) │
│ │ │ │
│ └── Deferred 的先藏起来 ─┘ ──► 喂给模型 │
└──────────────────────────────────────────────────────┘

模型回一条 tool call ▼
┌──────────────────────── 执行期 ────────────────────────┐
│ │
│ ToolRouter.dispatch ──► 找到对应 ToolExecutor │
│ │ │
│ ┌─────────┴─────────┐ │
│ 命令类(shell) 编辑类(apply_patch) │
│ │ │ │
│ exec_policy 判定 assess_patch_safety │
│ 放行/问用户/禁止 放行/问用户/拒绝 │
│ │ │ │
│ guardian 复核 ◄─────────────┘ (可选 LLM 护栏)│
│ │ │
│ ▼ │
│ SandboxManager.transform (选沙箱+改写 argv) │
│ │ │
│ ┌─────────────┼──────────────┐ │
│ seatbelt linux helper windows token │
│ (macOS) (bwrap+seccomp) (受限令牌) │
│ │ │
│ ▼ │
│ execute_env → spawn 真进程 → 回执给模型 │
└──────────────────────────────────────────────────────────┘

部件一句话职责:

部件干什么真实符号 / 位置
ToolExecutor trait每个工具都实现它:给出 spec、暴露级别、handle() 执行tools/src/tool_executor.rs:49
ToolExposure决定工具喂给模型还是先藏(延迟加载)tools/src/tool_executor.rs:15
ToolRouter把模型的 tool call 路由到对应运行时并分发core/src/tools/router.rs:35
exec_policy命令执行前的规则+启发式判定core/src/exec_policy.rs:270
SandboxManager选沙箱类型、把 argv 改写成沙箱启动命令sandboxing/src/manager.rs:275
execute_env统一执行入口:真的 spawn 子进程core/src/sandboxing/mod.rs:176
apply_patch把补丁模糊匹配后落到文件apply-patch/src/lib.rs:315
guardian高风险动作的 LLM 复核护栏core/src/guardian/mod.rs

主线走一遍(高层):

  1. 回合开始,core 把所有工具的 spec() 收集起来,按 ToolExposure 分级,序列化成 Responses API 的工具 JSON 喂给模型(延迟加载的先不喂,见 §3.2)。
  2. 模型回一条 tool callToolRouter 按工具名找到对应的 ToolExecutor
  3. 如果是命令类,exec_policy 先判定放行/问用户/禁止;编辑类走 assess_patch_safety。必要时 guardian 再做一次 LLM 复核。
  4. 通过之后,SandboxManager::transform 选平台沙箱,把原始 argv 改写成「沙箱启动器 + 原命令」。
  5. execute_env 真的 spawn 子进程,带超时和输出上限,把结果回执给模型。

下面逐个子系统由浅入深钻。


3. 工具体系(tools crate:声明、发现、执行)

这节讲一个工具从「定义」到「被模型看见」到「被调用」的完整链条,以及它和上一章 harness 仿真的接点。

3.1 三个层次的「工具」表示

同一个工具,在代码里有三种形态,分工清楚:

形态角色符号
ToolDefinition中间层元数据:名字+描述+输入/输出 schema+是否延迟加载tools/src/tool_definition.rs:7
ToolSpeccore 内部用的富枚举(Function/Freeform/Namespace/LocalShell/WebSearch…)tools/src/tool_spec.rs:ToolSpec
ResponsesApiTool最终序列化给模型看的那份 JSONtools/src/responses_api.rs:26

三者之间靠转换函数打通,例如 tool_definition_to_responses_api_tool(responses_api.rs:127)把中间层翻成模型可见 JSON。注意这里有个细节:strict 字段硬编码为 false,且 defer_loading 只在为真时才序列化出去(.then_some(true)),这样默认工具的 JSON 里根本不出现这个字段。

3.2 延迟加载:先藏起来,用到再给(deferred tools)

它要解决的小问题: 工具太多会撑爆上下文、也让模型分心。很多工具(尤其是 MCP 来的)平时用不上。

思路: 把工具分级——常用的直接喂,冷门的先只登记、不喂给模型,模型需要时用一个专门的 tool_search 工具去搜,搜到了再把完整 schema 补给它。

ToolExposure 就是这个分级开关(tool_executor.rs:15):

级别含义
Direct进初始工具列表;开了 code mode 时也作为嵌套工具可用
Deferred不进初始列表,只登记待搜;必须提供搜索元数据
DirectModelOnly只进初始列表,不进 code mode 的嵌套面
Hidden登记以便分发,但完全不给模型看

延迟加载的工具靠 into_deferred()(tool_definition.rs:21)把 output_schema 清空、defer_loading 置真。MCP 工具专门有一条 mcp_tool_to_deferred_responses_api_tool(responses_api.rs:116)走这条路。搜索用的常量与默认条数在 tool_discovery.rs:6(TOOL_SEARCH_TOOL_NAMETOOL_SEARCH_DEFAULT_LIMIT = 8),搜索元数据结构是 ToolSearchInfo(tool_search.rs:16),默认实现直接从工具的 spec 派生(tool_executor.rs:59search_info)。

这正好呼应本文顶部系统提示里描述的「deferred tools 通过 ToolSearch 载入 schema 才能调用」的机制——OI 把同一套「先给名字、要用再给 schema」的渐进式披露做进了工具层。

3.3 执行契约:ToolExecutor trait

所有模型可见工具共享一个运行时契约(tool_executor.rs:49):

// 真实源码节选,tools/src/tool_executor.rs:49
pub trait ToolExecutor<Invocation>: Send + Sync {
fn tool_name(&self) -> ToolName;
fn spec(&self) -> ToolSpec;
fn exposure(&self) -> ToolExposure { ToolExposure::Direct } // 默认直接暴露
fn handle(&self, invocation: Invocation) -> ToolExecutorFuture<'_>;
}

一句话:把「给模型看的 spec」和「真正执行的 handle」绑在同一个对象上,宿主(core)可以在外面叠路由、hooks、遥测,而不用把 spec 和运行时拆开。handle 返回一个 boxed future,产出 Box<dyn ToolOutput>FunctionCallErrorToolRouter(router.rs:35)在执行期负责 build_tool_calldispatch,ToolRegistry(registry.rs:322)是名字到运行时的注册表,重复注册会 panic(registry.rs:337)。

3.4 code mode:让模型写 JavaScript 来编排工具

它要解决的小问题: 一个任务要连着调好几个工具时,一次一个 tool call 来回太慢、也容易乱。

思路(这是 OI/Codex 一个挺妙的设计): 给模型一个叫 exec 的工具,模型往里塞一段 JavaScript,所有别的工具都挂在全局 tools 对象上,模型可以 await tools.exec_command(...) 像写脚本一样把多个工具串起来,在一个 V8 isolate 里跑完再回结果。

这段 exec 工具的描述模板写得很直白(code-mode-protocol/src/description.rs:12EXEC_DESCRIPTION_TEMPLATE),关键几条:

  • 全新 V8 isolate 里把代码当 async module 求值;
  • 所有嵌套工具挂在全局 tools,工具名规范化成 JS 标识符(如 tools.mcp__ologs__get_profile(...));
  • 纯 JavaScript,没有 Node、没有文件系统、没有网络、没有 console——沙箱本身就极窄;
  • 还提供 text()/image()/store()/load()/yield_control() 等全局辅助函数。

tools crate 侧负责把每个普通工具的 spec 翻译成 code-mode 的嵌套工具定义并在描述里塞一段 TS 声明示例:入口是 augment_tool_spec_for_code_mode(code_mode.rs:8)和 collect_code_mode_tool_definitions(code_mode.rs:61)。工具名到 code-mode 全局名的规范化在 code_mode_name_for_tool_name(code_mode.rs:158),命名空间工具会拼成 namespace__tool。真正渲染 TS 声明样例的是 augment_tool_definition(description.rs:348),它对除 exec/wait 之外的工具都追加一段声明。V8 运行时本体在 code-mode crate(code-mode/src/lib.rs,initialize_v8)。

3.5 MCP 工具:把外部服务的工具翻进来

parse_mcp_tool(mcp_tool.rs:6)把一个 rmcp::model::Tool(MCP 协议里的工具)转成本地 ToolDefinition。这里有个务实的兼容细节:OpenAI 模型要求 schema 里必须有 properties,而有些 MCP server 省了或给了 null,于是这里主动补一个空对象(mcp_tool.rs:12-19),对齐 Agents SDK 的行为。输出统一包成一个 MCP CallToolResult 形状的 schema(mcp_call_tool_result_output_schema,mcp_tool.rs:39)。

3.6 和 harness 编码的接点

工具体系并不是孤立的——它和上一章的 harness 仿真在**「用哪种 shell 工具形态」**这个点上耦合。shell_type_for_model_and_features(tool_config.rs:81)根据模型信息和 feature 开关,在 ShellCommandUnifiedExecDisabled 之间选;Harness 枚举(harness.rs:2)列出了 OI 支持仿真的一票 harness(ClaudeCodeKimiCodeQwenCodeOpenCode……)。也就是说:同一个「运行命令」的能力,喂给不同 harness 的模型时,工具的名字/形态/编码可能不同,而工具体系这层负责把这些差异吸收掉。细节见 02-harness-shaping.md


4. 命令执行(exec / exec_policy / exec_env)

这节讲一条 shell 命令从「决定要不要跑」到「真的跑起来」的两步:先判定(exec_policy),再执行(exec)。

4.1 执行前判定:exec_policy 的三档裁决

它要解决的小问题: 模型想跑的命令,有的绝对安全(ls),有的绝对危险(rm -rf /),大多数在中间。得有个东西给每条命令定性。

exec_policy 的核心产物是 ExecApprovalRequirement,由 create_exec_approval_requirement_for_command(exec_policy.rs:270)算出,落到三档:

裁决(Decision)结果位置
Forbidden直接拒,回错误给模型exec_policy.rs:328
Prompt需要审批(可能升级为 Forbidden)exec_policy.rs:339
Allow放行(可跳过沙箱审批)exec_policy.rs:371

判定的两个信息源:

  • 规则引擎 —— execpolicy crate 是一套可解析的策略语言(PolicyParserPolicyRule,execpolicy/src/lib.rs),规则文件放在 rules/ 目录、扩展名 .rules、默认 default.rules(exec_policy.rs:50-52)。用户/企业可以自己加规则,blocking_append_allow_prefix_rule 支持运行时追加前缀允许规则。
  • 启发式 —— 规则没覆盖到的命令,用 is_known_safe_command(已知安全)和 dangerous_command_match(危险模式)兜底(exec_policy.rs:28-29)。还有一张 BANNED_PREFIX_SUGGESTIONS 表(exec_policy.rs:53)列出 python3 -cbash -lcsudoosascript 等一票解释器/提权前缀,用于把「表面无害、实则能绕过」的命令识别出来。

在 Windows 上,判定还要区分 PowerShell 来源(ExecPolicyCommandOrigin::PowerShell,exec_policy.rs:110),因为 PowerShell 的安全/危险启发式跑在它自己的内层命令词上,而不是通用分类器上。

4.2 执行:process_exec_tool_call 流水

判定通过后,真正执行的门面是 process_exec_tool_call(exec.rs:297):它先调 build_exec_request(exec.rs:321)把「可移植的 exec 请求」变成「具体 argv/env + 沙箱策略」,再统一走 execute_env(core/src/sandboxing/mod.rs:176)spawn。

build_exec_request 里做的事(exec.rs:321-413):

  1. 从 permission profile 推出文件系统策略+网络策略(to_runtime_permissions);
  2. select_process_exec_tool_sandbox_type 选沙箱类型(见 §5.1);
  3. 如果有网络代理,把代理配置注入 env;
  4. 拆出 program + args,包成 SandboxCommand,交给 SandboxManager::transform 改写(见 §5.2)。

几个硬性护栏常量:

常量作用
DEFAULT_EXEC_COMMAND_TIMEOUT_MS10_000默认命令超时 10 秒
EXEC_OUTPUT_MAX_BYTES= DEFAULT_OUTPUT_BYTES_CAP单次 exec 输出保留上限,防一条命令刷爆内存
MAX_EXEC_OUTPUT_DELTAS_PER_CALL10_000实时输出 delta 事件条数上限
IO_DRAIN_TIMEOUT_MS2_000杀掉子进程后排水 stdout/stderr 的最长等待

超时/取消统一由 ExecExpiration 枚举建模(exec.rs:143),支持纯超时、纯取消、二者取先到(tokio::select! biased,exec.rs:197)。这个 IO_DRAIN_TIMEOUT_MS 有个真实动机:子进程可能 fork 出孙进程继承了管道 fd,杀掉直接子进程后管道还开着,read() 会永久阻塞——所以排水也要有超时,否则整个 agent 挂死(exec.rs:82-89 的注释写得很清楚)。

4.3 环境变量:白名单进,不放毒

exec_env.rs 负责给子进程构造干净的环境。create_env(exec_env.rs:25)按 ShellEnvironmentPolicy 过滤,调用方要先 env_clear()envs(),确保不泄漏宿主的杂七杂八变量。

一个安全细节:inject_permission_profile_env(exec_env.rs:37)会把当前生效的权限档名写进 CODEX_PERMISSION_PROFILE 环境变量,但注释明说这只是信息性的、子进程能覆写、绝不能当作强制证明(exec_env.rs:11-13)。真正的强制靠沙箱,不靠环境变量。


5. 原生沙箱(sandboxing crate:三平台各一套)

这节是本章的护栏核心:命令即使被放行,也关在一个尽量小的笼子里跑。README 的「native sandboxing」就在这。

5.1 先决定:要不要沙箱、用哪种

SandboxManager(manager.rs:275)分两步走:

第一步 should_sandbox / select_initial(manager.rs:280303): 按偏好 SandboxablePreference(Auto/Require/Forbid)决定要不要沙箱。Auto 时委托给 should_require_platform_sandbox(policy_transforms.rs:509),逻辑是:

  • 有托管网络要求 → 一定要沙箱;
  • 网络关着 → 除非是「外部沙箱」类型,否则要沙箱;
  • 网络开着 → 只有「受限文件系统且不能全盘写」才要沙箱。

第二步选平台: get_platform_sandbox(manager.rs:60)按编译目标平台挑一种:

平台SandboxType底层机制
macOSMacosSeatbeltsandbox-exec + SBPL 策略
LinuxLinuxSeccompcodex-linux-sandbox 助手(bubblewrap + seccomp)
WindowsWindowsRestrictedToken受限令牌 / 提权后端(仅在开关打开时)
其它/关闭None不沙箱

5.2 再改写:transform 把 argv 包进沙箱

SandboxManager::transform(manager.rs:321)是关键:它按选中的 SandboxType,把原始 argv 改写成「沙箱启动器 + 原命令」。三平台分支各不相同(manager.rs:357-426):

  • macOS: 前面拼 /usr/bin/sandbox-exec + 生成的 -p <策略> 参数(见 §5.3);
  • Linux: 前面拼 codex-linux-sandbox 可执行文件路径,并设置 arg0 覆盖(见 §5.4);
  • Windows: 直接留用,后续在 direct-spawn 阶段再包一层受限令牌 wrapper(见 §5.5)。

一个防篡改细节:macOS 只认 /usr/bin/sandbox-exec 这个绝对路径(MACOS_PATH_TO_SEATBELT_EXECUTABLE,seatbelt.rs:30),绝不查 PATH——注释说得直白:如果 /usr/bin/sandbox-exec 都被改了,那攻击者早就有 root 了(seatbelt.rs:26-29)。

5.3 macOS:Seatbelt 策略是「拼出来」的

macOS 的策略不是一个静态文件,而是运行时按权限拼接的字符串create_seatbelt_command_args(seatbelt.rs:623)把若干段策略 join 起来(seatbelt.rs:741):

policy_sections = [
基础策略(seatbelt_base_policy.sbpl,静态,deny default)
文件读策略(按可读根动态生成)
文件写策略(按可写根动态生成)
拒读策略(unreadable 通配)
网络策略(按代理/端口/unix socket 动态生成)
(可选)受限只读平台默认
]

基础策略 seatbelt_base_policy.sbpl 的第一句就是 (deny default)(seatbelt_base_policy.sbpl:8)——默认全关,再逐条开。它开的都是极窄的口子:子进程 exec/fork、读一批 hw.*/kern.* sysctl、openpty、读只读用户偏好等,连 /dev/null 的写都要 require-all 限定到字符设备。

文件读写用 build_seatbelt_access_policy(seatbelt.rs:352)按「可写根/可读根」生成带 -D 参数占位的规则;全盘写会退化成 (allow file-write* (regex #"^/"))(seatbelt.rs:645,注释称比 (allow file-write*) 更宽松)。网络策略最有意思:dynamic_network_policy_for_network(seatbelt.rs:274)在「有代理配置但推不出合法回环端点」时fail closed 直接返回空策略(seatbelt.rs:312-316),宁可断网也不静默放宽。unix socket 支持 AllowAll 和按 subpath 限定两种(seatbelt.rs:225)。最终这一整段策略作为 -p 的实参传给 sandbox-exec(seatbelt.rs:761)。

5.4 Linux:自己 fork 一个沙箱助手

Linux 走的是自调用助手模式:core 不在本进程里施加隔离,而是把命令交给一个专门的 codex-linux-sandbox 可执行文件去跑。create_linux_sandbox_command_args_for_permission_profile(landlock.rs:23)拼出助手的 CLI:把权限档序列化成 JSON 传进去,加上 --sandbox-policy-cwd--command-cwd,末尾 -- 后接原命令。

助手本体在 linux-sandbox crate,它的 lib 头注释讲清了它施加两层(linux-sandbox/src/lib.rs:1-5):

  • 进程内限制: no_new_privs + seccomp(限制系统调用);
  • bubblewrap: 文件系统隔离。

助手通过 arg0 识别自己(CODEX_LINUX_SANDBOX_ARG0 = "codex-linux-sandbox",landlock.rs:6),run_main(linux-sandbox/src/lib.rs:24)是入口。有个环境探测:bubblewrap 需要能创建 user namespace,sandboxing/src/bwrap.rs 会提前探测系统 bwrap 是否可用、是否在 WSL1(is_wsl1,bwrap.rs:138;WSL1 建不了 namespace,直接给警告 WSL1_BWRAP_WARNING,bwrap.rs:25)。旧机制 landlock 仍保留(--use-legacy-landlock),但代理网络场景强制走 bubblewrap 的隔离网络命名空间(landlock.rs:50-56)。

5.5 Windows:受限令牌,两种后端

Windows 用**受限访问令牌(restricted token)**这一 Win32 原生机制降权。windows_sandbox_uses_elevated_backend(windows.rs:32)决定用哪种后端:代理强制或配置为 Elevated 时用提权后端,否则用默认的受限令牌后端。文件系统允许/拒绝清单通过 resolve_windows_restricted_token_filesystem_overrides(windows.rs:78)/resolve_windows_elevated_filesystem_overrides(windows.rs:214)解析成 WindowsSandboxFilesystemOverrides(windows.rs:24)。受限令牌沙箱要求权限档不能全盘写(permission_profile_supports_windows_restricted_token_sandbox,windows.rs:42)。Windows 的包裹发生在 direct-spawn 阶段:transform_for_direct_spawn(manager.rs:464)会再套一层 wrapper 命令并把 SandboxType 重置为 None(因为隔离已经编码进 wrapper 里了,manager.rs:591)。

5.6 三平台差异速查

维度macOSLinuxWindows
底层机制sandbox-exec + SBPLbubblewrap + seccomp受限访问令牌
隔离在哪施加-p 策略,同进程树fork 独立助手进程direct-spawn wrapper
策略形态运行时拼的 SBPL 字符串命令行参数(JSON 权限档)过滤清单 overrides
默认姿态(deny default) 全关再开seccomp allowlist + bwrap 绑定令牌降权 + 读写根清单
网络动态 SBPL,推不出端点则断网代理走隔离网络命名空间防火墙绑登录用户身份
关键坑只认 /usr/bin/sandbox-execWSL1 建不了 namespace全盘写档不支持受限令牌

6. 文件编辑(apply_patch + apply-patch crate)

这节讲 agent 改代码那一支,和命令执行并列。核心难点前面点过:模型给的「旧代码」几乎从不和磁盘一字不差

6.1 补丁怎么落地

上层入口 core/src/apply_patch.rsapply_patch(apply_patch.rs:34)先做安全评估 assess_patch_safety,产出三档 SafetyCheck(AutoApprove / AskUser / Reject),对应到 InternalApplyPatchInvocation(apply_patch.rs:14)——要么当场返回、要么委托运行时通过所选环境的文件系统真正落盘。补丁的解析和应用在 apply-patch crate,apply_hunks(apply-patch/src/lib.rs:315)是应用入口,compute_replacements(lib.rs:716)算出每处替换,apply_replacements 从后往前打(lib.rs:806 附近,倒序是为了不让前面的替换打乱后面的下标)。

6.2 巧妙之处:seek_sequence 的四级模糊匹配

这是本章最值得借鉴的一处。 问题:补丁里的上下文行(old_lines)要在文件里定位,但空格、制表符、花引号、破折号都可能对不上。硬匹配会大量失败。

seek_sequence(apply-patch/src/seek_sequence.rs:12)的解法是逐级放宽,命中即停:

┌─────────────────────────────────────────────┐
pattern ─► │ ① 精确匹配 (整行逐字节相等) │─命中─► 返回下标
│ │ 失败 │
│ ▼ │
│ ② 忽略行尾空白 (trim_end 后比) │─命中─►
│ │ 失败 │
│ ▼ │
│ ③ 忽略首尾空白 (trim 后比) │─命中─►
│ │ 失败 │
│ ▼ │
│ ④ Unicode 归一 (花引号/破折号/异体空格→ASCII)│─命中─►
│ │ 全失败 │
│ ▼ None │
└─────────────────────────────────────────────┘

四级依次是:精确 → trim_end 忽略行尾空白(seek_sequence.rs:41)→ trim 忽略首尾空白(seek_sequence.rs:54)→ 最宽松的一遍:把各种 Unicode 破折号(\u{2010}\u{2015}\u{2212})、花引号(\u{2018} 等)、异体空格(\u{00A0}、全角空格 \u{3000} 等)归一成 ASCII 再比(normalise,seek_sequence.rs:76)。注释直言这是模仿 git apply 的模糊行为(seek_sequence.rs:67-74)。

还有两个防御性边界:空 pattern 直接返回 Some(start)(no-op);pattern.len() > lines.len() 直接返回 None(seek_sequence.rs:26),注释说这修的是 2025-04-12 之前会越界 panic 的 bug。eof 为真时会先从文件末尾对齐再回退(seek_sequence.rs:29),让「改文件结尾」的补丁能可靠命中。

compute_replacements 用它的方式也很务实:当直接匹配失败、且 pattern 最后一行是空串(代表文件末尾换行的哨兵)时,会去掉那个尾部空行再试一次(lib.rs:771-785),因为 split('\n') 会造出一个真实文件里不存在的尾部空片段。

6.3 流式解析

apply-patch/src/streaming_parser.rs(944 行)和 parser.rs(661 行)负责边收边解补丁文本,这样模型还在往外吐 diff 时就能开始解析,不必等整块补丁到齐。lark 语法文件在 core/src/tools/handlers/apply_patch.lark


7. 扩展面(MCP / skills / hooks / guardian)

这节讲怎么把 agent 的手脚接到「自己代码之外」的东西,以及执行前后的钩子和复核。

7.1 MCP:接外部工具服务

MCP(Model Context Protocol,模型上下文协议)让 agent 调用外部进程提供的工具。McpManager(core/src/mcp.rs:49)负责把「配置里的 server + 插件贡献的 server + 扩展运行时贡献的 server」合成一份生效目录(runtime_config_with_context,mcp.rs:103),处理覆盖(Set/Remove)、冲突告警、auth 门控。单次工具调用由 handle_mcp_tool_call(core/src/mcp_tool_call.rs:114)执行,统一把结果包成 CallToolResult。客户端实现、连接管理在 codex-mcp crate;OI 自身作为 MCP server 对外提供能力在 mcp-server crate(codex_tool_runner.rsexec_approval.rspatch_approval.rs)。

7.2 skills:可复用的技能包

core-skills crate 管理技能(skills)——预置的指令+工具组合。它有目录加载(root_loader.rs)、渲染注入(render.rsinjection.rs)、配置规则(config_rules.rs)等模块;工具侧在 ext/skills/src/tools/。渲染时会告诉模型「怎么用这些技能」(SKILLS_HOW_TO_USE_WITH_ABSOLUTE_PATHS 等常量,core-skills/src/lib.rs:24)。

7.3 hooks:执行前后的钩子生命周期

hooks 让宿主在关键节点插自己的逻辑。hook_runtime.rs(core/src/hook_runtime.rs)在这些事件点触发外部钩子命令,事件名枚举 HookEventName(hook_runtime.rs:700 的映射)覆盖:

事件时机
SessionStart会话开始
UserPromptSubmit用户提交 prompt
PreToolUse工具调用前
PermissionRequest请求权限时
PostToolUse工具调用后
PreCompact / PostCompact上下文压缩前后
SubagentStart / SubagentStop子 agent 起止
Stop停止

PreToolUsePermissionRequest 就是外部钩子能拦截/改写工具执行的挂点。定义与运行时在 hooks crate。

7.4 guardian:高风险动作的 LLM 复核护栏

前面的 exec_policy 是规则+启发式护栏,guardian(core/src/guardian/mod.rs)则是再叠一层 LLM 复核:对可疑动作,派一个「guardian 复核员」按一份策略(guardian/policy.md,里面定义了数据外泄、凭据探测等风险分类与允许/拒绝规则)给出结构化评估 GuardianAssessment(风险等级 + 用户授权 + 裁决 + 理由)。

几个硬性护栏参数:

参数作用
GUARDIAN_REVIEW_TIMEOUT90 秒复核超时
MAX_CONSECUTIVE_GUARDIAN_DENIALS_PER_TURN3一回合内连续拒绝上限
MAX_RECENT_AUTO_REVIEW_DENIALS_PER_TURN10一回合内近期拒绝总量上限

超过上限时,GuardianRejectionCircuitBreaker(guardian/mod.rs)会熔断打断整个回合(InterruptTurn),避免模型在被反复拒绝后死循环烧钱。


8. 巧妙之处(可带走的技术)

  • 模糊补丁匹配的四级降级 —— 精确→忽略行尾→忽略首尾→Unicode 归一,命中即停,外加空 pattern 和越界的防御性早返回。改代码 agent 的通用刚需。依据:apply-patch/src/seek_sequence.rs:12normalise at :76
  • 延迟加载 + tool_search —— 冷门工具先只登记不喂,靠 into_deferred() 清空 schema、模型用 tool_search 再取回,省上下文。依据:tools/src/tool_definition.rs:21tool_executor.rs:15
  • Seatbelt 策略动态拼接 + fail closed —— (deny default) 起步,按权限逐段拼允许规则;网络推不出合法端点时宁可返回空策略断网也不静默放宽。依据:sandboxing/src/seatbelt.rs:741:312-316
  • 只认绝对路径的 sandbox-exec —— 防 PATH 注入的极简但有效的加固。依据:seatbelt.rs:26-30
  • 排水超时防挂死 —— 意识到孙进程会继承管道 fd 导致 read() 永久阻塞,给排水也上超时。依据:core/src/exec.rs:82-89
  • code mode 用 V8 编排工具 —— 让模型写一段无 Node/无网络/无 FS 的纯 JS 把多个工具串起来跑,一个 isolate 一把梭。依据:code-mode-protocol/src/description.rs:12code-mode/src/lib.rs
  • guardian 熔断 —— 连续/近期拒绝超阈值就打断回合,防被拒后死循环。依据:core/src/guardian/mod.rsGuardianRejectionCircuitBreaker

9. 边界与局限(诚实)

  • 沙箱强度依赖平台原生能力。 macOS 靠 seatbelt、Linux 靠 bwrap+seccomp、Windows 靠受限令牌;任一平台的机制不可用(如 WSL1 建不了 namespace、Windows 未开沙箱开关)就会退化到无沙箱或报错。依据:bwrap.rs:25manager.rs:60-73
  • 权限环境变量不是强制证明。 CODEX_PERMISSION_PROFILE 只是信息性的,子进程能覆写,强制全靠沙箱。依据:exec_env.rs:11-13
  • exec_policy 的启发式不是完备的。 危险命令靠模式匹配+前缀黑名单,能被没覆盖到的写法绕过;guardian 的 LLM 复核也非确定性。二者是纵深防御,不是密不透风。依据:exec_policy.rs:53(BANNED_PREFIX_SUGGESTIONS)。
  • 模糊补丁匹配可能误配。 放宽到 Unicode 归一后,理论上可能匹配到本不该匹配的行;这是 git apply 式模糊性的固有取舍。依据:seek_sequence.rs:67-74
  • code mode 的 isolate 无网络/无 FS。 它只用来编排已声明的工具,不能在 JS 里直接读文件或发网络请求——那些仍要走真正的工具+沙箱。依据:description.rs:12 模板明示。

10. 代码地图(导航索引)

用符号名 grep 比行号更抗漂移。

主题文件路径关键符号
工具定义/延迟加载tools/src/tool_definition.rsToolDefinitioninto_deferred
工具暴露分级tools/src/tool_executor.rsToolExposureToolExecutorhandle
工具发现/搜索tools/src/tool_discovery.rsTOOL_SEARCH_TOOL_NAMEDiscoverableTool
搜索元数据tools/src/tool_search.rsToolSearchInfofrom_tool_spec
模型可见 JSONtools/src/responses_api.rsResponsesApiTooltool_definition_to_responses_api_toolmcp_tool_to_deferred_responses_api_tool
code mode 翻译tools/src/code_mode.rsaugment_tool_spec_for_code_modecode_mode_name_for_tool_name
code mode 描述/V8code-mode-protocol/src/description.rscode-mode/src/lib.rsEXEC_DESCRIPTION_TEMPLATEaugment_tool_definitioninitialize_v8
MCP 工具解析tools/src/mcp_tool.rsparse_mcp_toolmcp_call_tool_result_output_schema
shell 工具形态tools/src/tool_config.rsshell_type_for_model_and_features
harness 枚举tools/src/harness.rsHarness
工具路由/注册core/src/tools/router.rsregistry.rsToolRouterToolRegistrydispatch_tool_call_*
命令执行门面core/src/exec.rsprocess_exec_tool_callbuild_exec_requestExecExpiration
执行环境变量core/src/exec_env.rscreate_envinject_permission_profile_env
执行策略判定core/src/exec_policy.rscreate_exec_approval_requirement_for_commandBANNED_PREFIX_SUGGESTIONS
执行策略语言execpolicy/src/lib.rsPolicyParserPolicyRule
沙箱选择/改写sandboxing/src/manager.rsSandboxManagerget_platform_sandboxtransform
沙箱要否判定sandboxing/src/policy_transforms.rsshould_require_platform_sandboxeffective_permission_profile
macOS seatbeltsandboxing/src/seatbelt.rs + seatbelt_base_policy.sbplcreate_seatbelt_command_argsdynamic_network_policy_for_networkMACOS_PATH_TO_SEATBELT_EXECUTABLE
Linux 助手参数sandboxing/src/landlock.rscreate_linux_sandbox_command_args_for_permission_profileCODEX_LINUX_SANDBOX_ARG0
Linux 助手本体linux-sandbox/src/lib.rsrun_main(seccomp + bwrap)
bwrap 环境探测sandboxing/src/bwrap.rssystem_bwrap_warningis_wsl1
Windows 受限令牌sandboxing/src/windows.rswindows_sandbox_uses_elevated_backendresolve_windows_restricted_token_filesystem_overrides
统一执行入口core/src/sandboxing/mod.rsexecute_env
补丁上层门面core/src/apply_patch.rsapply_patchInternalApplyPatchInvocation
补丁应用apply-patch/src/lib.rsapply_hunkscompute_replacementsapply_replacements
模糊匹配核心apply-patch/src/seek_sequence.rsseek_sequencenormalise
补丁流式解析apply-patch/src/streaming_parser.rsparser.rs流式 diff 解析
MCP 管理core/src/mcp.rscore/src/mcp_tool_call.rsMcpManagerhandle_mcp_tool_call
技能core-skills/src/lib.rsskills 加载/渲染/注入
钩子core/src/hook_runtime.rsHookEventName(PreToolUse/PermissionRequest/PostToolUse…)
复核护栏core/src/guardian/mod.rs + policy.mdGuardianAssessmentGuardianRejectionCircuitBreaker

同组其它章:index.md(全景与阅读地图)· 01-harness-emulation.md(harness 仿真:为什么/枚举/路由)· 02-harness-shaping.md(请求塑形与响应回译)· 03-turn-loop-and-client.md(回合循环底座)。本章是「模型说的动作如何安全落到真实机器」的那一层;上游怎么把话说得对,看 01/02;一次回合怎么把工具调用编排进循环,看 03。