fix(sandbox): spawn confined argv directly (round 1)

This commit is contained in:
Hypatia May
2026-08-04 12:04:48 +08:00
parent 2eedb54849
commit e36d040d0a
34 changed files with 392 additions and 282 deletions
@@ -1,6 +1,6 @@
/**
* Deterministic real-process proofs for runner classification: the real local
* provider and sandbox bash executor exercise an outer-shell launch failure
* provider and sandbox bash executor exercise direct runner-spawn failures
* and a POSIX fake Landlock launcher that prints its notice before exec.
*/
@@ -9,14 +9,18 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import {
LAUNCHER_FAILURE_EXIT,
LAUNCHER_FATAL_PREFIX,
PARTIAL_ENFORCEMENT_NOTICE,
} from 'node-addon-landlock-run'
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 FATAL = `${LAUNCHER_FATAL_PREFIX}landlock ruleset error: Invalid argument`
const contexts: Context[] = []
const tempDirs: string[] = []
@@ -27,26 +31,26 @@ afterEach(async () => {
})
/** Write a fake native launcher that reports partial enforcement, then execs or fails. */
async function fakeLauncher(fatal: boolean): Promise<string> {
async function fakeLauncher(fatalExit?: number): 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` : ''
const fatalBranch = fatalExit === undefined ? '' : `printf '%s\\n' '${FATAL}' >&2\nexit ${fatalExit}\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 ;;
*) printf '%s\\n' '${LAUNCHER_FATAL_PREFIX}usage error: unexpected fake argument' >&2; exit ${LAUNCHER_FAILURE_EXIT} ;;
esac
done
printf '%s\\n' '${NOTICE}' >&2
printf '%s\\n' '${PARTIAL_ENFORCEMENT_NOTICE}' >&2
${fatalBranch}exec "$@"
`, { mode: 0o755 })
return launcher
}
async function setup(fatal = false): Promise<SandboxBashExecutor> {
async function setup(fatalExit?: number): Promise<SandboxBashExecutor> {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(LocalSandboxProvider, {})
@@ -55,7 +59,7 @@ async function setup(fatal = false): Promise<SandboxBashExecutor> {
platform: 'linux',
probeBwrap: () => false,
probeLandlock: () => 'partial',
landlockLauncher: await fakeLauncher(fatal),
landlockLauncher: await fakeLauncher(fatalExit),
}
await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: process.cwd() })
await ctx.plugin(LocalSubprocessService)
@@ -63,60 +67,96 @@ async function setup(fatal = false): Promise<SandboxBashExecutor> {
return ctx.bash as SandboxBashExecutor
}
describe('partial Landlock runner-failure classification', () => {
it.skipIf(process.platform === 'win32')('classifies a genuinely missing configured runner through the outer bash exec rule', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-missing-sandbox-runner-'))
tempDirs.push(dir)
const missingRunner = join(dir, 'missing-runner')
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(LocalSandboxProvider, {
runnerCommand: [missingRunner],
runnerFailureSignatures: ['configured-runner: 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 })
async function setupConfiguredRunner(runner: string): Promise<SandboxBashExecutor> {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(LocalSandboxProvider, {
runnerCommand: [runner],
runnerFailureSignatures: ['configured-runner: 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
}
const error = await ctx.bash.run(ctx.bash.resolve({ command: 'true' })).catch((value: unknown) => value)
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) => {
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 })
const bash = await setupConfiguredRunner(runner)
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(missingRunner)
expect((error as Error).message).toContain(runner)
const task = bash.start(bash.resolve({ command: 'true' }))
await task.done
expect(task.status).toBe('killed')
expect(task.readOutput().delta).toContain(`spawn failed: Error: spawn ${runner}`)
expect(task.sandbox).toEqual({
mode: 'read-only',
denied: false,
enforcement: 'full',
runnerFailed: true,
})
const accounting = (bash as unknown as { processFacts: Map<unknown, unknown> }).processFacts
expect(accounting.size).toBe(0)
})
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 }))
it.each([0, 1, 2, LAUNCHER_FAILURE_EXIT])(
'keeps child exit %i ordinary when the partial-enforcement notice is the only runner line',
async (exitCode) => {
const bash = await setup()
const result = await bash.run(bash.resolve({ command: `exit ${exitCode}` }))
expect(result.exitCode).toBe(exitCode)
expect(result.stderr.text).toBe(`${NOTICE}\n`)
expect(result.stderr.text).toBe(`${PARTIAL_ENFORCEMENT_NOTICE}\n`)
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
}
},
)
it.each([126, 127])('keeps a successfully launched Landlock child exit %i as an ordinary outcome', async (exitCode) => {
const bash = await setup()
const result = await bash.run(bash.resolve({ command: `exit ${exitCode}` }))
expect(result.exitCode).toBe(exitCode)
expect(result.stderr.text).toBe(`${PARTIAL_ENFORCEMENT_NOTICE}\n`)
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
})
it.each([1, 2])('keeps a Landlock fatal line at exit %i as insufficient runner-failure evidence', async (exitCode) => {
const bash = await setup(exitCode)
const result = await bash.run(bash.resolve({ command: 'true' }))
expect(result.exitCode).toBe(exitCode)
expect(result.stderr.text).toBe(`${PARTIAL_ENFORCEMENT_NOTICE}\n${FATAL}\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 bash = await setup(LAUNCHER_FAILURE_EXIT)
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)
expect((error as Error).message).not.toContain(PARTIAL_ENFORCEMENT_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.stderr.text).toBe(`${PARTIAL_ENFORCEMENT_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']) {
for (const command of ['exit 1', 'exit 2', `exit ${LAUNCHER_FAILURE_EXIT}`]) {
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)
expect(task.readOutput().delta).toContain(PARTIAL_ENFORCEMENT_NOTICE)
}
})
@@ -125,11 +165,11 @@ describe('partial Landlock runner-failure classification', () => {
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)
expect(task.readOutput().delta).toContain(PARTIAL_ENFORCEMENT_NOTICE)
})
it('makes a background fatal line outrank denial text after the notice', async () => {
const bash = await setup(true)
const bash = await setup(LAUNCHER_FAILURE_EXIT)
const task = bash.start(bash.resolve({ command: 'true' }))
await task.done
expect(task.sandbox).toEqual({
@@ -139,7 +179,7 @@ describe('partial Landlock runner-failure classification', () => {
runnerFailed: true,
})
const output = task.readOutput().delta
expect(output).toContain(NOTICE)
expect(output).toContain(PARTIAL_ENFORCEMENT_NOTICE)
expect(output).toContain(FATAL)
})
})
@@ -5,7 +5,7 @@
* the Unix denial signature used by the classifier without requiring a real sandbox runner.
*/
import { chmodSync, mkdirSync, mkdtempSync } from 'node:fs'
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
@@ -16,7 +16,7 @@ import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode, SandboxPolicy }
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import { classifyDenial, classifyRunnerFailure, shellQuote } from '../src/helpers.ts'
import { classifyDenial, classifyRunnerFailure } from '../src/helpers.ts'
import type { Config } from '@deepseek-ai/dsh-bash-sandbox'
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-sandbox-spec-'))
@@ -90,15 +90,49 @@ 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, runnerFailureRules: RUNNER_FAILURE }))
it('hands the provider\'s returned argv directly to ctx.subprocess.spawn', async () => {
const returnedArgv = ['env', 'DSH_WRAP=1', 'bash', '-c', 'printf "%s" "$DSH_WRAP"']
const { ctx, bash } = await setup({}, () => ({ argv: returnedArgv, enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE }))
const spawn = vi.spyOn(ctx.subprocess, 'spawn')
const result = await bash.run(bash.resolve({ command: 'printf "%s" "$DSH_WRAP"' }))
expect(result.stdout.text).toBe('1')
expect(spawn).toHaveBeenCalledTimes(1)
expect(spawn.mock.calls[0]?.[0].argv).toEqual(returnedArgv)
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
})
it('starts a non-Bash runner before the confined inner Bash evaluates BASH_ENV', async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-bash-env-order-'))
const hook = join(dir, 'hook.sh')
const order = join(dir, 'order.txt')
writeFileSync(hook, 'printf "hook\\n" >> "$DSH_ORDER_FILE"\n')
const runnerScript = [
'const { appendFileSync } = require("node:fs");',
'const { spawnSync } = require("node:child_process");',
'appendFileSync(process.env.DSH_ORDER_FILE, "runner\\n");',
'const child = spawnSync(process.argv[1], process.argv.slice(2), { env: process.env, stdio: "inherit" });',
'process.exit(child.status ?? 125);',
].join('')
const { bash } = await setup({}, argv => ({
argv: [process.execPath, '-e', runnerScript, ...argv],
enforcement: 'full',
denialSignatures: UNIX_SIGNATURES,
runnerFailureRules: RUNNER_FAILURE,
}))
try {
const result = await bash.run(bash.resolve({
command: 'true',
env: { BASH_ENV: hook },
dshEnv: { DSH_ORDER_FILE: order },
}))
expect(result.exitCode).toBe(0)
expect(readFileSync(order, 'utf8')).toBe('runner\nhook\n')
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('workspace-write rides the policy, workspaceRoot falling back to process.cwd() when not configured', async () => {
const { bash, calls } = await setup({ mode: 'workspace-write' })
const result = await bash.run(bash.resolve({ command: 'true' }))
@@ -120,9 +154,6 @@ describe('the provider hand-off', () => {
expect(calls).toHaveLength(2)
})
it('shellQuote survives embedded single quotes (the argv re-assembly primitive)', () => {
expect(shellQuote('a\'b')).toBe(String.raw`'a'\''b'`)
})
})
describe('fail closed', () => {
@@ -132,6 +163,14 @@ describe('fail closed', () => {
await expect(bash.run(spec)).rejects.toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
expect(() => bash.start(spec)).toThrow(SandboxUnavailableError)
})
it('preserves an already-aborted foreground call as cancellation', async () => {
const { bash } = await setup()
const controller = new AbortController()
const reason = new Error('caller cancelled before spawn')
controller.abort(reason)
await expect(bash.run(bash.resolve({ command: 'true', signal: controller.signal }))).rejects.toBe(reason)
})
})
describe('danger-full-access', () => {
@@ -252,19 +291,6 @@ describe('classifyRunnerFailure', () => {
expect(classifyRunnerFailure(125, `${notice}\nchild diagnostic\n${fatal}`, rules)).toEqual({ detail: fatal })
})
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] }]
@@ -297,6 +323,18 @@ describe('classifyRunnerFailure', () => {
})
describe('result facts', () => {
it.each([126, 127])('keeps a successfully launched wrapped child exit %i as an ordinary outcome', async (exitCode) => {
const { bash } = await setup({}, argv => ({
argv: ['env', ...argv],
enforcement: 'full',
denialSignatures: UNIX_SIGNATURES,
runnerFailureRules: RUNNER_FAILURE,
}))
const result = await bash.run(bash.resolve({ command: `exit ${exitCode}` }))
expect(result.exitCode).toBe(exitCode)
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
})
it('reports a real permission failure as a sandbox denial with the mode it ran under', async () => {
const { bash } = await setup()
const lockedDir = join(mkdtempSync(join(tmpdir(), 'dsh-sandbox-denied-')), 'locked')
@@ -324,7 +362,12 @@ describe('background sandbox facts', () => {
expect(task.status).toBe('killed')
expect(task.readOutput().delta).toContain('spawn failed:')
expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
expect(task.sandbox).toEqual({
mode: 'read-only',
denied: false,
enforcement: 'full',
runnerFailed: true,
})
const accounting = (bash as unknown as { processFacts: Map<unknown, unknown> }).processFacts
expect(accounting.size).toBe(0)
})
@@ -1,6 +1,6 @@
import { spawnSync } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { mkdtemp, rm } from 'node:fs/promises'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
@@ -76,6 +76,32 @@ describe.skipIf(!seatbeltUsable)('bash-sandbox: real Seatbelt confinement throug
expect(existsSync(join(outside, 'denied.txt'))).toBe(false)
})
it('evaluates BASH_ENV only after Seatbelt confines the inner Bash', async () => {
const workdir = await tempDir(homedir())
const outside = await tempDir(homedir())
const hook = join(workdir, 'bash-env-hook.sh')
const insideProbe = join(workdir, 'hook-ran.txt')
const outsideProbe = join(outside, 'escaped.txt')
await writeFile(hook, [
'printf hook > "$DSH_BASH_ENV_INSIDE"',
'printf escaped > "$DSH_BASH_ENV_OUTSIDE"',
'',
].join('\n'))
const bash = await sandboxedBash(workdir, 'workspace-write')
await bash.run(bash.resolve({
command: 'true',
env: { BASH_ENV: hook },
dshEnv: {
DSH_BASH_ENV_INSIDE: insideProbe,
DSH_BASH_ENV_OUTSIDE: outsideProbe,
},
}))
expect(readFileSync(insideProbe, 'utf8')).toBe('hook')
expect(existsSync(outsideProbe)).toBe(false)
})
it('classifies a background denial once the task settles', async () => {
const workdir = await tempDir(homedir())
const bash = await sandboxedBash(workdir, 'read-only')