Bundle
dsh-acl-sandbox-patch
Temporary fix for the DSH 0.1.7 Windows ACL sandbox failing on non-system-drive workspaces (grantWrite / SetNamedSecurityInfoW Win32 5) — restores workspace-write commands without touching any system ACL.
- Source
- masknull
- stars
- 6 stars
- License
- MIT
- Updated
- Updated yesterday
Readme
# dsh-acl-sandbox-patch
English: [README.en.md](README.en.md)
DSH 0.1.7 Windows ACL 沙箱在**非系统盘工作区**(D:/E:/F:,目录 DACL 只有 `Authenticated Users: Modify`、没有本机用户 FullControl)下的**临时修复插件**:不动任何系统 ACL,让 `workspace-write` 的命令恢复可执行。
## 修的是什么
0.1.7 起 `workspace-write` 的每条命令在 spawn 前都要给工作区做 ACL 授权,`@deepseek-ai/dsh-sandbox-windows-acl` 用**一次** `SetNamedSecurityInfoW` 同时下发三样东西:capability SID 的 Allow ACE、world SID 的 `FILE_DELETE_CHILD` Deny ACE、**Low 完整性强制标签**。写标签要求调用者对该目录持有 `WRITE_OWNER`,而:
- 非系统盘新建目录的默认 ACL 不含创建者 FullControl(只有继承来的 Modify);
- DSH 跑在 UAC 过滤令牌下,`BUILTIN\Administrators` 是 deny-only,吃不到组的 FullControl;
- 属主隐式权限只有 `READ_CONTROL + WRITE_DAC`,**不含 WRITE_OWNER**。
于是授权返回 `ERROR_ACCESS_DENIED (5)`,沙箱 fail-closed,命令从未 spawn:
```
SetNamedSecurityInfoW failed (Win32 5): grantWrite(<workspace-path>)
```
**不受影响的场景**:`read-only` 策略(不授予可写根,走不到这个调用);位于 `%USERPROFILE%` 下的工作区(用户 Profile 天生给用户 SID FullControl)。
上游已知问题(rc.2 的沙箱包与 rc.1 逐字节相同,未修复):
- [Discussion #7771 — 工作区所有者问题触发](https://github.com/deepseek-ai/deepseek-harness/discussions/7771)
- [Discussion #7720 — 同一缺陷的另一半触发条件](https://github.com/deepseek-ai/deepseek-harness/discussions/7720)
- [Discussion #7804 — 更细的实验报告](https://github.com/deepseek-ai/deepseek-harness/discussions/7804)
## 运行要求(最低支持版本)
| 项 | 要求 |
|---|---|
| 操作系统 | **Windows**(NTFS;其他平台插件自动 no-op) |
| **DSH 最低版本** | **`>= 0.1.7-rc.1`**(该版本引入 `@deepseek-ai/dsh-sandbox-windows-acl`,bug 于此引入;0.1.5 及更早无此沙箱,无需本插件) |
| 已验证版本 | 0.1.7-rc.1 / 0.1.7-rc.2(两者沙箱包逐字节相同) |
| 策略 | 仅 `workspace-write` 需要(`read-only` / `danger-full-access` 不需要) |
`engines.dsh` 已声明 `>=0.1.7-rc.1`。若未来 DSH 修复了该问题(对应 discussion 关闭或沙箱包升级),本插件的严格路径会直接成功、行为退化为无异于出厂,届时卸载即可。
## 原理
只包装沙箱自己发起的 FFI 调用,两条注入路径互兜底:
| 路径 | 覆盖对象 | 机制 |
|---|---|---|
| A. koffi 钩子 | 所有 node 子进程(sandbox runner、探针) | hook `Module._load` 拦 `require('koffi')`,包装 `koffi.load()` 返回对象的 `.func()`,对 dsh-win32-process 的 4 参数调用形式返回包装函数(C 声明式形式原样透传)。随 `NODE_OPTIONS=--require=lib/preload.cjs` 预加载,早于主模块图 |
| B. api 表直改 | DSH 服务端进程(负责 workspace standing grant) | 解析沙箱包后创建一次性 `AclWriteGrant` 物化进程级共享绑定表,直接替换表上目标属性后 dispose。与绑定时序无关 |
包装逻辑(`lib/preload.cjs`):
1. **`SetNamedSecurityInfoW`**:先按原语义完整尝试(含 Low 标签);仅当返回 Win32 5 时降级重试为 **DACL-only**(剥掉 `LABEL_SECURITY_INFORMATION`,SACL 传 NULL)。DACL 写入只需要属主隐式的 `WRITE_DAC`,可以成功。
2. **`SetTokenInformation(TokenIntegrityLevel=25)`**:双判据命中其一即**跳过把子进程令牌降到 Low**——工作区没有 Low 标签,Low 子进程按 no-write-up 什么都写不了。判据 1:状态文件(插件目录 `.degraded.state` 主路径)/ 环境变量 / 进程标志;判据 2:runner 从自身 argv 取 `--workspace` 直侦该目录有无 Low 标签(无 ⇒ 降级环境)。判据 2 不依赖任何文件与环境,是最终兜底。
3. **`GetNamedSecurityInfoW`**:降级发生后,对含 LABEL 位的查询返回合成的"已带精确 Low 标签" SACL(与 `hasExactLabel` 的逐字段比对同构),让幂等跳过重新命中。
环境健康时行为与出厂一致(完整授权、正常降完整性);只有真的打不上标签的环境才降级。
## 安全边界(诚实话在此)
保留:
- `WRITE_RESTRICTED` 受限令牌(正常 SID + 限制 SID 双列表访问检查)
- capability SID Allow ACE——只有工作区和会话私有 temp 可写
- world SID `FILE_DELETE_CHILD` Deny——删除权限收敛到 capability ACE
- fail-closed:其他任何 Win32 失败照旧抛错,绝不去掉沙箱
失去(仅发生在本来就打不上 Low 标签的环境):
- Low 完整性强制标签这一层纵深防御(子进程不再被强制 Low 完整性)
本来就没有的(与出厂一致,未变化):读限制、网络限制、进程可见性限制。
## 安装
```
dsh plugin --profile web add github:masknull/dsh-acl-sandbox-patch
```
该命令会链接包、记入依赖,并因包声明了 `dsh.bundle` 而自动加入 profile 的 bundle 列表。**重启 DSH** 使新的 bundle 层生效,启动日志应出现:
```
[dsh-acl-sandbox-patch] active — Windows ACL 沙箱 Low 标签/完整性降级补丁已安装(上游 Discussions #7771/#7720/#7804)
```
新会话里直接执行 shell 命令——不再逐条提权批准,也不再报 Win32 5。
> 供应链安全可固定提交:`github:masknull/dsh-acl-sandbox-patch#<commit sha>`。
## 验证
装好后最直接的验证:新会话跑 `whoami` / `echo hi`,无需批准、有输出;再试覆写一个文件、在沙箱里 `New-Item` 建个文件——都应直接成功。
**实测记录**(非系统盘上共 44,251 个文件的工作区,DSH 0.1.7-rc.2,v0.1.2):
| 场景 | 结果 |
|---|---|
| 未打补丁 | 任何命令 spawn 前失败:`SetNamedSecurityInfoW failed (Win32 5): grantWrite(<workspace-path>)` |
| 打补丁后普通 `pwsh` 执行 | 直接返回,无需逐条批准 |
| 重启 DSH 后首条 `workspace-write` 命令 | 约 20 秒(一次性 DACL 全树传播,固有代价) |
| 之后同进程内的命令 | 约 1.7 秒 |
| 新开会话的命令 | 2 秒以内(seam 的 per-provider 复用缓存生效) |
| **沙箱内令牌完整性(v0.1.2 关键指标)** | **Medium**(0.1.1 及以前为 Low,沙箱内写文件全被 no-write-up 拒) |
| **沙箱内文件操作(新建/覆写/删除)** | **全部正常**(0.1.1 及以前全部 Access denied) |
| 服务端降级状态文件 | 插件目录 `.degraded.state` + tmpdir 双路径正确落盘 |
| runner 决策路径(状态文件 + 标签直侦测) | 逐项验证通过 |
| write 工具覆盖已存在文件 | 正常(v1 的 `type specifier` 报错已修) |
## 回滚
```
dsh plugin --profile web remove dsh-acl-sandbox-patch
```
然后重启 DSH。插件**不产生任何持久副作用**:不改系统 ACL、不留文件(`NODE_OPTIONS` 只在本进程内设置并被子进程继承;状态文件位于插件目录/tmpdir,每次启动先清、运行期生成)、无后台任务。
## 已知限制
- **仅 Windows**;其他平台直接 no-op。
- 需要重启 DSH 生效(预加载要走进程启动)。
- **重启前已建立的会话,其命令通道可能是重启前的旧 runner 孤儿进程**(旧版行为:Low 令牌、无状态文件感知)——这是 DSH 重启不回收旧 runner 的进程管理行为,非插件缺陷;**新开会话即恢复正常**。
- **重启后首条命令慢**:降级后工作区的 exact-ACE skip 不再命中(Low 标签永远不在),每次 DSH 重启后对该工作区的首条 `workspace-write` 命令要做一次 DACL 全树传播(实测 44k 文件约 20 秒;沙箱机制的固有代价)。同进程内的后续命令与新会话走复用缓存,2 秒以内。
- 上游修复发布后(对应 discussion 关闭或沙箱包升级)应卸载本插件恢复出厂行为。
## 版本记录
### 0.1.2(当前)
1. **状态文件主路径迁到插件目录**(`.degraded.state`,零环境依赖):0.1.1 把状态文件放在 `os.tmpdir()`,但沙箱 runner 链路的 `TMP/TEMP` 会被改写到会话私有目录,runner 的 `os.tmpdir()` 指错地方,状态文件找不到 → 子进程仍被降 Low。现主路径为 `__dirname/../.degraded.state`(服务端写、runner 读,路径恒定),tmpdir 路径降级为兼容回退。
2. **工作区标签直侦测(判据 2,自包含)**:runner 从自身 argv 取 `--workspace`,直接读该目录有无 Low 完整性强制标签——有 ⇒ 严格环境(正常降完整性);无 ⇒ 降级环境(跳过降完整性)。不依赖任何文件与环境变量,是最终兜底的事实判据。
### 0.1.1
1. **koffi `lib.func` 调用形式误判**:koffi 有两种调用形式——4 参数(`dsh-win32-process` 的 bind)和 C 声明式单串(`dsh-fs-local` 等,串内含 `(`)。v1 一律按 4 参数转手,导致声明式被 koffi 类型解析器拒绝(`Unexpected character '(' in type specifier`),**所有"覆盖文件"的写操作失败**(新建文件不走该路径所以无症状)。现仅识别 4 参数形式,其余原样透传。
2. **降级标志跨进程传播(初版)**:v1 只靠环境变量传播"已降级"。subprocess runner 是会话级长生命周期进程,启动常早于服务端首次授权,拿不到标志 → 把子进程降成 Low 完整性。引入状态文件机制(0.1.2 又修了其路径问题并加标签直侦测)。
### 0.1.0
首个可用版:两条注入路径(koffi 钩子 + api 表直改)、strict-first 降级、环境变量传播。
## 文件
| 文件 | 作用 |
|---|---|
| `lib/index.js` | 插件入口(host entry):进程内安装 + 实例标识 + 状态清理 + `NODE_OPTIONS` 注入 |
| `lib/preload.cjs` | 补丁核心(CJS,零依赖):koffi 钩子 + api 表包装 + 降级判定 + 状态文件 |
| `cordis.patch.yml` | profile 树插入条目(install 机制同官方插件) |
| `.degraded.state` | 降级状态文件(运行期由服务端写/清,已加入 .gitignore) |
## License
[MIT](LICENSE)
Install
dsh plugin --profile web add github:masknull/dsh-acl-sandbox-patch
Profile: web
With the hub plugin installed, ask your agent to install it by name — it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install dsh-acl-sandbox-patch from the hub
- This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.