Skip to content

feat(graalvm): run the native image under the macOS App Sandbox - #899

Merged
kdroidFilter merged 7 commits into
nucleus-3.0from
feat/graalvm-sandbox
Nov 2, 2026
Merged

kdroidFilter merged 7 commits into
nucleus-3.0from
feat/graalvm-sandbox

Conversation

@kdroidFilter

@kdroidFilter kdroidFilter commented Nov 1, 2026 •

Copy link
Copy Markdown
Collaborator

What

A GraalVM app can now ship a Mac App Store PKG. When a macOS App Store format is configured, the plugin builds a second, sandboxed native image beside the regular one and packages the PKG from it. The regular image (command line, inputs, bundle, DMG/ZIP/Developer ID PKG) is unchanged.

Why a regular image could not run sandboxed

  1. Signal handling: Substrate creates the named POSIX semaphore /cSunMiscSignal-<pid> at start-up; the App Sandbox denies it and the image died before main. The sandboxed image is compiled with -R:-EnableSignalHandling, and TerminationSignals gives SIGINT / SIGTERM / SIGHUP their shutdown hooks back through libdispatch (exit 128 + signal, like HotSpot). The handler is an empty sigaction, not SIG_IGN, so child processes still die on SIGTERM.
  2. user.home: SubstrateVM reads it from the password database, outside the container. GraalVmInitializer follows HOME when APP_SANDBOX_CONTAINER_ID is set.
  3. Native code extracted at run time (JNA, zstd-kmp, compose-webview, tray) is quarantined in the sandbox. The image reuses the JVM store pipeline: libs extracted at build time, signed in Contents/Frameworks, load sites routed to NucleusSandboxLoader.

New tasks

stripGraalvmNativeLibsForSandboxing, stageGraalvmSandboxedJars, extractGraalvmNativeLibsForSandboxing, nativeImageCompileSandboxed, createSandboxedGraalvmNativeDistributable, runSandboxedGraalvmNativeDistributable. Details: docs/internals/graalvm-app-sandbox.md.

Verified (Lab, macOS 27 arm64, GraalVM CE 25.4)

  • Sandboxed Lab launched through LaunchServices: reports sandboxed, checks.json written in the container and reloaded, the 4 extracted libs load from Contents/Frameworks (zstd round trip identical), no file-access denial.
  • SIGTERM / SIGINT / SIGHUP: clean exit 143 / 130 / 129, same as the regular image.
  • packageGraalvmPkg packages the sandboxed bundle; the app taken from the PKG payload behaves the same (single instance included).
  • graalvm-runtime: tests (incl. new TerminationSignalsTest on a -Xrs child JVM), apiCheck, detekt, ktlint.
  • Plugin: detekt/ktlint clean on changed files; :plugin:test locally shows the same 7 host-dependent failures as the base branch (LinuxSigner, RenameMacAppBundle, ElectronBuilderLinuxDesktopEntryConfig), which pass in CI.

Also fixed (found while torturing the sandboxed Lab)

  • Pre-existing: any native image started through a symlink (or, sandboxed, a relative path) died with "Fontconfig head is null"; GraalVmInitializer.execDirOf now canonicalizes the launch path.
  • An explicit -Duser.home is kept inside the sandbox.

Torture run: every Lab probe opened sandboxed without crash; signal storms and start-up signals match the regular image; concurrent launches, kill -9 relaunch and accented paths behave. Details in the doc.

Not verified

  • A PKG signed with a real "3rd Party Mac Developer" identity and installed (no identity on the test machine).
  • Known and unchanged: an FSEvents watch inside the container logs harmless file-read-data denials on its ancestors (notify's WatchRoot flag), independent of GraalVM.

A Mac App Store PKG of a GraalVM app is now packaged from a second,
sandboxed image, built only when such a format is configured:
- third-party native libraries are extracted at build time into
  Contents/Frameworks and their load sites routed to NucleusSandboxLoader,
  reusing the JVM store pipeline's strip/extract tasks;
- the image is compiled without Substrate signal handling, whose named
  POSIX semaphore the sandbox denies;
- GraalVmInitializer wires Frameworks, the shim manifest and JNA for that
  bundle, and points user.home at the container when sandboxed.

The regular image, bundle and packages are unchanged.
The sandboxed image is compiled without Substrate's signal handling, so
SIGINT/SIGTERM/SIGHUP killed it without running the shutdown hooks.
TerminationSignals receives them through libdispatch (kqueue, allowed by
the App Sandbox) and exits like HotSpot: shutdown hooks, then 128 + signal.

The handler is an empty sigaction rather than SIG_IGN, which would be
inherited across exec and make every child process ignore SIGTERM.
…cit user.home

- execDirOf canonicalizes the launch path: an image started through a
  symlink (a CLI link in /usr/local/bin) looked for its fonts and
  libraries next to the link and died with "Fontconfig head is null".
  A relative launch path is resolved against PWD, since SubstrateVM's
  user.dir is not the start directory inside the App Sandbox.
- Inside the sandbox user.home only moves to the container while it still
  holds SubstrateVM's default, so an explicit -Duser.home wins.
- Document the torture run of the sandboxed Lab.
The escape hatch matches dependency JAR names, but the image is compiled
from a single uber JAR: a substring either matched nothing or kept every
native library verbatim, all of them then extracted at run time and
refused by the App Sandbox. Every library is now redirected, with a
warning when the option is set.
execDirOf resolved a relative launch path against PWD, which a launcher
that changes directory without updating it (Python subprocess with cwd=)
leaves stale: the regular image then died with "Fontconfig head is null".
Try user.dir first, then PWD, and keep the one that names the executable.

Also cover hostile inputs (trailing slashes, accented paths, a manifest
that is a directory, installing the signal handlers twice).
… check results

- LabPaths moves a plain file that sits where the data directory should
  be aside (NucleusLab.not-a-dir-<millis>) instead of failing every probe
  with FileAlreadyExistsException at start-up.
- CheckStore applies each change to checks.json as it is on disk, through
  CheckFile, and writes atomically: two Lab processes sharing a container
  no longer erase each other's results.
@kdroidFilter
kdroidFilter merged commit 4633065 into nucleus-3.0 Nov 2, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant