fix(sandbox): isolate Windows temp capabilities

This commit is contained in:
Tianyi Cui
2026-08-10 15:31:38 +08:00
parent 9a3c89d04a
commit fd650af340
30 changed files with 744 additions and 596 deletions
+118 -72
View File
@@ -2,10 +2,10 @@
* Windows ACL write-restriction sandbox backend for the DeepSeek Harness
* sandbox seam. Mirrors the mechanism of github.com/huoyaoyuan/
* windows-acl-restrict-poc @ 10e4dfb (the fixed revision): a WRITE_RESTRICTED
* token whose restricting SIDs include a write SID (`S-1-4-x-y`) that only
* this sandbox adds to the target directories' DACLs — the intersection
* check then allows writes exactly where that SID has a Write ACE, and
* nowhere else the write SID is concerned (the token's write check ALSO
* token whose restricting SIDs include distinct workspace and temp write
* SIDs that this sandbox adds to their owning directories' DACLs — the
* intersection check then allows writes exactly where either capability has
* a Write ACE, and nowhere else those SIDs are concerned (the check ALSO
* inherits the ambient write ACEs of the other restricting SIDs — the
* keep-alive group logon SID + Everyone; Authenticated Users, INTERACTIVE,
* and LOCAL are absent from both lists — see the seam's dual-list contract
@@ -15,7 +15,9 @@
* path, so the workspace-root ACE materializes once per workspace per
* machine and every later provision hits the exact-ACE skip — the
* grant-reuse story the per-session random SID paid a full tree propagation
* per session for. Unlike the POC, every API failure throws with the API
* per session for. Each private temp directory instead receives its own SID,
* so sibling sessions sharing a workspace cannot enter one another's temp
* trees. Unlike the POC, every API failure throws with the API
* name and exact Win32 code; a child is NEVER spawned unrestricted.
*
* Known boundaries (inherent to restricted tokens, not this port):
@@ -24,15 +26,14 @@
* - console isolation is unavailable — children share the host console
* (CREATE_NO_WINDOW / CREATE_NEW_CONSOLE children die with
* STATUS_DLL_INIT_FAILED under the restriction);
* - the temp directory and every writable directory must be owned by the
* - the private temp directory and every writable directory must be owned by the
* caller (owner-implicit WRITE_DAC);
* - grants are standing ACE mutations on real directories. WORKSPACE grants
* are deliberately never revoked — the ACE is the cross-session reuse
* cache (revoking would force the next session to re-propagate the whole
* tree). TEMP grants are revocable: dispose() removes them so a standing
* inheritable ACE never outlives its session's temp directory (an
* inheritable ACE on the ambient temp root would otherwise widen the
* SID's write reach to every future temp file). With `manageDacls: false`
* inheritable ACE never outlives its session's temp directory. The
* ambient temp root is never granted implicitly. With `manageDacls: false`
* the CALLER owns the DACLs (the sandbox seam's grant reuse):
* init()/dispose() skip grant/revoke entirely and the caller must not
* revoke under live children.
@@ -44,7 +45,7 @@ import { resolve } from 'node:path'
import { grantWrite, revokeWrite } from './acl.ts'
import { Win32Error } from './errors.ts'
import { allocPtrSlot, decodePtr, getTempPath, isNullPtr, throwLastError, win32 } from './ffi.ts'
import { allocPtrSlot, decodePtr, isNullPtr, throwLastError, win32 } from './ffi.ts'
import type { NativePtr, Win32Bindings } from './ffi.ts'
import { drainPipe, spawnSandboxed, spawnSandboxedInherited, waitForExit } from './spawn.ts'
import { createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken, setTokenDefaultDaclGrant } from './token.ts'
@@ -52,18 +53,17 @@ import * as abi from './win32-abi.ts'
export { quoteArg } from './spawn.ts'
export { AclWriteGrant } from './grant.ts'
export { workspaceWriteSid } from './workspace-sid.ts'
export { tempWriteSid, workspaceWriteSid } from './workspace-sid.ts'
export { Win32Error } from './errors.ts'
/** Construction options: the write allowlist, the optional temp grant, and the orphan SID identity. */
/** Construction options: the workspace/temp allowlists and their distinct SID identities. */
export interface AclSandboxOptions {
/** Directories the confined child may write into (must exist and be caller-owned). */
writableDirs: readonly string[]
/**
* Temp directory to also grant; defaults to GetTempPathW() at init time.
* Pass null for read-only confinement: NO explicit temp grant. Ambient
* Everyone authority remains part of the backend's documented partial
* boundary — see README.
* Existing private temp directory to grant. Workspace-write callers must
* pass it explicitly or pass null to disable temp writes; the ambient temp
* root is never an implicit grant. Read-only accepts only null/undefined.
*/
tempDir?: string | null
/**
@@ -74,6 +74,13 @@ export interface AclSandboxOptions {
* outlives every instance and later provisions hit the exact-ACE skip.
*/
writeSid?: string
/**
* The private temp directory's write SID. Required whenever
* workspace-write grants a temp directory, absent otherwise. It must be
* distinct from {@link writeSid}, so sibling sessions sharing a workspace
* cannot use the standing workspace capability in one another's temp tree.
*/
tempWriteSid?: string
/**
* The file-effect mode this instance confines under — selects the
* restricted token's restricting-SID list (I for read-only, J for
@@ -85,7 +92,7 @@ export interface AclSandboxOptions {
/**
* Whether this instance owns its DACL grants (default true). False means
* the CALLER has already materialized the ACEs (the sandbox seam's
* per-session grant reuse): init()/dispose() skip grant/revoke entirely —
* workspace/temp capability lifecycle): init()/dispose() skip grant/revoke entirely —
* the caller holds the grants for its own lifetime and revokes them.
*/
manageDacls?: boolean
@@ -135,8 +142,10 @@ export interface AclSandboxChild {
export class AclSandbox {
/** Absolute writable directories (constructor-validated). */
readonly writableDirs: string[]
/** The write SID string whose ACEs form the write allowlist (workspace-write only). */
/** The workspace SID string whose ACEs form the workspace allowlist. */
readonly writeSid: string | undefined
/** The private temp directory's write SID (workspace-write with temp only). */
readonly tempWriteSid: string | undefined
/** The file-effect mode — the restricted token's restricting-SID list selection. */
readonly mode: 'read-only' | 'workspace-write'
private readonly tempDirOption: string | null | undefined
@@ -145,9 +154,10 @@ export class AclSandbox {
private api: Win32Bindings | undefined
private token: NativePtr | undefined
private writeSidPtr: NativePtr | undefined
/** The well-known/logon SID allocations init() makes; freed by dispose() alongside the write SID. */
private tempWriteSidPtr: NativePtr | undefined
/** The well-known/logon SID allocations init() makes; freed by dispose() alongside the write SIDs. */
private sidAllocations: NativePtr[] = []
private grantedPaths: string[] = []
private grantedPaths: Array<{ path: string; sidPtr: NativePtr }> = []
constructor(options: AclSandboxOptions) {
this.mode = options.mode
@@ -161,9 +171,28 @@ export class AclSandbox {
})
this.tempDirOption = options.tempDir
this.writeSid = options.writeSid
this.tempWriteSid = options.tempWriteSid
if (this.mode === 'workspace-write' && this.writeSid === undefined) {
throw new Error('AclSandbox workspace-write requires a write SID — derive it from the workspace via workspaceWriteSid()')
}
if (this.mode === 'workspace-write' && this.tempDirOption === undefined) {
throw new Error('AclSandbox workspace-write requires an explicit private temp directory or null')
}
if (this.mode === 'read-only' && this.tempDirOption !== undefined && this.tempDirOption !== null) {
throw new Error('AclSandbox read-only does not accept a temp directory')
}
if (this.mode === 'read-only' && (this.writeSid !== undefined || this.tempWriteSid !== undefined)) {
throw new Error('AclSandbox read-only does not accept write SIDs')
}
if (this.mode === 'workspace-write' && this.tempDirOption !== null && this.tempWriteSid === undefined) {
throw new Error('AclSandbox workspace-write with temp requires a temp write SID — derive it via tempWriteSid()')
}
if (this.tempDirOption === null && this.tempWriteSid !== undefined) {
throw new Error('AclSandbox temp write SID requires a temp directory')
}
if (this.writeSid !== undefined && this.tempWriteSid === this.writeSid) {
throw new Error('AclSandbox workspace and temp write SIDs must be distinct')
}
}
/** Resolved temp directory (available after init; null when temp grants are disabled). */
@@ -171,56 +200,53 @@ export class AclSandbox {
return this.tempDirResolved
}
/** Create the restricted token and apply the orphan-SID grants. Idempotent-unsafe: once per instance. */
/** Create the restricted token and apply the capability-SID grants. Idempotent-unsafe: once per instance. */
async init(): Promise<void> {
if (this.api !== undefined) throw new Error('AclSandbox is already initialized')
const api = await win32()
const currentToken = openCurrentProcessToken(api)
let currentTokenOpen = true
let restrictedToken: NativePtr | undefined
try {
// Read-only runs carry no write SID (its restricting list has no
// orphan): nothing to parse, nothing to grant.
let writeSidPtr: NativePtr | undefined
if (this.writeSid !== undefined) {
const parseSid = (sid: string): NativePtr => {
const sidSlot = allocPtrSlot()
if (api.convertStringSidToSidW(this.writeSid, sidSlot) === 0) {
throwLastError(api, 'ConvertStringSidToSidW', this.writeSid)
if (api.convertStringSidToSidW(sid, sidSlot) === 0) {
throwLastError(api, 'ConvertStringSidToSidW', sid)
}
const parsedSid = decodePtr(sidSlot)
if (parsedSid === null) throw new Win32Error('ConvertStringSidToSidW', api.getLastError(), this.writeSid)
this.writeSidPtr = parsedSid
writeSidPtr = parsedSid
if (parsedSid === null) throw new Win32Error('ConvertStringSidToSidW', api.getLastError(), sid)
return parsedSid
}
this.writeSidPtr = this.writeSid === undefined ? undefined : parseSid(this.writeSid)
this.tempWriteSidPtr = this.tempWriteSid === undefined ? undefined : parseSid(this.tempWriteSid)
const tempDir = this.tempDirOption === null
? null
: this.tempDirOption !== undefined ? this.tempDirOption : getTempPath(api)
const tempDir = this.mode === 'read-only' || this.tempDirOption === null ? null : this.tempDirOption
if (tempDir === undefined) throw new Error('AclSandbox workspace-write temp directory was not resolved')
if (tempDir !== null) {
if (!existsSync(tempDir) || !statSync(tempDir).isDirectory()) {
throw new Error(`AclSandbox temp dir does not exist or is not a directory: ${tempDir}`)
}
this.tempDirResolved = tempDir
}
this.tempDirResolved = tempDir
// manageDacls: false — the caller (the sandbox seam's grant) already
// materialized the ACEs; this instance must neither add nor remove any.
// When this instance owns the DACLs, writableDir ACEs are STANDING (the
// per-workspace reuse cache — dispose() never revokes them, or the next
// provision would re-propagate the whole tree) and the temp ACE is
// REVOCABLE (dispose() removes it — an inheritable ACE on the ambient
// temp root must not outlive the instance, or it would widen the SID's
// write reach to every future temp file).
// REVOCABLE (dispose() removes it before the private directory is
// deleted; the ambient temp root is never granted).
if (this.manageDacls) {
if (writeSidPtr !== undefined) {
if (this.writeSidPtr !== undefined) {
for (const path of this.writableDirs) {
grantWrite(api, path, writeSidPtr)
grantWrite(api, path, this.writeSidPtr)
}
if (tempDir !== null) {
if (tempDir !== null && this.tempWriteSidPtr !== undefined) {
// Record BEFORE granting: grantWrite can throw after a successful
// apply (a LocalFree failure), and the fail-closed catch must still
// revoke that path (revoking an ungranted path is a no-op merge).
this.grantedPaths.push(tempDir)
grantWrite(api, tempDir, writeSidPtr)
this.grantedPaths.push({ path: tempDir, sidPtr: this.tempWriteSidPtr })
grantWrite(api, tempDir, this.tempWriteSidPtr)
}
}
}
@@ -228,40 +254,55 @@ export class AclSandbox {
this.sidAllocations.push(logonSid)
const worldSid = makeWellKnownSid(api, abi.WinWorldSid)
this.sidAllocations.push(worldSid)
const restricted = createRestrictedToken(
api, currentToken, logonSid, writeSidPtr,
const writeSids = [this.writeSidPtr, this.tempWriteSidPtr].filter((sid): sid is NativePtr => sid !== undefined)
restrictedToken = createRestrictedToken(
api, currentToken, logonSid, writeSids,
{ world: worldSid },
this.mode,
)
this.token = restrictedToken
// The restricted token's default DACL still names only the user's
// ambient SIDs — none of the restricting SIDs. Every NEW object the
// confined process creates (anonymous stdio pipes, sync objects) takes
// its DACL from that default, so the write pass-2 check would deny
// pipe creation (ERROR_ACCESS_DENIED; Node EPERM) and break every
// piped-stdio grandchild spawn. Merge a full-access ACE for a
// restricting SID (the write SID under workspace-write, Everyone under
// read-only): new-object creation stays gated by the parent object's
// DACL, while the new object's own DACL passes pass-2.
setTokenDefaultDaclGrant(api, restricted, writeSidPtr ?? worldSid)
this.token = restricted
// restricting SID (the PRIVATE temp SID when present, otherwise the
// workspace SID, or Everyone under read-only): new-object creation
// stays gated by the parent object's DACL, while the new object's own
// DACL passes pass-2. Choosing the temp SID prevents default-DACL
// objects in one session's temp tree from acquiring the shared
// workspace capability.
setTokenDefaultDaclGrant(api, restrictedToken, this.tempWriteSidPtr ?? this.writeSidPtr ?? worldSid)
if (api.closeHandle(currentToken) === 0) throwLastError(api, 'CloseHandle', 'current process token')
currentTokenOpen = false
this.api = api
} catch (error) {
// Best-effort close on the failure path (last error already captured in `error`).
api.closeHandle(currentToken)
// Fail-closed cleanup: never leave a revocable (temp) grant or SID
// allocation behind a failed init. Standing workspace ACEs are NOT
// revoked — they are the intended end state (the reuse cache), not an
// error artifact.
const cleanupFailures: unknown[] = []
const writeSidPtr = this.writeSidPtr
if (writeSidPtr !== undefined) {
for (const path of this.grantedPaths) {
try {
revokeWrite(api, path, writeSidPtr)
} catch (cleanupError) {
cleanupFailures.push(cleanupError)
}
if (currentTokenOpen && api.closeHandle(currentToken) === 0) {
cleanupFailures.push(new Win32Error('CloseHandle', api.getLastError(), 'current process token after init failure'))
}
if (restrictedToken !== undefined && api.closeHandle(restrictedToken) === 0) {
cleanupFailures.push(new Win32Error('CloseHandle', api.getLastError(), 'restricted token after init failure'))
}
for (const grant of this.grantedPaths) {
try {
revokeWrite(api, grant.path, grant.sidPtr)
} catch (cleanupError) {
cleanupFailures.push(cleanupError)
}
}
for (const [label, sidPtr] of [['workspace write SID', this.writeSidPtr], ['temp write SID', this.tempWriteSidPtr]] as const) {
if (sidPtr === undefined) continue
try {
const freed = api.localFree(sidPtr)
if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', label)
} catch (cleanupError) {
cleanupFailures.push(cleanupError)
}
}
for (const sidPtr of this.sidAllocations.splice(0)) {
@@ -272,10 +313,14 @@ export class AclSandbox {
cleanupFailures.push(cleanupError)
}
}
this.token = undefined
this.writeSidPtr = undefined
this.tempWriteSidPtr = undefined
this.grantedPaths = []
if (cleanupFailures.length > 0) {
throw new AggregateError(
[error, ...cleanupFailures],
`AclSandbox init failed and ${cleanupFailures.length} grant revocation(s) also failed`,
`AclSandbox init failed and ${cleanupFailures.length} cleanup operation(s) also failed`,
)
}
throw error
@@ -341,20 +386,20 @@ export class AclSandbox {
const api = this.api
if (api === undefined) return
const failures: unknown[] = []
const writeSidPtr = this.writeSidPtr
if (writeSidPtr !== undefined) {
if (this.manageDacls) {
for (const path of this.grantedPaths) {
try {
revokeWrite(api, path, writeSidPtr)
} catch (error) {
failures.push(error)
}
if (this.manageDacls) {
for (const grant of this.grantedPaths) {
try {
revokeWrite(api, grant.path, grant.sidPtr)
} catch (error) {
failures.push(error)
}
}
}
for (const [label, sidPtr] of [['workspace write SID', this.writeSidPtr], ['temp write SID', this.tempWriteSidPtr]] as const) {
if (sidPtr === undefined) continue
try {
const freed = api.localFree(writeSidPtr)
if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', 'write SID')
const freed = api.localFree(sidPtr)
if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', label)
} catch (error) {
failures.push(error)
}
@@ -378,6 +423,7 @@ export class AclSandbox {
this.api = undefined
this.token = undefined
this.writeSidPtr = undefined
this.tempWriteSidPtr = undefined
this.grantedPaths = []
if (failures.length > 0) {
throw new AggregateError(failures, `AclSandbox dispose completed with ${failures.length} cleanup failure(s)`)