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