Skip to content

This page dissects the Linux confinement backend that sandbox-local uses when bubblewrap is unusable. The binary is landlock-run — a self-restrict-then-exec Landlock launcher living under native/landlock-run/, built as a package family and consumed by the dsh sandbox seam. It is mechanism, not policy: dsh decides which paths a run may read or write; this package provides the launcher that enforces those grants and the JavaScript API that resolves and speaks to it.

What Landlock is

Landlock is a Linux kernel LSM (Linux Security Module) for unprivileged sandboxing. landlock-run installs a ruleset on itself and execs 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. Rulesets are allow-lists: everything not granted is denied.

Landlock needs none of the privileges bubblewrap relies on (no unprivileged user namespaces, no mount), which is why it is the Linux fallback when bwrap fails a functional probe. A grant on a non-directory keeps only its file-compatible access bits — this is how a --rw /dev/null grant works.

The package family

PackageRole
native/landlock-run (workspace root)Build/release project with its own versioning
native/landlock-run/packages/entry (@deepseek-ai/node-addon-landlock-run)ESM entry: launcherPath, probe, grantArgs, contract constants; ships the C source for auditability
native/landlock-run/packages/linux-x64Prebuilt static binary for x86_64 Linux
native/landlock-run/packages/linux-arm64Prebuilt static binary for aarch64 Linux

The family is two-layer, on the node-addon-require-builtin model: one entry package (owning the CLI contract) + per-platform binary packages (a static bin/landlock-run, a prebuilds.json, no JavaScript). Because the CLI parser and binary version together in one family, the entry package can never drift behind the binary. The entry package lists every platform package as an optionalDependency, and npm's os/cpu fields select the matching one at install time.

The public JavaScript API

The entry package exports a deliberately small surface (native/landlock-run/packages/entry/src/index.ts):

  • launcherPath() — absolute path of this host's launcher, resolved from @deepseek-ai/node-addon-landlock-run-<platform>-<arch>. On an unsupported platform the fallback path never exists (existence is deliberately unchecked — the probe is the availability signal).
  • probe(launcher?, { timeoutMs? })'full' | 'partial' | 'unusable'. A functional probe: it runs landlock-run --probe, which builds and enforces a real maximal ruleset in a short-lived child, because a version check would miss a kernel that has the syscalls but refuses enforcement.
  • grantArgs({ readOnly?, readWrite? }) — the --ro/--rw argument list; everything not granted is denied.
  • LAUNCHER_BIN ('landlock-run') and LAUNCHER_FAILURE_EXIT (125) — contract constants.

The CLI contract

Pinned in native/landlock-run/docs/cli-contract.md. Arguments are spelled --ro/--rw (mirroring the bwrap argv shape the executor wraps):

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.
  • -- — 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 (which binary confines a process must never be decidable by the ambient environment).

Exit codes: 125 on every launcher-level failure (usage error, kernel that cannot enforce, unopenable grant root, failed exec) — the wrapped command was not run. After a successful exec, child statuses pass through unchanged, including 125, so consumers require both status 125 and a landlock-run: fatal line to attribute launcher failure.

Report lines: --probe prints landlock: fully enforced or landlock: partially enforced (older ABI); a confined run on a partial-ABI kernel prints landlock-run: partial enforcement (older Landlock ABI) on stderr and proceeds (still confined for everything the kernel supports); every fatal error prints one landlock-run: line before exit 125.

How dsh invokes it

The seam-side glue lives in packages/sandbox/sandbox-local/src/profiles.ts and index.ts. landlockProfileArgs builds the grants for one policy:

ts
export function landlockProfileArgs(policy: SandboxPolicy): string[] {
  const readWrite = ['/dev/null']
  if (policy.mode === 'workspace-write') {
    readWrite.push('/tmp', policy.workspaceRoot)
  }
  return landlockGrantArgs({ readOnly: ['/'], readWrite })
}

So every mode grants readOnly: ['/'] (read + execute everywhere) and restricts writes to /dev/null under read-only, adding /tmp and the workspace root under workspace-write. confine then spawns [launcher, ...grants, '--', ...argv].

LocalSandboxProvider selects the Landlock rung only after the bwrap probe fails (the Linux chain ['bwrap', 'landlock']), via the functional probe probe(launcher, { timeoutMs }) from the entry package. The e2e behavior is exercised in packages/sandbox/sandbox-local/tests/landlock.e2e.ts and packages/shell/bash-sandbox/tests/landlock.e2e.ts.

The denial dialect it teaches consumers is ['permission denied'], and its runner-failure rule is gated on the versioned exit 125 plus the landlock-run: fatal line — the same tuple the partial-landlock fixture at examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts mirrors.

The launcher source itself

The C sits at native/landlock-run/packages/entry/src/main.c — ~300 lines of C11 over the raw Landlock UAPI, statically linked against musl, no libc beyond it. The only dependencies are this file plus the kernel's stable syscall contract. Notable mechanics:

  • no_new_privs is set first (prctl(PR_SET_NO_NEW_PRIVS, 1, …)) — mandatory for an unprivileged ruleset;
  • the chosen Landlock ABI is negotiated via syscall(__NR_landlock_create_ruleset, …) and LANDLOCK_CREATE_RULESET_VERSION;
  • the ruleset is applied with syscall(__NR_landlock_restrict_self, ruleset_fd, 0);
  • the wrapped command is launched with execvp(cli.command[0], cli.command).

The UAPI structs are defined locally rather than pulled from <linux/landlock.h> — the kernel ABI is stable by contract, self-defining keeps the build independent of the toolchain header vintage, and the definitions double as an audit record of exactly which kernel API the launcher touches.

Build, packaging, and release

  • Builds are native-only: scripts/build.ts compiles the running architecture's binaries with the distro musl-gcc (static — one binary works for both glibc and musl distros). CI's per-architecture runners are the builders of record; there is no cross toolchain in the repo — a platform package is added only together with a native runner that builds and proves it.
  • The package matrix is checked-in metadata (prebuilds.json + os/cpu fields); scripts/github-matrix.mjs derives the CI and Release matrices from it.
  • Release flow and notes: native/landlock-run/docs/packaging.md, native/landlock-run/docs/release.md; three gates byte-pin installed binaries against the workspace builds they were packed from.

Support matrix and fallback

The support matrix is pinned in native/landlock-run/docs/support-matrix.md:

Platform packageBuilder of recordNotes
…-landlock-run-linux-x64ubuntu-24.04static musl — glibc and musl distros alike
…-landlock-run-linux-arm64ubuntu-24.04-armstatic 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. The probe, not the kernel version, is the authority: a kernel built without Landlock, or with the LSM disabled, probes unusable regardless of version.

Deliberately unsupported: darwin (macOS confines via shipped sandbox-exec/Seatbelt), win32 (a different mechanism), and other Linux architectures (riscv64, s390x, …) with no native builder of record yet.

Fallback in dsh: on a host with no landlock-run — unsupported platform, missing optional dependency, or an unenforcing kernel — launcherPath() resolves a path that never exists, probe() reports unusable, and the probe is indistinguishable from a missing binary on purpose. sandbox-local then treats the Landlock rung as unavailable and, unless bwrap works, throws SandboxUnavailableError: the command never runs unconfined. The documented degradation is exercised by CI's darwin leg.

Further reading

  • Sandbox architecture: overview — where the Landlock rung sits in the ctx.sandbox runner chain and the escalation vocabulary around it.
  • Filesystem observation & sandbox policy — the file-effect policy the Landlock grants (and the fs fence) express.
  • native/landlock-run/docs/cli-contract.md — the full launcher invocation grammar, exit codes, and report lines.
  • native/landlock-run/docs/support-matrix.md — the authoritative platform/kernel support matrix.
  • native/landlock-run/packages/entry/src/index.ts and packages/entry/src/main.c — the JS API and the C11 launcher source.
  • packages/sandbox/sandbox-local/src/profiles.tslandlockProfileArgs, how dsh maps a mode to --ro/--rw grants.