index.ts 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  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 a complete
  7. * per-call policy.
  8. * @module @deepseek-ai/dsh-bash-sandbox
  9. */
  10. import { Context } from 'cordis'
  11. import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
  12. import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
  13. import type { ConfinedSandboxMode, SandboxEnforcement, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
  14. import type {} from '@deepseek-ai/dsh-sandbox-policy'
  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, verbatim. The sandbox policy —
  20. * the default mode and fallback `workspace-write` root — is NOT here: it lives
  21. * on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), which resolves
  22. * each calling session's mode and cwd for both enforcing families. The runner
  23. * choice is likewise the `ctx.sandbox` provider's 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. Tool calls pass the calling session's resolved policy; direct
  30. * calls fall back to deployment policy. The prompt does not state the standing
  31. * mode; `result.sandbox` reports the mode and enforcement actually used.
  32. */
  33. export class SandboxBashExecutor extends LocalBashExecutor {
  34. static override inject = ['subprocess', 'sandbox', 'sandboxPolicy']
  35. // No own Config: the sandbox default (mode + workspaceRoot) moved to
  36. // ctx.sandboxPolicy, so this executor inherits LocalBashExecutor's Config
  37. // verbatim (the config catalog walks the inherited static).
  38. private readonly mode: SandboxMode
  39. /**
  40. * Per-process confinement facts retained until settlement. Providers may
  41. * vary enforcement and diagnostic dialect between overlapping calls, so a
  42. * shared latest-wrap value would classify a process against the wrong facts.
  43. * Unconfined processes have no entry.
  44. */
  45. private readonly processFacts = new Map<BashProcess, {
  46. mode: ConfinedSandboxMode
  47. enforcement: SandboxEnforcement
  48. denialSignatures: readonly string[]
  49. runnerFailureSignatures: readonly string[]
  50. }>()
  51. constructor(ctx: Context, config: Config) {
  52. super(ctx, config)
  53. // The default mode is the capability fact used for schema advertisement;
  54. // actual tool executions carry their resolved per-call policy.
  55. this.mode = ctx.sandboxPolicy.defaultMode
  56. }
  57. /** The configured default mode — the capability fact the tool layer reads. */
  58. override get sandboxMode(): SandboxMode {
  59. return this.mode
  60. }
  61. /**
  62. * Stamp a complete per-call policy onto the spec. Tool calls supply the
  63. * calling session's resolved mode and root; lower-level callers fall back to
  64. * the deployment policy.
  65. */
  66. override resolve(request: BashExecRequest): BashExecSpec {
  67. return { ...super.resolve(request), sandboxPolicy: request.sandboxPolicy ?? this.ctx.sandboxPolicy.resolve() }
  68. }
  69. override async run(spec: BashExecSpec): Promise<BashRunResult> {
  70. const policy = spec.sandboxPolicy as SandboxExecutionPolicy
  71. const { mode } = policy
  72. if (mode === 'danger-full-access') {
  73. const result = await super.run(spec)
  74. return { ...result, sandbox: { mode, denied: false } }
  75. }
  76. const confined = this.confine(spec.command, { ...policy, mode })
  77. const result = await super.run({ ...spec, command: confined.command })
  78. // Runner failure outranks denial because the command did not run. Throw the
  79. // same fail-closed error as confine-time discovery with the first stderr line.
  80. if (classifyRunnerFailure(result, confined.runnerFailureSignatures)) {
  81. throw new SandboxUnavailableError(mode, result.stderr.text.trim().split('\n')[0])
  82. }
  83. return { ...result, sandbox: { mode, denied: classifyDenial(result, confined.denialSignatures), enforcement: confined.enforcement } }
  84. }
  85. override start(spec: BashExecSpec): BashProcess {
  86. const policy = spec.sandboxPolicy as SandboxExecutionPolicy
  87. const { mode } = policy
  88. if (mode === 'danger-full-access') return super.start(spec)
  89. // Install facts synchronously; promise settlement cannot run before start() returns.
  90. const confined = this.confine(spec.command, { ...policy, mode })
  91. const proc = super.start({ ...spec, command: confined.command })
  92. const { enforcement, denialSignatures, runnerFailureSignatures } = confined
  93. this.processFacts.set(proc, { mode, enforcement, denialSignatures, runnerFailureSignatures })
  94. return proc
  95. }
  96. /**
  97. * Stamp per-process sandbox facts before `done` settles. Full-access processes
  98. * have no facts; signal deaths are not denials.
  99. */
  100. protected override onProcessDone(proc: BashProcess, stderr: string): void {
  101. const facts = this.processFacts.get(proc)
  102. if (facts !== undefined) {
  103. this.processFacts.delete(proc)
  104. // Runner failure outranks denial because its diagnostics may contain denial terms.
  105. const runnerFailed = matchesSignature(proc.exitCode, stderr, facts.runnerFailureSignatures)
  106. proc.sandbox = {
  107. mode: facts.mode,
  108. denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures),
  109. enforcement: facts.enforcement,
  110. ...(runnerFailed ? { runnerFailed } : {}),
  111. }
  112. }
  113. super.onProcessDone(proc, stderr)
  114. }
  115. /**
  116. * Wrap one shell command via the `ctx.sandbox` provider: hand over the
  117. * exact `['bash', '-c', command]` argv this executor would spawn, get back
  118. * the confined argv, and re-assemble it into the `exec …` command string
  119. * the inherited spawn path runs (the outer `bash -c` the subprocess service spawns
  120. * `exec`s into the runner, so no extra shell lingers). Provider errors
  121. * (fail-closed `SANDBOX_UNAVAILABLE`) propagate to the caller unchanged.
  122. */
  123. private confine(command: string, policy: SandboxPolicy): {
  124. command: string
  125. enforcement: SandboxEnforcement
  126. denialSignatures: readonly string[]
  127. runnerFailureSignatures: readonly string[]
  128. } {
  129. const confined = this.ctx.sandbox.confine(['bash', '-c', command], policy)
  130. return {
  131. command: `exec ${confined.argv.map(shellQuote).join(' ')}`,
  132. enforcement: confined.enforcement,
  133. denialSignatures: confined.denialSignatures,
  134. runnerFailureSignatures: confined.runnerFailureSignatures,
  135. }
  136. }
  137. }
  138. export default SandboxBashExecutor