AI Agent 安全运行时:从 WebAssembly 沙箱到 Linux LSM 的多层隔离实战

引言

当 AI Agent 获得代码执行、文件读写和网络调用能力时,"能做什么"和"被允许做什么"之间的边界,就是安全工程的主战场。一个能调用 Python 解释器、读写本地文件、访问外部 API 的 Agent,本质上就是一个有特权暗示的微型租户——它需要在可控环境中运行,同时又要足够轻量级以支持毫秒级任务调度。

本文将深入探讨构建 AI Agent 安全运行时的完整技术栈:从用户态的 WebAssembly 内存沙箱,到 Linux LSM(Linux Security Module)的系统调用过滤,再到底层的 seccomp-bpf 与 cgroup 资源限制。这不是简单的功能罗列,而是围绕"纵深防御"原则,逐层分析每一层的攻击面与工程取舍。

第一层:WebAssembly 内存沙箱——能力边界的最内环

为什么选择 WASM 而非容器

传统的容器隔离(namespace + cgroup)启动时间通常在 100ms 到数秒级别,镜像体积动辄百 MB。而 WebAssembly 模块冷启动可低至数十微秒,模块体积可压缩到 KB 级别,这使其成为 Agent 单次任务执行的理想载体。

WASM 沙箱的核心安全模型基于三个约束:

约束机制 安全意义
线性内存隔离 模块无法访问宿主或其他模块的内存空间
能力显式导入 所有外部功能必须通过 import 声明,默认无权限
类型安全的 CFI 间接调用必须通过函数表,目标必须在编译期验证

// 示例:使用 wasmtime 限制模块能力
use wasmtime::{Engine, Module, Store, Instance, TypedFunc};
use wasmtime_wasi::{WasiCtx, WasiCtxBuilder};

struct AgentState {
    allowed_paths: Vec<String>,
    memory_limit: usize,
}

fn execute_agent_module(wasm_bytes: &[u8], state: &AgentState) -> Result<(), Box<dyn Error>> {
    let engine = Engine::default();
    let module = Module::new(&engine, wasm_bytes)?;
    
    // 构建最小化 WASI 上下文
    let wasi = WasiCtxBuilder::new()
        .inherit_stdio()
        .preopened_dir(
            Dir::open_ambient_dir(&state.allowed_paths[0], ambient_authority())?,
            "/workspace"
        )?
        .build();
    
    let mut store = Store::new(&engine, wasi);
    store.limiter(|state| &mut state.limiter);
    
    // 通过 Linker 仅暴露白名单函数
    let mut linker = Linker::new(&engine);
    wasmtime_wasi::add_to_linker(&mut linker, |s| s)?;
    
    // 仅注册 Agent 需要的宿主函数
    linker.func_wrap("env", "log_message", |mut caller: Caller<'_, WasiCtx>, ptr: i32, len: i32| {
        // 安全读取 WASM 内存并记录日志
        let memory = caller.get_export("memory").unwrap().into_memory().unwrap();
        let mut buffer = vec![0u8; len as usize];
        memory.read(&caller, ptr as usize, &mut buffer).unwrap();
        log::info!("Agent log: {}", String::from_utf8_lossy(&buffer));
    })?;
    
    let instance = linker.instantiate(&mut store, &module)?;
    let run: TypedFunc<(), ()> = instance.get_typed_func(&mut store, "_start")?;
    run.call(&mut store, ())?;
    Ok(())
}

突破沙箱的工程现实

WASM 沙箱并非万能。Spectre 类侧信道攻击可利用共享的 CPU 推测执行引擎跨越内存隔离边界。对于多租户 Agent 运行时,必须配合以下防护:

  • 禁用共享内存:shared_memory 特性在多租户场景下必须关闭
  • 确定性时间:使用 coarse_fuel 计量替代 wall clock 计时,防止高精度计时侧信道
  • 编译器屏障:在 WASI libc 敏感路径插入 lfence 指令

第二层:WASI 能力系统——最小权限的接口设计

WASI 与传统 POSIX 的范式冲突

POSIX 进程继承父进程的完整能力集(文件描述符、UID、GID、capabilities),然后通过主动降权来约束自己。WASI 则采用"默认拒绝"模型——模块启动时不具备任何能力,仅通过显式授予的"权利(rights)"来访问资源。


// WASI 权利的细粒度组合
pub struct FileSystemRights {
    pub read: bool,
    pub write: bool,
    pub create: bool,
    pub delete: bool,
    pub seek: bool,
}

impl FileSystemRights {
    // Agent 读取工具配置:只允许读
    fn read_only() -> Self {
        Self { read: true, write: false, create: false, delete: false, seek: true }
    }
    
    // Agent 输出写入:只允许在特定目录追加
    fn append_to_workspace() -> Self {
        Self { read: true, write: true, create: true, delete: false, seek: true }
    }
}

Descriptor 表的运行时守卫

WASI 中的 fd 是一个索引到 capability-bearing descriptor 的 token。关键在于:这些 token 的作用域可以绑定到特定的路径前缀。


// 实现路径约束的 fd 守卫
struct PathConstrainedFd {
    inner: WasiFd,
    allowed_prefix: PathBuf,
    
    fn pread(&self, buf: &mut [u8], offset: u64) -> Result<usize, Errno> {
        // 读取操作不需要路径验证
        self.inner.pread(buf, offset)
    }
    
    fn openat(&self, path: &Path, oflags: Oflags) -> Result<WasiFd, Errno> {
        let resolved = self.allowed_prefix.join(path).canonicalize()
            .map_err(|_| Errno::Noent)?;
        
        // 防止路径穿越:确保解析后的路径仍在允许前缀内
        if !resolved.starts_with(&self.allowed_prefix) {
            return Err(Errno::Access);
        }
        
        self.inner.openat(&resolved, oflags)
    }
}

第三层:Linux LSM 钩子——系统调用级的强制访问控制

LMS 栈的选择矩阵

LSM(Linux Security Module)通过在内核关键路径插入安全钩子实现强制访问控制。主流方案各有侧重:

LSM 方案 学习曲线 粒度 适用场景
AppArmor 低 路径级 固定路径的 Agent 工具
SELinux 高 标签级 多 Agent 混合部署
Landlock 中 文件级 无 root 权场景的自沙箱

实战:Landlock 程序化沙箱

Landlock 是 Linux 5.13 引入的 unprivileged LSM,允许非特权进程自行建立文件访问沙箱。这对 Agent 运行时至关重要——Agent 进程无需 root 就能限制自身的文件系统可见性。


#include <linux/landlock.h>
#include <sys/syscall.h>
#include <fcntl.h>

// Landlock 规则:允许读取 /usr/lib,允许读写 /workspace
int setup_landlock_sandbox(void) {
    struct landlock_ruleset_attr ruleset_attr = {
        .handled_access_fs = 
            LANDLOCK_ACCESS_FS_READ_FILE |
            LANDLOCK_ACCESS_FS_READ_DIR |
            LANDLOCK_ACCESS_FS_WRITE_FILE |
            LANDLOCK_ACCESS_FS_MAKE_DIR |
            LANDLOCK_ACCESS_FS_MAKE_REG |
            LANDLOCK_ACCESS_FS_REFER,
    };
    
    int ruleset_fd = syscall(__NR_landlock_create_ruleset,
                             &ruleset_attr, sizeof(ruleset_attr), 0);
    if (ruleset_fd < 0) return -1;
    
    // 规则1:只读访问系统库目录
    struct landlock_path_beneath_attr path_beneath = {
        .allowed_access = LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_READ_DIR,
        .parent_fd = open("/usr/lib", O_PATH | O_CLOEXEC),
    };
    syscall(__NR_landlock_add_rule, ruleset_fd,
            LANDLOCK_RULE_PATH_BENEATH, &path_beneath, 0);
    close(path_beneath.parent_fd);
    
    // 规则2:允许读写工作区
    path_beneath = (struct landlock_path_beneath_attr){
        .allowed_access = LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_READ_DIR |
                          LANDLOCK_ACCESS_FS_WRITE_FILE | LANDLOCK_ACCESS_FS_MAKE_DIR |
                          LANDLOCK_ACCESS_FS_MAKE_REG | LANDLOCK_ACCESS_FS_REFER,
        .parent_fd = open("/workspace", O_PATH | O_CLOEXEC),
    };
    syscall(__NR_landlock_add_rule, ruleset_fd,
            LANDLOCK_RULE_PATH_BENEATH, &path_beneath, 0);
    close(path_beneath.parent_fd);
    
    // 应用规则集——一旦生效无法放宽
    if (syscall(__NR_landlock_restrict_self, ruleset_fd, 0) < 0) {
        close(ruleset_fd);
        return -1;
    }
    close(ruleset_fd);
    return 0;
}

LSM 层的安全边界

LSM 策略在内核态强制执行,不受用户态进程自身的影响。这意味着:即使 WASM 沙箱内的代码存在漏洞,攻击者也无法突破 LSM 设置的访问控制——因为突破必须经过系统调用,而系统调用受 LSM 钩子审查。

第四层:seccomp-bpf 系统调用过滤

最小系统调用集的确定

通过分析 Agent 运行时的系统调用足迹,可以使用 strace -c 或 eBPF 来建立最小系统调用白名单。典型 Agent 工作负载所需的系统调用通常不超过 30 个。


# 使用 strace 分析 Agent 模块的系统调用足迹
# strace -c -f -e trace=all ./agent_module 2>&1 | tail -30

# 典型的最小系统调用集合(按功能分组)
AGENT_SYSCALL_WHITELIST = {
    # 内存管理
    "mmap", "mprotect", "munmap", "brk",
    # I/O(通过 pread/write,避免 seek 在不可寻址 fd 上的竞争)
    "read", "write", "pread64", "pwrite64",
    # epoll 事件循环
    "epoll_create1", "epoll_ctl", "epoll_pwait",
    # 同步
    "futex", "rt_sigreturn", "sigaltstack",
    # 时钟(仅允许 monotonic coarse,防止高精度计时侧信道)
    "clock_gettime",
    # 进程退出
    "exit_group",
    # 随机数(用于工具 ID 生成)
    "getrandom",
}

Seccomp-bpf 过滤器的实现


#include <linux/seccomp.h>
#include <linux/filter.h>
#include <linux/audit.h>
#include <sys/prctl>

int enable_seccomp_filter(void) {
    // 将白名单系统调用编号转换为 BPF 指令
    struct sock_filter filter[] = {
        // 加载系统调用号
        BPF_STMT(BPF_LD | BPF_W | BPF_ABS, 
                 (offsetof(struct seccomp_data, nr))),
        
        // 允许的系统调用——使用 jump 链
        ALLOW_SYSCALL(read),
        ALLOW_SYSCALL(write),
        ALLOW_SYSCALL(pread64),
        ALLOW_SYSCALL(pwrite64),
        ALLOW_SYSCALL(mmap),
        ALLOW_SYSCALL(mprotect),
        ALLOW_SYSCALL(munmap),
        ALLOW_SYSCALL(brk),
        ALLOW_SYSCALL(epoll_create1),
        ALLOW_SYSCALL(epoll_ctl),
        ALLOW_SYSCALL(epoll_pwait),
        ALLOW_SYSCALL(futex),
        ALLOW_SYSCALL(rt_sigreturn),
        ALLOW_SYSCALL(sigaltstack),
        ALLOW_SYSCALL(clock_gettime),
        ALLOW_SYSCALL(exit_group),
        ALLOW_SYSCALL(getrandom),
        
        // 默认:KILL(不在白名单中的调用直接终止进程)
        BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS),
    };
    
    struct sock_fprog prog = {
        .len = sizeof(filter) / sizeof(filter[0]),
        .filter = filter,
    };
    
    // 启用 TSYNC 以保证线程安全
    if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) < 0) return -1;
    if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog) < 0) return -1;
    
    return 0;
}

#define ALLOW_SYSCALL(name) \
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_##name, 0, 1), \
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW)

Seccomp-notify 的进阶应用

Linux 5.0+ 引入 seccomp_unotify 机制,允许用户态 supervisor 接住被阻断的系统调用,做出动态决策后重新下发执行。这为 Agent 运行时提供了"交互式系统调用审批"能力——例如当 Agent 尝试访问新文件时,supervisor 可以弹出确认对话框,或根据策略动态决定允许/拒绝。


// seccomp-notify 实现动态系统调用审批
use linux_seccomp_unotify::{SeccompUnotifyEvent, SeccompUnotifyResponse};

fn handle_blocked_syscall(
    event: &SeccompUnotifyEvent,
    agent_policy: &mut AgentPolicy,
) -> SeccompUnotifyResponse {
    match event.syscall_number {
        libc::SYS_openat => {
            let path = read_remote_path(event.pid, event.args[1]);
            
            // 动态策略判断
            if agent_policy.is_allowed_path(&path) {
                SeccompUnotifyResponse::Continue  // 放行并自动执行
            } else {
                // 询问 Agent orchestrator
                match agent_policy.request_permission(event.pid, &path) {
                    PermissionResult::Allow => SeccompUnotifyResponse::Continue,
                    PermissionResult::Deny => {
                        SeccompUnotifyResponse::Errno(libc::EACCES)
                    }
                }
            }
        }
        _ => SeccompUnotifyResponse::Kill,  // 未知系统调用直接终止
    }
}

第五层:cgroup 资源隔离——拒绝服务防御

为什么需要 cgroup 作为最后一道防线

即使前面四层都已完善,Agent 仍然可能通过计算密集循环、内存分配炸弹或 IO 洪流来影响宿主或其他租户。cgroup 提供了硬性的资源上限,是拒绝服务攻击的最后屏障。


// 使用 cgroups-rs 为 Agent 任务设置资源限制
use cgroups_rs::{Cgroup, hierarchies, Controller, cpu::CpuController, memory::MemController};

struct AgentResourceQuota {
    cpu_shares: u64,       // CPU 相对权重
    cpu_quota_us: i64,     // 每周期 CPU 配额(微秒)
    cpu_period_us: u64,    // CPU 周期长度
    memory_limit: u64,     // 内存硬上限(字节)
    pids_max: u64,         // 最大进程/线程数
}

fn sandbox_agent_cgroup(agent_id: &str, quota: AgentResourceQuota) -> Result<Cgroup, Box<dyn Error>> {
    let hierarchy = hierarchies::auto();
    let cg = Cgroup::new(hierarchy, format!("agents/{}", agent_id))?;
    
    // CPU 限制:最多使用半个核心
    {
        let cpu: &mut CpuController = cg.controller_of_mut().unwrap();
        cpu.set_cfs_quota(quota.cpu_quota_us)?;
        cpu.set_cfs_period(quota.cpu_period_us)?;
        cpu.set_shares(quota.cpu_shares)?;
    }
    
    // 内存限制:硬上限 + swap 禁用
    {
        let mem: &mut MemController = cg.controller_of_mut().unwrap();
        mem.set_knob("memory.swappiness".to_string(), "0".to_string())?;
        mem.set_limit(quota.memory_limit)?;
        // 设置 kernel memory 限制防止内核数据结构膨胀
        mem.set_knob("memory.kmem.limit_in_bytes".to_string(), 
                     (quota.memory_limit / 4).to_string())?;
    }
    
    // PID 限制:防止 fork 炸弹
    cg.set_pids_max(512)?;
    
    Ok(cg)
}

io-throttle 的特殊考量

Agent 常涉及频繁的 IO 操作(日志、checkpoint、工具输出)。cgroup v2 的 io.max 可以限制带宽,但对 Agent 这种小文件随机读写场景,更关键的是限制 IOPS(每秒 IO 操作数):


# 设置 IO 限制:每秒最多 1000 次 4K 写操作
echo "253:0 wiopsmax=1000" > /sys/fs/cgroup/agents/task-xyz/io.max

# 当前 IO 监控——检测异常 IO 模式
cat /sys/fs/cgroup/agents/task-xyz/io.stat

多层隔离的协同与失效传递

纵深防御的叠加效应


┌─────────────────────────────────────────────────────┐
│  Agent 代码(不可信)                                │
├─────────────────────────────────────────────────────┤
│  层1: WASM 内存沙箱    ← 内存安全、CFI              │
├─────────────────────────────────────────────────────┤
│  层2: WASI 能力系统    ← 显式授权                    │
├─────────────────────────────────────────────────────┤
│  层3: Landlock LSM     ← 文件系统强制访问控制       │
├─────────────────────────────────────────────────────┤
│  层4: seccomp-bpf      ← 系统调用最小集             │
├─────────────────────────────────────────────────────┤
│  层5: cgroup           ← 资源硬上限                 │
├─────────────────────────────────────────────────────┤
│  Linux Kernel          ← 核心信任基                  │
└─────────────────────────────────────────────────────┘

每一层设计为独立有效:当一层被突破时,攻击者仍需要突破后续层才能达成攻击目标。

失效传递分析

多层隔离并非毫无代价。每增加一层,调试复杂度、延迟开销和错误率都会累积:

层 增加延迟 调试难度 典型故障
WASM < 0.1ms 中(source map 有限) 燃料计量偏差
WASI ~0ms 低(错误码明确) 路径解析失败
Landlock ~0ms 中(内核日志) 规则自冲突
seccomp ~0.01ms 高(SIGSYS 难以溯源) 遗漏系统调用
cgroup ~0ms 低(OOM kill) IO 抖动误杀

关键原则:在每一层提供清晰的可观测性(tracing/metrics)和有用的错误信息,而不是让错误默默传递到下一层。

生产实践:Agent 沙箱的生命周期管理

沙箱预热与复用策略

对于高频短任务,冷启动 WASM 模块的开销(~10ms)可能占总执行时间的很大比例。生产环境可采用对象池模式:


use crossbeam_queue::ArrayQueue;

struct AgentSandboxPool {
    pool: ArrayQueue<PreWarmedSandbox>,
    engine: Engine,
    module: Arc<Module>,
}

struct PreWarmedSandbox {
    store: Store<WasiCtx>,
    instance: Instance,
    last_used: Instant,
}

impl AgentSandboxPool {
    fn new(pool_size: usize, wasm_bytes: &[u8]) -> Self {
        let engine = Engine::default();
        let module = Arc::new(Module::new(&engine, wasm_bytes).unwrap());
        let pool = ArrayQueue::new(pool_size);
        
        // 预热池
        for _ in 0..pool_size {
            let sandbox = Self::create_sandbox(&engine, &module);
            let _ = pool.push(PreWarmedSandbox {
                store: sandbox.0,
                instance: sandbox.1,
                last_used: Instant::now(),
            });
        }
        
        Self { pool, engine, module }
    }
    
    fn acquire(&self) -> Option<AgentSandboxLease> {
        self.pool.pop().map(|sandbox| AgentSandboxLease {
            sandbox,
            return_queue: &self.pool,
        })
    }
    
    fn create_sandbox(engine: &Engine, module: &Module) -> (Store<WasiCtx>, Instance) {
        let wasi = WasiCtxBuilder::new().inherit_stdio().build();
        let mut store = Store::new(engine, wasi);
        let instance = Instance::new(&mut store, module, &[]).unwrap();
        (store, instance)
    }
}

// RAII 归还沙箱
struct AgentSandboxLease {
    sandbox: PreWarmedSandbox,
    return_queue: &'static ArrayQueue<PreWarmedSandbox>,
}

impl Drop for AgentSandboxLease {
    fn drop(&mut self) {
        // 重置 WASI 状态但不销毁 Store(避免重新初始化开销)
        self.sandbox.last_used = Instant::now();
        let _ = self.return_queue.push(self.sandbox);
    }
}

监控指标与告警

生产环境应监控以下关键指标:

  • 沙箱执行耗时(P50/P99):超过配额视为潜在死循环
  • 内存使用曲线:突然增长可能是无限列表累积异常
  • seccomp kill 计数:突增表明 Agent 尝试恶意系统调用
  • cgroup OOM 频率:频繁触发表明配额不足或内存泄漏
  • Landlock 拒绝计数:反映 Agent 尝试越权访问路径的频率

总结与展望

AI Agent 的安全运行时不是某个单一工具或框架,而是由内存沙箱、能力系统、系统调用过滤和资源控制五层叠加而成的纵深防御体系。每一层解决不同的威胁层:

  • WASM 解决内存安全和代码完整性
  • WASI 解决权限最小化
  • Landlock 解决文件系统隔离
  • seccomp-bpf 解决内核攻击面收敛
  • cgroup 解决资源滥用和邻居干扰

未来,随着机密计算(Confidential Computing)技术(如 AMD SEV-SNP、Intel TDX)的成熟,我们可以在上述五层之外加入第六层:内存加密与远程证明。这将使 Agent 运行时能够在不可信的云环境中验证自身完整性,即使hypervisor被攻破也能保护 Agent 的机密数据和执行状态。

安全工程的本质是成本与风险的平衡。多层隔离并非追求绝对不可攻破,而是让攻击成本Agent 运行时的预期收益,从根本上消除攻击动机。


*本文基于 Linux 6.x 内核和 wasmtime 20+ 版本验证,代码示例遵循 Rust 2021 Edition。*

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部