Files
deepseek-harness/packages/shell/bash-sandbox/src/index.ts
T
Yichen Jiang c2fb2bd4e8 Merge branch 'worktree/jobs-api-review-3052' into worktree/jobs-api-review-3127
# Conflicts:
#	.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml
#	docs/config-catalog.i18n.yaml
#	docs/event-producer-consumer.i18n.yaml
#	docs/module-graph.i18n.yaml
#	docs/subsystems/jobs.i18n.yaml
#	docs/subsystems/shell.i18n.yaml
#	docs/subsystems/shell.md
#	docs/subsystems/shell.zh.md
#	docs/tool-catalog.i18n.yaml
#	packages/api/session-controller/README.i18n.yaml
#	packages/context/tmux-context/tests/tmux-context.spec.ts
#	packages/e2b/e2b/tests/fixtures/composition/bin.ts
#	packages/extensions/tool-cordis/src/api-catalog.ts
#	packages/shell/bash-local/README.i18n.yaml
#	packages/shell/bash-local/README.md
#	packages/shell/bash-local/README.zh.md
#	packages/shell/bash-local/src/index.ts
#	packages/shell/bash-local/tests/executor.spec.ts
#	packages/shell/bash-sandbox/README.i18n.yaml
#	packages/shell/bash-sandbox/README.md
#	packages/shell/bash-sandbox/README.zh.md
#	packages/shell/bash-sandbox/src/index.ts
#	packages/shell/bash-sandbox/tests/bwrap.e2e.ts
#	packages/shell/bash-sandbox/tests/landlock.e2e.ts
#	packages/shell/bash-sandbox/tests/partial-landlock.spec.ts
#	packages/shell/bash-sandbox/tests/sandbox.spec.ts
#	packages/shell/bash-sandbox/tests/seatbelt.e2e.ts
#	packages/shell/pwsh-local/README.i18n.yaml
#	packages/shell/pwsh-local/README.md
#	packages/shell/pwsh-local/README.zh.md
#	packages/shell/pwsh-local/src/index.ts
#	packages/shell/pwsh-local/tests/executor.spec.ts
#	packages/shell/pwsh-sandbox/README.i18n.yaml
#	packages/shell/pwsh-sandbox/README.md
#	packages/shell/pwsh-sandbox/README.zh.md
#	packages/shell/pwsh-sandbox/src/index.ts
#	packages/shell/pwsh-sandbox/tests/sandbox.spec.ts
#	packages/shell/shell/README.i18n.yaml
#	packages/shell/shell/README.md
#	packages/shell/shell/README.zh.md
#	packages/shell/shell/src/index.ts
#	packages/shell/shell/tests/service.spec.ts
#	packages/shell/tool-bash/README.i18n.yaml
#	packages/shell/tool-bash/src/index.ts
#	packages/shell/tool-bash/tests/tools.spec.ts
#	packages/shell/tool-pwsh/README.i18n.yaml
#	packages/shell/tool-pwsh/src/index.ts
#	packages/shell/tool-pwsh/tests/background.spec.ts
#	packages/shell/tool-pwsh/tests/tools.spec.ts
#	packages/workflow/tool-workflow/README.i18n.yaml
#	packages/workflow/tool-workflow/package.json
#	packages/workflow/tool-workflow/tests/tool-workflow.spec.ts
#	snapshots/session/ptc-python-turn/system-prompt.expected.md
2026-09-17 16:48:06 +08:00

193 lines
8.2 KiB
TypeScript

/**
* 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. Positive runner-executable evidence
* identifies a broken confinement runner: foreground calls throw
* `SANDBOX_UNAVAILABLE`, while background processes carry `runnerFailed`;
* other provider rejections retain stage-neutral local-executor semantics. The
* tool owns approval and passes a complete per-call policy.
* @module @deepseek-ai/dsh-bash-sandbox
*/
import { Context } from '@deepseek-ai/cordis'
import type { ShellExecRequest, ShellExecSpec, ShellExecution, ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell'
import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import type {
ConfinedArgv,
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'
import { classifyDenial, classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature } from './helpers.ts'
/**
* Plugin config: the local executor's knobs, verbatim. The sandbox policy —
* the default mode and fallback `workspace-write` root — is NOT here: it lives
* on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), which resolves
* each calling session's mode and cwd for every enforcing capability. The runner
* choice is likewise the `ctx.sandbox` provider's config, not this executor's.
*/
export type Config = LocalConfig
/**
* Registers as `ctx.shell` in place of the local executor and requires a
* `ctx.sandbox` provider plus `ctx.sandboxPolicy`; the tool layer is
* unchanged. Tool calls pass the calling session's resolved policy; direct
* calls fall back to deployment policy. `result.sandbox` reports the mode and
* enforcement actually used.
*/
export class SandboxBashExecutor extends LocalBashExecutor {
static override inject = ['subprocess', 'sandbox', 'sandboxPolicy']
// No own Config: the sandbox default (mode + workspaceRoot) is owned by
// ctx.sandboxPolicy, so this executor inherits LocalBashExecutor's Config
// verbatim (the config catalog walks the inherited static).
private readonly mode: SandboxMode
/**
* 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<ShellProcess, {
mode: ConfinedSandboxMode
enforcement: SandboxEnforcement
denialSignatures: readonly string[]
runnerFailureRules: readonly RunnerFailureRule[]
runnerProgram: string | undefined
workdir: string
}>()
constructor(ctx: Context, config: Config) {
super(ctx, config)
// The default mode is the capability fact used for schema advertisement;
// actual tool executions carry their resolved per-call policy.
this.mode = ctx.sandboxPolicy.defaultMode
}
/** The configured default mode — the capability fact the tool layer reads. */
override get sandboxMode(): SandboxMode {
return this.mode
}
/**
* Stamp a complete per-call policy onto the spec. Tool calls supply the
* calling session's resolved mode and root; lower-level callers fall back to
* the deployment policy.
*/
override resolve(request: ShellExecRequest): ShellExecSpec {
return { ...super.resolve(request), sandboxPolicy: request.sandboxPolicy ?? this.ctx.sandboxPolicy.resolve() }
}
override async execute(spec: ShellExecSpec): Promise<ShellExecution> {
const policy = spec.sandboxPolicy as SandboxExecutionPolicy
const { mode } = policy
if (mode === 'danger-full-access') {
return SandboxBashExecutor.decorateResult(
await super.execute(spec),
result => ({ ...result, sandbox: { mode, denied: false } }),
)
}
let confined: ConfinedArgv | undefined
const ex = await this.executeArgv(spec, async (signal) => {
const prepared = await this.confine(spec.command, { ...policy, mode }, signal)
signal.throwIfAborted()
confined = prepared
return prepared.argv
}, (process) => {
const facts = confined as ConfinedArgv
this.processFacts.set(process, {
mode,
enforcement: facts.enforcement,
denialSignatures: facts.denialSignatures,
runnerFailureRules: facts.runnerFailureRules,
runnerProgram: facts.argv[0],
workdir: spec.workdir,
})
})
return SandboxBashExecutor.decorateResult(ex, (result) => {
if (confined === undefined) return { ...result, sandbox: { mode, denied: false } }
const { enforcement, denialSignatures, runnerFailureRules } = confined
// 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, runnerFailureRules)
if (runnerFailure !== undefined) {
throw new SandboxUnavailableError(mode, runnerFailure.detail)
}
return { ...result, sandbox: { mode, denied: classifyDenial(result, denialSignatures), enforcement } }
}, (error) => {
// An upstream abort remains cancellation even when it prevents spawn.
if (spec.signal?.aborted === true) spec.signal.throwIfAborted()
if (confined !== undefined && isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) {
throw new SandboxUnavailableError(mode, String(error))
}
throw error
})
}
/**
* Decorate the handle's foreground projection in place, memoized once. The
* handle keeps its identity (never wrapped in a second object) because the
* per-process facts and `onProcessDone` key on the exact instance.
*/
private static decorateResult(
ex: ShellExecution,
map: (result: ShellRunResult) => ShellRunResult,
mapError?: (error: unknown) => never,
): ShellExecution {
const base = ex.result.bind(ex)
let decorated: Promise<ShellRunResult> | undefined
ex.result = () => {
decorated ??= base().then(map, mapError)
return decorated
}
return ex
}
/**
* Stamp per-process sandbox facts before `done` settles. Full-access processes
* have no facts; signal deaths are not denials.
*/
protected override onProcessDone(proc: ShellProcess, stderr: string, providerRejected: boolean, providerError?: unknown): void {
const facts = this.processFacts.get(proc)
if (facts !== undefined) {
this.processFacts.delete(proc)
// A provider rejection exposes no public failure stage. Attribute it to
// the confinement runner only when the error independently names argv[0].
// Otherwise settled runner failure outranks denial-like diagnostics.
const runnerFailed = providerRejected
? isRunnerSpawnFailure(providerError, facts.runnerProgram, facts.workdir)
: classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined
proc.sandbox = {
mode: facts.mode,
denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures),
enforcement: facts.enforcement,
...(runnerFailed ? { runnerFailed } : {}),
}
}
super.onProcessDone(proc, stderr, providerRejected, providerError)
}
/**
* Wrap one shell command via the `ctx.sandbox` provider. Provider errors
* propagate unchanged; the returned argv is handed directly to the local
* executor's subprocess path.
* @param command - shell source for the confined inner `bash -c`.
* @param policy - resolved confined execution policy.
* @param signal - cancellation of confinement preparation.
* @returns the provider's exact argv and settlement-classification facts.
*/
private confine(command: string, policy: SandboxPolicy, signal?: AbortSignal): Promise<ConfinedArgv> {
return this.ctx.sandbox.confine(['bash', '-c', command], policy, signal)
}
}
export default SandboxBashExecutor