index.ts 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. /**
  2. * Sandbox-consuming bash executor. It wraps the exact local bash argv through
  3. * `ctx.sandbox`, inherits local process mechanics, and reports the selected
  4. * mode, enforcement, and denial facts. Runner failure means the command never
  5. * ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while settled background
  6. * processes carry `runnerFailed`. The tool owns approval and passes per-call modes.
  7. * @module @deepseek-ai/dsh-bash-sandbox
  8. */
  9. import { resolve } from 'node:path'
  10. import { Context } from 'cordis'
  11. import z from 'schemastery'
  12. import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
  13. import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
  14. import type { ConfinedSandboxMode, SandboxEnforcement, SandboxMode } from '@deepseek-ai/dsh-sandbox'
  15. import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
  16. import type { Config as LocalConfig } from '@deepseek-ai/dsh-bash-local'
  17. import { classifyDenial, classifyRunnerFailure, matchesSignature, shellQuote } from './helpers.ts'
  18. /**
  19. * Plugin config: the local executor's knobs plus the sandbox policy. All
  20. * optional — `static Config` supplies the defaults (`mode: 'read-only'` is the
  21. * fail-safe default; an example that wants a workspace-writable agent opts in
  22. * explicitly). The runner choice is not configured here: which platform
  23. * backend confines the command is the `ctx.sandbox` provider's config.
  24. */
  25. export interface Config extends LocalConfig {
  26. /** File-sandbox mode commands run under (default: `read-only`). */
  27. mode?: SandboxMode
  28. /**
  29. * Root directory `workspace-write` mode may write under (default: the
  30. * executor's default working directory — `cwd`, else `process.cwd()`).
  31. */
  32. workspaceRoot?: string
  33. }
  34. /**
  35. * Registers as `ctx.bash` in place of the local executor and requires a
  36. * `ctx.sandbox` provider; the tool layer is unchanged. The configured mode is
  37. * the fallback, while a session override or approved one-shot escalation may
  38. * select each call's mode. The prompt does not state the standing mode;
  39. * `result.sandbox` reports the mode and enforcement actually used.
  40. */
  41. export class SandboxBashExecutor extends LocalBashExecutor {
  42. static inject = ['sandbox']
  43. // The sandbox-specific fields intersect the local executor's Config as an
  44. // inline schema call: the config catalog walks `static Config` statically.
  45. static override Config: z<Config> = z.intersect([
  46. LocalBashExecutor.Config,
  47. z.object({
  48. mode: z.union(['read-only', 'workspace-write', 'danger-full-access'] as const).default('read-only'),
  49. workspaceRoot: z.string(),
  50. }),
  51. ])
  52. private readonly mode: SandboxMode
  53. private readonly workspaceRoot: string
  54. /**
  55. * Per-process confinement facts retained until settlement. Providers may
  56. * vary enforcement and diagnostic dialect between overlapping calls, so a
  57. * shared latest-wrap value would classify a process against the wrong facts.
  58. * Unconfined processes have no entry.
  59. */
  60. private readonly processFacts = new Map<BashProcess, {
  61. mode: ConfinedSandboxMode
  62. enforcement: SandboxEnforcement
  63. denialSignatures: readonly string[]
  64. runnerFailureSignatures: readonly string[]
  65. }>()
  66. constructor(ctx: Context, config: Config) {
  67. super(ctx, config)
  68. // Schemastery fills mode before construction; workspaceRoot and cwd retain runtime fallbacks.
  69. this.mode = config.mode as SandboxMode
  70. this.workspaceRoot = resolve(config.workspaceRoot ?? config.cwd ?? process.cwd())
  71. }
  72. /** The configured default mode — the capability fact the tool layer reads. */
  73. override get sandboxMode(): SandboxMode {
  74. return this.mode
  75. }
  76. /**
  77. * Stamp the effective mode onto the spec — the request's explicit override
  78. * (an approved escalation), else this executor's configured default — so
  79. * defaulting stays an explicit resolve step and `run()`/`start()` read the
  80. * spec, never the config.
  81. */
  82. override resolve(request: BashExecRequest): BashExecSpec {
  83. return { ...super.resolve(request), sandboxMode: request.sandboxMode ?? this.mode }
  84. }
  85. override async run(spec: BashExecSpec): Promise<BashRunResult> {
  86. // resolve() always stamps the mode; the cast records that invariant
  87. // (mirrors the constructor's config casts).
  88. const mode = spec.sandboxMode as SandboxMode
  89. if (mode === 'danger-full-access') {
  90. const result = await super.run(spec)
  91. return { ...result, sandbox: { mode, denied: false } }
  92. }
  93. const confined = this.confine(spec.command, mode)
  94. const result = await super.run({ ...spec, command: confined.command })
  95. // Runner failure outranks denial because the command did not run. Throw the
  96. // same fail-closed error as confine-time discovery with the first stderr line.
  97. if (classifyRunnerFailure(result, confined.runnerFailureSignatures)) {
  98. throw new SandboxUnavailableError(mode, result.stderr.text.trim().split('\n')[0])
  99. }
  100. return { ...result, sandbox: { mode, denied: classifyDenial(result, confined.denialSignatures), enforcement: confined.enforcement } }
  101. }
  102. override start(spec: BashExecSpec): BashProcess {
  103. // Same stamped-by-resolve invariant as run().
  104. const mode = spec.sandboxMode as SandboxMode
  105. if (mode === 'danger-full-access') return super.start(spec)
  106. // Install facts synchronously; promise settlement cannot run before start() returns.
  107. const confined = this.confine(spec.command, mode)
  108. const proc = super.start({ ...spec, command: confined.command })
  109. const { enforcement, denialSignatures, runnerFailureSignatures } = confined
  110. this.processFacts.set(proc, { mode, enforcement, denialSignatures, runnerFailureSignatures })
  111. return proc
  112. }
  113. /**
  114. * Stamp per-process sandbox facts before `done` settles. Full-access processes
  115. * have no facts; signal deaths are not denials.
  116. */
  117. protected override onProcessDone(proc: BashProcess, stderr: string): void {
  118. const facts = this.processFacts.get(proc)
  119. if (facts !== undefined) {
  120. this.processFacts.delete(proc)
  121. // Runner failure outranks denial because its diagnostics may contain denial terms.
  122. const runnerFailed = matchesSignature(proc.exitCode, stderr, facts.runnerFailureSignatures)
  123. proc.sandbox = {
  124. mode: facts.mode,
  125. denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures),
  126. enforcement: facts.enforcement,
  127. ...(runnerFailed ? { runnerFailed } : {}),
  128. }
  129. }
  130. super.onProcessDone(proc, stderr)
  131. }
  132. /**
  133. * Wrap one shell command via the `ctx.sandbox` provider: hand over the
  134. * exact `['bash', '-c', command]` argv this executor would spawn, get back
  135. * the confined argv, and re-assemble it into the `exec …` command string
  136. * the inherited spawn path runs (the outer `bash -c` that `runBash` spawns
  137. * `exec`s into the runner, so no extra shell lingers). Provider errors
  138. * (fail-closed `SANDBOX_UNAVAILABLE`) propagate to the caller unchanged.
  139. */
  140. private confine(command: string, mode: ConfinedSandboxMode): {
  141. command: string
  142. enforcement: SandboxEnforcement
  143. denialSignatures: readonly string[]
  144. runnerFailureSignatures: readonly string[]
  145. } {
  146. const confined = this.ctx.sandbox.confine(['bash', '-c', command], { mode, workspaceRoot: this.workspaceRoot })
  147. return {
  148. command: `exec ${confined.argv.map(shellQuote).join(' ')}`,
  149. enforcement: confined.enforcement,
  150. denialSignatures: confined.denialSignatures,
  151. runnerFailureSignatures: confined.runnerFailureSignatures,
  152. }
  153. }
  154. }
  155. export default SandboxBashExecutor