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 c9e3414e4d..98d1f7f653 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: 6c358c1bdcccb8ca2260abfcc6743a0dd4b852b8 -2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 028b7dedf53944c4f00d2db37bd6af213d94fed0 +2026-08-08-windows-acl-restricted-token-sandbox.md: 54468fa46f7dbf7bcb7f765b6ca6bb7ed962e7d2 +2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 10a412a2cb4f584443ca50436ff1baf65141ac1e 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 6c358c1bdc..54468fa46f 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 an orphan SID (`S-1-4-x-y`); the orphan SID's Write ACEs on the workspace and temp roots are the entire 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 orphan SID is PER SESSION, not per spawn: the seam provisions one SID per session as a log-only `sandbox/acl-session` event on the session log (fork mints a fresh one; resume replays the same one), materializes its ACEs lazily at the session's first confined execution — a fresh provision kicks an immediate persistence flush right after the append (no write-behind debounce), so the record is durable within the flush latency; a crash inside that window can strand inert orphan-SID ACEs, the one documented self-healing gap — and holds them for the server process's lifetime (revoked on provider dispose; idempotent re-grant skips the eager full-tree re-propagation when the exact ACE survives a restart — no garbage collection). The record is BOUND to its owning session id and validated at the fold (orphan-SID shape, temp path inside the host temp root): a fork's copied parent record never provisions the child, and a tampered record fails loud instead of materializing grants. The token's restricting list is the keep-alive group plus the orphan SID only under workspace-write: read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, orphan]. The keep-alive invariants are logon SID + Everyone (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Read-only carries no orphan: a standing grant ACE from an earlier workspace-write period stays INERT (the pass-2 check grants only what the list carries, so read-only remains strictly zero-grant across a `/permission` downgrade or a crash-resumed session, while the unrevoked 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 random hex>` — unguessable, created exclusively, reparse points rejected — TMP/TEMP rewritten by the runner — bwrap `--tmpfs /tmp` semantics). 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 include a write SID (`S-1-4-x-y`); the write SID's Write ACEs on the workspace and temp roots are the entire 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 still provisions one log-only `sandbox/acl-session` event per session (fork mints a fresh one; resume replays the same one) carrying the session's workspace binding and PRIVATE temp subdirectory — no SID, so the record's old SID-tamper surface does not exist; a fresh provision kicks an immediate persistence flush right after the append (no write-behind debounce), so the record is durable within the flush latency — a crash inside that window can strand the private temp directory unrecorded, the one documented self-healing gap. 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 record is BOUND to its owning session id and validated at the fold (workspace/temp shape): a fork's copied parent record never provisions the child, and a tampered record fails loud instead of materializing grants. 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). Read-only carries no write SID: a standing grant ACE from an earlier workspace-write period stays INERT (the pass-2 check grants only what the list carries, so read-only remains strictly zero-grant 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 random hex>` — unguessable, created exclusively, reparse points rejected — TMP/TEMP rewritten by the runner — bwrap `--tmpfs /tmp` semantics). 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. ## 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: 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, revoked by provider dispose, self-healing across restarts via the durable per-session record — whose immediate flush precedes the ACEs, within the flush latency); grant materialization is an EAGER full-tree propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per session per server lifetime by the per-session reuse; 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. +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: 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, self-healing across restarts via the durable per-session record — whose immediate flush precedes the ACEs, within the flush latency); 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. ## 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 per-session grant machinery is pinned cross-platform by `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` (record fold/provision with the ownership binding — a fork's copied parent record never provisions the child — tamper validation on the write SID and temp path, one-shot materialization with the immediate-flush kick, exclusive temp creation with reparse-point rejection, fork/resume SID reuse, dispose revocation, and the mode-switch cycle — with the Win32 surface mocked) and on win32 by `grant.spec.ts` (real-DACL materialization), the `acl.spec.ts` idempotent-grant fast-path, 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), and the ConstrainedLanguage pins 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 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-session.spec.ts` (record fold/provision with the ownership binding — a fork's copied parent record never provisions the child — tamper validation on the workspace/temp paths, one-shot materialization with the immediate-flush kick, exclusive temp creation with reparse-point rejection, fork/resume temp reuse, 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), and the ConstrainedLanguage pins 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). ## 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 028b7dedf5..10a412a2cb 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 按会话而非按 spawn:seam 每会话供给一个 SID,作为 log-only 的 `sandbox/acl-session` 事件记录在会话日志中(fork 铸出新 SID;恢复回放同一个),其 ACE 在该会话首次受限执行时惰性物化——新供给在追加之后立即触发一次即时持久化 flush(无 write-behind 去抖),因此记录在 flush 延迟内即持久化;在该窗口内崩溃可能遗留失效的孤儿 SID ACE,这是唯一记录在案的自愈缺口——并在服务器进程生命周期内持有(提供方 dispose(资源释放)时回收;幂等重授权在该 ACE 跨重启原样存续时跳过急切的全树重传播——不做垃圾回收)。记录被**绑定**到其所属会话 id 并在 fold 处校验(孤儿 SID 形态、临时路径须位于宿主临时根之内):fork 复制的父记录绝不会为子会话供给 SID,被篡改的记录会响亮失败而非物化授权。令牌的 restricting list 是保活组加上仅 workspace-write 下的孤儿 SID:read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、孤儿 SID]。保活不变式是登录 SID + Everyone(没有它们,早期 DLL init 会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。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 random hex>`——不可猜测、独占创建、拒绝 reparse point——TMP/TEMP 由 runner 重写——bwrap `--tmpfs /tmp` 语义)。它以 [`@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 中包含写入 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 仍为每个会话供给一条 log-only 的 `sandbox/acl-session` 事件(fork 铸出新记录;恢复回放同一条),携带会话的工作区绑定与**私有**临时子目录——不含 SID,因此记录原先的 SID 篡改面已不存在;新供给在追加之后立即触发一次即时持久化 flush(无 write-behind 去抖),因此记录在 flush 延迟内即持久化——在该窗口内崩溃可能遗留未记录的私有临时目录,这是唯一记录在案的自愈缺口。seam 把工作区 ACE **常驻**物化(绝不撤销——就是缓存),把临时 ACE **可回收**物化(提供方 dispose(资源释放)时撤销,因此可继承 ACE 不会在环境临时根目录上比其会话的临时目录活得更久);记录被**绑定**到其所属会话 id 并在 fold 处校验(工作区/临时路径形态):fork 复制的父记录绝不会为子会话供给记录,被篡改的记录会响亮失败而非物化授权。令牌的 restricting list 是保活组加上仅 workspace-write 下的写入 SID:read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、写入 SID]。保活不变式是登录 SID + Everyone(没有它们,早期 DLL init 会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。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 random hex>`——不可猜测、独占创建、拒绝 reparse point——TMP/TEMP 由 runner 重写——bwrap `--tmpfs /tmp` 语义)。它以 [`@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)。 ## How the restriction works (why no new identity) @@ -32,11 +32,11 @@ AppContainer 令牌没有环境读访问:每个可读路径都必须预先通 ## Consequences -所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有,由提供方 dispose 回收,借助持久化的每会话记录跨重启自愈——其即时 flush 先于 ACE(flush 延迟内));授权物化是急切的全树传播(`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 中。 +所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有;工作区 ACE 按设计永久常驻——复用缓存,工作区改名时成为不可见残留——临时 ACE 由提供方 dispose 回收,借助持久化的每会话记录跨重启自愈——其即时 flush 先于 ACE(flush 延迟内));授权物化是急切的全树传播(`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 中。 ## 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-session.spec.ts` 钉住(带归属绑定的记录 fold/供给——fork 复制的父记录绝不会为子会话供给 SID——写 SID 与临时路径的篡改校验、带即时 flush 触发的一次性物化、独占临时目录创建并拒绝 reparse point、fork/恢复 SID 复用、dispose 回收,以及模式切换循环——mock 掉 Win32 表面),win32 侧由 `grant.spec.ts`(真实 DACL 物化)、`acl.spec.ts` 的幂等授权快速路径、`failure-paths.spec.ts` 的 suspension-orphan 回归(AssignProcessToJobObject 失败会终止子进程)与 `runner.spec.ts` 的 `--write-sid` 契约(调用者所有目录的授权、经 TMP/TEMP 的私有临时子目录、两种模式下的 CIM 拒绝探针、模式降级回归——驻留授权 ACE 在 read-only 下失效并在重新升级后再度生效——环境可写 Public-probe 回归(对 C:\Users\Public 子目录的写入在两种模式下都会被拒绝),以及两种模式下对 ConstrainedLanguage 的钉定)钉住。runner 失败分类以 127 退出码为门槛(受限命令仅仅在非 127 退出时打印 `windows-acl-run:` 签名,也绝不会被误分类为"命令未运行"——由 pwsh-sandbox helper 套件钉住)。 +产品可见的 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-session.spec.ts` 钉住(带归属绑定的记录 fold/供给——fork 复制的父记录绝不会为子会话供给记录——工作区/临时路径的篡改校验、带即时 flush 触发的一次性物化、独占临时目录创建并拒绝 reparse point、fork/恢复临时目录复用、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 子目录的写入在两种模式下都会被拒绝),以及两种模式下对 ConstrainedLanguage 的钉定)钉住。runner 失败分类以 127 退出码为门槛(受限命令仅仅在非 127 退出时打印 `windows-acl-run:` 签名,也绝不会被误分类为"命令未运行"——由 pwsh-sandbox helper 套件钉住)。 ## Related diff --git a/docs/config-catalog.md b/docs/config-catalog.md index e64a858e0a..8de276a4a9 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1231,7 +1231,7 @@ export interface Config { } ``` -Source: [`packages/sandbox/sandbox-local/src/index.ts:39`](../packages/sandbox/sandbox-local/src/index.ts) +Source: [`packages/sandbox/sandbox-local/src/index.ts:43`](../packages/sandbox/sandbox-local/src/index.ts) ## `@deepseek-ai/dsh-sandbox-policy` diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index c1e057542f..c1eae60ced 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -498,16 +498,16 @@ Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/ ```ts persistence-catalog /** - * The session's windows-acl write identity was provisioned — log-only + * The session's windows-acl write record was provisioned — log-only * (like `sandbox/mode`; NOT a surface event, carries no `surfaceOp`): * durable and replayable, never in the model transcript. The LAST such * event owned by the session is its record ({@link sessionAclRecord}); * the provider appends exactly one on the session's first Windows - * confined execution. + * confined execution. The write SID itself is NOT stored — it is the + * per-workspace identity derived from `workspace` + * (`workspaceWriteSid`). */ 'sandbox/acl-session': { - /** The orphan write SID (`S-1-4-x-y`) whose ACEs form the session's write allowlist. */ - writeSid: string /** The owning session — the binding a fork's copied event cannot satisfy. */ sessionId: SessionId /** The workspace root the grant applies to (the session's immutable cwd, as resolved). */ @@ -517,7 +517,7 @@ Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/ } ``` -Source: [`packages/sandbox/sandbox-local/src/acl-session.ts:36`](../packages/sandbox/sandbox-local/src/acl-session.ts) +Source: [`packages/sandbox/sandbox-local/src/acl-session.ts:43`](../packages/sandbox/sandbox-local/src/acl-session.ts) #### `sandbox/mode` — log-only diff --git a/docs/subsystems/sandbox.i18n.yaml b/docs/subsystems/sandbox.i18n.yaml index abef39cbf9..489daf02e4 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: 37aba27c589be80d8a3ea9201fd8d13c7b259eda -sandbox.zh.md: 244e351a5db1bfb0d2c9069e8d07bd52f9d15bb0 +sandbox.md: 20e0f36a5edb211ea409208d4e5e4a9be2e91d46 +sandbox.zh.md: 0619046f2fa98a89275e2dad85f0eadaf8e4a231 diff --git a/docs/subsystems/sandbox.md b/docs/subsystems/sandbox.md index 37aba27c58..20e0f36a5e 100644 --- a/docs/subsystems/sandbox.md +++ b/docs/subsystems/sandbox.md @@ -56,8 +56,9 @@ interface SandboxExecutionPolicy { /** * 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 write grant and private temp subdirectory); absent for - * agentless calls, which fall back to per-call backend state. + * 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?: SessionId } @@ -183,7 +184,7 @@ Abstract process-sandbox service. confine must return enforcing argv or fail clo abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv ``` -Source: [`packages/sandbox/sandbox/src/index.ts:157`](../../packages/sandbox/sandbox/src/index.ts) +Source: [`packages/sandbox/sandbox/src/index.ts:158`](../../packages/sandbox/sandbox/src/index.ts) diff --git a/docs/subsystems/sandbox.zh.md b/docs/subsystems/sandbox.zh.md index 244e351a5d..0619046f2f 100644 --- a/docs/subsystems/sandbox.zh.md +++ b/docs/subsystems/sandbox.zh.md @@ -56,8 +56,9 @@ interface SandboxExecutionPolicy { /** * 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 write grant and private temp subdirectory); absent for - * agentless calls, which fall back to per-call backend state. + * 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?: SessionId } @@ -183,7 +184,7 @@ Abstract process-sandbox service. confine must return enforcing argv or fail clo abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv ``` -Source: [`packages/sandbox/sandbox/src/index.ts:157`](../../packages/sandbox/sandbox/src/index.ts) +Source: [`packages/sandbox/sandbox/src/index.ts:158`](../../packages/sandbox/sandbox/src/index.ts) diff --git a/packages/sandbox/sandbox-local/src/acl-session.ts b/packages/sandbox/sandbox-local/src/acl-session.ts index 5247601250..2807ffd8a1 100644 --- a/packages/sandbox/sandbox-local/src/acl-session.ts +++ b/packages/sandbox/sandbox-local/src/acl-session.ts @@ -1,18 +1,24 @@ /** - * The windows-acl per-session write identity — the DURABLE half of the seam's - * per-session grant reuse. Each session owns exactly one record (one orphan - * write SID, one private temp subdirectory), stored as a log-only + * The windows-acl session write record — the DURABLE half of the seam's + * grant lifecycle. Each session owns exactly one record (its workspace + * binding plus one private temp subdirectory), stored as a log-only * `sandbox/acl-session` event on the session log (the `sandbox/mode` * precedent): replayable, never in the model transcript, and no external - * config store. The ACE half is server-lifetime state owned by the provider - * ({@link AclWriteGrant} materialization, revoked on dispose); the record - * survives restarts so a resumed session reuses the SAME SID — re-granting - * idempotently merges into (or skips) the standing ACEs instead of leaking a - * fresh dead SID's ACEs per restart. The record is BOUND to its owning - * session id, so a fork (which copies the parent's events, record included) - * never inherits the parent's identity — it provisions a fresh one. The - * record's payload is durable input and is validated at the fold (orphan-SID - * shape, well-formed temp path); a matching-but-tampered record fails loud. + * config store. The record carries NO SID: the write SID is the + * per-WORKSPACE identity derived from the workspace path + * (`workspaceWriteSid`) — deterministic across sessions and server + * restarts, so the workspace-root ACE materializes once per workspace per + * machine (the grant's exact-ACE skip makes every later provision O(1)) + * instead of once per session. The ACE half is server-lifetime state owned + * by the provider ({@link AclWriteGrant}: workspace ACEs standing, temp ACEs + * revocable); the record survives restarts so a resumed session reuses the + * SAME private temp subdirectory and the same derived SID — re-granting + * idempotently merges into (or skips) the standing ACEs. The record is + * BOUND to its owning session id, so a fork (which copies the parent's + * events, record included) never inherits the parent's temp identity — it + * provisions a fresh one. The record's payload is durable input and is + * validated at the fold (well-formed workspace/temp paths); a + * matching-but-tampered record fails loud. * * @module dsh-sandbox-local/acl-session */ @@ -20,22 +26,21 @@ import { randomBytes } from 'node:crypto' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' -import { randomWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' declare module '@deepseek-ai/dsh-session' { interface SessionEventMap { /** - * The session's windows-acl write identity was provisioned — log-only + * The session's windows-acl write record was provisioned — log-only * (like `sandbox/mode`; NOT a surface event, carries no `surfaceOp`): * durable and replayable, never in the model transcript. The LAST such * event owned by the session is its record ({@link sessionAclRecord}); * the provider appends exactly one on the session's first Windows - * confined execution. + * confined execution. The write SID itself is NOT stored — it is the + * per-workspace identity derived from `workspace` + * (`workspaceWriteSid`). */ 'sandbox/acl-session': { - /** The orphan write SID (`S-1-4-x-y`) whose ACEs form the session's write allowlist. */ - writeSid: string /** The owning session — the binding a fork's copied event cannot satisfy. */ sessionId: SessionId /** The workspace root the grant applies to (the session's immutable cwd, as resolved). */ @@ -48,24 +53,19 @@ declare module '@deepseek-ai/dsh-session' { /** The durable per-session record carried by one `sandbox/acl-session` event. */ export interface AclSessionRecord { - /** The orphan write SID whose ACEs form the session's write allowlist. */ - writeSid: string /** The owning session id (binds the record against fork inheritance). */ sessionId: SessionId - /** The workspace root the record was provisioned for. */ + /** The workspace root the record was provisioned for (the write SID derives from it). */ workspace: string /** The session's private temp subdirectory. */ tempDir: string } -/** Orphan shape `S-1-4-x-y` — a replayed `Everyone` SID would widen the grant to every token. */ -const ORPHAN_SID_PATTERN = /^S-1-4-\d+-\d+$/u - /** * The session's record: the last `sandbox/acl-session` event owned by it, or * undefined (never confined / a fork). Durable-input validation: tampered - * SID or temp path fails loud. @param events/@param sessionId/@returns as - * below. + * workspace or temp path fails loud. @param events/@param sessionId/@returns + * as below. * @param events - session events (other types skipped). * @param sessionId - owning session (fork binding). * @returns the last owned record, or undefined without one. @@ -77,12 +77,6 @@ export function sessionAclRecord(events: readonly SessionEvent[], sessionId: Ses const data = event.data // Fork copies the parent's record: skip non-owned records (fork mints fresh). if (data.sessionId !== sessionId) continue - if (typeof data.writeSid !== 'string' || !ORPHAN_SID_PATTERN.test(data.writeSid)) { - throw new Error( - `sandbox-local: session "${sessionId}" acl record carries a malformed write SID ${JSON.stringify(data.writeSid)} ` - + '(expected the orphan shape S-1-4-x-y)', - ) - } if (typeof data.workspace !== 'string' || data.workspace.length === 0) { throw new Error(`sandbox-local: session "${sessionId}" acl record carries an empty workspace`) } @@ -112,18 +106,17 @@ export function sessionTempDir(): string { /** * Provision the record for a session that has none (its first Windows - * confined execution): a fresh write SID plus the private temp subdirectory, - * appended as exactly one log-only `sandbox/acl-session` event — the - * provision IS its event, nothing mutates record state out of band. Fork - * (whose copied parent record is not its own) provisions a fresh record; - * resume replays the stored one. + * confined execution): the workspace binding plus the private temp + * subdirectory, appended as exactly one log-only `sandbox/acl-session` + * event — the provision IS its event, nothing mutates record state out of + * band. Fork (whose copied parent record is not its own) provisions a fresh + * record; resume replays the stored one. * @param session - the session the record belongs to. * @param workspaceRoot - the resolved policy root (the session's immutable cwd). * @returns the provisioned record. */ export function provisionAclSession(session: Session, workspaceRoot: string): AclSessionRecord { const record: AclSessionRecord = { - writeSid: randomWriteSid(), sessionId: session.id, workspace: workspaceRoot, tempDir: sessionTempDir(), diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index e18d68449a..0bf982607f 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -5,12 +5,16 @@ * classification facts. Missing or unusable confinement fails closed rather * than returning the original argv. * - * The windows-acl rung additionally owns the per-session write grant: one - * orphan write SID and one private temp subdirectory per session (durable - * record in the session log — see `./acl-session.ts`), ACEs materialized - * lazily at the session's first confined execution and held for the SERVER - * process's lifetime (revoked on dispose). The runner receives `--write-sid` - * and stops managing DACLs itself. + * 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 one private temp subdirectory per session + * (durable record in the session log — see `./acl-session.ts`). 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. * @module @deepseek-ai/dsh-sandbox-local */ @@ -30,7 +34,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 } from '@deepseek-ai/dsh-sandbox-windows-acl' +import { AclWriteGrant, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' import { provisionAclSession, sessionAclRecord } from './acl-session.ts' import type { AclSessionRecord } from './acl-session.ts' import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './profiles.ts' @@ -227,9 +231,10 @@ const RUNNER_FAILURE_RULES = { /** * Local process-sandbox provider. Registers as `ctx.sandbox`. Caches the - * chain verdict and, on the windows-acl rung, the per-session write grants - * ({@link AclWriteGrant}, one per session, revoked on provider dispose); the - * one-time probes spawn nothing else. + * 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. */ export class LocalSandboxProvider extends SandboxProvider { // Inline schema call: the config catalog walks `static Config` statically. @@ -248,11 +253,15 @@ export class LocalSandboxProvider extends SandboxProvider { /** Cached chain verdict; undefined until the first confined wrap needs it. */ private selectedRunner: SelectedRunner | 'unavailable' | undefined /** - * Server-lifetime per-session write grants (windows-acl rung), keyed by the - * session's orphan write SID — the native half of the per-session reuse; - * the durable half lives in the session log (`./acl-session.ts`). + * 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); the + * durable half (workspace binding + private temp dir) lives in the + * session log (`./acl-session.ts`). */ - private readonly aclGrants = new Map() + private readonly workspaceGrants = new Map() + private readonly tempGrants = new Map() constructor(ctx: Context, config: Config) { super(ctx) @@ -274,9 +283,10 @@ export class LocalSandboxProvider extends SandboxProvider { this.configuredRunnerFailureSignatures = runnerFailureSignatures this.probeTimeoutMs = config.probeTimeoutMs as number assertPositiveFinite('probeTimeoutMs', this.probeTimeoutMs) - // Standing ACL grants are revoked with the provider: a clean server - // shutdown leaves no orphan-SID ACEs behind (an unclean one leaves ACEs - // the session's durable record re-grants idempotently on resume). + // The temp grants are revoked with the provider: a clean server + // shutdown leaves no temp ACEs behind (workspace ACEs stand by design — + // the reuse cache; an unclean shutdown leaves them for the next + // provision's exact-ACE skip). ctx.effect(() => () => { this.revokeAclGrants() }) @@ -357,10 +367,11 @@ export class LocalSandboxProvider extends SandboxProvider { // 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. + // 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' ? record.tempDir : tmpdir(), '--mode', policy.mode, - '--write-sid', record.writeSid, + '--write-sid', workspaceWriteSid(record.workspace), ] } @@ -399,8 +410,8 @@ export class LocalSandboxProvider extends SandboxProvider { // Immediate durability kick: the append is write-behind (bounded // coordinator window); flush now so the record is durable as close to // its ACE materialization as the synchronous confine seam allows. The - // residual window (a crash inside the flush latency) can strand inert - // orphan-SID ACEs — documented in the README. + // residual window (a crash inside the flush latency) strands the + // private temp directory unrecorded — documented in the README. void store.flush(session) return record } @@ -408,29 +419,49 @@ export class LocalSandboxProvider extends SandboxProvider { /** * Materialize the record's ACEs once per server lifetime: lazily at the * session's first confined execution, reused for every later call (the map - * hit is the whole call). Workspace-write grants the workspace root and - * the private temp subdirectory — created here EXCLUSIVELY (the name is - * random and unguessable, a pre-existing entry throws EEXIST, and a - * reparse point is rejected, so the grant never lands on an - * attacker-placed object); read-only materializes NOTHING — its token - * alone restricts every write, and a standing grant from an earlier - * workspace-write period is KEPT through a downgrade (never revoked): the - * read-only restricted token carries no orphan 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 grant is revoked - * before the error propagates. + * hits are the whole call). The write SID is the per-workspace identity + * derived from the record's workspace. Workspace-write grants the + * workspace root STANDING (the ACE outlives every session — the reuse + * cache) and the private temp subdirectory REVOCABLY — created here + * EXCLUSIVELY (the name is random and unguessable, a pre-existing entry + * throws EEXIST, and a reparse point is rejected, so the grant never + * lands on an attacker-placed 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. * @param record - the session's durable record. * @param mode - the policy mode (grants exist only under workspace-write). */ private materializeAclGrant(record: AclSessionRecord, mode: ConfinedSandboxMode): void { - if (this.aclGrants.has(record.writeSid) || mode === 'read-only') return - const grant = AclWriteGrant.create(record.writeSid) + if (mode === 'read-only') return + const writeSid = workspaceWriteSid(record.workspace) + if (!this.workspaceGrants.has(record.workspace)) { + const grant = AclWriteGrant.create(writeSid) + try { + grant.add(record.workspace, true) + } catch (error) { + // Free the SID; a standing ACE (if the apply succeeded before a + // post-apply throw) is the intended end state, not an error + // artifact — nothing to revoke. + try { + grant.dispose() + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], 'sandbox-local windows-acl workspace grant failed and its cleanup also failed') + } + throw error + } + this.workspaceGrants.set(record.workspace, grant) + } + if (this.tempGrants.has(record.sessionId)) return + const grant = AclWriteGrant.create(writeSid) 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(record.tempDir) - grant.add(record.workspace) grant.add(record.tempDir) } catch (error) { // Revoke whatever stands and free the SID — never leave a half-grant @@ -438,30 +469,32 @@ export class LocalSandboxProvider extends SandboxProvider { try { grant.dispose() } catch (cleanupError) { - throw new AggregateError([error, cleanupError], 'sandbox-local windows-acl grant materialization failed and its cleanup also failed') + throw new AggregateError([error, cleanupError], 'sandbox-local windows-acl temp grant materialization failed and its cleanup also failed') } throw error } - this.aclGrants.set(record.writeSid, grant) + this.tempGrants.set(record.sessionId, grant) } /** - * Revoke every standing per-session grant and free every SID (provider - * dispose). Cleanup failures are reported, not thrown: cordis teardown - * must not be aborted by grant revocation, and the durable records make a - * missed revocation self-healing on the next resume. + * Dispose every write grant (provider dispose): the revocable temp ACEs + * are revoked and every SID allocation freed; the standing workspace ACEs + * stay (the reuse cache). Cleanup failures are reported, not thrown: + * cordis teardown must not be aborted by grant cleanup, and the durable + * records make a missed revocation self-healing on the next resume. */ private revokeAclGrants(): void { - if (this.aclGrants.size === 0) return + if (this.workspaceGrants.size === 0 && this.tempGrants.size === 0) return const failures: unknown[] = [] - for (const grant of this.aclGrants.values()) { + for (const grant of [...this.workspaceGrants.values(), ...this.tempGrants.values()]) { try { grant.dispose() } catch (error) { failures.push(error) } } - this.aclGrants.clear() + this.workspaceGrants.clear() + this.tempGrants.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) diff --git a/packages/sandbox/sandbox-local/tests/acl-session.spec.ts b/packages/sandbox/sandbox-local/tests/acl-session.spec.ts index 51b3dec417..ff04b97360 100644 --- a/packages/sandbox/sandbox-local/tests/acl-session.spec.ts +++ b/packages/sandbox/sandbox-local/tests/acl-session.spec.ts @@ -1,9 +1,11 @@ /** - * windows-acl per-session grant: the DURABLE record (log-event fold/provision - * with ownership binding + tamper validation) plus the SERVER-LIFETIME ACE - * materialization, through the REAL LocalSandboxProvider.confine() with a - * real session store. Win32 surface mocked at the package boundary; the - * real-FFI grant behavior lives in sandbox-windows-acl's win32 tests. + * windows-acl write grants: the DURABLE record (log-event fold/provision with + * ownership binding + tamper validation) plus the SERVER-LIFETIME ACE + * materialization (standing workspace grant per workspace, revocable temp + * grant per session), through the REAL LocalSandboxProvider.confine() with a + * real session store. 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. */ import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs' @@ -19,7 +21,7 @@ import { sessionTempDir } from '../src/acl-session.ts' /** Cross-file state shared with the vi.mock factory (hoisting contract). */ const mockState = vi.hoisted(() => ({ - grants: [] as Array<{ writeSid: string; added: string[]; disposed: boolean }>, + grants: [] as Array<{ writeSid: string; added: Array<{ path: string; standing: boolean }>; disposed: boolean }>, addFailure: undefined as Error | undefined, disposeFailure: undefined as Error | undefined, })) @@ -27,7 +29,7 @@ const mockState = vi.hoisted(() => ({ vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => { class MockAclWriteGrant { readonly writeSid: string - readonly added: string[] = [] + readonly added: Array<{ path: string; standing: boolean }> = [] disposed = false constructor(writeSid: string) { this.writeSid = writeSid @@ -36,20 +38,23 @@ vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => { static create(writeSid: string): MockAclWriteGrant { return new MockAclWriteGrant(writeSid) } - add(path: string): void { + add(path: string, standing = false): void { if (mockState.addFailure !== undefined) throw mockState.addFailure - this.added.push(path) + this.added.push({ path, standing }) } dispose(): void { if (mockState.disposeFailure !== undefined) throw mockState.disposeFailure this.disposed = true } } - return { AclWriteGrant: MockAclWriteGrant, randomWriteSid: () => 'S-1-4-42-42' } + return { AclWriteGrant: MockAclWriteGrant, workspaceWriteSid: () => 'S-1-4-42-42' } }) +/** The workspace-derived write SID the mock pins for every workspace. */ +const DERIVED_SID = 'S-1-4-42-42' + /** One provisioned record event, shaped like the live log's envelope. */ -function recordEvent(record: { writeSid: string; sessionId: SessionIdType; workspace: string; tempDir: string }): SessionEvent { +function recordEvent(record: { sessionId: SessionIdType; workspace: string; tempDir: string }): SessionEvent { return { type: 'sandbox/acl-session', seq: 0, time: 0, data: record } } @@ -72,7 +77,7 @@ function shapedTempPath(): string { return join(tmpdir(), `dsh-${'ab'.repeat(8)}`) } -describe('windows-acl per-session grant (LocalSandboxProvider)', () => { +describe('windows-acl write grants (LocalSandboxProvider)', () => { const scratch: string[] = [] beforeEach(() => { @@ -85,7 +90,7 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { for (const dir of scratch.splice(0)) rmSync(dir, { recursive: true, force: true }) } - it('workspace-write: first confine provisions the record and materializes the grant ONCE (--write-sid + the private temp dir)', async () => { + it('workspace-write: first confine provisions the record and materializes ONCE (standing workspace + revocable private temp)', async () => { try { const { ctx, sandbox, fiber } = await setup() const ws = workspaceRoot() @@ -95,28 +100,40 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { const confined = sandbox.confine(['pwsh', '/Command', 'x'], policy) expect(confined.argv).toContain('--write-sid') - expect(confined.argv).toContain('S-1-4-42-42') + expect(confined.argv).toContain(DERIVED_SID) expect(confined.argv).toContain('workspace-write') - expect(mockState.grants).toHaveLength(1) + expect(mockState.grants).toHaveLength(2) const tempDir = (session.events.at(-1)!.data as { tempDir: string }).tempDir scratch.push(tempDir) - expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-42-42', added: [ws, tempDir], disposed: false }) + 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(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) - // Reuse: the second confine is the map hit. + // Reuse: the second confine is the map hits. sandbox.confine(['pwsh', '/Command', 'x'], policy) - expect(mockState.grants).toHaveLength(1) + expect(mockState.grants).toHaveLength(2) expect(session.events).toHaveLength(1) 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) } finally { cleanup() } }) - it('mode switch: read-only materializes nothing, the upgrade materializes ONCE with the same SID, the downgrade keeps the standing grant', async () => { + it('mode switch: read-only materializes nothing, the upgrade materializes ONCE with the derived SID, the downgrade keeps the standing grant', async () => { try { const { ctx, sandbox } = await setup() const ws = workspaceRoot() @@ -134,28 +151,33 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { scratch.push(record.tempDir) expect(existsSync(record.tempDir)).toBe(false) - // Upgrade: first workspace-write materializes with the same SID. + // 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', record.tempDir, '--mode', 'workspace-write', - '--write-sid', 'S-1-4-42-42', + '--write-sid', DERIVED_SID, '--', 'true', ]) - expect(mockState.grants).toHaveLength(1) - expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-42-42', added: [ws, record.tempDir], disposed: false }) + 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: record.tempDir, standing: false }], + disposed: false, + }) expect(existsSync(record.tempDir)).toBe(true) - // Reuse: map hit. + // Reuse: map hits. sandbox.confine(['true'], workspaceWrite) - expect(mockState.grants).toHaveLength(1) + expect(mockState.grants).toHaveLength(2) // Downgrade: standing grant KEPT (inert under read-only, free re-upgrade). sandbox.confine(['true'], readOnly) - expect(mockState.grants).toHaveLength(1) + expect(mockState.grants).toHaveLength(2) expect(mockState.grants[0]!.disposed).toBe(false) expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) } finally { @@ -177,7 +199,7 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { '--workspace', ws, '--temp', tmpdir(), // NOT the private subdir: read-only grants nothing '--mode', 'read-only', - '--write-sid', 'S-1-4-42-42', + '--write-sid', DERIVED_SID, '--', 'true', ]) @@ -188,12 +210,12 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { } }) - it('resume: a seeded record replays with the SAME SID and no second event is appended', async () => { + it('resume: a seeded record replays the same derived SID and temp dir with no second event appended', async () => { try { const ws = workspaceRoot() scratch.push(ws) const tempDir = shapedTempPath() - const record = { writeSid: 'S-1-4-77-1', sessionId: SessionId('resumed'), workspace: ws, tempDir } + const record = { sessionId: SessionId('resumed'), workspace: ws, tempDir } scratch.push(tempDir) const first = await setup() @@ -202,9 +224,9 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('resumed') } const confined = first.sandbox.confine(['true'], policy) - expect(confined.argv).toContain('S-1-4-77-1') - expect(mockState.grants).toHaveLength(1) - expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-77-1', added: [ws, tempDir] }) + expect(confined.argv).toContain(DERIVED_SID) // re-derived from the record's workspace + expect(mockState.grants).toHaveLength(2) + expect(mockState.grants[1]).toMatchObject({ writeSid: DERIVED_SID, added: [{ path: tempDir, standing: false }] }) // Replay IS the state: nothing appended. expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) expect(session.events).toHaveLength(2) @@ -213,52 +235,52 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { } }) - it('fork: a child seeded with the PARENT\'s events ignores the parent record and provisions a fresh identity (sessionId binding)', async () => { + it('fork: a child seeded with the PARENT\'s events ignores the parent record and provisions a fresh temp identity (sessionId binding)', async () => { try { const { ctx, sandbox } = await setup() const ws = workspaceRoot() scratch.push(ws) const parentTemp = shapedTempPath() - const parentRecord = { writeSid: 'S-1-4-77-9', sessionId: SessionId('parent'), workspace: ws, tempDir: parentTemp } + const parentRecord = { sessionId: SessionId('parent'), workspace: ws, tempDir: parentTemp } scratch.push(parentTemp) // SessionStore.fork copies the parent's events verbatim — the child must NOT inherit the record. const child = ctx.sessions.create(SessionId('child'), { seed: [recordEvent(parentRecord)], meta: { cwd: ws } }) const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('child') } sandbox.confine(['true'], policy) - expect(mockState.grants).toHaveLength(1) - expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-42-42' }) // fresh, NOT the parent's + expect(mockState.grants).toHaveLength(2) + // Fresh temp identity, NOT the parent's (the workspace SID is shared by + // derivation — the workspace is the same). + const childTemp = (child.events.at(-1)!.data as { tempDir: string }).tempDir + expect(childTemp).not.toBe(parentTemp) + expect(mockState.grants[1]).toMatchObject({ added: [{ path: childTemp, standing: false }] }) expect(child.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(2) // parent's copied + child's fresh } finally { cleanup() } }) - it('fails loud on a matching-but-tampered record: non-orphan SID, foreign temp path, and non-string fields never materialize', async () => { + it('fails loud on a matching-but-tampered record: foreign temp path, empty workspace, and non-string fields never materialize', async () => { try { const { ctx, sandbox } = await setup() const ws = workspaceRoot() scratch.push(ws) - // writeSid = Everyone: would widen the grant to every token. - const everyone = { writeSid: 'S-1-1-0', sessionId: SessionId('tampered-sid'), workspace: ws, tempDir: shapedTempPath() } - ctx.sessions.create(SessionId('tampered-sid'), { seed: [recordEvent(everyone)], meta: { cwd: ws } }) - const sidPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('tampered-sid') } - expect(() => sandbox.confine(['true'], sidPolicy)).toThrow(/malformed write SID/) - // tempDir outside the host temp root. - const foreignTemp = { writeSid: 'S-1-4-42-7', sessionId: SessionId('tampered-temp'), workspace: ws, tempDir: '/attacker/path' } + const foreignTemp = { sessionId: SessionId('tampered-temp'), workspace: ws, tempDir: '/attacker/path' } ctx.sessions.create(SessionId('tampered-temp'), { seed: [recordEvent(foreignTemp)], meta: { cwd: ws } }) const tempPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('tampered-temp') } expect(() => sandbox.confine(['true'], tempPolicy)).toThrow(/outside the host temp root/) // Non-string durable fields (a corrupted/tampered JSONL payload): the - // typeof guards fail loud before any string operation runs. + // typeof guards fail loud before any string operation runs. There is + // NO stored SID to tamper with — the write SID is derived from the + // workspace path, so the old "SID rewritten to Everyone" attack + // surface does not exist. const cases: Array<{ id: string; record: Record; expect: RegExp }> = [ - { id: 'tampered-type-sid', record: { writeSid: 42, sessionId: SessionId('tampered-type-sid'), workspace: ws, tempDir: shapedTempPath() }, expect: /malformed write SID/ }, - { id: 'tampered-type-ws-null', record: { writeSid: 'S-1-4-42-6', sessionId: SessionId('tampered-type-ws-null'), workspace: null, tempDir: shapedTempPath() }, expect: /empty workspace/ }, - { id: 'tampered-type-ws-empty', record: { writeSid: 'S-1-4-42-6', sessionId: SessionId('tampered-type-ws-empty'), workspace: '', tempDir: shapedTempPath() }, expect: /empty workspace/ }, - { id: 'tampered-type-temp', record: { writeSid: 'S-1-4-42-6', sessionId: SessionId('tampered-type-temp'), workspace: ws, tempDir: 123 }, expect: /outside the host temp root/ }, + { id: 'tampered-type-ws-null', record: { sessionId: SessionId('tampered-type-ws-null'), workspace: null, tempDir: shapedTempPath() }, expect: /empty workspace/ }, + { id: 'tampered-type-ws-empty', record: { sessionId: SessionId('tampered-type-ws-empty'), workspace: '', tempDir: shapedTempPath() }, expect: /empty workspace/ }, + { id: 'tampered-type-temp', record: { sessionId: SessionId('tampered-type-temp'), workspace: ws, tempDir: 123 }, expect: /outside the host temp root/ }, ] for (const c of cases) { ctx.sessions.create(SessionId(c.id), { seed: [recordEvent(c.record as never)], meta: { cwd: ws } }) @@ -271,7 +293,7 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { } }) - it('creates the private temp dir EXCLUSIVELY: a pre-existing entry or a reparse point fails EEXIST, never receiving grants', async () => { + 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 { ctx, sandbox } = await setup() const ws = workspaceRoot() @@ -281,12 +303,15 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { const preexisting = shapedTempPath() mkdirSync(preexisting) scratch.push(preexisting) - const preRecord = { writeSid: 'S-1-4-42-8', sessionId: SessionId('preexisting'), workspace: ws, tempDir: preexisting } + const preRecord = { sessionId: SessionId('preexisting'), workspace: ws, tempDir: preexisting } ctx.sessions.create(SessionId('preexisting'), { seed: [recordEvent(preRecord)], meta: { cwd: ws } }) const prePolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('preexisting') } expect(() => sandbox.confine(['true'], prePolicy)).toThrow(/EEXIST/) - expect(mockState.grants).toHaveLength(1) - expect(mockState.grants[0]!.disposed).toBe(true) // self-revoked + // 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-')) @@ -294,12 +319,12 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { const linkPath = shapedTempPath().replace(/abab$/, 'cdcd') // distinct well-shaped name symlinkSync(target, linkPath) scratch.push(linkPath) - const linkRecord = { writeSid: 'S-1-4-42-9', sessionId: SessionId('reparse'), workspace: ws, tempDir: linkPath } + const linkRecord = { sessionId: SessionId('reparse'), workspace: ws, tempDir: linkPath } ctx.sessions.create(SessionId('reparse'), { seed: [recordEvent(linkRecord)], meta: { cwd: ws } }) const linkPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('reparse') } expect(() => sandbox.confine(['true'], linkPolicy)).toThrow(/EEXIST/) - expect(mockState.grants).toHaveLength(2) - expect(mockState.grants[1]!.disposed).toBe(true) + expect(mockState.grants).toHaveLength(4) + expect(mockState.grants[3]!.disposed).toBe(true) } finally { cleanup() } @@ -310,7 +335,7 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { const { ctx, sandbox } = await setup() const ws = workspaceRoot() scratch.push(ws) - const mismatched = { writeSid: 'S-1-4-77-2', sessionId: SessionId('stale'), workspace: '/somewhere-else', tempDir: shapedTempPath() } + const mismatched = { sessionId: SessionId('stale'), workspace: '/somewhere-else', tempDir: shapedTempPath() } ctx.sessions.create(SessionId('stale'), { seed: [recordEvent(mismatched)], meta: { cwd: ws } }) const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('stale') } expect(() => sandbox.confine(['true'], policy)).toThrow(/does not match the resolved policy root/) @@ -336,7 +361,7 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { } }) - it('a grant failure mid-materialization revokes what was granted and rethrows (AggregateError when the cleanup also fails)', async () => { + it('a grant failure mid-materialization disposes the failed grant and rethrows (AggregateError when the cleanup also fails)', async () => { try { const { ctx, sandbox } = await setup() const ws = workspaceRoot() @@ -344,7 +369,7 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { const session = ctx.sessions.create(SessionId('sess-add-fail'), { meta: { cwd: ws } }) const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-add-fail') } - // add() throws on the FIRST path: cleanup dispose() runs, original error propagates. + // 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') scratch.push((session.events.at(-1)!.data as { tempDir: string }).tempDir) @@ -381,7 +406,7 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { } }) - it('a failing revoke at provider dispose is reported via ctx.logger.warn and never thrown into teardown', async () => { + it('a failing dispose 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() @@ -390,12 +415,13 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-dispose') } sandbox.confine(['true'], policy) scratch.push((session.events.at(-1)!.data as { tempDir: string }).tempDir) - expect(mockState.grants).toHaveLength(1) + expect(mockState.grants).toHaveLength(2) mockState.disposeFailure = new Error('revoke exploded') const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) await fiber.dispose() - expect(warn).toHaveBeenCalledWith(expect.stringContaining('cleanup completed with 1 failure')) + // BOTH grants (standing workspace + revocable temp) fail their dispose. + expect(warn).toHaveBeenCalledWith(expect.stringContaining('cleanup completed with 2 failures')) expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'revoke exploded' })) } finally { cleanup() diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index 3fb0ec6692..7ba547bc10 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: 80d0502c3ace71da49e8f40efc6aa379286c74ac -README.zh.md: b0c50af1b339a9fdc206778b6184b8e9121ef19c +README.md: 3185510142750178b11c2f6bad863a0a4861a29c +README.zh.md: ab45d29c7dea79c8a75ac2187140e5926cf71581 diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index 80d0502c3a..3185510142 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -4,29 +4,30 @@ 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 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 an orphan SID (`S-1-4-x-y`) whose Write ACEs exist only on the session's workspace and private temp directories (the seam provisions ONE SID per session and materializes the ACEs for the server's lifetime — 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 orphan SID is the write allowlist, and it grants nothing anywhere else on the system; the token's write check also inherits the ambient write ACEs of the OTHER restricting SIDs (the keep-alive group logon SID + Everyone — the Modes section below is the complete boundary). +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 write allowlist, and it grants nothing anywhere else on the system; the token's write check also inherits the ambient write ACEs of the OTHER restricting SIDs (the keep-alive group logon SID + Everyone — the Modes section below is the complete boundary). 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 } from '@deepseek-ai/dsh-sandbox-windows-acl' +import { AclSandbox, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' const workspaceRoot = process.cwd() // mode selects the token's restricting-SID list (see Modes below) and must -// match the grant shape: read-only pairs with zero grants. -const sandbox = new AclSandbox({ writableDirs: [workspaceRoot], mode: 'workspace-write' }) +// 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' }) 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 all standing grants; reports every cleanup failure +sandbox.dispose() // revokes the revocable (temp) grant, keeps the standing workspace ACE; reports every cleanup failure ``` -A direct `AclSandbox` grants and revokes per instance (one allowlist per spawn cycle). The server-side per-session reuse is the `AclWriteGrant` class: one instance per session, `add()` per directory, `dispose()` on provider shutdown — 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` 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. ## The confinement runner @@ -36,17 +37,17 @@ The seam-facing shape is the **runner entry** (`./runner`), the argv-prefix wrap node runner.js --workspace --temp --mode [--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 all grants on exit. 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 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. -**Per-session grant reuse** (`--write-sid`): the seam provisions ONE orphan SID per session — stored as a log-only `sandbox/acl-session` event on the session log (bound to the owning session id, validated at the fold), so a resumed session replays the SAME SID and a fork mints a fresh one — and materializes its ACEs lazily at the session's first confined execution, holding them for the SERVER process's lifetime (revoked on provider dispose). A fresh provision kicks an IMMEDIATE persistence flush right after the append (no write-behind debounce), so the record is durable within the flush latency — a crash inside that window can strand inert orphan-SID ACEs, the one documented self-healing gap (the spawn seams are synchronous, so no await barrier exists between record and ACEs). Under `--write-sid` the runner neither grants nor revokes (`manageDacls: false`); without it (standalone use) it self-manages per-call grants as before. 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: the session's record re-grants the same SID, and the next dispose revokes them. Known cost: materializing a grant on a big workspace tree blocks for the full eager propagation once per session per server lifetime. +**Workspace grant reuse** (`--write-sid`): the write SID is DERIVED from the workspace path — no SID is stored anywhere (the previous per-session random SID and its tamper surface are gone). The seam still provisions ONE log-only `sandbox/acl-session` event per session (bound to the owning session id, validated at the fold) carrying the session's workspace binding and PRIVATE temp subdirectory: a resumed session replays the same temp dir, a fork mints a fresh one. 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. A fresh provision kicks an IMMEDIATE persistence flush right after the append (no write-behind debounce), so the record is durable within the flush latency — a crash inside that window can strand the private temp directory unrecorded, the one documented self-healing gap (the spawn seams are synchronous, so no await barrier exists between record and ACEs). 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). 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, orphan): the workspace and the session's PRIVATE temp subdirectory carry the orphan-SID Write grant; every other write is denied by the token intersection. -- `read-only` (logon SID, Everyone — NO orphan): STRICT zero grants — nothing is writable. The orphan stays OUT of the list on purpose: a standing 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 unrevoked ACE keeps the re-upgrade free of re-propagation. The NUL device is a securable object and is NOT granted (unlike Linux's `/dev/null` sink): `Set-Content NUL` and native `> NUL` writes fail with access denied, while PowerShell's `> $null` redirection keeps working (it discards without opening NUL). +- `workspace-write` (logon SID, Everyone, write SID): the workspace and the session's PRIVATE temp subdirectory carry the write-SID Write grant; every other write is denied by the token intersection. +- `read-only` (logon SID, Everyone — NO write SID): STRICT zero grants — nothing is writable. 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. The NUL device is a securable object and is NOT granted (unlike Linux's `/dev/null` sink): `Set-Content NUL` and native `> NUL` writes fail with access denied, while 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 per-session contract. +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. ## Header verification @@ -62,7 +63,7 @@ The koffi struct definitions assert their sizes against the probe at module load - **Writes are restricted; reads, network, and process visibility are not.** `WRITE_RESTRICTED` intersects write accesses only, so a confined child can read any caller-readable file and open sockets. `read-only` mode therefore cannot be expressed by this mechanism alone; pair it with a read-side policy or an AppContainer/`S-1-15-2` capability token for stronger confinement. - **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; `dispose()` revokes them, and `init()` revokes already-applied grants 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. The per-session record makes an unclean shutdown self-healing: the same SID is re-granted on resume (skipping the apply when the ACE stands) and revoked at the next dispose; orphan ACEs never accumulate a new SID per restart. +- **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 random hex>`, 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 itself is plain `%TEMP%` litter with no garbage collection: OS temp hygiene reclaims it, and the record's determinism lets a later resume reuse it. @@ -78,11 +79,12 @@ None directly; the denial surface belongs to the tool layer. ## Known Limitations and Deferred Work -- **One write allowlist per instance** — the orphan SID is the unit of the allowlist; reusing one sandbox instance across two workspaces widens both grants to both roots. Create one instance per workspace root (the seam's per-session record does exactly this: one SID per session, keyed to the session's immutable cwd). -- **Cleanup is best-effort by design** — `dispose()` attempts every revocation and aggregates failures into an `AggregateError`; a cleanup failure leaves a standing (but orphan-SID-only) ACE that this process's next `init()`/`dispose()` cycle or `icacls` (via the ACE, not the trustee name) can still remove. +- **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. +- **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. -- **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-session reuse pays it once per session per server lifetime (lazily at the first confined execution, skipped entirely when the exact ACE survives a restart); the self-managed runner fallback still pays it per invocation. If a session's workspace is huge, the first pwsh call of each server lifetime is correspondingly slow. -- **Resuming one session concurrently in two server processes grants two SIDs.** The durable record lives in the session log; both processes read or provision it independently, the per-path lock keeps the DACL merges consistent, and the last-written record wins for future resumes — the losing SID's ACEs are revoked by its own process's dispose. 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 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 races the record.** The durable record lives in the session log; both processes read or provision it independently — the derived write SID is identical, the per-path lock keeps the DACL merges consistent, and the private temp dir race resolves by the last-written record winning for future resumes. Single-writer session usage (the normal deployment) never sees this. - **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. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index b0c50af1b3..ab45d29c7d 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -2,87 +2,89 @@ [English](README.md) | 中文 -面向 [harness 沙盒接口](../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/) 链的 win32 档(`workspace-write` / `read-only` 模式)挂载;同一包还携带 Linux/macOS 后端。 +面向 [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/) 链的 win32 一级(`workspace-write` / `read-only` 两种模式);Linux/macOS 后端在同一包中。 -一句话机制:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 中加入一个孤儿 SID(`S-1-4-x-y`),该 SID 的 Write ACE 只存在于会话的工作区与私有临时目录上(seam 为每个会话只配置一个 SID,并为服务器的生命周期物化 ACE——见[隔离 runner](#the-confinement-runner))。此后 Windows 只在「调用者正常权限」与「restricting SID 交集」同时允许时才放行写入——孤儿 SID 就是写入白名单,而它在系统其余位置不授予任何权限;令牌的写检查还会继承**其他** restricting SID 的环境写 ACE(保活组登录 SID + Everyone——下文「模式」段是完整边界)。 +一句话机制:把调用者令牌复制为 `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——下文「模式」段是完整边界)。 -直接基于原始 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 则根本不支持任意路径读)。 +直接构建在原生 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 } from '@deepseek-ai/dsh-sandbox-windows-acl' +import { AclSandbox, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' const workspaceRoot = process.cwd() // mode selects the token's restricting-SID list (see Modes below) and must -// match the grant shape: read-only pairs with zero grants. -const sandbox = new AclSandbox({ writableDirs: [workspaceRoot], mode: 'workspace-write' }) +// 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' }) 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 all standing grants; reports every cleanup failure +sandbox.dispose() // revokes the revocable (temp) grant, keeps the standing workspace ACE; reports every cleanup failure ``` -直接使用 `AclSandbox` 时按实例授权与回收(每个 spawn 周期一个白名单)。服务器侧的按会话复用是 `AclWriteGrant` 类:每个会话一个实例,每个目录一次 `add()`,提供方关闭时 `dispose()` ——见下方 runner 契约。本包对**每一个** Win32 API 调用都做返回值检查;失败抛出 `Win32Error`,携带 API 名、精确的 Win32 错误码、`FormatMessageW` 系统文本和出错的路径/上下文。这是有意为之:原 POC 忽略所有返回值,当 `CreateRestrictedToken` 失败时会静默地用**完整未受限令牌**运行子进程(fail-open)。本移植从构造上保证 fail-closed。 +直接使用 `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。 ## 隔离 runner -面向 seam 的形态是 **runner 入口**(`./runner`):`@deepseek-ai/dsh-sandbox-local` 用它替换调用方命令的 argv 前缀包装——与 bwrap/landlock-run/sandbox-exec 同一架构,因此沙盒 seam 的 `confine()` 契约**无需任何改动**。稳定的 argv 契约: +面向 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 ] -- ``` -runner 创建受限令牌,在令牌下启动被包裹的 argv,stdio 直接透传(spawn 前后把调用方的管道句柄恢复/清除继承位——Node 启动时会清掉自身 stdio 的继承位,裸 spawn 必须补偿这一点),把子进程放进 `KILL_ON_JOB_CLOSE` 作业(runner 死亡即杀死子进程),忽略自身的控制台 Ctrl+C 让子进程自行处理,镜像子进程退出码,退出时回收所有授权。任何 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`):seam 为每个会话只配置一个孤儿 SID——以仅作日志记录的 `sandbox/acl-session` 事件写入会话日志(绑定其所属会话 id,在 fold 处校验),因此恢复的会话回放**同一个** SID,fork 则铸造一个新的——并在会话首次受限执行时惰性物化其 ACE,在**服务器**进程生命周期内持有(提供方 dispose 时撤销)。新供给在追加之后立即触发一次**即时**持久化 flush(无 write-behind 去抖),因此记录在 flush 延迟内即持久化——在该窗口内崩溃可能遗留失效的孤儿 SID ACE,这是唯一记录在案的自愈缺口(spawn seam 是同步的,因此记录与 ACE 之间不存在 await 屏障)。传入 `--write-sid` 时 runner 既不授权也不回收(`manageDacls: false`);不传它(独立使用)则与之前一样按调用自行管理授权。重启后重新授权是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过 `SetNamedSecurityInfoW` 的应用(该应用会把相同的 ACE 急切地重新传播到整棵树——大型工作区上以分钟计)。异常关闭遗留的 ACE 无需垃圾回收:会话记录重新授权同一个 SID,下一次 dispose 即撤销它们。已知代价:在大型工作区树上物化授权会阻塞整次急切传播,每个服务器生命周期内每会话一次。 +**按工作区授权复用**(`--write-sid`):写入 SID 从工作区路径**派生**——任何地方都不存储 SID(先前每会话随机 SID 及其篡改面已移除)。seam 仍会为每个会话只供给一条仅作日志记录的 `sandbox/acl-session` 事件(绑定其所属会话 id,在 fold 处校验),携带会话的工作区绑定与**私有**临时子目录:恢复的会话回放同一个临时目录,fork 则铸造一个新的。seam 把工作区 ACE **常驻**物化(每个工作区每服务器生命周期一次,绝不撤销——它就是复用缓存),把临时 ACE **可回收**物化(提供方 dispose 时撤销),两者都在会话首次受限执行时惰性进行。新供给在追加之后立即触发一次**即时**持久化 flush(无 write-behind 去抖),因此记录在 flush 延迟内即持久化——在该窗口内崩溃可能遗留未记录的私有临时目录,这是唯一记录在案的自愈缺口(spawn seam 是同步的,因此记录与 ACE 之间不存在 await 屏障)。传入 `--write-sid` 时 runner 既不授权也不回收(`manageDacls: false`)——该标志的存在标记 seam 管理的契约,其值即派生 SID;不传它(独立使用)时 runner 用**同一个**派生 SID 自行管理(工作区 ACE 常驻,临时 ACE 每次调用可回收)。重启后重新授权是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过 `SetNamedSecurityInfoW` 的应用(该应用会把相同的 ACE 急切地重新传播到整棵树——大型工作区上以分钟计)。异常关闭遗留的 ACE 无需垃圾回收——它们**就是**缓存;同一个派生 SID 永远重新命中它们。已知代价:在大型工作区树上物化授权会阻塞整次急切传播,每台机器每个工作区一次(该主机上的第一次受限写入)。 -模式(令牌的 restricting SID 列表随模式而定;保活组在**两种**模式下都是登录 SID + Everyone——没有它们,早期 DLL init 会以 `0xC0000142` 死亡,CNG 会让 pwsh 以 `0xE0434352` 崩溃): -- `workspace-write`(登录 SID、Everyone、孤儿 SID):工作区与会话的**私有**临时子目录携带孤儿 SID 的 Write 授权;其余写全部被令牌交集拒绝。 -- `read-only`(登录 SID、Everyone——不含孤儿 SID):**严格零授权**——没有任何可写位置。孤儿 SID 有意留在列表**之外**:先前 workspace-write 时期留下的驻留授权 ACE(`/permission` 降级,或崩溃后恢复的会话)在 read-only 下保持**失效**,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容——而未撤销的 ACE 让重新升级免于重新传播。NUL 设备是带安全描述符的对象,同样不被授权(区别于 Linux 的 `/dev/null` sink):`Set-Content NUL` 与原生 `> NUL` 写会以 access denied 失败,而 PowerShell 的 `> $null` 重定向不受影响(它直接丢弃、不打开 NUL)。 +模式(令牌的 restricting-SID 列表随模式而变;保活组登录 SID + Everyone 在**两种**模式下都存在——没有它们早期 DLL 初始化会以 `0xC0000142` 死亡、CNG 会让 pwsh 以 `0xE0434352` 崩溃): +- `workspace-write`(登录 SID、Everyone、写入 SID):工作区与会话的**私有**临时子目录携带写入 SID 的 Write 授权;其余写全部被令牌交集拒绝。 +- `read-only`(登录 SID、Everyone——**不含**写入 SID):**严格零授权**——没有任何可写位置。写入 SID 有意留在列表**之外**:先前 workspace-write 时期留下的常驻授权 ACE(`/permission` 降级,或崩溃后恢复的会话)在 read-only 下保持**失效**,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容——而常驻 ACE 让重新升级免于重新传播。NUL 设备是带安全描述符的对象,同样不被授权(区别于 Linux 的 `/dev/null` sink):`Set-Content NUL` 与原生 `> NUL` 写会以 access denied 失败,而 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-probe 回归钉住(见设计笔记)。 +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: null` 禁用临时授权)仍是直接 spawn 的编程 API;`AclWriteGrant` 是授权生命周期的服务端物化一半。 -## 头文件查证 +## 头部验证 -所有常量、函数签名和结构体布局都对照开发机的 Windows 头文件(MinGW `winnt.h` / `accctrl.h` / `aclapi.h` / `securitybaseapi.h` / `sddl.h` / `processthreadsapi.h` / `fileapi.h` / `namedpipeapi.h` / `synchapi.h` / `winbase.h`)逐一核实,并由 [`verify/abi-probe.cpp`](verify/abi-probe.cpp)(尺寸、偏移、枚举值、static_assert)交叉验证: +所有常量、签名与结构体布局都在开发机上对照 Windows 头文件(MinGW `winnt.h` / `accctrl.h` / `aclapi.h` / `securitybaseapi.h` / `sddl.h` / `processthreadsapi.h` / `fileapi.h` / `namedpipeapi.h` / `synchapi.h` / `winbase.h`)验证过,并在运行时由 [`verify/abi-probe.cpp`](verify/abi-probe.cpp)(大小、偏移、枚举值、静态断言)交叉检查: ```sh g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && ./abi-probe.exe ``` -模块加载时 koffi 结构体定义会与探针输出比对尺寸,头文件/koffi 布局一旦漂移立即报错,而不是悄悄写坏内存。 +koffi 结构体定义在模块加载时对照探针断言其大小,因此头文件/koffi 布局漂移会大声失败而不是破坏内存。 -## 已验证的边界(受限令牌固有,非本移植缺陷) +## 已验证边界(受限令牌固有,非本移植引入) -- **只限制写;读、网络、进程可见性均不受限。** `WRITE_RESTRICTED` 只对写访问做交集检查,受限子进程可以读取调用者能读的任何文件、可以开 socket。因此 `read-only` 模式无法仅靠本机制表达,需要叠加读侧策略或改用 AppContainer/`S-1-15-2` capability 令牌做强隔离。 -- **控制台隔离不可用。** 受限令牌下用 `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` 创建的子进程会在 DLL 初始化阶段以 `STATUS_DLL_INIT_FAILED`(`0xC0000142`)死亡。POC 曾试图把控制台登录 SID(`S-1-2-1`)加进 restricting 列表来修复:在 Windows 11 26200 上 `CreateWellKnownSid(WinLocalLogonSid)` 直接失败(`ERROR_INVALID_PARAMETER` 87),改用正确的 `WinConsoleLogonSid` 虽能得到合法的 `S-1-2-1`,子进程仍然死亡,POC 最终版本遂删除了该 SID 并放弃控制台隔离。因此子进程共享宿主控制台;stdio 重定向走管道,不受影响。 -- **ACL 授权是对真实目录的驻留改动。** 进程中途死亡会留下授权;`dispose()` 负责回收,`init()` 后续步骤失败时也会回滚已应用的授权。POC 注释里的手工清理命令(`icacls /remove '*S-1-4-…'`)在本平台实测失败(`ERROR_NONE_MAPPED` 1332)——请通过本模块回收。按会话记录让异常关闭可自愈:恢复时重新授权同一个 SID(ACE 已存在则跳过应用),并在下一次 dispose 撤销;孤儿 ACE 不会因每次重启而累积新 SID。 -- **被授权目录必须归调用者所有。** 所有者隐含的 `WRITE_DAC` 是免提权改 DACL 的前提。 -- **临时目录授权跟随 `GetTempPathW`** —— 尽可能显式传入 `tempDir`。`GetTempPathW` 读取的是原生环境块,用 worker 池管理 `process.env` 的宿主运行时(vitest 实测)不会把 worker 侧的 `process.env.TMP` 改动同步过去。seam 会传入会话的**私有**子目录(`\dsh-<16 random hex>`,独占创建——已有条目或 reparse point 会响亮失败);若默认授权落到真实临时目录,其 `(OI)(CI)` 继承会覆盖 temp 下所有子目录、静默扩大白名单——请指向按沙盒隔离的目录。 +- **写入受限;读取、网络与进程可见性不受限。** `WRITE_RESTRICTED` 只交叉检查写访问,因此受限子进程可以读取调用者可读的任何文件并打开套接字。`read-only` 模式因而不能仅靠该机制表达;将其与读侧策略或 AppContainer/`S-1-15-2` capability 令牌配对以获得更强隔离。 +- **控制台隔离不可用。** 在受限令牌下,以 `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` 创建的子进程在 DLL 初始化期间以 `STATUS_DLL_INIT_FAILED`(`0xC0000142`)死亡。POC 尝试把控制台登录 SID(`S-1-2-1`)加入 restricting 列表来修复;在 Windows 11 26200 上 `CreateWellKnownSid(WinLocalLogonSid)` 以 `ERROR_INVALID_PARAMETER`(87)失败,正确的 `WinConsoleLogonSid` 能产出合法 `S-1-2-1` 但子进程仍然死亡,POC 的最终修订同时移除了该 SID 与控制台隔离。子进程因此共享宿主控制台;stdio 重定向走管道,不受影响。 +- **ACL 授权是对真实目录的驻留改动。** 进程中途死亡会留下授权;工作区 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>`,独占创建——已存在条目或重解析点会大声失败);默认授权落在真实临时目录上会让 `(OI)(CI)` 继承到临时目录的每个子目录,静默扩大白名单——请改指向每个沙盒的目录。 - **受限子进程的临时根目录按会话私有**(workspace-write + `--write-sid`):runner 在 spawn 之前用 `SetEnvironmentVariableW` 把 TMP/TEMP 改写为会话的私有子目录,子进程继承改写后的环境块(bwrap `--tmpfs /tmp` 的语义)。read-only 保持环境中的临时目录条目不动——那里的写入反正会被拒绝。子目录本身只是 `%TEMP%` 下的普通垃圾、没有垃圾回收:OS 对临时目录的日常清理会回收它,记录的确定性让之后的恢复可以复用它。 -- **`whoami` 与令牌检查类 cmdlet 在受限令牌下会失败。** 副本上的 `GetTokenInformation` 对子进程部分不可用,因此 `whoami /all` 会报错——这是受限方案的诊断噪音,而非运行故障;真正重要的拒绝面(文件写入)不受影响。 +- **受限令牌下 `whoami` 与令牌检查 cmdlet 会失败。** 子进程对复制令牌的 `GetTokenInformation` 部分不可用,因此 `whoami /all` 报错——这是限制方案的诊断噪音,不是运行故障;真正重要的拒绝面(文件写入)不受影响。 -## 模型体验 +## Model Experience -经 [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md)、[`dsh-pwsh-sandbox`](../../bash/pwsh-sandbox/README.md) 及其工具间接生效:它们渲染本后端的强制完整性与拒绝事实(受限 stderr 由工具层按 `denialSignatures` 分类),而 [`dsh-sandbox`](../sandbox/README.md) seam 拥有 `SANDBOX_UNAVAILABLE` 文本与 runner 选择。 +间接地通过 [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md)、[`dsh-pwsh-sandbox`](../../bash/pwsh-sandbox/README.md) 及其工具呈现:它们渲染此后端的强制与拒绝事实(工具层通过 `denialSignatures` 分类的受限 stderr),而 [`dsh-sandbox`](../sandbox/README.md) seam 拥有 `SANDBOX_UNAVAILABLE` 文本与 runner 选择。 #### KV Cache 影响 -无直接影响;拒绝呈现面属于工具层。 +无直接影响;拒绝面属于工具层。 -## 已知限制与后续工作 +## Known Limitations and Deferred Work -- **每个实例一个写入白名单** —— 孤儿 SID 是白名单的基本单位;同一沙盒实例跨两个工作区复用时,两个根目录会互相扩大授权面。请按工作区根目录各建一个实例(seam 的按会话记录正是这样做的:每个会话一个 SID,以会话不可变的 cwd 为键)。 -- **清理尽力而为** —— `dispose()` 会尝试全部回收并把失败聚合为 `AggregateError`;清理失败只会留下仅含孤儿 SID 的 ACE,本进程下次 `init()`/`dispose()` 循环或 `icacls`(按 ACE 而非受托者名)仍可清除。 -- **NULL DACL 目录在 grant+revoke 下不保持身份。** 带 NULL DACL 的目录(罕见——Windows 创建的目录都带真实 DACL)意味着「所有人完全控制」;`grantWrite` 从该 null 构建新 ACL,而 revoke 往返之后留下的是**空**(deny-all)DACL,而非原来的 NULL DACL。POC 也有同样行为;真实的工作区与临时目录都带真实 DACL,因此这仍是一条记录在案的边角,而非被守护的路径。 -- **授权物化是急切的全树传播。** 对带可继承 ACE 的目录调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(**不是**按访问惰性求值——实测在大型工作区树加上真实临时根上要几十秒)。按会话复用使它在每个服务器生命周期内每会话只付一次(在首次受限执行时惰性发生;完全相同的 ACE 历经重启存活时整体跳过);自管理的 runner 回退路径仍每次调用都付。若会话的工作区巨大,每个服务器生命周期内的第一次 pwsh 调用会相应地变慢。 -- **在两个服务器进程中并发恢复同一会话会产生两个 SID。** 持久化记录存放在会话日志中;两个进程各自读取或创建记录,按路径的锁保持 DACL 合并一致,最后写入的记录胜出并用于后续恢复——落败 SID 的 ACE 由其所属进程的 dispose 撤销。单写者的会话用法(常规部署形态)不会遇到这种情况。 -- **读侧隔离与网络策略超出范围** —— `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 运行。 +- **每个工作区一个写入白名单** —— 写入 SID 是白名单的基本单位,且**就是**工作区身份;同一沙盒实例跨两个工作区复用时,两个根目录会互相扩大授权面(同一个 SID 将命名两个根)。请按工作区根目录各建一个实例——seam 正是这样做的,以工作区路径为键。 +- **清理尽力而为** —— `dispose()` 会尝试全部临时撤销并把失败聚合为 `AggregateError`;清理失败只会留下仅含写入 SID 的临时 ACE,本进程下次 `init()`/`dispose()` 循环或 `icacls`(按 ACE 而非受托者名)仍可清除。 +- **常驻工作区 ACE 是不可见残留。** 工作区改名会派生新的 SID;旧路径上的旧 ACE 留在原地(失效、仅含写入 SID)。未来的清理命令可以回收它们;它们不会引起任何重新传播。 +- **NULL-DACL 目录在 grant+revoke 往返下不保持身份。** 带 NULL DACL 的目录(罕见——Windows 创建的目录都带真实 DACL)意味着「所有人完全控制」;`grantWrite` 从该 null 构建新 ACL,撤销往返后留下的是 EMPTY(全部拒绝)DACL 而非原始 NULL DACL。POC 行为相同;真实工作区与临时目录都带真实 DACL,因此这仍是记录在案的边界情形而非守护路径。 +- **授权物化是急切的全树传播。** 在带可继承 ACE 的目录上调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(**不是**按访问惰性进行——大型工作区树上实测数十秒,加上真实临时根目录)。按工作区身份每台机器每个工作区只付一次(在首次受限执行时惰性进行,之后每次供给在精确 ACE 常驻时完全跳过)。如果工作区巨大,该主机上的第一次受限写入相应变慢。 +- **两个服务器进程并发恢复同一会话会竞争记录。** 持久记录在会话日志中;两个进程独立读取或供给它——派生出的写入 SID 相同,每路径锁保持 DACL 合并一致,私有临时目录的竞争以后写记录对后续恢复生效而解决。单写者会话用法(常规部署)永远不会遇到。 +- **读侧隔离与网络策略不在范围内** —— `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 下运行。 diff --git a/packages/sandbox/sandbox-windows-acl/src/grant.ts b/packages/sandbox/sandbox-windows-acl/src/grant.ts index 7826922f1d..7cfd6e1a36 100644 --- a/packages/sandbox/sandbox-windows-acl/src/grant.ts +++ b/packages/sandbox/sandbox-windows-acl/src/grant.ts @@ -19,16 +19,22 @@ import { allocPtrSlot, decodePtr, isNullPtr, throwLastError, win32Sync } from '. import type { NativePtr, Win32Bindings } from './ffi.ts' /** - * One orphan write SID's server-lifetime grant materialization: the parsed - * SID pointer plus every directory whose DACL currently carries its ACE. - * Create with {@link AclWriteGrant.create}; dispose revokes all. + * One write SID's server-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 + * provision would re-propagate the whole tree); temp paths are revocable + * (dispose() revokes them — an inheritable ACE must not outlive its + * session's temp directory). Create with {@link AclWriteGrant.create}; + * dispose revokes the revocable paths and frees the SID. */ export class AclWriteGrant { - /** The orphan write SID in SDDL string form. */ + /** The write SID in SDDL string form. */ readonly writeSid: string private readonly api: Win32Bindings private readonly sidPtr: NativePtr - private readonly grantedPaths: string[] = [] + private readonly revocablePaths: string[] = [] + private readonly standingPaths: string[] = [] private constructor(api: Win32Bindings, sidPtr: NativePtr, writeSid: string) { this.api = api @@ -57,28 +63,31 @@ export class AclWriteGrant { /** * Grant the write ACE on one directory (idempotent: an already-standing * exact ACE skips the eager full-tree re-propagation — see - * {@link grantWrite}) and record the path for {@link dispose}. The path is - * recorded BEFORE the grant: a post-apply throw (a LocalFree failure after - * SetNamedSecurityInfoW succeeded) must still revoke it, and revoking an - * ungranted path is a no-op merge. Callers treat a throw as a failed - * materialization and dispose the instance to revoke the paths granted so - * far. + * {@link grantWrite}) and record the path for {@link dispose} unless it is + * standing. The path is recorded BEFORE the grant: a post-apply throw (a + * LocalFree failure after SetNamedSecurityInfoW succeeded) must still + * revoke it, and revoking an ungranted path is a no-op merge. Callers + * treat a throw as a failed materialization and dispose the instance to + * revoke the paths granted so far. * @param path - the directory whose DACL gains the grant. + * @param standing - the ACE outlives this grant (the workspace reuse + * cache; dispose() skips revoking it). Default false (revoked on + * dispose — the temp-directory lifecycle). */ - add(path: string): void { - this.grantedPaths.push(path) + add(path: string, standing = false): void { + ;(standing ? this.standingPaths : this.revocablePaths).push(path) grantWrite(this.api, path, this.sidPtr) } /** Every directory currently carrying the grant, in grant order. */ get paths(): readonly string[] { - return this.grantedPaths + return [...this.standingPaths, ...this.revocablePaths] } - /** Revoke every standing grant and free the SID; reports every cleanup failure. */ + /** Revoke every revocable grant (standing ACEs stay) and free the SID; reports every cleanup failure. */ dispose(): void { const failures: unknown[] = [] - for (const path of this.grantedPaths) { + for (const path of this.revocablePaths) { try { revokeWrite(this.api, path, this.sidPtr) } catch (error) { diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 93e0e33c69..6180927123 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -2,17 +2,21 @@ * 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 an orphan SID (`S-1-4-x-y`) that only - * this sandbox instance adds to the target directories' DACLs — the - * intersection check then allows writes exactly where that SID has a Write - * ACE, and nowhere else the orphan SID is concerned (the token's write 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 in `packages/sandbox/sandbox-local` and the package - * README's Modes section for the complete boundary). Unlike the POC, every - * API failure throws with the API name and exact Win32 code; a child is - * NEVER spawned unrestricted. + * 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 + * 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 + * in `packages/sandbox/sandbox-local` and the package README's Modes section + * for the complete boundary). The write SID is the per-WORKSPACE identity + * ({@link workspaceWriteSid}): deterministic from the canonical workspace + * path, so the workspace-root ACE materializes once per workspace per + * machine and every later provision hits the exact-ACE skip — the + * grant-reuse story the per-session random SID paid a full tree propagation + * per session for. 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): * - writes are restricted; reads, network, and process visibility are NOT @@ -22,17 +26,19 @@ * STATUS_DLL_INIT_FAILED under the restriction); * - the temp directory and every writable directory must be owned by the * caller (owner-implicit WRITE_DAC); - * - grants are standing ACE mutations on real directories — revoke them via - * dispose() before the process exits (the POC's documented - * `icacls /remove '*S-1-4-…'` cleanup fails with ERROR_NONE_MAPPED; use - * this module's revoke instead). With `manageDacls: false` the CALLER owns - * the DACLs (the sandbox seam's per-session grant reuse): init()/dispose() - * skip grant/revoke entirely and the caller must not revoke under live - * children. + * - 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` + * 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. * @module @deepseek-ai/dsh-sandbox-windows-acl */ -import { randomInt } from 'node:crypto' import { existsSync, statSync } from 'node:fs' import { resolve } from 'node:path' @@ -46,6 +52,7 @@ import * as abi from './win32-abi.ts' export { quoteArg } from './spawn.ts' export { AclWriteGrant } from './grant.ts' +export { workspaceWriteSid } from './workspace-sid.ts' export { Win32Error } from './errors.ts' /** Construction options: the write allowlist, the optional temp grant, and the orphan SID identity. */ @@ -58,7 +65,13 @@ export interface AclSandboxOptions { * allowance — not even the NUL device is writable, see README). */ tempDir?: string | null - /** Orphan write SID; defaults to a random `S-1-4-x-y` (fresh allowlist per sandbox). */ + /** + * The write SID forming the workspace-write allowlist: REQUIRED under + * workspace-write, ignored (and must be absent) under read-only. Callers + * derive it from the workspace via {@link workspaceWriteSid} — the identity + * is per workspace, not per sandbox instance, so the workspace-root ACE + * outlives every instance and later provisions hit the exact-ACE skip. + */ writeSid?: string /** * The file-effect mode this instance confines under — selects the @@ -109,25 +122,20 @@ export interface AclSandboxChild { wait(): Promise } -/** Mint a fresh orphan write SID (`S-1-4-x-y`; the subauthorities are 30-bit). - * @returns the SDDL string form. - */ -export function randomWriteSid(): string { - return `S-1-4-${randomInt(1, 2 ** 30)}-${randomInt(1, 2 ** 30)}` -} - /** - * One write-restricted sandbox instance: token + orphan-SID grants + spawn. - * `init()` is fail-closed — any Win32 failure revokes whatever was granted - * and throws; `dispose()` revokes all grants and reports every cleanup - * failure. With `manageDacls: false` the caller owns the grants (per-session + * One write-restricted sandbox instance: token + write-SID grants + spawn. + * `init()` is fail-closed — any Win32 failure revokes the revocable (temp) + * grants and throws; `dispose()` revokes the temp grants, leaves the + * standing workspace ACEs in place (the cross-instance reuse cache), frees + * every allocation, and reports every cleanup failure. With + * `manageDacls: false` the caller owns the grants (the sandbox seam's grant * reuse): init() applies none and dispose() revokes none. */ export class AclSandbox { /** Absolute writable directories (constructor-validated). */ readonly writableDirs: string[] - /** The orphan SID string whose ACEs form the write allowlist. */ - readonly writeSid: string + /** The write SID string whose ACEs form the write allowlist (workspace-write only). */ + readonly writeSid: 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 @@ -151,7 +159,10 @@ export class AclSandbox { return absolute }) this.tempDirOption = options.tempDir - this.writeSid = options.writeSid ?? randomWriteSid() + this.writeSid = options.writeSid + 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()') + } } /** Resolved temp directory (available after init; null when temp grants are disabled). */ @@ -166,14 +177,19 @@ export class AclSandbox { const currentToken = openCurrentProcessToken(api) try { - const sidSlot = allocPtrSlot() - if (api.convertStringSidToSidW(this.writeSid, sidSlot) === 0) { - throwLastError(api, 'ConvertStringSidToSidW', this.writeSid) + // 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 sidSlot = allocPtrSlot() + if (api.convertStringSidToSidW(this.writeSid, sidSlot) === 0) { + throwLastError(api, 'ConvertStringSidToSidW', this.writeSid) + } + const parsedSid = decodePtr(sidSlot) + if (parsedSid === null) throw new Win32Error('ConvertStringSidToSidW', api.getLastError(), this.writeSid) + this.writeSidPtr = parsedSid + writeSidPtr = parsedSid } - const parsedSid = decodePtr(sidSlot) - if (parsedSid === null) throw new Win32Error('ConvertStringSidToSidW', api.getLastError(), this.writeSid) - this.writeSidPtr = parsedSid - const writeSidPtr = parsedSid const tempDir = this.tempDirOption === null ? null @@ -185,16 +201,26 @@ export class AclSandbox { this.tempDirResolved = tempDir } - // manageDacls: false — the caller (the sandbox seam's per-session grant) - // already materialized the ACEs; this instance must neither add nor - // remove any (its dispose() must not revoke the caller's standing grant). + // 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). if (this.manageDacls) { - for (const path of tempDir !== null ? [...this.writableDirs, tempDir] : this.writableDirs) { - // Record BEFORE granting: grantWrite can throw after a successful - // apply (a LocalFree failure), and the fail-closed catch must still - // revoke that path (revoking an ungranted path is a no-op merge). - this.grantedPaths.push(path) - grantWrite(api, path, writeSidPtr) + if (writeSidPtr !== undefined) { + for (const path of this.writableDirs) { + grantWrite(api, path, writeSidPtr) + } + if (tempDir !== null) { + // 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) + } } } const logonSid = findLogonSid(api, currentToken) @@ -212,8 +238,10 @@ export class AclSandbox { } catch (error) { // Best-effort close on the failure path (last error already captured in `error`). api.closeHandle(currentToken) - // Fail-closed cleanup: never leave standing grants or SID allocations - // behind a failed init. + // 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) { @@ -293,7 +321,11 @@ export class AclSandbox { } } - /** Revoke all standing grants, free the SID, close the token; reports every cleanup failure. */ + /** + * Revoke the revocable (temp) grants, free the SID, close the token; the + * standing workspace ACEs stay (the reuse cache). Reports every cleanup + * failure. + */ dispose(): void { const api = this.api if (api === undefined) return diff --git a/packages/sandbox/sandbox-windows-acl/src/runner.ts b/packages/sandbox/sandbox-windows-acl/src/runner.ts index 3487e182be..93f8cfcc01 100644 --- a/packages/sandbox/sandbox-windows-acl/src/runner.ts +++ b/packages/sandbox/sandbox-windows-acl/src/runner.ts @@ -1,9 +1,10 @@ /** * The windows-acl confinement runner: the argv-prefix wrapper the sandbox * seam spawns in place of the caller's command. It creates the - * WRITE_RESTRICTED token with the orphan-SID allowlist, spawns the wrapped - * argv under it with the CALLER'S stdio inherited (bytes flow straight - * through), mirrors the child's exit code, and revokes all grants on exit. + * WRITE_RESTRICTED token with the workspace write-SID allowlist, spawns the + * wrapped argv under it with the CALLER'S stdio inherited (bytes flow + * straight through), mirrors the child's exit code, and revokes its temp + * grant on exit (workspace ACEs stay standing as the reuse cache). * * Stable argv contract (the seam builds it; a native-exe replacement would * keep the same contract): @@ -22,19 +23,23 @@ * Public tree writes are denied); the two lists share the keep-alive group * (logon SID, EVERYONE) and differ only by the orphan. * - * `--write-sid`: the seam's per-session grant contract — the CALLER has - * already materialized the orphan-SID ACEs (once per session, server - * lifetime) and owns their revocation, so the runner neither grants nor - * revokes (manageDacls: false). Absent `--write-sid` (standalone/test use) - * the runner self-manages grants per invocation as before. 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 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). + * `--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 + * 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). * * Failure contract: every runner-side failure (bad args, missing * directories, token/grant/spawn errors) prints `windows-acl-run: ` @@ -47,6 +52,7 @@ import { existsSync, statSync } from 'node:fs' import { win32 } from './ffi.ts' import { AclSandbox } from './index.ts' +import { workspaceWriteSid } from './workspace-sid.ts' const RUNNER_SIGNATURE = 'windows-acl-run' const RUNNER_FAILURE_EXIT = 127 @@ -121,13 +127,17 @@ 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, - ...parsed.writeSid === undefined ? {} : { writeSid: parsed.writeSid }, - // With --write-sid the seam owns the DACLs (per-session grants): this - // invocation must neither add nor revoke ACEs. + ...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() diff --git a/packages/sandbox/sandbox-windows-acl/src/token.ts b/packages/sandbox/sandbox-windows-acl/src/token.ts index bd0f71ee9f..b0f9ea8f45 100644 --- a/packages/sandbox/sandbox-windows-acl/src/token.ts +++ b/packages/sandbox/sandbox-windows-acl/src/token.ts @@ -114,13 +114,13 @@ export interface RestrictingSidSet { * * 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 orphan SID joins ONLY - * workspace-write — read-only carries no orphan, so a standing grant ACE + * pwsh crashes 0xE0434352) fails without them. The write SID joins 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 * pass-2 check grants only what the restricting list carries, keeping * read-only strictly zero-grant even with stale ACEs standing, while the - * unrevoked ACE keeps the re-upgrade free (the seam's grant map hits it — no + * unrevoked ACE keeps the re-upgrade free (the grant's exact-ACE skip — no * re-propagation). 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 @@ -133,22 +133,24 @@ 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 orphan SID forming the write allowlist (workspace-write only). + * @param writeSid - the write SID forming the write allowlist (workspace-write only; absent under read-only). * @param known - the well-known SIDs entering the restricting list. - * @param mode - selects the restricting list (workspace-write adds the orphan). + * @param mode - selects the restricting list (workspace-write adds the write SID). * @returns the restricted token handle. */ export function createRestrictedToken( api: Win32Bindings, currentToken: NativePtr, logonSid: NativePtr, - writeSid: NativePtr, + writeSid: NativePtr | undefined, known: RestrictingSidSet, mode: 'read-only' | 'workspace-write', ): NativePtr { const restrictingSids = buildRestrictingSids(mode === 'read-only' ? [logonSid, known.world] - : [logonSid, known.world, writeSid]) + : writeSid === undefined + ? (() => { throw new Error('createRestrictedToken: workspace-write restricting list requires the write SID') })() + : [logonSid, known.world, writeSid]) 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 new file mode 100644 index 0000000000..db74893f36 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts @@ -0,0 +1,38 @@ +/** + * The per-workspace write identity: a deterministic `S-1-4-x-y` SID derived + * from the canonical workspace path, whose ACEs form that workspace's write + * allowlist. Every confined execution of the same workspace — across + * sessions, server restarts, and calls — carries the SAME write SID, so the + * workspace-root ACE materializes once per workspace per machine (the + * grant's exact-ACE skip then makes every later provision O(1)) instead of + * once per session. The SID's power is defined solely by the ACEs that name + * it (which exist only on the workspace tree and the session's private temp + * directory), and only tokens minted for that workspace carry it — the SID + * string itself is not a secret (the previous per-session SID was likewise + * logged in the plain). + * + * The input MUST be the canonical workspace path (`realpathSync.native` on + * Windows — the sandbox-policy `resolveWorkspaceRoot` already applies it): + * canonicalization converges case/alias spellings, so two spellings of one + * workspace derive one SID; an as-spelled fallback path would mint a second + * identity for the same directory (self-healing, at the cost of one extra + * tree propagation). Renaming the workspace directory derives a new SID — + * the old standing ACEs are inert residue, and the next session re-propagates + * once. + * @module @deepseek-ai/dsh-sandbox-windows-acl/workspace-sid + */ + +import { createHash } from 'node:crypto' + +/** + * Derive the workspace's write SID (`S-1-4-x-y`; subauthorities 30-bit, + * matching the orphan shape the token and ACE layers already carry). + * @param workspaceRoot - the canonical workspace path. + * @returns the SDDL string form. + */ +export function workspaceWriteSid(workspaceRoot: string): string { + const digest = createHash('sha256').update(workspaceRoot, '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}` +} diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts index 2e4c5baab7..25abe14392 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts @@ -17,6 +17,7 @@ import koffi from 'koffi' import { buildExplicitAccess, grantWrite, lockFilePath, revokeWrite, withPathLock } from '../src/acl.ts' import { AclSandbox } from '../src/index.ts' +import { createRestrictedToken } from '../src/token.ts' import { allocOverlapped, allocPtrSlot, decodePtr, isInvalidHandle, isNullPtr, win32 } from '../src/ffi.ts' import type { NativePtr, Win32Bindings } from '../src/ffi.ts' import * as abi from '../src/win32-abi.ts' @@ -170,18 +171,42 @@ describe.skipIf(!isWin32)('ACL editing', () => { } }) - it('interleaved sandbox instances: A.init → B.init → A.dispose → B.dispose leaves neither ACE', async () => { + it('interleaved sandbox instances: A.init → B.init → A.dispose → B.dispose leaves BOTH standing workspace ACEs (the per-workspace reuse cache)', async () => { const api = await win32() const dir = scratch() const sandboxA = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-9000-1', mode: 'workspace-write' }) const sandboxB = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-9000-2', mode: 'workspace-write' }) await sandboxA.init() await sandboxB.init() + // Workspace ACEs are STANDING: dispose frees the instance's SID + // allocations but deliberately leaves the ACEs — they are the reuse + // cache the next provision's exact-ACE skip consumes. sandboxA.dispose() sandboxB.dispose() const aces = readDirectAces(api, dir) - expect(aces.some(ace => ace.sid === 'S-1-4-9000-1')).toBe(false) - expect(aces.some(ace => ace.sid === 'S-1-4-9000-2')).toBe(false) + expect(aces.some(ace => ace.sid === 'S-1-4-9000-1')).toBe(true) + expect(aces.some(ace => ace.sid === 'S-1-4-9000-2')).toBe(true) + }) + + it('dispose revokes the revocable temp ACE and keeps the standing workspace ACE (self-managed flow)', async () => { + 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' }) + 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) + }) + + 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/) }) 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/grant.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/grant.spec.ts index 02090e2fe9..43810249ab 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/grant.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/grant.spec.ts @@ -1,8 +1,10 @@ /** - * AclWriteGrant tests: the server-side per-session grant materialization — - * SID parsing fail-closed, ACE add/dispose round-trip against the REAL - * directory DACL (observed through icacls, the operator's own tool), and - * the recorded path order. Win32-only, like the other real-FFI suites. + * AclWriteGrant tests: the server-side grant materialization — SID parsing + * fail-closed, ACE add/dispose round-trip against the REAL directory DACL + * (observed through icacls, the operator's own tool), the recorded path + * order, and the standing/revocable lifecycle split (workspace ACEs outlive + * dispose as the reuse cache; temp ACEs revoke). Win32-only, like the other + * real-FFI suites. */ import { spawnSync } from 'node:child_process' @@ -38,18 +40,24 @@ describe.skipIf(!isWin32)('AclWriteGrant (server-side materialization)', () => { expect(() => AclWriteGrant.create('S-1-4-abc-1')).toThrow(/ConvertStringSidToSidW/u) }) - it('add materializes the ACE (idempotently), paths report the grant order, dispose revokes it', () => { + it('add materializes the ACE (idempotently) and reports grant order; dispose revokes revocable paths and keeps standing paths standing', () => { const dir = scratch() + const standingDir = scratch() const grant = AclWriteGrant.create('S-1-4-9000-77') - grant.add(dir) - expect(grant.paths).toEqual([dir]) + grant.add(dir) // revocable: the session-temp lifecycle + grant.add(standingDir, true) // standing: the workspace reuse cache + expect(grant.paths).toEqual([standingDir, dir]) expect(icaclsText(dir)).toContain('S-1-4-9000-77') + expect(icaclsText(standingDir)).toContain('S-1-4-9000-77') // A second add over the standing exact ACE is a DACL-read no-op: the - // grant stays exactly one ACE (per-session reuse after a restart). + // grant stays exactly one ACE (the reuse across sessions/restarts). grant.add(dir) + grant.add(standingDir, true) expect(icaclsText(dir)).toContain('S-1-4-9000-77') + expect(icaclsText(standingDir)).toContain('S-1-4-9000-77') grant.dispose() expect(icaclsText(dir)).not.toContain('S-1-4-9000-77') + expect(icaclsText(standingDir)).toContain('S-1-4-9000-77') }) it('two grants with different SIDs coexist and revoke independently', () => { diff --git a/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts index 0eb78e16f4..0825695438 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts @@ -51,7 +51,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('AclSandbox write restriction', () // 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, mode: 'workspace-write' }) + sandbox = new AclSandbox({ writableDirs: [writableDir], tempDir: isolatedTemp, writeSid: 'S-1-4-9000-4', mode: 'workspace-write' }) await sandbox.init() }) diff --git a/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts new file mode 100644 index 0000000000..4d24c6f8fb --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts @@ -0,0 +1,30 @@ +/** + * workspaceWriteSid tests: the per-workspace write identity is deterministic + * (the same canonical path always derives the same SID — the property the + * cross-session grant reuse rests on), orphan-shaped, distinct across + * workspaces, and byte-sensitive (the canonical path is the caller's + * contract; an alias spelling derives a second identity, self-healing at + * the cost of one extra tree propagation). + */ + +import { describe, expect, it } from 'vitest' + +import { workspaceWriteSid } from '../src/index.ts' + +describe('workspaceWriteSid', () => { + it('derives a stable orphan-shaped SID per workspace path', () => { + const first = workspaceWriteSid('C:\\Users\\agent\\repo') + const second = workspaceWriteSid('C:\\Users\\agent\\repo') + expect(first).toBe(second) + expect(first).toMatch(/^S-1-4-\d+-\d+$/u) + }) + + it('derives distinct identities for distinct workspaces', () => { + expect(workspaceWriteSid('C:\\Users\\agent\\repo-a')).not.toBe(workspaceWriteSid('C:\\Users\\agent\\repo-b')) + }) + + it('is byte-sensitive: the canonical path is the caller\'s contract (an alias spelling derives a second identity)', () => { + expect(workspaceWriteSid('C:\\Repo')).not.toBe(workspaceWriteSid('c:\\repo')) + expect(workspaceWriteSid('C:\\Repo\\')).not.toBe(workspaceWriteSid('C:\\Repo')) + }) +}) diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts index 6c2397a2e0..4510e9a69a 100644 --- a/packages/sandbox/sandbox/src/index.ts +++ b/packages/sandbox/sandbox/src/index.ts @@ -44,8 +44,9 @@ export interface SandboxExecutionPolicy { /** * 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 write grant and private temp subdirectory); absent for - * agentless calls, which fall back to per-call backend state. + * 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?: SessionId }