sandbox.ts 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135
  1. /**
  2. * The sandbox-escalation surface shared by the `write` and `edit` tools: the
  3. * per-call mode stamp, the advertised escalation fields, and the denial-marker
  4. * mapping — all delegating the vocabulary and the fail-closed approval
  5. * sequence to `@deepseek-ai/dsh-sandbox` (the same pieces `@deepseek-ai/dsh-tool-bash`
  6. * uses), so bash and fs escalate identically. Built ONCE per plugin from
  7. * `ctx.fs.sandboxMode` (the capability fact — is a confining backend mounted?)
  8. * and shared by both mutating tools.
  9. *
  10. * @module @deepseek-ai/dsh-tool-fs/sandbox
  11. */
  12. import type { Context } from 'cordis'
  13. import type { ToolExecution } from '@deepseek-ai/dsh-tools'
  14. import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
  15. import { ESCALATION_TARGETS, approveEscalation, escalationHintMarker, sandboxDenialMarker, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox'
  16. import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
  17. import { FsError } from '@deepseek-ai/dsh-fs'
  18. /** The two escalation arguments a mutating tool may carry (advertised only under a confining backend). */
  19. export interface FsEscalationArgs {
  20. sandbox_permissions?: string
  21. justification?: string
  22. }
  23. /** The schema fields for the escalation arguments, spread into a tool's `parameters` when a confining backend is mounted. */
  24. export interface EscalationSchemaFields {
  25. sandbox_permissions: { type: 'string'; enum: string[]; description: string }
  26. justification: { type: 'string'; description: string }
  27. }
  28. /**
  29. * The filesystem escalation surface: advertisement gating, per-call mode
  30. * stamping (folding the session's `sandbox/mode` override), the one-approved
  31. * wider retry, and denial-marker mapping. A pure product of `ctx` at plugin
  32. * apply time.
  33. */
  34. export class FsSandboxSurface {
  35. /** The escalation targets this composition advertises (`[]` when no confining backend is mounted). */
  36. readonly escalationModes: readonly SandboxMode[]
  37. /** The backend's default mode, or `undefined` when `ctx.fs` does not confine. */
  38. private readonly defaultMode: SandboxMode | undefined
  39. constructor(private readonly ctx: Context) {
  40. this.defaultMode = ctx.fs.sandboxMode
  41. this.escalationModes = this.defaultMode === undefined ? [] : ESCALATION_TARGETS
  42. }
  43. /**
  44. * The escalation schema fields for a mutating tool's `parameters`. Call it
  45. * only under a confining backend (guard on {@link escalationModes}); the
  46. * enum pins the closed target vocabulary, the strict-wider check happens per
  47. * call at execution.
  48. * @returns the two escalation parameter specs.
  49. */
  50. schemaFields(): EscalationSchemaFields {
  51. return {
  52. sandbox_permissions: {
  53. type: 'string',
  54. enum: [...this.escalationModes],
  55. description: 'The wider sandbox mode this file operation needs. Only valid as a one-shot retry '
  56. + 'of an operation the sandbox just denied; requires justification and user approval.',
  57. },
  58. justification: {
  59. type: 'string',
  60. description: 'Required with sandbox_permissions: one sentence for the user explaining '
  61. + 'why this exact file operation needs the wider access.',
  62. },
  63. }
  64. }
  65. /**
  66. * The session's standing mode override for an ordinary (non-escalating)
  67. * call — the `sandbox/mode` fold of the calling agent's log. Undefined for a
  68. * non-confining backend and for agent-less callers.
  69. */
  70. private sessionOverride(exec: ToolExecution): SandboxMode | undefined {
  71. if (this.defaultMode === undefined || exec.agent === undefined) return undefined
  72. return effectiveSandboxMode(exec.agent.session.events)
  73. }
  74. /**
  75. * The mode to STAMP onto this mutation: an approved escalation grant (a
  76. * strictly wider retry resolved through `ctx.approval` before anything
  77. * executes), else the session's standing override, else `undefined` (the
  78. * backend applies its own default). Validates the escalation argument
  79. * pairing first.
  80. * @param toolName - the mutating tool's name, for the approval audit trail.
  81. * @param args - the call's escalation arguments.
  82. * @param exec - the tool-execution context (agent, callId, signal).
  83. * @returns the mode to pass to the mutation, or undefined for the backend default.
  84. */
  85. async stampMode(toolName: string, args: FsEscalationArgs, exec: ToolExecution): Promise<SandboxMode | undefined> {
  86. validateEscalationArgs(args.sandbox_permissions, args.justification)
  87. if (args.sandbox_permissions === undefined || args.justification === undefined) {
  88. return this.sessionOverride(exec)
  89. }
  90. if (this.escalationModes.length === 0) {
  91. throw new Error('sandbox_permissions is not available in this composition (no sandboxing filesystem to escalate)')
  92. }
  93. const effectiveMode = (this.sessionOverride(exec) ?? this.defaultMode) as SandboxMode
  94. return approveEscalation(
  95. { requestedMode: args.sandbox_permissions, justification: args.justification, effectiveMode, subject: 'operation' },
  96. {
  97. approver: this.ctx.get('approval'),
  98. agent: exec.agent,
  99. callId: exec.callId,
  100. toolName,
  101. ...exec.signal ? { signal: exec.signal } : {},
  102. },
  103. )
  104. }
  105. /**
  106. * Map a thrown provider error for the model: a `FS_SANDBOX_DENIED` becomes a
  107. * `FsError` whose text is the shared `[sandbox: …]` denial marker plus the
  108. * same-turn escalation hint, so a policy denial reads identically to bash's
  109. * WHILE keeping the structured `FS_SANDBOX_DENIED` code — `ToolRegistry`
  110. * populates `result.error` only for `HarnessError` instances, so a plain
  111. * `Error` would strip the code retry/observers key off. Any other error
  112. * passes through unchanged. A `FS_SANDBOX_DENIED` only arises under a
  113. * confining backend, which always advertises the escalation fields, so the
  114. * hint always applies here.
  115. * @param error - the error thrown by the mutation.
  116. * @param stampedMode - the mode stamped onto the call (names the mode in the marker).
  117. * @returns the error to throw — the marker `FsError` for a sandbox denial, else the original.
  118. */
  119. mapError(error: unknown, stampedMode: SandboxMode | undefined): unknown {
  120. if (!(error instanceof FsError) || error.code !== 'FS_SANDBOX_DENIED') return error
  121. // A FS_SANDBOX_DENIED only arises under a confining backend, so defaultMode
  122. // (hence the resolved mode) is defined here.
  123. const mode = (stampedMode ?? this.defaultMode) as SandboxMode
  124. return new FsError(`${sandboxDenialMarker(mode)}\n${escalationHintMarker('operation')}`, 'FS_SANDBOX_DENIED', { cause: error })
  125. }
  126. }