fix(sandbox): keep read-only strictly zero-grant by dropping the write-allowlist SID from list I
A session that materialized its workspace-write grant and then switched to read-only (or crashed and resumed under read-only) kept a writable workspace for the server lifetime: the standing orphan-SID ACE survived the downgrade and list I still carried the orphan, so the write-restricted pass-2 check granted it. List I is now [logon SID, EVERYONE] only — the standing ACE stays inert under read-only while the unrevoked ACE keeps the re-upgrade free (map hit, no re-propagation). Pinned by the runner's real-token mode-downgrade regression (standing ACE denied under read-only, effective again on re-upgrade), the acl-session mode-switch cycle (nothing under read-only, one materialization on upgrade, no revoke on downgrade), and ConstrainedLanguage pins in both modes.
This commit is contained in:
@@ -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: e93a066aca1a39fb177a7ae3c2f71f44724befd7
|
||||
2026-08-08-windows-acl-restricted-token-sandbox.zh.md: e4d5254504d2f2318c6b7646f09156dfef4d7619
|
||||
2026-08-08-windows-acl-restricted-token-sandbox.md: 4459e121c09fe566efc9a35604df10dae223928c
|
||||
2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 307f39d3664e5e9f837ce630fc8c8b442bc4d821
|
||||
|
||||
@@ -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, orphan — no Authenticated Users, so CIM is unavailable but the ambient AU-writable surface, notably the C:\-root tree-creation escape, is closed) and list J (`workspace-write` = + Authenticated Users, 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 (`<temp>\dsh-<hash>`, 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 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 (`<temp>\dsh-<hash>`, 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).
|
||||
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.
|
||||
|
||||
## 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 — 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 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).
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -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、孤儿——不含 Authenticated Users,因此 CIM 不可用,但环境 AU 可写面(尤其是 C:\-root 建树逃逸)被关闭)与 list J(`workspace-write` = + Authenticated Users,以保留该残余面为代价维持 CIM 通路存活);经验证的保活不变式是登录 SID + Everyone 支撑早期 DLL init 与 CNG,Authenticated Users 仅支撑 WMI namespace 安全校验。Workspace-write 子进程看到的是私有的每会话临时子目录(`<temp>\dsh-<hash>`,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 为双模式: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 子进程看到的是私有的每会话临时子目录(`<temp>\dsh-<hash>`,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 在受限令牌下失败(诊断噪音,已记录)。
|
||||
所得:仅写隔离、不引入新的 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 中。
|
||||
|
||||
## 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 回收——mock 掉 Win32 表面),win32 侧由 `grant.spec.ts`(真实 DACL 物化)、`acl.spec.ts` 的幂等授权快速路径与 `runner.spec.ts` 的 `--write-sid` 契约(调用者所有目录的授权、经 TMP/TEMP 的私有临时子目录、双模式 CIM 探针)钉住。
|
||||
产品可见的 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 的钉定)钉住。
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -378,8 +378,12 @@ export class LocalSandboxProvider extends SandboxProvider {
|
||||
* 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); read-only materializes
|
||||
* NOTHING — its token alone restricts every write. Fail-closed: a
|
||||
* half-materialized grant is revoked before the error propagates.
|
||||
* 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
|
||||
* 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.
|
||||
* @param mode - the policy mode (grants exist only under workspace-write).
|
||||
*/
|
||||
|
||||
@@ -150,6 +150,68 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('mode switch: read-only materializes nothing, the upgrade materializes ONCE with the same SID, and the downgrade keeps the standing grant (no revoke, no re-grant)', async () => {
|
||||
try {
|
||||
const { ctx, sandbox } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
const privateTemp = sessionTempDir('sess-switch')
|
||||
scratch.push(privateTemp)
|
||||
const session = ctx.sessions.create(SessionId('sess-switch'), { meta: { cwd: ws } })
|
||||
const readOnly: SandboxPolicy = { mode: 'read-only', workspaceRoot: ws, sessionId: 'sess-switch' }
|
||||
const workspaceWrite: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'sess-switch' }
|
||||
|
||||
// Read-only first: the record still rides along (--write-sid, one
|
||||
// event) but NOTHING is materialized and the ambient temp root is
|
||||
// passed — the map stays empty, so the later upgrade must materialize.
|
||||
const confinedRo = sandbox.confine(['true'], readOnly)
|
||||
expect(confinedRo.argv).toEqual([
|
||||
'node', 'windows-acl-runner.js',
|
||||
'--workspace', ws,
|
||||
'--temp', tmpdir(),
|
||||
'--mode', 'read-only',
|
||||
'--write-sid', 'S-1-4-42-42',
|
||||
'--',
|
||||
'true',
|
||||
])
|
||||
expect(mockState.grants).toHaveLength(0)
|
||||
expect(existsSync(privateTemp)).toBe(false)
|
||||
|
||||
// Upgrade: the FIRST workspace-write confine materializes the grant
|
||||
// (the map was empty — read-only never wrote it) with the SAME SID
|
||||
// and the private temp dir, so the upgrade path cannot dead-end.
|
||||
const upgraded = sandbox.confine(['true'], workspaceWrite)
|
||||
expect(upgraded.argv).toEqual([
|
||||
'node', 'windows-acl-runner.js',
|
||||
'--workspace', ws,
|
||||
'--temp', privateTemp,
|
||||
'--mode', 'workspace-write',
|
||||
'--write-sid', 'S-1-4-42-42',
|
||||
'--',
|
||||
'true',
|
||||
])
|
||||
expect(mockState.grants).toHaveLength(1)
|
||||
expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-42-42', added: [ws, privateTemp], disposed: false })
|
||||
expect(existsSync(privateTemp)).toBe(true)
|
||||
|
||||
// Reuse: the second workspace-write call is the map hit.
|
||||
sandbox.confine(['true'], workspaceWrite)
|
||||
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
|
||||
// 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.
|
||||
sandbox.confine(['true'], readOnly)
|
||||
expect(mockState.grants).toHaveLength(1)
|
||||
expect(mockState.grants[0]!.disposed).toBe(false)
|
||||
expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1)
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('resume: a seeded record replays with the SAME SID and no second event is appended', async () => {
|
||||
try {
|
||||
const ws = workspaceRoot()
|
||||
|
||||
@@ -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: 1969515f6692eda059fd5e83449d68c6a4da5c4f
|
||||
README.zh.md: 4c751ba6dc45bca8cd600b0313b342e37e9ebe54
|
||||
README.md: 2e3a16fa84541ffb2eb442c953777f37407e1b46
|
||||
README.zh.md: e58b70a120875ee57fe6e90376f7e36d9ce32ca3
|
||||
|
||||
@@ -42,7 +42,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):
|
||||
- `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, 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 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.
|
||||
- `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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -82,3 +82,4 @@ None directly; the denial surface belongs to the tool layer.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
@@ -42,7 +42,7 @@ runner 创建受限令牌,在令牌下启动被包裹的 argv,stdio 直接
|
||||
|
||||
模式(令牌的 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:\ 根逃逸被关闭)。NUL 设备是带安全描述符的对象,同样不被授权(区别于 Linux 的 `/dev/null` sink):`Set-Content NUL` 与原生 `> NUL` 写会以 access denied 失败,而 PowerShell 的 `> $null` 重定向不受影响(它直接丢弃、不打开 NUL)。代价是 CIM 不可用:WMI 命名空间安全检查失败(`0x80041003`),因此 CIM cmdlet 与 `Get-ComputerInfo`(静默返回不完整结果而非报错)不可用——模型可见面文档化的是这一契约,而非提示词承诺。
|
||||
- `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`(静默返回不完整结果而非报错)不可用——模型可见面文档化的是这一契约,而非提示词承诺。
|
||||
|
||||
`AclSandbox` 类(`tempDir: null` 关闭临时目录授权)仍是直接 spawn 场景的程序化 API;`AclWriteGrant` 是按会话契约中服务器侧的物化半边。
|
||||
|
||||
@@ -82,3 +82,4 @@ g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 &&
|
||||
- **在两个服务器进程中并发恢复同一会话会产生两个 SID。** 持久化记录存放在会话日志中;两个进程各自读取或创建记录,按路径的锁保持 DACL 合并一致,最后写入的记录胜出并用于后续恢复——落败 SID 的 ACE 由其所属进程的 dispose 撤销。单写者的会话用法(常规部署形态)不会遇到这种情况。
|
||||
- **读侧隔离与网络策略超出范围** —— `WRITE_RESTRICTED` 只对写访问做交集检查;更强的隔离需叠加读侧策略。
|
||||
- **宽目录与 FAT 卷警告留待后续** —— 针对异常宽的目录或 FAT 类(无 ACL)卷授权的 UI 侧警告尚未实现;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 运行。
|
||||
|
||||
@@ -111,7 +111,7 @@ export interface RestrictingSidSet {
|
||||
* 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, orphan]
|
||||
* - list I (read-only): [logon SID, EVERYONE]
|
||||
* - list J (workspace-write): [logon SID, EVERYONE, Authenticated Users, orphan]
|
||||
*
|
||||
* The logon SID and EVERYONE are shared: they keep the early startup chain
|
||||
@@ -121,15 +121,22 @@ export interface RestrictingSidSet {
|
||||
* (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. 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.
|
||||
* 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.
|
||||
* @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.
|
||||
* @param writeSid - the orphan SID forming the write allowlist (list J 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).
|
||||
* @returns the restricted token handle.
|
||||
@@ -143,7 +150,7 @@ export function createRestrictedToken(
|
||||
mode: 'read-only' | 'workspace-write',
|
||||
): NativePtr {
|
||||
const restrictingSids = buildRestrictingSids(mode === 'read-only'
|
||||
? [logonSid, known.world, writeSid]
|
||||
? [logonSid, known.world]
|
||||
: [logonSid, known.world, known.authUser, writeSid])
|
||||
const tokenSlot = allocPtrSlot()
|
||||
const created = api.createRestrictedToken(
|
||||
|
||||
@@ -57,6 +57,10 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
|
||||
it('workspace-write: the confined child writes granted directories only', () => {
|
||||
const probe = [
|
||||
"$ErrorActionPreference='SilentlyContinue';",
|
||||
// The restricted token puts pwsh into ConstrainedLanguage in BOTH modes
|
||||
// (documented Known Limitation) — pinned here so a token change that
|
||||
// silently restores FullLanguage is caught.
|
||||
'\'LANGMODE: \' + $ExecutionContext.SessionState.LanguageMode;',
|
||||
`try{Set-Content -Path '${writableDir}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`,
|
||||
`try{Set-Content -Path '${isolatedTemp}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`,
|
||||
`try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK (ESCAPE!)'}catch{'ESCAPE-WRITE: DENIED'};`,
|
||||
@@ -70,6 +74,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
|
||||
'--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe,
|
||||
])
|
||||
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
|
||||
expect(result.stdout).toContain('LANGMODE: ConstrainedLanguage')
|
||||
expect(result.stdout).toContain('TARGET-WRITE: OK')
|
||||
expect(result.stdout).toContain('TEMP-WRITE: OK')
|
||||
expect(result.stdout).toContain('ESCAPE-WRITE: DENIED')
|
||||
@@ -100,6 +105,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
|
||||
'--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe,
|
||||
])
|
||||
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
|
||||
expect(result.stdout).toContain('LANGMODE: ConstrainedLanguage')
|
||||
expect(result.stdout).toContain('TARGET-WRITE: DENIED')
|
||||
expect(result.stdout).toContain('TEMP-WRITE: DENIED')
|
||||
expect(result.stdout).toContain('NUL-WRITE: DENIED')
|
||||
@@ -168,6 +174,46 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
|
||||
}
|
||||
}, 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
|
||||
// switching to read-only, the restricted token's list I 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
|
||||
// without any re-grant.
|
||||
const writeSid = 'S-1-4-9001-7'
|
||||
const grant = AclWriteGrant.create(writeSid)
|
||||
grant.add(writableDir)
|
||||
try {
|
||||
const downgradeProbe = [
|
||||
"$ErrorActionPreference='SilentlyContinue';",
|
||||
`try{Set-Content -Path '${writableDir}\\downgraded.txt' -Value ok -ErrorAction Stop;'DOWNGRADE-WRITE: OK (LEAK!)'}catch{'DOWNGRADE-WRITE: DENIED'}`,
|
||||
].join('')
|
||||
const downgraded = runRunner([
|
||||
'--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'read-only', '--write-sid', writeSid,
|
||||
'--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', downgradeProbe,
|
||||
])
|
||||
expect(downgraded.status, `stderr: ${downgraded.stderr}`).toBe(0)
|
||||
expect(downgraded.stdout).toContain('DOWNGRADE-WRITE: DENIED')
|
||||
expect(existsSync(join(writableDir, 'downgraded.txt'))).toBe(false)
|
||||
|
||||
const reupgradeProbe = [
|
||||
"$ErrorActionPreference='SilentlyContinue';",
|
||||
`try{Set-Content -Path '${writableDir}\\reupgraded.txt' -Value ok -ErrorAction Stop;'REUPGRADE-WRITE: OK'}catch{'REUPGRADE-WRITE: DENIED'}`,
|
||||
].join('')
|
||||
const reupgraded = runRunner([
|
||||
'--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write', '--write-sid', writeSid,
|
||||
'--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', reupgradeProbe,
|
||||
])
|
||||
expect(reupgraded.status, `stderr: ${reupgraded.stderr}`).toBe(0)
|
||||
expect(reupgraded.stdout).toContain('REUPGRADE-WRITE: OK')
|
||||
expect(existsSync(join(writableDir, 'reupgraded.txt'))).toBe(true)
|
||||
} finally {
|
||||
grant.dispose()
|
||||
}
|
||||
}, 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)
|
||||
|
||||
Reference in New Issue
Block a user