| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157 |
- /**
- * 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 per-call modes.
- * @module @deepseek-ai/dsh-bash-sandbox
- */
- 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, SandboxMode } 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'
- import { classifyDenial, classifyRunnerFailure, matchesSignature, shellQuote } from './helpers.ts'
- /**
- * Plugin config: the local executor's knobs, verbatim. The sandbox policy —
- * the default mode and the `workspace-write` boundary root — is NOT here: it
- * lives on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), the one
- * home both enforcing families read, so bash and fs can never confine to
- * different roots. The runner choice is likewise the `ctx.sandbox` provider's
- * config, not this executor's.
- */
- export type Config = LocalConfig
- /**
- * Registers as `ctx.bash` in place of the local executor and requires a
- * `ctx.sandbox` provider plus `ctx.sandboxPolicy`; the tool layer is
- * unchanged. The policy default (mode + workspace root) is the fallback,
- * while a session override or approved one-shot escalation may select each
- * call's mode. The prompt does not state the standing mode; `result.sandbox`
- * reports the mode and enforcement actually used.
- */
- export class SandboxBashExecutor extends LocalBashExecutor {
- static inject = ['sandbox', 'sandboxPolicy']
- // No own Config: the sandbox default (mode + workspaceRoot) moved to
- // ctx.sandboxPolicy, so this executor inherits LocalBashExecutor's Config
- // verbatim (the config catalog walks the inherited static).
- private readonly mode: SandboxMode
- private readonly workspaceRoot: string
- /**
- * Per-process confinement facts retained until settlement. Providers may
- * vary enforcement and diagnostic dialect between overlapping calls, so a
- * shared latest-wrap value would classify a process against the wrong facts.
- * Unconfined processes have no entry.
- */
- private readonly processFacts = new Map<BashProcess, {
- mode: ConfinedSandboxMode
- enforcement: SandboxEnforcement
- denialSignatures: readonly string[]
- runnerFailureSignatures: readonly string[]
- }>()
- constructor(ctx: Context, config: Config) {
- super(ctx, config)
- // The sandbox default (mode + workspaceRoot) is the one shared policy home
- // both enforcing families read; injecting sandboxPolicy guarantees it is
- // constructed first. workspaceRoot arrives already resolved absolute.
- this.mode = ctx.sandboxPolicy.defaultMode
- this.workspaceRoot = ctx.sandboxPolicy.workspaceRoot
- }
- /** The configured default mode — the capability fact the tool layer reads. */
- override get sandboxMode(): SandboxMode {
- return this.mode
- }
- /**
- * Stamp the effective mode onto the spec — the request's explicit override
- * (an approved escalation), else this executor's configured default — so
- * defaulting stays an explicit resolve step and `run()`/`start()` read the
- * spec, never the config.
- */
- override resolve(request: BashExecRequest): BashExecSpec {
- return { ...super.resolve(request), sandboxMode: request.sandboxMode ?? this.mode }
- }
- override async run(spec: BashExecSpec): Promise<BashRunResult> {
- // resolve() always stamps the mode; the cast records that invariant
- // (mirrors the constructor's config casts).
- const mode = spec.sandboxMode as SandboxMode
- if (mode === 'danger-full-access') {
- const result = await super.run(spec)
- return { ...result, sandbox: { mode, denied: false } }
- }
- const confined = this.confine(spec.command, 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])
- }
- return { ...result, sandbox: { mode, denied: classifyDenial(result, confined.denialSignatures), enforcement: confined.enforcement } }
- }
- override start(spec: BashExecSpec): BashProcess {
- // Same stamped-by-resolve invariant as run().
- const mode = spec.sandboxMode as SandboxMode
- if (mode === 'danger-full-access') return super.start(spec)
- // Install facts synchronously; promise settlement cannot run before start() returns.
- const confined = this.confine(spec.command, mode)
- const proc = super.start({ ...spec, command: confined.command })
- const { enforcement, denialSignatures, runnerFailureSignatures } = confined
- this.processFacts.set(proc, { mode, enforcement, denialSignatures, runnerFailureSignatures })
- return proc
- }
- /**
- * Stamp per-process sandbox facts before `done` settles. Full-access processes
- * have no facts; signal deaths are not denials.
- */
- protected override onProcessDone(proc: BashProcess, stderr: string): void {
- const facts = this.processFacts.get(proc)
- 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)
- proc.sandbox = {
- mode: facts.mode,
- denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures),
- enforcement: facts.enforcement,
- ...(runnerFailed ? { runnerFailed } : {}),
- }
- }
- super.onProcessDone(proc, stderr)
- }
- /**
- * Wrap one shell command via the `ctx.sandbox` provider: hand over the
- * exact `['bash', '-c', command]` argv this executor would spawn, get back
- * the confined argv, and re-assemble it into the `exec …` command string
- * the inherited spawn path runs (the outer `bash -c` that `runBash` spawns
- * `exec`s into the runner, so no extra shell lingers). Provider errors
- * (fail-closed `SANDBOX_UNAVAILABLE`) propagate to the caller unchanged.
- */
- private confine(command: string, mode: ConfinedSandboxMode): {
- command: string
- enforcement: SandboxEnforcement
- denialSignatures: readonly string[]
- runnerFailureSignatures: readonly string[]
- } {
- const confined = this.ctx.sandbox.confine(['bash', '-c', command], { mode, workspaceRoot: this.workspaceRoot })
- return {
- command: `exec ${confined.argv.map(shellQuote).join(' ')}`,
- enforcement: confined.enforcement,
- denialSignatures: confined.denialSignatures,
- runnerFailureSignatures: confined.runnerFailureSignatures,
- }
- }
- }
- export default SandboxBashExecutor
|