fix(sandbox): evidence-gate runner failures (round 1)

This commit is contained in:
Hypatia May
2026-08-03 17:31:08 +08:00
parent 6a3caea277
commit 6343d8f6e6
51 changed files with 805 additions and 140 deletions
@@ -0,0 +1,125 @@
/**
* Deterministic composition proof for the partial-Landlock diagnostic: the
* real local provider and sandbox bash executor wrap commands through a POSIX
* fake launcher that prints the native informational line before exec.
*/
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { SANDBOX_UNAVAILABLE } from '@deepseek-ai/dsh-sandbox'
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
const NOTICE = 'landlock-run: partial enforcement (older Landlock ABI)'
const FATAL = 'landlock-run: landlock ruleset error: Invalid argument'
const contexts: Context[] = []
const tempDirs: string[] = []
afterEach(async () => {
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
})
/** Write a fake native launcher that reports partial enforcement, then execs or fails. */
async function fakeLauncher(fatal: boolean): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-partial-landlock-'))
tempDirs.push(dir)
const launcher = join(dir, 'landlock-run')
const fatalBranch = fatal ? `printf '%s\\n' '${FATAL}' >&2\nexit 125\n` : ''
await writeFile(launcher, `#!/bin/sh
while [ "$#" -gt 0 ]; do
case "$1" in
--ro|--rw) shift 2 ;;
--) shift; break ;;
*) printf '%s\\n' 'landlock-run: usage error: unexpected fake argument' >&2; exit 125 ;;
esac
done
printf '%s\\n' '${NOTICE}' >&2
${fatalBranch}exec "$@"
`, { mode: 0o755 })
return launcher
}
async function setup(fatal = false): Promise<SandboxBashExecutor> {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(LocalSandboxProvider, {})
const sandbox = ctx.sandbox as LocalSandboxProvider
sandbox.internals = {
platform: 'linux',
probeBwrap: () => false,
probeLandlock: () => 'partial',
landlockLauncher: await fakeLauncher(fatal),
}
await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: process.cwd() })
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(SandboxBashExecutor, { cwd: process.cwd(), timeoutMs: 5_000 })
return ctx.bash as SandboxBashExecutor
}
describe('partial Landlock runner-failure classification', () => {
it('keeps true, false, and child exit 125 as child outcomes when the notice is the only runner line', async () => {
const bash = await setup()
for (const [command, exitCode] of [['true', 0], ['false', 1], ['exit 125', 125]] as const) {
const result = await bash.run(bash.resolve({ command }))
expect(result.exitCode).toBe(exitCode)
expect(result.stderr.text).toBe(`${NOTICE}\n`)
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
}
})
it('reports the fatal line after the notice as SANDBOX_UNAVAILABLE detail', async () => {
const bash = await setup(true)
const error = await bash.run(bash.resolve({ command: 'true' })).catch((value: unknown) => value)
expect(error).toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
expect(error).toBeInstanceOf(Error)
expect((error as Error).message).toContain(`Runner failure: ${FATAL}`)
expect((error as Error).message).not.toContain(NOTICE)
})
it('classifies a notice plus child Permission denied as a denial, not runner failure', async () => {
const bash = await setup()
const result = await bash.run(bash.resolve({ command: 'printf "%s\\n" "child: Permission denied" >&2; exit 1' }))
expect(result.stderr.text).toBe(`${NOTICE}\nchild: Permission denied\n`)
expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' })
})
it('applies the same evidence rule to notice-only background exits', async () => {
const bash = await setup()
for (const command of ['false', 'exit 125']) {
const task = bash.start(bash.resolve({ command }))
await task.done
expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
expect(task.readOutput().delta).toContain(NOTICE)
}
})
it('classifies a background notice plus child Permission denied as denial', async () => {
const bash = await setup()
const task = bash.start(bash.resolve({ command: 'printf "%s\\n" "child: Permission denied" >&2; exit 1' }))
await task.done
expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' })
expect(task.readOutput().delta).toContain(NOTICE)
})
it('makes a background fatal line outrank denial text after the notice', async () => {
const bash = await setup(true)
const task = bash.start(bash.resolve({ command: 'true' }))
await task.done
expect(task.sandbox).toEqual({
mode: 'read-only',
denied: false,
enforcement: 'partial',
runnerFailed: true,
})
const output = task.readOutput().delta
expect(output).toContain(NOTICE)
expect(output).toContain(FATAL)
})
})
@@ -30,12 +30,12 @@ interface ConfineCall {
/** The Linux file-denial dialects the fake wraps carry — matches the unix-permission denials the tests below produce. */
const UNIX_SIGNATURES = ['read-only file system', 'permission denied'] as const
/** The runner-failure prefix the fake wraps carry (a fake-runner: error line marks the sandbox itself failing). */
const RUNNER_FAILURE = ['fake-runner: '] 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
/** 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, runnerFailureSignatures: RUNNER_FAILURE })
({ argv: [...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE })
/**
* Boot a context with a recording fake `ctx.sandbox` (behavior injectable
@@ -93,7 +93,7 @@ describe('the provider hand-off', () => {
it('a wrapped argv from the provider is what actually spawns (prefix survives, quoting round-trips)', async () => {
// The fake wraps with `env MARKER=...` — a real (if tiny) runner prefix:
// the sentinel only prints if the executor spawned the WRAPPED argv.
const { bash } = await setup({}, argv => ({ argv: ['env', 'DSH_WRAP=1', ...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE }))
const { bash } = await setup({}, argv => ({ argv: ['env', 'DSH_WRAP=1', ...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE }))
const result = await bash.run(bash.resolve({ command: 'printf "%s" "$DSH_WRAP"' }))
expect(result.stdout.text).toBe('1')
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
@@ -234,10 +234,47 @@ describe('classifyDenial', () => {
})
describe('classifyRunnerFailure', () => {
it('matches the dialect case-insensitively on BOTH sides — the seam declares it so, and producers compose signatures from runtime data (an argv0 path, the shell\'s `No such file or directory`)', () => {
const signatures = ['exec: /Opt/Runners/bwrap: not found', '/Opt/Runners/bwrap: No such file or directory']
expect(classifyRunnerFailure(runResult(127, 'bash: /Opt/Runners/bwrap: No such file or directory'), signatures)).toBe(true)
expect(classifyRunnerFailure(runResult(127, 'BASH: LINE 1: EXEC: /OPT/RUNNERS/BWRAP: NOT FOUND'), signatures)).toBe(true)
it('matches an outer-shell rule case-insensitively only at its exit codes and configured argv0', () => {
const rules = [{
allowedExitCodes: [126, 127],
fatalSignatures: ['exec: /Opt/Runners/bwrap: not found', '/Opt/Runners/bwrap: No such file or directory'],
}]
expect(classifyRunnerFailure(127, 'bash: /Opt/Runners/bwrap: No such file or directory', rules)?.detail)
.toBe('bash: /Opt/Runners/bwrap: No such file or directory')
expect(classifyRunnerFailure(126, 'BASH: LINE 1: EXEC: /OPT/RUNNERS/BWRAP: NOT FOUND', rules)?.detail)
.toBe('BASH: LINE 1: EXEC: /OPT/RUNNERS/BWRAP: NOT FOUND')
expect(classifyRunnerFailure(125, 'bash: /Opt/Runners/bwrap: No such file or directory', rules)).toBeUndefined()
expect(classifyRunnerFailure(127, 'bash: /other/bwrap: No such file or directory', rules)).toBeUndefined()
})
it('requires Landlock exit 125 plus a non-notice fatal line and returns that original line', () => {
const notice = 'landlock-run: partial enforcement (older Landlock ABI)'
const rules = [{ allowedExitCodes: [125], fatalSignatures: ['landlock-run: '], informationalLines: [notice] }]
expect(classifyRunnerFailure(1, notice, rules)).toBeUndefined()
expect(classifyRunnerFailure(2, notice, rules)).toBeUndefined()
expect(classifyRunnerFailure(125, notice, rules)).toBeUndefined()
expect(classifyRunnerFailure(125, notice.toUpperCase(), rules)).toBeUndefined()
expect(classifyRunnerFailure(125, `${notice}: extra detail`, rules))
.toEqual({ detail: `${notice}: extra detail` })
expect(classifyRunnerFailure(125, `${notice}\nlandlock-run: exec failed: No such file or directory`, rules))
.toEqual({ detail: 'landlock-run: exec failed: No such file or directory' })
})
it.each([
'landlock-run: usage error: missing `-- <argv>...` command',
'landlock-run: landlock is not enforced by this kernel (ABI unsupported or disabled)',
'landlock-run: cannot open rule path: /gone: No such file or directory',
'landlock-run: landlock ruleset error: Invalid argument',
'landlock-run: exec failed: Permission denied',
'landlock-run: out of memory',
'landlock-run: future fatal diagnostic',
])('keeps known and future Landlock fatal diagnostics fail-closed: %s', (fatal) => {
const rules = [{
allowedExitCodes: [125],
fatalSignatures: ['landlock-run: '],
informationalLines: ['landlock-run: partial enforcement (older Landlock ABI)'],
}]
expect(classifyRunnerFailure(125, fatal, rules)).toEqual({ detail: fatal })
})
})
@@ -253,7 +290,7 @@ describe('result facts', () => {
})
it('carries the provider\'s partial-enforcement fact through unchanged', async () => {
const { bash } = await setup({}, argv => ({ argv: [...argv], enforcement: 'partial', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE }))
const { bash } = await setup({}, argv => ({ argv: [...argv], enforcement: 'partial', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE }))
const result = await bash.run(bash.resolve({ command: 'true' }))
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
})
@@ -284,7 +321,7 @@ describe('background sandbox facts', () => {
it('a foreground runner failure throws the fail-closed error, never a task result', async () => {
// The wrap's runner prefix on a failed run means the SANDBOX broke and
// the command never ran — the late twin of the confine-time throw, with
// the runner's own first stderr line carried as the cause.
// the matched fatal stderr line carried as the cause.
const { bash } = await setup()
const run = bash.run(bash.resolve({ command: 'echo "fake-runner: ruleset rejected" >&2; exit 125' }))
await expect(run).rejects.toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
@@ -315,7 +352,7 @@ describe('background sandbox facts', () => {
let call = 0
const { bash } = await setup({}, (argv) => {
const wrap = wraps[Math.min(call++, wraps.length - 1)] as Pick<ConfinedArgv, 'enforcement' | 'denialSignatures'>
return { argv: [...argv], ...wrap, runnerFailureSignatures: RUNNER_FAILURE }
return { argv: [...argv], ...wrap, runnerFailureRules: RUNNER_FAILURE }
})
const slow = bash.start(bash.resolve({ command: 'sleep 0.4; echo "x: Permission denied" >&2; exit 1' }))
const quick = bash.start(bash.resolve({ command: 'true' }))