index.ts 8.1 KB

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