dsh-acl-sandbox-patch
English: 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 逐字节相同,未修复):
运行要求(最低支持版本)
| 项 | 要求 |
|---|---|
| 操作系统 | 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):
SetNamedSecurityInfoW:先按原语义完整尝试(含 Low 标签);仅当返回 Win32 5 时降级重试为 DACL-only(剥掉LABEL_SECURITY_INFORMATION,SACL 传 NULL)。DACL 写入只需要属主隐式的WRITE_DAC,可以成功。SetTokenInformation(TokenIntegrityLevel=25):双判据命中其一即跳过把子进程令牌降到 Low——工作区没有 Low 标签,Low 子进程按 no-write-up 什么都写不了。判据 1:状态文件(插件目录.degraded.state主路径)/ 环境变量 / 进程标志;判据 2:runner 从自身 argv 取--workspace直侦该目录有无 Low 标签(无 ⇒ 降级环境)。判据 2 不依赖任何文件与环境,是最终兜底。GetNamedSecurityInfoW:降级发生后,对含 LABEL 位的查询返回合成的"已带精确 Low 标签" SACL(与hasExactLabel的逐字段比对同构),让幂等跳过重新命中。
环境健康时行为与出厂一致(完整授权、正常降完整性);只有真的打不上标签的环境才降级。
安全边界(诚实话在此)
保留:
WRITE_RESTRICTED受限令牌(正常 SID + 限制 SID 双列表访问检查)- capability SID Allow ACE——只有工作区和会话私有 temp 可写
- world SID
FILE_DELETE_CHILDDeny——删除权限收敛到 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(当前)
- 状态文件主路径迁到插件目录(
.degraded.state,零环境依赖):0.1.1 把状态文件放在os.tmpdir(),但沙箱 runner 链路的TMP/TEMP会被改写到会话私有目录,runner 的os.tmpdir()指错地方,状态文件找不到 → 子进程仍被降 Low。现主路径为__dirname/../.degraded.state(服务端写、runner 读,路径恒定),tmpdir 路径降级为兼容回退。 - 工作区标签直侦测(判据 2,自包含):runner 从自身 argv 取
--workspace,直接读该目录有无 Low 完整性强制标签——有 ⇒ 严格环境(正常降完整性);无 ⇒ 降级环境(跳过降完整性)。不依赖任何文件与环境变量,是最终兜底的事实判据。
0.1.1
- koffi
lib.func调用形式误判:koffi 有两种调用形式——4 参数(dsh-win32-process的 bind)和 C 声明式单串(dsh-fs-local等,串内含()。v1 一律按 4 参数转手,导致声明式被 koffi 类型解析器拒绝(Unexpected character '(' in type specifier),所有"覆盖文件"的写操作失败(新建文件不走该路径所以无症状)。现仅识别 4 参数形式,其余原样透传。 - 降级标志跨进程传播(初版):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) |
No comments yet. Be the first to write one.