2026-07-09 16:05:44 +08:00
|
|
|
/**
|
2026-07-13 23:27:00 +08:00
|
|
|
* 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
|
2026-07-14 18:05:46 +08:00
|
|
|
* processes carry `runnerFailed`. The tool owns approval and passes per-call modes.
|
2026-07-09 16:05:44 +08:00
|
|
|
* @module @deepseek-ai/dsh-bash-sandbox
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { Context } from 'cordis'
|
2026-07-11 23:04:27 +08:00
|
|
|
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
|
2026-07-09 16:05:44 +08:00
|
|
|
import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
|
|
|
|
import type { ConfinedSandboxMode, SandboxEnforcement, SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
2026-07-14 20:05:57 +08:00
|
|
|
import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
2026-07-09 16:05:44 +08:00
|
|
|
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
|
|
|
|
import type { Config as LocalConfig } from '@deepseek-ai/dsh-bash-local'
|
2026-07-14 03:47:32 +08:00
|
|
|
import { classifyDenial, classifyRunnerFailure, matchesSignature, shellQuote } from './helpers.ts'
|
2026-07-09 16:05:44 +08:00
|
|
|
|
|
|
|
|
/**
|
2026-07-14 20:05:57 +08:00
|
|
|
* 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.
|
2026-07-09 16:05:44 +08:00
|
|
|
*/
|
2026-07-14 20:05:57 +08:00
|
|
|
export type Config = LocalConfig
|
2026-07-09 16:05:44 +08:00
|
|
|
|
|
|
|
|
/**
|
2026-07-14 14:37:16 +08:00
|
|
|
* Registers as `ctx.bash` in place of the local executor and requires a
|
2026-07-14 21:25:36 +08:00
|
|
|
* `ctx.sandbox` provider plus `ctx.sandboxPolicy`; the tool layer is
|
2026-07-16 23:31:51 +08:00
|
|
|
* 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.
|
2026-07-09 16:05:44 +08:00
|
|
|
*/
|
|
|
|
|
export class SandboxBashExecutor extends LocalBashExecutor {
|
2026-07-14 20:05:57 +08:00
|
|
|
static inject = ['sandbox', 'sandboxPolicy']
|
2026-07-09 16:05:44 +08:00
|
|
|
|
2026-07-14 20:05:57 +08:00
|
|
|
// 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).
|
2026-07-09 16:05:44 +08:00
|
|
|
|
|
|
|
|
private readonly mode: SandboxMode
|
|
|
|
|
private readonly workspaceRoot: string
|
|
|
|
|
/**
|
2026-07-15 21:08:58 +08:00
|
|
|
* 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.
|
2026-07-09 16:05:44 +08:00
|
|
|
*/
|
2026-07-11 23:04:27 +08:00
|
|
|
private readonly processFacts = new Map<BashProcess, {
|
2026-07-09 16:05:44 +08:00
|
|
|
mode: ConfinedSandboxMode
|
|
|
|
|
enforcement: SandboxEnforcement
|
|
|
|
|
denialSignatures: readonly string[]
|
|
|
|
|
runnerFailureSignatures: readonly string[]
|
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
constructor(ctx: Context, config: Config) {
|
|
|
|
|
super(ctx, config)
|
2026-07-14 20:05:57 +08:00
|
|
|
// 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
|
2026-07-09 16:05:44 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 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 })
|
2026-07-14 14:37:16 +08:00
|
|
|
// 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.
|
2026-07-09 16:05:44 +08:00
|
|
|
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 } }
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-11 23:04:27 +08:00
|
|
|
override start(spec: BashExecSpec): BashProcess {
|
2026-07-09 16:05:44 +08:00
|
|
|
// Same stamped-by-resolve invariant as run().
|
|
|
|
|
const mode = spec.sandboxMode as SandboxMode
|
|
|
|
|
if (mode === 'danger-full-access') return super.start(spec)
|
2026-07-15 21:08:58 +08:00
|
|
|
// Install facts synchronously; promise settlement cannot run before start() returns.
|
2026-07-09 16:05:44 +08:00
|
|
|
const confined = this.confine(spec.command, mode)
|
2026-07-11 23:04:27 +08:00
|
|
|
const proc = super.start({ ...spec, command: confined.command })
|
2026-07-09 16:05:44 +08:00
|
|
|
const { enforcement, denialSignatures, runnerFailureSignatures } = confined
|
2026-07-11 23:04:27 +08:00
|
|
|
this.processFacts.set(proc, { mode, enforcement, denialSignatures, runnerFailureSignatures })
|
|
|
|
|
return proc
|
2026-07-09 16:05:44 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-07-14 18:05:46 +08:00
|
|
|
* Stamp per-process sandbox facts before `done` settles. Full-access processes
|
|
|
|
|
* have no facts; signal deaths are not denials.
|
2026-07-09 16:05:44 +08:00
|
|
|
*/
|
2026-07-15 13:38:17 +08:00
|
|
|
protected override onProcessDone(proc: BashProcess, stderr: string): void {
|
2026-07-11 23:04:27 +08:00
|
|
|
const facts = this.processFacts.get(proc)
|
2026-07-09 16:05:44 +08:00
|
|
|
if (facts !== undefined) {
|
2026-07-11 23:04:27 +08:00
|
|
|
this.processFacts.delete(proc)
|
2026-07-15 21:08:58 +08:00
|
|
|
// Runner failure outranks denial because its diagnostics may contain denial terms.
|
2026-07-11 23:04:27 +08:00
|
|
|
const runnerFailed = matchesSignature(proc.exitCode, stderr, facts.runnerFailureSignatures)
|
|
|
|
|
proc.sandbox = {
|
2026-07-09 16:05:44 +08:00
|
|
|
mode: facts.mode,
|
2026-07-11 23:04:27 +08:00
|
|
|
denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures),
|
2026-07-09 16:05:44 +08:00
|
|
|
enforcement: facts.enforcement,
|
|
|
|
|
...(runnerFailed ? { runnerFailed } : {}),
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-15 13:38:17 +08:00
|
|
|
super.onProcessDone(proc, stderr)
|
2026-07-09 16:05:44 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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
|