181 lines
6.3 KiB
Markdown
181 lines
6.3 KiB
Markdown
# Sandbox 测试验证方案
|
||
|
||
> 创建时间:2026/04/08
|
||
> 状态:已计划,待实施
|
||
|
||
## Context
|
||
|
||
目前三平台沙箱(macOS seatbelt / Linux bwrap / Windows helper)没有任何测试文件覆盖,无法验证危险操作是否被正确拦截。需要新增:
|
||
- 单元测试(无需真实沙箱二进制)
|
||
- 跨平台集成测试
|
||
- 并修复已发现的 4 个安全 Gap
|
||
|
||
## 关键文件
|
||
|
||
| 文件 | 作用 |
|
||
|------|------|
|
||
| `src/main/services/sandbox/SandboxInvoker.ts` | seatbelt profile / bwrap args 生成 |
|
||
| `src/main/services/sandbox/sandboxProcessWrapper.ts` | ACP 进程包装 |
|
||
| `src/main/services/sandbox/policy.ts` | resolveSandboxType fallback |
|
||
| `vitest.config.ts` | 测试配置(已包含 `tests/**`) |
|
||
|
||
---
|
||
|
||
## 实现计划
|
||
|
||
### 1. 单元测试(`src/main/services/sandbox/`)
|
||
|
||
#### SandboxInvoker.test.ts
|
||
|
||
- mock `fs/promises.writeFile`,捕获 profile 内容
|
||
- **macOS**:
|
||
- profile 以 `(version 1)` `(deny default)` 开头
|
||
- `(allow network*)` 当 networkEnabled=true
|
||
- seatbeltProfilePath 返回值非 undefined,供调用方清理
|
||
- **Linux**:
|
||
- `--ro-bind / /` 存在于 bwrap args
|
||
- `--tmpfs /tmp` 存在
|
||
- `--dev-bind /dev /dev` 存在(记录 rw gap)
|
||
- networkEnabled=false → `--unshare-net` 存在
|
||
- networkEnabled=true → `--unshare-net` 不存在
|
||
- 每个 writablePath 生成 `--bind <p> <p>`,精确索引
|
||
- **Windows**:
|
||
- helper 不存在时抛 `SANDBOX_UNAVAILABLE` reject
|
||
- networkEnabled=false 时 policy JSON network_access=false(JSON 解析验证)
|
||
- writable_roots 含 workspace(JSON 解析验证)
|
||
- **none**:原样返回 command/args
|
||
- **docker**:返回原始命令并 log.warn
|
||
|
||
#### sandboxProcessWrapper.test.ts
|
||
|
||
- 测试 `buildSandboxedSpawnArgs()` 对各平台的包装结果
|
||
- enabled=false → 原样返回
|
||
- docker backend → 透传 + warn
|
||
|
||
#### policy.test.ts
|
||
|
||
- mock `fs.existsSync` 全返回 false + mock SQLite
|
||
- enabled=false → type=none, degraded=false
|
||
- 后端不可用 → degraded=true, reason 非空
|
||
- fallback 结果永远是 none,不会是其他可用类型
|
||
|
||
### 2. 集成测试(`tests/sandbox-integration/`)
|
||
|
||
#### shared-integration-utils.ts
|
||
|
||
```typescript
|
||
runInSeatbelt(profileContent: string, shellCommand: string): Promise<{exitCode, stdout, stderr, timedOut}>
|
||
runInBwrap(bwrapArgs: string[]): Promise<{exitCode, stdout, stderr, timedOut}>
|
||
expectBlocked(result): void
|
||
expectAllowed(result): void
|
||
```
|
||
|
||
#### macos-seatbelt.integration.test.ts
|
||
|
||
```typescript
|
||
describe.skipIf(process.platform !== 'darwin' || !existsSync('/usr/bin/sandbox-exec'))
|
||
```
|
||
|
||
使用与生产完全一致的 profile 格式(`(deny default)` + explicit allows,workspace writable,no network):
|
||
|
||
| 类别 | 命令 | 期望 |
|
||
|------|------|------|
|
||
| 文件写入 | `echo ... >> /etc/hosts` | BLOCKED |
|
||
| 文件写入 | `echo ... >> /etc/passwd` | BLOCKED |
|
||
| 文件写入 | `touch /usr/bin/evil-binary` | BLOCKED |
|
||
| 文件写入 | `touch ~/Documents/exfil.txt` | BLOCKED |
|
||
| 文件写入 | `touch /tmp/attacker-file.txt`(/tmp 不在 writablePaths)| BLOCKED |
|
||
| 文件写入 | workspace 内写入 | ALLOWED |
|
||
| 文件删除 | `rm /etc/resolv.conf` | BLOCKED |
|
||
| 文件删除 | `rm /usr/bin/ls` | BLOCKED |
|
||
| 提权 | `sudo id` | BLOCKED |
|
||
| 系统命令 | `/sbin/shutdown -h now`(exec 允许但 root 权限不足)| 输出 Permission 字样 |
|
||
| 网络 | `curl http://example.com`(无 allow network*)| BLOCKED |
|
||
|
||
含 `(allow network*)` 的 profile 下 curl 应 ALLOWED。
|
||
|
||
#### linux-bwrap.integration.test.ts
|
||
|
||
```typescript
|
||
describe.skipIf(process.platform !== 'linux')
|
||
```
|
||
|
||
测试场景(含 `--unshare-net`、`--tmpfs /tmp`、workspace bind):
|
||
|
||
| 类别 | 命令 | 期望 |
|
||
|------|------|------|
|
||
| 文件写入 | `/etc/hosts`, `/etc/passwd`, `/usr/bin/evil`, `/home/exfil.txt` | BLOCKED |
|
||
| 文件写入 | workspace 内 | ALLOWED |
|
||
| 文件写入 | `/tmp/sandbox-tmp.txt`(tmpfs) | ALLOWED,但宿主 /tmp 无此文件 |
|
||
| 文件删除 | `/etc/hostname`, `/bin/sh` | BLOCKED |
|
||
| 网络 | curl(--unshare-net) | BLOCKED |
|
||
| 网络 | loopback ping(--unshare-net 下仍有 lo)| ALLOWED(localhost 可用)|
|
||
| 提权 | `sudo id` | BLOCKED |
|
||
| 系统命令 | `reboot`, `halt` | BLOCKED |
|
||
| /dev gap | `mknod /dev/test-device` | BLOCKED |
|
||
| /dev gap | `echo evil > /dev/sda` | BLOCKED |
|
||
| PID 命名空间 | `ps aux \| wc -l` | < 10 行(与宿主隔离)|
|
||
|
||
---
|
||
|
||
### 3. 修复 4 个安全 Gap
|
||
|
||
#### Gap 1:`allow signal` 过于宽泛(macOS)
|
||
|
||
- **位置**:`SandboxInvoker.ts` `buildSeatbeltProfile()`
|
||
- **风险**:允许向任意可 reach 进程发信号(UNIX 权限仍阻止 PID 1,但若以 root 运行则有风险)
|
||
- **修复**:改为 `(allow signal (target self))`
|
||
|
||
#### Gap 2:`allow process-exec` 无限制(macOS)
|
||
|
||
- **位置**:`SandboxInvoker.ts` `buildSeatbeltProfile()`
|
||
- **风险**:可 exec `/sbin/shutdown`、`/sbin/reboot` 等系统命令,依赖权限不足阻止而非沙箱
|
||
- **修复**:用路径白名单限制可 exec 的目录
|
||
|
||
#### Gap 3:`--dev-bind /dev /dev` 可读写(Linux)
|
||
|
||
- **位置**:`SandboxInvoker.ts` `buildBwrap()`
|
||
- **风险**:若 `--unshare-user-try` fallback 失败(内核不支持),/dev 可写
|
||
- **修复**:改为最小 device allowlist(`--dev-bind /dev/null`、`/dev/urandom`、`/dev/zero`、`/dev/random`)
|
||
|
||
#### Gap 4:降级到 none 无用户提示
|
||
|
||
- **位置**:`policy.ts` `resolveSandboxType()`
|
||
- **风险**:用户以为有沙箱保护,实际在无沙箱运行
|
||
- **修复**:降级时 emit `sandbox:unavailable` 事件,日志级别从 debug 改为 warn
|
||
|
||
---
|
||
|
||
## 文件清单(新增)
|
||
|
||
```
|
||
src/main/services/sandbox/
|
||
SandboxInvoker.test.ts # 新增
|
||
sandboxProcessWrapper.test.ts # 新增
|
||
policy.test.ts # 新增
|
||
|
||
tests/sandbox-integration/
|
||
shared-integration-utils.ts # 新增
|
||
macos-seatbelt.integration.test.ts # 新增
|
||
linux-bwrap.integration.test.ts # 新增
|
||
```
|
||
|
||
## 运行方式
|
||
|
||
```bash
|
||
cd crates/agent-electron-client
|
||
|
||
# 单元测试(全平台)
|
||
npm run test:run -- src/main/services/sandbox/
|
||
|
||
# macOS 集成(在 macOS 机器上)
|
||
npm run test:run -- tests/sandbox-integration/macos-seatbelt.integration.test.ts
|
||
|
||
# Linux 集成(在 Linux 机器上)
|
||
npm run test:run -- tests/sandbox-integration/linux-bwrap.integration.test.ts
|
||
```
|
||
|
||
## CI 建议
|
||
|
||
在 `package.json` 增加集成测试的 platform-specific 配置,确保 CI 在对应平台运行。
|