index.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  1. /**
  2. * `SandboxedFileSystem`: the sandbox-enforcing implementation of the
  3. * `@deepseek-ai/dsh-fs` provider seam. It extends `LocalFileSystem` so all
  4. * text-storage mechanics — resolve, stat, read/stream, list, the atomic
  5. * write and the read-match-write edit critical section — are the local
  6. * implementation's, verbatim; this package adds only the per-call POLICY fence
  7. * on the two mutations. Reads pass through untouched: every mode permits
  8. * reading.
  9. *
  10. * The fence is a policy check in TRUSTED code over a MODEL-CONTROLLED path,
  11. * NOT a kernel boundary — the operations are the seam's own (open, rename),
  12. * and only the target path is untrusted, so canonicalize-then-contain is the
  13. * complete answer to this surface. Kernel-grade isolation of untrusted CODE
  14. * stays `ctx.bash`'s job (`@deepseek-ai/dsh-bash-sandbox`). This mirrors the
  15. * `code-runtime` stance: containment, not a security boundary. The residual
  16. * TOCTOU (an ancestor symlink swapped between the containment re-check and the
  17. * syscall) is narrowed by re-canonicalizing immediately before delegating and
  18. * is accepted for this threat model.
  19. *
  20. * Per-call policy: `read-only` denies every mutation; `workspace-write` allows
  21. * a mutation only when the target canonicalizes under the policy's workspace
  22. * root or a platform temp area (the SAME writable-root set Seatbelt grants,
  23. * derived from the one `writableRoots` function so bash and fs cannot drift);
  24. * `danger-full-access` delegates unfenced. A denial throws the structured
  25. * `FS_SANDBOX_DENIED` — no text inference is needed (unlike bash's kernel
  26. * stderr), because an in-process fence knows exactly what it refused. The
  27. * escalation retry lives in the tool layer (`@deepseek-ai/dsh-tool-fs`),
  28. * exactly as bash's does.
  29. *
  30. * @module @deepseek-ai/dsh-fs-sandbox
  31. */
  32. import { Context } from 'cordis'
  33. import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
  34. import type { Config as LocalConfig } from '@deepseek-ai/dsh-fs-local'
  35. import { FsError } from '@deepseek-ai/dsh-fs'
  36. import type { FsEditOutcome, FsEditRequest, FsTarget, FsVersion, FsWriteIntent, FsWriteOutcome } from '@deepseek-ai/dsh-fs'
  37. import { writableRoots } from '@deepseek-ai/dsh-sandbox'
  38. import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
  39. import type {} from '@deepseek-ai/dsh-sandbox-policy'
  40. import { isPathUnder } from './containment.ts'
  41. /**
  42. * Plugin config: the local backend's knobs, verbatim (only `cwd`, the resolve
  43. * base for relative paths). The sandbox default (mode + `workspace-write`
  44. * fallback root) is NOT here — `ctx.sandboxPolicy` resolves each calling
  45. * session for both enforcing families.
  46. */
  47. export type Config = LocalConfig
  48. /**
  49. * Sandbox-enforcing filesystem backend. Registers as `ctx.fs` (loading it
  50. * INSTEAD OF `dsh-fs-local`, together with a `ctx.sandboxPolicy`, is the whole
  51. * swap — the model-facing tools are untouched). Its configured default mode is
  52. * the capability fact exposed by {@link sandboxMode}; `dsh-tool-fs` resolves
  53. * each session's mode and cwd into a policy for every mutation, while an
  54. * approved escalation may stamp a strictly wider mode for one call.
  55. */
  56. export class SandboxedFileSystem extends LocalFileSystem {
  57. static inject = ['sandboxPolicy']
  58. private readonly defaultMode: SandboxMode
  59. constructor(ctx: Context, config: Config) {
  60. super(ctx, config)
  61. this.defaultMode = ctx.sandboxPolicy.defaultMode
  62. }
  63. /** The deployment default mode — the capability fact the tool layer reads to advertise escalation. */
  64. override get sandboxMode(): SandboxMode {
  65. return this.defaultMode
  66. }
  67. /**
  68. * Fence the write by the per-call policy, then delegate to the inherited
  69. * atomic write. See {@link checkedTarget}.
  70. * @param target - the resolved target to write.
  71. * @param content - the full new file content.
  72. * @param expected - the write intent guarding the write; omit for unconditional.
  73. * @param signal - aborts before the atomic rename takes effect.
  74. * @param sandboxPolicy - the per-call mode and workspace root; omit to use
  75. * the deployment fallback.
  76. * @returns the write outcome from the inherited backend.
  77. */
  78. override async writeText(
  79. target: FsTarget,
  80. content: string,
  81. expected?: FsWriteIntent,
  82. signal?: AbortSignal,
  83. sandboxPolicy?: SandboxExecutionPolicy,
  84. ): Promise<FsWriteOutcome> {
  85. return super.writeText(await this.checkedTarget(target, sandboxPolicy), content, expected, signal)
  86. }
  87. /**
  88. * Fence the edit by the per-call policy, then delegate to the inherited
  89. * atomic edit. See {@link checkedTarget}.
  90. * @param target - the resolved target to edit.
  91. * @param edit - the literal search/replace request.
  92. * @param expected - the version guard; omit for an unconditional edit.
  93. * @param signal - aborts before the atomic rename takes effect.
  94. * @param sandboxPolicy - the per-call mode and workspace root; omit to use
  95. * the deployment fallback.
  96. * @returns the edit outcome from the inherited backend.
  97. */
  98. override async editText(
  99. target: FsTarget,
  100. edit: FsEditRequest,
  101. expected?: { version: FsVersion },
  102. signal?: AbortSignal,
  103. sandboxPolicy?: SandboxExecutionPolicy,
  104. ): Promise<FsEditOutcome> {
  105. return super.editText(await this.checkedTarget(target, sandboxPolicy), edit, expected, signal)
  106. }
  107. /**
  108. * Enforce the per-call policy against `target` and return the EXACT target the
  109. * mutation must use, so the checked identity is the mutated one (no
  110. * check-here-write-there TOCTOU). `read-only` denies; `workspace-write`
  111. * re-canonicalizes NOW (`resolve` realpaths the deepest existing ancestor,
  112. * reflecting a concurrently swapped symlink), requires containment under a
  113. * writable root, and returns THAT fresh target; `danger-full-access` returns
  114. * the caller's target unfenced. Throws the structured `FS_SANDBOX_DENIED` on
  115. * refusal — the tool layer maps it to the model-facing `[sandbox: …]` marker
  116. * and the escalation hint.
  117. */
  118. private async checkedTarget(target: FsTarget, sandboxPolicy?: SandboxExecutionPolicy): Promise<FsTarget> {
  119. const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve()
  120. const { mode } = policy
  121. if (mode === 'danger-full-access') return target
  122. if (mode === 'read-only') {
  123. throw new FsError(`cannot write "${target.displayPath}": file access denied under read-only mode`, 'FS_SANDBOX_DENIED')
  124. }
  125. // workspace-write: containment on the FRESH canonical path (catches a
  126. // symlink ancestor swapped since the tool resolved this target), and the
  127. // mutation delegates with THIS fresh target — never the stale one.
  128. const fresh = await this.resolve(target.displayPath)
  129. let contained = false
  130. for (const root of writableRoots(policy)) {
  131. if (await isPathUnder(fresh.targetKey, root)) {
  132. contained = true
  133. break
  134. }
  135. }
  136. if (!contained) {
  137. throw new FsError(`cannot write "${target.displayPath}": file access denied under workspace-write mode`, 'FS_SANDBOX_DENIED')
  138. }
  139. return fresh
  140. }
  141. }
  142. export default SandboxedFileSystem