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:
kingwl
2026-07-14 23:39:58 +08:00
parent 513a8d2177
commit 0a486f09c9
46 changed files with 2582 additions and 1 deletions
+127
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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
+20
View File
@@ -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.
+13
View File
@@ -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
+50
View File
@@ -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).
+28
View File
@@ -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.
+58
View File
@@ -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).
+34
View File
@@ -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.
+34
View File
@@ -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`.
+30
View File
@@ -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.
+45
View File
@@ -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.
+57
View File
@@ -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.
+30
View File
@@ -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"
}
]
}
+345
View File
@@ -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: {}
+8
View File
@@ -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`);
}
+86
View File
@@ -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)}`);
+88
View File
@@ -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();
}
+76
View File
@@ -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');
+121
View File
@@ -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');
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"]
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"noEmit": true
},
"files": [],
"include": ["scripts/**/*.ts"],
"references": [
{ "path": "./packages/entry" }
]
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"AGENTS.md": 1370,
"AGENTS.md": 1375,
"docs/AGENTS.md": 1100,
"docs/architecture.md": 1790,
"docs/cordis-primer.md": 550,