/** * 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). * @param argument - one argv entry to quote. * @returns the quoted entry (bare when quoting is unnecessary). */ 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. * @param program - the executable (argv[0]). * @param args - the remaining argv entries. * @returns the joined, quoted command line. */ 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) } } /** A confined child spawned with piped stdio: process handle plus the pipe read ends to drain. */ 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. * @param api - the binding table. * @param token - the restricted token the child runs under. * @param options - command, args, and working directory. * @returns the spawned child's handles. */ 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. * @param api - the binding table. * @param handle - the pipe read end to drain (closed when done). * @returns the complete pipe contents. */ export async function drainPipe(api: Win32Bindings, handle: NativePtr): Promise { 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(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). * @param api - the binding table. * @param process - the child process handle (closed when done). * @returns the child's exit code. */ 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 } /** A confined child spawned with inherited stdio: process handle plus its kill-on-close 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. * @param api - the binding table. * @param token - the restricted token the child runs under. * @param options - command, args, and working directory. * @returns the spawned child's handles and job. */ 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 } }