Files
qiming/qimingclaw/docs/sandbox-writable-paths-fix.md

360 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# macOS Seatbelt Strict 模式下 ACP 引擎工具调用 EPERM 修复
> 日期: 2026-04-09
> 状态: 已修复(三轮迭代)
> 影响范围: macOS + seatbelt strict/compat 模式下的 ACP 引擎claude-code / qimingcode
> 跨平台: macOS / Linux / Windows 均已处理
---
## 1. 问题描述
在 macOS 上ACP 引擎被 seatbelt 沙箱以 `strict` 模式包裹后,所有需要 spawn 子进程的工具Bash、Glob 等)因 `EPERM` 失败,无法正常执行。同时引擎无法写入应用数据目录(`~/.qimingclaw`导致日志、npm 包等不可用。
### 1.1 claude-code 引擎
```
EPERM: operation not permitted, mkdir '/private/tmp/claude-501/-Users-apple-Downloads-test-electron-client-computer-project-workspace-1746495851-1544430'
```
claude-code 内部 Bash 工具使用**硬编码的** `/private/tmp/claude-{uid}/` 路径(不是 `os.tmpdir()`),不在 seatbelt profile 的 writablePaths 中。
### 1.2 qimingcode 引擎
```
EPERM: operation not permitted, posix_spawn '/var/folders/.../qimingclaw-acp-xxx/.local/share/qimingcode/bin/rg'
```
qimingcode 内部需要执行 `rg`ripgrep实现文件搜索工具但 strict 模式下 `startupExecAllowlist` 未生效,`rg` 不在 exec allow 中。
### 1.3 应用数据目录不可写
strict 模式下 writablePaths 不包含 `~/.qimingclaw`,引擎无法:
- 写日志到 `~/.qimingclaw/logs/`
- 访问 `~/.qimingclaw/node_modules/` 中的 npm 包
- 读写引擎配置缓存
### 1.4 Windows strict 模式限制
Windows sandbox helper 的 strict 模式只取 `writablePaths[0]`工作区忽略了应用数据目录、isolatedHome、临时目录等必要路径。
### 1.5 复现步骤
1. 启动 QimingClaw 桌面客户端,创建 ACP 会话sandbox mode = strict
2. 发送提示词:"运行 pwd 显示当前工作目录" 或 "运行 node -e ..."
3. agent 尝试执行 Bash/Glob 工具 → EPERM
---
## 2. 根因分析
### 2.1 Seatbelt Profile 两个限制
**限制 1写入权限不足**
strict 模式的 seatbelt profilewritablePaths 仅包含:
| 路径 | 来源 |
|------|------|
| `{projectWorkspaceDir}` | 用户工作区 |
| `{isolatedHome}` | 引擎隔离 HOME |
缺少系统临时目录(`/private/tmp/` 等)。
**限制 2执行权限不足**
strict 模式下,`SandboxInvoker.buildSeatbeltProfile()``startupExecAllowlist` 只在 compat 模式使用(原 line 442-443。strict 模式只允许执行:
- `/usr/bin/*`, `/bin/*`, `/usr/lib/*` — 系统路径
- `{command}` — 引擎主二进制(如 node / qimingcode
引擎内部二进制(如 isolatedHome 下的 `rg`)不在允许列表中。
### 2.2 失败工具调用清单(同一会话,共 5 次)
| Tool Call ID | 工具 | 命令 | 错误类型 |
|---|---|---|---|
| `call_0623c3816d9b47508f6ad079` | Bash | `pwd` | EPERM mkdir `/private/tmp/claude-501/...` |
| `call_2277b5404d0c455da0db355b` | Bash | `pwd` | EPERM mkdir `/private/tmp/claude-501/...` |
| `call_3400c1f44b76412e9bc0b258` | Bash | `echo $PWD` | EPERM mkdir `/private/tmp/claude-501/...` |
| `call_d44e1ea2a9c14d1fb200c6d9` | Glob | `*` | `spawn EPERM` |
| `call_b60209d94ce34affb3d032ec` | Bash | `ls -la` (含 `dangerouslyDisableSandbox:true`) | EPERM mkdir `/private/tmp/claude-501/...` |
注意:即使工具调用携带 `dangerouslyDisableSandbox: true`依然失败。seatbelt 沙箱包裹的是整个引擎进程(进程级),不是单个工具调用。进程级的 seatbelt 限制无法被内部参数绕过。
---
## 3. 修复方案(三轮迭代)
### 3.1 第一轮:设置 TMPDIR 环境变量 → 失败
`TMPDIR`/`TEMP`/`TMP` 指向 `isolatedHome/tmp``isolatedHome/tmp/` 目录成功创建,但 claude-code 内部 Bash 工具不读取 `TMPDIR`,仍使用硬编码的 `/private/tmp/claude-{uid}/`
**结论TMPDIR 方案对 claude-code 无效。**(该代码保留 — 对其他场景仍有价值。)
### 3.2 第二轮:添加 `/private/tmp/claude-{uid}/` → 部分有效
将引擎特定的 `/private/tmp/claude-{uid}/` 加入 writablePaths。修复了 claude-code 的写入问题,但:
- 路径是引擎特定claude-),不统一
- 没有覆盖 qimingcode 的 `rg` 执行问题
- 没有跨平台考虑
### 3.3 第三轮(最终方案):统一跨平台修复
**四个改动,统一适用于所有引擎和所有平台:**
#### 改动 1跨平台系统临时目录加入 writablePaths
**文件**: `acpClient.ts`
```typescript
const extraWritable: string[] = [isolatedHome, os.tmpdir()];
try {
extraWritable.push(fs.realpathSync(os.tmpdir()));
} catch { /* skip */ }
if (process.platform === "darwin") {
extraWritable.push("/tmp", "/private/tmp");
}
```
| 平台 | 添加的路径 |
|------|-----------|
| **macOS** | `os.tmpdir()` (`/var/folders/.../T`) + realpath + `/tmp` + `/private/tmp` |
| **Linux** | `os.tmpdir()` (`/tmp`) + realpath |
| **Windows** | `os.tmpdir()` (`C:\Users\...\AppData\Local\Temp`) + realpath |
所有引擎统一使用同一套路径,无引擎特定逻辑。
#### 改动 1b应用数据目录加入 writablePaths
**文件**: `acpClient.ts`
```typescript
// App data directory (e.g. ~/.qimingclaw) — engine writes logs, npm packages, etc.
const appDataDir = path.join(app.getPath("home"), APP_DATA_DIR_NAME);
extraWritable.push(appDataDir);
```
所有沙箱模式strict / compat / permissive和所有平台均可写入应用数据目录引擎可正常写日志、访问 npm 包和配置缓存。
#### 改动 2strict 模式也使用 startupExecAllowlist
**文件**: `SandboxInvoker.ts`
原代码中 `startupExecAllowlist` 只在 compat 模式生效。修改为 strict 和 compat 都使用:
```typescript
// Before: only compat used startupExecAllowlist
if (compat) {
for (const p of startupExecAllowlist) execAllow.add(p);
}
// After: both strict and compat include engine-internal binaries
for (const p of startupExecAllowlist) execAllow.add(p);
```
#### 改动 3writablePaths 自动添加 process-exec subpath 规则
**文件**: `SandboxInvoker.ts`
每个 writablePath 同时添加 `file-write*``process-exec` 的 subpath 规则:
```typescript
lines.push(`(allow file-write* (subpath "${p}"))`);
lines.push(`(allow process-exec (subpath "${p}"))`);
```
这解决了 qimingcode 的 `rg` 执行问题 — `rg` 位于 isolatedHome 内isolatedHome 是 writablePath现在也允许在其中执行二进制。
#### 改动 3bWindows strict 模式使用完整 writablePaths
**文件**: `SandboxInvoker.ts`
Windows sandbox helper 的 strict 模式原来只取 `writablePaths[0]`工作区忽略了应用数据目录、isolatedHome、临时目录等。现在所有模式统一使用完整 `writablePaths`,不再区分 strict/compat
```typescript
// Before:
if (sandboxMode === "strict") {
sandboxPolicy.writable_roots = [params.writablePaths[0]!];
} else {
sandboxPolicy.writable_roots = params.writablePaths;
}
// After:
sandboxPolicy.writable_roots = params.writablePaths;
```
### 3.4 修复后的 seatbelt profile示例
```seatbelt
(version 1)
(deny default)
(allow network*)
(allow file-read*)
(allow process-exec (regex #"^/usr/bin/"))
(allow process-exec (regex #"^/bin/"))
(allow process-exec (regex #"^/usr/lib/"))
(allow process-exec (literal ".../node")) ; 引擎主二进制
(allow signal (target self))
(allow process-fork)
(allow sysctl-read)
(allow mach-lookup)
(allow ipc-posix*)
(allow file-lock)
; writablePaths — 同时允许 file-write 和 process-exec
(allow file-write* (subpath "/Users/apple/project"))
(allow process-exec (subpath "/Users/apple/project"))
(allow file-write* (subpath ".../qimingclaw-acp-xxx"))
(allow process-exec (subpath ".../qimingclaw-acp-xxx")) ; rg 等引擎内部二进制可执行
(allow file-write* (subpath "/Users/apple/.qimingclaw"))
(allow process-exec (subpath "/Users/apple/.qimingclaw")) ; 应用数据目录日志、npm 包等)
(allow file-write* (subpath "/var/folders/.../T"))
(allow file-write* (subpath "/private/tmp")) ; claude-code Bash 工具
(allow process-exec (subpath "/private/tmp"))
(allow file-write* (subpath "/tmp"))
(allow process-exec (subpath "/tmp"))
(allow file-write* (literal "/dev/null"))
(allow file-write* (literal "/dev/dtracehelper"))
(allow file-write* (literal "/dev/urandom"))
```
---
## 4. 验证方式
1. 重新构建并启动 ACP 会话strict 模式),发送 `pwd` → 应正常返回工作目录
2. 发送 `node -e "console.log('hello')"` → 应正常输出
3. 发送 `sandbox-testing-prompts.md` 中 A1 安全命令 → 全部正常执行
4. 确认 qimingcode 引擎也能正常执行 Glob/Grep 工具(`rg` 不再 EPERM
5. 确认日志中 seatbelt profile 包含 `/private/tmp``process-exec (subpath isolatedHome)``~/.qimingclaw`
6. 确认引擎可以写日志到 `~/.qimingclaw/logs/`
7. 确认 compat/permissive 模式也正常工作
8. 确认 Windows strict 模式下引擎也能写日志和临时文件
---
## 5. 涉及文件
| 文件 | 改动类型 | 说明 |
|------|----------|------|
| `acpClient.ts` | 修改 | 跨平台系统临时目录 + 应用数据目录加入 extraWritablePaths |
| `SandboxInvoker.ts` | 修改 | strict 模式使用 startupExecAllowlist + writablePaths 添加 process-exec subpath + Windows strict 模式使用完整 writablePaths |
| `docs/sandbox-strict-tmpdir-fix.md` | 文档 | 本文档 |
---
## 6. 设计考量
### 为什么不直接去掉 ACP 引擎的 seatbelt 包裹
设计文档 (`sandbox-testing-prompts.md`) 提到 ACP 会话"不走 seatbelt",但实际实现中 seatbelt 包裹是进程级安全隔离的重要层。通过修复 seatbelt profile 的路径和执行权限,可以在保留 seatbelt 保护的同时让引擎正常工作。工具级权限由 PermissionManager 管理。
### 为什么将 `/private/tmp/` 整体加入 writablePaths
- 引擎claude-code硬编码使用 `/private/tmp/claude-{uid}/`,无法通过 TMPDIR 重定向
- 未来其他引擎也可能使用系统临时目录
- 统一跨平台处理,避免引擎特定逻辑
---
## 7. Windows serve 模式 WRITE_RESTRICTED 修复(第四轮)
> 日期: 2026-04-09
> 状态: 已修复
> 影响范围: Windows strict/compat 模式下的 qimingcode 引擎
### 7.1 问题描述
前三轮修复后qimingcodeopencode Go 二进制)的内部 `bash` 工具仍可写入 Desktop 等非工作区目录。Claude Code 引擎的 bash 通过 ACP Terminal API 已被正确沙箱化,但 qimingcode 的 bash 在内部执行,不经过 AcpTerminalManager。
### 7.2 根因
`qiming-sandbox-helper.exe serve` 子命令硬编码 `write_restricted=false``main.rs:585`)。
没有 `WRITE_RESTRICTED` flag 和 restricting SIDs 时Windows 访问检查不考虑 capability SID 的 DACL ACEs。结果进程级沙箱提供**零写入保护**。
```
write_restricted=false → token: DISABLE_MAX_PRIVILEGE | LUA_TOKEN无 restricting SIDs
→ add_allow_ace() / add_deny_write_ace() 对该 token 无效
→ qimingcode bash 可写任意路径
```
### 7.3 修复方案
**原则**: 最小侵入,单一权威源。
#### 改动 1: Rust helper `serve` 子命令新增 `--write-restricted` flag
```rust
struct ServeArgs {
common: CommonArgs,
#[arg(long, default_value_t = false)]
write_restricted: bool,
}
```
`write_restricted=true` 时,`create_restricted_token()` 添加:
- `WRITE_RESTRICTED` flag
- 3 个 restricting SIDs: capability SID + logon SID + everyone SID
- 只有带 ALLOW ACE 的路径可写
#### 改动 2: Policy JSON 新增 `sandbox_mode` 字段
```typescript
const sandboxPolicy = {
type: "workspace-write",
network_access: true,
sandbox_mode: "strict", // ← 新字段
writable_roots: [...],
};
```
Rust `policy.rs``SandboxPolicy::WorkspaceWrite` 解析此字段,
`compute_allow_paths()` 根据它决定是否将 APPDATA/LOCALAPPDATA 加入 ALLOW ACEs。
#### 改动 3: SandboxInvoker 传递 `--write-restricted` 给 serve 模式
```typescript
if (subcommand === "serve" && sandboxMode !== "permissive") {
helperArgs.push("--write-restricted");
}
```
#### 改动 4: acpEngine.ts 跨平台扩展
- 移除 `this.engineName === "claude-code"` 限制(两个引擎均受保护)
- 移除 `type === "windows-sandbox"` 限制(所有沙箱类型均受保护)
- sandboxed-fs MCP 和 disallowedTools 扩展到所有平台
### 7.4 修复后的行为
| Mode | serve 进程可写路径 | sandboxed-fs MCP |
|------|-------------------|-----------------|
| **strict** | workspace + TEMP/TMPAPPDATA 不可写) | 仅 workspace + TEMP/TMP |
| **compat** | workspace + TEMP/TMP + APPDATA/LOCALAPPDATA | workspace + TEMP/TMP + APPDATA |
| **permissive** | 无限制WRITE_RESTRICTED=false | 不注入 MCP |
### 7.5 涉及文件
| 文件 | 改动类型 | 说明 |
|------|----------|------|
| `crates/windows-sandbox-helper/src/main.rs` | 修改 | serve 子命令新增 `--write-restricted` flag |
| `crates/windows-sandbox-helper/src/policy.rs` | 修改 | `WorkspaceWrite` 新增 `sandbox_mode` 字段 |
| `crates/windows-sandbox-helper/src/allow.rs` | 修改 | strict 模式排除 APPDATAcompat/permissive 包含 |
| `SandboxInvoker.ts` | 修改 | 传递 `--write-restricted` + `sandbox_mode` 到 policy JSON |
| `sandboxProcessWrapper.ts` | 修改 | 移除冗余 APPDATA 添加Rust 是单一权威源) |
| `acpEngine.ts` | 修改 | 跨平台/跨引擎扩展 sandboxed-fs MCP 和 disallowedTools |
### 7.6 设计决策
**为什么 Rust `compute_allow_paths()` 是 APPDATA 的单一权威源?**
之前 APPDATA 在 TypeScript`sandboxProcessWrapper.ts`)和 Rust`allow.rs`)两处添加,
导致 strict 模式下 TypeScript 总是添加 APPDATA与 strict 语义矛盾。
现在统一由 Rust 根据 `sandbox_mode` 字段决定TypeScript 只负责传递 `sandbox_mode`
**为什么 strict 模式下 serve 进程的 APPDATA 不可写?**
strict 模式的目标是"最小写入面"。引擎基础设施日志写入通过 `isolatedHome`TEMP 目录下)解决,
不依赖 APPDATA。sandboxed-fs MCP 对 AI agent 的文件写入做独立路径验证,
进程级和 MCP 级保护互不干扰。