index.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. /**
  2. * `SandboxBashExecutor`: the sandbox-consuming implementation of the
  3. * `@deepseek-ai/dsh-bash` executor seam. Every spawned command is wrapped by
  4. * the `ctx.sandbox` provider (`@deepseek-ai/dsh-sandbox`) according to the
  5. * configured {@link SandboxMode}: the executor hands the provider the exact
  6. * `['bash', '-c', command]` argv it is about to spawn and spawns the wrapped
  7. * argv instead. WHICH platform runner confines it — and whether one is
  8. * usable at all (the provider fails CLOSED with a structured
  9. * `SANDBOX_UNAVAILABLE` error rather than passing the argv through) — is the
  10. * provider's concern (`@deepseek-ai/dsh-sandbox-local` first).
  11. *
  12. * Extends `LocalBashExecutor` so all process mechanics — spawn, process-group
  13. * kills, timeout escalation, output collection and spill files, background
  14. * tasks, the credential scrub — are the local implementation's, verbatim.
  15. * This package adds only the seam consumption and the result facts, which is
  16. * exactly the split the capability seam was designed for (a sandboxing
  17. * executor replaces `dsh-bash-local` without touching `dsh-tool-bash`, and
  18. * swapping the confinement backend never touches this package).
  19. *
  20. * A failed run whose stderr carries the selected backend's own denial
  21. * dialect (the signatures the provider stamps on every wrap) is classified
  22. * as a sandbox denial on `BashRunResult.sandbox`, and every confined result
  23. * also carries how completely the selected runner enforces the mode
  24. * (`sandbox.enforcement`, from the provider's wrap). A failure carrying the
  25. * backend's RUNNER-FAILURE signature instead means the sandbox itself broke
  26. * and the command never ran: the foreground path re-throws it as the
  27. * structured fail-closed `SANDBOX_UNAVAILABLE` error (late twin of the
  28. * provider's confine-time throw), a settled background task stamps
  29. * `sandbox.runnerFailed` — either way a broken sandbox can never read as a
  30. * failing command, and the command never slips through unconfined.
  31. *
  32. * Deny-only at the seam, escalation at the tool: a denial is a reported FACT
  33. * here, and the one-shot user-approved escalated retry of a denied action
  34. * (docs/rfc/implemented/feature/2026-07-06-sandbox.md) is driven by
  35. * `dsh-tool-bash` through `ctx.approval` — this executor's contribution is the
  36. * per-call `sandboxMode` override it honors in {@link resolve}: an escalated
  37. * call runs (and classifies, and reports) under ITS granted mode while every
  38. * neighboring call keeps its session's standing mode (or the configured
  39. * default when that session has no override).
  40. *
  41. * @module @deepseek-ai/dsh-bash-sandbox
  42. */
  43. import { resolve } from 'node:path'
  44. import { Context } from 'cordis'
  45. import z from 'schemastery'
  46. import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskId } from '@deepseek-ai/dsh-bash'
  47. import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
  48. import type { ConfinedSandboxMode, SandboxEnforcement, SandboxMode } from '@deepseek-ai/dsh-sandbox'
  49. import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
  50. import type { Config as LocalConfig } from '@deepseek-ai/dsh-bash-local'
  51. /**
  52. * Plugin config: the local executor's knobs plus the sandbox policy. All
  53. * optional — `static Config` supplies the defaults (`mode: 'read-only'` is the
  54. * fail-safe default; an example that wants a workspace-writable agent opts in
  55. * explicitly). The runner choice is NOT configured here: which platform
  56. * backend confines the command is the `ctx.sandbox` provider's config.
  57. */
  58. export interface Config extends LocalConfig {
  59. /** File-sandbox mode commands run under (default: `read-only`). */
  60. mode?: SandboxMode
  61. /**
  62. * Root directory `workspace-write` mode may write under (default: the
  63. * executor's default working directory — `cwd`, else `process.cwd()`).
  64. */
  65. workspaceRoot?: string
  66. }
  67. /**
  68. * Quote one string as a single-quoted POSIX shell word (embedded single
  69. * quotes become `'\''`), so a wrapped argv element survives the outer
  70. * `bash -c` re-parse byte-for-byte.
  71. * @param text - the raw argv element to quote.
  72. * @returns the single-quoted shell word.
  73. */
  74. export function shellQuote(text: string): string {
  75. return `'${text.replaceAll("'", String.raw`'\''`)}'`
  76. }
  77. /**
  78. * Conservative sandbox-denial classifier: a run counts as denied only when it
  79. * FAILED (nonzero exit — a signal kill is not a denial) and its stderr
  80. * carries one of the SELECTED BACKEND's own denial signatures — the dialect
  81. * the provider stamps on every wrap (`ConfinedArgv.denialSignatures`:
  82. * `Read-only file system` under bwrap's EROFS mounts, `Permission denied`
  83. * under Landlock's EACCES, `Operation not permitted` under Seatbelt's
  84. * EPERM). Matching the backend's dialect rather than a cross-backend union
  85. * keeps the classifier from claiming denials the active backend never
  86. * produces (bare EPERM text under a Linux runner names non-file boundaries —
  87. * mount, kill, ptrace — that fail the same way unsandboxed). Text inference
  88. * is the fallback signal until a runner provides a structured one (which
  89. * wins once it exists); it errs toward NOT claiming a denial, and its known
  90. * residual imprecision is non-sandbox text in the active dialect (an ssh
  91. * auth failure reads as a denial under Landlock, a refused `kill` under
  92. * Seatbelt).
  93. * @param result - the settled foreground run to classify.
  94. * @param signatures - the active wrap's denial dialect, case-insensitive
  95. * stderr substrings.
  96. * @returns whether the run's failure reads as a sandbox denial.
  97. */
  98. export function classifyDenial(result: BashRunResult, signatures: readonly string[]): boolean {
  99. return matchesSignature(result.exitCode, result.stderr.text, signatures)
  100. }
  101. /**
  102. * Runner-failure classifier: a failed run whose stderr carries the SELECTED
  103. * BACKEND's own runner-failure signature (`ConfinedArgv.
  104. * runnerFailureSignatures`: the runner's error prefix, which also matches
  105. * the shell's runner-not-found message) means the SANDBOX itself failed and
  106. * the command never ran. Checked BEFORE {@link classifyDenial} — a runner's
  107. * error text can contain denial words (an unopenable grant root reports
  108. * `Permission denied`) — and surfaced as the fail-closed
  109. * `SANDBOX_UNAVAILABLE` error on the foreground path, `sandbox.runnerFailed`
  110. * on a settled background task. Same conservative-text-inference stance and
  111. * residual imprecision as the denial classifier (a failing task that itself
  112. * prints the runner's prefix reads as a runner failure).
  113. * @param result - the settled foreground run to classify.
  114. * @param signatures - the active wrap's runner-failure signatures,
  115. * case-insensitive stderr substrings.
  116. * @returns whether the run's failure reads as the runner itself failing.
  117. */
  118. export function classifyRunnerFailure(result: BashRunResult, signatures: readonly string[]): boolean {
  119. return matchesSignature(result.exitCode, result.stderr.text, signatures)
  120. }
  121. /**
  122. * The classifier core shared by foreground results and settled background
  123. * tasks: failed AND signature present. Lowercases BOTH sides — the seam
  124. * declares its signatures case-insensitive, and producers compose them from
  125. * runtime data of any case (an `argv0` path, `No such file or directory`).
  126. */
  127. function matchesSignature(exitCode: number | null, stderr: string, signatures: readonly string[]): boolean {
  128. if (exitCode === null || exitCode === 0) return false
  129. const lowered = stderr.toLowerCase()
  130. return signatures.some(signature => lowered.includes(signature.toLowerCase()))
  131. }
  132. /**
  133. * Sandbox-consuming bash executor. Registers as `ctx.bash` (loading it
  134. * INSTEAD OF `dsh-bash-local`, together with a `ctx.sandbox` provider, is
  135. * the whole swap — the tool layer is untouched). Its configured mode is the
  136. * fallback exposed by {@link sandboxMode}; `dsh-tool-bash` folds a session's
  137. * durable `bash/sandbox-mode` override and stamps the effective mode onto each
  138. * request, while an approved escalation may stamp a strictly wider mode for
  139. * one call. The tool's per-agent prompt section states that same effective
  140. * mode, and each run's `result.sandbox` reports what actually executed plus
  141. * enforcement completeness.
  142. */
  143. export class SandboxBashExecutor extends LocalBashExecutor {
  144. static inject = ['sandbox']
  145. // The sandbox-specific fields intersect the local executor's Config as an
  146. // inline schema call: the config catalog walks `static Config` statically.
  147. static override Config: z<Config> = z.intersect([
  148. LocalBashExecutor.Config,
  149. z.object({
  150. mode: z.union(['read-only', 'workspace-write', 'danger-full-access'] as const).default('read-only'),
  151. workspaceRoot: z.string(),
  152. }),
  153. ])
  154. private readonly mode: SandboxMode
  155. private readonly workspaceRoot: string
  156. /**
  157. * Per-task facts, keyed by task id from `start()` until the settle stamp
  158. * consumes them: the mode the task runs under (per-call — an escalated task
  159. * differs from its neighbors) plus its wrap facts. The seam returns facts
  160. * PER WRAP — a provider may legally vary enforcement or dialect between
  161. * calls — so overlapping background tasks must each classify against their
  162. * OWN wrap; a single latest-wrap field would let a later `start()` clobber
  163. * an earlier task's facts before it settles. A `danger-full-access` task
  164. * has NO entry (nothing confined it), which is what the settle stamp keys
  165. * off.
  166. */
  167. private readonly taskFacts = new Map<BashTaskId, {
  168. mode: ConfinedSandboxMode
  169. enforcement: SandboxEnforcement
  170. denialSignatures: readonly string[]
  171. runnerFailureSignatures: readonly string[]
  172. }>()
  173. constructor(ctx: Context, config: Config) {
  174. super(ctx, config)
  175. // schemastery (static Config) already filled the defaulted fields — the
  176. // cast records that runtime fact (mirrors LocalBashExecutor's config
  177. // cast). `workspaceRoot` and `cwd` have NO schema default, so their
  178. // fallback chain is real branching.
  179. this.mode = config.mode as SandboxMode
  180. this.workspaceRoot = resolve(config.workspaceRoot ?? config.cwd ?? process.cwd())
  181. }
  182. /** The configured default mode — the capability fact the tool layer reads. */
  183. override get sandboxMode(): SandboxMode {
  184. return this.mode
  185. }
  186. /**
  187. * Stamp the effective mode onto the spec — the request's explicit override
  188. * (an approved escalation), else this executor's configured default — so
  189. * defaulting stays an explicit resolve step and `run()`/`start()` read the
  190. * spec, never the config.
  191. */
  192. override resolve(request: BashExecRequest): BashExecSpec {
  193. return { ...super.resolve(request), sandboxMode: request.sandboxMode ?? this.mode }
  194. }
  195. override async run(spec: BashExecSpec): Promise<BashRunResult> {
  196. // resolve() always stamps the mode; the cast records that invariant
  197. // (mirrors the constructor's config casts).
  198. const mode = spec.sandboxMode as SandboxMode
  199. if (mode === 'danger-full-access') {
  200. const result = await super.run(spec)
  201. return { ...result, sandbox: { mode, denied: false } }
  202. }
  203. const confined = this.confine(spec.command, mode)
  204. const result = await super.run({ ...spec, command: confined.command })
  205. // Runner failure outranks denial: the sandbox itself failed and the
  206. // command NEVER RAN — surface the same structured fail-closed error a
  207. // confine-time discovery throws (late detection, same outcome), with
  208. // the runner's own first stderr line as the cause. Returning it as a
  209. // task result would let a broken sandbox read as a failing command.
  210. if (classifyRunnerFailure(result, confined.runnerFailureSignatures)) {
  211. throw new SandboxUnavailableError(mode, result.stderr.text.trim().split('\n')[0])
  212. }
  213. return { ...result, sandbox: { mode, denied: classifyDenial(result, confined.denialSignatures), enforcement: confined.enforcement } }
  214. }
  215. override start(spec: BashExecSpec): BashTask {
  216. // Same stamped-by-resolve invariant as run().
  217. const mode = spec.sandboxMode as SandboxMode
  218. if (mode === 'danger-full-access') return super.start(spec)
  219. // Sandbox facts are stamped at settle time by {@link notifyTaskDone}
  220. // (denial classification runs against the settled task's collected
  221. // stderr). The map entry lands synchronously after spawn, strictly
  222. // before the earliest possible settle (a process exit reaches us no
  223. // sooner than the next tick).
  224. const confined = this.confine(spec.command, mode)
  225. const task = super.start({ ...spec, command: confined.command })
  226. const { enforcement, denialSignatures, runnerFailureSignatures } = confined
  227. this.taskFacts.set(task.id, { mode, enforcement, denialSignatures, runnerFailureSignatures })
  228. return task
  229. }
  230. /**
  231. * Stamp the sandbox facts BEFORE completion listeners run: the base
  232. * executor notifies from inside the task's settle path, so overriding the
  233. * notification point is what makes `task.sandbox` visible to `onTaskDone`
  234. * consumers and `done` awaiters alike. Each task classifies against the
  235. * facts of ITS OWN wrap and reports ITS OWN mode (consumed from the
  236. * per-task map here — settle is the entry's end of life): with per-call
  237. * escalation, tasks under different modes settle side by side, so keying
  238. * anything off the configured default would misreport them. A
  239. * `danger-full-access` task has no map entry and carries no facts (nothing
  240. * confined it); a signal-killed task (null exit code) is never a denial,
  241. * mirroring the foreground classifier.
  242. */
  243. protected override notifyTaskDone(task: BashTask): void {
  244. const facts = this.taskFacts.get(task.id)
  245. if (facts !== undefined) {
  246. this.taskFacts.delete(task.id)
  247. const stderr = this.collectedStderr(task.id)
  248. // Runner failure outranks denial (the command never ran; the runner's
  249. // own error text can contain denial words). A settled task has no
  250. // error channel left, so the fact IS the surface here — the foreground
  251. // path throws instead.
  252. const runnerFailed = matchesSignature(task.exitCode, stderr, facts.runnerFailureSignatures)
  253. task.sandbox = {
  254. mode: facts.mode,
  255. denied: !runnerFailed && matchesSignature(task.exitCode, stderr, facts.denialSignatures),
  256. enforcement: facts.enforcement,
  257. ...(runnerFailed ? { runnerFailed } : {}),
  258. }
  259. }
  260. super.notifyTaskDone(task)
  261. }
  262. /**
  263. * Wrap one shell command via the `ctx.sandbox` provider: hand over the
  264. * exact `['bash', '-c', command]` argv this executor would spawn, get back
  265. * the confined argv, and re-assemble it into the `exec …` command string
  266. * the inherited spawn path runs (the outer `bash -c` that `runBash` spawns
  267. * `exec`s into the runner, so no extra shell lingers). Provider errors
  268. * (fail-closed `SANDBOX_UNAVAILABLE`) propagate to the caller unchanged.
  269. */
  270. private confine(command: string, mode: ConfinedSandboxMode): {
  271. command: string
  272. enforcement: SandboxEnforcement
  273. denialSignatures: readonly string[]
  274. runnerFailureSignatures: readonly string[]
  275. } {
  276. const confined = this.ctx.sandbox.confine(['bash', '-c', command], { mode, workspaceRoot: this.workspaceRoot })
  277. return {
  278. command: `exec ${confined.argv.map(shellQuote).join(' ')}`,
  279. enforcement: confined.enforcement,
  280. denialSignatures: confined.denialSignatures,
  281. runnerFailureSignatures: confined.runnerFailureSignatures,
  282. }
  283. }
  284. }
  285. export default SandboxBashExecutor