JSDoc every sandbox-windows-acl export (the 138 verify-export-jsdoc violations); fix the sandbox-windows-acl README code block (undeclared workspaceRoot), bring its stale claims current (the provider wiring and kill-on-close are live), add the canonical Model Experience sections to both new packages, audited the backend's indirect Model Experience sentence, and regenerate docs/config-catalog.md.
322 lines
13 KiB
TypeScript
322 lines
13 KiB
TypeScript
/**
|
|
* 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<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).
|
|
* @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 }
|
|
}
|