Skip to content

build: run npm dependencies under Hardened JavaScript, pin the host globals and disable install scripts - #1642

Open
juanky201271 wants to merge 7 commits into
devfrom
lavamoat/1-allow-scripts
Open

juanky201271 wants to merge 7 commits into
devfrom
lavamoat/1-allow-scripts

Conversation

@juanky201271

@juanky201271 juanky201271 commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

The LavaMoat arc of #1641 in one PR: phase 1 (allow-scripts), phase 2 (SES lockdown) and phase 3 (pinned host globals). #1646 and #1652 carried phases 2 and 3 and were merged into this branch on 2026-11-10. The rulings of the grilling session of 2026-11-08 are in, the vocabulary is in the "Hardened runtime" section of CONTEXT.md, and ADR zingo-mobile/0021 (zingolabs/zingo-adrs#44) is amended in zingolabs/zingo-adrs#66.

Phase 1: no install scripts

  • .yarnrc sets ignore-scripts true. yarn runs no preinstall, install or postinstall of any dependency.
  • yarn setup is yarn install --frozen-lockfile && yarn allow-scripts. allow-scripts runs only the packages allowed under lavamoat.allowScripts in package.json, plus our own postinstall (patch-package).
  • The CI install steps and the Android and iOS quickstarts use yarn setup. The quickstarts say to run yarn allow-scripts again after a plain yarn add or yarn remove.
  • @lavamoat/preinstall-always-fail is a dev dependency whose one job is to fail the install when the .yarnrc entry is lost.
  • Every package that ships an install script today works without it, and each is false in the allowlist: esbuild (via tsx and vite), unrs-resolver and @parcel/watcher (via jest) take their binaries from optional platform packages. A new dependency with an install script fails allow-scripts until it is added on purpose (yarn allow-scripts auto adds it as false).

Phase 2: lockdown

  • @lavamoat/react-native-lockdown 3.0.0 (ses 2.3.0) is the serializer in metro.config.js. The SES shim for Hermes becomes the first Metro polyfill and repairs the intrinsics. After InitializeCore and before the entry file, hardenIntrinsics() freezes Object, Array, Promise, JSON and Function.prototype. No dependency can patch them once the app runs.
  • babel.config.js ignores ses.cjs and ses-hermes.cjs, and the shim reaches Hermes untouched. The pattern accepts backslashes because Metro hands Babel Windows paths.
  • The lockdown options are the package's. errorTaming and consoleTaming are off, which keeps the release console.log silencing in index.js (Audit Issue K) and complete stack traces. overrideTaming is severe, which keeps assignment to .constructor and .toString on objects that inherit from frozen prototypes.
  • Hermes has no Compartments (Feature Request: local eval for SES (Secure EcmaScript) support meta/hermes#957). This is lockdown only, with no per-package isolation.

Phase 3: pinned host globals

lockdown() freezes the language intrinsics and leaves globalThis writable. fetch, XMLHttpRequest, WebSocket and the timers are React Native globals. After phase 2 alone a dependency can still run global.fetch = evil and read every request. That was one half of the chalk and debug compromise of September 2025.

  • app/utils/pinGlobals.ts defines each host global as a non-configurable accessor on globalThis. The getter returns the value React Native installed. The setter throws a TypeError that names the global, in debug and release alike. The React Native Babel preset emits sloppy-mode modules, where a write to a non-writable data property completes as a silent no-op. The accessor removes that case, the same way SES removes it for the intrinsics under overrideTaming: severe.
  • The pinned set follows one rule: every host global that setUpXHR and setUpTimers install, minus the two renderer-private microtask names. The list holds 25 names, among them File, FileReader, requestIdleCallback and cancelIdleCallback. A test derives the set from those two React Native modules in both bridge modes.
  • The pin reads each lazy getter once before it defines the accessor. The read is on the cold-boot path under a budget of 5 ms on a low-end Android device. The pin reads the own descriptor, leaves an inherited binding alone, and skips its own accessor on a Fast Refresh re-run.
  • app/hardenRuntime.ts is the first import of index.js. InitializeCore has run and hardenIntrinsics() has frozen the language. Nothing else has evaluated.
  • Bindings only. XMLHttpRequest.prototype and WebSocket.prototype stay writable in this PR. Prototype hardening in both builds is the fourth step of the arc and its own PR. It gives up the in-app Network Inspector of the dev menu, which rewrites that prototype when toggled.
  • Strict mode for every module is the fifth step, tracked in build: compile every module in strict mode to make a write to a frozen property throw #1887.

Checked

  • A clean node_modules and yarn install --frozen-lockfile: tsx, esbuild, unrs-resolver and @parcel/watcher all load, and react-native-fs is patched only after yarn allow-scripts.
  • Release bundle for Android built offline (react-native bundle --dev false). The SES shim sits at the top of the bundle with zero Babel helpers in its region. The run order at the end is InitializeCore, then hardenIntrinsics(), then the entry module, whose first require is hardenRuntime. hermesc -O -emit-binary compiles the bundle without errors and LavaMoat reports no anomaly.
  • A scan of every runtime dependency for X.prototype.y = and defineProperty(X.prototype, ...) finds js-base64 (the opt-in extendString(), which the app never calls) and recyclerlistview (its own class). Nothing patches an intrinsic after the freeze.
  • react-native-worklets reassigns setTimeout, requestAnimationFrame and queueMicrotask inside executeOnUIRuntimeSync, on the worklet runtimes, which hold their own globalThis. The React Native runtime copy is untouched.
  • __tests__/pinGlobals.unit.ts holds six tests: a write throws, a write that discards the [[Set]] result throws as a sloppy-mode module's would, a lazy getter resolves once, a second run keeps rejecting writes, an inherited binding stays on its prototype, and the pinned set equals what React Native installs.
  • typecheck, eslint, prettier, depcheck and Jest are green. The CI run on the phase 3 head passed, with the 40 offline device tests green under the pin.
  • The branch is rebased onto dev 68244fc (2026-11-10). The two conflicts were in package.json and yarn.lock, where the @lavamoat entries sit beside dev's @playwright/test bump, and yarn install --frozen-lockfile accepts the lockfile.

Needs a device run

  1. Debug and release on Android and iOS: cold boot, then the paths with the most third-party code, which are the bottom sheets (Reanimated 4 and worklets), gestures, the QR scanner (vision-camera), toast and charts.
  2. Measure pinGlobals() once with a performance.now() pair, debug and release, and report the two numbers here against the 5 ms budget.
  3. A lockdown failure surfaces at startup as TypeError: Cannot assign to read only property <name> naming the module. The fix is to drop the package or to add its shim to getPolyfills before the freeze. A pin failure surfaces as TypeError: <name> is a Pinned Global and rejects writes after the entry file, or as TypeError: Cannot redefine property: <name> when anything calls polyfillGlobal after the entry file.
  4. bench:memory for the startup cost, the Maestro nightly and the visual capture in CI.
  5. The Storybook build (STORYBOOK_ENABLED=true). withStorybook leaves the serializer alone, and one run confirms it.

Known limits

  • Static Hermes (the Hermes V1 of React Native 0.82) is untested upstream. The next React Native bump may have to wait on LavaMoat.
  • The objects behind the networking globals stay writable until the fourth step. A sloppy-mode write that adds a new name to a frozen intrinsic still completes as a silent no-op until the fifth step.

🤖 Generated with Claude Code

@juanky201271 juanky201271 added the dependencies Pull requests that update a dependency file label Oct 15, 2026
@juanky201271
juanky201271 force-pushed the lavamoat/1-allow-scripts branch 2 times, most recently from f96ff10 to 90e317c Compare October 20, 2026 15:46
@zancas
zancas force-pushed the lavamoat/1-allow-scripts branch from e620b5d to 4a5759a Compare November 8, 2026 22:49
@zancas
zancas changed the base branch from feat/lavamoat to dev November 8, 2026 23:33
@zancas
zancas requested a review from dorianvp November 9, 2026 00:00
@zancas zancas changed the title build: disable dependency lifecycle scripts with allow-scripts build: run npm dependencies under Hardened JavaScript, pin the host globals and disable install scripts Nov 10, 2026
juanky201271 and others added 7 commits November 9, 2026 20:48
`yarn` no longer runs any dependency's preinstall/install/postinstall.
`yarn setup` installs with the frozen lockfile and then runs
`allow-scripts`, which executes only the scripts allowed under
lavamoat.allowScripts plus our own postinstall (patch-package).

Every package that currently ships an install script (esbuild,
unrs-resolver, @parcel/watcher) works without it because the prebuilt
binaries arrive as optional dependencies, so the allowlist starts
empty. @lavamoat/preinstall-always-fail makes a lost .yarnrc fail the
install instead of silently re-enabling scripts.

CI and the developer quickstarts use `yarn setup`.

Refs #1641

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The entry landed after skip-missing, which breaks the YAML.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The SES shim for Hermes loads as the first Metro polyfill and repairs the
intrinsics; after InitializeCore and before the entry file,
`hardenIntrinsics()` freezes Object, Array, Promise, JSON, Function
prototypes and the rest, so no dependency can patch them once the app
runs. Prototype pollution and intrinsic hooking from a compromised npm
package stop working.

Hermes has no Compartments, so this is lockdown only: a dependency keeps
direct access to whatever it can reach, it just cannot reach the rest of
the app through the language builtins.

The lockdown options are the package's: console and error taming off, so
the release console silencing in index.js keeps working and stack traces
stay complete. Babel ignores the shim so it reaches Hermes untouched.

Refs #1641

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
lockdown() freezes the language intrinsics but leaves globalThis
writable, and fetch, XMLHttpRequest, WebSocket and the timers are React
Native's globals, not the language's. A dependency could still replace
them. The first import of index.js makes their bindings non-writable and
non-configurable, once InitializeCore has installed them and before any
other module evaluates.

Bindings only: the prototypes behind them stay writable because the
debug network inspector patches them. react-native-worklets reassigns
timers, but on the UI and worklet runtimes, which have their own
globalThis.

Refs #1641

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A plain yarn add re-links node_modules without the lifecycle scripts, so
the patches are gone until allow-scripts runs again. Before
ignore-scripts our postinstall did that on its own.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The React Native Babel preset emits sloppy-mode modules, where a write
to a non-writable data property completes as a silent no-op. The pin now
defines each host global as a non-configurable accessor whose getter
returns the value React Native installed and whose setter throws, in
debug and release alike. SES gives the intrinsics the same form under
severe override taming.

The pinned set follows one rule: every host global that setUpXHR and
setUpTimers install, minus the two renderer-private microtask names.
File, FileReader, requestIdleCallback and cancelIdleCallback join the
list, and a test derives the set from those two modules in both bridge
modes. The pin reads the own descriptor, which leaves an inherited
binding alone and skips its own accessor on a second run.

CONTEXT.md gains the Hardened runtime vocabulary ruled on 2026-11-08.
The notifee uninstall moves to its own branch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The existing write test runs inside a Jest module, where the module mode
decides whether a data pin throws or completes the write as a silent
no-op. The new test writes through Reflect.set, which hands back false on
a non-writable data property in every module mode and calls the setter
on an accessor, and expects the TypeError that setter throws. Against a
data pin the test fails, which is the case the ruling of 2026-11-08 is
about.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@zancas
zancas force-pushed the lavamoat/1-allow-scripts branch from 7f302ba to 6d0792b Compare November 10, 2026 04:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants