feat(sandbox): support writable-directory sets on Windows

This commit is contained in:
Yixiang Tu committed 2026-11-04 16:07:04 +08:00
1 parent 69e6199cb7
commit a63ba54f30
30 files changed
+372 -189

No files matched your search

@@ -9,11 +9,11 @@
en: 2a32db1f27371403
zh: 89cf04705ef66769
/agent-note-session-writable-directory-effects/decision:
en: af67969841bedb67
zh: edabdfaa30bcadd1
en: 504c8d2399c47110
zh: 6c30739907a86f07
/agent-note-session-writable-directory-effects/alternatives-considered:
en: dad77744dea17bde
zh: bb3838f9f9099944
/agent-note-session-writable-directory-effects/consequences:
en: 7edcc1022b9f3ee5
zh: 61163e25d5a90f16
en: 008f7270b7d3aaaf
zh: 2b60ed4c4739ff75
@@ -16,6 +16,8 @@ Registration disposal affects subsequent operations, not already-resolved calls
Missing, non-directory, and symlinked extra paths stop execution with `SandboxInvalidRootError`, which identifies the path and its problem. Silently skipping a path would hide an invalid grant and let execution proceed with permissions different from those requested. Registration checks path spelling; enforcement validates the physical directory on each call without retaining an earlier resolution. Symlinks remain disallowed, keeping configured and plugin grants under the same validation rules.
Windows standing ACL grants use one deterministic SID per complete sorted, deduplicated canonical directory set. Each new execution resolves or creates that exact set's grant entry; changing the set never broadens the SID of a process already running. Reusing only the workspace identity would accumulate extra-directory authority across Sessions and after disposal. JSON encoding preserves directory boundaries in the hash input; singleton sets retain their existing SID. Standing ACEs remain reusable, while private temp grants keep their separate per-session/workspace identity and lifetime.
## Alternatives considered
**Shared mutable extra roots.** Enabling memory in one Session would also authorize unrelated Sessions using the same service.
@@ -26,4 +28,4 @@ Missing, non-directory, and symlinked extra paths stop execution with `SandboxIn
## Consequences
Independent registrations for the same directory survive another owner's disposal. Removing a registration cannot revoke overlapping configured or workspace access. A running process may retain a removed grant, or lack a newly added grant; a new process uses the current policy. Windows subprocess confinement retains its existing unsupported-extra-roots error. Focused coverage checks Session isolation, duplicate ownership, plugin unload, filesystem denial and mutation, request context, and retained PTC launch permissions.
Independent registrations for the same directory survive another owner's disposal. Removing a registration cannot revoke overlapping configured or workspace access. A running process may retain a removed grant, or lack a newly added grant; a new process uses the current policy. Focused coverage checks Session isolation, duplicate ownership, plugin unload, filesystem denial and mutation, request context, and retained PTC launch permissions.
@@ -16,6 +16,8 @@ sandbox-policy 服务拥有以活跃 Session 身份为键、受 effect 生命周
不存在、不是目录或经过符号链接的额外路径会以 `SandboxInvalidRootError` 阻止执行,错误指出路径及其问题。静默跳过路径会隐藏无效授权,使执行时的权限与请求的权限不同。注册时检查路径写法;执行限制时每次验证物理目录,不保留先前的解析结果。符号链接仍被禁止,使配置授权和插件授权遵循相同的验证规则。
Windows 常驻 ACL 授权为完整、排序去重后的规范目录集合使用一个确定性 SID。每次新执行查找或创建完全匹配该集合的授权项;集合变化不会扩大已运行进程的 SID 权限。如果仅复用工作区身份,额外目录权限就会在不同 Session 之间及注册清理后累积。JSON 编码在哈希输入中保留目录边界;单目录集合保留现有 SID。常驻 ACE 可继续复用,私有临时授权则保持独立的每会话/工作区身份与生命周期。
## 考虑过的替代方案
**共享的可变额外根目录。** 在一个 Session 中启用记忆,也会授权使用同一服务的无关 Session。
@@ -26,4 +28,4 @@ sandbox-policy 服务拥有以活跃 Session 身份为键、受 effect 生命周
## 影响
同一目录的独立注册不会随另一个所有者的清理而移除。移除注册无法撤销重叠的配置授权或工作区访问权限。运行中的进程可能保留已移除的授权,或缺少新添加的授权;新进程使用当前策略。Windows 子进程隔离保留现有的不支持额外根目录错误。针对性覆盖检查 Session 隔离、重复注册的所有权、插件卸载、文件系统拒绝与修改、请求上下文,以及保留 PTC 环境的启动权限。
同一目录的独立注册不会随另一个所有者的清理而移除。移除注册无法撤销重叠的配置授权或工作区访问权限。运行中的进程可能保留已移除的授权,或缺少新添加的授权;新进程使用当前策略。针对性覆盖检查 Session 隔离、重复注册的所有权、插件卸载、文件系统拒绝与修改、请求上下文,以及保留 PTC 环境的启动权限。
@@ -9,8 +9,8 @@
en: f62d87b7623a9904
zh: 48bbb7c35298f96c
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/decision:
en: 10e9cfb7972cff03
zh: 8406ba64d72a1fcc
en: 7c8a053085200aee
zh: dd36659227430d93
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/acl-diagnosis-and-recovery:
en: a5bcdf2a6133abfe
zh: 477d4915d61945e3
@@ -30,8 +30,8 @@
en: e2c573d90c4ddeb9
zh: f0259213e1321722
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/consequences:
en: dd43744417f207a5
zh: d7b4fee1e2d87b60
en: f14d0c8fb77d57ab
zh: 1842f13d91de8bd2
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/testing:
en: b223cb2cf13193c9
zh: 61ddd0a0b99f8f56
@@ -10,7 +10,7 @@ The original [historical sandbox decision](../../archived/feature/2026-07-06-san
## Decision
Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE`; `LUA_TOKEN` is not used) whose restricting SIDs carry distinct workspace and private-temp capabilities. `WRITE_RESTRICTED` intersects write accesses only, so reads keep the caller's ambient access while a write must also match one of these capability ACEs. That intersection covers only the object's own access check, and Windows also authorizes a write or a delete from the PARENT directory's `FILE_DELETE_CHILD` right — which no restricting SID has to co-sign, so `cmd /c del` deleted files outside the workspace wherever the caller's ambient SIDs hold Modify. The token is therefore also lowered to Low integrity and every granted directory receives a Low no-write-up mandatory label in the same call that adds its ACE; the kernel applies that policy inside the access check, whichever right supplied the authority, so the granted roots stay writable and everything else becomes delete-proof as well as write-proof. Because every granted root is labeled Low, the integrity check alone would still let one confined child delete inside ANOTHER granted root (another workspace, a live sibling temp directory), so each grant also DENIES `FILE_DELETE_CHILD` to the world SID: the capability ACE's DELETE bit becomes the only delete authority inside a granted root, for the confined child and for the user's own processes, whose ordinary deletes keep working because the same DACL grants them DELETE directly. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on ignored failures). The per-workspace SID is derived deterministically from the canonical workspace path (`workspaceWriteSid` — sha256 → `S-1-4-x-y`); its standing workspace ACE is the cross-session reuse cache, and an exact-ACE skip prevents repeated eager tree propagation. Each live session/workspace pair instead gets a random private temp directory and a domain-separated SID derived from that path (`tempWriteSid`); its ACE is revocable, TMP/TEMP point at that directory, and the token default DACL names the temp SID so newly created temp objects do not acquire the shared workspace capability. A fork therefore cannot write its sibling's temp tree. A fresh provider chooses a new path and SID even for the same resumed session, so crash residue is inert litter rather than a collision or inherited capability; agentless calls create and remove the same shape per invocation. The ambient temp root is never an implicit grant. A workspace equal to or containing the temp root fails before any ACL mutation because its inheritable standing ACE would otherwise reach every private child; the direct API rejects overlap in either direction between a writable root and the actual private temp directory. PowerShell can complete its startup AppLocker probe through this private-temp capability, so `workspace-write` remains FullLanguage absent a host-wide policy; `read-only` cannot create the probe files and conservatively enters ConstrainedLanguage. That split is PowerShell startup behavior, not part of the ACL boundary. The token lists are read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, workspace SID, optional temp SID]. Logon SID + Everyone are keep-alive invariants (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Everyone remains in both lists but no longer confers write authority, because the label layer denies it; because NTFS ACLs belong to file objects, a granted workspace hard link still grants an external alias; and because a Low-integrity token cannot open an object whose DACL names another sandbox's AppContainer package SID, a tree stamped by such a tool becomes unreadable to the confined child. Rejecting all hard links would reject ordinary pnpm workspaces, so the provider reports `enforcement: 'partial'` and the native suite pins both gaps. Read-only carries no capability SID, so standing workspace ACEs remain inert across a mode downgrade. Authenticated Users is absent from both lists — CIM is unavailable, closing the C:\-root tree-creation escape — and INTERACTIVE/LOCAL are absent, denying Public-tree writes. New anonymous pipes and sync objects inherit the temp SID (or workspace SID when temp is disabled, Everyone under read-only) through `SetTokenInformation(TokenDefaultDacl)`; named pipes keep the Win32 layer's owner/SYSTEM/Admins-full, Everyone/ANONYMOUS-read-only template, so piped grandchild stdio remains denied. It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md), the `win32` rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.Line truncated
Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE`; `LUA_TOKEN` is not used) whose restricting SIDs carry distinct workspace and private-temp capabilities. `WRITE_RESTRICTED` intersects write accesses only, so reads keep the caller's ambient access while a write must also match one of these capability ACEs. That intersection covers only the object's own access check, and Windows also authorizes a write or a delete from the PARENT directory's `FILE_DELETE_CHILD` right — which no restricting SID has to co-sign, so `cmd /c del` deleted files outside the workspace wherever the caller's ambient SIDs hold Modify. The token is therefore also lowered to Low integrity and every granted directory receives a Low no-write-up mandatory label in the same call that adds its ACE; the kernel applies that policy inside the access check, whichever right supplied the authority, so the granted roots stay writable and everything else becomes delete-proof as well as write-proof. Because every granted root is labeled Low, the integrity check alone would still let one confined child delete inside ANOTHER granted root (another workspace, a live sibling temp directory), so each grant also DENIES `FILE_DELETE_CHILD` to the world SID: the capability ACE's DELETE bit becomes the only delete authority inside a granted root, for the confined child and for the user's own processes, whose ordinary deletes keep working because the same DACL grants them DELETE directly. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on ignored failures). The write SID is derived deterministically from the complete canonical writable-directory set (`workspaceWriteSid` — sha256 → `S-1-4-x-y`); its standing ACEs are the reuse cache for that exact set, and an exact-ACE skip prevents repeated eager tree propagation. Each live session/workspace pair instead gets a random private temp directory and a domain-separated SID derived from that path (`tempWriteSid`); its ACE is revocable, TMP/TEMP point at that directory, and the token default DACL names the temp SID so newly created temp objects do not acquire the shared workspace capability. A fork therefore cannot write its sibling's temp tree. A fresh provider chooses a new path and SID even for the same resumed session, so crash residue is inert litter rather than a collision or inherited capability; agentless calls create and remove the same shape per invocation. The ambient temp root is never an implicit grant. A workspace equal to or containing the temp root fails before any ACL mutation because its inheritable standing ACE would otherwise reach every private child; the direct API rejects overlap in either direction between a writable root and the actual private temp directory. PowerShell can complete its startup AppLocker probe through this private-temp capability, so `workspace-write` remains FullLanguage absent a host-wide policy; `read-only` cannot create the probe files and conservatively enters ConstrainedLanguage. That split is PowerShell startup behavior, not part of the ACL boundary. The token lists are read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, workspace SID, optional temp SID]. Logon SID + Everyone are keep-alive invariants (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Everyone remains in both lists but no longer confers write authority, because the label layer denies it; because NTFS ACLs belong to file objects, a granted workspace hard link still grants an external alias; and because a Low-integrity token cannot open an object whose DACL names another sandbox's AppContainer package SID, a tree stamped by such a tool becomes unreadable to the confined child. Rejecting all hard links would reject ordinary pnpm workspaces, so the provider reports `enforcement: 'partial'` and the native suite pins both gaps. Read-only carries no capability SID, so standing workspace ACEs remain inert across a mode downgrade. Authenticated Users is absent from both lists — CIM is unavailable, closing the C:\-root tree-creation escape — and INTERACTIVE/LOCAL are absent, denying Public-tree writes. New anonymous pipes and sync objects inherit the temp SID (or workspace SID when temp is disabled, Everyone under read-only) through `SetTokenInformation(TokenDefaultDacl)`; named pipes keep the Win32 layer's owner/SYSTEM/Admins-full, Everyone/ANONYMOUS-read-only template, so piped grandchild stdio remains denied. It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md), the `win32` rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/RLine truncated
## ACL diagnosis and recovery
@@ -40,7 +40,7 @@ The landstrip evaluation was rejected before implementation (not battle-tested;
## Consequences
Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades; mandatory integrity control predates it as well), reads/network/process visibility untouched exactly as the mode vocabulary requires, deletes confined through every authority Windows accepts and confined to the granted root that owns them, and fail-closed errors carrying the API name and exact Win32 code. Sessions share the intentionally standing workspace capability but not their revocable temp capabilities; restart residue cannot block or authorize a resumed session. Cost: enforcement is structurally partial because NTFS hard-link aliases cannot be path-confined by this token shape and because a Low-integrity token cannot open a file whose DACL carries another sandbox's AppContainer package SID, which makes a tree stamped by such a tool unreadable to the confined child; no read-side or network isolation; console isolation unavailable (children created with `CREATE_NO_WINDOW` or `CREATE_NEW_CONSOLE` die with `STATUS_DLL_INIT_FAILED`; [startup visibility](../../../../packages/subprocess/subprocess-local/README.md) preserves console inheritance); standing workspace security-descriptor mutations (the reuse cache, plus inert residue when a workspace is renamed) and random temp litter after an unclean shutdown until OS hygiene reclaims it; EAGER full-tree workspace propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per workspace per machine; CIM unavailable in both confined modes (Authenticated Users is absent, closing the C:\-root tree-creation escape); FAT-class non-ACL targets unverified rather than writable; NULL-DACL directories not identity-preserving under a grant/revoke round trip; read-only pwsh entering ConstrainedLanguage while workspace-write remains FullLanguage absent host policy; and named-pipe opens remaining denied, so libuv piped-stdio grandchildren fail with EPERM while inherited/ignored stdio and anonymous pipes work. The package README owns these operational limits.
Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades; mandatory integrity control predates it as well), reads/network/process visibility untouched exactly as the mode vocabulary requires, deletes confined through every authority Windows accepts and confined to the granted root that owns them, and fail-closed errors carrying the API name and exact Win32 code. Sessions share the intentionally standing workspace capability but not their revocable temp capabilities; restart residue cannot block or authorize a resumed session. Cost: enforcement is structurally partial because NTFS hard-link aliases cannot be path-confined by this token shape and because a Low-integrity token cannot open a file whose DACL carries another sandbox's AppContainer package SID, which makes a tree stamped by such a tool unreadable to the confined child; no read-side or network isolation; console isolation unavailable (children created with `CREATE_NO_WINDOW` or `CREATE_NEW_CONSOLE` die with `STATUS_DLL_INIT_FAILED`; [startup visibility](../../../../packages/subprocess/subprocess-local/README.md) preserves console inheritance); standing workspace security-descriptor mutations (the reuse cache, plus inert residue when a workspace is renamed) and random temp litter after an unclean shutdown until OS hygiene reclaims it; EAGER full-tree workspace propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per directory set per machine; CIM unavailable in both confined modes (Authenticated Users is absent, closing the C:\-root tree-creation escape); FAT-class non-ACL targets unverified rather than writable; NULL-DACL directories not identity-preserving under a grant/revoke round trip; read-only pwsh entering ConstrainedLanguage while workspace-write remains FullLanguage absent host policy; and named-pipe opens remaining denied, so libuv piped-stdio grandchildren fail with EPERM while inherited/ignored stdio and anonymous pipes work. The package README owns these operational limits.
## Testing
@@ -10,7 +10,7 @@ Status: implemented
## 决策
直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE`,不采用 `LUA_TOKEN`),其 restricting SIDs 携带彼此独立的工作区能力与私有临时目录能力。`WRITE_RESTRICTED` 只对写访问做交集检查,因此读取保留调用者的环境访问,而写入还必须匹配这些能力 ACE 之一。该交集只覆盖对象自身的那次访问检查,而 Windows 也会依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除——这项权限不需要任何 restricting SID 副署,因此只要调用者的环境 SID 持有 Modify,`cmd /c del` 就能删除工作区之外的文件。所以令牌还会被降级为 Low 完整性,每个被授权目录都在添加 ACE 的同一次调用中收到 Low 禁止上调强制标签;无论权限来自哪一项,内核都会在访问检查内部执行该策略,于是被授权根目录保持可写,而其他一切不仅防写、也防删。由于每个授权根目录都被标记为 Low,仅靠完整性检查仍会让一个受限子进程删除**另一个授权根目录**(别的工作区、同级的活跃临时目录)内的文件,因此每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`:能力 ACE 的 DELETE 位由此成为授权根目录内唯一的删除授权来源——对受限子进程如此,对用户自身进程亦如此,而后者的日常删除仍然可用,因为同一份 DACL 直接向其授予 DELETE。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每个 API 调用并 fail-closed(POC 因忽略失败而 fail-open)。工作区 SID 由规范工作区路径确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`);其常驻工作区 ACE 是跨会话复用缓存,精确 ACE 跳过可避免重复的急切全树传播。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的、经过域分离的 SID(`tempWriteSid`);其 ACE 可回收,TMP/TEMP 指向该目录,令牌默认 DACL 列入该临时 SID,因此新建的临时对象不会获得共享的工作区能力。fork 因此无法写入同级会话的临时目录树。即使恢复的是同一会话,新的提供方也会选择新的路径和 SID,因此崩溃残留只是失效垃圾,而非冲突或继承的能力;无 agent(智能体)的调用会逐调用创建并移除同样的形态。环境临时根目录绝不会被隐式授权。如果工作区等于或包含临时根目录,调用会在任何 ACL 改动发生前失败,因为否则其可继承的常驻 ACE 会向每个私有子目录授权;直接 API 会拒绝可写根目录与实际私有临时目录在任一方向上的重叠。PowerShell 可借助这项私有临时目录能力完成启动时的 AppLocker 探针,因此在没有主机范围策略时,`workspace-write` 会保持 FullLanguage;`read-only` 无法创建探针文件,会保守地进入 ConstrainedLanguage。这一区别属于 PowerShell 启动行为,不是 ACL 边界的一部分。令牌列表为 read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、工作区 SID、可选临时 SID]。登录 SID + Everyone 是保活不变式(没有它们,早期 DLL 初始化会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Everyone 仍留在列表中,但它带来的写权限已被标签层否定;由于 NTFS ACL 属于文件对象,工作区内获授权的硬链接仍会使同一对象的外部别名获得授权,而 DACL 中携带其他沙箱 AppContainer 包 SID 的文件对 Low 完整性子进程不可读。拒绝所有硬链接会让普通 pnpm 工作区不可用,因此提供方报告 `enforcement: 'partial'`,原生套件则钉住这些缺口。Read-only 不含任何能力 SID,因此常驻工作区 ACE 在模式降级后保持失效。Authenticated Users 在两种列表中都不存在——CIM 不可用,从而关闭 C:\-root 建树逃逸——INTERACTIVE/LOCAL 也不存在,因此 Public 树写入被拒绝。新建匿名管道和同步对象通过 `SetTokenInformation(TokenDefaultDacl)` 继承临时 SID(禁用临时目录时继承工作区 SID,read-only 下继承 Everyone);named pipe 保持 Win32 层 owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读的模板,因此受限孙进程的管道 stdio 仍被拒绝。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.zh.md)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.zh.md) 的 `win32` 档,以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.zh.md) 交付。
直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE`,不采用 `LUA_TOKEN`),其 restricting SIDs 携带彼此独立的工作区能力与私有临时目录能力。`WRITE_RESTRICTED` 只对写访问做交集检查,因此读取保留调用者的环境访问,而写入还必须匹配这些能力 ACE 之一。该交集只覆盖对象自身的那次访问检查,而 Windows 也会依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除——这项权限不需要任何 restricting SID 副署,因此只要调用者的环境 SID 持有 Modify,`cmd /c del` 就能删除工作区之外的文件。所以令牌还会被降级为 Low 完整性,每个被授权目录都在添加 ACE 的同一次调用中收到 Low 禁止上调强制标签;无论权限来自哪一项,内核都会在访问检查内部执行该策略,于是被授权根目录保持可写,而其他一切不仅防写、也防删。由于每个授权根目录都被标记为 Low,仅靠完整性检查仍会让一个受限子进程删除**另一个授权根目录**(别的工作区、同级的活跃临时目录)内的文件,因此每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`:能力 ACE 的 DELETE 位由此成为授权根目录内唯一的删除授权来源——对受限子进程如此,对用户自身进程亦如此,而后者的日常删除仍然可用,因为同一份 DACL 直接向其授予 DELETE。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每个 API 调用并 fail-closed(POC 因忽略失败而 fail-open)。写入 SID 由完整的规范可写目录集合确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`);其常驻 ACE 是完全相同集合的复用缓存,精确 ACE 跳过可避免重复的急切全树传播。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的、经过域分离的 SID(`tempWriteSid`);其 ACE 可回收,TMP/TEMP 指向该目录,令牌默认 DACL 列入该临时 SID,因此新建的临时对象不会获得共享的工作区能力。fork 因此无法写入同级会话的临时目录树。即使恢复的是同一会话,新的提供方也会选择新的路径和 SID,因此崩溃残留只是失效垃圾,而非冲突或继承的能力;无 agent(智能体)的调用会逐调用创建并移除同样的形态。环境临时根目录绝不会被隐式授权。如果工作区等于或包含临时根目录,调用会在任何 ACL 改动发生前失败,因为否则其可继承的常驻 ACE 会向每个私有子目录授权;直接 API 会拒绝可写根目录与实际私有临时目录在任一方向上的重叠。PowerShell 可借助这项私有临时目录能力完成启动时的 AppLocker 探针,因此在没有主机范围策略时,`workspace-write` 会保持 FullLanguage;`read-only` 无法创建探针文件,会保守地进入 ConstrainedLanguage。这一区别属于 PowerShell 启动行为,不是 ACL 边界的一部分。令牌列表为 read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、工作区 SID、可选临时 SID]。登录 SID + Everyone 是保活不变式(没有它们,早期 DLL 初始化会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Everyone 仍留在列表中,但它带来的写权限已被标签层否定;由于 NTFS ACL 属于文件对象,工作区内获授权的硬链接仍会使同一对象的外部别名获得授权,而 DACL 中携带其他沙箱 AppContainer 包 SID 的文件对 Low 完整性子进程不可读。拒绝所有硬链接会让普通 pnpm 工作区不可用,因此提供方报告 `enforcement: 'partial'`,原生套件则钉住这些缺口。Read-only 不含任何能力 SID,因此常驻工作区 ACE 在模式降级后保持失效。Authenticated Users 在两种列表中都不存在——CIM 不可用,从而关闭 C:\-root 建树逃逸——INTERACTIVE/LOCAL 也不存在,因此 Public 树写入被拒绝。新建匿名管道和同步对象通过 `SetTokenInformation(TokenDefaultDacl)` 继承临时 SID(禁用临时目录时继承工作区 SID,read-only 下继承 Everyone);named pipe 保持 Win32 层 owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读的模板,因此受限孙进程的管道 stdio 仍被拒绝。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.zh.md)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.zh.md) 的 `win32` 档,以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.zh.md) 交付。
<a id="acl-diagnosis-and-recovery"></a>
## ACL 诊断与恢复
@@ -41,7 +41,7 @@ landstrip 评估在实现前已被否决(未经实战检验;自建 launcher
## 后果
所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年;强制完整性控制出现得更早)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、删除在 Windows 接受的每条授权路径上都被约束且被约束在各自的授权根目录内,且 fail-closed 错误携带 API 名与精确 Win32 错误码。会话共享有意常驻的工作区能力,但不共享各自可回收的临时能力;重启残留既不能阻塞恢复的会话,也不能向其授权。所失:强制执行在结构上只能是部分的,因为此令牌形态无法把 NTFS 硬链接别名限制在路径边界内,而 Low 完整性的令牌也无法打开 DACL 中携带其他沙箱 AppContainer 包 SID 的文件——被这类工具标记过的目录树对受限子进程将不可读;无读侧或网络隔离;控制台隔离不可用(通过 `CREATE_NO_WINDOW` 或 `CREATE_NEW_CONSOLE` 创建的子进程以 `STATUS_DLL_INIT_FAILED` 死亡;[启动可见性](../../../../packages/subprocess/subprocess-local/README.zh.md)保留控制台继承);工作区常驻安全描述符改动(复用缓存,以及工作区改名后的失效残留)与异常关闭后遗留的随机临时目录垃圾,直到 OS 卫生机制将其回收;工作区授权采用急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——大型工作区上耗时数十秒),每台机器每个工作区只付一次;CIM 在两种受限模式下均不可用(Authenticated Users 不存在,从而关闭 C:\-root 建树逃逸);FAT 类无 ACL 目标的行为未经验证而非「仍可写」;NULL-DACL 目录在 grant/revoke 往返下不保持身份;read-only pwsh 会进入 ConstrainedLanguage,而在没有主机策略时 workspace-write 保持 FullLanguage;named pipe 打开仍被拒绝,因此 libuv 管道 stdio 的孙进程以 EPERM 失败,而继承/忽略的 stdio 与匿名管道可用。包 README 负责记录这些运行限制。
所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年;强制完整性控制出现得更早)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、删除在 Windows 接受的每条授权路径上都被约束且被约束在各自的授权根目录内,且 fail-closed 错误携带 API 名与精确 Win32 错误码。会话共享有意常驻的工作区能力,但不共享各自可回收的临时能力;重启残留既不能阻塞恢复的会话,也不能向其授权。所失:强制执行在结构上只能是部分的,因为此令牌形态无法把 NTFS 硬链接别名限制在路径边界内,而 Low 完整性的令牌也无法打开 DACL 中携带其他沙箱 AppContainer 包 SID 的文件——被这类工具标记过的目录树对受限子进程将不可读;无读侧或网络隔离;控制台隔离不可用(通过 `CREATE_NO_WINDOW` 或 `CREATE_NEW_CONSOLE` 创建的子进程以 `STATUS_DLL_INIT_FAILED` 死亡;[启动可见性](../../../../packages/subprocess/subprocess-local/README.zh.md)保留控制台继承);工作区常驻安全描述符改动(复用缓存,以及工作区改名后的失效残留)与异常关闭后遗留的随机临时目录垃圾,直到 OS 卫生机制将其回收;工作区授权采用急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——大型工作区上耗时数十秒),每台机器每个目录集合只付一次;CIM 在两种受限模式下均不可用(Authenticated Users 不存在,从而关闭 C:\-root 建树逃逸);FAT 类无 ACL 目标的行为未经验证而非「仍可写」;NULL-DACL 目录在 grant/revoke 往返下不保持身份;read-only pwsh 会进入 ConstrainedLanguage,而在没有主机策略时 workspace-write 保持 FullLanguage;named pipe 打开仍被拒绝,因此 libuv 管道 stdio 的孙进程以 EPERM 失败,而继承/忽略的 stdio 与匿名管道可用。包 README 负责记录这些运行限制。
## 测试
+1 -1
View File
@@ -2926,7 +2926,7 @@ export interface Config {
## `@deepseek-ai/dsh-sandbox-local`
- `source`: [`packages/sandbox/sandbox-local/src/index.ts:45`](../packages/sandbox/sandbox-local/src/index.ts)
- `source`: [`packages/sandbox/sandbox-local/src/index.ts:39`](../packages/sandbox/sandbox-local/src/index.ts)
```ts config-catalog
/** Plugin config. All optional — `static Config` supplies the defaults. */
+1 -1
View File
@@ -2928,7 +2928,7 @@ export interface Config {
## `@deepseek-ai/dsh-sandbox-local`
- `source`: [`packages/sandbox/sandbox-local/src/index.ts:45`](../packages/sandbox/sandbox-local/src/index.ts)
- `source`: [`packages/sandbox/sandbox-local/src/index.ts:39`](../packages/sandbox/sandbox-local/src/index.ts)
```ts config-catalog
/** Plugin config. All optional — `static Config` supplies the defaults. */
+1 -1
View File
@@ -64,7 +64,7 @@ interface SandboxExecutionPolicy {
* Opaque identity of the calling session (the branded `dsh-session`
* SessionId). Backends key per-session state off it (e.g. windows-acl gives
* each live session/workspace pair a random private temp directory and SID,
* while the workspace SID and standing grant remain per-workspace); absent
* while standing grants are keyed by the complete writable-directory set); absent
* for agentless calls, which fall back to per-call backend state.
*/
sessionId?: SessionId
+1 -1
View File
@@ -64,7 +64,7 @@ interface SandboxExecutionPolicy {
* Opaque identity of the calling session (the branded `dsh-session`
* SessionId). Backends key per-session state off it (e.g. windows-acl gives
* each live session/workspace pair a random private temp directory and SID,
* while the workspace SID and standing grant remain per-workspace); absent
* while standing grants are keyed by the complete writable-directory set); absent
* for agentless calls, which fall back to per-call backend state.
*/
sessionId?: SessionId
@@ -36,8 +36,8 @@
en: f0c44248c2ca6acc
zh: 554e9f706671d222
/deepseek-ai-dsh-sandbox-local/understand-the-implementation/platform-profiles:
en: 008967ee30fbbe46
zh: 9e5c6a3e97851a69
en: bd441e16c283095e
zh: eef437f45922e174
/deepseek-ai-dsh-sandbox-local/understand-the-implementation/denial-and-runner-failure-dialects:
en: 62adfc9229c656cc
zh: 11b1534b6d61e5de
@@ -54,8 +54,8 @@
en: e290ccc9b3c92c08
zh: 469589ac12cdadab
/deepseek-ai-dsh-sandbox-local/known-limitations-and-deferred-work:
en: 38756b82fe0cd48c
zh: 441cc45ebe5b51fd
en: 71f863f5ee97d9ba
zh: cabc01953e6cb94d
/deepseek-ai-dsh-sandbox-local/known-limitations-and-deferred-work/dev-note:
en: cc273320f6a1beaf
zh: 5c57a312ddc50651
+2 -2
View File
@@ -80,7 +80,7 @@ The `@deepseek-ai/node-addon-system/landlock-run` API supplies the platform laun
The Seatbelt profile is allow-default with `(deny file-write*)` plus write allow-lists derived from the shared `writableRoots` helper, so exactly the mode's promised file effects are governed; every root is canonicalized because Seatbelt matches resolved paths (`/tmp` IS `/private/tmp`).
The Windows rung keeps one deterministic write SID and standing ACE per workspace, while every live session/workspace pair gets a random private temp directory with a distinct SID and revocable ACE — sessions sharing a workspace share its intended write authority without inheriting one another's temp authority. A fresh provider always chooses a new temp path and SID, so crash residue cannot block or authorize a resumed session. The rung reports `partial` enforcement because NTFS hard links alias one file object across paths, reads stay unconfined, and a tree another AppContainer tool has ACL'd with a package SID is unreadable to the Low-integrity child.
The Windows rung derives a deterministic write SID from each sorted, deduplicated set of canonical writable directories and reuses standing ACEs only for that exact set, while every live session/workspace pair gets a random private temp directory with a distinct SID and revocable ACE — sessions sharing a workspace share its intended write authority without inheriting one another's temp authority. A fresh provider always chooses a new temp path and SID, so crash residue cannot block or authorize a resumed session. The rung reports `partial` enforcement because NTFS hard links alias one file object across paths, reads stay unconfined, and a tree another AppContainer tool has ACL'd with a package SID is unreadable to the Low-integrity child.
With the built-in Windows runner and a skill registry, this provider registers the [ACL diagnosis skill](../sandbox-windows-acl/README.md#failures-and-recovery). An operator-supplied `runnerCommand` does not register it; provider disposal removes the skill and its extracted resources.
@@ -130,7 +130,7 @@ No direct invalidation; the named consumers own any request-prefix changes.
These limits define when the provider is a poor fit or needs special operational care. They are current package constraints, not a general platform comparison or a task backlog.
- **Windows extra roots** — Windows ACL rejects workspace-write subprocesses with existing writable directories beyond the workspace with `SANDBOX_UNSUPPORTED_POLICY`, before grants or child creation. Filesystem tools can enforce these grants independently. Remove the extra grants or use a supported host for process operations.
- **Windows temp separation** — writable roots cannot contain the ambient temp root or overlap the session's private temp directory. Those overlaps would expose private temp files through a shared directory capability.
- **Windows ACL enforcement is partial** — NTFS hard links alias one file object across workspace and external paths, reads stay unconfined, and a tree another AppContainer tool has ACL'd with a package SID is unreadable to the Low-integrity child. The provider reports `enforcement: 'partial'` rather than overstating that boundary as full.
- **Landlock may be partial** — older supported kernel ABIs confine only the access classes they expose, reported as `enforcement: 'partial'` rather than overstated as full.
- **Seatbelt depends on deprecated `sandbox-exec`** — macOS still ships it, but this provider cannot replace or probe that private policy engine if Apple removes it.
+2 -2
View File
@@ -80,7 +80,7 @@ bwrap profile 组合只读宿主根目录、全新 `/dev` 与私有 PID 命名
Seatbelt profile 默认允许,带 `(deny file-write*)` 与来自共享 `writableRoots` 辅助函数的写入 allow-list,因此恰好管辖模式承诺的文件操作;每个根目录都经过规范化,因为 Seatbelt 匹配解析后的路径(`/tmp` 就是 `/private/tmp`)。
Windows 档为每个工作区保留一个确定性写入 SID 和常驻 ACE,同时为每个活跃的会话/工作区对分配一个随机私有临时目录,以及不同的 SID 和可撤销 ACE——共享工作区的会话共享其预期写权限,却不会继承彼此的临时目录权限。新的提供方总会选择新的临时路径和 SID,因此崩溃残留既无法阻止恢复的会话,也无法向其授权。该档报告 `partial` 强制执行,因为 NTFS 硬链接会把同一文件对象别名为多个路径、读取仍不受限,且被其他 AppContainer 工具以包 SID 标记过的目录树对 Low 完整性子进程不可读。
Windows 档从排序、去重后的规范可写目录集合派生确定性写入 SID,并仅为完全相同的集合复用常驻 ACE,同时为每个活跃的会话/工作区对分配一个随机私有临时目录,以及不同的 SID 和可撤销 ACE——共享工作区的会话共享其预期写权限,却不会继承彼此的临时目录权限。新的提供方总会选择新的临时路径和 SID,因此崩溃残留既无法阻止恢复的会话,也无法向其授权。该档报告 `partial` 强制执行,因为 NTFS 硬链接会把同一文件对象别名为多个路径、读取仍不受限,且被其他 AppContainer 工具以包 SID 标记过的目录树对 Low 完整性子进程不可读。
当内置 Windows runner 与技能注册表同时可用时,此提供方会注册 [ACL 诊断技能](../sandbox-windows-acl/README.zh.md#failures-and-recovery)。自定义 `runnerCommand` 不注册该技能;提供方释放时移除技能及其提取的资源。
@@ -130,7 +130,7 @@ Windows 档为每个工作区保留一个确定性写入 SID 和常驻 ACE,同
这些限制说明提供方何时不合适,或何时需要特别运维。它们是当前包约束,不是通用平台对比或任务积压。
- **Windows 额外根目录**——Windows ACL 在授权或创建子进程前,以 `SANDBOX_UNSUPPORTED_POLICY` 拒绝授权工作区以外现存目录的 workspace-write 子进程。文件系统工具可独立执行这些授权。运行进程操作前,请移除额外授权或使用支持此功能的主机。
- **Windows 临时目录隔离**——可写根目录不能包含系统临时根目录,也不能与会话的私有临时目录重叠。这类重叠会通过共享目录能力暴露私有临时文件。
- **Windows ACL 只能实现部分强制执行**——NTFS 硬链接会使工作区路径与外部路径指向同一个文件对象,读取仍不受限,且被其他 AppContainer 工具以包 SID 标记过的目录树对 Low 完整性子进程不可读。提供方报告 `enforcement: 'partial'`,而不会把该边界夸大为完整强制执行。
- **Landlock 可能只实现部分强制执行**——较旧且受支持的内核 ABI 只能限制自身公开的访问类别,因此报告 `enforcement: 'partial'`,不会夸大为完整强制执行。
- **Seatbelt 依赖已弃用的 `sandbox-exec`**——macOS 仍会提供它,但若 Apple 移除该私有策略引擎,该提供方无法替换或探测。
+51 -68
View File
@@ -5,19 +5,13 @@
* classification facts. Missing or unusable confinement fails closed rather
* than returning the original argv.
*
* The windows-acl rung additionally owns the write grants: the write SID is
* the per-WORKSPACE identity derived from the canonical workspace path
* (`workspaceWriteSid`), while every live session receives a RANDOM private
* temp directory and its own derived capability (`tempWriteSid`). The
* workspace-root ACE materializes once per workspace per server lifetime
* and STANDS (the cross-session reuse cache — the exact-ACE skip makes
* every later provision O(1) instead of re-propagating the tree per
* session); the private-temp ACEs are revoked on dispose. The runner
* receives both SIDs (their presence marks the seam-managed contract) and
* stops managing DACLs itself. The rung reports partial enforcement because
* NTFS hard links alias one file object across paths, reads stay unconfined,
* and a tree another AppContainer tool has ACL'd with a package SID is not
* readable by the Low-integrity child.
* The windows-acl rung owns standing grants keyed by the complete canonical
* writable-directory set. Each set has a deterministic write SID; executions
* with different sets never share that capability. Every live session/workspace
* pair has a separate random private temp directory and revocable temp SID.
* The runner receives both identities and does not manage their DACLs.
* Enforcement remains partial because hard links alias file objects, reads
* stay unconfined, and AppContainer ACLs can deny reads to the child.
* @module @deepseek-ai/dsh-sandbox-local
*/
@@ -34,10 +28,10 @@ import {
} from '@deepseek-ai/node-addon-system/landlock-run'
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { SandboxProvider, SandboxUnavailableError, SandboxUnsupportedPolicyError, canonicalPath, workspaceWritableRoots } from '@deepseek-ai/dsh-sandbox'
import { SandboxProvider, SandboxUnavailableError, canonicalPath, workspaceWritableRoots } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, ConfinedSandboxMode, RunnerFailureRule, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import type { SessionId } from '@deepseek-ai/dsh-session'
import { AclWriteGrant, assertTempRootOutsideWorkspace, registerAclDiagnosisSkill, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
import { AclWriteGrant, assertPrivateTempDisjoint, assertTempRootOutsideWorkspace, registerAclDiagnosisSkill, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
import { assertNever } from '@deepseek-ai/dsh-util-values'
import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './profiles.ts'
@@ -244,7 +238,7 @@ const RUNNER_FAILURE_RULES = {
/**
* Local process-sandbox provider. Registers as `ctx.sandbox`. Caches the
* chain verdict and, on the windows-acl rung, the write grants
* ({@link AclWriteGrant}: the standing workspace-root grant per workspace
* ({@link AclWriteGrant}: standing grants per writable-directory set
* and the revocable private-temp grant per live session/workspace pair, the
* latter revoked on provider dispose); the one-time probes spawn nothing
* else.
@@ -265,14 +259,9 @@ export class LocalSandboxProvider extends SandboxProvider {
private readonly probeTimeoutMs: number
/** Cached chain verdict; undefined until the first confined wrap needs it. */
private selectedRunner: SelectedRunner | 'unavailable' | undefined
/**
* Server-lifetime write grants (windows-acl rung): the STANDING
* workspace-root grant per workspace (its ACE is the cross-session reuse
* cache and outlives the provider — never revoked) and the REVOCABLE
* private-temp grant per live session/workspace pair (revoked on provider
* dispose).
*/
private readonly workspaceGrants = new Map<string, AclWriteGrant>()
/** Standing grants keyed by the sorted canonical directory set; ACEs outlive the provider. */
private readonly directoryGrants = new Map<string, AclWriteGrant>()
/** Revocable private temp grants per live session/workspace pair. */
private readonly tempCapabilities = new Map<string, AclTempCapability>()
constructor(ctx: Context, config: Config) {
@@ -355,21 +344,15 @@ export class LocalSandboxProvider extends SandboxProvider {
}
/**
* The windows-acl runner argv for one policy. With a calling session (the
* policy's `sessionId`) under workspace-write, the grants are materialized
* once per provider lifetime — the standing workspace-root grant per
* workspace and a revocable, RANDOM private-temp capability per live
* session/workspace pair. The runner receives `--write-sid` plus
* `--temp-write-sid` and grants nothing itself. Agentless workspace-write
* calls pass the ambient temp ROOT and no SID flags: the runner creates and
* removes a random private child directory for that one invocation.
* Build the Windows runner invocation for the current writable-directory set.
* Session calls reuse standing directory grants and a private temp capability.
* Agentless calls let the runner materialize the set and own its private temp.
* @param policy - the resolved per-call policy.
* @returns the runner invocation.
*/
private windowsAclRunnerArgv(policy: SandboxPolicy): string[] {
if (workspaceWritableRoots(policy).length > 1) {
throw new SandboxUnsupportedPolicyError('Windows ACL confinement does not support additional writable directories; remove the extra grants or use a supported host')
}
const roots = workspaceWritableRoots(policy)
const extraArgs = roots.slice(1).flatMap(root => ['--writable-root', root])
const sessionId = policy.sessionId
if (sessionId === undefined || policy.mode === 'read-only') {
return [
@@ -377,54 +360,54 @@ export class LocalSandboxProvider extends SandboxProvider {
'--workspace', policy.workspaceRoot,
'--temp', tmpdir(),
'--mode', policy.mode,
...extraArgs,
]
}
const temp = this.materializeAclGrant(sessionId, policy.workspaceRoot)
const temp = this.materializeAclGrant(sessionId, policy.workspaceRoot, roots)
return [
...this.windowsAclRunnerInvocation(),
'--workspace', policy.workspaceRoot,
'--temp', temp.dir,
'--mode', policy.mode,
'--write-sid', workspaceWriteSid(policy.workspaceRoot),
...extraArgs,
'--write-sid', workspaceWriteSid(policy.workspaceRoot, roots),
'--temp-write-sid', temp.writeSid,
]
}
/**
* Materialize one workspace-write policy's ACEs once per provider
* lifetime. The workspace SID and standing root grant are shared by the
* workspace. The temp directory is random and carries a distinct SID, so
* another session on the same workspace cannot use the shared workspace
* SID to enter it. A fresh provider always chooses a new path; crash
* residue therefore cannot collide with or authorize a resumed session.
* Fail-closed: a half-materialized temp grant is revoked and its directory
* removed before the error propagates.
* Resolve or materialize the exact writable-directory set's standing ACEs.
* Private temp capabilities remain scoped to the session/workspace pair;
* changing directory sets does not revoke grants held by running processes.
* Failed temp materialization revokes its grant and removes its directory.
* @param sessionId - the policy's calling-session identity.
* @param workspaceRoot - the resolved policy root.
* @param roots - the complete canonical writable-directory set for this execution.
* @returns the pair's private temp directory and write capability.
*/
private materializeAclGrant(sessionId: SessionId, workspaceRoot: string): AclTempCapability {
assertTempRootOutsideWorkspace(workspaceRoot, tmpdir())
const writeSid = workspaceWriteSid(workspaceRoot)
if (!this.workspaceGrants.has(workspaceRoot)) {
const grant = AclWriteGrant.create(writeSid)
try {
grant.add(workspaceRoot, true)
} catch (error) {
// Free the SID; a standing ACE (if the apply succeeded before a
// post-apply throw) is the intended end state, not an error
// artifact — nothing to revoke.
try {
grant.dispose()
} catch (cleanupError) {
throw new AggregateError([error, cleanupError], 'sandbox-local windows-acl workspace grant failed and its cleanup also failed')
}
throw error
}
this.workspaceGrants.set(workspaceRoot, grant)
}
private materializeAclGrant(sessionId: SessionId, workspaceRoot: string, roots: readonly string[]): AclTempCapability {
for (const root of roots) assertTempRootOutsideWorkspace(root, tmpdir())
const key = JSON.stringify([String(sessionId), workspaceRoot])
const existing = this.tempCapabilities.get(key)
if (existing !== undefined) assertPrivateTempDisjoint(roots, existing.dir)
const writeSid = workspaceWriteSid(workspaceRoot, roots)
const grantKey = JSON.stringify([...roots].sort())
const cachedGrant = this.directoryGrants.get(grantKey)
const directoryGrant = cachedGrant ?? AclWriteGrant.create(writeSid)
try {
for (const root of roots) directoryGrant.add(root, true)
} catch (error) {
if (cachedGrant !== undefined) throw error
// Standing ACEs remain reusable; only the failed entry's allocations
// are released. A later execution retries the complete directory set.
try {
directoryGrant.dispose()
} catch (cleanupError) {
throw new AggregateError([error, cleanupError], 'sandbox-local windows-acl directory grant failed and its cleanup also failed')
}
throw error
}
this.directoryGrants.set(grantKey, directoryGrant)
if (existing !== undefined) return existing
const tempDir = mkdtempSync(join(tmpdir(), 'dsh-'))
const tempSid = tempWriteSid(tempDir)
@@ -466,9 +449,9 @@ export class LocalSandboxProvider extends SandboxProvider {
* OS temp hygiene (or manual removal) eventually reclaims it.
*/
private revokeAclGrants(): void {
if (this.workspaceGrants.size === 0 && this.tempCapabilities.size === 0) return
if (this.directoryGrants.size === 0 && this.tempCapabilities.size === 0) return
const failures: unknown[] = []
for (const grant of [...this.workspaceGrants.values(), ...[...this.tempCapabilities.values()].map(capability => capability.grant)]) {
for (const grant of [...this.directoryGrants.values(), ...[...this.tempCapabilities.values()].map(capability => capability.grant)]) {
try {
grant.dispose()
} catch (error) {
@@ -482,7 +465,7 @@ export class LocalSandboxProvider extends SandboxProvider {
failures.push(error)
}
}
this.workspaceGrants.clear()
this.directoryGrants.clear()
this.tempCapabilities.clear()
if (failures.length > 0) {
this.ctx.logger.warn(`sandbox-local: windows-acl grant cleanup completed with ${failures.length} failure(s)`)
@@ -5,7 +5,7 @@
* is mocked; native access checks live in sandbox-windows-acl's runner suite.
*/
import { existsSync, mkdtempSync, realpathSync, rmSync } from 'node:fs'
import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { basename, join } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -13,18 +13,21 @@ import { Context } from '@deepseek-ai/cordis'
import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import { SessionId } from '@deepseek-ai/dsh-session'
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import { workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
/** Cross-file state shared with the vi.mock factory (hoisting contract). */
const mockState = vi.hoisted(() => ({
grants: [] as Array<{ writeSid: string; added: Array<{ path: string; standing: boolean }>; disposed: boolean }>,
addFailure: undefined as Error | undefined,
addFailurePath: undefined as string | undefined,
/** Restrict an add failure to standing (workspace) or revocable (temp). */
addFailureStanding: undefined as boolean | undefined,
createTempFailure: undefined as Error | undefined,
disposeFailure: undefined as Error | undefined,
}))
vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => {
vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', async (importOriginal) => {
const actual = await importOriginal<typeof import('@deepseek-ai/dsh-sandbox-windows-acl')>()
class MockAclWriteGrant {
readonly writeSid: string
readonly added: Array<{ path: string; standing: boolean }> = []
@@ -40,6 +43,7 @@ vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => {
add(path: string, standing = false): void {
this.added.push({ path, standing })
if (mockState.addFailure !== undefined
&& (mockState.addFailurePath === undefined || mockState.addFailurePath === path)
&& (mockState.addFailureStanding === undefined || mockState.addFailureStanding === standing)) {
throw mockState.addFailure
}
@@ -50,6 +54,7 @@ vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => {
}
}
return {
...actual,
registerAclDiagnosisSkill: vi.fn(),
AclWriteGrant: MockAclWriteGrant,
assertTempRootOutsideWorkspace: (workspaceRoot: string, tempRoot: string) => {
@@ -59,13 +64,10 @@ vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => {
throw new Error(`Windows ACL temp root must be outside the workspace: workspace=${workspaceRoot}; temp=${tempRoot}`)
}
},
workspaceWriteSid: () => 'S-1-4-42-42',
tempWriteSid: (path: string) => `TEMP:${path}`,
}
})
const WORKSPACE_SID = 'S-1-4-42-42'
async function setup() {
const ctx = new Context()
const fiber = await ctx.plugin(LocalSandboxProvider, {})
@@ -89,6 +91,7 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => {
beforeEach(() => {
mockState.grants = []
mockState.addFailure = undefined
mockState.addFailurePath = undefined
mockState.addFailureStanding = undefined
mockState.createTempFailure = undefined
mockState.disposeFailure = undefined
@@ -129,19 +132,19 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => {
expect(tempDir).toBeDefined()
expect(basename(tempDir ?? '')).toMatch(/^dsh-[A-Za-z0-9_-]{6}$/u)
expect(tempSid).toBe(`TEMP:${tempDir}`)
expect(tempSid).not.toBe(WORKSPACE_SID)
expect(tempSid).not.toBe(workspaceWriteSid(ws))
expect(confined.argv).toEqual([
'node', 'windows-acl-runner.js',
'--workspace', ws,
'--temp', tempDir,
'--mode', 'workspace-write',
'--write-sid', WORKSPACE_SID,
'--write-sid', workspaceWriteSid(ws),
'--temp-write-sid', tempSid,
'--',
'pwsh', '/Command', 'x',
])
expect(mockState.grants).toEqual([
expect.objectContaining({ writeSid: WORKSPACE_SID, added: [{ path: ws, standing: true }], disposed: false }),
expect.objectContaining({ writeSid: workspaceWriteSid(ws), added: [{ path: ws, standing: true }], disposed: false }),
expect.objectContaining({ writeSid: tempSid, added: [{ path: tempDir, standing: false }], disposed: false }),
])
expect(existsSync(tempDir ?? '')).toBe(true)
@@ -157,6 +160,104 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => {
}
})
it('selects exact directory-set grants while retaining session-private temp capabilities', async () => {
const { sandbox, fiber } = await setup()
try {
const ws = workspaceRoot()
const memory = workspaceRoot()
const cache = workspaceRoot()
scratch.push(ws, memory, cache)
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sets') }
const base = await sandbox.confine(['true'], policy)
const expanded = await sandbox.confine(['true'], { ...policy, additionalWritableRoots: [memory, cache] })
const expandedSid = workspaceWriteSid(ws, [memory, cache])
expect(flag(expanded.argv, '--write-sid')).toBe(expandedSid)
expect(expandedSid).not.toBe(flag(base.argv, '--write-sid'))
expect(flag(expanded.argv, '--temp')).toBe(flag(base.argv, '--temp'))
expect(flag(expanded.argv, '--temp-write-sid')).toBe(flag(base.argv, '--temp-write-sid'))
expect(mockState.grants.filter(grant => grant.writeSid === expandedSid)).toEqual([
expect.objectContaining({ added: [ws, memory, cache].map(path => ({ path, standing: true })), disposed: false }),
])
const reordered = await sandbox.confine(['true'], { ...policy, additionalWritableRoots: [cache, memory, cache, ws] })
expect(flag(reordered.argv, '--write-sid')).toBe(expandedSid)
expect(mockState.grants).toHaveLength(3)
expect((await sandbox.confine(['true'], policy)).argv).toEqual(base.argv)
expect(mockState.grants.every(grant => !grant.disposed)).toBe(true)
const sibling = await sandbox.confine(['true'], { ...policy, sessionId: SessionId('sibling') })
expect(flag(sibling.argv, '--write-sid')).toBe(flag(base.argv, '--write-sid'))
expect(flag(sibling.argv, '--temp-write-sid')).not.toBe(flag(base.argv, '--temp-write-sid'))
expect(mockState.grants).toHaveLength(4)
} finally { await fiber.dispose(); cleanup() }
})
it('reapplies a cached set grant when an extra directory is recreated', async () => {
const { sandbox, fiber } = await setup()
try {
const ws = workspaceRoot()
const extra = workspaceRoot()
scratch.push(ws, extra)
const policy: SandboxPolicy = {
mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('recreated'), additionalWritableRoots: [extra],
}
const first = await sandbox.confine(['true'], policy)
const directoryGrant = mockState.grants[0]!
rmSync(extra, { recursive: true })
mkdirSync(extra)
const second = await sandbox.confine(['true'], policy)
expect(second.argv).toEqual(first.argv)
expect(mockState.grants).toHaveLength(2)
expect(mockState.grants[0]).toBe(directoryGrant)
expect(directoryGrant.added).toEqual([ws, extra, ws, extra].map(path => ({ path, standing: true })))
} finally { await fiber.dispose(); cleanup() }
})
it('retries every directory after a partial set grant fails', async () => {
const { sandbox, fiber } = await setup()
try {
const ws = workspaceRoot()
const extra = workspaceRoot()
scratch.push(ws, extra)
const policy: SandboxPolicy = {
mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('set-retry'), additionalWritableRoots: [extra],
}
mockState.addFailure = new Error('extra grant failed')
mockState.addFailurePath = extra
await expect(sandbox.confine(['true'], policy)).rejects.toThrow('extra grant failed')
expect(mockState.grants).toHaveLength(1)
expect(mockState.grants[0]).toMatchObject({
added: [{ path: ws, standing: true }, { path: extra, standing: true }], disposed: true,
})
mockState.addFailure = undefined
await sandbox.confine(['true'], policy)
expect(mockState.grants).toHaveLength(3)
expect(mockState.grants[1]).toMatchObject({
added: [{ path: ws, standing: true }, { path: extra, standing: true }], disposed: false,
})
mockState.addFailure = new Error('cached grant refresh failed')
await expect(sandbox.confine(['true'], policy)).rejects.toThrow('cached grant refresh failed')
expect(mockState.grants).toHaveLength(3)
expect(mockState.grants[1]!.disposed).toBe(false)
mockState.addFailure = undefined
await sandbox.confine(['true'], policy)
expect(mockState.grants).toHaveLength(3)
} finally { await fiber.dispose(); cleanup() }
})
it('refuses a changed set overlapping its private temp before adding standing grants', async () => {
const { sandbox, fiber } = await setup()
try {
const ws = workspaceRoot()
scratch.push(ws)
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('temp-overlap') }
const confined = await sandbox.confine(['true'], policy)
const temp = realpathSync.native(flag(confined.argv, '--temp')!)
await expect(sandbox.confine(['true'], { ...policy, additionalWritableRoots: [temp] }))
.rejects.toThrow('private temp directory must be disjoint from writable directories')
expect(mockState.grants).toHaveLength(2)
} finally { await fiber.dispose(); cleanup() }
})
it('read-only materializes no capability; upgrade creates them and downgrade leaves them reusable', async () => {
try {
const { sandbox, fiber } = await setup()
@@ -176,7 +277,7 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => {
expect(mockState.grants).toHaveLength(0)
const upgraded = await sandbox.confine(['true'], workspaceWrite)
expect(flag(upgraded.argv, '--temp-write-sid')).not.toBe(WORKSPACE_SID)
expect(flag(upgraded.argv, '--temp-write-sid')).not.toBe(workspaceWriteSid(ws))
expect(mockState.grants).toHaveLength(2)
await sandbox.confine(['true'], readOnly)
expect(mockState.grants).toHaveLength(2)
@@ -252,7 +353,7 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => {
mockState.disposeFailure = new Error('workspace cleanup exploded')
await expect(sandbox.confine(['true'], {
mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('workspace-cleanup-fail'),
})).rejects.toThrow(/workspace grant failed and its cleanup also failed/u)
})).rejects.toThrow(/directory grant failed and its cleanup also failed/u)
expect(mockState.grants).toHaveLength(2)
} finally {
cleanup()
@@ -430,7 +430,7 @@ describe('the default seatbelt probe (sandbox-exec contract)', () => {
})
describe('the windows-acl probe (runner invocation contract)', () => {
it('refuses unsupported extra grants before a Windows runner or ACL mutation', async () => {
it('passes existing extra directories to the Windows runner and skips non-directories', async () => {
const { ctx, sandbox } = await setup({}, {
chain: ['windows-acl'],
probeWindowsAcl: () => true,
@@ -442,8 +442,9 @@ describe('the windows-acl probe (runner invocation contract)', () => {
writeFileSync(file, 'file')
try {
await expect(sandbox.confine(['true'], { ...WW, additionalWritableRoots: [join(extra, 'missing'), file] })).rejects.toThrow('Invalid additional writable directory')
await expect(sandbox.confine(['true'], { ...WW, additionalWritableRoots: [extra] }))
.rejects.toThrow('Windows ACL confinement does not support additional writable directories')
const confined = await sandbox.confine(['true'], { ...WW, additionalWritableRoots: [extra] })
expect(confined.argv).toContain('--writable-root')
expect(confined.argv[confined.argv.indexOf('--writable-root') + 1]).toBe(extra)
await expect(sandbox.confine(['true'], { ...RO, additionalWritableRoots: ['/extra'] })).resolves.toBeDefined()
} finally {
await ctx.fiber.dispose()
@@ -24,8 +24,8 @@
en: c7e924a923bfac72
zh: fdaf930a5800ffa6
/deepseek-ai-dsh-sandbox-policy/use-this-package/registering-a-session-s-writable-directory:
en: 169b82d5392ec91a
zh: e0ef9428cb794f9c
en: 28587491ad2ac66a
zh: c5d4dc441e3f0459
/deepseek-ai-dsh-sandbox-policy/use-this-package/switching-a-session-s-mode:
en: 957d72691e470e5c
zh: c22a3a750af0e4a3
+1 -1
View File
@@ -56,7 +56,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
A plugin calls `ctx.sandboxPolicy.registerWritableRoot(session, directory)` through its owning context to add one physical absolute directory to that live Session's `workspace-write` permissions. The method returns the exact Cordis effect disposer; plugin unload or explicit disposal removes only that registration. Duplicate registrations and configured grants remain independent. Mount session-specific plugins under the Agent's context so their effects also end with the Agent. Registration validates the path spelling; the directory must exist on the execution host before an operation uses the grant. The same path checks as configured roots apply.
Registration and disposal affect subsequent policy resolutions and the next model request's logged context. Other Sessions, including subagent Sessions, and agentless calls receive no such grant. Grants are not saved or inherited through Session history; a resumed or forked Session needs its own registration. Already-resolved operations and persistent processes keep their original permissions, including directories whose registration has been removed. Start a new process to use the current grants. `read-only` remains read-only. On Windows, existing extra directories cause new `workspace-write` subprocess confinement to fail, even for commands that do not access those directories; see [sandbox-local](../sandbox-local/README.md#known-limitations-and-deferred-work).
Registration and disposal affect subsequent policy resolutions and the next model request's logged context. Other Sessions, including subagent Sessions, and agentless calls receive no such grant. Grants are not saved or inherited through Session history; a resumed or forked Session needs its own registration. Already-resolved operations and persistent processes keep their original permissions, including directories whose registration has been removed. Start a new process to use the current grants. `read-only` remains read-only.
### Switching a session's mode
+1 -1
View File
@@ -56,7 +56,7 @@ kind: "package-reference"
插件通过自己的所属上下文调用 `ctx.sandboxPolicy.registerWritableRoot(session, directory)`,为该活跃 Session 的 `workspace-write` 权限添加一个物理绝对目录。该方法返回原始 Cordis effect 清理函数;插件卸载或显式清理只移除该次注册。重复注册与配置授权彼此独立。将会话专用插件挂载在 Agent 的上下文下,使其 effect 也随 Agent 结束。注册时验证路径写法;操作使用该授权前,目录必须已存在于执行主机上。路径检查与配置根目录相同。
注册与清理影响后续策略解析及下一次模型请求中记录的上下文。其他 Session(包括子 agent 的 Session)与无 agent 调用不会获得此授权。授权不随 Session 历史保存或继承;恢复或派生的 Session 需要自行注册。已经解析策略的操作与持久进程保留原权限,包括注册已被移除的目录。启动新进程才能使用当前授权。`read-only` 仍然只读。在 Windows 上,已存在的额外目录会使新的 `workspace-write` 子进程约束失败,即使命令不访问这些目录;参见 [sandbox-local](../sandbox-local/README.zh.md#known-limitations-and-deferred-work)。
注册与清理影响后续策略解析及下一次模型请求中记录的上下文。其他 Session(包括子 agent 的 Session)与无 agent 调用不会获得此授权。授权不随 Session 历史保存或继承;恢复或派生的 Session 需要自行注册。已经解析策略的操作与持久进程保留原权限,包括注册已被移除的目录。启动新进程才能使用当前授权。`read-only` 仍然只读。
### 切换会话模式
@@ -9,8 +9,8 @@
en: e7cdbd2652fe80ab
zh: 04e1d187313a0bd5
/deepseek-ai-dsh-sandbox-windows-acl/summary:
en: 777921135c23ee55
zh: 7c726cc63995d443
en: dac35dbc120ce3ec
zh: b46a5c0d31af6f1f
/deepseek-ai-dsh-sandbox-windows-acl/table-of-contents:
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
@@ -24,8 +24,8 @@
en: 16596607bbccc2a3
zh: 30bc370a5281bf0d
/deepseek-ai-dsh-sandbox-windows-acl/use-this-package/what-confinement-gives-you:
en: 289e65bddf20dd79
zh: 7ff72ed64c4b4d1c
en: 0ae33482d28f637b
zh: a2b87b18737a6d07
/deepseek-ai-dsh-sandbox-windows-acl/use-this-package/failures-and-recovery:
en: 7fc0707debb1e67c
zh: e3b5529bf82f868e
@@ -33,14 +33,14 @@
en: c2cde86c85c2c619
zh: a29a114e591f9d3f
/deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation/mechanism:
en: a37ea37f2d527693
zh: 749b5a263d0e1534
en: 97ef643f608ae7d8
zh: bf4eb7941f4573f6
/deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation/modes-and-token-lists:
en: 9c8d9af84f9abd99
zh: 1c82b355aa5f210f
en: 1a4ed5c06a5be74d
zh: 053cb81bd76260ed
/deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation/the-confinement-runner:
en: 0847de0e53e50061
zh: 90fa3dcaf080c3be
en: de2dce4b18f77dad
zh: bbe7eef92b441364
/deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation/verified-boundaries:
en: ad02570eae90037f
zh: fd78c2318de18dda
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
On Windows, this package confines child-process writes and deletes to the workspace and a private temporary directory: `workspace-write` grants both, `read-only` grants neither. Mounting `dsh-sandbox-local` selects this for confined bash and PowerShell commands, or callers use the public `AclSandbox` API directly; any failed Win32 operation prevents an unrestricted spawn. Each grant combines a capability-SID allow ACE, a deny of the ambient parent-directory delete right, and a Low integrity label the lowered token must match, so one granted root cannot reach another. The guarantee stays partial: hard links alias file objects and files ACL'd by another AppContainer tool stay unreadable.
On Windows, this package confines child-process writes and deletes to the workspace, explicit extra directories, and a private temporary directory: `workspace-write` grants these directories, `read-only` grants none. Mount `dsh-sandbox-local` for confined bash and PowerShell commands, or use `AclSandbox`; Win32 failures never cause unrestricted spawning. Each grant combines a capability-SID allow ACE, a deny of the ambient parent-directory delete right, and a Low integrity label the lowered token must match, so one granted root cannot reach another. The guarantee stays partial: hard links alias file objects and files ACL'd by another AppContainer tool stay unreadable.
## Table of Contents
@@ -67,7 +67,7 @@ The workspace security descriptor edits are granted standing — `dispose()` lea
### What confinement gives you
Under `workspace-write`, the child may write into the workspace and its private temp directory; other ACL-addressable writes and deletes are denied except the documented hard-link and AppContainer-ACL boundaries. Under `read-only`, no explicit write grants exist, so writes and deletes are denied with the same documented boundaries.
Under `workspace-write`, the child may write into the workspace, its explicit extra roots, and its private temp directory; other ACL-addressable writes and deletes are denied except the documented hard-link and AppContainer-ACL boundaries. Under `read-only`, no explicit write grants exist, so writes and deletes are denied with the same documented boundaries.
Temp isolation is per live session/workspace pair: sessions sharing a workspace share its write authority but cannot write one another's temp directories. A fresh provider always chooses a new temp path and SID, so crash residue cannot block or authorize a resumed session.
@@ -95,11 +95,11 @@ This section explains the restricted-token mechanism, the token lists, the runne
### Mechanism
The caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs carry separate workspace and private-temp capabilities, and that token is lowered to Low integrity. Windows performs the access check twice — once against the normal SIDs, once against the restricting SIDs — and grants write-class access only where both checks pass; separately, the kernel's mandatory-integrity check denies write-class access to every object that is not labeled Low. The write-SID intersection covers only the object's OWN access check: Windows can also authorize a write or a delete from the parent directory's `FILE_DELETE_CHILD` right, which no restricting SID has to co-sign, so a token holding only the intersection could still delete anything its ambient user SIDs control — including files inside ANOTHER granted root, whose Low label clears the integrity check. Each grant therefore also denies `FILE_DELETE_CHILD` to the world SID, leaving the capability ACE's DELETE bit as the only delete authority inside the granted roots, and labels the same directory Low in the one `SetNamedSecurityInfoW` call that applies all three edits. The workspace SID is derived deterministically from the canonical workspace path (`workspaceWriteSid`), so the workspace-root security descriptor edit materializes once per workspace per machine and every later session, call, or restart hits the exact-ACE/exact-deny/exact-label skip. Each live session/workspace pair instead receives a random private temp directory and a SID derived from that path (`tempWriteSid`), so sessions share the intended workspace authority without inheriting one another's temp authority. Every policy-specific Win32 call and every process primitive from [`dsh-win32-process`](../../subprocess/win32-process/README.md) is checked; failures throw `Win32Error` carrying the API name, exact code, system text, and failing context — fail-closed by construction.
The caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs carry separate workspace and private-temp capabilities, and that token is lowered to Low integrity. Windows performs the access check twice — once against the normal SIDs, once against the restricting SIDs — and grants write-class access only where both checks pass; separately, the kernel's mandatory-integrity check denies write-class access to every object that is not labeled Low. The write-SID intersection covers only the object's OWN access check: Windows can also authorize a write or a delete from the parent directory's `FILE_DELETE_CHILD` right, which no restricting SID has to co-sign, so a token holding only the intersection could still delete anything its ambient user SIDs control — including files inside ANOTHER granted root, whose Low label clears the integrity check. Each grant therefore also denies `FILE_DELETE_CHILD` to the world SID, leaving the capability ACE's DELETE bit as the only delete authority inside the granted roots, and labels the same directory Low in the one `SetNamedSecurityInfoW` call that applies all three edits. `workspaceWriteSid(workspaceRoot, additionalWritableRoots)` hashes the sorted, deduplicated canonical directory set with unambiguous JSON encoding; a workspace-only set preserves its existing SID. Each execution resolves or materializes standing ACEs for that exact set. Adding or removing a directory selects a different SID for new processes, while running processes keep their launch token. Equal sets reuse their standing security descriptor edits through the exact-ACE/exact-deny/exact-label skip. Each live session/workspace pair instead receives a random private temp directory and a SID derived from that path (`tempWriteSid`), so sessions share the intended workspace authority without inheriting one another's temp authority. Every policy-specific Win32 call and every process primitive from [`dsh-win32-process`](../../subprocess/win32-process/README.md) is checked; failures throw `Win32Error` carrying the API name, exact code, system text, and failing context — fail-closed by construction.
### Modes and token lists
`workspace-write` (logon SID, Everyone, workspace SID, temp SID) grants the workspace and the session's private temp subdirectory separate Write grants; `read-only` (logon SID, Everyone — no write SID) grants none. Both modes spawn a Low-integrity token, and only labeled directories stay writable for it. The keep-alive group (logon SID + Everyone) is present in both modes: without it, early DLL initialization dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352`. The write SID stays out of the read-only list on purpose: the standing workspace grant from an earlier workspace-write period remains inert because the write-restricted pass-2 check grants only what the restricting list carries, while the standing security descriptor edit keeps a re-upgrade free of re-propagation.
`workspace-write` (logon SID, Everyone, directory-set SID, temp SID) grants the selected writable directories and the session's private temp subdirectory separate Write grants; `read-only` (logon SID, Everyone — no write SID) grants none. Both modes spawn a Low-integrity token, and only labeled directories stay writable for it. The keep-alive group (logon SID + Everyone) is present in both modes: without it, early DLL initialization dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352`. The write SID stays out of the read-only list on purpose: the standing workspace grant from an earlier workspace-write period remains inert because the write-restricted pass-2 check grants only what the restricting list carries, while the standing security descriptor edit keeps a re-upgrade free of re-propagation.
NUL writes are ambient, not granted: the device DACL grants Everyone read+write+execute (`0x1201BF`), so openers whose mask fits it (cmd `> NUL`, node `\\.\NUL`) can write it in both modes. `Set-Content NUL` fails in both modes (a PowerShell/.NET-layer effect, not the device DACL), while PowerShell's `> $null` redirection keeps working.
@@ -110,10 +110,10 @@ Authenticated Users is absent from both lists — the WMI namespace security che
The seam-facing shape is the runner entry (`./runner`): an argv-prefix wrapper `dsh-sandbox-local` spawns in place of the caller's command, with the same architecture as bwrap/landlock-run/sandbox-exec. The runner creates the restricted token, spawns the wrapped argv under it with the caller's stdio passed straight through, wraps the child in a `KILL_ON_JOB_CLOSE` job, mirrors the child's exit code, and revokes its self-managed temp grant on exit. Every runner-side failure prints `windows-acl-run: <detail>` to stderr and exits 127 — the seam's runner-failure rules match that signature.
```sh
node runner.js --workspace <dir> --temp <dir> --mode <read-only|workspace-write> [--write-sid <S-1-4-…> --temp-write-sid <S-1-4-…>] -- <argv...>
node runner.js --workspace <dir> --temp <dir> --mode <read-only|workspace-write> [--writable-root <dir>]... [--write-sid <S-1-4-…> --temp-write-sid <S-1-4-…>] -- <argv...>
```
The seam materializes the deterministic workspace SID's ACE standing (once per workspace per server lifetime — the reuse cache), then creates a random private temp directory and a distinct revocable SID for each live session/workspace pair, passing both as the required `--write-sid`/`--temp-write-sid` pair; the runner verifies each against its owning path and neither grants nor revokes (`manageDacls: false`). A fork receives a different temp capability, and a fresh provider gives even the same resumed session a new path and SID, so crash residue is inert litter. Without the pair, `--temp` names a root: an agentless workspace-write runner creates a random private child, self-manages its temp SID, rewrites TMP/TEMP, and removes the child on exit. Re-granting the standing workspace ACE after a restart is idempotent: `grantWrite` reads the current DACL and skips the re-propagation when the exact ACE already stands. A workspace equal to or containing the temp root is rejected before any grant.
The seam passes extra roots as repeated `--writable-root` arguments and reuses a grant entry for the exact directory set, rechecking each root's ACE before execution so recreated directories receive their grant, then creates a random private temp directory and a distinct revocable SID for each live session/workspace pair, passing both as the required `--write-sid`/`--temp-write-sid` pair; the runner verifies the directory-set SID against all selected roots and the temp SID against its private path and neither grants nor revokes (`manageDacls: false`). A fork receives a different temp capability, and a fresh provider gives even the same resumed session a new path and SID, so crash residue is inert litter. Without the pair, `--temp` names a root: an agentless workspace-write runner creates a random private child, self-manages its temp SID, rewrites TMP/TEMP, and removes the child on exit. Re-granting the standing workspace ACE after a restart is idempotent: `grantWrite` reads the current DACL and skips the re-propagation when the exact ACE already stands. A writable root equal to or containing the temp root is rejected before any grant; private temp directories must be disjoint from every writable root.
When launched with the subprocess control marker, the runner forwards fd 7 through the restricted child's CRT startup table and closes its own copy immediately after spawn. The optional `controlFileDescriptor: 7` input requires `stdio: 'inherit'`; requesting it with piped stdio fails before process creation.
@@ -9,7 +9,7 @@ kind: "package-library"
## 概述
在 Windows 上,本包将子进程的写入与删除限制在工作区和私有临时目录内:`workspace-write` 授予这两处,`read-only` 均不授予。挂载 `dsh-sandbox-local` 后,受限的 bash 与 PowerShell 命令即获得此行为;调用方也可以直接使用公开 `AclSandbox` API;任何 Win32 操作失败都会阻止不受限制的 spawn。每次授权同时写入能力 SID 允许 ACE、对父目录删除权限的环境性拒绝,以及被降级令牌必须匹配的 Low 完整性标签,因此一个授权根目录无法触及另一个。该保证仍为部分强制:硬链接是文件对象别名,而被其他 AppContainer 工具 ACL 过的文件不可读。
在 Windows 上,本包将子进程的写入与删除限制在工作区、显式额外目录和私有临时目录内:`workspace-write` 授予这些目录,`read-only` 均不授予。挂载 `dsh-sandbox-local` 可约束 bash 与 PowerShell 命令,也可使用 `AclSandbox`;Win32 失败绝不会导致不受限制的进程启动。每次授权同时写入能力 SID 允许 ACE、对父目录删除权限的环境性拒绝,以及被降级令牌必须匹配的 Low 完整性标签,因此一个授权根目录无法触及另一个。该保证仍为部分强制:硬链接是文件对象别名,而被其他 AppContainer 工具 ACL 过的文件不可读。
## 目录
@@ -67,7 +67,7 @@ rmSync(tempDir, { recursive: true, force: true })
### 隔离给你带来什么
在 `workspace-write` 下,子进程可以写入工作区及其私有临时目录;受 ACL 管辖的其他写入与删除都会被拒绝,已记录的硬链接与 AppContainer ACL 边界除外。在 `read-only` 下不存在显式写入授权,因此写入与删除都会被拒绝,同样带有已记录的边界。
在 `workspace-write` 下,子进程可以写入工作区、显式额外根目录及其私有临时目录;受 ACL 管辖的其他写入与删除都会被拒绝,已记录的硬链接与 AppContainer ACL 边界除外。在 `read-only` 下不存在显式写入授权,因此写入与删除都会被拒绝,同样带有已记录的边界。
临时隔离按每个活跃的会话/工作区对进行:共享工作区的会话共享其写权限,但无法写入彼此的临时目录。新的提供方总会选择新的临时路径和 SID,因此崩溃残留既无法阻止恢复的会话,也无法向其授权。
@@ -97,11 +97,11 @@ rmSync(tempDir, { recursive: true, force: true })
### 机制
调用者令牌被复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 携带彼此独立的工作区与私有临时目录能力,该令牌还会被降级为 Low 完整性。Windows 执行两次访问检查——先对正常 SID,再对 restricting SID——并且只在两次检查都通过时才授予写类访问;与此同时,内核的强制完整性检查会拒绝对任何未标记为 Low 的对象进行写类访问。写 SID 交叉检查只覆盖对象自身的那次访问检查:Windows 也可以依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除,而这项权限不需要任何 restricting SID 副署,因此只带交叉检查的令牌不仅能删除其环境用户 SID 所控制的任何文件,还能删除**另一个授权根目录**内的文件——那里的 Low 标签恰好能通过完整性检查。所以每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`,使能力 ACE 的 DELETE 位成为授权根目录内唯一的删除授权来源,并在同一次 `SetNamedSecurityInfoW` 调用中把该目录标记为 Low。工作区 SID 由规范工作区路径确定性派生(`workspaceWriteSid`),因此工作区根目录的安全描述符改动每台机器每个工作区只物化一次,之后每次会话、调用或重启都命中精确 ACE/精确拒绝/精确标签跳过。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的 SID(`tempWriteSid`),因此各会话共享预期的工作区权限,却不会继承彼此的临时目录权限。每个策略专用 Win32 调用和 [`dsh-win32-process`](../../subprocess/win32-process/README.zh.md) 提供的进程原语都有检查;失败抛出携带 API 名、精确错误码、系统文本与失败上下文的 `Win32Error`——从构造上 fail-closed。
调用者令牌被复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 携带彼此独立的工作区与私有临时目录能力,该令牌还会被降级为 Low 完整性。Windows 执行两次访问检查——先对正常 SID,再对 restricting SID——并且只在两次检查都通过时才授予写类访问;与此同时,内核的强制完整性检查会拒绝对任何未标记为 Low 的对象进行写类访问。写 SID 交叉检查只覆盖对象自身的那次访问检查:Windows 也可以依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除,而这项权限不需要任何 restricting SID 副署,因此只带交叉检查的令牌不仅能删除其环境用户 SID 所控制的任何文件,还能删除**另一个授权根目录**内的文件——那里的 Low 标签恰好能通过完整性检查。所以每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`,使能力 ACE 的 DELETE 位成为授权根目录内唯一的删除授权来源,并在同一次 `SetNamedSecurityInfoW` 调用中把该目录标记为 Low。`workspaceWriteSid(workspaceRoot, additionalWritableRoots)` 对排序、去重后的规范目录集合使用无歧义的 JSON 编码求哈希;仅含工作区的集合保留现有 SID。每次执行查找或物化完全匹配该集合的常驻 ACE。增加或移除目录会为新进程选择不同的 SID,运行中的进程保留启动令牌。相同集合通过精确 ACE/精确拒绝/精确标签跳过,复用常驻安全描述符改动。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的 SID(`tempWriteSid`),因此各会话共享预期的工作区权限,却不会继承彼此的临时目录权限。每个策略专用 Win32 调用和 [`dsh-win32-process`](../../subprocess/win32-process/README.zh.md) 提供的进程原语都有检查;失败抛出携带 API 名、精确错误码、系统文本与失败上下文的 `Win32Error`——从构造上 fail-closed。
### 模式与令牌列表
`workspace-write`(登录 SID、Everyone、工作区 SID、临时 SID)为工作区与会话的私有临时子目录分别授予 Write;`read-only`(登录 SID、Everyone——不含写入 SID)不授予任何内容。两种模式都 spawn 降级为 Low 完整性的令牌,对它而言只有被标记 Low 的目录保持可写。保活组(登录 SID + Everyone)在两种模式下都存在:没有它,早期 DLL 初始化会以 `0xC0000142` 死亡、CNG 会让 pwsh 以 `0xE0434352` 崩溃。写入 SID 有意留在 read-only 列表之外:先前 workspace-write 时期留下的常驻授权 ACE 仍然失效,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容,而常驻安全描述符改动让重新升级免于重新传播。
`workspace-write`(登录 SID、Everyone、目录集合 SID、临时 SID)为选定的可写目录与会话的私有临时子目录分别授予 Write;`read-only`(登录 SID、Everyone——不含写入 SID)不授予任何内容。两种模式都 spawn 降级为 Low 完整性的令牌,对它而言只有被标记 Low 的目录保持可写。保活组(登录 SID + Everyone)在两种模式下都存在:没有它,早期 DLL 初始化会以 `0xC0000142` 死亡、CNG 会让 pwsh 以 `0xE0434352` 崩溃。写入 SID 有意留在 read-only 列表之外:先前 workspace-write 时期留下的常驻授权 ACE 仍然失效,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容,而常驻安全描述符改动让重新升级免于重新传播。
NUL 写入是环境性的、不是被授权的:设备 DACL 授予 Everyone 读+写+执行(`0x1201BF`),因此访问掩码落在其内的打开者(cmd 的 `> NUL`、node 的 `\\.\NUL`)在两种模式下都能写。`Set-Content NUL` 在两种模式下都失败(PowerShell/.NET 层效应,非设备 DACL 所致),而 PowerShell 的 `> $null` 重定向不受影响。
@@ -112,10 +112,10 @@ Authenticated Users 在两种列表中都不存在——WMI 命名空间安全
面向 seam 的形态是 runner 入口(`./runner`):`dsh-sandbox-local` 在调用者命令的位置 spawn 的 argv 前缀包装——与 bwrap/landlock-run/sandbox-exec 同一架构。runner 创建受限令牌,在它之下 spawn 包装后的 argv,调用者的 stdio 直接透传,把子进程包进 `KILL_ON_JOB_CLOSE` job,镜像子进程的退出码,并在退出时撤销其自行管理的临时授权。每个 runner 侧失败都会向 stderr 打印 `windows-acl-run: <detail>` 并以 127 退出——seam 的 runner 失败规则匹配该签名。
```sh
node runner.js --workspace <dir> --temp <dir> --mode <read-only|workspace-write> [--write-sid <S-1-4-…> --temp-write-sid <S-1-4-…>] -- <argv...>
node runner.js --workspace <dir> --temp <dir> --mode <read-only|workspace-write> [--writable-root <dir>]... [--write-sid <S-1-4-…> --temp-write-sid <S-1-4-…>] -- <argv...>
```
seam 先把确定性工作区 SID 的 ACE 常驻物化(每个工作区每服务器生命周期一次——复用缓存),再为每个活跃的会话/工作区对创建随机私有临时目录和不同的可回收 SID,把两种身份作为必须成对出现的 `--write-sid`/`--temp-write-sid` 传入;runner 对照各自所属路径验证二者,既不授权也不撤销(`manageDacls: false`)。fork 获得不同的临时能力;即使恢复的是同一会话,新的提供方也会给出新的路径和 SID,因此崩溃残留只是失效垃圾。如果不带这一对标志,`--temp` 指定的是根目录:无 agent(智能体)/独立的 workspace-write runner 会创建随机私有子目录,自行管理其临时 SID,重写 TMP/TEMP,并在退出时移除该子目录。重启后重新授权常驻工作区 ACE 是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过重新传播。工作区若等于或包含临时根目录,会在任何授权前被拒绝。
seam 通过重复的 `--writable-root` 参数传入额外根目录,并为完全相同的目录集合复用授权项,每次执行前重新检查各根目录的 ACE,使重新创建的目录也获得授权,再为每个活跃的会话/工作区对创建随机私有临时目录和不同的可回收 SID,把两种身份作为必须成对出现的 `--write-sid`/`--temp-write-sid` 传入;runner 根据全部选定根目录验证目录集合 SID,并根据私有路径验证临时 SID,既不授权也不撤销(`manageDacls: false`)。fork 获得不同的临时能力;即使恢复的是同一会话,新的提供方也会给出新的路径和 SID,因此崩溃残留只是失效垃圾。如果不带这一对标志,`--temp` 指定的是根目录:无 agent(智能体)/独立的 workspace-write runner 会创建随机私有子目录,自行管理其临时 SID,重写 TMP/TEMP,并在退出时移除该子目录。重启后重新授权常驻工作区 ACE 是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过重新传播。可写根目录若等于或包含临时根目录,会在任何授权前被拒绝;私有临时目录必须与每个可写根目录互不重叠。
启动时若带有 subprocess 控制标记,runner 会通过受限子进程的 CRT 启动表转发 fd 7,并在 spawn 后立即关闭自身副本。可选的 `controlFileDescriptor: 7` 输入要求 `stdio: 'inherit'`;在管道 stdio 下请求它会在进程创建前失败。
@@ -1,7 +1,7 @@
/**
* Server-side write-grant materialization. The sandbox seam holds one
* standing workspace grant per workspace and one revocable temp grant per
* live session/workspace pair. Workspace identities survive by deterministic
* standing grant per writable-directory set and one revocable temp grant per
* live session/workspace pair. Directory-set identities survive by deterministic
* derivation and their standing ACE; temp identities derive from random
* private paths and are deliberately new after a restart.
*
@@ -91,7 +91,8 @@ export class AclWriteGrant {
* Grant the write ACE, the ambient-delete deny, and the Low mandatory label
* on one directory (idempotent: an already-standing exact ACE, deny, and
* label skip the eager full-tree re-propagation — see {@link grantWrite})
* and record the path for {@link dispose} unless it is standing. The path is
* and record the path once for {@link dispose} unless it is standing. Repeated
* calls recheck the ACE so a recreated directory receives its grant. The path is
* recorded BEFORE the grant: a post-apply throw (a LocalFree failure after
* SetNamedSecurityInfoW succeeded) must still revoke it, and revoking an
* ungranted path is a no-op merge. Callers treat a throw as a failed
@@ -103,7 +104,8 @@ export class AclWriteGrant {
* dispose — the temp-directory lifecycle).
*/
add(path: string, standing = false): void {
;(standing ? this.standingPaths : this.revocablePaths).push(path)
const paths = standing ? this.standingPaths : this.revocablePaths
if (!paths.includes(path)) paths.push(path)
grantWrite(this.api, path, this.sidPtr, this.lowLabelSidPtr, this.worldSidPtr)
}
@@ -13,13 +13,11 @@
* for the complete boundary). The intersection covers only the object's own
* access check, so the token is also lowered to Low integrity and every
* granted directory carries a Low no-write-up label and the ambient-delete
* deny the `acl` module documents. The write SID is the per-WORKSPACE identity
* ({@link workspaceWriteSid}): deterministic from the canonical workspace
* path, so the workspace-root ACE materializes once per workspace per
* machine and every later provision hits the exact-ACE skip — the
* grant-reuse story the per-session random SID paid a full tree propagation
* per session for. Each private temp directory instead receives its own SID,
* so sibling sessions sharing a workspace cannot enter one another's temp
* deny the `acl` module documents. The write SID identifies the complete
* canonical writable-directory set ({@link workspaceWriteSid}). Standing
* ACEs are reusable across calls and sessions with that exact set. Each
* private temp directory instead receives its own SID, so sibling sessions
* cannot use a shared directory-set capability to enter one another's temp
* trees. Unlike the POC, every API failure throws with the API
* name and exact Win32 code; a child is NEVER spawned unrestricted.
*
@@ -56,7 +54,7 @@ import { createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProce
import * as abi from './win32-abi.ts'
export { AclWriteGrant } from './grant.ts'
export { assertTempRootOutsideWorkspace } from './path-boundary.ts'
export { assertPrivateTempDisjoint, assertTempRootOutsideWorkspace } from './path-boundary.ts'
export { tempWriteSid, workspaceWriteSid } from './workspace-sid.ts'
export { ACL_DIAGNOSIS_SKILL, registerAclDiagnosisSkill } from './acl-skill.ts'
/** Construction options: the workspace/temp allowlists and their distinct SID identities. */
@@ -72,9 +70,9 @@ export interface AclSandboxOptions {
/**
* The write SID forming the workspace-write allowlist: REQUIRED under
* workspace-write, ignored (and must be absent) under read-only. Callers
* derive it from the workspace via {@link workspaceWriteSid} — the identity
* is per workspace, not per sandbox instance, so the workspace-root ACE
* outlives every instance and later provisions hit the exact-ACE skip.
* derive it from the complete canonical writable-directory set via
* {@link workspaceWriteSid}. Standing ACEs outlive sandbox instances and
* are reusable by later executions carrying that exact set identity.
*/
writeSid?: string
/**
@@ -163,7 +161,7 @@ function freeSidBestEffort(
export class AclSandbox {
/** Absolute writable directories (constructor-validated). */
readonly writableDirs: string[]
/** The workspace SID string whose ACEs form the workspace allowlist. */
/** The directory-set SID whose ACEs form the writable allowlist. */
readonly writeSid: string | undefined
/** The private temp directory's write SID (workspace-write with temp only). */
readonly tempWriteSid: string | undefined
@@ -194,7 +192,7 @@ export class AclSandbox {
this.writeSid = options.writeSid
this.tempWriteSid = options.tempWriteSid
if (this.mode === 'workspace-write' && this.writeSid === undefined) {
throw new Error('AclSandbox workspace-write requires a write SID — derive it from the workspace via workspaceWriteSid()')
throw new Error('AclSandbox workspace-write requires a write SID — derive it from the writable directories via workspaceWriteSid()')
}
if (this.mode === 'workspace-write' && this.tempDirOption === undefined) {
throw new Error('AclSandbox workspace-write requires an explicit private temp directory or null')
@@ -256,7 +254,7 @@ export class AclSandbox {
// manageDacls: false — the caller (the sandbox seam's grant) already
// materialized the ACEs; this instance must neither add nor remove any.
// When this instance owns the DACLs, writableDir ACEs are STANDING (the
// per-workspace reuse cache — dispose() never revokes them, or the next
// directory-set reuse cache — dispose() never revokes them, or the next
// provision would re-propagate the whole tree) and the temp ACE is
// REVOCABLE (dispose() removes it before the private directory is
// deleted; the ambient temp root is never granted).
@@ -1,7 +1,7 @@
/**
* The windows-acl confinement runner: the argv-prefix wrapper the sandbox
* seam spawns in place of the caller's command. It creates the
* WRITE_RESTRICTED token with the workspace write-SID allowlist, spawns the
* WRITE_RESTRICTED token with the directory-set write-SID allowlist, spawns the
* wrapped argv under it with the CALLER'S stdio inherited (bytes flow
* straight through), mirrors the child's exit code, and revokes its temp
* grant on exit (workspace ACEs stay standing as the reuse cache).
@@ -9,12 +9,12 @@
* Stable argv contract (the seam builds it; a native-exe replacement would
* keep the same contract):
* [node, runner.js, '--workspace', <dir>, '--temp', <dir>,
* '--mode', <read-only|workspace-write>,
* '--mode', <read-only|workspace-write>, ['--writable-root', <dir>]...,
* ['--write-sid', <S-1-4-…>,
* '--temp-write-sid', <S-1-4-…>], '--', <argv...>]
*
* Modes:
* - workspace-write: the workspace and temp directories carry distinct
* - workspace-write: the writable-directory set and private temp carry distinct
* capability-SID Write grants; other ACL-addressable writes are denied
* except for the documented Everyone and hard-link boundaries.
* - read-only: no capability-SID grants; the restricting list carries no
@@ -27,7 +27,7 @@
* `--write-sid` + `--temp-write-sid`: the seam's grant contract — the
* CALLER has already materialized distinct workspace and private-temp ACEs
* and owns their revocation, so the runner neither grants nor revokes
* (`manageDacls: false`). Both values are checked against their owning paths.
* (`manageDacls: false`). Both values are checked against their owning directory set or private path.
* Without the pair (standalone/agentless use), workspace-write treats
* `--temp` as a ROOT, creates a random private child directory, derives its
* own temp SID, and removes that directory after the child exits. In both
@@ -67,6 +67,7 @@ interface ParsedArgs {
workspace: string
temp: string
mode: 'read-only' | 'workspace-write'
writableRoots: string[]
writeSid: string | undefined
tempWriteSid: string | undefined
command: string
@@ -74,6 +75,7 @@ interface ParsedArgs {
}
function parseArgs(raw: string[]): ParsedArgs {
const writableRoots: string[] = []
let workspace: string | undefined
let temp: string | undefined
let mode: string | undefined
@@ -91,6 +93,7 @@ function parseArgs(raw: string[]): ParsedArgs {
if (value === undefined) fail(`missing value after ${token}`)
switch (token) {
case '--workspace': workspace = value; break
case '--writable-root': writableRoots.push(value); break
case '--temp': temp = value; break
case '--mode': mode = value; break
case '--write-sid': writeSid = value; break
@@ -104,7 +107,7 @@ function parseArgs(raw: string[]): ParsedArgs {
const argv = raw.slice(index)
const command = argv[0]
if (command === undefined) fail('missing command after --')
return { workspace, temp, mode, writeSid, tempWriteSid: parsedTempWriteSid, command, args: argv.slice(1) }
return { workspace, temp, mode, writableRoots, writeSid, tempWriteSid: parsedTempWriteSid, command, args: argv.slice(1) }
}
function requireDirectory(label: string, path: string): void {
@@ -119,8 +122,13 @@ async function main(): Promise<number> {
// a bogus root must fail loudly at the runner boundary, never mid-child.
requireDirectory('--workspace', parsed.workspace)
requireDirectory('--temp', parsed.temp)
for (const root of parsed.writableRoots) requireDirectory('--writable-root', root)
const writableDirs = [...new Set([parsed.workspace, ...parsed.writableRoots])]
const seamManaged = parsed.writeSid !== undefined || parsed.tempWriteSid !== undefined
if (parsed.mode === 'read-only' && parsed.writableRoots.length > 0) {
fail('read-only does not accept --writable-root')
}
if (parsed.mode === 'read-only' && seamManaged) {
fail('read-only does not accept --write-sid or --temp-write-sid')
}
@@ -128,7 +136,7 @@ async function main(): Promise<number> {
fail('workspace-write requires --write-sid and --temp-write-sid together')
}
if (parsed.mode === 'workspace-write') {
assertTempRootOutsideWorkspace(parsed.workspace, parsed.temp)
for (const root of writableDirs) assertTempRootOutsideWorkspace(root, parsed.temp)
}
const api = await win32()
@@ -147,9 +155,9 @@ async function main(): Promise<number> {
let writeSid: string | undefined
let privateTempSid: string | undefined
if (parsed.mode === 'workspace-write') {
writeSid = workspaceWriteSid(parsed.workspace)
writeSid = workspaceWriteSid(parsed.workspace, parsed.writableRoots)
if (seamManaged) {
if (parsed.writeSid !== writeSid) fail('--write-sid does not match --workspace')
if (parsed.writeSid !== writeSid) fail('--write-sid does not match writable directories')
privateTempDir = parsed.temp
privateTempSid = tempWriteSid(privateTempDir)
if (parsed.tempWriteSid !== privateTempSid) fail('--temp-write-sid does not match --temp')
@@ -160,7 +168,7 @@ async function main(): Promise<number> {
}
}
sandbox = new AclSandbox({
writableDirs: parsed.mode === 'workspace-write' ? [parsed.workspace] : [],
writableDirs: parsed.mode === 'workspace-write' ? writableDirs : [],
tempDir: privateTempDir,
mode: parsed.mode,
...writeSid === undefined ? {} : { writeSid },
@@ -1,39 +1,24 @@
/**
* The per-workspace write identity: a deterministic `S-1-4-x-y` SID derived
* from the canonical workspace path, whose ACEs form that workspace's write
* allowlist. Every confined execution of the same workspace — across
* sessions, server restarts, and calls — carries the SAME write SID, so the
* workspace-root ACE materializes once per workspace per machine (the
* grant's exact-ACE skip then makes every later provision O(1)) instead of
* once per session. The SID's power is defined solely by the ACEs that name
* it (which exist only on the workspace tree and the session's private temp
* directory), and only tokens minted for that workspace carry it — the SID
* string itself is not a secret. Temporary directories use a separate,
* per-directory identity from {@link tempWriteSid}; sharing the workspace
* identity with temp would let sibling sessions write one another's temp
* trees.
*
* The input MUST be the canonical workspace path (`realpathSync.native` on
* Windows — the sandbox-policy `resolveWorkspaceRoot` already applies it):
* canonicalization converges case/alias spellings, so two spellings of one
* workspace derive one SID; an as-spelled fallback path would mint a second
* identity for the same directory (self-healing, at the cost of one extra
* tree propagation). Renaming the workspace directory derives a new SID —
* the old standing ACEs are inert residue, and the next session re-propagates
* once.
* Deterministic write identities for canonical writable-directory sets and
* separate identities for private temp directories. Standing directory ACEs
* are reusable only by executions carrying the same complete set identity.
* @module @deepseek-ai/dsh-sandbox-windows-acl/workspace-sid
*/
import { createHash } from 'node:crypto'
/**
* Derive the workspace's write SID (`S-1-4-x-y`; subauthorities 30-bit,
* matching the workspace-capability shape the token and ACE layers carry).
* Derive the write SID for a workspace and its additional writable roots.
* Order and duplicate entries do not affect the identity. A workspace-only
* set retains its existing SID; larger sets use unambiguous JSON encoding.
* @param workspaceRoot - the canonical workspace path.
* @returns the SDDL string form.
* @param additionalWritableRoots - canonical extra directories; defaults to none.
* @returns the deterministic SDDL string for the complete directory set.
*/
export function workspaceWriteSid(workspaceRoot: string): string {
const digest = createHash('sha256').update(workspaceRoot, 'utf8').digest()
export function workspaceWriteSid(workspaceRoot: string, additionalWritableRoots: readonly string[] = []): string {
const roots = [...new Set([workspaceRoot, ...additionalWritableRoots])].sort()
const identity = roots.length === 1 ? workspaceRoot : JSON.stringify(roots)
const digest = createHash('sha256').update(identity, 'utf8').digest()
const first = (digest.readUInt32LE(0) % (2 ** 30 - 1)) + 1
const second = (digest.readUInt32LE(4) % (2 ** 30 - 1)) + 1
return `S-1-4-${first}-${second}`
@@ -8,7 +8,7 @@
*/
import { spawnSync } from 'node:child_process'
import { mkdtempSync, rmSync } from 'node:fs'
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
@@ -53,6 +53,7 @@ describe.skipIf(!isWin32)('AclWriteGrant (server-side materialization)', () => {
// grant stays exactly one ACE (the reuse across sessions/restarts).
grant.add(dir)
grant.add(standingDir, true)
expect(grant.paths).toEqual([standingDir, dir])
expect(icaclsText(dir)).toContain('S-1-4-9000-77')
expect(icaclsText(standingDir)).toContain('S-1-4-9000-77')
grant.dispose()
@@ -60,6 +61,20 @@ describe.skipIf(!isWin32)('AclWriteGrant (server-side materialization)', () => {
expect(icaclsText(standingDir)).toContain('S-1-4-9000-77')
})
it('restores a standing ACE on a recreated directory without duplicating its tracked path', () => {
const dir = scratch()
const grant = AclWriteGrant.create('S-1-4-9000-80')
try {
grant.add(dir, true)
rmSync(dir, { recursive: true })
mkdirSync(dir)
expect(icaclsText(dir)).not.toContain(grant.writeSid)
grant.add(dir, true)
expect(icaclsText(dir)).toContain(grant.writeSid)
expect(grant.paths).toEqual([dir])
} finally { grant.dispose() }
})
it('two grants with different SIDs coexist and revoke independently', () => {
const dir = scratch()
const grantA = AclWriteGrant.create('S-1-4-9000-78')
@@ -107,6 +107,62 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
expect(existsSync(join(writableDir, 'child-wrote.txt'))).toBe(true)
}, 30_000)
it('new processes use the exact writable set despite standing grants from a broader set', () => {
const extra = mkdtempSync(join(scratchRoot, 'extra-set-'))
const workspaceFile = join(writableDir, 'set-workspace.txt')
const extraFile = join(extra, 'set-extra.txt')
const probe = "const fs = require('node:fs'); for (const path of process.argv.slice(1)) { try { fs.writeFileSync(path, 'written'); console.log('ALLOW ' + path) } catch { console.log('DENY ' + path) } }"
const expanded = runRunner([
'--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write',
'--writable-root', extra,
'--', process.execPath, '-e', probe, workspaceFile, extraFile,
])
expect(expanded.error).toBeUndefined()
expect(expanded.signal).toBeNull()
expect(expanded.status, expanded.stderr).toBe(0)
expect(readFileSync(extraFile, 'utf8')).toBe('written')
writeFileSync(extraFile, 'retained')
const narrowed = runRunner([
'--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write',
'--', process.execPath, '-e', probe, workspaceFile, extraFile,
])
expect(narrowed.error).toBeUndefined()
expect(narrowed.signal).toBeNull()
expect(narrowed.status, narrowed.stderr).toBe(0)
expect(narrowed.stdout).toContain(`ALLOW ${workspaceFile}`)
expect(narrowed.stdout).toContain(`DENY ${extraFile}`)
expect(readFileSync(extraFile, 'utf8')).toBe('retained')
}, 90_000)
it('accepts a provider-materialized SID covering the workspace and additional directories', () => {
const extra = mkdtempSync(join(scratchRoot, 'managed-extra-'))
const privateTemp = mkdtempSync(join(isolatedTemp, 'managed-set-'))
const writeSid = workspaceWriteSid(writableDir, [extra])
const privateTempSid = tempWriteSid(privateTemp)
const directoryGrant = AclWriteGrant.create(writeSid)
const tempGrant = AclWriteGrant.create(privateTempSid)
try {
directoryGrant.add(writableDir, true)
directoryGrant.add(extra, true)
tempGrant.add(privateTemp)
const target = join(extra, 'managed-write.txt')
const result = runRunner([
'--workspace', writableDir, '--temp', privateTemp, '--mode', 'workspace-write',
'--writable-root', extra, '--write-sid', writeSid, '--temp-write-sid', privateTempSid,
'--', process.execPath, '-e', "require('node:fs').writeFileSync(process.argv[1], 'managed')", target,
])
expect(result.error).toBeUndefined()
expect(result.signal).toBeNull()
expect(result.status, result.stderr).toBe(0)
expect(readFileSync(target, 'utf8')).toBe('managed')
} finally {
directoryGrant.dispose()
tempGrant.dispose()
rmSync(privateTemp, { recursive: true, force: true })
}
}, 90_000)
it('read-only: no write-SID grants — workspace/temp writes denied, reads and $null redirection fine, CIM availability follows the caller token', () => {
const probe = [
"$ErrorActionPreference='SilentlyContinue';",
@@ -642,11 +698,24 @@ TryOpen 'DIRECTORY' '${child}'
expect(result.stderr).toContain('windows-acl-run: ')
}, 15_000)
it('read-only rejects writable-directory flags before starting the command', () => {
const result = runRunner([
'--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'read-only',
'--writable-root', worldWritableDir,
'--', process.execPath, '-e', 'process.exit(99)',
])
expect(result.error).toBeUndefined()
expect(result.signal).toBeNull()
expect(result.status, result.stderr).toBe(127)
expect(result.stderr).toContain('read-only does not accept --writable-root')
}, 90_000)
it('runner-side failure: seam-managed SID flags must be paired and match their owning paths', () => {
const writeSid = workspaceWriteSid(writableDir)
const tempSid = tempWriteSid(isolatedTemp)
const cases = [
['--write-sid', writeSid],
['--writable-root', worldWritableDir, '--write-sid', writeSid, '--temp-write-sid', tempSid],
['--write-sid', 'S-1-4-1-2', '--temp-write-sid', tempSid],
['--write-sid', writeSid, '--temp-write-sid', 'S-1-4-1-2-1'],
]
@@ -19,6 +19,23 @@ describe('workspaceWriteSid', () => {
expect(first).toMatch(/^S-1-4-\d+-\d+$/u)
})
it('uses the same identity for reordered and duplicated writable-directory sets', () => {
const workspace = 'C:\\repo'
const memory = 'C:\\memory'
const cache = 'C:\\cache'
const sid = workspaceWriteSid(workspace, [memory, cache])
expect(workspaceWriteSid(workspace, [cache, workspace, memory, cache])).toBe(sid)
expect(workspaceWriteSid(memory, [workspace, cache])).toBe(sid)
expect(workspaceWriteSid(workspace, [workspace])).toBe(workspaceWriteSid(workspace))
expect(sid).not.toBe(workspaceWriteSid(workspace))
expect(sid).not.toBe(workspaceWriteSid(workspace, [memory]))
})
it('keeps path boundaries distinct when their concatenated text matches', () => {
expect(workspaceWriteSid('C:\\a', ['C:\\bC:\\c']))
.not.toBe(workspaceWriteSid('C:\\aC:\\b', ['C:\\c']))
})
it('derives distinct identities for distinct workspaces', () => {
expect(workspaceWriteSid('C:\\Users\\agent\\repo-a')).not.toBe(workspaceWriteSid('C:\\Users\\agent\\repo-b'))
})
+1 -1
View File
@@ -53,7 +53,7 @@ export interface SandboxExecutionPolicy {
* Opaque identity of the calling session (the branded `dsh-session`
* SessionId). Backends key per-session state off it (e.g. windows-acl gives
* each live session/workspace pair a random private temp directory and SID,
* while the workspace SID and standing grant remain per-workspace); absent
* while standing grants are keyed by the complete writable-directory set); absent
* for agentless calls, which fall back to per-call backend state.
*/
sessionId?: SessionId