fix(sandbox): extend the restricted token's default DACL with a write-SID ACE
New objects created without an explicit security descriptor take their DACL from the token's default DACL, which CreateRestrictedToken builds from the user's ambient SIDs — none of them a restricting SID. Confined children therefore failed the write pass-2 check when creating anonymous pipes (CreatePipe: ERROR_ACCESS_DENIED, surfaced as Node EPERM), breaking PowerShell pipelines and other CreatePipe consumers. Merge a full-access write-SID ACE (Everyone under read-only) into the token default DACL at init via SetTokenInformation(TokenDefaultDacl). Named pipes are EXEMPT: their default security descriptor is the kernel's PUBLIC template (owner/SYSTEM/Admins full, Everyone read-only), which no token change influences, so libuv's piped stdio capture stays denied for confined grandchildren — the POC-documented boundary, now pinned by the runner suite (inherit/ignore OK, pipe DENIED) and documented in the README pair. The NUL paragraph is corrected to the measured matrix (Everyone has 0x1201BF on the device: cmd/node writes land; Set-Content fails at the PS layer).
This commit is contained in:
+2
-2
@@ -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: 54468fa46f7dbf7bcb7f765b6ca6bb7ed962e7d2
|
||||
2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 10a412a2cb4f584443ca50436ff1baf65141ac1e
|
||||
2026-08-08-windows-acl-restricted-token-sandbox.md: adbec6f733dcf2903e11b70726a07a9aae211fec
|
||||
2026-08-08-windows-acl-restricted-token-sandbox.zh.md: e35f4f0a20e7f58e24a8ad47964f43d6c9c43681
|
||||
+1
-1
@@ -10,7 +10,7 @@ The [sandbox decision](2026-07-06-sandbox.md) leaves `PLATFORM_CHAINS.win32` emp
|
||||
|
||||
## Decision
|
||||
|
||||
Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include a write SID (`S-1-4-x-y`); the write SID's Write ACEs on the workspace and temp roots are the 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 (`<temp>\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 (`<temp>\dsh-<16 random hex>` — unguessable, created exclusively, reparse points rejected — TMP/TEMP rewritten by the runner — bwrap `--tmpfs /tmp` semantics). The restricted token's DEFAULT DACL is extended with a full-access write-SID ACE (`SetTokenInformation(TokenDefaultDacl)`): new objects created without an explicit security descriptor (anonymous pipes — CreatePipe, sync objects) then carry a restricting-SID ACE and pass the write pass-2 check at creation; NAMED pipes are exempt — their default security descriptor is the kernel's PUBLIC template (owner/SYSTEM/Admins full, Everyone read-only), which the token cannot influence, so piped stdio capture stays denied for confined grandchildren (the POC-documented boundary, pinned by the runner suite). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack.
|
||||
|
||||
## How the restriction works (why no new identity)
|
||||
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ Status: implemented
|
||||
|
||||
## Decision
|
||||
|
||||
直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含写入 SID(`S-1-4-x-y`);工作区与临时目录上写入 SID 的 Write ACE 就是全部写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closed(POC 因忽略返回值而 fail-open)。写入 SID 是**按工作区**的身份,由规范工作区路径确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`),且**任何地方都不存储**:工作区根目录 ACE 因此每台机器每个工作区只物化一次——常驻 ACE 就是跨会话复用缓存,此后每次供给都命中精确 ACE 跳过(幂等重授权跳过急切的全树重传播——不做垃圾回收)——而不是每会话一次,这正是先前每会话随机 SID 每个会话都要付一次全树传播的代价。seam 仍为每个会话供给一条 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 子进程看到的是私有的每会话临时子目录(`<temp>\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 子进程看到的是私有的每会话临时子目录(`<temp>\dsh-<16 random hex>`——不可猜测、独占创建、拒绝 reparse point——TMP/TEMP 由 runner 重写——bwrap `--tmpfs /tmp` 语义)。受限令牌的**默认 DACL** 被扩展一条写入 SID 全权 ACE(`SetTokenInformation(TokenDefaultDacl)`):此后不带显式安全描述符创建的新对象(匿名管道——CreatePipe、同步对象)自带 restricting SID ACE,创建时的写 pass-2 检查通过;**named pipe 例外**——其默认安全描述符是内核的**公共模板**(owner/SYSTEM/Admins 全权、Everyone 只读),令牌无法影响,因此受限孙进程的管道 stdio 捕获保持拒绝(POC 记载的边界,由 runner 套件钉住)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付;Windows 平台层在受限 pwsh 栈之上重新启用完整权限面(sandbox/sandbox-policy/permission/approval/fs-sandbox)。
|
||||
|
||||
## How the restriction works (why no new identity)
|
||||
|
||||
|
||||
@@ -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: 3185510142750178b11c2f6bad863a0a4861a29c
|
||||
README.zh.md: ab45d29c7dea79c8a75ac2187140e5926cf71581
|
||||
README.md: 835b7dace0baf10eec78b35172109a37688d99c2
|
||||
README.zh.md: 82a890d75f857b766c7ae47cef7a374edb5ecd63
|
||||
@@ -43,7 +43,7 @@ The runner creates the restricted token, spawns the wrapped argv under it with t
|
||||
|
||||
Modes (the token's restricting-SID list follows the mode; the keep-alive group is logon SID + Everyone in BOTH modes — early DLL init dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352` without them):
|
||||
- `workspace-write` (logon SID, Everyone, write SID): the workspace and the session's PRIVATE temp subdirectory carry the write-SID Write grant; 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).
|
||||
- `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. NUL writes are AMBIENT, not granted: the device DACL grants Everyone read+write+execute (`0x1201BF`), so openers whose mask fits it (cmd `> NUL`, node `\\.\NUL`) can write it in BOTH modes — the sandbox cannot zero-grant the NUL device while Everyone stays in the keep-alive group. `Set-Content NUL` fails in both modes (a PowerShell/.NET-layer effect, pinned by the read-only suite — the device DACL is not the denying party); PowerShell's `> $null` redirection keeps working (it discards without opening NUL).
|
||||
|
||||
Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (`0x80041003`), so CIM cmdlets and `Get-ComputerInfo` (which silently returns incomplete results rather than an error) are unavailable in EVERY confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both — the model-facing surface documents that contract, not a prompt promise. INTERACTIVE/LOCAL are absent from BOTH lists too: the host's Public tree grants write to INTERACTIVE, so Public writes are denied — pinned by the runner's ambient-writable Public-probe regression (see the design note).
|
||||
|
||||
@@ -83,6 +83,7 @@ None directly; the denial surface belongs to the tool layer.
|
||||
- **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.
|
||||
- **Piped stdio capture is impossible for confined grandchildren (the named-pipe default SD template).** libuv's pipe stdio uses NAMED pipes; `CreateNamedPipeW` without security attributes takes the kernel's PUBLIC default SD template (owner/SYSTEM/Admins full, Everyone/ANONYMOUS read-only) — NOT the token default DACL — so the client-end open requests write access no restricting SID is granted: `spawn(..., { stdio: 'pipe' })` inside a confined process fails with EPERM, the POC-documented "no output redirection" boundary of WRITE_RESTRICTED tokens. Inherited (`inherit`/fd) and ignored (`ignore`) stdio spawns work, and anonymous pipes (CreatePipe — a token-default-DACL consumer, e.g. PowerShell pipelines) work because the restricted token's default DACL carries a full-access restricting-SID ACE (set at init). A confined process therefore cannot capture a grandchild's output through a pipe; tools that must capture output cannot run confined.
|
||||
- **Grant materialization is an eager full-tree propagation.** `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (NOT lazily per access — measured at tens of seconds on large workspace trees plus the real temp root). The per-workspace identity pays it once per workspace per machine (lazily at the first confined execution ever, skipped entirely on every later provision when the exact ACE stands). If a workspace is huge, the first confined write on this host is correspondingly slow.
|
||||
- **Resuming one session concurrently in two server processes 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.
|
||||
|
||||
@@ -43,7 +43,7 @@ runner 创建受限令牌,在它之下 spawn 包装后的 argv,调用者的
|
||||
|
||||
模式(令牌的 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)。
|
||||
- `read-only`(登录 SID、Everyone——**不含**写入 SID):**严格零授权**——没有任何可写位置。写入 SID 有意留在列表**之外**:先前 workspace-write 时期留下的常驻授权 ACE(`/permission` 降级,或崩溃后恢复的会话)在 read-only 下保持**失效**,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容——而常驻 ACE 让重新升级免于重新传播。NUL 写入是**环境性**的、不是被授权的:设备 DACL 授予 Everyone 读+写+执行(`0x1201BF`),因此访问掩码落在其内的打开者(cmd 的 `> NUL`、node 的 `\\.\NUL`)在**两种**模式下都能写——只要 Everyone 还在保活组里,沙盒就无法把 NUL 设备归零。`Set-Content NUL` 在两种模式下都失败(PowerShell/.NET 层效应,由 read-only 套件钉住——拒绝方不是设备 DACL);PowerShell 的 `> $null` 重定向不受影响(它直接丢弃、不打开 NUL)。
|
||||
|
||||
Authenticated Users 在**两种**列表中都不存在——WMI 命名空间安全检查失败(`0x80041003`),因此 CIM cmdlet 与 `Get-ComputerInfo`(它静默返回不完整结果而非报错)在**所有**受限模式下都不可用,且 C:\-root 树创建逃逸(常驻的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭——面向模型的表面记录的是该契约,而不是提示词承诺。INTERACTIVE/LOCAL 在两种列表中同样不存在:宿主的 Public 树向 INTERACTIVE 授予写权限,因此 Public 写入被拒绝——由 runner 的环境可写 Public 探针回归测试钉住(见设计笔记)。
|
||||
|
||||
@@ -83,6 +83,7 @@ koffi 结构体定义在模块加载时对照探针断言其大小,因此头
|
||||
- **清理尽力而为** —— `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,因此这仍是记录在案的边界情形而非守护路径。
|
||||
- **受限孙进程的管道 stdio 捕获不可用(named pipe 的默认 SD 模板)。** libuv 的管道 stdio 用的是 NAMED pipe;不带安全属性调用 `CreateNamedPipeW` 时,其默认安全描述符是内核的**公共模板**(owner/SYSTEM/Admins 全权,Everyone/ANONYMOUS 只读)——**不是**令牌默认 DACL——因此 client 端打开所请求的写访问没有任何 restricting SID 被授予:受限进程内 `spawn(..., { stdio: 'pipe' })` 以 EPERM 失败,这是 POC 记载的 WRITE_RESTRICTED「无法重定向输出」边界。继承(`inherit`/fd)与忽略(`ignore`)stdio 的 spawn 可用;匿名管道(CreatePipe——令牌默认 DACL 的消费者,例如 PowerShell 的管道)因受限令牌默认 DACL 携带 restricting SID 全权 ACE(init 时写入)而可用。受限进程因此无法用管道捕获孙进程输出;必须捕获输出的工具无法在受限下运行。
|
||||
- **授权物化是急切的全树传播。** 在带可继承 ACE 的目录上调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(**不是**按访问惰性进行——大型工作区树上实测数十秒,加上真实临时根目录)。按工作区身份每台机器每个工作区只付一次(在首次受限执行时惰性进行,之后每次供给在精确 ACE 常驻时完全跳过)。如果工作区巨大,该主机上的第一次受限写入相应变慢。
|
||||
- **两个服务器进程并发恢复同一会话会竞争记录。** 持久记录在会话日志中;两个进程独立读取或供给它——派生出的写入 SID 相同,每路径锁保持 DACL 合并一致,私有临时目录的竞争以后写记录对后续恢复生效而解决。单写者会话用法(常规部署)永远不会遇到。
|
||||
- **读侧隔离与网络策略不在范围内** —— `WRITE_RESTRICTED` 只交叉检查写访问;将此后端与读侧策略配对以获得更强隔离。
|
||||
|
||||
@@ -76,6 +76,7 @@ export interface Win32Bindings {
|
||||
copySid(length: number, destination: NativePtr, source: NativePtr): number
|
||||
// ---- token information ---------------------------------------------------
|
||||
getTokenInformation(token: NativePtr, cls: number, info: Buffer | null, length: number, needed: NativePtr): number
|
||||
setTokenInformation(token: NativePtr, cls: number, info: Buffer, length: number): number
|
||||
// ---- restricted token ----------------------------------------------------
|
||||
createRestrictedToken(
|
||||
existing: NativePtr, flags: number,
|
||||
@@ -392,6 +393,7 @@ function bindings(): Win32Bindings {
|
||||
getLengthSid: bind(advapi32, 'GetLengthSid', 'uint32', [PVOID]),
|
||||
copySid: bind(advapi32, 'CopySid', 'int', ['uint32', PVOID, PVOID]),
|
||||
getTokenInformation: bind(advapi32, 'GetTokenInformation', 'int', [PVOID, 'int', PVOID, 'uint32', koffi.pointer('uint32')]),
|
||||
setTokenInformation: bind(advapi32, 'SetTokenInformation', 'int', [PVOID, 'int', PVOID, 'uint32']),
|
||||
createRestrictedToken: bind(advapi32, 'CreateRestrictedToken', 'int', [PVOID, 'uint32', 'uint32', PVOID, 'uint32', PVOID, 'uint32', PVOID, PPVOID]),
|
||||
setEntriesInAclW: bind(advapi32, 'SetEntriesInAclW', 'uint32', ['uint32', PVOID, PVOID, PPVOID]),
|
||||
setNamedSecurityInfoW: bind(advapi32, 'SetNamedSecurityInfoW', 'uint32', ['str16', 'int', 'uint32', PVOID, PVOID, PVOID, PVOID]),
|
||||
|
||||
@@ -47,7 +47,7 @@ import { Win32Error } from './errors.ts'
|
||||
import { allocPtrSlot, decodePtr, getTempPath, isNullPtr, throwLastError, win32 } from './ffi.ts'
|
||||
import type { NativePtr, Win32Bindings } from './ffi.ts'
|
||||
import { drainPipe, spawnSandboxed, spawnSandboxedInherited, waitForExit } from './spawn.ts'
|
||||
import { createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken } from './token.ts'
|
||||
import { createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken, setTokenDefaultDaclGrant } from './token.ts'
|
||||
import * as abi from './win32-abi.ts'
|
||||
|
||||
export { quoteArg } from './spawn.ts'
|
||||
@@ -61,8 +61,9 @@ export interface AclSandboxOptions {
|
||||
writableDirs: readonly string[]
|
||||
/**
|
||||
* Temp directory to also grant; defaults to GetTempPathW() at init time.
|
||||
* Pass null for read-only confinement: NO temp grant (strict zero write
|
||||
* allowance — not even the NUL device is writable, see README).
|
||||
* Pass null for read-only confinement: NO temp grant (strict zero grant on
|
||||
* the filesystem; the NUL device stays ambient-writable via Everyone — see
|
||||
* README).
|
||||
*/
|
||||
tempDir?: string | null
|
||||
/**
|
||||
@@ -232,6 +233,16 @@ export class AclSandbox {
|
||||
{ world: worldSid },
|
||||
this.mode,
|
||||
)
|
||||
// The restricted token's default DACL still names only the user's
|
||||
// ambient SIDs — none of the restricting SIDs. Every NEW object the
|
||||
// confined process creates (anonymous stdio pipes, sync objects) takes
|
||||
// its DACL from that default, so the write pass-2 check would deny
|
||||
// pipe creation (ERROR_ACCESS_DENIED; Node EPERM) and break every
|
||||
// piped-stdio grandchild spawn. Merge a full-access ACE for a
|
||||
// restricting SID (the write SID under workspace-write, Everyone under
|
||||
// read-only): new-object creation stays gated by the parent object's
|
||||
// DACL, while the new object's own DACL passes pass-2.
|
||||
setTokenDefaultDaclGrant(api, restricted, writeSidPtr ?? worldSid)
|
||||
this.token = restricted
|
||||
if (api.closeHandle(currentToken) === 0) throwLastError(api, 'CloseHandle', 'current process token')
|
||||
this.api = api
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import { allocBytes, allocPtrSlot, allocUint32, decodePtr, decodePtrAt, decodeUint32, encodeUint32, isNullPtr, ptrAddress, throwLastError, throwWin32 } from './ffi.ts'
|
||||
import type { NativePtr, Win32Bindings } from './ffi.ts'
|
||||
import { buildExplicitAccess } from './acl.ts'
|
||||
import * as abi from './win32-abi.ts'
|
||||
|
||||
/**
|
||||
@@ -92,6 +93,57 @@ export function makeWellKnownSid(api: Win32Bindings, type: number): NativePtr {
|
||||
return sid
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge one full-access allow ACE for `sidPtr` into the token's DEFAULT DACL
|
||||
* — the DACL every NEW object the token holder creates (without an explicit
|
||||
* security descriptor) takes. The restricted token inherits the user's
|
||||
* default DACL verbatim, which names no restricting SID: a new anonymous pipe
|
||||
* (child stdio) therefore fails the write pass-2 check at creation
|
||||
* (ERROR_ACCESS_DENIED; Node surfaces it as spawn EPERM), breaking every
|
||||
* piped-stdio grandchild spawn. The merged ACE names a RESTRICTING SID (the
|
||||
* write SID under workspace-write, Everyone under read-only), so each new
|
||||
* object's own DACL passes pass-2 while object creation itself stays gated by
|
||||
* the parent container's DACL (files outside the granted trees remain
|
||||
* uncreatable). Fails closed: any Win32 failure throws before the spawn.
|
||||
* @param api - the binding table.
|
||||
* @param token - the restricted token to adjust (requires TOKEN_ADJUST_DEFAULT).
|
||||
* @param sidPtr - the restricting SID whose full-access ACE joins the default DACL.
|
||||
*/
|
||||
export function setTokenDefaultDaclGrant(api: Win32Bindings, token: NativePtr, sidPtr: NativePtr): void {
|
||||
const neededSlot = allocUint32()
|
||||
api.getTokenInformation(token, abi.TokenDefaultDacl, null, 0, neededSlot) // expected to fail with ERROR_INSUFFICIENT_BUFFER
|
||||
const needed = decodeUint32(neededSlot)
|
||||
if (needed === 0) throwLastError(api, 'GetTokenInformation', 'TokenDefaultDacl size query')
|
||||
const buffer = Buffer.alloc(needed)
|
||||
if (api.getTokenInformation(token, abi.TokenDefaultDacl, buffer, buffer.length, neededSlot) === 0) {
|
||||
throwLastError(api, 'GetTokenInformation', 'TokenDefaultDacl')
|
||||
}
|
||||
const currentDacl = decodePtrAt(buffer, 0)
|
||||
if (currentDacl === null) {
|
||||
throw new Error('setTokenDefaultDaclGrant: the token carries no default DACL to extend')
|
||||
}
|
||||
const newDaclSlot = allocPtrSlot()
|
||||
const result = api.setEntriesInAclW(
|
||||
1,
|
||||
buildExplicitAccess(sidPtr, abi.GRANT_ACCESS, abi.FILE_ALL_ACCESS),
|
||||
currentDacl,
|
||||
newDaclSlot,
|
||||
)
|
||||
if (result !== abi.ERROR_SUCCESS) throwWin32(api, 'SetEntriesInAclW', result, 'default DACL merge')
|
||||
const newDacl = decodePtr(newDaclSlot)
|
||||
if (newDacl === null) throwWin32(api, 'SetEntriesInAclW', result, 'null merged default DACL')
|
||||
// TOKEN_DEFAULT_DACL { PACL DefaultDacl; } — the struct is exactly the
|
||||
// pointer; SetTokenInformation copies the ACL before returning.
|
||||
const info = Buffer.alloc(8)
|
||||
info.writeBigUInt64LE(newDacl, 0)
|
||||
if (api.setTokenInformation(token, abi.TokenDefaultDacl, info, info.length) === 0) {
|
||||
const win32Code = api.getLastError()
|
||||
api.localFree(newDacl)
|
||||
throwWin32(api, 'SetTokenInformation', win32Code, 'TokenDefaultDacl')
|
||||
}
|
||||
api.localFree(newDacl)
|
||||
}
|
||||
|
||||
/** Pack `SID_AND_ATTRIBUTES[count]` (16-byte stride; Attributes stay 0). */
|
||||
function buildRestrictingSids(sids: readonly NativePtr[]): Buffer {
|
||||
const buffer = Buffer.alloc(abi.SID_AND_ATTRIBUTES_SIZE * sids.length)
|
||||
|
||||
@@ -70,6 +70,15 @@ export const FILE_DELETE_CHILD = 0x0040
|
||||
*/
|
||||
export const GRANT_MASK = (FILE_GENERIC_WRITE | DELETE | FILE_DELETE_CHILD) & ~STANDARD_RIGHTS_WRITE // 0x00110156
|
||||
|
||||
/**
|
||||
* FILE_ALL_ACCESS (winnt.h line ~2789: STANDARD_RIGHTS_REQUIRED | SYNCHRONIZE
|
||||
* | 0x1FF): full file-object access. The mask of the ACE merged into the
|
||||
* restricted token's DEFAULT DACL — the token holder must keep full access to
|
||||
* every NEW object it creates (pipes included), and the ACE must name a
|
||||
* restricting SID so the write pass-2 check passes at creation.
|
||||
*/
|
||||
export const FILE_ALL_ACCESS = 0x1F01FF
|
||||
|
||||
// CreateRestrictedToken flags (winnt.h lines ~4284)
|
||||
/** DISABLE_MAX_PRIVILEGE: strip the token's maximum-privilege elevation so the confined child cannot escalate. */
|
||||
export const DISABLE_MAX_PRIVILEGE = 0x1
|
||||
@@ -85,6 +94,8 @@ export const WinWorldSid = 1
|
||||
// TOKEN_INFORMATION_CLASS (winnt.h line ~3963: TokenUser=1, TokenGroups=2)
|
||||
/** TokenGroups: GetTokenInformation class returning the token's group SIDs. */
|
||||
export const TokenGroups = 2
|
||||
/** TokenDefaultDacl: the token's default DACL — the DACL every NEW object created without an explicit SD takes. */
|
||||
export const TokenDefaultDacl = 6
|
||||
|
||||
// SECURITY_INFORMATION (winnt.h line ~4293)
|
||||
/** DACL_SECURITY_INFORMATION: read/write only the DACL of a security descriptor. */
|
||||
|
||||
@@ -189,6 +189,37 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('confined children spawn grandchildren with inherited stdio; piped capture stays denied (named-pipe default SD template)', () => {
|
||||
// Two-layer pin of the grandchild-spawn boundary:
|
||||
// - the token default DACL carries a restricting-SID ACE (set in init),
|
||||
// so ANONYMOUS pipe creation (CreatePipe — the token-default-DACL
|
||||
// consumer) works and inherited/ignored stdio spawns succeed;
|
||||
// - libuv's pipe-stdio uses NAMED pipes, whose default security
|
||||
// descriptor is the kernel's PUBLIC template (owner/SYSTEM/Admins
|
||||
// full, Everyone read-only) — NOT the token default DACL — so the
|
||||
// client-end open requests write access no restricting SID is
|
||||
// granted: ERROR_ACCESS_DENIED, surfaced as spawn EPERM. That is the
|
||||
// POC-documented "no output redirection" boundary of WRITE_RESTRICTED
|
||||
// tokens; piped capture cannot work and is pinned as DENIED.
|
||||
const probe = [
|
||||
"const { spawnSync } = require('child_process');",
|
||||
"const t = (name, opts) => { const s = spawnSync(process.execPath, ['-e', '1'], { encoding: 'utf8', ...opts }); console.log(name + ':' + (s.status === 0 ? 'OK' : 'DENIED')); };",
|
||||
"t('inherit', { stdio: 'inherit' });",
|
||||
"t('ignore', { stdio: 'ignore' });",
|
||||
"t('pipe', { stdio: 'pipe' });",
|
||||
].join('')
|
||||
for (const mode of ['workspace-write', 'read-only'] as const) {
|
||||
const result = runRunner([
|
||||
'--workspace', writableDir, '--temp', isolatedTemp, '--mode', mode,
|
||||
'--', 'node', '-e', probe,
|
||||
])
|
||||
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
|
||||
expect(result.stdout, `mode: ${mode}`).toContain('inherit:OK')
|
||||
expect(result.stdout, `mode: ${mode}`).toContain('ignore:OK')
|
||||
expect(result.stdout, `mode: ${mode}`).toContain('pipe:DENIED')
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('mode-downgrade leak regression: a STANDING workspace grant is inert under read-only and effective again on re-upgrade', () => {
|
||||
// The reported defect: a session that materialized its grant in
|
||||
// workspace-write keeps the ACE standing for the server lifetime. After
|
||||
|
||||
Reference in New Issue
Block a user