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 3b28349045..8b534a3267 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: 4459e121c09fe566efc9a35604df10dae223928c -2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 307f39d3664e5e9f837ce630fc8c8b442bc4d821 +2026-08-08-windows-acl-restricted-token-sandbox.md: 62b9613adf45c8be25464118d7440954ac3e90ee +2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 95c2604d6ac8f8f6edce524477f4a49cefef79d3 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 4459e121c0..62b9613adf 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, 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 token's restricting list is dual-mode: list I (`read-only` = logon SID + Everyone only — no orphan and no Authenticated Users, so CIM is unavailable but the ambient AU-writable surface, notably the C:\-root tree-creation escape, is closed, and 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) and list J (`workspace-write` = + Authenticated Users + orphan, keeping the CIM path alive at the cost of that residual surface); the verified keep-alive invariants are logon SID + Everyone for early DLL init and CNG, and Authenticated Users for the WMI namespace security check alone. Workspace-write children see a PRIVATE per-session temp subdirectory (`\dsh-`, 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 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, 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 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-`, 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); 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; `read-only` loses CIM (AuthUsers dropped — the WMI namespace security check fails, and `Get-ComputerInfo` silently returns incomplete results), while `workspace-write` retains the Authenticated-Users residual (a C:\-root tree-creation escape) as the price of a working CIM path; `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, revoked by provider dispose, self-healing across restarts via the durable per-session record); 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); `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, one-shot materialization, fork/resume SID reuse, dispose revocation, and the mode-switch cycle — read-only materializes nothing, the upgrade materializes once, the downgrade keeps the standing grant — with the Win32 surface mocked) and on win32 by `grant.spec.ts` (real-DACL materialization), the `acl.spec.ts` idempotent-grant fast-path, and the `runner.spec.ts` `--write-sid` contract (caller-owned grants, private temp subdir through TMP/TEMP, dual-mode CIM probes, the mode-downgrade regression — a standing grant ACE is inert under read-only and effective again on re-upgrade — and the ConstrainedLanguage pins in both modes). +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, one-shot materialization, fork/resume SID reuse, dispose revocation, and the mode-switch cycle — read-only materializes nothing, the upgrade materializes once, the downgrade keeps the standing grant — with the Win32 surface mocked) and on win32 by `grant.spec.ts` (real-DACL materialization), the `acl.spec.ts` idempotent-grant fast-path, 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). ## 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 307f39d366..95c2604d6a 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 在该会话首次受限执行时惰性物化,并在服务器进程生命周期内持有(提供方 dispose(资源释放)时回收;幂等重授权在该 ACE 跨重启原样存续时跳过急切的全树重传播——不做垃圾回收)。令牌的 restricting list 为双模式:list I(`read-only` = 仅登录 SID + Everyone——不含孤儿 SID 与 Authenticated Users,因此 CIM 不可用,但环境 AU 可写面(尤其是 C:\-root 建树逃逸)被关闭,且先前 workspace-write 时期留下的驻留授权 ACE 保持**失效**:pass-2 检查只授予列表所携带的内容,因此 read-only 在 `/permission` 降级或崩溃后恢复的会话中始终保持严格零授权,而未撤销的 ACE 让重新升级保持零成本)与 list J(`workspace-write` = + Authenticated Users + 孤儿 SID,以保留该残余面为代价维持 CIM 通路存活);经验证的保活不变式是登录 SID + Everyone 支撑早期 DLL init 与 CNG,Authenticated Users 仅支撑 WMI namespace 安全校验。Workspace-write 子进程看到的是私有的每会话临时子目录(`\dsh-`,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 按会话而非按 spawn:seam 每会话供给一个 SID,作为 log-only 的 `sandbox/acl-session` 事件记录在会话日志中(fork 铸出新 SID;恢复回放同一个),其 ACE 在该会话首次受限执行时惰性物化,并在服务器进程生命周期内持有(提供方 dispose(资源释放)时回收;幂等重授权在该 ACE 跨重启原样存续时跳过急切的全树重传播——不做垃圾回收)。令牌的 restricting list 是保活组加上仅 workspace-write 下的孤儿 SID:read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、孤儿 SID]。保活不变式是登录 SID + Everyone(没有它们,早期 DLL init 会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。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-`,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 回收,借助持久化的每会话记录跨重启自愈);授权物化是急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——在大型工作区上耗时数十秒),因每会话复用,每个服务器生命周期每会话只付一次;`read-only` 失去 CIM(AuthUsers 被移除——WMI namespace 安全校验失败,`Get-ComputerInfo` 静默返回不完整结果),而 `workspace-write` 保留 Authenticated-Users 残余面(C:\-root 建树逃逸)作为 CIM 通路可用的代价;`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 改动(目录须为调用者所有,由提供方 dispose 回收,借助持久化的每会话记录跨重启自愈);授权物化是急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——在大型工作区上耗时数十秒),因每会话复用,每个服务器生命周期每会话只付一次;CIM 在**两种**受限模式下都不可用(AuthUsers 从两种列表中被移除——WMI namespace 安全校验失败,`Get-ComputerInfo` 静默返回不完整结果),作为关闭两种模式下 C:\-root 建树逃逸的代价;位于被授权根目录之外的 FAT 类(无 ACL)目标在两种模式下仍可写(没有可做交集的安全描述符——作为历史残留处理:不支持、仅警告、已在 README 中记录);`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 复用、dispose 回收,以及模式切换循环——read-only 不物化任何内容、升级只物化一次、降级保留驻留授权——mock 掉 Win32 表面),win32 侧由 `grant.spec.ts`(真实 DACL 物化)、`acl.spec.ts` 的幂等授权快速路径与 `runner.spec.ts` 的 `--write-sid` 契约(调用者所有目录的授权、经 TMP/TEMP 的私有临时子目录、双模式 CIM 探针、模式降级回归——驻留授权 ACE 在 read-only 下失效并在重新升级后再度生效——以及两种模式下对 ConstrainedLanguage 的钉定)钉住。 +产品可见的 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 复用、dispose 回收,以及模式切换循环——read-only 不物化任何内容、升级只物化一次、降级保留驻留授权——mock 掉 Win32 表面),win32 侧由 `grant.spec.ts`(真实 DACL 物化)、`acl.spec.ts` 的幂等授权快速路径与 `runner.spec.ts` 的 `--write-sid` 契约(调用者所有目录的授权、经 TMP/TEMP 的私有临时子目录、两种模式下的 CIM 拒绝探针、模式降级回归——驻留授权 ACE 在 read-only 下失效并在重新升级后再度生效——环境可写 Public-probe 回归(对 C:\Users\Public 子目录的写入在两种模式下都会被拒绝),以及两种模式下对 ConstrainedLanguage 的钉定)钉住。 ## Related diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index fb1a76cc9c..93b92de0a0 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -381,7 +381,7 @@ export class LocalSandboxProvider extends SandboxProvider { * 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 - * (list I), so the ACE is inert there, while the map hit keeps the + * (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. * @param record - the session's durable record. diff --git a/packages/sandbox/sandbox-local/tests/acl-session.spec.ts b/packages/sandbox/sandbox-local/tests/acl-session.spec.ts index 7ebb5ff2ed..d1cca979a0 100644 --- a/packages/sandbox/sandbox-local/tests/acl-session.spec.ts +++ b/packages/sandbox/sandbox-local/tests/acl-session.spec.ts @@ -199,7 +199,7 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { expect(mockState.grants).toHaveLength(1) // Downgrade: the standing grant is KEPT — never revoked, never - // re-granted. The read-only restricted token's list I carries no + // re-granted. The read-only restricted token's list carries no // orphan SID (pinned by the windows-acl runner regression), so the // ACE is inert under read-only while the map hit keeps the // re-upgrade free of eager propagation. diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index 80a35821d4..834cebbe3f 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: 2e3a16fa84541ffb2eb442c953777f37407e1b46 -README.zh.md: e58b70a120875ee57fe6e90376f7e36d9ce32ca3 +README.md: e9309caa874a33d3df32cc23b50c0d6375ee3066 +README.zh.md: eabf8f1c1e7e985960beee5abb540a33d052a05e diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index 2e3a16fa84..e9309caa87 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -40,9 +40,11 @@ The runner creates the restricted token, spawns the wrapped argv under it with t **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, 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). 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. -Modes (the token's restricting-SID list follows the mode): -- `workspace-write` (list J = logon SID, Everyone, Authenticated Users, 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. Authenticated Users stays in the list so the CIM path keeps working (`Get-CimInstance`, `Get-ComputerInfo`); the price is the residual Authenticated-Users-writable surface — notably the C:\ drive root, where standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs admit an AU-confined tree-creation escape — see the design note. -- `read-only` (list I = logon SID, Everyone — NO orphan): STRICT zero grants — nothing is writable, and the token also DROPS Authenticated Users for a zero ambient-write surface (the C:\-root escape above is closed). The orphan stays OUT of list I 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). The cost is CIM unavailability: the WMI namespace security check fails (`0x80041003`), so CIM cmdlets and `Get-ComputerInfo` (which silently returns incomplete results rather than an error) are unavailable — the model-facing surface documents that contract, not a prompt promise. +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). + +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. @@ -81,5 +83,5 @@ None directly; the denial surface belongs to the tool layer. - **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. - **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** — the UI-side warnings for granting unusually wide directories or FAT-class (non-ACL) volumes are not yet implemented; a FAT volume simply fails the grant loudly. +- **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 e58b70a120..eabf8f1c1e 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -40,9 +40,11 @@ runner 创建受限令牌,在令牌下启动被包裹的 argv,stdio 直接 **按会话授权复用**(`--write-sid`):seam 为每个会话只配置一个孤儿 SID——以仅作日志记录的 `sandbox/acl-session` 事件写入会话日志,因此恢复的会话回放**同一个** SID,fork 则铸造一个新的——并在会话首次受限执行时惰性物化其 ACE,在**服务器**进程生命周期内持有(提供方 dispose 时撤销)。传入 `--write-sid` 时 runner 既不授权也不回收(`manageDacls: false`);不传它(独立使用)则与之前一样按调用自行管理授权。重启后重新授权是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过 `SetNamedSecurityInfoW` 的应用(该应用会把相同的 ACE 急切地重新传播到整棵树——大型工作区上以分钟计)。异常关闭遗留的 ACE 无需垃圾回收:会话记录重新授权同一个 SID,下一次 dispose 即撤销它们。已知代价:在大型工作区树上物化授权会阻塞整次急切传播,每个服务器生命周期内每会话一次。 -模式(令牌的 restricting SID 列表随模式而定): -- `workspace-write`(列表 J = 登录 SID、Everyone、Authenticated Users、孤儿 SID):工作区与会话的**私有**临时子目录携带孤儿 SID 的 Write 授权;其余写全部被令牌交集拒绝。Authenticated Users 保留在列表中,CIM 路径才能继续工作(`Get-CimInstance`、`Get-ComputerInfo`);代价是残留的 Authenticated Users 可写面——尤其是 C:\ 盘根,那里驻留的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE 允许 AU 受限的子进程通过创建目录树逃逸——见设计笔记。 -- `read-only`(列表 I = 登录 SID、Everyone——不含孤儿 SID):**严格零授权**——没有任何可写位置,令牌还**去掉** Authenticated Users,让环境写入面归零(上述 C:\ 根逃逸被关闭)。孤儿 SID 有意留在列表 I **之外**:先前 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)。代价是 CIM 不可用:WMI 命名空间安全检查失败(`0x80041003`),因此 CIM cmdlet 与 `Get-ComputerInfo`(静默返回不完整结果而非报错)不可用——模型可见面文档化的是这一契约,而非提示词承诺。 +模式(令牌的 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)。 + +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 回归钉住(见设计笔记)。 `AclSandbox` 类(`tempDir: null` 关闭临时目录授权)仍是直接 spawn 场景的程序化 API;`AclWriteGrant` 是按会话契约中服务器侧的物化半边。 @@ -81,5 +83,5 @@ g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && - **授权物化是急切的全树传播。** 对带可继承 ACE 的目录调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(**不是**按访问惰性求值——实测在大型工作区树加上真实临时根上要几十秒)。按会话复用使它在每个服务器生命周期内每会话只付一次(在首次受限执行时惰性发生;完全相同的 ACE 历经重启存活时整体跳过);自管理的 runner 回退路径仍每次调用都付。若会话的工作区巨大,每个服务器生命周期内的第一次 pwsh 调用会相应地变慢。 - **在两个服务器进程中并发恢复同一会话会产生两个 SID。** 持久化记录存放在会话日志中;两个进程各自读取或创建记录,按路径的锁保持 DACL 合并一致,最后写入的记录胜出并用于后续恢复——落败 SID 的 ACE 由其所属进程的 dispose 撤销。单写者的会话用法(常规部署形态)不会遇到这种情况。 - **读侧隔离与网络策略超出范围** —— `WRITE_RESTRICTED` 只对写访问做交集检查;更强的隔离需叠加读侧策略。 -- **宽目录与 FAT 卷警告留待后续** —— 针对异常宽的目录或 FAT 类(无 ACL)卷授权的 UI 侧警告尚未实现;FAT 卷只会让授权立即报错。 +- **宽目录与 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/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 07f35ed177..f22dadfbb0 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -195,14 +195,9 @@ export class AclSandbox { this.sidAllocations.push(logonSid) const worldSid = makeWellKnownSid(api, abi.WinWorldSid) this.sidAllocations.push(worldSid) - const authUserSid = makeWellKnownSid(api, abi.WinAuthenticatedUserSid) - this.sidAllocations.push(authUserSid) const restricted = createRestrictedToken( api, currentToken, logonSid, writeSidPtr, - { - world: worldSid, - authUser: authUserSid, - }, + { world: worldSid }, this.mode, ) this.token = restricted diff --git a/packages/sandbox/sandbox-windows-acl/src/runner.ts b/packages/sandbox/sandbox-windows-acl/src/runner.ts index a6be67fc3f..3487e182be 100644 --- a/packages/sandbox/sandbox-windows-acl/src/runner.ts +++ b/packages/sandbox/sandbox-windows-acl/src/runner.ts @@ -15,9 +15,12 @@ * - workspace-write: the workspace and temp directories carry the orphan-SID * Write grant; every other write is denied by the token intersection. * - read-only: STRICT zero grants — no directory is writable, not even the - * NUL device (`> $null` fails with access denied); the token's restricting - * list also drops Authenticated Users (CIM unavailable — documented in - * README). + * NUL device (`> $null` fails with access denied); the restricting list + * carries no orphan SID, so a standing grant ACE from an earlier + * workspace-write period stays inert. BOTH modes drop Authenticated Users + * (CIM unavailable — documented in README) and INTERACTIVE/LOCAL (the + * Public tree writes are denied); the two lists share the keep-alive group + * (logon SID, EVERYONE) and differ only by the orphan. * * `--write-sid`: the seam's per-session grant contract — the CALLER has * already materialized the orphan-SID ACEs (once per session, server diff --git a/packages/sandbox/sandbox-windows-acl/src/token.ts b/packages/sandbox/sandbox-windows-acl/src/token.ts index 2b114a6567..bd0f71ee9f 100644 --- a/packages/sandbox/sandbox-windows-acl/src/token.ts +++ b/packages/sandbox/sandbox-windows-acl/src/token.ts @@ -101,44 +101,41 @@ function buildRestrictingSids(sids: readonly NativePtr[]): Buffer { return buffer } -/** The well-known SIDs packed into every restricted token's restricting list. */ +/** The well-known SID packed into every restricted token's restricting list. */ export interface RestrictingSidSet { world: NativePtr - authUser: NativePtr } /** * Create the write-restricted token with the mode-selected restricting list - * (dual lists verified on Win11 26200, see the POC-worktree restrict-variant - * harness): - * - list I (read-only): [logon SID, EVERYONE] - * - list J (workspace-write): [logon SID, EVERYONE, Authenticated Users, orphan] + * (verified on Win11 26200, see the POC-worktree restrict-variant harness): + * - read-only: [logon SID, EVERYONE] + * - workspace-write: [logon SID, EVERYONE, orphan] * - * The logon SID and EVERYONE are shared: they keep the early startup chain - * (0xC0000142 without them) and CNG (`\Device\CNG` write trustee — pwsh - * crashes 0xE0434352 without EVERYONE) alive. Authenticated Users exists in - * list J ONLY because the CIM path's WMI namespace security check requires it - * (0x80041003 otherwise) — read-only drops it for a zero ambient-write - * surface (it closes the host's C:\-root tree-creation escape, where - * `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs stand) at the cost of CIM - * unavailability; documented in README. List I also carries NO orphan: a - * standing grant ACE from an earlier workspace-write period (a - * `/permission` mode downgrade, or a crash-resumed session) must stay INERT - * under read-only — the WRITE_RESTRICTED pass-2 check grants only what the - * restricting list carries, so omitting the orphan keeps 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 re-propagation). - * INTERACTIVE/LOCAL are absent from BOTH lists — the host's Public tree - * grants write to INTERACTIVE, so removing it closes that escape. S-1-2-1 - * (console logon) is intentionally absent: see win32-abi.ts for the - * verified failure modes. FAILS CLOSED: any failure throws — never spawn - * unrestricted. + * 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 + * 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 + * 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 + * `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both — documented in + * README. INTERACTIVE/LOCAL are absent from BOTH lists too — the host's + * Public tree grants write to INTERACTIVE, so removing it closes that + * escape. S-1-2-1 (console logon) is intentionally absent: see win32-abi.ts + * for the verified failure modes. FAILS CLOSED: any failure throws — never + * spawn unrestricted. * @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 (list J only). + * @param writeSid - the orphan SID forming the write allowlist (workspace-write only). * @param known - the well-known SIDs entering the restricting list. - * @param mode - selects the restricting list (I for read-only, J for workspace-write). + * @param mode - selects the restricting list (workspace-write adds the orphan). * @returns the restricted token handle. */ export function createRestrictedToken( @@ -151,7 +148,7 @@ export function createRestrictedToken( ): NativePtr { const restrictingSids = buildRestrictingSids(mode === 'read-only' ? [logonSid, known.world] - : [logonSid, known.world, known.authUser, writeSid]) + : [logonSid, known.world, writeSid]) const tokenSlot = allocPtrSlot() const created = api.createRestrictedToken( currentToken, diff --git a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts index b96acf4942..cc250c2f9e 100644 --- a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts @@ -79,20 +79,8 @@ export const LUA_TOKEN = 0x4 export const WRITE_RESTRICTED = 0x8 // WELL_KNOWN_SID_TYPE (winnt.h lines ~3369-3407) -/** WinWorldSid: S-1-1-0 (Everyone). */ +/** WinWorldSid: S-1-1-0 (Everyone) — the only well-known SID the restricted tokens use (keep-alive group; see token.ts). */ export const WinWorldSid = 1 -/** - * WinLocalSid: S-1-2-0 (LOCAL) — safe, created successfully on every init - * (it sits in every restricted token's restricting list). The - * CreateWellKnownSid ERROR_INVALID_PARAMETER failure documented in this - * module's header comment belongs to WinLocalLogonSid (S-1-2-1), NOT to - * this type. - */ -export const WinLocalSid = 2 -/** WinInteractiveSid: S-1-5-4 (INTERACTIVE). */ -export const WinInteractiveSid = 11 -/** WinAuthenticatedUserSid: S-1-5-11 (Authenticated Users). */ -export const WinAuthenticatedUserSid = 17 // TOKEN_INFORMATION_CLASS (winnt.h line ~3963: TokenUser=1, TokenGroups=2) /** TokenGroups: GetTokenInformation class returning the token's group SIDs. */ diff --git a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts index 18af8a8ffd..2ce251c308 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts @@ -38,6 +38,13 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { let isolatedTemp!: string let secretFile!: string let escapeFile!: string + // The ambient-writable probe target: a subdirectory of C:\Users\Public. + // INTERACTIVE/LOCAL are absent from BOTH restricting lists, so the Public + // tree's INTERACTIVE grant must NOT satisfy the write check — the ambient + // boundary the dual-list design closes (bot-reported blind spot). The + // Public tree may be unavailable or unwritable for the test user on some + // hosts; the probe test skips itself when the directory cannot be created. + let publicProbeDir: string | undefined beforeAll(() => { scratchRoot = mkdtempSync(join(tmpdir(), 'dsh-acl-runner-')) @@ -47,11 +54,17 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { secretFile = join(scratchRoot, 'secret.txt') writeFileSync(secretFile, 'top secret - must stay readable to prove the read boundary') escapeFile = join(scratchRoot, 'escaped.txt') + try { + publicProbeDir = mkdtempSync(join(process.env.PUBLIC ?? 'C:\\Users\\Public', 'dsh-acl-public-')) + } catch { + publicProbeDir = undefined + } }) afterAll(() => { rmSync(scratchRoot, { recursive: true, force: true }) rmSync(isolatedTemp, { recursive: true, force: true }) + if (publicProbeDir !== undefined) rmSync(publicProbeDir, { recursive: true, force: true }) }) it('workspace-write: the confined child writes granted directories only', () => { @@ -65,8 +78,10 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { `try{Set-Content -Path '${isolatedTemp}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`, `try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK (ESCAPE!)'}catch{'ESCAPE-WRITE: DENIED'};`, `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'};`, - // List J carries Authenticated Users: the CIM path (WMI namespace - // security check) stays alive under workspace-write. + // Authenticated Users is absent from BOTH lists: the WMI namespace + // security check fails (0x80041003) — CIM is unavailable under every + // confined mode (the documented contract; the C:\-root tree-creation + // escape is closed in both as the other side of the trade). "try{Get-CimInstance Win32_OperatingSystem -ErrorAction Stop | Out-Null;'CIM: OK'}catch{'CIM: DENIED'}", ].join('') const result = runRunner([ @@ -79,12 +94,12 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { expect(result.stdout).toContain('TEMP-WRITE: OK') expect(result.stdout).toContain('ESCAPE-WRITE: DENIED') expect(result.stdout).toContain('SECRET-READ: OK') - expect(result.stdout).toContain('CIM: OK') + expect(result.stdout).toContain('CIM: DENIED') expect(existsSync(escapeFile)).toBe(false) expect(existsSync(join(writableDir, 'child-wrote.txt'))).toBe(true) }, 30_000) - it('read-only: strict zero grants — no writes anywhere (not even NUL), reads and $null redirection fine, CIM unavailable (list I)', () => { + it('read-only: strict zero grants — no writes anywhere (not even NUL), reads and $null redirection fine, CIM unavailable', () => { const probe = [ "$ErrorActionPreference='SilentlyContinue';", '\'LANGMODE: \' + $ExecutionContext.SessionState.LanguageMode;', @@ -95,9 +110,9 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { // PowerShell's $null redirection discards without opening NUL — must keep working. 'echo hi > $null;\'DOLLAR-NULL: OK\';', `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'};`, - // List I drops Authenticated Users: the WMI namespace security check - // fails (0x80041003) — the documented read-only CIM boundary, the - // price of the zero ambient-write surface. + // BOTH lists drop Authenticated Users: the WMI namespace security + // check fails (0x80041003) — the documented CIM boundary of every + // confined mode, the price of the zero ambient-write surface. "try{Get-CimInstance Win32_OperatingSystem -ErrorAction Stop | Out-Null;'CIM: OK'}catch{'CIM: DENIED'}", ].join('') const result = runRunner([ @@ -177,7 +192,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { 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 - // switching to read-only, the restricted token's list I must carry NO + // switching to read-only, the restricted token's read-only list must carry NO // orphan SID — the standing ACE stays but the pass-2 check cannot use // it, so the workspace write is denied (previously it LEAKED). The // switch back reuses the SAME standing ACE: the re-upgrade write lands @@ -214,6 +229,30 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { } }, 30_000) + it('ambient-writable escape regression: a C:\\Users\\Public subdirectory is denied under BOTH modes (INTERACTIVE absent from both lists)', (ctx) => { + // The Public tree grants write to INTERACTIVE; the D1-D6 matrix pinned + // that removing INTERACTIVE from the restricting lists closes the escape. + // The committed suites never probed it — this pins the ambient boundary + // end to end with the real restricted token. + if (publicProbeDir === undefined) { + ctx.skip() // Public unavailable/unwritable on this host + return + } + const probe = [ + "$ErrorActionPreference='SilentlyContinue';", + `try{Set-Content -Path '${publicProbeDir}\\public-escaped.txt' -Value ok -ErrorAction Stop;'PUBLIC-WRITE: OK (ESCAPE!)'}catch{'PUBLIC-WRITE: DENIED'}`, + ].join('') + for (const mode of ['read-only', 'workspace-write'] as const) { + const result = runRunner([ + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', mode, + '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe, + ]) + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + expect(result.stdout, `mode: ${mode}`).toContain('PUBLIC-WRITE: DENIED') + expect(existsSync(join(publicProbeDir, 'public-escaped.txt')), `mode: ${mode}`).toBe(false) + } + }, 30_000) + it('runner-side failure: signature on stderr and exit 127, the command never runs', () => { const result = runRunner(['--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write']) expect(result.status).toBe(127) diff --git a/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp b/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp index 866fda7160..a74afe9d80 100644 --- a/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp +++ b/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp @@ -109,9 +109,6 @@ int wmain() P(WRITE_RESTRICTED); P((int)WinWorldSid); - P((int)WinLocalSid); - P((int)WinInteractiveSid); - P((int)WinAuthenticatedUserSid); P((int)WinLocalLogonSid); P((int)WinConsoleLogonSid);