From d5ad3baeb5d531e31da5e586c3ec3ebedd825169 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 19 Sep 2026 01:38:29 +0800 Subject: [PATCH 1/9] fix(sandbox): confine Windows deletes with a Low integrity label The write-restricted token's capability-SID intersection only covers the write bits of the object's own access check. Windows also authorizes a write or a delete from the parent directory's FILE_DELETE_CHILD right, and no restricting SID has to co-sign that authority: the confined child could therefore delete any file its ambient user SIDs control. `cmd /c del` outside the workspace succeeded under BOTH modes, and .NET File.Delete/DeleteFileW did too wherever the ambient DACL held Modify. Lower the restricted token to Low integrity and apply a Low no-write-up mandatory label to every granted directory in the same SetNamedSecurityInfoW call that adds its capability ACE. The kernel evaluates the integrity policy inside the access check whichever right supplied the authority, so the granted roots stay writable while everything else becomes delete-proof as well as write-proof. The label inherits to new children and follows the existing standing/revocable grant lifecycle. Also closes the Everyone-Modify ambient write boundary, and pins the delete paths (cmd, .NET, Remove-Item, libuv) in runner.spec.ts. --- ...ows-acl-restricted-token-sandbox.i18n.yaml | 4 +- ...08-windows-acl-restricted-token-sandbox.md | 6 +- ...windows-acl-restricted-token-sandbox.zh.md | 6 +- .../sandbox-windows-acl/README.i18n.yaml | 4 +- .../sandbox/sandbox-windows-acl/README.md | 29 +- .../sandbox/sandbox-windows-acl/README.zh.md | 25 +- .../sandbox/sandbox-windows-acl/src/acl.ts | 154 +++++-- .../sandbox/sandbox-windows-acl/src/ffi.ts | 6 +- .../sandbox/sandbox-windows-acl/src/grant.ts | 59 +-- .../sandbox/sandbox-windows-acl/src/index.ts | 27 +- .../sandbox/sandbox-windows-acl/src/token.ts | 29 ++ .../sandbox-windows-acl/src/win32-abi.ts | 27 ++ .../tests/acl-failure-paths.spec.ts | 379 ++++++++++++------ .../sandbox-windows-acl/tests/acl.spec.ts | 93 ++++- .../tests/grant-failure-paths.spec.ts | 24 +- .../tests/index-failure-paths.spec.ts | 17 +- .../sandbox-windows-acl/tests/runner.spec.ts | 73 +++- .../tests/token-failure-paths.spec.ts | 37 +- 18 files changed, 753 insertions(+), 246 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml index 596033c717..aa10470b1a 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md -2026-08-08-windows-acl-restricted-token-sandbox.md: 9834e9b559f0a8045b0d36dd2dcfba864238996b -2026-08-08-windows-acl-restricted-token-sandbox.zh.md: d349b504ca6a1054237d5ca6aa61547c2a0dcdad +2026-08-08-windows-acl-restricted-token-sandbox.md: 1218d39271908cd09440646608088deb3ca64ed4 +2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 8b78fbe7132064f81a1f12f2c3a4e177b13337a1 diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md index 9834e9b559..1218d39271 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md @@ -10,7 +10,7 @@ The original [sandbox decision](2026-07-06-sandbox.md) left `PLATFORM_CHAINS.win ## 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`) 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. 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). Because Everyone remains, an external object granting Everyone write access clears both checks; because NTFS ACLs belong to file objects, a granted workspace hard link also grants an external alias. 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.md) as the confining executor. +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`) 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. 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.md) as the confining executor. ## How the restriction works (why no new identity) @@ -32,11 +32,11 @@ 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), reads/network/process visibility untouched exactly as the mode vocabulary requires, 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 Everyone-granted writes and NTFS hard-link aliases cannot be path-confined by this token shape; 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](../bug-fix/2026-09-16-windows-subprocess-console-visibility.md) preserves console inheritance); standing workspace ACE 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 still writable; NULL-DACL directories not identity-preserving under a grant/revoke round trip; `whoami` and token-inspection cmdlets failing under the restricted token; 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 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](../bug-fix/2026-09-16-windows-subprocess-console-visibility.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. ## Testing -The product-visible Windows roster flip is win32-only, so keyless snapshots that must replay on macOS/Linux cannot cover it; bundle composition specs plus the win32 real-runner suites are the substitute evidence, and the CI Windows lane owns the assembled signal. `sandbox-local/tests/acl-grants.spec.ts` pins random temp allocation, per-session/workspace reuse, fork/workspace separation, crash-resume non-collision, paired argv SIDs, failure cleanup, and standing-versus-revocable lifecycle with Win32 mocked. On Windows, `workspace-sid.spec.ts` pins workspace/temp derivation and domain separation; `acl.spec.ts` pins real DACL lifecycle; and `runner.spec.ts` pins paired-SID validation, sibling temp denial under a shared workspace SID, per-call agentless temp creation/removal, TMP/TEMP rewriting, mode downgrade, Public denial, Everyone/hard-link partial boundaries, mode-specific PowerShell language behavior, and grandchild stdio. ARM64 and emulated x64 native runs own the architecture-specific acceptance evidence. +The product-visible Windows roster flip is win32-only, so keyless snapshots that must replay on macOS/Linux cannot cover it; bundle composition specs plus the win32 real-runner suites are the substitute evidence, and the CI Windows lane owns the assembled signal. `sandbox-local/tests/acl-grants.spec.ts` pins random temp allocation, per-session/workspace reuse, fork/workspace separation, crash-resume non-collision, paired argv SIDs, failure cleanup, and standing-versus-revocable lifecycle with Win32 mocked. On Windows, `workspace-sid.spec.ts` pins workspace/temp derivation and domain separation; `acl.spec.ts` pins the real DACL and mandatory-label lifecycle; and `runner.spec.ts` pins the Low integrity level, paired-SID validation, sibling temp denial under a shared workspace SID, the outside-delete regression through `cmd`, .NET, `Remove-Item`, and libuv, per-call agentless temp creation/removal, TMP/TEMP rewriting, mode downgrade, Public and Everyone denial, the hard-link partial boundary, mode-specific PowerShell language behavior, and grandchild stdio. ARM64 and emulated x64 native runs own the architecture-specific acceptance evidence. ## Related diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md index d349b504ca..8b78fbe713 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 携带彼此独立的工作区能力与私有临时目录能力。`WRITE_RESTRICTED` 只对写访问做交集检查,因此读取保留调用者的环境访问,而写入还必须匹配这些能力 ACE 之一。该机制来自 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 仍在列表中,向 Everyone 授予写访问的外部对象会通过两次检查;由于 NTFS ACL 属于文件对象,工作区内获授权的硬链接也会使同一对象的外部别名获得授权。拒绝所有硬链接会让普通 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 禁止上调强制标签;无论权限来自哪一项,内核都会在访问检查内部执行该策略,于是被授权根目录保持可写,而其他一切不仅防写、也防删。该机制来自 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) 交付。 ## How the restriction works (why no new identity) @@ -32,11 +32,11 @@ landstrip 评估在实现前已被否决(未经实战检验;自建 launcher ## 后果 -所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致),且 fail-closed 错误携带 API 名与精确 Win32 错误码。会话共享有意常驻的工作区能力,但不共享各自可回收的临时能力;重启残留既不能阻塞恢复的会话,也不能向其授权。所失:强制执行在结构上只能是部分的,因为此令牌形态无法把 Everyone 授予的写入与 NTFS 硬链接别名限制在路径边界内;无读侧或网络隔离;控制台隔离不可用(通过 `CREATE_NO_WINDOW` 或 `CREATE_NEW_CONSOLE` 创建的子进程以 `STATUS_DLL_INIT_FAILED` 死亡;[启动可见性](../bug-fix/2026-09-16-windows-subprocess-console-visibility.zh.md)保留控制台继承);工作区常驻 ACE 改动(复用缓存,以及工作区改名后的失效残留)与异常关闭后遗留的随机临时目录垃圾,直到 OS 卫生机制将其回收;工作区授权采用急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——大型工作区上耗时数十秒),每台机器每个工作区只付一次;CIM 在两种受限模式下均不可用(Authenticated Users 不存在,从而关闭 C:\-root 建树逃逸);FAT 类无 ACL 目标仍可写;NULL-DACL 目录在 grant/revoke 往返下不保持身份;`whoami` 与令牌检查 cmdlet 在受限令牌下失败;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` 死亡;[启动可见性](../bug-fix/2026-09-16-windows-subprocess-console-visibility.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 负责记录这些运行限制。 ## 测试 -产品可见的 Windows 阵容切换仅存在于 win32,而必须在 macOS/Linux 上可重放的 keyless 快照无法覆盖它;替代证据是 bundle 组合 spec 加上 win32 真实 runner 套件,组装态信号由 CI 的 Windows lane 负责。`sandbox-local/tests/acl-grants.spec.ts` 在 mock Win32 的情况下钉住随机临时目录分配、按会话/工作区复用、fork/工作区分离、崩溃后恢复不冲突、成对 argv SID、失败清理,以及常驻/可回收生命周期。在 Windows 上,`workspace-sid.spec.ts` 钉住工作区/临时目录派生与域分离;`acl.spec.ts` 钉住真实 DACL 生命周期;`runner.spec.ts` 钉住成对 SID 验证、共享工作区 SID 时对同级会话临时目录的拒绝、无 agent 调用的逐调用临时目录创建/移除、TMP/TEMP 重写、模式降级、Public 拒绝、Everyone/硬链接部分边界、按模式区分的 PowerShell 语言行为与孙进程 stdio。ARM64 与模拟 x64 原生运行负责提供架构特定的验收证据。 +产品可见的 Windows 阵容切换仅存在于 win32,而必须在 macOS/Linux 上可重放的 keyless 快照无法覆盖它;替代证据是 bundle 组合 spec 加上 win32 真实 runner 套件,组装态信号由 CI 的 Windows lane 负责。`sandbox-local/tests/acl-grants.spec.ts` 在 mock Win32 的情况下钉住随机临时目录分配、按会话/工作区复用、fork/工作区分离、崩溃后恢复不冲突、成对 argv SID、失败清理,以及常驻/可回收生命周期。在 Windows 上,`workspace-sid.spec.ts` 钉住工作区/临时目录派生与域分离;`acl.spec.ts` 钉住真实 DACL 与强制标签生命周期;`runner.spec.ts` 钉住 Low 完整性级别、成对 SID 验证、共享工作区 SID 时对同级会话临时目录的拒绝、经 `cmd`、.NET、`Remove-Item` 与 libuv 触发的越界删除回归、无 agent 调用的逐调用临时目录创建/移除、TMP/TEMP 重写、模式降级、Public 与 Everyone 拒绝、硬链接部分边界、按模式区分的 PowerShell 语言行为与孙进程 stdio。ARM64 与模拟 x64 原生运行负责提供架构特定的验收证据。 ## Related diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index ed39d664a1..e197dfeeef 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sandbox/sandbox-windows-acl/README.md -README.md: 30eaea27b9f8a0ebd1691348c9e6f87706891866 -README.zh.md: eeb65e5db1044eb75e0c13c5f3c76667432dc485 +README.md: 92e08b922717f1f548350c2aac26015ae23a250f +README.zh.md: 9aa2bcdf81f1c6df4a99b7590246b14d134c0d93 diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index 30eaea27b9..92e08b9227 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -On Windows, this package confines child-process writes to the workspace and a private temporary directory. `workspace-write` grants both locations, while `read-only` grants neither. Mounting `dsh-sandbox-local` selects this behavior automatically for confined bash and PowerShell commands, or callers can use the public `AclSandbox` API directly with captured standard streams. Any failed Win32 operation prevents the child from starting unrestricted. The guarantee is intentionally partial because process startup retains Everyone access and NTFS hard links can expose the same file through another path; callers can detect this limitation through the reported `partial` enforcement level. +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. Two layers enforce the boundary, the write-restricted token's capability-SID intersection and a Low integrity label on the granted directories, but the guarantee stays partial: process startup retains Everyone access, hard links alias file objects, and files ACL'd by another AppContainer tool stay unreadable. ## Table of Contents @@ -59,15 +59,15 @@ await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricte const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot }) const { stdout, stderr, exitCode } = await child.wait() -sandbox.dispose() // revokes the revocable (temp) grant, keeps the standing workspace ACE; reports every cleanup failure +sandbox.dispose() // revokes the revocable (temp) grant and label, keeps the standing workspace pair rmSync(tempDir, { recursive: true, force: true }) ``` -The workspace ACEs are granted standing — `dispose()` leaves them, because they are the cross-instance reuse cache — while the distinct temp SID is granted revocably. The server-side counterpart is the `AclWriteGrant` class: `add(path, standing)` per directory, and `dispose()` revokes the revocable paths and frees the SID. +The workspace security descriptor edits are granted standing — `dispose()` leaves them, because they are the cross-instance reuse cache — while the distinct temp SID is granted revocably. Each grant is one call carrying both the capability-SID allow ACE and the Low no-write-up label. The server-side counterpart is the `AclWriteGrant` class: `add(path, standing)` per directory, and `dispose()` revokes the revocable paths and frees the SIDs. ### What confinement gives you -Under `workspace-write`, the child may write into the workspace and its private temp directory; other ACL-addressable writes are denied except the documented Everyone and hard-link boundaries. Under `read-only`, no explicit write grants exist, so writes are denied with the same documented ambient boundaries. +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. 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. @@ -87,11 +87,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. 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. The workspace SID is derived deterministically from the canonical workspace path (`workspaceWriteSid`), so the workspace-root ACE materializes once per workspace per machine and every later session, call, or restart hits the exact-ACE 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. The integrity label closes that route, because the mandatory check runs inside the access check whichever right supplied the authority, and every granted directory is labeled Low in the same call that adds its ACE. 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-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. 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 ACE keeps a re-upgrade free of re-propagation. +`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. 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. @@ -111,15 +111,15 @@ When launched with the subprocess control marker, the runner forwards fd 7 throu ### Verified boundaries -- **Everyone grants remain ambient write authority** — Everyone must stay in both restricting lists (removing it breaks early DLL initialization and CNG); an external NTFS object whose DACL grants Everyone a requested write right clears both checks and stays writable under both modes. -- **Hard links are file-object aliases, not path aliases** — an inheritable workspace ACE propagated onto an existing hard link changes the one underlying file security descriptor, so the same object is writable through an external alias; rejecting multiply-linked files is not viable for ordinary pnpm installations. -- **Writes are restricted; reads, network, and process visibility are not** — `WRITE_RESTRICTED` intersects write accesses only, so a confined child can read any caller-readable file and open sockets; `read-only` therefore needs a read-side policy to be expressed. +- **Everyone stays in both restricting lists, but no longer confers write authority** — the keep-alive group is required for early DLL initialization and CNG; the Low label now denies an Everyone-granted write outside the labeled roots, so that former gap is closed. +- **Writes and deletes are restricted; reads, network, and process visibility are not** — neither layer intersects reads, so a confined child can read any caller-readable file and open sockets; `read-only` therefore needs a read-side policy to be expressed. +- **Hard links are file-object aliases, not path aliases** — an inheritable workspace grant propagated onto an existing hard link labels and grants the one underlying file security descriptor, so the same object is writable through an external alias; rejecting multiply-linked files is not viable for ordinary pnpm installations. - **Console isolation is unavailable** — children created with `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` die during DLL initialization with `STATUS_DLL_INIT_FAILED` (`0xC0000142`); children share the host console, and pipe-based stdio redirection is unaffected. -- **ACL grants are standing directory mutations** — workspace ACEs stand by design (the reuse cache, never revoked); temp ACEs are revoked by `dispose()`; manual `icacls` cleanup cannot revoke them on this platform (`ERROR_NONE_MAPPED`, 1332), so revoke through this module. +- **Security descriptor edits are standing directory mutations** — workspace ACEs and labels stand by design (the reuse cache, never revoked); temp edits are revoked by `dispose()`; manual `icacls` cleanup cannot revoke them on this platform (`ERROR_NONE_MAPPED`, 1332), so revoke through this module. - **Granted directories must be caller-owned** — the owner's implicit `WRITE_DAC` is what lets the sandbox edit the DACL without elevation. - **The ambient temp root is never granted implicitly** — direct callers must supply an existing private `tempDir` plus its distinct `tempWriteSid`, or disable temp writes with `tempDir: null`; the actual temp directory must be disjoint from every writable root. - **The confined child's temp capability is private per live session/workspace pair** — the runner rewrites TMP/TEMP to that private directory before the spawn; two tokens sharing the same workspace SID cannot write one another's temp directories. -- **`whoami` and token-inspection cmdlets fail under the restricted token** — `GetTokenInformation` on the duplicate is partially unavailable to the child, which is diagnostic noise rather than an operational failure. +- **`whoami` and token-inspection cmdlets run but report the reduced token** — `whoami /groups` succeeds and reports `S-1-16-4096`, which is the cheapest way to observe the active integrity level. ### Header verification and source map @@ -127,10 +127,10 @@ The sandbox-owned SID, ACL, token, file, and lock declarations are checked again | File | Role | |---|---| -| [`src/index.ts`](src/index.ts) | `AclSandbox`: restricted-token policy, DACL grants, fail-closed spawn and dispose | +| [`src/index.ts`](src/index.ts) | `AclSandbox`: restricted-token policy, DACL and label grants, fail-closed spawn and dispose | | [`src/runner.ts`](src/runner.ts) | The runner entry over shared Win32 process primitives | | [`src/grant.ts`](src/grant.ts) | `AclWriteGrant`: server-side grant materialization and revocation | -| [`src/token.ts`](src/token.ts) + [`src/acl.ts`](src/acl.ts) | Win32 token and DACL primitives behind the sandbox | +| [`src/token.ts`](src/token.ts) + [`src/acl.ts`](src/acl.ts) | Win32 token, DACL, and mandatory-label primitives behind the sandbox | @@ -173,7 +173,8 @@ These limits define when the backend is a poor fit or needs special operational - **Piped stdio capture is impossible for confined grandchildren** — libuv's pipe stdio uses named pipes, whose client-end open requests write access no restricting SID is granted (the Win32 layer's default SD template, not the token default DACL), so `spawn(..., { stdio: 'pipe' })` inside a confined process fails with EPERM; inherited and ignored stdio spawns work, and anonymous pipes (PowerShell pipelines) work because the restricted token's default DACL carries a full-access restricting-SID ACE. - **Grant materialization is an eager full-tree propagation** — `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (tens of seconds on large workspace trees); the per-workspace identity pays it once per workspace per machine, and the exact-ACE skip makes every later provision cheap. - **Read-side confinement and network policy are out of scope** — `WRITE_RESTRICTED` intersects write accesses only; pair this backend with a read-side policy for stronger confinement. -- **Wide-directory and FAT-volume warnings are deferred; FAT-class targets stay writable** — the UI-side warnings are not implemented, a FAT volume as a grant root fails loudly, and a FAT-class target outside the granted roots has no security descriptors so it stays writable under both confined modes; FAT is treated as legacy residue. +- **Reads stop at objects another AppContainer-based tool has ACL'd with a package SID** — Windows denies a Low-integrity token access to a file whose DACL carries an ACE for a package SID (`S-1-15-2-…`), even when that DACL also grants the user full control and Everyone read. Tools that sandbox themselves with AppContainers grant exactly such ACEs, so a project tree stamped by one of them becomes unreadable to this backend's child. Removing the foreign ACE (or re-installing the affected tree) restores access; the package cannot grant it back. +- **Wide-directory and FAT-volume warnings are deferred; the FAT-class residue is unverified** — the UI-side warnings are not implemented, a FAT volume as a grant root fails loudly, and a FAT-class target outside the granted roots stores no security descriptor; its effective integrity label is assigned by the system rather than recorded on the object, so the label layer's behavior there is untested. FAT stays legacy residue. - **PowerShell language mode differs by confined mode** — under `read-only`, PowerShell cannot create its AppLocker probe files in temp and conservatively starts in ConstrainedLanguage (`Add-Type`, non-core .NET static calls, COM, and reflection fail); the shipped `workspace-write` path lets the probe complete, so pwsh stays in FullLanguage unless host-wide WDAC/AppLocker policy says otherwise, while a direct `AclSandbox` with `tempDir: null` has no such guarantee. This split is PowerShell startup behavior, not part of the ACL write boundary. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index eeb65e5db1..9aa2bcdf81 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -9,7 +9,7 @@ kind: "package-library" ## 概述 -在 Windows 上,本包将子进程的写入限制在工作区和私有临时目录内。`workspace-write` 授予对这两个位置的写入权限,`read-only` 则均不授予。挂载 `dsh-sandbox-local` 后,受限的 bash 和 PowerShell 命令会自动获得此行为;调用方也可以直接使用公开 `AclSandbox` API,并捕获标准流。任何 Win32 操作失败都会阻止子进程在不受限制的情况下启动。该保证特意标记为部分强制,因为进程启动会保留 Everyone 访问权限,NTFS 硬链接也可以通过其他路径暴露同一文件;调用方可通过报告的 `partial` 强制级别检测此限制。 +在 Windows 上,本包将子进程的写入与删除限制在工作区和私有临时目录内:`workspace-write` 授予这两处,`read-only` 均不授予。挂载 `dsh-sandbox-local` 后,受限的 bash 与 PowerShell 命令即获得此行为;调用方也可以直接使用公开 `AclSandbox` API;任何 Win32 操作失败都会阻止不受限制的 spawn。边界由两层共同强制:受限令牌的能力 SID 交叉检查,以及授予目录上的 Low 完整性标签;但该保证仍为部分强制:进程启动保留 Everyone 访问,硬链接是文件对象别名,而被其他 AppContainer 工具 ACL 过的文件不可读。 ## 目录 @@ -59,15 +59,15 @@ await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricte const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot }) const { stdout, stderr, exitCode } = await child.wait() -sandbox.dispose() // revokes the revocable (temp) grant, keeps the standing workspace ACE; reports every cleanup failure +sandbox.dispose() // revokes the revocable (temp) grant and label, keeps the standing workspace pair rmSync(tempDir, { recursive: true, force: true }) ``` -工作区 ACE 以常驻方式授予——`dispose()` 保留它们,因为它们是跨实例的复用缓存——而不同的临时 SID 以可回收方式授予。服务端对应实现是 `AclWriteGrant` 类:每个目录一次 `add(path, standing)`,`dispose()` 撤销可回收路径并释放 SID。 +工作区的安全描述符改动以常驻方式授予——`dispose()` 保留它们,因为它们是跨实例的复用缓存——而不同的临时 SID 以可回收方式授予。每次授权就是一次调用,同时携带能力 SID 允许 ACE 与 Low 禁止上调标签。服务端对应实现是 `AclWriteGrant` 类:每个目录一次 `add(path, standing)`,`dispose()` 撤销可回收路径并释放各 SID。 ### 隔离给你带来什么 -在 `workspace-write` 下,子进程可以写入工作区及其私有临时目录;受 ACL 管辖的其他写入都会被拒绝,已记录的 Everyone 与硬链接边界除外。在 `read-only` 下不存在显式写入授权,因此写入会被拒绝,同样带有已记录的边界。 +在 `workspace-write` 下,子进程可以写入工作区及其私有临时目录;受 ACL 管辖的其他写入与删除都会被拒绝,已记录的硬链接与 AppContainer ACL 边界除外。在 `read-only` 下不存在显式写入授权,因此写入与删除都会被拒绝,同样带有已记录的边界。 临时隔离按每个活跃的会话/工作区对进行:共享工作区的会话共享其写权限,但无法写入彼此的临时目录。新的提供方总会选择新的临时路径和 SID,因此崩溃残留既无法阻止恢复的会话,也无法向其授权。 @@ -87,11 +87,11 @@ rmSync(tempDir, { recursive: true, force: true }) ### 机制 -调用者令牌被复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 携带彼此独立的工作区与私有临时目录能力。Windows 执行两次访问检查——先对正常 SID,再对 restricting SID——并且只在两次检查都通过时才授予写类访问。工作区 SID 由规范工作区路径确定性派生(`workspaceWriteSid`),因此工作区根目录 ACE 每台机器每个工作区只物化一次,之后每次会话、调用或重启都命中精确 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 所控制的任何文件。完整性标签封堵了这条路径:无论权限来自哪一项,强制检查都在访问检查内部执行,而每个被授权目录都在添加 ACE 的同一次调用中被标记为 Low。工作区 SID 由规范工作区路径确定性派生(`workspaceWriteSid`),因此工作区根目录的安全描述符改动每台机器每个工作区只物化一次,之后每次会话、调用或重启都命中精确 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)不授予任何内容。保活组(登录 SID + Everyone)在两种模式下都存在:没有它,早期 DLL 初始化会以 `0xC0000142` 死亡、CNG 会让 pwsh 以 `0xE0434352` 崩溃。写入 SID 有意留在 read-only 列表之外:先前 workspace-write 时期留下的常驻授权 ACE 仍然失效,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容,而常驻 ACE 让重新升级免于重新传播。 +`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` 重定向不受影响。 @@ -111,15 +111,15 @@ seam 先把确定性工作区 SID 的 ACE 常驻物化(每个工作区每服 ### 已验证边界 -- **Everyone 授权仍是环境中的写权限来源。** Everyone 必须保留在两种 restricting 列表中(移除它会破坏早期 DLL 初始化与 CNG);外部 NTFS 对象若其 DACL 向 Everyone 授予所请求的写权限,就会同时通过两次检查,并在两种模式下保持可写。 -- **硬链接是文件对象别名,而非路径别名。** 传播到已有硬链接上的可继承工作区 ACE 会修改底层同一文件的安全描述符,因此同一对象也可通过外部别名写入;拒绝工作区中的所有多链接文件不具可行性,因为普通 pnpm 安装会使用硬链接。 -- **写入受限;读取、网络与进程可见性不受限。** `WRITE_RESTRICTED` 只交叉检查写访问,因此受限子进程可以读取调用者可读的任何文件并打开套接字;`read-only` 因而需要读侧策略才能表达。 +- **Everyone 仍留在两种 restricting 列表中,但不再带来写权限。** 保活组是早期 DLL 初始化与 CNG 所必需的;如今 Low 标签会拒绝对被标记根目录之外、由 Everyone 授权的写入,因此这一旧缺口已关闭。 +- **写入与删除受限;读取、网络与进程可见性不受限。** 两层都不交叉检查读取,因此受限子进程可以读取调用者可读的任何文件并打开套接字;`read-only` 因而需要读侧策略才能表达。 +- **硬链接是文件对象别名,而非路径别名。** 传播到已有硬链接上的可继承工作区授权会标记并授权底层同一文件的安全描述符,因此同一对象也可通过外部别名写入;拒绝工作区中的所有多链接文件不具可行性,因为普通 pnpm 安装会使用硬链接。 - **控制台隔离不可用。** 以 `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` 创建的子进程在 DLL 初始化期间以 `STATUS_DLL_INIT_FAILED`(`0xC0000142`)死亡;子进程共享宿主控制台,基于管道的 stdio 重定向不受影响。 -- **ACL 授权是对真实目录的驻留改动。** 工作区 ACE 按设计常驻(复用缓存,绝不撤销);临时 ACE 由 `dispose()` 撤销;手工 `icacls` 清理无法在本平台回收它们(`ERROR_NONE_MAPPED` 1332),请通过本模块回收。 +- **安全描述符改动是对真实目录的驻留改动。** 工作区 ACE 与标签按设计常驻(复用缓存,绝不撤销);临时改动由 `dispose()` 撤销;手工 `icacls` 清理无法在本平台回收它们(`ERROR_NONE_MAPPED` 1332),请通过本模块回收。 - **被授权目录必须由调用者拥有。** 所有者的隐式 `WRITE_DAC` 是沙箱无需提权即可编辑 DACL 的原因。 - **环境临时根目录绝不会被隐式授权。** 直接调用方必须提供已存在的私有 `tempDir` 及其不同的 `tempWriteSid`,或用 `tempDir: null` 禁用临时写入;实际临时目录不得与任何可写根目录重叠。 - **受限子进程的临时能力按每个活跃的会话/工作区对私有。** runner 在 spawn 之前把 TMP/TEMP 改写为该私有目录;共享同一工作区 SID 的两个令牌无法写入彼此的临时目录。 -- **受限令牌下 `whoami` 与令牌检查 cmdlet 会失败。** 子进程对复制令牌的 `GetTokenInformation` 部分不可用,这是诊断噪音而非运行故障。 +- **`whoami` 与令牌检查 cmdlet 可以运行,但报告的是被削减的令牌。** `whoami /groups` 成功并报告 `S-1-16-4096`,这是观察当前完整性级别最简便的方式。 ### 头文件验证与源码索引 @@ -173,7 +173,8 @@ seam 先把确定性工作区 SID 的 ACE 常驻物化(每个工作区每服 - **受限孙进程的管道 stdio 捕获不可用。** libuv 的管道 stdio 用的是 NAMED pipe,其 client 端打开所请求的写访问没有任何 restricting SID 被授予(是 Win32 层的默认 SD 模板,而非令牌默认 DACL),因此受限进程内 `spawn(..., { stdio: 'pipe' })` 以 EPERM 失败;继承与忽略 stdio 的 spawn 可用,匿名管道(PowerShell 的管道)因受限令牌默认 DACL 携带 restricting SID 全权 ACE 而可用。 - **授权物化是急切的全树传播。** 在带可继承 ACE 的目录上调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(大型工作区树上以数十秒计);按工作区身份每台机器每个工作区只付一次,精确 ACE 跳过让后续每次供给都很便宜。 - **读侧隔离与网络策略不在范围内**——`WRITE_RESTRICTED` 只交叉检查写访问;将此后端与读侧策略配对以获得更强隔离。 -- **宽目录与 FAT 卷警告已推迟;FAT 类目标保持可写。** UI 侧警告尚未实现,FAT 卷作为授权根会大声失败,而授权根之外的 FAT 类目标没有安全描述符,因此在两种受限模式下都可写;FAT 被视为遗留残留。 +- **读取会被其他基于 AppContainer 的工具以包 SID 授权过的对象挡住。** 当文件的 DACL 携带针对包 SID(`S-1-15-2-…`)的 ACE 时,即使该 DACL 同时向用户授予完全控制、向 Everyone 授予读取,Windows 也会拒绝 Low 完整性的令牌访问它。以 AppContainer 自我隔离的工具正是会写入这类 ACE,因此被它们标记过的项目树对本后端的子进程将变得不可读。移除外来 ACE(或重新安装受影响的目录树)即可恢复访问;本包无法反向授予。 +- **宽目录与 FAT 卷警告已推迟;FAT 类残留未经验证。** UI 侧警告尚未实现,FAT 卷作为授权根会大声失败,而授权根之外的 FAT 类目标不存储安全描述符;其有效完整性标签由系统分配而非记录在对象上,因此标签层在该处的行为未经测试。FAT 仍被视为遗留残留。 - **PowerShell 语言模式因受限模式而异。** 在 `read-only` 下,PowerShell 无法在临时目录中创建 AppLocker 探针文件,因此会保守地以 ConstrainedLanguage 启动(`Add-Type`、非核心 .NET 静态调用、COM 与反射失败);交付的 `workspace-write` 路径可让探针完成,因此除非主机范围的 WDAC/AppLocker 策略另有规定,否则 pwsh 保持 FullLanguage,而直接使用 `AclSandbox` 并配置 `tempDir: null` 时则没有这一保证。这一区别属于 PowerShell 启动行为,不是 ACL 写入边界的一部分。 diff --git a/packages/sandbox/sandbox-windows-acl/src/acl.ts b/packages/sandbox/sandbox-windows-acl/src/acl.ts index eded0f86a8..846670f489 100644 --- a/packages/sandbox/sandbox-windows-acl/src/acl.ts +++ b/packages/sandbox/sandbox-windows-acl/src/acl.ts @@ -5,6 +5,14 @@ * failure is reported with the API name, the exact Win32 code, the formatted * system text, and the affected path. * + * Each grant is a PAIR of edits applied in ONE SetNamedSecurityInfoW call: + * the capability-SID allow ACE in the DACL, and a Low mandatory label with the + * no-write-up policy in the label ACL ({@link buildLowLabelAcl}). The label + * denies write-class access to every object that is not labeled Low, and the + * kernel applies that policy inside the access check itself — including the + * parent directory's FILE_DELETE_CHILD authority, which the token's + * write-restricted intersection does not reach. + * * Concurrency: grants are read-merge-write against the directory's CURRENT * DACL, and the whole get-merge-set sequence runs under a per-path exclusive * LockFileEx lock (see {@link withPathLock}) so concurrent sandbox instances @@ -109,39 +117,108 @@ export function withPathLock(api: Win32Bindings, path: string, action: () => } /** - * Read the directory's current explicit DACL via GetNamedSecurityInfoW. + * Read the directory's current explicit DACL and mandatory label via + * GetNamedSecurityInfoW. * Allocation contract (the POC's RevokeAccess, minus its missing checks): the * returned ACL pointer sits INSIDE the security descriptor allocation — only * the descriptor may be LocalFree'd, and it must not be freed before * SetEntriesInAclW has consumed the ACL. Freeing the ACL pointer itself * corrupts the heap (verified the hard way). * @param api - the binding table. - * @param path - the directory whose DACL is read. - * @returns the current explicit DACL (null when the directory carries none) and its owning descriptor. + * @param path - the directory whose DACL and label are read. + * @returns the current explicit DACL and label ACL (null when the directory carries none) plus their owning descriptor. */ -function readCurrentDacl(api: Win32Bindings, path: string): { oldAcl: NativePtr | null; descriptor: NativePtr | null } { +function readCurrentSecurity( + api: Win32Bindings, + path: string, +): { oldAcl: NativePtr | null; labelAcl: NativePtr | null; descriptor: NativePtr | null } { const ownerSlot = allocPtrSlot() const groupSlot = allocPtrSlot() const daclSlot = allocPtrSlot() const saclSlot = allocPtrSlot() const descriptorSlot = allocPtrSlot() const readResult = api.getNamedSecurityInfoW( - path, abi.SE_FILE_OBJECT, abi.DACL_SECURITY_INFORMATION, + path, abi.SE_FILE_OBJECT, abi.DACL_SECURITY_INFORMATION | abi.LABEL_SECURITY_INFORMATION, ownerSlot, groupSlot, daclSlot, saclSlot, descriptorSlot, ) if (readResult !== abi.ERROR_SUCCESS) throwWin32(api, 'GetNamedSecurityInfoW', readResult, path) - return { oldAcl: decodePtr(daclSlot), descriptor: decodePtr(descriptorSlot) } + return { oldAcl: decodePtr(daclSlot), labelAcl: decodePtr(saclSlot), descriptor: decodePtr(descriptorSlot) } +} + +/** + * Build the Low mandatory label applied with every write grant: one + * SYSTEM_MANDATORY_LABEL_ACE naming `lowLabelSidPtr` with the no-write-up + * policy, inheriting to subcontainers and objects so files created later + * inside the granted tree carry the same label automatically. + * + * The caller frees the returned ACL with LocalFree; SetNamedSecurityInfoW + * copies it. Fail-closed: every Win32 call is checked and the half-built ACL + * is released before the error is thrown. + * @param api - the binding table. + * @param lowLabelSidPtr - the Low integrity SID (S-1-16-4096) the label names. + * @returns the ACL carrying the single inheritable label ACE. + */ +export function buildLowLabelAcl(api: Win32Bindings, lowLabelSidPtr: NativePtr): NativePtr { + const sidLength = api.getLengthSid(lowLabelSidPtr) + if (sidLength === 0) throwLastError(api, 'GetLengthSid', 'Low mandatory label SID') + const aclLength = abi.ACL_HEADER_SIZE + abi.MANDATORY_ACE_OVERHEAD + sidLength + const acl = api.localAlloc(abi.LPTR, aclLength) + if (isNullPtr(acl)) throwLastError(api, 'LocalAlloc', 'Low mandatory label ACL') + if (api.initializeAcl(acl, aclLength, abi.ACL_REVISION) === 0) { + const win32Code = api.getLastError() + api.localFree(acl) // best-effort on the error path + throwWin32(api, 'InitializeAcl', win32Code, 'Low mandatory label ACL') + } + if (api.addMandatoryAce( + acl, abi.ACL_REVISION, abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT, abi.SYSTEM_MANDATORY_LABEL_NO_WRITE_UP, lowLabelSidPtr, + ) === 0) { + const win32Code = api.getLastError() + api.localFree(acl) // best-effort on the error path + throwWin32(api, 'AddMandatoryAce', win32Code, 'Low mandatory label ACL') + } + return acl +} + +/** + * True when the label ACL already carries the EXACT label this module would + * add (mandatory-label ACE, OI|CI inheritance, no-write-up policy, the Low + * SID), so a re-grant can skip the eager full-tree propagation. The ACE + * layout matches {@link hasExactGrant} (header@0, Mask@4, inline SID@8), and a + * malformed header reads as "no exact label" so the caller falls back to the + * apply path, which owns the robust failure handling. + * @param labelAcl - the current label ACL pointer (from {@link readCurrentSecurity}). + * @param lowLabelSidPtr - the Low integrity SID to match. + * @returns whether the exact label ACE is already present. + */ +function hasExactLabel(labelAcl: NativePtr, lowLabelSidPtr: NativePtr): boolean { + const aclSize = decodeUint16At(labelAcl, 2) + const aceCount = decodeUint16At(labelAcl, 4) + if (aclSize < abi.ACL_HEADER_SIZE || aclSize > 1_048_576) return false // implausible: fall back to the apply path + let offset = abi.ACL_HEADER_SIZE // the first ACE follows the ACL header + for (let index = 0; index < aceCount; index++) { + const aceSize = decodeUint16At(labelAcl, offset + 2) + if (aceSize < abi.ACL_HEADER_SIZE || offset + aceSize > aclSize) return false // implausible: fall back to the apply path + const exact = decodeUint8At(labelAcl, offset) === abi.SYSTEM_MANDATORY_LABEL_ACE_TYPE + && decodeUint8At(labelAcl, offset + 1) === abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT + && decodeUint32At(labelAcl, offset + 4) === abi.SYSTEM_MANDATORY_LABEL_NO_WRITE_UP + if (exact && sameSidAt(labelAcl, offset + 8, lowLabelSidPtr, 0)) return true + offset += aceSize + } + return false } /** * Shared tail of grantWrite and revokeWrite: merge `entry` into `oldAcl` * (null = no explicit DACL yet; SetEntriesInAclW builds one from scratch), - * free the descriptor before applying the merged ACL, apply it, then free the - * merged ACL — checking every call and reporting with the caller's label. + * free the descriptor before applying the merged ACL, apply the merged DACL + * together with `labelAcl` in one SetNamedSecurityInfoW call (null clears the + * label), then free both ACLs — checking every call and reporting with the + * caller's label. * @param api - the binding table. - * @param path - the directory the DACL edit applies to. + * @param path - the directory the DACL and label edits apply to. * @param entry - the EXPLICIT_ACCESS_W to merge (grant or revoke). - * @param oldAcl - the current explicit DACL (from {@link readCurrentDacl}). + * @param oldAcl - the current explicit DACL (from {@link readCurrentSecurity}). + * @param labelAcl - the label ACL to apply, or null to clear the mandatory label. * @param descriptor - the descriptor allocation owning `oldAcl`. * @param label - the caller's name for error details. */ @@ -150,6 +227,7 @@ function mergeAndApply( path: string, entry: Buffer, oldAcl: NativePtr | null, + labelAcl: NativePtr | null, descriptor: NativePtr | null, label: string, ): void { @@ -169,13 +247,15 @@ function mergeAndApply( // before applying, exactly like the POC. const freedDescriptor = descriptor !== null ? api.localFree(descriptor) : null const applyResult = api.setNamedSecurityInfoW( - path, abi.SE_FILE_OBJECT, abi.DACL_SECURITY_INFORMATION, - null, null, newAcl, null, + path, abi.SE_FILE_OBJECT, abi.DACL_SECURITY_INFORMATION | abi.LABEL_SECURITY_INFORMATION, + null, null, newAcl, labelAcl, ) const freedNew = api.localFree(newAcl) + const freedLabel = labelAcl !== null ? api.localFree(labelAcl) : null if (applyResult !== abi.ERROR_SUCCESS) throwWin32(api, 'SetNamedSecurityInfoW', applyResult, `${label}(${path})`) if (freedDescriptor !== null && !isNullPtr(freedDescriptor)) throwLastError(api, 'LocalFree', `${label}(${path}) descriptor`) if (!isNullPtr(freedNew)) throwLastError(api, 'LocalFree', `${label}(${path}) new ACL`) + if (freedLabel !== null && !isNullPtr(freedLabel)) throwLastError(api, 'LocalFree', `${label}(${path}) label ACL`) } /** @@ -214,50 +294,56 @@ function hasExactGrant(oldAcl: NativePtr, sidPtr: NativePtr): boolean { /** * Grant `GRANT_MASK` (Write+Delete, displays as "Modify") to the capability SID - * on `path`, inheriting to subcontainers and objects. Idempotent: when the - * directory's current explicit DACL already carries the exact ACE (the - * per-session grant surviving from a previous server lifetime), the - * SetNamedSecurityInfoW apply is SKIPPED — it would otherwise re-propagate - * the identical ACE across the whole tree (eager inheritance; minutes on - * large workspaces). Otherwise read-merge-write: the new ACE merges into the - * directory's CURRENT explicit DACL (same shape as {@link revokeWrite}), so - * pre-existing explicit ACEs survive. Runs under the per-path lock. The - * directory must be owned by the caller (owner implicit WRITE_DAC) — same - * precondition as the POC. + * on `path`, inheriting to subcontainers and objects, and apply the Low + * mandatory label to the same directory in the same call. Idempotent: when the + * directory's current explicit DACL already carries the exact ACE AND its label + * ACL the exact label (the per-workspace grant surviving from a previous server + * lifetime), the SetNamedSecurityInfoW apply is SKIPPED — it would otherwise + * re-propagate the identical security descriptor across the whole tree (eager + * inheritance; minutes on large workspaces). Otherwise read-merge-write: the + * new ACE merges into the directory's CURRENT explicit DACL (same shape as + * {@link revokeWrite}), so pre-existing explicit ACEs survive. Runs under the + * per-path lock. The directory must be owned by the caller (owner implicit + * WRITE_DAC) — same precondition as the POC. * @param api - the binding table. - * @param path - the directory whose DACL gains the grant (the workspace or temp root). + * @param path - the directory whose DACL and label gain the grant (the workspace or temp root). * @param sidPtr - the capability SID the ACE names. + * @param lowLabelSidPtr - the Low integrity SID the mandatory label names. */ -export function grantWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): void { +export function grantWrite(api: Win32Bindings, path: string, sidPtr: NativePtr, lowLabelSidPtr: NativePtr): void { withPathLock(api, path, () => { - const { oldAcl, descriptor } = readCurrentDacl(api, path) - if (oldAcl !== null && hasExactGrant(oldAcl, sidPtr)) { - // The exact ACE stands: releasing the descriptor is the whole operation. + const { oldAcl, labelAcl, descriptor } = readCurrentSecurity(api, path) + if (oldAcl !== null && labelAcl !== null && hasExactGrant(oldAcl, sidPtr) && hasExactLabel(labelAcl, lowLabelSidPtr)) { + // The exact ACE and label stand: releasing the descriptor is the whole operation. if (descriptor !== null) { const freed = api.localFree(descriptor) if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', `grantWrite(${path}) descriptor`) } return } - mergeAndApply(api, path, buildExplicitAccess(sidPtr, abi.GRANT_ACCESS, abi.GRANT_MASK), oldAcl, descriptor, 'grantWrite') + mergeAndApply( + api, path, buildExplicitAccess(sidPtr, abi.GRANT_ACCESS, abi.GRANT_MASK), + oldAcl, buildLowLabelAcl(api, lowLabelSidPtr), descriptor, 'grantWrite', + ) }) } /** * Remove every ACE for the capability SID from the directory DACL (REVOKE_ACCESS - * merge — other entries are preserved). Returns whether an ACE removal was - * attempted (false when the directory carries no DACL at all). + * merge — other entries are preserved) and clear the Low mandatory label in the + * same call. Returns whether an ACE removal was attempted (false when the + * directory carries no DACL at all). * * Runs under the per-path lock (the whole get-merge-set sequence); the - * descriptor/ACL allocation contract lives on {@link readCurrentDacl}. + * descriptor/ACL allocation contract lives on {@link readCurrentSecurity}. * @param api - the binding table. - * @param path - the directory whose DACL loses the capability-SID ACEs. + * @param path - the directory whose DACL loses the capability-SID ACEs and whose label is cleared. * @param sidPtr - the capability SID whose ACEs are removed. * @returns whether an ACE removal was attempted (false when the directory carries no DACL at all). */ export function revokeWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): boolean { return withPathLock(api, path, () => { - const { oldAcl, descriptor } = readCurrentDacl(api, path) + const { oldAcl, descriptor } = readCurrentSecurity(api, path) if (oldAcl === null) { if (descriptor !== null) { const freed = api.localFree(descriptor) @@ -265,7 +351,7 @@ export function revokeWrite(api: Win32Bindings, path: string, sidPtr: NativePtr) } return false } - mergeAndApply(api, path, buildExplicitAccess(sidPtr, abi.REVOKE_ACCESS, 0), oldAcl, descriptor, 'revokeWrite') + mergeAndApply(api, path, buildExplicitAccess(sidPtr, abi.REVOKE_ACCESS, 0), oldAcl, null, descriptor, 'revokeWrite') return true }) } diff --git a/packages/sandbox/sandbox-windows-acl/src/ffi.ts b/packages/sandbox/sandbox-windows-acl/src/ffi.ts index e65e1631d3..76308bb419 100644 --- a/packages/sandbox/sandbox-windows-acl/src/ffi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/ffi.ts @@ -60,6 +60,8 @@ export interface Win32Bindings extends Win32ProcessBindings { newToken: NativePtr, ): number setEntriesInAclW(count: number, entries: Buffer, oldAcl: NativePtr | null, newAcl: NativePtr): number + initializeAcl(acl: NativePtr, length: number, revision: number): number + addMandatoryAce(acl: NativePtr, revision: number, aceFlags: number, policy: number, sid: NativePtr): number setNamedSecurityInfoW( path: string, objectType: number, @@ -67,7 +69,7 @@ export interface Win32Bindings extends Win32ProcessBindings { owner: null, group: null, dacl: NativePtr | null, - sacl: null, + sacl: NativePtr | null, ): number getNamedSecurityInfoW( path: string, @@ -252,6 +254,8 @@ function bindings(): Win32Bindings { PVOID, 'uint32', 'uint32', PVOID, 'uint32', PVOID, 'uint32', PVOID, PPVOID, ]), setEntriesInAclW: bind(advapi32, 'SetEntriesInAclW', 'uint32', ['uint32', PVOID, PVOID, PPVOID]), + initializeAcl: bind(advapi32, 'InitializeAcl', 'int', [PVOID, 'uint32', 'uint32']), + addMandatoryAce: bind(advapi32, 'AddMandatoryAce', 'int', [PVOID, 'uint32', 'uint32', 'uint32', PVOID]), setNamedSecurityInfoW: bind(advapi32, 'SetNamedSecurityInfoW', 'uint32', [ 'str16', 'int', 'uint32', PVOID, PVOID, PVOID, PVOID, ]), diff --git a/packages/sandbox/sandbox-windows-acl/src/grant.ts b/packages/sandbox/sandbox-windows-acl/src/grant.ts index edb0345579..e31db0e05f 100644 --- a/packages/sandbox/sandbox-windows-acl/src/grant.ts +++ b/packages/sandbox/sandbox-windows-acl/src/grant.ts @@ -14,34 +14,40 @@ import { grantWrite, revokeWrite } from './acl.ts' import { allocPtrSlot, decodePtr, isNullPtr, throwLastError, win32Sync } from './ffi.ts' import type { NativePtr, Win32Bindings } from './ffi.ts' +import { makeWellKnownSid } from './token.ts' +import * as abi from './win32-abi.ts' /** * One write SID's provider-lifetime grant materialization: the parsed SID - * pointer plus every directory whose DACL currently carries its ACE. - * Workspace paths are added STANDING (their ACEs are the cross-session reuse - * cache and outlive the grant — dispose() skips revoking them, or the next + * pointer plus every directory whose DACL currently carries its ACE and whose + * label ACL carries the Low mandatory label. Workspace paths are added + * STANDING (their security descriptor edits are the cross-session reuse cache + * and outlive the grant — dispose() skips revoking them, or the next * provision would re-propagate the whole tree); temp paths are revocable * (dispose() revokes them — an inheritable ACE must not outlive its * session's temp directory). Create with {@link AclWriteGrant.create}; - * dispose revokes the revocable paths and frees the SID. + * dispose revokes the revocable paths and frees every SID. */ export class AclWriteGrant { /** The write SID in SDDL string form. */ readonly writeSid: string private readonly api: Win32Bindings private readonly sidPtr: NativePtr + private readonly lowLabelSidPtr: NativePtr private readonly revocablePaths: string[] = [] private readonly standingPaths: string[] = [] - private constructor(api: Win32Bindings, sidPtr: NativePtr, writeSid: string) { + private constructor(api: Win32Bindings, sidPtr: NativePtr, lowLabelSidPtr: NativePtr, writeSid: string) { this.api = api this.sidPtr = sidPtr + this.lowLabelSidPtr = lowLabelSidPtr this.writeSid = writeSid } /** - * Parse the SID string and open the binding table (lazily, once per - * server). Fail-closed: any failure throws — nothing is granted yet. + * Parse the SID string, create the Low integrity SID the grants label with, + * and open the binding table (lazily, once per server). Fail-closed: any + * failure throws — nothing is granted yet. * @param writeSid - the workspace (`S-1-4-x-y`) or temp (`S-1-4-x-y-1`) capability SID string. * @param api - optional already-resolved bindings (tests). * @returns the ready grant (no ACEs yet). @@ -54,26 +60,27 @@ export class AclWriteGrant { } const sidPtr = decodePtr(sidSlot) if (sidPtr === null) throwLastError(bindings, 'ConvertStringSidToSidW', `null SID for ${writeSid}`) - return new AclWriteGrant(bindings, sidPtr, writeSid) + return new AclWriteGrant(bindings, sidPtr, makeWellKnownSid(bindings, abi.WinLowLabelSid), writeSid) } /** - * Grant the write ACE on one directory (idempotent: an already-standing - * exact ACE skips the eager full-tree re-propagation — see - * {@link grantWrite}) and record the path for {@link dispose} unless it is - * standing. 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 materialization and dispose the instance to - * revoke the paths granted so far. - * @param path - the directory whose DACL gains the grant. - * @param standing - the ACE outlives this grant (the workspace reuse + * Grant the write ACE and the Low mandatory label on one directory + * (idempotent: an already-standing exact ACE plus exact label skips the + * eager full-tree re-propagation — see {@link grantWrite}) and record the + * path for {@link dispose} unless it is standing. 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 + * materialization and dispose the instance to revoke the paths granted so + * far. + * @param path - the directory whose DACL and label gain the grant. + * @param standing - the edits outlive this grant (the workspace reuse * cache; dispose() skips revoking it). Default false (revoked on * dispose — the temp-directory lifecycle). */ add(path: string, standing = false): void { ;(standing ? this.standingPaths : this.revocablePaths).push(path) - grantWrite(this.api, path, this.sidPtr) + grantWrite(this.api, path, this.sidPtr, this.lowLabelSidPtr) } /** Every directory currently carrying the grant, in grant order. */ @@ -81,7 +88,7 @@ export class AclWriteGrant { return [...this.standingPaths, ...this.revocablePaths] } - /** Revoke every revocable grant (standing ACEs stay) and free the SID; reports every cleanup failure. */ + /** Revoke every revocable grant (standing security descriptor edits stay) and free the SIDs; reports every cleanup failure. */ dispose(): void { const failures: unknown[] = [] for (const path of this.revocablePaths) { @@ -91,11 +98,13 @@ export class AclWriteGrant { failures.push(error) } } - try { - const freed = this.api.localFree(this.sidPtr) - if (!isNullPtr(freed)) throwLastError(this.api, 'LocalFree', 'write SID') - } catch (error) { - failures.push(error) + for (const [label, sidPtr] of [['write SID', this.sidPtr], ['Low label SID', this.lowLabelSidPtr]] as const) { + try { + const freed = this.api.localFree(sidPtr) + if (!isNullPtr(freed)) throwLastError(this.api, 'LocalFree', label) + } catch (error) { + failures.push(error) + } } if (failures.length > 0) { throw new AggregateError(failures, `AclWriteGrant dispose completed with ${failures.length} cleanup failure(s)`) diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 70e7661993..2760758ebb 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -10,7 +10,14 @@ * keep-alive group logon SID + Everyone; Authenticated Users, INTERACTIVE, * and LOCAL are absent from both lists — see the seam's dual-list contract * in `packages/sandbox/sandbox-local` and the package README's Modes section - * for the complete boundary). The write SID is the per-WORKSPACE identity + * for the complete boundary). The intersection covers only the write bits of + * the object's OWN access check: Windows can also authorize a write or delete + * from the parent directory's FILE_DELETE_CHILD right, which no restricting + * SID has to co-sign. The token is therefore ALSO lowered to Low integrity + * and every granted directory carries a Low no-write-up label, so the + * kernel's mandatory-integrity check — evaluated inside the access check + * regardless of which right supplied the authority — confines the child to + * the labeled roots. 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 @@ -49,7 +56,7 @@ import { allocPtrSlot, decodePtr, isNullPtr, throwLastError, win32 } from './ffi import type { NativePtr, Win32Bindings } from './ffi.ts' import { assertPrivateTempDisjoint } from './path-boundary.ts' import { drainPipe, spawnSandboxed, spawnSandboxedInherited, waitForExit } from './spawn.ts' -import { createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken, setTokenDefaultDaclGrant } from './token.ts' +import { createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken, restrictTokenIntegrity, setTokenDefaultDaclGrant } from './token.ts' import * as abi from './win32-abi.ts' export { AclWriteGrant } from './grant.ts' @@ -256,17 +263,22 @@ export class AclSandbox { // 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). + // The Low integrity SID every granted directory is labeled with and the + // confined token itself is lowered to. + const lowLabelSid = makeWellKnownSid(api, abi.WinLowLabelSid) + this.sidAllocations.push(lowLabelSid) + if (this.manageDacls) { if (this.writeSidPtr !== undefined) { for (const path of this.writableDirs) { - grantWrite(api, path, this.writeSidPtr) + grantWrite(api, path, this.writeSidPtr, lowLabelSid) } if (tempDir !== null && this.tempWriteSidPtr !== undefined) { // Record BEFORE granting: grantWrite can throw after a successful // apply (a LocalFree failure), and the fail-closed catch must still // revoke that path (revoking an ungranted path is a no-op merge). this.grantedPaths.push({ path: tempDir, sidPtr: this.tempWriteSidPtr }) - grantWrite(api, tempDir, this.tempWriteSidPtr) + grantWrite(api, tempDir, this.tempWriteSidPtr, lowLabelSid) } } } @@ -280,6 +292,13 @@ export class AclSandbox { { world: worldSid }, this.mode, ) + // Lower the confined token to Low integrity. The write-SID intersection + // does not reach the parent directory's FILE_DELETE_CHILD authority, so + // without the matching label a confined child could delete anything its + // ambient user SIDs may delete; the label plus this level close that + // route, and the granted directories stay writable because + // grantWrite labels them Low too. + restrictTokenIntegrity(api, restrictedToken, lowLabelSid) this.token = restrictedToken // The restricted token's default DACL still names only the user's // ambient SIDs — none of the restricting SIDs. Every NEW object the diff --git a/packages/sandbox/sandbox-windows-acl/src/token.ts b/packages/sandbox/sandbox-windows-acl/src/token.ts index f1f2befbdd..27e8fd848b 100644 --- a/packages/sandbox/sandbox-windows-acl/src/token.ts +++ b/packages/sandbox/sandbox-windows-acl/src/token.ts @@ -144,6 +144,35 @@ export function setTokenDefaultDaclGrant(api: Win32Bindings, token: NativePtr, s api.localFree(newDacl) } +/** + * Lower the restricted token's integrity level to Low (S-1-16-4096). + * + * `WRITE_RESTRICTED` intersects only the write bits of the access mask it + * evaluates against an object's DACL. Windows can also authorize a write or a + * delete from the PARENT directory's FILE_DELETE_CHILD right, and that + * authority is granted without the restricting-SID intersection — so a + * confined child could still delete files anywhere its ambient user SIDs hold + * Modify. The mandatory label closes that route: the kernel applies the + * integrity policy inside the access check, wherever the authority came from, + * so a Low child only writes where the object is labeled Low (see + * `grantWrite`). + * + * Requires TOKEN_ADJUST_DEFAULT on the token (the caller opens it with the + * right). Fails closed: a failure throws before any child is spawned. + * @param api - the binding table. + * @param token - the restricted token to lower. + * @param lowLabelSidPtr - the Low integrity SID (S-1-16-4096). + */ +export function restrictTokenIntegrity(api: Win32Bindings, token: NativePtr, lowLabelSidPtr: NativePtr): void { + const sidLength = api.getLengthSid(lowLabelSidPtr) + const info = Buffer.alloc(abi.TOKEN_MANDATORY_LABEL_SIZE + sidLength) + info.writeBigUInt64LE(ptrAddress(lowLabelSidPtr), 0) // Label.Sid + info.writeUInt32LE(abi.SE_GROUP_INTEGRITY, 8) // Label.Attributes + if (api.setTokenInformation(token, abi.TokenIntegrityLevel, info, info.length) === 0) { + throwLastError(api, 'SetTokenInformation', 'TokenIntegrityLevel (Low)') + } +} + /** Pack `SID_AND_ATTRIBUTES[count]` (16-byte stride; Attributes stay 0). */ function buildRestrictingSids(sids: readonly NativePtr[]): Buffer { const buffer = Buffer.alloc(abi.SID_AND_ATTRIBUTES_SIZE * sids.length) diff --git a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts index 478fc71e1d..76a263e976 100644 --- a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts @@ -42,6 +42,33 @@ export const TokenGroups = 2 export const TokenDefaultDacl = 6 /** SECURITY_INFORMATION flag selecting the DACL. */ export const DACL_SECURITY_INFORMATION = 0x00000004 +/** SECURITY_INFORMATION flag selecting the mandatory integrity label. */ +export const LABEL_SECURITY_INFORMATION = 0x00000010 +/** ACE type carrying a mandatory integrity label. */ +export const SYSTEM_MANDATORY_LABEL_ACE_TYPE = 0x11 +/** + * Mandatory policy denying write-class access to higher-integrity objects. + * The kernel applies it inside the access check, so it also covers writes and + * deletes granted through a parent directory's FILE_DELETE_CHILD right — + * the path the write-restricted pass-2 intersection does not reach. + */ +export const SYSTEM_MANDATORY_LABEL_NO_WRITE_UP = 0x00000001 +/** TOKEN_INFORMATION_CLASS value for the token's integrity level. */ +export const TokenIntegrityLevel = 25 +/** Group attribute marking the integrity SID of a TOKEN_MANDATORY_LABEL. */ +export const SE_GROUP_INTEGRITY = 0x00000020 +/** WELL_KNOWN_SID_TYPE value for the Low mandatory level (S-1-16-4096). */ +export const WinLowLabelSid = 66 +/** x64 TOKEN_MANDATORY_LABEL byte size (the SID is referenced, not embedded). */ +export const TOKEN_MANDATORY_LABEL_SIZE = 16 +/** x64 ACL header byte size (AclRevision, Sbz1, AclSize, AceCount, Sbz2). */ +export const ACL_HEADER_SIZE = 8 +/** Bytes a SYSTEM_MANDATORY_LABEL_ACE occupies beyond the ACL header and its SID. */ +export const MANDATORY_ACE_OVERHEAD = 8 +/** ACL revision accepted by InitializeAcl and AddMandatoryAce. */ +export const ACL_REVISION = 2 +/** LocalAlloc flag selecting zero-initialized fixed memory (LMEM_FIXED | LMEM_ZEROINIT). */ +export const LPTR = 0x0040 /** SE_OBJECT_TYPE value for filesystem objects. */ export const SE_FILE_OBJECT = 1 /** TRUSTEE_TYPE value used when trustee classification is unknown. */ diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts index fce0c3562a..e93d6750cc 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts @@ -1,11 +1,11 @@ /** * ACL failure-path tests with minimal stub binding tables: every checked - * Win32 call in the lock, read-merge-write, and + * Win32 call in the lock, read-merge-write, mandatory-label, and * grant-skip sequence has a failing counterpart, and each failure closes the - * handles it created before throwing. The exact-ACE skip and the DACL-walk - * defenses are driven through crafted in-memory ACL/SID buffers. Pure - * stubs — no real Win32 calls, so these run on every platform; the - * real-FFI round-trip lives in acl.spec.ts (win32 only). + * handles it created before throwing. The exact-ACE/exact-label skip and the + * DACL/SACL-walk defenses are driven through crafted in-memory ACL/SID + * buffers. Pure stubs — no real Win32 calls, so these run on every platform; + * the real-FFI round-trip lives in acl.spec.ts (win32 only). */ import { tmpdir } from 'node:os' @@ -34,9 +34,10 @@ function aclApi(overrides: Partial = {}): Win32Bindings { closeHandle: vi.fn(() => 1), getNamedSecurityInfoW: vi.fn(( _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + dacl: NativePtr, sacl: NativePtr, descriptor: NativePtr, ) => { koffi.encode(dacl, PVOID, 0n) // no explicit DACL: the merge builds one + koffi.encode(sacl, PVOID, 0n) // no mandatory label either koffi.encode(descriptor, PVOID, 0n) return 0 }), @@ -45,6 +46,10 @@ function aclApi(overrides: Partial = {}): Win32Bindings { return 0 }), setNamedSecurityInfoW: vi.fn(() => 0), + localAlloc: vi.fn(() => 11n), + getLengthSid: vi.fn(() => 12), + initializeAcl: vi.fn(() => 1), + addMandatoryAce: vi.fn(() => 1), localFree: vi.fn(() => 0n as NativePtr), getLastError: vi.fn(() => 5), formatMessageW: vi.fn(() => 0), @@ -63,22 +68,27 @@ function craftSid(revision: number, count: number, authority: number[] = [0, 0, return sid } +/** The Low integrity SID both the token and the directory labels name. */ +function craftLowLabelSid(): NativePtr { + return craftSid(1, 0, [0, 0, 0, 0, 0, 16]) +} + /** - * One in-memory ACL carrying the exact grant ACE the skip checks for: - * header (AclRevision@0, AclSize@2, AceCount@4) then one ACCESS_ALLOWED_ACE - * (AceType@0, AceFlags@1, AceSize@2, Mask@4, inline SID@8). `match` selects - * whether the inline SID bytes equal `sid`. + * One in-memory ACL carrying a single inheritable ACE: header (AclRevision@0, + * AclSize@2, AceCount@4) then a 16-byte ACE (AceType@0, AceFlags@1, + * AceSize@2, Mask@4, inline SID@8). `match` selects whether the inline SID + * bytes equal `sid`. */ -function craftAclWithGrant(sid: NativePtr, match: boolean): NativePtr { +function craftAcl(aceType: number, mask: number, sid: NativePtr, match: boolean, aceSize = 16, aclSize = 24, count = 1): NativePtr { const acl = allocBytes(32) koffi.encode(acl, 'uint8', 2) // AclRevision - koffi.encode(acl, 2, 'uint16', 24) // AclSize: 8-byte header + one 16-byte ACE - koffi.encode(acl, 4, 'uint16', 1) // AceCount + koffi.encode(acl, 2, 'uint16', aclSize) + koffi.encode(acl, 4, 'uint16', count) const ace = 8 - koffi.encode(acl, ace + 0, 'uint8', abi.ACCESS_ALLOWED_ACE_TYPE) + koffi.encode(acl, ace + 0, 'uint8', aceType) koffi.encode(acl, ace + 1, 'uint8', abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT) - koffi.encode(acl, ace + 2, 'uint16', 16) // AceSize: header + mask + inline 8-byte SID - koffi.encode(acl, ace + 4, 'uint32', abi.GRANT_MASK) + koffi.encode(acl, ace + 2, 'uint16', aceSize) + koffi.encode(acl, ace + 4, 'uint32', mask) const inlineSid = ace + 8 for (let offset = 0; offset < 8; offset++) { koffi.encode(acl, inlineSid + offset, 'uint8', match @@ -88,6 +98,29 @@ function craftAclWithGrant(sid: NativePtr, match: boolean): NativePtr { return acl } +/** The DACL the exact-grant skip looks for: the capability ACE with GRANT_MASK. */ +function craftAclWithGrant(sid: NativePtr, match: boolean): NativePtr { + return craftAcl(abi.ACCESS_ALLOWED_ACE_TYPE, abi.GRANT_MASK, sid, match) +} + +/** The label ACL the exact-label skip looks for: the Low no-write-up label ACE. */ +function craftAclWithLabel(sid: NativePtr, match: boolean): NativePtr { + return craftAcl(abi.SYSTEM_MANDATORY_LABEL_ACE_TYPE, abi.SYSTEM_MANDATORY_LABEL_NO_WRITE_UP, sid, match) +} + +/** Encode a security-info read whose DACL and label ACL are the given pointers. */ +function readStub(dacl: NativePtr | null, label: NativePtr | null, descriptor: bigint | null) { + return vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + daclSlot: NativePtr, saclSlot: NativePtr, descriptorSlot: NativePtr, + ) => { + koffi.encode(daclSlot, PVOID, dacl === null ? 0n : ptrAddress(dacl)) + koffi.encode(saclSlot, PVOID, label === null ? 0n : ptrAddress(label)) + koffi.encode(descriptorSlot, PVOID, descriptor ?? 0n) + return 0 + }) +} + describe('withPathLock failure paths', () => { it('fails closed when CreateFileW returns an invalid handle', () => { const api = aclApi({ createFileW: vi.fn(() => 0n as NativePtr) }) @@ -142,13 +175,67 @@ describe('withPathLock failure paths', () => { }) }) +describe('buildLowLabelAcl failure paths', () => { + it('reports a failed GetLengthSid for the Low label SID', () => { + const api = aclApi({ getLengthSid: vi.fn(() => 0) }) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', craftSid(1, 0), craftLowLabelSid()) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('GetLengthSid') + }) + + it('reports a failed LocalAlloc for the label ACL', () => { + const api = aclApi({ localAlloc: vi.fn(() => 0n as NativePtr) }) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', craftSid(1, 0), craftLowLabelSid()) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('LocalAlloc') + }) + + it('frees the label ACL and reports when InitializeAcl fails', () => { + const localFree = vi.fn(() => 0n as NativePtr) + const api = aclApi({ initializeAcl: vi.fn(() => 0), localFree }) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', craftSid(1, 0), craftLowLabelSid()) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('InitializeAcl') + expect(localFree).toHaveBeenCalledWith(11n) + }) + + it('frees the label ACL and reports when AddMandatoryAce fails', () => { + const localFree = vi.fn(() => 0n as NativePtr) + const api = aclApi({ addMandatoryAce: vi.fn(() => 0), localFree }) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', craftSid(1, 0), craftLowLabelSid()) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('AddMandatoryAce') + expect(localFree).toHaveBeenCalledWith(11n) + }) +}) + describe('mergeAndApply failure paths', () => { it('reports a SetEntriesInAclW failure when the directory carries no descriptor to free', () => { const api = aclApi({ setEntriesInAclW: vi.fn(() => 5) }) // default descriptor: none const sid = craftSid(1, 0) let caught: unknown try { - grantWrite(api, 'C:\\granted', sid) + grantWrite(api, 'C:\\granted', sid, craftLowLabelSid()) } catch (error) { caught = error } @@ -161,7 +248,7 @@ describe('mergeAndApply failure paths', () => { const sid = craftSid(1, 0) let caught: unknown try { - grantWrite(api, 'C:\\granted', sid) + grantWrite(api, 'C:\\granted', sid, craftLowLabelSid()) } catch (error) { caught = error } @@ -172,21 +259,14 @@ describe('mergeAndApply failure paths', () => { it('frees the descriptor and reports when SetEntriesInAclW fails', () => { const localFree = vi.fn(() => 0n as NativePtr) const api = aclApi({ - getNamedSecurityInfoW: vi.fn(( - _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, - ) => { - koffi.encode(dacl, PVOID, 0n) - koffi.encode(descriptor, PVOID, 6n) // an existing explicit DACL - return 0 - }), + getNamedSecurityInfoW: readStub(null, null, 6n), // an existing descriptor without a DACL setEntriesInAclW: vi.fn(() => 5), localFree, }) const sid = craftSid(1, 0) let caught: unknown try { - grantWrite(api, 'C:\\granted', sid) + grantWrite(api, 'C:\\granted', sid, craftLowLabelSid()) } catch (error) { caught = error } @@ -198,21 +278,14 @@ describe('mergeAndApply failure paths', () => { it('frees the descriptor and reports a NULL merged ACL', () => { const localFree = vi.fn(() => 0n as NativePtr) const api = aclApi({ - getNamedSecurityInfoW: vi.fn(( - _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, - ) => { - koffi.encode(dacl, PVOID, 0n) - koffi.encode(descriptor, PVOID, 6n) - return 0 - }), + getNamedSecurityInfoW: readStub(null, null, 6n), setEntriesInAclW: vi.fn(() => 0), // success without writing the out slot localFree, }) const sid = craftSid(1, 0) let caught: unknown try { - grantWrite(api, 'C:\\granted', sid) + grantWrite(api, 'C:\\granted', sid, craftLowLabelSid()) } catch (error) { caught = error } @@ -221,37 +294,31 @@ describe('mergeAndApply failure paths', () => { expect(localFree).toHaveBeenCalledWith(6n) }) - it('frees the merged ACL and reports when SetNamedSecurityInfoW fails', () => { + it('frees the merged ACL and the label ACL and reports when SetNamedSecurityInfoW fails', () => { const localFree = vi.fn(() => 0n as NativePtr) const api = aclApi({ setNamedSecurityInfoW: vi.fn(() => 5), localFree }) const sid = craftSid(1, 0) let caught: unknown try { - grantWrite(api, 'C:\\granted', sid) + grantWrite(api, 'C:\\granted', sid, craftLowLabelSid()) } catch (error) { caught = error } expect(caught).toBeInstanceOf(Win32Error) expect((caught as Win32Error).api).toBe('SetNamedSecurityInfoW') - expect(localFree).toHaveBeenCalledWith(9n) + expect(localFree).toHaveBeenCalledWith(9n) // merged DACL + expect(localFree).toHaveBeenCalledWith(11n) // label ACL }) it('reports a failed descriptor LocalFree after a successful apply', () => { const api = aclApi({ - getNamedSecurityInfoW: vi.fn(( - _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, - ) => { - koffi.encode(dacl, PVOID, 0n) - koffi.encode(descriptor, PVOID, 6n) - return 0 - }), + getNamedSecurityInfoW: readStub(null, null, 6n), localFree: vi.fn(() => 1n as NativePtr), // both frees "fail"; the first is checked }) const sid = craftSid(1, 0) let caught: unknown try { - grantWrite(api, 'C:\\granted', sid) + grantWrite(api, 'C:\\granted', sid, craftLowLabelSid()) } catch (error) { caught = error } @@ -266,74 +333,80 @@ describe('mergeAndApply failure paths', () => { const sid = craftSid(1, 0) let caught: unknown try { - grantWrite(api, 'C:\\granted', sid) + grantWrite(api, 'C:\\granted', sid, craftLowLabelSid()) } catch (error) { caught = error } expect(caught).toBeInstanceOf(Win32Error) expect((caught as Win32Error).api).toBe('LocalFree') }) + + it('reports a failed label-ACL LocalFree after a successful apply', () => { + // Every free succeeds until the label ACL's: descriptor and merged ACL + // return null, the label ACL returns a stale pointer. + const localFree = vi.fn() + .mockReturnValueOnce(0n) + .mockReturnValueOnce(0n) + .mockReturnValue(1n) + const api = aclApi({ + getNamedSecurityInfoW: readStub(null, null, 6n), + localFree, + }) + const sid = craftSid(1, 0) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', sid, craftLowLabelSid()) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('LocalFree') + expect(localFree).toHaveBeenLastCalledWith(11n) + }) }) -describe('the exact-ACE skip and DACL-walk defenses', () => { - it('grantWrite skips the apply when the standing exact ACE matches (descriptor freed, nothing merged)', () => { +describe('the exact-ACE/exact-label skip and ACL-walk defenses', () => { + it('grantWrite skips the apply when the standing exact ACE and the exact label match (descriptor freed, nothing merged)', () => { const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() const localFree = vi.fn(() => 0n as NativePtr) const setNamedSecurityInfoW = vi.fn(() => 0) const api = aclApi({ - getNamedSecurityInfoW: vi.fn(( - _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, - ) => { - koffi.encode(dacl, PVOID, ptrAddress(craftAclWithGrant(sid, true))) - koffi.encode(descriptor, PVOID, 6n) - return 0 - }), + getNamedSecurityInfoW: readStub(craftAclWithGrant(sid, true), craftAclWithLabel(lowSid, true), 6n), localFree, setNamedSecurityInfoW, }) - grantWrite(api, 'C:\\granted', sid) + grantWrite(api, 'C:\\granted', sid, lowSid) expect(setNamedSecurityInfoW).not.toHaveBeenCalled() expect(localFree).toHaveBeenCalledWith(6n) }) - it('grantWrite skips the apply without freeing when the exact ACE stands but no descriptor owns it', () => { + it('grantWrite skips the apply without freeing when the exact ACE and label stand but no descriptor owns them', () => { const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() const localFree = vi.fn(() => 0n as NativePtr) const setNamedSecurityInfoW = vi.fn(() => 0) const api = aclApi({ - getNamedSecurityInfoW: vi.fn(( - _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, - ) => { - koffi.encode(dacl, PVOID, ptrAddress(craftAclWithGrant(sid, true))) - koffi.encode(descriptor, PVOID, 0n) // the read "returned" a bare ACL with no descriptor - return 0 - }), + // the read "returned" ACLs with no descriptor allocation of their own + getNamedSecurityInfoW: readStub(craftAclWithGrant(sid, true), craftAclWithLabel(lowSid, true), null), localFree, setNamedSecurityInfoW, }) - grantWrite(api, 'C:\\granted', sid) + grantWrite(api, 'C:\\granted', sid, lowSid) expect(setNamedSecurityInfoW).not.toHaveBeenCalled() expect(localFree).not.toHaveBeenCalled() }) - it('grantWrite reports a failed descriptor LocalFree on the exact-ACE skip path', () => { + it('grantWrite reports a failed descriptor LocalFree on the exact-ACE/exact-label skip path', () => { const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() const api = aclApi({ - getNamedSecurityInfoW: vi.fn(( - _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, - ) => { - koffi.encode(dacl, PVOID, ptrAddress(craftAclWithGrant(sid, true))) - koffi.encode(descriptor, PVOID, 6n) - return 0 - }), + getNamedSecurityInfoW: readStub(craftAclWithGrant(sid, true), craftAclWithLabel(lowSid, true), 6n), localFree: vi.fn(() => 1n as NativePtr), }) let caught: unknown try { - grantWrite(api, 'C:\\granted', sid) + grantWrite(api, 'C:\\granted', sid, lowSid) } catch (error) { caught = error } @@ -341,48 +414,61 @@ describe('the exact-ACE skip and DACL-walk defenses', () => { expect((caught as Win32Error).api).toBe('LocalFree') }) - it('falls back to the merge path when the standing ACE names a different SID', () => { + it('does not skip when the standing ACE and label match but the label ACL is absent', () => { const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() const setNamedSecurityInfoW = vi.fn(() => 0) const api = aclApi({ - getNamedSecurityInfoW: vi.fn(( - _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, - ) => { - koffi.encode(dacl, PVOID, ptrAddress(craftAclWithGrant(sid, false))) - koffi.encode(descriptor, PVOID, 6n) - return 0 - }), + getNamedSecurityInfoW: readStub(craftAclWithGrant(sid, true), null, 6n), setNamedSecurityInfoW, }) - grantWrite(api, 'C:\\granted', sid) + grantWrite(api, 'C:\\granted', sid, lowSid) + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) + }) + + it('does not skip when the exact ACE stands but the label names another integrity level', () => { + const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: readStub(craftAclWithGrant(sid, true), craftAclWithLabel(lowSid, false), 6n), + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid, lowSid) + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) + }) + + it('falls back to the merge path when the standing ACE names a different SID', () => { + const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: readStub(craftAclWithGrant(sid, false), craftAclWithLabel(lowSid, true), 6n), + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid, lowSid) expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) }) it('treats an implausibly small ACL size as no exact grant', () => { const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() const acl = allocBytes(32) koffi.encode(acl, 'uint8', 2) koffi.encode(acl, 2, 'uint16', 4) // smaller than the 8-byte ACL header koffi.encode(acl, 4, 'uint16', 1) const setNamedSecurityInfoW = vi.fn(() => 0) const api = aclApi({ - getNamedSecurityInfoW: vi.fn(( - _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, - ) => { - koffi.encode(dacl, PVOID, ptrAddress(acl)) - koffi.encode(descriptor, PVOID, 6n) - return 0 - }), + getNamedSecurityInfoW: readStub(acl, craftAclWithLabel(lowSid, true), 6n), setNamedSecurityInfoW, }) - grantWrite(api, 'C:\\granted', sid) + grantWrite(api, 'C:\\granted', sid, lowSid) expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) }) it('treats an ACE that would overrun the ACL as no exact grant', () => { const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() const acl = allocBytes(32) koffi.encode(acl, 'uint8', 2) koffi.encode(acl, 2, 'uint16', 8) // header only: no room for any ACE @@ -390,17 +476,76 @@ describe('the exact-ACE skip and DACL-walk defenses', () => { koffi.encode(acl, 10, 'uint16', 100) // the walk reads a lying ACE size const setNamedSecurityInfoW = vi.fn(() => 0) const api = aclApi({ - getNamedSecurityInfoW: vi.fn(( - _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, - ) => { - koffi.encode(dacl, PVOID, ptrAddress(acl)) - koffi.encode(descriptor, PVOID, 6n) - return 0 - }), + getNamedSecurityInfoW: readStub(acl, craftAclWithLabel(lowSid, true), 6n), setNamedSecurityInfoW, }) - grantWrite(api, 'C:\\granted', sid) + grantWrite(api, 'C:\\granted', sid, lowSid) + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) + }) + + it('treats an implausibly small label ACL size as no exact label', () => { + const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() + const label = allocBytes(32) + koffi.encode(label, 'uint8', 2) + koffi.encode(label, 2, 'uint16', 4) // smaller than the 8-byte ACL header + koffi.encode(label, 4, 'uint16', 1) + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: readStub(craftAclWithGrant(sid, true), label, 6n), + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid, lowSid) + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) + }) + + it('treats a label ACE that would overrun its ACL as no exact label', () => { + const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() + const label = allocBytes(32) + koffi.encode(label, 'uint8', 2) + koffi.encode(label, 2, 'uint16', 8) // header only: no room for any ACE + koffi.encode(label, 4, 'uint16', 1) + koffi.encode(label, 10, 'uint16', 100) // the walk reads a lying ACE size + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: readStub(craftAclWithGrant(sid, true), label, 6n), + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid, lowSid) + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) + }) + + it('does not treat a no-write-up ACL Allow ACE as the mandatory label', () => { + const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + // Same mask and SID, but an ACCESS_ALLOWED_ACE is not a mandatory label. + getNamedSecurityInfoW: readStub( + craftAclWithGrant(sid, true), + craftAcl(abi.ACCESS_ALLOWED_ACE_TYPE, abi.SYSTEM_MANDATORY_LABEL_NO_WRITE_UP, lowSid, true), + 6n, + ), + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid, lowSid) + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) + }) + + it('does not treat a label ACE granting write-up as the exact label', () => { + const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: readStub( + craftAclWithGrant(sid, true), + craftAcl(abi.SYSTEM_MANDATORY_LABEL_ACE_TYPE, 0, lowSid, true), + 6n, + ), + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid, lowSid) expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) }) }) @@ -416,14 +561,7 @@ describe('revokeWrite no-DACL path', () => { it('frees a descriptor that carries no DACL and reports nothing to revoke', () => { const localFree = vi.fn(() => 0n as NativePtr) const api = aclApi({ - getNamedSecurityInfoW: vi.fn(( - _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, - ) => { - koffi.encode(dacl, PVOID, 0n) - koffi.encode(descriptor, PVOID, 6n) // descriptor WITHOUT a DACL - return 0 - }), + getNamedSecurityInfoW: readStub(null, null, 6n), // descriptor WITHOUT a DACL localFree, }) const sid = craftSid(1, 0) @@ -433,14 +571,7 @@ describe('revokeWrite no-DACL path', () => { it('reports a failed descriptor LocalFree on the no-DACL path', () => { const api = aclApi({ - getNamedSecurityInfoW: vi.fn(( - _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, - ) => { - koffi.encode(dacl, PVOID, 0n) - koffi.encode(descriptor, PVOID, 6n) - return 0 - }), + getNamedSecurityInfoW: readStub(null, null, 6n), localFree: vi.fn(() => 1n as NativePtr), }) const sid = craftSid(1, 0) diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts index 8d548a16df..3b2bb976e5 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts @@ -17,7 +17,7 @@ import koffi from 'koffi' import { buildExplicitAccess, grantWrite, lockFilePath, revokeWrite, withPathLock } from '../src/acl.ts' import { AclSandbox } from '../src/index.ts' -import { createRestrictedToken } from '../src/token.ts' +import { createRestrictedToken, makeWellKnownSid } from '../src/token.ts' import { allocOverlapped, allocPtrSlot, decodePtr, isInvalidHandle, isNullPtr, win32 } from '../src/ffi.ts' import type { NativePtr, Win32Bindings } from '../src/ffi.ts' import * as abi from '../src/win32-abi.ts' @@ -104,6 +104,54 @@ function readDirectAces(api: Win32Bindings, path: string): DirectAce[] { } } +/** One direct (explicit, non-inherited) label ACE of a directory's mandatory label. */ +interface DirectLabelAce { + sid: string + mask: number +} + +/** The Low integrity SID every grant labels with (S-1-16-4096). */ +function lowLabelSid(api: Win32Bindings): NativePtr { + return makeWellKnownSid(api, abi.WinLowLabelSid) +} + +/** + * Read the directory's mandatory label ACEs (inherited entries excluded) — + * same ACE layout as {@link readDirectAces}, read through the label security + * information class. A directory with no label returns an empty list. + */ +function readLabelAces(api: Win32Bindings, path: string): DirectLabelAce[] { + const ownerSlot = allocPtrSlot() + const groupSlot = allocPtrSlot() + const daclSlot = allocPtrSlot() + const saclSlot = allocPtrSlot() + const descriptorSlot = allocPtrSlot() + const readResult = api.getNamedSecurityInfoW( + path, abi.SE_FILE_OBJECT, abi.LABEL_SECURITY_INFORMATION, + ownerSlot, groupSlot, daclSlot, saclSlot, descriptorSlot, + ) + if (readResult !== abi.ERROR_SUCCESS) throw new Error(`GetNamedSecurityInfoW (label) failed (${readResult}) for ${path}`) + const acl = decodePtr(saclSlot) + const descriptor = decodePtr(descriptorSlot) + try { + if (acl === null) return [] + const aclSize = koffi.decode(acl, 2, 'uint16') as number + const aces: DirectLabelAce[] = [] + for (let offset = 8; offset + 8 <= aclSize;) { + const type = koffi.decode(acl, offset, 'uint8') as number + const flags = koffi.decode(acl, offset + 1, 'uint8') as number + const aceSize = koffi.decode(acl, offset + 2, 'uint16') as number + if (type === abi.SYSTEM_MANDATORY_LABEL_ACE_TYPE && (flags & abi.INHERITED_ACE) === 0) { + aces.push({ sid: sidString(koffi.decode(acl, offset + 8, SID_STRUCT) as SidLayout), mask: koffi.decode(acl, offset + 4, 'uint32') as number }) + } + offset += aceSize + } + return aces + } finally { + if (descriptor !== null) api.localFree(descriptor) + } +} + describe.skipIf(!isWin32)('ACL editing', () => { const scratchDirs: string[] = [] afterEach(() => { @@ -121,6 +169,7 @@ describe.skipIf(!isWin32)('ACL editing', () => { const dir = scratch() const usersSid = sidFromString(api, 'S-1-5-32-545') const capabilitySid = sidFromString(api, 'S-1-4-4242-1') + const lowSid = lowLabelSid(api) try { // Install one explicit ACE (Users + benign read mask) with the // package's own bindings, exactly like a pre-existing explicit DACL @@ -137,37 +186,61 @@ describe.skipIf(!isWin32)('ACL editing', () => { expect(applyResult, `SetNamedSecurityInfoW setup (${applyResult})`).toBe(abi.ERROR_SUCCESS) expect(isNullPtr(freed)).toBe(true) - grantWrite(api, dir, capabilitySid) + grantWrite(api, dir, capabilitySid, lowSid) revokeWrite(api, dir, capabilitySid) const aces = readDirectAces(api, dir) expect(aces.some(ace => ace.sid === 'S-1-5-32-545')).toBe(true) // explicit ACE preserved expect(aces.some(ace => ace.sid === 'S-1-4-4242-1')).toBe(false) // orphan grant fully removed + expect(readLabelAces(api, dir)).toEqual([]) // the label went with it } finally { if (!isNullPtr(usersSid)) api.localFree(usersSid) if (!isNullPtr(capabilitySid)) api.localFree(capabilitySid) + if (!isNullPtr(lowSid)) api.localFree(lowSid) } }) - it('grantWrite is idempotent: a second grant over the standing exact ACE skips the SetNamedSecurityInfoW apply (no eager full-tree re-propagation)', async () => { + it('grantWrite applies the Low no-write-up label; revokeWrite clears it in the same edit', async () => { + const api = await win32() + const dir = scratch() + const capabilitySid = sidFromString(api, 'S-1-4-4242-9') + const lowSid = lowLabelSid(api) + try { + expect(readLabelAces(api, dir)).toEqual([]) + grantWrite(api, dir, capabilitySid, lowSid) + expect(readLabelAces(api, dir)).toEqual([ + { sid: 'S-1-16-4096', mask: abi.SYSTEM_MANDATORY_LABEL_NO_WRITE_UP }, + ]) + revokeWrite(api, dir, capabilitySid) + expect(readLabelAces(api, dir)).toEqual([]) + } finally { + if (!isNullPtr(capabilitySid)) api.localFree(capabilitySid) + if (!isNullPtr(lowSid)) api.localFree(lowSid) + } + }) + + it('grantWrite is idempotent: a second grant over the standing exact ACE and label skips the SetNamedSecurityInfoW apply (no eager full-tree re-propagation)', async () => { const api = await win32() const dir = scratch() const capabilitySid = sidFromString(api, 'S-1-4-4242-2') + const lowSid = lowLabelSid(api) const apply = vi.spyOn(api, 'setNamedSecurityInfoW') try { - grantWrite(api, dir, capabilitySid) + grantWrite(api, dir, capabilitySid, lowSid) expect(apply).toHaveBeenCalledTimes(1) - // The exact ACE now stands (the per-session grant surviving from a - // previous server lifetime): the second grant is a DACL read only. - grantWrite(api, dir, capabilitySid) + // The exact ACE and label now stand (the per-session grant surviving + // from a previous server lifetime): the second grant is a read only. + grantWrite(api, dir, capabilitySid, lowSid) expect(apply).toHaveBeenCalledTimes(1) const aces = readDirectAces(api, dir) expect(aces.filter(ace => ace.sid === 'S-1-4-4242-2')).toHaveLength(1) + expect(readLabelAces(api, dir)).toHaveLength(1) revokeWrite(api, dir, capabilitySid) expect(readDirectAces(api, dir).some(ace => ace.sid === 'S-1-4-4242-2')).toBe(false) } finally { apply.mockRestore() if (!isNullPtr(capabilitySid)) api.localFree(capabilitySid) + if (!isNullPtr(lowSid)) api.localFree(lowSid) } }) @@ -188,7 +261,7 @@ describe.skipIf(!isWin32)('ACL editing', () => { expect(aces.some(ace => ace.sid === 'S-1-4-9000-2')).toBe(true) }) - it('dispose revokes the revocable temp ACE and keeps the standing workspace ACE (self-managed flow)', async () => { + it('dispose revokes the revocable temp ACE and label and keeps the standing workspace pair (self-managed flow)', async () => { const api = await win32() const workspaceDir = scratch() const tempDir = scratch() @@ -203,8 +276,12 @@ describe.skipIf(!isWin32)('ACL editing', () => { sandbox.dispose() const workspaceAces = readDirectAces(api, workspaceDir) expect(workspaceAces.some(ace => ace.sid === 'S-1-4-9000-3')).toBe(true) + expect(readLabelAces(api, workspaceDir)).toEqual([ + { sid: 'S-1-16-4096', mask: abi.SYSTEM_MANDATORY_LABEL_NO_WRITE_UP }, + ]) const tempAces = readDirectAces(api, tempDir) expect(tempAces.some(ace => ace.sid === 'S-1-4-9000-3-1')).toBe(false) + expect(readLabelAces(api, tempDir)).toEqual([]) }) it('rejects an overlapping private temp directory before applying either capability', async () => { diff --git a/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts index e22a46060c..8be8b30a86 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts @@ -23,6 +23,12 @@ function grantThenFailApi(): { api: Win32Bindings; failReads: () => void } { koffi.encode(slot, PVOID, 42n) return 1 }), + createWellKnownSid: vi.fn(() => 1), + isValidSid: vi.fn(() => 1), + getLengthSid: vi.fn(() => 12), + localAlloc: vi.fn(() => 11n), + initializeAcl: vi.fn(() => 1), + addMandatoryAce: vi.fn(() => 1), getTempPathW: vi.fn((_length: number, buffer: Buffer) => { const temp = tmpdir().endsWith('/') || tmpdir().endsWith('\\') ? tmpdir() : `${tmpdir()}/` buffer.write(temp, 'utf16le') @@ -34,10 +40,11 @@ function grantThenFailApi(): { api: Win32Bindings; failReads: () => void } { closeHandle: vi.fn(() => 1), getNamedSecurityInfoW: vi.fn(( _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + dacl: NativePtr, sacl: NativePtr, descriptor: NativePtr, ) => { if (state.failReads) return 2 // ERROR_FILE_NOT_FOUND — the revoke's read fails koffi.encode(dacl, PVOID, 0n) // no explicit DACL: the merge builds one + koffi.encode(sacl, PVOID, 0n) koffi.encode(descriptor, PVOID, 0n) return 0 }), @@ -90,6 +97,8 @@ describe('AclWriteGrant failure paths', () => { koffi.encode(slot, PVOID, 42n) return 1 }), + createWellKnownSid: vi.fn(() => 1), + isValidSid: vi.fn(() => 1), localFree: vi.fn(() => 1n), // non-NULL: LocalFree "failed" getLastError: vi.fn(() => 87), formatMessageW: vi.fn(() => 0), @@ -97,4 +106,17 @@ describe('AclWriteGrant failure paths', () => { const grant = AclWriteGrant.create('S-1-4-42-42', api) expect(() =>{ grant.dispose() }).toThrow(AggregateError) }) + + it('create fails closed when the Low label SID cannot be created', () => { + const api = { + convertStringSidToSidW: vi.fn((_sid: string, slot: NativePtr) => { + koffi.encode(slot, PVOID, 42n) + return 1 + }), + createWellKnownSid: vi.fn(() => 0), + getLastError: vi.fn(() => 87), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + expect(() => AclWriteGrant.create('S-1-4-42-42', api)).toThrow(/CreateWellKnownSid/) + }) }) diff --git a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts index 50a4a9987e..95f639df88 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts @@ -35,6 +35,8 @@ interface HappyStubs { createRestrictedToken: MockFn createJobObjectW: MockFn getNamedSecurityInfoW: MockFn + setTokenInformation: MockFn + addMandatoryAce: MockFn } const state = vi.hoisted(() => ({ stubs: undefined as HappyStubs | undefined })) @@ -86,9 +88,10 @@ function happyStubs(): HappyStubs { const createFileW = vi.fn(() => fresh()) const getNamedSecurityInfoW = vi.fn(( _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + dacl: NativePtr, sacl: NativePtr, descriptor: NativePtr, ) => { koffi.encode(dacl, PVOID, 0n) + koffi.encode(sacl, PVOID, 0n) koffi.encode(descriptor, PVOID, 0n) return 0 }) @@ -97,6 +100,9 @@ function happyStubs(): HappyStubs { return 0 }) const setNamedSecurityInfoW = vi.fn(() => 0) + const localAlloc = vi.fn(() => fresh()) + const initializeAcl = vi.fn(() => 1) + const addMandatoryAce = vi.fn(() => 1) const getTokenInformation = vi.fn((_token: unknown, cls: number, info: Buffer | null, _length: number, needed: NativePtr) => { if (info === null) { koffi.encode(needed, 'uint32', cls === abi.TokenGroups ? 24 : 8) @@ -158,6 +164,7 @@ function happyStubs(): HappyStubs { openProcess, openProcessToken, convertStringSidToSidW, getTempPathW, createFileW, lockFileEx: vi.fn(() => 1), unlockFileEx: vi.fn(() => 1), getNamedSecurityInfoW, setEntriesInAclW, setNamedSecurityInfoW, getTokenInformation, + localAlloc, initializeAcl, addMandatoryAce, getLengthSid, copySid, createWellKnownSid, isValidSid, createRestrictedToken, setTokenInformation, createPipe, setHandleInformation, createProcessAsUserW, peekNamedPipe, readFile, waitForSingleObject, getExitCodeProcess, createJobObjectW, @@ -167,7 +174,7 @@ function happyStubs(): HappyStubs { } as unknown as Win32Bindings return { api, setNamedSecurityInfoW, convertStringSidToSidW, closeHandle, localFree, - createRestrictedToken, createJobObjectW, getNamedSecurityInfoW, + createRestrictedToken, createJobObjectW, getNamedSecurityInfoW, setTokenInformation, addMandatoryAce, } } @@ -338,10 +345,11 @@ describe('AclSandbox init', () => { localFree.mockImplementation(() => (inCleanup ? 1n : 0n)) getNamedSecurityInfoW.mockImplementation(( _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + dacl: NativePtr, sacl: NativePtr, descriptor: NativePtr, ) => { if (inCleanup) return 2 // the cleanup's revocation read fails too koffi.encode(dacl, PVOID, 0n) + koffi.encode(sacl, PVOID, 0n) koffi.encode(descriptor, PVOID, 0n) return 0 }) @@ -352,7 +360,8 @@ describe('AclSandbox init', () => { tempWriteSid: 'S-1-4-9000-10-1', mode: 'workspace-write', }) - await expect(sandbox.init()).rejects.toThrow(/5 cleanup operation\(s\) also failed/u) + // Five SID frees/mutations fail plus the Low label SID the init allocated. + await expect(sandbox.init()).rejects.toThrow(/6 cleanup operation\(s\) also failed/u) expect(sandbox.tempDir).toBeUndefined() }) }) diff --git a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts index b0d364202b..a58ac01d82 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts @@ -78,10 +78,13 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { const probe = [ "$ErrorActionPreference='SilentlyContinue';", // The private-temp capability lets PowerShell complete its startup - // AppLocker probe, so without a host policy workspace-write stays in - // FullLanguage. Read-only cannot create those scratch files and fails - // that probe closed to ConstrainedLanguage (pinned below). + // AppLocker probe, so workspace-write stays in FullLanguage. Read-only + // cannot create those scratch files and fails that probe closed to + // ConstrainedLanguage (pinned below). '\'LANGMODE: \' + $ExecutionContext.SessionState.LanguageMode;', + // The integrity level the token was lowered to: the mandatory label on + // the granted directories is what makes them writable at this level. + "(whoami /groups | Select-String 'Mandatory Label') -replace '\\s+', ' ';", `try{Set-Content -Path '${writableDir}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`, "try{Set-Content -Path (Join-Path $env:TEMP 'child-wrote.txt') -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};", `try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK (ESCAPE!)'}catch{'ESCAPE-WRITE: DENIED'};`, @@ -98,6 +101,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { ]) expect(result.status, `stderr: ${result.stderr}`).toBe(0) expect(result.stdout).toContain('LANGMODE: FullLanguage') + expect(result.stdout).toContain('S-1-16-4096') expect(result.stdout).toContain('TARGET-WRITE: OK') expect(result.stdout).toContain('TEMP-WRITE: OK') expect(result.stdout).toContain('ESCAPE-WRITE: DENIED') @@ -395,19 +399,72 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { } }, 30_000) - it('partial boundary: an external Everyone-Modify directory stays writable under BOTH modes', () => { + it('delete-escape regression: deletes outside the granted roots are denied in BOTH modes, through every delete authority', () => { + // Windows authorizes a delete from EITHER the object's own DELETE right OR + // the parent directory's FILE_DELETE_CHILD right, and the + // WRITE_RESTRICTED pass-2 intersection only covers the first. Every + // ordinary directory the ambient user SIDs control (a project checkout, + // %TEMP%, the profile) grants FILE_DELETE_CHILD, so `cmd /c del` deleted + // files outside the workspace even though a plain write from the same + // child was denied. The Low mandatory label closes both routes because the + // kernel applies the integrity policy inside the access check, whichever + // right supplied the authority; the remaining delete paths below are the + // ones a confined child reaches for. + for (const mode of ['read-only', 'workspace-write'] as const) { + const victims = { + cmd: join(scratchRoot, `delete-${mode}-cmd.txt`), + dotnet: join(scratchRoot, `delete-${mode}-dotnet.txt`), + remove: join(scratchRoot, `delete-${mode}-remove.txt`), + unlink: join(scratchRoot, `delete-${mode}-unlink.txt`), + inside: join(writableDir, `delete-${mode}-inside.txt`), + } + for (const path of Object.values(victims)) writeFileSync(path, 'delete me') + const quoted = (path: string) => `'${path}'` + const probe = [ + "$ErrorActionPreference='SilentlyContinue';", + `& '${process.env.SystemRoot ?? 'C:\\Windows'}\\System32\\cmd.exe' /c del /f /q ${quoted(victims.cmd)} 2>&1 | Out-Null;` + + `'CMD-DEL: ' + $(if (Test-Path -LiteralPath ${quoted(victims.cmd)}) {'DENIED'} else {'DELETED'});`, + `try{[System.IO.File]::Delete(${quoted(victims.dotnet)});'DOTNET-DELETE: DELETED'}catch{'DOTNET-DELETE: DENIED'};`, + `try{Remove-Item -LiteralPath ${quoted(victims.remove)} -ErrorAction Stop;'REMOVE-ITEM: DELETED'}catch{'REMOVE-ITEM: DENIED'};`, + `node -e "const fs=require('node:fs');try{fs.unlinkSync(process.argv[1]);console.log('NODE-UNLINK: DELETED')}catch(e){console.log('NODE-UNLINK: DENIED')}" ${quoted(victims.unlink)};`, + `try{[System.IO.File]::Delete(${quoted(victims.inside)});'INSIDE-DELETE: DELETED'}catch{'INSIDE-DELETE: DENIED'}`, + ].join('') + const result = runRunner([ + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', mode, + '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe, + ]) + expect(result.status, `mode: ${mode}\nstderr: ${result.stderr}`).toBe(0) + expect(result.stdout, `mode: ${mode}`).toContain('CMD-DEL: DENIED') + expect(result.stdout, `mode: ${mode}`).toContain('DOTNET-DELETE: DENIED') + expect(result.stdout, `mode: ${mode}`).toContain('REMOVE-ITEM: DENIED') + expect(result.stdout, `mode: ${mode}`).toContain('NODE-UNLINK: DENIED') + for (const path of [victims.cmd, victims.dotnet, victims.remove, victims.unlink]) { + expect(existsSync(path), `mode: ${mode}, ${path}`).toBe(true) + } + // The granted root keeps its own delete authority: read-only carries no + // write SID, so only workspace-write may delete there. + expect(result.stdout, `mode: ${mode}`).toContain( + mode === 'workspace-write' ? 'INSIDE-DELETE: DELETED' : 'INSIDE-DELETE: DENIED', + ) + expect(existsSync(victims.inside), `mode: ${mode}`).toBe(mode === 'read-only') + } + }, 60_000) + + it('the Low mandatory label closes the Everyone-Modify ambient boundary under BOTH modes', () => { // Everyone is a required keep-alive restricting SID: without it early DLL // initialization and CNG fail. A normal DACL that grants Everyone Modify - // therefore also clears the WRITE_RESTRICTED pass-2 check. Pin this - // unavoidable gap beside the provider's `partial` enforcement report. + // therefore also clears the WRITE_RESTRICTED pass-2 check. The Low label + // still denies the write, because the kernel evaluates the integrity + // policy inside the access check regardless of which right supplied the + // authority. for (const mode of ['read-only', 'workspace-write'] as const) { const target = join(worldWritableDir, `${mode}.txt`) const result = runRunner([ '--workspace', writableDir, '--temp', isolatedTemp, '--mode', mode, '--', process.execPath, '-e', "require('node:fs').writeFileSync(process.argv[1], 'written')", target, ]) - expect(result.status, `mode: ${mode}\nstderr: ${result.stderr}`).toBe(0) - expect(existsSync(target), `mode: ${mode}`).toBe(true) + expect(result.status, `mode: ${mode}\nstderr: ${result.stderr}`).not.toBe(0) + expect(existsSync(target), `mode: ${mode}`).toBe(false) } }, 30_000) diff --git a/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts index 6149d75277..36f18ff03d 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts @@ -15,7 +15,7 @@ import koffi from 'koffi' import { allocBytes, isNullPtr } from '../src/ffi.ts' import type { NativePtr, Win32Bindings } from '../src/ffi.ts' import { - createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken, setTokenDefaultDaclGrant, + createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken, restrictTokenIntegrity, setTokenDefaultDaclGrant, } from '../src/token.ts' import * as abi from '../src/win32-abi.ts' @@ -371,6 +371,41 @@ describe('setTokenDefaultDaclGrant failure paths', () => { }) }) +describe('restrictTokenIntegrity', () => { + it('sets TokenIntegrityLevel to the Low label SID with SE_GROUP_INTEGRITY', () => { + const lowSid = allocBytes(12) + const setTokenInformation = vi.fn(( + _token: unknown, cls: number, info: Buffer, length: number, + ) => { + expect(cls).toBe(abi.TokenIntegrityLevel) + expect(length).toBe(abi.TOKEN_MANDATORY_LABEL_SIZE + 12) + expect(info.readBigUInt64LE(0)).toBe(koffi.address(lowSid)) + expect(info.readUInt32LE(8)).toBe(abi.SE_GROUP_INTEGRITY) + return 1 + }) + const api = { getLengthSid: vi.fn(() => 12), setTokenInformation } as unknown as Win32Bindings + restrictTokenIntegrity(api, 5n as NativePtr, lowSid) + expect(setTokenInformation).toHaveBeenCalledTimes(1) + }) + + it('fails closed when SetTokenInformation rejects the integrity level', () => { + const api = { + getLengthSid: vi.fn(() => 12), + setTokenInformation: vi.fn(() => 0), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + let caught: unknown + try { + restrictTokenIntegrity(api, 5n as NativePtr, allocBytes(12)) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetTokenInformation') + }) +}) + describe('createRestrictedToken failure paths', () => { it('builds the read-only restricting list without a write SID', () => { const create = vi.fn(( From 36e632751e9684b3aad7f174488953ae5a41db85 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 19 Sep 2026 02:06:30 +0800 Subject: [PATCH 2/9] fix(sandbox): deny the ambient parent-delete right inside granted roots The Low integrity label confines writes and deletes to the labeled roots, but every granted root is labeled Low, so the integrity check alone still let one confined child delete inside ANOTHER granted root (another workspace, a live sibling temp directory): Windows authorizes a delete from the parent directory's FILE_DELETE_CHILD right, which the write-restricted intersection never reaches. Each grant now also denies FILE_DELETE_CHILD to the world SID in the same SetNamedSecurityInfoW call, so the capability ACE's DELETE bit is the only delete authority inside a granted root. The idempotent skip additionally requires the exact deny, so a root granted by an earlier build receives it on its next provision. Delete paths inside the roots keep working; a file whose own DACL grants no DELETE is no longer deletable through its parent's rights, for the confined child and for the user's own processes alike. --- ...ows-acl-restricted-token-sandbox.i18n.yaml | 4 +- ...08-windows-acl-restricted-token-sandbox.md | 6 +- ...windows-acl-restricted-token-sandbox.zh.md | 6 +- .../sandbox-windows-acl/README.i18n.yaml | 4 +- .../sandbox/sandbox-windows-acl/README.md | 11 +- .../sandbox/sandbox-windows-acl/README.zh.md | 11 +- .../sandbox/sandbox-windows-acl/src/acl.ts | 156 +++++++++---- .../sandbox/sandbox-windows-acl/src/grant.ts | 43 +++- .../sandbox/sandbox-windows-acl/src/index.ts | 14 +- .../sandbox-windows-acl/src/win32-abi.ts | 4 + .../tests/acl-failure-paths.spec.ts | 220 +++++++++++++----- .../sandbox-windows-acl/tests/acl.spec.ts | 38 ++- .../sandbox-windows-acl/tests/runner.spec.ts | 65 ++++++ 13 files changed, 432 insertions(+), 150 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml index aa10470b1a..43bc59bf13 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md -2026-08-08-windows-acl-restricted-token-sandbox.md: 1218d39271908cd09440646608088deb3ca64ed4 -2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 8b78fbe7132064f81a1f12f2c3a4e177b13337a1 +2026-08-08-windows-acl-restricted-token-sandbox.md: 2ab157c697d8917f2e3c6315e5ef161ce6eb5229 +2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 10cac08c3161c6c723414c4274333af4eebc36f3 diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md index 1218d39271..2ab157c697 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md @@ -10,7 +10,7 @@ The original [sandbox decision](2026-07-06-sandbox.md) left `PLATFORM_CHAINS.win ## 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`) 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. 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.md) as the confining executor. +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`) 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.md) as the confining executor. ## How the restriction works (why no new identity) @@ -32,11 +32,11 @@ 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 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](../bug-fix/2026-09-16-windows-subprocess-console-visibility.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](../bug-fix/2026-09-16-windows-subprocess-console-visibility.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. ## Testing -The product-visible Windows roster flip is win32-only, so keyless snapshots that must replay on macOS/Linux cannot cover it; bundle composition specs plus the win32 real-runner suites are the substitute evidence, and the CI Windows lane owns the assembled signal. `sandbox-local/tests/acl-grants.spec.ts` pins random temp allocation, per-session/workspace reuse, fork/workspace separation, crash-resume non-collision, paired argv SIDs, failure cleanup, and standing-versus-revocable lifecycle with Win32 mocked. On Windows, `workspace-sid.spec.ts` pins workspace/temp derivation and domain separation; `acl.spec.ts` pins the real DACL and mandatory-label lifecycle; and `runner.spec.ts` pins the Low integrity level, paired-SID validation, sibling temp denial under a shared workspace SID, the outside-delete regression through `cmd`, .NET, `Remove-Item`, and libuv, per-call agentless temp creation/removal, TMP/TEMP rewriting, mode downgrade, Public and Everyone denial, the hard-link partial boundary, mode-specific PowerShell language behavior, and grandchild stdio. ARM64 and emulated x64 native runs own the architecture-specific acceptance evidence. +The product-visible Windows roster flip is win32-only, so keyless snapshots that must replay on macOS/Linux cannot cover it; bundle composition specs plus the win32 real-runner suites are the substitute evidence, and the CI Windows lane owns the assembled signal. `sandbox-local/tests/acl-grants.spec.ts` pins random temp allocation, per-session/workspace reuse, fork/workspace separation, crash-resume non-collision, paired argv SIDs, failure cleanup, and standing-versus-revocable lifecycle with Win32 mocked. On Windows, `workspace-sid.spec.ts` pins workspace/temp derivation and domain separation; `acl.spec.ts` pins the real DACL, ambient-delete deny, and mandatory-label lifecycle; and `runner.spec.ts` pins the Low integrity level, paired-SID validation, sibling temp denial under a shared workspace SID, the outside-delete regression through `cmd`, .NET, `Remove-Item`, and libuv, the cross-root delete regression against another workspace and another live temp tree, per-call agentless temp creation/removal, TMP/TEMP rewriting, mode downgrade, Public and Everyone denial, the hard-link partial boundary, mode-specific PowerShell language behavior, and grandchild stdio. ARM64 and emulated x64 native runs own the architecture-specific acceptance evidence. ## Related diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md index 8b78fbe713..10cac08c31 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md @@ -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 禁止上调强制标签;无论权限来自哪一项,内核都会在访问检查内部执行该策略,于是被授权根目录保持可写,而其他一切不仅防写、也防删。该机制来自 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) 交付。 ## How the restriction works (why no new identity) @@ -32,11 +32,11 @@ 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` 死亡;[启动可见性](../bug-fix/2026-09-16-windows-subprocess-console-visibility.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` 死亡;[启动可见性](../bug-fix/2026-09-16-windows-subprocess-console-visibility.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 负责记录这些运行限制。 ## 测试 -产品可见的 Windows 阵容切换仅存在于 win32,而必须在 macOS/Linux 上可重放的 keyless 快照无法覆盖它;替代证据是 bundle 组合 spec 加上 win32 真实 runner 套件,组装态信号由 CI 的 Windows lane 负责。`sandbox-local/tests/acl-grants.spec.ts` 在 mock Win32 的情况下钉住随机临时目录分配、按会话/工作区复用、fork/工作区分离、崩溃后恢复不冲突、成对 argv SID、失败清理,以及常驻/可回收生命周期。在 Windows 上,`workspace-sid.spec.ts` 钉住工作区/临时目录派生与域分离;`acl.spec.ts` 钉住真实 DACL 与强制标签生命周期;`runner.spec.ts` 钉住 Low 完整性级别、成对 SID 验证、共享工作区 SID 时对同级会话临时目录的拒绝、经 `cmd`、.NET、`Remove-Item` 与 libuv 触发的越界删除回归、无 agent 调用的逐调用临时目录创建/移除、TMP/TEMP 重写、模式降级、Public 与 Everyone 拒绝、硬链接部分边界、按模式区分的 PowerShell 语言行为与孙进程 stdio。ARM64 与模拟 x64 原生运行负责提供架构特定的验收证据。 +产品可见的 Windows 阵容切换仅存在于 win32,而必须在 macOS/Linux 上可重放的 keyless 快照无法覆盖它;替代证据是 bundle 组合 spec 加上 win32 真实 runner 套件,组装态信号由 CI 的 Windows lane 负责。`sandbox-local/tests/acl-grants.spec.ts` 在 mock Win32 的情况下钉住随机临时目录分配、按会话/工作区复用、fork/工作区分离、崩溃后恢复不冲突、成对 argv SID、失败清理,以及常驻/可回收生命周期。在 Windows 上,`workspace-sid.spec.ts` 钉住工作区/临时目录派生与域分离;`acl.spec.ts` 钉住真实 DACL、环境性删除拒绝与强制标签生命周期;`runner.spec.ts` 钉住 Low 完整性级别、成对 SID 验证、共享工作区 SID 时对同级会话临时目录的拒绝、经 `cmd`、.NET、`Remove-Item` 与 libuv 触发的越界删除回归、针对另一个工作区与另一棵活跃临时目录树的跨根删除回归、无 agent 调用的逐调用临时目录创建/移除、TMP/TEMP 重写、模式降级、Public 与 Everyone 拒绝、硬链接部分边界、按模式区分的 PowerShell 语言行为与孙进程 stdio。ARM64 与模拟 x64 原生运行负责提供架构特定的验收证据。 ## Related diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index e197dfeeef..813c667920 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sandbox/sandbox-windows-acl/README.md -README.md: 92e08b922717f1f548350c2aac26015ae23a250f -README.zh.md: 9aa2bcdf81f1c6df4a99b7590246b14d134c0d93 +README.md: 94d49d76437799b19cb2468e96a020589058d624 +README.zh.md: 7709ddcf4f6d609fbce953946a532952b90ea620 diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index 92e08b9227..94d49d7643 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -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. Two layers enforce the boundary, the write-restricted token's capability-SID intersection and a Low integrity label on the granted directories, but the guarantee stays partial: process startup retains Everyone access, 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 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. ## Table of Contents @@ -63,7 +63,7 @@ sandbox.dispose() // revokes the revocable (temp) grant and label, keeps the sta rmSync(tempDir, { recursive: true, force: true }) ``` -The workspace security descriptor edits are granted standing — `dispose()` leaves them, because they are the cross-instance reuse cache — while the distinct temp SID is granted revocably. Each grant is one call carrying both the capability-SID allow ACE and the Low no-write-up label. The server-side counterpart is the `AclWriteGrant` class: `add(path, standing)` per directory, and `dispose()` revokes the revocable paths and frees the SIDs. +The workspace security descriptor edits are granted standing — `dispose()` leaves them, because they are the cross-instance reuse cache — while the distinct temp SID is granted revocably. Each grant is one call carrying the capability-SID allow ACE, the ambient-delete deny, and the Low no-write-up label. The server-side counterpart is the `AclWriteGrant` class: `add(path, standing)` per directory, and `dispose()` revokes the revocable paths and frees the SIDs. ### What confinement gives you @@ -87,7 +87,7 @@ 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. The integrity label closes that route, because the mandatory check runs inside the access check whichever right supplied the authority, and every granted directory is labeled Low in the same call that adds its ACE. 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-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. 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. ### Modes and token lists @@ -112,10 +112,11 @@ When launched with the subprocess control marker, the runner forwards fd 7 throu ### Verified boundaries - **Everyone stays in both restricting lists, but no longer confers write authority** — the keep-alive group is required for early DLL initialization and CNG; the Low label now denies an Everyone-granted write outside the labeled roots, so that former gap is closed. -- **Writes and deletes are restricted; reads, network, and process visibility are not** — neither layer intersects reads, so a confined child can read any caller-readable file and open sockets; `read-only` therefore needs a read-side policy to be expressed. +- **Inside a granted root, the capability ACE's DELETE bit is the only delete authority** — the grant denies `FILE_DELETE_CHILD` to the world SID, which also removes the ambient default: a file whose own DACL grants no DELETE is no longer deletable through its parent's rights, by the confined child or by the user's own processes. The user's ordinary deletes keep working because the workspace DACL grants them DELETE directly. +- **Writes and deletes are restricted; reads, network, and process visibility are not** — neither layer intersects reads, so a confined child can read any caller-readable file (including files in another workspace) and open sockets; `read-only` therefore needs a read-side policy to be expressed. - **Hard links are file-object aliases, not path aliases** — an inheritable workspace grant propagated onto an existing hard link labels and grants the one underlying file security descriptor, so the same object is writable through an external alias; rejecting multiply-linked files is not viable for ordinary pnpm installations. - **Console isolation is unavailable** — children created with `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` die during DLL initialization with `STATUS_DLL_INIT_FAILED` (`0xC0000142`); children share the host console, and pipe-based stdio redirection is unaffected. -- **Security descriptor edits are standing directory mutations** — workspace ACEs and labels stand by design (the reuse cache, never revoked); temp edits are revoked by `dispose()`; manual `icacls` cleanup cannot revoke them on this platform (`ERROR_NONE_MAPPED`, 1332), so revoke through this module. +- **Security descriptor edits are standing directory mutations** — workspace ACEs, denies, and labels stand by design (the reuse cache, never revoked); temp edits are revoked by `dispose()`, and the deny a revoke leaves behind disappears with the temp directory itself; manual `icacls` cleanup cannot revoke them on this platform (`ERROR_NONE_MAPPED`, 1332), so revoke through this module. - **Granted directories must be caller-owned** — the owner's implicit `WRITE_DAC` is what lets the sandbox edit the DACL without elevation. - **The ambient temp root is never granted implicitly** — direct callers must supply an existing private `tempDir` plus its distinct `tempWriteSid`, or disable temp writes with `tempDir: null`; the actual temp directory must be disjoint from every writable root. - **The confined child's temp capability is private per live session/workspace pair** — the runner rewrites TMP/TEMP to that private directory before the spawn; two tokens sharing the same workspace SID cannot write one another's temp directories. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index 9aa2bcdf81..7709ddcf4f 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -9,7 +9,7 @@ kind: "package-library" ## 概述 -在 Windows 上,本包将子进程的写入与删除限制在工作区和私有临时目录内:`workspace-write` 授予这两处,`read-only` 均不授予。挂载 `dsh-sandbox-local` 后,受限的 bash 与 PowerShell 命令即获得此行为;调用方也可以直接使用公开 `AclSandbox` API;任何 Win32 操作失败都会阻止不受限制的 spawn。边界由两层共同强制:受限令牌的能力 SID 交叉检查,以及授予目录上的 Low 完整性标签;但该保证仍为部分强制:进程启动保留 Everyone 访问,硬链接是文件对象别名,而被其他 AppContainer 工具 ACL 过的文件不可读。 +在 Windows 上,本包将子进程的写入与删除限制在工作区和私有临时目录内:`workspace-write` 授予这两处,`read-only` 均不授予。挂载 `dsh-sandbox-local` 后,受限的 bash 与 PowerShell 命令即获得此行为;调用方也可以直接使用公开 `AclSandbox` API;任何 Win32 操作失败都会阻止不受限制的 spawn。每次授权同时写入能力 SID 允许 ACE、对父目录删除权限的环境性拒绝,以及被降级令牌必须匹配的 Low 完整性标签,因此一个授权根目录无法触及另一个。该保证仍为部分强制:硬链接是文件对象别名,而被其他 AppContainer 工具 ACL 过的文件不可读。 ## 目录 @@ -63,7 +63,7 @@ sandbox.dispose() // revokes the revocable (temp) grant and label, keeps the sta rmSync(tempDir, { recursive: true, force: true }) ``` -工作区的安全描述符改动以常驻方式授予——`dispose()` 保留它们,因为它们是跨实例的复用缓存——而不同的临时 SID 以可回收方式授予。每次授权就是一次调用,同时携带能力 SID 允许 ACE 与 Low 禁止上调标签。服务端对应实现是 `AclWriteGrant` 类:每个目录一次 `add(path, standing)`,`dispose()` 撤销可回收路径并释放各 SID。 +工作区的安全描述符改动以常驻方式授予——`dispose()` 保留它们,因为它们是跨实例的复用缓存——而不同的临时 SID 以可回收方式授予。每次授权就是一次调用,同时携带能力 SID 允许 ACE、环境性删除拒绝与 Low 禁止上调标签。服务端对应实现是 `AclWriteGrant` 类:每个目录一次 `add(path, standing)`,`dispose()` 撤销可回收路径并释放各 SID。 ### 隔离给你带来什么 @@ -87,7 +87,7 @@ rmSync(tempDir, { recursive: true, force: true }) ### 机制 -调用者令牌被复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 携带彼此独立的工作区与私有临时目录能力,该令牌还会被降级为 Low 完整性。Windows 执行两次访问检查——先对正常 SID,再对 restricting SID——并且只在两次检查都通过时才授予写类访问;与此同时,内核的强制完整性检查会拒绝对任何未标记为 Low 的对象进行写类访问。写 SID 交叉检查只覆盖对象自身的那次访问检查:Windows 也可以依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除,而这项权限不需要任何 restricting SID 副署,因此只带交叉检查的令牌仍可删除其环境用户 SID 所控制的任何文件。完整性标签封堵了这条路径:无论权限来自哪一项,强制检查都在访问检查内部执行,而每个被授权目录都在添加 ACE 的同一次调用中被标记为 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。工作区 SID 由规范工作区路径确定性派生(`workspaceWriteSid`),因此工作区根目录的安全描述符改动每台机器每个工作区只物化一次,之后每次会话、调用或重启都命中精确 ACE/精确拒绝/精确标签跳过。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的 SID(`tempWriteSid`),因此各会话共享预期的工作区权限,却不会继承彼此的临时目录权限。每个策略专用 Win32 调用和 [`dsh-win32-process`](../../subprocess/win32-process/README.zh.md) 提供的进程原语都有检查;失败抛出携带 API 名、精确错误码、系统文本与失败上下文的 `Win32Error`——从构造上 fail-closed。 ### 模式与令牌列表 @@ -112,10 +112,11 @@ seam 先把确定性工作区 SID 的 ACE 常驻物化(每个工作区每服 ### 已验证边界 - **Everyone 仍留在两种 restricting 列表中,但不再带来写权限。** 保活组是早期 DLL 初始化与 CNG 所必需的;如今 Low 标签会拒绝对被标记根目录之外、由 Everyone 授权的写入,因此这一旧缺口已关闭。 -- **写入与删除受限;读取、网络与进程可见性不受限。** 两层都不交叉检查读取,因此受限子进程可以读取调用者可读的任何文件并打开套接字;`read-only` 因而需要读侧策略才能表达。 +- **在授权根目录内,能力 ACE 的 DELETE 位是唯一的删除授权来源。** 授权会向 world SID 拒绝 `FILE_DELETE_CHILD`,这同时移除了环境性默认行为:自身 DACL 未授予 DELETE 的文件不再能凭父目录权限删除——受限子进程与用户自身进程皆然。用户日常删除仍然可用,因为工作区 DACL 直接向其授予 DELETE。 +- **写入与删除受限;读取、网络与进程可见性不受限。** 两层都不交叉检查读取,因此受限子进程可以读取调用者可读的任何文件(包括其他工作区中的文件)并打开套接字;`read-only` 因而需要读侧策略才能表达。 - **硬链接是文件对象别名,而非路径别名。** 传播到已有硬链接上的可继承工作区授权会标记并授权底层同一文件的安全描述符,因此同一对象也可通过外部别名写入;拒绝工作区中的所有多链接文件不具可行性,因为普通 pnpm 安装会使用硬链接。 - **控制台隔离不可用。** 以 `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` 创建的子进程在 DLL 初始化期间以 `STATUS_DLL_INIT_FAILED`(`0xC0000142`)死亡;子进程共享宿主控制台,基于管道的 stdio 重定向不受影响。 -- **安全描述符改动是对真实目录的驻留改动。** 工作区 ACE 与标签按设计常驻(复用缓存,绝不撤销);临时改动由 `dispose()` 撤销;手工 `icacls` 清理无法在本平台回收它们(`ERROR_NONE_MAPPED` 1332),请通过本模块回收。 +- **安全描述符改动是对真实目录的驻留改动。** 工作区 ACE、拒绝项与标签按设计常驻(复用缓存,绝不撤销);临时改动由 `dispose()` 撤销,撤销后残留的那条拒绝项会随临时目录本身一并消失;手工 `icacls` 清理无法在本平台回收它们(`ERROR_NONE_MAPPED` 1332),请通过本模块回收。 - **被授权目录必须由调用者拥有。** 所有者的隐式 `WRITE_DAC` 是沙箱无需提权即可编辑 DACL 的原因。 - **环境临时根目录绝不会被隐式授权。** 直接调用方必须提供已存在的私有 `tempDir` 及其不同的 `tempWriteSid`,或用 `tempDir: null` 禁用临时写入;实际临时目录不得与任何可写根目录重叠。 - **受限子进程的临时能力按每个活跃的会话/工作区对私有。** runner 在 spawn 之前把 TMP/TEMP 改写为该私有目录;共享同一工作区 SID 的两个令牌无法写入彼此的临时目录。 diff --git a/packages/sandbox/sandbox-windows-acl/src/acl.ts b/packages/sandbox/sandbox-windows-acl/src/acl.ts index 846670f489..f9ce88d700 100644 --- a/packages/sandbox/sandbox-windows-acl/src/acl.ts +++ b/packages/sandbox/sandbox-windows-acl/src/acl.ts @@ -6,12 +6,16 @@ * system text, and the affected path. * * Each grant is a PAIR of edits applied in ONE SetNamedSecurityInfoW call: - * the capability-SID allow ACE in the DACL, and a Low mandatory label with the - * no-write-up policy in the label ACL ({@link buildLowLabelAcl}). The label - * denies write-class access to every object that is not labeled Low, and the - * kernel applies that policy inside the access check itself — including the - * parent directory's FILE_DELETE_CHILD authority, which the token's - * write-restricted intersection does not reach. + * a Deny ACE removing the ambient parent-directory delete right + * (`Everyone` / `FILE_DELETE_CHILD`) plus the capability-SID allow ACE in the + * DACL, and a Low mandatory label with the no-write-up policy in the label + * ACL ({@link buildLowLabelAcl}). The deny makes the capability ACE's DELETE + * bit the only delete authority inside the granted roots — Windows otherwise + * authorizes a delete from the parent directory's `FILE_DELETE_CHILD`, which + * the token's write-restricted intersection does not reach, so one confined + * child could delete files in another granted root. The label denies + * write-class access to every object that is not labeled Low, and the kernel + * applies that policy inside the access check itself. * * Concurrency: grants are read-merge-write against the directory's CURRENT * DACL, and the whole get-merge-set sequence runs under a per-path exclusive @@ -208,15 +212,16 @@ function hasExactLabel(labelAcl: NativePtr, lowLabelSidPtr: NativePtr): boolean } /** - * Shared tail of grantWrite and revokeWrite: merge `entry` into `oldAcl` + * Shared tail of grantWrite and revokeWrite: merge `entries` into `oldAcl` * (null = no explicit DACL yet; SetEntriesInAclW builds one from scratch), * free the descriptor before applying the merged ACL, apply the merged DACL * together with `labelAcl` in one SetNamedSecurityInfoW call (null clears the * label), then free both ACLs — checking every call and reporting with the - * caller's label. + * caller's label. The entry count derives from the buffer, so a grant can + * carry its capability ACE and its ambient-delete deny in one merge. * @param api - the binding table. * @param path - the directory the DACL and label edits apply to. - * @param entry - the EXPLICIT_ACCESS_W to merge (grant or revoke). + * @param entries - packed EXPLICIT_ACCESS_W records to merge (grant, deny, or revoke). * @param oldAcl - the current explicit DACL (from {@link readCurrentSecurity}). * @param labelAcl - the label ACL to apply, or null to clear the mandatory label. * @param descriptor - the descriptor allocation owning `oldAcl`. @@ -225,14 +230,14 @@ function hasExactLabel(labelAcl: NativePtr, lowLabelSidPtr: NativePtr): boolean function mergeAndApply( api: Win32Bindings, path: string, - entry: Buffer, + entries: Buffer, oldAcl: NativePtr | null, labelAcl: NativePtr | null, descriptor: NativePtr | null, label: string, ): void { const newAclSlot = allocPtrSlot() - const mergeResult = api.setEntriesInAclW(1, entry, oldAcl, newAclSlot) + const mergeResult = api.setEntriesInAclW(entries.length / abi.EXPLICIT_ACCESS_W_SIZE, entries, oldAcl, newAclSlot) if (mergeResult !== abi.ERROR_SUCCESS) { if (descriptor !== null) api.localFree(descriptor) // frees the ACL block too throwWin32(api, 'SetEntriesInAclW', mergeResult, `${label}(${path})`) @@ -259,62 +264,111 @@ function mergeAndApply( } /** - * True when the explicit DACL already carries the EXACT write grant this - * module would add (Allow ACE, OI|CI inheritance, {@link abi.GRANT_MASK}, the - * capability SID). Every field is read through koffi.decode at pointer offsets — - * no memcpy, no pointer arithmetic. The ACE's SID is INLINE (embedded in the - * ACE after the 4-byte mask — there is no pointer to read; reading one - * yields garbage addresses and crashed EqualSid, verified by gdb), so it is - * compared field-by-field against the capability SID through bounded offset - * reads ({@link sameSidAt}). A malformed header reads as "no exact grant" - * so the caller falls back to the merge-apply path, which owns the robust - * failure handling. - * @param oldAcl - the current explicit DACL pointer (from {@link readCurrentDacl}). - * @param sidPtr - the capability SID to match. - * @returns whether the exact grant ACE is already present. + * True when the explicit DACL already carries the EXACT entry + * `(aceType, OI|CI inheritance, mask, trustee SID)`. Every field is read + * through koffi.decode at pointer offsets — no memcpy, no pointer arithmetic. + * The ACE's SID is INLINE (embedded in the ACE after the 4-byte mask — there + * is no pointer to read; reading one yields garbage addresses and crashed + * EqualSid, verified by gdb), so it is compared field-by-field against the + * trustee SID through bounded offset reads ({@link sameSidAt}). Allowed and + * denied ACEs share the Mask@4/SID@8 layout. A malformed header reads as "no + * exact entry" so the caller falls back to the merge-apply path, which owns + * the robust failure handling. + * @param acl - the current explicit DACL pointer (from {@link readCurrentSecurity}). + * @param aceType - the ACE type to match. + * @param mask - the access mask to match. + * @param sidPtr - the trustee SID to match. + * @returns whether the exact entry is already present. */ -function hasExactGrant(oldAcl: NativePtr, sidPtr: NativePtr): boolean { - const aclSize = decodeUint16At(oldAcl, 2) - const aceCount = decodeUint16At(oldAcl, 4) +function hasExactEntry(acl: NativePtr, aceType: number, mask: number, sidPtr: NativePtr): boolean { + const aclSize = decodeUint16At(acl, 2) + const aceCount = decodeUint16At(acl, 4) if (aclSize < 8 || aclSize > 1_048_576) return false // implausible: fall back to the merge path let offset = 8 // the first ACE follows the 8-byte ACL header for (let index = 0; index < aceCount; index++) { - // ACE_HEADER: AceType@0, AceFlags@1, AceSize@2 (WORD); - // ACCESS_ALLOWED_ACE: Mask@4, inline SID@8. - const aceSize = decodeUint16At(oldAcl, offset + 2) + // ACE_HEADER: AceType@0, AceFlags@1, AceSize@2 (WORD); Mask@4, inline SID@8. + const aceSize = decodeUint16At(acl, offset + 2) if (aceSize < 8 || offset + aceSize > aclSize) return false // implausible: fall back to the merge path - const exact = decodeUint8At(oldAcl, offset) === abi.ACCESS_ALLOWED_ACE_TYPE - && decodeUint8At(oldAcl, offset + 1) === abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT - && decodeUint32At(oldAcl, offset + 4) === abi.GRANT_MASK - if (exact && sameSidAt(oldAcl, offset + 8, sidPtr, 0)) return true + const exact = decodeUint8At(acl, offset) === aceType + && decodeUint8At(acl, offset + 1) === abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT + && decodeUint32At(acl, offset + 4) === mask + if (exact && sameSidAt(acl, offset + 8, sidPtr, 0)) return true offset += aceSize } return false } +/** + * True when the explicit DACL already carries the EXACT write grant this + * module would add: the Allow ACE for {@link abi.GRANT_MASK} naming the + * capability SID. + * @param oldAcl - the current explicit DACL pointer (from {@link readCurrentSecurity}). + * @param sidPtr - the capability SID to match. + * @returns whether the exact grant ACE is already present. + */ +function hasExactGrant(oldAcl: NativePtr, sidPtr: NativePtr): boolean { + return hasExactEntry(oldAcl, abi.ACCESS_ALLOWED_ACE_TYPE, abi.GRANT_MASK, sidPtr) +} + +/** + * True when the merge's ambient-delete deny is already present: the exact + * Deny ACE for {@link abi.FILE_DELETE_CHILD} naming the world SID. Together + * with {@link hasExactGrant} and the label check this gates the idempotent + * skip, so a root granted by an earlier build receives the deny on its next + * provision instead of keeping the parent-`FILE_DELETE_CHILD` route open. + * @param oldAcl - the current explicit DACL pointer (from {@link readCurrentSecurity}). + * @param worldSidPtr - the Everyone SID the deny names. + * @returns whether the exact deny ACE is already present. + */ +function hasExactDeny(oldAcl: NativePtr, worldSidPtr: NativePtr): boolean { + return hasExactEntry(oldAcl, abi.ACCESS_DENIED_ACE_TYPE, abi.FILE_DELETE_CHILD, worldSidPtr) +} + /** * Grant `GRANT_MASK` (Write+Delete, displays as "Modify") to the capability SID - * on `path`, inheriting to subcontainers and objects, and apply the Low - * mandatory label to the same directory in the same call. Idempotent: when the - * directory's current explicit DACL already carries the exact ACE AND its label - * ACL the exact label (the per-workspace grant surviving from a previous server - * lifetime), the SetNamedSecurityInfoW apply is SKIPPED — it would otherwise - * re-propagate the identical security descriptor across the whole tree (eager - * inheritance; minutes on large workspaces). Otherwise read-merge-write: the - * new ACE merges into the directory's CURRENT explicit DACL (same shape as - * {@link revokeWrite}), so pre-existing explicit ACEs survive. Runs under the - * per-path lock. The directory must be owned by the caller (owner implicit - * WRITE_DAC) — same precondition as the POC. + * on `path`, deny the ambient `FILE_DELETE_CHILD` right to the world SID, and + * apply the Low mandatory label — all in one SetNamedSecurityInfoW call. + * + * The deny closes the parent-directory delete route inside the granted roots: + * Windows authorizes a delete from the object's own DELETE right OR from the + * parent's `FILE_DELETE_CHILD`, and the token's write-restricted intersection + * reaches only the first, so without the deny one confined child could delete + * files in ANOTHER granted root (another workspace, a live sibling temp + * directory) whose Low label clears the integrity check. With the deny in + * place the capability ACE's DELETE bit is the only delete authority inside + * the granted roots, for the confined child and the ambient user alike; a file + * whose own DACL grants no DELETE is therefore no longer deletable by its + * parent's default rights. + * + * Idempotent: when the directory's current explicit DACL already carries the + * exact ACE and deny AND its label ACL the exact label (the per-workspace + * grant surviving from a previous server lifetime), the SetNamedSecurityInfoW + * apply is SKIPPED — it would otherwise re-propagate the identical security + * descriptor across the whole tree (eager inheritance; minutes on large + * workspaces). Otherwise read-merge-write: the entries merge into the + * directory's CURRENT explicit DACL (same shape as {@link revokeWrite}), so + * pre-existing explicit ACEs survive. Runs under the per-path lock. The + * directory must be owned by the caller (owner implicit WRITE_DAC) — same + * precondition as the POC. * @param api - the binding table. * @param path - the directory whose DACL and label gain the grant (the workspace or temp root). * @param sidPtr - the capability SID the ACE names. * @param lowLabelSidPtr - the Low integrity SID the mandatory label names. + * @param worldSidPtr - the Everyone SID the ambient-delete deny names. */ -export function grantWrite(api: Win32Bindings, path: string, sidPtr: NativePtr, lowLabelSidPtr: NativePtr): void { +export function grantWrite( + api: Win32Bindings, + path: string, + sidPtr: NativePtr, + lowLabelSidPtr: NativePtr, + worldSidPtr: NativePtr, +): void { withPathLock(api, path, () => { const { oldAcl, labelAcl, descriptor } = readCurrentSecurity(api, path) - if (oldAcl !== null && labelAcl !== null && hasExactGrant(oldAcl, sidPtr) && hasExactLabel(labelAcl, lowLabelSidPtr)) { - // The exact ACE and label stand: releasing the descriptor is the whole operation. + if (oldAcl !== null && labelAcl !== null + && hasExactGrant(oldAcl, sidPtr) && hasExactDeny(oldAcl, worldSidPtr) + && hasExactLabel(labelAcl, lowLabelSidPtr)) { + // The exact ACE, deny, and label stand: releasing the descriptor is the whole operation. if (descriptor !== null) { const freed = api.localFree(descriptor) if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', `grantWrite(${path}) descriptor`) @@ -322,7 +376,11 @@ export function grantWrite(api: Win32Bindings, path: string, sidPtr: NativePtr, return } mergeAndApply( - api, path, buildExplicitAccess(sidPtr, abi.GRANT_ACCESS, abi.GRANT_MASK), + api, path, + Buffer.concat([ + buildExplicitAccess(worldSidPtr, abi.DENY_ACCESS, abi.FILE_DELETE_CHILD), + buildExplicitAccess(sidPtr, abi.GRANT_ACCESS, abi.GRANT_MASK), + ]), oldAcl, buildLowLabelAcl(api, lowLabelSidPtr), descriptor, 'grantWrite', ) }) diff --git a/packages/sandbox/sandbox-windows-acl/src/grant.ts b/packages/sandbox/sandbox-windows-acl/src/grant.ts index e31db0e05f..812fec2a43 100644 --- a/packages/sandbox/sandbox-windows-acl/src/grant.ts +++ b/packages/sandbox/sandbox-windows-acl/src/grant.ts @@ -34,20 +34,29 @@ export class AclWriteGrant { private readonly api: Win32Bindings private readonly sidPtr: NativePtr private readonly lowLabelSidPtr: NativePtr + private readonly worldSidPtr: NativePtr private readonly revocablePaths: string[] = [] private readonly standingPaths: string[] = [] - private constructor(api: Win32Bindings, sidPtr: NativePtr, lowLabelSidPtr: NativePtr, writeSid: string) { + private constructor( + api: Win32Bindings, + sidPtr: NativePtr, + lowLabelSidPtr: NativePtr, + worldSidPtr: NativePtr, + writeSid: string, + ) { this.api = api this.sidPtr = sidPtr this.lowLabelSidPtr = lowLabelSidPtr + this.worldSidPtr = worldSidPtr this.writeSid = writeSid } /** - * Parse the SID string, create the Low integrity SID the grants label with, - * and open the binding table (lazily, once per server). Fail-closed: any - * failure throws — nothing is granted yet. + * Parse the SID string, create the Low integrity SID the grants label with + * and the world SID their ambient-delete deny names, and open the binding + * table (lazily, once per server). Fail-closed: any failure throws — nothing + * is granted yet. * @param writeSid - the workspace (`S-1-4-x-y`) or temp (`S-1-4-x-y-1`) capability SID string. * @param api - optional already-resolved bindings (tests). * @returns the ready grant (no ACEs yet). @@ -60,15 +69,21 @@ export class AclWriteGrant { } const sidPtr = decodePtr(sidSlot) if (sidPtr === null) throwLastError(bindings, 'ConvertStringSidToSidW', `null SID for ${writeSid}`) - return new AclWriteGrant(bindings, sidPtr, makeWellKnownSid(bindings, abi.WinLowLabelSid), writeSid) + return new AclWriteGrant( + bindings, + sidPtr, + makeWellKnownSid(bindings, abi.WinLowLabelSid), + makeWellKnownSid(bindings, abi.WinWorldSid), + writeSid, + ) } /** - * Grant the write ACE and the Low mandatory label on one directory - * (idempotent: an already-standing exact ACE plus exact label skips the - * eager full-tree re-propagation — see {@link grantWrite}) and record the - * path for {@link dispose} unless it is standing. The path is recorded - * BEFORE the grant: a post-apply throw (a LocalFree failure after + * 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 + * 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 * materialization and dispose the instance to revoke the paths granted so @@ -80,7 +95,7 @@ export class AclWriteGrant { */ add(path: string, standing = false): void { ;(standing ? this.standingPaths : this.revocablePaths).push(path) - grantWrite(this.api, path, this.sidPtr, this.lowLabelSidPtr) + grantWrite(this.api, path, this.sidPtr, this.lowLabelSidPtr, this.worldSidPtr) } /** Every directory currently carrying the grant, in grant order. */ @@ -98,7 +113,11 @@ export class AclWriteGrant { failures.push(error) } } - for (const [label, sidPtr] of [['write SID', this.sidPtr], ['Low label SID', this.lowLabelSidPtr]] as const) { + for (const [label, sidPtr] of [ + ['write SID', this.sidPtr], + ['Low label SID', this.lowLabelSidPtr], + ['world SID', this.worldSidPtr], + ] as const) { try { const freed = this.api.localFree(sidPtr) if (!isNullPtr(freed)) throwLastError(this.api, 'LocalFree', label) diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 2760758ebb..3ecaee8458 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -263,29 +263,29 @@ export class AclSandbox { // 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). - // The Low integrity SID every granted directory is labeled with and the - // confined token itself is lowered to. + // The Low integrity SID every granted directory is labeled with (and the + // confined token is lowered to) plus the world SID the grants deny the + // ambient parent-directory delete right to. const lowLabelSid = makeWellKnownSid(api, abi.WinLowLabelSid) - this.sidAllocations.push(lowLabelSid) + const worldSid = makeWellKnownSid(api, abi.WinWorldSid) + this.sidAllocations.push(lowLabelSid, worldSid) if (this.manageDacls) { if (this.writeSidPtr !== undefined) { for (const path of this.writableDirs) { - grantWrite(api, path, this.writeSidPtr, lowLabelSid) + grantWrite(api, path, this.writeSidPtr, lowLabelSid, worldSid) } if (tempDir !== null && this.tempWriteSidPtr !== undefined) { // Record BEFORE granting: grantWrite can throw after a successful // apply (a LocalFree failure), and the fail-closed catch must still // revoke that path (revoking an ungranted path is a no-op merge). this.grantedPaths.push({ path: tempDir, sidPtr: this.tempWriteSidPtr }) - grantWrite(api, tempDir, this.tempWriteSidPtr, lowLabelSid) + grantWrite(api, tempDir, this.tempWriteSidPtr, lowLabelSid, worldSid) } } } const logonSid = findLogonSid(api, currentToken) this.sidAllocations.push(logonSid) - const worldSid = makeWellKnownSid(api, abi.WinWorldSid) - this.sidAllocations.push(worldSid) const writeSids = [this.writeSidPtr, this.tempWriteSidPtr].filter((sid): sid is NativePtr => sid !== undefined) restrictedToken = createRestrictedToken( api, currentToken, logonSid, writeSids, diff --git a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts index 76a263e976..bf8ad49851 100644 --- a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts @@ -79,6 +79,8 @@ export const TRUSTEE_IS_SID = 0 export const NO_MULTIPLE_TRUSTEE = 0 /** EXPLICIT_ACCESS mode that grants access. */ export const GRANT_ACCESS = 1 +/** EXPLICIT_ACCESS mode that denies access. */ +export const DENY_ACCESS = 3 /** EXPLICIT_ACCESS mode that revokes access. */ export const REVOKE_ACCESS = 4 /** ACE inheritance flags for child containers and objects. */ @@ -107,6 +109,8 @@ export const LOCKFILE_EXCLUSIVE_LOCK = 0x2 export const LOCKFILE_FAIL_IMMEDIATELY = 0x1 /** ACE type for an allowed-access entry. */ export const ACCESS_ALLOWED_ACE_TYPE = 0 +/** ACE type for a denied-access entry (shares the allowed ACE's Mask/SID layout). */ +export const ACCESS_DENIED_ACE_TYPE = 1 /** Maximum SID sub-authority count. */ export const SID_MAX_SUB_AUTHORITIES = 15 /** ACE flag marking inherited entries. */ diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts index e93d6750cc..88306142c0 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts @@ -73,34 +73,76 @@ function craftLowLabelSid(): NativePtr { return craftSid(1, 0, [0, 0, 0, 0, 0, 16]) } +/** The world SID the grant's ambient-delete deny names (crafted like the other stubs: 8 header bytes, no sub-authorities). */ +function craftWorldSid(): NativePtr { + return craftSid(1, 0, [0, 0, 0, 0, 0, 1]) +} + +/** Write one 16-byte ACE (AceType@0, AceFlags@1, AceSize@2, Mask@4, inline SID@8) at `offset`. */ +function writeAce(acl: NativePtr, offset: number, aceType: number, mask: number, sid: NativePtr, match: boolean): void { + koffi.encode(acl, offset + 0, 'uint8', aceType) + koffi.encode(acl, offset + 1, 'uint8', abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT) + koffi.encode(acl, offset + 2, 'uint16', 16) + koffi.encode(acl, offset + 4, 'uint32', mask) + for (let byte = 0; byte < 8; byte++) { + koffi.encode(acl, offset + 8 + byte, 'uint8', match + ? koffi.decode(sid, byte, 'uint8') as number + : byte === 0 ? 9 : 0) + } +} + /** * One in-memory ACL carrying a single inheritable ACE: header (AclRevision@0, - * AclSize@2, AceCount@4) then a 16-byte ACE (AceType@0, AceFlags@1, - * AceSize@2, Mask@4, inline SID@8). `match` selects whether the inline SID - * bytes equal `sid`. + * AclSize@2, AceCount@4) then a 16-byte ACE. `match` selects whether the + * inline SID bytes equal `sid`. */ -function craftAcl(aceType: number, mask: number, sid: NativePtr, match: boolean, aceSize = 16, aclSize = 24, count = 1): NativePtr { +function craftAcl(aceType: number, mask: number, sid: NativePtr, match: boolean): NativePtr { const acl = allocBytes(32) koffi.encode(acl, 'uint8', 2) // AclRevision - koffi.encode(acl, 2, 'uint16', aclSize) - koffi.encode(acl, 4, 'uint16', count) - const ace = 8 - koffi.encode(acl, ace + 0, 'uint8', aceType) - koffi.encode(acl, ace + 1, 'uint8', abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT) - koffi.encode(acl, ace + 2, 'uint16', aceSize) - koffi.encode(acl, ace + 4, 'uint32', mask) - const inlineSid = ace + 8 - for (let offset = 0; offset < 8; offset++) { - koffi.encode(acl, inlineSid + offset, 'uint8', match - ? koffi.decode(sid, offset, 'uint8') as number - : offset === 0 ? 9 : 0) - } + koffi.encode(acl, 2, 'uint16', 24) + koffi.encode(acl, 4, 'uint16', 1) + writeAce(acl, 8, aceType, mask, sid, match) return acl } -/** The DACL the exact-grant skip looks for: the capability ACE with GRANT_MASK. */ -function craftAclWithGrant(sid: NativePtr, match: boolean): NativePtr { - return craftAcl(abi.ACCESS_ALLOWED_ACE_TYPE, abi.GRANT_MASK, sid, match) +/** + * A two-ACE DACL: the ambient-delete deny plus the capability grant, each with + * caller-chosen type, mask, and trustee match so the skip's field checks can be + * driven one at a time. + */ +function craftPair( + grantSid: NativePtr, + world: NativePtr, + denyType: number, + denyMask: number, + denyMatches: boolean, + grantMatches: boolean, +): NativePtr { + const acl = allocBytes(64) + koffi.encode(acl, 'uint8', 2) + koffi.encode(acl, 2, 'uint16', 40) + koffi.encode(acl, 4, 'uint16', 2) + writeAce(acl, 8, denyType, denyMask, world, denyMatches) + writeAce(acl, 24, abi.ACCESS_ALLOWED_ACE_TYPE, abi.GRANT_MASK, grantSid, grantMatches) + return acl +} + +/** + * The DACL a fully granted directory carries: the ambient-delete deny for the + * world SID plus the capability grant. `grantMatches` selects whether the + * grant's inline SID equals `sid` (the deny always matches), and + * `includeDeny` drops the deny to model a root granted by an earlier build. + */ +function craftGrantedAcl(sid: NativePtr, world: NativePtr, grantMatches: boolean, includeDeny = true): NativePtr { + if (!includeDeny) { + const acl = allocBytes(64) + koffi.encode(acl, 'uint8', 2) + koffi.encode(acl, 2, 'uint16', 24) + koffi.encode(acl, 4, 'uint16', 1) + writeAce(acl, 8, abi.ACCESS_ALLOWED_ACE_TYPE, abi.GRANT_MASK, sid, grantMatches) + return acl + } + return craftPair(sid, world, abi.ACCESS_DENIED_ACE_TYPE, abi.FILE_DELETE_CHILD, true, grantMatches) } /** The label ACL the exact-label skip looks for: the Low no-write-up label ACE. */ @@ -180,7 +222,7 @@ describe('buildLowLabelAcl failure paths', () => { const api = aclApi({ getLengthSid: vi.fn(() => 0) }) let caught: unknown try { - grantWrite(api, 'C:\\granted', craftSid(1, 0), craftLowLabelSid()) + grantWrite(api, 'C:\\granted', craftSid(1, 0), craftLowLabelSid(), craftWorldSid()) } catch (error) { caught = error } @@ -192,7 +234,7 @@ describe('buildLowLabelAcl failure paths', () => { const api = aclApi({ localAlloc: vi.fn(() => 0n as NativePtr) }) let caught: unknown try { - grantWrite(api, 'C:\\granted', craftSid(1, 0), craftLowLabelSid()) + grantWrite(api, 'C:\\granted', craftSid(1, 0), craftLowLabelSid(), craftWorldSid()) } catch (error) { caught = error } @@ -205,7 +247,7 @@ describe('buildLowLabelAcl failure paths', () => { const api = aclApi({ initializeAcl: vi.fn(() => 0), localFree }) let caught: unknown try { - grantWrite(api, 'C:\\granted', craftSid(1, 0), craftLowLabelSid()) + grantWrite(api, 'C:\\granted', craftSid(1, 0), craftLowLabelSid(), craftWorldSid()) } catch (error) { caught = error } @@ -219,7 +261,7 @@ describe('buildLowLabelAcl failure paths', () => { const api = aclApi({ addMandatoryAce: vi.fn(() => 0), localFree }) let caught: unknown try { - grantWrite(api, 'C:\\granted', craftSid(1, 0), craftLowLabelSid()) + grantWrite(api, 'C:\\granted', craftSid(1, 0), craftLowLabelSid(), craftWorldSid()) } catch (error) { caught = error } @@ -235,7 +277,7 @@ describe('mergeAndApply failure paths', () => { const sid = craftSid(1, 0) let caught: unknown try { - grantWrite(api, 'C:\\granted', sid, craftLowLabelSid()) + grantWrite(api, 'C:\\granted', sid, craftLowLabelSid(), craftWorldSid()) } catch (error) { caught = error } @@ -248,7 +290,7 @@ describe('mergeAndApply failure paths', () => { const sid = craftSid(1, 0) let caught: unknown try { - grantWrite(api, 'C:\\granted', sid, craftLowLabelSid()) + grantWrite(api, 'C:\\granted', sid, craftLowLabelSid(), craftWorldSid()) } catch (error) { caught = error } @@ -266,7 +308,7 @@ describe('mergeAndApply failure paths', () => { const sid = craftSid(1, 0) let caught: unknown try { - grantWrite(api, 'C:\\granted', sid, craftLowLabelSid()) + grantWrite(api, 'C:\\granted', sid, craftLowLabelSid(), craftWorldSid()) } catch (error) { caught = error } @@ -285,7 +327,7 @@ describe('mergeAndApply failure paths', () => { const sid = craftSid(1, 0) let caught: unknown try { - grantWrite(api, 'C:\\granted', sid, craftLowLabelSid()) + grantWrite(api, 'C:\\granted', sid, craftLowLabelSid(), craftWorldSid()) } catch (error) { caught = error } @@ -300,7 +342,7 @@ describe('mergeAndApply failure paths', () => { const sid = craftSid(1, 0) let caught: unknown try { - grantWrite(api, 'C:\\granted', sid, craftLowLabelSid()) + grantWrite(api, 'C:\\granted', sid, craftLowLabelSid(), craftWorldSid()) } catch (error) { caught = error } @@ -318,7 +360,7 @@ describe('mergeAndApply failure paths', () => { const sid = craftSid(1, 0) let caught: unknown try { - grantWrite(api, 'C:\\granted', sid, craftLowLabelSid()) + grantWrite(api, 'C:\\granted', sid, craftLowLabelSid(), craftWorldSid()) } catch (error) { caught = error } @@ -333,7 +375,7 @@ describe('mergeAndApply failure paths', () => { const sid = craftSid(1, 0) let caught: unknown try { - grantWrite(api, 'C:\\granted', sid, craftLowLabelSid()) + grantWrite(api, 'C:\\granted', sid, craftLowLabelSid(), craftWorldSid()) } catch (error) { caught = error } @@ -355,7 +397,7 @@ describe('mergeAndApply failure paths', () => { const sid = craftSid(1, 0) let caught: unknown try { - grantWrite(api, 'C:\\granted', sid, craftLowLabelSid()) + grantWrite(api, 'C:\\granted', sid, craftLowLabelSid(), craftWorldSid()) } catch (error) { caught = error } @@ -369,14 +411,15 @@ describe('the exact-ACE/exact-label skip and ACL-walk defenses', () => { it('grantWrite skips the apply when the standing exact ACE and the exact label match (descriptor freed, nothing merged)', () => { const sid = craftSid(1, 0) const lowSid = craftLowLabelSid() + const world = craftWorldSid() const localFree = vi.fn(() => 0n as NativePtr) const setNamedSecurityInfoW = vi.fn(() => 0) const api = aclApi({ - getNamedSecurityInfoW: readStub(craftAclWithGrant(sid, true), craftAclWithLabel(lowSid, true), 6n), + getNamedSecurityInfoW: readStub(craftGrantedAcl(sid, world, true), craftAclWithLabel(lowSid, true), 6n), localFree, setNamedSecurityInfoW, }) - grantWrite(api, 'C:\\granted', sid, lowSid) + grantWrite(api, 'C:\\granted', sid, lowSid, world) expect(setNamedSecurityInfoW).not.toHaveBeenCalled() expect(localFree).toHaveBeenCalledWith(6n) }) @@ -384,15 +427,16 @@ describe('the exact-ACE/exact-label skip and ACL-walk defenses', () => { it('grantWrite skips the apply without freeing when the exact ACE and label stand but no descriptor owns them', () => { const sid = craftSid(1, 0) const lowSid = craftLowLabelSid() + const world = craftWorldSid() const localFree = vi.fn(() => 0n as NativePtr) const setNamedSecurityInfoW = vi.fn(() => 0) const api = aclApi({ // the read "returned" ACLs with no descriptor allocation of their own - getNamedSecurityInfoW: readStub(craftAclWithGrant(sid, true), craftAclWithLabel(lowSid, true), null), + getNamedSecurityInfoW: readStub(craftGrantedAcl(sid, world, true), craftAclWithLabel(lowSid, true), null), localFree, setNamedSecurityInfoW, }) - grantWrite(api, 'C:\\granted', sid, lowSid) + grantWrite(api, 'C:\\granted', sid, lowSid, world) expect(setNamedSecurityInfoW).not.toHaveBeenCalled() expect(localFree).not.toHaveBeenCalled() }) @@ -400,13 +444,14 @@ describe('the exact-ACE/exact-label skip and ACL-walk defenses', () => { it('grantWrite reports a failed descriptor LocalFree on the exact-ACE/exact-label skip path', () => { const sid = craftSid(1, 0) const lowSid = craftLowLabelSid() + const world = craftWorldSid() const api = aclApi({ - getNamedSecurityInfoW: readStub(craftAclWithGrant(sid, true), craftAclWithLabel(lowSid, true), 6n), + getNamedSecurityInfoW: readStub(craftGrantedAcl(sid, world, true), craftAclWithLabel(lowSid, true), 6n), localFree: vi.fn(() => 1n as NativePtr), }) let caught: unknown try { - grantWrite(api, 'C:\\granted', sid, lowSid) + grantWrite(api, 'C:\\granted', sid, lowSid, world) } catch (error) { caught = error } @@ -414,45 +459,107 @@ describe('the exact-ACE/exact-label skip and ACL-walk defenses', () => { expect((caught as Win32Error).api).toBe('LocalFree') }) + it('does not skip when a root granted by an earlier build carries the ACE and label but no ambient-delete deny', () => { + const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() + const world = craftWorldSid() + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: readStub(craftGrantedAcl(sid, world, true, false), craftAclWithLabel(lowSid, true), 6n), + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid, lowSid, world) + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) + }) + + it('does not skip when the deny names another trustee', () => { + const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() + const world = craftWorldSid() + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: readStub( + craftPair(sid, world, abi.ACCESS_DENIED_ACE_TYPE, abi.FILE_DELETE_CHILD, false, true), craftAclWithLabel(lowSid, true), 6n, + ), + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid, lowSid, world) + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) + }) + + it('does not treat a deny of another right as the ambient-delete deny', () => { + const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() + const world = craftWorldSid() + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: readStub( + craftPair(sid, world, abi.ACCESS_DENIED_ACE_TYPE, abi.GRANT_MASK, true, true), craftAclWithLabel(lowSid, true), 6n, + ), + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid, lowSid, world) + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) + }) + + it('does not treat an Allow ACE as the ambient-delete deny', () => { + const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() + const world = craftWorldSid() + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: readStub( + craftPair(sid, world, abi.ACCESS_ALLOWED_ACE_TYPE, abi.FILE_DELETE_CHILD, true, true), craftAclWithLabel(lowSid, true), 6n, + ), + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid, lowSid, world) + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) + }) + it('does not skip when the standing ACE and label match but the label ACL is absent', () => { const sid = craftSid(1, 0) const lowSid = craftLowLabelSid() + const world = craftWorldSid() const setNamedSecurityInfoW = vi.fn(() => 0) const api = aclApi({ - getNamedSecurityInfoW: readStub(craftAclWithGrant(sid, true), null, 6n), + getNamedSecurityInfoW: readStub(craftGrantedAcl(sid, world, true), null, 6n), setNamedSecurityInfoW, }) - grantWrite(api, 'C:\\granted', sid, lowSid) + grantWrite(api, 'C:\\granted', sid, lowSid, world) expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) }) it('does not skip when the exact ACE stands but the label names another integrity level', () => { const sid = craftSid(1, 0) const lowSid = craftLowLabelSid() + const world = craftWorldSid() const setNamedSecurityInfoW = vi.fn(() => 0) const api = aclApi({ - getNamedSecurityInfoW: readStub(craftAclWithGrant(sid, true), craftAclWithLabel(lowSid, false), 6n), + getNamedSecurityInfoW: readStub(craftGrantedAcl(sid, world, true), craftAclWithLabel(lowSid, false), 6n), setNamedSecurityInfoW, }) - grantWrite(api, 'C:\\granted', sid, lowSid) + grantWrite(api, 'C:\\granted', sid, lowSid, world) expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) }) it('falls back to the merge path when the standing ACE names a different SID', () => { const sid = craftSid(1, 0) const lowSid = craftLowLabelSid() + const world = craftWorldSid() const setNamedSecurityInfoW = vi.fn(() => 0) const api = aclApi({ - getNamedSecurityInfoW: readStub(craftAclWithGrant(sid, false), craftAclWithLabel(lowSid, true), 6n), + getNamedSecurityInfoW: readStub(craftGrantedAcl(sid, world, false), craftAclWithLabel(lowSid, true), 6n), setNamedSecurityInfoW, }) - grantWrite(api, 'C:\\granted', sid, lowSid) + grantWrite(api, 'C:\\granted', sid, lowSid, world) expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) }) it('treats an implausibly small ACL size as no exact grant', () => { const sid = craftSid(1, 0) const lowSid = craftLowLabelSid() + const world = craftWorldSid() const acl = allocBytes(32) koffi.encode(acl, 'uint8', 2) koffi.encode(acl, 2, 'uint16', 4) // smaller than the 8-byte ACL header @@ -462,13 +569,14 @@ describe('the exact-ACE/exact-label skip and ACL-walk defenses', () => { getNamedSecurityInfoW: readStub(acl, craftAclWithLabel(lowSid, true), 6n), setNamedSecurityInfoW, }) - grantWrite(api, 'C:\\granted', sid, lowSid) + grantWrite(api, 'C:\\granted', sid, lowSid, world) expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) }) it('treats an ACE that would overrun the ACL as no exact grant', () => { const sid = craftSid(1, 0) const lowSid = craftLowLabelSid() + const world = craftWorldSid() const acl = allocBytes(32) koffi.encode(acl, 'uint8', 2) koffi.encode(acl, 2, 'uint16', 8) // header only: no room for any ACE @@ -479,29 +587,31 @@ describe('the exact-ACE/exact-label skip and ACL-walk defenses', () => { getNamedSecurityInfoW: readStub(acl, craftAclWithLabel(lowSid, true), 6n), setNamedSecurityInfoW, }) - grantWrite(api, 'C:\\granted', sid, lowSid) + grantWrite(api, 'C:\\granted', sid, lowSid, world) expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) }) it('treats an implausibly small label ACL size as no exact label', () => { const sid = craftSid(1, 0) const lowSid = craftLowLabelSid() + const world = craftWorldSid() const label = allocBytes(32) koffi.encode(label, 'uint8', 2) koffi.encode(label, 2, 'uint16', 4) // smaller than the 8-byte ACL header koffi.encode(label, 4, 'uint16', 1) const setNamedSecurityInfoW = vi.fn(() => 0) const api = aclApi({ - getNamedSecurityInfoW: readStub(craftAclWithGrant(sid, true), label, 6n), + getNamedSecurityInfoW: readStub(craftGrantedAcl(sid, world, true), label, 6n), setNamedSecurityInfoW, }) - grantWrite(api, 'C:\\granted', sid, lowSid) + grantWrite(api, 'C:\\granted', sid, lowSid, world) expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) }) it('treats a label ACE that would overrun its ACL as no exact label', () => { const sid = craftSid(1, 0) const lowSid = craftLowLabelSid() + const world = craftWorldSid() const label = allocBytes(32) koffi.encode(label, 'uint8', 2) koffi.encode(label, 2, 'uint16', 8) // header only: no room for any ACE @@ -509,43 +619,45 @@ describe('the exact-ACE/exact-label skip and ACL-walk defenses', () => { koffi.encode(label, 10, 'uint16', 100) // the walk reads a lying ACE size const setNamedSecurityInfoW = vi.fn(() => 0) const api = aclApi({ - getNamedSecurityInfoW: readStub(craftAclWithGrant(sid, true), label, 6n), + getNamedSecurityInfoW: readStub(craftGrantedAcl(sid, world, true), label, 6n), setNamedSecurityInfoW, }) - grantWrite(api, 'C:\\granted', sid, lowSid) + grantWrite(api, 'C:\\granted', sid, lowSid, world) expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) }) it('does not treat a no-write-up ACL Allow ACE as the mandatory label', () => { const sid = craftSid(1, 0) const lowSid = craftLowLabelSid() + const world = craftWorldSid() const setNamedSecurityInfoW = vi.fn(() => 0) const api = aclApi({ // Same mask and SID, but an ACCESS_ALLOWED_ACE is not a mandatory label. getNamedSecurityInfoW: readStub( - craftAclWithGrant(sid, true), + craftGrantedAcl(sid, world, true), craftAcl(abi.ACCESS_ALLOWED_ACE_TYPE, abi.SYSTEM_MANDATORY_LABEL_NO_WRITE_UP, lowSid, true), 6n, ), setNamedSecurityInfoW, }) - grantWrite(api, 'C:\\granted', sid, lowSid) + grantWrite(api, 'C:\\granted', sid, lowSid, world) expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) }) it('does not treat a label ACE granting write-up as the exact label', () => { const sid = craftSid(1, 0) const lowSid = craftLowLabelSid() + const world = craftWorldSid() const setNamedSecurityInfoW = vi.fn(() => 0) const api = aclApi({ getNamedSecurityInfoW: readStub( - craftAclWithGrant(sid, true), + craftGrantedAcl(sid, world, true), craftAcl(abi.SYSTEM_MANDATORY_LABEL_ACE_TYPE, 0, lowSid, true), 6n, ), setNamedSecurityInfoW, }) - grantWrite(api, 'C:\\granted', sid, lowSid) + grantWrite(api, 'C:\\granted', sid, lowSid, world) expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) }) }) diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts index 3b2bb976e5..9d5e6e697a 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts @@ -115,6 +115,11 @@ function lowLabelSid(api: Win32Bindings): NativePtr { return makeWellKnownSid(api, abi.WinLowLabelSid) } +/** The world SID (S-1-1-0) every grant denies the ambient delete right to. */ +function worldSid(api: Win32Bindings): NativePtr { + return makeWellKnownSid(api, abi.WinWorldSid) +} + /** * Read the directory's mandatory label ACEs (inherited entries excluded) — * same ACE layout as {@link readDirectAces}, read through the label security @@ -170,6 +175,7 @@ describe.skipIf(!isWin32)('ACL editing', () => { const usersSid = sidFromString(api, 'S-1-5-32-545') const capabilitySid = sidFromString(api, 'S-1-4-4242-1') const lowSid = lowLabelSid(api) + const world = worldSid(api) try { // Install one explicit ACE (Users + benign read mask) with the // package's own bindings, exactly like a pre-existing explicit DACL @@ -186,7 +192,12 @@ describe.skipIf(!isWin32)('ACL editing', () => { expect(applyResult, `SetNamedSecurityInfoW setup (${applyResult})`).toBe(abi.ERROR_SUCCESS) expect(isNullPtr(freed)).toBe(true) - grantWrite(api, dir, capabilitySid, lowSid) + grantWrite(api, dir, capabilitySid, lowSid, world) + + const granted = readDirectAces(api, dir) + expect(granted.some(ace => ace.sid === 'S-1-5-32-545')).toBe(true) // explicit ACE preserved + expect(granted.some(ace => ace.sid === 'S-1-4-4242-1')).toBe(true) + revokeWrite(api, dir, capabilitySid) const aces = readDirectAces(api, dir) @@ -197,43 +208,53 @@ describe.skipIf(!isWin32)('ACL editing', () => { if (!isNullPtr(usersSid)) api.localFree(usersSid) if (!isNullPtr(capabilitySid)) api.localFree(capabilitySid) if (!isNullPtr(lowSid)) api.localFree(lowSid) + if (!isNullPtr(world)) api.localFree(world) } }) - it('grantWrite applies the Low no-write-up label; revokeWrite clears it in the same edit', async () => { + it('grantWrite applies the Low no-write-up label and denies the ambient parent-delete right; revokeWrite clears both capability and label', async () => { const api = await win32() const dir = scratch() const capabilitySid = sidFromString(api, 'S-1-4-4242-9') const lowSid = lowLabelSid(api) + const world = worldSid(api) try { expect(readLabelAces(api, dir)).toEqual([]) - grantWrite(api, dir, capabilitySid, lowSid) + grantWrite(api, dir, capabilitySid, lowSid, world) expect(readLabelAces(api, dir)).toEqual([ { sid: 'S-1-16-4096', mask: abi.SYSTEM_MANDATORY_LABEL_NO_WRITE_UP }, ]) + // The ambient delete route inside another granted root is what the deny + // removes; the capability ACE stays the only delete authority there. + const denied = readDirectAces(api, dir).filter(ace => ace.sid === 'S-1-1-0') + expect(denied).toEqual([{ sid: 'S-1-1-0', mask: abi.FILE_DELETE_CHILD }]) revokeWrite(api, dir, capabilitySid) expect(readLabelAces(api, dir)).toEqual([]) + expect(readDirectAces(api, dir).some(ace => ace.sid === 'S-1-4-4242-9')).toBe(false) } finally { if (!isNullPtr(capabilitySid)) api.localFree(capabilitySid) if (!isNullPtr(lowSid)) api.localFree(lowSid) + if (!isNullPtr(world)) api.localFree(world) } }) - it('grantWrite is idempotent: a second grant over the standing exact ACE and label skips the SetNamedSecurityInfoW apply (no eager full-tree re-propagation)', async () => { + it('grantWrite is idempotent: a second grant over the standing exact ACE, deny, and label skips the SetNamedSecurityInfoW apply (no eager full-tree re-propagation)', async () => { const api = await win32() const dir = scratch() const capabilitySid = sidFromString(api, 'S-1-4-4242-2') const lowSid = lowLabelSid(api) + const world = worldSid(api) const apply = vi.spyOn(api, 'setNamedSecurityInfoW') try { - grantWrite(api, dir, capabilitySid, lowSid) + grantWrite(api, dir, capabilitySid, lowSid, world) expect(apply).toHaveBeenCalledTimes(1) - // The exact ACE and label now stand (the per-session grant surviving - // from a previous server lifetime): the second grant is a read only. - grantWrite(api, dir, capabilitySid, lowSid) + // The exact ACE, deny, and label now stand (the per-session grant + // surviving from a previous server lifetime): the second grant is a read only. + grantWrite(api, dir, capabilitySid, lowSid, world) expect(apply).toHaveBeenCalledTimes(1) const aces = readDirectAces(api, dir) expect(aces.filter(ace => ace.sid === 'S-1-4-4242-2')).toHaveLength(1) + expect(aces.filter(ace => ace.sid === 'S-1-1-0')).toHaveLength(1) expect(readLabelAces(api, dir)).toHaveLength(1) revokeWrite(api, dir, capabilitySid) expect(readDirectAces(api, dir).some(ace => ace.sid === 'S-1-4-4242-2')).toBe(false) @@ -241,6 +262,7 @@ describe.skipIf(!isWin32)('ACL editing', () => { apply.mockRestore() if (!isNullPtr(capabilitySid)) api.localFree(capabilitySid) if (!isNullPtr(lowSid)) api.localFree(lowSid) + if (!isNullPtr(world)) api.localFree(world) } }) diff --git a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts index a58ac01d82..fb734ba0ec 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts @@ -450,6 +450,71 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { } }, 60_000) + it('cross-root delete regression: one session cannot delete inside ANOTHER granted root', () => { + // Both roots carry the Low label, so the integrity check alone would let a + // confined child delete in the other one: Windows authorizes a delete from + // the parent directory's FILE_DELETE_CHILD, which the write-restricted + // intersection does not reach. The grant therefore denies that right to the + // world SID, leaving the capability ACE's DELETE bit as the only delete + // authority inside a granted root. + const otherWorkspace = join(scratchRoot, 'other-workspace') + const otherTemp = join(scratchRoot, 'other-temp') + mkdirSync(otherWorkspace) + mkdirSync(otherTemp) + const otherWorkspaceSid = workspaceWriteSid(otherWorkspace) + const otherTempSid = tempWriteSid(otherTemp) + const otherWorkspaceGrant = AclWriteGrant.create(otherWorkspaceSid) + const otherTempGrant = AclWriteGrant.create(otherTempSid) + otherWorkspaceGrant.add(otherWorkspace, true) + otherTempGrant.add(otherTemp) + // Session A's own roots, granted the same way, run through the runner. + const ownWorkspace = join(scratchRoot, 'own-workspace') + const ownTemp = join(scratchRoot, 'own-temp') + mkdirSync(ownWorkspace) + mkdirSync(ownTemp) + const ownWorkspaceSid = workspaceWriteSid(ownWorkspace) + const ownTempSid = tempWriteSid(ownTemp) + const ownWorkspaceGrant = AclWriteGrant.create(ownWorkspaceSid) + const ownTempGrant = AclWriteGrant.create(ownTempSid) + ownWorkspaceGrant.add(ownWorkspace, true) + ownTempGrant.add(ownTemp) + try { + const victims = { + ownWork: join(ownWorkspace, 'victim.txt'), + otherWork: join(otherWorkspace, 'victim.txt'), + ownTemp: join(ownTemp, 'victim.txt'), + otherTemp: join(otherTemp, 'victim.txt'), + } + for (const path of Object.values(victims)) writeFileSync(path, 'delete me') + const probe = [ + "$ErrorActionPreference='SilentlyContinue';", + ...Object.entries(victims).map(([name, path]) => + `try{[System.IO.File]::Delete('${path}');'${name}: DELETED'}catch{'${name}: DENIED'};`), + ].join('') + const result = runRunner([ + '--workspace', ownWorkspace, '--temp', ownTemp, '--mode', 'workspace-write', + '--write-sid', ownWorkspaceSid, '--temp-write-sid', ownTempSid, + '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe, + ]) + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + expect(result.stdout).toContain('ownWork: DELETED') + expect(result.stdout).toContain('ownTemp: DELETED') + expect(result.stdout).toContain('otherWork: DENIED') + expect(result.stdout).toContain('otherTemp: DENIED') + expect(existsSync(victims.otherWork)).toBe(true) + expect(existsSync(victims.otherTemp)).toBe(true) + expect(existsSync(victims.ownWork)).toBe(false) + expect(existsSync(victims.ownTemp)).toBe(false) + } finally { + ownWorkspaceGrant.dispose() + ownTempGrant.dispose() + otherWorkspaceGrant.dispose() + otherTempGrant.dispose() + rmSync(ownTemp, { recursive: true, force: true }) + rmSync(otherTemp, { recursive: true, force: true }) + } + }, 60_000) + it('the Low mandatory label closes the Everyone-Modify ambient boundary under BOTH modes', () => { // Everyone is a required keep-alive restricting SID: without it early DLL // initialization and CNG fail. A normal DACL that grants Everyone Modify From 7450e1eb24c8269d1336db95ceea1044dc2beef8 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 19 Sep 2026 02:24:48 +0800 Subject: [PATCH 3/9] docs(sandbox): tighten the comments added by the delete-confinement fix One home per fact: the parent-FILE_DELETE_CHILD rationale now lives in the acl module doc and the package README instead of being retold in restrictTokenIntegrity, init(), and grantWrite. Drops the duplicated inline narration; behavior unchanged. --- .../sandbox/sandbox-windows-acl/src/acl.ts | 71 +++++++------------ .../sandbox/sandbox-windows-acl/src/index.ts | 22 ++---- .../sandbox/sandbox-windows-acl/src/token.ts | 18 ++--- .../sandbox-windows-acl/tests/runner.spec.ts | 26 +++---- 4 files changed, 46 insertions(+), 91 deletions(-) diff --git a/packages/sandbox/sandbox-windows-acl/src/acl.ts b/packages/sandbox/sandbox-windows-acl/src/acl.ts index f9ce88d700..199e9e394b 100644 --- a/packages/sandbox/sandbox-windows-acl/src/acl.ts +++ b/packages/sandbox/sandbox-windows-acl/src/acl.ts @@ -5,17 +5,14 @@ * failure is reported with the API name, the exact Win32 code, the formatted * system text, and the affected path. * - * Each grant is a PAIR of edits applied in ONE SetNamedSecurityInfoW call: - * a Deny ACE removing the ambient parent-directory delete right - * (`Everyone` / `FILE_DELETE_CHILD`) plus the capability-SID allow ACE in the - * DACL, and a Low mandatory label with the no-write-up policy in the label - * ACL ({@link buildLowLabelAcl}). The deny makes the capability ACE's DELETE - * bit the only delete authority inside the granted roots — Windows otherwise - * authorizes a delete from the parent directory's `FILE_DELETE_CHILD`, which - * the token's write-restricted intersection does not reach, so one confined - * child could delete files in another granted root. The label denies - * write-class access to every object that is not labeled Low, and the kernel - * applies that policy inside the access check itself. + * Each grant applies three edits in ONE SetNamedSecurityInfoW call: the + * capability-SID allow ACE, a Deny ACE that removes the ambient + * `FILE_DELETE_CHILD` right from the world SID, and a Low no-write-up + * mandatory label ({@link buildLowLabelAcl}). The deny is what keeps one + * granted root out of another's reach: Windows also authorizes a delete from + * the parent directory's `FILE_DELETE_CHILD` right, which the token's + * write-restricted intersection does not reach, and every granted root carries + * the Low label that clears the integrity check. * * Concurrency: grants are read-merge-write against the directory's CURRENT * DACL, and the whole get-merge-set sequence runs under a per-path exclusive @@ -152,12 +149,10 @@ function readCurrentSecurity( /** * Build the Low mandatory label applied with every write grant: one * SYSTEM_MANDATORY_LABEL_ACE naming `lowLabelSidPtr` with the no-write-up - * policy, inheriting to subcontainers and objects so files created later - * inside the granted tree carry the same label automatically. - * - * The caller frees the returned ACL with LocalFree; SetNamedSecurityInfoW - * copies it. Fail-closed: every Win32 call is checked and the half-built ACL - * is released before the error is thrown. + * policy, inheriting to subcontainers and objects so later children carry the + * same label. The caller frees the returned ACL with LocalFree + * (SetNamedSecurityInfoW copies it); every Win32 call is checked and a + * half-built ACL is released before the error is thrown. * @param api - the binding table. * @param lowLabelSidPtr - the Low integrity SID (S-1-16-4096) the label names. * @returns the ACL carrying the single inheritable label ACE. @@ -311,11 +306,10 @@ function hasExactGrant(oldAcl: NativePtr, sidPtr: NativePtr): boolean { } /** - * True when the merge's ambient-delete deny is already present: the exact - * Deny ACE for {@link abi.FILE_DELETE_CHILD} naming the world SID. Together - * with {@link hasExactGrant} and the label check this gates the idempotent - * skip, so a root granted by an earlier build receives the deny on its next - * provision instead of keeping the parent-`FILE_DELETE_CHILD` route open. + * True when the explicit DACL already carries the EXACT ambient-delete deny: + * the Deny ACE for {@link abi.FILE_DELETE_CHILD} naming the world SID. It is + * part of the idempotent skip, so a root granted by an earlier build receives + * the deny on its next provision. * @param oldAcl - the current explicit DACL pointer (from {@link readCurrentSecurity}). * @param worldSidPtr - the Everyone SID the deny names. * @returns whether the exact deny ACE is already present. @@ -326,29 +320,18 @@ function hasExactDeny(oldAcl: NativePtr, worldSidPtr: NativePtr): boolean { /** * Grant `GRANT_MASK` (Write+Delete, displays as "Modify") to the capability SID - * on `path`, deny the ambient `FILE_DELETE_CHILD` right to the world SID, and - * apply the Low mandatory label — all in one SetNamedSecurityInfoW call. + * on `path`, deny the world SID the ambient `FILE_DELETE_CHILD` right, and + * apply the Low mandatory label — one merge, inheriting to subcontainers and + * objects. The capability ACE's DELETE bit is then the only delete authority + * inside the root, so a file whose own DACL grants no DELETE is no longer + * deletable through its parent's rights. * - * The deny closes the parent-directory delete route inside the granted roots: - * Windows authorizes a delete from the object's own DELETE right OR from the - * parent's `FILE_DELETE_CHILD`, and the token's write-restricted intersection - * reaches only the first, so without the deny one confined child could delete - * files in ANOTHER granted root (another workspace, a live sibling temp - * directory) whose Low label clears the integrity check. With the deny in - * place the capability ACE's DELETE bit is the only delete authority inside - * the granted roots, for the confined child and the ambient user alike; a file - * whose own DACL grants no DELETE is therefore no longer deletable by its - * parent's default rights. - * - * Idempotent: when the directory's current explicit DACL already carries the - * exact ACE and deny AND its label ACL the exact label (the per-workspace - * grant surviving from a previous server lifetime), the SetNamedSecurityInfoW - * apply is SKIPPED — it would otherwise re-propagate the identical security - * descriptor across the whole tree (eager inheritance; minutes on large - * workspaces). Otherwise read-merge-write: the entries merge into the - * directory's CURRENT explicit DACL (same shape as {@link revokeWrite}), so - * pre-existing explicit ACEs survive. Runs under the per-path lock. The - * directory must be owned by the caller (owner implicit WRITE_DAC) — same + * Idempotent: the exact ACE, deny, and label together SKIP the + * SetNamedSecurityInfoW apply, which would otherwise re-propagate the + * identical descriptor across the whole tree (eager inheritance; minutes on + * large workspaces). Otherwise read-merge-write, so pre-existing explicit ACEs + * survive (same shape as {@link revokeWrite}). Runs under the per-path lock. + * The directory must be owned by the caller (owner implicit WRITE_DAC) — same * precondition as the POC. * @param api - the binding table. * @param path - the directory whose DACL and label gain the grant (the workspace or temp root). diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 3ecaee8458..75dd23e7a5 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -10,14 +10,10 @@ * keep-alive group logon SID + Everyone; Authenticated Users, INTERACTIVE, * and LOCAL are absent from both lists — see the seam's dual-list contract * in `packages/sandbox/sandbox-local` and the package README's Modes section - * for the complete boundary). The intersection covers only the write bits of - * the object's OWN access check: Windows can also authorize a write or delete - * from the parent directory's FILE_DELETE_CHILD right, which no restricting - * SID has to co-sign. The token is therefore ALSO lowered to Low integrity - * and every granted directory carries a Low no-write-up label, so the - * kernel's mandatory-integrity check — evaluated inside the access check - * regardless of which right supplied the authority — confines the child to - * the labeled roots. The write SID is the per-WORKSPACE identity + * 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 @@ -263,9 +259,7 @@ export class AclSandbox { // 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). - // The Low integrity SID every granted directory is labeled with (and the - // confined token is lowered to) plus the world SID the grants deny the - // ambient parent-directory delete right to. + // The Low label SID and the world SID the grants deny and label with. const lowLabelSid = makeWellKnownSid(api, abi.WinLowLabelSid) const worldSid = makeWellKnownSid(api, abi.WinWorldSid) this.sidAllocations.push(lowLabelSid, worldSid) @@ -292,12 +286,6 @@ export class AclSandbox { { world: worldSid }, this.mode, ) - // Lower the confined token to Low integrity. The write-SID intersection - // does not reach the parent directory's FILE_DELETE_CHILD authority, so - // without the matching label a confined child could delete anything its - // ambient user SIDs may delete; the label plus this level close that - // route, and the granted directories stay writable because - // grantWrite labels them Low too. restrictTokenIntegrity(api, restrictedToken, lowLabelSid) this.token = restrictedToken // The restricted token's default DACL still names only the user's diff --git a/packages/sandbox/sandbox-windows-acl/src/token.ts b/packages/sandbox/sandbox-windows-acl/src/token.ts index 27e8fd848b..10674afb26 100644 --- a/packages/sandbox/sandbox-windows-acl/src/token.ts +++ b/packages/sandbox/sandbox-windows-acl/src/token.ts @@ -145,20 +145,10 @@ export function setTokenDefaultDaclGrant(api: Win32Bindings, token: NativePtr, s } /** - * Lower the restricted token's integrity level to Low (S-1-16-4096). - * - * `WRITE_RESTRICTED` intersects only the write bits of the access mask it - * evaluates against an object's DACL. Windows can also authorize a write or a - * delete from the PARENT directory's FILE_DELETE_CHILD right, and that - * authority is granted without the restricting-SID intersection — so a - * confined child could still delete files anywhere its ambient user SIDs hold - * Modify. The mandatory label closes that route: the kernel applies the - * integrity policy inside the access check, wherever the authority came from, - * so a Low child only writes where the object is labeled Low (see - * `grantWrite`). - * - * Requires TOKEN_ADJUST_DEFAULT on the token (the caller opens it with the - * right). Fails closed: a failure throws before any child is spawned. + * Lower the restricted token's integrity level to Low (S-1-16-4096), the level + * the mandatory labels `grantWrite` applies are matched against; a token left + * at Medium would ignore them. Requires TOKEN_ADJUST_DEFAULT on the token; + * fails closed before any child is spawned. * @param api - the binding table. * @param token - the restricted token to lower. * @param lowLabelSidPtr - the Low integrity SID (S-1-16-4096). diff --git a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts index fb734ba0ec..f539b99f44 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts @@ -400,16 +400,11 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { }, 30_000) it('delete-escape regression: deletes outside the granted roots are denied in BOTH modes, through every delete authority', () => { - // Windows authorizes a delete from EITHER the object's own DELETE right OR - // the parent directory's FILE_DELETE_CHILD right, and the - // WRITE_RESTRICTED pass-2 intersection only covers the first. Every - // ordinary directory the ambient user SIDs control (a project checkout, - // %TEMP%, the profile) grants FILE_DELETE_CHILD, so `cmd /c del` deleted - // files outside the workspace even though a plain write from the same - // child was denied. The Low mandatory label closes both routes because the - // kernel applies the integrity policy inside the access check, whichever - // right supplied the authority; the remaining delete paths below are the - // ones a confined child reaches for. + // Windows authorizes a delete from the object's own DELETE right OR the + // parent directory's FILE_DELETE_CHILD, and the write-restricted + // intersection only covers the first — so `cmd /c del` used to delete + // outside the workspace. The Low label plus the grant's deny close both + // routes; these are the deleters a confined child reaches for. for (const mode of ['read-only', 'workspace-write'] as const) { const victims = { cmd: join(scratchRoot, `delete-${mode}-cmd.txt`), @@ -451,12 +446,11 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { }, 60_000) it('cross-root delete regression: one session cannot delete inside ANOTHER granted root', () => { - // Both roots carry the Low label, so the integrity check alone would let a - // confined child delete in the other one: Windows authorizes a delete from - // the parent directory's FILE_DELETE_CHILD, which the write-restricted - // intersection does not reach. The grant therefore denies that right to the - // world SID, leaving the capability ACE's DELETE bit as the only delete - // authority inside a granted root. + // Both roots carry the Low label, so the integrity check alone would not + // stop this: Windows also authorizes a delete from the parent directory's + // FILE_DELETE_CHILD right, which the capability intersection never reaches. + // The grant denies that right, leaving the capability DELETE bit as the + // only delete authority inside a granted root. const otherWorkspace = join(scratchRoot, 'other-workspace') const otherTemp = join(scratchRoot, 'other-temp') mkdirSync(otherWorkspace) From 688d7b694b15fb0df451f52bad073b0e8d7e9e10 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 19 Sep 2026 13:05:01 +0800 Subject: [PATCH 4/9] test(sandbox): drop the host-dependent whoami integrity assertion whoami /groups reports the mandatory label on this workstation but produces no matching line on the CI Windows runner, so the assertion pinned the host rather than the product. The lowered integrity level stays covered by the unit pin on restrictTokenIntegrity and by the behavior that only holds with it: the Everyone-Modify denial, the outside-delete denial, and the cross-root delete denial. README records the cmdlet as host-dependent diagnostic noise again. --- packages/sandbox/sandbox-windows-acl/README.i18n.yaml | 4 ++-- packages/sandbox/sandbox-windows-acl/README.md | 2 +- packages/sandbox/sandbox-windows-acl/README.zh.md | 2 +- packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts | 4 ---- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index 813c667920..42c27aa70c 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sandbox/sandbox-windows-acl/README.md -README.md: 94d49d76437799b19cb2468e96a020589058d624 -README.zh.md: 7709ddcf4f6d609fbce953946a532952b90ea620 +README.md: 41415249c2fcf05c17634f6930692b1a2a5960c8 +README.zh.md: 7c62b263fa553748c186f4c40d2fc0aa1259804f diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index 94d49d7643..41415249c2 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -120,7 +120,7 @@ When launched with the subprocess control marker, the runner forwards fd 7 throu - **Granted directories must be caller-owned** — the owner's implicit `WRITE_DAC` is what lets the sandbox edit the DACL without elevation. - **The ambient temp root is never granted implicitly** — direct callers must supply an existing private `tempDir` plus its distinct `tempWriteSid`, or disable temp writes with `tempDir: null`; the actual temp directory must be disjoint from every writable root. - **The confined child's temp capability is private per live session/workspace pair** — the runner rewrites TMP/TEMP to that private directory before the spawn; two tokens sharing the same workspace SID cannot write one another's temp directories. -- **`whoami` and token-inspection cmdlets run but report the reduced token** — `whoami /groups` succeeds and reports `S-1-16-4096`, which is the cheapest way to observe the active integrity level. +- **`whoami` and token-inspection cmdlets may fail under the restricted token** — `GetTokenInformation` on the duplicate is partially unavailable to the child, so whether a reporting cmdlet works is host-dependent; it is diagnostic noise rather than an operational failure. ### Header verification and source map diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index 7709ddcf4f..7c62b263fa 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -120,7 +120,7 @@ seam 先把确定性工作区 SID 的 ACE 常驻物化(每个工作区每服 - **被授权目录必须由调用者拥有。** 所有者的隐式 `WRITE_DAC` 是沙箱无需提权即可编辑 DACL 的原因。 - **环境临时根目录绝不会被隐式授权。** 直接调用方必须提供已存在的私有 `tempDir` 及其不同的 `tempWriteSid`,或用 `tempDir: null` 禁用临时写入;实际临时目录不得与任何可写根目录重叠。 - **受限子进程的临时能力按每个活跃的会话/工作区对私有。** runner 在 spawn 之前把 TMP/TEMP 改写为该私有目录;共享同一工作区 SID 的两个令牌无法写入彼此的临时目录。 -- **`whoami` 与令牌检查 cmdlet 可以运行,但报告的是被削减的令牌。** `whoami /groups` 成功并报告 `S-1-16-4096`,这是观察当前完整性级别最简便的方式。 +- **受限令牌下 `whoami` 与令牌检查 cmdlet 可能失败。** 子进程对复制令牌的 `GetTokenInformation` 部分不可用,因此这类报告型 cmdlet 是否可用取决于宿主环境;这是诊断噪音而非运行故障。 ### 头文件验证与源码索引 diff --git a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts index f539b99f44..a654b107ab 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts @@ -82,9 +82,6 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { // cannot create those scratch files and fails that probe closed to // ConstrainedLanguage (pinned below). '\'LANGMODE: \' + $ExecutionContext.SessionState.LanguageMode;', - // The integrity level the token was lowered to: the mandatory label on - // the granted directories is what makes them writable at this level. - "(whoami /groups | Select-String 'Mandatory Label') -replace '\\s+', ' ';", `try{Set-Content -Path '${writableDir}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`, "try{Set-Content -Path (Join-Path $env:TEMP 'child-wrote.txt') -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};", `try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK (ESCAPE!)'}catch{'ESCAPE-WRITE: DENIED'};`, @@ -101,7 +98,6 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { ]) expect(result.status, `stderr: ${result.stderr}`).toBe(0) expect(result.stdout).toContain('LANGMODE: FullLanguage') - expect(result.stdout).toContain('S-1-16-4096') expect(result.stdout).toContain('TARGET-WRITE: OK') expect(result.stdout).toContain('TEMP-WRITE: OK') expect(result.stdout).toContain('ESCAPE-WRITE: DENIED') From 3d5ba3b83f5419a47731516cde6d6ebb9f588bc9 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 19 Sep 2026 13:42:47 +0800 Subject: [PATCH 5/9] fix(sandbox): scope the delete deny to containers, clearing the review round The inheritable `Deny Everyone FILE_DELETE_CHILD` also reached FILES, and 0x40 is a member of FILE_ALL_ACCESS: measured against a real runner, a Medium caller's CreateFileW(GENERIC_ALL) on every file, directory, and temp root inside a granted root returned ACCESS_DENIED. The deny now carries CONTAINER_INHERIT_ACE, so files keep working; a FullControl open of a DIRECTORY inside a granted root stays denied and is pinned as the residual cost of denying a right the full-access mask contains. Also from the review: - Leaks on fail-closed paths: the label ACL on both early SetEntriesInAclW exits, the read descriptor when the label build fails, and the parsed capability SID when either well-known-SID creation fails. - `revokeWrite` keeps the shared Low label while another capability grant remains on the directory, so two grants on one path stay independently revocable; `hasExactLabel` delegates to hasExactEntry. - The delete probe uses process.execPath, restrictTokenIntegrity guards a zero-length SID, and verify/abi-probe.cpp covers the new constants (compiled; static_asserts pass). - Consumers updated in the same change: sandbox-local source comments and README pair, pwsh-sandbox README pair, docs/subsystems/sandbox pair, and the 2026-07-06 sandbox note pair no longer cite the Everyone-write boundary as a current partial reason. - New Agent Note records the mandatory-integrity decision, the deny scoping, the standing label with its externality (other Low-integrity processes running as the user, surviving the session), and the rejected alternatives; the 2026-08-08 note cross-links it. - New runner pins: NUL stays writable under the Low token, the FullControl file/directory split, and the shared-label revoke. Per-file coverage back to 100%. --- .../feature/2026-07-06-sandbox.i18n.yaml | 4 +- .../implemented/feature/2026-07-06-sandbox.md | 2 +- .../feature/2026-07-06-sandbox.zh.md | 2 +- ...ows-acl-restricted-token-sandbox.i18n.yaml | 4 +- ...08-windows-acl-restricted-token-sandbox.md | 2 + ...windows-acl-restricted-token-sandbox.zh.md | 2 + ...-mandatory-integrity-confinement.i18n.yaml | 6 + ...ows-acl-mandatory-integrity-confinement.md | 56 ++++++ ...-acl-mandatory-integrity-confinement.zh.md | 56 ++++++ docs/subsystems/sandbox.i18n.yaml | 6 +- docs/subsystems/sandbox.md | 2 +- docs/subsystems/sandbox.zh.md | 2 +- .../sandbox/sandbox-local/README.i18n.yaml | 6 +- packages/sandbox/sandbox-local/README.md | 6 +- packages/sandbox/sandbox-local/README.zh.md | 6 +- packages/sandbox/sandbox-local/src/index.ts | 19 +- .../sandbox-windows-acl/README.i18n.yaml | 6 +- .../sandbox/sandbox-windows-acl/README.md | 8 +- .../sandbox/sandbox-windows-acl/README.zh.md | 8 +- .../sandbox/sandbox-windows-acl/src/acl.ts | 156 ++++++++++------ .../sandbox/sandbox-windows-acl/src/grant.ts | 25 ++- .../sandbox/sandbox-windows-acl/src/token.ts | 1 + .../sandbox-windows-acl/src/win32-abi.ts | 2 + .../tests/acl-failure-paths.spec.ts | 169 +++++++++++++++++- .../sandbox-windows-acl/tests/acl.spec.ts | 100 ++++++++++- .../tests/grant-failure-paths.spec.ts | 22 +++ .../sandbox-windows-acl/tests/runner.spec.ts | 91 +++++++++- .../tests/token-failure-paths.spec.ts | 16 ++ .../sandbox-windows-acl/verify/abi-probe.cpp | 20 +++ packages/shell/pwsh-sandbox/README.i18n.yaml | 6 +- packages/shell/pwsh-sandbox/README.md | 4 +- packages/shell/pwsh-sandbox/README.zh.md | 4 +- 32 files changed, 709 insertions(+), 110 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.md create mode 100644 .agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index 0b2036ca86..0c0d28aa76 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-06-sandbox.md -2026-07-06-sandbox.md: 45a1c9c028f127c67e56f4436088852f59e10140 -2026-07-06-sandbox.zh.md: 6ad51a039583949849c853fab2f7c2a72d6d9db7 +2026-07-06-sandbox.md: f2c703ba67bc16529605250d35ed181412f05494 +2026-07-06-sandbox.zh.md: ff4d4ed592dc4235903555528a029b4b295d972e diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index 45a1c9c028..f2c703ba67 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -176,7 +176,7 @@ Costs and accepted limits: - **The one-wrapper illusion is given up knowingly.** A `tools/pre-execute` wrapper plus prompt conventions does not solve sandbox approval — the correct design costs structured denials, native runner probes, per-call policy carriage, and consistent cross-family enforcement, and this design pays it. - **`read-only` became a cross-family boundary through a follow-up.** This RFC shipped bash-only enforcement; the [cross-family fs sandbox RFC](2026-07-14-cross-family-fs-sandbox.md) extends the same mode vocabulary to the filesystem tools through a sandboxed `ctx.fs` provider and relocates the mode/root config and the `sandbox/mode` override to `ctx.sandboxPolicy` (§ In-process tools). -- **Windows is a partial backend.** This RFC originally reserved an empty, fail-closed win32 chain; the later [Windows ACL sandbox decision](2026-08-08-windows-acl-restricted-token-sandbox.md) filled it with the restricted-token runner. Its Everyone and hard-link gaps are reported as `enforcement: 'partial'`, never promoted to the full promise. +- **Windows is a partial backend.** This RFC originally reserved an empty, fail-closed win32 chain; the later [Windows ACL sandbox decision](2026-08-08-windows-acl-restricted-token-sandbox.md) filled it with the restricted-token runner. Its hard-link, unconfined-read, and AppContainer-ACL gaps are reported as `enforcement: 'partial'`, never promoted to the full promise. - **The Seatbelt rung leans on Apple's deprecated-but-shipped `sandbox-exec` CLI.** As darwin's sole candidate it is selected without probing, so a future removal under a usable workdir surfaces as a runner-attributable spawn failure and an executable refusal through its fatal signature — both become `SANDBOX_UNAVAILABLE`, and the command never runs; fail closed, never open. - **Landlock confinement is only as complete as the running kernel's ABI.** Reported as `enforcement: 'partial'` rather than refused — the deliberate trade that keeps the fallback available on older-kernel hosts. - **Runner attribution uses an in-band protocol.** Exit status plus stderr cannot cryptographically identify the writer, so a confined child can mimic a fatal runner line and status to cause an availability/diagnostic false attribution. The conjunction and exact notice exclusion reduce accidental matches; this is not a sandbox bypass because the child is already confined. diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index 6ad51a0395..ff4d4ed592 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -176,7 +176,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自能力边 - **单一包装层的幻觉被有意放弃。**`tools/pre-execute` 包装层加提示词约定无法解决沙箱批准——正确的设计需要结构化拒绝、原生 runner 探测、按调用策略承载和一致的跨工具族强制,本设计为此付出了代价。 - **`read-only` 通过后续设计成为跨工具族边界。** 本 Agent Note 最初只交付 bash 强制;[跨工具族 fs 沙箱 RFC](2026-07-14-cross-family-fs-sandbox.zh.md) 通过沙箱化的 `ctx.fs` 提供方把同一模式词汇扩展到文件系统工具,并将 mode/root 配置和 `sandbox/mode` 覆盖迁移到 `ctx.sandboxPolicy`(§ 进程内工具)。 -- **Windows 后端只提供部分强制执行。** 本 RFC 最初预留了一条空的、失败关闭的 win32 链;后续的 [Windows ACL 沙箱决策](2026-08-08-windows-acl-restricted-token-sandbox.zh.md)以受限令牌 runner 填充了它。其 Everyone 与硬链接缺口报告为 `enforcement: 'partial'`,绝不提升为完整承诺。 +- **Windows 后端只提供部分强制执行。** 本 RFC 最初预留了一条空的、失败关闭的 win32 链;后续的 [Windows ACL 沙箱决策](2026-08-08-windows-acl-restricted-token-sandbox.zh.md)以受限令牌 runner 填充了它。其硬链接、读取不受限与 AppContainer ACL 缺口报告为 `enforcement: 'partial'`,绝不提升为完整承诺。 - **Seatbelt 层级依赖 Apple 已弃用但仍交付的 `sandbox-exec` CLI。** 作为 darwin 的唯一候选,它无需探测即被选中,因此在 workdir 可用时,未来移除会表现为可归因于 runner 的 spawn 失败,可执行文件拒绝则通过其致命签名体现——两者都会变为 `SANDBOX_UNAVAILABLE`,且命令绝不会运行;失败关闭,绝不开放。 - **Landlock 约束的完整度取决于运行内核的 ABI。** 报告为 `enforcement: 'partial'` 而非拒绝——这是有意的权衡,使备选在旧内核主机上仍可用。 - **Runner 归因使用带内协议。** 退出状态与 stderr 无法以密码学方式识别写入者,因此受限子进程可以模仿 runner 的致命诊断行和状态,造成可用性或诊断误归因。多项证据的合取与精确通知排除减少了意外匹配;这不是沙箱绕过,因为子进程已经受到限制。 diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml index 43bc59bf13..65bc426f49 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md -2026-08-08-windows-acl-restricted-token-sandbox.md: 2ab157c697d8917f2e3c6315e5ef161ce6eb5229 -2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 10cac08c3161c6c723414c4274333af4eebc36f3 +2026-08-08-windows-acl-restricted-token-sandbox.md: 65780b9bba31605db02dc9be4c1b2454c74eb0f4 +2026-08-08-windows-acl-restricted-token-sandbox.zh.md: fe9aadb97b172d77d65ca6b0a843f05751075502 diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md index 2ab157c697..65780b9bba 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md @@ -41,3 +41,5 @@ The product-visible Windows roster flip is win32-only, so keyless snapshots that ## Related The [pwsh executor decision](../../archived/feature/2026-08-01-pwsh-tool-and-executor.md) owns the pwsh-sandbox/tool-pwsh dialect split this rung consumes. + +The [delete-confinement decision](2026-09-19-windows-acl-mandatory-integrity-confinement.md) extends this rung with the Low integrity layer and the ambient-delete deny; it supersedes only the delete route documented here. diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md index 10cac08c31..fe9aadb97b 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md @@ -41,3 +41,5 @@ landstrip 评估在实现前已被否决(未经实战检验;自建 launcher ## Related [pwsh 执行器决策](../../archived/feature/2026-08-01-pwsh-tool-and-executor.md)拥有本档所消费的 pwsh-sandbox/tool-pwsh 方言划分。 + +[删除约束决策](2026-09-19-windows-acl-mandatory-integrity-confinement.zh.md)以 Low 完整性层与环境性删除拒绝扩展本档;它只取代本文档所述的删除路径。 diff --git a/.agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.i18n.yaml b/.agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.i18n.yaml new file mode 100644 index 0000000000..4ab68b411e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.md +2026-09-19-windows-acl-mandatory-integrity-confinement.md: f53765f34a7d6d9bcae8b06839c30731a054165d +2026-09-19-windows-acl-mandatory-integrity-confinement.zh.md: a963fd7f549ca2e995f74893c95f1620034664eb diff --git a/.agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.md b/.agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.md new file mode 100644 index 0000000000..f53765f34a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.md @@ -0,0 +1,56 @@ +# Agent Note: Windows sandbox delete confinement: mandatory integrity plus an ambient-delete deny + +Status: implemented + +English | [中文](2026-09-19-windows-acl-mandatory-integrity-confinement.zh.md) + +## Problem + +The [restricted-token rung](2026-08-08-windows-acl-restricted-token-sandbox.md) confines writes by intersecting the requested access mask with restricting SIDs. That intersection covers only the access check against the object's OWN security descriptor: 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. `cmd /c del` and `[System.IO.File]::Delete` — anything reaching `DeleteFileW` — therefore deleted files outside the workspace in BOTH confined modes (issue #4581), while plain writes from the same child were denied. + +## Decision + +Every grant now applies three edits in one `SetNamedSecurityInfoW` call, and the restricted token is lowered to match: + +1. the capability-SID allow ACE (`GRANT_MASK`, `OI|CI`) — the write authority, unchanged; +2. a DENY ACE for the world SID on `FILE_DELETE_CHILD` with `CONTAINER_INHERIT_ACE` — the capability ACE's DELETE bit becomes the only delete authority inside a granted root; +3. a Low integrity mandatory label (`S-1-16-4096`, `SYSTEM_MANDATORY_LABEL_NO_WRITE_UP`, `OI|CI`) — and `SetTokenInformation(TokenIntegrityLevel)` lowers the confined token to the same level. + +Each piece is load-bearing. The label is what reaches the parent-`FILE_DELETE_CHILD` route at all: the kernel evaluates the mandatory policy inside the access check whichever right supplied the authority, so a Medium object outside the granted roots is delete-proof as well as write-proof. The deny is what keeps one granted root out of ANOTHER's reach: every granted root carries the Low label, so the integrity check alone would still pass between them. The container-only inheritance on the deny is what keeps the ambient ACL usable: `0x40` is a member of `FILE_ALL_ACCESS`, so a file-level copy would refuse every `GENERIC_ALL`/`FullControl` open inside the root — measured against a real runner before the flag was narrowed. + +The label is STANDING on workspace roots, like the capability ACE and for the same reason: revoking it per session would re-propagate the whole tree on every provision and would race with concurrent sessions. `revokeWrite` clears it only when no other capability grant remains on the directory, so two grants on one directory stay independently revocable. The idempotent skip requires the exact ACE, the exact deny, AND the exact label, so a root granted by an earlier build picks the deny up on its next provision. + +## Alternatives considered + +### Why not make the label revocable per session? + +It removes the externality below, but pays the eager full-tree propagation on every provision (tens of seconds on large workspaces) that the deterministic per-workspace SID exists to avoid, and a concurrent session or a crashed one leaves the label oscillating. + +### Why not Untrusted integrity for read-only mode? + +Measured on this host: an Untrusted (`S-1-16-0`) token cannot start `pwsh` at all — DLL initialization fails (`0x8007045A`, `BCrypt.dll`) — so the read-only mode would have no usable shell. + +### Why not deny `FILE_DELETE_CHILD` with `OI|CI`? + +It also lands the deny on every FILE inside the granted root (observed through `icacls`: `Everyone:(I)(DENY)(DC)`), and `0x40` is part of `FILE_ALL_ACCESS`, so `CreateFileW(GENERIC_ALL)` on those files returns `ERROR_ACCESS_DENIED` for the user, Administrators, SYSTEM, and the DSH host alike. Narrowing the flag removes that class; a FullControl open of a DIRECTORY inside a granted root stays denied, which is the irreducible cost of denying a right that is a member of the full-access mask. + +### Why not drop the confinement layer and document the escape? + +The escape is the report: `cmd /c del` deleting outside the workspace is exactly what the sandbox promises not to allow, and no read-side or ACL-only change closes it — the restricting-SID intersection cannot reach the parent route by construction. + +### Why not give each workspace its own integrity level? + +Mandatory integrity control has five fixed levels (Untrusted, Low, Medium, High, System); they cannot be derived per workspace the way `S-1-4-x-y` capability SIDs are. + +## Consequences + +Bought: deletes confined through every authority Windows accepts AND confined to the granted root that owns them; the previously documented Everyone-write boundary closed; writes, reads, and process visibility otherwise unchanged; fail-closed errors on every Win32 call. Cost, all recorded in the package README: the standing Low label widens the workspace tree for ANY other process running at Low integrity as the same user and outlives DSH (the price of having a write boundary at all at this integrity level); a granted directory must now also grant `WRITE_OWNER` for its label (a Full-control workspace has it, a Modify-only one fails loudly); a FullControl open of a directory inside a granted root is denied; a tree another AppContainer tool has ACL'd with a package SID is unreadable to the Low child; FAT-class targets stay unverified. The [restricted-token rung note](2026-08-08-windows-acl-restricted-token-sandbox.md) remains the owner of the rung's token lists, runner contract, and grant lifecycle; this note owns only the delete route and the integrity layer that closes it. + +## Testing + +`runner.spec.ts` pins the behavior against a real runner and a real restricted token: the delete escape is denied in both modes through `cmd`, .NET, `Remove-Item`, and libuv while the file survives on disk; one session cannot delete inside another granted root; NUL stays writable in both modes; a `GENERIC_ALL` open of a file inside a granted root succeeds while the same open of a directory is denied; revoking one of two grants on a directory leaves the surviving grant usable. `acl.spec.ts` pins the real DACL/label lifecycle, that the deny inherits to containers only, and the shared-label revocation rule; `token-failure-paths.spec.ts` pins the exact `TokenIntegrityLevel` payload; the failure-path suites cover every new allocation and early exit, including the label ACL and descriptor releases. + +## Related + +- [Windows sandbox rung: raw ACL restricted tokens](2026-08-08-windows-acl-restricted-token-sandbox.md) — the rung this change extends (kept active; this note supersedes only its delete-route boundary). +- [Sandbox decision](2026-07-06-sandbox.md) — the platform chains and the `partial` enforcement vocabulary. diff --git a/.agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.zh.md b/.agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.zh.md new file mode 100644 index 0000000000..a963fd7f54 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.zh.md @@ -0,0 +1,56 @@ +# Agent Note: Windows 沙箱删除约束:强制完整性 + 环境性删除拒绝 + +Status: implemented + +[English](2026-09-19-windows-acl-mandatory-integrity-confinement.md) | 中文 + +## 问题 + +[受限令牌档](2026-08-08-windows-acl-restricted-token-sandbox.zh.md)通过把请求的访问掩码与 restricting SIDs 求交集来约束写入。该交集只覆盖针对对象**自身**安全描述符的那次访问检查:Windows 也会依据**父目录**的 `FILE_DELETE_CHILD` 权限批准写入或删除,而这项权限不需要任何 restricting SID 副署。因此 `cmd /c del` 与 `[System.IO.File]::Delete`——凡走 `DeleteFileW` 的路径——在两种受限模式下都能删除工作区之外的文件(issue #4581),而同一子进程的普通写入却被拒绝。 + +## 决策 + +每次授权现在都在一次 `SetNamedSecurityInfoW` 调用中施加三项编辑,并把受限令牌降到同一级别: + +1. 能力 SID 的允许 ACE(`GRANT_MASK`,`OI|CI`)——写权限本身,未变; +2. 对 world SID 的 `FILE_DELETE_CHILD` 拒绝 ACE,带 `CONTAINER_INHERIT_ACE`——能力 ACE 的 DELETE 位成为授权根内唯一的删除授权来源; +3. Low 完整性强制标签(`S-1-16-4096`、`SYSTEM_MANDATORY_LABEL_NO_WRITE_UP`、`OI|CI`)——并用 `SetTokenInformation(TokenIntegrityLevel)` 把受限令牌降到同一级别。 + +三者都不可省。标签是唯一能触及父目录 `FILE_DELETE_CHILD` 路径的手段:无论权限来自哪一项,内核都在访问检查内部执行强制策略,因此授权根之外的 Medium 对象不仅防写、也防删。拒绝项则负责隔开**各个**授权根:所有授权根都带 Low 标签,仅靠完整性检查它们之间仍会放行。而拒绝项只做容器继承,是为了不破坏环境 ACL 的可用性:`0x40` 是 `FILE_ALL_ACCESS` 的成员,若落到文件上会让授权根内每次 `GENERIC_ALL`/`FullControl` 打开都被拒绝——收窄标志之前已在真实 runner 上实测确认。 + +标签在工作区根上**常驻**,与能力 ACE 同理:按会话回收会导致每次供给都重新传播整棵树,并与并发会话竞争。`revokeWrite` 只在该目录上不再留有其他能力授权时才清除标签,因此同一目录上的两份授权仍可独立撤销。幂等跳过要求精确 ACE、精确拒绝与精确标签三者齐备,因此早前版本已授权的工作区会在下次供给时补上拒绝项。 + +## 考虑过的替代方案 + +### 为什么不让标签按会话回收? + +那样可以消除下面的外部性,但每次供给都要付出确定性工作区 SID 本要避免的急切全树传播(大型工作区上以数十秒计),而且并发会话或崩溃会话会让标签反复消失与重建。 + +### 为什么 read-only 模式不用 Untrusted 完整性? + +本机实测:Untrusted(`S-1-16-0`)令牌根本无法启动 `pwsh`——DLL 初始化失败(`0x8007045A`,`BCrypt.dll`)——该模式将没有可用的 shell。 + +### 为什么不用 `OI|CI` 施加 `FILE_DELETE_CHILD` 拒绝? + +那样拒绝项也会落到授权根内的每个**文件**上(用 `icacls` 观察到的正是 `Everyone:(I)(DENY)(DC)`),而 `0x40` 属于 `FILE_ALL_ACCESS`,于是这些文件上的 `CreateFileW(GENERIC_ALL)` 会对用户、Administrators、SYSTEM 与 DSH host 一并返回 `ERROR_ACCESS_DENIED`。收窄标志消除了这一类影响;授权根内**目录**的 FullControl 打开仍被拒绝,这是拒绝一项属于完全访问掩码的权限所无法避免的代价。 + +### 为什么不干脆去掉这层约束、只把逃逸写进文档? + +逃逸本身就是报告内容:`cmd /c del` 删除工作区之外的文件,恰恰是沙箱承诺不允许的事;而仅靠读侧或纯 ACL 的改动关不掉它——restricting SID 交集在构造上就触及不到父目录路径。 + +### 为什么不让每个工作区拥有自己的完整性级别? + +强制完整性控制只有五个固定级别(Untrusted、Low、Medium、High、System),无法像 `S-1-4-x-y` 能力 SID 那样按工作区派生。 + +## 后果 + +所得:删除在 Windows 接受的每条授权路径上都被约束,且被约束在各自的授权根内;此前记录的 Everyone 写边界被关闭;写入、读取与进程可见性其余部分不变;每次 Win32 调用失败都 fail-closed。所失(全部记录在包 README 中):常驻 Low 标签会向**任何**以同一用户身份运行在 Low 完整性的进程放宽该工作区树,并比 DSH 生命期更长(这是在该完整性级别上拥有写边界本身的代价);被授权目录现在还必须授予 `WRITE_OWNER` 才能写入标签(完全控制的工作区具备,仅授予 Modify 的会大声失败);授权根内目录的 FullControl 打开被拒绝;被其他 AppContainer 工具以包 SID 标记过的目录树对 Low 子进程不可读;FAT 类目标仍未验证。[受限令牌档笔记](2026-08-08-windows-acl-restricted-token-sandbox.zh.md)仍是该档令牌列表、runner 约定与授权生命周期的归属者;本笔记只负责删除路径与封闭它的完整性层。 + +## 测试 + +`runner.spec.ts` 以真实 runner 与真实受限令牌钉住行为:两种模式下经 `cmd`、.NET、`Remove-Item` 与 libuv 的越界删除全部被拒且**宿主侧文件仍存在**;一个会话无法删除另一个授权根内的文件;NUL 在两种模式下仍可写;授权根内**文件**的 `GENERIC_ALL` 打开成功,而**目录**的同一打开被拒绝;同一目录上两份授权之一被撤销后,存留的授权仍可用。`acl.spec.ts` 钉住真实 DACL/标签生命周期、拒绝项只继承到容器、以及共享标签的撤销规则;`token-failure-paths.spec.ts` 钉住 `TokenIntegrityLevel` 的精确载荷;失败路径套件覆盖每处新增分配与提前退出,包括标签 ACL 与描述符的释放。 + +## 相关 + +- [Windows sandbox 档:原始 ACL 受限令牌](2026-08-08-windows-acl-restricted-token-sandbox.zh.md)——本次改动所扩展的档(保持 active;本笔记只取代其删除路径边界)。 +- [沙箱决策](2026-07-06-sandbox.zh.md)——平台链与 `partial` 强制执行词汇。 diff --git a/docs/subsystems/sandbox.i18n.yaml b/docs/subsystems/sandbox.i18n.yaml index 7bb8ec7c19..c8eeb0c0bb 100644 --- a/docs/subsystems/sandbox.i18n.yaml +++ b/docs/subsystems/sandbox.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write docs/subsystems/sandbox.md -sandbox.md: c29450d83c54d48a69f74f0b2fa38fe7c4f35ef2 -sandbox.zh.md: 71b55fcb8d46144e45daef717999d8905a78eec4 +# pnpm run verify-translation-pairing --write docs//subsystems//sandbox.md +sandbox.md: 05be79018f60e1e6af964449756322d283048c0d +sandbox.zh.md: 843be48f65ebe47b6c3307f5914ea011191e0bcb diff --git a/docs/subsystems/sandbox.md b/docs/subsystems/sandbox.md index c29450d83c..05be79018f 100644 --- a/docs/subsystems/sandbox.md +++ b/docs/subsystems/sandbox.md @@ -27,7 +27,7 @@ Only the first two modes can be sent to a provider. A `danger-full-access` consu type ConfinedSandboxMode = Exclude ``` -Enforcement is a reported fact. `full` means the backend governs every file effect promised by the mode; `partial` means an active backend or older kernel ABI governs only a subset, so consumers that require the absolute promise must reject or surface that distinction. Older Landlock ABIs and the Windows ACL runner's Everyone/hard-link boundaries are current partial cases. +Enforcement is a reported fact. `full` means the backend governs every file effect promised by the mode; `partial` means an active backend or older kernel ABI governs only a subset, so consumers that require the absolute promise must reject or surface that distinction. Older Landlock ABIs and the Windows ACL runner's hard-link, unconfined-read, and AppContainer-ACL boundaries are current partial cases. ```ts type-equiv /** diff --git a/docs/subsystems/sandbox.zh.md b/docs/subsystems/sandbox.zh.md index 71b55fcb8d..843be48f65 100644 --- a/docs/subsystems/sandbox.zh.md +++ b/docs/subsystems/sandbox.zh.md @@ -27,7 +27,7 @@ type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access' type ConfinedSandboxMode = Exclude ``` -强制执行完整性是后端报告的事实。`full` 表示后端管控了该模式承诺的所有文件效果;`partial` 表示活跃后端或较旧的内核 ABI 仅管控其中一个子集,因此要求绝对保证的消费方必须拒绝或向上暴露这一区别。当前的部分强制执行情形包括较旧的 Landlock ABI,以及 Windows ACL runner 的 Everyone 与硬链接边界。 +强制执行完整性是后端报告的事实。`full` 表示后端管控了该模式承诺的所有文件效果;`partial` 表示活跃后端或较旧的内核 ABI 仅管控其中一个子集,因此要求绝对保证的消费方必须拒绝或向上暴露这一区别。当前的部分强制执行情形包括较旧的 Landlock ABI,以及 Windows ACL runner 的硬链接、读取不受限与 AppContainer ACL 边界。 ```ts type-equiv /** diff --git a/packages/sandbox/sandbox-local/README.i18n.yaml b/packages/sandbox/sandbox-local/README.i18n.yaml index 30cfbd2154..13441f23d7 100644 --- a/packages/sandbox/sandbox-local/README.i18n.yaml +++ b/packages/sandbox/sandbox-local/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/sandbox/sandbox-local/README.md -README.md: 15da8baaa3ab7ce476f5c45c6207657915fd7815 -README.zh.md: 0e55553e1ff18ae6e91563c34eb30f4dab61f45c +# pnpm run verify-translation-pairing --write packages//sandbox//sandbox-local//README.md +README.md: 278f2e5810e0dee12b061b33d08716cdcf5b7ae2 +README.zh.md: a7854c30d03d6be84bc0dc5b2a7a4bd1e07f97e4 diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md index 15da8baaa3..278f2e5810 100644 --- a/packages/sandbox/sandbox-local/README.md +++ b/packages/sandbox/sandbox-local/README.md @@ -50,7 +50,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a ### Confined execution and enforcement -With the provider mounted, a command runs under the mode you resolve per call. Enforcement is a reported fact, not a promise: `full` means the backend governs every promised file effect, while `partial` means it governs only a subset — the Windows ACL rung (Everyone and hard-link boundaries) and older Landlock ABIs are the current partial cases, so a consumer that requires an absolute boundary can reject or surface them. Denied file effects surface through the backend's denial dialect, and a runner that fails before executing the command reports a structured runner-failure signature. +With the provider mounted, a command runs under the mode you resolve per call. Enforcement is a reported fact, not a promise: `full` means the backend governs every promised file effect, while `partial` means it governs only a subset — the Windows ACL rung (hard-link, unconfined-read, and AppContainer-ACL boundaries) and older Landlock ABIs are the current partial cases, so a consumer that requires an absolute boundary can reject or surface them. Denied file effects surface through the backend's denial dialect, and a runner that fails before executing the command reports a structured runner-failure signature. ### Failures and recovery @@ -78,7 +78,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 the restricted token must retain Everyone and NTFS hard links alias one file object across paths. +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. When the built ACL runner is absent, source launch pins the `tsx/esm/api` loader and TypeScript path mapping to this installation. The command's working directory and ambient `TSX_TSCONFIG_PATH` cannot select the runner's source dependencies. @@ -127,7 +127,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 ACL enforcement is partial** — the restricted token must retain Everyone for process initialization, so external objects granting Everyone write access remain writable; NTFS hard links also alias one file object across workspace and external paths. The provider reports `enforcement: 'partial'` rather than overstating that boundary as full. +- **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. - **Runner selection is cached for the provider lifetime** — installing, removing, or repairing a runner requires reloading the plugin before selection changes. diff --git a/packages/sandbox/sandbox-local/README.zh.md b/packages/sandbox/sandbox-local/README.zh.md index 0e55553e1f..a7854c30d0 100644 --- a/packages/sandbox/sandbox-local/README.zh.md +++ b/packages/sandbox/sandbox-local/README.zh.md @@ -50,7 +50,7 @@ kind: "package-reference" ### 受限执行与强制执行 -挂载提供方后,命令在你逐调用解析的模式下运行。强制执行是报告的事实,而非承诺:`full` 表示后端管辖模式承诺的每个文件操作,`partial` 表示它只管辖子集——Windows ACL 档(Everyone 与硬链接边界)与较旧的 Landlock ABI 是当前的部分强制执行情形,因此需要绝对边界的消费方可以拒绝或向上暴露它们。被拒绝的文件操作通过后端的拒绝方言呈现,执行命令前失败的 runner 会报告结构化的 runner 失败签名。 +挂载提供方后,命令在你逐调用解析的模式下运行。强制执行是报告的事实,而非承诺:`full` 表示后端管辖模式承诺的每个文件操作,`partial` 表示它只管辖子集——Windows ACL 档(硬链接、读取不受限与 AppContainer ACL 边界)与较旧的 Landlock ABI 是当前的部分强制执行情形,因此需要绝对边界的消费方可以拒绝或向上暴露它们。被拒绝的文件操作通过后端的拒绝方言呈现,执行命令前失败的 runner 会报告结构化的 runner 失败签名。 ### 失败与恢复 @@ -78,7 +78,7 @@ bwrap profile 组合只读宿主根目录、全新 `/dev` 与私有 PID 命名 Seatbelt profile 默认允许,带 `(deny file-write*)` 与来自共享 `writableRoots` 辅助函数的写入 allow-list,因此恰好管辖模式承诺的文件操作;每个根目录都经过规范化,因为 Seatbelt 匹配解析后的路径(`/tmp` 就是 `/private/tmp`)。 -Windows 档为每个工作区保留一个确定性写入 SID 和常驻 ACE,同时为每个活跃的会话/工作区对分配一个随机私有临时目录,以及不同的 SID 和可撤销 ACE——共享工作区的会话共享其预期写权限,却不会继承彼此的临时目录权限。新的提供方总会选择新的临时路径和 SID,因此崩溃残留既无法阻止恢复的会话,也无法向其授权。该档报告 `partial` 强制执行,因为受限令牌必须保留 Everyone,且 NTFS 硬链接会把同一文件对象别名为多个路径。 +Windows 档为每个工作区保留一个确定性写入 SID 和常驻 ACE,同时为每个活跃的会话/工作区对分配一个随机私有临时目录,以及不同的 SID 和可撤销 ACE——共享工作区的会话共享其预期写权限,却不会继承彼此的临时目录权限。新的提供方总会选择新的临时路径和 SID,因此崩溃残留既无法阻止恢复的会话,也无法向其授权。该档报告 `partial` 强制执行,因为 NTFS 硬链接会把同一文件对象别名为多个路径、读取仍不受限,且被其他 AppContainer 工具以包 SID 标记过的目录树对 Low 完整性子进程不可读。 构建后的 ACL runner 缺失时,源码启动将 `tsx/esm/api` 加载器和 TypeScript 路径映射固定到本安装目录。命令的工作目录和环境中的 `TSX_TSCONFIG_PATH` 无法选择 runner 的源码依赖。 @@ -127,7 +127,7 @@ Windows 档为每个工作区保留一个确定性写入 SID 和常驻 ACE,同 这些限制说明提供方何时不合适,或何时需要特别运维。它们是当前包约束,不是通用平台对比或任务积压。 -- **Windows ACL 只能实现部分强制执行**——受限令牌必须保留 Everyone 以完成进程初始化,因此授予 Everyone 写访问的外部对象仍可写;NTFS 硬链接也会使工作区路径与外部路径指向同一个文件对象。提供方报告 `enforcement: 'partial'`,而不会把该边界夸大为完整强制执行。 +- **Windows ACL 只能实现部分强制执行**——NTFS 硬链接会使工作区路径与外部路径指向同一个文件对象,读取仍不受限,且被其他 AppContainer 工具以包 SID 标记过的目录树对 Low 完整性子进程不可读。提供方报告 `enforcement: 'partial'`,而不会把该边界夸大为完整强制执行。 - **Landlock 可能只实现部分强制执行**——较旧且受支持的内核 ABI 只能限制自身公开的访问类别,因此报告 `enforcement: 'partial'`,不会夸大为完整强制执行。 - **Seatbelt 依赖已弃用的 `sandbox-exec`**——macOS 仍会提供它,但若 Apple 移除该私有策略引擎,该提供方无法替换或探测。 - **runner 选择在提供方生命周期内缓存**——安装、移除或修复 runner 后,必须重载插件才能改变选择。 diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 037dc90079..5f180668cf 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -15,8 +15,9 @@ * 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 - * WRITE_RESTRICTED must retain Everyone in its - * restricting list and NTFS hard links alias one file object across paths. + * 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. * @module @deepseek-ai/dsh-sandbox-local */ @@ -178,11 +179,12 @@ const STATIC_ENFORCEMENT: Record = bwrap: 'full', landlock: 'full', seatbelt: 'full', - // WRITE_RESTRICTED needs Everyone in both restricting lists for process - // initialization. An external object that grants Everyone write access - // therefore remains writable, and NTFS hard links can alias a granted - // workspace file to a path outside it. The backend enforces the remaining - // ACL-addressable surface but must not advertise the absolute promise. + // Everyone stays in both restricting lists for process initialization, but + // the Low label denies the write authority it used to confer. NTFS hard + // links still alias a granted workspace file to a path outside it, reads + // stay unconfined, and an AppContainer-ACL'd tree is unreadable to the + // child: the backend enforces the remaining ACL-addressable surface but + // must not advertise the absolute promise. 'windows-acl': 'partial', } @@ -518,7 +520,8 @@ export class LocalSandboxProvider extends SandboxProvider { // every promised file effect by construction, so their passing probes // are always full enforcement; the Landlock launcher's probe report // distinguishes full from per-ABI-partial, while windows-acl is always - // partial for its documented Everyone and hard-link boundaries. + // partial for its documented hard-link, unconfined-read, and + // AppContainer-ACL boundaries. switch (runner) { case 'bwrap': { const probe = this.internals.probeBwrap ?? (() => defaultProbeBwrap(this.probeTimeoutMs)) diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index 42c27aa70c..0e8cc8e2b6 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/sandbox/sandbox-windows-acl/README.md -README.md: 41415249c2fcf05c17634f6930692b1a2a5960c8 -README.zh.md: 7c62b263fa553748c186f4c40d2fc0aa1259804f +# pnpm run verify-translation-pairing --write packages//sandbox//sandbox-windows-acl//README.md +README.md: 42b2290028c9138e38594ae23ce94fb3f0ebaa85 +README.zh.md: 6a99eec14adf65027f199079f7ae08d94b0636ad diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index 41415249c2..42b2290028 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -113,11 +113,13 @@ When launched with the subprocess control marker, the runner forwards fd 7 throu - **Everyone stays in both restricting lists, but no longer confers write authority** — the keep-alive group is required for early DLL initialization and CNG; the Low label now denies an Everyone-granted write outside the labeled roots, so that former gap is closed. - **Inside a granted root, the capability ACE's DELETE bit is the only delete authority** — the grant denies `FILE_DELETE_CHILD` to the world SID, which also removes the ambient default: a file whose own DACL grants no DELETE is no longer deletable through its parent's rights, by the confined child or by the user's own processes. The user's ordinary deletes keep working because the workspace DACL grants them DELETE directly. +- **The deny inherits to subdirectories only, and a FullControl open of one is denied** — `FILE_DELETE_CHILD` is evaluated on directories, so the deny carries `CONTAINER_INHERIT_ACE` and never reaches files (its bit, `0x40`, is part of `FILE_ALL_ACCESS`, so a file-level copy would refuse every `GENERIC_ALL`/`FullControl` open by the user, Administrators, SYSTEM, or the DSH host). Directories inside a granted root keep the deny and therefore refuse those opens; `DELETE`-based deletes, `MAXIMUM_ALLOWED`, and ordinary read/write opens are unaffected, and both outcomes are pinned by the runner suite. - **Writes and deletes are restricted; reads, network, and process visibility are not** — neither layer intersects reads, so a confined child can read any caller-readable file (including files in another workspace) and open sockets; `read-only` therefore needs a read-side policy to be expressed. - **Hard links are file-object aliases, not path aliases** — an inheritable workspace grant propagated onto an existing hard link labels and grants the one underlying file security descriptor, so the same object is writable through an external alias; rejecting multiply-linked files is not viable for ordinary pnpm installations. - **Console isolation is unavailable** — children created with `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` die during DLL initialization with `STATUS_DLL_INIT_FAILED` (`0xC0000142`); children share the host console, and pipe-based stdio redirection is unaffected. -- **Security descriptor edits are standing directory mutations** — workspace ACEs, denies, and labels stand by design (the reuse cache, never revoked); temp edits are revoked by `dispose()`, and the deny a revoke leaves behind disappears with the temp directory itself; manual `icacls` cleanup cannot revoke them on this platform (`ERROR_NONE_MAPPED`, 1332), so revoke through this module. -- **Granted directories must be caller-owned** — the owner's implicit `WRITE_DAC` is what lets the sandbox edit the DACL without elevation. +- **Security descriptor edits are standing directory mutations** — workspace ACEs, denies, and labels stand by design (the reuse cache, never revoked); temp edits are revoked by `dispose()`, except that a revoke keeps the shared label while another capability grant remains on the directory, and the deny it leaves behind disappears with the temp directory itself; manual `icacls` cleanup cannot revoke them on this platform (`ERROR_NONE_MAPPED`, 1332), so revoke through this module. +- **A standing Low label outlives DSH and widens the tree for other Low-integrity processes** — the workspace's inheritable label survives the session (and same-volume moves), so any other process running at Low integrity as the same user — another product's Low-IL sandbox, a Protected Mode reader — can write and delete inside the workspace, where a Medium label would have denied it. The label is the price of the write boundary: without it the confined child cannot write at all, and revoking it per session would re-propagate the whole tree on every provision. +- **Granted directories must be caller-owned and grant `WRITE_OWNER`** — the owner's implicit rights cover only `READ_CONTROL` and `WRITE_DAC`; the label lives in the SACL, so the combined apply additionally needs `WRITE_OWNER` (a Full-control directory, the normal workspace case, has it). A directory whose DACL grants only Modify now fails the grant loudly instead of silently skipping the confinement. - **The ambient temp root is never granted implicitly** — direct callers must supply an existing private `tempDir` plus its distinct `tempWriteSid`, or disable temp writes with `tempDir: null`; the actual temp directory must be disjoint from every writable root. - **The confined child's temp capability is private per live session/workspace pair** — the runner rewrites TMP/TEMP to that private directory before the spawn; two tokens sharing the same workspace SID cannot write one another's temp directories. - **`whoami` and token-inspection cmdlets may fail under the restricted token** — `GetTokenInformation` on the duplicate is partially unavailable to the child, so whether a reporting cmdlet works is host-dependent; it is diagnostic noise rather than an operational failure. @@ -174,7 +176,7 @@ These limits define when the backend is a poor fit or needs special operational - **Piped stdio capture is impossible for confined grandchildren** — libuv's pipe stdio uses named pipes, whose client-end open requests write access no restricting SID is granted (the Win32 layer's default SD template, not the token default DACL), so `spawn(..., { stdio: 'pipe' })` inside a confined process fails with EPERM; inherited and ignored stdio spawns work, and anonymous pipes (PowerShell pipelines) work because the restricted token's default DACL carries a full-access restricting-SID ACE. - **Grant materialization is an eager full-tree propagation** — `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (tens of seconds on large workspace trees); the per-workspace identity pays it once per workspace per machine, and the exact-ACE skip makes every later provision cheap. - **Read-side confinement and network policy are out of scope** — `WRITE_RESTRICTED` intersects write accesses only; pair this backend with a read-side policy for stronger confinement. -- **Reads stop at objects another AppContainer-based tool has ACL'd with a package SID** — Windows denies a Low-integrity token access to a file whose DACL carries an ACE for a package SID (`S-1-15-2-…`), even when that DACL also grants the user full control and Everyone read. Tools that sandbox themselves with AppContainers grant exactly such ACEs, so a project tree stamped by one of them becomes unreadable to this backend's child. Removing the foreign ACE (or re-installing the affected tree) restores access; the package cannot grant it back. +- **Reads stop at objects another AppContainer-based tool has ACL'd with a package SID** — a file whose DACL carries an ACE for a package SID (`S-1-15-2-…`) is inaccessible to a Low-integrity token on this host, even when the same DACL grants the user full control and Everyone read (observed: adding that single ACE to a fresh file reproduces the denial, granting Everyone read does not lift it, and the same bytes copied elsewhere stay readable). The kernel rule behind it is unconfirmed and not this package's to change; tools that sandbox themselves with AppContainers stamp such ACEs, so a tree they touched becomes unreadable to this backend's child. Removing the foreign ACE (or re-installing the affected tree) restores access. - **Wide-directory and FAT-volume warnings are deferred; the FAT-class residue is unverified** — the UI-side warnings are not implemented, a FAT volume as a grant root fails loudly, and a FAT-class target outside the granted roots stores no security descriptor; its effective integrity label is assigned by the system rather than recorded on the object, so the label layer's behavior there is untested. FAT stays legacy residue. - **PowerShell language mode differs by confined mode** — under `read-only`, PowerShell cannot create its AppLocker probe files in temp and conservatively starts in ConstrainedLanguage (`Add-Type`, non-core .NET static calls, COM, and reflection fail); the shipped `workspace-write` path lets the probe complete, so pwsh stays in FullLanguage unless host-wide WDAC/AppLocker policy says otherwise, while a direct `AclSandbox` with `tempDir: null` has no such guarantee. This split is PowerShell startup behavior, not part of the ACL write boundary. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index 7c62b263fa..6a99eec14a 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -113,11 +113,13 @@ seam 先把确定性工作区 SID 的 ACE 常驻物化(每个工作区每服 - **Everyone 仍留在两种 restricting 列表中,但不再带来写权限。** 保活组是早期 DLL 初始化与 CNG 所必需的;如今 Low 标签会拒绝对被标记根目录之外、由 Everyone 授权的写入,因此这一旧缺口已关闭。 - **在授权根目录内,能力 ACE 的 DELETE 位是唯一的删除授权来源。** 授权会向 world SID 拒绝 `FILE_DELETE_CHILD`,这同时移除了环境性默认行为:自身 DACL 未授予 DELETE 的文件不再能凭父目录权限删除——受限子进程与用户自身进程皆然。用户日常删除仍然可用,因为工作区 DACL 直接向其授予 DELETE。 +- **拒绝项只继承到子目录,且子目录的 FullControl 打开会被拒。** `FILE_DELETE_CHILD` 只在目录上被评估,因此该 ACE 带 `CONTAINER_INHERIT_ACE`、绝不落到文件上(它的位 `0x40` 属于 `FILE_ALL_ACCESS`,若落到文件上会让用户、Administrators、SYSTEM 或 DSH host 的每次 `GENERIC_ALL`/`FullControl` 打开都被拒绝)。授权根内的目录保留该拒绝项,因而会拒绝这类打开;基于 `DELETE` 的删除、`MAXIMUM_ALLOWED` 与常规读写打开不受影响——两种结果都已被 runner 套件钉住。 - **写入与删除受限;读取、网络与进程可见性不受限。** 两层都不交叉检查读取,因此受限子进程可以读取调用者可读的任何文件(包括其他工作区中的文件)并打开套接字;`read-only` 因而需要读侧策略才能表达。 - **硬链接是文件对象别名,而非路径别名。** 传播到已有硬链接上的可继承工作区授权会标记并授权底层同一文件的安全描述符,因此同一对象也可通过外部别名写入;拒绝工作区中的所有多链接文件不具可行性,因为普通 pnpm 安装会使用硬链接。 - **控制台隔离不可用。** 以 `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` 创建的子进程在 DLL 初始化期间以 `STATUS_DLL_INIT_FAILED`(`0xC0000142`)死亡;子进程共享宿主控制台,基于管道的 stdio 重定向不受影响。 -- **安全描述符改动是对真实目录的驻留改动。** 工作区 ACE、拒绝项与标签按设计常驻(复用缓存,绝不撤销);临时改动由 `dispose()` 撤销,撤销后残留的那条拒绝项会随临时目录本身一并消失;手工 `icacls` 清理无法在本平台回收它们(`ERROR_NONE_MAPPED` 1332),请通过本模块回收。 -- **被授权目录必须由调用者拥有。** 所有者的隐式 `WRITE_DAC` 是沙箱无需提权即可编辑 DACL 的原因。 +- **安全描述符改动是对真实目录的驻留改动。** 工作区 ACE、拒绝项与标签按设计常驻(复用缓存,绝不撤销);临时改动由 `dispose()` 撤销——但若该目录上仍留有其他能力授权,撤销会保留共享的 Low 标签——撤销后残留的那条拒绝项会随临时目录本身一并消失;手工 `icacls` 清理无法在本平台回收它们(`ERROR_NONE_MAPPED` 1332),请通过本模块回收。 +- **常驻 Low 标签的生命期长于 DSH,并会向其他 Low 完整性的进程放宽该目录树。** 工作区的可继承标签在会话结束后(以及同卷移动后)依然存在,因此任何以同一用户身份运行在 Low 完整性的其他进程——别家产品的 Low-IL 沙箱、受保护模式阅读器——都能写入与删除工作区内的内容,而在 Medium 标签下这会被拒绝。这个标签是写边界的代价:没有它受限子进程根本无法写入,而按会话回收会导致每次供给都要重新传播整棵树。 +- **被授权目录必须由调用者拥有并授予 `WRITE_OWNER`。** 所有者隐式获得的只有 `READ_CONTROL` 与 `WRITE_DAC`;标签位于 SACL,因此合并应用还需要 `WRITE_OWNER`(完全控制目录——即正常工作区情形——本就具备)。DACL 只授予 Modify 的目录现在会大声失败,而不是静默跳过隔离。 - **环境临时根目录绝不会被隐式授权。** 直接调用方必须提供已存在的私有 `tempDir` 及其不同的 `tempWriteSid`,或用 `tempDir: null` 禁用临时写入;实际临时目录不得与任何可写根目录重叠。 - **受限子进程的临时能力按每个活跃的会话/工作区对私有。** runner 在 spawn 之前把 TMP/TEMP 改写为该私有目录;共享同一工作区 SID 的两个令牌无法写入彼此的临时目录。 - **受限令牌下 `whoami` 与令牌检查 cmdlet 可能失败。** 子进程对复制令牌的 `GetTokenInformation` 部分不可用,因此这类报告型 cmdlet 是否可用取决于宿主环境;这是诊断噪音而非运行故障。 @@ -174,7 +176,7 @@ seam 先把确定性工作区 SID 的 ACE 常驻物化(每个工作区每服 - **受限孙进程的管道 stdio 捕获不可用。** libuv 的管道 stdio 用的是 NAMED pipe,其 client 端打开所请求的写访问没有任何 restricting SID 被授予(是 Win32 层的默认 SD 模板,而非令牌默认 DACL),因此受限进程内 `spawn(..., { stdio: 'pipe' })` 以 EPERM 失败;继承与忽略 stdio 的 spawn 可用,匿名管道(PowerShell 的管道)因受限令牌默认 DACL 携带 restricting SID 全权 ACE 而可用。 - **授权物化是急切的全树传播。** 在带可继承 ACE 的目录上调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(大型工作区树上以数十秒计);按工作区身份每台机器每个工作区只付一次,精确 ACE 跳过让后续每次供给都很便宜。 - **读侧隔离与网络策略不在范围内**——`WRITE_RESTRICTED` 只交叉检查写访问;将此后端与读侧策略配对以获得更强隔离。 -- **读取会被其他基于 AppContainer 的工具以包 SID 授权过的对象挡住。** 当文件的 DACL 携带针对包 SID(`S-1-15-2-…`)的 ACE 时,即使该 DACL 同时向用户授予完全控制、向 Everyone 授予读取,Windows 也会拒绝 Low 完整性的令牌访问它。以 AppContainer 自我隔离的工具正是会写入这类 ACE,因此被它们标记过的项目树对本后端的子进程将变得不可读。移除外来 ACE(或重新安装受影响的目录树)即可恢复访问;本包无法反向授予。 +- **读取会被其他基于 AppContainer 的工具以包 SID 授权过的对象挡住。** 在本机上,当文件的 DACL 携带针对包 SID(`S-1-15-2-…`)的 ACE 时,Low 完整性的令牌无法访问它——即使同一份 DACL 同时向用户授予完全控制、向 Everyone 授予读取(已观测:只给新文件加这一条 ACE 即可复现拒绝,补授 Everyone 读取无法解除,而同样内容复制到别处仍可读)。其背后的内核规则尚未确证,也不由本包掌控;以 AppContainer 自我隔离的工具正是会写入这类 ACE,因此被它们标记过的目录树对本后端的子进程将不可读。移除外来 ACE(或重新安装受影响的目录树)即可恢复访问。 - **宽目录与 FAT 卷警告已推迟;FAT 类残留未经验证。** UI 侧警告尚未实现,FAT 卷作为授权根会大声失败,而授权根之外的 FAT 类目标不存储安全描述符;其有效完整性标签由系统分配而非记录在对象上,因此标签层在该处的行为未经测试。FAT 仍被视为遗留残留。 - **PowerShell 语言模式因受限模式而异。** 在 `read-only` 下,PowerShell 无法在临时目录中创建 AppLocker 探针文件,因此会保守地以 ConstrainedLanguage 启动(`Add-Type`、非核心 .NET 静态调用、COM 与反射失败);交付的 `workspace-write` 路径可让探针完成,因此除非主机范围的 WDAC/AppLocker 策略另有规定,否则 pwsh 保持 FullLanguage,而直接使用 `AclSandbox` 并配置 `tempDir: null` 时则没有这一保证。这一区别属于 PowerShell 启动行为,不是 ACL 写入边界的一部分。 diff --git a/packages/sandbox/sandbox-windows-acl/src/acl.ts b/packages/sandbox/sandbox-windows-acl/src/acl.ts index 199e9e394b..82f7030b67 100644 --- a/packages/sandbox/sandbox-windows-acl/src/acl.ts +++ b/packages/sandbox/sandbox-windows-acl/src/acl.ts @@ -34,17 +34,26 @@ import * as abi from './win32-abi.ts' * perms@0, mode@4, inheritance@8, Trustee@16 { pMultipleTrustee@16, * MultipleTrusteeOperation@24, TrusteeForm@28, TrusteeType@32, ptstrName@40 }. * `permissions` is the access mask; the POC passes 0 for REVOKE_ACCESS, which - * removes every ACE for the trustee. + * removes every ACE for the trustee. `inheritance` defaults to children of + * both kinds; the ambient-delete deny narrows it to containers because + * FILE_DELETE_CHILD is meaningless on a file and its bit would otherwise + * spread through the file's inherited mask. * @param sidPtr - the trustee SID the entry names. - * @param mode - the access mode (GRANT_ACCESS or REVOKE_ACCESS). - * @param permissions - the access mask to grant (0 for REVOKE_ACCESS). + * @param mode - the access mode (GRANT_ACCESS, DENY_ACCESS, or REVOKE_ACCESS). + * @param permissions - the access mask to grant or deny (0 for REVOKE_ACCESS). + * @param inheritance - the ACE inheritance flags. * @returns the packed entry buffer. */ -export function buildExplicitAccess(sidPtr: NativePtr, mode: number, permissions: number): Buffer { +export function buildExplicitAccess( + sidPtr: NativePtr, + mode: number, + permissions: number, + inheritance: number = abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT, +): Buffer { const entry = Buffer.alloc(abi.EXPLICIT_ACCESS_W_SIZE) entry.writeUInt32LE(permissions, 0) // grfAccessPermissions entry.writeUInt32LE(mode, 4) // grfAccessMode - entry.writeUInt32LE(abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT, 8) // grfInheritance: OI|CI + entry.writeUInt32LE(inheritance, 8) // grfInheritance entry.writeUInt32LE(abi.NO_MULTIPLE_TRUSTEE, 24) // Trustee.MultipleTrusteeOperation entry.writeUInt32LE(abi.TRUSTEE_IS_SID, 28) // Trustee.TrusteeForm entry.writeUInt32LE(abi.TRUSTEE_IS_UNKNOWN, 32) // Trustee.TrusteeType @@ -181,44 +190,39 @@ export function buildLowLabelAcl(api: Win32Bindings, lowLabelSidPtr: NativePtr): /** * True when the label ACL already carries the EXACT label this module would * add (mandatory-label ACE, OI|CI inheritance, no-write-up policy, the Low - * SID), so a re-grant can skip the eager full-tree propagation. The ACE - * layout matches {@link hasExactGrant} (header@0, Mask@4, inline SID@8), and a - * malformed header reads as "no exact label" so the caller falls back to the - * apply path, which owns the robust failure handling. + * SID), so a re-grant can skip the eager full-tree propagation. * @param labelAcl - the current label ACL pointer (from {@link readCurrentSecurity}). * @param lowLabelSidPtr - the Low integrity SID to match. * @returns whether the exact label ACE is already present. */ function hasExactLabel(labelAcl: NativePtr, lowLabelSidPtr: NativePtr): boolean { - const aclSize = decodeUint16At(labelAcl, 2) - const aceCount = decodeUint16At(labelAcl, 4) - if (aclSize < abi.ACL_HEADER_SIZE || aclSize > 1_048_576) return false // implausible: fall back to the apply path - let offset = abi.ACL_HEADER_SIZE // the first ACE follows the ACL header - for (let index = 0; index < aceCount; index++) { - const aceSize = decodeUint16At(labelAcl, offset + 2) - if (aceSize < abi.ACL_HEADER_SIZE || offset + aceSize > aclSize) return false // implausible: fall back to the apply path - const exact = decodeUint8At(labelAcl, offset) === abi.SYSTEM_MANDATORY_LABEL_ACE_TYPE - && decodeUint8At(labelAcl, offset + 1) === abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT - && decodeUint32At(labelAcl, offset + 4) === abi.SYSTEM_MANDATORY_LABEL_NO_WRITE_UP - if (exact && sameSidAt(labelAcl, offset + 8, lowLabelSidPtr, 0)) return true - offset += aceSize - } - return false + return hasExactEntry( + labelAcl, abi.SYSTEM_MANDATORY_LABEL_ACE_TYPE, abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT, + abi.SYSTEM_MANDATORY_LABEL_NO_WRITE_UP, lowLabelSidPtr, + ) } +/** + * The mandatory-label half of an apply: set the given label ACL, clear the + * label, or leave it untouched. `clear` is what a revoke does when the + * directory carries no other capability grant; `keep` is what it does while + * another grant still relies on the shared Low level. + */ +type LabelEdit = { kind: 'apply'; acl: NativePtr } | { kind: 'clear' } | { kind: 'keep' } + /** * Shared tail of grantWrite and revokeWrite: merge `entries` into `oldAcl` * (null = no explicit DACL yet; SetEntriesInAclW builds one from scratch), * free the descriptor before applying the merged ACL, apply the merged DACL - * together with `labelAcl` in one SetNamedSecurityInfoW call (null clears the - * label), then free both ACLs — checking every call and reporting with the + * together with the label edit in one SetNamedSecurityInfoW call, then free + * every ACL this call owns — checking each call and reporting with the * caller's label. The entry count derives from the buffer, so a grant can * carry its capability ACE and its ambient-delete deny in one merge. * @param api - the binding table. * @param path - the directory the DACL and label edits apply to. * @param entries - packed EXPLICIT_ACCESS_W records to merge (grant, deny, or revoke). * @param oldAcl - the current explicit DACL (from {@link readCurrentSecurity}). - * @param labelAcl - the label ACL to apply, or null to clear the mandatory label. + * @param labelEdit - the label change to apply alongside the DACL. * @param descriptor - the descriptor allocation owning `oldAcl`. * @param label - the caller's name for error details. */ @@ -227,7 +231,7 @@ function mergeAndApply( path: string, entries: Buffer, oldAcl: NativePtr | null, - labelAcl: NativePtr | null, + labelEdit: LabelEdit, descriptor: NativePtr | null, label: string, ): void { @@ -235,11 +239,13 @@ function mergeAndApply( const mergeResult = api.setEntriesInAclW(entries.length / abi.EXPLICIT_ACCESS_W_SIZE, entries, oldAcl, newAclSlot) if (mergeResult !== abi.ERROR_SUCCESS) { if (descriptor !== null) api.localFree(descriptor) // frees the ACL block too + if (labelEdit.kind === 'apply') api.localFree(labelEdit.acl) throwWin32(api, 'SetEntriesInAclW', mergeResult, `${label}(${path})`) } const newAcl = decodePtr(newAclSlot) if (newAcl === null) { if (descriptor !== null) api.localFree(descriptor) + if (labelEdit.kind === 'apply') api.localFree(labelEdit.acl) throwWin32(api, 'SetEntriesInAclW', api.getLastError(), `${label}(${path}): null new ACL`) } @@ -247,11 +253,12 @@ function mergeAndApply( // before applying, exactly like the POC. const freedDescriptor = descriptor !== null ? api.localFree(descriptor) : null const applyResult = api.setNamedSecurityInfoW( - path, abi.SE_FILE_OBJECT, abi.DACL_SECURITY_INFORMATION | abi.LABEL_SECURITY_INFORMATION, - null, null, newAcl, labelAcl, + path, abi.SE_FILE_OBJECT, + labelEdit.kind === 'keep' ? abi.DACL_SECURITY_INFORMATION : abi.DACL_SECURITY_INFORMATION | abi.LABEL_SECURITY_INFORMATION, + null, null, newAcl, labelEdit.kind === 'apply' ? labelEdit.acl : null, ) const freedNew = api.localFree(newAcl) - const freedLabel = labelAcl !== null ? api.localFree(labelAcl) : null + const freedLabel = labelEdit.kind === 'apply' ? api.localFree(labelEdit.acl) : null if (applyResult !== abi.ERROR_SUCCESS) throwWin32(api, 'SetNamedSecurityInfoW', applyResult, `${label}(${path})`) if (freedDescriptor !== null && !isNullPtr(freedDescriptor)) throwLastError(api, 'LocalFree', `${label}(${path}) descriptor`) if (!isNullPtr(freedNew)) throwLastError(api, 'LocalFree', `${label}(${path}) new ACL`) @@ -260,10 +267,10 @@ function mergeAndApply( /** * True when the explicit DACL already carries the EXACT entry - * `(aceType, OI|CI inheritance, mask, trustee SID)`. Every field is read - * through koffi.decode at pointer offsets — no memcpy, no pointer arithmetic. - * The ACE's SID is INLINE (embedded in the ACE after the 4-byte mask — there - * is no pointer to read; reading one yields garbage addresses and crashed + * `(aceType, inheritance, mask, trustee SID)`. Every field is read through + * koffi.decode at pointer offsets — no memcpy, no pointer arithmetic. The + * ACE's SID is INLINE (embedded in the ACE after the 4-byte mask — there is + * no pointer to read; reading one yields garbage addresses and crashed * EqualSid, verified by gdb), so it is compared field-by-field against the * trustee SID through bounded offset reads ({@link sameSidAt}). Allowed and * denied ACEs share the Mask@4/SID@8 layout. A malformed header reads as "no @@ -271,11 +278,12 @@ function mergeAndApply( * the robust failure handling. * @param acl - the current explicit DACL pointer (from {@link readCurrentSecurity}). * @param aceType - the ACE type to match. + * @param inheritance - the ACE inheritance flags to match. * @param mask - the access mask to match. * @param sidPtr - the trustee SID to match. * @returns whether the exact entry is already present. */ -function hasExactEntry(acl: NativePtr, aceType: number, mask: number, sidPtr: NativePtr): boolean { +function hasExactEntry(acl: NativePtr, aceType: number, inheritance: number, mask: number, sidPtr: NativePtr): boolean { const aclSize = decodeUint16At(acl, 2) const aceCount = decodeUint16At(acl, 4) if (aclSize < 8 || aclSize > 1_048_576) return false // implausible: fall back to the merge path @@ -285,7 +293,7 @@ function hasExactEntry(acl: NativePtr, aceType: number, mask: number, sidPtr: Na const aceSize = decodeUint16At(acl, offset + 2) if (aceSize < 8 || offset + aceSize > aclSize) return false // implausible: fall back to the merge path const exact = decodeUint8At(acl, offset) === aceType - && decodeUint8At(acl, offset + 1) === abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT + && decodeUint8At(acl, offset + 1) === inheritance && decodeUint32At(acl, offset + 4) === mask if (exact && sameSidAt(acl, offset + 8, sidPtr, 0)) return true offset += aceSize @@ -302,37 +310,64 @@ function hasExactEntry(acl: NativePtr, aceType: number, mask: number, sidPtr: Na * @returns whether the exact grant ACE is already present. */ function hasExactGrant(oldAcl: NativePtr, sidPtr: NativePtr): boolean { - return hasExactEntry(oldAcl, abi.ACCESS_ALLOWED_ACE_TYPE, abi.GRANT_MASK, sidPtr) + return hasExactEntry(oldAcl, abi.ACCESS_ALLOWED_ACE_TYPE, abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT, abi.GRANT_MASK, sidPtr) } /** * True when the explicit DACL already carries the EXACT ambient-delete deny: - * the Deny ACE for {@link abi.FILE_DELETE_CHILD} naming the world SID. It is - * part of the idempotent skip, so a root granted by an earlier build receives - * the deny on its next provision. + * the container-inherited Deny ACE for {@link abi.FILE_DELETE_CHILD} naming + * the world SID. It is part of the idempotent skip, so a root granted by an + * earlier build receives the deny on its next provision. * @param oldAcl - the current explicit DACL pointer (from {@link readCurrentSecurity}). * @param worldSidPtr - the Everyone SID the deny names. * @returns whether the exact deny ACE is already present. */ function hasExactDeny(oldAcl: NativePtr, worldSidPtr: NativePtr): boolean { - return hasExactEntry(oldAcl, abi.ACCESS_DENIED_ACE_TYPE, abi.FILE_DELETE_CHILD, worldSidPtr) + return hasExactEntry(oldAcl, abi.ACCESS_DENIED_ACE_TYPE, abi.CONTAINER_INHERIT_ACE, abi.FILE_DELETE_CHILD, worldSidPtr) +} + +/** + * True when a capability grant for a SID OTHER than `sidPtr` stands on this + * DACL — the condition under which a revoke must leave the shared Low label in + * place, or the remaining grant's child would lose its write authority. + * @param oldAcl - the current explicit DACL pointer (from {@link readCurrentSecurity}). + * @param sidPtr - the capability SID being revoked. + * @returns whether another capability grant remains. + */ +function hasForeignGrant(oldAcl: NativePtr, sidPtr: NativePtr): boolean { + const aclSize = decodeUint16At(oldAcl, 2) + const aceCount = decodeUint16At(oldAcl, 4) + if (aclSize < 8 || aclSize > 1_048_576) return false // implausible: leave the label alone + let offset = 8 + for (let index = 0; index < aceCount; index++) { + const aceSize = decodeUint16At(oldAcl, offset + 2) + if (aceSize < 8 || offset + aceSize > aclSize) return false // implausible: leave the label alone + const isGrant = decodeUint8At(oldAcl, offset) === abi.ACCESS_ALLOWED_ACE_TYPE + && decodeUint32At(oldAcl, offset + 4) === abi.GRANT_MASK + if (isGrant && !sameSidAt(oldAcl, offset + 8, sidPtr, 0)) return true + offset += aceSize + } + return false } /** * Grant `GRANT_MASK` (Write+Delete, displays as "Modify") to the capability SID * on `path`, deny the world SID the ambient `FILE_DELETE_CHILD` right, and - * apply the Low mandatory label — one merge, inheriting to subcontainers and - * objects. The capability ACE's DELETE bit is then the only delete authority - * inside the root, so a file whose own DACL grants no DELETE is no longer - * deletable through its parent's rights. + * apply the Low mandatory label — one merge. The deny inherits to containers + * only: the right is evaluated on directories, and inheriting its bit onto + * files would deny every `FILE_ALL_ACCESS`/`GENERIC_ALL` open inside the root + * (0x40 is a member of that mask). The capability ACE's DELETE bit is then the + * only delete authority inside the root, so a file whose own DACL grants no + * DELETE is no longer deletable through its parent's rights. * * Idempotent: the exact ACE, deny, and label together SKIP the * SetNamedSecurityInfoW apply, which would otherwise re-propagate the * identical descriptor across the whole tree (eager inheritance; minutes on * large workspaces). Otherwise read-merge-write, so pre-existing explicit ACEs * survive (same shape as {@link revokeWrite}). Runs under the per-path lock. - * The directory must be owned by the caller (owner implicit WRITE_DAC) — same - * precondition as the POC. + * The directory must be owned by the caller AND grant WRITE_OWNER (the label + * lives in the SACL; owner-implicit rights cover only READ_CONTROL and + * WRITE_DAC) — a Full-control workspace satisfies both. * @param api - the binding table. * @param path - the directory whose DACL and label gain the grant (the workspace or temp root). * @param sidPtr - the capability SID the ACE names. @@ -358,27 +393,38 @@ export function grantWrite( } return } + let label: NativePtr + try { + label = buildLowLabelAcl(api, lowLabelSidPtr) + } catch (error) { + // The read already owns a descriptor allocation; release it before the + // label failure propagates. + if (descriptor !== null) api.localFree(descriptor) + throw error + } mergeAndApply( api, path, Buffer.concat([ - buildExplicitAccess(worldSidPtr, abi.DENY_ACCESS, abi.FILE_DELETE_CHILD), + buildExplicitAccess(worldSidPtr, abi.DENY_ACCESS, abi.FILE_DELETE_CHILD, abi.CONTAINER_INHERIT_ACE), buildExplicitAccess(sidPtr, abi.GRANT_ACCESS, abi.GRANT_MASK), ]), - oldAcl, buildLowLabelAcl(api, lowLabelSidPtr), descriptor, 'grantWrite', + oldAcl, { kind: 'apply', acl: label }, descriptor, 'grantWrite', ) }) } /** * Remove every ACE for the capability SID from the directory DACL (REVOKE_ACCESS - * merge — other entries are preserved) and clear the Low mandatory label in the - * same call. Returns whether an ACE removal was attempted (false when the + * merge — other entries are preserved). The shared Low label is cleared only + * when no other capability grant remains on the directory: two grants may + * target one directory, and the surviving one still needs the label for its + * child's writes. Returns whether an ACE removal was attempted (false when the * directory carries no DACL at all). * * Runs under the per-path lock (the whole get-merge-set sequence); the * descriptor/ACL allocation contract lives on {@link readCurrentSecurity}. * @param api - the binding table. - * @param path - the directory whose DACL loses the capability-SID ACEs and whose label is cleared. + * @param path - the directory whose DACL loses the capability-SID ACEs. * @param sidPtr - the capability SID whose ACEs are removed. * @returns whether an ACE removal was attempted (false when the directory carries no DACL at all). */ @@ -392,7 +438,11 @@ export function revokeWrite(api: Win32Bindings, path: string, sidPtr: NativePtr) } return false } - mergeAndApply(api, path, buildExplicitAccess(sidPtr, abi.REVOKE_ACCESS, 0), oldAcl, null, descriptor, 'revokeWrite') + mergeAndApply( + api, path, buildExplicitAccess(sidPtr, abi.REVOKE_ACCESS, 0), oldAcl, + hasForeignGrant(oldAcl, sidPtr) ? { kind: 'keep' } : { kind: 'clear' }, + descriptor, 'revokeWrite', + ) return true }) } diff --git a/packages/sandbox/sandbox-windows-acl/src/grant.ts b/packages/sandbox/sandbox-windows-acl/src/grant.ts index 812fec2a43..7f7bcc4c57 100644 --- a/packages/sandbox/sandbox-windows-acl/src/grant.ts +++ b/packages/sandbox/sandbox-windows-acl/src/grant.ts @@ -7,7 +7,8 @@ * * Fail-closed: `add` throws on any grant failure and the caller disposes the * instance (revoking every path granted so far); `dispose` revokes every - * standing grant and reports every cleanup failure. + * revocable grant, leaves the standing workspace edits in place, and reports + * every cleanup failure. * @module @deepseek-ai/dsh-sandbox-windows-acl/grant */ @@ -69,13 +70,21 @@ export class AclWriteGrant { } const sidPtr = decodePtr(sidSlot) if (sidPtr === null) throwLastError(bindings, 'ConvertStringSidToSidW', `null SID for ${writeSid}`) - return new AclWriteGrant( - bindings, - sidPtr, - makeWellKnownSid(bindings, abi.WinLowLabelSid), - makeWellKnownSid(bindings, abi.WinWorldSid), - writeSid, - ) + try { + const lowLabelSidPtr = makeWellKnownSid(bindings, abi.WinLowLabelSid) + try { + const worldSidPtr = makeWellKnownSid(bindings, abi.WinWorldSid) + return new AclWriteGrant(bindings, sidPtr, lowLabelSidPtr, worldSidPtr, writeSid) + } catch (error) { + // The Low label SID is LocalAlloc'd: release it before the world-SID + // failure propagates to the sidPtr release below. + bindings.localFree(lowLabelSidPtr) + throw error + } + } catch (error) { + bindings.localFree(sidPtr) + throw error + } } /** diff --git a/packages/sandbox/sandbox-windows-acl/src/token.ts b/packages/sandbox/sandbox-windows-acl/src/token.ts index 10674afb26..d78bac049d 100644 --- a/packages/sandbox/sandbox-windows-acl/src/token.ts +++ b/packages/sandbox/sandbox-windows-acl/src/token.ts @@ -155,6 +155,7 @@ export function setTokenDefaultDaclGrant(api: Win32Bindings, token: NativePtr, s */ export function restrictTokenIntegrity(api: Win32Bindings, token: NativePtr, lowLabelSidPtr: NativePtr): void { const sidLength = api.getLengthSid(lowLabelSidPtr) + if (sidLength === 0) throwLastError(api, 'GetLengthSid', 'Low integrity label SID') const info = Buffer.alloc(abi.TOKEN_MANDATORY_LABEL_SIZE + sidLength) info.writeBigUInt64LE(ptrAddress(lowLabelSidPtr), 0) // Label.Sid info.writeUInt32LE(abi.SE_GROUP_INTEGRITY, 8) // Label.Attributes diff --git a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts index bf8ad49851..2968ff5b1b 100644 --- a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts @@ -85,6 +85,8 @@ export const DENY_ACCESS = 3 export const REVOKE_ACCESS = 4 /** ACE inheritance flags for child containers and objects. */ export const SUB_CONTAINERS_AND_OBJECTS_INHERIT = 0x3 +/** ACE inheritance flag for child containers only (directories; files do not inherit). */ +export const CONTAINER_INHERIT_ACE = 0x2 /** Legacy Win32 maximum path character count used by GetTempPathW. */ export const MAX_PATH = 260 /** Successful Win32 status code. */ diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts index 88306142c0..6b34293fcd 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts @@ -79,9 +79,17 @@ function craftWorldSid(): NativePtr { } /** Write one 16-byte ACE (AceType@0, AceFlags@1, AceSize@2, Mask@4, inline SID@8) at `offset`. */ -function writeAce(acl: NativePtr, offset: number, aceType: number, mask: number, sid: NativePtr, match: boolean): void { +function writeAce( + acl: NativePtr, + offset: number, + aceType: number, + mask: number, + sid: NativePtr, + match: boolean, + inheritance: number = abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT, +): void { koffi.encode(acl, offset + 0, 'uint8', aceType) - koffi.encode(acl, offset + 1, 'uint8', abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT) + koffi.encode(acl, offset + 1, 'uint8', inheritance) koffi.encode(acl, offset + 2, 'uint16', 16) koffi.encode(acl, offset + 4, 'uint32', mask) for (let byte = 0; byte < 8; byte++) { @@ -122,7 +130,7 @@ function craftPair( koffi.encode(acl, 'uint8', 2) koffi.encode(acl, 2, 'uint16', 40) koffi.encode(acl, 4, 'uint16', 2) - writeAce(acl, 8, denyType, denyMask, world, denyMatches) + writeAce(acl, 8, denyType, denyMask, world, denyMatches, abi.CONTAINER_INHERIT_ACE) writeAce(acl, 24, abi.ACCESS_ALLOWED_ACE_TYPE, abi.GRANT_MASK, grantSid, grantMatches) return acl } @@ -405,6 +413,161 @@ describe('mergeAndApply failure paths', () => { expect((caught as Win32Error).api).toBe('LocalFree') expect(localFree).toHaveBeenLastCalledWith(11n) }) + + it('frees the label ACL when SetEntriesInAclW fails during a revoke', () => { + // The revoke carries no label ACL of its own: the early exit must still + // release the descriptor and leave the label edit untouched. + const localFree = vi.fn(() => 0n as NativePtr) + const sid = craftSid(1, 0) + const api = aclApi({ + getNamedSecurityInfoW: readStub(craftAcl(abi.ACCESS_ALLOWED_ACE_TYPE, abi.GRANT_MASK, sid, true), null, 6n), + setEntriesInAclW: vi.fn(() => 5), + localFree, + }) + let caught: unknown + try { + revokeWrite(api, 'C:\\granted', sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetEntriesInAclW') + expect(localFree).toHaveBeenCalledWith(6n) + }) + + it('frees the descriptor on the null-merged-ACL path during a revoke', () => { + const localFree = vi.fn(() => 0n as NativePtr) + const sid = craftSid(1, 0) + const api = aclApi({ + getNamedSecurityInfoW: readStub(craftAcl(abi.ACCESS_ALLOWED_ACE_TYPE, abi.GRANT_MASK, sid, true), null, 6n), + setEntriesInAclW: vi.fn(() => 0), // success without writing the out slot + localFree, + }) + let caught: unknown + try { + revokeWrite(api, 'C:\\granted', sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetEntriesInAclW') + expect(localFree).toHaveBeenCalledWith(6n) + }) + + it('frees the descriptor when the label ACL build fails', () => { + // The read already owns a descriptor allocation; the label failure must + // not strand it. + const localFree = vi.fn(() => 0n as NativePtr) + const api = aclApi({ + getNamedSecurityInfoW: readStub(null, null, 6n), + initializeAcl: vi.fn(() => 0), + localFree, + }) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', craftSid(1, 0), craftLowLabelSid(), craftWorldSid()) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('InitializeAcl') + expect(localFree).toHaveBeenCalledWith(11n) // the half-built label ACL + expect(localFree).toHaveBeenCalledWith(6n) // the read descriptor + }) + + it('frees the label ACL when SetEntriesInAclW fails', () => { + const localFree = vi.fn(() => 0n as NativePtr) + const api = aclApi({ setEntriesInAclW: vi.fn(() => 5), localFree }) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', craftSid(1, 0), craftLowLabelSid(), craftWorldSid()) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetEntriesInAclW') + expect(localFree).toHaveBeenCalledWith(11n) // the label ACL this apply owns + }) + + it('frees the label ACL and the descriptor on the null-merged-ACL path', () => { + const localFree = vi.fn(() => 0n as NativePtr) + const api = aclApi({ + getNamedSecurityInfoW: readStub(null, null, 6n), + setEntriesInAclW: vi.fn(() => 0), // success without writing the out slot + localFree, + }) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', craftSid(1, 0), craftLowLabelSid(), craftWorldSid()) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetEntriesInAclW') + expect(localFree).toHaveBeenCalledWith(6n) + expect(localFree).toHaveBeenCalledWith(11n) + }) +}) + +describe('revokeWrite label handling', () => { + /** The SECURITY_INFORMATION and SACL of the last apply. */ + function applyArgs(api: Win32Bindings): { information: number; sacl: unknown } { + const call = (api.setNamedSecurityInfoW as unknown as ReturnType).mock.calls.at(-1) + return { information: call?.[2] as number, sacl: call?.[6] } + } + + it('keeps the shared Low label while another capability grant stands', () => { + const sid = craftSid(1, 0) + const otherSid = craftSid(1, 0, [0, 0, 0, 0, 0, 6]) + const world = craftWorldSid() + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + // The directory carries the deny plus a grant for a DIFFERENT SID. + getNamedSecurityInfoW: readStub( + craftPair(otherSid, world, abi.ACCESS_DENIED_ACE_TYPE, abi.FILE_DELETE_CHILD, true, true), null, 6n, + ), + setNamedSecurityInfoW, + }) + expect(revokeWrite(api, 'C:\\granted', sid)).toBe(true) + const { information, sacl } = applyArgs(api) + expect(information & abi.LABEL_SECURITY_INFORMATION).toBe(0) // the label was left untouched + expect(sacl).toBeNull() + }) + + it('clears the Low label once the last capability grant is revoked', () => { + const sid = craftSid(1, 0) + const world = craftWorldSid() + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: readStub(craftGrantedAcl(sid, world, true), null, 6n), + setNamedSecurityInfoW, + }) + expect(revokeWrite(api, 'C:\\granted', sid)).toBe(true) + expect(applyArgs(api).information & abi.LABEL_SECURITY_INFORMATION).toBe(abi.LABEL_SECURITY_INFORMATION) + }) + + it('clears the Low label when a malformed tiny DACL hides any foreign grant', () => { + const acl = allocBytes(32) + koffi.encode(acl, 'uint8', 2) + koffi.encode(acl, 2, 'uint16', 4) // smaller than the ACL header + koffi.encode(acl, 4, 'uint16', 1) + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ getNamedSecurityInfoW: readStub(acl, null, 6n), setNamedSecurityInfoW }) + expect(revokeWrite(api, 'C:\\granted', craftSid(1, 0))).toBe(true) + expect(applyArgs(api).information & abi.LABEL_SECURITY_INFORMATION).toBe(abi.LABEL_SECURITY_INFORMATION) + }) + + it('clears the Low label when a lying ACE size hides any foreign grant', () => { + const acl = allocBytes(32) + koffi.encode(acl, 'uint8', 2) + koffi.encode(acl, 2, 'uint16', 8) + koffi.encode(acl, 4, 'uint16', 1) + koffi.encode(acl, 10, 'uint16', 100) + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ getNamedSecurityInfoW: readStub(acl, null, 6n), setNamedSecurityInfoW }) + expect(revokeWrite(api, 'C:\\granted', craftSid(1, 0))).toBe(true) + expect(applyArgs(api).information & abi.LABEL_SECURITY_INFORMATION).toBe(abi.LABEL_SECURITY_INFORMATION) + }) }) describe('the exact-ACE/exact-label skip and ACL-walk defenses', () => { diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts index 9d5e6e697a..3bf86f4d43 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts @@ -9,7 +9,7 @@ * whose per-test lock file is removed in cleanup. */ -import { mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -157,6 +157,51 @@ function readLabelAces(api: Win32Bindings, path: string): DirectLabelAce[] { } } +/** One ACE of a directory DACL, inherited or not. */ +interface TypedAce { + sid: string + mask: number + type: number + flags: number +} + +/** + * Read every ACE of the object's DACL (inherited ones included) — the shape + * needed to observe what a grant propagated onto children. + */ +function readTypedAces(api: Win32Bindings, path: string): TypedAce[] { + const ownerSlot = allocPtrSlot() + const groupSlot = allocPtrSlot() + const daclSlot = allocPtrSlot() + const saclSlot = allocPtrSlot() + const descriptorSlot = allocPtrSlot() + const readResult = api.getNamedSecurityInfoW( + path, abi.SE_FILE_OBJECT, abi.DACL_SECURITY_INFORMATION, + ownerSlot, groupSlot, daclSlot, saclSlot, descriptorSlot, + ) + if (readResult !== abi.ERROR_SUCCESS) throw new Error(`GetNamedSecurityInfoW failed (${readResult}) for ${path}`) + const acl = decodePtr(daclSlot) + const descriptor = decodePtr(descriptorSlot) + try { + if (acl === null) return [] + const aclSize = koffi.decode(acl, 2, 'uint16') as number + const aces: TypedAce[] = [] + for (let offset = 8; offset + 8 <= aclSize;) { + const aceSize = koffi.decode(acl, offset + 2, 'uint16') as number + aces.push({ + sid: sidString(koffi.decode(acl, offset + 8, SID_STRUCT) as SidLayout), + mask: koffi.decode(acl, offset + 4, 'uint32') as number, + type: koffi.decode(acl, offset, 'uint8') as number, + flags: koffi.decode(acl, offset + 1, 'uint8') as number, + }) + offset += aceSize + } + return aces + } finally { + if (descriptor !== null) api.localFree(descriptor) + } +} + describe.skipIf(!isWin32)('ACL editing', () => { const scratchDirs: string[] = [] afterEach(() => { @@ -169,6 +214,59 @@ describe.skipIf(!isWin32)('ACL editing', () => { return dir } + it('the ambient-delete deny inherits to subdirectories only, never onto files', async () => { + // FILE_DELETE_CHILD is meaningless on a file and 0x40 is part of + // FILE_ALL_ACCESS, so a file-level copy would deny every FullControl open + // inside the granted root. + const api = await win32() + const dir = scratch() + const capabilitySid = sidFromString(api, 'S-1-4-4242-11') + const lowSid = lowLabelSid(api) + const world = worldSid(api) + const childDir = join(dir, 'child') + const childFile = join(dir, 'child.txt') + try { + grantWrite(api, dir, capabilitySid, lowSid, world) + mkdirSync(childDir) + writeFileSync(childFile, 'x') + const isWorldDeny = (ace: TypedAce): boolean => + ace.type === abi.ACCESS_DENIED_ACE_TYPE && ace.sid === 'S-1-1-0' && ace.mask === abi.FILE_DELETE_CHILD + expect(readTypedAces(api, dir).filter(isWorldDeny)).toHaveLength(1) // explicit on the root + expect(readTypedAces(api, childDir).filter(isWorldDeny)).toHaveLength(1) // inherited by the container + expect(readTypedAces(api, childFile).filter(isWorldDeny)).toHaveLength(0) // never by the file + const rootDeny = readTypedAces(api, dir).find(isWorldDeny) + expect(rootDeny?.flags).toBe(abi.CONTAINER_INHERIT_ACE) + } finally { + if (!isNullPtr(capabilitySid)) api.localFree(capabilitySid) + if (!isNullPtr(lowSid)) api.localFree(lowSid) + if (!isNullPtr(world)) api.localFree(world) + } + }) + + it('revoking one of two grants on a directory keeps the shared Low label', async () => { + const api = await win32() + const dir = scratch() + const sidA = sidFromString(api, 'S-1-4-4242-21') + const sidB = sidFromString(api, 'S-1-4-4242-22') + const lowSid = lowLabelSid(api) + const world = worldSid(api) + try { + grantWrite(api, dir, sidA, lowSid, world) + grantWrite(api, dir, sidB, lowSid, world) + revokeWrite(api, dir, sidA) + expect(readDirectAces(api, dir).some(ace => ace.sid === 'S-1-4-4242-21')).toBe(false) + expect(readDirectAces(api, dir).some(ace => ace.sid === 'S-1-4-4242-22')).toBe(true) + expect(readLabelAces(api, dir)).toHaveLength(1) // the surviving grant still needs it + revokeWrite(api, dir, sidB) + expect(readLabelAces(api, dir)).toEqual([]) // the last revoke clears it + } finally { + if (!isNullPtr(sidA)) api.localFree(sidA) + if (!isNullPtr(sidB)) api.localFree(sidB) + if (!isNullPtr(lowSid)) api.localFree(lowSid) + if (!isNullPtr(world)) api.localFree(world) + } + }) + it('grantWrite merges into the current DACL: an explicit Users ACE survives grant+revoke', async () => { const api = await win32() const dir = scratch() diff --git a/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts index 8be8b30a86..87e88bbd0f 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts @@ -108,15 +108,37 @@ describe('AclWriteGrant failure paths', () => { }) it('create fails closed when the Low label SID cannot be created', () => { + const localFree = vi.fn(() => 0n as NativePtr) const api = { convertStringSidToSidW: vi.fn((_sid: string, slot: NativePtr) => { koffi.encode(slot, PVOID, 42n) return 1 }), createWellKnownSid: vi.fn(() => 0), + localFree, getLastError: vi.fn(() => 87), formatMessageW: vi.fn(() => 0), } as unknown as Win32Bindings expect(() => AclWriteGrant.create('S-1-4-42-42', api)).toThrow(/CreateWellKnownSid/) + expect(localFree).toHaveBeenCalledWith(42n) // the parsed capability SID is not stranded + }) + + it('create frees both earlier SIDs when the world SID cannot be created', () => { + const localFree = vi.fn(() => 0n as NativePtr) + let calls = 0 + const api = { + convertStringSidToSidW: vi.fn((_sid: string, slot: NativePtr) => { + koffi.encode(slot, PVOID, 42n) + return 1 + }), + createWellKnownSid: vi.fn(() => (++calls === 1 ? 1 : 0)), // the Low label SID succeeds, the world SID fails + isValidSid: vi.fn(() => 1), + localFree, + getLastError: vi.fn(() => 87), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + expect(() => AclWriteGrant.create('S-1-4-42-42', api)).toThrow(/CreateWellKnownSid/) + expect(localFree).toHaveBeenCalledTimes(2) + expect(localFree).toHaveBeenCalledWith(42n) }) }) diff --git a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts index a654b107ab..9cf0517661 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts @@ -417,7 +417,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { + `'CMD-DEL: ' + $(if (Test-Path -LiteralPath ${quoted(victims.cmd)}) {'DENIED'} else {'DELETED'});`, `try{[System.IO.File]::Delete(${quoted(victims.dotnet)});'DOTNET-DELETE: DELETED'}catch{'DOTNET-DELETE: DENIED'};`, `try{Remove-Item -LiteralPath ${quoted(victims.remove)} -ErrorAction Stop;'REMOVE-ITEM: DELETED'}catch{'REMOVE-ITEM: DENIED'};`, - `node -e "const fs=require('node:fs');try{fs.unlinkSync(process.argv[1]);console.log('NODE-UNLINK: DELETED')}catch(e){console.log('NODE-UNLINK: DENIED')}" ${quoted(victims.unlink)};`, + `& "${process.execPath}" -e "const fs=require('node:fs');try{fs.unlinkSync(process.argv[1]);console.log('NODE-UNLINK: DELETED')}catch(e){console.log('NODE-UNLINK: DENIED')}" ${quoted(victims.unlink)};`, `try{[System.IO.File]::Delete(${quoted(victims.inside)});'INSIDE-DELETE: DELETED'}catch{'INSIDE-DELETE: DENIED'}`, ].join('') const result = runRunner([ @@ -505,6 +505,95 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { } }, 60_000) + it('NUL writes stay ambient under the Low token in BOTH modes', () => { + // The device DACL grants Everyone write and carries no higher label, so + // the documented `> NUL` redirection must survive the lowered token. + const cmd = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32\\cmd.exe') + const probe = [ + "$ErrorActionPreference='SilentlyContinue';", + `& '${cmd}' /c echo ok>nul 2>&1; 'CMD-NUL: exit=' + $LASTEXITCODE;`, + `& "${process.execPath}" -e "try{require('node:fs').writeFileSync(process.argv[1],'x');console.log('NODE-NUL: OK')}catch(e){console.log('NODE-NUL: DENIED '+e.code)}" '\\\\.\\NUL'`, + ].join('') + for (const mode of ['read-only', 'workspace-write'] as const) { + const result = runRunner([ + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', mode, + '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe, + ]) + expect(result.status, `mode: ${mode}\nstderr: ${result.stderr}`).toBe(0) + expect(result.stdout, `mode: ${mode}`).toContain('CMD-NUL: exit=0') + expect(result.stdout, `mode: ${mode}`).toContain('NODE-NUL: OK') + } + }, 30_000) + + it('a FullControl open inside a granted root still works for files (the deny inherits to containers only)', () => { + // The ambient-delete deny is 0x40, a member of FILE_ALL_ACCESS: inheriting + // it onto files would deny every GENERIC_ALL/FullControl open by the user, + // Administrators, SYSTEM, or the DSH host. Directories inside a granted + // root keep the deny (that is where FILE_DELETE_CHILD is evaluated), so a + // FullControl open of a DIRECTORY is the documented cost of the deny. + const granted = join(scratchRoot, 'fullcontrol-root') + const child = join(granted, 'child') + mkdirSync(granted) + mkdirSync(child) + writeFileSync(join(granted, 'file.txt'), 'x') + writeFileSync(join(child, 'deep.txt'), 'x') + const grant = AclWriteGrant.create(workspaceWriteSid(granted)) + grant.add(granted, true) + try { + const probe = ` +$ErrorActionPreference='SilentlyContinue' +Add-Type -Namespace P -Name F -MemberDefinition @' +[DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode, EntryPoint="CreateFileW")] +public static extern IntPtr CreateFileW(string n, uint a, uint s, IntPtr sa, uint d, uint f, IntPtr t); +[DllImport("kernel32.dll", SetLastError=true)] +public static extern bool CloseHandle(IntPtr h); +'@ | Out-Null +function TryOpen([string]$label, [string]$path) { + $h = [P.F]::CreateFileW($path, 0x10000000, 7, [IntPtr]::Zero, 3, 0x02000000, [IntPtr]::Zero) + if ($h -eq [IntPtr]::new(-1)) { "$($label): DENIED" } else { [void][P.F]::CloseHandle($h); "$($label): OK" } +} +TryOpen 'FILE' '${join(granted, 'file.txt')}' +TryOpen 'NESTED-FILE' '${join(child, 'deep.txt')}' +TryOpen 'DIRECTORY' '${child}' +` + const result = spawnSync('pwsh', ['-NoLogo', '-NonInteractive', '-NoProfile', '-Command', probe], { encoding: 'utf8', timeout: 60_000 }) + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + expect(result.stdout).toContain('FILE: OK') + expect(result.stdout).toContain('NESTED-FILE: OK') + expect(result.stdout).toContain('DIRECTORY: DENIED') + } finally { + grant.dispose() + rmSync(granted, { recursive: true, force: true }) + } + }, 60_000) + + it('revoking one of two grants on a directory leaves the shared Low label usable', () => { + // Two capabilities may target one directory; the revoke of the first must + // not strip the label the second one's child still writes through. + const shared = join(scratchRoot, 'shared-root') + mkdirSync(shared) + const sidA = workspaceWriteSid(shared) + const sidB = `${sidA}-1` + const grantA = AclWriteGrant.create(sidA) + const grantB = AclWriteGrant.create(sidB) + grantA.add(shared, true) + grantB.add(shared) + try { + grantB.dispose() // revokes B's ACE, must keep the shared label + const target = join(shared, 'after-revoke.txt') + const result = runRunner([ + '--workspace', shared, '--temp', isolatedTemp, '--mode', 'workspace-write', + '--write-sid', sidA, '--temp-write-sid', tempWriteSid(isolatedTemp), + '--', process.execPath, '-e', "require('node:fs').writeFileSync(process.argv[1], 'written')", target, + ]) + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + expect(existsSync(target)).toBe(true) + } finally { + grantA.dispose() + rmSync(shared, { recursive: true, force: true }) + } + }, 60_000) + it('the Low mandatory label closes the Everyone-Modify ambient boundary under BOTH modes', () => { // Everyone is a required keep-alive restricting SID: without it early DLL // initialization and CNG fail. A normal DACL that grants Everyone Modify diff --git a/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts index 36f18ff03d..62e7ccde7e 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts @@ -388,6 +388,22 @@ describe('restrictTokenIntegrity', () => { expect(setTokenInformation).toHaveBeenCalledTimes(1) }) + it('fails closed when the Low label SID has no length', () => { + const api = { + getLengthSid: vi.fn(() => 0), + getLastError: vi.fn(() => 87), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + let caught: unknown + try { + restrictTokenIntegrity(api, 5n as NativePtr, allocBytes(12)) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('GetLengthSid') + }) + it('fails closed when SetTokenInformation rejects the integrity level', () => { const api = { getLengthSid: vi.fn(() => 12), diff --git a/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp b/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp index 55a3d9c9d8..b38017f1ac 100644 --- a/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp +++ b/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp @@ -50,12 +50,32 @@ int wmain() P((int)GRANT_ACCESS); P((int)REVOKE_ACCESS); P(SUB_CONTAINERS_AND_OBJECTS_INHERIT); + P(CONTAINER_INHERIT_ACE); P(MAX_PATH); P(ERROR_SUCCESS); + P((int)ACCESS_DENIED_ACE_TYPE); + P((int)DENY_ACCESS); + P(LABEL_SECURITY_INFORMATION); + P((int)SYSTEM_MANDATORY_LABEL_ACE_TYPE); + P(SYSTEM_MANDATORY_LABEL_NO_WRITE_UP); + P((int)TokenIntegrityLevel); + P(SE_GROUP_INTEGRITY); + P((int)WinLowLabelSid); + P(sizeof(TOKEN_MANDATORY_LABEL)); + P(offsetof(TOKEN_MANDATORY_LABEL, Label)); + P(sizeof(ACL)); + P(sizeof(SYSTEM_MANDATORY_LABEL_ACE)); + P(ACL_REVISION); + P(LPTR); static_assert(sizeof(EXPLICIT_ACCESS_W) == 48, "EXPLICIT_ACCESS_W size"); static_assert(sizeof(TRUSTEE_W) == 32, "TRUSTEE_W size"); static_assert(sizeof(SID_AND_ATTRIBUTES) == 16, "SID_AND_ATTRIBUTES size"); + static_assert(sizeof(TOKEN_MANDATORY_LABEL) == 16, "TOKEN_MANDATORY_LABEL size"); + static_assert(sizeof(ACL) == 8, "ACL header size"); + static_assert(sizeof(SYSTEM_MANDATORY_LABEL_ACE) == 12, "mandatory label ACE body size"); + static_assert(ACL_REVISION == 2, "ACL revision"); + static_assert(LPTR == 0x40, "LocalAlloc flags"); static_assert(SECURITY_MAX_SID_SIZE == 68, "SECURITY_MAX_SID_SIZE"); static_assert(TOKEN_QUERY == 0x8 && TOKEN_DUPLICATE == 0x2 && TOKEN_ADJUST_DEFAULT == 0x80 && TOKEN_ASSIGN_PRIMARY == 0x1, "token rights"); static_assert(SE_GROUP_LOGON_ID == 0xC0000000, "logon id attr"); diff --git a/packages/shell/pwsh-sandbox/README.i18n.yaml b/packages/shell/pwsh-sandbox/README.i18n.yaml index a322e49d44..289ac2384f 100644 --- a/packages/shell/pwsh-sandbox/README.i18n.yaml +++ b/packages/shell/pwsh-sandbox/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/shell/pwsh-sandbox/README.md -README.md: cddcce511d01f3c10208304ea55bf6156ccf2429 -README.zh.md: 40aa055770e526cc41607f0e4cd63273b80ebc15 +# pnpm run verify-translation-pairing --write packages//shell//pwsh-sandbox//README.md +README.md: 34845dd2cc5eee1dd36a76bf63e266d6b06c7276 +README.zh.md: edf99c6b0930a7d603523e86af4c453992fc578a diff --git a/packages/shell/pwsh-sandbox/README.md b/packages/shell/pwsh-sandbox/README.md index cddcce511d..34845dd2cc 100644 --- a/packages/shell/pwsh-sandbox/README.md +++ b/packages/shell/pwsh-sandbox/README.md @@ -35,7 +35,7 @@ Choose it when a deployment needs file-level confinement for PowerShell commands | Mode | File effects | |---|---| -| `read-only` (default) | Writes are denied; the boundary stays partial because the restricted token retains Everyone | +| `read-only` (default) | Writes are denied; the boundary stays partial for the shared hard-link, unconfined-read, and AppContainer-ACL limits | | `workspace-write` | Writes under the policy's workspace root plus a private temp directory; `TMP`/`TEMP` are rewritten to it before spawning | | `danger-full-access` | No confinement; the provider is never consulted, and results carry `sandbox: { mode, denied: false }` | @@ -140,7 +140,7 @@ These limits define when this executor is only a partial boundary on Windows. Th - **Reads are unrestricted on Windows** — the ACL runner restricts writes only; the read boundary is documented in `@deepseek-ai/dsh-sandbox-windows-acl`. - **Windows workspace-write temp authority is private** — per live session/workspace pair; agentless calls receive a fresh private directory per invocation; the ambient temp root is never granted, and the runner rewrites `TMP`/`TEMP` to the private directory before spawning. -- **Windows read-only grants no explicit writable root but remains partial** — the restricted token must retain Everyone; objects whose DACL grants Everyone write access — including compatible opens of the NUL device — remain ambient authority, while PowerShell's `> $null` redirection still works without opening NUL. +- **Windows read-only grants no explicit writable root but remains partial** — 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. NUL stays writable in both modes because the device DACL grants Everyone write and carries no higher label; PowerShell's `> $null` redirection still works without opening it. ### Dev Note diff --git a/packages/shell/pwsh-sandbox/README.zh.md b/packages/shell/pwsh-sandbox/README.zh.md index 40aa055770..edf99c6b09 100644 --- a/packages/shell/pwsh-sandbox/README.zh.md +++ b/packages/shell/pwsh-sandbox/README.zh.md @@ -35,7 +35,7 @@ kind: "package-reference" | 模式 | 文件影响 | |---|---| -| `read-only`(默认) | 写入被拒绝;由于受限令牌必须保留 Everyone,边界仍是不完整的 | +| `read-only`(默认) | 写入被拒绝;边界仍受共享的硬链接、读取不受限与 AppContainer ACL 限制 | | `workspace-write` | 只能写入策略的工作区根目录加一个私有临时目录;spawn 前 `TMP`/`TEMP` 会被重写到该目录 | | `danger-full-access` | 不作限制;绝不咨询提供方,结果携带 `sandbox: { mode, denied: false }` | @@ -140,7 +140,7 @@ kind: "package-reference" - **Windows 上读不受限**——ACL runner 只限写;读边界文档在 `@deepseek-ai/dsh-sandbox-windows-acl`。 - **Windows workspace-write 的临时权限按每个活跃的会话/工作区对私有**——无 agent(智能体)的调用每次都获得一个新的私有目录;环境临时根目录绝不会被授权,runner 会在 spawn 前将 `TMP`/`TEMP` 重写为该私有目录。 -- **Windows read-only 不授予任何显式可写根目录,但仍为部分强制执行**——受限令牌必须保留 Everyone;DACL 向 Everyone 授予写访问的对象——包括以兼容方式打开的 NUL 设备——仍构成环境权限来源,而 PowerShell 的 `> $null` 重定向仍可工作,且不会打开 NUL。 +- **Windows read-only 不授予任何显式可写根目录,但仍为部分强制执行**——NTFS 硬链接会把同一文件对象别名为多个路径、读取仍不受限,且被其他 AppContainer 工具以包 SID 标记过的目录树对 Low 完整性子进程不可读。NUL 在两种模式下仍可写,因为设备 DACL 向 Everyone 授予写权限且不带更高的标签;PowerShell 的 `> $null` 重定向仍然工作,且不会打开 NUL。 ### 开发备注 From 4753e45f9564c9893f97a2d47a8d632c6da07736 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 19 Sep 2026 13:54:48 +0800 Subject: [PATCH 6/9] test(sandbox): probe cmd's own > NUL redirection The CI Windows runner reports an empty \0 for a PowerShell-level '>nul' redirection around the cmd call, and PowerShell's '>nul' opens a file named nul rather than exercising cmd's redirection. Pass the whole command to cmd so it parses '>NUL' itself and prove it with cmd's own output. --- packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts index 9cf0517661..7632c301c0 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts @@ -507,11 +507,13 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { it('NUL writes stay ambient under the Low token in BOTH modes', () => { // The device DACL grants Everyone write and carries no higher label, so - // the documented `> NUL` redirection must survive the lowered token. + // the documented `> NUL` redirection must survive the lowered token. The + // redirection stays inside cmd's own command line: PowerShell's `>nul` + // opens a file named nul instead, which is a different probe. const cmd = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32\\cmd.exe') const probe = [ "$ErrorActionPreference='SilentlyContinue';", - `& '${cmd}' /c echo ok>nul 2>&1; 'CMD-NUL: exit=' + $LASTEXITCODE;`, + `& '${cmd}' /c 'echo ok>NUL&&echo CMD-NUL: OK';`, `& "${process.execPath}" -e "try{require('node:fs').writeFileSync(process.argv[1],'x');console.log('NODE-NUL: OK')}catch(e){console.log('NODE-NUL: DENIED '+e.code)}" '\\\\.\\NUL'`, ].join('') for (const mode of ['read-only', 'workspace-write'] as const) { @@ -520,7 +522,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe, ]) expect(result.status, `mode: ${mode}\nstderr: ${result.stderr}`).toBe(0) - expect(result.stdout, `mode: ${mode}`).toContain('CMD-NUL: exit=0') + expect(result.stdout, `mode: ${mode}`).toContain('CMD-NUL: OK') expect(result.stdout, `mode: ${mode}`).toContain('NODE-NUL: OK') } }, 30_000) From 85724fb5b03de665437beaebbda626af2f4868d6 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 19 Sep 2026 14:05:18 +0800 Subject: [PATCH 7/9] docs(config-catalog): refresh the sandbox-local Config source line The comment edit in sandbox-local/src/index.ts moved the Config block from line 44 to 45, and the generated catalog records that line. --- docs/config-catalog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 77c80e50f9..e3ca3de577 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2077,7 +2077,7 @@ export interface Config { } ``` -Source: [`packages/sandbox/sandbox-local/src/index.ts:44`](../packages/sandbox/sandbox-local/src/index.ts) +Source: [`packages/sandbox/sandbox-local/src/index.ts:45`](../packages/sandbox/sandbox-local/src/index.ts) From 7201569aef390be22277207a4afb093150d73592 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 19 Sep 2026 14:17:59 +0800 Subject: [PATCH 8/9] docs(config-catalog): carry the source-line refresh to the zh pair The generated catalog is a bilingual pair, so the regenerated English side must be brought over with its Chinese counterpart before the pairing record is re-written. --- docs/config-catalog.i18n.yaml | 4 ++-- docs/config-catalog.zh.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 67d822716e..54b6408d9b 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 77c80e50f9174da638a13a8554ede5e9eb806853 -config-catalog.zh.md: d7b315342f5ebdd2c2830fbeb4ac6be987f7de45 +config-catalog.md: e3ca3de5778065b46c4b8e82726055041f7b40b7 +config-catalog.zh.md: 99a5ef9aa196c69ac7df21a1fc0450d1eff6edc5 diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index d7b315342f..99a5ef9aa1 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2079,7 +2079,7 @@ export interface Config { } ``` -来源:[`packages/sandbox/sandbox-local/src/index.ts:44`](../packages/sandbox/sandbox-local/src/index.ts) +来源:[`packages/sandbox/sandbox-local/src/index.ts:45`](../packages/sandbox/sandbox-local/src/index.ts) From e553344063bc939568c65801adc614dcfa1668fc Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 20 Sep 2026 13:42:58 +0800 Subject: [PATCH 9/9] test(sandbox): drop new unknown casts after the merge-forward The no-unknown-casts ratchet landed on master after this branch, so the new stub tables in the ACL failure paths can no longer assert through unknown. Route them through a typed partial stub, type the bigint handles as NativePtr, and prune the three retired baseline entries. --- .../tests/acl-failure-paths.spec.ts | 13 +++++---- .../tests/grant-failure-paths.spec.ts | 29 +++++++++++-------- .../tests/index-failure-paths.spec.ts | 13 ++++++--- .../tests/token-failure-paths.spec.ts | 15 ++++++---- scripts/no-unknown-casts.baseline.json | 5 ---- 5 files changed, 43 insertions(+), 32 deletions(-) diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts index b4c295e825..741e7e283f 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts @@ -11,6 +11,7 @@ import { tmpdir } from 'node:os' import { Win32Error } from '@deepseek-ai/dsh-win32-process' import { describe, expect, it, vi } from 'vitest' +import type { Mock } from 'vitest' import koffi from 'koffi' import { grantWrite, revokeWrite, withPathLock } from '../src/acl.ts' @@ -511,8 +512,8 @@ describe('mergeAndApply failure paths', () => { describe('revokeWrite label handling', () => { /** The SECURITY_INFORMATION and SACL of the last apply. */ - function applyArgs(api: Win32Bindings): { information: number; sacl: unknown } { - const call = (api.setNamedSecurityInfoW as unknown as ReturnType).mock.calls.at(-1) + function applyArgs(setNamedSecurityInfoW: Mock): { information: number; sacl: unknown } { + const call = setNamedSecurityInfoW.mock.calls.at(-1) return { information: call?.[2] as number, sacl: call?.[6] } } @@ -529,7 +530,7 @@ describe('revokeWrite label handling', () => { setNamedSecurityInfoW, }) expect(revokeWrite(api, 'C:\\granted', sid)).toBe(true) - const { information, sacl } = applyArgs(api) + const { information, sacl } = applyArgs(setNamedSecurityInfoW) expect(information & abi.LABEL_SECURITY_INFORMATION).toBe(0) // the label was left untouched expect(sacl).toBeNull() }) @@ -543,7 +544,7 @@ describe('revokeWrite label handling', () => { setNamedSecurityInfoW, }) expect(revokeWrite(api, 'C:\\granted', sid)).toBe(true) - expect(applyArgs(api).information & abi.LABEL_SECURITY_INFORMATION).toBe(abi.LABEL_SECURITY_INFORMATION) + expect(applyArgs(setNamedSecurityInfoW).information & abi.LABEL_SECURITY_INFORMATION).toBe(abi.LABEL_SECURITY_INFORMATION) }) it('clears the Low label when a malformed tiny DACL hides any foreign grant', () => { @@ -554,7 +555,7 @@ describe('revokeWrite label handling', () => { const setNamedSecurityInfoW = vi.fn(() => 0) const api = aclApi({ getNamedSecurityInfoW: readStub(acl, null, 6n), setNamedSecurityInfoW }) expect(revokeWrite(api, 'C:\\granted', craftSid(1, 0))).toBe(true) - expect(applyArgs(api).information & abi.LABEL_SECURITY_INFORMATION).toBe(abi.LABEL_SECURITY_INFORMATION) + expect(applyArgs(setNamedSecurityInfoW).information & abi.LABEL_SECURITY_INFORMATION).toBe(abi.LABEL_SECURITY_INFORMATION) }) it('clears the Low label when a lying ACE size hides any foreign grant', () => { @@ -566,7 +567,7 @@ describe('revokeWrite label handling', () => { const setNamedSecurityInfoW = vi.fn(() => 0) const api = aclApi({ getNamedSecurityInfoW: readStub(acl, null, 6n), setNamedSecurityInfoW }) expect(revokeWrite(api, 'C:\\granted', craftSid(1, 0))).toBe(true) - expect(applyArgs(api).information & abi.LABEL_SECURITY_INFORMATION).toBe(abi.LABEL_SECURITY_INFORMATION) + expect(applyArgs(setNamedSecurityInfoW).information & abi.LABEL_SECURITY_INFORMATION).toBe(abi.LABEL_SECURITY_INFORMATION) }) }) diff --git a/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts index 87e88bbd0f..208110863e 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts @@ -15,10 +15,15 @@ import { AclWriteGrant } from '../src/index.ts' const PVOID = koffi.pointer('void') +/** Stub binding table: only the members a test drives, so the rest are never called. */ +function stubBindings(overrides: Partial): Win32Bindings { + return overrides as Win32Bindings +} + /** The stub the grant-then-fail-revoke sequence needs: every call succeeds until the DACL read is flipped off. */ function grantThenFailApi(): { api: Win32Bindings; failReads: () => void } { const state = { failReads: false } - const api = { + const api = stubBindings({ convertStringSidToSidW: vi.fn((_sid: string, slot: NativePtr) => { koffi.encode(slot, PVOID, 42n) return 1 @@ -26,7 +31,7 @@ function grantThenFailApi(): { api: Win32Bindings; failReads: () => void } { createWellKnownSid: vi.fn(() => 1), isValidSid: vi.fn(() => 1), getLengthSid: vi.fn(() => 12), - localAlloc: vi.fn(() => 11n), + localAlloc: vi.fn(() => 11n as NativePtr), initializeAcl: vi.fn(() => 1), addMandatoryAce: vi.fn(() => 1), getTempPathW: vi.fn((_length: number, buffer: Buffer) => { @@ -34,7 +39,7 @@ function grantThenFailApi(): { api: Win32Bindings; failReads: () => void } { buffer.write(temp, 'utf16le') return temp.length }), - createFileW: vi.fn(() => 7n), + createFileW: vi.fn(() => 7n as NativePtr), lockFileEx: vi.fn(() => 1), unlockFileEx: vi.fn(() => 1), closeHandle: vi.fn(() => 1), @@ -53,10 +58,10 @@ function grantThenFailApi(): { api: Win32Bindings; failReads: () => void } { return 0 }), setNamedSecurityInfoW: vi.fn(() => 0), - localFree: vi.fn(() => 0n), + localFree: vi.fn(() => 0n as NativePtr), getLastError: vi.fn(() => 2), formatMessageW: vi.fn(() => 0), - } as unknown as Win32Bindings + }) return { api, failReads: () => { state.failReads = true } } } @@ -92,24 +97,24 @@ describe('AclWriteGrant failure paths', () => { }) it('dispose aggregates a failing SID free into an AggregateError', () => { - const api = { + const api = stubBindings({ convertStringSidToSidW: vi.fn((_sid: string, slot: NativePtr) => { koffi.encode(slot, PVOID, 42n) return 1 }), createWellKnownSid: vi.fn(() => 1), isValidSid: vi.fn(() => 1), - localFree: vi.fn(() => 1n), // non-NULL: LocalFree "failed" + localFree: vi.fn(() => 1n as NativePtr), // non-NULL: LocalFree "failed" getLastError: vi.fn(() => 87), formatMessageW: vi.fn(() => 0), - } as unknown as Win32Bindings + }) const grant = AclWriteGrant.create('S-1-4-42-42', api) expect(() =>{ grant.dispose() }).toThrow(AggregateError) }) it('create fails closed when the Low label SID cannot be created', () => { const localFree = vi.fn(() => 0n as NativePtr) - const api = { + const api = stubBindings({ convertStringSidToSidW: vi.fn((_sid: string, slot: NativePtr) => { koffi.encode(slot, PVOID, 42n) return 1 @@ -118,7 +123,7 @@ describe('AclWriteGrant failure paths', () => { localFree, getLastError: vi.fn(() => 87), formatMessageW: vi.fn(() => 0), - } as unknown as Win32Bindings + }) expect(() => AclWriteGrant.create('S-1-4-42-42', api)).toThrow(/CreateWellKnownSid/) expect(localFree).toHaveBeenCalledWith(42n) // the parsed capability SID is not stranded }) @@ -126,7 +131,7 @@ describe('AclWriteGrant failure paths', () => { it('create frees both earlier SIDs when the world SID cannot be created', () => { const localFree = vi.fn(() => 0n as NativePtr) let calls = 0 - const api = { + const api = stubBindings({ convertStringSidToSidW: vi.fn((_sid: string, slot: NativePtr) => { koffi.encode(slot, PVOID, 42n) return 1 @@ -136,7 +141,7 @@ describe('AclWriteGrant failure paths', () => { localFree, getLastError: vi.fn(() => 87), formatMessageW: vi.fn(() => 0), - } as unknown as Win32Bindings + }) expect(() => AclWriteGrant.create('S-1-4-42-42', api)).toThrow(/CreateWellKnownSid/) expect(localFree).toHaveBeenCalledTimes(2) expect(localFree).toHaveBeenCalledWith(42n) diff --git a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts index 95f639df88..64aac56080 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts @@ -61,6 +61,11 @@ function scratch(): string { return dir } +/** Stub binding table: only the members a test drives, so the rest are never called. */ +function stubBindings(overrides: Partial): Win32Bindings { + return overrides as Win32Bindings +} + /** * The stub the whole happy pipeline needs: token opening, capability-SID * parsing, workspace+temp grants, logon-SID scan, well-known SID, restricted token, @@ -69,7 +74,7 @@ function scratch(): string { */ function happyStubs(): HappyStubs { let next = 0n - const fresh = () => ++next + const fresh = (): NativePtr => ++next as NativePtr const openProcess = vi.fn(() => fresh()) const openProcessToken = vi.fn((_process: unknown, _access: unknown, slot: NativePtr) => { @@ -155,12 +160,12 @@ function happyStubs(): HappyStubs { const resumeThread = vi.fn(() => 0) const terminateProcess = vi.fn(() => 1) const getStdHandle = vi.fn(() => fresh()) - const localFree = vi.fn(() => 0n) + const localFree = vi.fn(() => 0n as NativePtr) const closeHandle = vi.fn(() => 1) const getLastError = vi.fn(() => ERROR_BROKEN_PIPE) // the drains' clean EOF const formatMessageW = vi.fn(() => 0) - const api = { + const api = stubBindings({ openProcess, openProcessToken, convertStringSidToSidW, getTempPathW, createFileW, lockFileEx: vi.fn(() => 1), unlockFileEx: vi.fn(() => 1), getNamedSecurityInfoW, setEntriesInAclW, setNamedSecurityInfoW, getTokenInformation, @@ -171,7 +176,7 @@ function happyStubs(): HappyStubs { setInformationJobObject, assignProcessToJobObject, resumeThread, terminateProcess, getStdHandle, localFree, closeHandle, getLastError, formatMessageW, - } as unknown as Win32Bindings + }) return { api, setNamedSecurityInfoW, convertStringSidToSidW, closeHandle, localFree, createRestrictedToken, createJobObjectW, getNamedSecurityInfoW, setTokenInformation, addMandatoryAce, diff --git a/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts index 62e7ccde7e..480f514952 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts @@ -21,6 +21,11 @@ import * as abi from '../src/win32-abi.ts' const PVOID = koffi.pointer('void') +/** Stub binding table: only the members a test drives, so the rest are never called. */ +function stubBindings(overrides: Partial): Win32Bindings { + return overrides as Win32Bindings +} + describe('openCurrentProcessToken failure paths', () => { it('reports when OpenProcess yields no handle', () => { const api = { @@ -383,17 +388,17 @@ describe('restrictTokenIntegrity', () => { expect(info.readUInt32LE(8)).toBe(abi.SE_GROUP_INTEGRITY) return 1 }) - const api = { getLengthSid: vi.fn(() => 12), setTokenInformation } as unknown as Win32Bindings + const api = stubBindings({ getLengthSid: vi.fn(() => 12), setTokenInformation }) restrictTokenIntegrity(api, 5n as NativePtr, lowSid) expect(setTokenInformation).toHaveBeenCalledTimes(1) }) it('fails closed when the Low label SID has no length', () => { - const api = { + const api = stubBindings({ getLengthSid: vi.fn(() => 0), getLastError: vi.fn(() => 87), formatMessageW: vi.fn(() => 0), - } as unknown as Win32Bindings + }) let caught: unknown try { restrictTokenIntegrity(api, 5n as NativePtr, allocBytes(12)) @@ -405,12 +410,12 @@ describe('restrictTokenIntegrity', () => { }) it('fails closed when SetTokenInformation rejects the integrity level', () => { - const api = { + const api = stubBindings({ getLengthSid: vi.fn(() => 12), setTokenInformation: vi.fn(() => 0), getLastError: vi.fn(() => 5), formatMessageW: vi.fn(() => 0), - } as unknown as Win32Bindings + }) let caught: unknown try { restrictTokenIntegrity(api, 5n as NativePtr, allocBytes(12)) diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index f5ecfea159..132d00b292 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -1702,14 +1702,9 @@ "9c749359b9458267505dc9d87b2b4e2a73e874c0e3908d7fa06e4fd4fd11cad4": 1 }, "packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts": { - "0ee37bb1bf169e1de1030cd3449529c550283dccba4ea2d9d2af967b9ac51895": 1, "24defbb8f6adc4ec8ebd3443b233aceadae85ab486413320de0967eaf7d43032": 1, - "d7b28f469106e3e88d52c1742fe001ac7807bc1bc21c0fd4ddcb5bd9b97bf31b": 1, "ff7b125eae197db9cb8866293613a5de2a984d42b4487b2cc37eeacc8c300783": 1 }, - "packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts": { - "916c26b0b6721f519ca6c95b93446c1b16b174f9bfb067e79f9cb03cec8fc87c": 1 - }, "packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts": { "d187b3ab8af0b780660e2b764b553ccff82e3af3254598ee18a059e5bd0381f5": 1 },