用 MicroPython 和 WASM 在沙箱中运行 Python 代码
这几年来,我一直在尝试各种在沙箱(sandbox)中运行代码的方法。最近这次尝试让我觉得,它也许终于具备了我一直寻找的全部特性。我已经把它作为一个名为 micropython-wasm 的 alpha 包发布出来,并且正用它为 Datasette Agent 开发一个代码执行沙箱插件,名为 datasette-agent-micropython。
- 我为什么想要一个沙箱?
- 我希望沙箱具备什么
- WebAssembly 在这方面很有希望
- WebAssembly 中的 MicroPython
- 构建第一个版本
- 自己试一试
- 你应该信任我靠氛围编程做出的沙箱吗?
我为什么想要一个沙箱?
我的几个主要开源项目——Datasette、LLM,甚至 sqlite-utils——都支持插件。
我非常喜欢用插件来扩展软件。经过精心设计的插件系统,能把尝试新事物的风险降到几乎为零:即使是最疯狂的想法,也不会对核心应用本身留下持久影响。我的软件可以一夜之间长出一个新功能,我甚至不需要审查拉取请求(pull request)!
不过,这有一个很大的缺点:我的插件系统都使用 Python 和 Pluggy,插件代码在我的应用中执行时拥有完整权限。有缺陷或恶意的插件可能把一切都搞坏,或者泄露私人数据。
我希望能在一种环境中运行类似插件的代码,让它无法读取未经批准的文件、连接网络,也无法以其他可能给应用的其余部分或用户电脑带来风险或伤害的方式运行。
我的兴趣不只在插件上。尤其对 Datasette 而言,我想支持的许多功能都能用上任意代码执行。我已经在 Datasette Enrichments 中做过这方面的实验,在那里可以用代码转换表中存储的值。我想做出这样一种机制:按计划运行代码,从经过批准的位置获取 JSON,运行一小段代码,把它重新整理成字典列表,再把这些字典作为行插入 SQLite 数据库表。
我希望沙箱具备什么
我的目标是在自己的 Python 应用中安全地执行代码。我需要的是:
- 依赖项能够直接从 PyPI 顺利安装;如有必要,也要有覆盖多个平台的二进制 wheel 包。我不希望软件用户除了直接安装我的 Python 包以外,还必须做任何额外操作。
- 执行的代码必须同时受到内存和 CPU 限制。我不想让
while True: s += "longer string"把我的应用或用户的电脑弄崩溃。 - 必须严格控制文件访问。要么完全不能访问文件系统,要么由我准确规定哪些文件可以读取、哪些文件可以写入。
- 网络访问也要受到控制。沙箱中的代码与任何对象通信,都应该经过一个完全由我控制的层。
- 支持与宿主函数(host functions)交互。如果我无法谨慎地把选定的平台功能提供给沙箱运行的代码,沙箱也就没有多大用处。
- 它必须稳健、有维护支持,而且文档清晰。我已经记不清看过多少个沙箱项目,其代码仓库里都警告说项目没有在积极维护了!
WebAssembly 在这方面很有希望
说到恶意代码,Web 浏览器是在能想象到的最恶劣环境中工作的。它们的工作,就是几乎每次加载页面时都从网上下载并执行不可信的代码。
照这样看,JavaScript 引擎应该是极好的沙箱候选。可惜这些引擎也极其复杂,而且并不是为了方便嵌入其他项目而设计的。我见过的大多数 v8-in-Python 项目都很少维护,并且警告不要用它们运行完全不可信的代码。
WebAssembly 是一个好得多的候选。它从一开始就是为了支持我所关心的全部特性而设计的,而且已经在浏览器中经受了近十年的检验。wasmtime 这个 Python 库有积极的维护,也提供二进制 wheel 包。
WebAssembly 中的 MicroPython
像 wasmtime 这样的 WebAssembly 引擎运行的是 WebAssembly 二进制文件。有些编程语言,比如 Rust,很容易直接编译为 WebAssembly。JavaScript 和 Python 这样的动态语言就更困难了:它们支持 eval() 这样的语言原语,这意味着运行时需要有一个完整的解释器。
要运行 Python,我们需要把完整的 Python 解释器编译成 WebAssembly,再把它连接起来,使我们可以方便地向它提供代码、接入宿主函数,并获取结果。
Pyodide 是一个出色的包,可以在浏览器中通过 WebAssembly 运行 Python,但它不支持在服务端 Python 中使用。我能找到的最新说明来自 2024 年 10 月,其中说:“Pyodide 由 Emscripten 工具链构建,只能在浏览器或 Node.js 中运行。”
前几天,我决定看看 MicroPython 能不能用来做这件事。MicroPython 网站上写道:
MicroPython 是 Python 3 编程语言的一种精简、高效的实现,包含 Python 标准库的一个小型子集,并针对微控制器和受限环境中的运行进行了优化。
对我来说,WebAssembly 确实很像一种受限环境!
构建第一个版本
我让 GPT-5.5 Pro 帮我做了一些研究,它找到了 Yamamoto Takahashi 向 MicroPython 提交的这个 PR,标题是“Experimental WASI support for ports/unix”(为 ports/unix 提供实验性 WASI 支持)。
随后它生成了这份 research.md 文档。于是我让 Codex Desktop 和 GPT-5.5 high 放手去做,看看会怎样:
阅读 research.md 文档,并把它做出来。作为这个项目的一部分,你可能需要编写一个脚本,编译定制的 MicroPython WASM 版本;在这个脚本中,把 MicroPython 代码获取到 /tmp 目录。
成功了。现在我有了一个 Python 库原型,可以在 WebAssembly 沙箱中执行 Python 代码!
最棘手的问题是解释器状态的持久化(persistent interpreter state)。我们这里使用的 WASM 构建只暴露一个入口点:启动解释器,运行代码,最后停止解释器。
对一次性脚本来说,这样没问题。但在 Datasette Agent 中,我希望变量和函数常驻内存,以便在多次代码执行调用之间复用它们。
与编程智能体一起工作,有一点很妙:你可以很快从一个想法做到概念验证。我给出的提示是:
关于让变量常驻:如果我们在 micropython 内部运行代码,让它调用宿主函数 get_next_python_code(),再把结果传给 eval(),而那个宿主函数会一直阻塞,直到有新的代码可用——也许可以让它在线程中运行,配上一个队列——会怎么样?这个想法或者类似的想法,在这里能帮上忙吗?
迭代了几轮之后,我们得到了一个可用的版本!现在你可以在 Python 代码中这样做:
from micropython_wasm import MicroPythonSession
with MicroPythonSession() as session:
print(session.run("x = 10\nprint(x)").stdout)
print(session.run("x += 5\nprint(x)").stdout)
print(session.run("print(x * 2)").stdout)
译者注:代码的字符串定界引号按作者公开原文恢复为ASCII引号。
其内部实现会启动一个线程,建立请求队列,然后针对 session.run() 命令向这个队列发送消息,每次都在回复队列上等待此次执行的结果。在 WASM 内部,MicroPython 解释器会阻塞,等待 __session_next__() 宿主函数返回下一行代码,然后对这行代码运行 eval();每个代码块成功执行后,再调用 __session_result__({"id": request_id, "ok": True})。
另一个复杂之处是支持宿主函数,让我的 Python 库能够有选择地暴露一些函数,供 MicroPython 中运行的代码调用。
Codex 最终用 78 行 C 代码解决了这个问题。这些代码最终被编译进了我随包分发的 362KB WebAssembly 二进制文件。
我绝对算不上 C 程序员,但我读过这些 C 代码,让两个不同的模型向我解释过它们(这里是 Claude 的解释),还让它们经受了大量测试。
使用 WebAssembly 的好处是,如果这些 C 代码最终被发现有致命缺陷,最坏的结果也就是 WebAssembly 执行抛出异常并失败。这种风险我可以接受。
wasmtime 直接支持内存限制。CPU 限制稍微难一点:wasmtime 提供了一个“燃料”(fuel)概念,用来限制一次 WebAssembly 调用能够执行多少次操作,它很适合解决这个问题,但这些单位很难直观理解。我目前正在尝试把默认“燃料”设为 2000 万,不过我并没有把握这就是最合适的数值。
自己试一试
micropython-wasm 的 alpha 版本现在已经在 PyPI 上发布。
你可以按照 README 中的说明,在自己的 Python 代码中试用它。我还在 0.1a2 版本中加入了一个简单的命令行界面(CLI)模式,这意味着你可以使用 uvx 来试用,无需先安装,像这样:
uvx micropython-wasm -c 'print("Hello world")'
# To see it run out of fuel:
uvx micropython-wasm -c 's = ""; while True: s += "longer"'
# Outputs: micropython-wasm: guest exited with code 1
译者校注:公开原文的“耗尽燃料”示例同样把while放在分号后,这不符合Python语法,不能按原样复制运行;此处保留原例的排列。
你也可以像下面这样在 Datasette Agent 中试用:
uvx llm keys set openai
# Paste in an OpenAI key, then:
uvx --with datasette-agent \
--with datasette-agent-micropython \
--prerelease allow \
datasette --internal internal.db \
-s plugins.datasette-llm.default_model gpt-5.5 \
--root -o
然后打开 http://127.0.0.1:8001/-/agent,输入提示:
show me some micropython
![聊天应用界面截图:深蓝灰色页眉左侧写着 home,右侧是 root 和汉堡菜单图标。下方导航行左侧有 ← Back 和 Chat,右侧有 EXPORT 按钮。蓝色用户消息气泡写着 show me some micropython。下面折叠的思考区写着 ▸Thinking: … to show the result clearly. After that, I can wrap up with a brief explanation!,其后是 ▶ Tool: execute_micropython 标签。接着是代码块:# A tiny MicroPython example: blink-style logic + Fibonacci / def fib(n): / a, b = 0, 1 / out = [] / for _ in range(n): / out.append(a) / a, b = b, a + b / return out / print("Hello from MicroPython!") / print("First 10 Fibonacci numbers:", fib(10)) / # MicroPython often runs on microcontrollers, e.g.: / # from machine import Pin / # led = Pin(2, Pin.OUT) / # led.value(1) # turn LED on / # led.value(0) # turn LED off。横线下方是输出:Hello from MicroPython! / First 10 Fibonacci numbers: [0, 1, 1, 2, 3, 5, 8, 13, 21, 34],接着是 ▶ Result: execute_micropython 标签。最下方是占位文字为 Type a message... 的输入框,以及蓝色 Send 按钮。](https://substackcdn.com/image/fetch/$s_!UEaC!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2a7150cd-6697-4e13-b1fa-160db02edb00_1650x1776.jpeg)
你应该信任我靠氛围编程(vibe coding)做出的沙箱吗?
我抱怨过那些不成熟、维护松散的沙箱库,如今却自己做了一个,这实在很讽刺!
我特意给它贴上了 alpha 发布版本的标签。对于不愿承担重大风险的人,我还没有准备好向他们推荐它。
我已经做了足够多的测试,自己用它是可以接受的。我发布了第一个使用它的插件,datasette-agent-micropython。我还把 GPT-5.5 xhigh 关在那个 Datasette Agent 插件里,挑战它逃出沙箱,到目前为止它还没能做到。
我希望这个实现能说服一些拥有专业安全团队、面临高风险问题的公司,投入使用在 WebAssembly 中运行 Python 这种沙箱方案,并开源它们自己的解决方案。
译者说明
作者关于对代码调用eval()的描述按公开原文保留。关于WebAssembly故障边界的判断、模型迄今未能逃逸的结果与alpha风险提示均为作者当时的判断和实验叙述。
此文章由 AI 翻译