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

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