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:
Huanqi Cao
2026-08-08 20:11:01 +08:00
parent e9b1c64a38
commit 2dd9af6f4b
10 changed files with 143 additions and 22 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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-closedPOC 因忽略返回值而 fail-open。孤儿 SID 按会话而非按 spawnseam 每会话供给一个 SID作为 log-only 的 `sandbox/acl-session` 事件记录在会话日志中fork 铸出新 SID恢复回放同一个其 ACE 在该会话首次受限执行时惰性物化,并在服务器进程生命周期内持有(提供方 dispose资源释放时回收幂等重授权在该 ACE 跨重启原样存续时跳过急切的全树重传播——不做垃圾回收)。令牌的 restricting list 为双模式list I`read-only` = 登录 SIDEveryone、孤儿——不含 Authenticated Users因此 CIM 不可用,但环境 AU 可写面(尤其是 C:\-root 建树逃逸)被关闭)与 list J`workspace-write` = + Authenticated Users以保留该残余面为代价维持 CIM 通路存活);经验证的保活不变式是登录 SID + Everyone 支撑早期 DLL init 与 CNGAuthenticated 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-closedPOC 因忽略返回值而 fail-open。孤儿 SID 按会话而非按 spawnseam 每会话供给一个 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 与 CNGAuthenticated 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` 失去 CIMAuthUsers 被移除——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` 失去 CIMAuthUsers 被移除——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

View File

@@ -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).
*/

View File

@@ -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()

View File

@@ -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

View File

@@ -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.

View File

@@ -42,7 +42,7 @@ runner 创建受限令牌,在令牌下启动被包裹的 argvstdio 直接
模式(令牌的 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 运行。

View File

@@ -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(

View File

@@ -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)