fix(sandbox): require runner-specific spawn evidence

This commit is contained in:
Hypatia May
2026-08-04 14:47:46 +08:00
parent 0cd1611023
commit 96ba98c99e
14 changed files with 217 additions and 39 deletions

View File

@@ -4,9 +4,56 @@
* @module @deepseek-ai/dsh-bash-sandbox/helpers
*/
import { accessSync, constants, statSync } from 'node:fs'
import { delimiter, resolve } from 'node:path'
import type { BashRunResult } from '@deepseek-ai/dsh-bash'
import type { RunnerFailureRule } from '@deepseek-ai/dsh-sandbox'
/** Spawn codes that can describe an unavailable executable. */
const EXECUTABLE_SPAWN_CODES = new Set(['EACCES', 'ENOENT', 'ENOEXEC', 'ENOTDIR', 'EPERM'])
/** Whether one resolved path is a regular executable file. */
function isExecutableFile(path: string): boolean {
try {
if (!statSync(path).isFile()) return false
accessSync(path, constants.X_OK)
return true
} catch {
return false
}
}
/**
* Require positive runner evidence instead of treating every spawn rejection
* as sandbox-owned. Node uses the same ENOENT/EACCES channel for unrelated
* launch failures, so the provider executable must also be absent or unusable.
* @param error - the original spawn rejection.
* @param runnerProgram - provider argv[0], the executable that establishes confinement.
* @param workdir - the spawn cwd, used to resolve relative executable paths.
* @param searchPath - the spawn environment's PATH value.
* @returns whether the rejection has executable-specific runner evidence.
*/
export function isRunnerSpawnFailure(
error: unknown,
runnerProgram: string | undefined,
workdir: string,
searchPath: string | undefined,
): boolean {
if (typeof error !== 'object' || error === null) return false
const code = (error as { code?: unknown }).code
if (typeof code !== 'string' || !EXECUTABLE_SPAWN_CODES.has(code) || runnerProgram === undefined) return false
const isPath = runnerProgram.includes('/') || runnerProgram.includes('\\')
const pathEntries = isPath ? [''] : searchPath?.split(delimiter) ?? []
if (pathEntries.length === 0) return false
return pathEntries.every((entry) => {
const candidate = isPath
? resolve(workdir, runnerProgram)
: resolve(workdir, entry.length > 0 ? entry : '.', runnerProgram)
return !isExecutableFile(candidate)
})
}
/** Fatal runner evidence retained for infrastructure-error detail. */
interface RunnerFailureMatch {
/** The original stderr line that matched a fatal signature. */
@@ -43,11 +90,10 @@ export function classifyRunnerFailure(
for (const rule of rules) {
if (rule.allowedExitCodes !== undefined && !rule.allowedExitCodes.includes(exitCode)) continue
const informationalLines = new Set((rule.informationalLines ?? []).map(line => line.toLowerCase()))
// An empty substring matches every string in JavaScript. Ignore it so a
// malformed public rule cannot turn a gated exit status into evidence by
// itself; keep any valid signatures beside it active.
// An empty or whitespace-only substring is not meaningful runner evidence.
// Ignore it while keeping any valid signatures beside it active.
const fatalSignatures = rule.fatalSignatures
.filter(signature => signature.length > 0)
.filter(signature => signature.trim().length > 0)
.map(signature => signature.toLowerCase())
for (const line of lines) {
const lowered = line.toLowerCase()

View File

@@ -1,10 +1,10 @@
/**
* Sandbox-consuming bash executor. It wraps the exact local bash argv through
* `ctx.sandbox`, inherits local process mechanics, and reports the selected
* mode, enforcement, and denial facts. Runner failure means the command never
* ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while settled background
* processes carry `runnerFailed`. The tool owns approval and passes a complete
* per-call policy.
* mode, enforcement, and denial facts. Positive runner-launch evidence means
* the command never ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while
* background processes carry `runnerFailed`; other spawn rejections retain
* local-executor semantics. The tool owns approval and passes a complete per-call policy.
* @module @deepseek-ai/dsh-bash-sandbox
*/
@@ -23,7 +23,7 @@ import type {
import type {} from '@deepseek-ai/dsh-sandbox-policy'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import type { Config as LocalConfig } from '@deepseek-ai/dsh-bash-local'
import { classifyDenial, classifyRunnerFailure, matchesSignature } from './helpers.ts'
import { classifyDenial, classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature } from './helpers.ts'
/**
* Plugin config: the local executor's knobs, verbatim. The sandbox policy —
@@ -60,6 +60,9 @@ export class SandboxBashExecutor extends LocalBashExecutor {
enforcement: SandboxEnforcement
denialSignatures: readonly string[]
runnerFailureRules: readonly RunnerFailureRule[]
runnerProgram: string | undefined
searchPath: string | undefined
workdir: string
}>()
constructor(ctx: Context, config: Config) {
@@ -97,7 +100,10 @@ export class SandboxBashExecutor extends LocalBashExecutor {
} catch (error) {
// An upstream abort remains cancellation even when it prevents spawn.
if (spec.signal?.aborted === true) spec.signal.throwIfAborted()
throw new SandboxUnavailableError(mode, String(error))
if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir, spec.env?.PATH ?? process.env.PATH)) {
throw new SandboxUnavailableError(mode, String(error))
}
throw error
}
// Runner failure outranks denial because the command did not run. Carry
// the matched fatal line, not an informational line that preceded it.
@@ -116,7 +122,15 @@ export class SandboxBashExecutor extends LocalBashExecutor {
const confined = this.confine(spec.command, { ...policy, mode })
const proc = this.startArgv(spec, confined.argv)
const { enforcement, denialSignatures, runnerFailureRules } = confined
this.processFacts.set(proc, { mode, enforcement, denialSignatures, runnerFailureRules })
this.processFacts.set(proc, {
mode,
enforcement,
denialSignatures,
runnerFailureRules,
runnerProgram: confined.argv[0],
searchPath: spec.env?.PATH ?? process.env.PATH,
workdir: spec.workdir,
})
return proc
}
@@ -131,7 +145,8 @@ export class SandboxBashExecutor extends LocalBashExecutor {
// A rejected spawn never started the confined launch. Otherwise runner
// failure outranks denial because its diagnostics may contain denial terms.
const runnerFailed = spawnFailed
|| classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined
? isRunnerSpawnFailure(spawnError, facts.runnerProgram, facts.workdir, facts.searchPath)
: classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined
proc.sandbox = {
mode: facts.mode,
denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures),