index.ts 7.9 KB

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