1
/**2
* Sandbox-consuming bash executor. It wraps the exact local bash argv through3
* `ctx.sandbox`, inherits local process mechanics, and reports the selected4
* mode, enforcement, and denial facts. Positive runner-executable evidence5
* identifies a broken confinement runner: foreground calls throw6
* `SANDBOX_UNAVAILABLE`, while background processes carry `runnerFailed`;7
* other provider rejections retain stage-neutral local-executor semantics. The8
* tool owns approval and passes a complete per-call policy.9
* @module @deepseek-ai/dsh-bash-sandbox10
*/12
import { Context } from '@deepseek-ai/cordis'13
import type { ShellExecRequest, ShellExecSpec, ShellExecution, ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell'14
import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'15
import type {16
ConfinedArgv,17
ConfinedSandboxMode,18
RunnerFailureRule,19
SandboxEnforcement,20
SandboxExecutionPolicy,21
SandboxMode,22
SandboxPolicy,23
} from '@deepseek-ai/dsh-sandbox'24
import type {} from '@deepseek-ai/dsh-sandbox-policy'25
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'26
import type { Config as LocalConfig } from '@deepseek-ai/dsh-bash-local'27
import { classifyDenial, classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature } from './helpers.ts'29
/**30
* Plugin config: the local executor's knobs, verbatim. The sandbox policy —31
* the default mode and fallback `workspace-write` root — is NOT here: it lives32
* on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), which resolves33
* each calling session's mode and cwd for every enforcing capability. The runner34
* choice is likewise the `ctx.sandbox` provider's config, not this executor's.35
*/36
export type Config = LocalConfig38
/**39
* Registers as `ctx.shell` in place of the local executor and requires a40
* `ctx.sandbox` provider plus `ctx.sandboxPolicy`; the tool layer is41
* unchanged. Tool calls pass the calling session's resolved policy; direct42
* calls fall back to deployment policy. `result.sandbox` reports the mode and43
* enforcement actually used.44
*/45
export class SandboxBashExecutor extends LocalBashExecutor {46
static override inject = ['subprocess', 'sandbox', 'sandboxPolicy']48
// No own Config: the sandbox default (mode + workspaceRoot) is owned by49
// ctx.sandboxPolicy, so this executor inherits LocalBashExecutor's Config50
// verbatim (the config catalog walks the inherited static).52
private readonly mode: SandboxMode53
/**54
* Per-process confinement facts retained until settlement. Providers may55
* vary enforcement and diagnostic dialect between overlapping calls, so a56
* shared latest-wrap value would classify a process against the wrong facts.57
* Unconfined processes have no entry.58
*/59
private readonly processFacts = new Map<ShellProcess, {60
mode: ConfinedSandboxMode61
enforcement: SandboxEnforcement62
denialSignatures: readonly string[]63
runnerFailureRules: readonly RunnerFailureRule[]64
runnerProgram: string | undefined65
workdir: string66
}>()68
constructor(ctx: Context, config: Config) {69
super(ctx, config)70
// The default mode is the capability fact used for schema advertisement;71
// actual tool executions carry their resolved per-call policy.72
this.mode = ctx.sandboxPolicy.defaultMode73
}75
/** The configured default mode — the capability fact the tool layer reads. */76
override get sandboxMode(): SandboxMode {77
return this.mode78
}80
/**81
* Stamp a complete per-call policy onto the spec. Tool calls supply the82
* calling session's resolved mode and root; lower-level callers fall back to83
* the deployment policy.84
*/85
override resolve(request: ShellExecRequest): ShellExecSpec {86
return { ...super.resolve(request), sandboxPolicy: request.sandboxPolicy ?? this.ctx.sandboxPolicy.resolve() }87
}89
override async execute(spec: ShellExecSpec): Promise<ShellExecution> {90
const policy = spec.sandboxPolicy as SandboxExecutionPolicy91
const { mode } = policy92
if (mode === 'danger-full-access') {93
return SandboxBashExecutor.decorateResult(94
await super.execute(spec),95
result => ({ ...result, sandbox: { mode, denied: false } }),96
)97
}98
let confined: ConfinedArgv | undefined99
const ex = await this.executeArgv(spec, async (signal) => {100
const prepared = await this.confine(spec.command, { ...policy, mode }, signal)101
signal.throwIfAborted()102
confined = prepared103
return prepared.argv104
}, (process) => {105
const facts = confined as ConfinedArgv106
this.processFacts.set(process, {107
mode,108
enforcement: facts.enforcement,109
denialSignatures: facts.denialSignatures,110
runnerFailureRules: facts.runnerFailureRules,111
runnerProgram: facts.argv[0],112
workdir: spec.workdir,113
})114
})115
return SandboxBashExecutor.decorateResult(ex, (result) => {116
if (confined === undefined) return { ...result, sandbox: { mode, denied: false } }117
const { enforcement, denialSignatures, runnerFailureRules } = confined118
// Runner failure outranks denial because the command did not run. Carry119
// the matched fatal line, not an informational line that preceded it.120
const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, runnerFailureRules)121
if (runnerFailure !== undefined) {122
throw new SandboxUnavailableError(mode, runnerFailure.detail)123
}124
return { ...result, sandbox: { mode, denied: classifyDenial(result, denialSignatures), enforcement } }125
}, (error) => {126
// An upstream abort remains cancellation even when it prevents spawn.127
if (spec.signal?.aborted === true) spec.signal.throwIfAborted()128
if (confined !== undefined && isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) {129
throw new SandboxUnavailableError(mode, String(error))130
}131
throw error132
})133
}135
/**136
* Decorate the handle's foreground projection in place, memoized once. The137
* handle keeps its identity (never wrapped in a second object) because the138
* per-process facts and `onProcessDone` key on the exact instance.139
*/140
private static decorateResult(141
ex: ShellExecution,142
map: (result: ShellRunResult) => ShellRunResult,143
mapError?: (error: unknown) => never,144
): ShellExecution {145
const base = ex.result.bind(ex)146
let decorated: Promise<ShellRunResult> | undefined147
ex.result = () => {148
decorated ??= base().then(map, mapError)149
return decorated150
}151
return ex152
}154
/**155
* Stamp per-process sandbox facts before `done` settles. Full-access processes156
* have no facts; signal deaths are not denials.157
*/158
protected override onProcessDone(proc: ShellProcess, stderr: string, providerRejected: boolean, providerError?: unknown): void {159
const facts = this.processFacts.get(proc)160
if (facts !== undefined) {161
this.processFacts.delete(proc)162
// A provider rejection exposes no public failure stage. Attribute it to163
// the confinement runner only when the error independently names argv[0].164
// Otherwise settled runner failure outranks denial-like diagnostics.165
const runnerFailed = providerRejected166
? isRunnerSpawnFailure(providerError, facts.runnerProgram, facts.workdir)167
: classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined168
proc.sandbox = {169
mode: facts.mode,170
denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures),171
enforcement: facts.enforcement,172
...(runnerFailed ? { runnerFailed } : {}),173
}174
}175
super.onProcessDone(proc, stderr, providerRejected, providerError)176
}178
/**179
* Wrap one shell command via the `ctx.sandbox` provider. Provider errors180
* propagate unchanged; the returned argv is handed directly to the local181
* executor's subprocess path.182
* @param command - shell source for the confined inner `bash -c`.183
* @param policy - resolved confined execution policy.184
* @param signal - cancellation of confinement preparation.185
* @returns the provider's exact argv and settlement-classification facts.186
*/187
private confine(command: string, policy: SandboxPolicy, signal?: AbortSignal): Promise<ConfinedArgv> {188
return this.ctx.sandbox.confine(['bash', '-c', command], policy, signal)189
}190
}192
export default SandboxBashExecutor