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
+33 -6
View File
@@ -5,6 +5,13 @@
*/
import type { BashRunResult } from '@deepseek-ai/dsh-bash'
import type { RunnerFailureRule } from '@deepseek-ai/dsh-sandbox'
/** Fatal runner evidence retained for infrastructure-error detail. */
interface RunnerFailureMatch {
/** The original stderr line that matched a fatal signature. */
detail: string
}
/**
* Quote one string as a single-quoted POSIX shell word.
@@ -26,13 +33,33 @@ export function classifyDenial(result: BashRunResult, signatures: readonly strin
}
/**
* Classify a failed run against the selected backend's runner-failure dialect.
* @param result - settled foreground run.
* @param signatures - case-insensitive runner-failure substrings from the active wrap.
* @returns whether the failed run matches that runner-failure dialect.
* Classify one settled process against the selected backend's structured
* runner-failure rules. Each rule requires a nonzero exit, its optional
* exit-code gate, and a fatal signature on one stderr line after exact
* informational lines are excluded.
* @param exitCode - process exit code; null means signal termination.
* @param stderr - collected stderr text, left unchanged.
* @param rules - structured runner-failure rules from the active wrap.
* @returns the first matching fatal line, or undefined when evidence is insufficient.
*/
export function classifyRunnerFailure(result: BashRunResult, signatures: readonly string[]): boolean {
return matchesSignature(result.exitCode, result.stderr.text, signatures)
export function classifyRunnerFailure(
exitCode: number | null,
stderr: string,
rules: readonly RunnerFailureRule[],
): RunnerFailureMatch | undefined {
if (exitCode === null || exitCode === 0) return undefined
const lines = stderr.split(/\r?\n/)
for (const rule of rules) {
if (rule.allowedExitCodes !== undefined && !rule.allowedExitCodes.includes(exitCode)) continue
const informationalLines = new Set((rule.informationalLines ?? []).map(line => line.toLowerCase()))
const fatalSignatures = rule.fatalSignatures.map(signature => signature.toLowerCase())
for (const line of lines) {
const lowered = line.toLowerCase()
if (informationalLines.has(lowered)) continue
if (fatalSignatures.some(signature => lowered.includes(signature))) return { detail: line }
}
}
return undefined
}
/**
+19 -11
View File
@@ -11,7 +11,14 @@
import { Context } from 'cordis'
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedSandboxMode, SandboxEnforcement, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import type {
ConfinedSandboxMode,
RunnerFailureRule,
SandboxEnforcement,
SandboxExecutionPolicy,
SandboxMode,
SandboxPolicy,
} from '@deepseek-ai/dsh-sandbox'
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'
@@ -51,7 +58,7 @@ export class SandboxBashExecutor extends LocalBashExecutor {
mode: ConfinedSandboxMode
enforcement: SandboxEnforcement
denialSignatures: readonly string[]
runnerFailureSignatures: readonly string[]
runnerFailureRules: readonly RunnerFailureRule[]
}>()
constructor(ctx: Context, config: Config) {
@@ -84,10 +91,11 @@ export class SandboxBashExecutor extends LocalBashExecutor {
}
const confined = this.confine(spec.command, { ...policy, mode })
const result = await super.run({ ...spec, command: confined.command })
// Runner failure outranks denial because the command did not run. Throw the
// same fail-closed error as confine-time discovery with the first stderr line.
if (classifyRunnerFailure(result, confined.runnerFailureSignatures)) {
throw new SandboxUnavailableError(mode, result.stderr.text.trim().split('\n')[0])
// Runner failure outranks denial because the command did not run. Carry
// the matched fatal line, not an informational line that preceded it.
const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, confined.runnerFailureRules)
if (runnerFailure !== undefined) {
throw new SandboxUnavailableError(mode, runnerFailure.detail)
}
return { ...result, sandbox: { mode, denied: classifyDenial(result, confined.denialSignatures), enforcement: confined.enforcement } }
}
@@ -99,8 +107,8 @@ export class SandboxBashExecutor extends LocalBashExecutor {
// Install facts synchronously; promise settlement cannot run before start() returns.
const confined = this.confine(spec.command, { ...policy, mode })
const proc = super.start({ ...spec, command: confined.command })
const { enforcement, denialSignatures, runnerFailureSignatures } = confined
this.processFacts.set(proc, { mode, enforcement, denialSignatures, runnerFailureSignatures })
const { enforcement, denialSignatures, runnerFailureRules } = confined
this.processFacts.set(proc, { mode, enforcement, denialSignatures, runnerFailureRules })
return proc
}
@@ -113,7 +121,7 @@ export class SandboxBashExecutor extends LocalBashExecutor {
if (facts !== undefined) {
this.processFacts.delete(proc)
// Runner failure outranks denial because its diagnostics may contain denial terms.
const runnerFailed = matchesSignature(proc.exitCode, stderr, facts.runnerFailureSignatures)
const runnerFailed = classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined
proc.sandbox = {
mode: facts.mode,
denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures),
@@ -136,14 +144,14 @@ export class SandboxBashExecutor extends LocalBashExecutor {
command: string
enforcement: SandboxEnforcement
denialSignatures: readonly string[]
runnerFailureSignatures: readonly string[]
runnerFailureRules: readonly RunnerFailureRule[]
} {
const confined = this.ctx.sandbox.confine(['bash', '-c', command], policy)
return {
command: `exec ${confined.argv.map(shellQuote).join(' ')}`,
enforcement: confined.enforcement,
denialSignatures: confined.denialSignatures,
runnerFailureSignatures: confined.runnerFailureSignatures,
runnerFailureRules: confined.runnerFailureRules,
}
}
}