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:34:47 +08:00
parent 513a8d2177
commit 0a486f09c9
46 changed files with 2582 additions and 1 deletions

View File

@@ -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.

View File

@@ -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:*"
}
}

View File

@@ -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'
}

View File

@@ -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));
}

View File

@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"declaration": true,
"outDir": "lib",
"rootDir": "src",
"tsBuildInfoFile": "lib/.tsbuildinfo"
},
"include": ["src/**/*.ts"]
}