fix(sandbox): round 5 harden spawn attribution

This commit is contained in:
Hypatia May
2026-08-04 16:06:54 +08:00
parent ec4a070f0e
commit 293fac9421
14 changed files with 192 additions and 87 deletions
@@ -79,11 +79,14 @@ async function setupConfiguredRunner(runner: string): Promise<SandboxBashExecuto
}
describe('partial Landlock runner-failure classification', () => {
it.each(['missing', 'unexecutable'] as const)('classifies a %s configured runner through the direct spawn error channel', async (kind) => {
it.each(['missing', 'unexecutable', 'missing-interpreter'] as const)('classifies a %s configured runner through the direct spawn error channel', async (kind) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-unusable-sandbox-runner-'))
tempDirs.push(dir)
const runner = join(dir, `${kind}-runner`)
if (kind === 'unexecutable') await writeFile(runner, '#!/bin/sh\nexit 0\n', { mode: 0o644 })
if (kind === 'missing-interpreter') {
await writeFile(runner, '#!/dsh-definitely-missing-sandbox-interpreter\nexit 0\n', { mode: 0o755 })
}
const bash = await setupConfiguredRunner(runner)
const error = await bash.run(bash.resolve({ command: 'true' })).catch((value: unknown) => value)
+133 -31
View File
@@ -34,6 +34,13 @@ const UNIX_SIGNATURES = ['read-only file system', 'permission denied'] as const
/** The runner-failure rule the fake wraps carry (a fake-runner: error line marks the sandbox itself failing). */
const RUNNER_FAILURE = [{ fatalSignatures: ['fake-runner: '] }] as const
/** Provider argv[0] forms that all share the caller-owned cwd spawn precondition. */
const RUNNER_FORMS = [
['absolute', process.execPath],
['bare', 'node'],
['relative', './sandbox-runner'],
] as const
/** A passthrough wrap: the caller's argv unchanged, asserted full — commands run unconfined, deterministically. */
const passthrough = (argv: readonly string[]): ConfinedArgv =>
({ argv: [...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE })
@@ -173,10 +180,15 @@ describe('fail closed', () => {
await expect(bash.run(bash.resolve({ command: 'true', signal: controller.signal }))).rejects.toBe(reason)
})
it.each(['read-only', 'danger-full-access'] as const)(
'keeps an invalid workdir as an ordinary foreground spawn failure in %s mode',
async (mode) => {
const { bash } = await setup({ mode })
it.each(RUNNER_FORMS)(
'keeps an invalid workdir ordinary with the %s provider-runner form',
async (_form, runner) => {
const { bash } = await setup({}, argv => ({
argv: [runner, ...argv],
enforcement: 'full',
denialSignatures: UNIX_SIGNATURES,
runnerFailureRules: RUNNER_FAILURE,
}))
const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
try {
const failure = await bash.run(bash.resolve({ command: 'true', workdir: join(parent, 'missing') }))
@@ -188,6 +200,63 @@ describe('fail closed', () => {
}
},
)
it('keeps an invalid workdir ordinary when danger-full-access bypasses the provider', async () => {
const { bash } = await setup({ mode: 'danger-full-access' })
const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
try {
const failure = await bash.run(bash.resolve({ command: 'true', workdir: join(parent, 'missing') }))
.catch((error: unknown) => error)
expect(failure).toMatchObject({ code: 'ENOENT' })
expect(failure).not.toBeInstanceOf(SandboxUnavailableError)
} finally {
rmSync(parent, { recursive: true, force: true })
}
})
it('classifies synchronous ENOEXEC as runner loading failure in run() and start()', async () => {
const runner = join(spillDir, 'malformed-runner')
const { ctx, bash } = await setup({}, argv => ({
argv: [runner, ...argv],
enforcement: 'full',
denialSignatures: UNIX_SIGNATURES,
runnerFailureRules: RUNNER_FAILURE,
}))
vi.spyOn(ctx.subprocess, 'spawn').mockImplementation(() => {
throw Object.assign(new Error('spawn ENOEXEC'), { code: 'ENOEXEC', syscall: 'spawn', path: runner })
})
await expect(bash.run(bash.resolve({ command: 'true' })))
.rejects.toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
expect(() => bash.start(bash.resolve({ command: 'true' })))
.toThrow(expect.objectContaining({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE }))
})
it('keeps a synchronous cwd-owned ENOENT as the original start() error', async () => {
const runner = './sandbox-runner'
const { ctx, bash } = await setup({}, argv => ({
argv: [runner, ...argv],
enforcement: 'full',
denialSignatures: UNIX_SIGNATURES,
runnerFailureRules: RUNNER_FAILURE,
}))
const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
const workdir = join(parent, 'missing')
const failure = Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT', syscall: `spawn ${runner}`, path: runner })
vi.spyOn(ctx.subprocess, 'spawn').mockImplementation(() => { throw failure })
try {
let thrown: unknown
try {
bash.start(bash.resolve({ command: 'true', workdir }))
} catch (error) {
thrown = error
}
expect(thrown).toBe(failure)
expect(thrown).not.toBeInstanceOf(SandboxUnavailableError)
} finally {
rmSync(parent, { recursive: true, force: true })
}
})
})
describe('danger-full-access', () => {
@@ -290,18 +359,44 @@ describe('classifyDenial', () => {
})
describe('isRunnerSpawnFailure', () => {
it('requires both an executable-class error and an independently unavailable provider executable', () => {
const missingRunner = join(spillDir, 'definitely-missing-runner')
const enoent = Object.assign(new Error('spawn failed'), { code: 'ENOENT' })
const emfile = Object.assign(new Error('spawn failed'), { code: 'EMFILE' })
it.each(['EACCES', 'ENOENT', 'ENOEXEC', 'ENOTDIR', 'EPERM'])(
'attributes executable-class spawn code %s to argv[0] once cwd ambiguity is eliminated',
(code) => {
const runner = join(spillDir, 'runner')
const error = Object.assign(new Error('spawn failed'), { code, syscall: `spawn ${runner}`, path: runner })
expect(isRunnerSpawnFailure(error, runner, process.cwd())).toBe(true)
},
)
expect(isRunnerSpawnFailure(enoent, missingRunner, process.cwd(), process.env.PATH)).toBe(true)
expect(isRunnerSpawnFailure(enoent, process.execPath, process.cwd(), process.env.PATH)).toBe(false)
expect(isRunnerSpawnFailure(enoent, spillDir, process.cwd(), process.env.PATH)).toBe(true)
expect(isRunnerSpawnFailure(emfile, missingRunner, process.cwd(), process.env.PATH)).toBe(false)
expect(isRunnerSpawnFailure(enoent, 'definitely-missing-runner', spillDir, '')).toBe(true)
expect(isRunnerSpawnFailure(enoent, 'definitely-missing-runner', spillDir, undefined)).toBe(false)
expect(isRunnerSpawnFailure(enoent, undefined, spillDir, process.env.PATH)).toBe(false)
it('requires a usable caller cwd before classifying absolute, bare, or relative runners', () => {
const missingWorkdir = join(spillDir, 'missing-workdir')
for (const [, runner] of RUNNER_FORMS) {
const error = Object.assign(new Error('spawn failed'), { code: 'ENOENT', syscall: `spawn ${runner}`, path: runner })
expect(isRunnerSpawnFailure(error, runner, missingWorkdir)).toBe(false)
}
const fileWorkdir = join(spillDir, 'not-a-workdir')
writeFileSync(fileWorkdir, '')
const error = Object.assign(new Error('spawn failed'), { code: 'ENOTDIR', syscall: 'spawn node', path: 'node' })
expect(isRunnerSpawnFailure(error, 'node', fileWorkdir)).toBe(false)
})
it('rejects resource, non-spawn, mismatched-program, and unstructured failures', () => {
const missingRunner = join(spillDir, 'definitely-missing-runner')
const spawnError = (code: unknown, syscall: unknown = `spawn ${missingRunner}`, path: unknown = missingRunner) =>
Object.assign(new Error('spawn failed'), { code, syscall, path })
expect(isRunnerSpawnFailure(spawnError('EMFILE'), missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(spawnError('ENOMEM'), missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(spawnError(2), missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(spawnError('ENOENT', 'open'), missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(spawnError('ENOENT', 1), missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(spawnError('ENOENT', 'spawn', process.execPath), missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(spawnError('ENOENT', 'spawn', 1), missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(spawnError('ENOENT', 'spawn', ''), missingRunner, process.cwd())).toBe(true)
expect(isRunnerSpawnFailure(spawnError('ENOENT', 'spawn', undefined), missingRunner, process.cwd())).toBe(true)
expect(isRunnerSpawnFailure(undefined, missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(null, missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(spawnError('ENOENT'), undefined, process.cwd())).toBe(false)
})
})
@@ -386,23 +481,30 @@ describe('result facts', () => {
})
describe('background sandbox facts', () => {
it('keeps an invalid-workdir spawn rejection ordinary and releases accounting', async () => {
const { bash } = await setup()
const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
const task = bash.start(bash.resolve({ command: 'true', workdir: join(parent, 'missing') }))
await task.done
expect(task.status).toBe('killed')
expect(task.readOutput().delta).toContain('spawn failed:')
expect(task.sandbox).toEqual({
mode: 'read-only',
denied: false,
it.each(RUNNER_FORMS)('keeps an invalid-workdir rejection ordinary for the %s provider-runner form', async (_form, runner) => {
const { bash } = await setup({}, argv => ({
argv: [runner, ...argv],
enforcement: 'full',
})
const accounting = (bash as unknown as { processFacts: Map<unknown, unknown> }).processFacts
expect(accounting.size).toBe(0)
rmSync(parent, { recursive: true, force: true })
denialSignatures: UNIX_SIGNATURES,
runnerFailureRules: RUNNER_FAILURE,
}))
const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
try {
const task = bash.start(bash.resolve({ command: 'true', workdir: join(parent, 'missing') }))
await task.done
expect(task.status).toBe('killed')
expect(task.readOutput().delta).toContain('spawn failed:')
expect(task.sandbox).toEqual({
mode: 'read-only',
denied: false,
enforcement: 'full',
})
const accounting = (bash as unknown as { processFacts: Map<unknown, unknown> }).processFacts
expect(accounting.size).toBe(0)
} finally {
rmSync(parent, { recursive: true, force: true })
}
})
it('does not invent runner evidence when a spawn rejection has no structured reason', async () => {