chore: adopt node-addon-landlock-run source as native/ subtree
Bring the node-addon-landlock-run tree (tag v0.0.1, commit 614f7fd) into native/landlock-run as its source of record: launcher development happens here, next to the harness consumers, and the standalone repository becomes the release mirror the tree is exported to for packing and publishing (procedure in native/README.md). The subtree keeps its own pnpm workspace and lockfile and is NOT added to the harness workspace: harness installs, gates, and CI never touch it. The mirror's .github/ stays out of the subtree; a separate manually-dispatched workflow (.github/workflows/landlock-run.yml) runs the subtree's CI legs — the per-architecture native builds, real-kernel launcher proofs, and pack rehearsal — adapted with working-directory/cache paths. eslint ignores the subtree like vendor/; AGENTS.md gains the native/ layout line (+5 words on its budget ceiling).
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
# Manually-dispatched CI for the landlock-run source of record
|
||||
# (native/landlock-run). A separate workflow from ci.yml on purpose: the
|
||||
# subtree is a self-contained pnpm workspace with its own gates, exercised on
|
||||
# demand — per-architecture native legs (build + behavioral tests + pack
|
||||
# rehearsal on real kernels) plus one darwin leg proving the documented
|
||||
# degradation on hosts without a platform package. Legs derive from the
|
||||
# subtree's checked-in package matrix (scripts/github-matrix.mjs). Packing
|
||||
# for npm happens in the release mirror (node-addon-landlock-run) after an
|
||||
# export — see native/README.md; this workflow never packs for release.
|
||||
name: Landlock Run
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: native/landlock-run
|
||||
|
||||
jobs:
|
||||
matrix:
|
||||
name: Matrix
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
ci: ${{ steps.matrix.outputs.ci }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- id: matrix
|
||||
run: echo "ci=$(node ./scripts/github-matrix.mjs ci)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
native:
|
||||
name: ${{ matrix.platform }}
|
||||
needs: matrix
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.matrix.outputs.ci) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
package_json_file: native/landlock-run/package.json
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
cache-dependency-path: native/landlock-run/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install musl toolchain
|
||||
run: |
|
||||
sudo apt-get update -q
|
||||
sudo apt-get install -yq musl-tools
|
||||
|
||||
- name: Build TypeScript
|
||||
run: pnpm build:ts
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Build native binaries (this architecture is the builder of record)
|
||||
run: pnpm build:native
|
||||
|
||||
- name: Entry tests (keyless)
|
||||
run: node ./test/entry.test.js
|
||||
|
||||
# NALR_REQUIRE_LANDLOCK: a self-skip on the very platform that exists to
|
||||
# prove enforcement would be a false green, so an unenforcing kernel
|
||||
# fails the leg instead of skipping.
|
||||
- name: Launcher tests (real kernel enforcement)
|
||||
run: node ./test/launcher.test.js
|
||||
env:
|
||||
NALR_REQUIRE_LANDLOCK: 1
|
||||
|
||||
- name: Pack rehearsal (pack → install → confine, this platform only)
|
||||
run: |
|
||||
node ./scripts/pack-release.mjs .release/npm --current-platform-only
|
||||
node ./scripts/verify-packed-install.mjs .release/npm --current-platform-only
|
||||
env:
|
||||
NALR_REQUIRE_LANDLOCK: 1
|
||||
|
||||
darwin:
|
||||
name: darwin (no platform package — degradation proof)
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
package_json_file: native/landlock-run/package.json
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
cache-dependency-path: native/landlock-run/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build TypeScript
|
||||
run: pnpm build:ts
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Entry tests (keyless)
|
||||
run: node ./test/entry.test.js
|
||||
|
||||
- name: Launcher tests (must self-skip cleanly)
|
||||
run: node ./test/launcher.test.js
|
||||
|
||||
- name: Pack rehearsal (entry only — fallback resolution + unusable probe)
|
||||
run: |
|
||||
node ./scripts/pack-release.mjs .release/npm --current-platform-only
|
||||
node ./scripts/verify-packed-install.mjs .release/npm --current-platform-only
|
||||
@@ -30,6 +30,7 @@ packages/ Harness packages at packages/<group>/<pkg>/, all named @deepseek-ai
|
||||
support/ dev/test infrastructure packages
|
||||
util/ zero-dependency utilities
|
||||
python/ Python SDK and bundled runtime (see python/README.md)
|
||||
native/ node-addon-landlock-run source of record (see native/README.md)
|
||||
examples/ Runnable demos: thin cordis.yml leaves over the app packages (see examples/AGENTS.md)
|
||||
docs/ architecture, generated catalogs, RFCs, postmortems, cookbook (see docs/AGENTS.md)
|
||||
scripts/ repo gates and generators
|
||||
|
||||
@@ -13,6 +13,7 @@ export default tseslint.config(
|
||||
'.claude/**', // harness-local state (worktrees, skills) — other checkouts, not this one's sources
|
||||
'**/.doc-typecheck-*/**',
|
||||
'vendor/**', // vendored source keeps upstream style and idioms
|
||||
'native/**', // imported landlock-run subtree: self-contained workspace with its own gates (native/README.md)
|
||||
'**/*.js',
|
||||
'**/*.mjs',
|
||||
'*.config.ts', // root tool configs (vitest, tsdown) — no project service
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# native/
|
||||
|
||||
Source of record for `node-addon-landlock-run`, the Landlock self-restrict-then-exec launcher the harness consumes from npm (`packages/sandbox/sandbox-local`, `packages/bash/bash-sandbox`). Launcher development happens HERE, next to the consumers; the standalone repository is the release mirror that packs and publishes the npm package family.
|
||||
|
||||
## Release mirror
|
||||
|
||||
| Directory | Mirror repo | Last exported release | Commit |
|
||||
|---|---|---|---|
|
||||
| `landlock-run/` | https://github.com/deepseek-harness/node-addon-landlock-run | `v0.0.1` | `614f7fd7dc11e6eaceefba9e7ff1fbe28b51ba22` |
|
||||
|
||||
The subtree is a self-contained pnpm workspace with its own `AGENTS.md`, docs, gates, and lockfile; it is NOT part of the harness workspace (`pnpm-workspace.yaml` does not include it), so harness installs, builds, and CI gates never touch it. The mirror's `.github/` stays out of the subtree — [.github/workflows/landlock-run.yml](../.github/workflows/landlock-run.yml) (manual dispatch) runs the subtree's CI legs here, and a change to those legs is mirrored into the mirror's `ci.yml` at the next export.
|
||||
|
||||
## Export procedure (cutting a release)
|
||||
|
||||
1. Land the launcher change here through a normal harness PR; dispatch the `Landlock Run` workflow and get its legs green.
|
||||
2. In the mirror checkout, replace everything except `.github/`: `git -C <mirror> rm -rq -- . ':!.github'`, then `git -C <harness> archive HEAD:native/landlock-run | tar -x -C <mirror>`, then `git -C <mirror> add -A` and commit.
|
||||
3. In the mirror, follow its release checklist (`docs/release.md`): `pnpm release:commit <version>` → merge → tag `vX.Y.Z` → two-phase `Release` workflow (`publish=false` rehearsal, then `publish=true` from the tag).
|
||||
4. Update the manifest table above with the released tag/commit, and bump the harness consumers' dependency range in the same change.
|
||||
|
||||
The mirror must not diverge: a change committed there directly (hotfix during a release) is ported back here before the next export.
|
||||
@@ -0,0 +1,13 @@
|
||||
# Built native binaries ride npm tarballs via each package's `files` list,
|
||||
# never git. Root-level rules on purpose: a package-nested ignore file would
|
||||
# also steer `pnpm pack` and has silently dropped payload from tarballs before.
|
||||
packages/*/bin/
|
||||
packages/*/lib/
|
||||
|
||||
/.claude/
|
||||
/.release/
|
||||
dist/
|
||||
node_modules/
|
||||
/package-lock.json
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,50 @@
|
||||
# AGENTS.md
|
||||
|
||||
This workspace builds `landlock-run`, a Landlock self-restrict-then-exec launcher: a small, auditable confinement binary distributed as prebuilt per-platform npm packages, plus the thin JS entry package that resolves it and speaks its CLI contract. The source of record is the `deepseek-harness` repository's `native/landlock-run/`; the `node-addon-landlock-run` repository is the release mirror this tree is exported to for packing and publishing (procedure: `native/README.md` in the harness repo). Make changes in the source of record, never only in the mirror.
|
||||
|
||||
## Pre-release stance
|
||||
|
||||
The project is pre-1.0. Prefer the correct public shape over compatibility shims: if a package name, exported field, layout, or contract detail is wrong, rename it and update all references in the same change. Do not add deprecated aliases unless a stable release already needs them.
|
||||
|
||||
## Runtime safety rules
|
||||
|
||||
- Every tool must fail closed. If a ruleset cannot be created or the kernel does not enforce it, exit non-zero WITHOUT exec'ing the wrapped command. Never run unconfined as a fallback.
|
||||
- Runtime binaries and the entry packages take NO environment-variable overrides: which binary confines a process must never be decidable by the ambient environment. Test injection is by function parameter; the `NALR_*` prefix is for build/test orchestration only.
|
||||
- Kernel UAPI is self-defined in the C source (verbatim from the kernel headers), keeping builds independent of toolchain header vintage and making the definitions part of the audit record.
|
||||
- No libraries beyond libc, linked statically against musl. The audit surface of a tool is its C source plus the kernel's stable syscall contract.
|
||||
- The CLI contract of each tool ([docs/cli-contract.md](docs/cli-contract.md)) is the cross-repo compatibility surface: argv grammar, exit codes, and report lines change only with a version bump and a changelog entry, and consumers parse them only through the entry package.
|
||||
- There is deliberately NO install-time build fallback: a host without a matching platform package gets a nonexistent launcher path, the consumer's probe fails, and the consumer falls closed — that degradation is part of the design, not a gap to fill with node-gyp.
|
||||
|
||||
## Repository layout
|
||||
|
||||
```text
|
||||
packages/entry/ Published entry package: JS seam (resolve/probe/grants) + the C source.
|
||||
packages/linux-*/ Published per-platform packages: one prebuilt static binary, no JavaScript.
|
||||
scripts/ Build, matrix derivation, prepack gates, and release orchestration.
|
||||
test/ Plain-node behavioral tests (entry seam + real-kernel launcher proofs).
|
||||
docs/ Architecture, packaging, CLI contract, release, support matrix, naming.
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
pnpm build:ts # entry packages → lib/
|
||||
pnpm build:native # this Linux architecture's binaries (needs musl-tools); fails fast elsewhere
|
||||
pnpm typecheck
|
||||
pnpm test # entry tests everywhere; launcher tests need linux + built binary
|
||||
```
|
||||
|
||||
## Packaging invariants
|
||||
|
||||
- The package matrix is explicit, checked-in metadata: `packages/<name>/package.json` (`os`, `cpu`), `packages/<name>/prebuilds.json` (the binaries that may exist there), and [docs/support-matrix.md](docs/support-matrix.md) stay synchronized when the matrix changes. `scripts/github-matrix.mjs` derives CI and release matrices from it; nothing else enumerates platforms.
|
||||
- Platform package names contain platform only (`-linux-x64`), never tool variants — those stay inside `prebuilds.json`. Static musl linking is why there is no libc suffix: one binary serves glibc and musl distros.
|
||||
- Platform packages ship no JavaScript; the entry package resolves them to file paths. Backends prove themselves at runtime through the functional probe, never through metadata trust.
|
||||
- Builds are native-only: each architecture compiles its own binary on its own runner (CI is the builder of record); no cross toolchain enters the repo.
|
||||
- Every tarball is gated at pack time: platform packages refuse to pack without their declared binaries present, executable, and in the right ELF architecture (`verify-launcher-binary.mjs`), entry packages without built `lib/` (`verify-entry-lib.mjs`), and the release pipeline byte-pins installed binaries against the workspace builds (`verify-packed-install.mjs`).
|
||||
- Platform tarballs are packed with `npm pack`, never `pnpm pack`: pnpm's pack path strips the executable bit (observed on 11.7.0), shipping a launcher no consumer can spawn. `pack-release.mjs` encodes the split; the rehearsal asserts executability of the installed copy so a regression fails loudly instead of masquerading as a non-enforcing kernel.
|
||||
- Generated artifacts stay out of git: `packages/*/bin/`, `packages/*/lib/`, `dist/`, `.release/`, `*.tsbuildinfo`. Ignore rules live in the ROOT `.gitignore` only — a package-nested ignore file can silently drop payload from tarballs.
|
||||
|
||||
## Documentation
|
||||
|
||||
User-facing docs are English. Keep the README focused on install, usage, and support status; durable design decisions belong in docs/ alongside the code, and the current implemented shape belongs in [docs/architecture.md](docs/architecture.md).
|
||||
@@ -0,0 +1,28 @@
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2026, node-addon-landlock-run contributors
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -0,0 +1,58 @@
|
||||
# node-addon-landlock-run
|
||||
|
||||
A [Landlock](https://landlock.io/) self-restrict-then-exec launcher for confining subprocesses on Linux, distributed as prebuilt per-platform npm packages plus a thin JS entry package that resolves the binary and speaks its CLI contract. Built for agent harnesses and other hosts that need to run untrusted commands under a filesystem allow-list without confining themselves.
|
||||
|
||||
The first tool is **`landlock-run`** — a self-restrict-then-exec [Landlock](https://landlock.io/) launcher (~300 lines of C11 over the raw kernel UAPI, statically linked against musl). It installs a Landlock ruleset on itself and `exec`s the wrapped command; the ruleset is inherited across `execve`, so the command and every process it spawns run confined while the invoking process stays unrestricted. Fail-closed: if the kernel cannot enforce, it exits without running the command.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
npm install node-addon-landlock-run
|
||||
```
|
||||
|
||||
Published packages use an entry package plus platform optional packages:
|
||||
|
||||
```text
|
||||
node-addon-landlock-run
|
||||
node-addon-landlock-run-linux-x64
|
||||
node-addon-landlock-run-linux-arm64
|
||||
```
|
||||
|
||||
npm's `os`/`cpu` fields make installers fetch only the matching platform package. There is no install-time build fallback on purpose: on a host without a platform package the resolved path never exists, the probe reports `unusable`, and the consumer falls closed.
|
||||
|
||||
## Usage
|
||||
|
||||
```js
|
||||
import { grantArgs, launcherPath, probe } from 'node-addon-landlock-run';
|
||||
|
||||
const launcher = launcherPath();
|
||||
if (probe(launcher) !== 'unusable') {
|
||||
const argv = [launcher, ...grantArgs({ readOnly: ['/'], readWrite: ['/tmp/work'] }), '--', 'bash', '-c', command];
|
||||
// spawn argv with your process runner of choice
|
||||
}
|
||||
```
|
||||
|
||||
The public API is intentionally small:
|
||||
|
||||
- `launcherPath()`: absolute path of this host's launcher (existence deliberately unchecked — the probe is the availability signal).
|
||||
- `probe(launcher?, { timeoutMs? })`: functional enforcement probe — `'full' | 'partial' | 'unusable'`.
|
||||
- `grantArgs({ readOnly?, readWrite? })`: the launcher's grant argv; everything not granted is denied.
|
||||
- `LAUNCHER_BIN`, `LAUNCHER_FAILURE_EXIT` (125): contract constants.
|
||||
|
||||
The full binary contract (argv grammar, exit codes, report lines) is pinned in [docs/cli-contract.md](docs/cli-contract.md).
|
||||
|
||||
## Support
|
||||
|
||||
linux-x64 and linux-arm64, kernel with Landlock enabled (5.13+; ABI level determines `full` vs `partial` enforcement — see [docs/support-matrix.md](docs/support-matrix.md)). Other platforms deliberately have no package: consumers run different confinement backends there.
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
corepack enable
|
||||
pnpm install
|
||||
pnpm build:ts # entry packages → lib/
|
||||
pnpm build:native # this Linux architecture's binaries (apt-get install musl-tools)
|
||||
pnpm test
|
||||
```
|
||||
|
||||
Binaries are git-ignored and built natively per architecture — locally for your own machine, by CI's per-arch runners as the builders of record. Release flow: [docs/release.md](docs/release.md).
|
||||
@@ -0,0 +1,34 @@
|
||||
# Architecture
|
||||
|
||||
This repository owns confinement *mechanism*, not policy: consumers (agent harnesses, sandbox seams) decide which paths a run may read or write; this package family provides the launcher that enforces those grants and the JS seam that resolves and speaks to it. The packaging follows the per-platform-package model of [`node-addon-require-builtin`](https://www.npmjs.com/package/@esplus/node-addon-require-builtin) (and esbuild), adapted from Node addons to standalone static executables.
|
||||
|
||||
## Two-layer package family
|
||||
|
||||
The family is one entry package plus per-platform binary packages:
|
||||
|
||||
- **Entry package** (`node-addon-landlock-run`): ESM JavaScript. Owns the tool's CLI contract — path resolution (`launcherPath`), the functional probe (`probe`), grant-argv construction (`grantArgs`), and the contract constants. Ships the C source in its tarball for auditability. Lists every platform package as an `optionalDependency`.
|
||||
- **Platform packages** (`node-addon-landlock-run-linux-{x64,arm64}`): one prebuilt static binary under `bin/`, a `prebuilds.json` declaring it, and no JavaScript at all. npm's `os`/`cpu` fields select the matching one at install time; the entry package resolves it to a file path — there is nothing to import.
|
||||
|
||||
Because the contract parser and the binary version together in one family, probe-parsing drift against the binary is structurally impossible — the failure mode the split exists to prevent.
|
||||
|
||||
There is no shared loader package: platform packages have nothing to load. If a second tool ever needs shared JS, extract it then, not preemptively.
|
||||
|
||||
## Resolution and availability
|
||||
|
||||
`launcherPath()` resolves `node-addon-landlock-run-<platform>-<arch>` and returns `<package>/bin/landlock-run`. When the package is not resolvable it returns a deterministic fallback path inside the entry package's own `node_modules` that simply never exists. Existence is deliberately unchecked either way: `probe()` is the single availability signal, and a missing binary probes `unusable` exactly like an unenforcing kernel. Consumers get one degradation path, not two.
|
||||
|
||||
The probe is functional — the launcher builds and enforces a real maximal ruleset in a short-lived child — because version checks would miss a kernel that has the syscalls but refuses enforcement.
|
||||
|
||||
## Fail-closed everywhere
|
||||
|
||||
The launcher exits `125` without exec'ing the command on any launcher-level failure: usage error, unenforcing kernel, unopenable grant root, failed exec. Partial enforcement (an older Landlock ABI governing only a subset of accesses) is accepted, reported on stderr, and surfaced by the probe as `partial` — the consumer decides what its mode vocabulary promises at each level. Neither the binary nor the entry package reads environment variables: which binary confines a process is never decidable by the ambient environment.
|
||||
|
||||
## Build and release model
|
||||
|
||||
Builds are native-only. `scripts/build.ts` compiles the running architecture's binaries with the distro `musl-gcc` (static: no loader or libc expectations on consumers, one binary for glibc and musl distros); CI's per-architecture runners are the builders of record, and no cross toolchain exists in the repo. The audit surface of a tool is its reviewed C source plus CI provenance, enforced by three gates: platform prepack refuses missing/wrong-ELF binaries, entry prepack refuses unbuilt `lib/`, and the release pipeline byte-pins installed binaries against the workspace builds they were packed from.
|
||||
|
||||
The package matrix is checked-in metadata (`prebuilds.json` + `os`/`cpu` fields); `scripts/github-matrix.mjs` derives the CI and Release matrices from it, so adding a platform extends automation without editing workflows.
|
||||
|
||||
## Adding a platform
|
||||
|
||||
A new platform adds one `packages/<platform>/` package (`package.json` with `os`/`cpu`, `prebuilds.json`, README, LICENSE), a runner entry in `scripts/github-matrix.mjs`, and a row in [support-matrix.md](support-matrix.md) — added only together with a native GitHub runner that builds and proves it (the no-cross-toolchain rule). Sibling launchers for other confinement mechanisms belong in their own repositories on this same template, not as second tools here.
|
||||
@@ -0,0 +1,34 @@
|
||||
# CLI contract: landlock-run
|
||||
|
||||
This file pins the launcher's externally observable behavior — the cross-repo compatibility surface between the binaries and every consumer. Consumers interact with it only through the entry package (`launcherPath`/`probe`/`grantArgs`); changing anything below requires a version bump for the whole package family and a note in the release notes.
|
||||
|
||||
## Invocation grammar
|
||||
|
||||
```text
|
||||
landlock-run [--ro <path>]... [--rw <path>]... -- <argv>...
|
||||
landlock-run --probe
|
||||
```
|
||||
|
||||
- `--ro <path>`: grant read + execute beneath `<path>`.
|
||||
- `--rw <path>`: grant full filesystem access beneath `<path>` (every access the negotiated kernel ABI can govern).
|
||||
- Everything not granted is denied — Landlock rulesets are allow-lists.
|
||||
- A grant on a non-directory keeps only its file-compatible access bits (this is how a `--rw /dev/null` grant works).
|
||||
- `--`: mandatory separator; everything after it is the command argv, exec'd via `execvp` with the launcher's environment unchanged.
|
||||
- `--probe`: mutually exclusive with grants and a command.
|
||||
- No other flags, no environment-variable inputs.
|
||||
|
||||
## Exit codes
|
||||
|
||||
- `125` (`LAUNCHER_FAILURE_EXIT`): every launcher-level failure — usage error, kernel that cannot enforce Landlock, unopenable grant root, failed `exec`. The wrapped command was NOT run (fail-closed; the one exception is `exec` itself failing after restriction, which by definition never ran the command either).
|
||||
- Any other status: the wrapped command's own exit status, passed through unchanged.
|
||||
- `--probe`: `0` when the kernel enforces (fully or partially), `125` otherwise.
|
||||
|
||||
## Report lines
|
||||
|
||||
- Probe success prints exactly one stdout line: `landlock: fully enforced` or `landlock: partially enforced (older ABI)`. The entry package's `probe()` maps these to `full`/`partial`; a non-zero probe exit maps to `unusable`.
|
||||
- A confined run under a partial-ABI kernel prints one stderr line `landlock-run: partial enforcement (older Landlock ABI)` and proceeds — still confined for everything the kernel supports.
|
||||
- Every fatal error prints one stderr line prefixed `landlock-run: ` before exiting `125`.
|
||||
|
||||
## Confinement semantics
|
||||
|
||||
The launcher sets `no_new_privs`, installs the ruleset on itself, and `exec`s the command; the ruleset is inherited across `execve`, so every descendant process is equally confined. The ruleset governs the filesystem accesses of the kernel's negotiated Landlock ABI (up to ABI 5); accesses newer than the running ABI are not governed and are the difference between `full` and `partial`.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Naming
|
||||
|
||||
## npm packages
|
||||
|
||||
The public package family is unscoped, using the `node-addon-landlock-run` package prefix; platform packages append platform information only:
|
||||
|
||||
```text
|
||||
node-addon-landlock-run
|
||||
node-addon-landlock-run-<platform>
|
||||
```
|
||||
|
||||
Platform suffixes carry no libc component (binaries are static musl) and no variant component — variants stay inside `prebuilds.json` and binary filenames.
|
||||
|
||||
## Binaries
|
||||
|
||||
The launcher executable is `landlock-run`, shipped at `bin/landlock-run` inside each platform package.
|
||||
|
||||
## Environment variables
|
||||
|
||||
The `NALR_` prefix (Node Addon Landlock Run) is reserved for build/test orchestration:
|
||||
|
||||
```text
|
||||
NALR_REQUIRE_LANDLOCK test-only: an unenforcing kernel fails instead of skipping
|
||||
```
|
||||
|
||||
Runtime binaries and entry packages read NO environment variables — a runtime safety rule ([AGENTS.md](../AGENTS.md)), not a naming convention. Do not include the npm scope in environment variable names.
|
||||
|
||||
## C symbols
|
||||
|
||||
The launcher is a single C file with static linkage; there is no exported symbol namespace. Kernel UAPI constants keep their kernel names prefixed `LL_` where locally defined.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Packaging
|
||||
|
||||
The package family uses the same broad shape as native packages such as esbuild: one JS entry package plus platform optional packages. Unlike Node addons there is no ABI or backend dimension — each platform package carries exactly the static executables its `prebuilds.json` declares.
|
||||
|
||||
## Published packages
|
||||
|
||||
```text
|
||||
node-addon-landlock-run
|
||||
node-addon-landlock-run-linux-x64
|
||||
node-addon-landlock-run-linux-arm64
|
||||
```
|
||||
|
||||
Unsupported platforms are intentionally absent from `optionalDependencies` — see [support-matrix.md](support-matrix.md).
|
||||
|
||||
## Package matrix
|
||||
|
||||
The matrix is explicit in checked-in metadata:
|
||||
|
||||
- `packages/entry/package.json` lists the platform packages as `optionalDependencies`.
|
||||
- `packages/<name>/package.json` declares `os` and `cpu`. There is no `libc` field on purpose: the binaries are statically linked against musl and run on glibc and musl distros alike.
|
||||
- `packages/<name>/prebuilds.json` declares the binaries that may exist in that package (`tool`, `kind`, `path`).
|
||||
- [support-matrix.md](support-matrix.md) explains why unsupported platform packages are not published.
|
||||
|
||||
`scripts/github-matrix.mjs` derives the CI and Release matrices from these files. `scripts/build.ts` builds only the current host's targets, into `packages/<name>/bin/`; it is not a matrix generator. When changing the matrix, update package metadata, `prebuilds.json`, the lockfile, and the support/release docs in the same change.
|
||||
|
||||
## Runtime selection
|
||||
|
||||
1. npm's `os`/`cpu` fields make installers fetch only the matching platform package.
|
||||
2. The entry package's `launcherPath()` resolves it to `<package>/bin/landlock-run`; unresolvable packages yield a deterministic, never-existing fallback path.
|
||||
3. `probe()` is the single availability signal: missing binary and unenforcing kernel are deliberately indistinguishable (`unusable`), so consumers have one fail-closed path.
|
||||
|
||||
## No install fallback
|
||||
|
||||
The entry package has NO install script and never compiles on the consumer host. A compile fallback would require a musl toolchain everywhere and turn a clean fail-closed degradation into an environment-dependent maybe. The packed-manifest check in `verify-packed-install.mjs` enforces the absence of install lifecycle scripts.
|
||||
|
||||
## Pack gates
|
||||
|
||||
Platform tarballs are produced by `npm pack`, entry tarballs by `pnpm pack` — deliberately split: `pnpm pack` (observed on 11.7.0) normalizes file modes and strips the executable bit, which would ship a launcher no consumer can spawn, while platform packages have no dependencies and so need none of pnpm's workspace-protocol conversion; entry packages need that conversion and carry no executables. `scripts/pack-release.mjs` encodes the split — never hand-pack a platform package with pnpm.
|
||||
|
||||
Both pack paths produce the exact publish bytes behind a `prepack` gate:
|
||||
|
||||
- Platform packages: `scripts/verify-launcher-binary.mjs` — every declared binary present, executable, ELF `e_machine` matching the declared `cpu`, nothing undeclared in `bin/`.
|
||||
- Entry packages: `scripts/verify-entry-lib.mjs` — built `lib/` present.
|
||||
|
||||
`scripts/verify-packed-install.mjs` then rehearses the consumer path from the packed tarballs: payload checks, a throwaway install, a byte-pin of the installed binary against the workspace build, an executability check on the installed copy, and a real confinement world-proof through the installed launcher. A non-executable or missing binary fails loudly here instead of masquerading as a non-enforcing kernel.
|
||||
@@ -0,0 +1,57 @@
|
||||
# Release
|
||||
|
||||
Pre-1.0: treat this as a release checklist, not a stability policy.
|
||||
|
||||
## Versioning
|
||||
|
||||
One version across every package in the repo. Use the bump helper:
|
||||
|
||||
```sh
|
||||
pnpm release:bump patch # or minor / major / x.y.z
|
||||
```
|
||||
|
||||
It updates the root and every `packages/*` manifest, refreshes the lockfile (`--ignore-scripts --lockfile-only`), and runs `release:verify`. Explicit versions accept full semver including prereleases (`pnpm release:bump 0.0.0-test.0`); the publish workflow puts prerelease versions under the `next` dist-tag, so `latest` never points at a test build. Keep `workspace:*` dependencies in source; pnpm converts them to concrete versions during pack.
|
||||
|
||||
Version bumps are normal source changes: open a release PR (or commit) with the manifests and lockfile, merge it, then create the matching `vX.Y.Z` tag from that commit. The publish workflow validates that the tag matches every package version.
|
||||
|
||||
```sh
|
||||
pnpm release:commit patch # bump + stage + commit in one command
|
||||
git tag v0.0.2
|
||||
```
|
||||
|
||||
## Preflight
|
||||
|
||||
```sh
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build:ts
|
||||
pnpm typecheck
|
||||
pnpm test # launcher half needs a Linux host with the binary built
|
||||
```
|
||||
|
||||
On a Linux host, also rehearse the pack path locally:
|
||||
|
||||
```sh
|
||||
pnpm build:native
|
||||
node ./scripts/pack-release.mjs .release/npm --current-platform-only
|
||||
node ./scripts/verify-packed-install.mjs .release/npm --current-platform-only
|
||||
```
|
||||
|
||||
## Publish
|
||||
|
||||
Use the `Release` workflow so every binary is built on its matching native runner:
|
||||
|
||||
1. Run it with `publish=false` (from the release commit) to build all platform binaries, assemble and verify the payloads, pack the tarballs in publish order, rehearse the packed install, and upload the `npm-tarballs` artifact for inspection.
|
||||
2. Create and push the `vX.Y.Z` tag matching the package versions.
|
||||
3. Run the same workflow from that tag with `publish=true`.
|
||||
|
||||
The workflow publishes only from the final packed tarballs, in `publish-order.txt` order (platform packages before the entry that optionally depends on them). It supports npm trusted publishing through GitHub OIDC; without it, provide an `NPM_TOKEN` secret in the `npm-publish` environment. Packages publish with `--access public`.
|
||||
|
||||
Manual local fallback (current platform's packages only) — always through `pack-release.mjs`, never `pnpm publish` directly (pnpm's pack path strips the launcher's executable bit; see [packaging.md](packaging.md)):
|
||||
|
||||
```sh
|
||||
node ./scripts/pack-release.mjs dist/npm --current-platform-only
|
||||
node ./scripts/verify-packed-install.mjs dist/npm --current-platform-only
|
||||
while IFS= read -r tarball; do npm publish "dist/npm/${tarball}" --access public; done < dist/npm/publish-order.txt
|
||||
```
|
||||
|
||||
Do not commit `.npmrc` files with tokens or registry overrides.
|
||||
@@ -0,0 +1,18 @@
|
||||
# Support matrix
|
||||
|
||||
## Supported
|
||||
|
||||
| Platform package | GitHub runner (builder of record) | Notes |
|
||||
|---|---|---|
|
||||
| `node-addon-landlock-run-linux-x64` | `ubuntu-24.04` | static musl — glibc and musl distros alike |
|
||||
| `node-addon-landlock-run-linux-arm64` | `ubuntu-24.04-arm` | static musl — glibc and musl distros alike |
|
||||
|
||||
Enforcement additionally requires a kernel with Landlock enabled (5.13+). The negotiated ABI level decides the probe verdict: every access this build knows governed → `full`; an older ABI governing a subset → `partial` (still confined for everything it supports); Landlock absent or disabled → `unusable`, and the launcher refuses to run commands at all. The probe — not the kernel version — is the authority: a kernel built without Landlock, or with the LSM disabled, probes `unusable` regardless of its version.
|
||||
|
||||
## Deliberately unsupported
|
||||
|
||||
- **darwin**: macOS consumers typically confine through `sandbox-exec`/Seatbelt, which ships with the OS — there is no binary to distribute.
|
||||
- **win32**: a Windows confinement launcher would be a different mechanism in its own repository, not a port of this one.
|
||||
- **Other Linux architectures** (riscv64, s390x, …): no native CI builder of record yet. The no-cross-toolchain rule means a platform package is added only together with a native runner that builds and proves it.
|
||||
|
||||
A consumer on an unsupported platform resolves a nonexistent launcher path, probes `unusable`, and falls closed — the documented degradation, exercised by CI's darwin leg.
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "node-addon-landlock-run-workspace",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "BSD-3-Clause",
|
||||
"packageManager": "pnpm@11.7.0",
|
||||
"scripts": {
|
||||
"build": "pnpm build:ts",
|
||||
"build:ts": "tsc -b",
|
||||
"build:native": "tsx ./scripts/build.ts",
|
||||
"typecheck": "tsc --noEmit && tsc -b --dry",
|
||||
"test": "node ./test/entry.test.js && node ./test/launcher.test.js",
|
||||
"test:entry": "node ./test/entry.test.js",
|
||||
"test:launcher": "node ./test/launcher.test.js",
|
||||
"gha:matrix": "node ./scripts/github-matrix.mjs",
|
||||
"release:bump": "node ./scripts/bump-release.mjs",
|
||||
"release:commit": "node ./scripts/commit-release.mjs",
|
||||
"release:assemble-prebuilds": "node ./scripts/assemble-prebuilds.mjs",
|
||||
"release:verify": "node ./scripts/verify-release.mjs",
|
||||
"release:pack": "node ./scripts/pack-release.mjs",
|
||||
"release:verify-packed-install": "node ./scripts/verify-packed-install.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"node-addon-landlock-run": "workspace:*",
|
||||
"@types/node": "^24.10.0",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
# node-addon-landlock-run
|
||||
|
||||
Landlock self-restrict-then-exec launcher for confining subprocesses on Linux: this entry package resolves the per-platform prebuilt binary, runs its functional enforcement probe, and builds its grant argv — consumers never spell launcher flags or parse launcher output themselves.
|
||||
|
||||
```js
|
||||
import { grantArgs, launcherPath, probe } from 'node-addon-landlock-run';
|
||||
|
||||
const launcher = launcherPath();
|
||||
if (probe(launcher) !== 'unusable') {
|
||||
const argv = [launcher, ...grantArgs({ readOnly: ['/'], readWrite: ['/tmp/work'] }), '--', 'bash', '-c', command];
|
||||
}
|
||||
```
|
||||
|
||||
The launcher installs a Landlock ruleset on itself and `exec`s the wrapped command; the ruleset is inherited across `execve`, so the whole process tree runs confined. Everything not granted is denied, and launcher failures exit `125` without running the command — fail-closed, never fail-open. The binary contract is pinned in the repo's `docs/cli-contract.md`; the C source rides this tarball (`src/main.c`) for audit.
|
||||
|
||||
Platform packages (`os`/`cpu`-selected optional dependencies, no JavaScript inside): `node-addon-landlock-run-linux-x64`, `node-addon-landlock-run-linux-arm64`. On hosts without one, `launcherPath()` returns a deterministic nonexistent path and `probe()` reports `'unusable'` — there is deliberately no install-time compile fallback.
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "node-addon-landlock-run",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"description": "Landlock self-restrict-then-exec launcher for sandboxing subprocesses on Linux: per-platform prebuilt static binaries plus the JS seam that resolves, probes, and speaks their CLI contract",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"README.md",
|
||||
"lib/",
|
||||
"!lib/*.tsbuildinfo",
|
||||
"src/main.c"
|
||||
],
|
||||
"scripts": {
|
||||
"build:js": "tsc -b",
|
||||
"prepack": "node ../../scripts/verify-entry-lib.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"node-addon-landlock-run-linux-arm64": "workspace:*",
|
||||
"node-addon-landlock-run-linux-x64": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* The JS seam over the prebuilt `landlock-run` launcher: resolve the
|
||||
* binary for this host, build its grant argv, and run its functional probe.
|
||||
*
|
||||
* This module owns the launcher's CLI contract (`docs/cli-contract.md`) so
|
||||
* consumers never parse launcher output or spell launcher flags themselves —
|
||||
* the contract and the binaries version together in one package family,
|
||||
* which makes probe-parsing drift against the binary structurally
|
||||
* impossible. Policy stays with the consumer: this package does not know
|
||||
* what a "sandbox mode" is, only which paths are granted read or write.
|
||||
*
|
||||
* Deliberately no environment-variable overrides anywhere in this module:
|
||||
* which binary confines a process must never be decidable by the ambient
|
||||
* environment. Test injection is by function parameter.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { createRequire } from 'node:module'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
/** The launcher binary's file name inside each platform package's `bin/`. */
|
||||
export const LAUNCHER_BIN = 'landlock-run'
|
||||
|
||||
/**
|
||||
* The exit code for every launcher-level failure (usage error, unenforcing
|
||||
* kernel, unopenable grant root, failed exec) — chosen because the wrapped
|
||||
* command itself is unlikely to use it, so a consumer can tell launcher
|
||||
* failures from command failures. Part of the CLI contract.
|
||||
*/
|
||||
export const LAUNCHER_FAILURE_EXIT = 125
|
||||
|
||||
/**
|
||||
* The probe's verdict on this host: `full` when the running kernel enforces
|
||||
* every access the launcher can govern, `partial` when an older Landlock ABI
|
||||
* governs only a subset (still confined for everything it supports), and
|
||||
* `unusable` when nothing can be enforced — a kernel without Landlock, a
|
||||
* disabled LSM, or a missing binary, all indistinguishable on purpose
|
||||
* because the consumer's answer is the same: do not trust this launcher.
|
||||
*/
|
||||
export type LandlockEnforcement = 'full' | 'partial' | 'unusable'
|
||||
|
||||
/**
|
||||
* Filesystem grants for one confined run. Everything not granted is denied —
|
||||
* Landlock rulesets are allow-lists.
|
||||
*/
|
||||
export interface LauncherGrants {
|
||||
/** Roots granted read + execute beneath (the launcher's `--ro`). */
|
||||
readonly readOnly?: readonly string[]
|
||||
/** Roots granted full filesystem access beneath (the launcher's `--rw`). */
|
||||
readonly readWrite?: readonly string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Path of the launcher binary for this host: resolved from the per-platform
|
||||
* npm package `node-addon-landlock-run-<platform>-<arch>` (npm's
|
||||
* `os`/`cpu` fields make installers fetch only the matching one). When the
|
||||
* package is not resolvable — a platform without one, or an install that
|
||||
* skipped the optional dependency — the returned fallback path points inside
|
||||
* this package's own `node_modules` and simply never exists. Existence is
|
||||
* deliberately not checked either way: {@link probe} is the single
|
||||
* availability signal (a missing binary probes `unusable` the same way an
|
||||
* unenforcing kernel does).
|
||||
* @param resolvePackageJson - test seam over `require.resolve` (the default
|
||||
* covers real installs); receives the platform package's `package.json`
|
||||
* specifier and returns its absolute path, throwing when unresolvable.
|
||||
* @returns the absolute launcher path to probe and exec.
|
||||
*/
|
||||
export function launcherPath(
|
||||
resolvePackageJson: (specifier: string) => string = createRequire(import.meta.url).resolve,
|
||||
): string {
|
||||
const platformPackage = `node-addon-landlock-run-${process.platform}-${process.arch}`
|
||||
try {
|
||||
return join(dirname(resolvePackageJson(`${platformPackage}/package.json`)), 'bin', LAUNCHER_BIN)
|
||||
} catch {
|
||||
// Unresolvable platform package: no such package exists for this host, or
|
||||
// it was not installed. Fall back to the path pnpm's layout WOULD use —
|
||||
// absolute, inside this package's boundary (never cwd-relative: a
|
||||
// spawnable relative path here would hand cwd control over which binary
|
||||
// confines), and nonexistent exactly when the package is absent.
|
||||
return fileURLToPath(new URL(`../node_modules/${platformPackage}/bin/${LAUNCHER_BIN}`, import.meta.url))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The launcher grant arguments for one set of filesystem grants — everything
|
||||
* before the `--` argv separator. A caller spawns
|
||||
* `[launcherPath(), ...grantArgs(grants), '--', ...command]`; the flag
|
||||
* spellings stay private to this package.
|
||||
* @param grants - the read-only and read-write roots to allow.
|
||||
* @returns the `--ro <path>` / `--rw <path>` argument list, read-only roots
|
||||
* first, in the caller's order.
|
||||
*/
|
||||
export function grantArgs(grants: LauncherGrants): string[] {
|
||||
return [
|
||||
...(grants.readOnly ?? []).flatMap(root => ['--ro', root]),
|
||||
...(grants.readWrite ?? []).flatMap(root => ['--rw', root]),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Functional probe: `landlock-run --probe` builds and enforces a maximal
|
||||
* ruleset in a short-lived child and exits 0 only when the running kernel
|
||||
* actually enforces it — `--version`-style checks would miss a kernel that
|
||||
* has the syscalls but refuses enforcement. The probe's one report line is
|
||||
* part of the CLI contract and distinguishes complete from per-ABI-subset
|
||||
* enforcement; a zero exit without the partial marker reads as `full`. A
|
||||
* failed or timed-out spawn (missing binary, wrong architecture, unenforcing
|
||||
* kernel) probes `unusable`. Synchronous by design: consumers run it once
|
||||
* and cache the verdict.
|
||||
* @param launcher - the launcher path to probe; defaults to
|
||||
* {@link launcherPath}'s resolution for this host.
|
||||
* @param options - `timeoutMs` bounds the probe child (default 2000).
|
||||
* @returns the enforcement verdict for this host.
|
||||
*/
|
||||
export function probe(
|
||||
launcher: string = launcherPath(),
|
||||
options: { timeoutMs?: number } = {},
|
||||
): LandlockEnforcement {
|
||||
const result = spawnSync(launcher, ['--probe'], {
|
||||
timeout: options.timeoutMs ?? 2000,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
})
|
||||
if (result.status !== 0) return 'unusable'
|
||||
return /partially enforced/.test(result.stdout) ? 'partial' : 'full'
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
/*
|
||||
* landlock-run: self-restrict-then-exec Landlock launcher.
|
||||
*
|
||||
* The Landlock rung of a consuming sandbox seam, for Linux hosts where
|
||||
* `bwrap` is
|
||||
* unusable (not installed, unprivileged user namespaces disabled, or an LSM
|
||||
* profile that denies mount — Landlock is an independent syscall family and
|
||||
* needs none of those). The launcher installs a Landlock
|
||||
* ruleset on itself and `exec`s the wrapped command; the ruleset is inherited
|
||||
* across `execve`, so the command (and every process it spawns) runs confined
|
||||
* while the invoking process stays unrestricted.
|
||||
*
|
||||
* CLI contract (mirrors the `bwrap` runner argv shape the executor wraps):
|
||||
*
|
||||
* landlock-run [--ro <path>]... [--rw <path>]... -- <argv>...
|
||||
* landlock-run --probe
|
||||
*
|
||||
* `--ro` grants read+execute beneath the path; `--rw` grants full filesystem
|
||||
* access beneath the path. Everything else is denied (Landlock is an
|
||||
* allow-list). `--probe` builds a maximal ruleset and reports whether the
|
||||
* running kernel actually enforces it — the executor's functional probe.
|
||||
*
|
||||
* Fail-closed: if the ruleset cannot be created or is NOT enforced by the
|
||||
* kernel, the launcher exits non-zero WITHOUT exec'ing the command. A partial
|
||||
* (best-effort) enforcement on an older ABI is accepted and reported on
|
||||
* stderr; the consumer's mode vocabulary keeps its file-effect promises
|
||||
* honest per ABI level (surfaced as `full` vs `partial` by the entry
|
||||
* package's probe).
|
||||
*
|
||||
* Plain C11 over the raw Landlock UAPI — no libraries beyond libc (musl,
|
||||
* linked statically), so the whole audit surface is this file plus the
|
||||
* kernel's stable syscall contract. Built natively per architecture by
|
||||
* `scripts/build.ts` into the per-platform npm packages
|
||||
* (`node-addon-landlock-run-linux-{x64,arm64}`); the argv grammar,
|
||||
* exit codes, and report lines are pinned in `docs/cli-contract.md`.
|
||||
*/
|
||||
|
||||
#define _GNU_SOURCE
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/prctl.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <unistd.h>
|
||||
|
||||
/*
|
||||
* The Landlock UAPI, defined locally instead of via <linux/landlock.h>: the
|
||||
* kernel's user-space ABI is stable by contract, self-defining it keeps the
|
||||
* build independent of the toolchain's header vintage, and the definitions
|
||||
* double as the audit record of exactly which kernel surface this launcher
|
||||
* touches. Layouts and values are verbatim from the kernel header (the
|
||||
* path-beneath struct is packed there, so it must be packed here).
|
||||
*/
|
||||
struct landlock_ruleset_attr {
|
||||
uint64_t handled_access_fs;
|
||||
};
|
||||
|
||||
struct landlock_path_beneath_attr {
|
||||
uint64_t allowed_access;
|
||||
int32_t parent_fd;
|
||||
} __attribute__((packed));
|
||||
|
||||
#define LANDLOCK_CREATE_RULESET_VERSION (1U << 0)
|
||||
#define LANDLOCK_RULE_PATH_BENEATH 1
|
||||
|
||||
/* Filesystem access bits, grouped by the Landlock ABI that introduced them. */
|
||||
#define LL_FS_EXECUTE (UINT64_C(1) << 0) /* ABI 1 */
|
||||
#define LL_FS_WRITE_FILE (UINT64_C(1) << 1)
|
||||
#define LL_FS_READ_FILE (UINT64_C(1) << 2)
|
||||
#define LL_FS_READ_DIR (UINT64_C(1) << 3)
|
||||
#define LL_FS_REMOVE_DIR (UINT64_C(1) << 4)
|
||||
#define LL_FS_REMOVE_FILE (UINT64_C(1) << 5)
|
||||
#define LL_FS_MAKE_CHAR (UINT64_C(1) << 6)
|
||||
#define LL_FS_MAKE_DIR (UINT64_C(1) << 7)
|
||||
#define LL_FS_MAKE_REG (UINT64_C(1) << 8)
|
||||
#define LL_FS_MAKE_SOCK (UINT64_C(1) << 9)
|
||||
#define LL_FS_MAKE_FIFO (UINT64_C(1) << 10)
|
||||
#define LL_FS_MAKE_BLOCK (UINT64_C(1) << 11)
|
||||
#define LL_FS_MAKE_SYM (UINT64_C(1) << 12)
|
||||
#define LL_FS_REFER (UINT64_C(1) << 13) /* ABI 2 */
|
||||
#define LL_FS_TRUNCATE (UINT64_C(1) << 14) /* ABI 3 (ABI 4 added TCP bits only) */
|
||||
#define LL_FS_IOCTL_DEV (UINT64_C(1) << 15) /* ABI 5 */
|
||||
|
||||
#define LL_ABI1_MASK (LL_FS_REFER - 1) /* bits 0..12: every ABI-1 access, nothing newer */
|
||||
|
||||
/*
|
||||
* Newest ABI this build knows; the negotiation below scales the actual
|
||||
* ruleset down to what the running kernel supports (the best-effort compat
|
||||
* stance of the previous Rust launcher, made explicit).
|
||||
*/
|
||||
#define MAX_ABI 5L
|
||||
|
||||
/*
|
||||
* Landlock has no libc wrappers; these are the raw syscalls. The numbers are
|
||||
* identical on every architecture (the post-2011 unified table) — the
|
||||
* fallbacks only matter to a libc older than the feature.
|
||||
*/
|
||||
#ifndef __NR_landlock_create_ruleset
|
||||
#define __NR_landlock_create_ruleset 444
|
||||
#define __NR_landlock_add_rule 445
|
||||
#define __NR_landlock_restrict_self 446
|
||||
#endif
|
||||
|
||||
/*
|
||||
* Every fatal launcher error prints `landlock-run: <message>` to stderr
|
||||
* and exits 125 — a code the wrapped command itself is unlikely to use, so
|
||||
* the executor can tell launcher failures from command failures.
|
||||
*/
|
||||
#define EXIT_LAUNCHER_FAILURE 125
|
||||
|
||||
static const char NOT_ENFORCED_MESSAGE[] =
|
||||
"landlock is not enforced by this kernel (ABI unsupported or disabled)";
|
||||
|
||||
/* Print one fatal `landlock-run: ...` line; returns the fatal exit code. */
|
||||
static int fail(const char *prefix, const char *detail) {
|
||||
if (detail == NULL) {
|
||||
fprintf(stderr, "landlock-run: %s\n", prefix);
|
||||
} else {
|
||||
fprintf(stderr, "landlock-run: %s: %s\n", prefix, detail);
|
||||
}
|
||||
return EXIT_LAUNCHER_FAILURE;
|
||||
}
|
||||
|
||||
static int fail_usage(const char *message, const char *detail) {
|
||||
fprintf(stderr, "landlock-run: usage error: %s%s\n", message, detail == NULL ? "" : detail);
|
||||
return EXIT_LAUNCHER_FAILURE;
|
||||
}
|
||||
|
||||
/* Parsed CLI: either a probe, or grants plus the command argv after `--`. */
|
||||
struct cli {
|
||||
int probe;
|
||||
const char **ro;
|
||||
size_t ro_count;
|
||||
const char **rw;
|
||||
size_t rw_count;
|
||||
char **command; /* NULL-terminated tail of main's argv */
|
||||
};
|
||||
|
||||
/*
|
||||
* Hand-rolled argv parsing — four flags do not justify a parsing library,
|
||||
* and the previous Rust launcher made the same call for the same reason.
|
||||
* Returns 0 on success, else the process exit code (message already printed).
|
||||
*/
|
||||
static int parse(int argc, char **argv, struct cli *cli) {
|
||||
/* argc bounds each grant list; the launcher execs or exits, so no free. */
|
||||
cli->ro = calloc(argc > 0 ? (size_t)argc : 1, sizeof *cli->ro);
|
||||
cli->rw = calloc(argc > 0 ? (size_t)argc : 1, sizeof *cli->rw);
|
||||
if (cli->ro == NULL || cli->rw == NULL) return fail("out of memory", NULL);
|
||||
|
||||
int index = 1;
|
||||
while (index < argc) {
|
||||
const char *arg = argv[index];
|
||||
if (strcmp(arg, "--probe") == 0) {
|
||||
cli->probe = 1;
|
||||
index += 1;
|
||||
} else if (strcmp(arg, "--ro") == 0 || strcmp(arg, "--rw") == 0) {
|
||||
if (index + 1 >= argc) {
|
||||
return fail_usage(arg, " requires a path");
|
||||
}
|
||||
if (strcmp(arg, "--ro") == 0) {
|
||||
cli->ro[cli->ro_count++] = argv[index + 1];
|
||||
} else {
|
||||
cli->rw[cli->rw_count++] = argv[index + 1];
|
||||
}
|
||||
index += 2;
|
||||
} else if (strcmp(arg, "--") == 0) {
|
||||
cli->command = &argv[index + 1];
|
||||
break;
|
||||
} else {
|
||||
return fail_usage("unknown argument: ", arg);
|
||||
}
|
||||
}
|
||||
if (cli->probe) {
|
||||
if (cli->ro_count > 0 || cli->rw_count > 0 || (cli->command != NULL && cli->command[0] != NULL)) {
|
||||
return fail_usage("--probe takes no other arguments", NULL);
|
||||
}
|
||||
} else if (cli->command == NULL || cli->command[0] == NULL) {
|
||||
return fail_usage("missing `-- <argv>...` command", NULL);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* The filesystem accesses the running kernel's ABI can govern. */
|
||||
static uint64_t fs_mask_for_abi(long abi) {
|
||||
uint64_t mask = LL_ABI1_MASK;
|
||||
if (abi >= 2) mask |= LL_FS_REFER;
|
||||
if (abi >= 3) mask |= LL_FS_TRUNCATE;
|
||||
if (abi >= 5) mask |= LL_FS_IOCTL_DEV;
|
||||
return mask;
|
||||
}
|
||||
|
||||
/* Add one path-beneath rule; 0 on success, else the exit code. */
|
||||
static int add_rule(int ruleset_fd, const char *path, uint64_t access) {
|
||||
int path_fd = open(path, O_PATH | O_CLOEXEC);
|
||||
if (path_fd < 0) {
|
||||
/* Fail closed on an unopenable grant root: silently narrowing the
|
||||
* granted set would be safe, but running with a profile the caller did
|
||||
* not get is not worth the ambiguity. */
|
||||
fprintf(stderr, "landlock-run: cannot open rule path: %s: %s\n", path, strerror(errno));
|
||||
return EXIT_LAUNCHER_FAILURE;
|
||||
}
|
||||
/* The kernel rejects directory-only accesses on a non-directory rule
|
||||
* (EINVAL), so a file grant keeps only the file-compatible bits — how the
|
||||
* `--rw /dev/null` grant works. Same clamp the Rust crate's
|
||||
* path_beneath_rules helper applied. */
|
||||
struct stat st;
|
||||
if (fstat(path_fd, &st) == 0 && !S_ISDIR(st.st_mode)) {
|
||||
access &= LL_FS_EXECUTE | LL_FS_WRITE_FILE | LL_FS_READ_FILE | LL_FS_TRUNCATE | LL_FS_IOCTL_DEV;
|
||||
}
|
||||
struct landlock_path_beneath_attr attr = { .allowed_access = access, .parent_fd = path_fd };
|
||||
if (syscall(__NR_landlock_add_rule, ruleset_fd, LANDLOCK_RULE_PATH_BENEATH, &attr, 0) != 0) {
|
||||
int saved = errno;
|
||||
close(path_fd);
|
||||
return fail("landlock ruleset error", strerror(saved));
|
||||
}
|
||||
close(path_fd);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* Install the ruleset on the current thread, negotiating the kernel's ABI
|
||||
* down from MAX_ABI. `--ro` paths get the read side of the vocabulary (read
|
||||
* file/dir + execute — the wrapped `bash` and everything it spawns must
|
||||
* remain executable); `--rw` paths get every filesystem access the
|
||||
* negotiated ABI can grant. Sets `no_new_privs` first (mandatory for an
|
||||
* unprivileged restrict, and it neutralizes setuid/setgid escalation inside
|
||||
* the sandbox). On success `*partial` reports whether the kernel governs
|
||||
* only a subset of MAX_ABI's accesses. Returns 0, else the exit code.
|
||||
*/
|
||||
static int restrict_self(const struct cli *cli, int *partial) {
|
||||
long abi = syscall(__NR_landlock_create_ruleset, NULL, 0, LANDLOCK_CREATE_RULESET_VERSION);
|
||||
if (abi < 0) {
|
||||
/* ENOSYS: kernel built without Landlock; EOPNOTSUPP: built but disabled.
|
||||
* Either way: not enforceable — fail CLOSED, never exec unconfined. */
|
||||
return fail(NOT_ENFORCED_MESSAGE, NULL);
|
||||
}
|
||||
*partial = abi < MAX_ABI;
|
||||
uint64_t handled = fs_mask_for_abi(abi < MAX_ABI ? abi : MAX_ABI);
|
||||
|
||||
struct landlock_ruleset_attr attr = { .handled_access_fs = handled };
|
||||
int ruleset_fd = (int)syscall(__NR_landlock_create_ruleset, &attr, sizeof attr, 0);
|
||||
if (ruleset_fd < 0) return fail("landlock ruleset error", strerror(errno));
|
||||
|
||||
const uint64_t read_side = LL_FS_EXECUTE | LL_FS_READ_FILE | LL_FS_READ_DIR;
|
||||
for (size_t i = 0; i < cli->ro_count; i++) {
|
||||
int code = add_rule(ruleset_fd, cli->ro[i], read_side & handled);
|
||||
if (code != 0) return code;
|
||||
}
|
||||
for (size_t i = 0; i < cli->rw_count; i++) {
|
||||
int code = add_rule(ruleset_fd, cli->rw[i], handled);
|
||||
if (code != 0) return code;
|
||||
}
|
||||
|
||||
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) {
|
||||
return fail("landlock ruleset error", strerror(errno));
|
||||
}
|
||||
if (syscall(__NR_landlock_restrict_self, ruleset_fd, 0) != 0) {
|
||||
return fail("landlock ruleset error", strerror(errno));
|
||||
}
|
||||
close(ruleset_fd);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
struct cli cli = { 0 };
|
||||
int code = parse(argc, argv, &cli);
|
||||
if (code != 0) return code;
|
||||
|
||||
if (cli.probe) {
|
||||
/* The functional probe: build and enforce a maximal ruleset in THIS
|
||||
* short-lived process (the probe run exits right after). `--version`
|
||||
* style checks would miss a kernel that has the syscalls but refuses
|
||||
* enforcement; actually restricting is the only honest signal. The one
|
||||
* report line is part of the launcher CLI contract — the executor reads
|
||||
* enforcement completeness from it. */
|
||||
static const char *probe_root = "/";
|
||||
struct cli probe = { .ro = &probe_root, .ro_count = 1 };
|
||||
int partial = 0;
|
||||
code = restrict_self(&probe, &partial);
|
||||
if (code != 0) return code;
|
||||
printf("landlock: %s\n", partial ? "partially enforced (older ABI)" : "fully enforced");
|
||||
return 0;
|
||||
}
|
||||
|
||||
int partial = 0;
|
||||
code = restrict_self(&cli, &partial);
|
||||
if (code != 0) return code;
|
||||
if (partial) {
|
||||
/* Older ABI: some handled accesses are not governed (e.g. truncate
|
||||
* before ABI 3). Still confined for everything the kernel supports —
|
||||
* report, do not refuse. */
|
||||
fprintf(stderr, "landlock-run: partial enforcement (older Landlock ABI)\n");
|
||||
}
|
||||
|
||||
execvp(cli.command[0], cli.command);
|
||||
/* exec only returns on failure. */
|
||||
return fail("exec failed", strerror(errno));
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"declaration": true,
|
||||
"outDir": "lib",
|
||||
"rootDir": "src",
|
||||
"tsBuildInfoFile": "lib/.tsbuildinfo"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2026, node-addon-landlock-run contributors
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -0,0 +1,7 @@
|
||||
# node-addon-landlock-run-linux-arm64
|
||||
|
||||
Prebuilt `bin/landlock-run` Landlock launcher for linux-arm64 — a static musl binary compiled natively (no cross toolchain) from the C source shipped in [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run). npm's `os`/`cpu` fields select this package at install time; the entry package resolves it to a file path — it ships no JavaScript and is never imported.
|
||||
|
||||
The binary is git-ignored and rides the npm tarball via the `files` list; the `prepack` gate refuses to pack when it is missing or has the wrong ELF architecture, and the release pipeline byte-pins the packed binary against the CI build it came from. Static musl linking means one binary for glibc and musl distros alike — hence no libc suffix in the name.
|
||||
|
||||
Sibling: `node-addon-landlock-run-linux-x64`.
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "node-addon-landlock-run-linux-arm64",
|
||||
"version": "0.0.1",
|
||||
"description": "Prebuilt landlock-run Landlock launcher binary for linux-arm64 (static musl) — resolved as a file path by node-addon-landlock-run, never imported",
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"files": [
|
||||
"README.md",
|
||||
"bin/",
|
||||
"prebuilds.json"
|
||||
],
|
||||
"scripts": {
|
||||
"prepack": "node ../../scripts/verify-launcher-binary.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"platform": "linux-arm64",
|
||||
"binaries": [
|
||||
{
|
||||
"tool": "landlock-run",
|
||||
"kind": "static-musl",
|
||||
"path": "bin/landlock-run"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2026, node-addon-landlock-run contributors
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -0,0 +1,7 @@
|
||||
# node-addon-landlock-run-linux-x64
|
||||
|
||||
Prebuilt `bin/landlock-run` Landlock launcher for linux-x64 — a static musl binary compiled natively (no cross toolchain) from the C source shipped in [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run). npm's `os`/`cpu` fields select this package at install time; the entry package resolves it to a file path — it ships no JavaScript and is never imported.
|
||||
|
||||
The binary is git-ignored and rides the npm tarball via the `files` list; the `prepack` gate refuses to pack when it is missing or has the wrong ELF architecture, and the release pipeline byte-pins the packed binary against the CI build it came from. Static musl linking means one binary for glibc and musl distros alike — hence no libc suffix in the name.
|
||||
|
||||
Sibling: `node-addon-landlock-run-linux-arm64`.
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "node-addon-landlock-run-linux-x64",
|
||||
"version": "0.0.1",
|
||||
"description": "Prebuilt landlock-run Landlock launcher binary for linux-x64 (static musl) — resolved as a file path by node-addon-landlock-run, never imported",
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"files": [
|
||||
"README.md",
|
||||
"bin/",
|
||||
"prebuilds.json"
|
||||
],
|
||||
"scripts": {
|
||||
"prepack": "node ../../scripts/verify-launcher-binary.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"platform": "linux-x64",
|
||||
"binaries": [
|
||||
{
|
||||
"tool": "landlock-run",
|
||||
"kind": "static-musl",
|
||||
"path": "bin/landlock-run"
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+345
@@ -0,0 +1,345 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^24.10.0
|
||||
version: 24.13.2
|
||||
node-addon-landlock-run:
|
||||
specifier: workspace:*
|
||||
version: link:packages/entry
|
||||
tsx:
|
||||
specifier: ^4.20.6
|
||||
version: 4.23.0
|
||||
typescript:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
|
||||
packages/entry:
|
||||
optionalDependencies:
|
||||
node-addon-landlock-run-linux-arm64:
|
||||
specifier: workspace:*
|
||||
version: link:../linux-arm64
|
||||
node-addon-landlock-run-linux-x64:
|
||||
specifier: workspace:*
|
||||
version: link:../linux-x64
|
||||
|
||||
packages/linux-arm64: {}
|
||||
|
||||
packages/linux-x64: {}
|
||||
|
||||
packages:
|
||||
|
||||
'@esbuild/aix-ppc64@0.28.1':
|
||||
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/android-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.28.1':
|
||||
resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.28.1':
|
||||
resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/darwin-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.28.1':
|
||||
resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.28.1':
|
||||
resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/linux-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.28.1':
|
||||
resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.28.1':
|
||||
resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.28.1':
|
||||
resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.28.1':
|
||||
resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.28.1':
|
||||
resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.28.1':
|
||||
resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.28.1':
|
||||
resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.28.1':
|
||||
resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/netbsd-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/netbsd-x64@0.28.1':
|
||||
resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/openbsd-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.28.1':
|
||||
resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openharmony-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@esbuild/sunos-x64@0.28.1':
|
||||
resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/win32-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.28.1':
|
||||
resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.28.1':
|
||||
resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@types/node@24.13.2':
|
||||
resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==}
|
||||
|
||||
esbuild@0.28.1:
|
||||
resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
tsx@4.23.0:
|
||||
resolution: {integrity: sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
undici-types@7.18.2:
|
||||
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@esbuild/aix-ppc64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openharmony-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@types/node@24.13.2':
|
||||
dependencies:
|
||||
undici-types: 7.18.2
|
||||
|
||||
esbuild@0.28.1:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.28.1
|
||||
'@esbuild/android-arm': 0.28.1
|
||||
'@esbuild/android-arm64': 0.28.1
|
||||
'@esbuild/android-x64': 0.28.1
|
||||
'@esbuild/darwin-arm64': 0.28.1
|
||||
'@esbuild/darwin-x64': 0.28.1
|
||||
'@esbuild/freebsd-arm64': 0.28.1
|
||||
'@esbuild/freebsd-x64': 0.28.1
|
||||
'@esbuild/linux-arm': 0.28.1
|
||||
'@esbuild/linux-arm64': 0.28.1
|
||||
'@esbuild/linux-ia32': 0.28.1
|
||||
'@esbuild/linux-loong64': 0.28.1
|
||||
'@esbuild/linux-mips64el': 0.28.1
|
||||
'@esbuild/linux-ppc64': 0.28.1
|
||||
'@esbuild/linux-riscv64': 0.28.1
|
||||
'@esbuild/linux-s390x': 0.28.1
|
||||
'@esbuild/linux-x64': 0.28.1
|
||||
'@esbuild/netbsd-arm64': 0.28.1
|
||||
'@esbuild/netbsd-x64': 0.28.1
|
||||
'@esbuild/openbsd-arm64': 0.28.1
|
||||
'@esbuild/openbsd-x64': 0.28.1
|
||||
'@esbuild/openharmony-arm64': 0.28.1
|
||||
'@esbuild/sunos-x64': 0.28.1
|
||||
'@esbuild/win32-arm64': 0.28.1
|
||||
'@esbuild/win32-ia32': 0.28.1
|
||||
'@esbuild/win32-x64': 0.28.1
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
tsx@4.23.0:
|
||||
dependencies:
|
||||
esbuild: 0.28.1
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
undici-types@7.18.2: {}
|
||||
@@ -0,0 +1,8 @@
|
||||
packages:
|
||||
- packages/*
|
||||
|
||||
# pnpm 10+ blocks any dependency shipping an install/build script until it is
|
||||
# explicitly reviewed here. Deny by default; esbuild (tsx's bundled native
|
||||
# binary) genuinely needs its script.
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Assemble downloaded release artifacts into the platform packages and
|
||||
* verify the result. The Release workflow's build legs upload one
|
||||
* `prebuild-<package>` artifact per platform package (its `bin/` payload);
|
||||
* this script copies each into `packages/<package>/bin/` and then checks
|
||||
* every declared binary for presence and ELF architecture.
|
||||
*
|
||||
* Usage: `node scripts/assemble-prebuilds.mjs <artifact-root>`.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { platformDirs, root, verifyPlatformBinaries } from './repo.mjs';
|
||||
|
||||
const artifactRoot = path.resolve(process.argv[2] || '.release/prebuild-artifacts');
|
||||
|
||||
if (!fs.existsSync(artifactRoot)) {
|
||||
throw new Error(`prebuild artifact directory does not exist: ${artifactRoot}`);
|
||||
}
|
||||
|
||||
const platforms = platformDirs().map((dir) => path.basename(dir));
|
||||
|
||||
for (const name of platforms) {
|
||||
const binDir = path.join(root, 'packages', name, 'bin');
|
||||
fs.rmSync(binDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
}
|
||||
|
||||
for (const artifactName of fs.readdirSync(artifactRoot)) {
|
||||
const artifactDir = path.join(artifactRoot, artifactName);
|
||||
if (!fs.statSync(artifactDir).isDirectory()) continue;
|
||||
|
||||
const name = platforms.find((candidate) => artifactName === `prebuild-${candidate}`);
|
||||
if (!name) {
|
||||
throw new Error(`cannot map artifact to a platform package: ${artifactName}`);
|
||||
}
|
||||
|
||||
for (const file of fs.readdirSync(artifactDir)) {
|
||||
const source = path.join(artifactDir, file);
|
||||
const destination = path.join(root, 'packages', name, 'bin', file);
|
||||
fs.copyFileSync(source, destination);
|
||||
fs.chmodSync(destination, 0o755);
|
||||
console.log(`Copied ${path.relative(root, source)} -> ${path.relative(root, destination)}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const dir of platformDirs()) {
|
||||
const { name, count } = verifyPlatformBinaries(path.join(root, dir));
|
||||
console.log(`Verified ${name}: ${count} binaries`);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Build every native tool this host can build, into its per-platform
|
||||
* package.
|
||||
*
|
||||
* Targets are derived from the checked-in matrix: each
|
||||
* `packages/<name>/prebuilds.json` whose `platform` matches this host names
|
||||
* the binaries to produce; the TOOLS table below maps each `tool` to its C
|
||||
* source. Builds are NATIVE-ONLY — each Linux architecture compiles its own
|
||||
* binary with the distro's `musl-gcc` (static musl: runs on glibc and musl
|
||||
* distros alike, no loader or libc expectations on the consumer host), and
|
||||
* CI's per-arch runners are the builders of record. No cross toolchain
|
||||
* exists here on purpose: native runners replace it, and the audit surface
|
||||
* is the reviewed C source plus CI provenance.
|
||||
*
|
||||
* Binaries land in `packages/<name>/bin/` — git-ignored (root
|
||||
* `.gitignore`), packed into the platform package's npm tarball behind its
|
||||
* `prepack` gate (`scripts/verify-launcher-binary.mjs`).
|
||||
*
|
||||
* Run: `pnpm run build:native` (Linux with musl-gcc on PATH:
|
||||
* `apt-get install musl-tools`). Non-Linux hosts fail fast — no platform
|
||||
* package exists for them to build.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
|
||||
/** Each native tool's C source, keyed by the `tool` field in prebuilds.json. */
|
||||
const TOOLS: Record<string, { source: string }> = {
|
||||
'landlock-run': { source: 'packages/entry/src/main.c' },
|
||||
}
|
||||
|
||||
const repoRoot = resolve(import.meta.dirname, '..')
|
||||
|
||||
if (process.platform !== 'linux') {
|
||||
console.error(`build: native tools are built natively per Linux architecture (no cross toolchain) — nothing to build on ${process.platform}. CI's per-arch runners build and rehearse every platform package.`)
|
||||
process.exit(1)
|
||||
}
|
||||
const hostPlatform = `linux-${process.arch}`
|
||||
|
||||
/** This host's platform packages, from the checked-in matrix. */
|
||||
const targets: { packageDir: string; tool: string; binaryPath: string; kind: string }[] = []
|
||||
const packagesRoot = join(repoRoot, 'packages')
|
||||
for (const name of readdirSync(packagesRoot).sort()) {
|
||||
const prebuildsFile = join(packagesRoot, name, 'prebuilds.json')
|
||||
if (!existsSync(prebuildsFile)) continue
|
||||
const prebuilds = JSON.parse(readFileSync(prebuildsFile, 'utf8')) as {
|
||||
platform: string
|
||||
binaries: { tool: string; kind: string; path: string }[]
|
||||
}
|
||||
if (prebuilds.platform !== hostPlatform) continue
|
||||
for (const binary of prebuilds.binaries) {
|
||||
targets.push({ packageDir: join(packagesRoot, name), tool: binary.tool, binaryPath: binary.path, kind: binary.kind })
|
||||
}
|
||||
}
|
||||
if (targets.length === 0) {
|
||||
console.error(`build: no platform package declares binaries for ${hostPlatform} — supported platforms are the packages/*/prebuilds.json "platform" values.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
for (const target of targets) {
|
||||
const tool = TOOLS[target.tool]
|
||||
if (tool === undefined) {
|
||||
console.error(`build: prebuilds.json names unknown tool "${target.tool}" — add it to the TOOLS table in scripts/build.ts.`)
|
||||
process.exit(1)
|
||||
}
|
||||
if (target.kind !== 'static-musl') {
|
||||
console.error(`build: unknown binary kind "${target.kind}" — the only toolchain here is static musl.`)
|
||||
process.exit(1)
|
||||
}
|
||||
const binary = join(target.packageDir, target.binaryPath)
|
||||
mkdirSync(dirname(binary), { recursive: true })
|
||||
|
||||
// -static against musl: self-contained, no loader/libc expectations on the
|
||||
// consumer host. -Werror is safe to keep hard: CI pins the builder images,
|
||||
// and a new warning on a toolchain bump deserves a look, not a pass.
|
||||
const result = spawnSync('musl-gcc', [
|
||||
'-std=c11', '-Os', '-Wall', '-Wextra', '-Werror', '-static', '-s',
|
||||
'-o', binary, join(repoRoot, tool.source),
|
||||
], { stdio: ['ignore', 'inherit', 'inherit'] })
|
||||
if (result.error !== undefined || result.status !== 0) {
|
||||
console.error('build: musl-gcc failed' +
|
||||
(result.error ? ` (${result.error.message} — is musl-tools installed?)` : ''))
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`build: built ${basename(target.packageDir)}/${target.binaryPath}`)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Bump every package (workspace root + packages/*) to one version, refresh
|
||||
* the lockfile, and verify. Usage: `pnpm release:bump <major|minor|patch|x.y.z>`.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { packageDirs, readJson, root } from './repo.mjs';
|
||||
|
||||
const bump = process.argv[2];
|
||||
const releaseTypes = new Set(['major', 'minor', 'patch']);
|
||||
|
||||
function writeJson(file, value) {
|
||||
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function run(command, args) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, CI: 'true' },
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
function packageFiles() {
|
||||
return ['package.json', ...packageDirs().map((dir) => path.join(dir, 'package.json'))];
|
||||
}
|
||||
|
||||
function parseVersion(version) {
|
||||
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(version);
|
||||
if (!match) {
|
||||
throw new Error(`increment types need a plain x.y.z current version (current: ${version}) — pass an explicit target version instead`);
|
||||
}
|
||||
return match.slice(1).map((part) => Number(part));
|
||||
}
|
||||
|
||||
/** Explicit target versions accept full semver, prereleases included (test publishes). */
|
||||
const EXPLICIT_VERSION = /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/;
|
||||
|
||||
function nextVersion(current, release) {
|
||||
if (EXPLICIT_VERSION.test(release)) return release;
|
||||
|
||||
if (!releaseTypes.has(release)) {
|
||||
throw new Error('Usage: pnpm release:bump <major|minor|patch|x.y.z>');
|
||||
}
|
||||
|
||||
const [major, minor, patch] = parseVersion(current);
|
||||
if (release === 'major') return `${major + 1}.0.0`;
|
||||
if (release === 'minor') return `${major}.${minor + 1}.0`;
|
||||
return `${major}.${minor}.${patch + 1}`;
|
||||
}
|
||||
|
||||
function currentPublishedVersion(files) {
|
||||
const versions = new Set(
|
||||
files
|
||||
.filter((file) => file.startsWith('packages/'))
|
||||
.map((file) => readJson(path.join(root, file)).version),
|
||||
);
|
||||
if (versions.size !== 1) {
|
||||
throw new Error(`published package versions differ: ${[...versions].join(', ')}`);
|
||||
}
|
||||
return [...versions][0];
|
||||
}
|
||||
|
||||
if (!bump) {
|
||||
console.error('Usage: pnpm release:bump <major|minor|patch|x.y.z>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const files = packageFiles();
|
||||
const targetVersion = nextVersion(currentPublishedVersion(files), bump);
|
||||
|
||||
for (const file of files) {
|
||||
const fullPath = path.join(root, file);
|
||||
const json = readJson(fullPath);
|
||||
json.version = targetVersion;
|
||||
writeJson(fullPath, json);
|
||||
console.log(`${file}: ${targetVersion}`);
|
||||
}
|
||||
|
||||
run('pnpm', ['install', '--ignore-scripts', '--lockfile-only']);
|
||||
run('node', ['./scripts/verify-release.mjs']);
|
||||
|
||||
console.log(`Release version bumped to ${targetVersion}`);
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Bump, stage, and commit a release in one command:
|
||||
* `pnpm release:commit <major|minor|patch|x.y.z>`. The tag stays manual —
|
||||
* create it from the merged release commit.
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { packageDirs, readJson, root } from './repo.mjs';
|
||||
|
||||
const bump = process.argv[2];
|
||||
|
||||
function run(command, args) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, CI: 'true' },
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!bump) {
|
||||
console.error('Usage: pnpm release:commit <major|minor|patch|x.y.z>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
run('node', ['./scripts/bump-release.mjs', bump]);
|
||||
|
||||
const version = readJson(path.join(root, packageDirs()[0], 'package.json')).version;
|
||||
run('git', [
|
||||
'add',
|
||||
'package.json',
|
||||
'packages/*/package.json',
|
||||
'pnpm-lock.yaml',
|
||||
]);
|
||||
run('git', ['commit', '-m', `release: ${version}`]);
|
||||
|
||||
console.log(`Committed release ${version}. Create the tag manually: git tag v${version}`);
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Derive the GitHub Actions matrices from the checked-in package matrix
|
||||
* (`packages/<name>/prebuilds.json`). Single source: adding a platform
|
||||
* package extends CI and Release without editing a workflow.
|
||||
*
|
||||
* node scripts/github-matrix.mjs ci → one leg per distinct platform
|
||||
* node scripts/github-matrix.mjs release-prebuild → one leg per platform package
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import { platformDirs, readJson, root } from './repo.mjs';
|
||||
|
||||
/** GitHub runner per prebuilds.json `platform` value — native builders only, no cross toolchain. */
|
||||
const RUNNERS = {
|
||||
'linux-x64': 'ubuntu-24.04',
|
||||
'linux-arm64': 'ubuntu-24.04-arm',
|
||||
};
|
||||
|
||||
function runnerFor(platform) {
|
||||
const runner = RUNNERS[platform];
|
||||
if (!runner) {
|
||||
throw new Error(`missing GitHub runner for platform: ${platform}`);
|
||||
}
|
||||
return runner;
|
||||
}
|
||||
|
||||
function platformManifests() {
|
||||
return platformDirs().map((dir) => ({
|
||||
dir,
|
||||
name: path.basename(dir),
|
||||
prebuilds: readJson(path.join(root, dir, 'prebuilds.json')),
|
||||
}));
|
||||
}
|
||||
|
||||
function ciMatrix() {
|
||||
const platforms = [...new Set(platformManifests().map(({ prebuilds }) => prebuilds.platform))].sort();
|
||||
return {
|
||||
include: platforms.map((platform) => ({ platform, runner: runnerFor(platform) })),
|
||||
};
|
||||
}
|
||||
|
||||
function releasePrebuildMatrix() {
|
||||
return {
|
||||
include: platformManifests().map(({ dir, name, prebuilds }) => ({
|
||||
platform: prebuilds.platform,
|
||||
package: name,
|
||||
dir,
|
||||
runner: runnerFor(prebuilds.platform),
|
||||
artifact: `prebuild-${name}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const target = process.argv[2];
|
||||
const matrices = {
|
||||
ci: ciMatrix,
|
||||
'release-prebuild': releasePrebuildMatrix,
|
||||
};
|
||||
|
||||
if (!target || !matrices[target]) {
|
||||
console.error(`Usage: node scripts/github-matrix.mjs <${Object.keys(matrices).join('|')}>`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify(matrices[target]()));
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Pack every published package into release tarballs, in publish order
|
||||
* (platform packages first, then the entries that optionally depend on
|
||||
* them), and write `publish-order.txt` next to them. `pnpm pack` produces
|
||||
* the EXACT bytes `pnpm publish` would upload and runs each package's
|
||||
* `prepack` gate, so a missing binary or unbuilt `lib/` refuses here.
|
||||
*
|
||||
* Usage: `node scripts/pack-release.mjs [dest] [--current-platform-only]`.
|
||||
* The flag packs only THIS host's platform package plus the entries — for
|
||||
* per-architecture CI legs, where the other architecture's binary does not
|
||||
* exist (the exact refusal its prepack gate exists for).
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { entryDirs, platformDirs, readJson, root } from './repo.mjs';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const currentPlatformOnly = args.includes('--current-platform-only');
|
||||
const destination = path.resolve(args.find((arg) => !arg.startsWith('--')) || path.join(root, 'dist', 'npm'));
|
||||
|
||||
function hostPlatformDirs() {
|
||||
const hostPlatform = `${process.platform}-${process.arch}`;
|
||||
return platformDirs().filter((dir) => readJson(path.join(root, dir, 'prebuilds.json')).platform === hostPlatform);
|
||||
}
|
||||
|
||||
function run(command, args) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
function tarballName(manifest) {
|
||||
if (manifest.name.startsWith('@')) {
|
||||
return `${manifest.name.slice(1).replace('/', '-')}-${manifest.version}.tgz`;
|
||||
}
|
||||
return `${manifest.name}-${manifest.version}.tgz`;
|
||||
}
|
||||
|
||||
fs.rmSync(destination, { recursive: true, force: true });
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
|
||||
const dirs = [...(currentPlatformOnly ? hostPlatformDirs() : platformDirs()), ...entryDirs()];
|
||||
const platformSet = new Set(platformDirs());
|
||||
const publishOrder = [];
|
||||
for (const dir of dirs) {
|
||||
const manifest = readJson(path.join(root, dir, 'package.json'));
|
||||
// Platform packages are packed with npm: pnpm pack (observed on 11.7.0)
|
||||
// normalizes file modes and STRIPS the executable bit, which ships a
|
||||
// launcher no consumer can spawn; npm pack preserves it. Platform packages
|
||||
// have no dependencies by construction, so they need none of pnpm's
|
||||
// workspace-protocol conversion — the entry packages do, and carry no
|
||||
// executables, so they keep pnpm pack.
|
||||
if (platformSet.has(dir)) {
|
||||
run('npm', ['pack', `./${dir}`, '--pack-destination', destination]);
|
||||
} else {
|
||||
run('pnpm', ['--dir', dir, 'pack', '--pack-destination', destination]);
|
||||
}
|
||||
|
||||
const tarball = tarballName(manifest);
|
||||
const tarballPath = path.join(destination, tarball);
|
||||
if (!fs.existsSync(tarballPath)) {
|
||||
throw new Error(`expected pack output not found: ${tarballPath}`);
|
||||
}
|
||||
publishOrder.push(tarball);
|
||||
}
|
||||
|
||||
fs.writeFileSync(path.join(destination, 'publish-order.txt'), `${publishOrder.join('\n')}\n`);
|
||||
console.log(`Packed ${publishOrder.length} packages into ${path.relative(root, destination)}`);
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Shared helpers for the repo scripts: package discovery, the checked-in
|
||||
* prebuild matrix, and binary verification. The package matrix is explicit
|
||||
* metadata — `packages/<name>/prebuilds.json` marks a platform package and
|
||||
* declares its binaries; everything else under `packages/` is an entry
|
||||
* package. Scripts derive from these files and never guess.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const root = fileURLToPath(new URL('..', import.meta.url));
|
||||
export const packagesRoot = path.join(root, 'packages');
|
||||
|
||||
/** ELF `e_machine` (offset 18, little-endian) per platform-package `cpu` value. */
|
||||
export const E_MACHINE = { x64: 62, arm64: 183 };
|
||||
|
||||
export function readJson(file) {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
}
|
||||
|
||||
/** Platform packages: every `packages/<name>` carrying a `prebuilds.json`. */
|
||||
export function platformDirs() {
|
||||
return fs.readdirSync(packagesRoot)
|
||||
.filter((name) => fs.existsSync(path.join(packagesRoot, name, 'prebuilds.json')))
|
||||
.sort()
|
||||
.map((name) => path.join('packages', name));
|
||||
}
|
||||
|
||||
/** Entry packages: every other `packages/<name>` with a `package.json`. */
|
||||
export function entryDirs() {
|
||||
return fs.readdirSync(packagesRoot)
|
||||
.filter((name) => !fs.existsSync(path.join(packagesRoot, name, 'prebuilds.json')))
|
||||
.filter((name) => fs.existsSync(path.join(packagesRoot, name, 'package.json')))
|
||||
.sort()
|
||||
.map((name) => path.join('packages', name));
|
||||
}
|
||||
|
||||
/** All published packages in publish order: platform packages before the entries that optionally depend on them. */
|
||||
export function packageDirs() {
|
||||
return [...platformDirs(), ...entryDirs()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify one platform package's binaries against its `prebuilds.json`:
|
||||
* every declared binary exists, nothing undeclared sits in `bin/`, and each
|
||||
* file's ELF `e_machine` matches the package's declared `cpu`. Throws with
|
||||
* a remediation message on the first mismatch.
|
||||
*/
|
||||
export function verifyPlatformBinaries(packageDir) {
|
||||
const manifest = readJson(path.join(packageDir, 'package.json'));
|
||||
const prebuilds = readJson(path.join(packageDir, 'prebuilds.json'));
|
||||
const cpu = manifest.cpu?.[0];
|
||||
if (cpu === undefined || !(cpu in E_MACHINE)) {
|
||||
throw new Error(`${manifest.name}: unsupported or missing "cpu" in package.json (expected one of: ${Object.keys(E_MACHINE).join(', ')})`);
|
||||
}
|
||||
|
||||
for (const binary of prebuilds.binaries) {
|
||||
const file = path.join(packageDir, binary.path);
|
||||
if (!fs.existsSync(file)) {
|
||||
throw new Error(`${manifest.name}: missing ${binary.path} — run \`pnpm build:native\` on a ${prebuilds.platform} host (or assemble release artifacts) before packing.`);
|
||||
}
|
||||
try {
|
||||
fs.accessSync(file, fs.constants.X_OK);
|
||||
} catch {
|
||||
// Only reachable when the mode was mangled somewhere between build and
|
||||
// here (e.g. an archive step that normalized permissions) — the build
|
||||
// itself always produces 755.
|
||||
throw new Error(`${manifest.name}: ${binary.path} is not executable — a pack/extract step stripped the mode bit.`);
|
||||
}
|
||||
const machine = fs.readFileSync(file).readUInt16LE(18);
|
||||
if (machine !== E_MACHINE[cpu]) {
|
||||
throw new Error(`${manifest.name}: ${binary.path} has ELF e_machine ${machine}, expected ${E_MACHINE[cpu]} for ${cpu} — the binary was built for a different architecture.`);
|
||||
}
|
||||
}
|
||||
|
||||
const declared = prebuilds.binaries.map((binary) => path.basename(binary.path)).sort();
|
||||
const binDir = path.join(packageDir, 'bin');
|
||||
const actual = fs.existsSync(binDir) ? fs.readdirSync(binDir).sort() : [];
|
||||
const extra = actual.filter((name) => !declared.includes(name));
|
||||
if (extra.length) {
|
||||
throw new Error(`${manifest.name}: bin/ contains files not declared in prebuilds.json: ${extra.join(', ')}`);
|
||||
}
|
||||
|
||||
return { name: manifest.name, count: prebuilds.binaries.length };
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Prepack gate for entry packages: refuse to pack a tarball whose built
|
||||
* `lib/` is missing. Entry `files` lists use globs, and a glob matching
|
||||
* nothing packs a silently JS-less tarball instead of failing — this gate
|
||||
* turns that into a loud refusal on a checkout that never ran
|
||||
* `pnpm build:ts`.
|
||||
*
|
||||
* Runs from each entry package's `prepack` hook (pnpm sets the script cwd
|
||||
* to the package directory).
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const packageDir = process.cwd();
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(packageDir, 'package.json'), 'utf8'));
|
||||
|
||||
for (const file of ['lib/index.js', 'lib/index.d.ts']) {
|
||||
if (!fs.existsSync(path.join(packageDir, file))) {
|
||||
console.error(`verify-entry-lib: ${manifest.name} has no ${file} — run \`pnpm build:ts\` before packing.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
console.log(`verify-entry-lib: ${manifest.name} built lib/ present.`);
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Prepack gate for platform packages: refuse to pack a tarball whose
|
||||
* declared binaries are missing or built for the wrong architecture.
|
||||
*
|
||||
* Without it, `pnpm pack` on a checkout that never ran
|
||||
* `pnpm run build:native` would ship an EMPTY platform package — the
|
||||
* binary's absence surfacing only at runtime as a failed probe on every
|
||||
* consumer — and a binary copied across packages would advertise an
|
||||
* architecture it cannot execute. The check is presence + ELF `e_machine`
|
||||
* against the package's declared `cpu`; byte provenance is
|
||||
* `verify-packed-install.mjs`'s concern (it pins the installed tarball
|
||||
* against the workspace build).
|
||||
*
|
||||
* Runs from each platform package's `prepack` hook (pnpm sets the script
|
||||
* cwd to the package directory). Also callable directly with an explicit
|
||||
* package directory: `node scripts/verify-launcher-binary.mjs packages/<name>`.
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import { root, verifyPlatformBinaries } from './repo.mjs';
|
||||
|
||||
const packageDir = process.argv[2] ? path.resolve(root, process.argv[2]) : process.cwd();
|
||||
|
||||
try {
|
||||
const { name, count } = verifyPlatformBinaries(packageDir);
|
||||
console.log(`verify-launcher-binary: ${name} — ${count} binaries present with the right ELF architecture.`);
|
||||
} catch (error) {
|
||||
console.error(`verify-launcher-binary: ${error instanceof Error ? error.message : error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Publish-path rehearsal without publishing: verify the packed tarballs are
|
||||
* exactly what a consumer install needs. `pnpm pack` already produced the
|
||||
* bytes `pnpm publish` would upload; this script checks the payload
|
||||
* (coverage, concrete dependency versions, NO lifecycle install scripts —
|
||||
* this family has no install fallback on purpose), unpacks the entry plus
|
||||
* THIS host's platform tarball into a throwaway consumer OUTSIDE the repo,
|
||||
* byte-pins the installed binary against the workspace build it was packed
|
||||
* from, and drives the INSTALLED entry under plain `node` — resolution,
|
||||
* probe, and a real confinement world-proof through the installed launcher.
|
||||
*
|
||||
* On non-Linux hosts (no platform package exists) it instead proves the
|
||||
* documented degradation: resolution falls back to a nonexistent path and
|
||||
* the probe reports `unusable`.
|
||||
*
|
||||
* Usage: `node scripts/verify-packed-install.mjs [tarball-dir] [--current-platform-only]`.
|
||||
* The flag skips the all-platforms tarball-presence check for
|
||||
* per-architecture CI legs. `NALR_REQUIRE_LANDLOCK=1` makes an unenforcing
|
||||
* kernel a failure instead of a skipped world-proof (set on CI, where the
|
||||
* kernel is known).
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { entryDirs, packageDirs, platformDirs, readJson, root } from './repo.mjs';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const currentPlatformOnly = args.includes('--current-platform-only');
|
||||
const tarballDir = path.resolve(args.find((arg) => !arg.startsWith('--')) || path.join(root, 'dist', 'npm'));
|
||||
const entryPackageName = 'node-addon-landlock-run';
|
||||
|
||||
function tarballName(manifest) {
|
||||
if (manifest.name.startsWith('@')) {
|
||||
return `${manifest.name.slice(1).replace('/', '-')}-${manifest.version}.tgz`;
|
||||
}
|
||||
return `${manifest.name}-${manifest.version}.tgz`;
|
||||
}
|
||||
|
||||
function tarballPath(manifest) {
|
||||
const tarball = path.join(tarballDir, tarballName(manifest));
|
||||
if (!fs.existsSync(tarball)) {
|
||||
throw new Error(`missing packed tarball: ${tarball}`);
|
||||
}
|
||||
return tarball;
|
||||
}
|
||||
|
||||
function run(command, commandArgs, options = {}) {
|
||||
const result = spawnSync(command, commandArgs, {
|
||||
cwd: options.cwd || root,
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, ...options.env },
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
function runCapture(command, commandArgs) {
|
||||
const result = spawnSync(command, commandArgs, { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) {
|
||||
process.stderr.write(result.stderr);
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
function readPackedManifest(manifest) {
|
||||
return JSON.parse(runCapture('tar', ['-xOf', tarballPath(manifest), 'package/package.json']));
|
||||
}
|
||||
|
||||
function verifyPackedManifest(packed) {
|
||||
const lifecycle = ['preinstall', 'install', 'postinstall', 'prepare'];
|
||||
for (const script of lifecycle) {
|
||||
if (packed.scripts?.[script]) {
|
||||
throw new Error(`${packed.name}: packed manifest carries a "${script}" lifecycle script — this family has no install fallback`);
|
||||
}
|
||||
}
|
||||
for (const field of ['dependencies', 'optionalDependencies', 'peerDependencies']) {
|
||||
for (const [name, version] of Object.entries(packed[field] ?? {})) {
|
||||
if (version.includes('workspace:')) {
|
||||
throw new Error(`${packed.name}: packed ${field} still uses the workspace protocol: ${name}@${version}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sha256(file) {
|
||||
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||
}
|
||||
|
||||
function packageInstallDir(packageName) {
|
||||
return path.join(tempRoot, 'node_modules', ...packageName.split('/'));
|
||||
}
|
||||
|
||||
function unpackTarball(manifest) {
|
||||
const extractRoot = fs.mkdtempSync(path.join(tempRoot, 'extract-'));
|
||||
run('tar', ['-xzf', tarballPath(manifest), '-C', extractRoot]);
|
||||
|
||||
const source = path.join(extractRoot, 'package');
|
||||
const destination = packageInstallDir(manifest.name);
|
||||
fs.rmSync(destination, { recursive: true, force: true });
|
||||
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
||||
fs.renameSync(source, destination);
|
||||
fs.rmSync(extractRoot, { recursive: true, force: true });
|
||||
console.log(`Unpacked ${manifest.name} -> ${path.relative(tempRoot, destination)}`);
|
||||
}
|
||||
|
||||
const manifests = packageDirs().map((dir) => ({ dir, manifest: readJson(path.join(root, dir, 'package.json')) }));
|
||||
const entryManifest = manifests.find(({ manifest }) => manifest.name === entryPackageName)?.manifest;
|
||||
if (!entryManifest) throw new Error(`missing source manifest for ${entryPackageName}`);
|
||||
|
||||
const hostPlatform = `${process.platform}-${process.arch}`;
|
||||
const currentPlatformEntry = manifests.find(
|
||||
({ dir, manifest }) => platformDirs().includes(dir) && manifest.name === `${entryPackageName}-${hostPlatform}`,
|
||||
);
|
||||
|
||||
// Payload checks: every expected tarball exists (full mode), the packed
|
||||
// entry's optional-dependency set names exactly the platform packages, and
|
||||
// no packed manifest carries workspace versions or install lifecycle.
|
||||
const expectedTarballs = currentPlatformOnly
|
||||
? manifests.filter(({ dir }) => entryDirs().includes(dir) || dir === currentPlatformEntry?.dir)
|
||||
: manifests;
|
||||
for (const { manifest } of expectedTarballs) {
|
||||
tarballPath(manifest);
|
||||
}
|
||||
|
||||
const packedEntry = readPackedManifest(entryManifest);
|
||||
const platformPackageNames = manifests
|
||||
.filter(({ dir }) => platformDirs().includes(dir))
|
||||
.map(({ manifest }) => manifest.name)
|
||||
.sort();
|
||||
const optionalNames = Object.keys(packedEntry.optionalDependencies || {}).sort();
|
||||
if (optionalNames.join('\n') !== platformPackageNames.join('\n')) {
|
||||
throw new Error(`packed entry optionalDependencies mismatch\nactual:\n${optionalNames.join('\n')}\nexpected:\n${platformPackageNames.join('\n')}`);
|
||||
}
|
||||
for (const { manifest } of expectedTarballs) {
|
||||
verifyPackedManifest(readPackedManifest(manifest));
|
||||
}
|
||||
|
||||
// Throwaway ESM consumer, built from local tarballs only — no registry.
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'nalr-packed-install-'));
|
||||
fs.writeFileSync(
|
||||
path.join(tempRoot, 'package.json'),
|
||||
`${JSON.stringify({ name: 'nalr-packed-install-check', version: '0.0.0', private: true, type: 'module' }, null, 2)}\n`,
|
||||
);
|
||||
console.log(`Verifying packed install in ${tempRoot}`);
|
||||
|
||||
unpackTarball(entryManifest);
|
||||
if (currentPlatformEntry) {
|
||||
unpackTarball(currentPlatformEntry.manifest);
|
||||
|
||||
// Byte-pin: the installed binary must be the workspace build it was packed
|
||||
// from — any divergence means the tarball did not carry the built bytes.
|
||||
const prebuilds = readJson(path.join(root, currentPlatformEntry.dir, 'prebuilds.json'));
|
||||
for (const binary of prebuilds.binaries) {
|
||||
const workspaceFile = path.join(root, currentPlatformEntry.dir, binary.path);
|
||||
const installedFile = path.join(packageInstallDir(currentPlatformEntry.manifest.name), binary.path);
|
||||
if (sha256(workspaceFile) !== sha256(installedFile)) {
|
||||
throw new Error(`installed ${binary.path} differs from the workspace build it was packed from`);
|
||||
}
|
||||
console.log(`Byte-pinned ${binary.path} against the workspace build`);
|
||||
}
|
||||
} else if (process.platform === 'linux') {
|
||||
throw new Error(`linux host without a platform package in the matrix: ${hostPlatform}`);
|
||||
}
|
||||
|
||||
// Drive the INSTALLED entry under plain node: resolution, probe, and (on an
|
||||
// enforcing kernel) a real confinement world-proof through the installed
|
||||
// launcher.
|
||||
const driver = path.join(tempRoot, 'driver.mjs');
|
||||
fs.writeFileSync(driver, `
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { grantArgs, launcherPath, probe } from 'node-addon-landlock-run';
|
||||
|
||||
const requireLandlock = process.env.NALR_REQUIRE_LANDLOCK === '1';
|
||||
const platformPackage = 'node-addon-landlock-run-' + process.platform + '-' + process.arch;
|
||||
const resolved = launcherPath();
|
||||
assert.ok(path.isAbsolute(resolved), 'launcherPath must be absolute');
|
||||
assert.ok(resolved.includes(path.join(...platformPackage.split('/'))), 'launcherPath must point into the platform package: ' + resolved);
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
assert.ok(fs.existsSync(resolved), 'installed launcher missing at ' + resolved);
|
||||
try {
|
||||
fs.accessSync(resolved, fs.constants.X_OK);
|
||||
} catch {
|
||||
throw new Error('installed launcher is not executable — the pack path stripped the mode bit: ' + resolved);
|
||||
}
|
||||
const enforcement = probe(resolved);
|
||||
console.log('probe through the installed launcher: ' + enforcement);
|
||||
if (enforcement === 'unusable') {
|
||||
if (requireLandlock) throw new Error('NALR_REQUIRE_LANDLOCK=1 but the probe reports unusable');
|
||||
console.log('kernel does not enforce Landlock — skipping the confinement world-proof');
|
||||
} else {
|
||||
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'nalr-confine-'));
|
||||
const denied = path.join(work, 'denied.txt');
|
||||
const deniedRun = spawnSync(resolved, [...grantArgs({ readOnly: ['/'] }), '--', '/bin/sh', '-c', 'echo x > ' + denied], { encoding: 'utf8' });
|
||||
assert.notEqual(deniedRun.status, 0, 'write outside the grants must fail');
|
||||
assert.ok(!fs.existsSync(denied), 'denied write must not land on disk');
|
||||
const granted = path.join(work, 'granted.txt');
|
||||
const grantedRun = spawnSync(resolved, [...grantArgs({ readOnly: ['/'], readWrite: [work] }), '--', '/bin/sh', '-c', 'echo ok > ' + granted], { encoding: 'utf8' });
|
||||
assert.equal(grantedRun.status, 0, 'granted write must succeed: ' + grantedRun.stderr);
|
||||
assert.equal(fs.readFileSync(granted, 'utf8').trim(), 'ok');
|
||||
console.log('confinement world-proof passed through the installed launcher');
|
||||
}
|
||||
} else {
|
||||
assert.ok(!fs.existsSync(resolved), 'no platform package exists for this host — the fallback path must not exist');
|
||||
assert.equal(probe(resolved), 'unusable');
|
||||
console.log('non-linux host: fallback resolution and unusable probe verified');
|
||||
}
|
||||
`);
|
||||
run(process.execPath, [driver], { cwd: tempRoot });
|
||||
|
||||
console.log('Packed install verification passed.');
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Release verification. Always: every published package carries one shared
|
||||
* version, and — when running from a tag or publishing — the `vX.Y.Z` tag
|
||||
* matches it. With `--prebuilds`: every platform package's declared
|
||||
* binaries exist with the right ELF architecture (run after
|
||||
* `assemble-prebuilds.mjs` or a local `build:native`).
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import { packageDirs, platformDirs, readJson, root, verifyPlatformBinaries } from './repo.mjs';
|
||||
|
||||
function verifyVersions() {
|
||||
const packages = packageDirs().map((dir) => ({
|
||||
dir,
|
||||
manifest: readJson(path.join(root, dir, 'package.json')),
|
||||
}));
|
||||
const versions = new Set(packages.map((pkg) => pkg.manifest.version));
|
||||
if (versions.size !== 1) {
|
||||
throw new Error([
|
||||
'published package versions must match:',
|
||||
...packages.map((pkg) => `${pkg.dir}: ${pkg.manifest.version}`),
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
const version = packages[0].manifest.version;
|
||||
const ref = process.env.GITHUB_REF || '';
|
||||
const publish = process.env.RELEASE_PUBLISH === 'true';
|
||||
if (publish && !ref.startsWith('refs/tags/v')) {
|
||||
throw new Error('publishing requires running the workflow from a v* tag');
|
||||
}
|
||||
if (ref.startsWith('refs/tags/v')) {
|
||||
const tagVersion = ref.slice('refs/tags/v'.length);
|
||||
if (tagVersion !== version) {
|
||||
throw new Error(`tag/version mismatch: tag v${tagVersion}, packages ${version}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Verified release version ${version}`);
|
||||
}
|
||||
|
||||
function verifyPrebuilds() {
|
||||
for (const dir of platformDirs()) {
|
||||
const { name, count } = verifyPlatformBinaries(path.join(root, dir));
|
||||
console.log(`Verified ${name}: ${count} binaries`);
|
||||
}
|
||||
}
|
||||
|
||||
verifyVersions();
|
||||
if (process.argv.includes('--prebuilds')) {
|
||||
verifyPrebuilds();
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Keyless entry-package tests — run on every host, no kernel or binary
|
||||
* required. Cover the JS seam's pure surface: grant-argv construction, the
|
||||
* resolution contract (platform package → fallback), and probe verdicts over
|
||||
* fake launchers. Requires built `lib/` (`pnpm build:ts`).
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
LAUNCHER_BIN,
|
||||
LAUNCHER_FAILURE_EXIT,
|
||||
grantArgs,
|
||||
launcherPath,
|
||||
probe,
|
||||
} from 'node-addon-landlock-run';
|
||||
|
||||
// --- constants are part of the CLI contract ---
|
||||
assert.equal(LAUNCHER_BIN, 'landlock-run');
|
||||
assert.equal(LAUNCHER_FAILURE_EXIT, 125);
|
||||
|
||||
// --- grantArgs: flag spelling, ordering, and empty grants ---
|
||||
assert.deepEqual(grantArgs({}), []);
|
||||
assert.deepEqual(grantArgs({ readOnly: ['/'] }), ['--ro', '/']);
|
||||
assert.deepEqual(
|
||||
grantArgs({ readOnly: ['/', '/opt'], readWrite: ['/tmp/work'] }),
|
||||
['--ro', '/', '--ro', '/opt', '--rw', '/tmp/work'],
|
||||
);
|
||||
assert.deepEqual(grantArgs({ readWrite: ['/a'], readOnly: ['/b'] }), ['--ro', '/b', '--rw', '/a']);
|
||||
|
||||
// --- launcherPath: resolves the platform package next to its package.json ---
|
||||
const platformPackage = `node-addon-landlock-run-${process.platform}-${process.arch}`;
|
||||
const resolvedViaSeam = launcherPath((specifier) => {
|
||||
assert.equal(specifier, `${platformPackage}/package.json`);
|
||||
return path.join('/fake-install', specifier);
|
||||
});
|
||||
assert.equal(resolvedViaSeam, path.join('/fake-install', platformPackage, 'bin', LAUNCHER_BIN));
|
||||
|
||||
// --- launcherPath: unresolvable package falls back to an absolute, package-boundary path ---
|
||||
const fallback = launcherPath(() => {
|
||||
throw new Error('not installed');
|
||||
});
|
||||
assert.ok(path.isAbsolute(fallback), 'fallback path must be absolute');
|
||||
assert.ok(
|
||||
fallback.includes(path.join('node_modules', ...platformPackage.split('/'), 'bin', LAUNCHER_BIN)),
|
||||
`fallback must point at the platform package layout: ${fallback}`,
|
||||
);
|
||||
|
||||
// --- launcherPath: default resolution agrees with this workspace's layout ---
|
||||
const defaultPath = launcherPath();
|
||||
assert.ok(path.isAbsolute(defaultPath));
|
||||
assert.ok(defaultPath.endsWith(path.join('bin', LAUNCHER_BIN)), defaultPath);
|
||||
|
||||
// --- probe: a missing launcher is unusable, indistinguishable from an unenforcing kernel ---
|
||||
assert.equal(probe(path.join(os.tmpdir(), 'nalr-no-such-launcher')), 'unusable');
|
||||
|
||||
// --- probe: verdict parsing over fake launchers (POSIX shells only) ---
|
||||
if (process.platform !== 'win32') {
|
||||
const fakeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nalr-fake-launcher-'));
|
||||
const fake = (name, script) => {
|
||||
const file = path.join(fakeDir, name);
|
||||
fs.writeFileSync(file, `#!/bin/sh\n${script}\n`, { mode: 0o755 });
|
||||
return file;
|
||||
};
|
||||
|
||||
assert.equal(probe(fake('full', 'echo "landlock: fully enforced"; exit 0')), 'full');
|
||||
assert.equal(probe(fake('partial', 'echo "landlock: partially enforced (older ABI)"; exit 0')), 'partial');
|
||||
assert.equal(probe(fake('failing', `exit ${LAUNCHER_FAILURE_EXIT}`)), 'unusable');
|
||||
assert.equal(probe(fake('hanging', 'sleep 10'), { timeoutMs: 200 }), 'unusable');
|
||||
|
||||
fs.rmSync(fakeDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log('entry.test: ok');
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Behavioral tests against the REAL launcher binary on a real kernel: the
|
||||
* CLI contract (usage errors, exit codes, argv passthrough) and the
|
||||
* confinement world-proofs (denied writes stay off disk, grants land).
|
||||
*
|
||||
* Preconditions and their skip semantics:
|
||||
* - Non-Linux host: skips entirely (exit 0) — there is nothing to build here.
|
||||
* - Linux without the built binary: FAILS — run `pnpm build:native` first.
|
||||
* - Linux whose kernel does not enforce Landlock: skips the enforcement
|
||||
* half, unless `NALR_REQUIRE_LANDLOCK=1` (set on CI, where a silent skip on
|
||||
* the very platform that exists to prove enforcement would be a false
|
||||
* green).
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
LAUNCHER_FAILURE_EXIT,
|
||||
grantArgs,
|
||||
launcherPath,
|
||||
probe,
|
||||
} from 'node-addon-landlock-run';
|
||||
|
||||
const requireLandlock = process.env.NALR_REQUIRE_LANDLOCK === '1';
|
||||
|
||||
if (process.platform !== 'linux') {
|
||||
console.log(`launcher.test: SKIP — the launcher only exists on linux (host: ${process.platform})`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const launcher = launcherPath();
|
||||
assert.ok(
|
||||
fs.existsSync(launcher),
|
||||
`launcher.test: no built launcher at ${launcher} — run \`pnpm build:native\` (apt-get install musl-tools) first`,
|
||||
);
|
||||
|
||||
const run = (args, options = {}) => spawnSync(launcher, args, { encoding: 'utf8', ...options });
|
||||
|
||||
// --- usage errors: parse failures exit LAUNCHER_FAILURE_EXIT before any restriction ---
|
||||
{
|
||||
const noCommand = run([]);
|
||||
assert.equal(noCommand.status, LAUNCHER_FAILURE_EXIT);
|
||||
assert.match(noCommand.stderr, /usage error: missing `-- <argv>\.\.\.` command/);
|
||||
|
||||
const unknownFlag = run(['--bogus', '--', 'true']);
|
||||
assert.equal(unknownFlag.status, LAUNCHER_FAILURE_EXIT);
|
||||
assert.match(unknownFlag.stderr, /usage error: unknown argument: --bogus/);
|
||||
|
||||
const danglingPath = run(['--ro']);
|
||||
assert.equal(danglingPath.status, LAUNCHER_FAILURE_EXIT);
|
||||
assert.match(danglingPath.stderr, /--ro requires a path/);
|
||||
|
||||
const probeWithExtras = run(['--probe', '--ro', '/']);
|
||||
assert.equal(probeWithExtras.status, LAUNCHER_FAILURE_EXIT);
|
||||
assert.match(probeWithExtras.stderr, /--probe takes no other arguments/);
|
||||
}
|
||||
|
||||
// --- probe: the functional availability signal ---
|
||||
const enforcement = probe(launcher);
|
||||
console.log(`launcher.test: probe → ${enforcement}`);
|
||||
if (enforcement === 'unusable') {
|
||||
if (requireLandlock) {
|
||||
console.error('launcher.test: NALR_REQUIRE_LANDLOCK=1 but the probe reports unusable — this kernel cannot prove enforcement');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('launcher.test: SKIP enforcement half — kernel does not enforce Landlock');
|
||||
process.exit(0);
|
||||
}
|
||||
{
|
||||
const probeRun = run(['--probe']);
|
||||
assert.equal(probeRun.status, 0);
|
||||
assert.match(probeRun.stdout, /^landlock: (fully enforced|partially enforced \(older ABI\))\n$/);
|
||||
}
|
||||
|
||||
// --- confined exec: the command runs, its exit code passes through ---
|
||||
{
|
||||
const echo = run([...grantArgs({ readOnly: ['/'] }), '--', '/bin/sh', '-c', 'echo confined-ok']);
|
||||
assert.equal(echo.status, 0, echo.stderr);
|
||||
assert.equal(echo.stdout, 'confined-ok\n');
|
||||
|
||||
const exitCode = run([...grantArgs({ readOnly: ['/'] }), '--', '/bin/sh', '-c', 'exit 7']);
|
||||
assert.equal(exitCode.status, 7, 'the wrapped command exit code must pass through unchanged');
|
||||
}
|
||||
|
||||
// --- world-proofs: denied writes stay off disk, grants land, inheritance crosses exec ---
|
||||
{
|
||||
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'nalr-launcher-test-'));
|
||||
|
||||
const denied = path.join(work, 'denied.txt');
|
||||
const deniedRun = run([...grantArgs({ readOnly: ['/'] }), '--', '/bin/sh', '-c', `echo x > ${denied}`]);
|
||||
assert.notEqual(deniedRun.status, 0, 'a write outside the grants must fail');
|
||||
assert.ok(!fs.existsSync(denied), 'the denied write must not land on disk');
|
||||
|
||||
const granted = path.join(work, 'granted.txt');
|
||||
const grantedRun = run([...grantArgs({ readOnly: ['/'], readWrite: [work] }), '--', '/bin/sh', '-c', `echo ok > ${granted}`]);
|
||||
assert.equal(grantedRun.status, 0, grantedRun.stderr);
|
||||
assert.equal(fs.readFileSync(granted, 'utf8'), 'ok\n');
|
||||
|
||||
// The ruleset is inherited across execve: a CHILD of the wrapped command
|
||||
// is confined too, not just the direct exec target.
|
||||
const nested = path.join(work, 'nested.txt');
|
||||
const nestedRun = run([...grantArgs({ readOnly: ['/'] }), '--', '/bin/sh', '-c', `/bin/sh -c 'echo x > ${nested}'; true`]);
|
||||
assert.equal(nestedRun.status, 0, nestedRun.stderr);
|
||||
assert.ok(!fs.existsSync(nested), 'a denied write from a nested child must not land either');
|
||||
|
||||
fs.rmSync(work, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// --- fail closed: an unopenable grant root refuses to exec at all ---
|
||||
{
|
||||
const marker = path.join(os.tmpdir(), `nalr-should-not-exist-${process.pid}`);
|
||||
const badGrant = run(['--ro', '/no/such/grant/root', '--', '/bin/sh', '-c', `echo x > ${marker}`]);
|
||||
assert.equal(badGrant.status, LAUNCHER_FAILURE_EXIT);
|
||||
assert.match(badGrant.stderr, /cannot open rule path/);
|
||||
assert.ok(!fs.existsSync(marker), 'the command must never run when the launcher fails');
|
||||
}
|
||||
|
||||
console.log('launcher.test: ok');
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
},
|
||||
"files": [],
|
||||
"include": ["scripts/**/*.ts"],
|
||||
"references": [
|
||||
{ "path": "./packages/entry" }
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"AGENTS.md": 1370,
|
||||
"AGENTS.md": 1375,
|
||||
"docs/AGENTS.md": 1100,
|
||||
"docs/architecture.md": 1790,
|
||||
"docs/cordis-primer.md": 550,
|
||||
|
||||
Reference in New Issue
Block a user