index.ts 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  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. Positive runner-launch evidence means
  5. * the command never ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while
  6. * background processes carry `runnerFailed`; other spawn rejections retain
  7. * local-executor semantics. The tool owns approval and passes a complete per-call policy.
  8. * @module @deepseek-ai/dsh-bash-sandbox
  9. */
  10. import { Context } from '@deepseek-ai/cordis'
  11. import type { ShellExecRequest, ShellExecSpec, ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell'
  12. import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
  13. import type {
  14. ConfinedArgv,
  15. ConfinedSandboxMode,
  16. RunnerFailureRule,
  17. SandboxEnforcement,
  18. SandboxExecutionPolicy,
  19. SandboxMode,
  20. SandboxPolicy,
  21. } from '@deepseek-ai/dsh-sandbox'
  22. import type {} from '@deepseek-ai/dsh-sandbox-policy'
  23. import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
  24. import type { Config as LocalConfig } from '@deepseek-ai/dsh-bash-local'
  25. import { classifyDenial, classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature } from './helpers.ts'
  26. /**
  27. * Plugin config: the local executor's knobs, verbatim. The sandbox policy —
  28. * the default mode and fallback `workspace-write` root — is NOT here: it lives
  29. * on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), which resolves
  30. * each calling session's mode and cwd for every enforcing capability. The runner
  31. * choice is likewise the `ctx.sandbox` provider's config, not this executor's.
  32. */
  33. export type Config = LocalConfig
  34. /**
  35. * Registers as `ctx.shell` in place of the local executor and requires a
  36. * `ctx.sandbox` provider plus `ctx.sandboxPolicy`; the tool layer is
  37. * unchanged. Tool calls pass the calling session's resolved policy; direct
  38. * calls fall back to deployment policy. `result.sandbox` reports the mode and
  39. * enforcement actually used.
  40. */
  41. export class SandboxBashExecutor extends LocalBashExecutor {
  42. static override inject = ['subprocess', 'sandbox', 'sandboxPolicy']
  43. // No own Config: the sandbox default (mode + workspaceRoot) is owned by
  44. // ctx.sandboxPolicy, so this executor inherits LocalBashExecutor's Config
  45. // verbatim (the config catalog walks the inherited static).
  46. private readonly mode: SandboxMode
  47. /**
  48. * Per-process confinement facts retained until settlement. Providers may
  49. * vary enforcement and diagnostic dialect between overlapping calls, so a
  50. * shared latest-wrap value would classify a process against the wrong facts.
  51. * Unconfined processes have no entry.
  52. */
  53. private readonly processFacts = new Map<ShellProcess, {
  54. mode: ConfinedSandboxMode
  55. enforcement: SandboxEnforcement
  56. denialSignatures: readonly string[]
  57. runnerFailureRules: readonly RunnerFailureRule[]
  58. runnerProgram: string | undefined
  59. workdir: string
  60. }>()
  61. constructor(ctx: Context, config: Config) {
  62. super(ctx, config)
  63. // The default mode is the capability fact used for schema advertisement;
  64. // actual tool executions carry their resolved per-call policy.
  65. this.mode = ctx.sandboxPolicy.defaultMode
  66. }
  67. /** The configured default mode — the capability fact the tool layer reads. */
  68. override get sandboxMode(): SandboxMode {
  69. return this.mode
  70. }
  71. /**
  72. * Stamp a complete per-call policy onto the spec. Tool calls supply the
  73. * calling session's resolved mode and root; lower-level callers fall back to
  74. * the deployment policy.
  75. */
  76. override resolve(request: ShellExecRequest): ShellExecSpec {
  77. return { ...super.resolve(request), sandboxPolicy: request.sandboxPolicy ?? this.ctx.sandboxPolicy.resolve() }
  78. }
  79. override async run(spec: ShellExecSpec): Promise<ShellRunResult> {
  80. const policy = spec.sandboxPolicy as SandboxExecutionPolicy
  81. const { mode } = policy
  82. if (mode === 'danger-full-access') {
  83. const result = await super.run(spec)
  84. return { ...result, sandbox: { mode, denied: false } }
  85. }
  86. const confined = this.confine(spec.command, { ...policy, mode })
  87. let result: ShellRunResult
  88. try {
  89. result = await this.runArgv(spec, confined.argv)
  90. } catch (error) {
  91. // An upstream abort remains cancellation even when it prevents spawn.
  92. if (spec.signal?.aborted === true) spec.signal.throwIfAborted()
  93. if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) {
  94. throw new SandboxUnavailableError(mode, String(error))
  95. }
  96. throw error
  97. }
  98. // Runner failure outranks denial because the command did not run. Carry
  99. // the matched fatal line, not an informational line that preceded it.
  100. const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, confined.runnerFailureRules)
  101. if (runnerFailure !== undefined) {
  102. throw new SandboxUnavailableError(mode, runnerFailure.detail)
  103. }
  104. return { ...result, sandbox: { mode, denied: classifyDenial(result, confined.denialSignatures), enforcement: confined.enforcement } }
  105. }
  106. override start(spec: ShellExecSpec): ShellProcess {
  107. const policy = spec.sandboxPolicy as SandboxExecutionPolicy
  108. const { mode } = policy
  109. if (mode === 'danger-full-access') return super.start(spec)
  110. // Once startArgv returns, install facts synchronously; promise settlement
  111. // cannot run before start() returns.
  112. const confined = this.confine(spec.command, { ...policy, mode })
  113. let proc: ShellProcess
  114. try {
  115. proc = this.startArgv(spec, confined.argv)
  116. } catch (error) {
  117. // LocalSubprocessRuntime reports ENOENT/EACCES with the failed executable path through async
  118. // `done` rejection; this covers alternatives that throw the same error synchronously.
  119. if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) {
  120. throw new SandboxUnavailableError(mode, String(error))
  121. }
  122. throw error
  123. }
  124. const { enforcement, denialSignatures, runnerFailureRules } = confined
  125. this.processFacts.set(proc, {
  126. mode,
  127. enforcement,
  128. denialSignatures,
  129. runnerFailureRules,
  130. runnerProgram: confined.argv[0],
  131. workdir: spec.workdir,
  132. })
  133. return proc
  134. }
  135. /**
  136. * Stamp per-process sandbox facts before `done` settles. Full-access processes
  137. * have no facts; signal deaths are not denials.
  138. */
  139. protected override onProcessDone(proc: ShellProcess, stderr: string, spawnFailed: boolean, spawnError?: unknown): void {
  140. const facts = this.processFacts.get(proc)
  141. if (facts !== undefined) {
  142. this.processFacts.delete(proc)
  143. // A rejected spawn never started the confined launch. Otherwise runner
  144. // failure outranks denial because its diagnostics may contain denial terms.
  145. const runnerFailed = spawnFailed
  146. ? isRunnerSpawnFailure(spawnError, facts.runnerProgram, facts.workdir)
  147. : classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined
  148. proc.sandbox = {
  149. mode: facts.mode,
  150. denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures),
  151. enforcement: facts.enforcement,
  152. ...(runnerFailed ? { runnerFailed } : {}),
  153. }
  154. }
  155. super.onProcessDone(proc, stderr, spawnFailed, spawnError)
  156. }
  157. /**
  158. * Wrap one shell command via the `ctx.sandbox` provider. Provider errors
  159. * propagate unchanged; the returned argv is handed directly to the local
  160. * executor's subprocess path.
  161. * @param command - shell source for the confined inner `bash -c`.
  162. * @param policy - resolved confined execution policy.
  163. * @returns the provider's exact argv and settlement-classification facts.
  164. */
  165. private confine(command: string, policy: SandboxPolicy): ConfinedArgv {
  166. return this.ctx.sandbox.confine(['bash', '-c', command], policy)
  167. }
  168. }
  169. export default SandboxBashExecutor