sandbox.spec.ts 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660
  1. /**
  2. * Consumer-side `SandboxBashExecutor` tests. A fake Cordis sandbox service makes wrapping,
  3. * policy hand-off, fail-closed propagation, classification, and fact stamping deterministic;
  4. * real-provider integration lives in `tests/landlock.e2e.ts`. A mode-0555 directory supplies
  5. * the Unix denial signature used by the classifier without requiring a real sandbox runner.
  6. */
  7. import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  8. import { tmpdir } from 'node:os'
  9. import { join, resolve } from 'node:path'
  10. import { describe, expect, it, vi } from 'vitest'
  11. import { Context } from '@deepseek-ai/cordis'
  12. import type { ShellRunResult, CollectedOutput } from '@deepseek-ai/dsh-shell'
  13. import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  14. import { SANDBOX_UNAVAILABLE, SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
  15. import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
  16. import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
  17. import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
  18. import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
  19. import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess'
  20. import { classifyDenial, classifyRunnerFailure, isRunnerSpawnFailure } from '../src/helpers.ts'
  21. import type { Config } from '@deepseek-ai/dsh-bash-sandbox'
  22. const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-sandbox-spec-'))
  23. /** One recorded provider call: the argv handed over and the policy it rode with. */
  24. interface ConfineCall {
  25. argv: string[]
  26. policy: SandboxPolicy
  27. }
  28. /** The Linux file-denial dialects the fake wraps carry — matches the unix-permission denials the tests below produce. */
  29. const UNIX_SIGNATURES = ['read-only file system', 'permission denied'] as const
  30. /** The runner-failure rule the fake wraps carry (a fake-runner: error line marks the sandbox itself failing). */
  31. const RUNNER_FAILURE = [{ fatalSignatures: ['fake-runner: '] }] as const
  32. /** Provider argv[0] forms that all share the caller-owned cwd spawn precondition. */
  33. const RUNNER_FORMS = [
  34. ['absolute', process.execPath],
  35. ['bare', 'node'],
  36. ['relative', './sandbox-runner'],
  37. ] as const
  38. /** A passthrough wrap: the caller's argv unchanged, asserted full — commands run unconfined, deterministically. */
  39. const passthrough = (argv: readonly string[]): ConfinedArgv =>
  40. ({ argv: [...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE })
  41. /**
  42. * Boot a context with a recording fake `ctx.sandbox` (behavior injectable
  43. * per test) and the executor under test on top of it.
  44. */
  45. async function setup(
  46. config: { mode?: SandboxMode; workspaceRoot?: string } & Config = {},
  47. behavior: (argv: readonly string[], policy: SandboxPolicy) => ConfinedArgv = passthrough,
  48. ) {
  49. const { mode, workspaceRoot, ...execConfig } = config
  50. const calls: ConfineCall[] = []
  51. class FakeSandboxProvider extends SandboxProvider {
  52. confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
  53. calls.push({ argv: [...argv], policy })
  54. return behavior(argv, policy)
  55. }
  56. }
  57. const ctx = new Context()
  58. await ctx.plugin(SessionProjectionRegistry)
  59. await ctx.plugin(FakeSandboxProvider)
  60. await ctx.plugin(SandboxPolicyService, {
  61. ...mode !== undefined ? { mode } : {},
  62. ...workspaceRoot !== undefined ? { workspaceRoot } : {},
  63. })
  64. await ctx.plugin(LocalSubprocessRuntime)
  65. ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir }
  66. await ctx.plugin(SandboxBashExecutor, { graceMs: 200, ...execConfig })
  67. const bash = ctx.shell as SandboxBashExecutor
  68. return { ctx, bash, calls }
  69. }
  70. function output(text: string): CollectedOutput {
  71. return { text, truncated: false }
  72. }
  73. function runResult(exitCode: number | null, stderr: string): ShellRunResult {
  74. return { exitCode, signal: null, timedOut: false, aborted: false, timeoutMs: 1000, stdout: output(''), stderr: output(stderr) }
  75. }
  76. function executionPolicy(mode: SandboxMode, workspaceRoot = resolve(process.cwd())): SandboxExecutionPolicy {
  77. return { mode, workspaceRoot }
  78. }
  79. describe('the provider hand-off', () => {
  80. it('hands the provider the exact bash argv and the per-call policy, and runs the returned argv', async () => {
  81. const { bash, calls } = await setup()
  82. const result = await bash.run(bash.resolve({ command: 'echo \'a b\' "c\'d"' }))
  83. expect(result.stdout.text).toBe('a b c\'d\n')
  84. expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
  85. expect(calls).toEqual([{
  86. argv: ['bash', '-c', 'echo \'a b\' "c\'d"'],
  87. policy: { mode: 'read-only', workspaceRoot: resolve(process.cwd()) },
  88. }])
  89. })
  90. it('hands the provider\'s returned argv directly to ctx.subprocess.spawn', async () => {
  91. const returnedArgv = ['env', 'DSH_WRAP=1', 'bash', '-c', 'printf "%s" "$DSH_WRAP"']
  92. const { ctx, bash } = await setup({}, () => ({ argv: returnedArgv, enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE }))
  93. const spawn = vi.spyOn(ctx.subprocess, 'spawn')
  94. const result = await bash.run(bash.resolve({ command: 'printf "%s" "$DSH_WRAP"' }))
  95. expect(result.stdout.text).toBe('1')
  96. expect(spawn).toHaveBeenCalledTimes(1)
  97. expect(spawn.mock.calls[0]?.[0].argv).toEqual(returnedArgv)
  98. expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
  99. })
  100. it('starts a non-Bash runner before the confined inner Bash evaluates BASH_ENV', async () => {
  101. const dir = mkdtempSync(join(tmpdir(), 'dsh-bash-env-order-'))
  102. const hook = join(dir, 'hook.sh')
  103. const order = join(dir, 'order.txt')
  104. writeFileSync(hook, 'printf "hook\\n" >> "$DSH_ORDER_FILE"\n')
  105. const runnerScript = [
  106. 'const { appendFileSync } = require("node:fs");',
  107. 'const { spawnSync } = require("node:child_process");',
  108. 'appendFileSync(process.env.DSH_ORDER_FILE, "runner\\n");',
  109. 'const child = spawnSync(process.argv[1], process.argv.slice(2), { env: process.env, stdio: "inherit" });',
  110. 'process.exit(child.status ?? 125);',
  111. ].join('')
  112. const { bash } = await setup({}, argv => ({
  113. argv: [process.execPath, '-e', runnerScript, ...argv],
  114. enforcement: 'full',
  115. denialSignatures: UNIX_SIGNATURES,
  116. runnerFailureRules: RUNNER_FAILURE,
  117. }))
  118. try {
  119. const result = await bash.run(bash.resolve({
  120. command: 'true',
  121. env: { BASH_ENV: hook },
  122. dshEnv: { DSH_ORDER_FILE: order },
  123. }))
  124. expect(result.exitCode).toBe(0)
  125. expect(readFileSync(order, 'utf8')).toBe('runner\nhook\n')
  126. } finally {
  127. rmSync(dir, { recursive: true, force: true })
  128. }
  129. })
  130. it('workspace-write rides the policy, workspaceRoot falling back to process.cwd() when not configured', async () => {
  131. const { bash, calls } = await setup({ mode: 'workspace-write' })
  132. const result = await bash.run(bash.resolve({ command: 'true' }))
  133. expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
  134. expect(calls[0]?.policy).toEqual({ mode: 'workspace-write', workspaceRoot: resolve(process.cwd()) })
  135. })
  136. it('an explicit workspaceRoot on the policy wins', async () => {
  137. const { calls, bash } = await setup({ mode: 'workspace-write', workspaceRoot: '/ws', cwd: tmpdir() })
  138. await bash.run(bash.resolve({ command: 'true' }))
  139. expect(calls[0]?.policy.workspaceRoot).toBe(resolve('/ws'))
  140. })
  141. it('the provider is consulted per wrap (no caching in the consumer): run and start each hand off', async () => {
  142. const { bash, calls } = await setup()
  143. await bash.run(bash.resolve({ command: 'true' }))
  144. const task = bash.start(bash.resolve({ command: 'true' }))
  145. await task.done
  146. expect(calls).toHaveLength(2)
  147. })
  148. })
  149. describe('fail closed', () => {
  150. it('propagates the provider\'s structured SANDBOX_UNAVAILABLE on run() and start()', async () => {
  151. const { bash } = await setup({}, () => { throw new SandboxUnavailableError('read-only') })
  152. const spec = bash.resolve({ command: 'echo hi' })
  153. await expect(bash.run(spec)).rejects.toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
  154. expect(() => bash.start(spec)).toThrow(SandboxUnavailableError)
  155. })
  156. it('preserves an already-aborted foreground call as cancellation', async () => {
  157. const { bash } = await setup()
  158. const controller = new AbortController()
  159. const reason = new Error('caller cancelled before spawn')
  160. controller.abort(reason)
  161. await expect(bash.run(bash.resolve({ command: 'true', signal: controller.signal }))).rejects.toBe(reason)
  162. })
  163. it.each(RUNNER_FORMS)(
  164. 'keeps an invalid workdir ordinary with the %s provider-runner form',
  165. async (_form, runner) => {
  166. const { bash } = await setup({}, argv => ({
  167. argv: [runner, ...argv],
  168. enforcement: 'full',
  169. denialSignatures: UNIX_SIGNATURES,
  170. runnerFailureRules: RUNNER_FAILURE,
  171. }))
  172. const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
  173. try {
  174. const failure = await bash.run(bash.resolve({ command: 'true', workdir: join(parent, 'missing') }))
  175. .catch((error: unknown) => error)
  176. expect(failure).toMatchObject({ code: 'ENOENT' })
  177. expect(failure).not.toBeInstanceOf(SandboxUnavailableError)
  178. } finally {
  179. rmSync(parent, { recursive: true, force: true })
  180. }
  181. },
  182. )
  183. it('keeps an invalid workdir ordinary when danger-full-access bypasses the provider', async () => {
  184. const { bash } = await setup({ mode: 'danger-full-access' })
  185. const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
  186. try {
  187. const failure = await bash.run(bash.resolve({ command: 'true', workdir: join(parent, 'missing') }))
  188. .catch((error: unknown) => error)
  189. expect(failure).toMatchObject({ code: 'ENOENT' })
  190. expect(failure).not.toBeInstanceOf(SandboxUnavailableError)
  191. } finally {
  192. rmSync(parent, { recursive: true, force: true })
  193. }
  194. })
  195. it('keeps Node-shaped synchronous ENOEXEC ordinary in run() and start()', async () => {
  196. const runner = join(spillDir, 'malformed-runner')
  197. const { ctx, bash } = await setup({}, argv => ({
  198. argv: [runner, ...argv],
  199. enforcement: 'full',
  200. denialSignatures: UNIX_SIGNATURES,
  201. runnerFailureRules: RUNNER_FAILURE,
  202. }))
  203. vi.spyOn(ctx.subprocess, 'spawn').mockImplementation(() => {
  204. throw Object.assign(new Error('spawn ENOEXEC'), { code: 'ENOEXEC', syscall: 'spawn' })
  205. })
  206. const foreground = await bash.run(bash.resolve({ command: 'true' })).catch((error: unknown) => error)
  207. expect(foreground).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' })
  208. expect(foreground).not.toBeInstanceOf(SandboxUnavailableError)
  209. let background: unknown
  210. try {
  211. bash.start(bash.resolve({ command: 'true' }))
  212. } catch (error) {
  213. background = error
  214. }
  215. expect(background).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' })
  216. expect(background).not.toBeInstanceOf(SandboxUnavailableError)
  217. })
  218. it('classifies a synchronous SubprocessRuntime EACCES with the exact runner path', async () => {
  219. const runner = join(spillDir, 'unexecutable-runner')
  220. const { ctx, bash } = await setup({}, argv => ({
  221. argv: [runner, ...argv],
  222. enforcement: 'full',
  223. denialSignatures: UNIX_SIGNATURES,
  224. runnerFailureRules: RUNNER_FAILURE,
  225. }))
  226. // This pins an alternative SubprocessRuntime's synchronous seam, not the
  227. // shipped local behavior.
  228. vi.spyOn(ctx.subprocess, 'spawn').mockImplementation(() => {
  229. throw Object.assign(new Error('spawn EACCES'), { code: 'EACCES', syscall: 'spawn', path: runner })
  230. })
  231. await expect(bash.run(bash.resolve({ command: 'true' })))
  232. .rejects.toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
  233. expect(() => bash.start(bash.resolve({ command: 'true' })))
  234. .toThrow(expect.objectContaining({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE }))
  235. })
  236. it('keeps a synchronous cwd-owned ENOENT as the original start() error', async () => {
  237. const runner = './sandbox-runner'
  238. const { ctx, bash } = await setup({}, argv => ({
  239. argv: [runner, ...argv],
  240. enforcement: 'full',
  241. denialSignatures: UNIX_SIGNATURES,
  242. runnerFailureRules: RUNNER_FAILURE,
  243. }))
  244. const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
  245. const workdir = join(parent, 'missing')
  246. const failure = Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT', syscall: `spawn ${runner}`, path: runner })
  247. vi.spyOn(ctx.subprocess, 'spawn').mockImplementation(() => { throw failure })
  248. try {
  249. let thrown: unknown
  250. try {
  251. bash.start(bash.resolve({ command: 'true', workdir }))
  252. } catch (error) {
  253. thrown = error
  254. }
  255. expect(thrown).toBe(failure)
  256. expect(thrown).not.toBeInstanceOf(SandboxUnavailableError)
  257. } finally {
  258. rmSync(parent, { recursive: true, force: true })
  259. }
  260. })
  261. })
  262. describe('danger-full-access', () => {
  263. it('runs unwrapped: the provider is never consulted, facts carry no enforcement', async () => {
  264. const { bash, calls } = await setup({ mode: 'danger-full-access' })
  265. const result = await bash.run(bash.resolve({ command: 'echo free' }))
  266. expect(result.stdout.text).toBe('free\n')
  267. expect(result.sandbox).toEqual({ mode: 'danger-full-access', denied: false })
  268. expect(calls).toHaveLength(0)
  269. })
  270. it('start() passes through unwrapped and stamps nothing at settle', async () => {
  271. const { bash, calls } = await setup({ mode: 'danger-full-access' })
  272. const task = bash.start(bash.resolve({ command: 'echo free-bg' }))
  273. await task.done
  274. expect(task.sandbox).toBeUndefined()
  275. expect(task.readOutput().delta).toContain('free-bg')
  276. expect(calls).toHaveLength(0)
  277. })
  278. })
  279. describe('per-call sandbox policy (the session and escalation carrier)', () => {
  280. it('exposes the configured default as the capability fact, and resolve() stamps it', async () => {
  281. const { bash } = await setup()
  282. expect(bash.sandboxMode).toBe('read-only')
  283. expect(bash.resolve({ command: 'true' }).sandboxPolicy).toEqual(executionPolicy('read-only'))
  284. })
  285. it('an explicit policy outranks the default at resolve(), and the wrap follows its mode and root', async () => {
  286. const { bash, calls } = await setup()
  287. const explicit = executionPolicy('workspace-write', '/session/project')
  288. expect(bash.resolve({ command: 'true', sandboxPolicy: explicit }).sandboxPolicy).toEqual(explicit)
  289. await bash.run(bash.resolve({ command: 'true', sandboxPolicy: explicit }))
  290. await bash.run(bash.resolve({ command: 'true' }))
  291. expect(calls.map(call => call.policy)).toEqual([explicit, executionPolicy('read-only')])
  292. })
  293. it('an escalated run reports the mode it ACTUALLY ran under', async () => {
  294. const { bash } = await setup()
  295. const result = await bash.run(bash.resolve({ command: 'true', sandboxPolicy: executionPolicy('workspace-write') }))
  296. expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
  297. })
  298. it('escalating to danger-full-access bypasses the provider entirely — the grant, not a probe, is the authority there', async () => {
  299. const { bash, calls } = await setup()
  300. const result = await bash.run(bash.resolve({ command: 'echo free', sandboxPolicy: executionPolicy('danger-full-access') }))
  301. expect(result.stdout.text).toBe('free\n')
  302. expect(result.sandbox).toEqual({ mode: 'danger-full-access', denied: false })
  303. expect(calls).toHaveLength(0)
  304. })
  305. it('overlapping background jobs settle with their OWN modes (an escalated task next to a default one)', async () => {
  306. // With per-call policy, tasks under different modes are in flight at
  307. // once — anything keyed off the configured default would misreport the
  308. // escalated one at its settle stamp.
  309. const { bash } = await setup()
  310. const escalated = bash.start(bash.resolve({ command: 'sleep 0.3; echo "x: Permission denied" >&2; exit 1', sandboxPolicy: executionPolicy('workspace-write') }))
  311. const plain = bash.start(bash.resolve({ command: 'true' }))
  312. await plain.done
  313. await escalated.done
  314. expect(escalated.sandbox).toEqual({ mode: 'workspace-write', denied: true, enforcement: 'full' })
  315. expect(plain.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
  316. })
  317. it('an escalated danger-full-access background job carries no facts (nothing confined it)', async () => {
  318. const { bash, calls } = await setup()
  319. const task = bash.start(bash.resolve({ command: 'echo bg-free', sandboxPolicy: executionPolicy('danger-full-access') }))
  320. await task.done
  321. expect(task.sandbox).toBeUndefined()
  322. expect(task.readOutput().delta).toContain('bg-free')
  323. expect(calls).toHaveLength(0)
  324. })
  325. })
  326. describe('classifyDenial', () => {
  327. it('never classifies a clean exit or a signal kill as a denial', () => {
  328. expect(classifyDenial(runResult(0, 'Permission denied'), UNIX_SIGNATURES)).toBe(false)
  329. expect(classifyDenial(runResult(null, 'Permission denied'), UNIX_SIGNATURES)).toBe(false)
  330. })
  331. it('classifies failed runs by the wrap\'s own dialect, conservatively', () => {
  332. expect(classifyDenial(runResult(1, 'touch: cannot touch /x: Read-only file system'), UNIX_SIGNATURES)).toBe(true)
  333. expect(classifyDenial(runResult(1, 'sh: /x: Permission denied'), UNIX_SIGNATURES)).toBe(true)
  334. // Bare EPERM is not a Linux runner's dialect: mount/kill/ptrace fail with
  335. // it unsandboxed too, and the mode vocabulary governs file effects only —
  336. // claiming a file denial here would tell the model the sandbox blocked
  337. // something it never governed.
  338. expect(classifyDenial(runResult(1, 'mount: Operation not permitted'), UNIX_SIGNATURES)).toBe(false)
  339. expect(classifyDenial(runResult(1, 'No such file or directory'), UNIX_SIGNATURES)).toBe(false)
  340. })
  341. it('matches exactly the active backend\'s dialect: EPERM classifies under Seatbelt, EACCES does not under bwrap', () => {
  342. // The same stderr flips meaning with the backend: under Seatbelt, EPERM
  343. // text IS how the kernel refuses a governed file write; under bwrap's
  344. // EROFS-only dialect, `Permission denied` is ordinary DAC, not the
  345. // sandbox — per-wrap signatures are what keep both classifications honest.
  346. expect(classifyDenial(runResult(1, 'bash: /etc/x: Operation not permitted'), ['operation not permitted'])).toBe(true)
  347. expect(classifyDenial(runResult(1, 'sh: /x: Permission denied'), ['read-only file system'])).toBe(false)
  348. })
  349. })
  350. describe('isRunnerSpawnFailure', () => {
  351. it.each(['EACCES', 'ENOENT'])(
  352. 'attributes executable-class spawn code %s to argv[0] once cwd ambiguity is eliminated',
  353. (code) => {
  354. const runner = join(spillDir, 'runner')
  355. const error = Object.assign(new Error('spawn failed'), { code, syscall: `spawn ${runner}`, path: runner })
  356. expect(isRunnerSpawnFailure(error, runner, process.cwd())).toBe(true)
  357. },
  358. )
  359. it.each(['ENOEXEC', 'ENOTDIR', 'EPERM'])(
  360. 'keeps unproven executable code %s ordinary despite synthetic argv[0] fields',
  361. (code) => {
  362. const runner = join(spillDir, 'runner')
  363. const error = Object.assign(new Error('spawn failed'), { code, syscall: `spawn ${runner}`, path: runner })
  364. expect(isRunnerSpawnFailure(error, runner, process.cwd())).toBe(false)
  365. },
  366. )
  367. it('requires a usable caller cwd before classifying absolute, bare, or relative runners', () => {
  368. const missingWorkdir = join(spillDir, 'missing-workdir')
  369. for (const [, runner] of RUNNER_FORMS) {
  370. const error = Object.assign(new Error('spawn failed'), { code: 'ENOENT', syscall: `spawn ${runner}`, path: runner })
  371. expect(isRunnerSpawnFailure(error, runner, missingWorkdir)).toBe(false)
  372. }
  373. const fileWorkdir = join(spillDir, 'not-a-workdir')
  374. writeFileSync(fileWorkdir, '')
  375. const error = Object.assign(new Error('spawn failed'), { code: 'ENOTDIR', syscall: 'spawn node', path: 'node' })
  376. expect(isRunnerSpawnFailure(error, 'node', fileWorkdir)).toBe(false)
  377. })
  378. it('rejects resource, non-spawn, mismatched-program, and unstructured failures', () => {
  379. const missingRunner = join(spillDir, 'definitely-missing-runner')
  380. const spawnError = (code: unknown, syscall: unknown = `spawn ${missingRunner}`, path: unknown = missingRunner) =>
  381. Object.assign(new Error('spawn failed'), { code, syscall, path })
  382. const spawnErrorWithoutPath = (syscall: string) =>
  383. Object.assign(new Error('spawn failed'), { code: 'ENOENT', syscall })
  384. expect(isRunnerSpawnFailure(spawnError('EMFILE'), missingRunner, process.cwd())).toBe(false)
  385. expect(isRunnerSpawnFailure(spawnError('ENOMEM'), missingRunner, process.cwd())).toBe(false)
  386. expect(isRunnerSpawnFailure(spawnError(2), missingRunner, process.cwd())).toBe(false)
  387. expect(isRunnerSpawnFailure(spawnError('ENOENT', 'open'), missingRunner, process.cwd())).toBe(false)
  388. expect(isRunnerSpawnFailure(spawnError('ENOENT', 1), missingRunner, process.cwd())).toBe(false)
  389. expect(isRunnerSpawnFailure(spawnError('ENOENT', 'spawn', process.execPath), missingRunner, process.cwd())).toBe(false)
  390. expect(isRunnerSpawnFailure(spawnError('ENOENT', 'spawn', 1), missingRunner, process.cwd())).toBe(false)
  391. expect(isRunnerSpawnFailure(spawnError('ENOENT', 'spawn', ''), missingRunner, process.cwd())).toBe(false)
  392. expect(isRunnerSpawnFailure(spawnErrorWithoutPath('spawn'), missingRunner, process.cwd())).toBe(false)
  393. expect(isRunnerSpawnFailure(spawnErrorWithoutPath('spawn other-runner'), missingRunner, process.cwd())).toBe(false)
  394. expect(isRunnerSpawnFailure(undefined, missingRunner, process.cwd())).toBe(false)
  395. expect(isRunnerSpawnFailure(null, missingRunner, process.cwd())).toBe(false)
  396. expect(isRunnerSpawnFailure(spawnError('ENOENT'), undefined, process.cwd())).toBe(false)
  397. })
  398. it('accepts only syscall and error-path facts that identify the exact runner program', () => {
  399. const runner = join(spillDir, 'runner with spaces')
  400. const spawnError = (syscall: string, path?: string) =>
  401. Object.assign(new Error('spawn failed'), { code: 'ENOENT', syscall, path })
  402. expect(isRunnerSpawnFailure(spawnError('spawn', runner), runner, process.cwd())).toBe(true)
  403. expect(isRunnerSpawnFailure(spawnError(`spawn ${runner}`, runner), runner, process.cwd())).toBe(true)
  404. expect(isRunnerSpawnFailure(spawnError(`spawn ${runner}`), runner, process.cwd())).toBe(true)
  405. expect(isRunnerSpawnFailure(spawnError('spawn other-runner', runner), runner, process.cwd())).toBe(false)
  406. })
  407. })
  408. describe('classifyRunnerFailure', () => {
  409. it('ignores empty and whitespace-only fatal signatures instead of treating exit status or notice text as evidence', () => {
  410. const notice = 'landlock-run: partial enforcement (older Landlock ABI)'
  411. const emptyRule = [{ allowedExitCodes: [125], fatalSignatures: ['', ' ', '\t'] }]
  412. expect(classifyRunnerFailure(125, '', emptyRule)).toBeUndefined()
  413. expect(classifyRunnerFailure(125, notice, emptyRule)).toBeUndefined()
  414. })
  415. it('keeps valid fatal signatures active beside an ignored empty entry', () => {
  416. const notice = 'landlock-run: partial enforcement (older Landlock ABI)'
  417. const fatal = 'landlock-run: ruleset creation failed'
  418. const rules = [{
  419. allowedExitCodes: [125],
  420. fatalSignatures: ['', ' ', 'landlock-run: '],
  421. informationalLines: [notice],
  422. }]
  423. expect(classifyRunnerFailure(125, `${notice}\nchild diagnostic\n${fatal}`, rules)).toEqual({ detail: fatal })
  424. })
  425. it('requires Landlock exit 125 plus a non-notice fatal line and returns that original line', () => {
  426. const notice = 'landlock-run: partial enforcement (older Landlock ABI)'
  427. const rules = [{ allowedExitCodes: [125], fatalSignatures: ['landlock-run: '], informationalLines: [notice] }]
  428. expect(classifyRunnerFailure(1, notice, rules)).toBeUndefined()
  429. expect(classifyRunnerFailure(2, notice, rules)).toBeUndefined()
  430. expect(classifyRunnerFailure(125, notice, rules)).toBeUndefined()
  431. expect(classifyRunnerFailure(125, notice.toUpperCase(), rules)).toBeUndefined()
  432. expect(classifyRunnerFailure(125, `${notice}: extra detail`, rules))
  433. .toEqual({ detail: `${notice}: extra detail` })
  434. expect(classifyRunnerFailure(125, `${notice}\nlandlock-run: exec failed: No such file or directory`, rules))
  435. .toEqual({ detail: 'landlock-run: exec failed: No such file or directory' })
  436. })
  437. it.each([
  438. 'landlock-run: usage error: missing `-- <argv>...` command',
  439. 'landlock-run: landlock is not enforced by this kernel (ABI unsupported or disabled)',
  440. 'landlock-run: cannot open rule path: /gone: No such file or directory',
  441. 'landlock-run: landlock ruleset error: Invalid argument',
  442. 'landlock-run: exec failed: Permission denied',
  443. 'landlock-run: out of memory',
  444. 'landlock-run: future fatal diagnostic',
  445. ])('keeps known and future Landlock fatal diagnostics fail-closed: %s', (fatal) => {
  446. const rules = [{
  447. allowedExitCodes: [125],
  448. fatalSignatures: ['landlock-run: '],
  449. informationalLines: ['landlock-run: partial enforcement (older Landlock ABI)'],
  450. }]
  451. expect(classifyRunnerFailure(125, fatal, rules)).toEqual({ detail: fatal })
  452. })
  453. })
  454. describe('result facts', () => {
  455. it.each([126, 127])('keeps a successfully launched wrapped child exit %i as an ordinary outcome', async (exitCode) => {
  456. const { bash } = await setup({}, argv => ({
  457. argv: ['env', ...argv],
  458. enforcement: 'full',
  459. denialSignatures: UNIX_SIGNATURES,
  460. runnerFailureRules: RUNNER_FAILURE,
  461. }))
  462. const result = await bash.run(bash.resolve({ command: `exit ${exitCode}` }))
  463. expect(result.exitCode).toBe(exitCode)
  464. expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
  465. })
  466. it('reports a real permission failure as a sandbox denial with the mode it ran under', async () => {
  467. const { bash } = await setup()
  468. const lockedDir = join(mkdtempSync(join(tmpdir(), 'dsh-sandbox-denied-')), 'locked')
  469. mkdirSync(lockedDir)
  470. chmodSync(lockedDir, 0o555)
  471. const result = await bash.run(bash.resolve({ command: `echo x > ${lockedDir}/f` }))
  472. expect(result.exitCode).not.toBe(0)
  473. expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
  474. })
  475. it('carries the provider\'s partial-enforcement fact through unchanged', async () => {
  476. const { bash } = await setup({}, argv => ({ argv: [...argv], enforcement: 'partial', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE }))
  477. const result = await bash.run(bash.resolve({ command: 'true' }))
  478. expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
  479. })
  480. })
  481. describe('background sandbox facts', () => {
  482. it.each(RUNNER_FORMS)('keeps an invalid-workdir rejection ordinary for the %s provider-runner form', async (_form, runner) => {
  483. const { bash } = await setup({}, argv => ({
  484. argv: [runner, ...argv],
  485. enforcement: 'full',
  486. denialSignatures: UNIX_SIGNATURES,
  487. runnerFailureRules: RUNNER_FAILURE,
  488. }))
  489. const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
  490. try {
  491. const task = bash.start(bash.resolve({ command: 'true', workdir: join(parent, 'missing') }))
  492. await task.done
  493. expect(task.status).toBe('killed')
  494. expect(task.readOutput().delta).toContain('spawn failed:')
  495. expect(task.sandbox).toEqual({
  496. mode: 'read-only',
  497. denied: false,
  498. enforcement: 'full',
  499. })
  500. const accounting = (bash as unknown as { processFacts: Map<unknown, unknown> }).processFacts
  501. expect(accounting.size).toBe(0)
  502. } finally {
  503. rmSync(parent, { recursive: true, force: true })
  504. }
  505. })
  506. it('does not invent runner evidence when a spawn rejection has no structured reason', async () => {
  507. const { ctx, bash } = await setup()
  508. const emptyReader: SubprocessOutputReader = {
  509. readFrom: () => ({ text: '', nextOffset: 0, lossy: false }),
  510. }
  511. vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue({
  512. pid: -1,
  513. stdin: undefined,
  514. stdout: undefined,
  515. stderr: undefined,
  516. collected: { stdout: emptyReader, stderr: emptyReader },
  517. // Arbitrary subprocess providers can reject without a value; that edge is the point of this test.
  518. // oxlint-disable-next-line typescript/prefer-promise-reject-errors
  519. done: Promise.reject(undefined),
  520. terminate: vi.fn(),
  521. waitForExit: async () => true,
  522. } satisfies SubprocessHandle)
  523. const task = bash.start(bash.resolve({ command: 'true' }))
  524. await task.done
  525. expect(task.readOutput().delta).toContain('spawn failed: undefined')
  526. expect(task.sandbox).toEqual({
  527. mode: 'read-only',
  528. denied: false,
  529. enforcement: 'full',
  530. })
  531. })
  532. it('stamps a settled denial: nonzero exit + permission stderr under a confined mode', async () => {
  533. const { bash } = await setup()
  534. const task = bash.start(bash.resolve({ command: 'echo "x: Permission denied" >&2; exit 1' }))
  535. await task.done
  536. expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
  537. })
  538. it('a foreground runner failure throws the fail-closed error, never a task result', async () => {
  539. // The wrap's runner prefix on a failed run means the SANDBOX broke and
  540. // the command never ran — the late twin of the confine-time throw, with
  541. // the matched fatal stderr line carried as the cause.
  542. const { bash } = await setup()
  543. const run = bash.run(bash.resolve({ command: 'echo "fake-runner: ruleset rejected" >&2; exit 125' }))
  544. await expect(run).rejects.toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
  545. await expect(run).rejects.toThrow('fake-runner: ruleset rejected')
  546. })
  547. it('a foreground runner failure outranks denial: runner error text may contain denial words', async () => {
  548. const { bash } = await setup()
  549. await expect(bash.run(bash.resolve({ command: 'echo "fake-runner: cannot open rule path: /x: Permission denied" >&2; exit 125' })))
  550. .rejects.toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
  551. })
  552. it('a settled background runner failure stamps runnerFailed (no error channel remains), not denied', async () => {
  553. const { bash } = await setup()
  554. const task = bash.start(bash.resolve({ command: 'echo "fake-runner: cannot open rule path: /x: Permission denied" >&2; exit 125' }))
  555. await task.done
  556. expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full', runnerFailed: true })
  557. })
  558. it('overlapping background jobs keep their OWN wrap facts (per-task, not latest-wrap)', async () => {
  559. // Facts belong to each wrap and may vary between calls. The slow task settles after the
  560. // quick task starts; a shared latest-wrap field would classify and stamp it with the wrong
  561. // task's dialect and enforcement.
  562. const wraps: Array<Pick<ConfinedArgv, 'enforcement' | 'denialSignatures'>> = [
  563. { enforcement: 'partial', denialSignatures: ['permission denied'] },
  564. { enforcement: 'full', denialSignatures: ['read-only file system'] },
  565. ]
  566. let call = 0
  567. const { bash } = await setup({}, (argv) => {
  568. const wrap = wraps[Math.min(call++, wraps.length - 1)] as Pick<ConfinedArgv, 'enforcement' | 'denialSignatures'>
  569. return { argv: [...argv], ...wrap, runnerFailureRules: RUNNER_FAILURE }
  570. })
  571. const slow = bash.start(bash.resolve({ command: 'sleep 0.4; echo "x: Permission denied" >&2; exit 1' }))
  572. const quick = bash.start(bash.resolve({ command: 'true' }))
  573. await quick.done
  574. await slow.done
  575. expect(slow.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' })
  576. expect(quick.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
  577. })
  578. it('a signal-killed task is never a denial (null exit code)', async () => {
  579. const { bash } = await setup()
  580. const task = bash.start(bash.resolve({ command: 'echo "Permission denied" >&2; sleep 30' }))
  581. // Let the stderr land before the kill so the classifier sees the
  582. // signature and must still refuse it on the null exit code alone.
  583. await vi.waitFor(() => { expect(task.readOutput().delta).toContain('Permission denied') })
  584. task.kill()
  585. await task.done
  586. expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
  587. })
  588. it('disposal kills wrapped background jobs (inherited HMR safety)', async () => {
  589. const { ctx, bash } = await setup()
  590. const task = bash.start(bash.resolve({ command: 'sleep 30' }))
  591. await ctx.fiber.dispose()
  592. expect(task.status).toBe('killed')
  593. })
  594. })