AI Agent 的代码执行沙箱:WebAssembly 与 WASI 0.2 的安全边界工程实践

引言:当 Agent 开始「动手」,安全边界就成了架构问题

2024 年我们讨论的是 Agent 能不能规划好一个任务,2026 年讨论的已经是:它写出来的代码,你敢不敢跑。

工具调用(Tool Use)把大模型从「聊天窗口」推进了「执行引擎」。一旦 Agent 具备写代码并执行的能力——无论是数据分析、自动化运维,还是自愈式测试——你就必须回答一个问题:这段由模型生成、未经人类逐行审阅的代码,在哪一层被隔离?

行业里最常见的答案是「起个容器」。这没错,但代价被严重低估了。本文要论证的是:对于「执行一段短生命周期的不可信代码」这个具体场景,容器是过重的抽象,而 WebAssembly(WASM)配合 WASI 0.2 组件模型,是目前工程上最合理的默认选择。


一、为什么容器不是正确答案

先看容器的真实成本。假设 Agent 每次调用要跑一段 200 行的 Python 脚本:

维度 Docker gVisor Firecracker microVM WASM
冷启动 300ms–2s 500ms+ 100–125ms <1ms
单实例内存 ~50MB+ ~30MB+ ~128MB ~2–8MB
隔离粒度 进程/内核共享 系统调用拦截 硬件虚拟化 能力授予(capability)
密度(单机) 数十 数十 百级 数千

冷启动不是小事。Agent 的一次任务往往包含 5–20 次工具调用,如果每次都要等 800ms 拉起一个沙箱,端到端延迟会被工具开销吃掉一大半,交互体验直接崩掉。更别说容器镜像的构建、分发和版本治理成本——为了跑 200 行脚本,你维护了一套镜像供应链。

但真正的问题不是性能,是安全模型的错配。容器的安全边界是「命名空间 + cgroups + seccomp」,它假设你在运行一个完整的操作系统发行版。你想给 Agent 的最小权限其实是「读这一个目录、访问这一个 HTTP 端点」,但你能表达的最小单位是「一个容器的全部系统调用面」。于是实践中大量团队干脆 privileged: true 或者挂载过宽的卷——边界形同虚设。

WASM 的模型完全不同:默认是零权限。一个 WASM 模块默认不能读文件、不能开网络、不能读时钟。它能做什么,必须由宿主显式「授予」(grant)具体的 capability 句柄。这是能力安全(capability-based security)而非访问控制列表(ACL),权限不能被凭空捏造,只能被传递。


二、WASI 0.2:从「一个模块的 syscall」到「组件化接口」

WASI Preview 1(wasi_snapshot_preview1)本质是把 POSIX 的一部分系统调用搬进了 WASM 世界,它的问题是:接口是扁平的、按「模块」组织的,一个模块要么全有要么全无。

WASI 0.2(2024 年初定稿,2025–2026 年工具链全面落地)带来的是组件模型(Component Model):模块不再直接暴露 syscall,而是通过 WIT(Wasm Interface Type)声明自己的世界(world)—— imports 是它需要的,exports 是它提供的。宿主在链接时决定:你声明的 import,我给不给你实现。

一个 Agent 沙箱的接口大概可以这样声明:

// sandbox-world.wit
package ybb:[email protected];

interface fs {
  read-file: func(path: string) -> result<string, io-error>;
  write-file: func(path: string, contents: string) -> result<_, io-error>;
}

interface http {
  variant method { get, post }
  record request { url: string, method: method, body: option<string> }
  record response { status: u16, body: string }
  fetch: func(req: request) -> result<response, http-error>;
}

interface env {
  get: func(key: string) -> option<string>;
  log: func(level: string, msg: string);
}

world agent-sandbox {
  // Agent 代码需要的能力,逐项声明
  import fs;
  import http;
  import env;

  // Agent 代码必须提供的入口
  export run: func(input: string) -> result<string, string>;
}

这份 WIT 本身就是一份安全契约。审阅 Agent 权限时,你读的是 30 行接口声明,而不是一份 Dockerfile 加几百行 seccomp profile。更进一步:如果宿主不提供 http 的实现,链接阶段就会失败——权限缺失是编译期错误,不是运行时的意外行为。


三、实战:用 Wasmtime 搭一个 Agent 代码沙箱

下面用 Rust + Wasmtime(Bytecode Alliance 的参考运行时)实现一个最小可用的执行器。核心是三件事:限制资源、授予最小 capability、保证可中断。

3.1 基础执行器

use anyhow::Result;
use wasmtime::{Config, Engine, Linker, Module, Store, Val};
use wasmtime_wasi::{Dir, WasiCtxBuilder};

pub struct Sandbox {
    engine: Engine,
}

impl Sandbox {
    pub fn new() -> Result<Self> {
        let mut config = Config::new();

        // 1) 关键:开启 epoch-based 中断,用于超时控制
        config.epoch_interruption(true);

        // 2) 关键:开启 fuel,用于 CPU 计量(防止死循环)
        config.consume_fuel(true);

        // 3) 关闭会破坏确定性的特性
        config.wasm_threads(false);
        config.cranelift_opt_level(wasmtime::OptLevel::Speed);

        let engine = Engine::new(&config)?;
        Ok(Self { engine })
    }

    /// 执行一段不可信代码,data_dir 是唯一可访问的目录
    pub fn run(
        &self,
        wasm_bytes: &[u8],
        data_dir: &std::path::Path,
        input: &str,
        fuel_budget: u64,
    ) -> Result<String> {
        let module = Module::new(&self.engine, wasm_bytes)?;

        // 用 WASI ctx 精确授予能力:只给一个目录的只读权限
        let wasi = WasiCtxBuilder::new()
            .preopened_dir(
                Dir::open_ambient_dir(data_dir, wasmtime_wasi::sync::DirPerms::READ)?,
                "/data",   // Guest 视角看到的挂载点
            )?
            // 不 inherit_stdio:Agent 代码不应污染宿主 stdout
            .stdout(wasmtime_wasi::pipe::MemoryOutputPipe::new(64 * 1024))
            .stderr(wasmtime_wasi::pipe::MemoryOutputPipe::new(64 * 1024))
            .build();

        let mut store = Store::new(&self.engine, wasi);
        store.set_fuel(fuel_budget)?;
        // epoch 每 10ms tick 一次,配合外部 watchdog 实现硬超时
        store.set_epoch_deadline(1);

        let mut linker = Linker::new(&self.engine);
        wasmtime_wasi::add_to_linker_sync(&mut linker)?;

        let instance = linker.instantiate(&mut store, &module)?;
        let run = instance.get_typed_func::<(&str,), (Result<String, String>,)>(
            &mut store, "run"
        )?;

        // 把 input 写入 linear memory 后调用
        let out = run.call(&mut store, (input,))?;
        match out {
            Ok(s)  => Ok(s),
            Err(e) => anyhow::bail!("guest error: {e}"),
        }
    }
}

3.2 三种「跑飞」的防御

Agent 生成的代码有几种典型的失控方式,必须分别处理:

(1)死循环 —— 用 fuel 计量。 fuel 是确定性的指令计数器,与机器性能无关。同一段代码在任何机器上消耗相同数量的 fuel,因此可复现、可做计费、可做配额。fuel 耗尽时抛 Trap,宿主可以捕获并给出友好错误。

(2)阻塞调用 —— 用 epoch 中断。 fuel 只能管 CPU 密集,管不了「卡在 IO 上」。epoch 机制让宿主每 N 毫秒递增一次计数器,超过 deadline 就中断。实践中我会在独立线程里跑一个 watchdog:

std::thread::spawn({
    let engine = engine.clone();
    move || loop {
        std::thread::sleep(std::time::Duration::from_millis(10));
        engine.increment_epoch();
    }
});

(3)内存爆炸 —— 预分配 + 无增长。 WASM 线性内存可以声明上限。宿主应禁止 memory.grow 超过阈值,否则一个 vec.push 循环就能打爆宿主机。

let mut config = Config::new();
config.static_memory_maximum_size(64 * 1024 * 1024); // 硬上限 64MB
config.wasm_memory64(false);

3.3 Python 侧的调用

不是所有团队都用 Rust。Wasmtime 有官方 Python 绑定,可以在 Agent 编排层直接调用:

from wasmtime import Engine, Store, Module, Linker, WasiConfig, Config

config = Config()
config.consume_fuel = True
config.epoch_interruption = True
engine = Engine(config)

store = Store(engine)
store.set_fuel(50_000_000)
store.set_epoch_deadline(1)

wasi = WasiConfig()
wasi.preopen_dir("/srv/agent-data", "/data")   # 唯一可见目录
store.set_wasi(wasi)

module = Module.from_file(engine, "agent_code.wasm")
linker = Linker(engine)
linker.define_wasi()

instance = linker.instantiate(store, module)
result = instance.exports(store)["run"](store, user_input)

四、工程落地清单与常见坑

1. 别把 WASM 当万能沙箱。 WASM 隔离的是 WASM 模块本身;一旦你的 host 提供了 http.fetch 且允许任意 URL,那 Agent 就能对外通信。安全边界的强弱取决于你授予了什么,不取决于 WASM 本身。最小权限是设计纪律,不是运行时保证。

2. 语言生态是有成本的。 Rust / Zig / C 编译到 WASM 体验最好,产物也小。Python 生态要靠组件化的 CPython 构建(或 componentize-py),产物动辄 20–40MB,冷启动优势会被吃掉一部分。我的建议是:把「沙箱内执行」和「数据分析」拆开——沙箱里跑轻量逻辑,重活用声明式接口交给宿主的数据服务。

3. 状态要外置。 Agent 的每次执行都应是幂等的、无状态的。需要持久化时,用 capability 注入一个 KV 接口,而不是直接给文件系统写权限。这样你能审计每一次写入,也能在出错时回滚。

4. 可观测性别省。 fuel 消耗、epoch 中断次数、preopen 目录访问路径、host 函数调用序列——这些是事后复盘 Agent 异常行为的唯一线索。建议在 host 函数里统一打点,形成「一次执行的调用轨迹」。

5. 与现有方案共存,而不是替换。 如果你已经在跑 gVisor 或 Firecracker,不必推翻。合理的分层是:重活(需要完整 Linux 环境、装包、跑浏览器)走 microVM;轻活(纯计算、模板渲染、数据变换)走 WASM。 按调用特征分流,而不是按信仰选型。


五、我的判断

沙箱选型的本质,是在「隔离强度」和「单位成本」之间找交点。容器家族把隔离做到了操作系统级,成本也就跟着上了一个数量级;WASM 把隔离做在语言运行时级,换来了毫秒级启动和极低密度成本——代价是你必须放弃「跑任意二进制」的幻想。

对于 Agent 代码执行这个场景,这个交易是划算的:Agent 生成的代码绝大多数是短小、纯逻辑、不需要完整 OS 的。为这 95% 的场景支付容器级的开销,是工程设计上的浪费。

WASI 0.2 的组件模型真正改变的是权限的表达方式:从"我给你一个环境,你自己小心",变成"我给你四个函数句柄,多一个都没有"。这种可审计、可静态验证的最小权限模型,恰恰是自治系统在规模化部署时最需要的东西。

如果说 2025 年 Agent 工程的关键词是「编排」,那 2026 年大概率是「边界」。能跑起来不稀奇,能安全、可审计、低成本地跑起来,才是生产级。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部