From fd650af340d7113a3f9771782a4ec30e805cda74 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:31:38 +0800 Subject: [PATCH] fix(sandbox): isolate Windows temp capabilities --- ...ows-acl-restricted-token-sandbox.i18n.yaml | 4 +- ...08-windows-acl-restricted-token-sandbox.md | 6 +- ...windows-acl-restricted-token-sandbox.zh.md | 6 +- docs/subsystems/sandbox.i18n.yaml | 4 +- docs/subsystems/sandbox.md | 8 +- docs/subsystems/sandbox.zh.md | 8 +- packages/bash/pwsh-sandbox/tests/acl.e2e.ts | 30 +- packages/bash/tool-pwsh/README.i18n.yaml | 4 +- packages/bash/tool-pwsh/README.md | 2 +- packages/bash/tool-pwsh/README.zh.md | 2 +- packages/bash/tool-pwsh/src/index.ts | 6 +- packages/bash/tool-pwsh/tests/tools.spec.ts | 1 + .../sandbox/sandbox-local/README.i18n.yaml | 4 +- packages/sandbox/sandbox-local/README.md | 2 + packages/sandbox/sandbox-local/README.zh.md | 2 + packages/sandbox/sandbox-local/src/index.ts | 172 ++++---- .../sandbox-local/tests/acl-grants.spec.ts | 406 +++++++----------- .../sandbox-windows-acl/README.i18n.yaml | 4 +- .../sandbox/sandbox-windows-acl/README.md | 44 +- .../sandbox/sandbox-windows-acl/README.zh.md | 44 +- .../sandbox/sandbox-windows-acl/src/grant.ts | 15 +- .../sandbox/sandbox-windows-acl/src/index.ts | 190 ++++---- .../sandbox/sandbox-windows-acl/src/runner.ts | 137 +++--- .../sandbox/sandbox-windows-acl/src/token.ts | 17 +- .../sandbox-windows-acl/src/workspace-sid.ts | 20 +- .../sandbox-windows-acl/tests/acl.spec.ts | 25 +- .../sandbox-windows-acl/tests/probe.spec.ts | 24 +- .../tests/provider-chain.spec.ts | 2 +- .../sandbox-windows-acl/tests/runner.spec.ts | 136 +++++- .../tests/workspace-sid.spec.ts | 15 +- 30 files changed, 744 insertions(+), 596 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 4eaf9e4158..aac04f71de 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: 0680fb3a9b7aac5793c0b275f6db5257c27b81da -2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 611172d2b5502a7f45233c62a9848c9b2491c53b +2026-08-08-windows-acl-restricted-token-sandbox.md: 60db72042d7924b90423aac37f7360e317b991ca +2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 10b2c78accbd70610d8e0a29f76fedbb713ccc06 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 0680fb3a9b..60db72042d 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 [sandbox decision](2026-07-06-sandbox.md) leaves `PLATFORM_CHAINS.win32` emp ## 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 include a write SID (`S-1-4-x-y`); the write SID's Write ACEs on the workspace and temp roots are the primary write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. 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 every ignored return value). The write SID is the PER-WORKSPACE identity, derived deterministically from the canonical workspace path (`workspaceWriteSid` — sha256 → `S-1-4-x-y`) and stored NOWHERE: the workspace-root ACE therefore materializes once per workspace per machine — the standing ACE is the cross-session reuse cache, and every later provision hits the exact-ACE skip (idempotent re-grant skips the eager full-tree re-propagation — no garbage collection) — instead of once per session, which is what the earlier per-session random SID paid a full tree propagation per session for. The seam derives the session's PRIVATE temp subdirectory from the session id + workspace (sha256, 16 hex — stored nowhere, so no tamper surface exists) and creates it exclusively; it is removed on provider dispose, and a crash leaves it as `%TEMP%` litter whose next resume fails loudly at the exclusive creation until temp hygiene reclaims it. The seam materializes the workspace ACE STANDING (never revoked — the cache) and the temp ACE REVOCABLY (revoked on provider dispose, so an inheritable ACE never outlives its session's temp directory on the ambient temp root). The token's restricting list is the keep-alive group plus the write SID only under workspace-write: read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, write SID]. The keep-alive invariants are logon SID + Everyone (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Because Everyone remains a restricting SID, an external object that grants 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 runner suite pins both gaps. Read-only carries no workspace write SID: a standing grant ACE from an earlier workspace-write period stays INERT (the pass-2 check grants only what the restricting list carries, so read-only adds no writable root across a `/permission` downgrade or a crash-resumed session, while the standing ACE keeps the re-upgrade free). Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (0x80041003), so CIM is unavailable in every confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both; INTERACTIVE/LOCAL are likewise absent from both (the Public tree writes are denied — pinned by the runner's Public-probe regression). Workspace-write children see a PRIVATE per-session temp subdirectory (`\dsh-<16 hex>` derived from the session id + workspace — created exclusively, reparse points rejected, removed on provider dispose — TMP/TEMP rewritten by the runner — bwrap `--tmpfs /tmp` semantics). The restricted token's DEFAULT DACL is extended with a full-access write-SID ACE (`SetTokenInformation(TokenDefaultDacl)`): new objects created without an explicit security descriptor (anonymous pipes — CreatePipe, sync objects) then carry a restricting-SID ACE and pass the write pass-2 check at creation; NAMED pipes are exempt — their default security descriptor is the Win32 layer's user-mode default SD template (built by KernelBase — owner/SYSTEM/Admins full, Everyone/ANONYMOUS read-only), which the token cannot influence, so piped stdio capture stays denied for confined grandchildren (the POC-documented boundary, pinned by the runner suite). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack. +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. 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/bash/pwsh-sandbox/README.md) as the confining executor. ## How the restriction works (why no new identity) @@ -32,11 +32,11 @@ The [landstrip evaluation](../../rejected/feature/2026-07-26-evaluate-landstrip- ## 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 the exact Win32 code. Cost: enforcement is structurally partial because Everyone-granted writes and NTFS hard-link aliases cannot be made path-confined by this token shape; no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories; workspace ACEs stand forever by design — the reuse cache, invisible residue when a workspace is renamed — temp ACEs revoked by provider dispose together with the derived private temp directory — a crash leaves both behind and the next resume fails loudly at the exclusive creation until temp hygiene reclaims the directory); grant materialization is an EAGER full-tree propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per workspace per machine by the per-workspace identity; CIM is unavailable in BOTH confined modes (AuthUsers dropped from both lists — the WMI namespace security check fails, and `Get-ComputerInfo` silently returns incomplete results) as the price of closing the C:\-root tree-creation escape in both; FAT-class (non-ACL) targets outside the granted roots remain writable under both modes (no security descriptors to intersect — a legacy residue treated as unsupported, warn-only, documented in the README); NULL-DACL directories are not identity-preserving under a grant+revoke round-trip (documented edge, the POC shares it); `whoami` and token-inspection cmdlets fail under the restricted token (diagnostic noise, documented); and BOTH confined modes run `pwsh` in ConstrainedLanguage mode — the restricted token trips PowerShell's lockdown detection, so `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors while `-f` formatting, property access, and core cmdlets/types keep working, and the language mode cannot be lifted back to FullLanguage from inside — taught to the model in the pwsh tool description and documented in the package README's Known Limitations; BOTH confined modes also deny named-pipe opens — libuv's piped-stdio spawns fail with EPERM (the POC-documented "no output redirection" boundary; inherited/ignored stdio and anonymous pipes work) — documented in the package README's Known Limitations and taught to the model in the pwsh tool description. +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 (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); 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. ## Testing -The product-visible Windows roster flip is win32-only, so the keyless snapshot fixtures — which must replay on macOS/Linux — cannot cover it; the bundle composition specs ([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts), [`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts)) plus the win32 real-runner suites (`packages/sandbox/sandbox-windows-acl/tests/`, `packages/bash/pwsh-sandbox/tests/`) are the substitute evidence, and the CI Windows lane owns the assembled signal. The grant machinery is pinned cross-platform by `packages/sandbox/sandbox-local/tests/acl-grants.spec.ts` (the derived private-temp identity — deterministic per session + workspace, distinct across sessions — one-shot materialization, exclusive temp creation with reparse-point rejection and self-cleanup on failure, clean-restart re-grant of the same derived directory, the standing-vs-revocable lifecycle across dispose and the mode-switch cycle, and the derived-SID argv contract — with the Win32 surface mocked) and on win32 by `workspace-sid.spec.ts` (derivation determinism/shape/distinctness), `grant.spec.ts` (real-DACL materialization: revocable paths revoke on dispose, standing paths survive it), the `acl.spec.ts` idempotent-grant fast-path and standing-ACE-after-dispose contract, the `failure-paths.spec.ts` suspension-orphan regression (AssignProcessToJobObject failure terminates the child), and the `runner.spec.ts` `--write-sid` contract (caller-owned grants, private temp subdir through TMP/TEMP, both-mode CIM-denial probes, the mode-downgrade regression — a standing grant ACE is inert under read-only and effective again on re-upgrade — the ambient-writable Public-probe regression (a C:\Users\Public subdirectory write is denied under both modes), the partial-boundary regressions (external `Everyone:Modify` writes in both modes and a workspace hard link mutating its external file object), and the ConstrainedLanguage pins in both modes, plus the grandchild-stdio matrix pins — inherited/ignored stdio spawns succeed while piped capture is DENIED in both modes). The runner-failure classification is exit-gated on 127 (a confined command that merely prints the `windows-acl-run:` signature on a non-127 exit is never misclassified as "the command did not run" — pinned in the pwsh-sandbox helper suite). +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. ## 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 611172d2b5..10b2c78acc 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 ## Decision -直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含写入 SID(`S-1-4-x-y`);工作区与临时目录上写入 SID 的 Write ACE 是主要写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closed(POC 因忽略返回值而 fail-open)。写入 SID 是**按工作区**的身份,由规范工作区路径确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`),且**任何地方都不存储**:工作区根目录 ACE 因此每台机器每个工作区只物化一次——常驻 ACE 就是跨会话复用缓存,此后每次供给都命中精确 ACE 跳过(幂等重授权跳过急切的全树重传播——不做垃圾回收)——而不是每会话一次,这正是先前每会话随机 SID 每个会话都要付一次全树传播的代价。seam 从会话 id + 工作区派生会话的**私有**临时子目录(sha256、16 位 hex——任何地方都不存储,因此不存在篡改面)并独占创建;它在提供方 dispose 时移除,崩溃则把它留作 `%TEMP%` 垃圾,其下一次恢复会在独占创建处大声失败,直到临时目录卫生机制将其回收。seam 把工作区 ACE **常驻**物化(绝不撤销——就是缓存),把临时 ACE **可回收**物化(提供方 dispose(资源释放)时撤销,因此可继承 ACE 不会在环境临时根目录上比其会话的临时目录活得更久)。令牌的 restricting list 是保活组加上仅 workspace-write 下的写入 SID:read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、写入 SID]。保活不变式是登录 SID + Everyone(没有它们,早期 DLL init 会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。由于 Everyone 仍是 restricting SID,向 Everyone 授予写访问的外部对象会通过两次检查;由于 NTFS ACL 属于文件对象,工作区内获授权的硬链接也会使同一对象的外部别名获得授权。拒绝所有硬链接会让普通 pnpm 工作区不可用,因此提供方报告 `enforcement: 'partial'`,原生 runner 套件则钉住这两个缺口。Read-only 不含工作区写入 SID:先前 workspace-write 时期留下的常驻授权 ACE 保持**失效**(pass-2 检查只授予列表所携带的内容,因此 read-only 在 `/permission` 降级或崩溃后恢复的会话中不会增加任何可写根目录,而常驻 ACE 让重新升级保持零成本)。Authenticated Users 在**两种**列表中都缺席——WMI namespace 安全校验失败(0x80041003),因此 CIM 在每一种受限模式下都不可用,且 C:\-root 建树逃逸(驻留的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭;INTERACTIVE/LOCAL 同样在两种列表中都缺席(Public 树的写入被拒绝——由 runner 的 Public-probe 回归钉住)。Workspace-write 子进程看到的是私有的每会话临时子目录(`\dsh-<16 hex>`——由会话 id + 工作区派生、独占创建、拒绝 reparse point、提供方 dispose 时移除——TMP/TEMP 由 runner 重写——bwrap `--tmpfs /tmp` 语义)。受限令牌的**默认 DACL** 被扩展一条写入 SID 全权 ACE(`SetTokenInformation(TokenDefaultDacl)`):此后不带显式安全描述符创建的新对象(匿名管道——CreatePipe、同步对象)自带 restricting SID ACE,创建时的写 pass-2 检查通过;**named pipe 例外**——其默认安全描述符是 Win32 层在用户态安装的默认 SD 模板(由 KernelBase 构建——owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读),令牌无法影响,因此受限孙进程的管道 stdio 捕获保持拒绝(POC 记载的边界,由 runner 套件钉住)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付;Windows 平台层在受限 pwsh 栈之上重新启用完整权限面(sandbox/sandbox-policy/permission/approval/fs-sandbox)。 +直接基于原始 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(智能体)的调用会逐调用创建并移除同样的形态。环境临时根目录绝不会被隐式授权。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.md)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 档,以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付。 ## How the restriction works (why no new identity) @@ -32,11 +32,11 @@ AppContainer 令牌没有环境读访问:每个可读路径都必须预先通 ## Consequences -所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:强制执行在结构上只能是部分的,因为此令牌形态无法把 Everyone 授予的写入与 NTFS 硬链接别名限制在路径边界内;无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有;工作区 ACE 按设计永久常驻——复用缓存,工作区改名时成为不可见残留——临时 ACE 由提供方 dispose 连同派生的私有临时目录一起回收——崩溃会把两者都留下,下一次恢复会在独占创建处大声失败,直到临时目录卫生回收该目录);授权物化是急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——在大型工作区上耗时数十秒),因按工作区身份,每台机器每个工作区只付一次;CIM 在**两种**受限模式下都不可用(AuthUsers 从两种列表中被移除——WMI namespace 安全校验失败,`Get-ComputerInfo` 静默返回不完整结果),作为关闭两种模式下 C:\-root 建树逃逸的代价;位于被授权根目录之外的 FAT 类(无 ACL)目标在两种模式下仍可写(没有可做交集的安全描述符——作为历史残留处理:不支持、仅警告、已在 README 中记录);NULL DACL 目录在 grant+revoke 往返下不保持身份(记录在案的边角,POC 亦有此行为);`whoami` 与令牌检查 cmdlet 在受限令牌下失败(诊断噪音,已记录);且**两种**受限模式都以 ConstrainedLanguage 模式运行 `pwsh`——受限令牌触发 PowerShell 的锁定检测,因此 `Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,而 `-f` 格式化、属性访问与核心 cmdlet/类型继续工作,语言模式也无法从内部提升回 FullLanguage——已在 pwsh 工具描述中教给模型,并记录在包 README 的 Known Limitations 中;**两种**受限模式同样拒绝 named-pipe 打开——libuv 的管道 stdio spawn 以 EPERM 失败(POC 记载的“无法重定向输出”边界;继承/忽略的 stdio 与匿名管道可用)——记录在包 README 的 Known Limitations 中,并在 pwsh 工具描述中教给模型。 +所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致),且 fail-closed 错误携带 API 名与精确 Win32 错误码。会话共享有意常驻的工作区能力,但不共享各自可回收的临时能力;重启残留既不能阻塞恢复的会话,也不能向其授权。所失:强制执行在结构上只能是部分的,因为此令牌形态无法把 Everyone 授予的写入与 NTFS 硬链接别名限制在路径边界内;无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);工作区常驻 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 负责记录这些运行限制。 ## Testing -产品可见的 Windows 阵容切换仅存在于 win32,而 keyless 快照夹具必须在 macOS/Linux 上可重放,因此无法覆盖它;替代证据是 bundle 组合 spec([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts)、[`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts))加上 win32 真实 runner 套件(`packages/sandbox/sandbox-windows-acl/tests/`、`packages/bash/pwsh-sandbox/tests/`),组装态信号由 CI 的 Windows lane 负责。授权机制在跨平台侧由 `packages/sandbox/sandbox-local/tests/acl-grants.spec.ts` 钉住(派生的私有临时身份——按会话 + 工作区确定性、跨会话相异——一次性物化、独占临时目录创建并拒绝 reparse point、失败时自我清理、干净重启时对同一派生目录的重新授权、dispose 与模式切换循环中的常驻/可回收生命周期,以及派生 SID 的 argv 契约——mock 掉 Win32 表面),win32 侧由 `workspace-sid.spec.ts`(派生的确定性/形态/相异性)、`grant.spec.ts`(真实 DACL 物化:可回收路径在 dispose 时撤销、常驻路径存活)、`acl.spec.ts` 的幂等授权快速路径与 dispose 后常驻 ACE 契约、`failure-paths.spec.ts` 的 suspension-orphan 回归(AssignProcessToJobObject 失败会终止子进程)与 `runner.spec.ts` 的 `--write-sid` 契约(调用者所有目录的授权、经 TMP/TEMP 的私有临时子目录、两种模式下的 CIM 拒绝探针、模式降级回归——驻留授权 ACE 在 read-only 下失效并在重新升级后再度生效——环境可写 Public-probe 回归(对 C:\Users\Public 子目录的写入在两种模式下都会被拒绝)、部分强制执行边界回归(两种模式下对外部 `Everyone:Modify` 目标的写入,以及通过工作区硬链接修改其外部文件对象),以及两种模式下对 ConstrainedLanguage 的钉定,加上孙进程 stdio 矩阵钉定——继承/忽略的 stdio spawn 成功,而管道捕获在两种模式下都被**拒绝**)钉住。runner 失败分类以 127 退出码为门槛(受限命令仅仅在非 127 退出时打印 `windows-acl-run:` 签名,也绝不会被误分类为"命令未运行"——由 pwsh-sandbox helper 套件钉住)。 +产品可见的 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 原生运行负责提供架构特定的验收证据。 ## Related diff --git a/docs/subsystems/sandbox.i18n.yaml b/docs/subsystems/sandbox.i18n.yaml index c4e82947e0..3fdd226576 100644 --- a/docs/subsystems/sandbox.i18n.yaml +++ b/docs/subsystems/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 docs/subsystems/sandbox.md -sandbox.md: 6a8f5d4a1f06036cd96ad44f578bd5f13401170f -sandbox.zh.md: 63a033e8c344a5839a1cbe113ea01da5f5a65e73 +sandbox.md: 0478e30ada949102193407ee536d4974c841e371 +sandbox.zh.md: d2a59d1703b22deb1a4206911f488b4e30059580 diff --git a/docs/subsystems/sandbox.md b/docs/subsystems/sandbox.md index 6a8f5d4a1f..0478e30ada 100644 --- a/docs/subsystems/sandbox.md +++ b/docs/subsystems/sandbox.md @@ -55,10 +55,10 @@ interface SandboxExecutionPolicy { workspaceRoot: string /** * Opaque identity of the calling session (the branded `dsh-session` - * SessionId). Backends key per-session state off it (e.g. the windows-acl - * per-session private temp subdirectory — the write grant itself is - * per-workspace, derived from the workspace root); absent for agentless - * calls, which fall back to per-call backend state. + * SessionId). Backends key per-session state off it (e.g. windows-acl gives + * each live session/workspace pair a random private temp directory and SID, + * while the workspace SID and standing grant remain per-workspace); absent + * for agentless calls, which fall back to per-call backend state. */ sessionId?: SessionId } diff --git a/docs/subsystems/sandbox.zh.md b/docs/subsystems/sandbox.zh.md index 63a033e8c3..d2a59d1703 100644 --- a/docs/subsystems/sandbox.zh.md +++ b/docs/subsystems/sandbox.zh.md @@ -55,10 +55,10 @@ interface SandboxExecutionPolicy { workspaceRoot: string /** * Opaque identity of the calling session (the branded `dsh-session` - * SessionId). Backends key per-session state off it (e.g. the windows-acl - * per-session private temp subdirectory — the write grant itself is - * per-workspace, derived from the workspace root); absent for agentless - * calls, which fall back to per-call backend state. + * SessionId). Backends key per-session state off it (e.g. windows-acl gives + * each live session/workspace pair a random private temp directory and SID, + * while the workspace SID and standing grant remain per-workspace); absent + * for agentless calls, which fall back to per-call backend state. */ sessionId?: SessionId } diff --git a/packages/bash/pwsh-sandbox/tests/acl.e2e.ts b/packages/bash/pwsh-sandbox/tests/acl.e2e.ts index cb319038cb..1b78dc5ff7 100644 --- a/packages/bash/pwsh-sandbox/tests/acl.e2e.ts +++ b/packages/bash/pwsh-sandbox/tests/acl.e2e.ts @@ -29,21 +29,19 @@ function pwshAvailable(): boolean { describe.skipIf(!isWin32 || !pwshAvailable())('pwsh-sandbox real ACL confinement', () => { let scratchRoot!: string let writableDir!: string - let isolatedTemp!: string + let outsideTempDir!: string let secretFile!: string let escapeFile!: string let executor!: SandboxPwshExecutor beforeAll(async () => { - // The escape probe must live OUTSIDE every legitimately granted tree: the - // provider's workspace-write grants the workspace plus the REAL temp dir - // (the 'backend-defined temp area', same as Landlock granting /tmp), so a - // scratch dir under temp would inherit the grant and the probe would be a - // false pass. A mkdtemp under the profile is removed by afterAll. + // The workspace escape sits under the profile. A separate directory under + // the ambient temp root proves that the root itself is not granted: the + // runner creates its own private child and rewrites TMP/TEMP to it. scratchRoot = mkdtempSync(join(homedir(), 'dsh-pwsh-sandbox-e2e-')) writableDir = join(scratchRoot, 'writable') mkdirSync(writableDir) - isolatedTemp = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-e2e-temp-')) + outsideTempDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-e2e-outside-temp-')) secretFile = join(scratchRoot, 'secret.txt') writeFileSync(secretFile, 'top secret - must stay readable to prove the read boundary') escapeFile = join(scratchRoot, 'escaped.txt') @@ -58,7 +56,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('pwsh-sandbox real ACL confinement afterAll(() => { rmSync(scratchRoot, { recursive: true, force: true }) - rmSync(isolatedTemp, { recursive: true, force: true }) + rmSync(outsideTempDir, { recursive: true, force: true }) }) it('read-only: ordinary path writes denied, reads fine, partial and denial facts ride the result', async () => { @@ -66,7 +64,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('pwsh-sandbox real ACL confinement const probe = [ "$ErrorActionPreference='SilentlyContinue';", `try{Set-Content -Path '${writableDir}\\ro-write.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`, - `try{Set-Content -Path '${isolatedTemp}\\ro-write.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`, + `try{Set-Content -Path '${outsideTempDir}\\ro-write.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`, `try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK'}catch{'ESCAPE-WRITE: DENIED'};`, `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`, ].join('') @@ -89,23 +87,31 @@ describe.skipIf(!isWin32 || !pwshAvailable())('pwsh-sandbox real ACL confinement expect(denied.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' }) }, 60_000) - it('workspace-write: workspace and temp writable, escape denied, reads fine', async () => { + it('workspace-write: workspace and private temp writable, ambient temp and escape denied', async () => { const policy: SandboxExecutionPolicy = { mode: 'workspace-write', workspaceRoot: writableDir } const probe = [ "$ErrorActionPreference='SilentlyContinue';", `try{Set-Content -Path '${writableDir}\\ww-write.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`, - `try{Set-Content -Path '${isolatedTemp}\\ww-write.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`, + "try{Set-Content -Path (Join-Path $env:TEMP 'ww-write.txt') -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};", + `try{Set-Content -Path '${outsideTempDir}\\ww-write.txt' -Value ok -ErrorAction Stop;'AMBIENT-TEMP-WRITE: OK'}catch{'AMBIENT-TEMP-WRITE: DENIED'};`, `try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK'}catch{'ESCAPE-WRITE: DENIED'};`, - `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`, + `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'};`, + "'TEMP-PATH: ' + $env:TEMP", ].join('') const result = await executor.run(executor.resolve({ command: probe, sandboxPolicy: policy })) expect(result.exitCode, `stderr: ${result.stderr.text}`).toBe(0) expect(result.stdout.text).toContain('TARGET-WRITE: OK') expect(result.stdout.text).toContain('TEMP-WRITE: OK') + expect(result.stdout.text).toContain('AMBIENT-TEMP-WRITE: DENIED') expect(result.stdout.text).toContain('ESCAPE-WRITE: DENIED') expect(result.stdout.text).toContain('SECRET-READ: OK') expect(existsSync(join(writableDir, 'ww-write.txt'))).toBe(true) + expect(existsSync(join(outsideTempDir, 'ww-write.txt'))).toBe(false) expect(existsSync(escapeFile)).toBe(false) + const privateTemp = result.stdout.text.match(/^TEMP-PATH: (.+)$/mu)?.[1]?.trim() + expect(privateTemp).toBeDefined() + expect(privateTemp?.startsWith(tmpdir())).toBe(true) + expect(existsSync(privateTemp ?? '')).toBe(false) expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'partial' }) }, 60_000) }) diff --git a/packages/bash/tool-pwsh/README.i18n.yaml b/packages/bash/tool-pwsh/README.i18n.yaml index b6e043fd84..04e314ac14 100644 --- a/packages/bash/tool-pwsh/README.i18n.yaml +++ b/packages/bash/tool-pwsh/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/bash/tool-pwsh/README.md -README.md: 3fd5a53946e2b101d6ef4457e312e52f4db5f3a8 -README.zh.md: c06b4354b6973a6ff196cda7c49966c7c40e0a90 +README.md: 4126e718c569f17fb8be465351b2576970e93c63 +README.zh.md: aba669733a7ecb9924287abb298bb9a154b5afa7 diff --git a/packages/bash/tool-pwsh/README.md b/packages/bash/tool-pwsh/README.md index 3fd5a53946..4126e718c5 100644 --- a/packages/bash/tool-pwsh/README.md +++ b/packages/bash/tool-pwsh/README.md @@ -120,7 +120,7 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work -- **ConstrainedLanguage and named-pipe capture under the Windows sandbox** — when the [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md) confines a call (read-only or workspace-write), the restricted token puts pwsh into ConstrainedLanguage mode: `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors, and the mode cannot be lifted from inside. The same modes deny named-pipe opens, so a piped-stdio spawn inside a confined command fails with EPERM. The tool description teaches both contracts to the model; the backend README owns the full limitations. +- **Language mode and named-pipe capture under the Windows sandbox** — under the [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md), read-only pwsh starts in ConstrainedLanguage because its temp write denial makes PowerShell's AppLocker probe fail closed: `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors, and the mode cannot be lifted from inside. Workspace-write's private temp lets the probe complete, so it stays in FullLanguage unless host policy says otherwise. Both confined modes deny named-pipe opens, so a piped-stdio spawn inside a confined command fails with EPERM. The tool description teaches both contracts to the model; the backend README owns the full limitations. - **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; the PTY backends are Linux/macOS-only today, and a Windows ConPTY persistent shell is roadmap work. - **PowerShell-dialect contract** — the model must write PowerShell (native paths, `$env:` variables), not bash; there is no dialect translation. - **Session-cwd identity is not canonicalized** — the workdir base is the session header cwd as-is, unlike the bash tool's sandbox-root-canonicalized identity. Under a confining executor the policy's workspace root IS canonicalized (by the shared policy service), so the workdir and the confinement root can diverge when the raw session cwd differs from its canonical form — a parity gap deferred to the shared shell-tool base extraction. diff --git a/packages/bash/tool-pwsh/README.zh.md b/packages/bash/tool-pwsh/README.zh.md index c06b4354b6..aba669733a 100644 --- a/packages/bash/tool-pwsh/README.zh.md +++ b/packages/bash/tool-pwsh/README.zh.md @@ -120,7 +120,7 @@ ack 是固定短行;任务输出按读取有界。 ## Known Limitations and Deferred Work -- **Windows sandbox 下的 ConstrainedLanguage 与 named-pipe 捕获** — 当 [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md) 隔离某次调用(read-only 或 workspace-write)时,受限令牌使 pwsh 进入 ConstrainedLanguage 模式:`Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,且该模式无法从内部解除。这两种模式同样会拒绝 named-pipe 打开,因此受限命令内的管道 stdio spawn 以 EPERM 失败。工具描述把这两个约定教给模型;后端 README 负责完整的限制说明。 +- **Windows 沙箱下的语言模式与 named-pipe 捕获** — 在 [Windows ACL 沙箱](../../sandbox/sandbox-windows-acl/README.md) 下,read-only pwsh 会以 ConstrainedLanguage 启动,因为临时目录写入被拒绝,导致 PowerShell 的 AppLocker 探针失败并按 fail-closed 处理:`Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,且该模式无法从内部解除。workspace-write 的私有临时目录使探针得以完成,因此除非主机策略另有规定,否则它保持 FullLanguage。两种受限模式都拒绝 named-pipe 打开,因此受限命令内的管道 stdio spawn 以 EPERM 失败。工具描述把这两个约定教给模型;后端 README 负责完整的限制说明。 - **无持久 shell 或 PTY** — 每次调用都启动全新的 `pwsh -Command`;PTY 后端目前仅限 Linux/macOS,Windows ConPTY 持久 shell 属于路线图工作。 - **PowerShell 方言约定** — 模型必须写 PowerShell(原生路径、`$env:` 变量),而不是 bash;没有方言翻译。 - **会话 cwd 身份不做规范化** — workdir 基座直接取会话头 cwd 原值,不同于 bash 工具经 sandbox-root 规范化的身份。在隔离执行器下,策略的工作区根**会**被规范化(由共享的策略服务完成),因此当原始会话 cwd 与其规范化形态不同时,workdir 与隔离根可能不一致——这一 parity 差距留待共享 shell 工具基座提取时解决。 diff --git a/packages/bash/tool-pwsh/src/index.ts b/packages/bash/tool-pwsh/src/index.ts index 9ef6650748..4c63d7cbc0 100644 --- a/packages/bash/tool-pwsh/src/index.ts +++ b/packages/bash/tool-pwsh/src/index.ts @@ -114,15 +114,15 @@ function pwshDescription(backgroundEnabled: boolean, escalationModes: readonly S + 'On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. ' + background if (escalationModes.length === 0) return base - // The CLM and named-pipe contracts below are Windows-restricted-token + // The language-mode and named-pipe contracts below are Windows-restricted-token // behavior, but the gate is 'any confining executor is mounted' // (escalationModes non-empty). The conflation is safe today because every // shipped composition pairing tool-pwsh with a confining executor is // win32-only; a future POSIX pwsh-sandbox composition must gate both // sentences on the platform instead (tracked in the pwsh-tool-and-executor // Agent Note). - return base + ' Under the Windows sandbox, pwsh runs in PowerShell ConstrainedLanguage mode (read-only and ' - + 'workspace-write): prefer cmdlets and core types (`[string]`, `[datetime]`, `[regex]`, `[guid]`); ' + return base + ' Under the Windows sandbox, read-only pwsh runs in PowerShell ConstrainedLanguage mode, while ' + + 'workspace-write stays in FullLanguage unless host policy says otherwise. In read-only, prefer cmdlets and core types (`[string]`, `[datetime]`, `[regex]`, `[guid]`); ' + '.NET static calls (`[System.IO.*]::`, `[math]::`), `Add-Type`, COM objects, and reflection fail ' + 'with "only core types" errors. `-f` formatting, property access, and core cmdlets work. ' + 'In the same modes, programs cannot open named pipes, so a command that captures another ' diff --git a/packages/bash/tool-pwsh/tests/tools.spec.ts b/packages/bash/tool-pwsh/tests/tools.spec.ts index 91ad796ce5..dd89e53b96 100644 --- a/packages/bash/tool-pwsh/tests/tools.spec.ts +++ b/packages/bash/tool-pwsh/tests/tools.spec.ts @@ -559,6 +559,7 @@ describe('sandbox escalation through ctx.approval', () => { expect(properties['sandbox_permissions']?.enum).toEqual(['workspace-write', 'danger-full-access']) expect(schema.description).toContain('approval prompt') expect(schema.description).toContain('ConstrainedLanguage') + expect(schema.description).toContain('workspace-write stays in FullLanguage') expect(schema.description).toContain('named pipes') expect(schema.description).toContain('fails with EPERM') diff --git a/packages/sandbox/sandbox-local/README.i18n.yaml b/packages/sandbox/sandbox-local/README.i18n.yaml index ffcdc6ed45..b93ff75c48 100644 --- a/packages/sandbox/sandbox-local/README.i18n.yaml +++ b/packages/sandbox/sandbox-local/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-local/README.md -README.md: 2d36802305ebe34676d896404913b4686d318b97 -README.zh.md: 565910781dc0f43635dd22d08fefb32f7f4f27e3 +README.md: d9294948161cfa54d1269b35b236e651b2a9f916 +README.zh.md: c6836089012c850af8a26e768d4107f257a9914a diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md index 2d36802305..d929494816 100644 --- a/packages/sandbox/sandbox-local/README.md +++ b/packages/sandbox/sandbox-local/README.md @@ -12,6 +12,8 @@ Policy is per call; the provider stores only the mechanism and cached runner ver The Seatbelt profile is allow-default with `(deny file-write*)` plus write allow-lists, so exactly the mode's promised file effects are governed: `read-only` grants the `/dev/null` literal alone; `workspace-write` adds the workspace root, `/tmp`, and the per-user darwin temp dir (`os.tmpdir()` — the platform's real temp area for mkstemp-family tools), every root canonicalized because Seatbelt matches resolved paths (`/tmp` IS `/private/tmp`). Apple marks the `sandbox-exec` CLI deprecated but ships it on every macOS; the functional probe is what fails closed if that ever changes. +The Windows rung keeps one deterministic write SID and standing ACE per workspace, but gives every live session/workspace pair a random private temp directory with a distinct SID and revocable ACE. Sessions sharing a workspace therefore 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; agentless calls receive the same per-invocation isolation from the runner. + [`@deepseek-ai/node-addon-landlock-run`](https://www.npmjs.com/package/@deepseek-ai/node-addon-landlock-run) supplies the platform launcher, functional probe, and CLI argument vocabulary. This provider owns only mode-to-grant mapping and runner selection. Keeping path resolution and probe parsing with the versioned binary prevents contract drift. ```yaml diff --git a/packages/sandbox/sandbox-local/README.zh.md b/packages/sandbox/sandbox-local/README.zh.md index 565910781d..c683608901 100644 --- a/packages/sandbox/sandbox-local/README.zh.md +++ b/packages/sandbox/sandbox-local/README.zh.md @@ -12,6 +12,8 @@ Seatbelt profile 默认允许,但带 `(deny file-write*)` 和写入 allow-list,因此恰好约束相应模式承诺的文件操作:`read-only` 只授予 `/dev/null` 字面路径;`workspace-write` 另加工作区根目录、`/tmp` 和逐用户 darwin 临时目录(`os.tmpdir()`,即平台供 mkstemp 家族工具使用的真实临时区域)。每个根目录都经过规范化,因为 Seatbelt 匹配解析后的路径(`/tmp` 就是 `/private/tmp`)。Apple 将 `sandbox-exec` CLI(命令行界面)标为 deprecated,但所有 macOS 系统仍会提供它;若情况发生变化,功能探测会使执行被拒绝。 +Windows 档为每个工作区保留一个确定性写入 SID 和常驻 ACE,但为每个活跃的会话/工作区对分配一个随机私有临时目录,以及不同的 SID 和可回收 ACE。因此,共享工作区的会话会共享预期的写权限,却不会继承彼此的临时目录权限。新的提供方总会选择新的临时路径和 SID,因此崩溃残留既无法阻止恢复的会话,也无法向其授权;runner 会为无 agent(智能体)的调用提供同样的逐调用隔离。 + [`@deepseek-ai/node-addon-landlock-run`](https://www.npmjs.com/package/@deepseek-ai/node-addon-landlock-run)提供平台 launcher、功能探测和 CLI 参数词汇。该提供方只负责模式到授权的映射与 runner 选择。把路径解析和探测解析保留在带版本的 binary 中,可防止约定漂移。 ```yaml diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 4eb0b41dbe..45a996b342 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -7,22 +7,21 @@ * * The windows-acl rung additionally owns the write grants: the write SID is * the per-WORKSPACE identity derived from the canonical workspace path - * (`workspaceWriteSid`), and the private temp subdirectory is DERIVED per - * session (session id + workspace — nothing stored). The + * (`workspaceWriteSid`), while every live session receives a RANDOM private + * temp directory and its own derived capability (`tempWriteSid`). The * workspace-root ACE materializes once per workspace per server lifetime * and STANDS (the cross-session reuse cache — the exact-ACE skip makes * every later provision O(1) instead of re-propagating the tree per * session); the private-temp ACEs are revoked on dispose. The runner - * receives `--write-sid` (the derived identity; its presence marks the - * seam-managed contract) and stops managing DACLs itself. The rung reports - * partial enforcement because WRITE_RESTRICTED must retain Everyone in its + * 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. * @module @deepseek-ai/dsh-sandbox-local */ import { spawnSync } from 'node:child_process' -import { createHash } from 'node:crypto' -import { existsSync, mkdirSync, rmSync } from 'node:fs' +import { existsSync, mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -38,7 +37,7 @@ import { assertNever } from '@deepseek-ai/dsh-llm' import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, ConfinedSandboxMode, RunnerFailureRule, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import type { SessionId } from '@deepseek-ai/dsh-session' -import { AclWriteGrant, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' +import { AclWriteGrant, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './profiles.ts' /** Plugin config. All optional — `static Config` supplies the defaults. */ @@ -112,25 +111,6 @@ function defaultProbeWindowsAcl(runnerInvocation: string[], timeoutMs: number): return probe.status === 0 } -/** - * The session's private temp subdirectory: `\dsh-<16 hex>`, derived - * from the session id and its workspace instead of stored. The same session - * and workspace always name the same directory — a resumed session - * re-grants it (the exact-ACE skip keeps that O(1)) — while a fork's - * different session id names a fresh one. The name is predictable to anyone - * who knows the session id (the confined command sees it as - * `DSH_SESSION_ID`), so the provider creates the directory EXCLUSIVELY and - * rejects reparse points: a pre-placed entry fails the first confined run - * loudly, and cannot redirect the grant onto a foreign object. - * @param sessionId - the policy's calling-session identity. - * @param workspaceRoot - the resolved policy root. - * @returns the session's private temp subdirectory path. - */ -export function sessionTempDir(sessionId: SessionId, workspaceRoot: string): string { - const digest = createHash('sha256').update(String(sessionId)).update('\0').update(workspaceRoot).digest('hex') - return join(tmpdir(), `dsh-${digest.slice(0, 16)}`) -} - /** Test hook: inject probe verdicts / a fake launcher / a platform without real runners. */ export interface SandboxInternals { /** Replaces `process.platform` for chain selection (exercise any platform's chain from any host). */ @@ -160,6 +140,13 @@ export interface SandboxInternals { /** The chain's verdict: which runner confines, and how completely it enforces. */ type SelectedRunner = { runner: 'bwrap' | 'landlock' | 'seatbelt' | 'windows-acl'; enforcement: SandboxEnforcement } +/** One live session/workspace pair's private temp directory and capability. */ +interface AclTempCapability { + dir: string + writeSid: string + grant: AclWriteGrant +} + /** * The runner chain per platform — selection is BY PLATFORM first, probes * second: a platform's chain is probed in preference order only when it has @@ -256,8 +243,9 @@ const RUNNER_FAILURE_RULES = { * Local process-sandbox provider. Registers as `ctx.sandbox`. Caches the * chain verdict and, on the windows-acl rung, the write grants * ({@link AclWriteGrant}: the standing workspace-root grant per workspace - * and the revocable private-temp grant per session, the latter revoked on - * provider dispose); the one-time probes spawn nothing else. + * and the revocable private-temp grant per live session/workspace pair, the + * latter revoked on provider dispose); the one-time probes spawn nothing + * else. */ export class LocalSandboxProvider extends SandboxProvider { // Inline schema call: the config catalog walks `static Config` statically. @@ -279,12 +267,11 @@ export class LocalSandboxProvider extends SandboxProvider { * Server-lifetime write grants (windows-acl rung): the STANDING * workspace-root grant per workspace (its ACE is the cross-session reuse * cache and outlives the provider — never revoked) and the REVOCABLE - * private-temp grant per session (revoked on provider dispose). + * private-temp grant per live session/workspace pair (revoked on provider + * dispose). */ private readonly workspaceGrants = new Map() - private readonly tempGrants = new Map() - /** Session id → the private temp directory this provider created (removed on dispose). */ - private readonly tempDirs = new Map() + private readonly tempCapabilities = new Map() constructor(ctx: Context, config: Config) { super(ctx) @@ -358,20 +345,19 @@ export class LocalSandboxProvider extends SandboxProvider { /** * The windows-acl runner argv for one policy. With a calling session (the - * policy's `sessionId`), the write grants are materialized once per server - * lifetime — the standing workspace-root grant per workspace and the - * revocable private-temp grant per session — and the runner receives - * `--write-sid` (the workspace-derived identity; its presence marks the - * seam-managed DACL contract) plus, under workspace-write, the session's - * PRIVATE temp subdirectory (derived from session id + workspace) — it - * grants nothing and revokes nothing. Agentless calls pass the ambient - * temp root and no `--write-sid`: the runner self-manages its DACLs. + * policy's `sessionId`) under workspace-write, the grants are materialized + * once per provider lifetime — the standing workspace-root grant per + * workspace and a revocable, RANDOM private-temp capability per live + * session/workspace pair. The runner receives `--write-sid` plus + * `--temp-write-sid` and grants nothing itself. Agentless workspace-write + * calls pass the ambient temp ROOT and no SID flags: the runner creates and + * removes a random private child directory for that one invocation. * @param policy - the resolved per-call policy. * @returns the runner invocation. */ private windowsAclRunnerArgv(policy: SandboxPolicy): string[] { const sessionId = policy.sessionId - if (sessionId === undefined) { + if (sessionId === undefined || policy.mode === 'read-only') { return [ ...this.windowsAclRunnerInvocation(), '--workspace', policy.workspaceRoot, @@ -379,45 +365,32 @@ export class LocalSandboxProvider extends SandboxProvider { '--mode', policy.mode, ] } - this.materializeAclGrant(sessionId, policy.workspaceRoot, policy.mode) + const temp = this.materializeAclGrant(sessionId, policy.workspaceRoot) return [ ...this.windowsAclRunnerInvocation(), '--workspace', policy.workspaceRoot, - // Workspace-write sessions confine their temp writes to the PRIVATE - // per-session subdirectory (bwrap --tmpfs /tmp semantics); read-only - // runs pass the ambient temp root — the runner validates it exists - // but grants nothing. The derived write SID is the per-workspace - // identity; the flag's presence marks the seam-managed DACL contract. - '--temp', policy.mode === 'workspace-write' ? sessionTempDir(sessionId, policy.workspaceRoot) : tmpdir(), + '--temp', temp.dir, '--mode', policy.mode, '--write-sid', workspaceWriteSid(policy.workspaceRoot), + '--temp-write-sid', temp.writeSid, ] } /** - * Materialize the session's ACEs once per server lifetime: lazily at its - * first confined execution, reused for every later call (the map hits are - * the whole call). The write SID is the per-workspace identity derived - * from the workspace. Workspace-write grants the workspace root STANDING - * (the ACE outlives every session — the reuse cache) and the session's - * private temp subdirectory REVOCABLY — the directory is derived from - * session id + workspace, created here EXCLUSIVELY (a pre-existing entry - * or a reparse point fails the first confined run loudly, so the grant - * never lands on a foreign object); read-only materializes NOTHING — its - * token alone restricts every write, and the standing grant from an - * earlier workspace-write period is KEPT through a downgrade (never - * revoked): the read-only restricted token carries no write SID (the - * read-only list), so the ACE is inert there, while the map hit keeps the - * re-upgrade free of re-propagation. Fail-closed: a half-materialized - * temp grant is revoked before the error propagates. + * Materialize one workspace-write policy's ACEs once per provider + * lifetime. The workspace SID and standing root grant are shared by the + * workspace. The temp directory is random and carries a distinct SID, so + * another session on the same workspace cannot use the shared workspace + * SID to enter it. A fresh provider always chooses a new path; crash + * residue therefore cannot collide with or authorize a resumed session. + * Fail-closed: a half-materialized temp grant is revoked and its directory + * removed before the error propagates. * @param sessionId - the policy's calling-session identity. * @param workspaceRoot - the resolved policy root. - * @param mode - the policy mode (grants exist only under workspace-write). + * @returns the pair's private temp directory and write capability. */ - private materializeAclGrant(sessionId: SessionId, workspaceRoot: string, mode: ConfinedSandboxMode): void { - if (mode === 'read-only') return + private materializeAclGrant(sessionId: SessionId, workspaceRoot: string): AclTempCapability { const writeSid = workspaceWriteSid(workspaceRoot) - const tempDir = sessionTempDir(sessionId, workspaceRoot) if (!this.workspaceGrants.has(workspaceRoot)) { const grant = AclWriteGrant.create(writeSid) try { @@ -435,32 +408,37 @@ export class LocalSandboxProvider extends SandboxProvider { } this.workspaceGrants.set(workspaceRoot, grant) } - if (this.tempGrants.has(sessionId)) return - const grant = AclWriteGrant.create(writeSid) - // The directory is removed again in the catch only when THIS confine - // created it — a pre-existing entry (EEXIST) is a foreign object and is - // never deleted. - let created = false + const key = JSON.stringify([String(sessionId), workspaceRoot]) + const existing = this.tempCapabilities.get(key) + if (existing !== undefined) return existing + const tempDir = mkdtempSync(join(tmpdir(), 'dsh-')) + const tempSid = tempWriteSid(tempDir) + let grant: AclWriteGrant | undefined try { - // Exclusive creation (no `recursive`): a pre-existing entry OR a - // reparse point both fail EEXIST — the grant never lands on a foreign - // object. - mkdirSync(tempDir) - created = true + grant = AclWriteGrant.create(tempSid) grant.add(tempDir) } catch (error) { - if (created) rmSync(tempDir, { recursive: true, force: true }) - // Revoke whatever stands and free the SID — never leave a half-grant - // behind a failed confine (the runner never runs). + const cleanupFailures: unknown[] = [] + if (grant !== undefined) { + try { + grant.dispose() + } catch (cleanupError) { + cleanupFailures.push(cleanupError) + } + } try { - grant.dispose() + this.removeTempDir(tempDir) } catch (cleanupError) { - throw new AggregateError([error, cleanupError], 'sandbox-local windows-acl temp grant materialization failed and its cleanup also failed') + cleanupFailures.push(cleanupError) + } + if (cleanupFailures.length > 0) { + throw new AggregateError([error, ...cleanupFailures], 'sandbox-local windows-acl temp grant materialization failed and its cleanup also failed') } throw error } - this.tempGrants.set(sessionId, grant) - this.tempDirs.set(sessionId, tempDir) + const capability = { dir: tempDir, writeSid: tempSid, grant } + this.tempCapabilities.set(key, capability) + return capability } /** @@ -469,36 +447,40 @@ export class LocalSandboxProvider extends SandboxProvider { * removed, and every SID allocation is freed; the standing workspace ACEs * stay (the reuse cache). Cleanup failures are reported, not thrown: * cordis teardown must not be aborted by grant cleanup. A crash skips all - * of it — the next resume then fails loudly at the exclusive creation and - * OS temp hygiene (or manual removal) recovers. + * of it, but a new provider never reuses the residue's random path or SID; + * OS temp hygiene (or manual removal) eventually reclaims it. */ private revokeAclGrants(): void { - if (this.workspaceGrants.size === 0 && this.tempGrants.size === 0) return + if (this.workspaceGrants.size === 0 && this.tempCapabilities.size === 0) return const failures: unknown[] = [] - for (const grant of [...this.workspaceGrants.values(), ...this.tempGrants.values()]) { + for (const grant of [...this.workspaceGrants.values(), ...[...this.tempCapabilities.values()].map(capability => capability.grant)]) { try { grant.dispose() } catch (error) { failures.push(error) } } - const rmTempDir = this.internals.rmTempDir ?? ((dir: string) => { rmSync(dir, { recursive: true, force: true }) }) - for (const dir of this.tempDirs.values()) { + for (const { dir } of this.tempCapabilities.values()) { try { - rmTempDir(dir) + this.removeTempDir(dir) } catch (error) { failures.push(error) } } this.workspaceGrants.clear() - this.tempGrants.clear() - this.tempDirs.clear() + this.tempCapabilities.clear() if (failures.length > 0) { this.ctx.logger.warn(`sandbox-local: windows-acl grant cleanup completed with ${failures.length} failure(s)`) for (const error of failures) this.ctx.logger.warn(error) } } + /** Remove one provider-owned private temp directory (injectable for cleanup tests). */ + private removeTempDir(dir: string): void { + const remove = this.internals.rmTempDir ?? ((path: string) => { rmSync(path, { recursive: true, force: true }) }) + remove(dir) + } + /** * Resolve which runner confines commands, once, for the provider's * lifetime: this platform's chain ({@link PLATFORM_CHAINS}), its sole diff --git a/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts b/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts index ca2410c317..3f6c3f2dc3 100644 --- a/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts +++ b/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts @@ -1,27 +1,26 @@ /** - * windows-acl write grants: the SERVER-LIFETIME ACE materialization - * (standing workspace grant per workspace, revocable private-temp grant per - * session) plus the derived private-temp identity, through the REAL - * LocalSandboxProvider.confine(). Win32 surface mocked at the package - * boundary (the workspace-derived SID mocked to a constant); the real-FFI - * grant behavior lives in sandbox-windows-acl's win32 tests. + * windows-acl grant ownership through the real LocalSandboxProvider: one + * standing capability per workspace plus one random, distinct, revocable + * temp capability per live session/workspace pair. The Win32 grant surface + * is mocked; native access checks live in sandbox-windows-acl's runner suite. */ -import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs' +import { existsSync, mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { basename, join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { SessionId } from '@deepseek-ai/dsh-session' -import { LocalSandboxProvider, sessionTempDir } from '@deepseek-ai/dsh-sandbox-local' +import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' /** Cross-file state shared with the vi.mock factory (hoisting contract). */ const mockState = vi.hoisted(() => ({ grants: [] as Array<{ writeSid: string; added: Array<{ path: string; standing: boolean }>; disposed: boolean }>, addFailure: undefined as Error | undefined, - /** Restricts {@link addFailure} to this path (undefined = every add throws). */ - addFailurePath: undefined as string | undefined, + /** Restrict an add failure to standing (workspace) or revocable (temp). */ + addFailureStanding: undefined as boolean | undefined, + createTempFailure: undefined as Error | undefined, disposeFailure: undefined as Error | undefined, })) @@ -35,24 +34,29 @@ vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => { mockState.grants.push(this) } static create(writeSid: string): MockAclWriteGrant { + if (writeSid.startsWith('TEMP:') && mockState.createTempFailure !== undefined) throw mockState.createTempFailure return new MockAclWriteGrant(writeSid) } add(path: string, standing = false): void { - if (mockState.addFailure !== undefined && (mockState.addFailurePath === undefined || mockState.addFailurePath === path)) { + this.added.push({ path, standing }) + if (mockState.addFailure !== undefined + && (mockState.addFailureStanding === undefined || mockState.addFailureStanding === standing)) { throw mockState.addFailure } - this.added.push({ path, standing }) } dispose(): void { if (mockState.disposeFailure !== undefined) throw mockState.disposeFailure this.disposed = true } } - return { AclWriteGrant: MockAclWriteGrant, workspaceWriteSid: () => 'S-1-4-42-42' } + return { + AclWriteGrant: MockAclWriteGrant, + workspaceWriteSid: () => 'S-1-4-42-42', + tempWriteSid: (path: string) => `TEMP:${path}`, + } }) -/** The workspace-derived write SID the mock pins for every workspace. */ -const DERIVED_SID = 'S-1-4-42-42' +const WORKSPACE_SID = 'S-1-4-42-42' async function setup() { const ctx = new Context() @@ -62,279 +66,214 @@ async function setup() { return { ctx, sandbox, fiber } } -/** A workspace root the policy carries. */ function workspaceRoot(): string { return mkdtempSync(join(tmpdir(), 'dsh-acl-grants-ws-')) } +function flag(argv: readonly string[], name: string): string | undefined { + const index = argv.indexOf(name) + return index < 0 ? undefined : argv[index + 1] +} + describe('windows-acl write grants (LocalSandboxProvider)', () => { const scratch: string[] = [] beforeEach(() => { mockState.grants = [] mockState.addFailure = undefined - mockState.addFailurePath = undefined + mockState.addFailureStanding = undefined + mockState.createTempFailure = undefined mockState.disposeFailure = undefined }) const cleanup = () => { + for (const grant of mockState.grants) { + for (const added of grant.added) { + if (!added.standing) rmSync(added.path, { recursive: true, force: true }) + } + } for (const dir of scratch.splice(0)) rmSync(dir, { recursive: true, force: true }) } - it('workspace-write: first confine materializes ONCE (standing workspace + revocable private temp), the derived temp dir rides the argv', async () => { + it('workspace-write materializes one standing workspace grant and one private temp capability, then reuses both', async () => { try { const { sandbox, fiber } = await setup() const ws = workspaceRoot() scratch.push(ws) - const tempDir = sessionTempDir(SessionId('sess-1'), ws) - scratch.push(tempDir) const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-1') } const confined = sandbox.confine(['pwsh', '/Command', 'x'], policy) + const tempDir = flag(confined.argv, '--temp') + const tempSid = flag(confined.argv, '--temp-write-sid') + expect(tempDir).toBeDefined() + expect(basename(tempDir ?? '')).toMatch(/^dsh-[A-Za-z0-9_-]{6}$/u) + expect(tempSid).toBe(`TEMP:${tempDir}`) + expect(tempSid).not.toBe(WORKSPACE_SID) expect(confined.argv).toEqual([ 'node', 'windows-acl-runner.js', '--workspace', ws, '--temp', tempDir, '--mode', 'workspace-write', - '--write-sid', DERIVED_SID, + '--write-sid', WORKSPACE_SID, + '--temp-write-sid', tempSid, '--', 'pwsh', '/Command', 'x', ]) - expect(mockState.grants).toHaveLength(2) - expect(mockState.grants[0]).toMatchObject({ - writeSid: DERIVED_SID, - added: [{ path: ws, standing: true }], // standing: the reuse cache, never revoked - disposed: false, - }) - expect(mockState.grants[1]).toMatchObject({ - writeSid: DERIVED_SID, - added: [{ path: tempDir, standing: false }], - disposed: false, - }) - expect(existsSync(tempDir)).toBe(true) // created exclusively + expect(mockState.grants).toEqual([ + expect.objectContaining({ writeSid: WORKSPACE_SID, added: [{ path: ws, standing: true }], disposed: false }), + expect.objectContaining({ writeSid: tempSid, added: [{ path: tempDir, standing: false }], disposed: false }), + ]) + expect(existsSync(tempDir ?? '')).toBe(true) - // Reuse: the second confine is the map hits. - sandbox.confine(['pwsh', '/Command', 'x'], policy) + expect(sandbox.confine(['pwsh', '/Command', 'x'], policy).argv).toEqual(confined.argv) expect(mockState.grants).toHaveLength(2) await fiber.dispose() - // dispose() runs on BOTH grants: the standing workspace ACE is left in - // place (the mock marks it disposed only as instance teardown). - expect(mockState.grants[0]!.disposed).toBe(true) - expect(mockState.grants[1]!.disposed).toBe(true) + expect(mockState.grants.every(grant => grant.disposed)).toBe(true) + expect(existsSync(tempDir ?? '')).toBe(false) } finally { cleanup() } }) - it('mode switch: read-only materializes nothing, the upgrade materializes ONCE with the derived SID, the downgrade keeps the standing grant', async () => { + it('read-only materializes no capability; upgrade creates them and downgrade leaves them reusable', async () => { try { - const { sandbox } = await setup() + const { sandbox, fiber } = await setup() const ws = workspaceRoot() scratch.push(ws) - const tempDir = sessionTempDir(SessionId('sess-switch'), ws) - scratch.push(tempDir) - const readOnly: SandboxPolicy = { mode: 'read-only', workspaceRoot: ws, sessionId: SessionId('sess-switch') } - const workspaceWrite: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-switch') } + const readOnly: SandboxPolicy = { mode: 'read-only', workspaceRoot: ws, sessionId: SessionId('switch') } + const workspaceWrite: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('switch') } - // read-only first: nothing materialized, ambient temp. - const confinedRo = sandbox.confine(['true'], readOnly) - expect(confinedRo.argv).toEqual([ + expect(sandbox.confine(['true'], readOnly).argv).toEqual([ 'node', 'windows-acl-runner.js', '--workspace', ws, - '--temp', tmpdir(), // NOT the private subdir: read-only grants nothing + '--temp', tmpdir(), '--mode', 'read-only', - '--write-sid', DERIVED_SID, '--', 'true', ]) expect(mockState.grants).toHaveLength(0) - expect(existsSync(tempDir)).toBe(false) - // Upgrade: first workspace-write materializes with the derived SID. const upgraded = sandbox.confine(['true'], workspaceWrite) - expect(upgraded.argv).toEqual([ - 'node', 'windows-acl-runner.js', - '--workspace', ws, - '--temp', tempDir, - '--mode', 'workspace-write', - '--write-sid', DERIVED_SID, - '--', - 'true', - ]) + expect(flag(upgraded.argv, '--temp-write-sid')).not.toBe(WORKSPACE_SID) expect(mockState.grants).toHaveLength(2) - expect(mockState.grants[0]).toMatchObject({ writeSid: DERIVED_SID, added: [{ path: ws, standing: true }], disposed: false }) - expect(mockState.grants[1]).toMatchObject({ - writeSid: DERIVED_SID, - added: [{ path: tempDir, standing: false }], - disposed: false, - }) - expect(existsSync(tempDir)).toBe(true) - - // Reuse: map hits. - sandbox.confine(['true'], workspaceWrite) - expect(mockState.grants).toHaveLength(2) - - // Downgrade: standing grant KEPT (inert under read-only, free re-upgrade). sandbox.confine(['true'], readOnly) expect(mockState.grants).toHaveLength(2) - expect(mockState.grants[0]!.disposed).toBe(false) + expect(mockState.grants.every(grant => !grant.disposed)).toBe(true) + expect(sandbox.confine(['true'], workspaceWrite).argv).toEqual(upgraded.argv) + + await fiber.dispose() } finally { cleanup() } }) - it('resume: a fresh provider derives the SAME temp dir for the same session and workspace and re-grants it', async () => { + it('a fresh provider gives a resumed session a new temp path and SID, so crash residue cannot collide', async () => { try { const ws = workspaceRoot() scratch.push(ws) - const first = await setup() const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('resumed') } + const first = await setup() const firstConfined = first.sandbox.confine(['true'], policy) - expect(mockState.grants).toHaveLength(2) + const firstTemp = flag(firstConfined.argv, '--temp') ?? '' - // Clean restart: dispose revokes the temp ACE and removes the private - // temp directory, so the fresh provider's exclusive creation succeeds. - await first.fiber.dispose() - mockState.grants = [] + // The first provider remains live: model an unclean prior process whose + // temp directory and ACE survived. A new provider must still proceed. const second = await setup() const secondConfined = second.sandbox.confine(['true'], policy) - expect(secondConfined.argv).toEqual(firstConfined.argv) - expect(mockState.grants).toHaveLength(2) - expect(mockState.grants[1]).toMatchObject({ - writeSid: DERIVED_SID, - added: [{ path: sessionTempDir(SessionId('resumed'), ws), standing: false }], - }) + const secondTemp = flag(secondConfined.argv, '--temp') ?? '' + expect(secondTemp).not.toBe(firstTemp) + expect(flag(secondConfined.argv, '--temp-write-sid')).not.toBe(flag(firstConfined.argv, '--temp-write-sid')) + expect(existsSync(firstTemp)).toBe(true) + expect(existsSync(secondTemp)).toBe(true) + await second.fiber.dispose() + await first.fiber.dispose() } finally { cleanup() } }) - it('fork: a different session id derives a DIFFERENT private temp identity over the same workspace', async () => { - try { - const { sandbox } = await setup() - const ws = workspaceRoot() - scratch.push(ws) - const parentPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('parent') } - const childPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('child') } - - sandbox.confine(['true'], parentPolicy) - const parentTemp = sessionTempDir(SessionId('parent'), ws) - scratch.push(parentTemp) - sandbox.confine(['true'], childPolicy) - const childTemp = sessionTempDir(SessionId('child'), ws) - scratch.push(childTemp) - - // Fresh temp identity, NOT the parent's (the workspace SID is shared by - // derivation — the workspace is the same, so the standing grant is the - // map hit and only the child's temp grant joins). - expect(childTemp).not.toBe(parentTemp) - expect(mockState.grants).toHaveLength(3) - expect(mockState.grants[2]).toMatchObject({ added: [{ path: childTemp, standing: false }] }) - } finally { - cleanup() - } - }) - - it('creates the private temp dir EXCLUSIVELY: a pre-existing entry or a reparse point fails EEXIST, never receiving the temp grant', async () => { - try { - const { sandbox } = await setup() - const ws = workspaceRoot() - scratch.push(ws) - - // Pre-existing entry: exclusive mkdir throws EEXIST instead of adopting it. - const preexisting = sessionTempDir(SessionId('preexisting'), ws) - mkdirSync(preexisting) - scratch.push(preexisting) - const prePolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('preexisting') } - expect(() => sandbox.confine(['true'], prePolicy)).toThrow(/EEXIST/) - // The standing workspace grant is the intended end state and stays; the - // failed temp grant self-disposes. - expect(mockState.grants).toHaveLength(2) - expect(mockState.grants[0]!.disposed).toBe(false) - expect(mockState.grants[1]!.disposed).toBe(true) // self-revoked - - // Reparse point: same EEXIST (exclusive mkdir never follows links). - const target = mkdtempSync(join(tmpdir(), 'dsh-acl-junction-target-')) - scratch.push(target) - const linkPath = sessionTempDir(SessionId('reparse'), ws) - symlinkSync(target, linkPath) - scratch.push(linkPath) - const linkPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('reparse') } - expect(() => sandbox.confine(['true'], linkPolicy)).toThrow(/EEXIST/) - // Same workspace as the preexisting case: the standing workspace grant - // is the map hit (not recreated) — only the failed temp grant joins. - expect(mockState.grants).toHaveLength(3) - expect(mockState.grants[2]!.disposed).toBe(true) - - // Temp-side cleanup failure: the standing workspace grant stays (map - // hit), the exclusive mkdir fails, AND the temp grant's dispose also - // fails — the temp cleanup AggregateError propagates. - mockState.grants = [] - mockState.disposeFailure = new Error('temp cleanup exploded') - const dupTemp = sessionTempDir(SessionId('temp-cleanup-fail'), ws) - mkdirSync(dupTemp) - scratch.push(dupTemp) - const dupPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('temp-cleanup-fail') } - expect(() => sandbox.confine(['true'], dupPolicy)).toThrow(/temp grant materialization failed and its cleanup also failed/) - expect(mockState.grants).toHaveLength(1) // only the failed temp grant (the workspace grant was the map hit) - } finally { - cleanup() - } - }) - - it('a grant failure mid-materialization disposes the failed grant and rethrows (AggregateError when the cleanup also fails)', async () => { - try { - const { sandbox } = await setup() - const ws = workspaceRoot() - scratch.push(ws) - scratch.push(sessionTempDir(SessionId('sess-add-fail'), ws)) - const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-add-fail') } - - // add() throws on the FIRST (workspace) grant: cleanup dispose() runs, original error propagates. - mockState.addFailure = new Error('grant exploded') - expect(() => sandbox.confine(['true'], policy)).toThrow('grant exploded') - expect(mockState.grants).toHaveLength(1) - expect(mockState.grants[0]!.disposed).toBe(true) - - // add() AND dispose() both throw: AggregateError. - mockState.grants = [] - mockState.addFailure = new Error('grant exploded again') - mockState.disposeFailure = new Error('cleanup exploded') - expect(() => sandbox.confine(['true'], policy)).toThrow(AggregateError) - } finally { - cleanup() - } - }) - - it('a temp add failure after the exclusive mkdir removed the half-created directory again', async () => { - try { - const { sandbox } = await setup() - const ws = workspaceRoot() - scratch.push(ws) - const tempDir = sessionTempDir(SessionId('sess-temp-add-fail'), ws) - const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-temp-add-fail') } - - // The workspace grant succeeds; only the TEMP grant's add throws (the - // path-targeted failure keeps the workspace branch intact). - mockState.addFailurePath = tempDir - mockState.addFailure = new Error('temp add exploded') - expect(() => sandbox.confine(['true'], policy)).toThrow('temp add exploded') - expect(existsSync(tempDir)).toBe(false) // the half-created directory is removed again - expect(mockState.grants).toHaveLength(2) - expect(mockState.grants[0]!.disposed).toBe(false) // the standing workspace grant stays - expect(mockState.grants[1]!.disposed).toBe(true) // the failed temp grant self-disposes - } finally { - cleanup() - } - }) - - it('agentless calls stay self-managed: no --write-sid, the ambient temp root, no grants', async () => { + it('forks and workspace changes receive distinct temp capabilities while each workspace grant is reused', async () => { try { const { sandbox, fiber } = await setup() - const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: '/ws' } - const confined = sandbox.confine(['pwsh', '/Command', 'x'], policy) + const wsA = workspaceRoot() + const wsB = workspaceRoot() + scratch.push(wsA, wsB) + const parent = sandbox.confine(['true'], { mode: 'workspace-write', workspaceRoot: wsA, sessionId: SessionId('parent') }) + const child = sandbox.confine(['true'], { mode: 'workspace-write', workspaceRoot: wsA, sessionId: SessionId('child') }) + const moved = sandbox.confine(['true'], { mode: 'workspace-write', workspaceRoot: wsB, sessionId: SessionId('parent') }) + + expect(flag(child.argv, '--temp')).not.toBe(flag(parent.argv, '--temp')) + expect(flag(child.argv, '--temp-write-sid')).not.toBe(flag(parent.argv, '--temp-write-sid')) + expect(flag(moved.argv, '--temp')).not.toBe(flag(parent.argv, '--temp')) + expect(mockState.grants).toHaveLength(5) // workspace A + two temps + workspace B + one temp + + await fiber.dispose() + } finally { + cleanup() + } + }) + + it('workspace grant failure disposes its SID and never creates a temp directory', async () => { + try { + const { sandbox } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + mockState.addFailureStanding = true + mockState.addFailure = new Error('workspace grant exploded') + expect(() => sandbox.confine(['true'], { + mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('workspace-fail'), + })).toThrow('workspace grant exploded') + expect(mockState.grants).toHaveLength(1) + expect(mockState.grants[0]!.disposed).toBe(true) + } finally { + cleanup() + } + }) + + it('temp grant creation/add failures remove the random directory; cleanup failures aggregate', async () => { + try { + const { sandbox } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + + mockState.createTempFailure = new Error('temp SID creation exploded') + expect(() => sandbox.confine(['true'], { + mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('create-fail'), + })).toThrow('temp SID creation exploded') + expect(mockState.grants).toHaveLength(1) // workspace only; random temp was removed + + mockState.createTempFailure = undefined + mockState.addFailureStanding = false + mockState.addFailure = new Error('temp add exploded') + expect(() => sandbox.confine(['true'], { + mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('add-fail'), + })).toThrow('temp add exploded') + const failedTempGrant = mockState.grants.at(-1) + expect(failedTempGrant?.disposed).toBe(true) + expect(failedTempGrant?.added).toHaveLength(1) + expect(existsSync(failedTempGrant?.added[0]?.path ?? '')).toBe(false) + + sandbox.internals.rmTempDir = () => { throw new Error('temp rm exploded') } + expect(() => sandbox.confine(['true'], { + mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('rm-fail'), + })).toThrow(/temp grant materialization failed and its cleanup also failed/u) + delete sandbox.internals.rmTempDir + + mockState.disposeFailure = new Error('temp cleanup exploded') + expect(() => sandbox.confine(['true'], { + mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('aggregate-fail'), + })).toThrow(/temp grant materialization failed and its cleanup also failed/u) + } finally { + cleanup() + } + }) + + it('agentless calls pass a temp root and no capabilities; the runner owns the private child lifecycle', async () => { + try { + const { sandbox, fiber } = await setup() + const confined = sandbox.confine(['pwsh', '/Command', 'x'], { mode: 'workspace-write', workspaceRoot: '/ws' }) expect(confined.argv).toEqual([ 'node', 'windows-acl-runner.js', '--workspace', '/ws', @@ -350,55 +289,26 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { } }) - it('a failing dispose at provider teardown is reported via ctx.logger.warn and never thrown into teardown', async () => { + it('provider teardown reports grant and directory cleanup failures without aborting teardown', async () => { try { const { ctx, sandbox, fiber } = await setup() const ws = workspaceRoot() scratch.push(ws) - scratch.push(sessionTempDir(SessionId('sess-dispose'), ws)) - const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-dispose') } - sandbox.confine(['true'], policy) - expect(mockState.grants).toHaveLength(2) - + const confined = sandbox.confine(['true'], { + mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('dispose'), + }) + const tempDir = flag(confined.argv, '--temp') ?? '' mockState.disposeFailure = new Error('revoke exploded') - const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - await fiber.dispose() - // BOTH grants (standing workspace + revocable temp) fail their dispose. - expect(warn).toHaveBeenCalledWith(expect.stringContaining('cleanup completed with 2 failure(s)')) - expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'revoke exploded' })) - } finally { - cleanup() - } - }) - - it('a failing private-temp removal at provider teardown is reported via ctx.logger.warn and never thrown into teardown', async () => { - try { - const { ctx, sandbox, fiber } = await setup() - const ws = workspaceRoot() - scratch.push(ws) - scratch.push(sessionTempDir(SessionId('sess-rm-fail'), ws)) - const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-rm-fail') } - sandbox.confine(['true'], policy) - expect(mockState.grants).toHaveLength(2) - sandbox.internals.rmTempDir = () => { throw new Error('rm exploded') } const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + await fiber.dispose() - // Both grants dispose cleanly; only the directory removal fails. - expect(warn).toHaveBeenCalledWith(expect.stringContaining('cleanup completed with 1 failure(s)')) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('cleanup completed with 3 failure(s)')) + expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'revoke exploded' })) expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'rm exploded' })) + expect(existsSync(tempDir)).toBe(true) // injected removal failed; test cleanup reclaims it } finally { cleanup() } }) - - it('sessionTempDir derives the same well-shaped name for the same session and workspace, distinct otherwise', () => { - const base = sessionTempDir(SessionId('sess-a'), '/ws/a') - expect(basename(base)).toMatch(/^dsh-[0-9a-f]{16}$/) - expect(sessionTempDir(SessionId('sess-a'), '/ws/a')).toBe(base) - expect(sessionTempDir(SessionId('sess-b'), '/ws/a')).not.toBe(base) // different session - expect(sessionTempDir(SessionId('sess-a'), '/ws/b')).not.toBe(base) // different workspace - // The separator prevents id/workspace collisions from merging inputs. - expect(sessionTempDir(SessionId('ab'), '/ws/c')).not.toBe(sessionTempDir(SessionId('a'), '/ws/bc')) - }) }) diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index f328417051..2425f2d0de 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: 2abd6239d2888d5d39ea22763b1cc9a29dc6075d -README.zh.md: fe1f22e3819d21cacc1ea342684c5e3b88c96f9b +README.md: 9134fa7b07699f4de8a264c8831aa2347f2d5e18 +README.zh.md: 49b5ebbd792395c86e1031f4efc49e3042dbfe55 diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index 2abd6239d2..9134fa7b07 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -4,50 +4,61 @@ English | [中文](README.zh.md) Windows write-restriction sandbox backend for the [harness sandbox seam](../sandbox/): a Node.js/[koffi](https://koffi.dev/) port of the mechanism in [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc) (`10e4dfb`, the fixed revision), mounted as the `enforcement: 'partial'` win32 rung of the [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) chain (`workspace-write` / `read-only` modes); the same package carries the Linux/macOS backends. -Mechanism in one line: the caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs include a write SID (`S-1-4-x-y`) whose Write ACEs exist only on the workspace and the session's private temp directory. The write SID is the per-WORKSPACE identity, derived deterministically from the canonical workspace path (`workspaceWriteSid`), so the workspace-root ACE materializes once per workspace per machine — every later session, call, or restart hits the exact-ACE skip — instead of once per session (see [The confinement runner](#the-confinement-runner)). Windows then grants a write only where BOTH the caller's normal access AND the restricting-SID intersection allow it. The write SID is the primary allowlist and grants nothing elsewhere, but the check also inherits ambient write ACEs of the OTHER restricting SIDs (the keep-alive group logon SID + Everyone), and NTFS ACLs belong to file objects rather than paths; the Everyone and hard-link boundaries are why the rung reports partial rather than full enforcement. +Mechanism in one line: the caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs carry separate workspace and private-temp capabilities. 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 temp directory and a SID derived from that path (`tempWriteSid`), so sessions share the intended workspace authority without inheriting one another's temp authority. Windows grants a write only where BOTH the caller's normal access AND the restricting-SID intersection allow it. These SIDs are the primary allowlists and grant nothing elsewhere, but the check also inherits ambient write ACEs of the OTHER restricting SIDs (the keep-alive group logon SID + Everyone), and NTFS ACLs belong to file objects rather than paths; the Everyone and hard-link boundaries are why the rung reports partial rather than full enforcement. Building directly on the raw ACL mechanism is the recorded design choice: it implements both confinement modes without the problems the rejected container options carry — see the [design note](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md) ([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) needs an OS floor of Windows 11 24H2 and wholesale host DACL writes for arbitrary-path reads; AppContainer cannot do arbitrary-path reads at all). ## Usage ```ts -import { AclSandbox, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { AclSandbox, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' const workspaceRoot = process.cwd() +const tempDir = mkdtempSync(join(tmpdir(), 'dsh-')) // mode selects the token's restricting-SID list (see Modes below) and must -// match the grant shape: read-only pairs with zero grants. workspace-write -// REQUIRES the workspace's write SID — the per-workspace identity. -const sandbox = new AclSandbox({ writableDirs: [workspaceRoot], writeSid: workspaceWriteSid(workspaceRoot), mode: 'workspace-write' }) +// match the grant shape. workspace-write requires distinct workspace and +// private-temp identities; pass tempDir: null to disable temp writes. +const sandbox = new AclSandbox({ + writableDirs: [workspaceRoot], + tempDir, + writeSid: workspaceWriteSid(workspaceRoot), + tempWriteSid: tempWriteSid(tempDir), + mode: 'workspace-write', +}) await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricted 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 +rmSync(tempDir, { recursive: true, force: true }) ``` -A direct `AclSandbox` grants the workspace ACEs STANDING (dispose() leaves them — they are the cross-instance reuse cache) and the temp ACE revocably (dispose() revokes it, so an inheritable ACE never outlives the instance on the ambient temp root). The server-side reuse is the `AclWriteGrant` class: `add(path, standing)` per directory, `dispose()` revokes the revocable paths and frees the SID — see the runner contract below. Every Win32 API call in this package is checked; failures throw `Win32Error` carrying the API name, the exact Win32 code, the `FormatMessageW` system text, and the failing path/context. This is deliberate: the POC ignored every return value and, when `CreateRestrictedToken` failed, silently ran the child with the FULL unrestricted token (fail-open). This port fails closed by construction. +A direct `AclSandbox` requires an explicit private temp directory (or `tempDir: null`; the ambient temp root is never an implicit grant), grants the workspace ACEs STANDING (dispose() leaves them — they are the cross-instance reuse cache), and grants the distinct temp SID revocably. The server-side reuse is the `AclWriteGrant` class: `add(path, standing)` per directory, `dispose()` revokes the revocable paths and frees the SID — see the runner contract below. Every Win32 API call in this package is checked; failures throw `Win32Error` carrying the API name, the exact Win32 code, the `FormatMessageW` system text, and the failing path/context. This is deliberate: the POC ignored every return value and, when `CreateRestrictedToken` failed, silently ran the child with the FULL unrestricted token (fail-open). This port fails closed by construction. ## The confinement runner The seam-facing shape is the **runner entry** (`./runner`), the argv-prefix wrapper `@deepseek-ai/dsh-sandbox-local` spawns in place of the caller's command — the same architecture as bwrap/landlock-run/sandbox-exec, so the sandbox seam's `confine()` contract needs no change. Stable argv contract: ```sh -node runner.js --workspace --temp --mode [--write-sid ] -- +node runner.js --workspace --temp --mode [--write-sid --temp-write-sid ] -- ``` -The runner creates the restricted token, spawns the wrapped argv under it with the caller's stdio passed straight through (the caller's pipes, made inheritable around the spawn — Node clears stdio inheritability at startup, which raw spawns must compensate for), wraps the child in a `KILL_ON_JOB_CLOSE` job (a dead runner kills the child), ignores its own console Ctrl+C so the child handles its own, mirrors the child's exit code, and revokes its temp grant on exit (workspace ACEs stand). Every runner-side failure prints `windows-acl-run: ` to stderr and exits 127 — the seam's `RUNNER_FAILURE_RULES` match that signature, so a runner refusal is never mistaken for a denial. +The runner creates the restricted token, spawns the wrapped argv under it with the caller's stdio passed straight through (the caller's pipes, made inheritable around the spawn — Node clears stdio inheritability at startup, which raw spawns must compensate for), wraps the child in a `KILL_ON_JOB_CLOSE` job (a dead runner kills the child), ignores its own console Ctrl+C so the child handles its own, mirrors the child's exit code, and revokes its self-managed temp grant on exit (workspace ACEs stand). Every runner-side failure prints `windows-acl-run: ` to stderr and exits 127 — the seam's `RUNNER_FAILURE_RULES` match that signature, so a runner refusal is never mistaken for a denial. -**Workspace grant reuse** (`--write-sid`): the write SID is DERIVED from the workspace path — no SID or temp-dir state is stored anywhere (the previous per-session random SID and its tamper surface are gone). The seam materializes the workspace ACE STANDING (once per workspace per server lifetime, never revoked — it is the reuse cache) and the temp ACE revocably (revoked on provider dispose), both lazily at the session's first confined execution. The session's private temp subdirectory is DERIVED from the session id + workspace (sha256, 16 hex) instead of stored: a resumed session derives the same directory and re-grants it (the exact-ACE skip keeps that O(1)), while a fork's different session id derives a fresh one. The directory is created EXCLUSIVELY — a pre-existing entry or a reparse point fails the first confined run loudly, so the grant never lands on a foreign object — and removed again on provider dispose. Under `--write-sid` the runner neither grants nor revokes (`manageDacls: false`) — the flag's presence marks the seam-managed contract, its value is the derived SID; without it (standalone use) the runner self-manages with the SAME derived SID (workspace ACEs standing, temp ACE revocable per call). Re-granting after a restart is idempotent: `grantWrite` reads the current DACL and SKIPS the `SetNamedSecurityInfoW` apply when the exact ACE already stands (that apply eagerly re-propagates the identical ACE across the whole tree — minutes on large workspaces). Standing ACEs from an unclean shutdown need no garbage collection — they ARE the cache; the same derived SID re-hits them forever. Known cost: materializing the grant on a big workspace tree blocks for the full eager propagation once per workspace per machine (the first confined write ever on this host). +**Workspace reuse and temp isolation**: the seam materializes the deterministic workspace SID's ACE STANDING (once per workspace per server lifetime, never revoked — it is the reuse cache), then creates a random private temp directory and distinct revocable SID for each live session/workspace pair. It passes both identities as the required `--write-sid`/`--temp-write-sid` pair; the runner verifies each against its owning path and neither grants nor revokes (`manageDacls: false`). A fork receives a different temp capability, and a fresh provider gives even the same resumed session a new path and SID, so crash residue is inert litter rather than a collision or inherited capability. Without the pair, `--temp` names a root: an agentless/standalone workspace-write runner creates a random private child, self-manages its temp SID, rewrites TMP/TEMP, and removes the child on exit. Re-granting the standing workspace ACE after a restart is idempotent: `grantWrite` reads the current DACL and skips `SetNamedSecurityInfoW` when the exact ACE already stands (that apply eagerly re-propagates the identical ACE across the whole tree — minutes on large workspaces). Known cost: the first grant on a big workspace tree blocks for that eager propagation once per workspace per machine. Modes (the token's restricting-SID list follows the mode; the keep-alive group is logon SID + Everyone in BOTH modes — early DLL init dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352` without them): -- `workspace-write` (logon SID, Everyone, write SID): the workspace and the session's PRIVATE temp subdirectory carry the write-SID Write grant; other ACL-addressable writes are denied except for the documented Everyone and hard-link boundaries. +- `workspace-write` (logon SID, Everyone, workspace SID, temp SID): the workspace and the session's PRIVATE temp subdirectory carry separate Write grants; other ACL-addressable writes are denied except for the documented Everyone and hard-link boundaries. - `read-only` (logon SID, Everyone — NO write SID): no explicit write-SID grants. The write SID stays OUT of the list on purpose: the standing workspace grant ACE from an earlier workspace-write period (a `/permission` downgrade, or a crash-resumed session) remains INERT under read-only because the write-restricted pass-2 check grants only what the restricting list carries — while the standing ACE keeps the re-upgrade free of re-propagation. Everyone's ambient rights remain the documented partial boundary. 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 — the sandbox cannot zero-grant the NUL device while Everyone stays in the keep-alive group. `Set-Content NUL` fails in both modes (a PowerShell/.NET-layer effect, pinned by the read-only suite — the device DACL is not the denying party); PowerShell's `> $null` redirection keeps working (it discards without opening NUL). Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (`0x80041003`), so CIM cmdlets and `Get-ComputerInfo` (which silently returns incomplete results rather than an error) are unavailable in EVERY confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both — the model-facing surface documents that contract, not a prompt promise. INTERACTIVE/LOCAL are absent from BOTH lists too: the host's Public tree grants write to INTERACTIVE, so Public writes are denied — pinned by the runner's ambient-writable Public-probe regression (see the design note). -The `AclSandbox` class (`tempDir: null` disables the temp grant) remains the programmatic API for direct spawns; `AclWriteGrant` is the server-side materialization half of the grant lifecycle. +The `AclSandbox` class (explicit private `tempDir` + `tempWriteSid`, or `tempDir: null` to disable temp writes) remains the programmatic API for direct spawns; `AclWriteGrant` is the server-side materialization half of the grant lifecycle. ## Header verification @@ -67,8 +78,8 @@ The koffi struct definitions assert their sizes against the probe at module load - **Console isolation is unavailable.** Under the restricted token, children created with `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` die during DLL initialization with `STATUS_DLL_INIT_FAILED` (`0xC0000142`). The POC tried to fix this by adding the console logon SID (`S-1-2-1`) to the restricting list; on Windows 11 26200 `CreateWellKnownSid(WinLocalLogonSid)` fails with `ERROR_INVALID_PARAMETER` (87), the correct `WinConsoleLogonSid` yields a valid `S-1-2-1` but the child still dies, and the POC's final revision removed both the SID and console isolation. Children therefore share the host console; stdio redirection is pipe-based and unaffected. - **ACL grants are standing directory mutations.** They persist if the process dies mid-run; workspace ACEs are standing BY DESIGN (never revoked — the reuse cache), temp ACEs are revoked by `dispose()` (`init()` also revokes an already-applied temp grant when a later step fails). The POC's documented manual cleanup (`icacls /remove '*S-1-4-…'`) fails on this platform with `ERROR_NONE_MAPPED` (1332) — revoke through this module instead. An unclean shutdown needs no self-healing for the workspace ACE: the derived SID re-hits the standing ACE on the next provision (skipping the apply); the write-SID ACE never accumulates a second identity per restart because the identity IS the workspace. - **Granted directories must be caller-owned.** The owner's implicit `WRITE_DAC` is what lets the sandbox edit the DACL without elevation. -- **The temp grant follows `GetTempPathW`** — pass `tempDir` explicitly whenever possible. `GetTempPathW` reads the NATIVE environment block, which host runtimes that manage `process.env` through worker pools may not keep in sync (verified with vitest: a worker-side `process.env.TMP` change never reached the native block). The seam passes the session's PRIVATE subdirectory (`\dsh-<16 hex>` derived from the session id + workspace, created exclusively — a pre-existing entry or reparse point fails loudly); a defaulted grant landing on the real temp dir inherits `(OI)(CI)` over every subdirectory of temp, silently widening the allowlist — point it at a per-sandbox directory instead. -- **The confined child's temp root is private per session** (workspace-write + `--write-sid`): the runner rewrites TMP/TEMP via `SetEnvironmentVariableW` to the session's private subdirectory before the spawn and the child inherits the rewritten block (bwrap `--tmpfs /tmp` semantics). Read-only leaves the ambient temp entries untouched — writes there are denied anyway. The subdirectory is removed on provider dispose; after a crash it may survive as plain `%TEMP%` litter until OS temp hygiene (or manual removal) reclaims it — a later resume then fails loudly at the exclusive creation. +- **The ambient temp root is never granted implicitly.** A direct `AclSandbox` workspace-write caller must supply an existing private `tempDir` plus its distinct `tempWriteSid`, or explicitly disable temp writes with `tempDir: null`. The seam creates a random private directory; agentless runner calls treat `--temp` as the parent root and create their own random child. +- **The confined child's temp capability is private per live session/workspace pair.** The runner rewrites TMP/TEMP via `SetEnvironmentVariableW` to that private directory before the spawn and the child inherits the rewritten block (bwrap `--tmpfs /tmp` semantics). The temp ACE and directory are removed on provider disposal, or after each agentless invocation. A crash can leave inert `%TEMP%` litter, but a resumed provider chooses a new random path and SID instead of colliding with or reauthorizing the residue. The native runner suite proves that 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, so `whoami /all` reports errors — diagnostic noise of the restriction scheme, not an operational failure; the denial surfaces that matter (file writes) are unaffected. ## Model Experience @@ -82,12 +93,11 @@ None directly; the denial surface belongs to the tool layer. ## Known Limitations and Deferred Work - **One write allowlist per workspace** — the write SID is the unit of the allowlist and IS the workspace identity; reusing one sandbox instance across two workspaces widens both grants to both roots (the same SID would then name two roots). Create one instance per workspace root — the seam does exactly this, keyed by the workspace path. -- **Cleanup is best-effort by design** — `dispose()` attempts every temp revocation and aggregates failures into an `AggregateError`; a cleanup failure leaves a standing (but write-SID-only) temp ACE that this process's next `init()`/`dispose()` cycle or `icacls` (via the ACE, not the trustee name) can still remove. +- **Cleanup is best-effort by design** — `dispose()` attempts every temp revocation and aggregates failures into an `AggregateError`; a cleanup failure can leave the random directory and its temp-SID-only ACE behind. Once the process exits no future token carries that SID, so the residue is inert until OS temp hygiene or manual directory removal reclaims it. - **Standing workspace ACEs are invisible residue.** Renaming a workspace derives a new SID; the old ACEs on the old path stay (inert, write-SID-only). A future cleanup command may reap them; nothing re-propagates because of them. - **NULL-DACL directories are not identity-preserving under grant+revoke.** A directory with a NULL DACL (rare — Windows-created directories carry real DACLs) means "everyone full control"; `grantWrite` builds the new ACL from that null, and the revoke round-trip leaves an EMPTY (deny-all) DACL rather than the original NULL DACL. The POC shares the behavior; real workspace and temp directories carry real DACLs, so this stays a documented edge rather than a guarded path. - **Piped stdio capture is impossible for confined grandchildren (the named-pipe default SD template).** libuv's pipe stdio uses NAMED pipes; `CreateNamedPipeW` without security attributes installs the Win32 layer's user-mode default SD template (built by KernelBase — owner/SYSTEM/Admins full, Everyone/ANONYMOUS read-only, the fixed template [MS documents](https://learn.microsoft.com/en-us/windows/win32/ipc/named-pipe-security-and-access-rights)) — NOT the token default DACL, which is what the kernel applies to a raw SD-null create — so the client-end open requests write access no restricting SID is granted: `spawn(..., { stdio: 'pipe' })` inside a confined process fails with EPERM, the POC-documented "no output redirection" boundary of WRITE_RESTRICTED tokens. Inherited (`inherit`/fd) and ignored (`ignore`) stdio spawns work, and anonymous pipes (CreatePipe — a token-default-DACL consumer, e.g. PowerShell pipelines) work because the restricted token's default DACL carries a full-access restricting-SID ACE (set at init). A confined process therefore cannot capture a grandchild's output through a pipe; tools that must capture output cannot run confined. -- **Grant materialization is an eager full-tree propagation.** `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (NOT lazily per access — measured at tens of seconds on large workspace trees plus the real temp root). The per-workspace identity pays it once per workspace per machine (lazily at the first confined execution ever, skipped entirely on every later provision when the exact ACE stands). If a workspace is huge, the first confined write on this host is correspondingly slow. -- **Resuming one session concurrently in two server processes fails the second at its first confined write.** Both processes derive the same private temp directory; the second one's exclusive creation hits the first one's directory and fails loudly. Single-writer session usage (the normal deployment) never sees this. +- **Grant materialization is an eager full-tree propagation.** `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (NOT lazily per access — measured at tens of seconds on large workspace trees). The per-workspace identity pays it once per workspace per machine (lazily at the first confined execution ever, skipped entirely on every later provision when the exact ACE stands). Private temp directories start empty, so their distinct grant is cheap. If a workspace is huge, the first confined write on this host is correspondingly slow. - **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 for granting unusually wide directories or FAT-class (non-ACL) volumes are not yet implemented, and a FAT volume as a grant ROOT simply fails the grant loudly (no ACL support). A FAT-class target OUTSIDE the granted roots is different: it has no security descriptors, so the restricted token's write check passes (Everyone sits in both lists) and such targets are writable under BOTH confined modes. FAT is treated as a legacy residue — unsupported and not engineered around; this warn-only posture is documented here rather than mitigated. -- **Both confined modes run `pwsh` in ConstrainedLanguage.** The restricted token trips PowerShell's lockdown detection, so under `read-only` AND `workspace-write` the language mode is ConstrainedLanguage: `Add-Type` (C# compile, P/Invoke), non-core .NET static calls (`[System.IO.*]::`, `[math]::`, `[Environment]::`), COM objects, and reflection fail with `Cannot create type` / `Cannot invoke method` ("only core types") errors, and `$ExecutionContext.SessionState.LanguageMode = 'FullLanguage'` is refused. Core cmdlets, core types (`[string]`, `[datetime]`, `[regex]`, `[guid]`), `-f` formatting, and property access keep working. The `pwsh` tool description teaches this contract to the model; `danger-full-access` calls run unconfined at FullLanguage. +- **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` (C# compile, P/Invoke), non-core .NET static calls (`[System.IO.*]::`, `[math]::`, `[Environment]::`), COM objects, and reflection fail with `Cannot create type` / `Cannot invoke method` ("only core types") errors, and `$ExecutionContext.SessionState.LanguageMode = 'FullLanguage'` is refused. Under the shipped `workspace-write` path, the private-temp capability lets that probe complete, so pwsh stays in FullLanguage unless host-wide WDAC/AppLocker policy says otherwise; a direct `AclSandbox` configured with `tempDir: null` has no such guarantee and can fail the probe closed like read-only. This split is PowerShell startup behavior, not part of the ACL write boundary. The `pwsh` tool description teaches the shipped modes to the model; `danger-full-access` calls run unconfined at FullLanguage. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index fe1f22e381..49b5ebbd79 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -4,30 +4,41 @@ 面向 [harness 沙盒 seam](../sandbox/) 的 Windows 写入限制沙盒后端:一个 Node.js/[koffi](https://koffi.dev/) 实现的、对 [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc)(`10e4dfb`,修复后的修订)机制的移植,挂载为 [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) 链中报告 `enforcement: 'partial'` 的 win32 一级(`workspace-write` / `read-only` 两种模式);Linux/macOS 后端在同一包中。 -一句话机制:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 中加入一个写入 SID(`S-1-4-x-y`),该 SID 的 Write ACE 只存在于工作区与会话的私有临时目录上。写入 SID 是**按工作区**的身份,由规范工作区路径确定性派生(`workspaceWriteSid`),因此工作区根目录 ACE 每台机器每个工作区只物化一次——之后每次会话、调用、重启都命中精确 ACE 跳过——而不是每会话一次(见[隔离 runner](#the-confinement-runner))。此后 Windows 只在「调用者正常权限」与「restricting SID 交集」同时允许时才放行写入。写入 SID 是主要写入白名单,在系统其余位置不授予任何权限;但该检查还会继承**其他** restricting SID 的环境写 ACE(保活组登录 SID + Everyone),而 NTFS ACL 属于文件对象而非路径。Everyone 与硬链接边界正是该档报告部分而非完整强制执行的原因。 +一句话机制:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 携带彼此独立的工作区能力与私有临时目录能力。工作区 SID 由规范工作区路径确定性派生(`workspaceWriteSid`),因此工作区根目录 ACE 每台机器每个工作区只物化一次,之后每次会话、调用或重启都命中精确 ACE 跳过。每个活跃的会话/工作区对则获得一个随机临时目录,以及一个从该路径派生的 SID(`tempWriteSid`),因此各会话共享预期的工作区权限,却不会继承彼此的临时目录权限。此后 Windows 只在「调用者正常权限」与「restricting SID 交集」同时允许时才放行写入。这些 SID 是主要白名单,在系统其余位置不授予任何权限;但该检查还会继承**其他** restricting SID 的环境写 ACE(保活组登录 SID + Everyone),而 NTFS ACL 属于文件对象而非路径。Everyone 与硬链接边界正是该档报告部分而非完整强制执行的原因。 直接构建在原生 ACL 机制上是记录在案的设计选择:它实现两种隔离模式,且不背负被否决的容器方案的问题——见[设计笔记](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md)([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) 要求 Windows 11 24H2 的 OS 下限,且任意路径读取需要整体改写宿主 DACL;AppContainer 根本无法任意路径读取)。 ## 用法 ```ts -import { AclSandbox, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { AclSandbox, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' const workspaceRoot = process.cwd() +const tempDir = mkdtempSync(join(tmpdir(), 'dsh-')) // mode selects the token's restricting-SID list (see Modes below) and must -// match the grant shape: read-only pairs with zero grants. workspace-write -// REQUIRES the workspace's write SID — the per-workspace identity. -const sandbox = new AclSandbox({ writableDirs: [workspaceRoot], writeSid: workspaceWriteSid(workspaceRoot), mode: 'workspace-write' }) +// match the grant shape. workspace-write requires distinct workspace and +// private-temp identities; pass tempDir: null to disable temp writes. +const sandbox = new AclSandbox({ + writableDirs: [workspaceRoot], + tempDir, + writeSid: workspaceWriteSid(workspaceRoot), + tempWriteSid: tempWriteSid(tempDir), + mode: 'workspace-write', +}) await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricted 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 +rmSync(tempDir, { recursive: true, force: true }) ``` -直接使用 `AclSandbox` 时,工作区 ACE 以**常驻**方式授予(`dispose()` 保留它们——它们是跨实例的复用缓存),临时 ACE 以**可回收**方式授予(`dispose()` 撤销它,这样可继承 ACE 不会在环境临时根目录上比实例活得更久)。服务端复用则是 `AclWriteGrant` 类:每个目录一次 `add(path, standing)`,`dispose()` 撤销可回收路径并释放 SID——见下方 runner 契约。本包中的每个 Win32 API 调用都有检查;失败抛出 `Win32Error`,携带 API 名、精确 Win32 错误码、`FormatMessageW` 系统文本和失败的路径/上下文。这是刻意的:POC 忽略每个返回值,当 `CreateRestrictedToken` 失败时用完整无限制令牌静默运行子进程(fail-open)。本移植从构造上 fail-closed。 +直接使用 `AclSandbox` 时,必须显式提供私有临时目录(或通过 `tempDir: null` 禁用临时写入;环境临时根目录绝不会被隐式授权),工作区 ACE 以**常驻**方式授予(`dispose()` 保留它们——它们是跨实例的复用缓存),不同的临时 SID 则以**可回收**方式授予。服务端复用则是 `AclWriteGrant` 类:每个目录一次 `add(path, standing)`,`dispose()` 撤销可回收路径并释放 SID——见下方 runner 契约。本包中的每个 Win32 API 调用都有检查;失败抛出 `Win32Error`,携带 API 名、精确 Win32 错误码、`FormatMessageW` 系统文本和失败的路径/上下文。这是刻意的:POC 忽略每个返回值,当 `CreateRestrictedToken` 失败时用完整无限制令牌静默运行子进程(fail-open)。本移植从构造上 fail-closed。 @@ -36,20 +47,20 @@ sandbox.dispose() // revokes the revocable (temp) grant, keeps the standing work 面向 seam 的形态是 **runner 入口**(`./runner`):`@deepseek-ai/dsh-sandbox-local` 在调用者命令的位置 spawn 的 argv 前缀包装——与 bwrap/landlock-run/sandbox-exec 同一架构,因此沙盒 seam 的 `confine()` 契约无需改动。稳定的 argv 契约: ```sh -node runner.js --workspace --temp --mode [--write-sid ] -- +node runner.js --workspace --temp --mode [--write-sid --temp-write-sid ] -- ``` -runner 创建受限令牌,在它之下 spawn 包装后的 argv,调用者的 stdio 直接透传(调用者的管道在 spawn 前后被设为可继承——Node 在启动时清除 stdio 可继承性,裸 spawn 必须补偿这一点),把子进程包进 `KILL_ON_JOB_CLOSE` job(runner 死亡则子进程死亡),忽略自身的控制台 Ctrl+C 让子进程自行处理,镜像子进程的退出码,并在退出时撤销其临时授权(工作区 ACE 常驻)。每个 runner 侧失败都会向 stderr 打印 `windows-acl-run: ` 并以 127 退出——seam 的 `RUNNER_FAILURE_RULES` 匹配该签名,因此 runner 拒绝永远不会被误判为拒绝授权。 +runner 创建受限令牌,在它之下 spawn 包装后的 argv,调用者的 stdio 直接透传(调用者的管道在 spawn 前后被设为可继承——Node 在启动时清除 stdio 可继承性,裸 spawn 必须补偿这一点),把子进程包进 `KILL_ON_JOB_CLOSE` job(runner 死亡则子进程死亡),忽略自身的控制台 Ctrl+C 让子进程自行处理,镜像子进程的退出码,并在退出时撤销其自行管理的临时授权(工作区 ACE 常驻)。每个 runner 侧失败都会向 stderr 打印 `windows-acl-run: ` 并以 127 退出——seam 的 `RUNNER_FAILURE_RULES` 匹配该签名,因此 runner 拒绝永远不会被误判为拒绝授权。 -**按工作区授权复用**(`--write-sid`):写入 SID 从工作区路径**派生**——任何地方都不存储 SID 或临时目录状态(先前每会话随机 SID 及其篡改面已移除)。seam 把工作区 ACE **常驻**物化(每个工作区每服务器生命周期一次,绝不撤销——它就是复用缓存),把临时 ACE **可回收**物化(提供方 dispose 时撤销),两者都在会话首次受限执行时惰性进行。会话的私有临时子目录由会话 id + 工作区**派生**(sha256、16 位 hex)而非存储:恢复的会话派生同一个目录并重新授权(精确 ACE 跳过使这一步保持 O(1)),而 fork 的不同会话 id 会派生出一个全新的目录。该目录以**独占**方式创建——已存在条目或重解析点会让首次受限运行大声失败,因此授权永远不会落到外部对象上——并在提供方 dispose 时再次移除。传入 `--write-sid` 时 runner 既不授权也不回收(`manageDacls: false`)——该标志的存在标记 seam 管理的契约,其值即派生 SID;不传它(独立使用)时 runner 用**同一个**派生 SID 自行管理(工作区 ACE 常驻,临时 ACE 每次调用可回收)。重启后重新授权是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过 `SetNamedSecurityInfoW` 的应用(该应用会把相同的 ACE 急切地重新传播到整棵树——大型工作区上以分钟计)。异常关闭遗留的 ACE 无需垃圾回收——它们**就是**缓存;同一个派生 SID 永远重新命中它们。已知代价:在大型工作区树上物化授权会阻塞整次急切传播,每台机器每个工作区一次(该主机上的第一次受限写入)。 +**工作区复用与临时隔离**:seam 先把确定性工作区 SID 的 ACE **常驻**物化(每个工作区每服务器生命周期一次,绝不撤销——它就是复用缓存),再为每个活跃的会话/工作区对创建随机私有临时目录和不同的可回收 SID。它把两种身份作为必须成对出现的 `--write-sid`/`--temp-write-sid` 传入;runner 对照各自所属路径验证二者,既不授权也不撤销(`manageDacls: false`)。fork 获得不同的临时能力;即使恢复的是同一会话,新的提供方也会给出新的路径和 SID,因此崩溃残留只是失效垃圾,而非冲突或继承的能力。如果不带这一对标志,`--temp` 指定的是根目录:无 agent(智能体)/独立的 workspace-write runner 会创建随机私有子目录,自行管理其临时 SID,重写 TMP/TEMP,并在退出时移除该子目录。重启后重新授权常驻工作区 ACE 是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过 `SetNamedSecurityInfoW`(应用该 ACE 会把相同的 ACE 急切地重新传播到整棵树——大型工作区上以分钟计)。已知代价:大型工作区树的首次授权会阻塞整次急切传播,每台机器每个工作区一次。 模式(令牌的 restricting-SID 列表随模式而变;保活组登录 SID + Everyone 在**两种**模式下都存在——没有它们早期 DLL 初始化会以 `0xC0000142` 死亡、CNG 会让 pwsh 以 `0xE0434352` 崩溃): -- `workspace-write`(登录 SID、Everyone、写入 SID):工作区与会话的**私有**临时子目录携带写入 SID 的 Write 授权;受 ACL 管辖的其他写入都会被拒绝,已记录的 Everyone 与硬链接边界除外。 +- `workspace-write`(登录 SID、Everyone、工作区 SID、临时 SID):工作区与会话的**私有**临时子目录分别携带 Write 授权;受 ACL 管辖的其他写入都会被拒绝,已记录的 Everyone 与硬链接边界除外。 - `read-only`(登录 SID、Everyone——**不含**写入 SID):不存在显式的写入 SID 授权。写入 SID 有意留在列表**之外**:先前 workspace-write 时期留下的常驻授权 ACE(`/permission` 降级,或崩溃后恢复的会话)在 read-only 下保持**失效**,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容——而常驻 ACE 让重新升级免于重新传播。Everyone 的环境权限仍构成已记录的部分强制执行边界。NUL 写入是**环境性**的、不是被授权的:设备 DACL 授予 Everyone 读+写+执行(`0x1201BF`),因此访问掩码落在其内的打开者(cmd 的 `> NUL`、node 的 `\\.\NUL`)在**两种**模式下都能写——只要 Everyone 还在保活组里,沙盒就无法把 NUL 设备归零。`Set-Content NUL` 在两种模式下都失败(PowerShell/.NET 层效应,由 read-only 套件钉住——拒绝方不是设备 DACL);PowerShell 的 `> $null` 重定向不受影响(它直接丢弃、不打开 NUL)。 Authenticated Users 在**两种**列表中都不存在——WMI 命名空间安全检查失败(`0x80041003`),因此 CIM cmdlet 与 `Get-ComputerInfo`(它静默返回不完整结果而非报错)在**所有**受限模式下都不可用,且 C:\-root 树创建逃逸(常驻的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭——面向模型的表面记录的是该契约,而不是提示词承诺。INTERACTIVE/LOCAL 在两种列表中同样不存在:宿主的 Public 树向 INTERACTIVE 授予写权限,因此 Public 写入被拒绝——由 runner 的环境可写 Public 探针回归测试钉住(见设计笔记)。 -`AclSandbox` 类(`tempDir: null` 禁用临时授权)仍是直接 spawn 的编程 API;`AclWriteGrant` 是授权生命周期的服务端物化一半。 +`AclSandbox` 类(显式私有 `tempDir` + `tempWriteSid`,或用 `tempDir: null` 禁用临时写入)仍是直接 spawn 的编程 API;`AclWriteGrant` 是授权生命周期的服务端物化一半。 ## 头部验证 @@ -69,8 +80,8 @@ koffi 结构体定义在模块加载时对照探针断言其大小,因此头 - **控制台隔离不可用。** 在受限令牌下,以 `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` 创建的子进程在 DLL 初始化期间以 `STATUS_DLL_INIT_FAILED`(`0xC0000142`)死亡。POC 尝试把控制台登录 SID(`S-1-2-1`)加入 restricting 列表来修复;在 Windows 11 26200 上 `CreateWellKnownSid(WinLocalLogonSid)` 以 `ERROR_INVALID_PARAMETER`(87)失败,正确的 `WinConsoleLogonSid` 能产出合法 `S-1-2-1` 但子进程仍然死亡,POC 的最终修订同时移除了该 SID 与控制台隔离。子进程因此共享宿主控制台;stdio 重定向走管道,不受影响。 - **ACL 授权是对真实目录的驻留改动。** 进程中途死亡会留下授权;工作区 ACE **按设计**常驻(绝不撤销——复用缓存),临时 ACE 由 `dispose()` 撤销(后续步骤失败时 `init()` 也会撤销已应用的临时授权)。POC 注释里的手工清理命令(`icacls /remove '*S-1-4-…'`)在本平台实测失败(`ERROR_NONE_MAPPED` 1332)——请通过本模块回收。工作区 ACE 在异常关闭后无需自愈:派生 SID 在下一次供给时重新命中常驻 ACE(跳过应用);写入 SID ACE 不会因每次重启而累积第二个身份,因为身份**就是**工作区。 - **被授权目录必须由调用者拥有。** 所有者的隐式 `WRITE_DAC` 是沙盒无需提权即可编辑 DACL 的原因。 -- **临时授权跟随 `GetTempPathW`**——尽可能显式传 `tempDir`。`GetTempPathW` 读取**原生**环境块,而通过 worker 池管理 `process.env` 的宿主运行时可能没有与之保持同步(vitest 实测:worker 侧的 `process.env.TMP` 变更从未到达原生块)。seam 传入会话的**私有**子目录(`\dsh-<16 hex>`,由会话 id + 工作区派生、独占创建——已存在条目或重解析点会大声失败);默认授权落在真实临时目录上会让 `(OI)(CI)` 继承到临时目录的每个子目录,静默扩大白名单——请改指向每个沙盒的目录。 -- **受限子进程的临时根目录按会话私有**(workspace-write + `--write-sid`):runner 在 spawn 之前用 `SetEnvironmentVariableW` 把 TMP/TEMP 改写为会话的私有子目录,子进程继承改写后的环境块(bwrap `--tmpfs /tmp` 的语义)。read-only 保持环境中的临时目录条目不动——那里的写入反正会被拒绝。子目录在提供方 dispose 时移除;崩溃后它可能作为普通 `%TEMP%` 垃圾存活,直到 OS 的临时目录卫生(或手动删除)将其回收——之后的恢复会在独占创建处大声失败。 +- **环境临时根目录绝不会被隐式授权。** 直接使用 `AclSandbox` 的 workspace-write 调用方必须提供一个已存在的私有 `tempDir` 及其不同的 `tempWriteSid`,或通过 `tempDir: null` 显式禁用临时写入。seam 会创建随机私有目录;无 agent runner 调用把 `--temp` 视为父根目录,并自行创建随机子目录。 +- **受限子进程的临时能力按每个活跃的会话/工作区对私有。** runner 在 spawn 之前用 `SetEnvironmentVariableW` 把 TMP/TEMP 改写为该私有目录,子进程继承改写后的环境块(bwrap `--tmpfs /tmp` 的语义)。临时 ACE 与目录会在提供方 dispose 时移除,或在每次无 agent 调用后移除。崩溃可能留下失效的 `%TEMP%` 垃圾,但恢复后的提供方会选择新的随机路径和 SID,而不会与残留发生冲突或重新向其授权。原生 runner 套件证明,共享同一工作区 SID 的两个令牌无法写入彼此的临时目录。 - **受限令牌下 `whoami` 与令牌检查 cmdlet 会失败。** 子进程对复制令牌的 `GetTokenInformation` 部分不可用,因此 `whoami /all` 报错——这是限制方案的诊断噪音,不是运行故障;真正重要的拒绝面(文件写入)不受影响。 ## Model Experience @@ -84,12 +95,11 @@ koffi 结构体定义在模块加载时对照探针断言其大小,因此头 ## Known Limitations and Deferred Work - **每个工作区一个写入白名单** —— 写入 SID 是白名单的基本单位,且**就是**工作区身份;同一沙盒实例跨两个工作区复用时,两个根目录会互相扩大授权面(同一个 SID 将命名两个根)。请按工作区根目录各建一个实例——seam 正是这样做的,以工作区路径为键。 -- **清理尽力而为** —— `dispose()` 会尝试全部临时撤销并把失败聚合为 `AggregateError`;清理失败只会留下仅含写入 SID 的临时 ACE,本进程下次 `init()`/`dispose()` 循环或 `icacls`(按 ACE 而非受托者名)仍可清除。 +- **清理按设计尽力而为** —— `dispose()` 会尝试全部临时撤销并把失败聚合为 `AggregateError`;清理失败可能留下随机目录及其仅含临时 SID 的 ACE。进程退出后,不会再有令牌携带该 SID,因此残留保持失效,直到 OS 临时目录卫生或手动移除目录将其回收。 - **常驻工作区 ACE 是不可见残留。** 工作区改名会派生新的 SID;旧路径上的旧 ACE 留在原地(失效、仅含写入 SID)。未来的清理命令可以回收它们;它们不会引起任何重新传播。 - **NULL-DACL 目录在 grant+revoke 往返下不保持身份。** 带 NULL DACL 的目录(罕见——Windows 创建的目录都带真实 DACL)意味着「所有人完全控制」;`grantWrite` 从该 null 构建新 ACL,撤销往返后留下的是 EMPTY(全部拒绝)DACL 而非原始 NULL DACL。POC 行为相同;真实工作区与临时目录都带真实 DACL,因此这仍是记录在案的边界情形而非守护路径。 - **受限孙进程的管道 stdio 捕获不可用(named pipe 的默认 SD 模板)。** libuv 的管道 stdio 用的是 NAMED pipe;不带安全属性调用 `CreateNamedPipeW` 时,其默认安全描述符不是内核的模板,而是 Win32 层在用户态安装的默认 SD 模板(由 KernelBase 构建——owner/SYSTEM/Admins 全权,Everyone/ANONYMOUS 只读,即 [MS 文档](https://learn.microsoft.com/en-us/windows/win32/ipc/named-pipe-security-and-access-rights)记载的固定模板)——**不是**令牌默认 DACL(后者才是内核在原始 SD-null 创建时应用的)——因此 client 端打开所请求的写访问没有任何 restricting SID 被授予:受限进程内 `spawn(..., { stdio: 'pipe' })` 以 EPERM 失败,这是 POC 记载的 WRITE_RESTRICTED「无法重定向输出」边界。继承(`inherit`/fd)与忽略(`ignore`)stdio 的 spawn 可用;匿名管道(CreatePipe——令牌默认 DACL 的消费者,例如 PowerShell 的管道)因受限令牌默认 DACL 携带 restricting SID 全权 ACE(init 时写入)而可用。受限进程因此无法用管道捕获孙进程输出;必须捕获输出的工具无法在受限下运行。 -- **授权物化是急切的全树传播。** 在带可继承 ACE 的目录上调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(**不是**按访问惰性进行——大型工作区树上实测数十秒,加上真实临时根目录)。按工作区身份每台机器每个工作区只付一次(在首次受限执行时惰性进行,之后每次供给在精确 ACE 常驻时完全跳过)。如果工作区巨大,该主机上的第一次受限写入相应变慢。 -- **两个服务器进程并发恢复同一会话时,第二个会在其首次受限写入处失败。** 两个进程派生同一个私有临时目录;第二个的独占创建撞上第一个的目录并大声失败。单写者会话用法(常规部署)永远不会遇到。 +- **授权物化是急切的全树传播。** 在带可继承 ACE 的目录上调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(**不是**按访问惰性进行——大型工作区树上实测数十秒)。按工作区身份每台机器每个工作区只付一次(在首次受限执行时惰性进行,之后每次供给在精确 ACE 常驻时完全跳过)。私有临时目录创建时为空,因此其独立授权开销很小。如果工作区巨大,该主机上的第一次受限写入相应变慢。 - **读侧隔离与网络策略不在范围内** —— `WRITE_RESTRICTED` 只交叉检查写访问;将此后端与读侧策略配对以获得更强隔离。 - **宽目录与 FAT 卷警告已推迟;FAT 类目标保持可写。** 对异常宽的目录或 FAT 类(非 ACL)卷的 UI 侧警告尚未实现,且 FAT 卷作为授权**根**只会大声失败(无 ACL 支持)。授权根**之外**的 FAT 类目标则不同:它没有安全描述符,因此受限令牌的写检查通过(Everyone 在两种列表中都在)——此类目标在**两种**受限模式下都可写。FAT 被视为遗留残留——不受支持、不围绕它设计;此处记录的是这种仅警告的立场,而非缓解措施。 -- **两种受限模式都运行 ConstrainedLanguage 的 `pwsh`。** 受限令牌会触发 PowerShell 的锁定检测,因此在 `read-only` **和** `workspace-write` 下语言模式都是 ConstrainedLanguage:`Add-Type`(C# 编译、P/Invoke)、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`、`[Environment]::`)、COM 对象与反射以 `Cannot create type` / `Cannot invoke method`(「only core types」)错误失败,且 `$ExecutionContext.SessionState.LanguageMode = 'FullLanguage'` 被拒绝。核心 cmdlet、核心类型(`[string]`、`[datetime]`、`[regex]`、`[guid]`)、`-f` 格式化与属性访问保持可用。`pwsh` 工具描述向模型传授该契约;`danger-full-access` 调用不受限地在 FullLanguage 下运行。 +- **PowerShell 语言模式因受限模式而异。** 在 `read-only` 下,PowerShell 无法在临时目录中创建 AppLocker 探针文件,因此会保守地以 ConstrainedLanguage 启动:`Add-Type`(C# 编译、P/Invoke)、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`、`[Environment]::`)、COM 对象与反射以 `Cannot create type` / `Cannot invoke method`(「only core types」)错误失败,且 `$ExecutionContext.SessionState.LanguageMode = 'FullLanguage'` 被拒绝。交付的 `workspace-write` 路径拥有私有临时目录能力,可使该探针完成,因此除非主机范围的 WDAC/AppLocker 策略另有规定,否则 pwsh 保持 FullLanguage;直接使用 `AclSandbox` 并配置 `tempDir: null` 时则没有这一保证,探针可能像 read-only 一样失败并按 fail-closed 处理。这一区别属于 PowerShell 启动行为,不是 ACL 写入边界的一部分。`pwsh` 工具描述向模型传授这些交付模式;`danger-full-access` 调用不受限地在 FullLanguage 下运行。 diff --git a/packages/sandbox/sandbox-windows-acl/src/grant.ts b/packages/sandbox/sandbox-windows-acl/src/grant.ts index 7cfd6e1a36..eed5f0c828 100644 --- a/packages/sandbox/sandbox-windows-acl/src/grant.ts +++ b/packages/sandbox/sandbox-windows-acl/src/grant.ts @@ -1,12 +1,9 @@ /** - * Server-side per-session write grant: the ACE materialization half of the - * sandbox seam's per-session grant reuse. The seam (sandbox-local) holds ONE - * {@link AclWriteGrant} per session for the server process's lifetime — - * created lazily at the session's first confined execution, reused (never - * re-applied) for every later call, revoked on provider dispose. The durable - * half (the session's SID and paths surviving a restart) lives in the - * session log, owned by the seam; this module owns only the native half: the - * parsed SID pointer and the standing ACEs. + * Server-side write-grant materialization. The sandbox seam holds one + * standing workspace grant per workspace and one revocable temp grant per + * live session/workspace pair. Workspace identities survive by deterministic + * derivation and their standing ACE; temp identities derive from random + * private paths and are deliberately new after a restart. * * Fail-closed: `add` throws on any grant failure and the caller disposes the * instance (revoking every path granted so far); `dispose` revokes every @@ -19,7 +16,7 @@ import { allocPtrSlot, decodePtr, isNullPtr, throwLastError, win32Sync } from '. import type { NativePtr, Win32Bindings } from './ffi.ts' /** - * One write SID's server-lifetime grant materialization: the parsed SID + * 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 diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 7f65cf664e..873c9feada 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -2,10 +2,10 @@ * Windows ACL write-restriction sandbox backend for the DeepSeek Harness * sandbox seam. Mirrors the mechanism of github.com/huoyaoyuan/ * windows-acl-restrict-poc @ 10e4dfb (the fixed revision): a WRITE_RESTRICTED - * token whose restricting SIDs include a write SID (`S-1-4-x-y`) that only - * this sandbox adds to the target directories' DACLs — the intersection - * check then allows writes exactly where that SID has a Write ACE, and - * nowhere else the write SID is concerned (the token's write check ALSO + * token whose restricting SIDs include distinct workspace and temp write + * SIDs that this sandbox adds to their owning directories' DACLs — the + * intersection check then allows writes exactly where either capability has + * a Write ACE, and nowhere else those SIDs are concerned (the check ALSO * inherits the ambient write ACEs of the other restricting SIDs — the * keep-alive group logon SID + Everyone; Authenticated Users, INTERACTIVE, * and LOCAL are absent from both lists — see the seam's dual-list contract @@ -15,7 +15,9 @@ * path, so the workspace-root ACE materializes once per workspace per * machine and every later provision hits the exact-ACE skip — the * grant-reuse story the per-session random SID paid a full tree propagation - * per session for. Unlike the POC, every API failure throws with the API + * per session for. Each private temp directory instead receives its own SID, + * so sibling sessions sharing a workspace cannot enter one another's temp + * trees. Unlike the POC, every API failure throws with the API * name and exact Win32 code; a child is NEVER spawned unrestricted. * * Known boundaries (inherent to restricted tokens, not this port): @@ -24,15 +26,14 @@ * - console isolation is unavailable — children share the host console * (CREATE_NO_WINDOW / CREATE_NEW_CONSOLE children die with * STATUS_DLL_INIT_FAILED under the restriction); - * - the temp directory and every writable directory must be owned by the + * - the private temp directory and every writable directory must be owned by the * caller (owner-implicit WRITE_DAC); * - grants are standing ACE mutations on real directories. WORKSPACE grants * are deliberately never revoked — the ACE is the cross-session reuse * cache (revoking would force the next session to re-propagate the whole * tree). TEMP grants are revocable: dispose() removes them so a standing - * inheritable ACE never outlives its session's temp directory (an - * inheritable ACE on the ambient temp root would otherwise widen the - * SID's write reach to every future temp file). With `manageDacls: false` + * inheritable ACE never outlives its session's temp directory. The + * ambient temp root is never granted implicitly. With `manageDacls: false` * the CALLER owns the DACLs (the sandbox seam's grant reuse): * init()/dispose() skip grant/revoke entirely and the caller must not * revoke under live children. @@ -44,7 +45,7 @@ import { resolve } from 'node:path' import { grantWrite, revokeWrite } from './acl.ts' import { Win32Error } from './errors.ts' -import { allocPtrSlot, decodePtr, getTempPath, isNullPtr, throwLastError, win32 } from './ffi.ts' +import { allocPtrSlot, decodePtr, isNullPtr, throwLastError, win32 } from './ffi.ts' import type { NativePtr, Win32Bindings } from './ffi.ts' import { drainPipe, spawnSandboxed, spawnSandboxedInherited, waitForExit } from './spawn.ts' import { createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken, setTokenDefaultDaclGrant } from './token.ts' @@ -52,18 +53,17 @@ import * as abi from './win32-abi.ts' export { quoteArg } from './spawn.ts' export { AclWriteGrant } from './grant.ts' -export { workspaceWriteSid } from './workspace-sid.ts' +export { tempWriteSid, workspaceWriteSid } from './workspace-sid.ts' export { Win32Error } from './errors.ts' -/** Construction options: the write allowlist, the optional temp grant, and the orphan SID identity. */ +/** Construction options: the workspace/temp allowlists and their distinct SID identities. */ export interface AclSandboxOptions { /** Directories the confined child may write into (must exist and be caller-owned). */ writableDirs: readonly string[] /** - * Temp directory to also grant; defaults to GetTempPathW() at init time. - * Pass null for read-only confinement: NO explicit temp grant. Ambient - * Everyone authority remains part of the backend's documented partial - * boundary — see README. + * Existing private temp directory to grant. Workspace-write callers must + * pass it explicitly or pass null to disable temp writes; the ambient temp + * root is never an implicit grant. Read-only accepts only null/undefined. */ tempDir?: string | null /** @@ -74,6 +74,13 @@ export interface AclSandboxOptions { * outlives every instance and later provisions hit the exact-ACE skip. */ writeSid?: string + /** + * The private temp directory's write SID. Required whenever + * workspace-write grants a temp directory, absent otherwise. It must be + * distinct from {@link writeSid}, so sibling sessions sharing a workspace + * cannot use the standing workspace capability in one another's temp tree. + */ + tempWriteSid?: string /** * The file-effect mode this instance confines under — selects the * restricted token's restricting-SID list (I for read-only, J for @@ -85,7 +92,7 @@ export interface AclSandboxOptions { /** * Whether this instance owns its DACL grants (default true). False means * the CALLER has already materialized the ACEs (the sandbox seam's - * per-session grant reuse): init()/dispose() skip grant/revoke entirely — + * workspace/temp capability lifecycle): init()/dispose() skip grant/revoke entirely — * the caller holds the grants for its own lifetime and revokes them. */ manageDacls?: boolean @@ -135,8 +142,10 @@ export interface AclSandboxChild { export class AclSandbox { /** Absolute writable directories (constructor-validated). */ readonly writableDirs: string[] - /** The write SID string whose ACEs form the write allowlist (workspace-write only). */ + /** The workspace SID string whose ACEs form the workspace allowlist. */ readonly writeSid: string | undefined + /** The private temp directory's write SID (workspace-write with temp only). */ + readonly tempWriteSid: string | undefined /** The file-effect mode — the restricted token's restricting-SID list selection. */ readonly mode: 'read-only' | 'workspace-write' private readonly tempDirOption: string | null | undefined @@ -145,9 +154,10 @@ export class AclSandbox { private api: Win32Bindings | undefined private token: NativePtr | undefined private writeSidPtr: NativePtr | undefined - /** The well-known/logon SID allocations init() makes; freed by dispose() alongside the write SID. */ + private tempWriteSidPtr: NativePtr | undefined + /** The well-known/logon SID allocations init() makes; freed by dispose() alongside the write SIDs. */ private sidAllocations: NativePtr[] = [] - private grantedPaths: string[] = [] + private grantedPaths: Array<{ path: string; sidPtr: NativePtr }> = [] constructor(options: AclSandboxOptions) { this.mode = options.mode @@ -161,9 +171,28 @@ export class AclSandbox { }) this.tempDirOption = options.tempDir this.writeSid = options.writeSid + this.tempWriteSid = options.tempWriteSid if (this.mode === 'workspace-write' && this.writeSid === undefined) { throw new Error('AclSandbox workspace-write requires a write SID — derive it from the workspace via workspaceWriteSid()') } + if (this.mode === 'workspace-write' && this.tempDirOption === undefined) { + throw new Error('AclSandbox workspace-write requires an explicit private temp directory or null') + } + if (this.mode === 'read-only' && this.tempDirOption !== undefined && this.tempDirOption !== null) { + throw new Error('AclSandbox read-only does not accept a temp directory') + } + if (this.mode === 'read-only' && (this.writeSid !== undefined || this.tempWriteSid !== undefined)) { + throw new Error('AclSandbox read-only does not accept write SIDs') + } + if (this.mode === 'workspace-write' && this.tempDirOption !== null && this.tempWriteSid === undefined) { + throw new Error('AclSandbox workspace-write with temp requires a temp write SID — derive it via tempWriteSid()') + } + if (this.tempDirOption === null && this.tempWriteSid !== undefined) { + throw new Error('AclSandbox temp write SID requires a temp directory') + } + if (this.writeSid !== undefined && this.tempWriteSid === this.writeSid) { + throw new Error('AclSandbox workspace and temp write SIDs must be distinct') + } } /** Resolved temp directory (available after init; null when temp grants are disabled). */ @@ -171,56 +200,53 @@ export class AclSandbox { return this.tempDirResolved } - /** Create the restricted token and apply the orphan-SID grants. Idempotent-unsafe: once per instance. */ + /** Create the restricted token and apply the capability-SID grants. Idempotent-unsafe: once per instance. */ async init(): Promise { if (this.api !== undefined) throw new Error('AclSandbox is already initialized') const api = await win32() - const currentToken = openCurrentProcessToken(api) + let currentTokenOpen = true + let restrictedToken: NativePtr | undefined try { - // Read-only runs carry no write SID (its restricting list has no - // orphan): nothing to parse, nothing to grant. - let writeSidPtr: NativePtr | undefined - if (this.writeSid !== undefined) { + const parseSid = (sid: string): NativePtr => { const sidSlot = allocPtrSlot() - if (api.convertStringSidToSidW(this.writeSid, sidSlot) === 0) { - throwLastError(api, 'ConvertStringSidToSidW', this.writeSid) + if (api.convertStringSidToSidW(sid, sidSlot) === 0) { + throwLastError(api, 'ConvertStringSidToSidW', sid) } const parsedSid = decodePtr(sidSlot) - if (parsedSid === null) throw new Win32Error('ConvertStringSidToSidW', api.getLastError(), this.writeSid) - this.writeSidPtr = parsedSid - writeSidPtr = parsedSid + if (parsedSid === null) throw new Win32Error('ConvertStringSidToSidW', api.getLastError(), sid) + return parsedSid } + this.writeSidPtr = this.writeSid === undefined ? undefined : parseSid(this.writeSid) + this.tempWriteSidPtr = this.tempWriteSid === undefined ? undefined : parseSid(this.tempWriteSid) - const tempDir = this.tempDirOption === null - ? null - : this.tempDirOption !== undefined ? this.tempDirOption : getTempPath(api) + const tempDir = this.mode === 'read-only' || this.tempDirOption === null ? null : this.tempDirOption + if (tempDir === undefined) throw new Error('AclSandbox workspace-write temp directory was not resolved') if (tempDir !== null) { if (!existsSync(tempDir) || !statSync(tempDir).isDirectory()) { throw new Error(`AclSandbox temp dir does not exist or is not a directory: ${tempDir}`) } - this.tempDirResolved = tempDir } + this.tempDirResolved = tempDir // manageDacls: false — the caller (the sandbox seam's grant) already // materialized the ACEs; this instance must neither add nor remove any. // When this instance owns the DACLs, writableDir ACEs are STANDING (the // per-workspace reuse cache — dispose() never revokes them, or the next // provision would re-propagate the whole tree) and the temp ACE is - // REVOCABLE (dispose() removes it — an inheritable ACE on the ambient - // temp root must not outlive the instance, or it would widen the SID's - // write reach to every future temp file). + // REVOCABLE (dispose() removes it before the private directory is + // deleted; the ambient temp root is never granted). if (this.manageDacls) { - if (writeSidPtr !== undefined) { + if (this.writeSidPtr !== undefined) { for (const path of this.writableDirs) { - grantWrite(api, path, writeSidPtr) + grantWrite(api, path, this.writeSidPtr) } - if (tempDir !== null) { + 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(tempDir) - grantWrite(api, tempDir, writeSidPtr) + this.grantedPaths.push({ path: tempDir, sidPtr: this.tempWriteSidPtr }) + grantWrite(api, tempDir, this.tempWriteSidPtr) } } } @@ -228,40 +254,55 @@ export class AclSandbox { this.sidAllocations.push(logonSid) const worldSid = makeWellKnownSid(api, abi.WinWorldSid) this.sidAllocations.push(worldSid) - const restricted = createRestrictedToken( - api, currentToken, logonSid, writeSidPtr, + const writeSids = [this.writeSidPtr, this.tempWriteSidPtr].filter((sid): sid is NativePtr => sid !== undefined) + restrictedToken = createRestrictedToken( + api, currentToken, logonSid, writeSids, { world: worldSid }, this.mode, ) + 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 // confined process creates (anonymous stdio pipes, sync objects) takes // its DACL from that default, so the write pass-2 check would deny // pipe creation (ERROR_ACCESS_DENIED; Node EPERM) and break every // piped-stdio grandchild spawn. Merge a full-access ACE for a - // restricting SID (the write SID under workspace-write, Everyone under - // read-only): new-object creation stays gated by the parent object's - // DACL, while the new object's own DACL passes pass-2. - setTokenDefaultDaclGrant(api, restricted, writeSidPtr ?? worldSid) - this.token = restricted + // restricting SID (the PRIVATE temp SID when present, otherwise the + // workspace SID, or Everyone under read-only): new-object creation + // stays gated by the parent object's DACL, while the new object's own + // DACL passes pass-2. Choosing the temp SID prevents default-DACL + // objects in one session's temp tree from acquiring the shared + // workspace capability. + setTokenDefaultDaclGrant(api, restrictedToken, this.tempWriteSidPtr ?? this.writeSidPtr ?? worldSid) if (api.closeHandle(currentToken) === 0) throwLastError(api, 'CloseHandle', 'current process token') + currentTokenOpen = false this.api = api } catch (error) { - // Best-effort close on the failure path (last error already captured in `error`). - api.closeHandle(currentToken) // Fail-closed cleanup: never leave a revocable (temp) grant or SID // allocation behind a failed init. Standing workspace ACEs are NOT // revoked — they are the intended end state (the reuse cache), not an // error artifact. const cleanupFailures: unknown[] = [] - const writeSidPtr = this.writeSidPtr - if (writeSidPtr !== undefined) { - for (const path of this.grantedPaths) { - try { - revokeWrite(api, path, writeSidPtr) - } catch (cleanupError) { - cleanupFailures.push(cleanupError) - } + if (currentTokenOpen && api.closeHandle(currentToken) === 0) { + cleanupFailures.push(new Win32Error('CloseHandle', api.getLastError(), 'current process token after init failure')) + } + if (restrictedToken !== undefined && api.closeHandle(restrictedToken) === 0) { + cleanupFailures.push(new Win32Error('CloseHandle', api.getLastError(), 'restricted token after init failure')) + } + for (const grant of this.grantedPaths) { + try { + revokeWrite(api, grant.path, grant.sidPtr) + } catch (cleanupError) { + cleanupFailures.push(cleanupError) + } + } + for (const [label, sidPtr] of [['workspace write SID', this.writeSidPtr], ['temp write SID', this.tempWriteSidPtr]] as const) { + if (sidPtr === undefined) continue + try { + const freed = api.localFree(sidPtr) + if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', label) + } catch (cleanupError) { + cleanupFailures.push(cleanupError) } } for (const sidPtr of this.sidAllocations.splice(0)) { @@ -272,10 +313,14 @@ export class AclSandbox { cleanupFailures.push(cleanupError) } } + this.token = undefined + this.writeSidPtr = undefined + this.tempWriteSidPtr = undefined + this.grantedPaths = [] if (cleanupFailures.length > 0) { throw new AggregateError( [error, ...cleanupFailures], - `AclSandbox init failed and ${cleanupFailures.length} grant revocation(s) also failed`, + `AclSandbox init failed and ${cleanupFailures.length} cleanup operation(s) also failed`, ) } throw error @@ -341,20 +386,20 @@ export class AclSandbox { const api = this.api if (api === undefined) return const failures: unknown[] = [] - const writeSidPtr = this.writeSidPtr - if (writeSidPtr !== undefined) { - if (this.manageDacls) { - for (const path of this.grantedPaths) { - try { - revokeWrite(api, path, writeSidPtr) - } catch (error) { - failures.push(error) - } + if (this.manageDacls) { + for (const grant of this.grantedPaths) { + try { + revokeWrite(api, grant.path, grant.sidPtr) + } catch (error) { + failures.push(error) } } + } + for (const [label, sidPtr] of [['workspace write SID', this.writeSidPtr], ['temp write SID', this.tempWriteSidPtr]] as const) { + if (sidPtr === undefined) continue try { - const freed = api.localFree(writeSidPtr) - if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', 'write SID') + const freed = api.localFree(sidPtr) + if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', label) } catch (error) { failures.push(error) } @@ -378,6 +423,7 @@ export class AclSandbox { this.api = undefined this.token = undefined this.writeSidPtr = undefined + this.tempWriteSidPtr = undefined this.grantedPaths = [] if (failures.length > 0) { throw new AggregateError(failures, `AclSandbox dispose completed with ${failures.length} cleanup failure(s)`) diff --git a/packages/sandbox/sandbox-windows-acl/src/runner.ts b/packages/sandbox/sandbox-windows-acl/src/runner.ts index 819884c4ac..1a63a18545 100644 --- a/packages/sandbox/sandbox-windows-acl/src/runner.ts +++ b/packages/sandbox/sandbox-windows-acl/src/runner.ts @@ -10,33 +10,29 @@ * keep the same contract): * [node, runner.js, '--workspace', , '--temp', , * '--mode', , - * ['--write-sid', ], '--', ] + * ['--write-sid', , + * '--temp-write-sid', ], '--', ] * * Modes: - * - workspace-write: the workspace and temp directories carry the orphan-SID - * Write grant; other ACL-addressable writes are denied except for the - * documented Everyone and hard-link boundaries. + * - workspace-write: the workspace and temp directories carry distinct + * capability-SID Write grants; other ACL-addressable writes are denied + * except for the documented Everyone and hard-link boundaries. * - read-only: no orphan-SID grants; the restricting list carries no orphan * SID, so a standing grant ACE from an earlier * workspace-write period stays inert. BOTH modes drop Authenticated Users * (CIM unavailable — documented in README) and INTERACTIVE/LOCAL (the * Public tree writes are denied); the two lists share the keep-alive group - * (logon SID, EVERYONE) and differ only by the orphan. + * (logon SID, EVERYONE) and differ only by the capabilities. * - * `--write-sid`: the seam's grant contract — the CALLER has already - * materialized the write-SID ACEs (the seam's workspace + private-temp - * grants, server lifetime) and owns their revocation, so the runner neither - * grants nor revokes (manageDacls: false). The carried SID is the - * per-workspace identity ({@link workspaceWriteSid}) — the seam derives it - * from the policy root; the flag's PRESENCE is the seam-managed marker (its - * value must equal the workspace-derived SID). Absent `--write-sid` - * (standalone/test use) the runner self-manages grants per invocation with - * the same workspace-derived SID (its workspace ACEs are standing — the - * reuse cache — and its temp ACE is revoked on exit). With `--write-sid` in - * workspace-write mode, the runner rewrites the TMP/TEMP entries of its OWN - * environment (SetEnvironmentVariableW) to the `--temp` directory — a - * PRIVATE per-session temp subdirectory the seam provisions (bwrap `--tmpfs - * /tmp` semantics) — and the child inherits the rewritten block (lpEnvironment + * `--write-sid` + `--temp-write-sid`: the seam's grant contract — the + * CALLER has already materialized distinct workspace and private-temp ACEs + * and owns their revocation, so the runner neither grants nor revokes + * (`manageDacls: false`). Both values are checked against their owning paths. + * Without the pair (standalone/agentless use), workspace-write treats + * `--temp` as a ROOT, creates a random private child directory, derives its + * own temp SID, and removes that directory after the child exits. In both + * flows the runner rewrites TMP/TEMP in its OWN environment to the private + * directory before spawning; the child inherits that block (`lpEnvironment` * NULL; an explicit block through koffi trips ERROR_INVALID_PARAMETER in * CreateProcessAsUserW, verified empirically). Read-only leaves the ambient * temp entries untouched (writes there are denied anyway). @@ -48,11 +44,12 @@ * @module @deepseek-ai/dsh-sandbox-windows-acl/runner */ -import { existsSync, statSync } from 'node:fs' +import { existsSync, mkdtempSync, rmSync, statSync } from 'node:fs' +import { join } from 'node:path' import { win32 } from './ffi.ts' import { AclSandbox } from './index.ts' -import { workspaceWriteSid } from './workspace-sid.ts' +import { tempWriteSid, workspaceWriteSid } from './workspace-sid.ts' const RUNNER_SIGNATURE = 'windows-acl-run' const RUNNER_FAILURE_EXIT = 127 @@ -70,6 +67,7 @@ interface ParsedArgs { temp: string mode: 'read-only' | 'workspace-write' writeSid: string | undefined + tempWriteSid: string | undefined command: string args: string[] } @@ -79,6 +77,7 @@ function parseArgs(raw: string[]): ParsedArgs { let temp: string | undefined let mode: string | undefined let writeSid: string | undefined + let parsedTempWriteSid: string | undefined let index = 0 for (; index < raw.length; index++) { const token = raw[index] @@ -94,6 +93,7 @@ function parseArgs(raw: string[]): ParsedArgs { case '--temp': temp = value; break case '--mode': mode = value; break case '--write-sid': writeSid = value; break + case '--temp-write-sid': parsedTempWriteSid = value; break default: fail(`unknown argument: ${token}`) } } @@ -103,7 +103,7 @@ function parseArgs(raw: string[]): ParsedArgs { const argv = raw.slice(index) const command = argv[0] if (command === undefined) fail('missing command after --') - return { workspace, temp, mode, writeSid, command, args: argv.slice(1) } + return { workspace, temp, mode, writeSid, tempWriteSid: parsedTempWriteSid, command, args: argv.slice(1) } } function requireDirectory(label: string, path: string): void { @@ -119,6 +119,14 @@ async function main(): Promise { requireDirectory('--workspace', parsed.workspace) requireDirectory('--temp', parsed.temp) + const seamManaged = parsed.writeSid !== undefined || parsed.tempWriteSid !== undefined + if (parsed.mode === 'read-only' && seamManaged) { + fail('read-only does not accept --write-sid or --temp-write-sid') + } + if (parsed.mode === 'workspace-write' && (parsed.writeSid === undefined) !== (parsed.tempWriteSid === undefined)) { + fail('workspace-write requires --write-sid and --temp-write-sid together') + } + const api = await win32() // Ignore this process's own CTRL+C: the confined child (same console) keeps // handling its own; the runner must survive to revoke grants and mirror the @@ -127,36 +135,46 @@ async function main(): Promise { fail(`SetConsoleCtrlHandler failed (Win32 ${api.getLastError()})`) } - // The write SID is the per-workspace identity in BOTH flows; the flag's - // presence (seam-derived, or the self-managed derivation) selects who - // owns the DACLs below. - const writeSid = parsed.mode === 'workspace-write' ? parsed.writeSid ?? workspaceWriteSid(parsed.workspace) : undefined - const sandbox = new AclSandbox({ - writableDirs: parsed.mode === 'workspace-write' ? [parsed.workspace] : [], - tempDir: parsed.mode === 'workspace-write' ? parsed.temp : null, - mode: parsed.mode, - ...writeSid === undefined ? {} : { writeSid }, - // With --write-sid the seam owns the DACLs (workspace + private-temp - // grants): this invocation must neither add nor revoke ACEs. - manageDacls: parsed.writeSid === undefined, - }) - await sandbox.init() - - // The seam's per-session temp contract: under --write-sid, workspace-write - // children see the PRIVATE per-session temp subdirectory through TMP/TEMP - // (bwrap --tmpfs /tmp semantics). The runner rewrites its OWN environment - // (SetEnvironmentVariableW) and the child inherits the block; self-managed - // and read-only runs keep the ambient entries. - if (parsed.mode === 'workspace-write' && parsed.writeSid !== undefined) { - if (api.setEnvironmentVariableW('TMP', parsed.temp) === 0) { - fail(`SetEnvironmentVariableW TMP failed (Win32 ${api.getLastError()})`) - } - if (api.setEnvironmentVariableW('TEMP', parsed.temp) === 0) { - fail(`SetEnvironmentVariableW TEMP failed (Win32 ${api.getLastError()})`) - } - } - + let ownedTempDir: string | undefined + let sandbox: AclSandbox | undefined + let initialized = false try { + let privateTempDir: string | null = null + let writeSid: string | undefined + let privateTempSid: string | undefined + if (parsed.mode === 'workspace-write') { + writeSid = workspaceWriteSid(parsed.workspace) + if (seamManaged) { + if (parsed.writeSid !== writeSid) fail('--write-sid does not match --workspace') + privateTempDir = parsed.temp + privateTempSid = tempWriteSid(privateTempDir) + if (parsed.tempWriteSid !== privateTempSid) fail('--temp-write-sid does not match --temp') + } else { + ownedTempDir = mkdtempSync(join(parsed.temp, 'dsh-')) + privateTempDir = ownedTempDir + privateTempSid = tempWriteSid(privateTempDir) + } + } + sandbox = new AclSandbox({ + writableDirs: parsed.mode === 'workspace-write' ? [parsed.workspace] : [], + tempDir: privateTempDir, + mode: parsed.mode, + ...writeSid === undefined ? {} : { writeSid }, + ...privateTempSid === undefined ? {} : { tempWriteSid: privateTempSid }, + manageDacls: !seamManaged, + }) + await sandbox.init() + initialized = true + + if (privateTempDir !== null) { + if (api.setEnvironmentVariableW('TMP', privateTempDir) === 0) { + fail(`SetEnvironmentVariableW TMP failed (Win32 ${api.getLastError()})`) + } + if (api.setEnvironmentVariableW('TEMP', privateTempDir) === 0) { + fail(`SetEnvironmentVariableW TEMP failed (Win32 ${api.getLastError()})`) + } + } + const child = sandbox.spawn({ command: parsed.command, args: parsed.args, @@ -166,10 +184,19 @@ async function main(): Promise { return result.exitCode } finally { // Cleanup failures must not mask the child's exit code: report and keep going. - try { - sandbox.dispose() - } catch (error) { - process.stderr.write(`${RUNNER_SIGNATURE}: cleanup: ${error instanceof Error ? error.message : String(error)}\n`) + if (initialized) { + try { + sandbox?.dispose() + } catch (error) { + process.stderr.write(`${RUNNER_SIGNATURE}: cleanup: ${error instanceof Error ? error.message : String(error)}\n`) + } + } + if (ownedTempDir !== undefined) { + try { + rmSync(ownedTempDir, { recursive: true, force: true }) + } catch (error) { + process.stderr.write(`${RUNNER_SIGNATURE}: cleanup: ${error instanceof Error ? error.message : String(error)}\n`) + } } } } diff --git a/packages/sandbox/sandbox-windows-acl/src/token.ts b/packages/sandbox/sandbox-windows-acl/src/token.ts index 051afc740d..abd0c73617 100644 --- a/packages/sandbox/sandbox-windows-acl/src/token.ts +++ b/packages/sandbox/sandbox-windows-acl/src/token.ts @@ -162,11 +162,11 @@ export interface RestrictingSidSet { * Create the write-restricted token with the mode-selected restricting list * (verified on Win11 26200, see the POC-worktree restrict-variant harness): * - read-only: [logon SID, EVERYONE] - * - workspace-write: [logon SID, EVERYONE, orphan] + * - workspace-write: [logon SID, EVERYONE, workspace SID, optional temp SID] * * The logon SID + EVERYONE keep-alive group is shared by both modes: early * DLL init dies with 0xC0000142 and CNG (`\Device\CNG` write trustee — - * pwsh crashes 0xE0434352) fails without them. The write SID joins ONLY + * pwsh crashes 0xE0434352) fails without them. The write SIDs join ONLY * workspace-write — read-only carries no write SID, so a standing grant ACE * from an earlier workspace-write period (a `/permission` mode downgrade, or * a crash-resumed session) stays INERT under read-only: the WRITE_RESTRICTED @@ -186,24 +186,25 @@ export interface RestrictingSidSet { * @param api - the binding table. * @param currentToken - the process token to restrict. * @param logonSid - the copied logon session SID. - * @param writeSid - the write SID forming the write allowlist (workspace-write only; absent under read-only). + * @param writeSids - the distinct write SIDs forming the workspace and + * optional temp allowlists (workspace-write only; empty under read-only). * @param known - the well-known SIDs entering the restricting list. - * @param mode - selects the restricting list (workspace-write adds the write SID). + * @param mode - selects the restricting list (workspace-write adds the capability SIDs). * @returns the restricted token handle. */ export function createRestrictedToken( api: Win32Bindings, currentToken: NativePtr, logonSid: NativePtr, - writeSid: NativePtr | undefined, + writeSids: readonly NativePtr[], known: RestrictingSidSet, mode: 'read-only' | 'workspace-write', ): NativePtr { const restrictingSids = buildRestrictingSids(mode === 'read-only' ? [logonSid, known.world] - : writeSid === undefined - ? (() => { throw new Error('createRestrictedToken: workspace-write restricting list requires the write SID') })() - : [logonSid, known.world, writeSid]) + : writeSids.length === 0 + ? (() => { throw new Error('createRestrictedToken: workspace-write restricting list requires at least one write SID') })() + : [logonSid, known.world, ...writeSids]) const tokenSlot = allocPtrSlot() const created = api.createRestrictedToken( currentToken, diff --git a/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts b/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts index db74893f36..46a64d5710 100644 --- a/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts +++ b/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts @@ -8,8 +8,10 @@ * once per session. The SID's power is defined solely by the ACEs that name * it (which exist only on the workspace tree and the session's private temp * directory), and only tokens minted for that workspace carry it — the SID - * string itself is not a secret (the previous per-session SID was likewise - * logged in the plain). + * string itself is not a secret. Temporary directories use a separate, + * per-directory identity from {@link tempWriteSid}; sharing the workspace + * identity with temp would let sibling sessions write one another's temp + * trees. * * The input MUST be the canonical workspace path (`realpathSync.native` on * Windows — the sandbox-policy `resolveWorkspaceRoot` already applies it): @@ -36,3 +38,17 @@ export function workspaceWriteSid(workspaceRoot: string): string { const second = (digest.readUInt32LE(4) % (2 ** 30 - 1)) + 1 return `S-1-4-${first}-${second}` } + +/** + * Derive one private temp directory's write SID. The random directory path + * is the capability identity; a fixed third subauthority domain-separates + * the result from every two-subauthority workspace SID. + * @param tempDir - the private temp directory's absolute path. + * @returns the SDDL string form. + */ +export function tempWriteSid(tempDir: string): string { + const digest = createHash('sha256').update('temp\0', 'utf8').update(tempDir, 'utf8').digest() + const first = (digest.readUInt32LE(0) % (2 ** 30 - 1)) + 1 + const second = (digest.readUInt32LE(4) % (2 ** 30 - 1)) + 1 + return `S-1-4-${first}-${second}-1` +} diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts index 25abe14392..729123b7d7 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts @@ -192,21 +192,38 @@ describe.skipIf(!isWin32)('ACL editing', () => { const api = await win32() const workspaceDir = scratch() const tempDir = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspaceDir], tempDir, writeSid: 'S-1-4-9000-3', mode: 'workspace-write' }) + const sandbox = new AclSandbox({ + writableDirs: [workspaceDir], + tempDir, + writeSid: 'S-1-4-9000-3', + tempWriteSid: 'S-1-4-9000-3-1', + mode: 'workspace-write', + }) await sandbox.init() sandbox.dispose() const workspaceAces = readDirectAces(api, workspaceDir) expect(workspaceAces.some(ace => ace.sid === 'S-1-4-9000-3')).toBe(true) const tempAces = readDirectAces(api, tempDir) - expect(tempAces.some(ace => ace.sid === 'S-1-4-9000-3')).toBe(false) + expect(tempAces.some(ace => ace.sid === 'S-1-4-9000-3-1')).toBe(false) }) it('workspace-write without a write SID fails at construction; the token layer guards the same contract', () => { const dir = scratch() expect(() => new AclSandbox({ writableDirs: [dir], tempDir: null, mode: 'workspace-write' })) .toThrow(/requires a write SID/) - expect(() => createRestrictedToken({} as never, 0n as never, 0n as never, undefined, { world: 0n as never }, 'workspace-write')) - .toThrow(/requires the write SID/) + expect(() => new AclSandbox({ writableDirs: [dir], writeSid: 'S-1-4-1-1', mode: 'workspace-write' })) + .toThrow(/requires an explicit private temp directory or null/) + expect(() => new AclSandbox({ writableDirs: [dir], tempDir: dir, writeSid: 'S-1-4-1-1', mode: 'workspace-write' })) + .toThrow(/requires a temp write SID/) + expect(() => new AclSandbox({ + writableDirs: [dir], + tempDir: dir, + writeSid: 'S-1-4-1-1', + tempWriteSid: 'S-1-4-1-1', + mode: 'workspace-write', + })).toThrow(/must be distinct/) + expect(() => createRestrictedToken({} as never, 0n as never, 0n as never, [], { world: 0n as never }, 'workspace-write')) + .toThrow(/requires at least one write SID/) }) it('the per-path lock is exclusive: a second immediate lock attempt fails with ERROR_LOCK_VIOLATION until release', async () => { diff --git a/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts index 0825695438..8d20c4ab32 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts @@ -6,10 +6,10 @@ * WRITE_RESTRICTED token intersects write accesses only. * * The escape target sits in its own scratch dir under the system temp - * directory, OUTSIDE both granted trees: tempDir is passed EXPLICITLY (never - * defaulted through GetTempPathW, whose grant would inherit (OI)(CI) over the - * whole real temp tree) and the writable dir is a separate mkdtemp directory - * that contains neither sibling. Nothing under the user profile is touched. + * directory, OUTSIDE both granted trees: tempDir is an explicit private + * mkdtemp directory (the API never grants the ambient temp root implicitly), + * and the writable dir is a separate mkdtemp directory that contains neither + * sibling. Nothing under the user profile is touched. */ import { execFileSync } from 'node:child_process' @@ -47,11 +47,15 @@ describe.skipIf(!isWin32 || !pwshAvailable())('AclSandbox write restriction', () secretFile = join(scratchRoot, 'secret.txt') writeFileSync(secretFile, 'top secret - must stay readable to prove the read boundary') escapeFile = join(scratchRoot, 'escaped.txt') - // tempDir is passed explicitly: GetTempPathW reads the native environment - // block, which host runtimes (vitest worker pools) may not keep in sync - // with process.env — and a real-temp grant would inherit over every - // temp subdirectory, including this test's scratch dir. - sandbox = new AclSandbox({ writableDirs: [writableDir], tempDir: isolatedTemp, writeSid: 'S-1-4-9000-4', mode: 'workspace-write' }) + // The direct API requires this explicit private temp directory and its + // own SID; it never widens the grant over the ambient temp root. + sandbox = new AclSandbox({ + writableDirs: [writableDir], + tempDir: isolatedTemp, + writeSid: 'S-1-4-9000-4', + tempWriteSid: 'S-1-4-9000-4-1', + mode: 'workspace-write', + }) await sandbox.init() }) @@ -91,7 +95,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('AclSandbox write restriction', () it('fails closed when the write SID cannot be parsed (no unrestricted fallback)', async () => { // A malformed SID makes ConvertStringSidToSidW fail; init must throw // before any grant is applied and never spawn unrestricted. - const broken = new AclSandbox({ writableDirs: [writableDir], writeSid: 'S-1-4-abc-1', mode: 'workspace-write' }) + const broken = new AclSandbox({ writableDirs: [writableDir], tempDir: null, writeSid: 'S-1-4-abc-1', mode: 'workspace-write' }) await expect(broken.init()).rejects.toThrow(/ConvertStringSidToSidW/u) }, 15_000) }) diff --git a/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts index 0534bb00d7..3223a940d9 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts @@ -25,7 +25,7 @@ async function setup(internals: LocalSandboxProvider['internals']) { } describe('windows-acl win32 chain (LocalSandboxProvider)', () => { - it('workspace-write: runner argv prefix, explicit temp, mode flag, partial enforcement, ACL denial dialect', async () => { + it('agentless workspace-write: runner argv prefix, temp root, mode flag, partial enforcement, ACL denial dialect', async () => { const probeWindowsAcl = vi.fn(() => true) const sandbox = await setup({ platform: 'win32', diff --git a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts index 82ffe6606a..36b3ce17d2 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts @@ -13,7 +13,7 @@ import { fileURLToPath } from 'node:url' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' -import { AclWriteGrant } from '../src/index.ts' +import { AclWriteGrant, tempWriteSid, workspaceWriteSid } from '../src/index.ts' const isWin32 = process.platform === 'win32' const runnerEntry = fileURLToPath(new URL('../src/runner.ts', import.meta.url)) @@ -77,12 +77,13 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { it('workspace-write: the confined child writes granted directories only', () => { const probe = [ "$ErrorActionPreference='SilentlyContinue';", - // The restricted token puts pwsh into ConstrainedLanguage in BOTH modes - // (documented Known Limitation) — pinned here so a token change that - // silently restores FullLanguage is caught. + // 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). '\'LANGMODE: \' + $ExecutionContext.SessionState.LanguageMode;', `try{Set-Content -Path '${writableDir}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`, - `try{Set-Content -Path '${isolatedTemp}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-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'};`, `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'};`, // Authenticated Users is absent from BOTH lists: the WMI namespace @@ -96,7 +97,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe, ]) expect(result.status, `stderr: ${result.stderr}`).toBe(0) - expect(result.stdout).toContain('LANGMODE: ConstrainedLanguage') + expect(result.stdout).toContain('LANGMODE: FullLanguage') expect(result.stdout).toContain('TARGET-WRITE: OK') expect(result.stdout).toContain('TEMP-WRITE: OK') expect(result.stdout).toContain('ESCAPE-WRITE: DENIED') @@ -163,33 +164,37 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { expect(existsSync(renamedDir)).toBe(true) }, 30_000) - it('--write-sid: the runner trusts the caller-owned grants — private temp subdir via the TMP/TEMP env rewrite, no grants of its own', () => { - const writeSid = 'S-1-4-9000-99' + it('paired SIDs: the runner trusts caller-owned private-temp grants and materializes nothing itself', () => { + const seamWorkspace = join(scratchRoot, 'seam-workspace') + mkdirSync(seamWorkspace) + const writeSid = workspaceWriteSid(seamWorkspace) const privateTemp = join(isolatedTemp, 'private-subdir') mkdirSync(privateTemp) - const grant = AclWriteGrant.create(writeSid) + const privateTempSid = tempWriteSid(privateTemp) + const grant = AclWriteGrant.create(privateTempSid) grant.add(privateTemp) try { const probe = [ "$ErrorActionPreference='SilentlyContinue';", - `try{Set-Content -Path '${writableDir}\\server-granted.txt' -Value ok -ErrorAction Stop;'WORKSPACE-WRITE: OK'}catch{'WORKSPACE-WRITE: DENIED'};`, + `try{Set-Content -Path '${seamWorkspace}\\server-granted.txt' -Value ok -ErrorAction Stop;'WORKSPACE-WRITE: OK'}catch{'WORKSPACE-WRITE: DENIED'};`, `try{Set-Content -Path '${privateTemp}\\server-granted.txt' -Value ok -ErrorAction Stop;'PRIVATE-TEMP-WRITE: OK'}catch{'PRIVATE-TEMP-WRITE: DENIED'};`, "'TEMP-ENV: ' + $env:TEMP;", "'TMP-ENV: ' + $env:TMP", ].join('') const result = runRunner([ - '--workspace', writableDir, '--temp', privateTemp, '--mode', 'workspace-write', '--write-sid', writeSid, + '--workspace', seamWorkspace, '--temp', privateTemp, '--mode', 'workspace-write', '--write-sid', writeSid, + '--temp-write-sid', privateTempSid, '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe, ]) expect(result.status, `stderr: ${result.stderr}`).toBe(0) - // The runner granted nothing (only the caller's private-temp grant + // The runner granted nothing (only the caller's temp-SID grant // stands): the workspace write is denied, the private temp write lands, // and the child's TMP/TEMP point at the private subdirectory. expect(result.stdout).toContain('WORKSPACE-WRITE: DENIED') expect(result.stdout).toContain('PRIVATE-TEMP-WRITE: OK') expect(result.stdout).toContain(`TEMP-ENV: ${privateTemp}`) expect(result.stdout).toContain(`TMP-ENV: ${privateTemp}`) - expect(existsSync(join(writableDir, 'server-granted.txt'))).toBe(false) + expect(existsSync(join(seamWorkspace, 'server-granted.txt'))).toBe(false) expect(existsSync(join(privateTemp, 'server-granted.txt'))).toBe(true) } finally { grant.dispose() @@ -197,6 +202,81 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { } }, 30_000) + it('temp capabilities isolate sibling sessions that share one workspace SID', () => { + const writeSid = workspaceWriteSid(writableDir) + const tempA = join(isolatedTemp, 'session-a') + const tempB = join(isolatedTemp, 'session-b') + mkdirSync(tempA) + mkdirSync(tempB) + const sidA = tempWriteSid(tempA) + const sidB = tempWriteSid(tempB) + const workspaceGrant = AclWriteGrant.create(writeSid) + const grantA = AclWriteGrant.create(sidA) + const grantB = AclWriteGrant.create(sidB) + workspaceGrant.add(writableDir) + grantA.add(tempA) + grantB.add(tempB) + const sharedWorkspaceFile = join(writableDir, 'shared-between-sessions.txt') + const probe = [ + "const fs = require('node:fs');", + "const targets = [['OWN', process.argv[1]], ['SIBLING', process.argv[2]], ['WORKSPACE', process.argv[3]]];", + "if (process.argv[4]) targets.push(['SIBLING-EXISTING', process.argv[4]]);", + 'for (const [name, target] of targets) {', + "try { fs.writeFileSync(target, name); console.log(name + ': OK'); } catch { console.log(name + ': DENIED'); }", + '}', + ].join('') + try { + const resultA = runRunner([ + '--workspace', writableDir, '--temp', tempA, '--mode', 'workspace-write', + '--write-sid', writeSid, '--temp-write-sid', sidA, + '--', process.execPath, '-e', probe, join(tempA, 'a.txt'), join(tempB, 'a-escaped.txt'), sharedWorkspaceFile, + ]) + expect(resultA.status, `stderr: ${resultA.stderr}`).toBe(0) + expect(resultA.stdout).toContain('OWN: OK') + expect(resultA.stdout).toContain('SIBLING: DENIED') + expect(resultA.stdout).toContain('WORKSPACE: OK') + + const resultB = runRunner([ + '--workspace', writableDir, '--temp', tempB, '--mode', 'workspace-write', + '--write-sid', writeSid, '--temp-write-sid', sidB, + '--', process.execPath, '-e', probe, join(tempB, 'b.txt'), join(tempA, 'b-escaped.txt'), sharedWorkspaceFile, join(tempA, 'a.txt'), + ]) + expect(resultB.status, `stderr: ${resultB.stderr}`).toBe(0) + expect(resultB.stdout).toContain('OWN: OK') + expect(resultB.stdout).toContain('SIBLING: DENIED') + expect(resultB.stdout).toContain('SIBLING-EXISTING: DENIED') + expect(resultB.stdout).toContain('WORKSPACE: OK') + expect(existsSync(join(tempB, 'a-escaped.txt'))).toBe(false) + expect(existsSync(join(tempA, 'b-escaped.txt'))).toBe(false) + expect(readFileSync(join(tempA, 'a.txt'), 'utf8')).toBe('OWN') + } finally { + workspaceGrant.dispose() + grantA.dispose() + grantB.dispose() + rmSync(tempA, { recursive: true, force: true }) + rmSync(tempB, { recursive: true, force: true }) + } + }, 30_000) + + it('agentless workspace-write creates a fresh private temp per call and removes it on exit', () => { + const captureA = join(writableDir, 'agentless-temp-a.txt') + const captureB = join(writableDir, 'agentless-temp-b.txt') + for (const capture of [captureA, captureB]) { + const result = runRunner([ + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write', + '--', process.execPath, '-e', "require('node:fs').writeFileSync(process.argv[1], process.env.TEMP)", capture, + ]) + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + } + const tempA = readFileSync(captureA, 'utf8') + const tempB = readFileSync(captureB, 'utf8') + expect(tempA).not.toBe(tempB) + expect(tempA.startsWith(isolatedTemp)).toBe(true) + expect(tempB.startsWith(isolatedTemp)).toBe(true) + expect(existsSync(tempA)).toBe(false) + expect(existsSync(tempB)).toBe(false) + }, 30_000) + it('confined children spawn grandchildren with inherited stdio; piped capture stays denied (named-pipe default SD template)', () => { // Two-layer pin of the grandchild-spawn boundary: // - the token default DACL carries a restricting-SID ACE (set in init), @@ -238,7 +318,10 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { // it, so the workspace write is denied (previously it LEAKED). The // switch back reuses the SAME standing ACE: the re-upgrade write lands // without any re-grant. - const writeSid = 'S-1-4-9001-7' + const writeSid = workspaceWriteSid(writableDir) + const privateTemp = join(isolatedTemp, 'mode-switch-temp') + mkdirSync(privateTemp) + const privateTempSid = tempWriteSid(privateTemp) const grant = AclWriteGrant.create(writeSid) grant.add(writableDir) try { @@ -247,7 +330,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { `try{Set-Content -Path '${writableDir}\\downgraded.txt' -Value ok -ErrorAction Stop;'DOWNGRADE-WRITE: OK (LEAK!)'}catch{'DOWNGRADE-WRITE: DENIED'}`, ].join('') const downgraded = runRunner([ - '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'read-only', '--write-sid', writeSid, + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'read-only', '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', downgradeProbe, ]) expect(downgraded.status, `stderr: ${downgraded.stderr}`).toBe(0) @@ -259,7 +342,8 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { `try{Set-Content -Path '${writableDir}\\reupgraded.txt' -Value ok -ErrorAction Stop;'REUPGRADE-WRITE: OK'}catch{'REUPGRADE-WRITE: DENIED'}`, ].join('') const reupgraded = runRunner([ - '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write', '--write-sid', writeSid, + '--workspace', writableDir, '--temp', privateTemp, '--mode', 'workspace-write', '--write-sid', writeSid, + '--temp-write-sid', privateTempSid, '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', reupgradeProbe, ]) expect(reupgraded.status, `stderr: ${reupgraded.stderr}`).toBe(0) @@ -267,6 +351,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { expect(existsSync(join(writableDir, 'reupgraded.txt'))).toBe(true) } finally { grant.dispose() + rmSync(privateTemp, { recursive: true, force: true }) } }, 30_000) @@ -338,4 +423,23 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { expect(result.status).toBe(127) expect(result.stderr).toContain('windows-acl-run: ') }, 15_000) + + it('runner-side failure: seam-managed SID flags must be paired and match their owning paths', () => { + const writeSid = workspaceWriteSid(writableDir) + const tempSid = tempWriteSid(isolatedTemp) + const cases = [ + ['--write-sid', writeSid], + ['--write-sid', 'S-1-4-1-2', '--temp-write-sid', tempSid], + ['--write-sid', writeSid, '--temp-write-sid', 'S-1-4-1-2-1'], + ] + for (const args of cases) { + const result = runRunner([ + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write', + ...args, + '--', process.execPath, '-e', 'process.exit(99)', + ]) + expect(result.status, `args: ${args.join(' ')}\nstderr: ${result.stderr}`).toBe(127) + expect(result.stderr).toContain('windows-acl-run: ') + } + }, 15_000) }) diff --git a/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts index 4d24c6f8fb..8581f39112 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts @@ -9,7 +9,7 @@ import { describe, expect, it } from 'vitest' -import { workspaceWriteSid } from '../src/index.ts' +import { tempWriteSid, workspaceWriteSid } from '../src/index.ts' describe('workspaceWriteSid', () => { it('derives a stable orphan-shaped SID per workspace path', () => { @@ -28,3 +28,16 @@ describe('workspaceWriteSid', () => { expect(workspaceWriteSid('C:\\Repo\\')).not.toBe(workspaceWriteSid('C:\\Repo')) }) }) + +describe('tempWriteSid', () => { + it('derives a stable domain-separated SID per private temp path', () => { + const temp = tempWriteSid('C:\\Users\\agent\\AppData\\Local\\Temp\\dsh-abc123') + expect(temp).toBe(tempWriteSid('C:\\Users\\agent\\AppData\\Local\\Temp\\dsh-abc123')) + expect(temp).toMatch(/^S-1-4-\d+-\d+-1$/u) + expect(temp).not.toBe(workspaceWriteSid('C:\\Users\\agent\\AppData\\Local\\Temp\\dsh-abc123')) + }) + + it('derives distinct capabilities for distinct private temp paths', () => { + expect(tempWriteSid('C:\\Temp\\dsh-a')).not.toBe(tempWriteSid('C:\\Temp\\dsh-b')) + }) +})