feat(sandbox): Windows ACL write-restriction sandbox (restricted-token runner)
Confine Windows command execution through a WRITE_RESTRICTED token whose restricting SIDs carry an orphan-SID write allowlist, ported from https://github.com/huoyaoyuan/windows-acl-restrict-poc (@ 10e4dfb). Every Win32 call is checked and fails closed - the POC silently ran children with the FULL token when CreateRestrictedToken failed. - @deepseek-ai/dsh-sandbox-windows-acl: koffi primitives verified against the MinGW Windows headers (verify/abi-probe.cpp) plus the confinement runner ([node, runner, --workspace, --temp, --mode, --, argv...]: kill-on-close job, stdio passthrough, exit-code mirroring, windows-acl-run: failure signature, grant revocation). read-only = strict zero grants (NUL device not writable; documented). Windows-only execution: exempted from the Linux coverage lane (windowsOnlyCoverageExclusions). - @deepseek-ai/dsh-sandbox-local: PLATFORM_CHAINS.win32 filled with the windows-acl runner (full enforcement, ACL denial dialect, runner-failure rules). - @deepseek-ai/dsh-pwsh-sandbox: sandbox-consuming pwsh executor (call-for-call mirror of dsh-bash-sandbox) over a new argv-level seam in dsh-pwsh-local; per-file coverage complete via the fake-provider spec. - bundle/base: the Windows platform layer mounts the confined pwsh roster - sandbox/policy/fs-sandbox/permission/approval re-enabled, the POSIX bash stack stays disabled. Co-authored-by: Huo Yaoyuan <huoyaoyuan@hotmail.com>
This commit is contained in:
296
packages/sandbox/sandbox-windows-acl/src/spawn.ts
Normal file
296
packages/sandbox/sandbox-windows-acl/src/spawn.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* Restricted-process spawning: anonymous pipes for stdio, STARTUPINFOW with
|
||||
* STARTF_USESTDHANDLES, CreateProcessAsUserW under the restricted token, then
|
||||
* asynchronous pipe draining and exit waiting. Console isolation
|
||||
* (CREATE_NO_WINDOW / CREATE_NEW_CONSOLE) is intentionally absent: under this
|
||||
* restriction scheme hidden-console children die with STATUS_DLL_INIT_FAILED
|
||||
* (0xC0000142) — verified empirically, see win32-abi.ts. Stdio redirection is
|
||||
* pipe-based and unaffected; the child shares the host console.
|
||||
* @module @deepseek-ai/dsh-sandbox-windows-acl/spawn
|
||||
*/
|
||||
|
||||
import { allocPtrSlot, allocProcessInfo, allocStartupInfo, allocUint32, decodePtr, decodeProcessInfo, decodeUint32, encodeStartupInfo, isNullPtr, throwLastError, throwWin32 } from './ffi.ts'
|
||||
import type { NativePtr, Win32Bindings } from './ffi.ts'
|
||||
import * as abi from './win32-abi.ts'
|
||||
|
||||
/**
|
||||
* Quote one argument per the CommandLineToArgvW parsing rules (backslash
|
||||
* escaping only before quotes; a trailing backslash before the closing quote
|
||||
* is doubled).
|
||||
*/
|
||||
export function quoteArg(argument: string): string {
|
||||
if (argument === '') return '""'
|
||||
if (!/[\s"]/u.test(argument)) return argument
|
||||
let quoted = '"'
|
||||
for (let index = 0; index < argument.length; index++) {
|
||||
let backslashes = 0
|
||||
while (index < argument.length && argument.charAt(index) === '\\') {
|
||||
backslashes++
|
||||
index++
|
||||
}
|
||||
if (index < argument.length && argument.charAt(index) === '"') {
|
||||
quoted += '\\'.repeat(backslashes * 2 + 1) + '"'
|
||||
} else {
|
||||
quoted += '\\'.repeat(backslashes) + (index < argument.length ? argument.charAt(index) : '')
|
||||
}
|
||||
}
|
||||
return quoted + '"'
|
||||
}
|
||||
|
||||
/** Build the single command line CreateProcess parses from program + argv. */
|
||||
export function buildCommandLine(program: string, args: readonly string[]): string {
|
||||
return [program, ...args].map(quoteArg).join(' ')
|
||||
}
|
||||
|
||||
interface PipePair {
|
||||
read: NativePtr
|
||||
write: NativePtr
|
||||
}
|
||||
|
||||
function createPipe(api: Win32Bindings): PipePair {
|
||||
const readSlot = allocPtrSlot()
|
||||
const writeSlot = allocPtrSlot()
|
||||
if (api.createPipe(readSlot, writeSlot, null, 0) === 0) throwLastError(api, 'CreatePipe')
|
||||
const read = decodePtr(readSlot)
|
||||
const write = decodePtr(writeSlot)
|
||||
if (read === null || write === null) throwLastError(api, 'CreatePipe', 'null pipe handle')
|
||||
return { read, write }
|
||||
}
|
||||
|
||||
function setInheritable(api: Win32Bindings, handle: NativePtr, label: string): void {
|
||||
if (api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, abi.HANDLE_FLAG_INHERIT) === 0) {
|
||||
throwLastError(api, 'SetHandleInformation', label)
|
||||
}
|
||||
}
|
||||
|
||||
export interface SpawnedNative {
|
||||
pid: number
|
||||
process: NativePtr
|
||||
stdoutRead: NativePtr
|
||||
stderrRead: NativePtr
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a process under the restricted token with piped stdio. The child's
|
||||
* stdin is closed immediately (EOF), matching the POC; stdout/stderr read ends
|
||||
* are returned for draining.
|
||||
*/
|
||||
export function spawnSandboxed(
|
||||
api: Win32Bindings,
|
||||
token: NativePtr,
|
||||
options: { command: string; args: readonly string[]; cwd: string },
|
||||
): SpawnedNative {
|
||||
const stdIn = createPipe(api)
|
||||
const stdOut = createPipe(api)
|
||||
const stdErr = createPipe(api)
|
||||
// Child side of each pipe must be inheritable (POC lines 262-268).
|
||||
setInheritable(api, stdIn.read, 'stdin read end')
|
||||
setInheritable(api, stdOut.write, 'stdout write end')
|
||||
setInheritable(api, stdErr.write, 'stderr write end')
|
||||
|
||||
const startupInfo = allocStartupInfo()
|
||||
encodeStartupInfo(startupInfo, {
|
||||
cb: abi.STARTUPINFOW_SIZE,
|
||||
dwFlags: abi.STARTF_USESTDHANDLES,
|
||||
hStdInput: stdIn.read,
|
||||
hStdOutput: stdOut.write,
|
||||
hStdError: stdErr.write,
|
||||
})
|
||||
|
||||
const processInfo = allocProcessInfo()
|
||||
const commandLine = buildCommandLine(options.command, options.args)
|
||||
const created = api.createProcessAsUserW(
|
||||
token, null, commandLine,
|
||||
null, null,
|
||||
1, // bInheritHandles: required for redirection
|
||||
0, // no creation flags: suspended/no-window variants are unusable under the restriction
|
||||
null, options.cwd,
|
||||
startupInfo, processInfo,
|
||||
)
|
||||
// Capture the failure before CloseHandle calls clobber GetLastError.
|
||||
if (created === 0) throwLastError(api, 'CreateProcessAsUserW', `command: ${options.command}, cwd: ${options.cwd}`)
|
||||
|
||||
const info = decodeProcessInfo(processInfo)
|
||||
const processHandle = info.hProcess
|
||||
const threadHandle = info.hThread
|
||||
if (processHandle === null || threadHandle === null) {
|
||||
throw new Error(`CreateProcessAsUserW succeeded but returned null process/thread handles (pid ${info.dwProcessId})`)
|
||||
}
|
||||
|
||||
// Host-side cleanup: child handles are now duplicated in the child; the
|
||||
// host closes its copies so ReadFile sees EOF when the child exits.
|
||||
api.closeHandle(stdIn.read)
|
||||
api.closeHandle(stdOut.write)
|
||||
api.closeHandle(stdErr.write)
|
||||
api.closeHandle(stdIn.write)
|
||||
api.closeHandle(threadHandle)
|
||||
|
||||
return {
|
||||
pid: info.dwProcessId,
|
||||
process: processHandle,
|
||||
stdoutRead: stdOut.read,
|
||||
stderrRead: stdErr.read,
|
||||
}
|
||||
}
|
||||
|
||||
/** Drain one pipe read end to a Buffer via non-blocking PeekNamedPipe polling. */
|
||||
export async function drainPipe(api: Win32Bindings, handle: NativePtr): Promise<Buffer> {
|
||||
const chunks: Buffer[] = []
|
||||
for (;;) {
|
||||
const bytesReadSlot = allocUint32()
|
||||
const totalAvailSlot = allocUint32()
|
||||
const leftThisMessageSlot = allocUint32()
|
||||
const peeked = api.peekNamedPipe(handle, null, 0, bytesReadSlot, totalAvailSlot, leftThisMessageSlot)
|
||||
if (peeked === 0) {
|
||||
const win32Code = api.getLastError()
|
||||
if (win32Code === abi.ERROR_BROKEN_PIPE || win32Code === abi.ERROR_NO_DATA) break // child closed its end: clean EOF
|
||||
throwLastError(api, 'PeekNamedPipe', `drain failure after ${chunks.length} chunk(s)`)
|
||||
}
|
||||
const available = decodeUint32(totalAvailSlot)
|
||||
if (available > 0) {
|
||||
const chunk = Buffer.alloc(available)
|
||||
const readSlot = allocUint32()
|
||||
if (api.readFile(handle, chunk, chunk.length, readSlot, null) === 0) {
|
||||
throwLastError(api, 'ReadFile', `drain failure after ${chunks.length} chunk(s)`)
|
||||
}
|
||||
chunks.push(chunk.subarray(0, decodeUint32(readSlot)))
|
||||
}
|
||||
await new Promise<void>(resolve => setImmediate(resolve))
|
||||
}
|
||||
api.closeHandle(handle)
|
||||
return Buffer.concat(chunks)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for process exit and return its exit code. Call only after both drains
|
||||
* have resolved — the drains finish when the child closed its pipe ends, i.e.
|
||||
* the child has already exited, so this wait returns immediately. Calling it
|
||||
* earlier would block the event loop and starve the drains (the pipe-buffer
|
||||
* deadlock the POC comments warn about).
|
||||
*/
|
||||
export function waitForExit(api: Win32Bindings, process: NativePtr): number {
|
||||
const waitResult = api.waitForSingleObject(process, abi.INFINITE)
|
||||
if (waitResult === 0xFFFFFFFF) throwLastError(api, 'WaitForSingleObject')
|
||||
const exitCodeSlot = allocUint32()
|
||||
if (api.getExitCodeProcess(process, exitCodeSlot) === 0) throwLastError(api, 'GetExitCodeProcess')
|
||||
api.closeHandle(process)
|
||||
return decodeUint32(exitCodeSlot)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a kill-on-close job object (JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE at
|
||||
* LimitFlags offset 16 of JOBOBJECT_EXTENDED_LIMIT_INFORMATION, layout
|
||||
* verified by abi-probe.cpp). When the caller dies with the job handle open,
|
||||
* Windows terminates every process in the job — the orphan-child backstop.
|
||||
* The caller keeps the returned handle open for the child's lifetime.
|
||||
*/
|
||||
function createKillOnCloseJob(api: Win32Bindings): NativePtr {
|
||||
const job = api.createJobObjectW(null, null)
|
||||
if (isNullPtr(job)) throwLastError(api, 'CreateJobObjectW')
|
||||
const information = Buffer.alloc(abi.JOBOBJECT_EXTENDED_LIMIT_SIZE)
|
||||
information.writeUInt32LE(abi.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, abi.JOBOBJECT_EXTENDED_LIMIT_FLAGS_OFFSET)
|
||||
if (api.setInformationJobObject(job, abi.JobObjectExtendedLimitInformation, information, information.length) === 0) {
|
||||
const win32Code = api.getLastError()
|
||||
api.closeHandle(job)
|
||||
throwWin32(api, 'SetInformationJobObject', win32Code)
|
||||
}
|
||||
return job
|
||||
}
|
||||
|
||||
export interface SpawnedInherited {
|
||||
pid: number
|
||||
process: NativePtr
|
||||
/** Kill-on-close job the child was placed in; caller closes it after the child exits. */
|
||||
job: NativePtr
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a process under the restricted token whose stdio passes straight
|
||||
* through to the caller's pipes. This is the runner shape: the harness spawns
|
||||
* the runner with piped stdio, and the runner's confined child writes to
|
||||
* those same pipes.
|
||||
*
|
||||
* Node clears the inheritability of its stdio handles at startup
|
||||
* (uv_disable_stdio_inheritance), so raw spawns must re-enable the inherit
|
||||
* bit around the call (libuv instead duplicates the handles; re-enabling is
|
||||
* equivalent here and cheaper) and pass them explicitly via
|
||||
* STARTF_USESTDHANDLES — otherwise the child receives INVALID std handles
|
||||
* ("The handle is invalid", verified the hard way). The child starts
|
||||
* suspended so it can be assigned to a kill-on-close job before it runs.
|
||||
*/
|
||||
export function spawnSandboxedInherited(
|
||||
api: Win32Bindings,
|
||||
token: NativePtr,
|
||||
options: { command: string; args: readonly string[]; cwd: string },
|
||||
): SpawnedInherited {
|
||||
const job = createKillOnCloseJob(api)
|
||||
const stdIn = api.getStdHandle(abi.STD_INPUT_HANDLE)
|
||||
const stdOut = api.getStdHandle(abi.STD_OUTPUT_HANDLE)
|
||||
const stdErr = api.getStdHandle(abi.STD_ERROR_HANDLE)
|
||||
if (isNullPtr(stdIn) || isNullPtr(stdOut) || isNullPtr(stdErr)) {
|
||||
api.closeHandle(job)
|
||||
throwLastError(api, 'GetStdHandle', 'null standard handle')
|
||||
}
|
||||
|
||||
const makeInheritable = (handle: NativePtr, label: string): void => {
|
||||
if (api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, abi.HANDLE_FLAG_INHERIT) === 0) {
|
||||
throwLastError(api, 'SetHandleInformation', `${label} (enable inherit)`)
|
||||
}
|
||||
}
|
||||
const restoreInherit = (handle: NativePtr): void => {
|
||||
// Best-effort hygiene: the runner spawns nothing else; failures here must
|
||||
// not mask the child outcome, so the result is deliberately unchecked.
|
||||
api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, 0)
|
||||
}
|
||||
makeInheritable(stdIn, 'stdin')
|
||||
makeInheritable(stdOut, 'stdout')
|
||||
makeInheritable(stdErr, 'stderr')
|
||||
|
||||
const startupInfo = allocStartupInfo()
|
||||
encodeStartupInfo(startupInfo, {
|
||||
cb: abi.STARTUPINFOW_SIZE,
|
||||
dwFlags: abi.STARTF_USESTDHANDLES,
|
||||
hStdInput: stdIn,
|
||||
hStdOutput: stdOut,
|
||||
hStdError: stdErr,
|
||||
})
|
||||
|
||||
const processInfo = allocProcessInfo()
|
||||
const commandLine = buildCommandLine(options.command, options.args)
|
||||
const created = api.createProcessAsUserW(
|
||||
token, null, commandLine,
|
||||
null, null,
|
||||
1, // bInheritHandles: the re-enabled std handles must be inheritable
|
||||
abi.CREATE_SUSPENDED, // suspended so job assignment precedes any execution
|
||||
null, options.cwd,
|
||||
startupInfo, processInfo,
|
||||
)
|
||||
restoreInherit(stdIn)
|
||||
restoreInherit(stdOut)
|
||||
restoreInherit(stdErr)
|
||||
if (created === 0) {
|
||||
const win32Code = api.getLastError()
|
||||
api.closeHandle(job)
|
||||
throwWin32(api, 'CreateProcessAsUserW', win32Code, `command: ${options.command}, cwd: ${options.cwd}`)
|
||||
}
|
||||
|
||||
const info = decodeProcessInfo(processInfo)
|
||||
const processHandle = info.hProcess
|
||||
const threadHandle = info.hThread
|
||||
if (processHandle === null || threadHandle === null) {
|
||||
api.closeHandle(job)
|
||||
throw new Error(`CreateProcessAsUserW succeeded but returned null process/thread handles (pid ${info.dwProcessId})`)
|
||||
}
|
||||
|
||||
if (api.assignProcessToJobObject(job, processHandle) === 0) {
|
||||
const win32Code = api.getLastError()
|
||||
api.closeHandle(threadHandle)
|
||||
api.closeHandle(processHandle)
|
||||
api.closeHandle(job)
|
||||
throwWin32(api, 'AssignProcessToJobObject', win32Code, `pid ${info.dwProcessId}`)
|
||||
}
|
||||
if (api.resumeThread(threadHandle) === 0xFFFFFFFF) throwLastError(api, 'ResumeThread', `pid ${info.dwProcessId}`)
|
||||
api.closeHandle(threadHandle)
|
||||
|
||||
return { pid: info.dwProcessId, process: processHandle, job }
|
||||
}
|
||||
Reference in New Issue
Block a user