乐于分享
好东西不私藏

.NET+AI | Agent | 给 Agent 一个健壮的 Shell 工具

.NET+AI | Agent | 给 Agent 一个健壮的 Shell 工具

用 MAF 的 ShellEnvironmentProvider 注入 Shell 环境快照:让模型稳定产出可执行命令

你可能遇到过这种“看起来离谱但非常真实”的翻车:

  • 模型生成了 export DEMO_TOKEN=hello-world,但你实际运行的是 PowerShell。
  • 模型让你 cd /tmp,但你在 Windows 环境里根本没有这个路径。
  • 模型建议执行 dotnet test,但当前 Shell 的 PATH 里没有 dotnet
  • 模型写了相对路径 ./src/...,但当前工作目录并不在 repo 根目录。
  • 同一组命令在一个 Agent session 里能跑,换一个 session 就不行。

这些问题的本质往往不是模型“不聪明”,而是它在猜环境

命令型 Tool 尤其依赖运行时上下文:Shell 类型、操作系统、当前目录、常用 CLI 是否安装、CLI 版本是什么。只要其中一个前提猜错,命令逻辑再正确,也可能无法执行。

所以本文的核心判断很直接:

对 Shell / CLI 型 Agent 来说,把真实环境快照注入到模型上下文,是提升命令正确率和可复现性的关键工程手段。

在 Microsoft Agent Framework (MAF) 里,这个能力已经有了对应实现:Microsoft.Agents.AI.Tools.Shell 包中的 ShellEnvironmentProvider

它会探测当前 Shell 环境,并把一段环境说明注入到 AIContext.Instructions,让模型基于真实环境生成命令,而不是凭经验猜。


问题复盘:命令型 Tool 为什么最容易翻车

当你把 Shell 工具接进 Agent,让模型产出“可执行命令”时,它最容易在哪些点翻车?通常集中在四类环境差异。

1. Shell 类型不一致:PowerShell vs POSIX Shell

PowerShell 和 bash / zsh / sh 的语法差异非常大。

比如设置环境变量,在 PowerShell 中是:

而在 POSIX Shell 中是:

如果模型不知道当前 Shell 类型,就很容易在 PowerShell 里生成 bash 命令,或者在 Linux/macOS 里生成 PowerShell 命令。

2. 当前工作目录不一致

模型写相对路径时,通常隐含了一个前提:我现在在项目根目录

但真实执行时,当前目录可能是:

  • Notebook 所在目录
  • 后端服务启动目录
  • 临时目录
  • Docker 容器内 /workspace
  • Persistent Shell 上一次 cd 后留下的目录

一旦 cwd 不一致,./src../artifactsdotnet test ./tests/... 都可能失败。

3. PATH 与可用 CLI 差异

模型可能假设这些命令存在:

  • dotnet
  • git
  • node
  • python
  • docker
  • kubectl

但真实环境不一定安装,或者版本不一致。

对命令型 Agent 来说,“有没有这个 CLI”和“版本是多少”不是细节,而是命令能否执行的前提。

4. OS 差异:Windows / Linux / macOS

不同 OS 会影响:

  • 路径分隔符
  • 默认 Shell
  • 命令别名
  • 可执行文件后缀
  • 临时目录路径
  • 编码行为
  • 文件权限语义

所以同一句“看起来正确”的命令,在不同 OS 上可能完全不可用。

结论是:

我们要的不是“更会猜命令的模型”,而是“在给定环境约束下,产出可复现、可执行命令的 Agent”。

这就引出了 ShellEnvironmentProvider 的价值。


MAF Shell 工具的真实组成

Microsoft.Agents.AI.Tools.Shell 提供了几个核心类型:

类型
作用
ShellExecutor
抽象基类,定义初始化、执行命令、转换为工具、释放资源
LocalShellExecutor
在当前宿主机真实 Shell 中执行命令
DockerShellExecutor
在 Docker 容器中执行命令,提供更强隔离
ShellResult
表示一次命令执行结果,包括 stdout、stderr、exit code、duration、timeout 等
ShellEnvironmentProvider
探测 Shell 环境,并把环境说明注入到 AIContext.Instructions
ShellMode
控制 Shell 执行模式:Stateless 或 Persistent
ShellPolicy
基于 regex 的 allow / deny 预过滤器

典型用法如下:

如果你要把 Shell 暴露给 Agent,可以使用:

默认工具名是 run_shell

更重要的是,默认情况下 AsAIFunction() 会返回带审批语义的工具,也就是每次 Shell 命令执行前都应该经过用户确认。这一点非常关键,因为 Shell 工具不是普通查询工具,它会对真实环境产生影响。


ShellEnvironmentProvider 做了什么

ShellEnvironmentProvider 的职责不是执行用户任务,而是回答一个更基础的问题:

当前 Agent 面对的 Shell 环境到底是什么?

它会通过传入的 ShellExecutor 执行探测命令,捕获一份 ShellEnvironmentSnapshot

这份快照包含:

  • Shell family:Posix 或 PowerShell
  • OS 描述
  • Shell 版本
  • 当前工作目录
  • 常用 CLI 版本,例如:
    • git
    • dotnet
    • node
    • python
    • docker

然后它会把这份快照格式化成一段系统提示,注入到 AIContext.Instructions

这点很重要:它不是把环境信息作为普通 user message 注入,而是作为 instructions 注入。原因是 Shell 环境属于稳定运行时元数据,应该以更高优先级约束模型行为。

例如 POSIX 环境下,它会提示模型:

PowerShell 环境下,则会提示:

这就是“让模型读环境,而不是猜环境”。


为什么是 Context Provider,而不是写死在 Tool 描述里

你可能会问:为什么不直接把 Shell 信息写进工具 description?

因为工具 description 更适合描述“这个工具能做什么”,而不是描述“当前运行环境是什么”。

当前运行环境是动态的:

  • 当前目录可能变化
  • CLI 可能安装或卸载
  • Docker 容器环境和宿主机环境不同
  • Persistent Shell 的 cwd 和环境变量可能跨调用保留
  • 不同 session 可能对应不同 workspace

所以环境信息应该由 Context Provider 在调用前动态提供,而不是写死在静态工具描述里。

ShellEnvironmentProvider 的位置可以理解为:

它解决的是“模型调用工具前知道环境约束”的问题。


Stateless vs Persistent:Shell 状态到底怎么管理

LocalShellExecutor 支持两种模式:

这两个模式决定了 Shell 状态是否跨调用保留。

Stateless:每次调用都是新 Shell

Stateless 模式下,每次 RunAsync() 都启动一个新的 Shell 进程。

这意味着:

  • cd
     不会影响下一次调用
  • export
     / $env: 设置不会影响下一次调用
  • 每次命令更独立
  • 状态隔离更强

适合:

  • 只读检查
  • 单次命令
  • CI / 批处理
  • 你更关注可复现性,而不是交互便利性

示例:

如果你先执行:

下一次再执行:

它通常不会保留上一次的 cd 状态。

Persistent:同一个长生命周期 Shell

Persistent 模式下,一个长生命周期 Shell 会被复用。

这意味着:

  • cd
     会影响后续命令
  • 环境变量会保留
  • Shell 函数定义可能保留
  • 更适合多步骤 Coding Agent

例如:

如果 Agent 的任务是:

  1. 进入项目目录
  2. 查看文件
  3. 运行测试
  4. 根据失败结果修改命令

Persistent 模式更自然,因为它避免了“Agent 已经 cd 过,但下一次命令又回到默认目录”的问题。

但 Persistent 也带来一个非常重要的生命周期约束:

一个 Persistent executor 应该只属于一个 conversation / agent session,不能跨用户、跨租户、跨并发会话共享。

因为它携带可变状态:

  • cwd
  • 环境变量
  • Shell history
  • 函数定义
  • 后台任务
  • 容器内文件系统状态

如果把它注册成 Singleton,就可能发生状态泄露和命令串扰。


ShellEnvironmentProvider 与 Persistent Shell 的配合

ShellEnvironmentProvider 和 Persistent 模式搭配时,价值会更明显。

Persistent Shell 会让 cwd 和环境变量跨调用保留;而 provider 会把当前 Shell 环境注入给模型。

示例结构:

RefreshAsync() 可以强制重新探测环境。

适用场景包括:

  • Agent 安装了新 CLI
  • 用户切换了工作目录
  • 命令失败提示 command not found
  • 容器环境初始化完成后需要重新读取环境

需要注意的是:当前 ShellEnvironmentProvider 会缓存探测结果。也就是说,它不是每次都无条件重新探测。这样可以减少探测成本,但当环境发生关键变化时,你应该显式调用 RefreshAsync()


ShellResult:让模型基于真实输出回答

Shell 命令执行后,结果会保存在 ShellResult 中:

字段
含义
Stdout
标准输出
Stderr
标准错误
ExitCode
退出码
Duration
执行耗时
TimedOut
是否超时
Truncated
输出是否被截断

它还提供:

这个方法会把 stdout、stderr、timeout 和 exit code 整理成模型容易理解的文本。

这对 Agent 很重要:模型不应该“假设命令成功”,而应该根据真实 exit code 和输出继续推理。


安全边界:ShellPolicy 有用,但不是安全机制

ShellPolicy 提供 allow / deny regex 过滤能力。

例如:

它适合做 UX 预过滤:

  • 明显危险的命令提前拒绝
  • 站点特定的敏感命令提前拒绝
  • 给用户更清晰的错误信息

但它不是安全边界。

因为 pattern 很容易被绕过:

  • 变量拼接:${RM:=rm} -rf /
  • 解释器逃逸:python -c "..."
  • 命令替代:$(base64 -d <<< ...)
  • PowerShell 变体:Remove-Item -Recurse -Force
  • 替代工具:find / -delete

所以真正的安全防线应该包括:

  1. approval-in-the-loop
  2. 受控工作目录
  3. 超时限制
  4. 输出截断
  5. 环境变量清理
  6. Docker / VM / 沙箱隔离
  7. 审计日志

DockerShellExecutor:更适合不可信命令

当你不希望模型生成的命令直接运行在宿主机上,可以使用 DockerShellExecutor

它默认偏向安全基线:

  • 网络:none
  • 用户:非 root,65534:65534
  • root filesystem:read-only
  • memory limit:512 MiB
  • pids limit:256
  • capabilities:--cap-drop=ALL
  • security opt:no-new-privileges
  • /tmp
    :tmpfs,限制大小并禁用 suid / device

这比直接运行本地 Shell 更适合不可信命令。

但要注意:容器隔离也不是绝对安全。高风险场景仍应考虑:

  • 专用 VM
  • gVisor / Kata
  • 网络分段
  • 只读挂载
  • 最小权限
  • 完整审计

工程落地建议

你可以按场景选择执行边界:

场景
推荐执行器
审批
隔离策略
本地教学 / Demo
LocalShellExecutor
可在只读命令下显式关闭
受控目录 + 固定命令
个人 Coding Agent
LocalShellExecutor
 或 DockerShellExecutor
高风险命令必须审批
workspace 限制 + timeout + 输出截断
企业生产 / 多租户
DockerShellExecutor
 或更强沙箱
默认开启审批并记录审计
容器 / VM / 网络分段 / 最小权限

如果使用 Persistent 模式,要额外遵守:

  • 不要把 executor 注册为 Singleton
  • 一个用户会话一个 executor
  • 会话结束时释放 executor
  • 提供 reset / refresh 机制
  • 环境变化后调用 RefreshAsync()

总结

命令型 Tool 失败,很多时候不是模型不会写命令,而是它缺少环境前提。

ShellEnvironmentProvider 的价值,就是把这些前提显式注入给模型:

  • 当前是什么 Shell
  • 当前是什么 OS
  • 当前工作目录在哪里
  • 哪些 CLI 可用
  • 这些 CLI 的版本是什么
  • 应该使用哪种命令语法

这样模型就不再“猜环境”,而是基于真实环境生成命令。

同时,Shell 工具也必须谨慎使用:

  • ShellPolicy
     不是安全边界
  • 本地 Shell 默认应保留人工审批
  • Persistent executor 必须一会话一实例
  • 高风险任务应使用 Docker 或更强隔离
  • 环境变化后应刷新 snapshot

最终目标不是让模型“更会写命令”,而是让 Agent 在明确环境约束下,稳定地产出可执行、可复现、可审计的命令。