进阶 📋 6 个步骤 第 568 / 569 篇

用 wasi-python 给 Agent 生成的代码一个自带科学栈的沙箱:Grant 授权、四道限流闸与结果判读实操

Agent 写的代码直接跑在你进程里等于交出一切。wasi-python 把 CPython 3.15 与四十多个科学计算包编成一个 WebAssembly 二进制,默认看不见宿主文件、连不上网、起不了进程。本教程六步落地。

2026-10-26· 12 分钟上手· 约 3917 字· 🧊 wasi-python / 🛡️ 代码沙箱

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 生成的数据分析脚本、用户提交的公式、来自外部的输入。本教程按装上跑通、发放目录通行证、看清三道默认封锁、四道限流闸、读懂结果字段、换宿主与生产落地六步把它用起来。

🧊 本教程适合:需要在服务端跑 Agent 生成代码、又不想为它单独开容器集群的开发者。你需要 Python 3.11 及以上;wheel 自带运行时,解开约 250 MB,不会再联网下载任何东西。文中 API 以 PyPI 上的项目说明与官方文档站为准;0.1.0 是很早的版本号,升级时请重新扫一遍限制清单。

Step 1:装上,跑通一条命令

1 一条 pip,一条 CLI

安装之后会多出一个 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。

💡 想省下开局的冷启动,用 python-snapshot.wasm。它是同一个二进制,但解释器已经起好了,适合那种「每次请求一个短脚本」的服务形态;跑长任务则无所谓。

Step 2:给访客发通行证,只发你愿意给的那一格

2 Grant 之外,它什么都没有

沙箱里的程序是访客,你的程序是宿主。默认它看不见任何宿主文件;要让访客读一个目录,必须显式 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

3 先假设它什么都做不了

除了文件系统,另外三件事在访客里同样是默认不存在的:网络、子进程、以及包管理。让访客去访问一个网址,拿到的是 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 任务本来就该联网(比如抓网页再分析),那就别把它塞进沙箱,改成宿主侧取数、沙箱侧计算的两段式。

💡 两段式是这类沙箱最实用的落地形态:宿主负责取数与落盘到临时目录,Grant 给沙箱,沙箱只做纯计算并把结果写回同一个目录。职责一分开,权限就收得住了。

沙箱不等于无害:它挡的是宿主文件、网络与进程,不挡「在你给的那块内存里死循环」、不挡「把挂载目录里的东西算成一份摘要再吐出来」。所以挂载目录的最小化与下面的四道限流闸必须一起上,缺一道都不算闭环。

Step 4:四道限流闸:时间、指令、内存、输出

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 打印一百兆日志把你的上下文与磁盘一起撑爆。任何一道被撞上,调用都会结束,并且结果里会写明是哪一道。

💡 给 Agent 场景的建议组合:deadline_s 设成你能接受的最长等待,fuel 按「同类任务实测值的三到五倍」给,memory_bytes 按数据量的两倍给,output_bytes 按你准备塞回上下文的上限给。四个值都写进配置而不是散落在调用处,事后才好统一调。

被截断的输出不是完整输出:truncated 里写明哪一项被截了,就说明访客想说的比你能看到的更多。把它当成「结果不完整」处理——要么调大上限重跑,要么让脚本自己先聚合再打印,别拿截断后的半截结果去下结论。

Step 5:读懂返回:exit_code、trapped 与 truncated 的分工

5 谁停下的,和代码怎么写错是两回事

把返回字段的语义分清,日志才好写。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 与这一步的限流,整套模型是「能力式授权 + 资源封顶」,而不是「先跑起来再看有没有出事」。

💡 把 trapped 与 fuel_used 打进你的监控。某类任务的燃料消耗突然翻倍,通常意味着 Agent 写出了更绕的实现或者悄悄多跑了一轮循环——这比看耗时更早暴露问题,因为燃料计数与机器负载无关。

Step 6:换宿主、自带运行时与生产落地

6 同一份二进制,不止能给 Python 用

发布物有四个: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 的每一处差异,每条都附了原因——用之前先扫一遍,尤其是涉及进程、线程、动态加载与网络的部分。它防的是越权与超支,不是防「算错」:访客在你的数据上跑出一个错误的回归结果,沙箱不会拦。所以生产落地的收尾动作是业务侧校验——对结果做范围与量级检查,而不是因为跑在沙箱里就默认可信。

💡 缓存粒度按「包 + 版本」而不是按请求。wheel 解开约 250 MB,容器镜像里预装一次远比每次冷启动划算;再叠加 python-snapshot.wasm,短任务的冷启动开销可以压到很低。

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;镜像里预装,别每次现装
结果算错了沙箱会拦吗不会;它管越权与超支,业务正确性要自己做范围校验
← 返回教程中心