Files
deepseek-harness/packages/sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts
Huanqi Cao 6478da61e3 fix(sandbox): harden the per-session record and the ACL runner failure paths (review round v6)
Durable record: bound to the owning session id and validated at the fold (orphan-SID shape, temp path inside the host temp root) — a fork's copied parent record no longer provisions the child, and a tampered record fails loud. Private temp dir: random unguessable name persisted in the record, created exclusively (pre-existing entries and reparse points fail EEXIST). Persistence: a fresh provision kicks an immediate flush (no write-behind debounce), narrowing the crash window to the flush latency — documented as the one self-healing gap. Runner-failure rules: exit-gated on 127 so a confined command that prints the signature on a non-127 exit is never misclassified. Spawn: AssignProcessToJobObject failure terminates the suspended child (no hanging orphans). SandboxExecutionPolicy.sessionId is the branded SessionId. Boundary docs: qualifying clause on the absolutist sentences, NULL-DACL Known Limitation, 'full' scoped to the supported NTFS surface, CLM gate comment.
2026-08-08 23:23:38 +08:00

139 lines
5.7 KiB
TypeScript

/**
* Failure-path unit tests with minimal stub binding tables: the spawn
* helpers must close every handle they created before throwing, and
* getTempPath must refuse to decode a buffer GetTempPathW never wrote.
* Pure stubs — no real Win32 calls, so these run on every platform.
*/
import { describe, expect, it, vi } from 'vitest'
import koffi from 'koffi'
import { PROCESS_INFORMATION, getTempPath } from '../src/ffi.ts'
import type { NativePtr, Win32Bindings } from '../src/ffi.ts'
import { Win32Error } from '../src/errors.ts'
import { spawnSandboxed, spawnSandboxedInherited } from '../src/spawn.ts'
const PVOID = koffi.pointer('void')
/** The stub the CreateProcessAsUserW failure branch needs: pipes "succeed", the spawn fails with Win32 5. */
function pipeFailureApi(): { api: Win32Bindings; closed: bigint[]; closeHandle: ReturnType<typeof vi.fn> } {
const closed: bigint[] = []
let next = 1n
const closeHandle = vi.fn((handle: NativePtr) => {
closed.push(handle)
return 1
})
const api = {
createPipe: vi.fn((readSlot: NativePtr, writeSlot: NativePtr) => {
koffi.encode(readSlot, PVOID, next++)
koffi.encode(writeSlot, PVOID, next++)
return 1
}),
setHandleInformation: vi.fn(() => 1),
createProcessAsUserW: vi.fn(() => 0),
getLastError: vi.fn(() => 5), // ERROR_ACCESS_DENIED: the failure the branch reports
closeHandle,
formatMessageW: vi.fn(() => 0),
} as unknown as Win32Bindings
return { api, closed, closeHandle }
}
/** The stub the ResumeThread failure branch needs: everything succeeds until ResumeThread returns 0xFFFFFFFF. */
function resumeFailureApi(): { api: Win32Bindings; closed: bigint[]; closeHandle: ReturnType<typeof vi.fn> } {
const closed: bigint[] = []
let std = 50n
const closeHandle = vi.fn((handle: NativePtr) => {
closed.push(handle)
return 1
})
const api = {
createJobObjectW: vi.fn(() => 100n),
setInformationJobObject: vi.fn(() => 1),
getStdHandle: vi.fn(() => std++),
setHandleInformation: vi.fn(() => 1),
createProcessAsUserW: vi.fn((
_token: unknown, _app: unknown, _cmd: unknown, _pa: unknown, _ta: unknown,
_inherit: unknown, _flags: unknown, _env: unknown, _cwd: unknown, _si: unknown, processInfo: NativePtr,
) => {
koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: 200n, hThread: 201n, dwProcessId: 1234, dwThreadId: 5678 })
return 1
}),
assignProcessToJobObject: vi.fn(() => 1),
resumeThread: vi.fn(() => 0xFFFFFFFF),
getLastError: vi.fn(() => 5),
closeHandle,
formatMessageW: vi.fn(() => 0),
} as unknown as Win32Bindings
return { api, closed, closeHandle }
}
describe('spawn failure paths close their handles', () => {
// A dummy token value; the stubbed spawn never reads it.
const token = 1n as NativePtr
it('spawnSandboxed closes all six pipe handles before throwing when CreateProcessAsUserW fails', () => {
const { api, closed, closeHandle } = pipeFailureApi()
let caught: unknown
try {
spawnSandboxed(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' })
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Win32Error)
expect((caught as Win32Error).api).toBe('CreateProcessAsUserW')
expect((caught as Win32Error).win32Code).toBe(5)
expect(closeHandle).toHaveBeenCalledTimes(6)
expect(closed).toEqual([1n, 2n, 3n, 4n, 5n, 6n])
})
it('spawnSandboxedInherited closes thread, process, and kill-on-close job before throwing when ResumeThread fails', () => {
const { api, closed, closeHandle } = resumeFailureApi()
let caught: unknown
try {
spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' })
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Win32Error)
expect((caught as Win32Error).api).toBe('ResumeThread')
expect((caught as Win32Error).win32Code).toBe(5)
// thread, process, job — closing the job triggers kill-on-close so the
// suspended child dies instead of hanging until this process exits.
expect(closeHandle).toHaveBeenCalledTimes(3)
expect(closed).toEqual([201n, 200n, 100n])
})
it('spawnSandboxedInherited TERMINATES the suspended child before closing handles when AssignProcessToJobObject fails', () => {
// The child is created suspended and is NOT in the kill-on-close job when
// the assignment fails: closing the job cannot kill it, so the failure
// branch must TerminateProcess first or every failure strands a hanging
// orphan forever.
const { api: baseApi, closeHandle } = resumeFailureApi()
type JobFailureApi = Win32Bindings & {
assignProcessToJobObject: ReturnType<typeof vi.fn>
terminateProcess: ReturnType<typeof vi.fn>
}
const api = baseApi as JobFailureApi
api.assignProcessToJobObject = vi.fn(() => 0)
api.terminateProcess = vi.fn(() => 1)
let caught: unknown
try {
spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' })
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Win32Error)
expect((caught as Win32Error).api).toBe('AssignProcessToJobObject')
expect(api.terminateProcess).toHaveBeenCalledExactlyOnceWith(200n, 1)
// thread, process, job — and the child is already dead before they close.
expect(closeHandle).toHaveBeenCalledTimes(3)
})
})
describe('getTempPath buffer defense', () => {
it('throws a clear error instead of decoding a buffer GetTempPathW never wrote', () => {
const api = { getTempPathW: vi.fn(() => 300) } as unknown as Win32Bindings // 300 > the 261-char buffer
expect(() => getTempPath(api)).toThrow(/GetTempPathW failed \(Win32 122\): required 300/u)
})
})