index.ts 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127
  1. /**
  2. * The JavaScript API over the prebuilt `landlock-run` launcher: resolve the
  3. * binary for this host, build its grant argv, and run its functional probe.
  4. *
  5. * This module owns the launcher's CLI contract (`docs/cli-contract.md`) so
  6. * consumers never parse launcher output or spell launcher flags themselves —
  7. * the contract and the binaries version together in one package family,
  8. * which makes probe-parsing drift against the binary structurally
  9. * impossible. Policy stays with the consumer: this package does not know
  10. * what a "sandbox mode" is, only which paths are granted read or write.
  11. *
  12. * Deliberately no environment-variable overrides anywhere in this module:
  13. * which binary confines a process must never be decidable by the ambient
  14. * environment. Test injection is by function parameter.
  15. */
  16. import { spawnSync } from 'node:child_process'
  17. import { createRequire } from 'node:module'
  18. import { dirname, join } from 'node:path'
  19. import { fileURLToPath } from 'node:url'
  20. /** The launcher binary's file name inside each platform package's `bin/`. */
  21. export const LAUNCHER_BIN = 'landlock-run'
  22. /**
  23. * The exit code for every launcher-level failure (usage error, unenforcing
  24. * kernel, unopenable grant root, failed exec). After a successful `exec`, the
  25. * wrapped command may also return 125, so consumers also require a matching
  26. * launcher-owned fatal diagnostic to attribute launcher failure. Part of the
  27. * CLI contract.
  28. */
  29. export const LAUNCHER_FAILURE_EXIT = 125
  30. /**
  31. * The probe's verdict on this host: `full` when the running kernel enforces
  32. * every access the launcher can govern, `partial` when an older Landlock ABI
  33. * governs only a subset (still confined for everything it supports), and
  34. * `unusable` when nothing can be enforced — a kernel without Landlock, a
  35. * disabled LSM, or a missing binary, all indistinguishable on purpose
  36. * because the consumer's answer is the same: do not trust this launcher.
  37. */
  38. export type LandlockEnforcement = 'full' | 'partial' | 'unusable'
  39. /**
  40. * Filesystem grants for one confined run. Everything not granted is denied —
  41. * Landlock rulesets are allow-lists.
  42. */
  43. export interface LauncherGrants {
  44. /** Roots granted read + execute beneath (the launcher's `--ro`). */
  45. readonly readOnly?: readonly string[]
  46. /** Roots granted full filesystem access beneath (the launcher's `--rw`). */
  47. readonly readWrite?: readonly string[]
  48. }
  49. /**
  50. * Path of the launcher binary for this host: resolved from the per-platform
  51. * npm package `@deepseek-ai/node-addon-system-<platform>-<arch>` (npm's
  52. * `os`/`cpu` fields make installers fetch only the matching one). When the
  53. * package is not resolvable — a platform without one, or an install that
  54. * skipped the optional dependency — the returned fallback path points inside
  55. * this package's own `node_modules` and simply never exists. Existence is
  56. * deliberately not checked either way: {@link probe} is the single
  57. * availability signal (a missing binary probes `unusable` the same way an
  58. * unenforcing kernel does).
  59. * @param resolvePackageJson - test hook over `require.resolve` (the default
  60. * covers real installs); receives the platform package's `package.json`
  61. * specifier and returns its absolute path, throwing when unresolvable.
  62. * @returns the absolute launcher path to probe and exec.
  63. */
  64. export function launcherPath(
  65. resolvePackageJson: (specifier: string) => string = createRequire(import.meta.url).resolve,
  66. ): string {
  67. const platformPackage = `@deepseek-ai/node-addon-system-${process.platform}-${process.arch}`
  68. try {
  69. return join(dirname(resolvePackageJson(`${platformPackage}/package.json`)), 'bin', LAUNCHER_BIN)
  70. } catch {
  71. // Unresolvable platform package: no such package exists for this host, or
  72. // it was not installed. Fall back to the path pnpm's layout WOULD use —
  73. // absolute, inside this package's boundary (never cwd-relative: a
  74. // spawnable relative path here would hand cwd control over which binary
  75. // confines), and nonexistent exactly when the package is absent.
  76. return fileURLToPath(new URL(`../node_modules/${platformPackage}/bin/${LAUNCHER_BIN}`, import.meta.url))
  77. }
  78. }
  79. /**
  80. * The launcher grant arguments for one set of filesystem grants — everything
  81. * before the `--` argv separator. A caller spawns
  82. * `[launcherPath(), ...grantArgs(grants), '--', ...command]`; the flag
  83. * spellings stay private to this package.
  84. * @param grants - the read-only and read-write roots to allow.
  85. * @returns the `--ro <path>` / `--rw <path>` argument list, read-only roots
  86. * first, in the caller's order.
  87. */
  88. export function grantArgs(grants: LauncherGrants): string[] {
  89. return [
  90. ...(grants.readOnly ?? []).flatMap(root => ['--ro', root]),
  91. ...(grants.readWrite ?? []).flatMap(root => ['--rw', root]),
  92. ]
  93. }
  94. /**
  95. * Functional probe: `landlock-run --probe` builds and enforces a maximal
  96. * ruleset in a short-lived child and exits 0 only when the running kernel
  97. * actually enforces it — `--version`-style checks would miss a kernel that
  98. * has the syscalls but refuses enforcement. The probe's one report line is
  99. * part of the CLI contract and distinguishes complete from per-ABI-subset
  100. * enforcement; a zero exit without the partial marker reads as `full`. A
  101. * failed or timed-out spawn (missing binary, wrong architecture, unenforcing
  102. * kernel) probes `unusable`. Synchronous by design: consumers run it once
  103. * and cache the verdict.
  104. * @param launcher - the launcher path to probe; defaults to
  105. * {@link launcherPath}'s resolution for this host.
  106. * @param options - `timeoutMs` bounds the probe child (default 2000).
  107. * @returns the enforcement verdict for this host.
  108. */
  109. export function probe(
  110. launcher: string = launcherPath(),
  111. options: { timeoutMs?: number } = {},
  112. ): LandlockEnforcement {
  113. const result = spawnSync(launcher, ['--probe'], {
  114. timeout: options.timeoutMs ?? 2000,
  115. encoding: 'utf8',
  116. stdio: ['ignore', 'pipe', 'ignore'],
  117. })
  118. if (result.status !== 0) return 'unusable'
  119. return /partially enforced/.test(result.stdout) ? 'partial' : 'full'
  120. }