fix(sandbox): spawn confined argv directly (round 1)

This commit is contained in:
Hypatia May
2026-08-04 12:04:48 +08:00
parent 2eedb54849
commit e36d040d0a
34 changed files with 392 additions and 282 deletions
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write native/landlock-run/README.md
README.md: 284d5df764cf5a5205973696211aee2366d3b76e
README.zh.md: f369799cc8dcfb6de7c4b7b8c18857693d310418
README.md: 483b14df7cb91fc6486f69efc1ddc560ab635ed5
README.zh.md: a239889ec68a8d56bb901b17feaac7f7acbf66fa
+1 -1
View File
@@ -39,7 +39,7 @@ 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.
- `LAUNCHER_BIN`, `LAUNCHER_FAILURE_EXIT` (125), `LAUNCHER_FATAL_PREFIX`, and `PARTIAL_ENFORCEMENT_NOTICE`: contract constants. Every launcher failure uses 125, but a successfully exec'd child may also return 125, so failure attribution requires the fatal prefix too.
The full binary contract (argv grammar, exit codes, report lines) is pinned in [docs/cli-contract.md](docs/cli-contract.md).
+1 -1
View File
@@ -39,7 +39,7 @@ if (probe(launcher) !== 'unusable') {
- `launcherPath()`:当前宿主启动器的绝对路径(有意不检查是否存在;探测结果才是可用性信号)。
- `probe(launcher?, { timeoutMs? })`:功能性强制执行探测,返回 `'full' | 'partial' | 'unusable'`
- `grantArgs({ readOnly?, readWrite? })`:启动器的授权 argv;未授予的一切都被拒绝。
- `LAUNCHER_BIN``LAUNCHER_FAILURE_EXIT`125:契约常量
- `LAUNCHER_BIN``LAUNCHER_FAILURE_EXIT`125`LAUNCHER_FATAL_PREFIX``PARTIAL_ENFORCEMENT_NOTICE`:契约常量。所有 launcher 失败的退出码都是 125,但成功完成 exec 的子进程也可能返回 125,因此还必须有致命前缀,才能将结果归因为 launcher 失败
完整的二进制契约(argv 语法、退出码、报告行)锁定在 [docs/cli-contract.md](docs/cli-contract.md) 中。
+5 -5
View File
@@ -1,6 +1,6 @@
# 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.
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` and its protocol constants); changing anything below requires a version bump for the whole package family and a note in the release notes.
## Invocation grammar
@@ -19,15 +19,15 @@ landlock-run --probe
## 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.
- `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.
- After a successful `exec`, every child status is passed through unchanged, including 125. Consumers therefore require both status 125 and a `LAUNCHER_FATAL_PREFIX` line to attribute launcher failure.
- `--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`.
- A confined run under a partial-ABI kernel prints one stderr line `landlock-run: partial enforcement (older Landlock ABI)` (`PARTIAL_ENFORCEMENT_NOTICE`) and proceeds — still confined for everything the kernel supports.
- Every fatal error prints one stderr line prefixed `landlock-run: ` (`LAUNCHER_FATAL_PREFIX`) before exiting `125`.
## Confinement semantics
@@ -21,11 +21,18 @@ import { fileURLToPath } from 'node:url'
/** The launcher binary's file name inside each platform package's `bin/`. */
export const LAUNCHER_BIN = 'landlock-run'
/** Prefix on every launcher-owned fatal stderr line. */
export const LAUNCHER_FATAL_PREFIX = 'landlock-run: '
/** Informational stderr line emitted before child execution under partial enforcement. */
export const PARTIAL_ENFORCEMENT_NOTICE = 'landlock-run: partial enforcement (older Landlock ABI)'
/**
* 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.
* kernel, unopenable grant root, failed exec). After a successful `exec`, the
* wrapped command may also return 125, so consumers require a matching
* {@link LAUNCHER_FATAL_PREFIX} diagnostic to attribute launcher failure.
* Part of the CLI contract.
*/
export const LAUNCHER_FAILURE_EXIT = 125
+4
View File
@@ -12,6 +12,8 @@ import path from 'node:path';
import {
LAUNCHER_BIN,
LAUNCHER_FAILURE_EXIT,
LAUNCHER_FATAL_PREFIX,
PARTIAL_ENFORCEMENT_NOTICE,
grantArgs,
launcherPath,
probe,
@@ -20,6 +22,8 @@ import {
// --- constants are part of the CLI contract ---
assert.equal(LAUNCHER_BIN, 'landlock-run');
assert.equal(LAUNCHER_FAILURE_EXIT, 125);
assert.equal(LAUNCHER_FATAL_PREFIX, 'landlock-run: ');
assert.equal(PARTIAL_ENFORCEMENT_NOTICE, 'landlock-run: partial enforcement (older Landlock ABI)');
// --- grantArgs: flag spelling, ordering, and empty grants ---
assert.deepEqual(grantArgs({}), []);
+10
View File
@@ -19,6 +19,8 @@ import path from 'node:path';
import { spawnSync } from 'node:child_process';
import {
LAUNCHER_FAILURE_EXIT,
LAUNCHER_FATAL_PREFIX,
PARTIAL_ENFORCEMENT_NOTICE,
grantArgs,
launcherPath,
probe,
@@ -43,6 +45,7 @@ const run = (args, options = {}) => spawnSync(launcher, args, { encoding: 'utf8'
{
const noCommand = run([]);
assert.equal(noCommand.status, LAUNCHER_FAILURE_EXIT);
assert.ok(noCommand.stderr.startsWith(LAUNCHER_FATAL_PREFIX));
assert.match(noCommand.stderr, /usage error: missing `-- <argv>\.\.\.` command/);
const unknownFlag = run(['--bogus', '--', 'true']);
@@ -75,6 +78,7 @@ if (enforcement === 'unusable') {
console.log('launcher.test: SKIP enforcement half — kernel does not enforce Landlock');
process.exit(0);
}
const expectedNotice = enforcement === 'partial' ? `${PARTIAL_ENFORCEMENT_NOTICE}\n` : '';
{
const probeRun = run(['--probe']);
assert.equal(probeRun.status, 0);
@@ -86,9 +90,14 @@ if (enforcement === 'unusable') {
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');
assert.equal(echo.stderr, expectedNotice);
const exitCode = run([...grantArgs({ readOnly: ['/'] }), '--', '/bin/sh', '-c', 'exit 7']);
assert.equal(exitCode.status, 7, 'the wrapped command exit code must pass through unchanged');
const child125 = run([...grantArgs({ readOnly: ['/'] }), '--', '/bin/sh', '-c', `exit ${LAUNCHER_FAILURE_EXIT}`]);
assert.equal(child125.status, LAUNCHER_FAILURE_EXIT, 'a wrapped child may itself return the launcher failure status');
assert.equal(child125.stderr, expectedNotice);
}
// --- world-proofs: denied writes stay off disk, grants land, inheritance crosses exec ---
@@ -120,6 +129,7 @@ if (enforcement === 'unusable') {
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.ok(badGrant.stderr.startsWith(LAUNCHER_FATAL_PREFIX));
assert.match(badGrant.stderr, /cannot open rule path/);
assert.ok(!fs.existsSync(marker), 'the command must never run when the launcher fails');
}