partial-landlock.spec.ts 12 KB

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