index.ts 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  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 { Context } from 'cordis'
  10. import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
  11. import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
  12. import type { ConfinedSandboxMode, SandboxEnforcement, SandboxMode } from '@deepseek-ai/dsh-sandbox'
  13. import type {} from '@deepseek-ai/dsh-sandbox-policy'
  14. import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
  15. import type { Config as LocalConfig } from '@deepseek-ai/dsh-bash-local'
  16. import { classifyDenial, classifyRunnerFailure, matchesSignature, shellQuote } from './helpers.ts'
  17. /**
  18. * Plugin config: the local executor's knobs, verbatim. The sandbox policy —
  19. * the default mode and the `workspace-write` boundary root — is NOT here: it
  20. * lives on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), the one
  21. * home both enforcing families read, so bash and fs can never confine to
  22. * different roots. The runner choice is likewise the `ctx.sandbox` provider's
  23. * config, not this executor's.
  24. */
  25. export type Config = LocalConfig
  26. /**
  27. * Registers as `ctx.bash` in place of the local executor and requires a
  28. * `ctx.sandbox` provider plus `ctx.sandboxPolicy`; the tool layer is
  29. * unchanged. The policy default (mode + workspace root) is the fallback,
  30. * while a session override or approved one-shot escalation may select each
  31. * call's mode. The prompt does not state the standing mode; `result.sandbox`
  32. * reports the mode and enforcement actually used.
  33. */
  34. export class SandboxBashExecutor extends LocalBashExecutor {
  35. static inject = ['sandbox', 'sandboxPolicy']
  36. // No own Config: the sandbox default (mode + workspaceRoot) moved to
  37. // ctx.sandboxPolicy, so this executor inherits LocalBashExecutor's Config
  38. // verbatim (the config catalog walks the inherited static).
  39. private readonly mode: SandboxMode
  40. private readonly workspaceRoot: string
  41. /**
  42. * Per-process confinement facts retained until settlement. Providers may
  43. * vary enforcement and diagnostic dialect between overlapping calls, so a
  44. * shared latest-wrap value would classify a process against the wrong facts.
  45. * Unconfined processes have no entry.
  46. */
  47. private readonly processFacts = new Map<BashProcess, {
  48. mode: ConfinedSandboxMode
  49. enforcement: SandboxEnforcement
  50. denialSignatures: readonly string[]
  51. runnerFailureSignatures: readonly string[]
  52. }>()
  53. constructor(ctx: Context, config: Config) {
  54. super(ctx, config)
  55. // The sandbox default (mode + workspaceRoot) is the one shared policy home
  56. // both enforcing families read; injecting sandboxPolicy guarantees it is
  57. // constructed first. workspaceRoot arrives already resolved absolute.
  58. this.mode = ctx.sandboxPolicy.defaultMode
  59. this.workspaceRoot = ctx.sandboxPolicy.workspaceRoot
  60. }
  61. /** The configured default mode — the capability fact the tool layer reads. */
  62. override get sandboxMode(): SandboxMode {
  63. return this.mode
  64. }
  65. /**
  66. * Stamp the effective mode onto the spec — the request's explicit override
  67. * (an approved escalation), else this executor's configured default — so
  68. * defaulting stays an explicit resolve step and `run()`/`start()` read the
  69. * spec, never the config.
  70. */
  71. override resolve(request: BashExecRequest): BashExecSpec {
  72. return { ...super.resolve(request), sandboxMode: request.sandboxMode ?? this.mode }
  73. }
  74. override async run(spec: BashExecSpec): Promise<BashRunResult> {
  75. // resolve() always stamps the mode; the cast records that invariant
  76. // (mirrors the constructor's config casts).
  77. const mode = spec.sandboxMode as SandboxMode
  78. if (mode === 'danger-full-access') {
  79. const result = await super.run(spec)
  80. return { ...result, sandbox: { mode, denied: false } }
  81. }
  82. const confined = this.confine(spec.command, mode)
  83. const result = await super.run({ ...spec, command: confined.command })
  84. // Runner failure outranks denial because the command did not run. Throw the
  85. // same fail-closed error as confine-time discovery with the first stderr line.
  86. if (classifyRunnerFailure(result, confined.runnerFailureSignatures)) {
  87. throw new SandboxUnavailableError(mode, result.stderr.text.trim().split('\n')[0])
  88. }
  89. return { ...result, sandbox: { mode, denied: classifyDenial(result, confined.denialSignatures), enforcement: confined.enforcement } }
  90. }
  91. override start(spec: BashExecSpec): BashProcess {
  92. // Same stamped-by-resolve invariant as run().
  93. const mode = spec.sandboxMode as SandboxMode
  94. if (mode === 'danger-full-access') return super.start(spec)
  95. // Install facts synchronously; promise settlement cannot run before start() returns.
  96. const confined = this.confine(spec.command, mode)
  97. const proc = super.start({ ...spec, command: confined.command })
  98. const { enforcement, denialSignatures, runnerFailureSignatures } = confined
  99. this.processFacts.set(proc, { mode, enforcement, denialSignatures, runnerFailureSignatures })
  100. return proc
  101. }
  102. /**
  103. * Stamp per-process sandbox facts before `done` settles. Full-access processes
  104. * have no facts; signal deaths are not denials.
  105. */
  106. protected override onProcessDone(proc: BashProcess, stderr: string): void {
  107. const facts = this.processFacts.get(proc)
  108. if (facts !== undefined) {
  109. this.processFacts.delete(proc)
  110. // Runner failure outranks denial because its diagnostics may contain denial terms.
  111. const runnerFailed = matchesSignature(proc.exitCode, stderr, facts.runnerFailureSignatures)
  112. proc.sandbox = {
  113. mode: facts.mode,
  114. denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures),
  115. enforcement: facts.enforcement,
  116. ...(runnerFailed ? { runnerFailed } : {}),
  117. }
  118. }
  119. super.onProcessDone(proc, stderr)
  120. }
  121. /**
  122. * Wrap one shell command via the `ctx.sandbox` provider: hand over the
  123. * exact `['bash', '-c', command]` argv this executor would spawn, get back
  124. * the confined argv, and re-assemble it into the `exec …` command string
  125. * the inherited spawn path runs (the outer `bash -c` that `runBash` spawns
  126. * `exec`s into the runner, so no extra shell lingers). Provider errors
  127. * (fail-closed `SANDBOX_UNAVAILABLE`) propagate to the caller unchanged.
  128. */
  129. private confine(command: string, mode: ConfinedSandboxMode): {
  130. command: string
  131. enforcement: SandboxEnforcement
  132. denialSignatures: readonly string[]
  133. runnerFailureSignatures: readonly string[]
  134. } {
  135. const confined = this.ctx.sandbox.confine(['bash', '-c', command], { mode, workspaceRoot: this.workspaceRoot })
  136. return {
  137. command: `exec ${confined.argv.map(shellQuote).join(' ')}`,
  138. enforcement: confined.enforcement,
  139. denialSignatures: confined.denialSignatures,
  140. runnerFailureSignatures: confined.runnerFailureSignatures,
  141. }
  142. }
  143. }
  144. export default SandboxBashExecutor