partial-landlock.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. /**
  2. * Deterministic real-process proofs for runner classification: the real local
  3. * provider and sandbox bash executor exercise direct runner-spawn failures
  4. * and a POSIX fake Landlock launcher that prints its notice before exec.
  5. */
  6. import { mkdtemp, rm, writeFile } from 'node:fs/promises'
  7. import { tmpdir } from 'node:os'
  8. import { join } from 'node:path'
  9. import { afterEach, describe, expect, it } from 'vitest'
  10. import { Context } from 'cordis'
  11. import { LAUNCHER_FAILURE_EXIT } from 'node-addon-landlock-run'
  12. import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
  13. import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
  14. import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
  15. import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
  16. import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
  17. const NOTICE = 'landlock-run: partial enforcement (older Landlock ABI)'
  18. const FATAL_PREFIX = 'landlock-run: '
  19. const FATAL = `${FATAL_PREFIX}landlock ruleset error: Invalid argument`
  20. const contexts: Context[] = []
  21. const tempDirs: string[] = []
  22. afterEach(async () => {
  23. await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
  24. await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
  25. })
  26. /** Write a fake native launcher that reports partial enforcement, then execs or fails. */
  27. async function fakeLauncher(fatalExit?: number): Promise<string> {
  28. const dir = await mkdtemp(join(tmpdir(), 'dsh-partial-landlock-'))
  29. tempDirs.push(dir)
  30. const launcher = join(dir, 'landlock-run')
  31. const fatalBranch = fatalExit === undefined ? '' : `printf '%s\\n' '${FATAL}' >&2\nexit ${fatalExit}\n`
  32. await writeFile(launcher, `#!/bin/sh
  33. while [ "$#" -gt 0 ]; do
  34. case "$1" in
  35. --ro|--rw) shift 2 ;;
  36. --) shift; break ;;
  37. *) printf '%s\\n' '${FATAL_PREFIX}usage error: unexpected fake argument' >&2; exit ${LAUNCHER_FAILURE_EXIT} ;;
  38. esac
  39. done
  40. printf '%s\\n' '${NOTICE}' >&2
  41. ${fatalBranch}exec "$@"
  42. `, { mode: 0o755 })
  43. return launcher
  44. }
  45. async function setup(fatalExit?: number): Promise<SandboxBashExecutor> {
  46. const ctx = new Context()
  47. contexts.push(ctx)
  48. await ctx.plugin(LocalSandboxProvider, {})
  49. const sandbox = ctx.sandbox as LocalSandboxProvider
  50. sandbox.internals = {
  51. platform: 'linux',
  52. probeBwrap: () => false,
  53. probeLandlock: () => 'partial',
  54. landlockLauncher: await fakeLauncher(fatalExit),
  55. }
  56. await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: process.cwd() })
  57. await ctx.plugin(LocalSubprocessService)
  58. await ctx.plugin(SandboxBashExecutor, { cwd: process.cwd(), timeoutMs: 5_000 })
  59. return ctx.bash as SandboxBashExecutor
  60. }
  61. async function setupConfiguredRunner(runner: string): Promise<SandboxBashExecutor> {
  62. const ctx = new Context()
  63. contexts.push(ctx)
  64. await ctx.plugin(LocalSandboxProvider, {
  65. runnerCommand: [runner],
  66. runnerFailureSignatures: ['configured-runner: fatal'],
  67. })
  68. await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: process.cwd() })
  69. await ctx.plugin(LocalSubprocessService)
  70. await ctx.plugin(SandboxBashExecutor, { cwd: process.cwd(), timeoutMs: 5_000 })
  71. return ctx.bash as SandboxBashExecutor
  72. }
  73. describe('partial Landlock runner-failure classification', () => {
  74. it.each(['missing', 'unexecutable', 'missing-interpreter'] as const)('classifies a %s configured runner through the direct spawn error channel', async (kind) => {
  75. const dir = await mkdtemp(join(tmpdir(), 'dsh-unusable-sandbox-runner-'))
  76. tempDirs.push(dir)
  77. const runner = join(dir, `${kind}-runner`)
  78. if (kind === 'unexecutable') await writeFile(runner, '#!/bin/sh\nexit 0\n', { mode: 0o644 })
  79. if (kind === 'missing-interpreter') {
  80. await writeFile(runner, '#!/dsh-definitely-missing-sandbox-interpreter\nexit 0\n', { mode: 0o755 })
  81. }
  82. const bash = await setupConfiguredRunner(runner)
  83. const error = await bash.run(bash.resolve({ command: 'true' })).catch((value: unknown) => value)
  84. expect(error).toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
  85. expect(error).toBeInstanceOf(Error)
  86. expect((error as Error).message).toContain(runner)
  87. const task = bash.start(bash.resolve({ command: 'true' }))
  88. await task.done
  89. expect(task.status).toBe('killed')
  90. expect(task.readOutput().delta).toContain(`spawn failed: Error: spawn ${runner}`)
  91. expect(task.sandbox).toEqual({
  92. mode: 'read-only',
  93. denied: false,
  94. enforcement: 'full',
  95. runnerFailed: true,
  96. })
  97. const accounting = (bash as unknown as { processFacts: Map<unknown, unknown> }).processFacts
  98. expect(accounting.size).toBe(0)
  99. })
  100. it.each(['bare-name', 'relative'] as const)(
  101. 'classifies a %s runner whose shebang interpreter is missing',
  102. async (form) => {
  103. const dir = await mkdtemp(join(tmpdir(), 'dsh-argv-form-sandbox-runner-'))
  104. tempDirs.push(dir)
  105. const filename = 'missing-interpreter-runner'
  106. const runner = form === 'bare-name' ? filename : `./${filename}`
  107. await writeFile(join(dir, filename), '#!/dsh-definitely-missing-sandbox-interpreter\nexit 0\n', { mode: 0o755 })
  108. const bash = await setupConfiguredRunner(runner)
  109. const request = form === 'bare-name'
  110. ? { command: 'true', env: { PATH: dir } }
  111. : { command: 'true', workdir: dir }
  112. const error = await bash.run(bash.resolve(request)).catch((value: unknown) => value)
  113. expect(error).toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
  114. expect(error).toBeInstanceOf(Error)
  115. // Empirically, Darwin and Linux Node 24 preserve the passed bare/relative
  116. // argv[0] in this spawn error rather than resolving it to an absolute path.
  117. expect((error as Error).message).toContain(`spawn ${runner} ENOENT`)
  118. const task = bash.start(bash.resolve(request))
  119. await task.done
  120. expect(task.status).toBe('killed')
  121. expect(task.readOutput().delta).toContain(`spawn failed: Error: spawn ${runner} ENOENT`)
  122. expect(task.sandbox).toEqual({
  123. mode: 'read-only',
  124. denied: false,
  125. enforcement: 'full',
  126. runnerFailed: true,
  127. })
  128. },
  129. )
  130. it('keeps a real malformed executable ordinary across no-shebang spawn behavior', async () => {
  131. const dir = await mkdtemp(join(tmpdir(), 'dsh-malformed-sandbox-runner-'))
  132. tempDirs.push(dir)
  133. const runner = join(dir, 'malformed-runner')
  134. await writeFile(runner, 'not a native executable or shebang script\n', { mode: 0o755 })
  135. const bash = await setupConfiguredRunner(runner)
  136. const request = { command: 'true' }
  137. // Node/libuv may expose execve's ENOEXEC directly (Darwin) or retry a
  138. // no-shebang executable through /bin/sh (Linux). Neither path supplies the
  139. // provenanced ENOENT/EACCES evidence required for runner attribution.
  140. const foreground = await bash.run(bash.resolve(request)).catch((value: unknown) => value)
  141. expect(foreground).not.toBeInstanceOf(SandboxUnavailableError)
  142. if (foreground instanceof Error) {
  143. expect(foreground).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' })
  144. expect((foreground as { path?: unknown }).path).toBeUndefined()
  145. let background: unknown
  146. try {
  147. bash.start(bash.resolve(request))
  148. } catch (error) {
  149. background = error
  150. }
  151. expect(background).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' })
  152. expect((background as { path?: unknown }).path).toBeUndefined()
  153. expect(background).not.toBeInstanceOf(SandboxUnavailableError)
  154. } else {
  155. expect(foreground).toMatchObject({
  156. exitCode: 127,
  157. signal: null,
  158. sandbox: { mode: 'read-only', denied: false, enforcement: 'full' },
  159. })
  160. expect((foreground as { stderr: { text: string } }).stderr.text.length).toBeGreaterThan(0)
  161. const background = bash.start(bash.resolve(request))
  162. await background.done
  163. expect(background.status).toBe('completed')
  164. expect(background.exitCode).toBe(127)
  165. expect(background.signal).toBeNull()
  166. expect(background.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
  167. const output = background.readOutput().delta
  168. expect(output.startsWith('[stderr]\n')).toBe(true)
  169. expect(output.length).toBeGreaterThan('[stderr]\n'.length)
  170. expect(output).not.toContain('spawn failed:')
  171. }
  172. const accounting = (bash as unknown as { processFacts: Map<unknown, unknown> }).processFacts
  173. expect(accounting.size).toBe(0)
  174. })
  175. it.each([0, 1, 2, LAUNCHER_FAILURE_EXIT])(
  176. 'keeps child exit %i ordinary when the partial-enforcement notice is the only runner line',
  177. async (exitCode) => {
  178. const bash = await setup()
  179. const result = await bash.run(bash.resolve({ command: `exit ${exitCode}` }))
  180. expect(result.exitCode).toBe(exitCode)
  181. expect(result.stderr.text).toBe(`${NOTICE}\n`)
  182. expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
  183. },
  184. )
  185. it.each([126, 127])('keeps a successfully launched Landlock child exit %i as an ordinary outcome', async (exitCode) => {
  186. const bash = await setup()
  187. const result = await bash.run(bash.resolve({ command: `exit ${exitCode}` }))
  188. expect(result.exitCode).toBe(exitCode)
  189. expect(result.stderr.text).toBe(`${NOTICE}\n`)
  190. expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
  191. })
  192. it.each([1, 2])('keeps a Landlock fatal line at exit %i as insufficient runner-failure evidence', async (exitCode) => {
  193. const bash = await setup(exitCode)
  194. const result = await bash.run(bash.resolve({ command: 'true' }))
  195. expect(result.exitCode).toBe(exitCode)
  196. expect(result.stderr.text).toBe(`${NOTICE}\n${FATAL}\n`)
  197. expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
  198. })
  199. it('reports the fatal line after the notice as SANDBOX_UNAVAILABLE detail', async () => {
  200. const bash = await setup(LAUNCHER_FAILURE_EXIT)
  201. const error = await bash.run(bash.resolve({ command: 'true' })).catch((value: unknown) => value)
  202. expect(error).toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
  203. expect(error).toBeInstanceOf(Error)
  204. expect((error as Error).message).toContain(`Runner failure: ${FATAL}`)
  205. expect((error as Error).message).not.toContain(NOTICE)
  206. })
  207. it('classifies a notice plus child Permission denied as a denial, not runner failure', async () => {
  208. const bash = await setup()
  209. const result = await bash.run(bash.resolve({ command: 'printf "%s\\n" "child: Permission denied" >&2; exit 1' }))
  210. expect(result.stderr.text).toBe(`${NOTICE}\nchild: Permission denied\n`)
  211. expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' })
  212. })
  213. it('applies the same evidence rule to notice-only background exits', async () => {
  214. const bash = await setup()
  215. for (const command of ['exit 1', 'exit 2', `exit ${LAUNCHER_FAILURE_EXIT}`]) {
  216. const task = bash.start(bash.resolve({ command }))
  217. await task.done
  218. expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
  219. expect(task.readOutput().delta).toContain(NOTICE)
  220. }
  221. })
  222. it('classifies a background notice plus child Permission denied as denial', async () => {
  223. const bash = await setup()
  224. const task = bash.start(bash.resolve({ command: 'printf "%s\\n" "child: Permission denied" >&2; exit 1' }))
  225. await task.done
  226. expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' })
  227. expect(task.readOutput().delta).toContain(NOTICE)
  228. })
  229. it('makes a background fatal line outrank denial text after the notice', async () => {
  230. const bash = await setup(LAUNCHER_FAILURE_EXIT)
  231. const task = bash.start(bash.resolve({ command: 'true' }))
  232. await task.done
  233. expect(task.sandbox).toEqual({
  234. mode: 'read-only',
  235. denied: false,
  236. enforcement: 'partial',
  237. runnerFailed: true,
  238. })
  239. const output = task.readOutput().delta
  240. expect(output).toContain(NOTICE)
  241. expect(output).toContain(FATAL)
  242. })
  243. })