Agent代码沙箱技术:OpenSandbox和增强code-interpreter镜像

AI 智能摘要

在 AI 应用里,让大模型“会写代码”并不难,难的是让它安全、稳定、可控地执行代码

在项目中接入 OpenSandbox 的目标,不是做一个通用远程终端,而是给大模型一个受控的代码执行能力:读取用户上传的 Excel、CSV、Word、PDF 等文件,完成分析、计算、转换,再把生成结果以下载链接返回给用户。

这篇文章总结一下我目前使用 OpenSandbox 的一些技巧和踩坑经验。

1. 不把 OpenSandbox 原子 API 直接暴露给大模型

OpenSandbox 提供了很多底层能力,例如:

  • /files:文件上传、下载、搜索
  • /directories:目录操作
  • /code:代码执行
  • /code/context:有状态代码执行
  • /command:Shell 命令
  • /metrics/watch:实时监控

一开始很容易想把这些接口一比一封装成 MCP tool 给大模型调用。但实际使用下来,我更倾向于只暴露一个高层工具:

sandbox_execute_command(language, script, outputFilePaths)

大模型只需要关心三件事:

  1. 用什么语言执行,当前只开放 Python 和 JavaScript。
  2. 要执行的完整脚本是什么。
  3. 是否需要生成输出文件,生成哪些文件。

会话 ID、上传文件路径、本地输出目录、下载链接生成,都由服务端处理,不交给大模型填写。

这样做的好处是边界清晰:大模型负责“写业务脚本”,服务端负责“安全执行和文件治理”。

2. sessionId 不让模型传,由 ToolContext 注入

沙箱执行必须和当前对话会话绑定,否则很容易出现跨会话读写文件的问题。

我的做法是:

  • 对话入口保证每个 chat session 都有 sessionId
  • sessionId 放进 Spring AI 的 ToolContext
  • MCP tool 参数里不暴露 sessionId
  • 沙箱工具从 ToolContext 读取 sessionId

这样大模型既不能伪造 session,也不会因为写错 sessionId 导致文件落错目录。

// ToolContext 只在服务端传递,不进入大模型 tool 参数;沙箱工具用它绑定会话和上传文件。
toolContext.put(TOOL_CONTEXT_SESSION_ID, sessionId);
toolContext.put(TOOL_CONTEXT_FILE_URLS, fileUrls);

这个设计很关键:模型能看到业务参数,但不能控制安全边界参数

3. 一个会话复用一个 Sandbox,并设置生命周期清理

代码执行服务里维护了一个 ConcurrentHashMap<String, Sandbox>

private final ConcurrentHashMap<String, Sandbox> sandboxes = new ConcurrentHashMap<>();

同一个 sessionId 下,如果沙箱还健康,就复用并续期;如果不健康,就关闭后重新创建。

if (cached != null && cached.isHealthy()) {
    cached.renew(properties.getSandboxTimeout());
    return cached;
}

这样可以减少频繁创建沙箱的成本,也为后续的交互式分析留下空间。

同时,会话结束时监听会话结束事件,主动清理沙箱:

@EventListener
public void onToolExecutionFinished(AiSessionFinishedEvent event) {
    sandboxExecutionService.clearSandboxes(event.getSessionId());
}

这比单纯依赖超时更稳,避免沙箱长时间占用资源。

4. 使用持久化 workspace,而不是依赖内存状态

当前默认开启会话级持久化工作区:

open-sandbox:
  persistent-workspace-enabled: true
  workspace: /workspace
  allowed-output-paths:
    - /home/app/upload/sandbox/

映射关系大致是:

沙箱内:/workspace
宿主机:/home/app/upload/sandbox/{sessionId}

也就是说,同一个会话里,脚本生成的文件会保留在会话目录下。即使沙箱重建,只要重新挂载同一个目录,文件状态仍然在。

我现在更推荐这种“文件状态持久化”,而不是一开始就依赖 /code/context 的内存变量持久化。

原因是:

  • 文件更容易审计。
  • 文件更容易下载和复用。
  • 文件不会因为内核重启直接丢失。
  • 财务、表格、文档分析这类场景,本来就天然以文件为中间产物。

/code/context 更适合 Notebook 式连续分析,例如连续复用 dfresult、辅助函数。它有价值,但不建议默认替代当前的一次性脚本执行模式。

5. 输入文件由服务端解析并上传到 /workspace

用户上传文件时,大模型看到的通常是文件 URL。我的处理方式是:

  1. ToolContext.fileUrls 读取上传文件。
  2. 通过业务文件服务解析成本地文件。
  3. 校验文件类型是否支持。
  4. 上传到沙箱 /workspace
  5. 返回模型真实可访问的沙箱路径。

支持的输入类型包括:

xlsx, xls, csv, tsv, docx, doc, pdf, pptx, ppt,
txt, md, html, json, xml, yaml 等

同时对文件名做安全处理:

  • 替换控制字符。
  • 替换 /\
  • 限制文件名长度。
  • 同名文件自动改成 xxx_2.xlsxxxx_3.xlsx

这样可以避免多个上传文件在沙箱中互相覆盖。

6. 输出文件路径不让模型写死,而是注入 OUTPUT_FILES

这是整个方案里最实用的技巧之一。

大模型不应该在脚本里写死:

df.to_excel("/workspace/result.xlsx")

而应该写:

df.to_excel(OUTPUT_FILES[0], index=False)

服务端根据 outputFilePaths 计算真实输出路径,并在脚本前注入:

OUTPUT_DIR = "/workspace"
OUTPUT_FILES = ["/workspace/report.xlsx"]

JavaScript 也是类似:

const OUTPUT_DIR = "/workspace";
const OUTPUT_FILES = ["/workspace/report.xlsx"];

这样做可以保证:

  • 模型生成路径和服务端下载路径一致。
  • 输出文件一定落在当前会话目录。
  • 多文件输出时顺序明确。
  • 不需要让模型理解宿主机真实路径。

7. 不做“唯一文件兜底下载”

有些代码执行工具会在脚本没写到指定输出文件时,自动搜索 workspace 里唯一的新增文件并下载。

我没有这么做。

如果期望输出是:

/workspace/result.xlsx

但模型实际写成了:

/workspace/abc.xlsx

工具会返回明确错误:

输出文件未生成,期望脚本写入:/workspace/result.xlsx。
检测到脚本可能生成了唯一非输入文件:/workspace/abc.xlsx。
请让脚本写入 /workspace/result.xlsx,或把 outputFilePaths 改为 abc.xlsx。
工具不会自动兜底下载,避免多文件误匹配。

这看起来“不够智能”,但更安全。

尤其在多文件场景下,自动猜测很容易下载错文件。工具应该帮模型定位问题,而不是替模型做不确定的决定。

8. 本地路径必须做白名单和真实路径校验

代码执行工具最容易出问题的地方是文件路径。

当前实现里分了两个白名单:

open-sandbox:
  allowed-local-paths:
    - /home/app/upload/
  allowed-output-paths:
    - /home/app/upload/sandbox/

含义是:

  • allowed-local-paths:允许作为输入读取的本地目录。
  • allowed-output-paths:允许作为输出写入的本地目录,范围更小。

输出文件最终会被解析到:

allowed-output-paths/{sessionId}/xxx

绝对路径也允许,但必须已经位于当前会话目录内。相对路径则统一挂到当前 session 输出目录下。

此外,还要防几类绕过:

  • ../ 路径跳出当前目录。
  • session 目录是符号链接。
  • 输出文件本身是符号链接。
  • 父目录通过符号链接绕到其他目录。
  • 输出文件存在多个硬链接,覆盖时影响其他会话文件。

这些校验看起来繁琐,但非常必要。大模型写代码时不会天然有安全意识,服务端必须兜住边界。

9. 下载文件时用原子替换,避免半成品暴露

如果不开启持久化 workspace,工具会从沙箱读取文件内容,再写回本地输出目录。

这里我没有直接写目标文件,而是:

  1. 先写同目录临时文件。
  2. 再用 atomic move 替换目标文件。
  3. 不支持原子替换时降级为普通替换。
  4. 最后清理临时文件。
Files.move(tempPath, localPath, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);

这样可以避免下载接口读到半截文件。

10. 自定义 Code Interpreter 镜像,提前补齐 Office/PDF 能力

官方 Code Interpreter 镜像能跑代码,但业务里经常要处理 Office 文档,所以我做了增强镜像。

系统依赖包括:

libreoffice-writer
libreoffice-calc
libreoffice-impress
poppler-utils
中文字体

Python 依赖包括:

pandas
numpy
openpyxl
xlsxwriter
xlrd
pyxlsb
odfpy
python-docx
python-pptx
pypdf
PyMuPDF
pdfplumber
reportlab
pillow

构建阶段还会预初始化 Jupyter kernelspec,避免每次沙箱启动时重复初始化 Python、JavaScript、TypeScript、Go、Bash 内核。

这是一个很实际的优化:代码执行工具的首轮响应速度,往往决定用户是否觉得“能用”。 附上Dockerfile

FROM sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2

USER root

ARG CODE_INTERPRETER_PYTHON_VERSION=3.13
ARG CODE_INTERPRETER_NODE_VERSION=20

RUN apt-get update \
    && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
        libreoffice-writer \
        libreoffice-calc \
        libreoffice-impress \
        poppler-utils \
        fontconfig \
        fonts-noto-cjk \
        fonts-wqy-zenhei \
    && fc-cache -f || true \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*

RUN echo "code-interpreter build python selector: ${CODE_INTERPRETER_PYTHON_VERSION}" \
    && source /opt/opensandbox/code-interpreter-env.sh python "${CODE_INTERPRETER_PYTHON_VERSION}" \
    && python3 -m pip install --prefer-binary --break-system-packages \
        pandas \
        numpy \
        openpyxl \
        xlsxwriter \
        xlrd \
        pyxlsb \
        odfpy \
        python-docx \
        docxcompose \
        mammoth \
        python-pptx \
        pypdf \
        PyMuPDF \
        pdfplumber \
        pdfminer.six \
        reportlab \
        pillow \
        lxml

COPY code-interpreter.sh /opt/opensandbox/my-code-interpreter.sh
RUN chmod +x /opt/opensandbox/my-code-interpreter.sh \
    && ln -sf /opt/opensandbox/my-code-interpreter.sh /opt/opensandbox/code-interpreter.sh \
    && CODE_INTERPRETER_SETUP_ONLY=1 PYTHON_VERSION="${CODE_INTERPRETER_PYTHON_VERSION}" NODE_VERSION="${CODE_INTERPRETER_NODE_VERSION}" /opt/opensandbox/my-code-interpreter.sh

WORKDIR /workspace

ENTRYPOINT ["/opt/opensandbox/my-code-interpreter.sh"]

11. 默认禁用沙箱出网

当前配置里默认关闭网络:

open-sandbox:
  network-enabled: false

创建沙箱时下发 networkPolicy,默认拒绝出网。

这对企业内部知识库、文件分析、财务数据处理很重要。用户上传的文件进入沙箱后,不应该被脚本随意发往外网。

如果某些场景确实需要联网,应该做成明确配置,而不是默认开放。

12. 清理任务只扫沙箱输出目录

输出文件需要定期清理,但清理逻辑不能太激进。

我的做法是:

  • 只扫描 allowed-output-paths
  • 额外扫描对话临时上传目录。
  • 按最后修改时间清理过期文件。
  • 空目录也必须超过保留时间才删除。
  • 使用独立 daemon 线程,不依赖项目全局调度配置。
open-sandbox:
  cleanup-enabled: true
  output-retention: 24h
  cleanup-interval: 1h

清理任务的边界一定要小,宁可少删,也不能误删业务上传目录。

13. 为什么暂时不直接开放 /command

OpenSandbox 的 /command 很强,但我不建议直接给大模型。

Shell 命令的风险比 /code 高很多:

  • 可以操作更多系统命令。
  • 更容易绕过业务约束。
  • 输出和副作用更难控制。
  • 大模型容易为了“方便”执行过宽命令。

如果以后确实需要 Shell 能力,我更倾向于做白名单命令工具,例如:

convert_document_to_pdf
inspect_file_type
list_workspace_files

而不是暴露一个通用 execute_shell_command

14. /code/context 什么时候值得做

/code/context 的优势是变量能跨多次执行保留。

比如用户上传 Excel 后连续问:

  1. 先读取资产负债表。
  2. 再计算收入、成本、毛利率。
  3. 再按部门分组。
  4. 再换一个口径重新计算。
  5. 最后导出图表和报告。

这种交互式财务分析场景下,保留 df、清洗规则、字段映射和辅助函数,会明显提升体验。

但我不会让它替代当前的 sandbox_execute_command。更合理的方式是新增一个专用工具:

excel_data_analysis_context

并且加上约束:

  • context_id 由服务端绑定 sessionId
  • 同一个 context 串行执行。
  • 支持 reset。
  • 返回当前变量摘要。
  • 保留执行历史,方便最终生成可复现脚本。
  • 设置 TTL 和内存上限。

简单说:一次性文件处理用 /code,多轮交互分析再上 /code/context

15. 总结

  1. 大模型只写业务脚本,不控制安全参数。
  2. 文件输入输出都由服务端托管。
  3. 输出路径由服务端注入,不让模型写死。
  4. sessionId、上传文件、下载路径都不要暴露成模型参数。
  5. 默认禁用出网。
  6. workspace 可以持久化,内存 context 不急着默认开放。
  7. 失败时给模型明确修正建议,但不要自动猜测危险结果。
  8. 路径校验要按真实路径做,不能只看字符串前缀。

OpenSandbox 本身提供的是底层能力,真正落地到 AI 产品里,关键在于再包一层业务边界。

这层边界越清晰,模型越容易稳定完成任务,系统也越不容易出安全问题。