Shell 工具:把模型生成的命令变成可控进程

文件读取工具只能让 Agent 观察项目,文件编辑工具也只能修改已知内容。编码 Agent 要安装依赖、运行测试、调用编译器,最终还是要进入命令行。因此,MAF 把能力暴露为 run_shell,OpenCode 使用 bash,Codex 则同时维护 shell_commandexec_command

从模型的角度看,Shell 工具只有一段命令和一段输出。但在运行时内部,它至少要回答下面几个问题:命令交给哪个 Shell 解释,进程在哪里启动,运行多久后返回,输出太大怎么办,长时间运行的进程如何继续交互,以及执行前是否需要审批或沙箱。

正在渲染 Mermaid 图表...

这条链路看起来很直,但任何一步处理不当都会留下问题。直接执行字符串无法解析管道和重定向,只杀父进程可能留下仍在运行的子进程,无限制收集日志会迅速填满模型上下文,而把 rm -rf 之类的命令直接交给宿主机则会越过最基本的安全边界。

下面以 Microsoft Agent Framework、OpenCode 和 Codex 的当前源码为例,看看三个项目怎样处理同一个问题。这里讨论的是源码中的实际执行路径,不把工具描述中的宣传文字当成实现依据。

Shell 负责解释命令,运行时负责控制进程

模型通常不会只生成一个可执行文件名。下面这条命令包含变量展开、管道和条件执行,这些语法都要由 Shell 解释:

files=$(git diff --name-only) && printf '%s\n' "$files" | grep '\.cs$'

因此,运行时一般不会尝试自己拆分整段脚本,而是先确定 Shell,再构造对应参数:

Shell常见调用形式
Bash、Zsh、Shshell -c <command>shell -lc <command>
PowerShellpwsh -NoProfile -Command <command>
cmd.execmd.exe /d /c <command>

参数必须通过进程 API 的参数数组传递,不能先拼成另一段宿主 Shell 字符串。参数数组避免了外层 Shell 再解释一次引号,但其中的 command 仍会由目标 Shell 解析,这正是管道、重定向和变量能够工作的原因。

Shell 方言也要告诉模型。PowerShell 使用 $env:TEMP,POSIX Shell 使用 $TMPDIR;PowerShell 常用 ; 连接命令,Bash 则经常使用 &&。如果工具只叫 shell,却没有描述当前操作系统、Shell 类型和工作目录,模型就只能猜测语法。

三个项目都在解决这个问题,但侧重点不同:MAF 提供可嵌入应用的执行器,OpenCode 围绕一次性命令与终端界面组织实现,Codex 则把命令执行放进审批、沙箱和可交互会话组成的完整链路。

MAF:由宿主选择本地执行还是容器执行

MAF 的实现集中在:

src/Microsoft.Agents.AI.Tools.Shell/
├── ShellExecutor.cs
├── LocalShellExecutor.cs
├── LocalShellExecutorOptions.cs
├── DockerShellExecutor.cs
├── ShellResolver.cs
├── ShellSession.cs
├── HeadTailBuffer.cs
└── ShellEnvironmentProvider.cs

ShellExecutor 定义统一入口,LocalShellExecutor 在宿主机执行,DockerShellExecutor 在容器中执行。两者都可以通过 AsAIFunction() 变成模型工具,默认工具名是 run_shell

本地执行器在 Windows 上依次寻找 pwshpowershellcmd.exe,在 Linux 或 macOS 上优先使用 /bin/bash,再回退到 /bin/sh。应用可以通过 LocalShellExecutorOptions.ShellShellArgv 或环境变量 AGENT_FRAMEWORK_SHELL 覆盖结果。

var executor = new LocalShellExecutor(new LocalShellExecutorOptions
{
    Mode = ShellMode.Stateless,
    WorkingDirectory = projectDirectory,
    Timeout = TimeSpan.FromSeconds(30),
    MaxOutputBytes = 64 * 1024,
});

var shellTool = executor.AsAIFunction();

这里特意显式设置了 Timeout。当前源码虽然提供 LocalShellExecutor.DefaultTimeout,值为 30 秒,但它只是推荐值,LocalShellExecutorOptions.Timeout 默认是 null,也就是不启用超时。把推荐常量误写成实际默认值,会让调用方以为失控进程一定会自动结束。

无状态进程与持久 Shell

MAF 同时支持两种模式:

模式进程生命周期状态
Stateless每次调用启动新的 Shell不保留变量、函数和当前目录
Persistent一个执行器复用一个长期运行的 Shell可以保留变量和函数

当前默认是 Persistent。它不为每条命令重新启动 Shell,而是把脚本写入同一个进程的标准输入。问题随之而来:长期运行的 Shell 不会自动告诉宿主程序“本条命令已经结束”,所以 ShellSession 为每次调用生成随机 sentinel,并在脚本末尾输出 sentinel 与退出码。

POSIX Shell 中的包装逻辑可以简化为:

{ <command>
}; __af_rc=$?; set +e; printf '\n<SENTINEL>_%s\n' "$__af_rc"

读取循环不断查找本次 sentinel。找到它,运行时就能切出本条命令的 stdout,并从后缀解析退出码。PowerShell 分支会先把命令编码为 Base64,再通过 Invoke-Expression 执行,避免多行脚本让 -Command - 一直等待后续输入。

“持久”也不等于所有状态都会默认泄漏到下一次调用。ConfineWorkingDirectory 默认为 true,此时每条命令前都会重新进入配置的 WorkingDirectory,所以命令内部的 cd 不会影响下一次调用。环境变量和函数定义仍可以保留。只有关闭该选项时,当前目录才会随会话持续变化。

持久执行器必须属于单个 Agent 会话,不能注册为跨用户共享的单例。它内部只有一条 stdin/stdout 管道,命令会串行执行,而且环境变量、函数和后台任务都属于可见状态。跨会话复用会同时造成状态泄漏和排队阻塞。

输出、取消与安全边界

无状态模式分别读取 stdout 和 stderr,每个流使用 HeadTailBuffer 保留头部和尾部,默认上限都是 64 KiB。超限后舍弃中间内容,并插入明确的截断标记。保留头尾比单纯保留开头更适合编译和测试日志,因为错误摘要通常出现在末尾。

无状态命令超时时,LocalShellExecutor 调用 Process.Kill(entireProcessTree: true) 清理进程树,并以退出码 124 返回。持久模式不能简单杀掉 Shell,否则之前的环境状态也会丢失。它会先向进程组发送中断,短暂等待 sentinel;如果中断成功,会话继续使用,否则关闭整个会话并在下次调用时重建。

本地工具默认由 ApprovalRequiredAIFunction 包装。要关闭审批,调用方不仅要传入 requireApproval: false,还要设置 AcknowledgeUnsafe = trueShellPolicy 可以用规则提前拒绝某些命令,但源码明确把它定位为 UX 预过滤器,而不是安全边界。字符串规则无法列举同一种破坏行为的所有写法。

需要无人值守执行时,MAF 还提供 DockerShellExecutor。它默认关闭网络,以非 root 用户运行,使用只读根文件系统,移除 Linux capabilities,并限制内存和进程数。宿主工作目录即使挂载进容器,默认也是只读。容器隔离比命令黑名单可靠,但是否开放网络、是否允许写挂载、镜像中有哪些程序,仍然需要宿主应用明确配置。

OpenCode:一次调用,一个进程,一段可追踪输出

OpenCode 当前有两套相关代码。产品主路径位于 packages/opencode/src/tool/shell.ts,工具名为 bashpackages/core/src/tool/bash.ts 是正在演进的 V2 实现。两套代码的限制并不完全相同,不能把 V2 的参数校验写成主路径已经具备的行为。

主路径向模型暴露三个参数:

type Parameters = {
  command: string
  timeout?: number
  workdir?: string
}

虽然工具名叫 bash,它并不保证实际使用 Bash。Shell 选择逻辑位于 packages/core/src/shell.ts。Windows 依次尝试 PowerShell 7、Windows PowerShell、Git Bash 和 cmd.exe,Unix 则使用配置的 Shell 或平台回退值。fishnu 被标记为不可接受,因为它们与常见 POSIX 命令的差异会显著提高模型出错概率。

Windows PowerShell 会以 -NoLogo -NoProfile -NonInteractive -Command 启动。其他 Shell 主要交给 Node 子进程的 shell 选项处理。工作目录不依赖前一条命令中的 cd,而是由 workdir 参数或当前项目目录决定。

这里要纠正工具提示中的一句旧描述:主实现不是持久 Shell。每次调用都会创建新的子进程,命令结束后进程随作用域回收,因此上一条调用中的 cd 和环境变量不会自动进入下一条调用。

OpenCode 把 stdout 和 stderr 合并为同一条流。这样会失去两个流的边界,却能保留更接近实际发生顺序的终端输出。执行过程中,工具持续更新 ToolPartmetadata.output,界面可以显示最新日志;模型拿到的正式工具结果仍在本次调用结束后生成。

默认超时是 120000 毫秒,可以由运行标志覆盖。主实现要求 timeout 是正整数,但没有最大值;10 分钟上限属于 V2 bash 工具的 schema。超时或用户取消后,子进程句柄会先请求终止,并在 3 秒后升级为强制清理。Unix 使用独立进程组,Windows 使用 taskkill /T /F,目的都是清理整棵进程树。

输出管理比简单的字符串截断多一层。默认超过 2000 行或 50 KiB 时,完整输出会保存到截断目录,返回给模型的是尾部预览和文件路径:

...output truncated...

Full output saved to: <path>

<tail preview>

这种设计把“输出太长”转换为下一次检索任务。模型可以使用 Grep 定位关键内容,再用 Read 按偏移读取局部,而不是让一次构建日志占满上下文。截断文件默认保留 7 天,后台任务会定期清理。

命令执行前,主实现还会使用 tree-sitter 解析 Bash 或 PowerShell 语法树。它从命令节点中提取外部路径和可复用命令前缀,例如把 git checkout main 归约为 git checkout *,再交给权限系统决定 allowaskdeny。语法树比按空格切字符串更能处理管道、引号和多条命令,但它仍然服务于审批决策,不构成操作系统隔离。

OpenCode 仓库已经有后台任务基础设施,但当前模型可见的 bash 工具没有 background 参数,也没有 Shell session ID。长时间运行的命令只能等待结束、超时或被取消,不能像 Codex 那样通过另一个工具继续向同一个进程写入。

Codex:经典命令与可交互会话并存

Codex 保留两条执行路径。经典路径使用 shell_command,统一执行路径使用 exec_commandwrite_stdin。它们共享审批和沙箱编排,但进程生命周期不同。

能力shell_commandexec_command
输入参数commandcmdttyyield_time_ms
stdin关闭PTY 模式下可写
输出捕获stdout、stderr 管道会话缓冲区
生命周期单次调用可以返回 session_id
后续交互不支持使用 write_stdin

经典路径根据用户 Shell 构造 -lc-c-Command/c 参数,然后以管道方式启动进程。默认超时为 10 秒。stdout 和 stderr 分开读取,聚合时为错误流保留更多空间,因为失败原因通常比正常日志更重要。超时会清理进程组,对外使用退出码 124。

统一执行路径默认先等待 10 秒。命令在这段时间内结束,结果直接返回;仍在运行则返回 session_id,模型随后调用 write_stdin 发送字符或轮询近期输出。

exec_command(cmd="python", tty=true)
    -> session_id: 18427

write_stdin(session_id=18427, chars="print(6 * 7)\n")
    -> output: 42

tty=true 时,子进程位于 PTY 中,REPL、终端检测和交互输入才能正常工作。tty=false 时使用普通管道,而且当前实现不会开放普通 stdin,除特殊的中断字符外,不能把它当作可交互会话。工具描述里虽然仍有“Runs a command in a PTY”的概括,真正决定是否分配 PTY 的是 tty 参数和执行分支。

yield_time_ms 不是命令总超时,而是本次调用最多等多久再把控制权还给模型。初次执行默认 10000 毫秒,有效范围通常是 250 到 30000 毫秒,Windows 初次等待下限为 10000 毫秒。空的 write_stdin 调用用于轮询,可以等待更久。进程管理器最多保存 64 个统一执行会话,默认后台终端等待上限为 300000 毫秒,并在会话移除或管理器关闭时终止遗留进程。

统一执行的缓冲区最多保留 1 MiB,头部和尾部各占一半,中间被丢弃时插入:

... N bytes omitted ...

结果还带有 original_token_count,让调用方知道截断前的大致规模。在执行器的 1 MiB 限制之外,返回模型前还会根据 max_output_tokens 和当前模型的截断策略再次收缩。前一层保护进程内存,后一层保护模型上下文,两者解决的问题不同。

审批和沙箱位于进程启动之前

Codex 的两条路径都会进入 ToolOrchestrator。它先计算命令是否需要审批,再选择当前平台的沙箱,然后才调用实际运行时。如果沙箱拒绝了某项操作,编排器会根据审批策略判断是直接返回失败,还是请求额外权限后重试。

正在渲染 Mermaid 图表...

当前 SandboxType 包含 MacosSeatbeltLinuxSeccompWindowsRestrictedToken。macOS 使用 Seatbelt,Linux 默认组合 bubblewrap 与 seccomp,并保留 legacy Landlock 路径。Windows 的枚举名仍是 WindowsRestrictedToken,但当前实现已经可以根据配置进入 legacy restricted-token 后端或 elevated runner 后端,所以不能再把 Windows 沙箱简单描述成单一的受限令牌实现。

审批和沙箱解决的是不同问题。审批让用户知道 Agent 想做什么,沙箱则在命令已经运行后强制限制它能读写哪些路径、能否访问网络。只做审批会把所有风险判断压给用户,只做命令黑名单又无法覆盖 Shell 的组合能力。Codex 把审批、权限配置和平台沙箱放在同一条调用链中,代价是实现复杂度远高于普通的子进程封装。

三种实现放在一起看

三者都把模型命令交给真实 Shell,但产品定位决定了不同的工程重心。

维度MAFOpenCodeCodex
主要定位可嵌入应用的 SDK终端编码 Agent带系统沙箱的编码 Agent
默认生命周期持久 Shell每次调用新进程经典单次与统一会话并存
工作目录配置项,可每次重新锚定每次由 workdir 决定每次由 workdir 或 turn cwd 决定
输出流stdout、stderr 分开合并后实时更新界面经典分开,统一会话缓冲
大输出每流 64 KiB,保留头尾默认 50 KiB 或 2000 行,完整内容落盘统一执行保留 1 MiB 头尾,再按 Token 截断
长任务持久 Shell,但无模型可见 session ID当前 Shell 工具不支持后台交互session_idwrite_stdin
安全主线审批或 Docker 隔离规则审批与外部路径检查审批、权限配置与 OS 沙箱

不能简单地说哪一种实现最好。应用只是偶尔运行一个受控脚本时,MAF 的 Stateless 加审批已经足够清楚;需要保留环境状态时,可以为每个会话创建独立的持久执行器。OpenCode 的一次性进程更容易回收,完整日志落盘也适合构建和测试。需要 REPL、开发服务器或持续读取输出时,Codex 的 session 模型更完整,但同时要处理会话数量、遗留进程、轮询和交互锁。

设计 Shell 工具时应先确定边界

自己实现 Shell 工具时,首先要决定命令运行在哪里。直接运行在宿主机上,就必须把审批当作默认路径,并限制工作目录、环境变量和超时;需要无人值守执行不受信任命令时,应优先使用容器或操作系统沙箱,而不是继续扩充危险命令列表。

其次要区分“命令超时”和“本次等待时间”。普通单次工具只有总超时,超时后必须清理进程树。可交互工具还需要 yield,等待时间到达后进程继续运行,工具返回 session ID。把这两个概念混为一个 timeout,要么无法支持长任务,要么容易留下无人管理的进程。

输出也不应该先无限收集,再在最后截断。读取循环要持续排空 stdout 和 stderr,内存中只保留有界缓冲;超出限制时,可以保留头尾,也可以把完整内容写入文件并返回检索提示。退出码、是否超时、是否截断、执行耗时和 session ID 都是高价值元数据,应与正文一起返回。

最后,工具描述必须来自实际环境。至少要告诉模型当前操作系统、Shell 方言、工作目录是否跨调用保留、是否支持交互、超时与输出限制。Shell 工具真正困难的地方不在于启动一个进程,而在于让模型知道自己能做什么,同时让运行时保证它只能在明确的边界内完成这些事。

本文对应的主要源码入口如下:

  • MAF:src/Microsoft.Agents.AI.Tools.Shell/
  • OpenCode:packages/opencode/src/tool/shell.tspackages/opencode/src/tool/truncate.tspackages/core/src/shell.ts
  • Codex:codex-rs/core/src/tools/handlers/shell_spec.rscodex-rs/core/src/unified_exec/codex-rs/core/src/tools/orchestrator.rscodex-rs/sandboxing/src/manager.rs