360 lines
14 KiB
Markdown
360 lines
14 KiB
Markdown
# 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 profile,writablePaths 仅包含:
|
||
|
||
| 路径 | 来源 |
|
||
|------|------|
|
||
| `{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 包和配置缓存。
|
||
|
||
#### 改动 2:strict 模式也使用 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);
|
||
```
|
||
|
||
#### 改动 3:writablePaths 自动添加 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,现在也允许在其中执行二进制。
|
||
|
||
#### 改动 3b:Windows 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 问题描述
|
||
|
||
前三轮修复后,qimingcode(opencode 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/TMP(APPDATA 不可写) | 仅 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 模式排除 APPDATA;compat/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 级保护互不干扰。
|