sandbox.spec.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. /**
  2. * SandboxBashExecutor tests: the CONSUMER side of the sandbox seam. A fake
  3. * `ctx.sandbox` provider (injected as a real cordis service) makes wrapping,
  4. * policy hand-off, fail-closed propagation, classification, and fact
  5. * stamping all deterministic without any real runner; the real-provider
  6. * integration proof lives in `tests/landlock.e2e.ts`. Denials are produced
  7. * with plain unix permissions (a 0555 directory), which exercises the same
  8. * stderr signature the classifier keys on.
  9. */
  10. import { chmodSync, mkdirSync, mkdtempSync } from 'node:fs'
  11. import { tmpdir } from 'node:os'
  12. import { join, resolve } from 'node:path'
  13. import { describe, expect, it, vi } from 'vitest'
  14. import { Context } from 'cordis'
  15. import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
  16. import { SANDBOX_UNAVAILABLE, SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
  17. import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
  18. import { classifyDenial, classifyRunnerFailure, SandboxBashExecutor, shellQuote } from '@deepseek-ai/dsh-bash-sandbox'
  19. import type { Config } from '@deepseek-ai/dsh-bash-sandbox'
  20. const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-sandbox-spec-'))
  21. /** One recorded provider call: the argv handed over and the policy it rode with. */
  22. interface ConfineCall {
  23. argv: string[]
  24. policy: SandboxPolicy
  25. }
  26. /** The Linux file-denial dialects the fake wraps carry — matches the unix-permission denials the tests below produce. */
  27. const UNIX_SIGNATURES = ['read-only file system', 'permission denied'] as const
  28. /** The runner-failure prefix the fake wraps carry (a fake-runner: error line marks the sandbox itself failing). */
  29. const RUNNER_FAILURE = ['fake-runner: '] as const
  30. /** A passthrough wrap: the caller's argv unchanged, asserted full — commands run unconfined, deterministically. */
  31. const passthrough = (argv: readonly string[]): ConfinedArgv =>
  32. ({ argv: [...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE })
  33. /**
  34. * Boot a context with a recording fake `ctx.sandbox` (behavior injectable
  35. * per test) and the executor under test on top of it.
  36. */
  37. async function setup(config: Config = {}, behavior: (argv: readonly string[], policy: SandboxPolicy) => ConfinedArgv = passthrough) {
  38. const calls: ConfineCall[] = []
  39. class FakeSandboxProvider extends SandboxProvider {
  40. confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
  41. calls.push({ argv: [...argv], policy })
  42. return behavior(argv, policy)
  43. }
  44. }
  45. const ctx = new Context()
  46. await ctx.plugin(FakeSandboxProvider)
  47. await ctx.plugin(SandboxBashExecutor, { graceMs: 200, ...config })
  48. const bash = ctx.bash as SandboxBashExecutor
  49. bash.internals = { spillDir }
  50. return { ctx, bash, calls }
  51. }
  52. function output(text: string): CollectedOutput {
  53. return { text, truncated: false }
  54. }
  55. function runResult(exitCode: number | null, stderr: string): BashRunResult {
  56. return { exitCode, signal: null, timedOut: false, aborted: false, timeoutMs: 1000, stdout: output(''), stderr: output(stderr) }
  57. }
  58. describe('the provider hand-off', () => {
  59. it('hands the provider the exact bash argv and the per-call policy, and runs the returned argv', async () => {
  60. const { bash, calls } = await setup()
  61. const result = await bash.run(bash.resolve({ command: 'echo \'a b\' "c\'d"' }))
  62. expect(result.stdout.text).toBe('a b c\'d\n')
  63. expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
  64. expect(calls).toEqual([{
  65. argv: ['bash', '-c', 'echo \'a b\' "c\'d"'],
  66. policy: { mode: 'read-only', workspaceRoot: resolve(process.cwd()) },
  67. }])
  68. })
  69. it('a wrapped argv from the provider is what actually spawns (prefix survives, quoting round-trips)', async () => {
  70. // The fake wraps with `env MARKER=...` — a real (if tiny) runner prefix:
  71. // the sentinel only prints if the executor spawned the WRAPPED argv.
  72. const { bash } = await setup({}, argv => ({ argv: ['env', 'DSH_WRAP=1', ...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE }))
  73. const result = await bash.run(bash.resolve({ command: 'printf "%s" "$DSH_WRAP"' }))
  74. expect(result.stdout.text).toBe('1')
  75. expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
  76. })
  77. it('workspace-write rides the policy, workspaceRoot falling back to cwd when not configured', async () => {
  78. const { bash, calls } = await setup({ mode: 'workspace-write', cwd: tmpdir() })
  79. const result = await bash.run(bash.resolve({ command: 'true' }))
  80. expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
  81. expect(calls[0]?.policy).toEqual({ mode: 'workspace-write', workspaceRoot: resolve(tmpdir()) })
  82. })
  83. it('an explicit workspaceRoot wins over cwd', async () => {
  84. const { calls, bash } = await setup({ mode: 'workspace-write', workspaceRoot: '/ws', cwd: tmpdir() })
  85. await bash.run(bash.resolve({ command: 'true' }))
  86. expect(calls[0]?.policy.workspaceRoot).toBe(resolve('/ws'))
  87. })
  88. it('the provider is consulted per wrap (no caching in the consumer): run and start each hand off', async () => {
  89. const { bash, calls } = await setup()
  90. await bash.run(bash.resolve({ command: 'true' }))
  91. const task = bash.start(bash.resolve({ command: 'true' }))
  92. await task.done
  93. expect(calls).toHaveLength(2)
  94. })
  95. it('shellQuote survives embedded single quotes (the argv re-assembly primitive)', () => {
  96. expect(shellQuote('a\'b')).toBe(String.raw`'a'\''b'`)
  97. })
  98. })
  99. describe('fail closed', () => {
  100. it('propagates the provider\'s structured SANDBOX_UNAVAILABLE on run() and start()', async () => {
  101. const { bash } = await setup({}, () => { throw new SandboxUnavailableError('read-only') })
  102. const spec = bash.resolve({ command: 'echo hi' })
  103. await expect(bash.run(spec)).rejects.toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
  104. expect(() => bash.start(spec)).toThrow(SandboxUnavailableError)
  105. })
  106. })
  107. describe('danger-full-access', () => {
  108. it('runs unwrapped: the provider is never consulted, facts carry no enforcement', async () => {
  109. const { bash, calls } = await setup({ mode: 'danger-full-access' })
  110. const result = await bash.run(bash.resolve({ command: 'echo free' }))
  111. expect(result.stdout.text).toBe('free\n')
  112. expect(result.sandbox).toEqual({ mode: 'danger-full-access', denied: false })
  113. expect(calls).toHaveLength(0)
  114. })
  115. it('start() passes through unwrapped and stamps nothing at settle', async () => {
  116. const { bash, calls } = await setup({ mode: 'danger-full-access' })
  117. const task = bash.start(bash.resolve({ command: 'echo free-bg' }))
  118. await task.done
  119. expect(task.sandbox).toBeUndefined()
  120. expect(bash.readOutput(task.id).delta).toContain('free-bg')
  121. expect(calls).toHaveLength(0)
  122. })
  123. })
  124. describe('per-call sandboxMode override (the escalation mechanism)', () => {
  125. it('exposes the configured default as the capability fact, and resolve() stamps it', async () => {
  126. const { bash } = await setup()
  127. expect(bash.sandboxMode).toBe('read-only')
  128. expect(bash.resolve({ command: 'true' }).sandboxMode).toBe('read-only')
  129. })
  130. it('an explicit override outranks the default at resolve(), and the wrap policy follows it', async () => {
  131. const { bash, calls } = await setup()
  132. expect(bash.resolve({ command: 'true', sandboxMode: 'workspace-write' }).sandboxMode).toBe('workspace-write')
  133. await bash.run(bash.resolve({ command: 'true', sandboxMode: 'workspace-write' }))
  134. await bash.run(bash.resolve({ command: 'true' }))
  135. expect(calls.map(call => call.policy.mode)).toEqual(['workspace-write', 'read-only'])
  136. })
  137. it('an escalated run reports the mode it ACTUALLY ran under', async () => {
  138. const { bash } = await setup()
  139. const result = await bash.run(bash.resolve({ command: 'true', sandboxMode: 'workspace-write' }))
  140. expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
  141. })
  142. it('escalating to danger-full-access bypasses the provider entirely — the grant, not a probe, is the authority there', async () => {
  143. const { bash, calls } = await setup()
  144. const result = await bash.run(bash.resolve({ command: 'echo free', sandboxMode: 'danger-full-access' }))
  145. expect(result.stdout.text).toBe('free\n')
  146. expect(result.sandbox).toEqual({ mode: 'danger-full-access', denied: false })
  147. expect(calls).toHaveLength(0)
  148. })
  149. it('overlapping background tasks settle with their OWN modes (an escalated task next to a default one)', async () => {
  150. // With per-call policy, tasks under different modes are in flight at
  151. // once — anything keyed off the configured default would misreport the
  152. // escalated one at its settle stamp.
  153. const { bash } = await setup()
  154. const escalated = bash.start(bash.resolve({ command: 'sleep 0.3; echo "x: Permission denied" >&2; exit 1', sandboxMode: 'workspace-write' }))
  155. const plain = bash.start(bash.resolve({ command: 'true' }))
  156. await plain.done
  157. await escalated.done
  158. expect(escalated.sandbox).toEqual({ mode: 'workspace-write', denied: true, enforcement: 'full' })
  159. expect(plain.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
  160. })
  161. it('an escalated danger-full-access background task carries no facts (nothing confined it)', async () => {
  162. const { bash, calls } = await setup()
  163. const task = bash.start(bash.resolve({ command: 'echo bg-free', sandboxMode: 'danger-full-access' }))
  164. await task.done
  165. expect(task.sandbox).toBeUndefined()
  166. expect(bash.readOutput(task.id).delta).toContain('bg-free')
  167. expect(calls).toHaveLength(0)
  168. })
  169. })
  170. describe('classifyDenial', () => {
  171. it('never classifies a clean exit or a signal kill as a denial', () => {
  172. expect(classifyDenial(runResult(0, 'Permission denied'), UNIX_SIGNATURES)).toBe(false)
  173. expect(classifyDenial(runResult(null, 'Permission denied'), UNIX_SIGNATURES)).toBe(false)
  174. })
  175. it('classifies failed runs by the wrap\'s own dialect, conservatively', () => {
  176. expect(classifyDenial(runResult(1, 'touch: cannot touch /x: Read-only file system'), UNIX_SIGNATURES)).toBe(true)
  177. expect(classifyDenial(runResult(1, 'sh: /x: Permission denied'), UNIX_SIGNATURES)).toBe(true)
  178. // Bare EPERM is not a Linux runner's dialect: mount/kill/ptrace fail with
  179. // it unsandboxed too, and the mode vocabulary governs file effects only —
  180. // claiming a file denial here would tell the model the sandbox blocked
  181. // something it never governed.
  182. expect(classifyDenial(runResult(1, 'mount: Operation not permitted'), UNIX_SIGNATURES)).toBe(false)
  183. expect(classifyDenial(runResult(1, 'No such file or directory'), UNIX_SIGNATURES)).toBe(false)
  184. })
  185. it('matches exactly the active backend\'s dialect: EPERM classifies under Seatbelt, EACCES does not under bwrap', () => {
  186. // The same stderr flips meaning with the backend: under Seatbelt, EPERM
  187. // text IS how the kernel refuses a governed file write; under bwrap's
  188. // EROFS-only dialect, `Permission denied` is ordinary DAC, not the
  189. // sandbox — per-wrap signatures are what keep both classifications honest.
  190. expect(classifyDenial(runResult(1, 'bash: /etc/x: Operation not permitted'), ['operation not permitted'])).toBe(true)
  191. expect(classifyDenial(runResult(1, 'sh: /x: Permission denied'), ['read-only file system'])).toBe(false)
  192. })
  193. })
  194. describe('classifyRunnerFailure', () => {
  195. it('matches the dialect case-insensitively on BOTH sides — the seam declares it so, and producers compose signatures from runtime data (an argv0 path, the shell\'s `No such file or directory`)', () => {
  196. const signatures = ['exec: /Opt/Runners/bwrap: not found', '/Opt/Runners/bwrap: No such file or directory']
  197. expect(classifyRunnerFailure(runResult(127, 'bash: /Opt/Runners/bwrap: No such file or directory'), signatures)).toBe(true)
  198. expect(classifyRunnerFailure(runResult(127, 'BASH: LINE 1: EXEC: /OPT/RUNNERS/BWRAP: NOT FOUND'), signatures)).toBe(true)
  199. })
  200. })
  201. describe('result facts', () => {
  202. it('reports a real permission failure as a sandbox denial with the mode it ran under', async () => {
  203. const { bash } = await setup()
  204. const lockedDir = join(mkdtempSync(join(tmpdir(), 'dsh-sandbox-denied-')), 'locked')
  205. mkdirSync(lockedDir)
  206. chmodSync(lockedDir, 0o555)
  207. const result = await bash.run(bash.resolve({ command: `echo x > ${lockedDir}/f` }))
  208. expect(result.exitCode).not.toBe(0)
  209. expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
  210. })
  211. it('carries the provider\'s partial-enforcement fact through unchanged', async () => {
  212. const { bash } = await setup({}, argv => ({ argv: [...argv], enforcement: 'partial', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE }))
  213. const result = await bash.run(bash.resolve({ command: 'true' }))
  214. expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
  215. })
  216. })
  217. describe('background sandbox facts', () => {
  218. it('stamps a settled denial: nonzero exit + permission stderr under a confined mode', async () => {
  219. const { bash } = await setup()
  220. const task = bash.start(bash.resolve({ command: 'echo "x: Permission denied" >&2; exit 1' }))
  221. await task.done
  222. expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
  223. })
  224. it('a foreground runner failure throws the fail-closed error, never a task result', async () => {
  225. // The wrap's runner prefix on a failed run means the SANDBOX broke and
  226. // the command never ran — the late twin of the confine-time throw, with
  227. // the runner's own first stderr line carried as the cause.
  228. const { bash } = await setup()
  229. const run = bash.run(bash.resolve({ command: 'echo "fake-runner: ruleset rejected" >&2; exit 125' }))
  230. await expect(run).rejects.toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
  231. await expect(run).rejects.toThrow('fake-runner: ruleset rejected')
  232. })
  233. it('a foreground runner failure outranks denial: runner error text may contain denial words', async () => {
  234. const { bash } = await setup()
  235. await expect(bash.run(bash.resolve({ command: 'echo "fake-runner: cannot open rule path: /x: Permission denied" >&2; exit 125' })))
  236. .rejects.toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
  237. })
  238. it('a settled background runner failure stamps runnerFailed (no error channel remains), not denied', async () => {
  239. const { bash } = await setup()
  240. const task = bash.start(bash.resolve({ command: 'echo "fake-runner: cannot open rule path: /x: Permission denied" >&2; exit 125' }))
  241. await task.done
  242. expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full', runnerFailed: true })
  243. })
  244. it('completion listeners already see the stamped facts (stamp precedes notify)', async () => {
  245. const { ctx, bash } = await setup({}, argv => ({ argv: [...argv], enforcement: 'partial', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE }))
  246. const seen: unknown[] = []
  247. ctx.bash.onTaskDone((task) => { seen.push(task.sandbox) })
  248. const task = bash.start(bash.resolve({ command: 'echo "x: Permission denied" >&2; exit 1' }))
  249. await task.done
  250. expect(seen).toEqual([{ mode: 'read-only', denied: true, enforcement: 'partial' }])
  251. })
  252. it('overlapping background tasks keep their OWN wrap facts (per-task, not latest-wrap)', async () => {
  253. // The seam returns facts PER WRAP — a legal provider may vary them
  254. // between calls. The slow task settles AFTER the quick one started, so a
  255. // latest-wrap field would classify its denial against the quick task's
  256. // dialect (missing it) and stamp the wrong enforcement.
  257. const wraps: Array<Pick<ConfinedArgv, 'enforcement' | 'denialSignatures'>> = [
  258. { enforcement: 'partial', denialSignatures: ['permission denied'] },
  259. { enforcement: 'full', denialSignatures: ['read-only file system'] },
  260. ]
  261. let call = 0
  262. const { bash } = await setup({}, (argv) => {
  263. const wrap = wraps[Math.min(call++, wraps.length - 1)] as Pick<ConfinedArgv, 'enforcement' | 'denialSignatures'>
  264. return { argv: [...argv], ...wrap, runnerFailureSignatures: RUNNER_FAILURE }
  265. })
  266. const slow = bash.start(bash.resolve({ command: 'sleep 0.4; echo "x: Permission denied" >&2; exit 1' }))
  267. const quick = bash.start(bash.resolve({ command: 'true' }))
  268. await quick.done
  269. await slow.done
  270. expect(slow.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' })
  271. expect(quick.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
  272. })
  273. it('a signal-killed task is never a denial (null exit code)', async () => {
  274. const { bash } = await setup()
  275. const task = bash.start(bash.resolve({ command: 'echo "Permission denied" >&2; sleep 30' }))
  276. // Let the stderr land before the kill so the classifier sees the
  277. // signature and must still refuse it on the null exit code alone.
  278. await vi.waitFor(() => { expect(bash.readOutput(task.id).delta).toContain('Permission denied') })
  279. bash.kill(task.id)
  280. await task.done
  281. expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
  282. })
  283. it('disposal kills wrapped background tasks (inherited HMR safety)', async () => {
  284. const { ctx, bash } = await setup()
  285. const task = bash.start(bash.resolve({ command: 'sleep 30' }))
  286. await ctx.fiber.dispose()
  287. expect(task.status).toBe('killed')
  288. })
  289. })