Files
deepseek-harness/packages/sandbox/sandbox-windows-acl/README.zh.md
Huanqi Cao f64ba40f43 feat(sandbox): Windows ACL write-restriction sandbox (restricted-token runner)
Confine Windows command execution through a WRITE_RESTRICTED token whose restricting SIDs carry an orphan-SID write allowlist, ported from https://github.com/huoyaoyuan/windows-acl-restrict-poc (@ 10e4dfb). Every Win32 call is checked and fails closed - the POC silently ran children with the FULL token when CreateRestrictedToken failed.

- @deepseek-ai/dsh-sandbox-windows-acl: koffi primitives verified against the MinGW Windows headers (verify/abi-probe.cpp) plus the confinement runner ([node, runner, --workspace, --temp, --mode, --, argv...]: kill-on-close job, stdio passthrough, exit-code mirroring, windows-acl-run: failure signature, grant revocation). read-only = strict zero grants (NUL device not writable; documented). Windows-only execution: exempted from the Linux coverage lane (windowsOnlyCoverageExclusions).
- @deepseek-ai/dsh-sandbox-local: PLATFORM_CHAINS.win32 filled with the windows-acl runner (full enforcement, ACL denial dialect, runner-failure rules).
- @deepseek-ai/dsh-pwsh-sandbox: sandbox-consuming pwsh executor (call-for-call mirror of dsh-bash-sandbox) over a new argv-level seam in dsh-pwsh-local; per-file coverage complete via the fake-provider spec.
- bundle/base: the Windows platform layer mounts the confined pwsh roster - sandbox/policy/fs-sandbox/permission/approval re-enabled, the POSIX bash stack stays disabled.

Co-authored-by: Huo Yaoyuan <huoyaoyuan@hotmail.com>
2026-08-08 01:24:33 +08:00

7.0 KiB
Raw Blame History

@deepseek-ai/dsh-sandbox-windows-acl

English | 中文

面向 harness 沙盒接口 的 Windows 写入限制沙盒后端:用 Node.js/koffi 移植了 huoyaoyuan/windows-acl-restrict-poc(10e4dfb 修复版)的机制,作为 Windows 端 SandboxProvider(workspace-write / read-only 模式)实装的准备层。Linux/macOS 后端见 @deepseek-ai/dsh-sandbox-local。

一句话机制:把调用者令牌复制为 WRITE_RESTRICTED 受限令牌,其 restricting SIDs 中加入一个孤儿 SID(S-1-4-x-y),该 SID 只被本沙盒实例加到工作区与临时目录的 DACL 上。此后 Windows 只在「调用者正常权限」与「restricting SID 交集」同时允许时才放行写入——孤儿 SID 就是写入白名单,而它在系统其余位置不授予任何权限。

用法

import { AclSandbox } from '@deepseek-ai/dsh-sandbox-windows-acl'

const sandbox = new AclSandbox({ writableDirs: [workspaceRoot] })
await sandbox.init() // 任何 Win32 调用失败都会抛错——绝不降级为无沙盒运行

const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot })
const { stdout, stderr, exitCode } = await child.wait()

sandbox.dispose() // 回收所有挂起的授权;逐项报告清理失败

本包对每一个 Win32 API 调用都做返回值检查;失败抛出 Win32Error,携带 API 名、精确的 Win32 错误码、FormatMessageW 系统文本和出错的路径/上下文。这是有意为之:原 POC 忽略所有返回值,当 CreateRestrictedToken 失败时会静默地用完整未受限令牌运行子进程(fail-open)。本移植从构造上保证 fail-closed。

隔离 runner

面向 seam 的形态是 runner 入口(./runner):@deepseek-ai/dsh-sandbox-local 用它替换调用方命令的 argv 前缀包装——与 bwrap/landlock-run/sandbox-exec 同一架构,因此沙盒 seam 的 confine() 契约无需任何改动。稳定的 argv 契约:

node runner.js --workspace <dir> --temp <dir> --mode <read-only|workspace-write> -- <argv...>

runner 创建受限令牌,在令牌下启动被包裹的 argv,stdio 直接透传(spawn 前后把调用方的管道句柄恢复/清除继承位——Node 启动时会清掉自身 stdio 的继承位,裸 spawn 必须补偿这一点),把子进程放进 KILL_ON_JOB_CLOSE 作业(runner 死亡即杀死子进程),忽略自身的控制台 Ctrl+C 让子进程自行处理,镜像子进程退出码,退出时回收所有授权。任何 runner 侧失败都会向 stderr 打印 windows-acl-run: <detail> 并以 127 退出——seam 的 RUNNER_FAILURE_RULES 据此区分 runner 失败与真正的权限拒绝。

模式:

  • workspace-write:工作区与临时目录携带孤儿 SID 的 Write 授权;其余写全部被令牌交集拒绝。
  • read-only:严格零授权——没有任何可写位置。NUL 设备是带安全描述符的对象,同样不被授权(区别于 Linux 的 /dev/null sink):Set-Content NUL 与原生 > NUL 写会以 access denied 失败,而 PowerShell 的 > $null 重定向不受影响(它直接丢弃、不打开 NUL)。这是文档化的行为,不是给模型的承诺——模型可见面没有对 read-only 模式做过任何 sink 承诺。

AclSandbox 类(tempDir: null 关闭临时目录授权)仍是直接 spawn 场景的程序化 API。

头文件查证

所有常量、函数签名和结构体布局都对照开发机的 Windows 头文件(MinGW winnt.h / accctrl.h / aclapi.h / securitybaseapi.h / sddl.h / processthreadsapi.h / fileapi.h / namedpipeapi.h / synchapi.h / winbase.h)逐一核实,并由 verify/abi-probe.cpp(尺寸、偏移、枚举值、static_assert)交叉验证:

g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && ./abi-probe.exe

模块加载时 koffi 结构体定义会与探针输出比对尺寸,头文件/koffi 布局一旦漂移立即报错,而不是悄悄写坏内存。

已验证的边界(受限令牌固有,非本移植缺陷)

  • 只限制写;读、网络、进程可见性均不受限。 WRITE_RESTRICTED 只对写访问做交集检查,受限子进程可以读取调用者能读的任何文件、可以开 socket。因此 read-only 模式无法仅靠本机制表达,需要叠加读侧策略或改用 AppContainer/S-1-15-2 capability 令牌做强隔离。
  • 控制台隔离不可用。 受限令牌下用 CREATE_NO_WINDOW / CREATE_NEW_CONSOLE 创建的子进程会在 DLL 初始化阶段以 STATUS_DLL_INIT_FAILED(0xC0000142)死亡。POC 曾试图把控制台登录 SID(S-1-2-1)加进 restricting 列表来修复:在 Windows 11 26200 上 CreateWellKnownSid(WinLocalLogonSid) 直接失败(ERROR_INVALID_PARAMETER 87),改用正确的 WinConsoleLogonSid 虽能得到合法的 S-1-2-1,子进程仍然死亡,POC 最终版本遂删除了该 SID 并放弃控制台隔离。因此子进程共享宿主控制台;stdio 重定向走管道,不受影响。
  • ACL 授权是对真实目录的驻留改动。 进程中途死亡会留下授权;dispose() 负责回收,init() 后续步骤失败时也会回滚已应用的授权。POC 注释里的手工清理命令(icacls <dir> /remove '*S-1-4-…')在本平台实测失败(ERROR_NONE_MAPPED 1332)——请通过本模块回收。
  • 被授权目录必须归调用者所有。 所有者隐含的 WRITE_DAC 是免提权改 DACL 的前提。
  • 临时目录授权跟随 GetTempPathW —— 尽可能显式传入 tempDir。GetTempPathW 读取的是原生环境块,用 worker 池管理 process.env 的宿主运行时(vitest 实测)不会把 worker 侧的 process.env.TMP 改动同步过去。若默认授权落到真实临时目录,其 (OI)(CI) 继承会覆盖 temp 下所有子目录、静默扩大白名单——请指向按沙盒隔离的目录。

已知限制与后续工作

  • 尚未接入 SandboxProvider —— 本包是原语层;ctx.sandbox.confine() 的集成(在 spawn 侧应用受限令牌,并补齐 denialSignatures/runnerFailureRules 契约)是下一步。该集成不能沿用 dsh-sandbox-local 的 argv 包装风格,因为受限令牌必须在 CreateProcess 时生效。
  • 每个实例一个写入白名单 —— 孤儿 SID 是白名单的基本单位;同一沙盒实例跨两个工作区复用时,两个根目录会互相扩大授权面。请按工作区根目录各建一个实例。
  • 清理尽力而为 —— dispose() 会尝试全部回收并把失败聚合为 AggregateError;清理失败只会留下仅含孤儿 SID 的 ACE,本进程下次 init()/dispose() 循环或 icacls(按 ACE 而非受托者名)仍可清除。
  • 读侧隔离、网络策略、job-object 关闭即杀 超出本层范围,留给未来的 provider 设计。