Agent 写出的代码你敢直接跑吗?让它在你自己的进程里执行,等于把文件系统、网络与凭据一起交出去;丢进容器,又要为每次调用付出几百毫秒启动与一层运维。wasi-python 给的是第三条路:把 CPython 3.15 编译成一个静态的 WebAssembly 二进制(wasm32-wasip1),把 numpy、pandas、 scipy、scikit-learn、statsmodels、matplotlib、lxml 等四十多个包一起编进去,运行时默认看不见宿主文件、连不上网络、也起不了进程。0.1.0 发布于 10 月 23 日,Apache-2.0 协议,一条 pip 安装即可用,也可以把同一份二进制塞进任何支持异常处理提案的 WebAssembly 宿主。它要解决的正是「跑不信任代码」这件事:Agent 生成的数据分析脚本、用户提交的公式、来自外部的输入。本教程按装上跑通、发放目录通行证、看清三道默认封锁、四道限流闸、读懂结果字段、换宿主与生产落地六步把它用起来。
Step 1:装上,跑通一条命令
安装之后会多出一个 wasi-python 命令,也能在 Python 里直接 import。CLI 的写法是 wasi-python run 后面跟你想传给解释器的参数,中间用两个短横线隔开。返回值是一行 JSON:exit_code、stdout、stderr、fuel_used、trapped、truncated 六个字段,机器读起来很方便。wheel 里带着运行时本身,所以在离线机器上也能装,代价是体积不小;每个版本都在 Linux(x86-64 与 arm64)、macOS(arm64)与 Windows(x86-64)上测过。
$ pip install wasi-python
$ wasi-python run -- -c "print('hello, WebAssembly')"
{"exit_code": 0, "fuel_used": null, "stderr": "",
"stdout": "hello, WebAssembly\n", "trapped": null, "truncated": []}
Python 里的用法同样直接:from wasi_python import run,然后 run(["-c", 代码])。真正让它适合 Agent 场景的是科学计算栈已经在里面——不必在沙箱里 pip install 任何东西,import numpy 就能用。下面这段一次跑完频域分析、曲线拟合、聚类与汇总,全是沙箱内的真实计算。
from wasi_python import run
code = """
import numpy as np
import pandas as pd
from scipy import optimize
from sklearn.cluster import KMeans
rng = np.random.default_rng(7)
t = np.linspace(0, 5, 200)
signal = np.sin(2 * np.pi * 3 * t) + rng.normal(0, 0.3, t.size)
freqs = np.fft.rfftfreq(t.size, d=t[1] - t[0])
print(f"dominant frequency: {freqs[np.abs(np.fft.rfft(signal)).argmax()]:.1f} Hz")
(a, k), _ = optimize.curve_fit(
lambda t, a, k: a * np.exp(-k * t),
t, 3 * np.exp(-0.7 * t) + rng.normal(0, 0.1, t.size))
print(f"decay fit: a={a:.2f} k={k:.2f}")
points = np.vstack([c + rng.normal(0, 0.5, (100, 2)) for c in ([1, 1], [5, 5], [1, 5])])
labels = KMeans(n_clusters=3, n_init=10, random_state=0).fit_predict(points)
print(pd.DataFrame(points, columns=["x", "y"]).groupby(labels).mean().round(1))
"""
done = run(["-c", code])
print(done.stdout.decode())
输出会给出主频 3.0 Hz、衰减拟合的 a 与 k,以及三簇中心——也就是说访客拿到的是完整可用的科学栈,而不是一个瘸腿的 Python。返回结果对象除了 stdout 之外还带 exit_code、stderr、fuel_used 与 trapped,其中的 trapped 会点名是哪道闸把这次调用停下的,例如 deadline 或 out of fuel。
Step 2:给访客发通行证,只发你愿意给的那一格
沙箱里的程序是访客,你的程序是宿主。默认它看不见任何宿主文件;要让访客读一个目录,必须显式 Grant,并指定它在访客内部的挂载点。这个模型是能力式的,不是路径式的:没有 grant 的目录在访客眼里根本不存在,连列目录都列不到。下面这段建两个目录各放一个文件,只把其中一个挂进沙箱,然后让访客分别读共享文件、私有文件、以及用相对路径「跳出去」的那一次。
import shutil
from pathlib import Path
from wasi_python import Grant, run
for name in ("wasi_python_shared", "wasi_python_private"):
Path(name).mkdir(exist_ok=True)
(Path(name) / "hello.txt").write_text(f"hello from {name}\n")
code = """
for path in ("/shared/hello.txt", "/private/hello.txt", "/shared/../private/hello.txt"):
try:
print(path, "->", open(path).read().strip())
except OSError as e:
print(path, "->", type(e).__name__)
try:
open("/shared/notes.txt", "w")
except OSError as e:
print("write to /shared ->", type(e).__name__)
"""
done = run(["-c", code], grants=[Grant("wasi_python_shared", "/shared")])
print(done.stdout.decode())
结果是:共享文件读得到;私有文件是 FileNotFoundError,仿佛世界上没有这个目录;用 ../ 想跳出挂载点拿到的是 PermissionError,而不是悄悄成功;写入同样被拒——Grant 默认给的是只读,写权限要另外开。这三条合起来就是这套沙箱最有说服力的地方:越界不是靠约定,是靠能力集里根本没有那个名字。
挂载点就是新的根目录:把包含凭据、.env、私钥或客户数据的目录挂进沙箱之前先想清楚——访客能读到挂载点下的全部内容,包括你没打算给它看的子目录。做法上应该为每次任务准备一个干净的临时目录,把输入拷进去,用完删掉,而不是直接把项目根目录挂上。
Step 3:三道默认封锁:网络、进程、pip
除了文件系统,另外三件事在访客里同样是默认不存在的:网络、子进程、以及包管理。让访客去访问一个网址,拿到的是 URLError——不是超时,是压根没有这套能力。这意味着 Agent 生成的脚本无法偷偷把读到的数据发出去,也无法下载第二阶段 payload。同理,访客里没有 pip,你也无法在运行时给它装包;要用什么库,必须在构建时就已经编进那个二进制。
from wasi_python import run
code = """
import urllib.request
try:
urllib.request.urlopen("https://pypi.org")
except Exception as e:
print("network ->", type(e).__name__)
"""
done = run(["-c", code])
print(done.stdout.decode())
# network -> URLError
把这三道封锁合起来看,它防的正是 Agent 场景里最典型的那条链:读文件 → 联网外传 → 下载并执行。同时也要看清代价:需要联网的库、需要起子进程的工具、需要动态装包的脚本,在沙箱里都跑不了。判断标准很简单——如果你的 Agent 任务本来就该联网(比如抓网页再分析),那就别把它塞进沙箱,改成宿主侧取数、沙箱侧计算的两段式。
沙箱不等于无害:它挡的是宿主文件、网络与进程,不挡「在你给的那块内存里死循环」、不挡「把挂载目录里的东西算成一份摘要再吐出来」。所以挂载目录的最小化与下面的四道限流闸必须一起上,缺一道都不算闭环。
Step 4:四道限流闸:时间、指令、内存、输出
指令条数、墙钟时间、内存、输出字节数、打开文件数,每一项都有默认上限,并且可以在调用时覆盖。这段演示把四道闸都调低好让你看见它们各自怎么收口:一个空转循环分别被时间与指令预算拦下;一次超大分配撞上内存上限,访客拿到 MemoryError 且退出码非 0;一次超大输出被截断到指定字节数,并在 truncated 里写明截断的是 stdout。
from wasi_python import run
spin = "while True: pass"
done = run(["-c", spin], deadline_s=1)
print("deadline ->", done.trapped) # deadline
done = run(["-c", spin], fuel=50_000_000)
print("fuel ->", done.trapped, done.fuel_used) # out of fuel 50000000
done = run(["-c", "x = bytearray(512 * 1024 * 1024)"],
memory_bytes=128 * 1024 * 1024)
print("memory ->", done.exit_code,
done.stderr.decode().strip().splitlines()[-1]) # 1 MemoryError
done = run(["-c", "print('x' * 10_000_000)"], output_bytes=1_000_000)
print("output ->", len(done.stdout), done.truncated) # 1000000 ('stdout',)
四道闸的分工值得记清楚:deadline 防的是死循环与卡住的系统调用,是最该先设的一道;fuel 按指令计费,与机器快慢无关,因此同一个脚本在不同机器上消耗一致,适合做配额;memory 防的是一次超大分配把宿主拖垮;output 防的是 Agent 打印一百兆日志把你的上下文与磁盘一起撑爆。任何一道被撞上,调用都会结束,并且结果里会写明是哪一道。
被截断的输出不是完整输出:truncated 里写明哪一项被截了,就说明访客想说的比你能看到的更多。把它当成「结果不完整」处理——要么调大上限重跑,要么让脚本自己先聚合再打印,别拿截断后的半截结果去下结论。
Step 5:读懂返回:exit_code、trapped 与 truncated 的分工
把返回字段的语义分清,日志才好写。exit_code 与 stderr 描述访客程序自己发生了什么——异常、非零退出、MemoryError;trapped 描述的是沙箱把这次调用停下了,值会是 deadline、out of fuel 这类限流名,没被拦时是 null;fuel_used 记的是实际烧掉的指令数,没开燃料计时是 null;truncated 是一个元组,写明哪几路输出被截了。判断顺序应当是先看 trapped,再看 exit_code:被闸拦下的调用,exit_code 的含义已经不重要了。
# 一次调用之后,按这个顺序看
done = run(["-c", code], deadline_s=5, fuel=200_000_000)
if done.trapped:
# 被沙箱拦下:deadline / out of fuel 等,属于资源问题
print("stopped by", done.trapped, "fuel:", done.fuel_used)
elif done.exit_code != 0:
# 访客自己的错:异常栈在 stderr 里
print("guest failed", done.exit_code, done.stderr.decode()[-500:])
else:
# 正常;注意 truncated 非空说明输出不全
print(done.stdout.decode(), "truncated:", done.truncated)
还有一个细节容易被忽略:访客是带独立内存的 WebAssembly 模块,它拿不到指向你进程的指针,能调进宿主的只有 WASI 函数与你显式传入的 tools。也就是说,即使访客代码里有什么越界写法,能碰到的也只是它自己的线性内存。配合上一步的 Grant 与这一步的限流,整套模型是「能力式授权 + 资源封顶」,而不是「先跑起来再看有没有出事」。
Step 6:换宿主、自带运行时与生产落地
发布物有四个:python.wasm 是带科学栈的 WASI reactor;python-snapshot.wasm 是同一个二进制但解释器已预启动;python-stdlib.zip 把标准库与这些包的 Python 代码打包成只读字节码 zip;MANIFEST 记着这次构建用到的每一个源码、工具链与配方哈希。它们既在 GitHub 的发布资产里,也在 PyPI 的 wheel 里。每个版本都是可复现构建并由构建它的工作流签名,官方也给了校验下载的方法。
# 想用自己的宿主:只装文件,不装 wasmtime-py
pip install --no-deps wasi-python
# 然后在 Python 里定位那几个文件
from wasi_python import artifacts
print(artifacts())
# python.wasm / python-snapshot.wasm / python-stdlib.zip / MANIFEST
二进制用了 WebAssembly 的异常处理提案,所以宿主必须支持它。官方自己跑的是 wasmtime 49;按其声明,wasmtime 47 及以上(CLI、Rust、wasmtime-py)与 WasmEdge 0.16 及以上默认可跑;wasmer 6 及以上在 LLVM 或 V8 后端、或 7 及以上在 Cranelift 后端(不含 Singlepass)、wazero 1.12 及以上、Node 24.15 及以上需要开关。要注意 limit 由谁执行:wasi_python 这个 Python 包负责施加第二步那四道闸,换成别的宿主则由宿主自己定——别以为换了宿主还自动带着同样的封顶。
# 生产形态的一个可行切分
# 宿主进程:接任务 → 准备输入目录 → 调用 run() → 读回输出目录
# 沙箱内部:只读输入 → 纯计算 → 写结果,不联网、不起进程
from wasi_python import Grant, run
done = run(["-c", code],
grants=[Grant("/tmp/job-42/in", "/in"), Grant("/tmp/job-42/out", "/out")],
deadline_s=30,
memory_bytes=512 * 1024 * 1024,
output_bytes=200_000)
最后把边界说完整。官方文档有一页专门列出与原生 Python 的每一处差异,每条都附了原因——用之前先扫一遍,尤其是涉及进程、线程、动态加载与网络的部分。它防的是越权与超支,不是防「算错」:访客在你的数据上跑出一个错误的回归结果,沙箱不会拦。所以生产落地的收尾动作是业务侧校验——对结果做范围与量级检查,而不是因为跑在沙箱里就默认可信。
0.1.0 是早期版本,别把限制清单当成常量:包清单、宿主支持矩阵与默认上限都会随版本变化。升级时至少做三件事:重读限制页、跑一遍你自己的关键脚本、确认默认限流值没有被改小。安全类依赖建议在内部镜像里固定版本并校验签名后再上线。
常见问题速查
| 现象 | 原因与处理 |
|---|---|
| 装完报 Python 版本不够 | 要求 3.11 及以上;wheel 自带运行时,不会再联网下载 |
| 访客读不到我的文件 | 默认无任何宿主文件;用 Grant 显式挂载并指定内部路径 |
| 想跳出挂载目录读上级 | 会得到 PermissionError,能力式模型下越界路径不存在 |
| 写文件被拒 | Grant 默认只读,写权限需要另外开 |
| 访问网址报 URLError | 沙箱内无网络;改成宿主取数、沙箱计算的两段式 |
| 想在运行时装包 | 访客里没有 pip;需要什么库必须在构建时编进二进制 |
| 调用被莫名结束 | 看 trapped 字段:deadline、out of fuel 等会点名是哪道闸 |
| 结果看起来不完整 | 看 truncated,被截的那一路上限调大或让脚本先聚合 |
| 内存类报错退出码非 0 | 撞上 memory_bytes;访客拿到 MemoryError,属预期 |
| 想换 wasmtime 以外的宿主 | pip install --no-deps,再用 artifacts() 定位文件;限流需宿主自己实施 |
| 冷启动嫌慢 | 用 python-snapshot.wasm;镜像里预装,别每次现装 |
| 结果算错了沙箱会拦吗 | 不会;它管越权与超支,业务正确性要自己做范围校验 |